diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a96908e97..6c6554a54 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,6 +19,6 @@ repos: exclude: (tests/messages/data/) - id: name-tests-test args: [ '--django' ] - exclude: (tests/messages/data/) + exclude: (tests/messages/data/|.*(consts|utils).py) - id: requirements-txt-fixer - id: trailing-whitespace diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 0008a9b84..4d61f0163 100644 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -40,22 +40,17 @@ log = logging.getLogger('babel') -try: - # See: https://setuptools.pypa.io/en/latest/deprecated/distutils-legacy.html - from setuptools import Command as _Command - distutils_log = log # "distutils.log → (no replacement yet)" - try: - from setuptools.errors import BaseError, OptionError, SetupError - except ImportError: # Error aliases only added in setuptools 59 (2021-11). - OptionError = SetupError = BaseError = Exception +class BaseError(Exception): + pass -except ImportError: - from distutils import log as distutils_log - from distutils.cmd import Command as _Command - from distutils.errors import DistutilsError as BaseError - from distutils.errors import DistutilsOptionError as OptionError - from distutils.errors import DistutilsSetupError as SetupError + +class OptionError(BaseError): + pass + + +class SetupError(BaseError): + pass def listify_value(arg, split=None): @@ -100,7 +95,7 @@ def listify_value(arg, split=None): return out -class Command(_Command): +class CommandMixin: # This class is a small shim between Distutils commands and # optparse option parsing in the frontend command line. @@ -128,7 +123,7 @@ class Command(_Command): option_choices = {} #: Log object. To allow replacement in the script command line runner. - log = distutils_log + log = log def __init__(self, dist=None): # A less strict version of distutils' `__init__`. @@ -140,24 +135,21 @@ def __init__(self, dist=None): self.help = 0 self.finalized = 0 + def initialize_options(self): + pass -class compile_catalog(Command): - """Catalog compilation command for use in ``setup.py`` scripts. - - If correctly installed, this command is available to Setuptools-using - setup scripts automatically. For projects using plain old ``distutils``, - the command needs to be registered explicitly in ``setup.py``:: - - from babel.messages.frontend import compile_catalog + def ensure_finalized(self): + if not self.finalized: + self.finalize_options() + self.finalized = 1 - setup( - ... - cmdclass = {'compile_catalog': compile_catalog} + def finalize_options(self): + raise RuntimeError( + f"abstract method -- subclass {self.__class__} must override", ) - .. versionadded:: 0.9 - """ +class CompileCatalog(CommandMixin): description = 'compile message catalogs to binary MO files' user_options = [ ('domain=', 'D', @@ -280,6 +272,7 @@ def _make_directory_filter(ignore_patterns): """ Build a directory_filter function based on a list of ignore patterns. """ + def cli_directory_filter(dirname): basename = os.path.basename(dirname) return not any( @@ -287,24 +280,11 @@ def cli_directory_filter(dirname): for ignore_pattern in ignore_patterns ) - return cli_directory_filter - -class extract_messages(Command): - """Message extraction command for use in ``setup.py`` scripts. - - If correctly installed, this command is available to Setuptools-using - setup scripts automatically. For projects using plain old ``distutils``, - the command needs to be registered explicitly in ``setup.py``:: - - from babel.messages.frontend import extract_messages + return cli_directory_filter - setup( - ... - cmdclass = {'extract_messages': extract_messages} - ) - """ +class ExtractMessages(CommandMixin): description = 'extract localizable strings from the project code' user_options = [ ('charset=', None, @@ -497,6 +477,7 @@ def callback(filename: str, method: str, options: dict): opt_values = ", ".join(f'{k}="{v}"' for k, v in options.items()) optstr = f" ({opt_values})" self.log.info('extracting messages from %s%s', filepath, optstr) + return callback def run(self): @@ -572,38 +553,7 @@ def _get_mappings(self): return mappings -def check_message_extractors(dist, name, value): - """Validate the ``message_extractors`` keyword argument to ``setup()``. - - :param dist: the distutils/setuptools ``Distribution`` object - :param name: the name of the keyword argument (should always be - "message_extractors") - :param value: the value of the keyword argument - :raise `DistutilsSetupError`: if the value is not valid - """ - assert name == 'message_extractors' - if not isinstance(value, dict): - raise SetupError( - 'the value of the "message_extractors" ' - 'parameter must be a dictionary' - ) - - -class init_catalog(Command): - """New catalog initialization command for use in ``setup.py`` scripts. - - If correctly installed, this command is available to Setuptools-using - setup scripts automatically. For projects using plain old ``distutils``, - the command needs to be registered explicitly in ``setup.py``:: - - from babel.messages.frontend import init_catalog - - setup( - ... - cmdclass = {'init_catalog': init_catalog} - ) - """ - +class InitCatalog(CommandMixin): description = 'create a new catalog based on a POT file' user_options = [ ('domain=', 'D', @@ -678,23 +628,7 @@ def run(self): write_po(outfile, catalog, width=self.width) -class update_catalog(Command): - """Catalog merging command for use in ``setup.py`` scripts. - - If correctly installed, this command is available to Setuptools-using - setup scripts automatically. For projects using plain old ``distutils``, - the command needs to be registered explicitly in ``setup.py``:: - - from babel.messages.frontend import update_catalog - - setup( - ... - cmdclass = {'update_catalog': update_catalog} - ) - - .. versionadded:: 0.9 - """ - +class UpdateCatalog(CommandMixin): description = 'update message catalogs from a POT file' user_options = [ ('domain=', 'D', @@ -911,10 +845,10 @@ class CommandLineInterface: } command_classes = { - 'compile': compile_catalog, - 'extract': extract_messages, - 'init': init_catalog, - 'update': update_catalog, + 'compile': CompileCatalog, + 'extract': ExtractMessages, + 'init': InitCatalog, + 'update': UpdateCatalog, } log = None # Replaced on instance level @@ -996,7 +930,7 @@ def _configure_command(self, cmdname, argv): cmdinst = cmdclass() if self.log: cmdinst.log = self.log # Use our logger, not distutils'. - assert isinstance(cmdinst, Command) + assert isinstance(cmdinst, CommandMixin) cmdinst.initialize_options() parser = optparse.OptionParser( @@ -1113,7 +1047,8 @@ def parse_mapping(fileobj, filename=None): return method_map, options_map -def _parse_spec(s: str) -> tuple[int | None, tuple[int|tuple[int, str], ...]]: + +def _parse_spec(s: str) -> tuple[int | None, tuple[int | tuple[int, str], ...]]: inds = [] number = None for x in s.split(','): @@ -1125,6 +1060,7 @@ def _parse_spec(s: str) -> tuple[int | None, tuple[int|tuple[int, str], ...]]: inds.append(int(x)) return number, tuple(inds) + def parse_keywords(strings: Iterable[str] = ()): """Parse keywords specifications from the given list of strings. @@ -1173,5 +1109,16 @@ def parse_keywords(strings: Iterable[str] = ()): return keywords +def __getattr__(name: str): + # Re-exports for backwards compatibility; + # `setuptools_frontend` is the canonical import location. + if name in {'check_message_extractors', 'compile_catalog', 'extract_messages', 'init_catalog', 'update_catalog'}: + from babel.messages import setuptools_frontend + + return getattr(setuptools_frontend, name) + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + if __name__ == '__main__': main() diff --git a/babel/messages/setuptools_frontend.py b/babel/messages/setuptools_frontend.py new file mode 100644 index 000000000..2f23fc182 --- /dev/null +++ b/babel/messages/setuptools_frontend.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from babel.messages import frontend + +try: + # See: https://setuptools.pypa.io/en/latest/deprecated/distutils-legacy.html + from setuptools import Command + + try: + from setuptools.errors import BaseError, OptionError, SetupError + except ImportError: # Error aliases only added in setuptools 59 (2021-11). + OptionError = SetupError = BaseError = Exception + +except ImportError: + from distutils.cmd import Command + from distutils.errors import DistutilsSetupError as SetupError + + +def check_message_extractors(dist, name, value): + """Validate the ``message_extractors`` keyword argument to ``setup()``. + + :param dist: the distutils/setuptools ``Distribution`` object + :param name: the name of the keyword argument (should always be + "message_extractors") + :param value: the value of the keyword argument + :raise `DistutilsSetupError`: if the value is not valid + """ + assert name == "message_extractors" + if not isinstance(value, dict): + raise SetupError( + 'the value of the "message_extractors" parameter must be a dictionary' + ) + + +class compile_catalog(frontend.CompileCatalog, Command): + """Catalog compilation command for use in ``setup.py`` scripts. + + If correctly installed, this command is available to Setuptools-using + setup scripts automatically. For projects using plain old ``distutils``, + the command needs to be registered explicitly in ``setup.py``:: + + from babel.messages.setuptools_frontend import compile_catalog + + setup( + ... + cmdclass = {'compile_catalog': compile_catalog} + ) + + .. versionadded:: 0.9 + """ + + +class extract_messages(frontend.ExtractMessages, Command): + """Message extraction command for use in ``setup.py`` scripts. + + If correctly installed, this command is available to Setuptools-using + setup scripts automatically. For projects using plain old ``distutils``, + the command needs to be registered explicitly in ``setup.py``:: + + from babel.messages.setuptools_frontend import extract_messages + + setup( + ... + cmdclass = {'extract_messages': extract_messages} + ) + """ + + +class init_catalog(frontend.InitCatalog, Command): + """New catalog initialization command for use in ``setup.py`` scripts. + + If correctly installed, this command is available to Setuptools-using + setup scripts automatically. For projects using plain old ``distutils``, + the command needs to be registered explicitly in ``setup.py``:: + + from babel.messages.setuptools_frontend import init_catalog + + setup( + ... + cmdclass = {'init_catalog': init_catalog} + ) + """ + + +class update_catalog(frontend.UpdateCatalog, Command): + """Catalog merging command for use in ``setup.py`` scripts. + + If correctly installed, this command is available to Setuptools-using + setup scripts automatically. For projects using plain old ``distutils``, + the command needs to be registered explicitly in ``setup.py``:: + + from babel.messages.setuptools_frontend import update_catalog + + setup( + ... + cmdclass = {'update_catalog': update_catalog} + ) + + .. versionadded:: 0.9 + """ + + +COMMANDS = { + "compile_catalog": compile_catalog, + "extract_messages": extract_messages, + "init_catalog": init_catalog, + "update_catalog": update_catalog, +} diff --git a/conftest.py b/conftest.py index 3982cef4e..79aeecf81 100644 --- a/conftest.py +++ b/conftest.py @@ -2,7 +2,11 @@ from _pytest.doctest import DoctestModule -collect_ignore = ['tests/messages/data', 'setup.py'] +collect_ignore = [ + 'babel/messages/setuptools_frontend.py', + 'setup.py', + 'tests/messages/data', +] babel_path = Path(__file__).parent / 'babel' diff --git a/setup.py b/setup.py index 6d43080d2..5233af1c8 100755 --- a/setup.py +++ b/setup.py @@ -67,9 +67,6 @@ def run(self): # higher. # Python 3.9 and later include zoneinfo which replaces pytz 'pytz>=2015.7; python_version<"3.9"', - # https://github.com/python/cpython/issues/95299 - # https://github.com/python-babel/babel/issues/1031 - 'setuptools; python_version>="3.12"', ], extras_require={ 'dev': [ @@ -89,13 +86,13 @@ def run(self): pybabel = babel.messages.frontend:main [distutils.commands] - compile_catalog = babel.messages.frontend:compile_catalog - extract_messages = babel.messages.frontend:extract_messages - init_catalog = babel.messages.frontend:init_catalog - update_catalog = babel.messages.frontend:update_catalog + compile_catalog = babel.messages.setuptools_frontend:compile_catalog + extract_messages = babel.messages.setuptools_frontend:extract_messages + init_catalog = babel.messages.setuptools_frontend:init_catalog + update_catalog = babel.messages.setuptools_frontend:update_catalog [distutils.setup_keywords] - message_extractors = babel.messages.frontend:check_message_extractors + message_extractors = babel.messages.setuptools_frontend:check_message_extractors [babel.checkers] num_plurals = babel.messages.checkers:num_plurals diff --git a/tests/messages/consts.py b/tests/messages/consts.py new file mode 100644 index 000000000..34509b304 --- /dev/null +++ b/tests/messages/consts.py @@ -0,0 +1,12 @@ +import os + +TEST_PROJECT_DISTRIBUTION_DATA = { + "name": "TestProject", + "version": "0.1", + "packages": ["project"], +} +this_dir = os.path.abspath(os.path.dirname(__file__)) +data_dir = os.path.join(this_dir, 'data') +project_dir = os.path.join(data_dir, 'project') +i18n_dir = os.path.join(project_dir, 'i18n') +pot_file = os.path.join(i18n_dir, 'temp.pot') diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py index b28cb0da2..a5f436305 100644 --- a/tests/messages/test_frontend.py +++ b/tests/messages/test_frontend.py @@ -18,10 +18,10 @@ import unittest from datetime import datetime, timedelta from io import BytesIO, StringIO +from typing import List import pytest from freezegun import freeze_time -from setuptools import Distribution from babel import __version__ as VERSION from babel.dates import format_datetime @@ -29,30 +29,41 @@ from babel.messages.frontend import ( BaseError, CommandLineInterface, + ExtractMessages, OptionError, - extract_messages, - update_catalog, + UpdateCatalog, ) from babel.messages.pofile import read_po, write_po from babel.util import LOCALTZ - -TEST_PROJECT_DISTRIBUTION_DATA = { - "name": "TestProject", - "version": "0.1", - "packages": ["project"], -} - -this_dir = os.path.abspath(os.path.dirname(__file__)) -data_dir = os.path.join(this_dir, 'data') -project_dir = os.path.join(data_dir, 'project') -i18n_dir = os.path.join(project_dir, 'i18n') -pot_file = os.path.join(i18n_dir, 'temp.pot') +from tests.messages.consts import ( + TEST_PROJECT_DISTRIBUTION_DATA, + data_dir, + i18n_dir, + pot_file, + project_dir, + this_dir, +) def _po_file(locale): return os.path.join(i18n_dir, locale, 'LC_MESSAGES', 'messages.po') +class Distribution: # subset of distutils.dist.Distribution + def __init__(self, attrs: dict) -> None: + self.attrs = attrs + + def get_name(self) -> str: + return self.attrs['name'] + + def get_version(self) -> str: + return self.attrs['version'] + + @property + def packages(self) -> List[str]: + return self.attrs['packages'] + + class CompileCatalogTestCase(unittest.TestCase): def setUp(self): @@ -60,7 +71,7 @@ def setUp(self): os.chdir(data_dir) self.dist = Distribution(TEST_PROJECT_DISTRIBUTION_DATA) - self.cmd = frontend.compile_catalog(self.dist) + self.cmd = frontend.CompileCatalog(self.dist) self.cmd.initialize_options() def tearDown(self): @@ -86,7 +97,7 @@ def setUp(self): os.chdir(data_dir) self.dist = Distribution(TEST_PROJECT_DISTRIBUTION_DATA) - self.cmd = frontend.extract_messages(self.dist) + self.cmd = frontend.ExtractMessages(self.dist) self.cmd.initialize_options() def tearDown(self): @@ -355,7 +366,7 @@ def setUp(self): os.chdir(data_dir) self.dist = Distribution(TEST_PROJECT_DISTRIBUTION_DATA) - self.cmd = frontend.init_catalog(self.dist) + self.cmd = frontend.InitCatalog(self.dist) self.cmd.initialize_options() def tearDown(self): @@ -1433,6 +1444,7 @@ def test_parse_keywords_with_t(): } } + def test_extract_messages_with_t(): content = rb""" _("1 arg, arg 1") @@ -1464,27 +1476,6 @@ def configure_cli_command(cmdline): return cmdinst -def configure_distutils_command(cmdline): - """ - Helper to configure a command class, but not run it just yet. - - This will have strange side effects if you pass in things - `distutils` deals with internally. - - :param cmdline: The command line (sans the executable name) - :return: Command instance - """ - d = Distribution(attrs={ - "cmdclass": vars(frontend), - "script_args": shlex.split(cmdline), - }) - d.parse_command_line() - assert len(d.commands) == 1 - cmdinst = d.get_command_obj(d.commands[0]) - cmdinst.ensure_finalized() - return cmdinst - - @pytest.mark.parametrize("split", (False, True)) @pytest.mark.parametrize("arg_name", ("-k", "--keyword", "--keywords")) def test_extract_keyword_args_384(split, arg_name): @@ -1515,7 +1506,7 @@ def test_extract_keyword_args_384(split, arg_name): cmdinst = configure_cli_command( f"extract -F babel-django.cfg --add-comments Translators: -o django232.pot {kwarg_text} ." ) - assert isinstance(cmdinst, extract_messages) + assert isinstance(cmdinst, ExtractMessages) assert set(cmdinst.keywords.keys()) == {'_', 'dgettext', 'dngettext', 'gettext', 'gettext_lazy', 'gettext_noop', 'N_', 'ngettext', @@ -1526,31 +1517,10 @@ def test_extract_keyword_args_384(split, arg_name): 'ungettext', 'ungettext_lazy'} -@pytest.mark.parametrize("kwarg,expected", [ - ("LW_", ("LW_",)), - ("LW_ QQ Q", ("LW_", "QQ", "Q")), - ("yiy aia", ("yiy", "aia")), -]) -def test_extract_distutils_keyword_arg_388(kwarg, expected): - # This is a regression test for https://github.com/python-babel/babel/issues/388 - - # Note that distutils-based commands only support a single repetition of the same argument; - # hence `--keyword ignored` will actually never end up in the output. - - cmdinst = configure_distutils_command( - "extract_messages --no-default-keywords --keyword ignored --keyword '%s' " - "--input-dirs . --output-file django233.pot --add-comments Bar,Foo" % kwarg - ) - assert isinstance(cmdinst, extract_messages) - assert set(cmdinst.keywords.keys()) == set(expected) - - # Test the comma-separated comment argument while we're at it: - assert set(cmdinst.add_comments) == {"Bar", "Foo"} - - def test_update_catalog_boolean_args(): - cmdinst = configure_cli_command("update --init-missing --no-wrap -N --ignore-obsolete --previous -i foo -o foo -l en") - assert isinstance(cmdinst, update_catalog) + cmdinst = configure_cli_command( + "update --init-missing --no-wrap -N --ignore-obsolete --previous -i foo -o foo -l en") + assert isinstance(cmdinst, UpdateCatalog) assert cmdinst.init_missing is True assert cmdinst.no_wrap is True assert cmdinst.no_fuzzy_matching is True @@ -1561,25 +1531,25 @@ def test_update_catalog_boolean_args(): def test_extract_cli_knows_dash_s(): # This is a regression test for https://github.com/python-babel/babel/issues/390 cmdinst = configure_cli_command("extract -s -o foo babel") - assert isinstance(cmdinst, extract_messages) + assert isinstance(cmdinst, ExtractMessages) assert cmdinst.strip_comments def test_extract_add_location(): cmdinst = configure_cli_command("extract -o foo babel --add-location full") - assert isinstance(cmdinst, extract_messages) + assert isinstance(cmdinst, ExtractMessages) assert cmdinst.add_location == 'full' assert not cmdinst.no_location assert cmdinst.include_lineno cmdinst = configure_cli_command("extract -o foo babel --add-location file") - assert isinstance(cmdinst, extract_messages) + assert isinstance(cmdinst, ExtractMessages) assert cmdinst.add_location == 'file' assert not cmdinst.no_location assert not cmdinst.include_lineno cmdinst = configure_cli_command("extract -o foo babel --add-location never") - assert isinstance(cmdinst, extract_messages) + assert isinstance(cmdinst, ExtractMessages) assert cmdinst.add_location == 'never' assert cmdinst.no_location @@ -1603,7 +1573,7 @@ def test_extract_ignore_dirs(monkeypatch, capsys, tmp_path, with_underscore_igno # This also tests that multiple arguments are supported. cmd += "--ignore-dirs '_*'" cmdinst = configure_cli_command(cmd) - assert isinstance(cmdinst, extract_messages) + assert isinstance(cmdinst, ExtractMessages) assert cmdinst.directory_filter cmdinst.run() pot_content = pot_file.read_text() diff --git a/tests/messages/test_setuptools_frontend.py b/tests/messages/test_setuptools_frontend.py new file mode 100644 index 000000000..825d214f2 --- /dev/null +++ b/tests/messages/test_setuptools_frontend.py @@ -0,0 +1,102 @@ +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from tests.messages.consts import data_dir + +Distribution = pytest.importorskip("setuptools").Distribution + + +@pytest.mark.parametrize("kwarg,expected", [ + ("LW_", ("LW_",)), + ("LW_ QQ Q", ("LW_", "QQ", "Q")), + ("yiy aia", ("yiy", "aia")), +]) +def test_extract_distutils_keyword_arg_388(kwarg, expected): + from babel.messages import frontend, setuptools_frontend + + # This is a regression test for https://github.com/python-babel/babel/issues/388 + + # Note that distutils-based commands only support a single repetition of the same argument; + # hence `--keyword ignored` will actually never end up in the output. + + cmdline = ( + "extract_messages --no-default-keywords --keyword ignored --keyword '%s' " + "--input-dirs . --output-file django233.pot --add-comments Bar,Foo" % kwarg + ) + d = Distribution(attrs={ + "cmdclass": setuptools_frontend.COMMANDS, + "script_args": shlex.split(cmdline), + }) + d.parse_command_line() + assert len(d.commands) == 1 + cmdinst = d.get_command_obj(d.commands[0]) + cmdinst.ensure_finalized() + assert isinstance(cmdinst, frontend.ExtractMessages) + assert isinstance(cmdinst, setuptools_frontend.extract_messages) + assert set(cmdinst.keywords.keys()) == set(expected) + + # Test the comma-separated comment argument while we're at it: + assert set(cmdinst.add_comments) == {"Bar", "Foo"} + + +def test_setuptools_commands(tmp_path, monkeypatch): + """ + Smoke-tests all of the setuptools versions of the commands in turn. + + Their full functionality is tested better in `test_frontend.py`. + """ + # Copy the test project to a temporary directory and work there + dest = tmp_path / "dest" + shutil.copytree(data_dir, dest) + monkeypatch.chdir(dest) + + env = os.environ.copy() + # When in Tox, we need to hack things a bit so as not to have the + # sub-interpreter `sys.executable` use the tox virtualenv's Babel + # installation, so the locale data is where we expect it to be. + if "BABEL_TOX_INI_DIR" in env: + env["PYTHONPATH"] = env["BABEL_TOX_INI_DIR"] + + # Initialize an empty catalog + subprocess.check_call([ + sys.executable, + "setup.py", + "init_catalog", + "-i", os.devnull, + "-l", "fi", + "-d", "inited", + ], env=env) + po_file = Path("inited/fi/LC_MESSAGES/messages.po") + orig_po_data = po_file.read_text() + subprocess.check_call([ + sys.executable, + "setup.py", + "extract_messages", + "-o", "extracted.pot", + ], env=env) + pot_file = Path("extracted.pot") + pot_data = pot_file.read_text() + assert "FooBar, TM" in pot_data # should be read from setup.cfg + assert "bugs.address@email.tld" in pot_data # should be read from setup.cfg + subprocess.check_call([ + sys.executable, + "setup.py", + "update_catalog", + "-i", "extracted.pot", + "-d", "inited", + ], env=env) + new_po_data = po_file.read_text() + assert new_po_data != orig_po_data # check we updated the file + subprocess.check_call([ + sys.executable, + "setup.py", + "compile_catalog", + "-d", "inited", + ], env=env) + assert po_file.with_suffix(".mo").exists() diff --git a/tox.ini b/tox.ini index dd1b9a6ff..ec0c9cd6f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,10 @@ [tox] +isolated_build = true envlist = py{37,38,39,310,311,312} pypy3 py{37,38}-pytz + py{311,312}-setuptools [testenv] extras = @@ -11,10 +13,12 @@ deps = backports.zoneinfo;python_version<"3.9" tzdata;sys_platform == 'win32' pytz: pytz + setuptools: setuptools allowlist_externals = make commands = make clean-cldr test setenv = PYTEST_FLAGS=--cov=babel --cov-report=xml:{env:COVERAGE_XML_PATH:.coverage_cache}/coverage.{envname}.xml + BABEL_TOX_INI_DIR={toxinidir} passenv = BABEL_* PYTEST_*