Compare commits

..

No commits in common. "master" and "2.4.1" have entirely different histories.

20 changed files with 138 additions and 221 deletions

View file

@ -1,13 +0,0 @@
# Keep GitHub Actions up to date with GitHub's Dependabot...
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
version: 2
updates:
- package-ecosystem: github-actions
directory: /
groups:
github-actions:
patterns:
- "*" # Group all Actions updates into a single larger pull request
schedule:
interval: weekly

View file

@ -11,22 +11,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v4
with:
python-version: 3.x
python-version: 3.8
- name: Get pip cache dir
id: pip-cache
run: |
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
echo "::set-output name=dir::$(pip cache dir)"
- name: Cache
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: ${{ steps.pip-cache.outputs.dir }}
key: release-${{ hashFiles('**/setup.py') }}
@ -50,4 +50,4 @@ jobs:
with:
user: jazzband
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
repository-url: https://jazzband.co/projects/django-configurations/upload
repository_url: https://jazzband.co/projects/django-configurations/upload

View file

@ -11,15 +11,15 @@ jobs:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 6
max-parallel: 5
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy-3.10']
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9']
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
@ -29,7 +29,7 @@ jobs:
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: ${{ steps.pip-cache.outputs.dir }}
key:
@ -47,11 +47,7 @@ jobs:
tox --verbose
- name: Upload coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v3
with:
name: coverage-data-${{ matrix.python-version }}
path: ".coverage.*"
include-hidden-files: true
merge-multiple: true
name: Python ${{ matrix.python-version }}
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -1,9 +1,9 @@
---
version: 2
build:
os: ubuntu-22.04
os: ubuntu-20.04
tools:
python: "3.10"
python: "3.9"
python:
install:
- requirements: docs/requirements.txt

View file

@ -137,7 +137,7 @@ Or if you are not serving your app via WSGI but ASGI instead, you need to modify
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
os.environ.setdefault('DJANGO_CONFIGURATION', 'Dev')
os.environ.setdefault('DJANGO_CONFIGURATION', 'DEV')
from configurations.asgi import get_asgi_application

View file

@ -46,10 +46,6 @@ class ConfigurationBase(type):
# suppressed, as downstream users are expected to make a decision.
# https://docs.djangoproject.com/en/3.2/releases/3.2/#customizing-type-of-auto-created-primary-keys
"DEFAULT_AUTO_FIELD",
# FORMS_URLFIELD_ASSUME_HTTPS is a transitional setting introduced
# in Django 5.0.
# https://docs.djangoproject.com/en/5.0/releases/5.0/#id2
"FORMS_URLFIELD_ASSUME_HTTPS"
}
# PASSWORD_RESET_TIMEOUT_DAYS is deprecated in favor of
# PASSWORD_RESET_TIMEOUT in Django 3.1
@ -70,7 +66,7 @@ class ConfigurationBase(type):
return super().__new__(cls, name, bases, attrs)
def __repr__(self):
return "<Configuration '{}.{}'>".format(self.__module__,
return "<Configuration '{0}.{1}'>".format(self.__module__,
self.__name__)
@ -107,7 +103,7 @@ class Configuration(metaclass=ConfigurationBase):
environment variables from a .env file located in the project root
or provided directory.
https://wellfire.co/learn/easier-12-factor-django/
http://www.wellfireinteractive.com/blog/easier-12-factor-django/
https://gist.github.com/bennylope/2999704
"""
# check if the class has DOTENV set whether with a path or None
@ -119,7 +115,7 @@ class Configuration(metaclass=ConfigurationBase):
# now check if we can access the file since we know we really want to
try:
with open(dotenv) as f:
with open(dotenv, 'r') as f:
content = f.read()
except OSError as e:
raise ImproperlyConfigured("Couldn't read .env file "

View file

@ -1,4 +1,4 @@
from importlib.machinery import PathFinder
import imp
import logging
import os
import sys
@ -46,12 +46,12 @@ def install(check_options=False):
return parser
base.BaseCommand.create_parser = create_parser
importer = ConfigurationFinder(check_options=check_options)
importer = ConfigurationImporter(check_options=check_options)
sys.meta_path.insert(0, importer)
installed = True
class ConfigurationFinder(PathFinder):
class ConfigurationImporter:
modvar = SETTINGS_ENVIRONMENT_VARIABLE
namevar = CONFIGURATION_ENVIRONMENT_VARIABLE
error_msg = ("Configuration cannot be imported, "
@ -70,7 +70,7 @@ class ConfigurationFinder(PathFinder):
self.announce()
def __repr__(self):
return "<ConfigurationFinder for '{}.{}'>".format(self.module,
return "<ConfigurationImporter for '{0}.{1}'>".format(self.module,
self.name)
@property
@ -121,60 +121,58 @@ class ConfigurationFinder(PathFinder):
if (self.argv[1] == 'runserver'
and os.environ.get('RUN_MAIN') == 'true'):
message = ("django-configurations version {}, using "
"configuration {}".format(__version__ or "",
message = ("django-configurations version {0}, using "
"configuration {1}".format(__version__ or "",
self.name))
self.logger.debug(stylize(message))
def find_spec(self, fullname, path=None, target=None):
def find_module(self, fullname, path=None):
if fullname is not None and fullname == self.module:
spec = super().find_spec(fullname, path, target)
if spec is not None:
wrap_loader(spec.loader, self.name)
return spec
module = fullname.rsplit('.', 1)[-1]
return ConfigurationLoader(self.name,
imp.find_module(module, path))
return None
class ConfigurationLoader:
def __init__(self, name, location):
self.name = name
self.location = location
def load_module(self, fullname):
if fullname in sys.modules:
mod = sys.modules[fullname] # pragma: no cover
else:
return None
mod = imp.load_module(fullname, *self.location)
cls_path = '{0}.{1}'.format(mod.__name__, self.name)
try:
cls = getattr(mod, self.name)
except AttributeError as err: # pragma: no cover
reraise(err, "Couldn't find configuration '{0}' "
"in module '{1}'".format(self.name,
mod.__package__))
try:
cls.pre_setup()
cls.setup()
obj = cls()
attributes = uppercase_attributes(obj).items()
for name, value in attributes:
if callable(value) and not getattr(value, 'pristine', False):
value = value()
# in case a method returns a Value instance we have
# to do the same as the Configuration.setup method
if isinstance(value, Value):
setup_value(mod, name, value)
continue
setattr(mod, name, value)
def wrap_loader(loader, class_name):
class ConfigurationLoader(loader.__class__):
def exec_module(self, module):
super().exec_module(module)
setattr(mod, 'CONFIGURATION', '{0}.{1}'.format(fullname,
self.name))
cls.post_setup()
mod = module
except Exception as err:
reraise(err, "Couldn't setup configuration '{0}'".format(cls_path))
cls_path = f'{mod.__name__}.{class_name}'
try:
cls = getattr(mod, class_name)
except AttributeError as err: # pragma: no cover
reraise(
err,
(
f"Couldn't find configuration '{class_name}' in "
f"module '{mod.__package__}'"
),
)
try:
cls.pre_setup()
cls.setup()
obj = cls()
attributes = uppercase_attributes(obj).items()
for name, value in attributes:
if callable(value) and not getattr(value, 'pristine', False):
value = value()
# in case a method returns a Value instance we have
# to do the same as the Configuration.setup method
if isinstance(value, Value):
setup_value(mod, name, value)
continue
setattr(mod, name, value)
setattr(mod, 'CONFIGURATION', '{0}.{1}'.format(module.__name__,
class_name))
cls.post_setup()
except Exception as err:
reraise(err, f"Couldn't setup configuration '{cls_path}'")
loader.__class__ = ConfigurationLoader
return mod

View file

@ -29,21 +29,21 @@ def import_by_path(dotted_path, error_prefix=''):
try:
module_path, class_name = dotted_path.rsplit('.', 1)
except ValueError:
raise ImproperlyConfigured("{}{} doesn't look like "
raise ImproperlyConfigured("{0}{1} doesn't look like "
"a module path".format(error_prefix,
dotted_path))
try:
module = import_module(module_path)
except ImportError as err:
msg = '{}Error importing module {}: "{}"'.format(error_prefix,
msg = '{0}Error importing module {1}: "{2}"'.format(error_prefix,
module_path,
err)
raise ImproperlyConfigured(msg).with_traceback(sys.exc_info()[2])
try:
attr = getattr(module, class_name)
except AttributeError:
raise ImproperlyConfigured('{}Module "{}" does not define a '
'"{}" attribute/class'.format(error_prefix,
raise ImproperlyConfigured('{0}Module "{1}" does not define a '
'"{2}" attribute/class'.format(error_prefix,
module_path,
class_name))
return attr
@ -61,7 +61,7 @@ def reraise(exc, prefix=None, suffix=None):
suffix = ''
elif not (suffix.startswith('(') and suffix.endswith(')')):
suffix = '(' + suffix + ')'
exc.args = (f'{prefix} {args[0]} {suffix}',) + args[1:]
exc.args = ('{0} {1} {2}'.format(prefix, args[0], suffix),) + args[1:]
raise exc

View file

@ -92,7 +92,7 @@ class Value:
else:
environ_name = name.upper()
if self.environ_prefix:
environ_name = f'{self.environ_prefix}_{environ_name}'
environ_name = '{0}_{1}'.format(self.environ_prefix, environ_name)
return environ_name
def setup(self, name):
@ -102,8 +102,8 @@ class Value:
if full_environ_name in os.environ:
value = self.to_python(os.environ[full_environ_name])
elif self.environ_required:
raise ValueError('Value {!r} is required to be set as the '
'environment variable {!r}'
raise ValueError('Value {0!r} is required to be set as the '
'environment variable {1!r}'
.format(name, full_environ_name))
self.value = value
return value
@ -128,7 +128,7 @@ class BooleanValue(Value):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.default not in (True, False):
raise ValueError('Default value {!r} is not a '
raise ValueError('Default value {0!r} is not a '
'boolean value'.format(self.default))
def to_python(self, value):
@ -139,7 +139,7 @@ class BooleanValue(Value):
return False
else:
raise ValueError('Cannot interpret '
'boolean value {!r}'.format(value))
'boolean value {0!r}'.format(value))
class CastingMixin:
@ -152,12 +152,12 @@ class CastingMixin:
try:
self._caster = import_string(self.caster)
except ImportError as err:
msg = f"Could not import {self.caster!r}"
msg = "Could not import {!r}".format(self.caster)
raise ImproperlyConfigured(msg) from err
elif callable(self.caster):
self._caster = self.caster
else:
error = 'Cannot use caster of {} ({!r})'.format(self,
error = 'Cannot use caster of {0} ({1!r})'.format(self,
self.caster)
raise ValueError(error)
try:
@ -345,13 +345,13 @@ class ValidationMixin:
try:
self._validator = import_string(self.validator)
except ImportError as err:
msg = f"Could not import {self.validator!r}"
msg = "Could not import {!r}".format(self.validator)
raise ImproperlyConfigured(msg) from err
elif callable(self.validator):
self._validator = self.validator
else:
raise ValueError('Cannot use validator of '
'{} ({!r})'.format(self, self.validator))
'{0} ({1!r})'.format(self, self.validator))
if self.default:
self.to_python(self.default)
@ -397,7 +397,7 @@ class PathValue(Value):
value = super().setup(name)
value = os.path.expanduser(value)
if self.check_exists and not os.path.exists(value):
raise ValueError(f'Path {value!r} does not exist.')
raise ValueError('Path {0!r} does not exist.'.format(value))
return os.path.abspath(value)
@ -414,7 +414,7 @@ class SecretValue(Value):
def setup(self, name):
value = super().setup(name)
if not value:
raise ValueError(f'Secret value {name!r} is not set')
raise ValueError('Secret value {0!r} is not set'.format(name))
return value

View file

@ -1,4 +1,7 @@
from importlib.metadata import PackageNotFoundError, version
try:
from importlib.metadata import PackageNotFoundError, version
except ImportError:
from importlib_metadata import PackageNotFoundError, version
try:
__version__ = version("django-configurations")

View file

@ -3,30 +3,6 @@
Changelog
---------
Unreleased
^^^^^^^^^^
- Prevent warning about ``FORMS_URLFIELD_ASSUME_HTTPS`` on Django 5.0.
v2.5.1 (2023-11-30)
^^^^^^^^^^^^^^^^^^^
- Add compatibility with Python 3.12
v2.5 (2023-10-20)
^^^^^^^^^^^^^^^^^
- Update Github actions and fix pipeline warnings
- Add compatibility with Django 5.0
- **BACKWARD INCOMPATIBLE** Drop compatibility for Django 4.0
- **BACKWARD INCOMPATIBLE** Drop compatibility for Python 3.7 and PyPy < 3.10
v2.4.2 (2023-09-27)
^^^^^^^^^^^^^^^^^^^
- Replace imp (due for removal in Python 3.12) with importlib
- Test on PyPy 3.10.
v2.4.1 (2023-04-04)
^^^^^^^^^^^^^^^^^^^

View file

@ -31,8 +31,9 @@ setup(
},
install_requires=[
'django>=3.2',
'importlib-metadata;python_version<"3.8"',
],
python_requires='>=3.9, <4.0',
python_requires='>=3.7, <4.0',
extras_require={
'cache': ['django-cache-url'],
'database': ['dj-database-url'],
@ -49,21 +50,20 @@ setup(
'Development Status :: 5 - Production/Stable',
'Framework :: Django',
'Framework :: Django :: 3.2',
'Framework :: Django :: 4.0',
'Framework :: Django :: 4.1',
'Framework :: Django :: 4.2',
'Framework :: Django :: 5.0',
'Framework :: Django :: 5.1',
'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Programming Language :: Python :: 3.13',
'Programming Language :: Python :: Implementation :: PyPy',
'Topic :: Utilities',
],

View file

@ -6,6 +6,3 @@ class DotEnvConfiguration(Configuration):
DOTENV = 'test_project/.env'
DOTENV_VALUE = values.Value()
def DOTENV_VALUE_METHOD(self):
return values.Value(environ_name="DOTENV_VALUE")

View file

@ -1,8 +0,0 @@
from configurations import Configuration
class ErrorConfiguration(Configuration):
@classmethod
def pre_setup(cls):
raise ValueError("Error in pre_setup")

View file

@ -1,7 +1,9 @@
import os
import uuid
import django
from configurations import Configuration, pristinemethod
from configurations.values import BooleanValue
class Test(Configuration):
@ -9,6 +11,8 @@ class Test(Configuration):
os.path.join(os.path.dirname(
os.path.abspath(__file__)), os.pardir))
ENV_LOADED = BooleanValue(False)
DEBUG = True
SITE_ID = 1
@ -32,6 +36,9 @@ class Test(Configuration):
ROOT_URLCONF = 'tests.urls'
if django.VERSION[:2] < (1, 6):
TEST_RUNNER = 'discover_runner.DiscoverRunner'
@property
def ALLOWED_HOSTS(self):
allowed_hosts = super().ALLOWED_HOSTS[:]

View file

@ -11,5 +11,4 @@ class DotEnvLoadingTests(TestCase):
def test_env_loaded(self):
from tests.settings import dot_env
self.assertEqual(dot_env.DOTENV_VALUE, 'is set')
self.assertEqual(dot_env.DOTENV_VALUE_METHOD, 'is set')
self.assertEqual(dot_env.DOTENV_LOADED, dot_env.DOTENV)

View file

@ -1,22 +0,0 @@
import os
from django.test import TestCase
from unittest.mock import patch
class ErrorTests(TestCase):
@patch.dict(os.environ, clear=True,
DJANGO_CONFIGURATION='ErrorConfiguration',
DJANGO_SETTINGS_MODULE='tests.settings.error')
def test_env_loaded(self):
with self.assertRaises(ValueError) as cm:
from tests.settings import error # noqa: F401
self.assertIsInstance(cm.exception, ValueError)
self.assertEqual(
cm.exception.args,
(
"Couldn't setup configuration "
"'tests.settings.error.ErrorConfiguration': Error in pre_setup ",
)
)

View file

@ -7,7 +7,7 @@ from django.core.exceptions import ImproperlyConfigured
from unittest.mock import patch
from configurations.importer import ConfigurationFinder
from configurations.importer import ConfigurationImporter
ROOT_DIR = os.path.dirname(os.path.dirname(__file__))
TEST_PROJECT_DIR = os.path.join(ROOT_DIR, 'test_project')
@ -42,14 +42,12 @@ class MainTests(TestCase):
@patch.dict(os.environ, clear=True, DJANGO_CONFIGURATION='Test')
def test_empty_module_var(self):
with self.assertRaises(ImproperlyConfigured):
ConfigurationFinder()
self.assertRaises(ImproperlyConfigured, ConfigurationImporter)
@patch.dict(os.environ, clear=True,
DJANGO_SETTINGS_MODULE='tests.settings.main')
def test_empty_class_var(self):
with self.assertRaises(ImproperlyConfigured):
ConfigurationFinder()
self.assertRaises(ImproperlyConfigured, ConfigurationImporter)
def test_global_settings(self):
from configurations.base import Configuration
@ -72,21 +70,21 @@ class MainTests(TestCase):
DJANGO_SETTINGS_MODULE='tests.settings.main',
DJANGO_CONFIGURATION='Test')
def test_initialization(self):
finder = ConfigurationFinder()
self.assertEqual(finder.module, 'tests.settings.main')
self.assertEqual(finder.name, 'Test')
importer = ConfigurationImporter()
self.assertEqual(importer.module, 'tests.settings.main')
self.assertEqual(importer.name, 'Test')
self.assertEqual(
repr(finder),
"<ConfigurationFinder for 'tests.settings.main.Test'>")
repr(importer),
"<ConfigurationImporter for 'tests.settings.main.Test'>")
@patch.dict(os.environ, clear=True,
DJANGO_SETTINGS_MODULE='tests.settings.inheritance',
DJANGO_CONFIGURATION='Inheritance')
def test_initialization_inheritance(self):
finder = ConfigurationFinder()
self.assertEqual(finder.module,
importer = ConfigurationImporter()
self.assertEqual(importer.module,
'tests.settings.inheritance')
self.assertEqual(finder.name, 'Inheritance')
self.assertEqual(importer.name, 'Inheritance')
@patch.dict(os.environ, clear=True,
DJANGO_SETTINGS_MODULE='tests.settings.main',
@ -95,12 +93,12 @@ class MainTests(TestCase):
'--settings=tests.settings.main',
'--configuration=Test'])
def test_configuration_option(self):
finder = ConfigurationFinder(check_options=False)
self.assertEqual(finder.module, 'tests.settings.main')
self.assertEqual(finder.name, 'NonExisting')
finder = ConfigurationFinder(check_options=True)
self.assertEqual(finder.module, 'tests.settings.main')
self.assertEqual(finder.name, 'Test')
importer = ConfigurationImporter(check_options=False)
self.assertEqual(importer.module, 'tests.settings.main')
self.assertEqual(importer.name, 'NonExisting')
importer = ConfigurationImporter(check_options=True)
self.assertEqual(importer.module, 'tests.settings.main')
self.assertEqual(importer.name, 'Test')
def test_configuration_argument_in_cli(self):
"""

View file

@ -34,7 +34,7 @@ class ValueTests(TestCase):
def test_value_with_default(self):
value = Value('default', environ=False)
self.assertEqual(type(value), str)
self.assertEqual(type(value), type('default'))
self.assertEqual(value, 'default')
self.assertEqual(str(value), 'default')
@ -44,17 +44,17 @@ class ValueTests(TestCase):
with env(DJANGO_TEST='override'):
self.assertEqual(value.setup('TEST'), 'default')
value = Value(environ_name='TEST')
self.assertEqual(type(value), str)
self.assertEqual(type(value), type('override'))
self.assertEqual(value, 'override')
self.assertEqual(str(value), 'override')
self.assertEqual(f'{value}', 'override')
self.assertEqual('{0}'.format(value), 'override')
self.assertEqual('%s' % value, 'override')
value = Value(environ_name='TEST', late_binding=True)
self.assertEqual(type(value), Value)
self.assertEqual(value.value, 'override')
self.assertEqual(str(value), 'override')
self.assertEqual(f'{value}', 'override')
self.assertEqual('{0}'.format(value), 'override')
self.assertEqual('%s' % value, 'override')
self.assertEqual(repr(value), repr('override'))
@ -373,23 +373,17 @@ class ValueTests(TestCase):
value = DatabaseURLValue()
self.assertEqual(value.default, {})
with env(DATABASE_URL='sqlite://'):
settings_value = value.setup('DATABASE_URL')
# Compare the embedded dicts in the "default" entry so that the difference can be seen if
# it fails ... DatabaseURLValue(|) uses an external app that can add additional entries
self.assertDictEqual(
{
self.assertEqual(value.setup('DATABASE_URL'), {
'default': {
'CONN_HEALTH_CHECKS': False,
'CONN_MAX_AGE': 0,
'DISABLE_SERVER_SIDE_CURSORS': False,
'ENGINE': 'django.db.backends.sqlite3',
'HOST': '',
'NAME': ':memory:',
'PASSWORD': '',
'PORT': '',
'USER': '',
},
settings_value['default']
)
}})
def test_database_url_additional_args(self):

30
tox.ini
View file

@ -3,22 +3,23 @@ skipsdist = true
usedevelop = true
minversion = 1.8
envlist =
py311-checkqa
py37-checkqa
docs
py{39}-dj{32,41,42}
py{310,py310}-dj{32,41,42,50,main}
py{311}-dj{41,42,50,51,main}
py{312}-dj{50,51,main}
py{313}-dj{50,51,main}
py{37,py37}-dj{32}
py{38,py38,39,py39}-dj{32,40,41,42}
py{310}-dj{32,40,41,42,main}
py{311}-dj{41,42,main}
[gh-actions]
python =
3.7: py37,flake8,readme
3.8: py38
3.9: py39
3.10: py310
3.11: py311,flake8,readme
3.12: py312
3.13: py313
pypy-3.10: pypy310
3.11: py311
pypy-3.7: pypy37
pypy-3.8: pypy38
pypy-3.9: pypy39
[testenv]
usedevelop = true
@ -28,15 +29,10 @@ setenv =
COVERAGE_PROCESS_START = {toxinidir}/setup.cfg
deps =
dj32: django~=3.2.9
dj40: django~=4.0.0
dj41: django~=4.1.3
dj42: django~=4.2.0
dj50: django~=5.0.0
dj51: django~=5.1.0
djmain: https://github.com/django/django/archive/main.tar.gz
py312: setuptools
py312: wheel
py313: setuptools
py313: wheel
coverage
coverage_enable_subprocess
extras = testing
@ -47,7 +43,7 @@ commands =
coverage report -m --skip-covered
coverage xml
[testenv:py311-checkqa]
[testenv:py37-checkqa]
commands =
flake8 {toxinidir}
check-manifest -v