Files
LLeMbas/scripts/i18n_extract.py
T
HomerandClaude Opus 5 a16510aba8 The interface speaks Slovak
969 strings, an instance default and a per-person choice, and no half-done corner:
the admin prose is translated too. Design and reasoning: LLeMbas.wiki/Translations.

KEYED BY THE ENGLISH SENTENCE

A missing entry renders the key, which is the English -- so an untranslated string
looks as it always did, an English instance is byte-for-byte 1.6.0, and a
half-finished catalogue is a half-translated page rather than a page of dotted key
names. The cost is that editing an English sentence orphans its translation
silently, which is what tests/test_translations.py asserts in both directions.

No gettext: .po -> .mo is a build step and this project does not have one.

A JINJA GLOBAL, AND THEREFORE A CONTEXTVAR

`t()` is a global for the reason `brand` already documents -- render() is bypassed
by 25 TemplateResponse calls and 8 get_template().render() calls, the latter being
the SSE frames, which have no Request at all. A global is bound once at import and
the language is per person, so the active language is a ContextVar set per request.

🚨 `get_current_user` had to become `async def`. FastAPI runs a sync dependency in a
threadpool, and anyio copies the context in and discards it on the way out -- so
the language was set where nothing could see it and every page rendered in the
instance's language whatever anybody had chosen, with no error anywhere.

NOT TRANSLATED, ON PURPOSE

Everything a model reads: the 60 prompt fragments, and the dates in harness.py,
schedule/runner.py and schedule/compile.py. Only `i18n.stamp` is localised, and
only where a person reads it -- with the *format* translatable as well as the
words, because "26. septembra 2026" is a different pattern rather than the same one
with different words in it. A process locale is not an option: global, not
thread-safe, two people's pages at once.

A second fragment telling models to answer in the reader's language was written
during this work and removed: `core.style` has said it since long before, and
test_an_empty_override_turns_a_fragment_off caught the duplicate.

THE BULK PASS

1213 sites wrapped by a one-off script that only touched patterns it could not
misread. It got three wrong in a way that mattered -- `t('…')` inside `attr="…"`
where the sentence held an apostrophe, closing the Jinja string and 500ing two
pages whose partials no test renders. tests/test_translations.py now compiles all
110 templates. It also wrapped the product's own name, an SSH key header, a
keystroke hint and an example URL, all taken back out: a string is not
translatable just because it is a string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 14:46:33 +00:00

157 lines
5.7 KiB
Python

"""Find every translatable string, and say what the catalogues are missing.
Run it:
python scripts/i18n_extract.py # a report
python scripts/i18n_extract.py --write sk # fill sk.py with what is missing
A development instrument, like `shoot.py` and `fetch_vendor.py`: nothing in `src/`
imports it. What it knows is the one thing a catalogue keyed on source text cannot
know for itself -- that an English sentence has been edited, leaving its
translation stranded under the old wording. `tests/test_translations.py` asserts
the same property from the other side, so a catalogue cannot rot quietly.
The scan is deliberately simple: `t("…")` and `t('…')`, in templates and in
Python. A string built by concatenation or an f-string is not found, and that is
the point -- a sentence assembled from pieces cannot be translated, because the
order of the pieces is not the same in every language. Use `t("… %(name)s …",
name=…)`.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SRC = REPO / "src/lembas"
TEMPLATES = SRC / "web/templates"
CATALOGUES = SRC / "web/i18n"
# `t("…")` with either quote, allowing escaped quotes inside. Multi-line, because
# a paragraph in a template is wrapped for the width of the file.
CALL = re.compile(r"""\bt\(\s*(?P<q>["'])(?P<text>(?:\\.|(?!\1).)*?)\1""", re.S)
# `i18n.stamp(value, "%d %B %Y")` -- the *format* is translated too, so a language
# that puts the day first, or wants a full stop after it, says so in the
# catalogue. A second pattern rather than a looser first one: widening `t(` to
# "any call with a string in it" would sweep up every `select("…")` in the
# codebase.
STAMP = re.compile(
# One level of nesting allowed in the first argument, because it is usually a
# call: `stamp(clock.now_for(user), "…")`.
r"""\bstamp\((?:[^()"']|\([^()]*\))*,\s*(?P<q>["'])(?P<text>(?:\\.|(?!\1).)*?)\1""",
re.S,
)
def normalise(text: str) -> str:
"""The key: whitespace collapsed, escapes resolved.
A template wraps one sentence across three lines, and the same sentence in a
Python file across two. Keying on the exact bytes would need an entry per
wrapping, so the key is the text with its runs of whitespace flattened -- which
is exactly what `i18n.translate` looks up.
"""
text = text.replace('\\"', '"').replace("\\'", "'").replace("\\n", " ")
return " ".join(text.split())
def sources() -> list[Path]:
files = sorted(TEMPLATES.rglob("*.html"))
files += [
path
for path in sorted(SRC.rglob("*.py"))
if "web/i18n" not in str(path) and "__pycache__" not in str(path)
]
return files
JINJA_COMMENT = re.compile(r"\{#.*?#\}", re.S)
PY_COMMENT = re.compile(r"^[ \t]*#.*$", re.M)
def strip_comments(path: Path, text: str) -> str:
"""Comments are not strings. This file's own docstrings quote `t("Save")`, and
so does `web/templating.py`'s comment explaining the global -- both would
otherwise arrive in the catalogue as things to translate."""
if path.suffix == ".html":
return JINJA_COMMENT.sub("", text)
return PY_COMMENT.sub("", text)
def found() -> dict[str, list[str]]:
"""Every string, with the files it appears in."""
out: dict[str, list[str]] = {}
for path in sources():
text = strip_comments(path, path.read_text(encoding="utf-8"))
for match in list(CALL.finditer(text)) + list(STAMP.finditer(text)):
key = normalise(match.group("text"))
if not key:
continue
out.setdefault(key, [])
where = str(path.relative_to(REPO))
if where not in out[key]:
out[key].append(where)
return out
def catalogue(code: str) -> dict[str, str]:
path = CATALOGUES / f"{code}.py"
if not path.exists():
return {}
namespace: dict[str, object] = {}
exec(compile(path.read_text(encoding="utf-8"), str(path), "exec"), namespace)
return dict(namespace.get("MESSAGES", {})) # type: ignore[arg-type]
def report() -> int:
strings = found()
print(f"{len(strings)} translatable strings in {len(sources())} files")
for code in ("sk",):
have = catalogue(code)
missing = [key for key in strings if key not in have]
orphans = [key for key in have if key not in strings]
done = len(strings) - len(missing)
print(
f" {code}: {done}/{len(strings)} translated"
f" ({len(missing)} missing, {len(orphans)} orphaned)"
)
for key in orphans[:10]:
print(f" orphan: {key[:80]!r}")
return 0
def write(code: str) -> int:
"""Append the missing keys to a catalogue, each mapped to itself.
Mapped to the English rather than to "" on purpose: an empty translation would
render as an empty paragraph, while the English renders as what it already
said. A catalogue half-filled is a page half-translated, never a page with
holes in it.
"""
strings = found()
have = catalogue(code)
missing = [key for key in strings if key not in have]
if not missing:
print(f"{code}: nothing missing")
return 0
path = CATALOGUES / f"{code}.py"
with path.open("a", encoding="utf-8") as handle:
handle.write(f"\n# --- {len(missing)} added by scripts/i18n_extract.py ---\n")
for key in missing:
handle.write(f"MESSAGES[{key!r}] = {key!r}\n")
print(f"{code}: added {len(missing)} keys to {path.relative_to(REPO)}")
return 0
def main() -> int:
if "--write" in sys.argv:
return write(sys.argv[sys.argv.index("--write") + 1])
return report()
if __name__ == "__main__":
raise SystemExit(main())