Skip to content

Commit

Permalink
Decouple pybabel frontend from distutils/setuptools; remove depende…
Browse files Browse the repository at this point in the history
…ncy (#1041)

* Decouple `pybabel` frontend from distutils/setuptools; remove dependency

* Add tox configuration for testing with setuptools too

* Use `__getattr__` for re-export

Fixes #1040

Co-authored-by: Oleh Prypin <oleh@pryp.in>
  • Loading branch information
akx and oprypin committed Nov 22, 2023
1 parent a6c52b3 commit bf7b2ca
Show file tree
Hide file tree
Showing 9 changed files with 322 additions and 178 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Expand Up @@ -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
145 changes: 46 additions & 99 deletions babel/messages/frontend.py
Expand Up @@ -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):
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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__`.
Expand All @@ -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',
Expand Down Expand Up @@ -280,31 +272,19 @@ 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(
fnmatch.fnmatch(basename, ignore_pattern)
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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(','):
Expand All @@ -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.
Expand Down Expand Up @@ -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()
108 changes: 108 additions & 0 deletions 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,
}
6 changes: 5 additions & 1 deletion conftest.py
Expand Up @@ -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'


Expand Down

0 comments on commit bf7b2ca

Please sign in to comment.