From e9409a8c650ea552289d95fb0e9f7bf98c5ffeda Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:51:23 -0400 Subject: [PATCH] Add --check mode to the extract command The update command already supports a --check mode that verifies catalogs are up to date without writing them, which is handy in CI. This adds the equivalent for extract: with --check, messages are extracted as usual but the output file is not written; instead the freshly extracted catalog is compared against the existing output file, and a non-zero exit code is returned if the file is missing or would be changed. Differences in the POT-Creation-Date header alone are ignored, since it is regenerated on every extraction. Closes #900 --- babel/messages/frontend.py | 153 +++++++++++++++++----------- docs/cmdline.rst | 12 +++ tests/messages/frontend/test_cli.py | 50 +++++++++ 3 files changed, 158 insertions(+), 57 deletions(-) diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 89826e834..782be9a81 100644 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -23,7 +23,7 @@ import warnings from collections import Counter, defaultdict from configparser import RawConfigParser -from io import StringIO +from io import BytesIO, StringIO from typing import TYPE_CHECKING, Any, BinaryIO, Iterable, Literal if TYPE_CHECKING: @@ -355,6 +355,10 @@ class ExtractMessages(CommandMixin): 'header comment for the catalog'), ('last-translator=', None, 'set the name and email of the last translator in output'), + ('check', None, + "don't write the output file, just check whether it is up to date. " + "Return code 0 means the file is up to date, return code 1 means " + "that it would be changed."), ] # fmt: skip boolean_options = [ 'no-default-keywords', @@ -364,6 +368,7 @@ class ExtractMessages(CommandMixin): 'sort-output', 'sort-by-file', 'strip-comments', + 'check', ] as_args = 'input-paths' multiple_value_options = ( @@ -407,6 +412,7 @@ def initialize_options(self): self.ignore_dirs = None self.header_comment = None self.last_translator = None + self.check = False def finalize_options(self): if self.input_dirs: @@ -497,67 +503,100 @@ def callback(filename: str, method: str, options: dict): def run(self): mappings = self._get_mappings() - with open(self.output_file, 'wb') as outfile: - catalog = Catalog( - project=self.project, - version=self.version, - msgid_bugs_address=self.msgid_bugs_address, - copyright_holder=self.copyright_holder, - charset=self.charset, - header_comment=(self.header_comment or DEFAULT_HEADER), - last_translator=self.last_translator, - ) + catalog = Catalog( + project=self.project, + version=self.version, + msgid_bugs_address=self.msgid_bugs_address, + copyright_holder=self.copyright_holder, + charset=self.charset, + header_comment=(self.header_comment or DEFAULT_HEADER), + last_translator=self.last_translator, + ) - for path, method_map, options_map in mappings: - callback = self._build_callback(path) + for path, method_map, options_map in mappings: + callback = self._build_callback(path) + if os.path.isfile(path): + current_dir = os.getcwd() + extracted = check_and_call_extract_file( + path, + method_map, + options_map, + callback=callback, + comment_tags=self.add_comments, + dirpath=current_dir, + keywords=self.keywords, + strip_comment_tags=self.strip_comments, + ) + else: + extracted = extract_from_dir( + path, + method_map, + options_map, + callback=callback, + comment_tags=self.add_comments, + directory_filter=self.directory_filter, + keywords=self.keywords, + strip_comment_tags=self.strip_comments, + ) + for filename, lineno, message, comments, context in extracted: if os.path.isfile(path): - current_dir = os.getcwd() - extracted = check_and_call_extract_file( - path, - method_map, - options_map, - callback=callback, - comment_tags=self.add_comments, - dirpath=current_dir, - keywords=self.keywords, - strip_comment_tags=self.strip_comments, - ) + filepath = filename # already normalized else: - extracted = extract_from_dir( - path, - method_map, - options_map, - callback=callback, - comment_tags=self.add_comments, - directory_filter=self.directory_filter, - keywords=self.keywords, - strip_comment_tags=self.strip_comments, - ) - for filename, lineno, message, comments, context in extracted: - if os.path.isfile(path): - filepath = filename # already normalized - else: - filepath = os.path.normpath(os.path.join(path, filename)) - - catalog.add( - message, - None, - [(filepath, lineno)], - auto_comments=comments, - context=context, - ) + filepath = os.path.normpath(os.path.join(path, filename)) + + catalog.add( + message, + None, + [(filepath, lineno)], + auto_comments=comments, + context=context, + ) + + if self.check: + self._run_check(catalog) + return + with open(self.output_file, 'wb') as outfile: self.log.info('writing PO template file to %s', self.output_file) - write_po( - outfile, - catalog, - include_lineno=self.include_lineno, - no_location=self.no_location, - omit_header=self.omit_header, - sort_by_file=self.sort_by_file, - sort_output=self.sort_output, - width=self.width, - ) + self._write_catalog(outfile, catalog) + + def _write_catalog(self, fileobj, catalog): + write_po( + fileobj, + catalog, + include_lineno=self.include_lineno, + no_location=self.no_location, + omit_header=self.omit_header, + sort_by_file=self.sort_by_file, + sort_output=self.sort_output, + width=self.width, + ) + + def _run_check(self, catalog): + if not os.path.exists(self.output_file): + self.log.warning('PO template file %s does not exist.', self.output_file) + raise BaseError(f"POT file {self.output_file} is out of date.") + + # Serialize the freshly extracted catalog the same way it would be + # written out, then read it back, so that the comparison is done on + # equal footing with the existing file (which is also read back). + buf = BytesIO() + self._write_catalog(buf, catalog) + buf.seek(0) + new_catalog = read_po(buf) + + with open(self.output_file, 'rb') as infile: + existing_catalog = read_po(infile) + + # The POT-Creation-Date header is regenerated on every extraction, so + # a difference there alone does not mean the file is out of date. + new_catalog.creation_date = existing_catalog.creation_date + + if new_catalog.is_identical(existing_catalog): + self.log.info('PO template file %s is up to date.', self.output_file) + else: + self.log.warning('PO template file %s is out of date.', self.output_file) + raise BaseError(f"POT file {self.output_file} is out of date.") def _get_mappings(self): mappings = [] diff --git a/docs/cmdline.rst b/docs/cmdline.rst index 672bbfe70..51a189be9 100644 --- a/docs/cmdline.rst +++ b/docs/cmdline.rst @@ -131,6 +131,18 @@ a collection of source files:: (default ".* ._") --header-comment=HEADER_COMMENT header comment for the catalog + --check don't write the output file, just check whether it + is up to date. Return code 0 means the file is up to + date, return code 1 means that it would be changed. + + +When ``--check`` is given, the messages are extracted as usual but the output +file is not written. Instead, the freshly extracted catalog is compared to the +existing ``output-file``, and a non-zero exit code is returned if the file is +missing or would be changed. Differences in the ``POT-Creation-Date`` header +alone are ignored, as it is regenerated on every extraction. This is useful in +a CI pipeline to verify that the template has been kept in sync with the source +code. The meaning of ``--keyword`` values is as follows: diff --git a/tests/messages/frontend/test_cli.py b/tests/messages/frontend/test_cli.py index c6ec2ab17..faf89e343 100644 --- a/tests/messages/frontend/test_cli.py +++ b/tests/messages/frontend/test_cli.py @@ -613,6 +613,56 @@ def test_check_pot_creation_date(cli): ]) # fmt: skip +def test_extract_check(cli, tmp_path): + source = tmp_path / "app.py" + source.write_text("from gettext import gettext as _\n_('one')\n_('two')\n") + pot_file = str(tmp_path / "messages.pot") + + # A missing output file is considered out of date, and the check must + # not create it. + with pytest.raises(BaseError): + cli.run(['pybabel', 'extract', '--check', '-o', pot_file, str(source)]) + assert not os.path.exists(pot_file) + + # Generate the template; a check without any source changes should pass + # and leave the file untouched. + cli.run(['pybabel', 'extract', '-o', pot_file, str(source)]) + original = open(pot_file, "rb").read() + cli.run(['pybabel', 'extract', '--check', '-o', pot_file, str(source)]) + assert open(pot_file, "rb").read() == original + + # Add a new message and expect the check to fail. + source.write_text(source.read_text() + "_('three')\n") + with pytest.raises(BaseError): + cli.run(['pybabel', 'extract', '--check', '-o', pot_file, str(source)]) + # The failing check must not have modified the file either. + assert open(pot_file, "rb").read() == original + + # Regenerate the template and expect the check to pass again. + cli.run(['pybabel', 'extract', '-o', pot_file, str(source)]) + cli.run(['pybabel', 'extract', '--check', '-o', pot_file, str(source)]) + + +def test_extract_check_ignores_pot_creation_date(cli, tmp_path): + source = tmp_path / "app.py" + source.write_text("from gettext import gettext as _\n_('one')\n") + pot_file = tmp_path / "messages.pot" + + cli.run(['pybabel', 'extract', '-o', str(pot_file), str(source)]) + + # Rewrite the template with an older POT-Creation-Date. The content is + # otherwise unchanged, so the check should still consider it up to date. + lines = pot_file.read_text().splitlines(keepends=True) + lines = [ + '"POT-Creation-Date: 1990-04-01 15:30+0000\\n"\n' + if line.startswith('"POT-Creation-Date:') else line + for line in lines + ] + pot_file.write_text("".join(lines)) + + cli.run(['pybabel', 'extract', '--check', '-o', str(pot_file), str(source)]) + + def test_update_init_missing(cli): template = Catalog() template.add("1")