Skip to content

Apps

AppsAccessor on Dhis2Client.apps — install / uninstall / update DHIS2 apps via /api/apps and the configured App Hub (/api/appHub). The App model is generated from the OpenAPI schema; AppHubApp + AppHubVersion are thin wrappers with extra="allow" over the hub's proxied JSON, so new hub fields ride through without a codegen bump.

Typical flow:

  1. client.apps.list_apps() — enumerate installed apps.
  2. client.apps.hub_list() — enumerate App Hub catalog.
  3. client.apps.install_from_hub(version_id) or install_from_file(path) to install.
  4. client.apps.uninstall(key) to remove.

For update orchestration (compare installed version to hub latest, install newer), see dhis2w_core.plugins.apps.service.update_all — also exposed as the dhis2 apps update --all CLI verb with a --dry-run preview mode.

apps

DHIS2 apps + App Hub — /api/apps and /api/appHub.

Covers the installed-apps surface (list / install from zip / install from App Hub / uninstall / reload-from-disk) plus read-only App Hub queries used by the update verbs in the apps plugin.

Terminology:

  • An installed app is a row DHIS2 returns from GET /api/apps. Its identifier is the folder name (key); that's what the DELETE endpoint takes. app_hub_id on the installed record matches the App Hub's own id so an update verb can locate the latest version.
  • An App Hub app is a catalog entry under GET /api/appHub. Each has a list of versions, each with a unique id (the versionId the install endpoint takes).

Classes

App

Bases: BaseModel

OpenAPI schema App.

Source code in packages/dhis2w-client/src/dhis2w_client/generated/v42/oas/app.py
class App(_BaseModel):
    """OpenAPI schema `App`."""

    model_config = _ConfigDict(extra="allow", populate_by_name=True, defer_build=True)

    activities: AppActivities | None = None
    appState: AppStatus | None = None
    appStorageSource: AppStorageSource | None = None
    appType: AppType | None = None
    app_hub_id: str | None = None
    authorities: list[str] | None = None
    basePath: str | None = None
    baseUrl: str | None = None
    bundled: bool | None = None
    core_app: bool | None = None
    default_locale: str | None = None
    description: str | None = None
    developer: AppDeveloper | None = None
    displayDescription: str | None = None
    displayName: str | None = None
    folderName: str | None = None
    icons: AppIcons | None = None
    installs_allowed_from: list[str] | None = None
    key: str | None = None
    launchUrl: str | None = None
    launch_path: str | None = None
    name: str | None = None
    pluginLaunchUrl: str | None = None
    plugin_launch_path: str | None = None
    plugin_type: str | None = None
    settings: AppSettings | None = None
    short_name: str | None = None
    shortcuts: list[AppShortcut] | None = None
    version: str | None = None

AppStatus

Bases: StrEnum

AppStatus.

Source code in packages/dhis2w-client/src/dhis2w_client/generated/v42/oas/_enums.py
class AppStatus(StrEnum):
    """AppStatus."""

    OK = "OK"
    INVALID_BUNDLED_APP_OVERRIDE = "INVALID_BUNDLED_APP_OVERRIDE"
    INVALID_CORE_APP = "INVALID_CORE_APP"
    NAMESPACE_TAKEN = "NAMESPACE_TAKEN"
    NAMESPACE_INVALID = "NAMESPACE_INVALID"
    INVALID_ZIP_FORMAT = "INVALID_ZIP_FORMAT"
    MISSING_MANIFEST = "MISSING_MANIFEST"
    INVALID_MANIFEST_JSON = "INVALID_MANIFEST_JSON"
    INSTALLATION_FAILED = "INSTALLATION_FAILED"
    NOT_FOUND = "NOT_FOUND"
    MISSING_SYSTEM_BASE_URL = "MISSING_SYSTEM_BASE_URL"
    APPROVED = "APPROVED"
    PENDING = "PENDING"
    NOT_APPROVED = "NOT_APPROVED"
    DELETION_IN_PROGRESS = "DELETION_IN_PROGRESS"

AppType

Bases: StrEnum

AppType.

Source code in packages/dhis2w-client/src/dhis2w_client/generated/v42/oas/_enums.py
class AppType(StrEnum):
    """AppType."""

    APP = "APP"
    RESOURCE = "RESOURCE"
    DASHBOARD_WIDGET = "DASHBOARD_WIDGET"

AppHubVersion

Bases: BaseModel

One version of an App Hub app — the install target for POST /api/appHub/{versionId}.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
class AppHubVersion(BaseModel):
    """One version of an App Hub app — the install target for `POST /api/appHub/{versionId}`."""

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

    id: str | None = None
    version: str | None = None
    min_dhis2_version: str | None = None
    max_dhis2_version: str | None = None
    # DHIS2's App Hub returns epoch-millis integers here (e.g. 1747820526374);
    # the type is lax to absorb both shapes without a custom validator.
    created: int | str | None = None
    last_updated: int | str | None = None
    download_url: str | None = None
    channel: str | None = None

AppSnapshotEntry

Bases: BaseModel

One row of an AppsSnapshot — a single installed app captured for later restore.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
class AppSnapshotEntry(BaseModel):
    """One row of an `AppsSnapshot` — a single installed app captured for later restore."""

    model_config = ConfigDict(frozen=True)

    key: str
    name: str
    version: str | None = None
    app_hub_id: str | None = None
    bundled: bool = False
    source: str  # "app-hub" | "side-loaded"
    hub_version_id: str | None = None
    hub_download_url: str | None = None

RestoreOutcome

Bases: BaseModel

Per-app result of an apps.restore(snapshot) call.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
class RestoreOutcome(BaseModel):
    """Per-app result of an `apps.restore(snapshot)` call."""

    model_config = ConfigDict(frozen=True)

    key: str
    name: str
    from_version: str | None = None
    to_version: str | None = None
    status: str  # RESTORED / AVAILABLE / UP_TO_DATE / SKIPPED / FAILED
    reason: str | None = None

RestoreSummary

Bases: BaseModel

Aggregate of apps.restore(...) — rows plus totals for the table footer.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
class RestoreSummary(BaseModel):
    """Aggregate of `apps.restore(...)` — rows plus totals for the table footer."""

    model_config = ConfigDict(frozen=True)

    outcomes: list[RestoreOutcome]

    @property
    def restored(self) -> int:
        """Count of apps successfully installed from their snapshot version."""
        return sum(1 for o in self.outcomes if o.status == "RESTORED")

    @property
    def available(self) -> int:
        """Count of apps that *would* restore under `dry_run=True`."""
        return sum(1 for o in self.outcomes if o.status == "AVAILABLE")

    @property
    def up_to_date(self) -> int:
        """Count of apps already at the snapshot's version (no-op)."""
        return sum(1 for o in self.outcomes if o.status == "UP_TO_DATE")

    @property
    def skipped(self) -> int:
        """Count of side-loaded entries with no hub version to restore from."""
        return sum(1 for o in self.outcomes if o.status == "SKIPPED")

    @property
    def failed(self) -> int:
        """Count of install calls that raised."""
        return sum(1 for o in self.outcomes if o.status == "FAILED")
Attributes
restored property

Count of apps successfully installed from their snapshot version.

available property

Count of apps that would restore under dry_run=True.

up_to_date property

Count of apps already at the snapshot's version (no-op).

skipped property

Count of side-loaded entries with no hub version to restore from.

failed property

Count of install calls that raised.

AppsSnapshot

Bases: BaseModel

Typed inventory of every installed app — portable across instances.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
class AppsSnapshot(BaseModel):
    """Typed inventory of every installed app — portable across instances."""

    model_config = ConfigDict(frozen=True)

    entries: list[AppSnapshotEntry]

    @property
    def hub_backed(self) -> int:
        """Count of entries that can be rehydrated via `install_from_hub`."""
        return sum(1 for e in self.entries if e.hub_version_id)

    @property
    def side_loaded(self) -> int:
        """Count of entries that have no hub match (need an external zip to restore)."""
        return sum(1 for e in self.entries if not e.hub_version_id)
Attributes
hub_backed property

Count of entries that can be rehydrated via install_from_hub.

side_loaded property

Count of entries that have no hub match (need an external zip to restore).

AppHubApp

Bases: BaseModel

One catalog entry from GET /api/appHub — the App Hub's view of an app.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
class AppHubApp(BaseModel):
    """One catalog entry from `GET /api/appHub` — the App Hub's view of an app."""

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

    id: str | None = None
    name: str | None = None
    app_type: str | None = None
    description: str | None = None
    developer: dict[str, Any] | None = None
    icons: dict[str, Any] | None = None
    versions: list[AppHubVersion] = []

AppsAccessor

Dhis2Client.apps — list / install / uninstall / reload + App Hub queries.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
class AppsAccessor:
    """`Dhis2Client.apps` — list / install / uninstall / reload + App Hub queries."""

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

    async def list_apps(self) -> list[App]:
        """Return every installed app — `GET /api/apps`.

        The response body is a bare JSON array (no envelope key), so
        `get_raw` wraps it under `"data"` — unwrapping is a one-liner.
        Every row becomes a typed `App`; extra fields DHIS2 tacks on in
        minor versions ride through via `model_config = extra="allow"`.
        """
        raw = await self._client.get_raw("/api/apps")
        rows = _unwrap_array(raw)
        return [App.model_validate(row) for row in rows if isinstance(row, dict)]

    async def get(self, key: str) -> App | None:
        """Find one installed app by `key` (folder name). Returns None if not installed."""
        for app in await self.list_apps():
            if app.key == key:
                return app
        return None

    async def install_from_file(self, path: Path | str, *, filename: str | None = None) -> None:
        """Upload an `.zip` to `POST /api/apps` — installs or updates in place.

        DHIS2 accepts multipart/form-data with a single `file` field. On
        success the endpoint returns 204 No Content (or a thin JSON body);
        the caller then calls `list()` again to see the new row.
        `filename` overrides the on-the-wire filename (defaults to the
        local path's basename).
        """
        file_path = Path(path)
        if not file_path.is_file():
            raise FileNotFoundError(f"no such app zip: {file_path}")
        data = file_path.read_bytes()
        resolved_filename = filename or file_path.name
        await self._client._request(  # noqa: SLF001 — accessor is tight with the client
            "POST",
            "/api/apps",
            files={"file": (resolved_filename, data, "application/zip")},
        )

    async def install_from_hub(self, version_id: str) -> None:
        """Install a specific App Hub version — `POST /api/appHub/{versionId}`.

        DHIS2 streams the app zip from the App Hub server-side, so the
        local caller only supplies the `versionId` (usually a UUID the
        App Hub hands out). On success the installed row is returned
        from the next `list()` call.
        """
        if not version_id:
            raise ValueError("install_from_hub requires a non-empty version_id")
        await self._client.post_raw(f"/api/appHub/{version_id}")

    async def uninstall(self, key: str) -> None:
        """Remove an installed app by `key` — `DELETE /api/apps/{key}`."""
        if not key:
            raise ValueError("uninstall requires a non-empty app key")
        await self._client.delete_raw(f"/api/apps/{key}")

    async def reload(self) -> None:
        """Re-read every app from disk — `PUT /api/apps`. No new versions are fetched."""
        await self._client.put_raw("/api/apps")

    async def hub_list(self, *, query: str | None = None) -> list[AppHubApp]:
        """List every app in the configured App Hub — `GET /api/appHub`.

        DHIS2 proxies this call to the App Hub server the instance is
        pointed at (typically `apps.dhis2.org`, configurable via the
        `keyAppHubUrl` system setting). The response is a JSON array of
        hub-app records; each has a `versions` list whose `id` fields
        are install targets for `install_from_hub(version_id)`.

        `query` applies a case-insensitive substring filter on `name` +
        `description` after the full catalog lands — the App Hub proxy
        doesn't expose a server-side query parameter in v42, so the
        filter is client-side.
        """
        raw = await self._client.get_raw("/api/appHub")
        rows = _unwrap_array(raw)
        catalog = [AppHubApp.model_validate(row) for row in rows if isinstance(row, dict)]
        if not query:
            return catalog
        needle = query.lower()
        return [
            app
            for app in catalog
            if (app.name and needle in app.name.lower()) or (app.description and needle in app.description.lower())
        ]

    async def snapshot(self) -> AppsSnapshot:
        """Capture an inventory of every installed app into a typed snapshot.

        Each entry records the app's `key`, human name, installed
        `version`, and — when the app was installed from the App Hub —
        its `app_hub_id` plus the matching hub `version_id` +
        `download_url` so the snapshot is sufficient to rehydrate the
        same catalog on another instance via `install_from_hub`.

        Apps without an `app_hub_id` (side-loaded zips uploaded via the
        legacy UI or `install_from_file`) are captured too, but with
        `source="side-loaded"` + no reinstall target. A restore step
        would have to source those zips externally.
        """
        installed = await self.list_apps()
        hub = await self.hub_list()
        hub_by_id = {app.id: app for app in hub if app.id}
        entries: list[AppSnapshotEntry] = []
        for app in installed:
            hub_entry = hub_by_id.get(app.app_hub_id) if app.app_hub_id else None
            hub_version: AppHubVersion | None = None
            if hub_entry is not None and app.version is not None:
                hub_version = next(
                    (v for v in hub_entry.versions if v.version == app.version),
                    None,
                )
            entries.append(
                AppSnapshotEntry(
                    key=app.key or app.folderName or app.name or "?",
                    name=app.displayName or app.name or app.key or "?",
                    version=app.version,
                    app_hub_id=app.app_hub_id,
                    bundled=bool(app.bundled),
                    source="app-hub" if app.app_hub_id else "side-loaded",
                    hub_version_id=hub_version.id if hub_version else None,
                    hub_download_url=hub_version.download_url if hub_version else None,
                ),
            )
        return AppsSnapshot(entries=entries)

    async def restore(self, snapshot: AppsSnapshot, *, dry_run: bool = False) -> RestoreSummary:
        """Reinstall every hub-backed entry in `snapshot` via `install_from_hub`.

        For each entry:

        - Side-loaded apps (no `hub_version_id`) → `SKIPPED` with a reason.
        - App already installed at the same version → `UP_TO_DATE`, no POST.
        - `dry_run=True` + install would happen → `AVAILABLE`, no POST.
        - Otherwise → `POST /api/appHub/{hub_version_id}`, outcome
          `RESTORED` on 2xx or `FAILED` with the exception string.

        The flip side of `snapshot()` — same data model, opposite
        direction. Use to rehydrate a pinned app catalog on another
        instance or recover a known-good state after an upgrade.
        """
        installed_by_key = {app.key: app for app in await self.list_apps() if app.key}
        outcomes: list[RestoreOutcome] = []
        for entry in snapshot.entries:
            outcomes.append(await self._apply_restore(entry, installed_by_key, dry_run=dry_run))
        return RestoreSummary(outcomes=outcomes)

    async def _apply_restore(
        self,
        entry: AppSnapshotEntry,
        installed_by_key: dict[str, App],
        *,
        dry_run: bool,
    ) -> RestoreOutcome:
        """Classify one snapshot entry + install if needed; returns a `RestoreOutcome`."""
        installed = installed_by_key.get(entry.key)
        from_version = installed.version if installed else None
        if not entry.hub_version_id:
            return RestoreOutcome(
                key=entry.key,
                name=entry.name,
                from_version=from_version,
                to_version=entry.version,
                status="SKIPPED",
                reason="no hub_version_id in snapshot (side-loaded zip — needs external source)",
            )
        if from_version == entry.version:
            return RestoreOutcome(
                key=entry.key,
                name=entry.name,
                from_version=from_version,
                to_version=entry.version,
                status="UP_TO_DATE",
            )
        if dry_run:
            return RestoreOutcome(
                key=entry.key,
                name=entry.name,
                from_version=from_version,
                to_version=entry.version,
                status="AVAILABLE",
            )
        try:
            await self.install_from_hub(entry.hub_version_id)
        except Exception as exc:  # noqa: BLE001 — capture the reason for the summary row
            return RestoreOutcome(
                key=entry.key,
                name=entry.name,
                from_version=from_version,
                to_version=entry.version,
                status="FAILED",
                reason=f"{type(exc).__name__}: {exc}",
            )
        return RestoreOutcome(
            key=entry.key,
            name=entry.name,
            from_version=from_version,
            to_version=entry.version,
            status="RESTORED",
        )

    async def get_hub_url(self) -> str | None:
        """Return the configured App Hub URL (`keyAppHubUrl` system setting) or None if unset.

        When None, DHIS2 falls back to its hard-coded default
        (`https://apps.dhis2.org/api` on v42). The App Hub is open
        source (https://github.com/dhis2/app-hub); self-hosters can
        point their instance at a private catalog by setting this.
        """
        return await self._client.system.setting("keyAppHubUrl", use_cache=False)

    async def set_hub_url(self, url: str | None) -> None:
        """Set the `keyAppHubUrl` system setting — points DHIS2 at a different App Hub.

        Passing `None` clears the setting (server reverts to its
        hard-coded default). Any HTTP call that hits `/api/appHub` on
        this instance after this write uses the new URL.
        """
        await self._client.system.set_setting("keyAppHubUrl", url)
Functions
__init__(client)

Bind to the sharing client.

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

Return every installed app — GET /api/apps.

The response body is a bare JSON array (no envelope key), so get_raw wraps it under "data" — unwrapping is a one-liner. Every row becomes a typed App; extra fields DHIS2 tacks on in minor versions ride through via model_config = extra="allow".

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
async def list_apps(self) -> list[App]:
    """Return every installed app — `GET /api/apps`.

    The response body is a bare JSON array (no envelope key), so
    `get_raw` wraps it under `"data"` — unwrapping is a one-liner.
    Every row becomes a typed `App`; extra fields DHIS2 tacks on in
    minor versions ride through via `model_config = extra="allow"`.
    """
    raw = await self._client.get_raw("/api/apps")
    rows = _unwrap_array(raw)
    return [App.model_validate(row) for row in rows if isinstance(row, dict)]
get(key) async

Find one installed app by key (folder name). Returns None if not installed.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
async def get(self, key: str) -> App | None:
    """Find one installed app by `key` (folder name). Returns None if not installed."""
    for app in await self.list_apps():
        if app.key == key:
            return app
    return None
install_from_file(path, *, filename=None) async

Upload an .zip to POST /api/apps — installs or updates in place.

DHIS2 accepts multipart/form-data with a single file field. On success the endpoint returns 204 No Content (or a thin JSON body); the caller then calls list() again to see the new row. filename overrides the on-the-wire filename (defaults to the local path's basename).

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
async def install_from_file(self, path: Path | str, *, filename: str | None = None) -> None:
    """Upload an `.zip` to `POST /api/apps` — installs or updates in place.

    DHIS2 accepts multipart/form-data with a single `file` field. On
    success the endpoint returns 204 No Content (or a thin JSON body);
    the caller then calls `list()` again to see the new row.
    `filename` overrides the on-the-wire filename (defaults to the
    local path's basename).
    """
    file_path = Path(path)
    if not file_path.is_file():
        raise FileNotFoundError(f"no such app zip: {file_path}")
    data = file_path.read_bytes()
    resolved_filename = filename or file_path.name
    await self._client._request(  # noqa: SLF001 — accessor is tight with the client
        "POST",
        "/api/apps",
        files={"file": (resolved_filename, data, "application/zip")},
    )
install_from_hub(version_id) async

Install a specific App Hub version — POST /api/appHub/{versionId}.

DHIS2 streams the app zip from the App Hub server-side, so the local caller only supplies the versionId (usually a UUID the App Hub hands out). On success the installed row is returned from the next list() call.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
async def install_from_hub(self, version_id: str) -> None:
    """Install a specific App Hub version — `POST /api/appHub/{versionId}`.

    DHIS2 streams the app zip from the App Hub server-side, so the
    local caller only supplies the `versionId` (usually a UUID the
    App Hub hands out). On success the installed row is returned
    from the next `list()` call.
    """
    if not version_id:
        raise ValueError("install_from_hub requires a non-empty version_id")
    await self._client.post_raw(f"/api/appHub/{version_id}")
uninstall(key) async

Remove an installed app by keyDELETE /api/apps/{key}.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
async def uninstall(self, key: str) -> None:
    """Remove an installed app by `key` — `DELETE /api/apps/{key}`."""
    if not key:
        raise ValueError("uninstall requires a non-empty app key")
    await self._client.delete_raw(f"/api/apps/{key}")
reload() async

Re-read every app from disk — PUT /api/apps. No new versions are fetched.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
async def reload(self) -> None:
    """Re-read every app from disk — `PUT /api/apps`. No new versions are fetched."""
    await self._client.put_raw("/api/apps")
hub_list(*, query=None) async

List every app in the configured App Hub — GET /api/appHub.

DHIS2 proxies this call to the App Hub server the instance is pointed at (typically apps.dhis2.org, configurable via the keyAppHubUrl system setting). The response is a JSON array of hub-app records; each has a versions list whose id fields are install targets for install_from_hub(version_id).

query applies a case-insensitive substring filter on name + description after the full catalog lands — the App Hub proxy doesn't expose a server-side query parameter in v42, so the filter is client-side.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
async def hub_list(self, *, query: str | None = None) -> list[AppHubApp]:
    """List every app in the configured App Hub — `GET /api/appHub`.

    DHIS2 proxies this call to the App Hub server the instance is
    pointed at (typically `apps.dhis2.org`, configurable via the
    `keyAppHubUrl` system setting). The response is a JSON array of
    hub-app records; each has a `versions` list whose `id` fields
    are install targets for `install_from_hub(version_id)`.

    `query` applies a case-insensitive substring filter on `name` +
    `description` after the full catalog lands — the App Hub proxy
    doesn't expose a server-side query parameter in v42, so the
    filter is client-side.
    """
    raw = await self._client.get_raw("/api/appHub")
    rows = _unwrap_array(raw)
    catalog = [AppHubApp.model_validate(row) for row in rows if isinstance(row, dict)]
    if not query:
        return catalog
    needle = query.lower()
    return [
        app
        for app in catalog
        if (app.name and needle in app.name.lower()) or (app.description and needle in app.description.lower())
    ]
snapshot() async

Capture an inventory of every installed app into a typed snapshot.

Each entry records the app's key, human name, installed version, and — when the app was installed from the App Hub — its app_hub_id plus the matching hub version_id + download_url so the snapshot is sufficient to rehydrate the same catalog on another instance via install_from_hub.

Apps without an app_hub_id (side-loaded zips uploaded via the legacy UI or install_from_file) are captured too, but with source="side-loaded" + no reinstall target. A restore step would have to source those zips externally.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
async def snapshot(self) -> AppsSnapshot:
    """Capture an inventory of every installed app into a typed snapshot.

    Each entry records the app's `key`, human name, installed
    `version`, and — when the app was installed from the App Hub —
    its `app_hub_id` plus the matching hub `version_id` +
    `download_url` so the snapshot is sufficient to rehydrate the
    same catalog on another instance via `install_from_hub`.

    Apps without an `app_hub_id` (side-loaded zips uploaded via the
    legacy UI or `install_from_file`) are captured too, but with
    `source="side-loaded"` + no reinstall target. A restore step
    would have to source those zips externally.
    """
    installed = await self.list_apps()
    hub = await self.hub_list()
    hub_by_id = {app.id: app for app in hub if app.id}
    entries: list[AppSnapshotEntry] = []
    for app in installed:
        hub_entry = hub_by_id.get(app.app_hub_id) if app.app_hub_id else None
        hub_version: AppHubVersion | None = None
        if hub_entry is not None and app.version is not None:
            hub_version = next(
                (v for v in hub_entry.versions if v.version == app.version),
                None,
            )
        entries.append(
            AppSnapshotEntry(
                key=app.key or app.folderName or app.name or "?",
                name=app.displayName or app.name or app.key or "?",
                version=app.version,
                app_hub_id=app.app_hub_id,
                bundled=bool(app.bundled),
                source="app-hub" if app.app_hub_id else "side-loaded",
                hub_version_id=hub_version.id if hub_version else None,
                hub_download_url=hub_version.download_url if hub_version else None,
            ),
        )
    return AppsSnapshot(entries=entries)
restore(snapshot, *, dry_run=False) async

Reinstall every hub-backed entry in snapshot via install_from_hub.

For each entry:

  • Side-loaded apps (no hub_version_id) → SKIPPED with a reason.
  • App already installed at the same version → UP_TO_DATE, no POST.
  • dry_run=True + install would happen → AVAILABLE, no POST.
  • Otherwise → POST /api/appHub/{hub_version_id}, outcome RESTORED on 2xx or FAILED with the exception string.

The flip side of snapshot() — same data model, opposite direction. Use to rehydrate a pinned app catalog on another instance or recover a known-good state after an upgrade.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
async def restore(self, snapshot: AppsSnapshot, *, dry_run: bool = False) -> RestoreSummary:
    """Reinstall every hub-backed entry in `snapshot` via `install_from_hub`.

    For each entry:

    - Side-loaded apps (no `hub_version_id`) → `SKIPPED` with a reason.
    - App already installed at the same version → `UP_TO_DATE`, no POST.
    - `dry_run=True` + install would happen → `AVAILABLE`, no POST.
    - Otherwise → `POST /api/appHub/{hub_version_id}`, outcome
      `RESTORED` on 2xx or `FAILED` with the exception string.

    The flip side of `snapshot()` — same data model, opposite
    direction. Use to rehydrate a pinned app catalog on another
    instance or recover a known-good state after an upgrade.
    """
    installed_by_key = {app.key: app for app in await self.list_apps() if app.key}
    outcomes: list[RestoreOutcome] = []
    for entry in snapshot.entries:
        outcomes.append(await self._apply_restore(entry, installed_by_key, dry_run=dry_run))
    return RestoreSummary(outcomes=outcomes)
get_hub_url() async

Return the configured App Hub URL (keyAppHubUrl system setting) or None if unset.

When None, DHIS2 falls back to its hard-coded default (https://apps.dhis2.org/api on v42). The App Hub is open source (https://github.com/dhis2/app-hub); self-hosters can point their instance at a private catalog by setting this.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
async def get_hub_url(self) -> str | None:
    """Return the configured App Hub URL (`keyAppHubUrl` system setting) or None if unset.

    When None, DHIS2 falls back to its hard-coded default
    (`https://apps.dhis2.org/api` on v42). The App Hub is open
    source (https://github.com/dhis2/app-hub); self-hosters can
    point their instance at a private catalog by setting this.
    """
    return await self._client.system.setting("keyAppHubUrl", use_cache=False)
set_hub_url(url) async

Set the keyAppHubUrl system setting — points DHIS2 at a different App Hub.

Passing None clears the setting (server reverts to its hard-coded default). Any HTTP call that hits /api/appHub on this instance after this write uses the new URL.

Source code in packages/dhis2w-client/src/dhis2w_client/apps.py
async def set_hub_url(self, url: str | None) -> None:
    """Set the `keyAppHubUrl` system setting — points DHIS2 at a different App Hub.

    Passing `None` clears the setting (server reverts to its
    hard-coded default). Any HTTP call that hits `/api/appHub` on
    this instance after this write uses the new URL.
    """
    await self._client.system.set_setting("keyAppHubUrl", url)