The smallest Postulo plugin there is: a capture source and a notifier, with translations and tests of its own. MIT, so you can copy it into yours. https://source.tiagoagueda.com/postulo/postulo/src/branch/main/docs/PLUGINS.md
Find a file
Tiago Águeda 19f0bcc296
All checks were successful
CI / test (push) Successful in 1m7s
Stop telling people to copy the surface check
`tests/test_surface.py` said *"Copy this file into your own plugin; the
only line to change is PACKAGE"*, and the README said the same in one
word: *copy it*. Four plugins did. That is how five near-identical AST
walks ended up in five repositories, no two agreeing about relative
imports or about what counted as reaching past -- and a check that
disagrees with the core's is a check that passes while the plugin is
broken.

postulo/postulo#229 publishes the walk as `postulo.plugins.testing`, so
this file calls it. Nothing moves in `src/`: this plugin has only ever
imported the surface. What is left is the two things a plugin author
actually has to write -- the call over their own package, and the names
they ask of the surface asserted against `api.__all__`, because
`__all__` is what the promise is made about.

This is the plugin people are told to copy, so what it shows should be
the check the core maintains rather than a snapshot of it. The docstring
now says not to copy the file, and says why.

The core dev dependency had no ref at all, so these tests passed against
whatever `main` was that day. It is `1d6cdd7e0`, which is also where
`postulo.plugins.testing` starts.

Closes #3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:29:10 +02:00
.forgejo/workflows Run CI in an image that can check the repository out 2026-09-16 11:17:39 +02:00
src/postulo_helloworld Take a file name rather than a path, and match the host exactly 2026-09-16 10:39:05 +02:00
tests Stop telling people to copy the surface check 2026-09-21 17:29:10 +02:00
.gitignore The smallest Postulo plugin, to be copied 2026-09-05 22:52:27 +02:00
LICENSE The smallest Postulo plugin, to be copied 2026-09-05 22:52:27 +02:00
pyproject.toml Stop telling people to copy the surface check 2026-09-21 17:29:10 +02:00
README.md Stop telling people to copy the surface check 2026-09-21 17:29:10 +02:00
uv.lock Stop telling people to copy the surface check 2026-09-21 17:29:10 +02:00

postulo-helloworld

The smallest Postulo plugin there is. Copy it to start your own.

Postulo puts an interface wherever a choice could reasonably vary — where a posting is read from, how a person is told about things — and its own implementations are plugins that happen to ship in the box. A plugin written by somebody else is installed with one command and no change to Postulo. This repository is one such plugin, deliberately tiny, with everything a real one needs already in place: two kinds of plugin, translations of its own, tests that run it inside a real Postulo, and a CI workflow.

What is in the box

Path What it is
src/postulo_helloworld/source.py A capture source: four names (name, version, can_handle, parse), no base class. Reads a posting out of a tiny page format and returns JobPostingData.
src/postulo_helloworld/notifier.py A notifier, one of the connected plugins: declares the fields it needs, Postulo draws the form, and send() appends each notification to a file. The person names the file; the operator names the directory, and file_for() explains at length why it is that way round.
src/postulo_helloworld/locale/ This plugin's own translations, French and Portuguese. Postulo never translates a plugin's strings; every plugin holds its locale.
tests/ Runs both plugins through Postulo's registry, exactly as an instance would, and checks the French comes from this package. tests/test_surface.py fails on an import that reaches past postulo.plugins.api, by calling the core's own postulo.plugins.testing — do that rather than copying this file. tests/settings.py is Postulo's test configuration, borrowed.
pyproject.toml The entry points that make the plugins appear, and nothing else: a plugin has no dependencies of its own, it runs inside Postulo's environment.

Try it

Into the environment of a running Postulo:

uv pip install git+https://source.tiagoagueda.com/postulo/postulo-helloworld.git

Restart Postulo. Capture a posting from any address on hello.example and the source answers; under Settings → Connections → Add, Hello world is offered, asks for a file name, and its Test button writes a line. That is the whole installation; uninstalling the package removes both.

The lines go to data/helloworld/ beside Postulo's own data, or wherever POSTULO_HELLOWORLD_DIR points. Nothing a person types moves that: the field is a name, never a path.

Make it yours

  1. Copy the repository and rename postulo_helloworld to your package.

  2. Keep or delete either plugin; a package may hold one kind or several.

  3. Change the entry points in pyproject.toml to point at your classes. The groups are postulo.sources, postulo.notifiers, postulo.stores and postulo.syncs.

  4. Wrap every string a person reads in gettext / gettext_lazy, then:

    uv run postulo-messages extract   # a catalogue for every language Postulo offers
    uv run postulo-messages compile   # writes the .mo files, no gettext needed
    

    Postulo's own catalogue tool, installed with Postulo as a dev dependency. Commit the .mo files: they ship with the package. English is the source; French and European Portuguese are filled while working, and uv run pytest -m release holds the twenty-four European Union catalogues complete before a release.

  5. Run the tests:

    uv sync        # brings in Postulo itself from its repository, for the tests
    uv run pytest
    

The contract in full — every field of JobPostingData, how sources are chosen, what a connected plugin's config_fields() and test() must do, how to add a settings section — is in Postulo's docs/PLUGINS.md.

Rules worth keeping

  • Leave a field empty rather than guessing. A blank box on the review screen invites typing; a confidently wrong value has to be noticed first.
  • Return None when the page is not yours. Postulo tries the next source.
  • A person names a thing; the operator names the place. A connection's settings are ordinary user input handed to you with the server's privileges attached, so a field that takes a path lets any account on the instance reach any file the process may write. Take a name, refuse a separator rather than cleaning it out, and put the directory in the environment.
  • Match the host, not the address. "board.example" in url also says yes to https://evil.test/?board.example, and a source that says yes is parsing a stranger's page ahead of Postulo's own parsers.
  • Import postulo.plugins.api and nothing else from Postulo. Everything else is internals that may be renamed in a patch release; tests/test_surface.py holds you to it.
  • Use postulo.plugins.http.client() for every request a connected plugin makes. It carries Postulo's timeouts, user agent and destination policy.
  • Raise on failure in send(). Postulo logs it and shows it on the connection; it never fails whatever caused the notification.
  • Translate your own strings. Ship locale/; Postulo reads it when it loads you.

Licence

MIT, so that copying it into your own plugin is a decision you never have to think about. Postulo itself is AGPL-3.0-or-later.