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" },