Skip to content

Category combos

client.category_combos — CRUD over /api/categoryCombos. A CategoryCombo is the actual disaggregation attached to a DataElement (or DataSet); it's defined as a cross-product of Category records. The server materialises the cross-product as a matrix of CategoryOptionCombo rows.

async with Dhis2Client(...) as client:
    cc = await client.category_combos.create(
        name="Sex x Age",
        category_uids=["sexCat0001U", "ageCat0001U"],
    )

v43 caveat — manual COC matrix regeneration

On DHIS2 v43, saving a CategoryCombo no longer triggers automatic regeneration of the CategoryOptionCombo matrix (BUGS.md #33). The accessor exposes wait_for_coc_generation(uid, expected_count, ...) which fires POST /api/maintenance/categoryOptionComboUpdate once + polls until the matrix lands.

cc = await client.category_combos.create(name="Sex x Age", category_uids=[...])
# v43: kick the maintenance trigger; on v42 / v41 this is a no-op.
await client.category_combos.wait_for_coc_generation(cc.id, expected_count=4)

Worked example: examples/v43/client/category_combo_coc_regen.py.

For the higher-level "build everything in one call" helper see Category combo builder.

category_combos

CategoryCombo authoring — Dhis2Client.category_combos.

A CategoryCombo is the disaggregation grid: an ordered list of Categorys whose cross-product of options materialises (server-side) as the CategoryOptionCombo set that data values key on. Aggregate data elements + data sets reference a CategoryCombo to declare what their disaggregation looks like.

This module covers the CategoryCombo layer. The Category leaf ships in dhis2w_client.categories; the auto-generated CategoryOptionCombo matrix is exposed read-only in dhis2w_client.category_option_combos.

Server-side matrix generation: when a CategoryCombo is created or its categories list changes, DHIS2 regenerates the CategoryOptionCombo set in the background. The wait_for_coc_generation helper polls /api/categoryCombos/{uid}/categoryOptionCombos until the expected count lands — cold-start regen on a large combo can take tens of seconds, especially under arm64 emulation of the linux/amd64 image.

Note on the wire field name: /api/schemas/categoryCombo reports fieldName='categories' (correct English) on both v42 and v43. v42 also accepted the misspelled alias categorys, but v43 dropped the alias and now silently ignores unknown fields, so writes need the correct spelling. The generated CategoryCombo model uses categories, and this accessor's payloads + field selectors do too.

Classes

CategoryCombo

Bases: BaseModel

Generated model for DHIS2 CategoryCombo.

DHIS2 Category Combo - persisted metadata (generated from /api/schemas at DHIS2 v42).

API endpoint: /api/categoryCombos.

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/category_combo.py
class CategoryCombo(BaseModel):
    """Generated model for DHIS2 `CategoryCombo`.

    DHIS2 Category Combo - persisted metadata (generated from /api/schemas at DHIS2 v42).

    API endpoint: /api/categoryCombos.

    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)."
    )
    categories: list[Any] | None = Field(default=None, description="Collection of Category.")
    categoryOptionCombos: list[Any] | None = Field(
        default=None, description="Collection of CategoryOptionCombo. Read-only (inverse side)."
    )
    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.")
    dataDimensionType: DataDimensionType | None = None
    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.")
    isDefault: bool | None = Field(default=None, description="Read-only.")
    lastUpdated: datetime | None = None
    lastUpdatedBy: Reference | None = Field(default=None, description="Reference to User.")
    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.")
    skipTotal: bool | 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).")

CategoryCombosAccessor

Dhis2Client.category_combos — CRUD + ordered category membership + matrix-poll helper.

Source code in packages/dhis2w-client/src/dhis2w_client/v42/category_combos.py
class CategoryCombosAccessor:
    """`Dhis2Client.category_combos` — CRUD + ordered category membership + matrix-poll helper."""

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

    async def list_all(
        self,
        *,
        page: int = 1,
        page_size: int = 50,
    ) -> list[CategoryCombo]:
        """Page through CategoryCombos with categories + COCs resolved inline."""
        raw = await self._client.get_raw(
            "/api/categoryCombos",
            params={
                "fields": _CATEGORY_COMBO_FIELDS,
                "page": str(page),
                "pageSize": str(page_size),
            },
        )
        return parse_collection(raw, "categoryCombos", CategoryCombo)

    async def get(self, uid: str) -> CategoryCombo:
        """Fetch one CategoryCombo by UID."""
        return await self._client.get(
            f"/api/categoryCombos/{uid}", model=CategoryCombo, params={"fields": _CATEGORY_COMBO_FIELDS}
        )

    async def create(
        self,
        *,
        name: str,
        categories: list[str],
        code: str | None = None,
        data_dimension_type: str = "DISAGGREGATION",
        skip_total: bool = False,
        uid: str | None = None,
    ) -> CategoryCombo:
        """Create a CategoryCombo with an ordered list of Category UIDs.

        `data_dimension_type` is `DISAGGREGATION` (the default — combos
        that participate in the data-value matrix) or `ATTRIBUTE` (combos
        used for attribute-option-combo metadata). `skip_total=True`
        omits the "total" CategoryOptionCombo aggregation row in
        downstream tables.

        DHIS2 regenerates the `CategoryOptionCombo` matrix server-side
        on save — call `wait_for_coc_generation(uid, expected_count)`
        after a large combo create to block until the matrix lands.
        """
        if not categories:
            raise ValueError("CategoryCombo requires at least one category UID")
        payload: dict[str, Any] = {
            "name": name,
            "dataDimensionType": data_dimension_type,
            "skipTotal": skip_total,
            "categories": [{"id": cat_uid} for cat_uid in categories],
        }
        if code:
            payload["code"] = code
        if uid:
            payload["id"] = uid
        envelope = await self._client.post("/api/categoryCombos", payload, model=WebMessageResponse)
        created_uid = envelope.created_uid or uid
        if not created_uid:
            raise RuntimeError("category-combo create did not return a uid")
        return await self.get(created_uid)

    async def update(self, combo: CategoryCombo) -> CategoryCombo:
        """PUT an edited CategoryCombo back. `combo.id` must be set."""
        if not combo.id:
            raise ValueError("update requires combo.id to be set")
        body = combo.model_dump(by_alias=True, exclude_none=True, mode="json")
        await self._client.put_raw(f"/api/categoryCombos/{combo.id}", body=body)
        return await self.get(combo.id)

    async def rename(
        self,
        uid: str,
        *,
        name: str | None = None,
        code: str | None = None,
    ) -> CategoryCombo:
        """Partial-update the label fields — read, mutate, PUT."""
        if name is None and code is None:
            raise ValueError("rename requires at least one of name / code")
        current = await self.get(uid)
        if name is not None:
            current.name = name
        if code is not None:
            current.code = code
        return await self.update(current)

    async def add_category(self, uid: str, category_uid: str) -> None:
        """Append a Category to this combo's ordered membership.

        DHIS2 preserves insertion order on the `categories` array — the
        order drives the CategoryOptionCombo matrix shape. Re-ordering
        requires a full PUT via `update(combo)` with the desired list.
        """
        await self._client.resources.category_combos.add_collection_item(uid, "categories", category_uid)

    async def remove_category(self, uid: str, category_uid: str) -> None:
        """Remove a Category from this combo's membership."""
        await self._client.resources.category_combos.remove_collection_item(uid, "categories", category_uid)

    async def wait_for_coc_generation(
        self,
        uid: str,
        *,
        expected_count: int,
        timeout_seconds: float = 60.0,
        poll_interval_seconds: float = 1.0,
    ) -> int:
        """Block until the CategoryOptionCombo matrix reaches `expected_count`.

        v42 regenerates the COC matrix automatically on every
        CategoryCombo save — this method just polls until the count lands.
        The v43 sibling at `dhis2w_client.v43.category_combos` fires
        `POST /api/maintenance/categoryOptionComboUpdate` once before
        polling because v43 stopped auto-regenerating (BUGS.md #33).

        Polling reads `client.category_option_combos.list_for_combo(uid)`
        until the count matches `expected_count`. On cold-start or under
        arm64 emulation a large combo's regen can still take tens of
        seconds.

        Raises `TimeoutError` when `timeout_seconds` elapses without
        reaching the expected count. Returns the final count.
        """
        deadline = asyncio.get_running_loop().time() + timeout_seconds
        while True:
            current = len(await self._client.category_option_combos.list_for_combo(uid))
            if current >= expected_count:
                return current
            if asyncio.get_running_loop().time() >= deadline:
                raise TimeoutError(
                    f"category-combo {uid}: expected {expected_count} categoryOptionCombos, "
                    f"have {current} after {timeout_seconds:.0f}s",
                )
            await asyncio.sleep(poll_interval_seconds)

    async def delete(self, uid: str) -> None:
        """Delete a CategoryCombo — DHIS2 rejects the default combo + combos in use."""
        if not uid:
            raise ValueError("delete requires a non-empty uid")
        await self._client.resources.category_combos.delete(uid)
Functions
__init__(client)

Bind to the sharing client.

Source code in packages/dhis2w-client/src/dhis2w_client/v42/category_combos.py
def __init__(self, client: Dhis2Client) -> None:
    """Bind to the sharing client."""
    self._client = client
list_all(*, page=1, page_size=50) async

Page through CategoryCombos with categories + COCs resolved inline.

Source code in packages/dhis2w-client/src/dhis2w_client/v42/category_combos.py
async def list_all(
    self,
    *,
    page: int = 1,
    page_size: int = 50,
) -> list[CategoryCombo]:
    """Page through CategoryCombos with categories + COCs resolved inline."""
    raw = await self._client.get_raw(
        "/api/categoryCombos",
        params={
            "fields": _CATEGORY_COMBO_FIELDS,
            "page": str(page),
            "pageSize": str(page_size),
        },
    )
    return parse_collection(raw, "categoryCombos", CategoryCombo)
get(uid) async

Fetch one CategoryCombo by UID.

Source code in packages/dhis2w-client/src/dhis2w_client/v42/category_combos.py
async def get(self, uid: str) -> CategoryCombo:
    """Fetch one CategoryCombo by UID."""
    return await self._client.get(
        f"/api/categoryCombos/{uid}", model=CategoryCombo, params={"fields": _CATEGORY_COMBO_FIELDS}
    )
create(*, name, categories, code=None, data_dimension_type='DISAGGREGATION', skip_total=False, uid=None) async

Create a CategoryCombo with an ordered list of Category UIDs.

data_dimension_type is DISAGGREGATION (the default — combos that participate in the data-value matrix) or ATTRIBUTE (combos used for attribute-option-combo metadata). skip_total=True omits the "total" CategoryOptionCombo aggregation row in downstream tables.

DHIS2 regenerates the CategoryOptionCombo matrix server-side on save — call wait_for_coc_generation(uid, expected_count) after a large combo create to block until the matrix lands.

Source code in packages/dhis2w-client/src/dhis2w_client/v42/category_combos.py
async def create(
    self,
    *,
    name: str,
    categories: list[str],
    code: str | None = None,
    data_dimension_type: str = "DISAGGREGATION",
    skip_total: bool = False,
    uid: str | None = None,
) -> CategoryCombo:
    """Create a CategoryCombo with an ordered list of Category UIDs.

    `data_dimension_type` is `DISAGGREGATION` (the default — combos
    that participate in the data-value matrix) or `ATTRIBUTE` (combos
    used for attribute-option-combo metadata). `skip_total=True`
    omits the "total" CategoryOptionCombo aggregation row in
    downstream tables.

    DHIS2 regenerates the `CategoryOptionCombo` matrix server-side
    on save — call `wait_for_coc_generation(uid, expected_count)`
    after a large combo create to block until the matrix lands.
    """
    if not categories:
        raise ValueError("CategoryCombo requires at least one category UID")
    payload: dict[str, Any] = {
        "name": name,
        "dataDimensionType": data_dimension_type,
        "skipTotal": skip_total,
        "categories": [{"id": cat_uid} for cat_uid in categories],
    }
    if code:
        payload["code"] = code
    if uid:
        payload["id"] = uid
    envelope = await self._client.post("/api/categoryCombos", payload, model=WebMessageResponse)
    created_uid = envelope.created_uid or uid
    if not created_uid:
        raise RuntimeError("category-combo create did not return a uid")
    return await self.get(created_uid)
update(combo) async

PUT an edited CategoryCombo back. combo.id must be set.

Source code in packages/dhis2w-client/src/dhis2w_client/v42/category_combos.py
async def update(self, combo: CategoryCombo) -> CategoryCombo:
    """PUT an edited CategoryCombo back. `combo.id` must be set."""
    if not combo.id:
        raise ValueError("update requires combo.id to be set")
    body = combo.model_dump(by_alias=True, exclude_none=True, mode="json")
    await self._client.put_raw(f"/api/categoryCombos/{combo.id}", body=body)
    return await self.get(combo.id)
rename(uid, *, name=None, code=None) async

Partial-update the label fields — read, mutate, PUT.

Source code in packages/dhis2w-client/src/dhis2w_client/v42/category_combos.py
async def rename(
    self,
    uid: str,
    *,
    name: str | None = None,
    code: str | None = None,
) -> CategoryCombo:
    """Partial-update the label fields — read, mutate, PUT."""
    if name is None and code is None:
        raise ValueError("rename requires at least one of name / code")
    current = await self.get(uid)
    if name is not None:
        current.name = name
    if code is not None:
        current.code = code
    return await self.update(current)
add_category(uid, category_uid) async

Append a Category to this combo's ordered membership.

DHIS2 preserves insertion order on the categories array — the order drives the CategoryOptionCombo matrix shape. Re-ordering requires a full PUT via update(combo) with the desired list.

Source code in packages/dhis2w-client/src/dhis2w_client/v42/category_combos.py
async def add_category(self, uid: str, category_uid: str) -> None:
    """Append a Category to this combo's ordered membership.

    DHIS2 preserves insertion order on the `categories` array — the
    order drives the CategoryOptionCombo matrix shape. Re-ordering
    requires a full PUT via `update(combo)` with the desired list.
    """
    await self._client.resources.category_combos.add_collection_item(uid, "categories", category_uid)
remove_category(uid, category_uid) async

Remove a Category from this combo's membership.

Source code in packages/dhis2w-client/src/dhis2w_client/v42/category_combos.py
async def remove_category(self, uid: str, category_uid: str) -> None:
    """Remove a Category from this combo's membership."""
    await self._client.resources.category_combos.remove_collection_item(uid, "categories", category_uid)
wait_for_coc_generation(uid, *, expected_count, timeout_seconds=60.0, poll_interval_seconds=1.0) async

Block until the CategoryOptionCombo matrix reaches expected_count.

v42 regenerates the COC matrix automatically on every CategoryCombo save — this method just polls until the count lands. The v43 sibling at dhis2w_client.v43.category_combos fires POST /api/maintenance/categoryOptionComboUpdate once before polling because v43 stopped auto-regenerating (BUGS.md #33).

Polling reads client.category_option_combos.list_for_combo(uid) until the count matches expected_count. On cold-start or under arm64 emulation a large combo's regen can still take tens of seconds.

Raises TimeoutError when timeout_seconds elapses without reaching the expected count. Returns the final count.

Source code in packages/dhis2w-client/src/dhis2w_client/v42/category_combos.py
async def wait_for_coc_generation(
    self,
    uid: str,
    *,
    expected_count: int,
    timeout_seconds: float = 60.0,
    poll_interval_seconds: float = 1.0,
) -> int:
    """Block until the CategoryOptionCombo matrix reaches `expected_count`.

    v42 regenerates the COC matrix automatically on every
    CategoryCombo save — this method just polls until the count lands.
    The v43 sibling at `dhis2w_client.v43.category_combos` fires
    `POST /api/maintenance/categoryOptionComboUpdate` once before
    polling because v43 stopped auto-regenerating (BUGS.md #33).

    Polling reads `client.category_option_combos.list_for_combo(uid)`
    until the count matches `expected_count`. On cold-start or under
    arm64 emulation a large combo's regen can still take tens of
    seconds.

    Raises `TimeoutError` when `timeout_seconds` elapses without
    reaching the expected count. Returns the final count.
    """
    deadline = asyncio.get_running_loop().time() + timeout_seconds
    while True:
        current = len(await self._client.category_option_combos.list_for_combo(uid))
        if current >= expected_count:
            return current
        if asyncio.get_running_loop().time() >= deadline:
            raise TimeoutError(
                f"category-combo {uid}: expected {expected_count} categoryOptionCombos, "
                f"have {current} after {timeout_seconds:.0f}s",
            )
        await asyncio.sleep(poll_interval_seconds)
delete(uid) async

Delete a CategoryCombo — DHIS2 rejects the default combo + combos in use.

Source code in packages/dhis2w-client/src/dhis2w_client/v42/category_combos.py
async def delete(self, uid: str) -> None:
    """Delete a CategoryCombo — DHIS2 rejects the default combo + combos in use."""
    if not uid:
        raise ValueError("delete requires a non-empty uid")
    await self._client.resources.category_combos.delete(uid)

Functions