Say what a plugin may import, so self-contained can be checked #126

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

Observation

and imperative is that a plugin, even a internal must be a self-contained as possible
having is own manifest, is own locale, etc, and dont depende on the core

First of three prerequisites. "Do not depend on the core" cannot be enforced, or even
checked, until there is a written answer to depend on what, then.

What exists

docs/PLUGINS.md defines a contract per kind, and every one of them is narrow and
data-shaped:

Kind What it is handed What it gives back
source a URL and some HTML a JobPostingData
importer bytes a record
notifier / store / sync a Connection and a payload a result
transport a message sent or raised
feature nothing nothing; it is a declaration

For those, independence is nearly free: a source imports JobPostingData and touches no
model. plugins/builtin.py proves it — zero imports from postulo.

The rest do not have that shape. EmailNotifier, LocalStore, EuropassImporter and
PhoneNumbersFeature each import from postulo, and the last of them is barely a module:
its behaviour lives in core/models.py, core/phone_numbers.py, two forms, a template
partial and two views. A widget plugin (#125) would be the same shape again — it needs a
template, a context, and querysets scoped with for_user().

There is no declared surface for any of that. A plugin needing a model, a form, a
template or ownership scoping reaches into postulo.core and hopes, and nothing tells it
which of those imports are a contract and which are this month's internals.

What this asks for

The list of what a plugin may import, written down and enforced, so that "self-contained"
becomes a property something can fail.

Worth being careful about

Total independence is not the goal and cannot be. A plugin that holds one person's data
must scope it with for_user(), or it breaks the project's first promise; one that renders
must extend the base template or it renders unstyled; one that redirects must go through
safe_next(). Each of those is a reason to depend on Postulo, and the imperative is
served by making them a small, named, stable set — not by pretending they are avoidable.

The candidates, roughly in order of how obviously they belong: OwnedModel and
OwnedQuerySet.for_user; safe_next; the Manifest/@declares machinery; the field
specs a connection form is built from; the template blocks a page may extend; and whatever
a widget is handed. Everything else stays private.

A surface is a promise about breakage. Once postulo.plugins.api exists, changing it
breaks third-party plugins between releases, which is exactly the cost the project has
avoided so far by having no such surface. Whether that promise is made now, at 0.3.0, or
deferred until third parties actually exist, is the decision inside this issue.

Enforcement is what makes it real. A rule nobody checks drifts back in a release.
tests/test_plugin_manifests.py already walks every built-in and asserts what it declares;
the same shape of test can walk a built-in package's imports and fail on one that reaches
past the surface. Without that, this issue produces a document and no change.

The stateless kinds are already there. Whatever surface is written, the two built-in
sources satisfy it today, which makes them the check that the surface is not so wide as to
be meaningless.

## Observation > and imperative is that a plugin, even a internal must be a self-contained as possible > having is own manifest, is own locale, etc, and dont depende on the core First of three prerequisites. "Do not depend on the core" cannot be enforced, or even checked, until there is a written answer to *depend on what, then*. ## What exists `docs/PLUGINS.md` defines a contract per kind, and every one of them is narrow and data-shaped: | Kind | What it is handed | What it gives back | | --- | --- | --- | | `source` | a URL and some HTML | a `JobPostingData` | | `importer` | bytes | a record | | `notifier` / `store` / `sync` | a `Connection` and a payload | a result | | `transport` | a message | sent or raised | | `feature` | nothing | nothing; it is a declaration | For those, independence is nearly free: a source imports `JobPostingData` and touches no model. `plugins/builtin.py` proves it — **zero** imports from `postulo`. The rest do not have that shape. `EmailNotifier`, `LocalStore`, `EuropassImporter` and `PhoneNumbersFeature` each import from `postulo`, and the last of them is barely a module: its behaviour lives in `core/models.py`, `core/phone_numbers.py`, two forms, a template partial and two views. A widget plugin (#125) would be the same shape again — it needs a template, a context, and querysets scoped with `for_user()`. There is **no declared surface** for any of that. A plugin needing a model, a form, a template or ownership scoping reaches into `postulo.core` and hopes, and nothing tells it which of those imports are a contract and which are this month's internals. ## What this asks for The list of what a plugin may import, written down and enforced, so that "self-contained" becomes a property something can fail. ## Worth being careful about **Total independence is not the goal and cannot be.** A plugin that holds one person's data must scope it with `for_user()`, or it breaks the project's first promise; one that renders must extend the base template or it renders unstyled; one that redirects must go through `safe_next()`. Each of those is a *reason to depend on Postulo*, and the imperative is served by making them a small, named, stable set — not by pretending they are avoidable. **The candidates, roughly in order of how obviously they belong:** `OwnedModel` and `OwnedQuerySet.for_user`; `safe_next`; the `Manifest`/`@declares` machinery; the field specs a connection form is built from; the template blocks a page may extend; and whatever a widget is handed. Everything else stays private. **A surface is a promise about breakage.** Once `postulo.plugins.api` exists, changing it breaks third-party plugins between releases, which is exactly the cost the project has avoided so far by having no such surface. Whether that promise is made now, at 0.3.0, or deferred until third parties actually exist, is the decision inside this issue. **Enforcement is what makes it real.** A rule nobody checks drifts back in a release. `tests/test_plugin_manifests.py` already walks every built-in and asserts what it declares; the same shape of test can walk a built-in package's imports and fail on one that reaches past the surface. Without that, this issue produces a document and no change. **The stateless kinds are already there.** Whatever surface is written, the two built-in sources satisfy it today, which makes them the check that the surface is not so wide as to be meaningless.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 10:15:49 +00:00
Author
Owner

postulo.plugins.api is the surface, and tests/test_plugin_surface.py is what makes it real.

The decision the issue named — now or deferred — is now. Having no surface was free, and
it stops being free the moment #129 moves shipped plugins into their own packages: a package
outside src/postulo has to know what it may import. Deferring means either deferring that
work or setting a boundary by accident, which is the worse of the two. From here, a change to
any name in the surface is a ### ⚠️ Deprecated entry first and a ### 🗑️ Removed entry
later, never a silent rename.

The candidates the issue listed are all in, and the four that carry the argument are
tested individually rather than counted, because each is a promise the project makes rather
than a convenience: OwnedModel/OwnedQuerySet (or one person sees another's data),
safe_next (or a plugin bounces somebody off the instance), client (or a plugin dials where
the server should not), and access_token from #150 (or a plugin keeps a token instead of
refreshing it). Plus the manifest machinery, the field specs, the protocols, the import
refusals, and the transport mediums.

Enforcement reads the source rather than importing it, which matters more than it sounds:
every remaining dependency in this codebase is a lazy from postulo.core import site inside
a method, and an importing check would have found none of them.

REACHING_PAST is the map of what #129 has left to move, not a list of exemptions — five
entries across three plugins, each saying what the plugin actually needs:

  • the email notifier → site, for the instance's name and from-address
  • the SMTP transport → mail and destinations
  • the local store → absolute_url
  • the Europass importer → the identifier schemes, PersonIdentifier, and phone_numbers

A stale entry fails the test too, because one nobody removed hides the next real
dependency.

And the surface is checked for not being meaningless, using exactly the plugins the issue
suggested: the two built-in sources import nothing from Postulo whatsoever, and the
telephone-numbers feature imports only the surface. Both are asserted.

What a dashboard widget is handed (#125) and what a template may extend are deliberately left
out and said to be left out, in the module docstring and in docs/PLUGINS.md.

18 tests, and a section in docs/PLUGINS.md for plugin authors. No new user-facing strings —
this is a contract, not an interface.

Shipped in 07700a6 on 0.3.0, with main kept level. #129 and #151 both wanted this.

`postulo.plugins.api` is the surface, and `tests/test_plugin_surface.py` is what makes it real. **The decision the issue named — now or deferred — is now.** Having no surface was free, and it stops being free the moment #129 moves shipped plugins into their own packages: a package outside `src/postulo` has to know what it may import. Deferring means either deferring that work or setting a boundary by accident, which is the worse of the two. From here, a change to any name in the surface is a `### ⚠️ Deprecated` entry first and a `### 🗑️ Removed` entry later, never a silent rename. **The candidates the issue listed are all in**, and the four that carry the argument are tested individually rather than counted, because each is a promise the project makes rather than a convenience: `OwnedModel`/`OwnedQuerySet` (or one person sees another's data), `safe_next` (or a plugin bounces somebody off the instance), `client` (or a plugin dials where the server should not), and `access_token` from #150 (or a plugin keeps a token instead of refreshing it). Plus the manifest machinery, the field specs, the protocols, the import refusals, and the transport mediums. **Enforcement reads the source rather than importing it**, which matters more than it sounds: every remaining dependency in this codebase is a *lazy* `from postulo.core import site` inside a method, and an importing check would have found none of them. **`REACHING_PAST` is the map of what #129 has left to move**, not a list of exemptions — five entries across three plugins, each saying what the plugin actually needs: - the email notifier → `site`, for the instance's name and from-address - the SMTP transport → `mail` and `destinations` - the local store → `absolute_url` - the Europass importer → the identifier schemes, `PersonIdentifier`, and `phone_numbers` A **stale** entry fails the test too, because one nobody removed hides the next real dependency. **And the surface is checked for not being meaningless**, using exactly the plugins the issue suggested: the two built-in sources import nothing from Postulo whatsoever, and the telephone-numbers feature imports only the surface. Both are asserted. What a dashboard widget is handed (#125) and what a template may extend are deliberately left out and said to be left out, in the module docstring and in `docs/PLUGINS.md`. 18 tests, and a section in `docs/PLUGINS.md` for plugin authors. No new user-facing strings — this is a contract, not an interface. Shipped in `07700a6` on `0.3.0`, with `main` kept level. #129 and #151 both 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#126
No description provided.