Skip to content

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 — sets GENERATED = True and re-exports every resource schema (from dhis2w_client.generated.v43 import DataElement).
  • schemas/ — one pydantic BaseModel per DHIS2 metadata type, with Field(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/schemas response used at generation time. Committed so d2w dev codegen rebuild can 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 no dev-2-44 channel).
  • 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

  1. Add the member to the Dhis2 enum in dhis2w_client.generated (for example V45 = "v45"). Every supported-version list derives from it.
  2. Pin the image in infra/versions.env. A release pin such as 2.45.0.0 resolves to dhis2/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.sh turns the pin into DHIS2_IMAGE, which infra/compose.yml runs.
  3. Create infra/v{N}/ with dhis.conf and a dump.sql.gz, plus infra/fixtures/v{N}/.
  4. Copy the hand-written trees: uv run python infra/scripts/clone_version_tree.py v43 v{N}. The script copies dhis2w_client.v43 and dhis2w_core.v43 to the new major, rewrites version tokens (v43-only becomes v43+), keeps release strings such as 2.43.1 (they record what was observed on that release), and lists every line that still names v43 for review.
  5. Generate the wire types: make dhis2-codegen-all VERSIONS=v{N} (see Codegen).
  6. Sample several boots with infra/scripts/openapi_stability.sh v{N} <output-dir> and pin any OpenAPI component whose shape changes between boots.
  7. Add the key to the test lists that enumerate majors.
  8. 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):

  1. profile.version — if the active profile carries version = "v41" | "v42" | "v43" | "v44" in profiles.toml, that tree is loaded.
  2. DHIS2_VERSION env var — the vXX key (v41 / v42 / v43 / v44). Lets make verify-examples DHIS2_VERSION=v43 exercise the v43 plugin tree against a v43 stack without hand-editing every profile. A bare digit (43) is not accepted.
  3. 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():

  1. GET /api/system/info → raw version string (e.g. "2.43.0").
  2. The minor component is extracted (e.g. 43) and mapped to "v43".
  3. dhis2w_client.generated.available_versions() is consulted — only populated versions (GENERATED = True) are candidates.
  4. If "v43" is populated, that module is loaded and bound to client.resources, client.models, etc.
  5. If "v43" is not populated and allow_version_fallback=False (default), UnsupportedVersionError is raised, pointing the user at d2w codegen.
  6. 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_extra because every generated class uses ConfigDict(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.user Reference \| None list[User] (renamed users on the wire)
    TrackedEntityAttribute.favorite bool list[str] (renamed favorites on the wire)
    Section.user Reference \| None removed
    Program.favorite list[str] removed
    Legend full 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:

  1. Brings up a fresh stack on that major's pinned DHIS2_IMAGE with an empty-gzip placeholder where infra/v{N}/dump.sql.gz sits (so Flyway bootstraps a clean schema instead of loading the seeded e2e dump into a fresh stack).
  2. Waits for /api/system/info to respond.
  3. Runs d2w dev codegen generate against http://localhost:8080 with admin/district, which writes generated/v{N}/schemas/, resources.py, __init__.py, and schemas_manifest.json.
  4. Runs d2w dev codegen fetch-openapi to capture the live /api/openapi/openapi.json into generated/v{N}/openapi.json, byte for byte.
  5. Runs d2w dev codegen oas-rebuild --version v{N} so oas/ and openapi_manifest.json follow the new document and carry the same raw_version as the schema manifest.
  6. 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-client to 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-client and 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.