A built-in plugin carries its own catalogues, without falling out of coverage #127
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Blocks
Reference
Postulo/postulo#127
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Observation
Second of three prerequisites, and the one with a documented rule that every built-in
currently breaks.
What exists
docs/PLUGINS.mdstates the rule without qualification:plugins/locale.pyimplements it: when the registry loads a plugin it adds that package'slocale/toLOCALE_PATHSand throws away Django's merged-catalogue cache.And there is exactly one locale directory in the repository:
Every string belonging to a built-in plugin — the SMTP transport's field labels, the local
store's messages, the Europass importer's refusals, and the twenty strings the
phone-numbersfeature added last week — is in Postulo's own catalogues, translated intoall 39 European languages as part of Postulo's release. The rule holds for third-party
plugins and is false for every plugin Postulo ships.
scripts/messages.pyis built for exactly one catalogue set:LOCALE = PACKAGE / "locale",one
extract, onecheck, onestats, and it skipslocaledirectories when scanningfor strings.
What this asks for
Per-plugin catalogues that the tooling and the tests understand, so a built-in plugin can
carry its own translations without its strings falling out of the coverage that keeps them
translated.
Worth being careful about
The completeness test is the thing at risk.
test_every_european_union_language_stays_ completewalkssrc/postulo/localeand fails if any EU language drops below 100%. Move abuilt-in's strings into its own catalogue and they leave that test's sight. The likely
outcome is not "translated by the plugin author" — for a plugin Postulo ships, Postulo is
the author — it is "quietly untranslated". Either
messages.pylearns about severalcatalogue sets and the test walks all of them, or this change trades a real guarantee for a
tidier directory layout.
extracthas to find the strings and write them to the right catalogue. Today one passover the package writes one set of files. Per-plugin means deciding, for each string, which
catalogue owns it — which is a question about where the code lives, so it only becomes
answerable once the built-ins are separate packages.
Compiled catalogues are shipped, not built at runtime. The rule for third parties is
"ship the
.mofiles". For a built-in that would mean compiled artefacts in the repository,or a build step per plugin in the Dockerfile, where today
messages.py compiledoes itonce. The image build is currently unexercised by CI (#81, #121), so a new build step there
is a step nothing checks.
LOCALE_PATHSorder decides who wins. Two catalogues can define the same msgid, andDjango takes the first path that has it. With one catalogue that cannot happen; with a dozen
it can, silently, and the string that changes is the one somebody reads.
Cache-busting on every load.
register_locale_dirclearstrans_real._translationseach time a new path is added. Six or seventeen built-ins registering at
AppConfig.readywould clear it repeatedly during startup — harmless there, but worth confirming it stays at
startup and never happens mid-request.
A plugin with no
locale/shows English, which is the documented and correct behaviourfor a third party and would be a regression for a built-in that reads in Portuguese today.
Whatever moves has to move with its translations, all 39 of them, in the same commit.
The issue set the bar itself: "Either
messages.pylearns about several catalogue sets andthe test walks all of them, or this change trades a real guarantee for a tidier directory
layout." It learned about several sets.
A catalogue set is discovered, not listed. A directory under
src/postulowith alocale/in it is one.extractwrites each string to the set that owns the file it camefrom,
check,statsandcompilewalk all of them, and #129 can move a built-in's stringsout by creating a directory and re-running
extract— with nothing to remember to addanywhere, which is what makes "one commit per plugin" actually cheap.
The completeness test now parametrises over (set, language). So does the plural-rule
check, the placeholder check, and "the catalogues are current".
test_every_european_union_ language_stays_completefails on any set that drops below 100 %, which is the guarantee theissue named as the thing at risk.
statsandstatus.jsonreport the sum, not core's share — 1811 rather than 1808 —because that figure is shown to somebody choosing a language, and "português is complete" has
to mean the interface they will see.
The five warnings, each answered
Ordering.
LOCALE_PATHSdecides a msgid two catalogues both define, and the answer turnsout to be already correct for a reason worth writing down: Django merges
reversed(LOCALE_PATHS)with each merge overriding the last, so the first path wins, andregister_locale_dirappends. Postulo's own is first. A plugin can add a word to theinterface and cannot change one. Now asserted twice — the discovery order, and a test with
two catalogues defining the same string that checks which one a reader gets.
Cache-busting stays at start-up.
register_builtin_locales()runs inPluginsConfig.ready, and a test asserts the built-in's directory is inLOCALE_PATHSbeforeanything is served. Never mid-request.
Compiled catalogues. Still one step.
messages.py compilewalks every set, so theDockerfile line is unchanged and there is no per-plugin build to add to a workflow nothing
exercises (#81, #121).
Extraction had to answer "which catalogue owns this string". It does, by where the file
is — which the issue said would only become answerable once the built-ins were separate
packages. One of them already was, in every way except the directory.
"Whatever moves has to move with its translations, all 39, in the same commit." It did.
Nothing was retranslated; the same strings are in a different file. A built-in with no
catalogue showing English would have been a regression for somebody reading in Portuguese
today.
What moved, and one thing found on the way
The two built-in capture sources, because they were already independent in every other way —
plugins/builtin.pybecameplugins/builtin/, with 68 catalogues and the HTML helper it wasthe only user of.
They were also the plugins
tests/test_plugin_surface.pycalled wholly independent, andthat was an artefact rather than a fact: the checker read only absolute imports, and their
dependency on
plugins/basewas spelledfrom .base import. Moving the file changed thedot's depth and the pretence ended. The checker now resolves relative imports, which found
three more real dependencies that had been hiding the same way:
documents.models— the rows whose files it storesnotifications.base—Notification, which is what a notifier is handedresume.models— the rows a read file becomesAll three are recorded in
REACHING_PASTwith what each needs. They are the data question#129 names, and they are why the store, the notifier and the importer cannot move before it is
answered. A false guarantee is worse than none, so a plugin's own submodules are excluded — a
package has an inside, and
plugins/builtincarrying its HTML helper is the point of #129rather than a violation.
No user-visible change, so no wiki page;
docs/PLUGINS.md,docs/TRANSLATING.mdand the treein
docs/PLAN.mdcarry it. 2633 tests pass.Shipped in
26481dcon0.3.0, withmainkept level. #129 wanted this.