- Python 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo | ||
| src/postulo_helloworld | ||
| tests | ||
| .gitignore | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
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. The directories carry gettext's names (fr_FR, pt_PT), written by a tool; everywhere else a language is fr-FR, as Make it yours explains. |
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. |
.forgejo/ISSUE_TEMPLATE/ |
The issue forms: Bug report and Feature request, and the links shown above them. Report a problem with this plugin through them. A plugin's own repository wants its own forms: copy these, change the names, the fields and the links, and keep the link to Postulo's SECURITY.md and the warning about personal details. |
.forgejo/workflows/ |
The CI: ci.yml (lint, catalogues, tests, and the dependency audit as its own job), weekly.yml (the audit alone, every Monday) and release.yml (the release gate, on a tag). Each step is a call to the shared plugin action, Postulo/plugin-ci, by its full address, so a new plugin copies these three files as they are and a change to the plugin CI (a uv bump) is made there once. Its README lists the inputs, such as extra Debian packages. |
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
-
Copy the repository and rename it everywhere the name lives, or the copy will not import. The package is
postulo_helloworld, the distributionpostulo-helloworld(postulo-messagesfinds the package from the distribution's name, hyphens read as underscores), and between them they are written in:- the directory
src/postulo_helloworld/; pyproject.toml:[project] name,[tool.hatch.build.targets.wheel] packagesandartifacts, and the entry points (step 3);src/postulo_helloworld/__init__.py: the distribution's name inversion(...), which raisesPackageNotFoundErroron import once nothing of that name is installed;tests/test_surface.py:PACKAGE.
tests/test_helloworld.pyfails while__init__.pyand[project] namedisagree. - the directory
-
Keep or delete either plugin; a package may hold one kind or several.
-
Change the entry points in
pyproject.tomlto point at your classes. The groups arepostulo.sources,postulo.importers,postulo.notifiers,postulo.outboxes,postulo.stores,postulo.syncs,postulo.transports,postulo.features,postulo.link_services,postulo.messaging_services,postulo.register_adaptersandpostulo.backups, and the class each expects is onpostulo.plugins.api. A plugin'snameis unique across every kind on an instance -- its switch, its form and its logo are keyed by it, and Postulo leaves out a second plugin of the same name -- which is why the source here ishelloworldand the notifierhelloworld-notifier. -
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 neededPostulo's own catalogue tool, installed with Postulo as a dev dependency. Do not commit the
.mofiles:compilewrites them before the tests and beforeuv build, andpyproject.tomlputs them in the wheel. English is the source. Translate your plugin wherever you like; the official plugins, this one included, are translated in Weblate at translate.tiagoagueda.com and never in a commit (uv run postulo-messages guardrefuses one, postulo/postulo#706), anduv run pytest -m releaseholds the languages the paired core release selects complete before a release.A language code is a BCP 47 tag in its canonical form: the language in lower case, a script with a capital, a region in capitals —
fr-FR,pt-BR,sr-Cyrl. That is how Postulo writes every code it hands you (Notification.language,DocumentMetadata.language) and how you write one in code, in tests and in what you store or send. The directories underlocale/are the one exception, and not yours to spell:pt_BRandsr_Cyrlare gettext's names for a catalogue, whichpostulo-messagesderives from the tags, so nobody types one. A code that comes from outside — a form, a file, another service — may be spelt any way: put it in the canonical form withlanguage_tag, check that it is one withis_language_tag, and find the nearest of the languages you have withlanguage_match, all frompostulo.plugins.api.tests/test_catalogues.pyfails on a code written in lower case anywhere in the repository. -
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 the wiki's
Writing a plugin;
the names you may use are the ones in postulo.plugins.api.__all__.
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
Nonewhen 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 urlalso says yes tohttps://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.apiand nothing else from Postulo. Everything else is internals that may be renamed in a patch release;tests/test_surface.pyholds you to it. - Use
postulo.plugins.api.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. - Write a language code the way Postulo does:
pt-BR. A BCP 47 tag in its canonical form, everywhere but the names of thelocale/directories. Compare two withlanguage_match, never with==: tags are the same whatever their case, andpt-BRasked of a plugin that only hasptshould find it.
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.