Version-aware generated clients¶
URLs do not carry the version¶
DHIS2's API is always mounted at /api/.... Earlier DHIS2 releases exposed a versioned path variant (/api/30/dataElements); that's being phased out. Every URL this client constructs uses the plain /api/{plural} form.
Version-awareness therefore lives in the payload shapes, not the URLs. DHIS2 2.42 returns slightly different fields for (say) a DataElement than 2.44. Our generated pydantic models capture those differences per-version, and Dhis2Client.connect() picks the right module for whatever instance you're connected to.
Why version-scoped models at all¶
DHIS2 schemas evolve across versions. New metadata types appear, existing types get new properties, enums pick up new constants. A single hand-curated client either gets out of date or lags behind the latest release.
Instead of fighting that, we lean in: each supported DHIS2 version gets its own generated module under dhis2w_client.generated.v{NN}, produced by dhis2 codegen from that instance's /api/schemas endpoint.
Layout¶
packages/dhis2w-client/src/dhis2w_client/generated/
├── __init__.py # version registry + loader + Dhis2 enum
├── v40/ # DHIS2 2.40.11 (125 schemas)
├── v41/ # DHIS2 2.41.8 (125 schemas)
├── v42/ # DHIS2 2.42.4 (119 schemas)
├── v43/ # empty — dhis2/core:43 not yet on Docker Hub
└── v44/ # rebuilt from committed manifest (116 schemas)
Each populated v{NN}/ carries:
__init__.py— setsGENERATED = Trueand re-exports every resource schema (from dhis2w_client.generated.v42 import DataElement).schemas/— one pydanticBaseModelper DHIS2 metadata type, withField(description=...)hints for owner/writable/bounds.resources.py— typed CRUD accessors (client.resources.dataElements.get/list/create/update/delete).schemas_manifest.json— snapshot of the/api/schemasresponse used at generation time. Committed sodhis2 dev codegen rebuildcan regenerate offline.
The generated code is committed, not gitignored. Diffs are reviewable in PRs — you can see when a new field appears on a resource, when an enum gains a constant, when an endpoint is removed. The per-version infra/v{N}/dump.sql.gz dumps (Flyway-bootstrapped, no user data) sit alongside as cheap restore points.
The Dhis2 enum¶
dhis2w_client.Dhis2 is a StrEnum listing every version with a generated client — Dhis2.V40 through Dhis2.V44. Two uses:
from dhis2w_client import Dhis2, Dhis2Client
# 1. Pin the version, skip auto-detection via /api/system/info.
async with Dhis2Client(url, auth=auth, version=Dhis2.V42) as client:
...
# 2. Direct schema import without the full path.
from dhis2w_client.generated.v42 import DataElement, OrganisationUnit
Runtime dispatch¶
On Dhis2Client.connect():
GET /api/system/info→ raw version string (e.g."2.42.0").- The minor component is extracted (e.g.
42) and mapped to"v42". dhis2w_client.generated.available_versions()is consulted — only populated versions (GENERATED = True) are candidates.- If
"v42"is populated, that module is loaded and bound toclient.resources,client.models, etc. - If
"v42"is not populated andallow_version_fallback=False(default),UnsupportedVersionErroris raised, pointing the user atdhis2 codegen. - If fallback is enabled, the nearest-lower populated version is chosen (e.g.
v41for a v42 instance when only v40/v41 have been generated). Never picks a higher version.
async with Dhis2Client(
base_url="https://play.im.dhis2.org/stable-2-42-0",
auth=BasicAuth("admin", "district"),
) as client:
# client.version_key == "v42"
# client.raw_version == "2.42.0"
...
Why strict by default¶
When a user points at a v43 instance and only v40/v41/v42 are generated, the choice is:
- Refuse — force the user to run codegen against the live instance. Guarantees typing matches reality.
- Fall back to v42 — v43-specific fields silently disappear; typed access to v42-known fields still works.
- Runtime-generate — build pydantic models on the fly from
/api/schemas. Dynamic types, no static analysis, no IDE autocomplete. Rejected.
We default to "refuse" because a strict codebase that loudly fails when things are stale beats one that silently diverges. Opt-in soft fallback (allow_version_fallback=True) is there for CLIs and agents that want to keep working against unknown versions with a warning.
Regenerating all versions¶
make dhis2-codegen-all (or the underlying infra/scripts/codegen_all_versions.sh) orchestrates the whole pipeline. Default set is v42 — other versions are regenerated explicitly because their boots under arm64 emulation can race the compose healthcheck window:
infra/scripts/codegen_all_versions.sh 40 41 42 # explicit set
infra/scripts/codegen_all_versions.sh # default — just v42
For each version N, the script:
- Brings up a fresh
dhis2/core:Nstack with an empty-gzip placeholder whereinfra/v42/dump.sql.gzsits (so Flyway bootstraps a clean schema instead of trying to load a v42 dump into a v40 stack). - Waits for
/api/system/infoto respond. - Runs
dhis2 dev codegen generateagainsthttp://localhost:8080with admin/district, which writesgenerated/v{N}/schemas/,resources.py,__init__.py, andschemas_manifest.json. pg_dumps the post-Flyway schema intoinfra/v{N}/dump.sql.gz(excluding derived analytics_, aggregated_, completeness_*, and_*tables — those are regenerated byanalytics-trigger).- Tears down, restores the committed dump.
Rebuilding from a committed manifest (no network) is cheap:
uv run dhis2 dev codegen rebuild # every v{N}/schemas_manifest.json
uv run dhis2 dev codegen rebuild --manifest path/to/foo.json # just one
Useful after touching emit.py or the Jinja templates when you want every version refreshed without booting each server.
What happens when a new DHIS2 version ships¶
- Confirm the image is on Docker Hub:
docker pull dhis2/core:45. infra/scripts/codegen_all_versions.sh 45— boots the stack, runs codegen, dumps, tears down.- Add
V45 = "v45"to theDhis2enum indhis2w_client.generated.__init__. - Commit the new
generated/v45/tree,infra/v45/dump.sql.gz, and the enum addition. Diff shows exactly what changed vs v44 — new types, new properties, removed endpoints. Dhis2Client.connect()against v45 instances now auto-binds to the new module.
Trade-offs¶
- More code to review. Every DHIS2 release produces a committed diff. That's the point — it's auditable — but it means the
generated/folder grows with each version. - Committed generated code. Some teams prefer gitignored generators. We don't — we want diffs reviewable, and we want
dhis2w-clientto work on PyPI without users having to run codegen themselves. - One codegen per version, not per instance. If two v42 instances have wildly different custom metadata, one of them will have models its code doesn't describe. We cover the standard schema; per-instance customization is not a goal of
dhis2w-clientand probably belongs in a plugin. - Minor version only. We key on
"v42", not"2.42.1". Patch-level differences are not worth separate modules. If DHIS2 changes the schema mid-minor (which they shouldn't), we'd need to revisit.