From af58d3382d2b2a51dda0edefbbf75dae5680d495 Mon Sep 17 00:00:00 2001 From: Maximilian Moser Date: Tue, 7 Jul 2026 00:27:12 +0200 Subject: [PATCH 1/2] Implement parsing of command configuration from pyproject.toml If present, use a "pyproject.toml" file for default configuration of `pybabel` commands. --- babel/messages/frontend.py | 147 ++++++++++++++++++----- tests/messages/frontend/test_frontend.py | 4 +- tests/messages/test_toml_config.py | 4 +- 3 files changed, 118 insertions(+), 37 deletions(-) diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 89826e834..7d26de49d 100644 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -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 @@ -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' @@ -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' @@ -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, @@ -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 @@ -733,6 +834,7 @@ class UpdateCatalog(CommandMixin): 'check', 'ignore-pot-creation-date', ] + toml_config_table = "update_catalog" def initialize_options(self): self.domain = 'messages' @@ -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? cmdinst = self._configure_command(cmdname, args[1:]) return cmdinst.run() @@ -1252,6 +1355,7 @@ 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], @@ -1259,7 +1363,8 @@ def _configure_command(self, cmdname: str, argv: list[str]) -> CommandMixin: 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}") @@ -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(): @@ -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", @@ -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], ...]]: diff --git a/tests/messages/frontend/test_frontend.py b/tests/messages/frontend/test_frontend.py index c8b8d17d5..366357741 100644 --- a/tests/messages/frontend/test_frontend.py +++ b/tests/messages/frontend/test_frontend.py @@ -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, ), diff --git a/tests/messages/test_toml_config.py b/tests/messages/test_toml_config.py index 1dd37a7ac..dd7b5d6bb 100644 --- a/tests/messages/test_toml_config.py +++ b/tests/messages/test_toml_config.py @@ -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(): @@ -78,7 +78,7 @@ 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", From 8bcec80028cf384271de2f36e50cc0e4e54d3d62 Mon Sep 17 00:00:00 2001 From: Maximilian Moser Date: Fri, 30 Oct 2026 13:01:55 +0100 Subject: [PATCH 2/2] Add tests for the configuration parsing from `pyproject.toml` --- tests/messages/frontend/test_cli.py | 67 +++++++++++++++++++++++++++++ tests/messages/test_toml_config.py | 27 ++++++++++++ 2 files changed, 94 insertions(+) diff --git a/tests/messages/frontend/test_cli.py b/tests/messages/frontend/test_cli.py index c6ec2ab17..eb32845ac 100644 --- a/tests/messages/frontend/test_cli.py +++ b/tests/messages/frontend/test_cli.py @@ -14,6 +14,7 @@ import logging import os +import pathlib import shutil import sys import time @@ -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 @@ -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 , {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 \n" +"Language-Team: LANGUAGE \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) diff --git a/tests/messages/test_toml_config.py b/tests/messages/test_toml_config.py index dd7b5d6bb..3ff7e1066 100644 --- a/tests/messages/test_toml_config.py +++ b/tests/messages/test_toml_config.py @@ -83,3 +83,30 @@ def test_bad_toml_test_case(test_case: pathlib.Path): 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()