Shipping an external plugin¶
dhis2w-core's plugin loader (dhis2w_core.plugin) walks two sources at CLI
startup:
- A package scan over
dhis2w_core.plugins.*— this picks up every first-party plugin underpackages/dhis2w-core/src/dhis2w_core/plugins/. importlib.metadata.entry_points(group="dhis2.plugins")— any separately-installed Python package can register a plugin here.
External plugins are full first-class citizens. They get the same
access to Dhis2Client, the same profile resolution, the same MCP
integration, and the same CLI mounting — no hooks, no registry file, no
core code changes.
The reference implementation¶
examples/plugin-external/ ships a minimal runnable plugin
(dhis2-plugin-hello) that greets the authenticated DHIS2 user:
examples/plugin-external/
├── pyproject.toml [project.entry-points."dhis2.plugins"]
│ hello = "dhis2_plugin_hello:plugin"
└── src/dhis2_plugin_hello/
├── __init__.py exports `plugin = _HelloPlugin()`
├── service.py uses `open_client(profile)` like first-party plugins
├── cli.py Typer sub-app; `register(app)` mounts `dhis2 hello`
└── mcp.py FastMCP tool `hello_say`
Install + verify:
uv add --editable examples/plugin-external/
dhis2 --help | grep hello
# hello External plugin example.
dhis2 hello say
# Hello, admin admin!
The contract¶
Two things make a package a valid external plugin:
- A module-level
pluginattribute that satisfiesdhis2w_core.plugin.Plugin— pydantic model withname,description,register_cli(app),register_mcp(mcp). The hello example uses a frozenBaseModel; first-party plugins do the same. - An entry-point line in
pyproject.tomlpointing at that attribute: The group namedhis2.pluginsis fixed — the loader only looks there. The surface-name on the left of=is free (but usually matches what ends up indhis2 <name>).
That's the entire contract. Everything else (service.py / cli.py / mcp.py
file split) is convention, not requirement — a plugin that only registers
a CLI (and no MCP tool) simply implements register_mcp as a no-op, or
vice versa.
Why CLI + MCP parity is voluntary¶
Every first-party plugin ships both — same typed call from either surface
is a hard rule in this workspace. External plugins aren't obligated. A
plugin that only makes sense in a terminal can skip MCP registration; an
agent-only tool can skip the CLI side. Just make the corresponding
register_* a no-op:
Error behaviour¶
If an entry-point's import fails (package not installed in the current
env, typo in the import path), the loader silently skips it —
ImportError isn't propagated. Broken plugins shouldn't take down
dhis2 --help.
If a plugin raises during register_cli / register_mcp, that does
propagate, and the CLI aborts. Fail loudly when the plugin itself is
broken; stay quiet when the environment doesn't have it installed.
Testing an external plugin¶
Same tooling as first-party: respx for HTTP mocking, Typer's CliRunner
for CLI verification, fastmcp.Client for MCP tools. Nothing plugin-
specific — test service.py directly, test cli.py via CliRunner
against a fake Resources or a mocked open_client.
Publishing¶
uv build → uv publish (or PyPI Trusted Publishing via your own GitHub
Actions workflow). Users install your plugin alongside their dhis2w-cli
install — uv tool install --with your-plugin-name dhis2w-cli for a
global tool, or uv add your-plugin-name inside a project that already
has dhis2w-cli. Version-pin dhis2w-client / dhis2w-core in your
dependencies if your plugin uses generated models that might move.