From f590fb5c2859c70e325dd7bb5a15c7abf5fe6f1d Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:21:35 +0700 Subject: [PATCH] Write PO-Revision-Date in the format the PO header is defined to use The published site had no Vietnamese on it at all. Not less than the coverage table claimed, none: all 548 catalogs and all 1 437 reviewed strings, gone from every page, on the first deploy after apply started writing headers. apply stamps a run as 2026-08-18T03:55Z, because that is what a run is called everywhere else in this tool: in the queue, in the run= field of a provenance comment, and in the memory. PO-Revision-Date is not ISO 8601. It is specified as %Y-%m-%d %H:%M with a numeric offset, Babel's reader raises on the T and the Z, Sphinx catches that as a reading error and skips writing the .mo for that catalog, and every page in it renders in English. Nothing reports this. Sphinx logs one warning among several thousand and carries on. The build succeeds. The coverage table counts the .po files and is entirely right about every one of them. The audit passes S08, because both sides of that comparison are our own renderer and our own renderer is happy. The only place the failure is visible is the rendered HTML, which is the one thing nothing was reading. So the test for it reads the file back with Babel rather than with polib. polib parses our ISO date without complaining, which is why every test we had agreed with itself for the whole time this was broken. Babel is what Sphinx reads with, and the consumer's parser is the only one whose opinion decides whether a reader sees Vietnamese. That is worth a dev dependency. A date already in the right shape is handed back untouched, so a date written by Transifex survives a round trip through apply unchanged and only the ones this tool got wrong are rewritten. --- pyproject.toml | 7 +++++++ src/pydocvi/apply.py | 41 ++++++++++++++++++++++++++++++++++++++++- tests/test_apply.py | 31 ++++++++++++++++++++++++++++++- uv.lock | 13 ++++++++++++- 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9755776..0361928 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,13 @@ pydocvi = "pydocvi.cli:main" [dependency-groups] dev = [ + # Not used by the package, and here because it is what Sphinx reads these + # files with. polib is more forgiving than Babel, so a test that reads our + # own output back with our own reader agrees with itself and proves nothing: + # it passed the whole time the corpus was shipping a PO-Revision-Date that + # made Sphinx skip every catalog. The consumer's parser is the only one whose + # opinion decides whether the site has any Vietnamese on it. + "babel>=2.16", "pytest>=8.3", "pytest-cov>=6", "pytest-asyncio>=0.25", diff --git a/src/pydocvi/apply.py b/src/pydocvi/apply.py index c95eaee..815af63 100644 --- a/src/pydocvi/apply.py +++ b/src/pydocvi/apply.py @@ -19,6 +19,7 @@ from collections.abc import Iterable, Sequence from dataclasses import dataclass +from datetime import UTC, datetime from pathlib import Path from pydocvi import __version__, catalog, classify @@ -67,6 +68,44 @@ PASSTHROUGH_FIELD = "passthrough=" +#: How this tool stamps a run, everywhere a run is named: in the queue, in the +#: ``run=`` field of a provenance comment, and in the memory. +RUN_DATE = "%Y-%m-%dT%H:%MZ" + +#: How the PO header is defined to write a date, which is not ISO 8601 and is +#: the whole reason :func:`po_date` exists. +PO_DATE = "%Y-%m-%d %H:%M%z" + + +def po_date(when: str) -> str: + """A run stamp in the format ``PO-Revision-Date`` is defined to use. + + The two are not the same and the difference is not cosmetic. A run is + stamped ``2026-08-18T03:55Z`` because that is what a run is called + everywhere else in this tool, and the PO header wants ``2026-08-18 + 03:55+0000``. Babel's reader raises on the ``T`` and the ``Z``, Sphinx + catches that as a reading error and skips writing the ``.mo`` for that + catalog, and every page in it then renders in English. + + Nothing anywhere reports this. Not Sphinx, which logs a warning among + thousands and carries on; not the build, which succeeds; not the coverage + table, which counts the .po files and is entirely right about them. The + published site simply loses every translation it has. That is what happened + to all 548 catalogs and all 1 437 reviewed strings on the first deploy after + ``apply`` started writing headers, and the only way anybody found out was + reading the rendered HTML. + + A date already in the right shape is handed back untouched, so a date + written by Transifex survives a round trip through ``apply`` unchanged and + only the ones this tool got wrong are rewritten. + """ + try: + parsed = datetime.strptime(when, RUN_DATE).replace(tzinfo=UTC) + except ValueError: + return when + return parsed.strftime(PO_DATE) + + class ApplyError(ValueError): """The catalogs and the memory disagree in a way writing cannot resolve.""" @@ -93,7 +132,7 @@ class Stamp: @property def revised(self) -> str: """The header's ``PO-Revision-Date``, which defaults to the run.""" - return self.revision or self.run + return po_date(self.revision or self.run) @dataclass(frozen=True, slots=True, kw_only=True) diff --git a/tests/test_apply.py b/tests/test_apply.py index 89d2f6c..680e787 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -308,9 +308,38 @@ def test_it_says_the_translations_are_machine_made_and_unreviewed(self) -> None: assert out.metadata["Last-Translator"] == "pydocvi (machine translation, unreviewed)" def test_the_project_and_the_date_come_from_the_stamp(self) -> None: + """The date is the run, rewritten into the format the PO header is + defined to use. This asserted the run verbatim until August 2026, which + is how a corpus shipped with an ISO date in a field that is not ISO.""" out = applied(upstream(block("Dealing with Bugs")), Memory()) assert out.metadata["Project-Id-Version"] == "Python 3.15" - assert out.metadata["PO-Revision-Date"] == "2026-08-17T09:30Z" + assert out.metadata["PO-Revision-Date"] == "2026-08-17 09:30+0000" + + def test_a_date_already_in_the_right_shape_is_left_alone(self) -> None: + """So a date written by Transifex survives a round trip through apply, + and only the ones this tool got wrong are rewritten.""" + stamp = Stamp( + project="Python 3.15", run="2026-08-17T09:30Z", revision="2025-09-16 00:00+0000" + ) + out = apply.header(upstream(block("Dealing with Bugs")), stamp) + assert out.metadata["PO-Revision-Date"] == "2025-09-16 00:00+0000" + + def test_sphinx_can_read_the_file_back(self) -> None: + """The one that matters, and it has to use Babel rather than polib. + + polib reads our ISO date without complaint, so every test we had agreed + with itself while Sphinx was refusing the whole catalog. Babel is what + Sphinx reads with: it raises on the date, Sphinx logs one warning among + thousands and skips writing the .mo, the build succeeds, and the + published page comes out in English. 548 catalogs and 1 437 reviewed + strings went out that way. + """ + from io import StringIO + + from babel.messages.pofile import read_po + + out = applied(upstream(block("Dealing with Bugs")), Memory()) + assert read_po(StringIO(catalog.render(out)), locale="vi").revision_date is not None def test_vietnamese_gets_one_plural_form(self) -> None: out = applied(upstream(block("Dealing with Bugs")), Memory()) diff --git a/uv.lock b/uv.lock index c7727bc..22fdb74 100644 --- a/uv.lock +++ b/uv.lock @@ -100,6 +100,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -669,7 +678,7 @@ wheels = [ [[package]] name = "python-docs-vi-translator" -version = "0.1.0" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -683,6 +692,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "babel" }, { name = "hypothesis" }, { name = "mypy" }, { name = "pre-commit" }, @@ -705,6 +715,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "babel", specifier = ">=2.16" }, { name = "hypothesis", specifier = ">=6.120" }, { name = "mypy", specifier = ">=1.14" }, { name = "pre-commit", specifier = ">=4" },