fix(i18n): stop a percent sign in prose from making a catalog uncompilable

xgettext counts a space as a format flag, so the prose `95% confidence` parses
as the directive `% c` and the entry is marked python-format. Every translation
moves the word behind the percent sign, msgfmt then reads `% V` and refuses the
catalog. Quoted Jinja blocks and percent-encoded URLs fall into the same trap.
A run over the documentation catalogs left 312 such entries in 19 files, none
of which could be compiled to .mo.

Removing the flag when writing does nothing: babel derives it from the msgid on
every load, so the value in the file is decoration. gettext's own switch for
this is no-python-format, which babel does not touch, and the writer now sets
it on entries the derivation flags but that carry no real placeholder. The
derived flag has to go at the same time, because gettext reads the flag line in
order and `no-python-format, python-format` cancels itself out.

What counts as a real placeholder was already spelled out in PROTECTED, so it
moves into PRINTF and is used from both places rather than written twice.

The repository lint gains the check that would have caught this. It compares
the msgid against itself first and reports only when the translation alone
fails, so a source string that mixes positional and named placeholders, which
gettext accepts, does not raise a finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-24 21:24:52 +02:00
1 parent a427b8d107
commit bd39b0dc8f
4 files changed
+126 -2

No files matched your search

@@ -0,0 +1,76 @@
"""A percent sign in prose must not make a catalog uncompilable.
xgettext counts a space as a format flag, so ``95% confidence`` parses as the
directive ``% c``. Every translation moves the following word, msgfmt then reads
``% V`` and refuses the catalog. The writer marks such entries
``no-python-format``; the negative cases below are shapes taken from the
documentation catalogs.
"""
from __future__ import annotations
import unittest
from babel.messages.catalog import Catalog
from utils.i18n.catalog import render
from utils.i18n.placeholders import carries_placeholder
class TestCarriesPlaceholder(unittest.TestCase):
def test_a_bare_conversion(self) -> None:
self.assertTrue(carries_placeholder("deployed %s"))
def test_a_named_conversion(self) -> None:
self.assertTrue(carries_placeholder("deployed %(app)s"))
def test_an_integer_conversion(self) -> None:
self.assertTrue(carries_placeholder("%d apps"))
def test_any_plural_form_is_enough(self) -> None:
self.assertTrue(carries_placeholder(("one app", "%d apps")))
def test_a_percentage_in_prose(self) -> None:
self.assertFalse(carries_placeholder("reach at least 95% confidence"))
def test_a_quoted_jinja_block(self) -> None:
self.assertFalse(carries_placeholder("wrap it in `{% if enabled %}`"))
def test_a_percent_encoded_url(self) -> None:
self.assertFalse(carries_placeholder("see /repo%2FCONTRIBUTING.md"))
def test_a_plural_of_prose(self) -> None:
self.assertFalse(carries_placeholder(("one at 95% load", "many")))
class TestRenderSuppressesTheFalseFlag(unittest.TestCase):
def _flags(self, msgid: str, msgstr: str) -> set[str]:
catalog = Catalog(locale="de")
catalog.add(msgid, msgstr)
render(catalog)
return next(m.flags for m in catalog if m.id == msgid)
def test_prose_loses_the_derived_flag(self) -> None:
flags = self._flags("reach at least 95% confidence", "95% Vertrauen")
self.assertIn("no-python-format", flags)
self.assertNotIn("python-format", flags)
def test_a_real_placeholder_keeps_its_flag(self) -> None:
flags = self._flags("deployed %s", "%s ausgerollt")
self.assertIn("python-format", flags)
self.assertNotIn("no-python-format", flags)
def test_the_written_catalog_carries_only_the_suppression(self) -> None:
catalog = Catalog(locale="de")
catalog.add("reach at least 95% confidence", "95% Vertrauen")
written = render(catalog).decode("utf-8")
self.assertIn("#, no-python-format\n", written)
self.assertNotIn(", python-format", written)
if __name__ == "__main__":
unittest.main()