Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow use of importlib.metadata for finding entrypoints #1102

Merged
merged 3 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions babel/messages/_compat.py
Original file line number Diff line number Diff line change
@@ -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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the try/except block needed if we have an explicit version check? importlib.metadata should always be available on 3.10+

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the try/except block needed if we have an explicit version check? importlib.metadata should always be available on 3.10+

#1102 (comment)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As @podgorniy94 mentioned, I think it's a good thing to double-check, for supporting some more esoteric environments.

Copy link
Contributor

@podgorniy94 podgorniy94 Jul 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As @podgorniy94 mentioned, I think it's a good thing to double-check, for supporting some more esoteric environments.

Actually, there's no need to check the Python version. We simply try pkg_resources, and if the library isn't available, then we attempt importlib.metadata. To avoid strict version checking for, as you said, esoteric environments where there might be different version labeling :) What do you think?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes and no – using pkg_resources where it's deprecated will raise a DeprecationWarning, which in some (in e.g. my work-work) environments deprecation warnings are hard errors in tests.

pass

Check warning on line 18 in babel/messages/_compat.py

View check run for this annotation

Codecov / codecov/patch

babel/messages/_compat.py#L17-L18

Added lines #L17 - L18 were not covered by tests
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

Check warning on line 31 in babel/messages/_compat.py

View check run for this annotation

Codecov / codecov/patch

babel/messages/_compat.py#L30-L31

Added lines #L30 - L31 were not covered by tests
else:
for entry_point in working_set.iter_entry_points(group_name):
yield (entry_point.name, partial(entry_point.load, require=True))
11 changes: 3 additions & 8 deletions babel/messages/checkers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 21 additions & 18 deletions babel/messages/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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,
}
Empty file added tests/interop/__init__.py
Empty file.
1 change: 1 addition & 0 deletions tests/interop/jinja2_data/hello.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{% trans %}Hello, {{ name }}!{% endtrans %}
1 change: 1 addition & 0 deletions tests/interop/jinja2_data/mapping.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[jinja2: *.html]
20 changes: 20 additions & 0 deletions tests/interop/test_jinja2_interop.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ envlist =
pypy3
py{38}-pytz
py{311,312}-setuptools
py312-jinja

[testenv]
extras =
Expand All @@ -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 =
Expand Down