Skip to content

Decisions log

Running list of architectural choices and the reasoning behind them. Each entry is a terse "we decided X because Y, alternatives were Z". This file is a first stop when you're wondering "why is it done that way?".

2026-04-19 — Two codegen paths: /api/schemas + /api/openapi.json

Decision: dhis2w-codegen emits from both sources into the same per-version directory. /api/schemas drives generated/v{N}/schemas/ + resources.py + enums.py (the metadata resources). /api/openapi.json drives generated/v{N}/oas/ (the instance-side shapes /api/schemas can't describe — WebMessage envelopes, tracker read/write, DataValue / DataValueSet, auth-scheme leaves, data-integrity checks, SystemInfo).

Top-level domain modules (dhis2w_client.envelopes, .aggregate, .system, .maintenance, .auth_schemes, .generated.v42.tracker) shim over the OAS output. They add caller-friendly helpers (WebMessageResponse.created_uid() / task_ref() / conflicts() etc., the AuthScheme discriminated union, TrackerBundle) that OpenAPI doesn't express on its own.

Hand-written hold-outs: Me (not in OpenAPI), PeriodType (Java class hierarchy upstream, not an enum), analytics.py (OpenAPI's Grid shape differs from our current accessors — a behaviour-changing migration left for a future touch), Notification (OpenAPI ships typed category / dataType / level enums; caller churn to thread them through).

Why: hand-writing the ~15 models was tractable for the initial landing; once the surface broadened, the drift risk (DHIS2 ships new fields every minor, the hand-written classes lag silently) justified the emitter infrastructure. The OAS emitter is ~600 LOC + four Jinja templates + openapi_manifest.json for reviewable rebuild diffs.

Three emitter deltas that matter for OAS output:

  • Every field optional (DHIS2 over-marks required relative to real response contents — WebMessage.errorCode is flagged required but no 200-OK response includes it).
  • Enums with > 64 members demote to str aliases (ErrorCode ships 488 members and grows every minor; a strict StrEnum would reject unknown codes between regen passes).
  • Builtin shadows rename to DHIS2<Name> (only WarningDHIS2Warning in v42). Pydantic resolves list[Warning] to the builtin class at FieldInfo construction regardless of defer_build, so emit-time renaming is the only reliable fix.

Alternatives rejected: leave everything as dict[str, Any] (fast but no static checking); keep everything hand-written indefinitely (drift + onboarding cost scale with the number of endpoints we care about).

2026-04-18 — Generated pydantic wrappers live in schemas/

Decision: dhis2w_client/generated/v{N}/schemas/ holds the per-resource pydantic classes.

Why: two reasons. One, "model" widely means a SQLAlchemy/Django ORM row, and we already have those in dhis2w-core/token_store.py — same name for two different things would confuse. Two, DHIS2's own REST API calls these /api/schemas; using the server's term anchors the generated code to the source it derives from.

Alternatives rejected: models/ (accepted common-in-pydantic-ecosystem naming but breaks our internal consistency); types/ (overlaps with typing + collides with metadata type list CLI sub-command).

2026-04-18 — Dhis2 StrEnum + Dhis2Client(version=...) kwarg

Decision: dhis2w_client.Dhis2 is a StrEnum listing every generated-client version (V40..V44). Dhis2Client(..., version=Dhis2.V42) skips auto-detection via /api/system/info and binds the specified generated module. Omit to let the client auto-detect.

Why: users targeting a known DHIS2 line shouldn't have to eat a roundtrip to /api/system/info and shouldn't have to guess whether auto-fallback will land them on a close-but-wrong version. The enum makes valid values discoverable in IDE autocomplete; the kwarg makes intent explicit.

Alternatives rejected: version: str (no tab-completion, typo-prone); a version: str | Dhis2 union (adds a string-parsing branch, awkward for zero API gain).

2026-04-18 — OAuth2 redirect receiver is FastAPI + uvicorn, not asyncio.start_server

Decision: the redirect receiver invoked during dhis2 profile login is a FastAPI + uvicorn app (in dhis2w-core/oauth2_redirect.py) injected into OAuth2Auth via a pluggable redirect_capturer. dhis2w-client keeps a bare asyncio.start_server fallback so the published package stays FastAPI-free.

Why: CLAUDE.md mandates FastAPI for any HTTP service. The receiver renders a styled success/error page the user sees after the redirect, and FastAPI makes the route handling, query parsing, and HTML response idiomatic. Keeping FastAPI out of dhis2w-client preserves the PyPI-thin client rule.

Alternatives rejected: bare asyncio.start_server (violates the FastAPI rule, produces a terrible UX HTML page); running uvicorn from dhis2w-client (drags FastAPI into the PyPI dep list).

2026-04-18 — Preflight-check DHIS2 before running the OAuth2 flow

Decision: both dhis2 profile verify (for oauth2 profiles) and dhis2 profile login probe GET /.well-known/openid-configuration before doing anything else. On 404 / 500 / connection error, we emit an actionable message ("set oauth2.server.enabled = on in dhis.conf and restart") and bail out.

Why: DHIS2 ships Spring Authorization Server switched off. Without the preflight, users would see a cryptic mid-flow failure (404 after the browser opens, or a token-exchange HTTP error). The one extra roundtrip catches the common misconfig and produces a message the user can act on.

Alternatives rejected: rely on the main OAuth2 call to fail — poor UX, fails too deep in the flow to suggest a config fix.

2026-04-18 — OAuth2 client registration with BCrypt-hashed secret, Jackson-serialized settings, scopes = "ALL"

Decision: infra/scripts/_seed_auth_oauth2.py POSTs to /api/oAuth2Clients with clientSecret pre-hashed by BCrypt, with clientSettings / tokenSettings populated with the exact Jackson-serialized Spring AS JSON that the DHIS2 settings UI writes, and with scopes = "ALL" only. The seed additionally PATCHes the admin user's openId to match the username so JWTs with sub=admin map to a real DHIS2 user.

Why: each of these were real failure modes uncovered during OAuth2 bring-up against DHIS2 v2.42:

  • Plaintext clientSecret → 401 invalid_client at /oauth2/token (DHIS2 uses BCryptPasswordEncoder).
  • Empty clientSettings / tokenSettings → 500 IllegalArgumentException: settings cannot be empty at /oauth2/authorize.
  • scopes = "openid email ALL" (space-separated) → Spring AS's validateScopes rejects whitespace inside a scope, and DHIS2 has no fine-grained scopes anyway.
  • Empty openId on admin → 401 Found no matching DHIS2 user for the mapping claim when using a valid JWT.

Alternatives rejected: creating clients via the DHIS2 settings UI — the UI omits scopes and clientAuthenticationMethods, so UI-created clients can't complete the end-to-end flow.

2026-04-18 — DHIS2's own AS is registered as a generic OIDC provider to its own API

Decision: dhis.conf carries a full oidc.provider.dhis2.* block (client_id, client_secret, issuer_uri, authorization_uri, token_uri, jwk_uri, user_info_uri, redirect_url, scopes, mapping_claim). This tells DHIS2's API-side JWT validator that its own Spring AS is a trusted issuer — without it, even tokens minted by DHIS2 itself are rejected as "Invalid issuer" on /api/*.

Why: DHIS2 v2.42 doesn't auto-wire the AS as an internal OIDC provider when oauth2.server.enabled=on. The JWT validator's registry (DhisOidcProviderRepository) only contains what oidc.provider.<id>.* populates. The generic OIDC parser does NOT fall back to OIDC discovery for missing URIs — every endpoint has to be listed explicitly (observed at startup: missing a required property: 'user_info_uri', missing a required property: 'authorization_uri', etc.).

Alternatives rejected: relying on issuer-URI auto-discovery (not implemented in v2.42); documenting it as a post-seed manual step (invisible bootstrap — wrong default).

2026-04-18 — Default profile scope is global; --global/--local flag pair

Decision: dhis2 profile add with no scope flag writes to ~/.config/dhis2/profiles.toml. --local opts into .dhis2/profiles.toml in the current directory. --global is an explicit no-op alias. --scope global|project is removed from docs (still works internally).

Why: users typically have 1-3 DHIS2 instances they return to; global is the correct default. Scoping to the current directory by default would silently create .dhis2/ in whatever directory you happened to run the command — surprising. The --global/--local flag pair matches git (git config --global), npm (npm install -g), and aws configure --profile, all of which treat global as the baseline and local as the override.

2026-04-18 — Profile names restricted to ^[A-Za-z][A-Za-z0-9_]*$

Decision: validate_profile_name() enforces a strict identifier-like grammar — must start with a letter, then letters/digits/underscores only, max 64 characters. Checked at every mutation (add, rename, default). Names like "he llo", prod-eu, 1stthing are rejected with a clean error pointing at the rules.

Why: names become env var suffixes (DHIS2_PROFILE=prod_eu), TOML keys, and unquoted shell arguments. Allowing spaces/hyphens/dots means every call site needs quoting discipline; the failure mode is subtle and platform-dependent. A narrow grammar avoids the whole class. Typical user names (local, prod, laohis42) fit trivially.

2026-04-18 — dhis2 profile rename preserves scope + default

Decision: rename_profile(old, new) mutates whichever file the old name lives in (project or global), preserves key ordering, and updates the default key if the renamed profile was the default. Refuses to clobber an existing name.

Why: renames are a common "I picked the wrong name" recovery action. Preserving scope keeps a project-local profile local (no surprise scope jump); preserving default keeps workflows working after the rename without a separate profile default step.

2026-04-18 — Profiles live in directories, not loose TOML files

Decision: .dhis2/profiles.toml (project) and ~/.config/dhis2/profiles.toml (global). The .dhis2/ and ~/.config/dhis2/ are directories, not bare files.

Why: the directory holds every scope-local artefact, not just profiles — OAuth2 token DB, metadata cache, per-scope preferences all land under the same prefix. Costs nothing over a loose file and scales cleanly as new artefacts land.

2026-04-18 — Name-as-ID for profiles, no UUIDs

Decision: profile identifier is the user-chosen name (local, prod, staging). No separate opaque ID.

Why: profiles are low-cardinality (2–10 per user over a lifetime), human-picked, rarely moved. UUIDs would be clutter. The name is the API.

2026-04-18 — MCP profile tools are read-only; mutations are CLI-only

Decision: profile_list, profile_verify, verify_all_profiles, profile_show are exposed as MCP tools. add_profile, remove_profile, set_default_profile are not — they're CLI-only.

Why: an autonomous agent rewriting the user's credential files is the wrong default. Reading (and probing with existing creds) is safe. Writing requires a human at the keyboard.

2026-04-18 — Every MCP tool takes an optional profile: str | None

Decision: instead of making the MCP server stateful (use_profile setter + shared state), every tool accepts a per-call profile kwarg that overrides the default.

Why: stateless is simpler, matches MCP's function-call model, and avoids surprises when multiple agents share a server. The call-site precedence is then: tool arg → DHIS2_PROFILE env → raw DHIS2_URL/PAT env → project TOML default → global TOML default → NoProfileError. All five layers exist and are individually useful.

2026-04-17 — uv workspace instead of single package

Decision: repo is a virtual uv workspace with multiple members under packages/.

Why: dhis2w-client must ship to PyPI without dragging Typer/FastMCP/Playwright deps. MCP servers deployed in Docker don't need the CLI. New surfaces (FastAPI, TUI) should land as new folders, not conditional imports.

Alternatives rejected:

  • Single package with optional-dependency extras — doesn't reduce import-time surface, and extras don't help when users install from PyPI.
  • Monorepo with separate PyPI projects per top-level folder — same thing, more ceremony.

2026-04-17 — Chapkit's linter config copied verbatim

Decision: ruff + mypy + pyright configs in the workspace root match chapkit exactly.

Why: chapkit is the house standard. Matching conventions across personal projects means less mental overhead. Divergence requires justification.

2026-04-17 — dhis2-claude-theme for docs with left-side nav only

Decision: mkdocs with the custom claude theme, navigation.sections + navigation.expand + navigation.indexes features, no navigation.tabs.

Why: user finds top-tab nav distracting. The sidebar carries everything, auto-expanded.

2026-04-17 — Plugin runtime in dhis2w-core, both CLI and MCP mount it

Decision: plugins live in dhis2w-core/plugins/<name>/ with service.py + cli.py + mcp.py. dhis2w-cli and dhis2w-mcp discover them at startup via module walk + entry points.

Why: MCP tool calls should never subprocess the CLI (latency, lost typing, text parsing). Sharing service.py across both surfaces gives parity for free and lets tests cover both through one code path.

Alternatives rejected:

  • MCP shelling out to dhis2 CLI — slow, brittle, loses pydantic in/out.
  • MCP importing Typer commands programmatically — fights Typer's CLI ergonomics; you end up wanting the underlying function anyway.

2026-04-17 — Pluggable auth via Protocol

Decision: dhis2w-client defines AuthProvider Protocol; ships Basic/PAT/OAuth2 providers; accepts any conforming class.

Why: DHIS2 has at least three auth mechanisms in common use (Basic, PAT, OAuth2/OIDC) and future providers will appear (service-account JWT, OIDC federation, proxied auth). Hardcoding auth into the client means forking it whenever a new mechanism is needed.

2026-04-17 — OAuth2 loopback via asyncio.start_server

Decision: OAuth2 provider uses asyncio.start_server for the loopback redirect, not http.server.HTTPServer on a thread.

Why: native async cleanup, no thread-pool juggling, no concurrent-request surprise, clearer lifecycle. Matches the async-first stance of the rest of the client.

Alternative rejected: running http.server.HTTPServer.handle_request in run_in_executor. Works, but requires custom subclass for silent logging and doesn't integrate with async task cancellation.

2026-04-17 — Version-aware committed generated clients

Decision: dhis2w_client.generated.v{40,41,42,43}/ are separate modules, populated by dhis2 codegen, committed to git. Dhis2Client.connect() picks the right one via /api/system/info.

Why: user's explicit direction. DHIS2 schemas evolve per version; a single hand-curated client either lags or grows shims. Committed generated code means PyPI users don't need to run the generator, and diffs are reviewable in PRs.

Alternatives rejected:

  • Runtime dynamic models (pydantic.create_model) — kills static analysis, no autocomplete, pyright-strict incompatible.
  • Single hand-written model set covering v40–v43 — combinatorial explosion of optional fields, or worse, silent accuracy drift.
  • Code-generated output gitignored and regenerated at CI — PyPI install wouldn't have the types.

2026-04-17 — Strict version dispatch by default, opt-in soft fallback

Decision: Dhis2Client raises UnsupportedVersionError when the reported DHIS2 version has no generated module. allow_version_fallback=True enables nearest-lower fallback with a warning (never nearest-higher).

Why: strict by default protects library users from silently losing typed fields. Agents and CLIs that want to keep running against unknown versions opt in explicitly; library code that cares about correctness gets the default.

2026-04-17 — Async-only, no sync façade

Decision: all public APIs in dhis2w-client are async. No sync wrapper generated via unasync or similar.

Why: FastMCP and FastAPI are async, httpx is async, and notebook users who actually need sync can do asyncio.run(...). A sync wrapper would double the test surface for negligible ergonomic gain.

2026-04-17 — camelCase fields in generated pydantic models

Decision: generated pydantic models use DHIS2's camelCase field names directly (e.g. displayName), not snake_case with aliases.

Why: eliminates alias translation at parse/serialise time; the wire format and the Python field names are identical. Codegen output is explicitly not PEP 8 pure — it mirrors the source API.

2026-04-17 — SQLAlchemy + SQLite for persisted state

Decision: any persistent storage in this workspace (OAuth2 tokens, metadata cache, run history) uses SQLAlchemy async + aiosqlite with Alembic migrations. No Postgres, no raw SQL, no pickled files.

Why: SQLite is the correct scale for personal/project-scoped tooling. SQLAlchemy gives us typed Mapped[...] columns for free. Alembic means schema changes are reviewable.

2026-04-17 — Filesystem-scan version discovery, not a hardcoded list

Decision: dhis2w_client.generated.available_versions() walks the generated/ folder and imports each v\d+ subpackage, returning only those whose __init__.py sets GENERATED = True. No hardcoded _KNOWN tuple.

Why: originally the list was hardcoded to ("v40", "v41", "v42", "v43"). Play dev turned out to be v44, which broke discovery. Scanning the filesystem means adding a new version is literally just running codegen — no Python edit required.

2026-04-17 — Codegen templates use relative imports

Decision: generated __init__.py does from .resources import Resources, and resources.py does from .schemas.<name> import <Name>. Not absolute from dhis2w_client.generated.v44.resources ....

Why: absolute imports tie the generated code to exactly one install location, breaking when the module is imported from tmp_path during tests, or if a downstream project vendors the generated code elsewhere. Relative imports resolve wherever the package sits.

(Originally written with .models.<name> — directory was renamed to schemas/ on 2026-04-18 to match DHIS2's own /api/schemas endpoint and free up "model" for SQLAlchemy-style DB models.)

2026-04-17 — Codegen derives resource class names from schema.klass, not schema.name

Decision: the Java-class tail (klass.rsplit(".", 1)[-1]) is the primary identifier for a generated resource. schema.singular and schema.name are fallbacks.

Why: schema.name is not unique across DHIS2's /api/schemas response — six schemas (JobConfiguration, Route, etc.) all report name="identifiableObject". klass is fully qualified and always distinct. The emitter also dedupes by final class name + plural attr-name to catch any remaining collisions.

2026-04-17 — Generated resources return raw dicts for create/update/delete

Decision: create, update, and delete return dict[str, Any] — the raw DHIS2 import-summary response — not parsed pydantic models.

Why: DHIS2's response shape for write operations ({status, stats, response: {uid, errorReports, ...}}) is not the resource shape. Parsing into the resource model would discard detail. Typed GET/LIST keep pydantic; write responses stay raw.

2026-04-17 — System module endpoints use dedicated models

Decision: /api/system/info and /api/me get pydantic models in dhis2w_client/system.py. client.system.info() and client.system.me() are accessors on the client.

Why: these aren't metadata types, so /api/schemas doesn't describe them. SystemInfo was hand-written initially; it now re-exports from the OAS codegen output at generated/v42/oas/system_info.py (46 fields where we'd hand-maintained 9). Me stays hand-written because /api/me isn't a component schema in the OpenAPI spec.

2026-04-17 — Integration tests use string fixtures, not shared dataclass

Decision: conftest.py in each member exposes play_url, play_username, play_password as separate session-scoped string fixtures. Test files accept them as individual parameters rather than a single PlayCredentials object.

Why: pytest's conftest auto-discovery doesn't make fixtures importable as module attributes — from tests.conftest import PlayCredentials fails in mypy and at runtime (tests/ is not a real package). Flat string fixtures sidestep that entirely and let each member have its own conftest.

2026-04-17 — COMPLEX schema properties become Any, not dict[str, Any]

Decision: codegen maps DHIS2 schema property type COMPLEX to Any in generated pydantic fields.

Why: empirically, DHIS2's COMPLEX fields return dicts, lists, empty lists, or nested arrays of dicts across different metadata types. dict[str, Any] forces pydantic to reject anything non-dict, which breaks real server responses (observed with Constant.attributeValues = []). Any plus extra="allow" preserves the payload without validation failures.

2026-04-17 — Generated CRUD dumps with mode="json"

Decision: create and update in the generated resources template use model.model_dump(by_alias=True, exclude_none=True, mode="json").

Why: default pydantic dumps leave datetime objects raw, which httpx.Request(json=...) cannot serialise. mode="json" converts datetime → ISO 8601 strings and handles other JSON-unfriendly types transparently. No cost on models that don't have such fields.

2026-04-17 — DHIS2_HEADFUL=1 env var flips Playwright to visible mode

Decision: dhis2w_browser.session.resolve_headless() is the single source of truth for headed-vs-headless. Explicit headless=bool kwargs override. Otherwise, DHIS2_HEADFUL=1 (or true/yes/on) shows the browser; anything else keeps it headless.

Why: tests and automation want headless for speed; humans debugging a flow want to see what's happening. A single env var applied across every Playwright entry point (CLI, test fixtures, programmatic callers) means one switch controls all of them.

2026-04-17 — Dep floors bumped to installed latest

Decision: every >= floor across member and workspace pyproject.toml files was raised to match the currently-installed version. Major-version gaps (e.g. fastmcp>=2.0 while we run 3.x, pytest>=8.4 while we run 9.x, mkdocstrings>=0.26 while we run 1.x) were closed.

Why: the lockfile always pinned latest — but the floors in pyproject.toml were stale reading "we support 2.x" when we actively require 3.x features. Tightening the floors keeps documentation and constraints honest, without changing actual installed versions. Future uv lock --upgrade runs (make deps-upgrade) continue to pick up latest.

2026-04-17 — Playwright PAT helper uses Playwright for login, API for creation

Decision: dhis2w_browser.create_pat navigates to the DHIS2 login UI with Playwright (driven form fill + submit), then uses the authenticated browser context's page.request.post to hit /api/apiToken. It does not automate the PAT-creation UI.

Why: mixing UI flow (for auth cookie) with API flow (for PAT creation) is robust to future UI changes — the login selectors are stable across DHIS2 versions; the PAT UI isn't. Using the authenticated context's request client preserves cookies without us having to marshal them manually.

2026-04-17 — In-process FastMCP Client for MCP testing

Decision: MCP integration tests construct a FastMCP server via build_server(), hand it to fastmcp.Client(server), and call tools inside the same Python process. No subprocess, no stdio framing.

Why: FastMCP's Client accepts a FastMCP instance directly when you pass the server object. This gives real tool invocation through FastMCP's dispatch machinery without the cost or flakiness of spawning a subprocess. ~50ms per test instead of multi-second. The code path exercised is the same one an external agent would hit; we only skip transport serialization.

2026-04-17 — CLI tested via typer.testing.CliRunner, not subprocess

Decision: dhis2w-cli integration tests use CliRunner().invoke(build_app(), [...]) rather than subprocess.run(["uv", "run", "dhis2", ...]).

Why: subprocess invocation is ~2s per test (venv resolution overhead). CliRunner runs Typer dispatch in-process in ~5ms and covers everything that can actually break — command wiring, Typer argument parsing, async-run bridging, printed output. The console-script entry point itself is a one-liner we trust.

2026-04-17 — Shared profile_from_env() for CLI and MCP

Decision: every plugin service call reads the DHIS2 profile from environment variables via dhis2w_core.profile.profile_from_env(). CLI commands and MCP tools both call this at invocation time; no profile threading through arguments.

Why: keeps the two surfaces perfectly symmetric. The CLI user exports env vars in their shell; the MCP client configures env in its server spec. Neither surface has to invent its own profile flag. A future .dhis2/profiles.toml layer will be added inside profile_from_env() without either surface changing.

2026-04-17 — Tests auto-source seeded .env.auth

Decision: conftest files in dhis2w-client, dhis2w-cli, and dhis2w-mcp walk up from the test file to find infra/home/credentials/.env.auth and os.environ.setdefault(...) every line. Explicit env overrides win; the seeded file is a fallback.

Why: when the user runs make dhis2-run, we write the PATs into that file. The test suite picks them up automatically on the next make test-slow run — no manual source step. The setdefault means CI or an explicit env override still takes precedence.

2026-04-17 — mypy excludes conftest.py to allow per-member conftests

Decision: root pyproject.toml sets [tool.mypy] exclude = "^packages/[^/]+/tests/conftest\\.py$".

Why: two conftest.py files at sibling tests/ dirs both resolve to the module name conftest, which mypy rejects as duplicate. Excluding them from mypy is pragmatic — they're fixture-only, pytest still discovers them normally, and actual test functions stay fully typechecked.