Repository navigation
W3-4: extract messages with pybabel, add i18n catalog staleness test - #80
Merged
Merged
Conversation
added 2 commits
September 29, 2026 08:37
make messages used pygettext3 --keyword=_, which drops ngettext plurals and N_() markers, so messages.pot was stale and 66 code msgids (most action and parameter descriptions) had no ru translation. - make messages: pybabel extract with babel.cfg (_, ngettext, N_, ...) - regenerate messages.pot, add the missing ru translations, recompile .mo - tests/unit/test_i18n_catalog.py: pot matches code msgids, every code msgid is translated in ru, no duplicate msgids, .mo matches .po, and every action description localizes in ru - README: document the gettext workflow
WebSocket error messages (invalid/non-object JSON, rate limit, spectator action, unknown message/action/reaction, no player) were translated in the process default language (DND_LANGUAGE, ru when unset) instead of the session's: I18nMiddleware only runs for HTTP requests, not for WS. Only 'No reaction prompt is pending' used language_context(session.lang). Hidden until now because those msgids had no ru translation; the previous commit added them and the en-session integration tests (TestSpectator) saw Russian text. _send_session_error translates an N_()-marked msgid under language_context(session.lang); JSON envelope parsing runs in the same context. Unit regression tests cover player and spectator sockets.
This was referenced Sep 29, 2026
Owner
Author
|
Independent review: PASS (merging will need a rebase; see below)
|
added 2 commits
September 29, 2026 09:17
Resolve routes_ws.py: keep #82's message-size and params bounds and send the params errors through _send_session_error so they use the session language (the size error is already rendered under the session language by the envelope parse). Keep both sides' ru translations, regenerate messages.pot, recompile the .mo, and extend the ru-session WS test with the three new errors.
make messages keeps both steps: pybabel extract for code msgids, then #84's catalog_messages appends the YAML catalog names. The catalog test expects the babel code msgids plus catalog_msgids(content) in the .pot. README keeps the gettext workflow and the containerized-stack section. Regenerated messages.pot, kept every ru translation from both sides, recompiled the .mo.
Owner
Author
|
Re-review after the merges with main (#82, #84): PASS
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
make messagesranpygettext3 --keyword=_, so it extracted only_(). It missedngettext()plurals andN_()markers. As a result:messages.potwas stale. It had 0msgid_pluralentries, lacked 83 current msgids, and still listed 2 removed ones (You try to flee{desc},{entity} tries to flee{desc}).N_()action and parameter descriptions incore/action_defs.py(e.g. "End your turn.", "Move up to your speed."). The rest were a few validation, handler, WebSocket and LLM-prompt strings, plus theTreasurecontainer name.All 4 card items applied to current main (2f79530), so none were skipped.
Fix
make messagesnow runsuv run pybabel extract -F babel.cfg --no-wrap --omit-header. It uses babel's default keywords (_,gettext,ngettext,N_,pgettext, …), which cover every keyword the code uses (_,ngettext,N_).--no-wrapkeeps msgids on one line, matching the.po.--omit-headerstops the creation date from changing on every run. The.potdiff is large once because the format changes from pygettext to babel.messages.pot, which now has both plurals. I added the 66 missing ru translations by hand as two new sections at the end of the.po, then recompiled the.mo. I did not runpybabel updateon the ru.po: 64 of its translations are for msgids built at runtime (enum values, item, race and weather names via_(value)). Extraction can't see those, so an update would mark them all obsolete. No existing translation changed.tests/unit/test_i18n_catalog.py, which runs inmake check. It extracts msgids with the samebabel.cfgand checks that:.potlists exactly the code msgids (ignoring source locations, so moving lines doesn't make it fail)ngettext.pohas no duplicate msgids.mois the compilation of the.poActionDefdescription and parameter description renders in Cyrillic under ru (the user-visible symptom)Real bug 1: untranslated strings
N_(), so these msgids never reached the.potand nobody translated them.test_every_code_msgid_has_a_ru_translation,test_every_action_description_localizes_in_ru.Real bug 2: WebSocket errors used the server language, not the session's (commit 76399a8)
Symptom: the first CI run of this PR failed 2 integration tests,
TestSpectator::test_cannot_submitandTestSpectator::test_non_object_json_is_recoverable_and_shared_with_player_protocol. The test sessions are created withlang: "en", but the spectator socket answered in Russian ("Зрители не могут отправлять действия", "JSON-сообщение должно быть объектом"). The tests were right and the code was wrong.Root cause:
routes_ws.pytranslated its transport errors in the process default language. That isDND_LANGUAGE, which isruwhen unset, as in the compose backend.I18nMiddlewaresets the session language only for HTTP requests;BaseHTTPMiddlewaredoes not run for WebSocket connections. Only "No reaction prompt is pending" usedlanguage_context(session.lang). The bug was invisible on main because these msgids had no ru translation, so they fell back to English whatever the language.Fix: a new
_send_session_error(ws, session, N_(msgid), *args)helper translates underlanguage_context(session.lang). JSON envelope parsing now runs in the same context. It covers these errors on both the player and spectator sockets:"Session not found" still uses the process default, because no session exists at that point.
Catching tests:
tests/unit/test_ws.py::TestWebSocketErrorLanguage::{test_player_socket_errors_use_session_language, test_spectator_socket_errors_use_session_language}use a ru session, while the process default in unit tests is en. Both fail without the fix and pass with it.Tests
test_pot_lists_exactly_the_code_msgids,test_every_code_msgid_has_a_ru_translationandtest_every_action_description_localizes_in_rufail on main and pass here. The duplicate and.mochecks pass on main too; they guard against future drift.make checkis green: ruff, format, mypy, 2946 pytest, eslint, tsc and 353 vitest.docker-compose.test.yml:DND_DICE_SEED=42,DND_CONTENT_DIR=tests/integration/content,DND_LANGUAGEunset,BACKEND_URL=http://127.0.0.1:8001.tests/integration/suite passes (184 passed). No other newly translated string is asserted in English.Found but not fixed (out of scope)
ngettext()for counts,N_()for module-level constants; after changing strings runmake messages, translate by hand in the ru.po(nopybabel update: it holds runtime-built msgids), thenmake compile-messages;tests/unit/test_i18n_catalog.pyenforces it."Treasurecontainer name:layers/entities/materialization.py:345applies_()when the container is created. The name is stored in the entity (and the save) in whatever language was active at that moment, rather than translated when displayed.llm/brain.py:154inserts the raw English enum value (trigger.trigger_type.value, e.g. "leaving reach") into a translated sentence.?lang=query parameter instead.