A built-in plugin carries its own catalogues, without falling out of coverage #127

Closed
opened 2026-09-09 10:15:50 +00:00 by tiagoagueda · 1 comment
Owner

Observation

having is own manifest, is own locale, etc

Second of three prerequisites, and the one with a documented rule that every built-in
currently breaks.

What exists

docs/PLUGINS.md states the rule without qualification:

Postulo speaks many languages, and a plugin must speak them itself. Its labels, help texts
and messages are never added to Postulo's catalogues.

plugins/locale.py implements it: when the registry loads a plugin it adds that package's
locale/ to LOCALE_PATHS and throws away Django's merged-catalogue cache.

And there is exactly one locale directory in the repository:

src/postulo/locale

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-numbers feature added last week — is in Postulo's own catalogues, translated into
all 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.py is built for exactly one catalogue set: LOCALE = PACKAGE / "locale",
one extract, one check, one stats, and it skips locale directories when scanning
for 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_ complete walks src/postulo/locale and fails if any EU language drops below 100%. Move a
built-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.py learns about several
catalogue sets and the test walks all of them, or this change trades a real guarantee for a
tidier directory layout.

extract has to find the strings and write them to the right catalogue. Today one pass
over 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 .mo files". 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 compile does it
once. The image build is currently unexercised by CI (#81, #121), so a new build step there
is a step nothing checks.

LOCALE_PATHS order decides who wins. Two catalogues can define the same msgid, and
Django 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_dir clears trans_real._translations
each time a new path is added. Six or seventeen built-ins registering at AppConfig.ready
would 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 behaviour
for 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.

## Observation > having is own manifest, is own locale, etc Second of three prerequisites, and the one with a documented rule that every built-in currently breaks. ## What exists `docs/PLUGINS.md` states the rule without qualification: > Postulo speaks many languages, and a plugin must speak them itself. Its labels, help texts > and messages are **never** added to Postulo's catalogues. `plugins/locale.py` implements it: when the registry loads a plugin it adds that package's `locale/` to `LOCALE_PATHS` and throws away Django's merged-catalogue cache. And there is exactly one locale directory in the repository: ``` src/postulo/locale ``` 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-numbers` feature added last week — is in Postulo's own catalogues, translated into all 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.py` is built for exactly one catalogue set: `LOCALE = PACKAGE / "locale"`, one `extract`, one `check`, one `stats`, and it skips `locale` directories when scanning for 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_ complete` walks `src/postulo/locale` and fails if any EU language drops below 100%. Move a built-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.py` learns about several catalogue sets and the test walks all of them, or this change trades a real guarantee for a tidier directory layout. **`extract` has to find the strings and write them to the right catalogue.** Today one pass over 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 `.mo` files". 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 compile` does it once. The image build is currently unexercised by CI (#81, #121), so a new build step there is a step nothing checks. **`LOCALE_PATHS` order decides who wins.** Two catalogues can define the same msgid, and Django 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_dir` clears `trans_real._translations` each time a new path is added. Six or seventeen built-ins registering at `AppConfig.ready` would 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 behaviour for 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.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 10:15:50 +00:00
Author
Owner

The issue set the bar itself: "Either messages.py learns about several catalogue sets and
the 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/postulo with a
locale/ in it is one. extract writes each string to the set that owns the file it came
from, check, stats and compile walk all of them, and #129 can move a built-in's strings
out by creating a directory and re-running extract — with nothing to remember to add
anywhere, 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_complete fails on any set that drops below 100 %, which is the guarantee the
issue named as the thing at risk.

stats and status.json report 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_PATHS decides a msgid two catalogues both define, and the answer turns
out 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, and
register_locale_dir appends. Postulo's own is first. A plugin can add a word to the
interface 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 in
PluginsConfig.ready, and a test asserts the built-in's directory is in LOCALE_PATHS before
anything is served. Never mid-request.

Compiled catalogues. Still one step. messages.py compile walks every set, so the
Dockerfile 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.py became plugins/builtin/, with 68 catalogues and the HTML helper it was
the only user of.

They were also the plugins tests/test_plugin_surface.py called wholly independent, and
that was an artefact rather than a fact: the checker read only absolute imports, and their
dependency on plugins/base was spelled from .base import. Moving the file changed the
dot'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:

  • the local store on documents.models — the rows whose files it stores
  • the email notifier on notifications.base — Notification, which is what a notifier is handed
  • the Europass importer on resume.models — the rows a read file becomes

All three are recorded in REACHING_PAST with 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/builtin carrying its HTML helper is the point of #129
rather than a violation.

No user-visible change, so no wiki page; docs/PLUGINS.md, docs/TRANSLATING.md and the tree
in docs/PLAN.md carry it. 2633 tests pass.

Shipped in 26481dc on 0.3.0, with main kept level. #129 wanted this.

The issue set the bar itself: *"Either `messages.py` learns about several catalogue sets and the 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/postulo` with a `locale/` in it is one. `extract` writes each string to the set that owns the file it came from, `check`, `stats` and `compile` walk all of them, and #129 can move a built-in's strings out by creating a directory and re-running `extract` — with nothing to remember to add anywhere, 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_complete` fails on any set that drops below 100 %, which is the guarantee the issue named as the thing at risk. **`stats` and `status.json` report 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_PATHS` decides a msgid two catalogues both define, and the answer turns out 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, and `register_locale_dir` appends. Postulo's own is first. A plugin can add a word to the interface 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 in `PluginsConfig.ready`, and a test asserts the built-in's directory is in `LOCALE_PATHS` before anything is served. Never mid-request. **Compiled catalogues.** Still one step. `messages.py compile` walks every set, so the Dockerfile 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.py` became `plugins/builtin/`, with 68 catalogues and the HTML helper it was the only user of. They were also the plugins `tests/test_plugin_surface.py` called *wholly* independent, and that was an artefact rather than a fact: the checker read only absolute imports, and their dependency on `plugins/base` was spelled `from .base import`. Moving the file changed the dot'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: - the local store on `documents.models` — the rows whose files it stores - the email notifier on `notifications.base` — `Notification`, which is what a notifier is handed - the Europass importer on `resume.models` — the rows a read file becomes All three are recorded in `REACHING_PAST` with 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/builtin` carrying its HTML helper is the point of #129 rather than a violation. No user-visible change, so no wiki page; `docs/PLUGINS.md`, `docs/TRANSLATING.md` and the tree in `docs/PLAN.md` carry it. 2633 tests pass. Shipped in `26481dc` on `0.3.0`, with `main` kept level. #129 wanted this.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
Postulo/postulo#127
No description provided.