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 d2w codegen from that instance's /api/schemas endpoint.
Layout¶
packages/dhis2w-client/src/dhis2w_client/
├── __init__.py # version-agnostic re-exports (Dhis2 enum, Dhis2Client, ...)
├── generated/ # auto-generated wire types per version
│ ├── __init__.py # version registry + loader + Dhis2 enum
│ ├── v41/ # DHIS2 2.41.x
│ ├── v42/ # DHIS2 2.42.x (119 schemas)
│ └── v43/ # DHIS2 2.43.x (116 schemas)
├── v41/ # hand-written client surface for v41
├── v42/ # hand-written client surface for v42 (canonical)
├── v43/ # hand-written client surface for v43
└── <submodule>.py # top-level shims re-exporting from v42 for backwards-compat
packages/dhis2w-core/src/dhis2w_core/
├── plugin.py # discovery walks dhis2w_core.v{N}.plugins.*
├── v42/plugins/<name>/ # canonical plugin tree (cli.py, mcp.py, service.py, ...)
├── v41/plugins/<name>/ # mirror of v42, diverges per-file as v41 quirks land
└── v43/plugins/<name>/ # mirror of v42, diverges per-file as v43 quirks land
Three supported majors — v41, v42, v43. Other DHIS2 majors are out of scope; the codegen tooling can still target them via d2w dev codegen generate --url ... against an arbitrary stack, but no manifests or generated trees are committed.
The hand-written v{N}/ subpackages start as byte-equivalent copies of v42 and diverge per-file as version-specific behaviour lands (the categorys -> categories field rename on v43's CategoryCombo, the missing OAuth2ClientCredentialsAuthScheme on v41's generated tree, etc.). Until a file diverges, all three trees import from dhis2w_client.generated.v42.* to keep the symbol set consistent. Divergence is per-method and called out in BUGS.md.
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 sod2w 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 the three supported majors — Dhis2.V41, Dhis2.V42, Dhis2.V43. 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
Plugin-tree selection at CLI / MCP startup¶
The CLI (d2w ...) and MCP server (dhis2w-mcp) pick a single plugin tree at bootstrap from dhis2w_core.v{41,42,43}.plugins.*. The selection chain (dhis2w_core.plugin.resolve_startup_version):
profile.version— if the active profile carriesversion = "v41" | "v42" | "v43"inprofiles.toml, that tree is loaded.DHIS2_VERSIONenv var — the vXX key (v41/v42/v43). Letsmake verify-examples DHIS2_VERSION=v43exercise the v43 plugin tree against a v43 stack without hand-editing every profile. A bare digit (43) is not accepted.- Default
v42— the canonical baseline.
This selection is independent of the wire client's actual version detection (Dhis2Client.connect() — see below). A profile pinned to v43 plugin tree against a v42 stack would load v43-specific plugin overrides + the v42 wire client; runtime dispatch swaps accessors after connect() so the wire chain remains correct regardless.
# Force the v43 plugin tree for a one-off run (overrides profile.version)
DHIS2_VERSION=v43 d2w metadata list dataElements
Library callers using from dhis2w_client.v43 import Dhis2Client skip the resolution chain entirely — the import path pins the version.
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 atd2w codegen. - If fallback is enabled and the live version isn't populated, the nearest-lower populated version is chosen — never higher. With v41 + v42 + v43 populated, the practical case is "any DHIS2 above v43 falls back to v43".
from dhis2w_core.client_context import open_client
from dhis2w_core.profile import profile_from_env
async with open_client(profile_from_env()) as client:
# client.version_key == "v42" (or "v43", depending on the active profile)
# client.raw_version == "2.42.0"
...
Working with version-specific types¶
Hand-written client helpers (client.system.info(), client.dashboards.list(), client.tracked_entity_attributes.get(), etc.) currently parse responses against the v42 generated models. That's fine for the ~95% of fields that are stable across DHIS2 v42 and v43, but it means:
- v43-only fields (e.g.
Program.enableChangeLog,TrackedEntityAttribute.trigramIndexed) are not visible at typed-access time. They survive on the parsed model undermodel_extrabecause every generated class usesConfigDict(extra="allow"). -
A handful of breaking-shape schemas — fields where the v43 wire shape isn't structurally compatible with the v42 model — fail to parse against v43 wire data. The full list is in Schema diff: v41 -> v42 -> v43. The headline cases:
Schema v42 v43 DashboardItem.userReference \| Nonelist[User](renameduserson the wire)TrackedEntityAttribute.favoriteboollist[str](renamedfavoriteson the wire)Section.userReference \| Noneremoved Program.favoritelist[str]removed Legendfull identifiable-object surface almost everything stripped (~20 fields removed)
If you need typed access to v43-only fields, or you want to defensively branch on the live version, here are the patterns.
Pattern 1 — branch on client.version_key¶
Dhis2Client.version_key returns the loaded module key ("v42", "v43", ...) after connect(). Use it to decide which path to take when the wire shape differs:
from dhis2w_core.client_context import open_client
from dhis2w_core.profile import profile_from_env
async with open_client(profile_from_env()) as client:
if client.version_key == "v43":
# v43-only field, accessed via model_extra (the v42-typed model has it under .model_extra).
info = await client.system.info()
capability = (info.model_extra or {}).get("systemCapabilities")
else:
capability = None
Pattern 2 — direct dhis2w_client.generated.v43.* imports¶
For typed access to a v43-only model, import it directly. This bypasses the v42-pinned helper and works against any v43 instance:
from dhis2w_client.generated.v43.schemas.tracked_entity_attribute import TrackedEntityAttribute as TrackedEntityAttributeV43
from dhis2w_core.client_context import open_client
from dhis2w_core.profile import profile_from_env
async with open_client(profile_from_env()) as client:
raw = await client.get_raw("/api/trackedEntityAttributes/foo")
# On v43, this gets you the typed `favorites: list[str]` plus the new search fields.
attribute = TrackedEntityAttributeV43.model_validate(raw)
print(attribute.favorites, attribute.trigramIndexed)
The dhis2w_client.generated.v43.* paths are first-class — every v43 schema is importable. The examples/v42/client/v43_*.py files are runnable end-to-end demos, one per changed schema (DashboardItem, TrackedEntityAttribute, Program, EventVisualization, Map, Section, removed resources). Pick the file matching the schema you care about; each shows both the model_extra path and the direct-v43-import path.
Pattern 3 — pin the client to a known version¶
If you control the deployment and want to skip the /api/system/info round-trip on connect(), pass version=Dhis2.V43 explicitly. The client will assert the server matches and bind the v43 generated module up-front:
from dhis2w_client import Dhis2, Dhis2Client
async with Dhis2Client(url, auth=auth, version=Dhis2.V43) as client:
...
What this does NOT solve¶
Hand-written helper return types are still annotated as v42-shape at static-type-check time. mypy / pyright will flag program.enableChangeLog as unknown even though the parsed object has it in model_extra. The honest options are: cast, getattr(model, "enableChangeLog", None), or use Pattern 2 above. We may revisit this with a generic-over-version client in a future release; the current contract is "runtime is correct, static is v42-flavored."
Why strict by default¶
When Dhis2Client.connect() finds the live version doesn't have a populated module — e.g. someone runs against a DHIS2 above v43 — there are three reasonable choices:
- Refuse — force the user to run codegen against the live instance. Guarantees typing matches reality.
- Fall back to the nearest-lower populated version — newer fields silently disappear; typed access to 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¶
make dhis2-codegen-all (or the underlying infra/scripts/codegen_all_versions.sh) orchestrates the whole pipeline. Default set is v41 + v42 + v43:
infra/scripts/codegen_all_versions.sh # default — v41 + v42 + v43
infra/scripts/codegen_all_versions.sh 43 # subset
For each version N, the script:
- Brings up a fresh
dhis2/core:Nstack with an empty-gzip placeholder whereinfra/v{N}/dump.sql.gzsits (so Flyway bootstraps a clean schema instead of loading the seeded e2e dump into a fresh stack). - Waits for
/api/system/infoto respond. - Runs
d2w 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 d2w dev codegen rebuild # every v{N}/schemas_manifest.json
uv run d2w dev codegen rebuild --manifest path/to/foo.json # just one
Useful after touching emit.py or the Jinja templates when you want all three trees refreshed without booting each server.
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.