Skip to content
Merged
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
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 40 additions & 1 deletion src/pydocvi/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand All @@ -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)
Expand Down
31 changes: 30 additions & 1 deletion tests/test_apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
13 changes: 12 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading