Workspace layout¶
The repo is a uv workspace with a virtual root (the root pyproject.toml has no [project], only [tool.uv.workspace]). Every shippable unit of code is its own member under packages/.
Why a workspace instead of one package¶
Three reasons:
dhis2w-clienthas to be publishable on its own. A single-package layout would force PyPI users of the client to pull in Typer, FastMCP, Playwright — none of which they need. A workspace lets us ship the client lean.- CLI and MCP shouldn't be the same install. A server running
dhis2w-mcpin a Docker image doesn't need the CLI's Typer tree. A developer runningd2wlocally doesn't need the MCP stdio loop. The MCP surface is thedhis2w-mcpplugin pack, in a repository of its own, and builds ondhis2w-corelike the CLI does. - New surfaces land cleanly. A new surface is a new member or a plugin pack in a repository of its own, not a conditional import inside an existing package. The FHIR Implementation Guide tooling is the worked example: the
dhis2w-fhirpack splits the generator from the FastAPI facade behindd2w fhir serve, so an install that only generates stays free of FastAPI and uvicorn, and the CLI reaches either through an optional extra.
Layout¶
dhis2w/
├── pyproject.toml # virtual workspace root + shared tool config
├── uv.lock # single workspace-wide lock
├── Makefile # drives install/lint/test/docs/build/publish
├── mkdocs.yml # docs config (claude theme, left-side nav)
├── CLAUDE.md # non-negotiable project rules
├── docs/ # this site's source
├── site/ # mkdocs output (gitignored)
├── examples/
└── packages/
├── dhis2w-client/ # httpx2 + pydantic lib + Profile + open_client (PAT/Basic/session) (PyPI)
├── dhis2w-core/ # TOML profile resolution + OAuth2 token store + plugin runtime + plugins (PyPI)
├── dhis2w-cli/ # Typer console script `d2w` (PyPI)
└── dhis2w-codegen/ # generator — registers `d2w dev codegen` subcommand (workspace-only)
The plugin packs live in repositories of their own: dhis2w-mcp (the dhis2w-mcp server, dhis2w-mcp-bridge, and dhis2w-mcp-router), dhis2w-fhir (dhis2w-fhir, dhis2w-fhir-serve, and dhis2w-fhir-engine), dhis2w-browser, and dhis2w-security.
Configuration split¶
All lint/type/test tooling (ruff, mypy, pyright, pytest, coverage) is configured once at the workspace root. Members inherit these settings automatically — no per-member ruff.toml or duplicated mypy stanzas.
Each member's pyproject.toml has just:
[project]— name, version, description, Python floor, dependencies[project.scripts]— console entrypoints (d2wfromdhis2w-cli, for example)[project.entry-points."dhis2w.plugins.v1"]— plugin registration (what each plugin pack declares)[build-system]—uv_buildbackend
Build + publish¶
make build produces wheels for all members. PyPI publishing is automated — tag a vX.Y.Z and .github/workflows/pypi-publish.yml builds + uploads every publishable member via PyPI Trusted Publishing (OIDC). Three members ship: dhis2w-client, dhis2w-core, and dhis2w-cli. The plugin packs publish their own packages from their own repositories, at the same version, after this workspace. One stays workspace-only: dhis2w-codegen, a developer tool that emits committed code into dhis2w-client's tree. See Releasing to PyPI for the full bump-and-tag flow.
Open questions¶
- Docs per-member or one site? Currently one site. If per-member doc surfaces grow significantly, we may split to one mkdocs config per member stitched together, but starting unified is simpler.