Skip to content

Architecture overview

dhis2-utils is designed around three orthogonal axes of extensibility. Extending one should never force edits to another — that's how we keep this codebase maintainable as it grows.

The three axes

1. Workspace members (shipping units)

Each shippable unit of code is a uv workspace member under packages/:

Member Role
dhis2w-client Pure async DHIS2 API client. PyPI-publishable.
dhis2w-core Profile system, plugin registry, first-party plugins.
dhis2w-cli Thin Typer console-script shell.
dhis2w-mcp Thin FastMCP server shell.
dhis2w-browser Playwright helpers for UI automation.
dhis2w-codegen Version-aware client generator.

New surfaces (a future FastAPI web UI, an HTTP webhook receiver, a TUI) land as new members. No edits required to existing ones.

2. Plugins inside dhis2w-core

Each DHIS2 domain (metadata, tracker, analytics, screenshots, indicator validation, …) is a self-contained plugin package in dhis2w-core/src/dhis2w_core/plugins/<name>/. Every plugin is a folder with this shape:

<name>/
├── __init__.py        # exports `plugin = Plugin(name="<name>", ...)`
├── models.py          # plugin-internal pydantic view-models (reports, summaries, job state)
├── service.py         # async pure functions — single source of truth for the domain
├── cli.py             # Typer sub-app wrapping service.py
├── mcp.py             # FastMCP tool registrations wrapping service.py
└── tests/

The CLI and MCP surfaces both call into the same service.py. They never drift out of parity because neither is primary.

Plugins are discovered two ways:

  • Built-ins — iterate dhis2w_core.plugins.* at startup.
  • Externalimportlib.metadata.entry_points(group="dhis2.plugins"). An external package (like dhis2w-codegen) can add commands/tools without a PR.

3. Auth providers inside dhis2w-client

dhis2w-client defines an AuthProvider Protocol. The client never touches auth internals — it just asks for headers. Three providers ship in-box: BasicAuth, PatAuth, OAuth2Auth. Future providers (service-account JWT, OIDC federation, proxy-injected headers) land as new files in dhis2w-client/auth/ without touching client.py.

Dependency arrows

dhis2w-browser  ─►  dhis2w-client
dhis2w-core     ─►  dhis2w-client
dhis2w-cli      ─►  dhis2w-core    (─► dhis2w-browser as optional extra)
dhis2w-mcp      ─►  dhis2w-core    (─► dhis2w-browser as optional extra)
dhis2w-codegen  ─►  dhis2w-client

No cycles. dhis2w-client is the foundation everything builds on, which is what lets it ship to PyPI independently.

Why this matters

Every time a new requirement comes in, we should be able to say "that's a plugin", "that's a new auth provider", or "that's a new workspace member" — and build it in isolation. If a new requirement forces edits across three members, the architecture is wrong.