]> git.ipfire.org Git - thirdparty/babel.git/commitdiff
Allow use of importlib.metadata for finding entrypoints (#1102)
authorAarni Koskela <akx@iki.fi>
Thu, 25 Jul 2024 09:51:43 +0000 (12:51 +0300)
committerGitHub <noreply@github.com>
Thu, 25 Jul 2024 09:51:43 +0000 (12:51 +0300)
* Support importlib.metadata for finding entrypoints
* Add a basic interoperability test for Jinja2 extraction
* Only use importlib.metadata on Python 3.10+

Co-authored-by: podgorniy94 <podgorniy.inc@gmail.com>
babel/messages/_compat.py [new file with mode: 0644]
babel/messages/checkers.py
babel/messages/extract.py
tests/interop/__init__.py [new file with mode: 0644]
tests/interop/jinja2_data/hello.html [new file with mode: 0644]
tests/interop/jinja2_data/mapping.cfg [new file with mode: 0644]
tests/interop/test_jinja2_interop.py [new file with mode: 0644]
tox.ini

diff --git a/babel/messages/_compat.py b/babel/messages/_compat.py
new file mode 100644 (file)
index 0000000..319b545
--- /dev/null
@@ -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))
index df1159dedf2e064f8af1b50abb3eaba609e8c358..2889b4e6c0d3a5c6c698824643930de11ed116e4 100644 (file)
@@ -155,16 +155,11 @@ def _validate_format(format: str, alternative: str) -> None:
 
 
 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
index 26d736e7a887c74f3aa4a70d5d5a8d31da5a10ad..8d4bbeaf8c67a8d2bc4c4a4ae86cef69de4e2947 100644 (file)
@@ -30,11 +30,13 @@ from collections.abc import (
     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 (file)
index 0000000..e69de29
diff --git a/tests/interop/jinja2_data/hello.html b/tests/interop/jinja2_data/hello.html
new file mode 100644 (file)
index 0000000..c2bb4eb
--- /dev/null
@@ -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 (file)
index 0000000..ae3f8bd
--- /dev/null
@@ -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 (file)
index 0000000..ff04df1
--- /dev/null
@@ -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 cdb2514e207fd586b8f9cd366afc3d1c4f824d28..70e5e7ed24905cd4d2b79690ef54929fa849fdb7 100644 (file)
--- 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 =