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
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):
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.
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__`.
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',
"""
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(
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,
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):
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',
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',
}
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
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(
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(','):
inds.append(int(x))
return number, tuple(inds)
+
def parse_keywords(strings: Iterable[str] = ()):
"""Parse keywords specifications from the given list of strings.
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()
--- /dev/null
+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,
+}
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'
# 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': [
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
--- /dev/null
+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')
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
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):
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):
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):
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):
}
}
+
def test_extract_messages_with_t():
content = rb"""
_("1 arg, arg 1")
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):
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',
'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
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
# 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()
--- /dev/null
+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()
[tox]
+isolated_build = true
envlist =
py{37,38,39,310,311,312}
pypy3
py{37,38}-pytz
+ py{311,312}-setuptools
[testenv]
extras =
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_*