Skip to content

Legend sets

LegendSetsAccessor on Dhis2Client.legend_sets — create / read / clone / delete LegendSets that visualizations and maps reference by UID to colour their data.

A DHIS2 LegendSet is an ordered list of Legend entries, each assigning a colour (#RRGGBB hex) + display name to a half-open numeric range [startValue, endValue). At render time DHIS2 buckets each cell by which legend its value falls into and paints it with that legend's colour. Visualizations reference a legend set via VisualizationSpec(legend_set=<uid>, ...); maps via MapSpec(legend_set=<uid>, ...) on the thematic layer.

LegendSetSpec + LegendSpec — the builder pattern

LegendSet and Legend are generated models — pydantic classes emitted from DHIS2's OpenAPI schema. They carry every field the API can return (roughly 20 on LegendSet, 18 on Legend) including DHIS2-maintained bookkeeping (created, lastUpdated, href, access, createdBy, favorites, translations). When authoring a new legend set, you only care about a handful of those: the name, optional code, the ordered legends, and per-legend (startValue, endValue, color, name). Populating the full model by hand for each new set is tedious and error-prone.

LegendSetSpec and LegendSpec are the authoring shapes — small frozen pydantic models whose fields are the tiny subset the caller actually supplies:

Spec field Generated equivalent Notes
LegendSetSpec.uid LegendSet.id Optional; build() auto-generates an 11-char UID if omitted.
LegendSetSpec.name LegendSet.name Required.
LegendSetSpec.code LegendSet.code Optional business code.
LegendSetSpec.legends LegendSet.legends[] Ordered list of LegendSpecs — one per colour range.
LegendSpec.start Legend.startValue Inclusive range start. Must be < end.
LegendSpec.end Legend.endValue Exclusive range end.
LegendSpec.color Legend.color Hex #RRGGBB or #RRGGBBAA.
LegendSpec.name Legend.name Optional; auto-named from the numeric range when omitted.

LegendSetSpec.build() materialises the spec into the full typed LegendSet with every child Legend inlined under legends[] (DHIS2's metadata importer requires full objects, not sibling references — see the "Why POST through /api/metadata" note below). The builder also generates stable per-legend UIDs, validates that end > start, and sets sensible defaults on the Legend fields the spec didn't specify.

This is the same pattern the workspace uses for every non-trivial authoring flow:

Spec Generated model Builder method
VisualizationSpec Visualization VisualizationsAccessor.create_from_spec
MapSpec + MapLayerSpec Map + MapView MapsAccessor.create_from_spec
LegendSetSpec + LegendSpec LegendSet + Legend LegendSetsAccessor.create_from_spec
OptionSpec Option OptionSetsAccessor.sync

Rule of thumb: a *Spec exists only where the accessor needs to transform caller intent into a wire shape the generated model can't represent directly. Three concrete reasons drive every spec in the workspace:

  • A typed enum on the spec fans out into many boolean flags on the wire — VisualizationSpec.relative_periods: frozenset[RelativePeriod] materialises into 45 individual booleans on Visualization.relativePeriods.
  • One field selects the population rule for others — VisualizationSpec.viz_type picks chart-type-aware rows / columns / filters defaults; MapLayerSpec.layer_kind decides whether dimension selectors are populated at all.
  • A parent's children need server-correct UIDs / defaults the caller shouldn't have to invent — LegendSetSpec.build() mints per-Legend UIDs so re-runs are well-formed.

Every other write accessor — organisation_units, data_elements, indicators, categories, category_options, category_combos, programs, program_stages, tracked_entity_attributes, validation_rules, predictors, data_sets, and the rest — ships without a spec. The generated model already mirrors what DHIS2 accepts on POST, so the accessor takes plain keyword args and posts them. Reach for a spec only when the accessor needs to do transformation work like the three above; reach for kwargs in every other case.

Why POST through /api/metadata

A direct PUT /api/legendSets/{uid} with a sibling legends collection is rejected by DHIS2's importer — the importer doesn't cross-link legends references back into the parent LegendSet, so every child needs to be inlined under legendSets[*].legends[] as a full object. LegendSetSpec.build() handles this by generating a LegendSet with inline Legend children already dumped; LegendSetsAccessor.create_from_spec POSTs the whole object atomically via /api/metadata?importStrategy=CREATE_AND_UPDATE&atomicMode=OBJECT.

Typed builder

from dhis2w_client import LegendSpec, LegendSetSpec

spec = LegendSetSpec(
    name="Dose coverage",
    code="DOSE_COVERAGE",
    legends=[
        LegendSpec(start=0, end=50, color="#d73027", name="Low"),
        LegendSpec(start=50, end=80, color="#fdae61", name="Medium"),
        LegendSpec(start=80, end=120, color="#1a9850", name="High"),
    ],
)
async with Dhis2Client(...) as client:
    legend_set = await client.legend_sets.create_from_spec(spec)

LegendSpec.end must be strictly greater than start — the builder rejects inverted / zero-width ranges. Each child Legend gets a fresh UID on build() so the spec is idempotent across re-runs only when the caller supplies a fixed uid on the spec itself; otherwise each run produces a new set.

Attach to a visualization

from dhis2w_client import VisualizationSpec
from dhis2w_client.generated.v42.enums import VisualizationType

viz_spec = VisualizationSpec(
    name="BCG doses 2024 monthly",
    viz_type=VisualizationType.COLUMN,
    data_elements=["s46m5MS0hxu"],
    periods=[f"2024{m:02d}" for m in range(1, 13)],
    organisation_units=["ImspTQPwCqd"],
    legend_set=legend_set.id,  # <- threshold colouring on render
)

The workspace seed (infra/scripts/seed/workspace_fixtures.py) ships LsDoseBand1 — four colour ranges tuned to 2024 monthly dose-count totals (red < 2k, amber 2–5k, yellow 5–10k, green 10k+) — and attaches it to uwtuVAnbt6E (Measles monthly) and D3oOqWAM0az (Penta-1 monthly) on the Immunization dashboard.

legend_sets

Legend set authoring helpers — Dhis2Client.legend_sets.

A DHIS2 LegendSet is an ordered list of Legend objects, each assigning a colour (hex string) + display name to a half-open numeric band [startValue, endValue). Visualizations and maps reference a legend set by UID; at render time DHIS2 buckets each data-value cell by band and paints it with the matching colour.

Hand-rolling this via POST /api/metadata is tedious — ten legends means ten Legend dicts with precomputed UIDs, correct sort order, and colour validation. LegendSetSpec + LegendSpec cover the common case with sensible defaults, generating stable UIDs for each legend so re-runs of the same spec are idempotent.

Why POST through /api/metadata

Same reason as Visualization and Map: a direct PUT /api/legendSets/{uid} with nested legends works for simple cases but doesn't round-trip the whole object faithfully (DHIS2 computes derived fields on the full-metadata path). Route creates + updates through POST /api/metadata?importStrategy=CREATE_AND_UPDATE so both the LegendSet + its child Legends import atomically.

Classes

Legend

Bases: BaseModel

Generated model for DHIS2 Legend.

DHIS2 Legend - DHIS2 resource (generated from /api/schemas at DHIS2 v42).

Field Field(description=...) entries flag DHIS2 semantics the bare type can't capture: which side of a relationship owns the link (writable) vs the inverse side (ignored by the API), uniqueness constraints, and length bounds.

Source code in packages/dhis2w-client/src/dhis2w_client/generated/v42/schemas/legend.py
class Legend(BaseModel):
    """Generated model for DHIS2 `Legend`.

    DHIS2 Legend - DHIS2 resource (generated from /api/schemas at DHIS2 v42).


    Field `Field(description=...)` entries flag DHIS2 semantics the bare
    type can't capture: which side of a relationship owns the link
    (writable) vs the inverse side (ignored by the API), uniqueness
    constraints, and length bounds.
    """

    model_config = ConfigDict(extra="allow", populate_by_name=True)

    access: Any | None = Field(default=None, description="Reference to Access. Read-only (inverse side).")
    attributeValues: Any | None = Field(
        default=None, description="Reference to AttributeValues. Read-only (inverse side)."
    )
    code: str | None = Field(default=None, description="Unique. Length/value max=50.")
    color: str | None = Field(default=None, description="Length/value max=255.")
    created: datetime | None = None
    createdBy: Reference | None = Field(default=None, description="Reference to User. Read-only (inverse side).")
    displayName: str | None = Field(default=None, description="Read-only.")
    endValue: float | None = None
    favorite: bool | None = Field(default=None, description="Read-only.")
    favorites: list[Any] | None = Field(default=None, description="Collection of String. Read-only (inverse side).")
    href: str | None = None
    id: str | None = Field(default=None, description="Unique. Length/value min=11, max=11.")
    image: str | None = Field(default=None, description="Length/value max=255.")
    lastUpdated: datetime | None = None
    lastUpdatedBy: Reference | None = Field(default=None, description="Reference to User.")
    name: str | None = Field(default=None, description="Length/value min=1, max=230.")
    sharing: Any | None = Field(default=None, description="Reference to Sharing. Read-only (inverse side).")
    startValue: float | None = None
    translations: list[Any] | None = Field(default=None, description="Collection of Translation. Length/value max=255.")
    user: Reference | None = Field(default=None, description="Reference to User. Read-only (inverse side).")

LegendSet

Bases: BaseModel

Generated model for DHIS2 LegendSet.

DHIS2 Legend Set - persisted metadata (generated from /api/schemas at DHIS2 v42).

API endpoint: /api/legendSets.

Field Field(description=...) entries flag DHIS2 semantics the bare type can't capture: which side of a relationship owns the link (writable) vs the inverse side (ignored by the API), uniqueness constraints, and length bounds.

Source code in packages/dhis2w-client/src/dhis2w_client/generated/v42/schemas/legend_set.py
class LegendSet(BaseModel):
    """Generated model for DHIS2 `LegendSet`.

    DHIS2 Legend Set - persisted metadata (generated from /api/schemas at DHIS2 v42).

    API endpoint: /api/legendSets.

    Field `Field(description=...)` entries flag DHIS2 semantics the bare
    type can't capture: which side of a relationship owns the link
    (writable) vs the inverse side (ignored by the API), uniqueness
    constraints, and length bounds.
    """

    model_config = ConfigDict(extra="allow", populate_by_name=True)

    access: Any | None = Field(default=None, description="Reference to Access. Read-only (inverse side).")
    attributeValues: Any | None = Field(default=None, description="Reference to AttributeValues. Length/value max=255.")
    code: str | None = Field(default=None, description="Unique. Length/value max=50.")
    created: datetime | None = None
    createdBy: Reference | None = Field(default=None, description="Reference to User.")
    displayName: str | None = Field(default=None, description="Read-only.")
    favorite: bool | None = Field(default=None, description="Read-only.")
    favorites: list[Any] | None = Field(default=None, description="Collection of String. Read-only (inverse side).")
    href: str | None = None
    id: str | None = Field(default=None, description="Unique. Length/value min=11, max=11.")
    lastUpdated: datetime | None = None
    lastUpdatedBy: Reference | None = Field(default=None, description="Reference to User.")
    legends: list[Any] | None = Field(default=None, description="Collection of Legend.")
    name: str | None = Field(default=None, description="Unique. Length/value min=1, max=230.")
    sharing: Any | None = Field(default=None, description="Reference to Sharing. Length/value max=255.")
    symbolizer: str | None = Field(default=None, description="Length/value max=255.")
    translations: list[Any] | None = Field(default=None, description="Collection of Translation. Length/value max=255.")
    user: Reference | None = Field(default=None, description="Reference to User. Read-only (inverse side).")

LegendSpec

Bases: BaseModel

Typed builder for one Legend inside a LegendSet — a [start, end) colour range.

start must be strictly less than end; the validator enforces this. color is a CSS-style hex string (#RRGGBB or #RRGGBBAA); anything else DHIS2 renders as the default greyscale. name defaults to the legend's numeric range when omitted, matching DHIS2's own auto-naming in the legend editor.

Source code in packages/dhis2w-client/src/dhis2w_client/legend_sets.py
class LegendSpec(BaseModel):
    """Typed builder for one `Legend` inside a `LegendSet` — a `[start, end)` colour range.

    `start` must be strictly less than `end`; the validator enforces
    this. `color` is a CSS-style hex string (`#RRGGBB` or `#RRGGBBAA`);
    anything else DHIS2 renders as the default greyscale. `name`
    defaults to the legend's numeric range when omitted, matching
    DHIS2's own auto-naming in the legend editor.
    """

    model_config = ConfigDict(frozen=True)

    start: float
    end: float
    color: str = Field(description="Hex colour — `#RRGGBB` or `#RRGGBBAA`.")
    name: str | None = None

    @field_validator("end")
    @classmethod
    def _end_must_exceed_start(cls, end: float, info: Any) -> float:
        """Reject legends where `end <= start` — they produce an empty interval."""
        start = info.data.get("start")
        if start is not None and end <= start:
            raise ValueError(f"legend end ({end}) must be strictly greater than start ({start})")
        return end

LegendSetSpec

Bases: BaseModel

Typed builder for a LegendSet — name + ordered list of Legend entries.

uid is optional; an 11-char UID is generated when omitted. Each child legend also gets a stable auto-generated UID so re-runs of the same spec (same uid, same legend order) upsert without creating duplicates.

Legends are expected in ascending order of start; they're kept as-given (no implicit sort) so a spec with overlapping or non-monotonic legends lands on DHIS2 verbatim and fails at render time — that's a user-authored mistake the builder doesn't mask.

Source code in packages/dhis2w-client/src/dhis2w_client/legend_sets.py
class LegendSetSpec(BaseModel):
    """Typed builder for a `LegendSet` — name + ordered list of `Legend` entries.

    `uid` is optional; an 11-char UID is generated when omitted. Each
    child legend also gets a stable auto-generated UID so re-runs of
    the same spec (same `uid`, same legend order) upsert without
    creating duplicates.

    Legends are expected in ascending order of `start`; they're kept
    as-given (no implicit sort) so a spec with overlapping or
    non-monotonic legends lands on DHIS2 verbatim and fails at render
    time — that's a user-authored mistake the builder doesn't mask.
    """

    model_config = ConfigDict(frozen=True)

    uid: str | None = None
    name: str
    code: str | None = None
    legends: list[LegendSpec]

    def build(self) -> LegendSet:
        """Materialise the spec into a typed `LegendSet` with inline `Legend` children.

        DHIS2's `/api/metadata` importer for LegendSets requires every
        Legend to be inlined under `legendSets[*].legends[]` as a full
        object with `startValue` / `endValue` / `name` / `color` —
        passing `legends` as a sibling collection of references is
        rejected with `E4000` ("Missing required property") because
        the server-side importer doesn't cross-link references back
        into the parent.
        """
        set_uid = self.uid or generate_uid()
        inline_legends: list[dict[str, Any]] = []
        for legend in self.legends:
            inline_legends.append(
                Legend(
                    id=generate_uid(),
                    name=legend.name or f"{legend.start:g}{legend.end:g}",
                    startValue=legend.start,
                    endValue=legend.end,
                    color=legend.color,
                ).model_dump(by_alias=True, exclude_none=True, mode="json"),
            )
        return LegendSet(
            id=set_uid,
            name=self.name,
            code=self.code,
            legends=inline_legends,
        )
Functions
build()

Materialise the spec into a typed LegendSet with inline Legend children.

DHIS2's /api/metadata importer for LegendSets requires every Legend to be inlined under legendSets[*].legends[] as a full object with startValue / endValue / name / color — passing legends as a sibling collection of references is rejected with E4000 ("Missing required property") because the server-side importer doesn't cross-link references back into the parent.

Source code in packages/dhis2w-client/src/dhis2w_client/legend_sets.py
def build(self) -> LegendSet:
    """Materialise the spec into a typed `LegendSet` with inline `Legend` children.

    DHIS2's `/api/metadata` importer for LegendSets requires every
    Legend to be inlined under `legendSets[*].legends[]` as a full
    object with `startValue` / `endValue` / `name` / `color` —
    passing `legends` as a sibling collection of references is
    rejected with `E4000` ("Missing required property") because
    the server-side importer doesn't cross-link references back
    into the parent.
    """
    set_uid = self.uid or generate_uid()
    inline_legends: list[dict[str, Any]] = []
    for legend in self.legends:
        inline_legends.append(
            Legend(
                id=generate_uid(),
                name=legend.name or f"{legend.start:g}{legend.end:g}",
                startValue=legend.start,
                endValue=legend.end,
                color=legend.color,
            ).model_dump(by_alias=True, exclude_none=True, mode="json"),
        )
    return LegendSet(
        id=set_uid,
        name=self.name,
        code=self.code,
        legends=inline_legends,
    )

LegendSetsAccessor

Dhis2Client.legend_sets — list / get / create / clone / delete legend sets.

Source code in packages/dhis2w-client/src/dhis2w_client/legend_sets.py
class LegendSetsAccessor:
    """`Dhis2Client.legend_sets` — list / get / create / clone / delete legend sets."""

    def __init__(self, client: Dhis2Client) -> None:
        """Bind to the sharing client."""
        self._client = client

    async def list_all(self) -> list[LegendSet]:
        """Return every LegendSet with its legends resolved inline."""
        raw = await self._client.get_raw(
            "/api/legendSets",
            params={"fields": _LEGEND_SET_FIELDS, "paging": "false"},
        )
        rows = raw.get("legendSets") or []
        return [LegendSet.model_validate(row) for row in rows if isinstance(row, dict)]

    async def get(self, uid: str) -> LegendSet:
        """Fetch one LegendSet by UID with its `legends` child list resolved inline."""
        raw = await self._client.get_raw(
            f"/api/legendSets/{uid}",
            params={"fields": _LEGEND_SET_FIELDS},
        )
        return LegendSet.model_validate(raw)

    async def create_from_spec(self, spec: LegendSetSpec) -> LegendSet:
        """Build + POST a LegendSet via `/api/metadata` with inline Legend children.

        Returns the freshly-fetched `LegendSet` so the caller sees
        DHIS2's computed fields (`href`, `displayName`, …) populated.
        """
        legend_set = spec.build()
        bundle = {
            "legendSets": [legend_set.model_dump(by_alias=True, exclude_none=True, mode="json")],
        }
        raw = await self._client.post_raw(
            "/api/metadata",
            body=bundle,
            params={"importStrategy": "CREATE_AND_UPDATE", "atomicMode": "OBJECT"},
        )
        envelope = WebMessageResponse.model_validate(raw)
        if envelope.status and envelope.status.upper() not in ("OK", "SUCCESS"):
            raise RuntimeError(
                f"legend-set import failed: status={envelope.status}  message={envelope.message!r}",
            )
        assert legend_set.id is not None
        return await self.get(legend_set.id)

    async def clone(
        self,
        source_uid: str,
        *,
        new_uid: str | None = None,
        new_name: str | None = None,
        new_code: str | None = None,
    ) -> LegendSet:
        """Duplicate an existing LegendSet — same legends, fresh UIDs on the set and each legend.

        Useful for forking a base set ("Coverage 0-100") into a variant
        with tweaked colours ("Coverage 0-100 monochrome") without
        rebuilding the legends by hand.
        """
        source = await self.get(source_uid)
        legends_list = source.legends or []
        legend_specs: list[LegendSpec] = []
        for legend in legends_list:
            if not isinstance(legend, dict):
                # Referenced-only legend (no inline payload) — skip rather than fail;
                # DHIS2's `/legendSets/{uid}?fields=legends[...]` always inlines on v42.
                continue
            start = legend.get("startValue")
            end = legend.get("endValue")
            color = legend.get("color")
            if start is None or end is None or not isinstance(color, str):
                continue
            legend_specs.append(
                LegendSpec(
                    start=float(start),
                    end=float(end),
                    color=color,
                    name=legend.get("name") if isinstance(legend.get("name"), str) else None,
                ),
            )
        spec = LegendSetSpec(
            uid=new_uid,
            name=new_name or f"{source.name or 'LegendSet'} (clone)",
            code=new_code,
            legends=legend_specs,
        )
        return await self.create_from_spec(spec)

    async def delete(self, uid: str) -> None:
        """Delete a LegendSet — `DELETE /api/legendSets/{uid}`."""
        if not uid:
            raise ValueError("delete requires a non-empty uid")
        await self._client.resources.legend_sets.delete(uid)
Functions
__init__(client)

Bind to the sharing client.

Source code in packages/dhis2w-client/src/dhis2w_client/legend_sets.py
def __init__(self, client: Dhis2Client) -> None:
    """Bind to the sharing client."""
    self._client = client
list_all() async

Return every LegendSet with its legends resolved inline.

Source code in packages/dhis2w-client/src/dhis2w_client/legend_sets.py
async def list_all(self) -> list[LegendSet]:
    """Return every LegendSet with its legends resolved inline."""
    raw = await self._client.get_raw(
        "/api/legendSets",
        params={"fields": _LEGEND_SET_FIELDS, "paging": "false"},
    )
    rows = raw.get("legendSets") or []
    return [LegendSet.model_validate(row) for row in rows if isinstance(row, dict)]
get(uid) async

Fetch one LegendSet by UID with its legends child list resolved inline.

Source code in packages/dhis2w-client/src/dhis2w_client/legend_sets.py
async def get(self, uid: str) -> LegendSet:
    """Fetch one LegendSet by UID with its `legends` child list resolved inline."""
    raw = await self._client.get_raw(
        f"/api/legendSets/{uid}",
        params={"fields": _LEGEND_SET_FIELDS},
    )
    return LegendSet.model_validate(raw)
create_from_spec(spec) async

Build + POST a LegendSet via /api/metadata with inline Legend children.

Returns the freshly-fetched LegendSet so the caller sees DHIS2's computed fields (href, displayName, …) populated.

Source code in packages/dhis2w-client/src/dhis2w_client/legend_sets.py
async def create_from_spec(self, spec: LegendSetSpec) -> LegendSet:
    """Build + POST a LegendSet via `/api/metadata` with inline Legend children.

    Returns the freshly-fetched `LegendSet` so the caller sees
    DHIS2's computed fields (`href`, `displayName`, …) populated.
    """
    legend_set = spec.build()
    bundle = {
        "legendSets": [legend_set.model_dump(by_alias=True, exclude_none=True, mode="json")],
    }
    raw = await self._client.post_raw(
        "/api/metadata",
        body=bundle,
        params={"importStrategy": "CREATE_AND_UPDATE", "atomicMode": "OBJECT"},
    )
    envelope = WebMessageResponse.model_validate(raw)
    if envelope.status and envelope.status.upper() not in ("OK", "SUCCESS"):
        raise RuntimeError(
            f"legend-set import failed: status={envelope.status}  message={envelope.message!r}",
        )
    assert legend_set.id is not None
    return await self.get(legend_set.id)
clone(source_uid, *, new_uid=None, new_name=None, new_code=None) async

Duplicate an existing LegendSet — same legends, fresh UIDs on the set and each legend.

Useful for forking a base set ("Coverage 0-100") into a variant with tweaked colours ("Coverage 0-100 monochrome") without rebuilding the legends by hand.

Source code in packages/dhis2w-client/src/dhis2w_client/legend_sets.py
async def clone(
    self,
    source_uid: str,
    *,
    new_uid: str | None = None,
    new_name: str | None = None,
    new_code: str | None = None,
) -> LegendSet:
    """Duplicate an existing LegendSet — same legends, fresh UIDs on the set and each legend.

    Useful for forking a base set ("Coverage 0-100") into a variant
    with tweaked colours ("Coverage 0-100 monochrome") without
    rebuilding the legends by hand.
    """
    source = await self.get(source_uid)
    legends_list = source.legends or []
    legend_specs: list[LegendSpec] = []
    for legend in legends_list:
        if not isinstance(legend, dict):
            # Referenced-only legend (no inline payload) — skip rather than fail;
            # DHIS2's `/legendSets/{uid}?fields=legends[...]` always inlines on v42.
            continue
        start = legend.get("startValue")
        end = legend.get("endValue")
        color = legend.get("color")
        if start is None or end is None or not isinstance(color, str):
            continue
        legend_specs.append(
            LegendSpec(
                start=float(start),
                end=float(end),
                color=color,
                name=legend.get("name") if isinstance(legend.get("name"), str) else None,
            ),
        )
    spec = LegendSetSpec(
        uid=new_uid,
        name=new_name or f"{source.name or 'LegendSet'} (clone)",
        code=new_code,
        legends=legend_specs,
    )
    return await self.create_from_spec(spec)
delete(uid) async

Delete a LegendSet — DELETE /api/legendSets/{uid}.

Source code in packages/dhis2w-client/src/dhis2w_client/legend_sets.py
async def delete(self, uid: str) -> None:
    """Delete a LegendSet — `DELETE /api/legendSets/{uid}`."""
    if not uid:
        raise ValueError("delete requires a non-empty uid")
    await self._client.resources.legend_sets.delete(uid)

Functions