From 0cc63800704fce641c6dad265b57632e84eb01a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 12:29:28 +0200 Subject: [PATCH 01/14] Add custom app data model, ACLs, and YAML schema --- .../_cdf_tk/client/identifiers/__init__.py | 2 + .../client/identifiers/_identifiers.py | 13 +++++ .../_cdf_tk/client/resource_classes/app.py | 46 ++++++++++++++++++ .../client/resource_classes/group/__init__.py | 2 + .../client/resource_classes/group/acls.py | 9 ++++ cognite_toolkit/_cdf_tk/client/testing.py | 2 + .../_cdf_tk/yaml_classes/__init__.py | 2 + cognite_toolkit/_cdf_tk/yaml_classes/apps.py | 47 +++++++++++++++++++ .../_cdf_tk/yaml_classes/capabilities.py | 6 +++ 9 files changed, 129 insertions(+) create mode 100644 cognite_toolkit/_cdf_tk/client/resource_classes/app.py create mode 100644 cognite_toolkit/_cdf_tk/yaml_classes/apps.py diff --git a/cognite_toolkit/_cdf_tk/client/identifiers/__init__.py b/cognite_toolkit/_cdf_tk/client/identifiers/__init__.py index 2581a49aa3..7efde1fba3 100644 --- a/cognite_toolkit/_cdf_tk/client/identifiers/__init__.py +++ b/cognite_toolkit/_cdf_tk/client/identifiers/__init__.py @@ -23,6 +23,7 @@ ViewUntypedId, ) from ._identifiers import ( + AppVersionId, DataProductVersionId, DataSetId, ExternalId, @@ -47,6 +48,7 @@ from ._migration import AssetCentricExternalId __all__ = [ + "AppVersionId", "AssetCentricExternalId", "ContainerConstraintId", "ContainerDirectId", diff --git a/cognite_toolkit/_cdf_tk/client/identifiers/_identifiers.py b/cognite_toolkit/_cdf_tk/client/identifiers/_identifiers.py index f68b805c41..c4e94cc85f 100644 --- a/cognite_toolkit/_cdf_tk/client/identifiers/_identifiers.py +++ b/cognite_toolkit/_cdf_tk/client/identifiers/_identifiers.py @@ -170,6 +170,19 @@ def _as_filename(self, include_type: bool = False) -> str: return f"{self.workflow_external_id}.{self.version}" +class AppVersionId(Identifier): + external_id: str + version: str + + def __str__(self) -> str: + return f"externalId='{self.external_id}', version='{self.version}'" + + def _as_filename(self, include_type: bool = False) -> str: + if include_type: + return f"externalId-{self.external_id}.version-{self.version}" + return f"{self.external_id}.{self.version}" + + class ThreeDModelRevisionId(Identifier): model_id: int = Field(exclude=True) id: int diff --git a/cognite_toolkit/_cdf_tk/client/resource_classes/app.py b/cognite_toolkit/_cdf_tk/client/resource_classes/app.py new file mode 100644 index 0000000000..2aa6003b16 --- /dev/null +++ b/cognite_toolkit/_cdf_tk/client/resource_classes/app.py @@ -0,0 +1,46 @@ +from typing import Any, Literal + +from cognite_toolkit._cdf_tk.client._resource_base import BaseModelObject, ResponseResource, UpdatableRequestResource +from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId + + +class AppShared(BaseModelObject): + """Fields shared between App Hosting request and response models.""" + + external_id: str + version: str + name: str + description: str | None = None + lifecycle_state: Literal["DRAFT", "PUBLISHED", "DEPRECATED", "ARCHIVED"] = "PUBLISHED" + alias: Literal["ACTIVE", "PREVIEW"] | None = None + entrypoint: str = "index.html" + + +class AppRequest(AppShared, UpdatableRequestResource): + """Local representation of a custom app version for App Hosting deployment.""" + + def as_id(self) -> AppVersionId: + return AppVersionId(external_id=self.external_id, version=self.version) + + def dump( + self, camel_case: bool = True, exclude_extra: bool = False, context: Literal["api", "toolkit"] = "api" + ) -> dict[str, Any]: + if context == "toolkit": + return super().dump(camel_case=camel_case, exclude_extra=exclude_extra) + # Body for POST /apphosting/apps (ensure-app call) + key = "externalId" if camel_case else "external_id" + body: dict[str, Any] = {key: self.external_id, "name": self.name} + if self.description: + body["description"] = self.description + return body + + def as_update(self, mode: Literal["patch", "replace"]) -> dict[str, Any]: + return {} + + +class AppResponse(AppShared, ResponseResource[AppRequest]): + """Response from App Hosting after a successful deploy.""" + + @classmethod + def request_cls(cls) -> type[AppRequest]: + return AppRequest diff --git a/cognite_toolkit/_cdf_tk/client/resource_classes/group/__init__.py b/cognite_toolkit/_cdf_tk/client/resource_classes/group/__init__.py index 5adefa9d53..beaf5e95f2 100644 --- a/cognite_toolkit/_cdf_tk/client/resource_classes/group/__init__.py +++ b/cognite_toolkit/_cdf_tk/client/resource_classes/group/__init__.py @@ -11,6 +11,7 @@ AnalyticsAcl, AnnotationsAcl, AppConfigAcl, + AppHostingAcl, AssetsAcl, AuditlogAcl, DataModelInstancesAcl, @@ -110,6 +111,7 @@ "AnnotationsAcl", "AppConfigAcl", "AppConfigScope", + "AppHostingAcl", "AssetRootIDScope", "AssetsAcl", "AuditlogAcl", diff --git a/cognite_toolkit/_cdf_tk/client/resource_classes/group/acls.py b/cognite_toolkit/_cdf_tk/client/resource_classes/group/acls.py index 4292a737c0..a40427b85c 100644 --- a/cognite_toolkit/_cdf_tk/client/resource_classes/group/acls.py +++ b/cognite_toolkit/_cdf_tk/client/resource_classes/group/acls.py @@ -108,6 +108,14 @@ class AppConfigAcl(Acl): scope: AllScope | AppConfigScope +class AppHostingAcl(Acl): + """ACL for App Hosting resources.""" + + acl_name: Literal["appHostingAcl"] = Field("appHostingAcl", exclude=True) + actions: Sequence[Literal["READ", "WRITE", "RUN"]] + scope: AllScope + + class AssetsAcl(Acl): """ACL for Assets resources.""" @@ -631,6 +639,7 @@ def _is_unknown_scope_or_action(error: ErrorDetails) -> bool: | AnalyticsAcl | AnnotationsAcl | AppConfigAcl + | AppHostingAcl | AssetsAcl | AuditlogAcl | ChartsAdminAcl diff --git a/cognite_toolkit/_cdf_tk/client/testing.py b/cognite_toolkit/_cdf_tk/client/testing.py index 32c39a086e..9a60669237 100644 --- a/cognite_toolkit/_cdf_tk/client/testing.py +++ b/cognite_toolkit/_cdf_tk/client/testing.py @@ -32,6 +32,7 @@ from ._toolkit_client import ToolAPI from .api.agents import AgentsAPI +from .api.apps import AppsAPI from .api.assets import AssetsAPI from .api.chart_scheduled_calculations import ChartScheduledCalculationsAPI from .api.charts_monitoring_job import ChartMonitoringJobsAPI @@ -161,6 +162,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.tool = MagicMock(spec=ToolAPI) self.tool.agents = MagicMock(spec=AgentsAPI) + self.tool.apps = MagicMock(spec=AppsAPI) self.tool.datapoint_subscriptions = MagicMock(spec=DatapointSubscriptionsAPI) self.tool.three_d = MagicMock(spec=ThreeDAPI) self.tool.three_d.models_classic = MagicMock(spec_set=ThreeDClassicModelsAPI) diff --git a/cognite_toolkit/_cdf_tk/yaml_classes/__init__.py b/cognite_toolkit/_cdf_tk/yaml_classes/__init__.py index 0b2b6c0806..9c19fc6169 100644 --- a/cognite_toolkit/_cdf_tk/yaml_classes/__init__.py +++ b/cognite_toolkit/_cdf_tk/yaml_classes/__init__.py @@ -8,6 +8,7 @@ """ from .agent import AgentYAML +from .apps import AppsYAML from .asset import AssetYAML from .base import BaseModelResource, ToolkitResource from .cognitefile import CogniteFileYAML @@ -65,6 +66,7 @@ __all__ = [ "AgentYAML", + "AppsYAML", "AssetYAML", "BaseModelResource", "CogniteFileYAML", diff --git a/cognite_toolkit/_cdf_tk/yaml_classes/apps.py b/cognite_toolkit/_cdf_tk/yaml_classes/apps.py new file mode 100644 index 0000000000..cb87b52adc --- /dev/null +++ b/cognite_toolkit/_cdf_tk/yaml_classes/apps.py @@ -0,0 +1,47 @@ +from typing import Literal + +from pydantic import Field + +from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId + +from .base import ToolkitResource + + +class AppsYAML(ToolkitResource): + """Custom app deployed via the CDF App Hosting API.""" + + external_id: str = Field( + description="Stable app identifier; must match the sibling directory name containing app sources.", + max_length=255, + ) + version: str = Field(description="Version sent to App Hosting on upload.", max_length=64) + name: str = Field(description="Display name for the app.", max_length=140) + description: str | None = Field(default=None, description="App description.", max_length=500) + lifecycle_state: Literal["DRAFT", "PUBLISHED", "DEPRECATED", "ARCHIVED"] = Field( + default="PUBLISHED", + description="Lifecycle state of the version. Transitions are forward-only: DRAFT → PUBLISHED → DEPRECATED → ARCHIVED.", + ) + alias: Literal["ACTIVE", "PREVIEW"] | None = Field( + default=None, + description=( + "Alias assigned to the version. ACTIVE is unique per app (set automatically clears the previous holder). " + "PREVIEW allows multiple. Only PUBLISHED versions can hold an alias." + ), + ) + entrypoint: str = Field( + default="index.html", + description="Path to the entry HTML inside the version zip.", + ) + source_path: str | None = Field( + default=None, + description=( + "Path to the app source, relative to this YAML file. " + "Can point at the build output directory directly, or at the app source root whose " + "dist/ subdirectory contains the build output (dist/ is preferred when it contains " + "the entrypoint). The entrypoint file must exist at the resolved location. " + "Defaults to a sibling directory named after externalId." + ), + ) + + def as_id(self) -> AppVersionId: + return AppVersionId(external_id=self.external_id, version=self.version) diff --git a/cognite_toolkit/_cdf_tk/yaml_classes/capabilities.py b/cognite_toolkit/_cdf_tk/yaml_classes/capabilities.py index 6a7b3aac5f..8608540f79 100644 --- a/cognite_toolkit/_cdf_tk/yaml_classes/capabilities.py +++ b/cognite_toolkit/_cdf_tk/yaml_classes/capabilities.py @@ -197,6 +197,12 @@ class AppConfigAcl(Capability): scope: AllScope | AppConfigScope +class AppHostingAcl(Capability): + _capability_name = "appHostingAcl" + actions: list[Literal["READ", "WRITE", "RUN"]] + scope: AllScope + + class AssetsAcl(Capability): _capability_name = "assetsAcl" actions: list[Literal["READ", "WRITE"]] From 58492c2d2e569edf6c74c2be6775495ab330caec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 12:59:00 +0200 Subject: [PATCH 02/14] external_id->app_external_id to match version api --- .../_cdf_tk/client/identifiers/_identifiers.py | 8 ++++---- cognite_toolkit/_cdf_tk/client/resource_classes/app.py | 2 +- cognite_toolkit/_cdf_tk/yaml_classes/apps.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/client/identifiers/_identifiers.py b/cognite_toolkit/_cdf_tk/client/identifiers/_identifiers.py index c4e94cc85f..94bbfa1723 100644 --- a/cognite_toolkit/_cdf_tk/client/identifiers/_identifiers.py +++ b/cognite_toolkit/_cdf_tk/client/identifiers/_identifiers.py @@ -171,16 +171,16 @@ def _as_filename(self, include_type: bool = False) -> str: class AppVersionId(Identifier): - external_id: str + app_external_id: str version: str def __str__(self) -> str: - return f"externalId='{self.external_id}', version='{self.version}'" + return f"appExternalId='{self.app_external_id}', version='{self.version}'" def _as_filename(self, include_type: bool = False) -> str: if include_type: - return f"externalId-{self.external_id}.version-{self.version}" - return f"{self.external_id}.{self.version}" + return f"appExternalId-{self.app_external_id}.version-{self.version}" + return f"{self.app_external_id}.{self.version}" class ThreeDModelRevisionId(Identifier): diff --git a/cognite_toolkit/_cdf_tk/client/resource_classes/app.py b/cognite_toolkit/_cdf_tk/client/resource_classes/app.py index 2aa6003b16..af7249055b 100644 --- a/cognite_toolkit/_cdf_tk/client/resource_classes/app.py +++ b/cognite_toolkit/_cdf_tk/client/resource_classes/app.py @@ -20,7 +20,7 @@ class AppRequest(AppShared, UpdatableRequestResource): """Local representation of a custom app version for App Hosting deployment.""" def as_id(self) -> AppVersionId: - return AppVersionId(external_id=self.external_id, version=self.version) + return AppVersionId(app_external_id=self.external_id, version=self.version) def dump( self, camel_case: bool = True, exclude_extra: bool = False, context: Literal["api", "toolkit"] = "api" diff --git a/cognite_toolkit/_cdf_tk/yaml_classes/apps.py b/cognite_toolkit/_cdf_tk/yaml_classes/apps.py index cb87b52adc..b3b4697d8e 100644 --- a/cognite_toolkit/_cdf_tk/yaml_classes/apps.py +++ b/cognite_toolkit/_cdf_tk/yaml_classes/apps.py @@ -44,4 +44,4 @@ class AppsYAML(ToolkitResource): ) def as_id(self) -> AppVersionId: - return AppVersionId(external_id=self.external_id, version=self.version) + return AppVersionId(app_external_id=self.external_id, version=self.version) From 5740a9987fbcf4974563957e8963a0bef05bee2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 13:04:16 +0200 Subject: [PATCH 03/14] Refactoring --- cognite_toolkit/_cdf_tk/client/resource_classes/app.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/client/resource_classes/app.py b/cognite_toolkit/_cdf_tk/client/resource_classes/app.py index af7249055b..8b177ef278 100644 --- a/cognite_toolkit/_cdf_tk/client/resource_classes/app.py +++ b/cognite_toolkit/_cdf_tk/client/resource_classes/app.py @@ -4,9 +4,7 @@ from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId -class AppShared(BaseModelObject): - """Fields shared between App Hosting request and response models.""" - +class App(BaseModelObject): external_id: str version: str name: str @@ -16,7 +14,7 @@ class AppShared(BaseModelObject): entrypoint: str = "index.html" -class AppRequest(AppShared, UpdatableRequestResource): +class AppRequest(App, UpdatableRequestResource): """Local representation of a custom app version for App Hosting deployment.""" def as_id(self) -> AppVersionId: @@ -38,7 +36,7 @@ def as_update(self, mode: Literal["patch", "replace"]) -> dict[str, Any]: return {} -class AppResponse(AppShared, ResponseResource[AppRequest]): +class AppResponse(App, ResponseResource[AppRequest]): """Response from App Hosting after a successful deploy.""" @classmethod From 4df33dc50ad4ddc7747cd70a4e9f61056fe2d4c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 13:06:11 +0200 Subject: [PATCH 04/14] Fix CI --- cognite_toolkit/_cdf_tk/client/testing.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/client/testing.py b/cognite_toolkit/_cdf_tk/client/testing.py index 9a60669237..32c39a086e 100644 --- a/cognite_toolkit/_cdf_tk/client/testing.py +++ b/cognite_toolkit/_cdf_tk/client/testing.py @@ -32,7 +32,6 @@ from ._toolkit_client import ToolAPI from .api.agents import AgentsAPI -from .api.apps import AppsAPI from .api.assets import AssetsAPI from .api.chart_scheduled_calculations import ChartScheduledCalculationsAPI from .api.charts_monitoring_job import ChartMonitoringJobsAPI @@ -162,7 +161,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.tool = MagicMock(spec=ToolAPI) self.tool.agents = MagicMock(spec=AgentsAPI) - self.tool.apps = MagicMock(spec=AppsAPI) self.tool.datapoint_subscriptions = MagicMock(spec=DatapointSubscriptionsAPI) self.tool.three_d = MagicMock(spec=ThreeDAPI) self.tool.three_d.models_classic = MagicMock(spec_set=ThreeDClassicModelsAPI) From 62a51927ae96e54f06e38e63d39b4177c4ee368d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 13:17:34 +0200 Subject: [PATCH 05/14] Remove as_update --- cognite_toolkit/_cdf_tk/client/resource_classes/app.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/client/resource_classes/app.py b/cognite_toolkit/_cdf_tk/client/resource_classes/app.py index 8b177ef278..30657afbd6 100644 --- a/cognite_toolkit/_cdf_tk/client/resource_classes/app.py +++ b/cognite_toolkit/_cdf_tk/client/resource_classes/app.py @@ -1,6 +1,6 @@ from typing import Any, Literal -from cognite_toolkit._cdf_tk.client._resource_base import BaseModelObject, ResponseResource, UpdatableRequestResource +from cognite_toolkit._cdf_tk.client._resource_base import BaseModelObject, RequestResource, ResponseResource from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId @@ -14,7 +14,7 @@ class App(BaseModelObject): entrypoint: str = "index.html" -class AppRequest(App, UpdatableRequestResource): +class AppRequest(App, RequestResource): """Local representation of a custom app version for App Hosting deployment.""" def as_id(self) -> AppVersionId: @@ -32,9 +32,6 @@ def dump( body["description"] = self.description return body - def as_update(self, mode: Literal["patch", "replace"]) -> dict[str, Any]: - return {} - class AppResponse(App, ResponseResource[AppRequest]): """Response from App Hosting after a successful deploy.""" From 28d0db91e909de3b2d0ac891629e78b31602b939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 12:30:17 +0200 Subject: [PATCH 06/14] Add AppsAPI client for the App Hosting API --- .../_cdf_tk/client/_toolkit_client.py | 2 + cognite_toolkit/_cdf_tk/client/api/apps.py | 285 ++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 cognite_toolkit/_cdf_tk/client/api/apps.py diff --git a/cognite_toolkit/_cdf_tk/client/_toolkit_client.py b/cognite_toolkit/_cdf_tk/client/_toolkit_client.py index bd97cfbcc5..9962774666 100644 --- a/cognite_toolkit/_cdf_tk/client/_toolkit_client.py +++ b/cognite_toolkit/_cdf_tk/client/_toolkit_client.py @@ -10,6 +10,7 @@ from .api.agents import AgentsAPI from .api.alerts import AlertsAPI from .api.annotations import AnnotationsAPI +from .api.apps import AppsAPI from .api.assets import AssetsAPI from .api.canvas import IndustrialCanvasAPI from .api.cognite_files import CogniteFilesAPI @@ -64,6 +65,7 @@ class ToolAPI: def __init__(self, http_client: HTTPClient, console: Console) -> None: self.http_client = http_client self.agents = AgentsAPI(http_client) + self.apps = AppsAPI(http_client) self.annotations = AnnotationsAPI(http_client) self.assets = AssetsAPI(http_client) self.cognite_files = CogniteFilesAPI(http_client) diff --git a/cognite_toolkit/_cdf_tk/client/api/apps.py b/cognite_toolkit/_cdf_tk/client/api/apps.py new file mode 100644 index 0000000000..e63b440c74 --- /dev/null +++ b/cognite_toolkit/_cdf_tk/client/api/apps.py @@ -0,0 +1,285 @@ +"""AppsAPI: Custom apps deployed via the CDF App Hosting API.""" + +import json +import uuid +from collections.abc import Iterable, Sequence +from typing import Literal + +from cognite_toolkit._cdf_tk.client.http_client import HTTPClient, RequestMessage +from cognite_toolkit._cdf_tk.client.http_client._data_classes import FailedResponse, SuccessResponse +from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId, ExternalId +from cognite_toolkit._cdf_tk.client.resource_classes.app import AppRequest, AppResponse + + +def _build_multipart(fields: dict[str, str], zip_bytes: bytes, filename: str = "app.zip") -> tuple[bytes, str]: + boundary = uuid.uuid4().hex + parts: list[bytes] = [] + for name, value in fields.items(): + parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode()) + parts.append( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' + f"Content-Type: application/zip\r\n" + f"\r\n".encode() + + zip_bytes + + b"\r\n" + ) + parts.append(f"--{boundary}--\r\n".encode()) + return b"".join(parts), f"multipart/form-data; boundary={boundary}" + + +_LIFECYCLE_ORDER = ["DRAFT", "PUBLISHED", "DEPRECATED", "ARCHIVED"] + + +class AppsAPI: + """Client for the CDF App Hosting API (POST /apphosting/...).""" + + def __init__(self, http_client: HTTPClient) -> None: + self._http_client = http_client + + def _url(self, path: str) -> str: + return self._http_client.config.create_api_url(path) + + def ensure_app(self, item: AppRequest) -> None: + """POST /apphosting/apps — create the app if it does not exist; 409 = already exists (idempotent).""" + request = RequestMessage( + endpoint_url=self._url("/apphosting/apps"), + method="POST", + body_content={"items": [item.dump()]}, + ) + result = self._http_client.request_single_retries(request) + if isinstance(result, SuccessResponse) or (isinstance(result, FailedResponse) and result.status_code == 409): + return + result.get_success_or_raise(request) + + def upload_version( + self, + external_id: str, + version: str, + entrypoint: str, + zip_bytes: bytes, + ) -> None: + """POST /apphosting/apps/{externalId}/versions — multipart upload of the zipped app.""" + body, content_type = _build_multipart( + fields={"version": version, "entryPath": entrypoint}, + zip_bytes=zip_bytes, + ) + request = RequestMessage( + endpoint_url=self._url(f"/apphosting/apps/{external_id}/versions"), + method="POST", + data_content=body, + content_type=content_type, + disable_gzip=True, + ) + result = self._http_client.request_single_retries(request) + # 409 means this exact version already exists — treat as success (idempotent). + if isinstance(result, SuccessResponse) or (isinstance(result, FailedResponse) and result.status_code == 409): + return + result.get_success_or_raise(request) + + def transition_lifecycle( + self, + external_id: str, + version: str, + target: Literal["DRAFT", "PUBLISHED", "DEPRECATED", "ARCHIVED"], + ) -> None: + """POST /apphosting/apps/{externalId}/versions/update — advance version lifecycle state (forward-only).""" + request = RequestMessage( + endpoint_url=self._url(f"/apphosting/apps/{external_id}/versions/update"), + method="POST", + body_content={ + "items": [ + { + "version": version, + "update": {"lifecycleState": {"set": target}}, + } + ] + }, + ) + self._http_client.request_single_retries(request).get_success_or_raise(request) + + def set_alias( + self, + external_id: str, + version: str, + alias: Literal["ACTIVE", "PREVIEW"] | None, + ) -> None: + """POST /apphosting/apps/{externalId}/versions/update — set or clear the version alias.""" + if alias is None: + alias_update: dict = {"setNull": True} + else: + alias_update = {"set": alias} + request = RequestMessage( + endpoint_url=self._url(f"/apphosting/apps/{external_id}/versions/update"), + method="POST", + body_content={ + "items": [ + { + "version": version, + "update": {"alias": alias_update}, + } + ] + }, + ) + self._http_client.request_single_retries(request).get_success_or_raise(request) + + def list_app_versions( + self, + external_id: str, + alias: str | None = None, + limit: int = 25, + ) -> list[AppResponse]: + """POST /apphosting/apps/{externalId}/versions/list — list versions for one app, optionally filtered by alias.""" + body: dict = {"limit": limit} + if alias is not None: + body["filter"] = {"alias": alias} + request = RequestMessage( + endpoint_url=self._url(f"/apphosting/apps/{external_id}/versions/list"), + method="POST", + body_content=body, + ) + result = self._http_client.request_single_retries(request) + if not isinstance(result, SuccessResponse): + if isinstance(result, FailedResponse) and result.status_code in (400, 404): + return [] + result.get_success_or_raise(request) + return [] + data = json.loads(result.body) + return [ + AppResponse( + external_id=item.get("appExternalId", external_id), + version=item["version"], + name="", + description=None, + lifecycle_state=item.get("lifecycleState", "DRAFT"), + alias=item.get("alias"), + entrypoint=item.get("entrypoint", "index.html"), + ) + for item in data.get("items", []) + ] + + def retrieve_version(self, external_id: str, version: str, ignore_unknown_ids: bool = False) -> AppResponse | None: + """Retrieve version metadata + app-level name/description in two calls.""" + version_request = RequestMessage( + endpoint_url=self._url(f"/apphosting/apps/{external_id}/versions/{version}"), + method="GET", + ) + version_result = self._http_client.request_single_retries(version_request) + if not isinstance(version_result, SuccessResponse): + if ( + isinstance(version_result, FailedResponse) + and version_result.status_code in (400, 404) + and ignore_unknown_ids + ): + return None + version_result.get_success_or_raise(version_request) + return None + + version_data = json.loads(version_result.body) + + app_request = RequestMessage( + endpoint_url=self._url(f"/apphosting/apps/{external_id}"), + method="GET", + ) + app_result = self._http_client.request_single_retries(app_request) + app_data = json.loads(app_result.body) if isinstance(app_result, SuccessResponse) else {} + + return AppResponse( + external_id=version_data.get("appExternalId", external_id), + version=version_data.get("version", version), + name=app_data.get("name", ""), + description=app_data.get("description"), + lifecycle_state=version_data.get("lifecycleState", "DRAFT"), + alias=version_data.get("alias"), + entrypoint=version_data.get("entrypoint", "index.html"), + ) + + def retrieve(self, items: Sequence[ExternalId], ignore_unknown_ids: bool = False) -> list[AppResponse]: + """GET /apphosting/apps/{appExternalId} for each id.""" + results: list[AppResponse] = [] + for item in items: + request = RequestMessage( + endpoint_url=self._url(f"/apphosting/apps/{item.external_id}"), + method="GET", + ) + result = self._http_client.request_single_retries(request) + if isinstance(result, SuccessResponse): + data = json.loads(result.body) + results.append( + AppResponse( + external_id=data["externalId"], + version="", + name=data.get("name", ""), + description=data.get("description"), + ) + ) + elif isinstance(result, FailedResponse) and result.status_code in (400, 404) and ignore_unknown_ids: + continue + else: + result.get_success_or_raise(request) + return results + + def iterate(self, limit: int | None = 100) -> Iterable[list[AppResponse]]: + """POST /apphosting/versions/list — paginated list of all versions across all apps.""" + cursor: str | None = None + page_limit = min(limit, 1000) if limit is not None else 1000 + fetched = 0 + while True: + body: dict = {"limit": page_limit} + if cursor: + body["cursor"] = cursor + request = RequestMessage( + endpoint_url=self._url("/apphosting/versions/list"), + method="POST", + body_content=body, + ) + result = self._http_client.request_single_retries(request) + if not isinstance(result, SuccessResponse): + result.get_success_or_raise(request) + break + + data = json.loads(result.body) + page_items = [ + AppResponse( + external_id=item["appExternalId"], + version=item["version"], + name="", + description=None, + lifecycle_state=item.get("lifecycleState", "DRAFT"), + alias=item.get("alias"), + entrypoint=item.get("entrypoint", "index.html"), + ) + for item in data.get("items", []) + ] + if page_items: + yield page_items + fetched += len(page_items) + + cursor = data.get("nextCursor") + if not cursor or (limit is not None and fetched >= limit): + break + + def delete_version(self, external_id: str, versions: Sequence[AppVersionId]) -> None: + """POST /apphosting/apps/{externalId}/versions/delete — delete specific versions of an app.""" + if not versions: + return + request = RequestMessage( + endpoint_url=self._url(f"/apphosting/apps/{external_id}/versions/delete"), + method="POST", + body_content={"items": [{"version": v.version} for v in versions]}, + ) + self._http_client.request_single_retries(request).get_success_or_raise(request) + + def delete(self, items: Sequence[ExternalId], ignore_unknown_ids: bool = False) -> None: + """POST /apphosting/apps/delete — soft-delete apps and all their versions.""" + if not items: + return + request = RequestMessage( + endpoint_url=self._url("/apphosting/apps/delete"), + method="POST", + body_content={ + "items": [{"externalId": item.external_id} for item in items], + "ignoreUnknownIds": ignore_unknown_ids, + }, + ) + self._http_client.request_single_retries(request).get_success_or_raise(request) From 2cb5b9c3ad6a193a26486d58f408dbe67c060b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 13:30:09 +0200 Subject: [PATCH 07/14] Replace transition_lifecycle and set_alias with combined update_version --- cognite_toolkit/_cdf_tk/client/api/apps.py | 43 ++-------------------- 1 file changed, 3 insertions(+), 40 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/client/api/apps.py b/cognite_toolkit/_cdf_tk/client/api/apps.py index e63b440c74..03ac107890 100644 --- a/cognite_toolkit/_cdf_tk/client/api/apps.py +++ b/cognite_toolkit/_cdf_tk/client/api/apps.py @@ -77,49 +77,12 @@ def upload_version( return result.get_success_or_raise(request) - def transition_lifecycle( - self, - external_id: str, - version: str, - target: Literal["DRAFT", "PUBLISHED", "DEPRECATED", "ARCHIVED"], - ) -> None: - """POST /apphosting/apps/{externalId}/versions/update — advance version lifecycle state (forward-only).""" - request = RequestMessage( - endpoint_url=self._url(f"/apphosting/apps/{external_id}/versions/update"), - method="POST", - body_content={ - "items": [ - { - "version": version, - "update": {"lifecycleState": {"set": target}}, - } - ] - }, - ) - self._http_client.request_single_retries(request).get_success_or_raise(request) - - def set_alias( - self, - external_id: str, - version: str, - alias: Literal["ACTIVE", "PREVIEW"] | None, - ) -> None: - """POST /apphosting/apps/{externalId}/versions/update — set or clear the version alias.""" - if alias is None: - alias_update: dict = {"setNull": True} - else: - alias_update = {"set": alias} + def update_version(self, external_id: str, version: str, update: dict) -> None: + """POST /apphosting/apps/{externalId}/versions/update — apply one or more field updates to a version.""" request = RequestMessage( endpoint_url=self._url(f"/apphosting/apps/{external_id}/versions/update"), method="POST", - body_content={ - "items": [ - { - "version": version, - "update": {"alias": alias_update}, - } - ] - }, + body_content={"items": [{"version": version, "update": update}]}, ) self._http_client.request_single_retries(request).get_success_or_raise(request) From 67d77d021e3554ef10e39bc83b86b489d98de1a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 13:45:06 +0200 Subject: [PATCH 08/14] Fix lint --- cognite_toolkit/_cdf_tk/client/api/apps.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cognite_toolkit/_cdf_tk/client/api/apps.py b/cognite_toolkit/_cdf_tk/client/api/apps.py index 03ac107890..f05e84ec64 100644 --- a/cognite_toolkit/_cdf_tk/client/api/apps.py +++ b/cognite_toolkit/_cdf_tk/client/api/apps.py @@ -3,7 +3,6 @@ import json import uuid from collections.abc import Iterable, Sequence -from typing import Literal from cognite_toolkit._cdf_tk.client.http_client import HTTPClient, RequestMessage from cognite_toolkit._cdf_tk.client.http_client._data_classes import FailedResponse, SuccessResponse From 1d1aaabb234927f04a4894ad54292d847b0fe82e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 13:56:37 +0200 Subject: [PATCH 09/14] Cleanup --- cognite_toolkit/_cdf_tk/client/api/apps.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/client/api/apps.py b/cognite_toolkit/_cdf_tk/client/api/apps.py index f05e84ec64..031c3e5f33 100644 --- a/cognite_toolkit/_cdf_tk/client/api/apps.py +++ b/cognite_toolkit/_cdf_tk/client/api/apps.py @@ -27,9 +27,6 @@ def _build_multipart(fields: dict[str, str], zip_bytes: bytes, filename: str = " return b"".join(parts), f"multipart/form-data; boundary={boundary}" -_LIFECYCLE_ORDER = ["DRAFT", "PUBLISHED", "DEPRECATED", "ARCHIVED"] - - class AppsAPI: """Client for the CDF App Hosting API (POST /apphosting/...).""" From ae88737a63dbff5639aa99b3b64534bfd0aaa03c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 14:18:47 +0200 Subject: [PATCH 10/14] Remove unused AppsAPI methods: list_app_versions, retrieve, delete --- cognite_toolkit/_cdf_tk/client/api/apps.py | 75 +--------------------- 1 file changed, 1 insertion(+), 74 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/client/api/apps.py b/cognite_toolkit/_cdf_tk/client/api/apps.py index 031c3e5f33..83b8d0ef60 100644 --- a/cognite_toolkit/_cdf_tk/client/api/apps.py +++ b/cognite_toolkit/_cdf_tk/client/api/apps.py @@ -6,7 +6,7 @@ from cognite_toolkit._cdf_tk.client.http_client import HTTPClient, RequestMessage from cognite_toolkit._cdf_tk.client.http_client._data_classes import FailedResponse, SuccessResponse -from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId, ExternalId +from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId from cognite_toolkit._cdf_tk.client.resource_classes.app import AppRequest, AppResponse @@ -82,41 +82,6 @@ def update_version(self, external_id: str, version: str, update: dict) -> None: ) self._http_client.request_single_retries(request).get_success_or_raise(request) - def list_app_versions( - self, - external_id: str, - alias: str | None = None, - limit: int = 25, - ) -> list[AppResponse]: - """POST /apphosting/apps/{externalId}/versions/list — list versions for one app, optionally filtered by alias.""" - body: dict = {"limit": limit} - if alias is not None: - body["filter"] = {"alias": alias} - request = RequestMessage( - endpoint_url=self._url(f"/apphosting/apps/{external_id}/versions/list"), - method="POST", - body_content=body, - ) - result = self._http_client.request_single_retries(request) - if not isinstance(result, SuccessResponse): - if isinstance(result, FailedResponse) and result.status_code in (400, 404): - return [] - result.get_success_or_raise(request) - return [] - data = json.loads(result.body) - return [ - AppResponse( - external_id=item.get("appExternalId", external_id), - version=item["version"], - name="", - description=None, - lifecycle_state=item.get("lifecycleState", "DRAFT"), - alias=item.get("alias"), - entrypoint=item.get("entrypoint", "index.html"), - ) - for item in data.get("items", []) - ] - def retrieve_version(self, external_id: str, version: str, ignore_unknown_ids: bool = False) -> AppResponse | None: """Retrieve version metadata + app-level name/description in two calls.""" version_request = RequestMessage( @@ -153,31 +118,6 @@ def retrieve_version(self, external_id: str, version: str, ignore_unknown_ids: b entrypoint=version_data.get("entrypoint", "index.html"), ) - def retrieve(self, items: Sequence[ExternalId], ignore_unknown_ids: bool = False) -> list[AppResponse]: - """GET /apphosting/apps/{appExternalId} for each id.""" - results: list[AppResponse] = [] - for item in items: - request = RequestMessage( - endpoint_url=self._url(f"/apphosting/apps/{item.external_id}"), - method="GET", - ) - result = self._http_client.request_single_retries(request) - if isinstance(result, SuccessResponse): - data = json.loads(result.body) - results.append( - AppResponse( - external_id=data["externalId"], - version="", - name=data.get("name", ""), - description=data.get("description"), - ) - ) - elif isinstance(result, FailedResponse) and result.status_code in (400, 404) and ignore_unknown_ids: - continue - else: - result.get_success_or_raise(request) - return results - def iterate(self, limit: int | None = 100) -> Iterable[list[AppResponse]]: """POST /apphosting/versions/list — paginated list of all versions across all apps.""" cursor: str | None = None @@ -229,16 +169,3 @@ def delete_version(self, external_id: str, versions: Sequence[AppVersionId]) -> ) self._http_client.request_single_retries(request).get_success_or_raise(request) - def delete(self, items: Sequence[ExternalId], ignore_unknown_ids: bool = False) -> None: - """POST /apphosting/apps/delete — soft-delete apps and all their versions.""" - if not items: - return - request = RequestMessage( - endpoint_url=self._url("/apphosting/apps/delete"), - method="POST", - body_content={ - "items": [{"externalId": item.external_id} for item in items], - "ignoreUnknownIds": ignore_unknown_ids, - }, - ) - self._http_client.request_single_retries(request).get_success_or_raise(request) From 91a83462481719cfafcb46b675f8d6d14a8cc354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 14:36:51 +0200 Subject: [PATCH 11/14] Add AppsAPI unit tests --- .../test_cdf_tk/test_client/test_cdf_apis.py | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/tests/test_unit/test_cdf_tk/test_client/test_cdf_apis.py b/tests/test_unit/test_cdf_tk/test_client/test_cdf_apis.py index a50cad8183..2a9039e25a 100644 --- a/tests/test_unit/test_cdf_tk/test_client/test_cdf_apis.py +++ b/tests/test_unit/test_cdf_tk/test_client/test_cdf_apis.py @@ -12,6 +12,7 @@ from cognite_toolkit._cdf_tk.client._resource_base import ResponseResource from cognite_toolkit._cdf_tk.client.api.alert_channels import AlertChannelsAPI from cognite_toolkit._cdf_tk.client.api.annotations import AnnotationsAPI +from cognite_toolkit._cdf_tk.client.api.apps import AppsAPI from cognite_toolkit._cdf_tk.client.api.chart_scheduled_calculations import ChartScheduledCalculationsAPI from cognite_toolkit._cdf_tk.client.api.charts_folders import ChartFoldersAPI from cognite_toolkit._cdf_tk.client.api.charts_monitoring_job import ChartMonitoringJobsAPI @@ -34,10 +35,11 @@ from cognite_toolkit._cdf_tk.client.cdf_client import CDFResourceAPI, PagedResponse from cognite_toolkit._cdf_tk.client.cdf_client.api import APIMethod from cognite_toolkit._cdf_tk.client.http_client import HTTPClient -from cognite_toolkit._cdf_tk.client.identifiers import ExternalId, PrincipalId +from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId, ExternalId, PrincipalId from cognite_toolkit._cdf_tk.client.request_classes.filters import AnnotationFilter from cognite_toolkit._cdf_tk.client.resource_classes.alert_channel import AlertChannelResponse from cognite_toolkit._cdf_tk.client.resource_classes.annotation import AnnotationResponse +from cognite_toolkit._cdf_tk.client.resource_classes.app import AppRequest from cognite_toolkit._cdf_tk.client.resource_classes.chart_folder import ( ChartFolderRequest, ChartFolderResponse, @@ -1226,6 +1228,76 @@ def test_alert_channels_api_list_method( assert len(listed) == 1 assert listed[0].dump() == resource + def test_apps_api_methods(self, toolkit_config: ToolkitClientConfig, respx_mock: respx.MockRouter) -> None: + config = toolkit_config + api = AppsAPI(HTTPClient(config)) + app_external_id = "my-app" + version = "1.0.0" + app_request = AppRequest(external_id=app_external_id, version=version, name="My App") + version_json = { + "appExternalId": app_external_id, + "version": version, + "lifecycleState": "DRAFT", + "entrypoint": "index.html", + } + app_json = {"externalId": app_external_id, "name": "My App"} + + # Test ensure_app (200 and 409 both succeed) + respx_mock.post(config.create_api_url("/apphosting/apps")).mock(return_value=httpx.Response(status_code=200)) + api.ensure_app(app_request) + respx_mock.post(config.create_api_url("/apphosting/apps")).mock(return_value=httpx.Response(status_code=409)) + api.ensure_app(app_request) + + # Test upload_version (200 and 409 both succeed) + respx_mock.post(config.create_api_url(f"/apphosting/apps/{app_external_id}/versions")).mock( + return_value=httpx.Response(status_code=200) + ) + api.upload_version(app_external_id, version, "index.html", b"fake-zip") + respx_mock.post(config.create_api_url(f"/apphosting/apps/{app_external_id}/versions")).mock( + return_value=httpx.Response(status_code=409) + ) + api.upload_version(app_external_id, version, "index.html", b"fake-zip") + + # Test update_version + respx_mock.post(config.create_api_url(f"/apphosting/apps/{app_external_id}/versions/update")).mock( + return_value=httpx.Response(status_code=200, json={"items": [version_json]}) + ) + api.update_version(app_external_id, version, {"lifecycleState": {"set": "PUBLISHED"}}) + + # Test retrieve_version (two calls merged into one response) + respx_mock.get(config.create_api_url(f"/apphosting/apps/{app_external_id}/versions/{version}")).mock( + return_value=httpx.Response(status_code=200, json=version_json) + ) + respx_mock.get(config.create_api_url(f"/apphosting/apps/{app_external_id}")).mock( + return_value=httpx.Response(status_code=200, json=app_json) + ) + retrieved = api.retrieve_version(app_external_id, version) + assert retrieved is not None + assert retrieved.version == version + assert retrieved.name == "My App" + assert retrieved.lifecycle_state == "DRAFT" + + # Test retrieve_version with 404 and ignore_unknown_ids + respx_mock.get(config.create_api_url(f"/apphosting/apps/{app_external_id}/versions/{version}")).mock( + return_value=httpx.Response(status_code=404) + ) + assert api.retrieve_version(app_external_id, version, ignore_unknown_ids=True) is None + + # Test iterate + respx_mock.post(config.create_api_url("/apphosting/versions/list")).mock( + return_value=httpx.Response(status_code=200, json={"items": [version_json]}) + ) + batches = list(api.iterate(limit=10)) + assert len(batches) == 1 + assert batches[0][0].version == version + + # Test delete_version + respx_mock.post(config.create_api_url(f"/apphosting/apps/{app_external_id}/versions/delete")).mock( + return_value=httpx.Response(status_code=200) + ) + api.delete_version(app_external_id, [AppVersionId(app_external_id=app_external_id, version=version)]) + assert len(respx_mock.calls) >= 1 + def test_task_move_type_to_field_handles_none_validation_data() -> None: """Pydantic may supply ValidationInfo.data as None; avoid 'in' on None (deploy dry-run).""" From 1735ad805bfa4d56490adc1c98a362ba9752cfae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 14:41:14 +0200 Subject: [PATCH 12/14] Fix lint --- cognite_toolkit/_cdf_tk/client/api/apps.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cognite_toolkit/_cdf_tk/client/api/apps.py b/cognite_toolkit/_cdf_tk/client/api/apps.py index 83b8d0ef60..46b44ccc90 100644 --- a/cognite_toolkit/_cdf_tk/client/api/apps.py +++ b/cognite_toolkit/_cdf_tk/client/api/apps.py @@ -168,4 +168,3 @@ def delete_version(self, external_id: str, versions: Sequence[AppVersionId]) -> body_content={"items": [{"version": v.version} for v in versions]}, ) self._http_client.request_single_retries(request).get_success_or_raise(request) - From 807d97d6f6755dd1cdcd4189babf325fa20bb178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Thu, 7 May 2026 09:39:59 +0200 Subject: [PATCH 13/14] address review comment --- cognite_toolkit/_cdf_tk/client/resource_classes/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cognite_toolkit/_cdf_tk/client/resource_classes/app.py b/cognite_toolkit/_cdf_tk/client/resource_classes/app.py index 30657afbd6..99e344cbd2 100644 --- a/cognite_toolkit/_cdf_tk/client/resource_classes/app.py +++ b/cognite_toolkit/_cdf_tk/client/resource_classes/app.py @@ -28,7 +28,7 @@ def dump( # Body for POST /apphosting/apps (ensure-app call) key = "externalId" if camel_case else "external_id" body: dict[str, Any] = {key: self.external_id, "name": self.name} - if self.description: + if self.description is not None: body["description"] = self.description return body From 133dcc30c7ee9357f48c065f1cb6d98e016af9aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Tue, 12 May 2026 12:37:17 +0200 Subject: [PATCH 14/14] WIP --- cognite_toolkit/_cdf_tk/client/api/apps.py | 39 +++-------- .../_cdf_tk/client/http_client/_client.py | 66 ++++++++++++++----- .../test_cdf_tk/test_client/test_cdf_apis.py | 9 ++- 3 files changed, 65 insertions(+), 49 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/client/api/apps.py b/cognite_toolkit/_cdf_tk/client/api/apps.py index 46b44ccc90..ca1dcb36c1 100644 --- a/cognite_toolkit/_cdf_tk/client/api/apps.py +++ b/cognite_toolkit/_cdf_tk/client/api/apps.py @@ -1,32 +1,16 @@ """AppsAPI: Custom apps deployed via the CDF App Hosting API.""" import json -import uuid from collections.abc import Iterable, Sequence +from pathlib import Path from cognite_toolkit._cdf_tk.client.http_client import HTTPClient, RequestMessage from cognite_toolkit._cdf_tk.client.http_client._data_classes import FailedResponse, SuccessResponse +from cognite_toolkit._cdf_tk.client.http_client._exception import ToolkitAPIError from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId from cognite_toolkit._cdf_tk.client.resource_classes.app import AppRequest, AppResponse -def _build_multipart(fields: dict[str, str], zip_bytes: bytes, filename: str = "app.zip") -> tuple[bytes, str]: - boundary = uuid.uuid4().hex - parts: list[bytes] = [] - for name, value in fields.items(): - parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode()) - parts.append( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' - f"Content-Type: application/zip\r\n" - f"\r\n".encode() - + zip_bytes - + b"\r\n" - ) - parts.append(f"--{boundary}--\r\n".encode()) - return b"".join(parts), f"multipart/form-data; boundary={boundary}" - - class AppsAPI: """Client for the CDF App Hosting API (POST /apphosting/...).""" @@ -53,25 +37,20 @@ def upload_version( external_id: str, version: str, entrypoint: str, - zip_bytes: bytes, + zip_path: Path, ) -> None: """POST /apphosting/apps/{externalId}/versions — multipart upload of the zipped app.""" - body, content_type = _build_multipart( - fields={"version": version, "entryPath": entrypoint}, - zip_bytes=zip_bytes, - ) - request = RequestMessage( - endpoint_url=self._url(f"/apphosting/apps/{external_id}/versions"), + result = self._http_client.request_raw_retries( method="POST", - data_content=body, - content_type=content_type, - disable_gzip=True, + url=self._url(f"/apphosting/apps/{external_id}/versions"), + files={"file": ("app.zip", zip_path, "application/zip")}, + data={"version": version, "entryPath": entrypoint}, + add_auth=True, ) - result = self._http_client.request_single_retries(request) # 409 means this exact version already exists — treat as success (idempotent). if isinstance(result, SuccessResponse) or (isinstance(result, FailedResponse) and result.status_code == 409): return - result.get_success_or_raise(request) + raise ToolkitAPIError(message=result.body, code=result.status_code) def update_version(self, external_id: str, version: str, update: dict) -> None: """POST /apphosting/apps/{externalId}/versions/update — apply one or more field updates to a version.""" diff --git a/cognite_toolkit/_cdf_tk/client/http_client/_client.py b/cognite_toolkit/_cdf_tk/client/http_client/_client.py index d7d70f7970..18cd4712bf 100644 --- a/cognite_toolkit/_cdf_tk/client/http_client/_client.py +++ b/cognite_toolkit/_cdf_tk/client/http_client/_client.py @@ -3,6 +3,7 @@ import time from collections import deque from collections.abc import Iterable, MutableMapping, Sequence, Set +from pathlib import Path from typing import Literal, TypeVar import httpx @@ -263,37 +264,70 @@ def request_raw_retries( self, method: Literal["GET", "POST", "PUT", "DELETE"], url: str, - content: bytes | Iterable[bytes], + content: bytes | Iterable[bytes] | None = None, headers: dict[str, str] | None = None, max_retries: int | None = None, + files: dict[str, tuple[str, Path, str]] | None = None, + data: dict[str, str] | None = None, + add_auth: bool = False, ) -> SuccessResponse | FailedResponse: - """Send a raw HTTP request with retry logic but without authentication headers. + """Send a raw HTTP request with retry logic. - This is useful for uploading to signed URLs (e.g., GCS signed URLs) where - authentication is embedded in the URL and adding auth headers would cause errors. + By default does not add authentication headers, which makes it suitable for + uploading to signed URLs (e.g., GCS signed URLs) where authentication is + embedded in the URL. Set add_auth=True for authenticated CDF endpoints. + + Pass either content (raw bytes/stream) or files+data (multipart form upload). + When files is provided, each file is re-opened on every retry attempt. Args: method: HTTP method to use. url: The URL to send the request to. - content: The content to send. Can be bytes or an iterable of bytes for streaming. - headers: Optional headers to include in the request. + content: Raw bytes or streaming content. Mutually exclusive with files. + headers: Optional extra headers to include in the request. max_retries: Maximum number of retries. Defaults to the client's max_retries setting. + files: Multipart file parts as {field_name: (filename, path, mime_type)}. + data: Multipart text fields, sent alongside files. + add_auth: When True, adds CDF authentication and SDK headers. Returns: HTTPResult: The result of the HTTP request, either SuccessResponse or FailedResponse. """ retries = max_retries if max_retries is not None else self._max_retries + if add_auth: + merged_headers = dict(self._create_headers(disable_gzip=True)) + del merged_headers["Content-Type"] # httpx sets this for multipart/form-data + if headers: + merged_headers.update(headers) + request_headers: dict[str, str] | None = merged_headers + else: + request_headers = headers attempt = 0 last_error_code: int = -1 while attempt <= retries: try: - response = self.session.request( - method=method, - url=url, - content=content, - headers=headers, - follow_redirects=False, - ) + if files is not None: + open_files = {name: (fname, path.open("rb"), mime) for name, (fname, path, mime) in files.items()} + try: + response = self.session.request( + method=method, + url=url, + files=open_files, + data=data, + headers=request_headers, + follow_redirects=False, + ) + finally: + for _name, (_fname, file_obj, _mime) in open_files.items(): + file_obj.close() + else: + response = self.session.request( + method=method, + url=url, + content=content, + headers=request_headers, + follow_redirects=False, + ) if 200 <= response.status_code < 300: return SuccessResponse( status_code=response.status_code, @@ -331,10 +365,10 @@ def request_raw_retries( return FailedResponse( status_code=last_error_code, body=f"Request failed after {attempt} attempts: {e!s}", - error=ErrorDetails(code=last_error_code, message=f"Request failed after {attempt} attempts: {e!s}"), + error=ErrorDetails( + code=last_error_code, message=f"Request failed after {attempt} attempts: {e!s}" + ), ) - - # Should not reach here, but just in case return FailedResponse( status_code=last_error_code, body=f"Request failed after {attempt} attempts.", diff --git a/tests/test_unit/test_cdf_tk/test_client/test_cdf_apis.py b/tests/test_unit/test_cdf_tk/test_client/test_cdf_apis.py index 2a9039e25a..3317719e49 100644 --- a/tests/test_unit/test_cdf_tk/test_client/test_cdf_apis.py +++ b/tests/test_unit/test_cdf_tk/test_client/test_cdf_apis.py @@ -1,5 +1,6 @@ import gzip import json +from pathlib import Path from typing import Any from unittest.mock import MagicMock @@ -1228,12 +1229,14 @@ def test_alert_channels_api_list_method( assert len(listed) == 1 assert listed[0].dump() == resource - def test_apps_api_methods(self, toolkit_config: ToolkitClientConfig, respx_mock: respx.MockRouter) -> None: + def test_apps_api_methods(self, toolkit_config: ToolkitClientConfig, respx_mock: respx.MockRouter, tmp_path: Path) -> None: config = toolkit_config api = AppsAPI(HTTPClient(config)) app_external_id = "my-app" version = "1.0.0" app_request = AppRequest(external_id=app_external_id, version=version, name="My App") + zip_path = tmp_path / "app.zip" + zip_path.write_bytes(b"fake-zip") version_json = { "appExternalId": app_external_id, "version": version, @@ -1252,11 +1255,11 @@ def test_apps_api_methods(self, toolkit_config: ToolkitClientConfig, respx_mock: respx_mock.post(config.create_api_url(f"/apphosting/apps/{app_external_id}/versions")).mock( return_value=httpx.Response(status_code=200) ) - api.upload_version(app_external_id, version, "index.html", b"fake-zip") + api.upload_version(app_external_id, version, "index.html", zip_path) respx_mock.post(config.create_api_url(f"/apphosting/apps/{app_external_id}/versions")).mock( return_value=httpx.Response(status_code=409) ) - api.upload_version(app_external_id, version, "index.html", b"fake-zip") + api.upload_version(app_external_id, version, "index.html", zip_path) # Test update_version respx_mock.post(config.create_api_url(f"/apphosting/apps/{app_external_id}/versions/update")).mock(