diff --git a/babel/messages/_compat.py b/babel/messages/_compat.py new file mode 100644 index 000000000..319b545f0 --- /dev/null +++ b/babel/messages/_compat.py @@ -0,0 +1,34 @@ +import sys +from functools import partial + + +def find_entrypoints(group_name: str): + """ + Find entrypoints of a given group using either `importlib.metadata` or the + older `pkg_resources` mechanism. + + Yields tuples of the entrypoint name and a callable function that will + load the actual entrypoint. + """ + if sys.version_info >= (3, 10): + # "Changed in version 3.10: importlib.metadata is no longer provisional." + try: + from importlib.metadata import entry_points + except ImportError: + pass + else: + eps = entry_points(group=group_name) + # Only do this if this implementation of `importlib.metadata` is + # modern enough to not return a dict. + if not isinstance(eps, dict): + for entry_point in eps: + yield (entry_point.name, entry_point.load) + return + + try: + from pkg_resources import working_set + except ImportError: + pass + else: + for entry_point in working_set.iter_entry_points(group_name): + yield (entry_point.name, partial(entry_point.load, require=True)) diff --git a/babel/messages/checkers.py b/babel/messages/checkers.py index df1159ded..2889b4e6c 100644 --- a/babel/messages/checkers.py +++ b/babel/messages/checkers.py @@ -155,16 +155,11 @@ def _check_positional(results: list[tuple[str, str]]) -> bool: def _find_checkers() -> list[Callable[[Catalog | None, Message], object]]: + from babel.messages._compat import find_entrypoints checkers: list[Callable[[Catalog | None, Message], object]] = [] - try: - from pkg_resources import working_set - except ImportError: - pass - else: - for entry_point in working_set.iter_entry_points('babel.checkers'): - checkers.append(entry_point.load()) + checkers.extend(load() for (name, load) in find_entrypoints('babel.checkers')) if len(checkers) == 0: - # if pkg_resources is not available or no usable egg-info was found + # if entrypoints are not available or no usable egg-info was found # (see #230), just resort to hard-coded checkers return [num_plurals, python_format] return checkers diff --git a/babel/messages/extract.py b/babel/messages/extract.py index 26d736e7a..8d4bbeaf8 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -30,11 +30,13 @@ Mapping, MutableSequence, ) +from functools import lru_cache from os.path import relpath from textwrap import dedent from tokenize import COMMENT, NAME, OP, STRING, generate_tokens from typing import TYPE_CHECKING, Any +from babel.messages._compat import find_entrypoints from babel.util import parse_encoding, parse_future_flags, pathmatch if TYPE_CHECKING: @@ -363,6 +365,14 @@ def _match_messages_against_spec(lineno: int, messages: list[str|None], comments return lineno, translatable, comments, context +@lru_cache(maxsize=None) +def _find_extractor(name: str): + for ep_name, load in find_entrypoints(GROUP_NAME): + if ep_name == name: + return load() + return None + + def extract( method: _ExtractionMethod, fileobj: _FileObj, @@ -421,25 +431,11 @@ def extract( module, attrname = method.split(':', 1) func = getattr(__import__(module, {}, {}, [attrname]), attrname) else: - try: - from pkg_resources import working_set - except ImportError: - pass - else: - for entry_point in working_set.iter_entry_points(GROUP_NAME, - method): - func = entry_point.load(require=True) - break + func = _find_extractor(method) if func is None: - # if pkg_resources is not available or no usable egg-info was found - # (see #230), we resort to looking up the builtin extractors - # directly - builtin = { - 'ignore': extract_nothing, - 'python': extract_python, - 'javascript': extract_javascript, - } - func = builtin.get(method) + # if no named entry point was found, + # we resort to looking up a builtin extractor + func = _BUILTIN_EXTRACTORS.get(method) if func is None: raise ValueError(f"Unknown extraction method {method!r}") @@ -838,3 +834,10 @@ def parse_template_string( lineno += len(line_re.findall(expression_contents)) expression_contents = '' prev_character = character + + +_BUILTIN_EXTRACTORS = { + 'ignore': extract_nothing, + 'python': extract_python, + 'javascript': extract_javascript, +} diff --git a/tests/interop/__init__.py b/tests/interop/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/interop/jinja2_data/hello.html b/tests/interop/jinja2_data/hello.html new file mode 100644 index 000000000..c2bb4ebdf --- /dev/null +++ b/tests/interop/jinja2_data/hello.html @@ -0,0 +1 @@ +{% trans %}Hello, {{ name }}!{% endtrans %} diff --git a/tests/interop/jinja2_data/mapping.cfg b/tests/interop/jinja2_data/mapping.cfg new file mode 100644 index 000000000..ae3f8bd58 --- /dev/null +++ b/tests/interop/jinja2_data/mapping.cfg @@ -0,0 +1 @@ +[jinja2: *.html] diff --git a/tests/interop/test_jinja2_interop.py b/tests/interop/test_jinja2_interop.py new file mode 100644 index 000000000..ff04df171 --- /dev/null +++ b/tests/interop/test_jinja2_interop.py @@ -0,0 +1,20 @@ +import pathlib + +import pytest + +from babel.messages import frontend + +jinja2 = pytest.importorskip("jinja2") + +jinja2_data_path = pathlib.Path(__file__).parent / "jinja2_data" + + +def test_jinja2_interop(monkeypatch, tmp_path): + """ + Test that babel can extract messages from Jinja2 templates. + """ + monkeypatch.chdir(jinja2_data_path) + cli = frontend.CommandLineInterface() + pot_file = tmp_path / "messages.pot" + cli.run(['pybabel', 'extract', '--mapping', 'mapping.cfg', '-o', str(pot_file), '.']) + assert '"Hello, %(name)s!"' in pot_file.read_text() diff --git a/tox.ini b/tox.ini index cdb2514e2..70e5e7ed2 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ envlist = pypy3 py{38}-pytz py{311,312}-setuptools + py312-jinja [testenv] extras = @@ -15,6 +16,7 @@ deps = tzdata;sys_platform == 'win32' pytz: pytz setuptools: setuptools + jinja: jinja2>=3.0 allowlist_externals = make commands = make clean-cldr test setenv =