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
153 changes: 96 additions & 57 deletions babel/messages/frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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',
Expand All @@ -364,6 +368,7 @@ class ExtractMessages(CommandMixin):
'sort-output',
'sort-by-file',
'strip-comments',
'check',
]
as_args = 'input-paths'
multiple_value_options = (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 = []
Expand Down
12 changes: 12 additions & 0 deletions docs/cmdline.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions tests/messages/frontend/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down