Skip to content
Open
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
147 changes: 114 additions & 33 deletions babel/messages/frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,104 @@
log = logging.getLogger('babel')


def _parse_toml(
fileobj: BinaryIO,
style: Literal["standalone", "pyproject.toml"],
filename: str = "(unknown)",
allow_missing_sections: bool = False,
) -> dict[str, Any]:
"""Parse the Babel configuration from the given binary file object.

The ``style`` can be either "pyproject.toml" or "standalone", and dictates
the sections/tables in the TOML file that will be used.
The ``filename`` will be used in error messages.
If ``allow_missing_sections`` is set and the TOML file doesn't have a
``[tool.babel]`` section, an empty dictionary will be returned instead of raising
a ``ConfigurationError``.
"""
try:
import tomllib
except ImportError:
try:
import tomli as tomllib
except ImportError as ie: # pragma: no cover
raise ImportError(
"tomli or tomllib is required to parse TOML files"
) from ie

try:
parsed_data = tomllib.load(fileobj)
except tomllib.TOMLDecodeError as e:
raise ConfigurationError(f"{filename}: Error parsing TOML file: {e}") from e

if style == "pyproject.toml":
try:
babel_data = parsed_data["tool"]["babel"]
except (TypeError, KeyError) as e:
if not allow_missing_sections:
raise ConfigurationError(
f"{filename}: No 'tool.babel' section found in file",
) from e
else:
return {}

elif style == "standalone":
babel_data = parsed_data
if "babel" in babel_data:
raise ConfigurationError(
f"{filename}: 'babel' should not be present in a stand-alone configuration file",
)
else: # pragma: no cover
raise ValueError(f"Unknown TOML style {style!r}")

return babel_data


def find_pyproject_toml(path: pathlib.Path | None, max_depth=10) -> pathlib.Path | None:
"""Find the closest "pyproject.toml" file to the specified path.

If the path already points to a "pyproject.toml" file, return it.
Otherwise, check if the path's directory contains one and return that, or walk
up the directory hierarchy until one has been found or the maximum search depth
has been exhausted.
"""
if path is None:
path = pathlib.Path(".")

if not path.is_dir() and path.name == "pyproject.toml":
return path

tries = 0
while tries < max_depth:
pyproject_file = path / "pyproject.toml"
if pyproject_file.exists():
return pyproject_file

parent = path.parent
if path == parent:
return None

path = parent
tries += 1

return None


def parse_pyproject_toml_config(path: pathlib.Path | None, table_name: str) -> dict:
"""Parse the Babel config from a "pyproject.toml" file near the specified path."""
path = find_pyproject_toml(path)
if path and path.exists():
babel_data = _parse_toml(
path.open("rb"),
style="pyproject.toml",
filename=path.name,
allow_missing_sections=True,
)
return babel_data.get(table_name, {})
else:
return {}


class BaseError(Exception):
pass

Expand Down Expand Up @@ -192,6 +290,7 @@ class CompileCatalog(CommandMixin):
'print statistics about translations'),
] # fmt: skip
boolean_options = ['use-fuzzy', 'statistics']
toml_config_table = "compile_catalog"

def initialize_options(self):
self.domain = 'messages'
Expand Down Expand Up @@ -381,6 +480,7 @@ class ExtractMessages(CommandMixin):
option_choices = {
'add-location': ('full', 'file', 'never'),
}
toml_config_table = "extract_messages"

def initialize_options(self):
self.charset = 'utf-8'
Expand Down Expand Up @@ -570,7 +670,7 @@ def _get_mappings(self):
if os.path.basename(self.mapping_file) == "pyproject.toml"
else "standalone"
)
method_map, options_map = _parse_mapping_toml(
method_map, options_map = _parse_mappings_from_toml(
fileobj,
filename=self.mapping_file,
style=file_style,
Expand Down Expand Up @@ -641,6 +741,7 @@ class InitCatalog(CommandMixin):
'into several lines'),
] # fmt: skip
boolean_options = ['no-wrap']
toml_config_table = "init_catalog"

def initialize_options(self):
self.output_dir = None
Expand Down Expand Up @@ -733,6 +834,7 @@ class UpdateCatalog(CommandMixin):
'check',
'ignore-pot-creation-date',
]
toml_config_table = "update_catalog"

def initialize_options(self):
self.domain = 'messages'
Expand Down Expand Up @@ -1219,6 +1321,7 @@ def run(self, argv=None):
if cmdname not in self.commands:
self.parser.error(f'unknown command "{cmdname}"')

# TODO add possibility to turn TOML config parsing off, e.g. new option above?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@akx thoughts & prayers?

cmdinst = self._configure_command(cmdname, args[1:])
return cmdinst.run()

Expand Down Expand Up @@ -1252,14 +1355,16 @@ def _configure_command(self, cmdname: str, argv: list[str]) -> CommandMixin:
assert isinstance(cmdinst, CommandMixin)
cmdinst.initialize_options()

toml_config = parse_pyproject_toml_config(None, cmdinst.toml_config_table)
parser = optparse.OptionParser(
usage=self.usage % (cmdname, ''),
description=self.commands[cmdname],
)
as_args: str | None = getattr(cmdclass, "as_args", None)
for long, short, help in cmdclass.user_options:
name = long.strip("=")
default = getattr(cmdinst, name.replace("-", "_"))
normalized_name = name.replace("-", "_")
default = toml_config.get(normalized_name) or getattr(cmdinst, normalized_name)
strs = [f"--{name}"]
if short:
strs.append(f"-{short}")
Expand All @@ -1273,9 +1378,13 @@ def _configure_command(self, cmdname: str, argv: list[str]) -> CommandMixin:
parser.add_option(*strs, action="append", help=help, choices=choices)
else:
parser.add_option(*strs, help=help, default=default, choices=choices)

parser.set_defaults(**toml_config)
options, args = parser.parse_args(argv)

if as_args:
# use the TOML config as fallback if args isn't set
args = args or toml_config.get(as_args.replace("-", "_"), args)
setattr(options, as_args.replace('-', '_'), args)

for key, value in vars(options).items():
Expand Down Expand Up @@ -1437,7 +1546,7 @@ def _parse_config_object(config: dict, *, filename="(unknown)"):
return method_map, options_map


def _parse_mapping_toml(
def _parse_mappings_from_toml(
fileobj: BinaryIO,
filename: str = "(unknown)",
style: Literal["standalone", "pyproject.toml"] = "standalone",
Expand All @@ -1450,36 +1559,8 @@ def _parse_mapping_toml(
:param filename: the name of the file being parsed, for error messages
:param style: whether the file is in the style of a `pyproject.toml` file, i.e. whether to look for `tool.babel`.
"""
try:
import tomllib
except ImportError:
try:
import tomli as tomllib
except ImportError as ie: # pragma: no cover
raise ImportError("tomli or tomllib is required to parse TOML files") from ie

try:
parsed_data = tomllib.load(fileobj)
except tomllib.TOMLDecodeError as e:
raise ConfigurationError(f"{filename}: Error parsing TOML file: {e}") from e

if style == "pyproject.toml":
try:
babel_data = parsed_data["tool"]["babel"]
except (TypeError, KeyError) as e:
raise ConfigurationError(
f"{filename}: No 'tool.babel' section found in file",
) from e
elif style == "standalone":
babel_data = parsed_data
if "babel" in babel_data:
raise ConfigurationError(
f"{filename}: 'babel' should not be present in a stand-alone configuration file",
)
else: # pragma: no cover
raise ValueError(f"Unknown TOML style {style!r}")

return _parse_config_object(babel_data, filename=filename)
parsed_data = _parse_toml(fileobj, style, filename=filename)
return _parse_config_object(parsed_data, filename=filename)


def _parse_spec(s: str) -> tuple[int | None, tuple[int | tuple[int, str], ...]]:
Expand Down
67 changes: 67 additions & 0 deletions tests/messages/frontend/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import logging
import os
import pathlib
import shutil
import sys
import time
Expand Down Expand Up @@ -44,6 +45,17 @@ def cli(monkeypatch, capsys):
_remove_log_handlers()


@pytest.fixture
def pyproject_toml_file():
"""
Fixture to create ``${data_dir}/pyproject.toml`` and clean it after the test.
"""
pyproject_toml = (pathlib.Path(data_dir) / "pyproject.toml")
pyproject_toml.touch()
yield pyproject_toml
pyproject_toml.unlink()


def _remove_log_handlers():
# Logging handlers will be reused if possible (#227). This breaks the
# implicit assumption that our newly created StringIO for sys.stderr
Expand Down Expand Up @@ -219,6 +231,61 @@ def test_extract_with_mapping_file(formatted_frozen_time, cli, pot_file):
assert expected_content == pot_file.read_text()


def test_extract_with_mapping_file_from_pyproject_toml_config(
formatted_frozen_time, cli, pot_file, pyproject_toml_file,
):
"""
Test the same as ``test_extract_with_mapping_file()`` but with ``pyproject.toml``
as the configuration source instead of CLI arguments.
"""
pyproject_toml_file.write_text(
'[tool.babel.extract_messages]\n'
'copyright_holder = "FooBar, Inc."\n'
'project = "TestProject"\n'
'version = "0.1"\n'
'msgid_bugs_address = "bugs.address@email.tld"\n'
f'mapping_file = "{os.path.join(data_dir, 'mapping.cfg')}"\n'
'add_comments = "TRANSLATOR,TRANSLATORS:"\n'
f'output_file = "{pot_file}"\n'
)
cli.run(['pybabel', 'extract', 'project'])

expected_content = rf"""# Translations template for TestProject.
# Copyright (C) {time.strftime('%Y')} FooBar, Inc.
# This file is distributed under the same license as the TestProject
# project.
# FIRST AUTHOR <EMAIL@ADDRESS>, {time.strftime('%Y')}.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: TestProject 0.1\n"
"Report-Msgid-Bugs-To: bugs.address@email.tld\n"
"POT-Creation-Date: {formatted_frozen_time}\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel {VERSION}\n"

#. TRANSLATOR: This will be a translator coment,
#. that will include several lines
#: project/file1.py:8
msgid "bar"
msgstr ""

#: project/file2.py:9
msgid "foobar"
msgid_plural "foobars"
msgstr[0] ""
msgstr[1] ""

"""
assert expected_content == pot_file.read_text()


def test_extract_with_exact_file(formatted_frozen_time, cli, pot_file):
"""Tests that we can call extract with a particular file and only
strings from that file get extracted. (Note the absence of strings from file1.py)
Expand Down
4 changes: 2 additions & 2 deletions tests/messages/frontend/test_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,13 @@
),
(
mapping_toml,
frontend._parse_mapping_toml,
frontend._parse_mappings_from_toml,
None,
True,
),
(
mapping_toml,
partial(frontend._parse_mapping_toml, style="pyproject.toml"),
partial(frontend._parse_mappings_from_toml, style="pyproject.toml"),
lambda s: re.sub(r"^(\[+)", r"\1tool.babel.", s, flags=re.MULTILINE),
True,
),
Expand Down
31 changes: 29 additions & 2 deletions tests/messages/test_toml_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
def parse_toml(cfg: bytes | str):
if isinstance(cfg, str):
cfg = cfg.encode("utf-8")
return frontend._parse_mapping_toml(BytesIO(cfg))
return frontend._parse_mappings_from_toml(BytesIO(cfg))


def test_toml_mapping_multiple_patterns():
Expand Down Expand Up @@ -78,8 +78,35 @@ def test_bad_toml_test_case(test_case: pathlib.Path):
"""
with pytest.raises(frontend.ConfigurationError):
with test_case.open("rb") as f:
frontend._parse_mapping_toml(
frontend._parse_mappings_from_toml(
f,
filename=test_case.name,
style="pyproject.toml" if "pyproject" in test_case.name else "standalone",
)


def test_parse_pyproject_toml_config_without_pyproject_toml_file(tmp_path):
"""
Test that trying to parse the config from a missing ``pyproject.toml`` doesn't crash.
"""
assert frontend.parse_pyproject_toml_config(tmp_path, "extract_messages") == {}


@pytest.mark.parametrize(
("pyproject_toml_content", "expected"),
[
('[project]\nname = "test"\n', {}), # no [tool.babel] section
('[tool.babel.compile_catalog]\ndomain = "test"\n', {}), # different command
(
'[tool.babel.extract_messages]\noutput_file = "test.pot"\n',
{"output_file": "test.pot"},
),
],
)
def test_parse_pyproject_toml_config(tmp_path, pyproject_toml_content, expected):
"""
Test different cases of Babel configuration in ``pyproject.toml``.
"""
(tmp_path / "pyproject.toml").write_text(pyproject_toml_content)
assert frontend.parse_pyproject_toml_config(tmp_path, "extract_messages") == expected
(tmp_path / "pyproject.toml").unlink()