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 (124 schemas)
│ ├── v42/ # DHIS2 2.42.x (118 schemas)
│ ├── v43/ # DHIS2 2.43.x (116 schemas)
│ └── v44/ # DHIS2 2.44 development build (preview)
├── v41/ # hand-written client surface for v41
├── v42/ # hand-written client surface for v42
├── v43/ # hand-written client surface for v43 (canonical)
├── v44/ # hand-written client surface for v44 (preview)
└── __init__.py # top-level re-exports from v43
packages/dhis2w-core/src/dhis2w_core/
├── plugin.py # discovery walks dhis2w_core.v{N}.plugins.*
├── cli_output.py # tree-neutral helpers, one copy each, typed structurally:
├── cli_errors.py # rendering, error funnel, task watching, the token store
├── token_store.py # (generic over the tree's OAuth2Token), PAT and OAuth2
├── ... # registration
├── v43/client_context.py # binds the tree: opens a connected dhis2w_client.v43.Dhis2Client
├── v43/plugins/<name>/ # canonical plugin tree (cli.py, service.py, ...)
├── v41/plugins/<name>/ # mirror of v43, diverges per-file as v41 quirks land
├── v42/plugins/<name>/ # mirror of v43, diverges per-file as v42 quirks land
└── v44/plugins/<name>/ # mirror of v43, diverges per-file as v44 behaviour lands
A plugin tree imports its helpers from dhis2w_core.* and only client_context (plus the small
admin_auth) from its own dhis2w_core.v{N}. The helpers accept any tree's envelope, conflict row
or token through Protocol types (WebMessageLike, ConflictRowLike, OAuth2TokenLike), so a
plugin pack in another repository has one import path per helper regardless of the tree it serves.
Four supported majors — v41, v42, v43, v44. The set is defined in one place, the Dhis2 StrEnum in dhis2w_client.generated; dhis2w_client._dispatch._KNOWN_VERSION_KEYS and dhis2w_core.plugin.SUPPORTED_VERSION_KEYS derive from it, and CLI and profile validation check against SUPPORTED_VERSION_KEYS. 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 copies of v43, rewritten to import their own major's generated tree, 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.). Each tree imports from its own dhis2w_client.generated.v{N}.*, so the symbol set stays parallel while the shapes track the major. Divergence is per-method and called out in DHIS2_ISSUES.md.
Each populated v{NN}/ carries:
__init__.py— setsGENERATED = Trueand re-exports every resource schema (from dhis2w_client.generated.v43 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 are the seeded e2e databases built by make dhis2-build-e2e-dump; codegen never changes them.
v44 is a preview¶
2.44.0 is not released. The v44 trees (dhis2w_client.v44, dhis2w_core.v44.plugins.*, dhis2w_client.generated.v44) target one build of the DHIS2 development line, pinned by digest in infra/versions.env: dhis2/core-dev@sha256:4807b025..., the 2.44-SNAPSHOT build of 2026-10-06 (revision c48aedc). The pin is marked # held, so the automated version-bump check leaves it alone. Preview means:
- The v44 generated code describes that one build. A later development build may add, rename, or remove schemas before 2.44.0 ships.
- The v44 end-to-end CI leg runs with
continue-on-error: it reports, but does not gate a merge. - Contract tests reach v44 at
https://play.im.dhis2.org/dev, the play channel that tracks the development line (there is nodev-2-44channel). - v43 stays the canonical baseline and the startup default.
When 2.44.0 ships, re-pin v44 in infra/versions.env to 2.44.0.0 and drop the # held marker, move infra/v44/dump.sql.gz to the release image (make dhis2-migrate-e2e-dump DHIS2_VERSION=v44), regenerate the tree (make dhis2-codegen-all VERSIONS=v44), sample boots with infra/scripts/openapi_stability.sh v44 <output-dir>, and review the codegen diff before making the v44 CI leg required.
Adding a major¶
- Add the member to the
Dhis2enum indhis2w_client.generated(for exampleV45 = "v45"). Every supported-version list derives from it. - Pin the image in
infra/versions.env. A release pin such as2.45.0.0resolves todhis2/core:2.45.0.0; a pin containing/(a full image reference, such as a development build by digest) is used as-is.infra/scripts/_resolve_image.shturns the pin intoDHIS2_IMAGE, whichinfra/compose.ymlruns. - Create
infra/v{N}/withdhis.confand adump.sql.gz, plusinfra/fixtures/v{N}/. - Copy the hand-written trees:
uv run python infra/scripts/clone_version_tree.py v43 v{N}. The script copiesdhis2w_client.v43anddhis2w_core.v43to the new major, rewrites version tokens (v43-onlybecomesv43+), keeps release strings such as2.43.1(they record what was observed on that release), and lists every line that still names v43 for review. - Generate the wire types:
make dhis2-codegen-all VERSIONS=v{N}(see Codegen). - Sample several boots with
infra/scripts/openapi_stability.sh v{N} <output-dir>and pin any OpenAPI component whose shape changes between boots. - Add the key to the test lists that enumerate majors.
- Add the major to the plugin packs that carry per-version trees, such as
dhis2w-mcp(dhis2w_mcp/tools/v{N}/), after the host release that introduces it.
The Dhis2 enum¶
dhis2w_client.Dhis2 is a StrEnum listing the four supported majors — Dhis2.V41, Dhis2.V42, Dhis2.V43, Dhis2.V44 — and is the single source of the supported version set. 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.V43) as client:
...
# 2. Direct schema import without the full path.
from dhis2w_client.generated.v43 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,44}.plugins.*. The selection chain (dhis2w_core.plugin.resolve_startup_version):
profile.version— if the active profile carriesversion = "v41" | "v42" | "v43" | "v44"inprofiles.toml, that tree is loaded.DHIS2_VERSIONenv var — the vXX key (v41/v42/v43/v44). 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
v43— the canonical baseline. Every tree's client re-binds its accessors to the server's major on connect, so the default constrains only which plugin tree the CLI and MCP server load, not which server an unpinned profile may reach.
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.43.0").- The minor component is extracted (e.g.
43) and mapped to"v43". dhis2w_client.generated.available_versions()is consulted — only populated versions (GENERATED = True) are candidates.- If
"v43"is populated, that module is loaded and bound toclient.resources,client.models, etc. - If
"v43"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 through v44 populated, the practical case is "any DHIS2 above v44 falls back to v44".
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 == "v43" (whatever major the server reports)
# client.raw_version == "2.43.0"
...
Working with version-specific types¶
Hand-written client helpers (client.system.info(), client.dashboards.list(), client.tracked_entity_attributes.get(), etc.) parse responses against the generated models of the live major. connect() rebinds every accessor to the detected tree, so a client imported from dhis2w_client (home tree v43) talking to a v41 server runs v41's hand-written code against v41's models.
What stays fixed is the static type. mypy and pyright see the home tree's shapes — v43 for the top-level dhis2w_client import, v41 or v42 when you import that tree directly. So:
- A field that exists only on a major other than your home tree is not visible at typed-access time. It survives on the parsed model under
model_extrabecause every generated class usesConfigDict(extra="allow"). -
A handful of schemas change shape outright between majors, so a model from one tree cannot validate another tree's 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 a field outside your home tree's shape, 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 ("v41", "v42", "v43", "v44") 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":
# Typed on the v43 home tree; reachable through model_extra from v41 or v42.
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 from a v41- or v42-home client, import it directly. This bypasses the helper's static type 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/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 annotated with the home tree's shapes at static-type-check time. Import Dhis2Client from dhis2w_client.v41 and mypy / pyright will flag program.enableChangeLog as unknown even though the object parsed off a v43 server has it. The honest options are: cast, getattr(model, "enableChangeLog", None), or use Pattern 2 above. A generic-over-version client would close the gap; the current contract is "runtime is correct, static is home-tree-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 v44 — 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 + v44:
infra/scripts/codegen_all_versions.sh # default — v41 + v42 + v43 + v44
infra/scripts/codegen_all_versions.sh v43 # subset
For each version N, the script:
- Brings up a fresh stack on that major's pinned
DHIS2_IMAGEwith 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. - Runs
d2w dev codegen fetch-openapito capture the live/api/openapi/openapi.jsonintogenerated/v{N}/openapi.json, byte for byte. - Runs
d2w dev codegen oas-rebuild --version v{N}sooas/andopenapi_manifest.jsonfollow the new document and carry the sameraw_versionas the schema manifest. - Tears down and puts the committed seeded dump back where the placeholder sat.
Captures differ between boots¶
DHIS2 builds both introspection surfaces at startup, and where two Java members map to one
property, the one that wins changes from boot to boot of the same image (DHIS2_ISSUES.md #95, #133). Two
captures of 2.43.1 or 2.43.2 can disagree on CategoryOption.aggregationType (enum or boolean), and two of
2.42.6 on the item type of Page. The codegen pins every such property before emission
(spec_patches.pin-boot-dependent-shapes for OpenAPI, schema_patches for /api/schemas), so any
capture of a pinned release emits the same models. The raw capture is committed as it arrived.
When a pin moves, or a new major lands, sample several boots and pin whatever the report lists:
infra/scripts/openapi_stability.sh v43 reports/openapi-v43 3 # three fresh boots, then the flip report
uv run d2w dev codegen oas-flips a/openapi.json b/openapi.json # compare any captures directly
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 every tree 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.