Skip to content

Category options

Three accessors on Dhis2Client for the CategoryOption triple — the last of the four analytics-authoring triples:

Accessor API path Purpose
client.category_options /api/categoryOptions Disaggregation values (sex, age band, ownership, …). CRUD + rename + validity-window helper.
client.category_option_groups /api/categoryOptionGroups Thematic groupings of options. Per-item membership add/remove.
client.category_option_group_sets /api/categoryOptionGroupSets Analytics dimensions collecting option groups.

Scope

This triple covers the CategoryOption layer of DHIS2's disaggregation model. The surrounding CategoryCategoryComboCategoryOptionCombo authoring remains a strategic option on roadmap.md — those resources have tangled cross-linkage plus async regeneration of the CoC matrix on save, so they deserve their own PR rather than piggybacking on the triples pattern.

No *Spec builder

Same design call as every other triple: keyword args on the accessor.

Validity window

CategoryOption is the only one of the four triples with a startDate / endDate bound. DHIS2 rejects data-value entry against options whose window doesn't cover the period being written. The accessor exposes a dedicated helper so callers can narrow / widen the window without reaching for update(option):

async with Dhis2Client(...) as client:
    co = await client.category_options.create(
        name="Calendar Year 2024",
        short_name="CY2024",
        start_date="2024-01-01",
        end_date="2024-12-31",
    )

    # Narrow to H1 later without reconstructing the model.
    co = await client.category_options.set_validity_window(
        co.id,
        start_date="2024-01-01",
        end_date="2024-06-30",
    )

Pass None on either side of set_validity_window to clear that bound — DHIS2 treats an unset window as "always valid".

CLI

dhis2 metadata category-options list
dhis2 metadata category-options create \
    --name "CY2024" --short-name "CY2024" \
    --start-date 2024-01-01 --end-date 2024-12-31
dhis2 metadata category-options set-validity <CO_UID> --start-date 2024-01-01 --end-date 2024-06-30
dhis2 metadata category-option-groups create --name "Calendar years" --short-name "Years"
dhis2 metadata category-option-groups add-members <GROUP_UID> --category-option <CO_UID>
dhis2 metadata category-option-group-sets create --name "Reporting calendar" --short-name "Cal"
dhis2 metadata category-option-group-sets add-groups <SET_UID> --group <GROUP_UID>

Every list has an ls alias; every destructive verb accepts --yes / -y.

MCP

18 tools mirroring the CLI surface.

category_options

CategoryOption authoring — Dhis2Client.category_options.

CategoryOptions are the values of a Category — e.g. Male / Female for a Sex category, <1y / 1-4y / 5-14y / 15+ for an Age group category. DHIS2 combines categories into a CategoryCombo (the disaggregation grid), and the cross-product of category options in a combo becomes the CategoryOptionCombo set that data values key on.

This module covers the CategoryOption layer of that chain; the Category / CategoryCombo / CategoryOptionCombo plumbing stays a strategic option on the roadmap — the shallower triples in this PR are independently useful without committing to the whole disaggregation authoring surface.

Generic CRUD stays on the generated accessor (client.resources.category_options); this module adds keyword-arg creation + partial rename + a validity-window helper for startDate / endDate (DHIS2 allows a date range that bounds when the option is usable for data entry).

Classes

CategoryOption

Bases: BaseModel

Generated model for DHIS2 CategoryOption.

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

API endpoint: /api/categoryOptions.

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

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

    API endpoint: /api/categoryOptions.

    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).")
    aggregationType: AggregationType | None = None
    attributeValues: Any | None = Field(default=None, description="Reference to AttributeValues. Length/value max=255.")
    categories: list[Any] | None = Field(default=None, description="Collection of Category. Read-only (inverse side).")
    categoryOptionCombos: list[Any] | None = Field(
        default=None, description="Collection of CategoryOptionCombo. Read-only (inverse side)."
    )
    categoryOptionGroups: list[Any] | None = Field(
        default=None, description="Collection of CategoryOptionGroup. 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.")
    description: str | None = Field(default=None, description="Length/value min=1, max=2147483647.")
    dimensionItem: str | None = Field(default=None, description="Read-only.")
    dimensionItemType: DimensionItemType | None = None
    displayDescription: str | None = Field(default=None, description="Read-only.")
    displayFormName: str | None = Field(default=None, description="Read-only.")
    displayName: str | None = Field(default=None, description="Read-only.")
    displayShortName: str | None = Field(default=None, description="Read-only.")
    endDate: datetime | 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).")
    formName: str | None = Field(default=None, description="Length/value min=2, max=230.")
    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.")
    legendSet: Reference | None = Field(default=None, description="Reference to LegendSet. Read-only (inverse side).")
    legendSets: list[Any] | None = Field(default=None, description="Collection of LegendSet. Read-only (inverse side).")
    name: str | None = Field(default=None, description="Unique. Length/value min=1, max=230.")
    organisationUnits: list[Any] | None = Field(default=None, description="Collection of OrganisationUnit.")
    queryMods: Any | None = Field(default=None, description="Reference to QueryModifiers. Read-only (inverse side).")
    sharing: Any | None = Field(default=None, description="Reference to Sharing. Length/value max=255.")
    shortName: str | None = Field(default=None, description="Unique. Length/value min=1, max=50.")
    startDate: datetime | None = None
    style: Any | None = Field(default=None, description="Reference to ObjectStyle. 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).")

CategoryOptionsAccessor

Dhis2Client.category_options — CRUD + rename + validity-window helpers.

Source code in packages/dhis2w-client/src/dhis2w_client/category_options.py
class CategoryOptionsAccessor:
    """`Dhis2Client.category_options` — CRUD + rename + validity-window helpers."""

    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[CategoryOption]:
        """Page through CategoryOptions with references resolved inline."""
        raw = await self._client.get_raw(
            "/api/categoryOptions",
            params={
                "fields": _CO_FIELDS,
                "page": str(page),
                "pageSize": str(page_size),
            },
        )
        rows = raw.get("categoryOptions") or []
        return [CategoryOption.model_validate(row) for row in rows if isinstance(row, dict)]

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

    async def create(
        self,
        *,
        name: str,
        short_name: str,
        code: str | None = None,
        description: str | None = None,
        form_name: str | None = None,
        start_date: datetime | str | None = None,
        end_date: datetime | str | None = None,
        uid: str | None = None,
    ) -> CategoryOption:
        """Create a CategoryOption.

        `start_date` / `end_date` bound the validity window: DHIS2 rejects
        data-value entry against options whose window doesn't cover the
        period. Pass ISO-8601 strings (`"2024-01-01"`) or `datetime`
        instances; omit for an always-valid option.
        """
        payload: dict[str, Any] = {"name": name, "shortName": short_name}
        if code:
            payload["code"] = code
        if description:
            payload["description"] = description
        if form_name:
            payload["formName"] = form_name
        if start_date is not None:
            payload["startDate"] = _serialise_date(start_date)
        if end_date is not None:
            payload["endDate"] = _serialise_date(end_date)
        if uid:
            payload["id"] = uid
        envelope = await self._client.post("/api/categoryOptions", payload, model=WebMessageResponse)
        created_uid = envelope.created_uid or uid
        if not created_uid:
            raise RuntimeError("category-option create did not return a uid")
        return await self.get(created_uid)

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

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

    async def set_validity_window(
        self,
        uid: str,
        *,
        start_date: datetime | str | None,
        end_date: datetime | str | None,
    ) -> CategoryOption:
        """Set the `startDate` / `endDate` validity window on a CategoryOption.

        Pass `None` for either side to clear that bound. DHIS2 treats an
        unset window as "always valid"; a set window rejects data-value
        entry for periods outside it.
        """
        current = await self.get(uid)
        current.startDate = _to_datetime(start_date)
        current.endDate = _to_datetime(end_date)
        return await self.update(current)

    async def delete(self, uid: str) -> None:
        """Delete a CategoryOption — DHIS2 rejects deletes on options in use."""
        if not uid:
            raise ValueError("delete requires a non-empty uid")
        await self._client.resources.category_options.delete(uid)
Functions
__init__(client)

Bind to the sharing client.

Source code in packages/dhis2w-client/src/dhis2w_client/category_options.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 CategoryOptions with references resolved inline.

Source code in packages/dhis2w-client/src/dhis2w_client/category_options.py
async def list_all(
    self,
    *,
    page: int = 1,
    page_size: int = 50,
) -> list[CategoryOption]:
    """Page through CategoryOptions with references resolved inline."""
    raw = await self._client.get_raw(
        "/api/categoryOptions",
        params={
            "fields": _CO_FIELDS,
            "page": str(page),
            "pageSize": str(page_size),
        },
    )
    rows = raw.get("categoryOptions") or []
    return [CategoryOption.model_validate(row) for row in rows if isinstance(row, dict)]
get(uid) async

Fetch one CategoryOption by UID.

Source code in packages/dhis2w-client/src/dhis2w_client/category_options.py
async def get(self, uid: str) -> CategoryOption:
    """Fetch one CategoryOption by UID."""
    return await self._client.get(
        f"/api/categoryOptions/{uid}", model=CategoryOption, params={"fields": _CO_FIELDS}
    )
create(*, name, short_name, code=None, description=None, form_name=None, start_date=None, end_date=None, uid=None) async

Create a CategoryOption.

start_date / end_date bound the validity window: DHIS2 rejects data-value entry against options whose window doesn't cover the period. Pass ISO-8601 strings ("2024-01-01") or datetime instances; omit for an always-valid option.

Source code in packages/dhis2w-client/src/dhis2w_client/category_options.py
async def create(
    self,
    *,
    name: str,
    short_name: str,
    code: str | None = None,
    description: str | None = None,
    form_name: str | None = None,
    start_date: datetime | str | None = None,
    end_date: datetime | str | None = None,
    uid: str | None = None,
) -> CategoryOption:
    """Create a CategoryOption.

    `start_date` / `end_date` bound the validity window: DHIS2 rejects
    data-value entry against options whose window doesn't cover the
    period. Pass ISO-8601 strings (`"2024-01-01"`) or `datetime`
    instances; omit for an always-valid option.
    """
    payload: dict[str, Any] = {"name": name, "shortName": short_name}
    if code:
        payload["code"] = code
    if description:
        payload["description"] = description
    if form_name:
        payload["formName"] = form_name
    if start_date is not None:
        payload["startDate"] = _serialise_date(start_date)
    if end_date is not None:
        payload["endDate"] = _serialise_date(end_date)
    if uid:
        payload["id"] = uid
    envelope = await self._client.post("/api/categoryOptions", payload, model=WebMessageResponse)
    created_uid = envelope.created_uid or uid
    if not created_uid:
        raise RuntimeError("category-option create did not return a uid")
    return await self.get(created_uid)
update(option) async

PUT an edited CategoryOption back. option.id must be set.

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

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

Source code in packages/dhis2w-client/src/dhis2w_client/category_options.py
async def rename(
    self,
    uid: str,
    *,
    name: str | None = None,
    short_name: str | None = None,
    form_name: str | None = None,
    description: str | None = None,
) -> CategoryOption:
    """Partial-update the label fields — read, mutate, PUT."""
    if name is None and short_name is None and form_name is None and description is None:
        raise ValueError("rename requires at least one of name / short_name / form_name / description")
    current = await self.get(uid)
    if name is not None:
        current.name = name
    if short_name is not None:
        current.shortName = short_name
    if form_name is not None:
        current.formName = form_name
    if description is not None:
        current.description = description
    return await self.update(current)
set_validity_window(uid, *, start_date, end_date) async

Set the startDate / endDate validity window on a CategoryOption.

Pass None for either side to clear that bound. DHIS2 treats an unset window as "always valid"; a set window rejects data-value entry for periods outside it.

Source code in packages/dhis2w-client/src/dhis2w_client/category_options.py
async def set_validity_window(
    self,
    uid: str,
    *,
    start_date: datetime | str | None,
    end_date: datetime | str | None,
) -> CategoryOption:
    """Set the `startDate` / `endDate` validity window on a CategoryOption.

    Pass `None` for either side to clear that bound. DHIS2 treats an
    unset window as "always valid"; a set window rejects data-value
    entry for periods outside it.
    """
    current = await self.get(uid)
    current.startDate = _to_datetime(start_date)
    current.endDate = _to_datetime(end_date)
    return await self.update(current)
delete(uid) async

Delete a CategoryOption — DHIS2 rejects deletes on options in use.

Source code in packages/dhis2w-client/src/dhis2w_client/category_options.py
async def delete(self, uid: str) -> None:
    """Delete a CategoryOption — DHIS2 rejects deletes on options in use."""
    if not uid:
        raise ValueError("delete requires a non-empty uid")
    await self._client.resources.category_options.delete(uid)

category_option_groups

CategoryOptionGroup authoring — Dhis2Client.category_option_groups.

CategoryOptionGroups collect CategoryOptions thematically for cross-disaggregation analysis (e.g. a Maternal age bands group bundling several age CategoryOptions). Mirrors DataElementGroupsAccessor: CRUD + per-item membership shortcuts.

Classes

CategoryOptionGroup

Bases: BaseModel

Generated model for DHIS2 CategoryOptionGroup.

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

API endpoint: /api/categoryOptionGroups.

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

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

    API endpoint: /api/categoryOptionGroups.

    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).")
    aggregationType: AggregationType | None = None
    attributeValues: Any | None = Field(default=None, description="Reference to AttributeValues. Length/value max=255.")
    categoryOptions: list[Any] | None = Field(default=None, description="Collection of CategoryOption.")
    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
    description: str | None = Field(default=None, description="Length/value min=1, max=2147483647.")
    dimensionItem: str | None = Field(default=None, description="Read-only.")
    dimensionItemType: DimensionItemType | None = None
    displayDescription: str | None = Field(default=None, description="Read-only.")
    displayFormName: str | None = Field(default=None, description="Read-only.")
    displayName: str | None = Field(default=None, description="Read-only.")
    displayShortName: 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).")
    formName: str | None = Field(default=None, description="Length/value max=2147483647.")
    groupSets: list[Any] | None = Field(
        default=None, description="Collection of CategoryOptionGroupSet. 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.")
    legendSet: Reference | None = Field(default=None, description="Reference to LegendSet. Read-only (inverse side).")
    legendSets: list[Any] | None = Field(default=None, description="Collection of LegendSet. Read-only (inverse side).")
    name: str | None = Field(default=None, description="Unique. Length/value min=1, max=230.")
    queryMods: Any | None = Field(default=None, description="Reference to QueryModifiers. Read-only (inverse side).")
    sharing: Any | None = Field(default=None, description="Reference to Sharing. Length/value max=255.")
    shortName: str | None = Field(default=None, description="Unique. Length/value min=1, max=50.")
    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).")

CategoryOptionGroupsAccessor

Dhis2Client.category_option_groups — CRUD + membership helpers.

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_groups.py
class CategoryOptionGroupsAccessor:
    """`Dhis2Client.category_option_groups` — CRUD + membership helpers."""

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

    async def list_all(self) -> list[CategoryOptionGroup]:
        """Return every CategoryOptionGroup."""
        return cast(
            list[CategoryOptionGroup],
            await self._client.resources.category_option_groups.list(
                fields=_COG_FIELDS,
                paging=False,
            ),
        )

    async def get(self, uid: str) -> CategoryOptionGroup:
        """Fetch one group by UID with `categoryOptions` + `groupSets` populated."""
        return await self._client.get(
            f"/api/categoryOptionGroups/{uid}", model=CategoryOptionGroup, params={"fields": _COG_FIELDS}
        )

    async def list_members(
        self,
        uid: str,
        *,
        page: int = 1,
        page_size: int = 50,
    ) -> list[CategoryOption]:
        """Page through CategoryOptions belonging to one group."""
        return cast(
            list[CategoryOption],
            await self._client.resources.category_options.list(
                fields=_MEMBER_FIELDS,
                filters=[f"categoryOptionGroups.id:eq:{uid}"],
                order=["name:asc"],
                page=page,
                page_size=page_size,
            ),
        )

    async def create(
        self,
        *,
        name: str,
        short_name: str,
        data_dimension_type: str = "DISAGGREGATION",
        uid: str | None = None,
        code: str | None = None,
        description: str | None = None,
    ) -> CategoryOptionGroup:
        """Create an empty group; add members afterwards via `add_members`.

        `data_dimension_type=DISAGGREGATION` (default) is the common case;
        `ATTRIBUTE` is the other value DHIS2 accepts — used when the group
        targets the attribute-combo grid (data source / funder / etc.)
        instead of the disaggregation grid (sex / age / etc.).
        """
        payload: dict[str, Any] = {
            "name": name,
            "shortName": short_name,
            "dataDimensionType": data_dimension_type,
        }
        if uid:
            payload["id"] = uid
        if code:
            payload["code"] = code
        if description:
            payload["description"] = description
        envelope = await self._client.post("/api/categoryOptionGroups", payload, model=WebMessageResponse)
        created_uid = envelope.created_uid or uid
        if not created_uid:
            raise RuntimeError("category-option-group create did not return a uid")
        return await self.get(created_uid)

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

    async def add_members(self, uid: str, *, category_option_uids: list[str]) -> CategoryOptionGroup:
        """Add CategoryOptions to the group via the per-item POST shortcut."""
        for co_uid in category_option_uids:
            await self._client.resources.category_option_groups.add_collection_item(uid, "categoryOptions", co_uid)
        return await self.get(uid)

    async def remove_members(
        self,
        uid: str,
        *,
        category_option_uids: list[str],
    ) -> CategoryOptionGroup:
        """Drop CategoryOptions from the group via the per-item DELETE shortcut."""
        for co_uid in category_option_uids:
            await self._client.resources.category_option_groups.remove_collection_item(uid, "categoryOptions", co_uid)
        return await self.get(uid)

    async def delete(self, uid: str) -> None:
        """Delete the grouping row — member category options stay."""
        if not uid:
            raise ValueError("delete requires a non-empty uid")
        await self._client.resources.category_option_groups.delete(uid)
Functions
__init__(client)

Bind to the sharing client.

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

Return every CategoryOptionGroup.

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_groups.py
async def list_all(self) -> list[CategoryOptionGroup]:
    """Return every CategoryOptionGroup."""
    return cast(
        list[CategoryOptionGroup],
        await self._client.resources.category_option_groups.list(
            fields=_COG_FIELDS,
            paging=False,
        ),
    )
get(uid) async

Fetch one group by UID with categoryOptions + groupSets populated.

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_groups.py
async def get(self, uid: str) -> CategoryOptionGroup:
    """Fetch one group by UID with `categoryOptions` + `groupSets` populated."""
    return await self._client.get(
        f"/api/categoryOptionGroups/{uid}", model=CategoryOptionGroup, params={"fields": _COG_FIELDS}
    )
list_members(uid, *, page=1, page_size=50) async

Page through CategoryOptions belonging to one group.

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_groups.py
async def list_members(
    self,
    uid: str,
    *,
    page: int = 1,
    page_size: int = 50,
) -> list[CategoryOption]:
    """Page through CategoryOptions belonging to one group."""
    return cast(
        list[CategoryOption],
        await self._client.resources.category_options.list(
            fields=_MEMBER_FIELDS,
            filters=[f"categoryOptionGroups.id:eq:{uid}"],
            order=["name:asc"],
            page=page,
            page_size=page_size,
        ),
    )
create(*, name, short_name, data_dimension_type='DISAGGREGATION', uid=None, code=None, description=None) async

Create an empty group; add members afterwards via add_members.

data_dimension_type=DISAGGREGATION (default) is the common case; ATTRIBUTE is the other value DHIS2 accepts — used when the group targets the attribute-combo grid (data source / funder / etc.) instead of the disaggregation grid (sex / age / etc.).

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_groups.py
async def create(
    self,
    *,
    name: str,
    short_name: str,
    data_dimension_type: str = "DISAGGREGATION",
    uid: str | None = None,
    code: str | None = None,
    description: str | None = None,
) -> CategoryOptionGroup:
    """Create an empty group; add members afterwards via `add_members`.

    `data_dimension_type=DISAGGREGATION` (default) is the common case;
    `ATTRIBUTE` is the other value DHIS2 accepts — used when the group
    targets the attribute-combo grid (data source / funder / etc.)
    instead of the disaggregation grid (sex / age / etc.).
    """
    payload: dict[str, Any] = {
        "name": name,
        "shortName": short_name,
        "dataDimensionType": data_dimension_type,
    }
    if uid:
        payload["id"] = uid
    if code:
        payload["code"] = code
    if description:
        payload["description"] = description
    envelope = await self._client.post("/api/categoryOptionGroups", payload, model=WebMessageResponse)
    created_uid = envelope.created_uid or uid
    if not created_uid:
        raise RuntimeError("category-option-group create did not return a uid")
    return await self.get(created_uid)
update(group) async

PUT an edited group back. group.id must be set.

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

Add CategoryOptions to the group via the per-item POST shortcut.

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_groups.py
async def add_members(self, uid: str, *, category_option_uids: list[str]) -> CategoryOptionGroup:
    """Add CategoryOptions to the group via the per-item POST shortcut."""
    for co_uid in category_option_uids:
        await self._client.resources.category_option_groups.add_collection_item(uid, "categoryOptions", co_uid)
    return await self.get(uid)
remove_members(uid, *, category_option_uids) async

Drop CategoryOptions from the group via the per-item DELETE shortcut.

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_groups.py
async def remove_members(
    self,
    uid: str,
    *,
    category_option_uids: list[str],
) -> CategoryOptionGroup:
    """Drop CategoryOptions from the group via the per-item DELETE shortcut."""
    for co_uid in category_option_uids:
        await self._client.resources.category_option_groups.remove_collection_item(uid, "categoryOptions", co_uid)
    return await self.get(uid)
delete(uid) async

Delete the grouping row — member category options stay.

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_groups.py
async def delete(self, uid: str) -> None:
    """Delete the grouping row — member category options stay."""
    if not uid:
        raise ValueError("delete requires a non-empty uid")
    await self._client.resources.category_option_groups.delete(uid)

category_option_group_sets

CategoryOptionGroupSet authoring — Dhis2Client.category_option_group_sets.

A CategoryOptionGroupSet is the analytics dimension that collects CategoryOptionGroups — e.g. "Programme funder" carries groups for each disaggregated donor. Mirrors DataElementGroupSetsAccessor.

Classes

CategoryOptionGroupSet

Bases: BaseModel

Generated model for DHIS2 CategoryOptionGroupSet.

DHIS2 Category Option Group Set - persisted metadata (generated from /api/schemas at DHIS2 v42).

API endpoint: /api/categoryOptionGroupSets.

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

    DHIS2 Category Option Group Set - persisted metadata (generated from /api/schemas at DHIS2 v42).

    API endpoint: /api/categoryOptionGroupSets.

    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).")
    aggregationType: AggregationType | None = None
    allItems: bool | None = None
    attributeValues: Any | None = Field(default=None, description="Reference to AttributeValues. Length/value max=255.")
    categoryOptionGroups: list[Any] | None = Field(default=None, description="Collection of CategoryOptionGroup.")
    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.")
    dataDimension: bool | None = None
    dataDimensionType: DataDimensionType | None = None
    description: str | None = Field(default=None, description="Length/value min=1, max=2147483647.")
    dimension: str | None = Field(default=None, description="Length/value max=2147483647.")
    dimensionItemKeywords: Any | None = Field(
        default=None, description="Reference to DimensionItemKeywords. Read-only (inverse side)."
    )
    dimensionType: DimensionType | None = None
    displayDescription: str | None = Field(default=None, description="Read-only.")
    displayFormName: str | None = Field(default=None, description="Read-only.")
    displayName: str | None = Field(default=None, description="Read-only.")
    displayShortName: 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).")
    filter: str | None = Field(default=None, description="Length/value max=2147483647.")
    formName: str | None = Field(default=None, description="Length/value max=2147483647.")
    href: str | None = None
    id: str | None = Field(default=None, description="Unique. Length/value min=11, max=11.")
    items: list[Any] | None = Field(
        default=None, description="Collection of DimensionalItemObject. Read-only (inverse side)."
    )
    lastUpdated: datetime | None = None
    lastUpdatedBy: Reference | None = Field(default=None, description="Reference to User.")
    legendSet: Reference | None = Field(default=None, description="Reference to LegendSet. Read-only (inverse side).")
    name: str | None = Field(default=None, description="Unique. Length/value min=1, max=230.")
    optionSet: Reference | None = Field(default=None, description="Reference to OptionSet. Read-only (inverse side).")
    program: Reference | None = Field(default=None, description="Reference to Program. Read-only (inverse side).")
    programStage: Reference | None = Field(
        default=None, description="Reference to ProgramStage. Read-only (inverse side)."
    )
    repetition: Any | None = Field(default=None, description="Reference to EventRepetition. Read-only (inverse side).")
    sharing: Any | None = Field(default=None, description="Reference to Sharing. Length/value max=255.")
    shortName: str | None = Field(default=None, description="Unique. Length/value min=1, max=50.")
    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).")
    valueType: ValueType | None = Field(default=None, description="Read-only.")

CategoryOptionGroupSetsAccessor

Dhis2Client.category_option_group_sets — CRUD + group-membership helpers.

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_group_sets.py
class CategoryOptionGroupSetsAccessor:
    """`Dhis2Client.category_option_group_sets` — CRUD + group-membership helpers."""

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

    async def list_all(self) -> list[CategoryOptionGroupSet]:
        """Return every CategoryOptionGroupSet."""
        return cast(
            list[CategoryOptionGroupSet],
            await self._client.resources.category_option_group_sets.list(
                fields=_COGS_FIELDS,
                paging=False,
            ),
        )

    async def get(self, uid: str) -> CategoryOptionGroupSet:
        """Fetch one group set by UID with its groups inline."""
        return await self._client.get(
            f"/api/categoryOptionGroupSets/{uid}", model=CategoryOptionGroupSet, params={"fields": _COGS_FIELDS}
        )

    async def list_groups(self, uid: str) -> list[CategoryOptionGroup]:
        """Return the groups in the set, in definition order."""
        group_set = await self.get(uid)
        groups = group_set.categoryOptionGroups or []
        return [CategoryOptionGroup.model_validate(g) for g in groups if isinstance(g, dict)]

    async def create(
        self,
        *,
        name: str,
        short_name: str,
        data_dimension_type: str = "DISAGGREGATION",
        data_dimension: bool = True,
        uid: str | None = None,
        code: str | None = None,
        description: str | None = None,
    ) -> CategoryOptionGroupSet:
        """Create an empty group set; add groups via `add_groups`.

        `data_dimension=True` (the default) exposes the set as an
        analytics axis (pivot tables, visualisations).
        `data_dimension_type="DISAGGREGATION"` targets the disaggregation
        grid; `"ATTRIBUTE"` targets the attribute-combo grid (data
        source / funder / etc.).
        """
        payload: dict[str, Any] = {
            "name": name,
            "shortName": short_name,
            "dataDimensionType": data_dimension_type,
            "dataDimension": data_dimension,
        }
        if uid:
            payload["id"] = uid
        if code:
            payload["code"] = code
        if description:
            payload["description"] = description
        envelope = await self._client.post("/api/categoryOptionGroupSets", payload, model=WebMessageResponse)
        created_uid = envelope.created_uid or uid
        if not created_uid:
            raise RuntimeError("category-option-group-set create did not return a uid")
        return await self.get(created_uid)

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

    async def add_groups(self, uid: str, *, group_uids: list[str]) -> CategoryOptionGroupSet:
        """Add `group_uids` to the set via the per-item POST shortcut."""
        for group_uid in group_uids:
            await self._client.resources.category_option_group_sets.add_collection_item(
                uid, "categoryOptionGroups", group_uid
            )
        return await self.get(uid)

    async def remove_groups(self, uid: str, *, group_uids: list[str]) -> CategoryOptionGroupSet:
        """Drop `group_uids` from the set via the per-item DELETE shortcut."""
        for group_uid in group_uids:
            await self._client.resources.category_option_group_sets.remove_collection_item(
                uid, "categoryOptionGroups", group_uid
            )
        return await self.get(uid)

    async def delete(self, uid: str) -> None:
        """Delete a group set — groups stay."""
        if not uid:
            raise ValueError("delete requires a non-empty uid")
        await self._client.resources.category_option_group_sets.delete(uid)
Functions
__init__(client)

Bind to the sharing client.

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

Return every CategoryOptionGroupSet.

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_group_sets.py
async def list_all(self) -> list[CategoryOptionGroupSet]:
    """Return every CategoryOptionGroupSet."""
    return cast(
        list[CategoryOptionGroupSet],
        await self._client.resources.category_option_group_sets.list(
            fields=_COGS_FIELDS,
            paging=False,
        ),
    )
get(uid) async

Fetch one group set by UID with its groups inline.

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_group_sets.py
async def get(self, uid: str) -> CategoryOptionGroupSet:
    """Fetch one group set by UID with its groups inline."""
    return await self._client.get(
        f"/api/categoryOptionGroupSets/{uid}", model=CategoryOptionGroupSet, params={"fields": _COGS_FIELDS}
    )
list_groups(uid) async

Return the groups in the set, in definition order.

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_group_sets.py
async def list_groups(self, uid: str) -> list[CategoryOptionGroup]:
    """Return the groups in the set, in definition order."""
    group_set = await self.get(uid)
    groups = group_set.categoryOptionGroups or []
    return [CategoryOptionGroup.model_validate(g) for g in groups if isinstance(g, dict)]
create(*, name, short_name, data_dimension_type='DISAGGREGATION', data_dimension=True, uid=None, code=None, description=None) async

Create an empty group set; add groups via add_groups.

data_dimension=True (the default) exposes the set as an analytics axis (pivot tables, visualisations). data_dimension_type="DISAGGREGATION" targets the disaggregation grid; "ATTRIBUTE" targets the attribute-combo grid (data source / funder / etc.).

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_group_sets.py
async def create(
    self,
    *,
    name: str,
    short_name: str,
    data_dimension_type: str = "DISAGGREGATION",
    data_dimension: bool = True,
    uid: str | None = None,
    code: str | None = None,
    description: str | None = None,
) -> CategoryOptionGroupSet:
    """Create an empty group set; add groups via `add_groups`.

    `data_dimension=True` (the default) exposes the set as an
    analytics axis (pivot tables, visualisations).
    `data_dimension_type="DISAGGREGATION"` targets the disaggregation
    grid; `"ATTRIBUTE"` targets the attribute-combo grid (data
    source / funder / etc.).
    """
    payload: dict[str, Any] = {
        "name": name,
        "shortName": short_name,
        "dataDimensionType": data_dimension_type,
        "dataDimension": data_dimension,
    }
    if uid:
        payload["id"] = uid
    if code:
        payload["code"] = code
    if description:
        payload["description"] = description
    envelope = await self._client.post("/api/categoryOptionGroupSets", payload, model=WebMessageResponse)
    created_uid = envelope.created_uid or uid
    if not created_uid:
        raise RuntimeError("category-option-group-set create did not return a uid")
    return await self.get(created_uid)
update(group_set) async

PUT an edited group set back. group_set.id must be set.

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_group_sets.py
async def update(self, group_set: CategoryOptionGroupSet) -> CategoryOptionGroupSet:
    """PUT an edited group set back. `group_set.id` must be set."""
    if not group_set.id:
        raise ValueError("update requires group_set.id to be set")
    body = group_set.model_dump(by_alias=True, exclude_none=True, mode="json")
    await self._client.put_raw(f"/api/categoryOptionGroupSets/{group_set.id}", body=body)
    return await self.get(group_set.id)
add_groups(uid, *, group_uids) async

Add group_uids to the set via the per-item POST shortcut.

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_group_sets.py
async def add_groups(self, uid: str, *, group_uids: list[str]) -> CategoryOptionGroupSet:
    """Add `group_uids` to the set via the per-item POST shortcut."""
    for group_uid in group_uids:
        await self._client.resources.category_option_group_sets.add_collection_item(
            uid, "categoryOptionGroups", group_uid
        )
    return await self.get(uid)
remove_groups(uid, *, group_uids) async

Drop group_uids from the set via the per-item DELETE shortcut.

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_group_sets.py
async def remove_groups(self, uid: str, *, group_uids: list[str]) -> CategoryOptionGroupSet:
    """Drop `group_uids` from the set via the per-item DELETE shortcut."""
    for group_uid in group_uids:
        await self._client.resources.category_option_group_sets.remove_collection_item(
            uid, "categoryOptionGroups", group_uid
        )
    return await self.get(uid)
delete(uid) async

Delete a group set — groups stay.

Source code in packages/dhis2w-client/src/dhis2w_client/category_option_group_sets.py
async def delete(self, uid: str) -> None:
    """Delete a group set — groups stay."""
    if not uid:
        raise ValueError("delete requires a non-empty uid")
    await self._client.resources.category_option_group_sets.delete(uid)