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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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 142f1dd6646ba9dbe7174ff235920d4e001cf0f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 12:30:53 +0200 Subject: [PATCH 07/26] Add AppIO resource loader with lifecycle/alias reconcile and build validation --- cognite_toolkit/_cdf_tk/feature_flags.py | 4 + .../_cdf_tk/resource_ios/__init__.py | 5 + .../resource_ios/_resource_ios/__init__.py | 2 + .../_cdf_tk/resource_ios/_resource_ios/app.py | 281 ++++++++++++++ .../test_commands/test_deploy.py | 3 +- .../test_cdf_tk/test_cruds/test_app.py | 344 ++++++++++++++++++ .../test_cdf_tk/test_cruds/test_base.py | 5 + 7 files changed, 643 insertions(+), 1 deletion(-) create mode 100644 cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py create mode 100644 tests/test_unit/test_cdf_tk/test_cruds/test_app.py diff --git a/cognite_toolkit/_cdf_tk/feature_flags.py b/cognite_toolkit/_cdf_tk/feature_flags.py index 048d47eaea..e2ed22eaa1 100644 --- a/cognite_toolkit/_cdf_tk/feature_flags.py +++ b/cognite_toolkit/_cdf_tk/feature_flags.py @@ -82,6 +82,10 @@ class Flags(Enum): visible=True, description="Enables the entity-matching command family under the dev plugin", ) + CUSTOM_APPS = FlagMetadata( + visible=False, + description="Enables support for custom app resources (App Hosting API deployment)", + ) def is_enabled(self) -> bool: return FeatureFlag.is_enabled(self) diff --git a/cognite_toolkit/_cdf_tk/resource_ios/__init__.py b/cognite_toolkit/_cdf_tk/resource_ios/__init__.py index c49449b23e..5b4f5c93f6 100644 --- a/cognite_toolkit/_cdf_tk/resource_ios/__init__.py +++ b/cognite_toolkit/_cdf_tk/resource_ios/__init__.py @@ -21,6 +21,7 @@ from ._data_cruds import DatapointsCRUD, FileCRUD, RawFileCRUD from ._resource_ios import ( AgentIO, + AppIO, AssetIO, CogniteFileCRUD, ContainerCRUD, @@ -103,6 +104,8 @@ _EXCLUDED_CRUDS.add(DataProductVersionIO) _EXCLUDED_CRUDS.add(RuleSetIO) _EXCLUDED_CRUDS.add(RuleSetVersionIO) +if not FeatureFlag.is_enabled(Flags.CUSTOM_APPS): + _EXCLUDED_CRUDS.add(AppIO) CRUDS_BY_FOLDER_NAME_INCLUDE_ALPHA: defaultdict[str, list[type[Loader]]] = defaultdict(list) CRUDS_BY_FOLDER_NAME: defaultdict[str, list[type[Loader]]] = defaultdict(list) @@ -151,6 +154,7 @@ ResourceTypes: TypeAlias = Literal[ "3dmodels", "agents", + "apps", "auth", "cdf_applications", "classic", @@ -199,6 +203,7 @@ def get_crud(resource_dir: str, kind: str) -> type[Loader]: "RESOURCE_DATA_CRUD_LIST", "_EXCLUDED_CRUDS", "AgentIO", + "AppIO", "AssetIO", "CogniteFileCRUD", "ContainerCRUD", diff --git a/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/__init__.py b/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/__init__.py index 7da3605dfd..17a3973073 100644 --- a/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/__init__.py +++ b/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/__init__.py @@ -1,4 +1,5 @@ from .agent import AgentIO +from .app import AppIO from .auth import GroupAllScopedCRUD, GroupIO, SecurityCategoryIO from .classic import AssetIO, EventIO, SequenceIO, SequenceRowIO from .configuration import SearchConfigIO @@ -54,6 +55,7 @@ __all__ = [ "AgentIO", + "AppIO", "AssetIO", "CogniteFileCRUD", "ContainerCRUD", diff --git a/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py b/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py new file mode 100644 index 0000000000..d42f5a5cb8 --- /dev/null +++ b/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py @@ -0,0 +1,281 @@ +import io +import os +import zipfile +from collections.abc import Hashable, Iterable, Sequence +from pathlib import Path +from typing import Any, Literal, final + +from rich.console import Console + +from cognite_toolkit._cdf_tk.client import ToolkitClient +from cognite_toolkit._cdf_tk.client._resource_base import Identifier +from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId +from cognite_toolkit._cdf_tk.client.resource_classes.app import AppRequest, AppResponse +from cognite_toolkit._cdf_tk.client.resource_classes.group import ( + AclType, + AllScope, + AppHostingAcl, + ScopeDefinition, +) +from cognite_toolkit._cdf_tk.exceptions import ToolkitRequiredValueError, ToolkitValueError +from cognite_toolkit._cdf_tk.resource_ios._base_ios import FailedReadExtra, ReadExtra, ResourceIO, SuccessExtra +from cognite_toolkit._cdf_tk.utils.hashing import calculate_directory_hash +from cognite_toolkit._cdf_tk.yaml_classes import AppsYAML + +from .auth import GroupAllScopedCRUD + +_EXCLUDE_DIRS = {"__pycache__", "node_modules", ".git"} + +_LIFECYCLE_ORDER = ["DRAFT", "PUBLISHED", "DEPRECATED", "ARCHIVED"] + + +def _zip_app_directory(source_dir: Path) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", strict_timestamps=False) as zf: + for root, dirs, files in os.walk(source_dir): + dirs[:] = [d for d in dirs if d not in _EXCLUDE_DIRS] + root_path = Path(root) + arc_root = root_path.relative_to(source_dir) + zf.write(root_path, arcname=str(arc_root)) + for filename in files: + file_path = root_path / filename + zf.write(file_path, arcname=str(file_path.relative_to(source_dir))) + return buffer.getvalue() + + +@final +class AppIO(ResourceIO[AppVersionId, AppRequest, AppResponse]): + support_drop = True + folder_name = "apps" + resource_cls = AppResponse + resource_write_cls = AppRequest + kind = "App" + yaml_cls = AppsYAML + dependencies = frozenset({GroupAllScopedCRUD}) + _doc_url = "Apps/operation/appsCreate" + support_update = True + + def __init__(self, client: ToolkitClient, build_path: Path | None, console: Console | None): + super().__init__(client, build_path, console) + self.zip_path_by_version_id: dict[AppVersionId, Path] = {} + + @property + def display_name(self) -> str: + return "apps" + + @classmethod + def get_minimum_scope(cls, items: Sequence[AppRequest]) -> ScopeDefinition: + return AllScope() + + @classmethod + def create_acl(cls, actions: set[Literal["READ", "WRITE"]], scope: ScopeDefinition) -> Iterable[AclType]: + if isinstance(scope, AllScope): + yield AppHostingAcl(actions=sorted(actions), scope=scope) + + @classmethod + def get_id(cls, item: AppResponse | AppRequest | dict) -> AppVersionId: + if isinstance(item, dict): + ext = item.get("externalId") or item.get("external_id") + version = item.get("version") + if ext is None: + raise ToolkitRequiredValueError("App YAML must define externalId.") + if version is None: + raise ToolkitRequiredValueError("App YAML must define version.") + return AppVersionId(external_id=ext, version=version) + if isinstance(item, AppRequest): + return item.as_id() + return AppVersionId(external_id=item.external_id, version=item.version) + + @classmethod + def dump_id(cls, identifier: AppVersionId) -> dict[str, Any]: + return identifier.dump() + + @classmethod + def as_str(cls, identifier: AppVersionId) -> str: + return str(identifier) + + @classmethod + def get_dependent_items(cls, item: dict) -> Iterable[tuple[type[ResourceIO], Hashable]]: + return [] + + @classmethod + def get_dependencies(cls, resource: AppsYAML) -> Iterable[tuple[type[ResourceIO], Identifier]]: + return [] + + @classmethod + def get_extra_files(cls, filepath: Path, identifier: AppVersionId, item: dict[str, Any]) -> Iterable[ReadExtra]: + app_external_id = identifier.external_id + source_path_str = item.get("sourcePath") or item.get("source_path") + if source_path_str is not None: + app_root = (filepath.parent / source_path_str).resolve() + else: + app_root = filepath.with_name(app_external_id) + + if not app_root.is_dir(): + yield FailedReadExtra( + code="MISSING", + error=( + f"App directory not found for externalId {app_external_id!r}. " + f"Expected {app_root.as_posix()} to exist." + ), + source_path=app_root, + ) + return + + entrypoint = item.get("entrypoint") or "index.html" + dist_dir = app_root / "dist" + if (dist_dir / entrypoint).is_file(): + source_dir = dist_dir + elif (app_root / "src").is_dir() and (app_root / "package.json").is_file(): + yield FailedReadExtra( + code="MISSING", + error=( + f"App {app_external_id!r} looks like an unbuilt web project: " + f"Run `npm run build` (or your project's build command) in {app_root.as_posix()} " + f"before deploying with Toolkit." + ), + source_path=app_root, + ) + return + elif (app_root / entrypoint).is_file(): + source_dir = app_root + else: + yield FailedReadExtra( + code="MISSING", + error=( + f"Could not locate entrypoint {entrypoint!r} for app {app_external_id!r}. " + f"Expected {(dist_dir / entrypoint).as_posix()} or " + f"{(app_root / entrypoint).as_posix()} to exist. " + f"If your app has a build step, run it before deploying with Toolkit." + ), + source_path=app_root, + ) + return + source_hash = calculate_directory_hash(source_dir) + zip_bytes = _zip_app_directory(source_dir) + yield SuccessExtra( + source_path=source_dir, + source_hash=source_hash, + suffix=".zip", + byte_content=zip_bytes, + description="app bundle", + ) + + def load_resource_file( + self, filepath: Path, environment_variables: dict[str, str | None] | None = None + ) -> list[dict[str, Any]]: + if filepath.parent.name != self.folder_name: + return [] + + raw_list = super().load_resource_file(filepath, environment_variables) + for item in raw_list: + app_external_id = item.get("externalId") or item.get("external_id") + if not app_external_id: + raise ToolkitRequiredValueError("App YAML must define externalId.") + version = item.get("version") + if not version: + raise ToolkitRequiredValueError("App YAML must define version.") + filestem = filepath.stem.rsplit(".", 1)[0] + version_id = AppVersionId(external_id=app_external_id, version=version) + self.zip_path_by_version_id[version_id] = filepath.parent / f"{filestem}.zip" + + return raw_list + + def load_resource(self, resource: dict[str, Any], is_dry_run: bool = False) -> AppRequest: + return AppRequest.model_validate(resource) + + def dump_resource(self, resource: AppResponse, local: dict[str, Any] | None = None) -> dict[str, Any]: + dumped = resource.as_request_resource().dump(context="toolkit") + local = local or {} + # name and description are immutable in CDF post-create; always use local values to suppress stale diff. + for immutable_key in ("name", "description"): + if immutable_key in local: + dumped[immutable_key] = local[immutable_key] + for local_only_key in ("sourcePath", "source_path"): + if local_only_key in local: + dumped[local_only_key] = local[local_only_key] + return dumped + + def _deploy(self, item: AppRequest) -> AppResponse: + version_id = item.as_id() + zip_path = self.zip_path_by_version_id.get(version_id) + if zip_path is None or not zip_path.exists(): + raise ToolkitRequiredValueError( + f"App zip not found for {item.external_id!r} version {item.version!r}. Ensure build was run first." + ) + self.client.tool.apps.ensure_app(item) + zip_bytes = zip_path.read_bytes() + self.client.tool.apps.upload_version( + external_id=item.external_id, + version=item.version, + entrypoint=item.entrypoint, + zip_bytes=zip_bytes, + ) + + current = self.client.tool.apps.retrieve_version(item.external_id, item.version, ignore_unknown_ids=True) + current_lifecycle = current.lifecycle_state if current else "DRAFT" + current_alias = current.alias if current else None + + if item.lifecycle_state != current_lifecycle: + current_idx = _LIFECYCLE_ORDER.index(current_lifecycle) if current_lifecycle in _LIFECYCLE_ORDER else 0 + target_idx = _LIFECYCLE_ORDER.index(item.lifecycle_state) if item.lifecycle_state in _LIFECYCLE_ORDER else 0 + if target_idx < current_idx: + raise ToolkitValueError( + f"Cannot transition app {item.external_id!r} version {item.version!r} " + f"from {current_lifecycle!r} to {item.lifecycle_state!r}: lifecycle transitions are forward-only." + ) + self.client.tool.apps.transition_lifecycle(item.external_id, item.version, item.lifecycle_state) + + alias_explicitly_set = "alias" in item.model_fields_set + if alias_explicitly_set and item.alias != current_alias: + if item.alias is not None and item.lifecycle_state not in ("PUBLISHED",): + raise ToolkitValueError( + f"Cannot set alias {item.alias!r} on app {item.external_id!r} version {item.version!r}: " + f"aliases are only valid on PUBLISHED versions (current lifecycle: {item.lifecycle_state!r})." + ) + self.client.tool.apps.set_alias(item.external_id, item.version, item.alias) + + return AppResponse( + external_id=item.external_id, + version=item.version, + name=item.name, + description=item.description, + lifecycle_state=item.lifecycle_state, + alias=item.alias, + entrypoint=item.entrypoint, + ) + + def create(self, items: Sequence[AppRequest]) -> list[AppResponse]: + return [self._deploy(item) for item in items] + + def update(self, items: Sequence[AppRequest]) -> list[AppResponse]: + return [self._deploy(item) for item in items] + + def retrieve(self, ids: Sequence[AppVersionId]) -> list[AppResponse]: + results: list[AppResponse] = [] + for version_id in ids: + response = self.client.tool.apps.retrieve_version( + version_id.external_id, version_id.version, ignore_unknown_ids=True + ) + if response is not None: + results.append(response) + return results + + def delete(self, ids: Sequence[AppVersionId]) -> int: + if not ids: + return 0 + by_app: dict[str, list[AppVersionId]] = {} + for version_id in ids: + by_app.setdefault(version_id.external_id, []).append(version_id) + for app_external_id, version_ids in by_app.items(): + self.client.tool.apps.delete_version(app_external_id, version_ids) + return len(ids) + + def _iterate( + self, + data_set_external_id: str | None = None, + space: str | None = None, + parent_ids: Sequence[Hashable] | None = None, + ) -> Iterable[AppResponse]: + for page in self.client.tool.apps.iterate(): + yield from page diff --git a/tests/test_integration/test_commands/test_deploy.py b/tests/test_integration/test_commands/test_deploy.py index 6c66cc25d6..31a4acc6d9 100644 --- a/tests/test_integration/test_commands/test_deploy.py +++ b/tests/test_integration/test_commands/test_deploy.py @@ -17,6 +17,7 @@ from cognite_toolkit._cdf_tk.resource_ios import ( CRUDS_BY_FOLDER_NAME, RESOURCE_CRUD_LIST, + AppIO, CogniteFileCRUD, FileMetadataCRUD, FunctionIO, @@ -189,7 +190,7 @@ def get_changed_source_files( # Authentication that causes the diff to fail loader_cls in {HostedExtractorSourceIO, HostedExtractorDestinationIO} # External files that cannot (or not yet supported) be pulled - or loader_cls in {GraphQLCRUD, FunctionIO, StreamlitIO} + or loader_cls in {GraphQLCRUD, FunctionIO, AppIO, StreamlitIO} # Have authentication hashes that is different for each environment or loader_cls in {TransformationIO, FunctionScheduleIO, WorkflowTriggerIO} # LocationFilterLoader needs to split the file into multiple files, so we cannot compare them diff --git a/tests/test_unit/test_cdf_tk/test_cruds/test_app.py b/tests/test_unit/test_cdf_tk/test_cruds/test_app.py new file mode 100644 index 0000000000..a88395324c --- /dev/null +++ b/tests/test_unit/test_cdf_tk/test_cruds/test_app.py @@ -0,0 +1,344 @@ +import io +import zipfile +from pathlib import Path + +import pytest + +from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId +from cognite_toolkit._cdf_tk.client.resource_classes.app import AppRequest, AppResponse +from cognite_toolkit._cdf_tk.client.testing import monkeypatch_toolkit_client +from cognite_toolkit._cdf_tk.exceptions import ToolkitRequiredValueError, ToolkitValueError +from cognite_toolkit._cdf_tk.resource_ios._base_ios import FailedReadExtra +from cognite_toolkit._cdf_tk.resource_ios._resource_ios.app import AppIO + + +def _make_app_request( + external_id: str = "my-app", + version: str = "1.0.0", + name: str = "My App", + lifecycle_state: str = "PUBLISHED", + alias: str | None = None, + entrypoint: str = "index.html", +) -> AppRequest: + return AppRequest( + external_id=external_id, + version=version, + name=name, + lifecycle_state=lifecycle_state, + alias=alias, + entrypoint=entrypoint, + ) + + +def _make_app_response( + external_id: str = "my-app", + version: str = "1.0.0", + lifecycle_state: str = "PUBLISHED", + alias: str | None = "ACTIVE", +) -> AppResponse: + return AppResponse( + external_id=external_id, + version=version, + name="My App", + lifecycle_state=lifecycle_state, + alias=alias, + ) + + +def _write_zip(path: Path, filenames: list[str] | None = None) -> None: + if filenames is None: + filenames = ["index.html"] + with zipfile.ZipFile(path, "w") as zf: + for filename in filenames: + zf.writestr(filename, b"content") + + +class TestAppIODeploy: + @pytest.fixture + def app_io_with_zip(self, tmp_path: Path): + with monkeypatch_toolkit_client() as client: + loader = AppIO.create_loader(client, tmp_path) + zip_path = tmp_path / "1-my-app-my-app.zip" + _write_zip(zip_path) + version_id = AppVersionId(external_id="my-app", version="1.0.0") + loader.zip_path_by_version_id[version_id] = zip_path + yield loader, client + + def test_create_calls_ensure_and_upload(self, app_io_with_zip): + loader, client = app_io_with_zip + item = _make_app_request(lifecycle_state="DRAFT", alias=None) + client.tool.apps.retrieve_version.return_value = None + + loader.create([item]) + + client.tool.apps.ensure_app.assert_called_once_with(item) + client.tool.apps.upload_version.assert_called_once_with( + external_id="my-app", + version="1.0.0", + entrypoint="index.html", + zip_bytes=loader.zip_path_by_version_id[AppVersionId(external_id="my-app", version="1.0.0")].read_bytes(), + ) + + def test_deploy_promotes_draft_to_published_with_active_alias(self, app_io_with_zip): + loader, client = app_io_with_zip + item = _make_app_request(lifecycle_state="PUBLISHED", alias="ACTIVE") + client.tool.apps.retrieve_version.return_value = None + + loader.create([item]) + + client.tool.apps.transition_lifecycle.assert_called_once_with("my-app", "1.0.0", "PUBLISHED") + client.tool.apps.set_alias.assert_called_once_with("my-app", "1.0.0", "ACTIVE") + + def test_deploy_clears_alias_when_local_alias_is_none(self, app_io_with_zip): + loader, client = app_io_with_zip + item = _make_app_request(lifecycle_state="PUBLISHED", alias=None) + client.tool.apps.retrieve_version.return_value = _make_app_response(lifecycle_state="PUBLISHED", alias="ACTIVE") + + loader.create([item]) + + client.tool.apps.transition_lifecycle.assert_not_called() + client.tool.apps.set_alias.assert_called_once_with("my-app", "1.0.0", None) + + def test_deploy_swaps_alias_to_preview(self, app_io_with_zip): + loader, client = app_io_with_zip + item = _make_app_request(lifecycle_state="PUBLISHED", alias="PREVIEW") + client.tool.apps.retrieve_version.return_value = _make_app_response(lifecycle_state="PUBLISHED", alias="ACTIVE") + + loader.create([item]) + + client.tool.apps.transition_lifecycle.assert_not_called() + client.tool.apps.set_alias.assert_called_once_with("my-app", "1.0.0", "PREVIEW") + + def test_deploy_noop_when_lifecycle_and_alias_match(self, app_io_with_zip): + loader, client = app_io_with_zip + item = _make_app_request(lifecycle_state="PUBLISHED", alias="ACTIVE") + client.tool.apps.retrieve_version.return_value = _make_app_response(lifecycle_state="PUBLISHED", alias="ACTIVE") + + loader.create([item]) + + client.tool.apps.transition_lifecycle.assert_not_called() + client.tool.apps.set_alias.assert_not_called() + + def test_deploy_rejects_backward_lifecycle_transition(self, app_io_with_zip): + loader, client = app_io_with_zip + item = _make_app_request(lifecycle_state="DRAFT", alias=None) + client.tool.apps.retrieve_version.return_value = _make_app_response(lifecycle_state="PUBLISHED", alias=None) + + with pytest.raises(ToolkitValueError, match="forward-only"): + loader.create([item]) + + def test_deploy_rejects_alias_on_non_published_version(self, app_io_with_zip): + loader, client = app_io_with_zip + item = _make_app_request(lifecycle_state="DRAFT", alias="ACTIVE") + client.tool.apps.retrieve_version.return_value = None + + with pytest.raises(ToolkitValueError, match="alias"): + loader.create([item]) + + def test_deploy_raises_when_zip_missing(self, tmp_path: Path): + with monkeypatch_toolkit_client() as client: + loader = AppIO.create_loader(client, tmp_path) + item = _make_app_request(external_id="missing-app") + with pytest.raises(ToolkitRequiredValueError, match="missing-app"): + loader.create([item]) + + def test_deploy_returns_response_with_correct_fields(self, app_io_with_zip): + loader, _client = app_io_with_zip + item = _make_app_request(lifecycle_state="PUBLISHED", alias="ACTIVE") + _client.tool.apps.retrieve_version.return_value = None + + results = loader.create([item]) + + assert len(results) == 1 + response = results[0] + assert isinstance(response, AppResponse) + assert response.external_id == "my-app" + assert response.version == "1.0.0" + assert response.lifecycle_state == "PUBLISHED" + assert response.alias == "ACTIVE" + + def test_update_calls_ensure_and_upload(self, app_io_with_zip): + loader, client = app_io_with_zip + item = _make_app_request(version="2.0.0", lifecycle_state="DRAFT", alias=None) + # Register zip for 2.0.0 + zip_path = loader.zip_path_by_version_id[AppVersionId(external_id="my-app", version="1.0.0")] + loader.zip_path_by_version_id[AppVersionId(external_id="my-app", version="2.0.0")] = zip_path + client.tool.apps.retrieve_version.return_value = None + + loader.update([item]) + + client.tool.apps.ensure_app.assert_called_once_with(item) + client.tool.apps.upload_version.assert_called_once() + + def test_delete_calls_delete_version_grouped_by_app(self, tmp_path: Path): + with monkeypatch_toolkit_client() as client: + loader = AppIO.create_loader(client, tmp_path) + ids = [ + AppVersionId(external_id="my-app", version="1.0.0"), + AppVersionId(external_id="my-app", version="2.0.0"), + ] + loader.delete(ids) + + client.tool.apps.delete_version.assert_called_once_with("my-app", ids) + + +class TestAppIODumpResource: + def test_uses_local_name_and_description_when_immutable_drift(self): + with monkeypatch_toolkit_client() as client: + loader = AppIO.create_loader(client, None) + + response = AppResponse( + external_id="my-app", + version="1.0.0", + name="Old name from CDF", + description=None, + lifecycle_state="PUBLISHED", + alias="ACTIVE", + ) + local = {"name": "New local name", "description": "New description"} + + dumped = loader.dump_resource(response, local=local) + + assert dumped["name"] == "New local name" + assert dumped["description"] == "New description" + + def test_copies_source_path_from_local(self): + with monkeypatch_toolkit_client() as client: + loader = AppIO.create_loader(client, None) + + response = AppResponse( + external_id="my-app", + version="1.0.0", + name="My App", + lifecycle_state="PUBLISHED", + alias="ACTIVE", + ) + local = {"sourcePath": "../../../../my-custom-app"} + + dumped = loader.dump_resource(response, local=local) + + assert dumped["sourcePath"] == "../../../../my-custom-app" + + +class TestAppIOGetExtraFiles: + def test_yields_zip_with_dist_contents(self, tmp_path: Path): + app_dir = tmp_path / "my-app" + dist_dir = app_dir / "dist" + dist_dir.mkdir(parents=True) + (dist_dir / "index.html").write_text("") + (dist_dir / "bundle.js").write_text("console.log('hi')") + + yaml_file = tmp_path / "my-app.App.yaml" + yaml_file.write_text("") + item = {"externalId": "my-app", "version": "1.0.0", "name": "My App"} + + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(external_id="my-app", version="1.0.0"), item)) + + assert len(extras) == 1 + extra = extras[0] + assert extra.suffix == ".zip" + assert extra.byte_content is not None + with zipfile.ZipFile(io.BytesIO(extra.byte_content)) as zf: + names = zf.namelist() + assert any("index.html" in n for n in names) + assert any("bundle.js" in n for n in names) + + def test_falls_back_to_root_without_dist(self, tmp_path: Path): + app_dir = tmp_path / "my-app" + app_dir.mkdir() + (app_dir / "index.html").write_text("") + + yaml_file = tmp_path / "my-app.App.yaml" + yaml_file.write_text("") + item = {"externalId": "my-app", "version": "1.0.0", "name": "My App"} + + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(external_id="my-app", version="1.0.0"), item)) + + assert len(extras) == 1 + assert extras[0].suffix == ".zip" + + def test_fails_when_entrypoint_missing_from_root_and_dist(self, tmp_path: Path): + app_dir = tmp_path / "my-app" + app_dir.mkdir() + # No index.html at root, no dist/, no src/+package.json + + yaml_file = tmp_path / "my-app.App.yaml" + yaml_file.write_text("") + item = {"externalId": "my-app", "version": "1.0.0", "name": "My App"} + + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(external_id="my-app", version="1.0.0"), item)) + + assert len(extras) == 1 + assert isinstance(extras[0], FailedReadExtra) + assert "index.html" in extras[0].error + + def test_fails_with_build_hint_when_unbuilt_webapp(self, tmp_path: Path): + app_dir = tmp_path / "my-app" + app_dir.mkdir() + (app_dir / "src").mkdir() + (app_dir / "package.json").write_text("{}") + (app_dir / "index.html").write_text("") # Vite template at root + + yaml_file = tmp_path / "my-app.App.yaml" + yaml_file.write_text("") + item = {"externalId": "my-app", "version": "1.0.0", "name": "My App"} + + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(external_id="my-app", version="1.0.0"), item)) + + assert len(extras) == 1 + assert isinstance(extras[0], FailedReadExtra) + assert "npm run build" in extras[0].error + + def test_fails_when_app_dir_missing(self, tmp_path: Path): + yaml_file = tmp_path / "missing-app.App.yaml" + yaml_file.write_text("") + item = {"externalId": "missing-app", "version": "1.0.0", "name": "Missing App"} + + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(external_id="missing-app", version="1.0.0"), item)) + + assert len(extras) == 1 + assert isinstance(extras[0], FailedReadExtra) + + def test_uses_source_path_field(self, tmp_path: Path): + external_dir = tmp_path / "my-custom-app" + dist_dir = external_dir / "dist" + dist_dir.mkdir(parents=True) + (dist_dir / "index.html").write_text("") + + modules_dir = tmp_path / "modules" / "my_module" / "apps" + modules_dir.mkdir(parents=True) + yaml_file = modules_dir / "my-app.App.yaml" + yaml_file.write_text("") + item = { + "externalId": "my-app", + "version": "1.0.0", + "name": "My App", + "sourcePath": "../../../my-custom-app", + } + + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(external_id="my-app", version="1.0.0"), item)) + + assert len(extras) == 1 + assert extras[0].suffix == ".zip" + + def test_excludes_node_modules_and_git(self, tmp_path: Path): + app_dir = tmp_path / "my-app" + app_dir.mkdir() + (app_dir / "index.html").write_text("") + (app_dir / "node_modules").mkdir() + (app_dir / "node_modules" / "pkg.js").write_text("module") + (app_dir / ".git").mkdir() + (app_dir / ".git" / "config").write_text("[core]") + + yaml_file = tmp_path / "my-app.App.yaml" + yaml_file.write_text("") + item = {"externalId": "my-app", "version": "1.0.0", "name": "My App"} + + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(external_id="my-app", version="1.0.0"), item)) + + assert len(extras) == 1 + with zipfile.ZipFile(io.BytesIO(extras[0].byte_content)) as zf: # type: ignore[arg-type] + names = zf.namelist() + assert not any("node_modules" in n for n in names) + assert not any(".git" in n for n in names) + assert any("index.html" in n for n in names) diff --git a/tests/test_unit/test_cdf_tk/test_cruds/test_base.py b/tests/test_unit/test_cdf_tk/test_cruds/test_base.py index 77805793a6..39dd566e0d 100644 --- a/tests/test_unit/test_cdf_tk/test_cruds/test_base.py +++ b/tests/test_unit/test_cdf_tk/test_cruds/test_base.py @@ -21,6 +21,7 @@ from pytest import MonkeyPatch from cognite_toolkit._cdf_tk.cdf_toml import CDFToml +from cognite_toolkit._cdf_tk.client.resource_classes.app import AppResponse from cognite_toolkit._cdf_tk.client.resource_classes.cognite_file import CogniteFileResponse from cognite_toolkit._cdf_tk.client.resource_classes.filemetadata import FileMetadataResponse from cognite_toolkit._cdf_tk.client.resource_classes.graphql_data_model import GraphQLDataModelResponse @@ -126,6 +127,7 @@ def test_loader_takes_dict( StreamlitResponse, CogniteFileResponse, FileMetadataResponse, + AppResponse, ]: pytest.skip("Skipped loaders that require secondary files") elif loader.resource_cls in [Edge, Node, Destination]: @@ -170,6 +172,7 @@ def test_loader_takes_list( StreamlitResponse, CogniteFileResponse, FileMetadataResponse, + AppResponse, ]: pytest.skip("Skipped loaders that require secondary files") elif loader.resource_cls in [Edge, Node, Destination]: @@ -228,6 +231,8 @@ def test_resource_types_is_up_to_date() -> None: if not FeatureFlag.is_enabled(Flags.DATA_PRODUCTS): extra.discard("data_products") extra.discard("rulesets") + if not FeatureFlag.is_enabled(Flags.CUSTOM_APPS): + extra.discard("apps") if not FeatureFlag.is_enabled(Flags.SIGNALS): extra.discard("signals") assert not missing, f"Missing {missing=}" From 0f61f10e21b4fe9fa5eb593aac904833f1473284 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 08/26] 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 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 09/26] 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 0d54e458fe8716426ded77af0835758d9a64e8ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 13:33:36 +0200 Subject: [PATCH 10/26] Refactor --- .../_cdf_tk/resource_ios/_resource_ios/app.py | 9 +++- .../test_cdf_tk/test_cruds/test_app.py | 44 ++++++++++--------- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py b/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py index d42f5a5cb8..0886f671cf 100644 --- a/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py +++ b/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py @@ -216,6 +216,8 @@ def _deploy(self, item: AppRequest) -> AppResponse: current_lifecycle = current.lifecycle_state if current else "DRAFT" current_alias = current.alias if current else None + update: dict = {} + if item.lifecycle_state != current_lifecycle: current_idx = _LIFECYCLE_ORDER.index(current_lifecycle) if current_lifecycle in _LIFECYCLE_ORDER else 0 target_idx = _LIFECYCLE_ORDER.index(item.lifecycle_state) if item.lifecycle_state in _LIFECYCLE_ORDER else 0 @@ -224,7 +226,7 @@ def _deploy(self, item: AppRequest) -> AppResponse: f"Cannot transition app {item.external_id!r} version {item.version!r} " f"from {current_lifecycle!r} to {item.lifecycle_state!r}: lifecycle transitions are forward-only." ) - self.client.tool.apps.transition_lifecycle(item.external_id, item.version, item.lifecycle_state) + update["lifecycleState"] = {"set": item.lifecycle_state} alias_explicitly_set = "alias" in item.model_fields_set if alias_explicitly_set and item.alias != current_alias: @@ -233,7 +235,10 @@ def _deploy(self, item: AppRequest) -> AppResponse: f"Cannot set alias {item.alias!r} on app {item.external_id!r} version {item.version!r}: " f"aliases are only valid on PUBLISHED versions (current lifecycle: {item.lifecycle_state!r})." ) - self.client.tool.apps.set_alias(item.external_id, item.version, item.alias) + update["alias"] = {"setNull": True} if item.alias is None else {"set": item.alias} + + if update: + self.client.tool.apps.update_version(item.external_id, item.version, update) return AppResponse( external_id=item.external_id, diff --git a/tests/test_unit/test_cdf_tk/test_cruds/test_app.py b/tests/test_unit/test_cdf_tk/test_cruds/test_app.py index a88395324c..d7741f11b7 100644 --- a/tests/test_unit/test_cdf_tk/test_cruds/test_app.py +++ b/tests/test_unit/test_cdf_tk/test_cruds/test_app.py @@ -60,7 +60,7 @@ def app_io_with_zip(self, tmp_path: Path): loader = AppIO.create_loader(client, tmp_path) zip_path = tmp_path / "1-my-app-my-app.zip" _write_zip(zip_path) - version_id = AppVersionId(external_id="my-app", version="1.0.0") + version_id = AppVersionId(app_external_id="my-app", version="1.0.0") loader.zip_path_by_version_id[version_id] = zip_path yield loader, client @@ -76,7 +76,7 @@ def test_create_calls_ensure_and_upload(self, app_io_with_zip): external_id="my-app", version="1.0.0", entrypoint="index.html", - zip_bytes=loader.zip_path_by_version_id[AppVersionId(external_id="my-app", version="1.0.0")].read_bytes(), + zip_bytes=loader.zip_path_by_version_id[AppVersionId(app_external_id="my-app", version="1.0.0")].read_bytes(), ) def test_deploy_promotes_draft_to_published_with_active_alias(self, app_io_with_zip): @@ -86,8 +86,9 @@ def test_deploy_promotes_draft_to_published_with_active_alias(self, app_io_with_ loader.create([item]) - client.tool.apps.transition_lifecycle.assert_called_once_with("my-app", "1.0.0", "PUBLISHED") - client.tool.apps.set_alias.assert_called_once_with("my-app", "1.0.0", "ACTIVE") + client.tool.apps.update_version.assert_called_once_with( + "my-app", "1.0.0", {"lifecycleState": {"set": "PUBLISHED"}, "alias": {"set": "ACTIVE"}} + ) def test_deploy_clears_alias_when_local_alias_is_none(self, app_io_with_zip): loader, client = app_io_with_zip @@ -96,8 +97,9 @@ def test_deploy_clears_alias_when_local_alias_is_none(self, app_io_with_zip): loader.create([item]) - client.tool.apps.transition_lifecycle.assert_not_called() - client.tool.apps.set_alias.assert_called_once_with("my-app", "1.0.0", None) + client.tool.apps.update_version.assert_called_once_with( + "my-app", "1.0.0", {"alias": {"setNull": True}} + ) def test_deploy_swaps_alias_to_preview(self, app_io_with_zip): loader, client = app_io_with_zip @@ -106,8 +108,9 @@ def test_deploy_swaps_alias_to_preview(self, app_io_with_zip): loader.create([item]) - client.tool.apps.transition_lifecycle.assert_not_called() - client.tool.apps.set_alias.assert_called_once_with("my-app", "1.0.0", "PREVIEW") + client.tool.apps.update_version.assert_called_once_with( + "my-app", "1.0.0", {"alias": {"set": "PREVIEW"}} + ) def test_deploy_noop_when_lifecycle_and_alias_match(self, app_io_with_zip): loader, client = app_io_with_zip @@ -116,8 +119,7 @@ def test_deploy_noop_when_lifecycle_and_alias_match(self, app_io_with_zip): loader.create([item]) - client.tool.apps.transition_lifecycle.assert_not_called() - client.tool.apps.set_alias.assert_not_called() + client.tool.apps.update_version.assert_not_called() def test_deploy_rejects_backward_lifecycle_transition(self, app_io_with_zip): loader, client = app_io_with_zip @@ -161,8 +163,8 @@ def test_update_calls_ensure_and_upload(self, app_io_with_zip): loader, client = app_io_with_zip item = _make_app_request(version="2.0.0", lifecycle_state="DRAFT", alias=None) # Register zip for 2.0.0 - zip_path = loader.zip_path_by_version_id[AppVersionId(external_id="my-app", version="1.0.0")] - loader.zip_path_by_version_id[AppVersionId(external_id="my-app", version="2.0.0")] = zip_path + zip_path = loader.zip_path_by_version_id[AppVersionId(app_external_id="my-app", version="1.0.0")] + loader.zip_path_by_version_id[AppVersionId(app_external_id="my-app", version="2.0.0")] = zip_path client.tool.apps.retrieve_version.return_value = None loader.update([item]) @@ -174,8 +176,8 @@ def test_delete_calls_delete_version_grouped_by_app(self, tmp_path: Path): with monkeypatch_toolkit_client() as client: loader = AppIO.create_loader(client, tmp_path) ids = [ - AppVersionId(external_id="my-app", version="1.0.0"), - AppVersionId(external_id="my-app", version="2.0.0"), + AppVersionId(app_external_id="my-app", version="1.0.0"), + AppVersionId(app_external_id="my-app", version="2.0.0"), ] loader.delete(ids) @@ -232,7 +234,7 @@ def test_yields_zip_with_dist_contents(self, tmp_path: Path): yaml_file.write_text("") item = {"externalId": "my-app", "version": "1.0.0", "name": "My App"} - extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(external_id="my-app", version="1.0.0"), item)) + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(app_external_id="my-app", version="1.0.0"), item)) assert len(extras) == 1 extra = extras[0] @@ -252,7 +254,7 @@ def test_falls_back_to_root_without_dist(self, tmp_path: Path): yaml_file.write_text("") item = {"externalId": "my-app", "version": "1.0.0", "name": "My App"} - extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(external_id="my-app", version="1.0.0"), item)) + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(app_external_id="my-app", version="1.0.0"), item)) assert len(extras) == 1 assert extras[0].suffix == ".zip" @@ -266,7 +268,7 @@ def test_fails_when_entrypoint_missing_from_root_and_dist(self, tmp_path: Path): yaml_file.write_text("") item = {"externalId": "my-app", "version": "1.0.0", "name": "My App"} - extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(external_id="my-app", version="1.0.0"), item)) + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(app_external_id="my-app", version="1.0.0"), item)) assert len(extras) == 1 assert isinstance(extras[0], FailedReadExtra) @@ -283,7 +285,7 @@ def test_fails_with_build_hint_when_unbuilt_webapp(self, tmp_path: Path): yaml_file.write_text("") item = {"externalId": "my-app", "version": "1.0.0", "name": "My App"} - extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(external_id="my-app", version="1.0.0"), item)) + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(app_external_id="my-app", version="1.0.0"), item)) assert len(extras) == 1 assert isinstance(extras[0], FailedReadExtra) @@ -294,7 +296,7 @@ def test_fails_when_app_dir_missing(self, tmp_path: Path): yaml_file.write_text("") item = {"externalId": "missing-app", "version": "1.0.0", "name": "Missing App"} - extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(external_id="missing-app", version="1.0.0"), item)) + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(app_external_id="missing-app", version="1.0.0"), item)) assert len(extras) == 1 assert isinstance(extras[0], FailedReadExtra) @@ -316,7 +318,7 @@ def test_uses_source_path_field(self, tmp_path: Path): "sourcePath": "../../../my-custom-app", } - extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(external_id="my-app", version="1.0.0"), item)) + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(app_external_id="my-app", version="1.0.0"), item)) assert len(extras) == 1 assert extras[0].suffix == ".zip" @@ -334,7 +336,7 @@ def test_excludes_node_modules_and_git(self, tmp_path: Path): yaml_file.write_text("") item = {"externalId": "my-app", "version": "1.0.0", "name": "My App"} - extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(external_id="my-app", version="1.0.0"), item)) + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(app_external_id="my-app", version="1.0.0"), item)) assert len(extras) == 1 with zipfile.ZipFile(io.BytesIO(extras[0].byte_content)) as zf: # type: ignore[arg-type] From 897aae93eaa810fef09f8d1ee39f4d1d0f4a4970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 13:42:32 +0200 Subject: [PATCH 11/26] Fix app_external_id --- .../_cdf_tk/resource_ios/_resource_ios/app.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py b/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py index 0886f671cf..b95533b3bc 100644 --- a/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py +++ b/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py @@ -81,10 +81,10 @@ def get_id(cls, item: AppResponse | AppRequest | dict) -> AppVersionId: raise ToolkitRequiredValueError("App YAML must define externalId.") if version is None: raise ToolkitRequiredValueError("App YAML must define version.") - return AppVersionId(external_id=ext, version=version) + return AppVersionId(app_external_id=ext, version=version) if isinstance(item, AppRequest): return item.as_id() - return AppVersionId(external_id=item.external_id, version=item.version) + return AppVersionId(app_external_id=item.external_id, version=item.version) @classmethod def dump_id(cls, identifier: AppVersionId) -> dict[str, Any]: @@ -104,7 +104,7 @@ def get_dependencies(cls, resource: AppsYAML) -> Iterable[tuple[type[ResourceIO] @classmethod def get_extra_files(cls, filepath: Path, identifier: AppVersionId, item: dict[str, Any]) -> Iterable[ReadExtra]: - app_external_id = identifier.external_id + app_external_id = identifier.app_external_id source_path_str = item.get("sourcePath") or item.get("source_path") if source_path_str is not None: app_root = (filepath.parent / source_path_str).resolve() @@ -176,7 +176,7 @@ def load_resource_file( if not version: raise ToolkitRequiredValueError("App YAML must define version.") filestem = filepath.stem.rsplit(".", 1)[0] - version_id = AppVersionId(external_id=app_external_id, version=version) + version_id = AppVersionId(app_external_id=app_external_id, version=version) self.zip_path_by_version_id[version_id] = filepath.parent / f"{filestem}.zip" return raw_list @@ -260,7 +260,7 @@ def retrieve(self, ids: Sequence[AppVersionId]) -> list[AppResponse]: results: list[AppResponse] = [] for version_id in ids: response = self.client.tool.apps.retrieve_version( - version_id.external_id, version_id.version, ignore_unknown_ids=True + version_id.app_external_id, version_id.version, ignore_unknown_ids=True ) if response is not None: results.append(response) @@ -271,7 +271,7 @@ def delete(self, ids: Sequence[AppVersionId]) -> int: return 0 by_app: dict[str, list[AppVersionId]] = {} for version_id in ids: - by_app.setdefault(version_id.external_id, []).append(version_id) + by_app.setdefault(version_id.app_external_id, []).append(version_id) for app_external_id, version_ids in by_app.items(): self.client.tool.apps.delete_version(app_external_id, version_ids) return len(ids) 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 12/26] 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 61b8de74a8ce84383144a6f2f54a699b0a7037b6 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 13/26] 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 14/26] 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 15/26] 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 494ab88628267d8d181cc3fb484316303d446c9f 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 16/26] 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 dc1adc5248c8534235f12874e2b31549f0d0edbd 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 17/26] 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 8eeec7196d9a8a610fa9e7bf036bc25b28bbb248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 14:32:29 +0200 Subject: [PATCH 18/26] Add api tests --- cognite_toolkit/_cdf_tk/client/api/apps.py | 1 - .../test_cdf_tk/test_client/test_cdf_apis.py | 74 ++++++++++++++++++- .../test_cdf_tk/test_cruds/test_app.py | 16 ++-- 3 files changed, 81 insertions(+), 10 deletions(-) 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) - 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).""" diff --git a/tests/test_unit/test_cdf_tk/test_cruds/test_app.py b/tests/test_unit/test_cdf_tk/test_cruds/test_app.py index d7741f11b7..32cf9706d0 100644 --- a/tests/test_unit/test_cdf_tk/test_cruds/test_app.py +++ b/tests/test_unit/test_cdf_tk/test_cruds/test_app.py @@ -76,7 +76,9 @@ def test_create_calls_ensure_and_upload(self, app_io_with_zip): external_id="my-app", version="1.0.0", entrypoint="index.html", - zip_bytes=loader.zip_path_by_version_id[AppVersionId(app_external_id="my-app", version="1.0.0")].read_bytes(), + zip_bytes=loader.zip_path_by_version_id[ + AppVersionId(app_external_id="my-app", version="1.0.0") + ].read_bytes(), ) def test_deploy_promotes_draft_to_published_with_active_alias(self, app_io_with_zip): @@ -97,9 +99,7 @@ def test_deploy_clears_alias_when_local_alias_is_none(self, app_io_with_zip): loader.create([item]) - client.tool.apps.update_version.assert_called_once_with( - "my-app", "1.0.0", {"alias": {"setNull": True}} - ) + client.tool.apps.update_version.assert_called_once_with("my-app", "1.0.0", {"alias": {"setNull": True}}) def test_deploy_swaps_alias_to_preview(self, app_io_with_zip): loader, client = app_io_with_zip @@ -108,9 +108,7 @@ def test_deploy_swaps_alias_to_preview(self, app_io_with_zip): loader.create([item]) - client.tool.apps.update_version.assert_called_once_with( - "my-app", "1.0.0", {"alias": {"set": "PREVIEW"}} - ) + client.tool.apps.update_version.assert_called_once_with("my-app", "1.0.0", {"alias": {"set": "PREVIEW"}}) def test_deploy_noop_when_lifecycle_and_alias_match(self, app_io_with_zip): loader, client = app_io_with_zip @@ -296,7 +294,9 @@ def test_fails_when_app_dir_missing(self, tmp_path: Path): yaml_file.write_text("") item = {"externalId": "missing-app", "version": "1.0.0", "name": "Missing App"} - extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(app_external_id="missing-app", version="1.0.0"), item)) + extras = list( + AppIO.get_extra_files(yaml_file, AppVersionId(app_external_id="missing-app", version="1.0.0"), item) + ) assert len(extras) == 1 assert isinstance(extras[0], FailedReadExtra) 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 19/26] 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 20/26] 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 be7111612380081328b54c78b2b851a7bf9fb19d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 14:47:49 +0200 Subject: [PATCH 21/26] Fix tests --- cognite_toolkit/_cdf_tk/client/testing.py | 2 ++ 1 file changed, 2 insertions(+) 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) From 4705a81f98577dd985e43c0ef615d51459605718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Wed, 6 May 2026 15:20:37 +0200 Subject: [PATCH 22/26] Add tests --- .../_cdf_tk/resource_ios/_resource_ios/app.py | 7 +- .../test_cdf_tk/test_cruds/test_app.py | 109 ++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py b/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py index b95533b3bc..64ab9c55b9 100644 --- a/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py +++ b/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py @@ -75,7 +75,12 @@ def create_acl(cls, actions: set[Literal["READ", "WRITE"]], scope: ScopeDefiniti @classmethod def get_id(cls, item: AppResponse | AppRequest | dict) -> AppVersionId: if isinstance(item, dict): - ext = item.get("externalId") or item.get("external_id") + ext = ( + item.get("appExternalId") + or item.get("app_external_id") + or item.get("externalId") + or item.get("external_id") + ) version = item.get("version") if ext is None: raise ToolkitRequiredValueError("App YAML must define externalId.") diff --git a/tests/test_unit/test_cdf_tk/test_cruds/test_app.py b/tests/test_unit/test_cdf_tk/test_cruds/test_app.py index 32cf9706d0..60869c386c 100644 --- a/tests/test_unit/test_cdf_tk/test_cruds/test_app.py +++ b/tests/test_unit/test_cdf_tk/test_cruds/test_app.py @@ -182,6 +182,105 @@ def test_delete_calls_delete_version_grouped_by_app(self, tmp_path: Path): client.tool.apps.delete_version.assert_called_once_with("my-app", ids) +class TestAppIOGetId: + @pytest.mark.parametrize("ext_key", ["externalId", "appExternalId", "external_id", "app_external_id"]) + def test_from_dict_all_key_variants(self, ext_key: str): + assert AppIO.get_id({ext_key: "my-app", "version": "1.0.0"}) == AppVersionId( + app_external_id="my-app", version="1.0.0" + ) + + @pytest.mark.parametrize( + "item, match", + [ + ({"version": "1.0.0"}, "externalId"), + ({"externalId": "my-app"}, "version"), + ], + ) + def test_from_dict_raises_when_field_missing(self, item: dict, match: str): + with pytest.raises(ToolkitRequiredValueError, match=match): + AppIO.get_id(item) + + @pytest.mark.parametrize( + "item", + [ + AppRequest(external_id="my-app", version="1.0.0", name="My App"), + AppResponse(external_id="my-app", version="1.0.0", name="My App", lifecycle_state="DRAFT"), + ], + ) + def test_from_resource_object(self, item: AppRequest | AppResponse): + assert AppIO.get_id(item) == AppVersionId(app_external_id="my-app", version="1.0.0") + + +class TestAppIOLoadResourceFile: + def test_registers_zip_path_for_valid_yaml(self, tmp_path: Path): + apps_dir = tmp_path / "apps" + apps_dir.mkdir() + yaml_file = apps_dir / "my-app.App.yaml" + yaml_file.write_text("externalId: my-app\nversion: 1.0.0\nname: My App\n") + + with monkeypatch_toolkit_client() as client: + loader = AppIO.create_loader(client, tmp_path) + result = loader.load_resource_file(yaml_file) + + assert result == [{"externalId": "my-app", "version": "1.0.0", "name": "My App"}] + version_id = AppVersionId(app_external_id="my-app", version="1.0.0") + assert version_id in loader.zip_path_by_version_id + assert loader.zip_path_by_version_id[version_id] == apps_dir / "my-app.zip" + + def test_returns_empty_when_parent_not_apps(self, tmp_path: Path): + other_dir = tmp_path / "other" + other_dir.mkdir() + yaml_file = other_dir / "my-app.App.yaml" + yaml_file.write_text("externalId: my-app\nversion: 1.0.0\nname: My App\n") + + with monkeypatch_toolkit_client() as client: + loader = AppIO.create_loader(client, tmp_path) + result = loader.load_resource_file(yaml_file) + + assert result == [] + + +class TestAppIORetrieveAndIterate: + def test_retrieve_returns_matching_responses(self, tmp_path: Path): + with monkeypatch_toolkit_client() as client: + loader = AppIO.create_loader(client, tmp_path) + expected = _make_app_response() + client.tool.apps.retrieve_version.return_value = expected + ids = [AppVersionId(app_external_id="my-app", version="1.0.0")] + + result = loader.retrieve(ids) + + assert result == [expected] + + def test_retrieve_skips_not_found(self, tmp_path: Path): + with monkeypatch_toolkit_client() as client: + loader = AppIO.create_loader(client, tmp_path) + client.tool.apps.retrieve_version.return_value = None + ids = [AppVersionId(app_external_id="missing", version="1.0.0")] + + result = loader.retrieve(ids) + + assert result == [] + + def test_iterate_yields_all_pages(self, tmp_path: Path): + with monkeypatch_toolkit_client() as client: + loader = AppIO.create_loader(client, tmp_path) + page = [_make_app_response()] + client.tool.apps.iterate.return_value = iter([page]) + + result = list(loader._iterate()) + + assert result == page + + def test_delete_empty_list_returns_zero(self, tmp_path: Path): + with monkeypatch_toolkit_client() as client: + loader = AppIO.create_loader(client, tmp_path) + result = loader.delete([]) + + assert result == 0 + client.tool.apps.delete_version.assert_not_called() + + class TestAppIODumpResource: def test_uses_local_name_and_description_when_immutable_drift(self): with monkeypatch_toolkit_client() as client: @@ -323,6 +422,16 @@ def test_uses_source_path_field(self, tmp_path: Path): assert len(extras) == 1 assert extras[0].suffix == ".zip" + def test_fails_when_app_dir_missing_from_source_path(self, tmp_path: Path): + yaml_file = tmp_path / "my-app.App.yaml" + yaml_file.write_text("") + item = {"externalId": "my-app", "version": "1.0.0", "name": "My App", "sourcePath": "does-not-exist"} + + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(app_external_id="my-app", version="1.0.0"), item)) + + assert len(extras) == 1 + assert isinstance(extras[0], FailedReadExtra) + def test_excludes_node_modules_and_git(self, tmp_path: Path): app_dir = tmp_path / "my-app" app_dir.mkdir() 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 23/26] 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 3619521a768edcd1812b81e8d67513aef63ee5c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Fri, 15 May 2026 13:11:08 +0200 Subject: [PATCH 24/26] Reworking App/AppVersion split --- .../_cdf_tk/client/_toolkit_client.py | 2 + .../_cdf_tk/client/api/app_versions.py | 142 ++++++++++++++ cognite_toolkit/_cdf_tk/client/api/apps.py | 181 +++--------------- .../_cdf_tk/client/resource_classes/app.py | 28 +-- .../client/resource_classes/app_version.py | 40 ++++ cognite_toolkit/_cdf_tk/client/testing.py | 2 + .../_cdf_tk/resource_ios/_resource_ios/app.py | 92 +++++---- .../test_cdf_tk/test_client/test_cdf_apis.py | 76 +++++--- .../test_cdf_tk/test_cruds/test_app.py | 91 +++++---- .../test_cdf_tk/test_cruds/test_base.py | 6 +- 10 files changed, 364 insertions(+), 296 deletions(-) create mode 100644 cognite_toolkit/_cdf_tk/client/api/app_versions.py create mode 100644 cognite_toolkit/_cdf_tk/client/resource_classes/app_version.py diff --git a/cognite_toolkit/_cdf_tk/client/_toolkit_client.py b/cognite_toolkit/_cdf_tk/client/_toolkit_client.py index 39255a6696..f44b13da7e 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.app_versions import AppVersionsAPI from .api.apps import AppsAPI from .api.assets import AssetsAPI from .api.canvas import IndustrialCanvasAPI @@ -66,6 +67,7 @@ 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.app_versions = AppVersionsAPI(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/app_versions.py b/cognite_toolkit/_cdf_tk/client/api/app_versions.py new file mode 100644 index 0000000000..6e83ec9b23 --- /dev/null +++ b/cognite_toolkit/_cdf_tk/client/api/app_versions.py @@ -0,0 +1,142 @@ +"""AppVersionsAPI: Version management for custom apps via the CDF App Hosting API.""" + +import json +import uuid +from collections.abc import Iterable, Sequence + +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 +from cognite_toolkit._cdf_tk.client.resource_classes.app_version import AppVersionResponse + + +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 AppVersionsAPI: + """Client for the CDF App Hosting Versions API (POST /apphosting/apps/{externalId}/versions/...).""" + + 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 upload( + 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, + ) + self._http_client.request_single_retries(request).get_success_or_raise(request) + + def update(self, external_id: str, version: str, patch: dict) -> None: + """POST /apphosting/apps/{externalId}/versions/update — apply a lifecycle/alias patch to a version.""" + request = RequestMessage( + endpoint_url=self._url(f"/apphosting/apps/{external_id}/versions/update"), + method="POST", + body_content={"items": [{"version": version, "update": patch}]}, + ) + self._http_client.request_single_retries(request).get_success_or_raise(request) + + def retrieve(self, items: Sequence[AppVersionId], ignore_unknown_ids: bool = False) -> list[AppVersionResponse]: + """GET /apphosting/apps/{externalId}/versions/{version} — retrieve version metadata.""" + results: list[AppVersionResponse] = [] + for item in items: + request = RequestMessage( + endpoint_url=self._url(f"/apphosting/apps/{item.app_external_id}/versions/{item.version}"), + method="GET", + ) + result = self._http_client.request_single_retries(request) + if not isinstance(result, SuccessResponse): + if isinstance(result, FailedResponse) and result.status_code in (400, 404) and ignore_unknown_ids: + continue + result.get_success_or_raise(request) + continue + data = json.loads(result.body) + results.append(AppVersionResponse( + app_external_id=data.get("appExternalId", item.app_external_id), + version=data.get("version", item.version), + lifecycle_state=data.get("lifecycleState", "DRAFT"), + alias=data.get("alias"), + entrypoint=data.get("entrypoint", "index.html"), + )) + return results + + def iterate(self, limit: int | None = 100) -> Iterable[list[AppVersionResponse]]: + """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 = [ + AppVersionResponse( + app_external_id=item["appExternalId"], + version=item["version"], + 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(self, versions: Sequence[AppVersionId]) -> None: + """POST /apphosting/apps/{externalId}/versions/delete — delete specific versions, grouped by app.""" + by_app: dict[str, list[AppVersionId]] = {} + for version_id in versions: + by_app.setdefault(version_id.app_external_id, []).append(version_id) + for app_external_id, app_versions in by_app.items(): + request = RequestMessage( + endpoint_url=self._url(f"/apphosting/apps/{app_external_id}/versions/delete"), + method="POST", + body_content={"items": [{"version": v.version} for v in app_versions]}, + ) + self._http_client.request_single_retries(request).get_success_or_raise(request) diff --git a/cognite_toolkit/_cdf_tk/client/api/apps.py b/cognite_toolkit/_cdf_tk/client/api/apps.py index 46b44ccc90..39a967edef 100644 --- a/cognite_toolkit/_cdf_tk/client/api/apps.py +++ b/cognite_toolkit/_cdf_tk/client/api/apps.py @@ -1,170 +1,41 @@ """AppsAPI: Custom apps deployed via the CDF App Hosting API.""" -import json -import uuid -from collections.abc import Iterable, Sequence +from collections.abc import Sequence -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 +from cognite_toolkit._cdf_tk.client.cdf_client import CDFResourceAPI, PagedResponse +from cognite_toolkit._cdf_tk.client.cdf_client.api import Endpoint +from cognite_toolkit._cdf_tk.client.http_client import HTTPClient, ItemsSuccessResponse, RequestMessage, SuccessResponse +from cognite_toolkit._cdf_tk.client.http_client._data_classes import FailedResponse 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/...).""" +class AppsAPI(CDFResourceAPI[AppResponse]): + """Client for the CDF App Hosting API (/apphosting/apps).""" 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()]}, + super().__init__( + http_client=http_client, + method_endpoint_map={ + "create": Endpoint(method="POST", path="/apphosting/apps", item_limit=1), + }, ) - 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 _validate_page_response( + self, response: SuccessResponse | ItemsSuccessResponse + ) -> PagedResponse[AppResponse]: + return PagedResponse[AppResponse].model_validate_json(response.body) - 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": update}]}, - ) - self._http_client.request_single_retries(request).get_success_or_raise(request) + def create(self, items: Sequence[AppRequest]) -> list[AppResponse]: + """POST /apphosting/apps — create apps.""" + return self._request_item_response(items, "create") - 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}"), + def retrieve(self, external_id: str) -> AppResponse | None: + """GET /apphosting/apps/{externalId} — fetch app-level metadata (name, description).""" + request = RequestMessage( + endpoint_url=self._make_url(f"/apphosting/apps/{external_id}"), 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) + result = self._http_client.request_single_retries(request) + if isinstance(result, FailedResponse) and result.status_code == 404: 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 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) + return AppResponse.model_validate_json(result.get_success_or_raise(request).body) diff --git a/cognite_toolkit/_cdf_tk/client/resource_classes/app.py b/cognite_toolkit/_cdf_tk/client/resource_classes/app.py index 99e344cbd2..f9a4db5afb 100644 --- a/cognite_toolkit/_cdf_tk/client/resource_classes/app.py +++ b/cognite_toolkit/_cdf_tk/client/resource_classes/app.py @@ -1,40 +1,22 @@ -from typing import Any, Literal - from cognite_toolkit._cdf_tk.client._resource_base import BaseModelObject, RequestResource, ResponseResource -from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId +from cognite_toolkit._cdf_tk.client.identifiers import ExternalId class App(BaseModelObject): 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(App, RequestResource): - """Local representation of a custom app version for App Hosting deployment.""" - - def as_id(self) -> AppVersionId: - return AppVersionId(app_external_id=self.external_id, version=self.version) + """Write resource for POST /apphosting/apps.""" - 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 is not None: - body["description"] = self.description - return body + def as_id(self) -> ExternalId: + return ExternalId(external_id=self.external_id) class AppResponse(App, ResponseResource[AppRequest]): - """Response from App Hosting after a successful deploy.""" + """Response from GET/POST /apphosting/apps.""" @classmethod def request_cls(cls) -> type[AppRequest]: diff --git a/cognite_toolkit/_cdf_tk/client/resource_classes/app_version.py b/cognite_toolkit/_cdf_tk/client/resource_classes/app_version.py new file mode 100644 index 0000000000..d31fe965b8 --- /dev/null +++ b/cognite_toolkit/_cdf_tk/client/resource_classes/app_version.py @@ -0,0 +1,40 @@ +from typing import Literal + +from cognite_toolkit._cdf_tk.client._resource_base import BaseModelObject, RequestResource, ResponseResource +from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId +from cognite_toolkit._cdf_tk.client.resource_classes.app import App + + +class AppVersion(BaseModelObject): + version: str + lifecycle_state: Literal["DRAFT", "PUBLISHED", "DEPRECATED", "ARCHIVED"] = "PUBLISHED" + alias: Literal["ACTIVE", "PREVIEW"] | None = None + entrypoint: str = "index.html" + + +class AppVersionRequest(App, AppVersion, RequestResource): + """Toolkit write class — the union of App (externalId/name/description) and AppVersion + (version/lifecycleState/alias/entrypoint) fields, matching the single-YAML user experience. + + The App Hosting API splits these across two endpoints: POST /apphosting/apps and + POST /apphosting/apps/{id}/versions. AppIO._deploy splits this object into both calls. + AppVersionResponse uses app_external_id (not external_id) and omits name/description because + the versions API wire format differs from the user-facing YAML representation. + """ + + def as_id(self) -> AppVersionId: + return AppVersionId(app_external_id=self.external_id, version=self.version) + + +class AppVersionResponse(AppVersion, ResponseResource[AppVersionRequest]): + """Response from the App Hosting versions API (GET/POST /apphosting/apps/{id}/versions/...). + + Uses app_external_id (not external_id) because the wire format returns `appExternalId` to + refer to the parent app's ID. App versions themselves do not have an unique `externalId` field. + """ + + app_external_id: str + + @classmethod + def request_cls(cls) -> type[AppVersionRequest]: + return AppVersionRequest diff --git a/cognite_toolkit/_cdf_tk/client/testing.py b/cognite_toolkit/_cdf_tk/client/testing.py index b8ea4151e9..3a6eacc10c 100644 --- a/cognite_toolkit/_cdf_tk/client/testing.py +++ b/cognite_toolkit/_cdf_tk/client/testing.py @@ -33,6 +33,7 @@ from . import ToolkitClientConfig from ._toolkit_client import ToolAPI from .api.agents import AgentsAPI +from .api.app_versions import AppVersionsAPI from .api.apps import AppsAPI from .api.assets import AssetsAPI from .api.chart_scheduled_calculations import ChartScheduledCalculationsAPI @@ -180,6 +181,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.app_versions = MagicMock(spec=AppVersionsAPI) 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/resource_ios/_resource_ios/app.py b/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py index 64ab9c55b9..6d6f743d0a 100644 --- a/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py +++ b/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py @@ -10,13 +10,15 @@ from cognite_toolkit._cdf_tk.client import ToolkitClient from cognite_toolkit._cdf_tk.client._resource_base import Identifier from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId -from cognite_toolkit._cdf_tk.client.resource_classes.app import AppRequest, AppResponse +from cognite_toolkit._cdf_tk.client.resource_classes.app import AppRequest +from cognite_toolkit._cdf_tk.client.resource_classes.app_version import AppVersionRequest, AppVersionResponse from cognite_toolkit._cdf_tk.client.resource_classes.group import ( AclType, AllScope, AppHostingAcl, ScopeDefinition, ) +from cognite_toolkit._cdf_tk.client.http_client import ToolkitAPIError from cognite_toolkit._cdf_tk.exceptions import ToolkitRequiredValueError, ToolkitValueError from cognite_toolkit._cdf_tk.resource_ios._base_ios import FailedReadExtra, ReadExtra, ResourceIO, SuccessExtra from cognite_toolkit._cdf_tk.utils.hashing import calculate_directory_hash @@ -44,11 +46,11 @@ def _zip_app_directory(source_dir: Path) -> bytes: @final -class AppIO(ResourceIO[AppVersionId, AppRequest, AppResponse]): +class AppIO(ResourceIO[AppVersionId, AppVersionRequest, AppVersionResponse]): support_drop = True folder_name = "apps" - resource_cls = AppResponse - resource_write_cls = AppRequest + resource_cls = AppVersionResponse + resource_write_cls = AppVersionRequest kind = "App" yaml_cls = AppsYAML dependencies = frozenset({GroupAllScopedCRUD}) @@ -64,7 +66,7 @@ def display_name(self) -> str: return "apps" @classmethod - def get_minimum_scope(cls, items: Sequence[AppRequest]) -> ScopeDefinition: + def get_minimum_scope(cls, items: Sequence[AppVersionRequest]) -> ScopeDefinition: return AllScope() @classmethod @@ -73,7 +75,7 @@ def create_acl(cls, actions: set[Literal["READ", "WRITE"]], scope: ScopeDefiniti yield AppHostingAcl(actions=sorted(actions), scope=scope) @classmethod - def get_id(cls, item: AppResponse | AppRequest | dict) -> AppVersionId: + def get_id(cls, item: AppVersionResponse | AppVersionRequest | dict) -> AppVersionId: if isinstance(item, dict): ext = ( item.get("appExternalId") @@ -87,9 +89,9 @@ def get_id(cls, item: AppResponse | AppRequest | dict) -> AppVersionId: if version is None: raise ToolkitRequiredValueError("App YAML must define version.") return AppVersionId(app_external_id=ext, version=version) - if isinstance(item, AppRequest): + if isinstance(item, AppVersionRequest): return item.as_id() - return AppVersionId(app_external_id=item.external_id, version=item.version) + return AppVersionId(app_external_id=item.app_external_id, version=item.version) @classmethod def dump_id(cls, identifier: AppVersionId) -> dict[str, Any]: @@ -186,13 +188,20 @@ def load_resource_file( return raw_list - def load_resource(self, resource: dict[str, Any], is_dry_run: bool = False) -> AppRequest: - return AppRequest.model_validate(resource) + def load_resource(self, resource: dict[str, Any], is_dry_run: bool = False) -> AppVersionRequest: + return AppVersionRequest.model_validate(resource) - def dump_resource(self, resource: AppResponse, local: dict[str, Any] | None = None) -> dict[str, Any]: - dumped = resource.as_request_resource().dump(context="toolkit") + def dump_resource(self, resource: AppVersionResponse, local: dict[str, Any] | None = None) -> dict[str, Any]: local = local or {} - # name and description are immutable in CDF post-create; always use local values to suppress stale diff. + dumped: dict[str, Any] = { + "externalId": resource.app_external_id, + "version": resource.version, + "lifecycleState": resource.lifecycle_state, + "entrypoint": resource.entrypoint, + } + if resource.alias is not None: + dumped["alias"] = resource.alias + # name and description are app-level and immutable post-create; always use local values to suppress stale diff. for immutable_key in ("name", "description"): if immutable_key in local: dumped[immutable_key] = local[immutable_key] @@ -201,23 +210,30 @@ def dump_resource(self, resource: AppResponse, local: dict[str, Any] | None = No dumped[local_only_key] = local[local_only_key] return dumped - def _deploy(self, item: AppRequest) -> AppResponse: + def _deploy(self, item: AppVersionRequest) -> AppVersionResponse: version_id = item.as_id() zip_path = self.zip_path_by_version_id.get(version_id) if zip_path is None or not zip_path.exists(): raise ToolkitRequiredValueError( f"App zip not found for {item.external_id!r} version {item.version!r}. Ensure build was run first." ) - self.client.tool.apps.ensure_app(item) + try: + self.client.tool.apps.create([AppRequest(external_id=item.external_id, name=item.name, description=item.description)]) + except ToolkitAPIError as error: + if error.code != 409: + raise zip_bytes = zip_path.read_bytes() - self.client.tool.apps.upload_version( + self.client.tool.app_versions.upload( external_id=item.external_id, version=item.version, entrypoint=item.entrypoint, zip_bytes=zip_bytes, ) - current = self.client.tool.apps.retrieve_version(item.external_id, item.version, ignore_unknown_ids=True) + retrieved = self.client.tool.app_versions.retrieve( + [AppVersionId(app_external_id=item.external_id, version=item.version)], ignore_unknown_ids=True + ) + current = retrieved[0] if retrieved else None current_lifecycle = current.lifecycle_state if current else "DRAFT" current_alias = current.alias if current else None @@ -243,42 +259,42 @@ def _deploy(self, item: AppRequest) -> AppResponse: update["alias"] = {"setNull": True} if item.alias is None else {"set": item.alias} if update: - self.client.tool.apps.update_version(item.external_id, item.version, update) + self.client.tool.app_versions.update(item.external_id, item.version, update) - return AppResponse( - external_id=item.external_id, + return AppVersionResponse( + app_external_id=item.external_id, version=item.version, - name=item.name, - description=item.description, lifecycle_state=item.lifecycle_state, alias=item.alias, entrypoint=item.entrypoint, ) - def create(self, items: Sequence[AppRequest]) -> list[AppResponse]: + def create(self, items: Sequence[AppVersionRequest]) -> list[AppVersionResponse]: return [self._deploy(item) for item in items] - def update(self, items: Sequence[AppRequest]) -> list[AppResponse]: + def update(self, items: Sequence[AppVersionRequest]) -> list[AppVersionResponse]: return [self._deploy(item) for item in items] - def retrieve(self, ids: Sequence[AppVersionId]) -> list[AppResponse]: - results: list[AppResponse] = [] + def retrieve(self, ids: Sequence[AppVersionId]) -> list[AppVersionResponse]: + results: list[AppVersionResponse] = [] for version_id in ids: - response = self.client.tool.apps.retrieve_version( - version_id.app_external_id, version_id.version, ignore_unknown_ids=True - ) - if response is not None: - results.append(response) + version_responses = self.client.tool.app_versions.retrieve([version_id], ignore_unknown_ids=True) + if not version_responses: + continue + version_response = version_responses[0] + results.append(AppVersionResponse( + app_external_id=version_response.app_external_id, + version=version_response.version, + lifecycle_state=version_response.lifecycle_state, + alias=version_response.alias, + entrypoint=version_response.entrypoint, + )) return results def delete(self, ids: Sequence[AppVersionId]) -> int: if not ids: return 0 - by_app: dict[str, list[AppVersionId]] = {} - for version_id in ids: - by_app.setdefault(version_id.app_external_id, []).append(version_id) - for app_external_id, version_ids in by_app.items(): - self.client.tool.apps.delete_version(app_external_id, version_ids) + self.client.tool.app_versions.delete(ids) return len(ids) def _iterate( @@ -286,6 +302,6 @@ def _iterate( data_set_external_id: str | None = None, space: str | None = None, parent_ids: Sequence[Hashable] | None = None, - ) -> Iterable[AppResponse]: - for page in self.client.tool.apps.iterate(): + ) -> Iterable[AppVersionResponse]: + for page in self.client.tool.app_versions.iterate(): yield from page 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..fb9f46d82c 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.app_versions import AppVersionsAPI 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 @@ -1232,56 +1233,69 @@ def test_apps_api_methods(self, toolkit_config: ToolkitClientConfig, respx_mock: config = toolkit_config api = AppsAPI(HTTPClient(config)) app_external_id = "my-app" + app_request = AppRequest(external_id=app_external_id, name="My App") + + app_json = {"externalId": app_external_id, "name": "My App"} + respx_mock.post(config.create_api_url("/apphosting/apps")).mock( + return_value=httpx.Response(status_code=201, json={"items": [app_json]}) + ) + created = api.create([app_request]) + assert len(created) == 1 + assert created[0].name == "My App" + + # Test retrieve + respx_mock.get(config.create_api_url(f"/apphosting/apps/{app_external_id}")).mock( + return_value=httpx.Response(status_code=200, json=app_json) + ) + result = api.retrieve(app_external_id) + assert result is not None + assert result.name == "My App" + + # Test retrieve with 404 + respx_mock.get(config.create_api_url(f"/apphosting/apps/{app_external_id}")).mock( + return_value=httpx.Response(status_code=404) + ) + assert api.retrieve(app_external_id) is None + + def test_app_versions_api_methods(self, toolkit_config: ToolkitClientConfig, respx_mock: respx.MockRouter) -> None: + config = toolkit_config + api = AppVersionsAPI(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") + # Test upload respx_mock.post(config.create_api_url(f"/apphosting/apps/{app_external_id}/versions")).mock( - return_value=httpx.Response(status_code=409) + return_value=httpx.Response(status_code=201) ) - api.upload_version(app_external_id, version, "index.html", b"fake-zip") + api.upload(app_external_id, version, "index.html", b"fake-zip") - # Test update_version + # Test update 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"}}) + api.update(app_external_id, version, {"lifecycleState": {"set": "PUBLISHED"}}) - # Test retrieve_version (two calls merged into one response) + # Test retrieve 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" + version_id = AppVersionId(app_external_id=app_external_id, version=version) + retrieved = api.retrieve([version_id]) + assert len(retrieved) == 1 + assert retrieved[0].app_external_id == app_external_id + assert retrieved[0].version == version + assert retrieved[0].lifecycle_state == "DRAFT" - # Test retrieve_version with 404 and ignore_unknown_ids + # Test retrieve 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 + assert api.retrieve([version_id], ignore_unknown_ids=True) == [] # Test iterate respx_mock.post(config.create_api_url("/apphosting/versions/list")).mock( @@ -1291,11 +1305,11 @@ def test_apps_api_methods(self, toolkit_config: ToolkitClientConfig, respx_mock: assert len(batches) == 1 assert batches[0][0].version == version - # Test delete_version + # Test delete 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)]) + api.delete([AppVersionId(app_external_id=app_external_id, version=version)]) assert len(respx_mock.calls) >= 1 diff --git a/tests/test_unit/test_cdf_tk/test_cruds/test_app.py b/tests/test_unit/test_cdf_tk/test_cruds/test_app.py index 60869c386c..17c51430de 100644 --- a/tests/test_unit/test_cdf_tk/test_cruds/test_app.py +++ b/tests/test_unit/test_cdf_tk/test_cruds/test_app.py @@ -5,7 +5,8 @@ import pytest from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId -from cognite_toolkit._cdf_tk.client.resource_classes.app import AppRequest, AppResponse +from cognite_toolkit._cdf_tk.client.resource_classes.app import AppRequest +from cognite_toolkit._cdf_tk.client.resource_classes.app_version import AppVersionRequest, AppVersionResponse from cognite_toolkit._cdf_tk.client.testing import monkeypatch_toolkit_client from cognite_toolkit._cdf_tk.exceptions import ToolkitRequiredValueError, ToolkitValueError from cognite_toolkit._cdf_tk.resource_ios._base_ios import FailedReadExtra @@ -19,8 +20,8 @@ def _make_app_request( lifecycle_state: str = "PUBLISHED", alias: str | None = None, entrypoint: str = "index.html", -) -> AppRequest: - return AppRequest( +) -> AppVersionRequest: + return AppVersionRequest( external_id=external_id, version=version, name=name, @@ -31,15 +32,14 @@ def _make_app_request( def _make_app_response( - external_id: str = "my-app", + app_external_id: str = "my-app", version: str = "1.0.0", lifecycle_state: str = "PUBLISHED", alias: str | None = "ACTIVE", -) -> AppResponse: - return AppResponse( - external_id=external_id, +) -> AppVersionResponse: + return AppVersionResponse( + app_external_id=app_external_id, version=version, - name="My App", lifecycle_state=lifecycle_state, alias=alias, ) @@ -64,15 +64,15 @@ def app_io_with_zip(self, tmp_path: Path): loader.zip_path_by_version_id[version_id] = zip_path yield loader, client - def test_create_calls_ensure_and_upload(self, app_io_with_zip): + def test_create_calls_create_and_upload(self, app_io_with_zip): loader, client = app_io_with_zip item = _make_app_request(lifecycle_state="DRAFT", alias=None) - client.tool.apps.retrieve_version.return_value = None + client.tool.app_versions.retrieve.return_value = [] loader.create([item]) - client.tool.apps.ensure_app.assert_called_once_with(item) - client.tool.apps.upload_version.assert_called_once_with( + client.tool.apps.create.assert_called_once_with([AppRequest(external_id="my-app", name="My App")]) + client.tool.app_versions.upload.assert_called_once_with( external_id="my-app", version="1.0.0", entrypoint="index.html", @@ -84,45 +84,45 @@ def test_create_calls_ensure_and_upload(self, app_io_with_zip): def test_deploy_promotes_draft_to_published_with_active_alias(self, app_io_with_zip): loader, client = app_io_with_zip item = _make_app_request(lifecycle_state="PUBLISHED", alias="ACTIVE") - client.tool.apps.retrieve_version.return_value = None + client.tool.app_versions.retrieve.return_value = [] loader.create([item]) - client.tool.apps.update_version.assert_called_once_with( + client.tool.app_versions.update.assert_called_once_with( "my-app", "1.0.0", {"lifecycleState": {"set": "PUBLISHED"}, "alias": {"set": "ACTIVE"}} ) def test_deploy_clears_alias_when_local_alias_is_none(self, app_io_with_zip): loader, client = app_io_with_zip item = _make_app_request(lifecycle_state="PUBLISHED", alias=None) - client.tool.apps.retrieve_version.return_value = _make_app_response(lifecycle_state="PUBLISHED", alias="ACTIVE") + client.tool.app_versions.retrieve.return_value = [_make_app_response(lifecycle_state="PUBLISHED", alias="ACTIVE")] loader.create([item]) - client.tool.apps.update_version.assert_called_once_with("my-app", "1.0.0", {"alias": {"setNull": True}}) + client.tool.app_versions.update.assert_called_once_with("my-app", "1.0.0", {"alias": {"setNull": True}}) def test_deploy_swaps_alias_to_preview(self, app_io_with_zip): loader, client = app_io_with_zip item = _make_app_request(lifecycle_state="PUBLISHED", alias="PREVIEW") - client.tool.apps.retrieve_version.return_value = _make_app_response(lifecycle_state="PUBLISHED", alias="ACTIVE") + client.tool.app_versions.retrieve.return_value = [_make_app_response(lifecycle_state="PUBLISHED", alias="ACTIVE")] loader.create([item]) - client.tool.apps.update_version.assert_called_once_with("my-app", "1.0.0", {"alias": {"set": "PREVIEW"}}) + client.tool.app_versions.update.assert_called_once_with("my-app", "1.0.0", {"alias": {"set": "PREVIEW"}}) def test_deploy_noop_when_lifecycle_and_alias_match(self, app_io_with_zip): loader, client = app_io_with_zip item = _make_app_request(lifecycle_state="PUBLISHED", alias="ACTIVE") - client.tool.apps.retrieve_version.return_value = _make_app_response(lifecycle_state="PUBLISHED", alias="ACTIVE") + client.tool.app_versions.retrieve.return_value = [_make_app_response(lifecycle_state="PUBLISHED", alias="ACTIVE")] loader.create([item]) - client.tool.apps.update_version.assert_not_called() + client.tool.app_versions.update.assert_not_called() def test_deploy_rejects_backward_lifecycle_transition(self, app_io_with_zip): loader, client = app_io_with_zip item = _make_app_request(lifecycle_state="DRAFT", alias=None) - client.tool.apps.retrieve_version.return_value = _make_app_response(lifecycle_state="PUBLISHED", alias=None) + client.tool.app_versions.retrieve.return_value = [_make_app_response(lifecycle_state="PUBLISHED", alias=None)] with pytest.raises(ToolkitValueError, match="forward-only"): loader.create([item]) @@ -130,7 +130,7 @@ def test_deploy_rejects_backward_lifecycle_transition(self, app_io_with_zip): def test_deploy_rejects_alias_on_non_published_version(self, app_io_with_zip): loader, client = app_io_with_zip item = _make_app_request(lifecycle_state="DRAFT", alias="ACTIVE") - client.tool.apps.retrieve_version.return_value = None + client.tool.app_versions.retrieve.return_value = [] with pytest.raises(ToolkitValueError, match="alias"): loader.create([item]) @@ -145,30 +145,30 @@ def test_deploy_raises_when_zip_missing(self, tmp_path: Path): def test_deploy_returns_response_with_correct_fields(self, app_io_with_zip): loader, _client = app_io_with_zip item = _make_app_request(lifecycle_state="PUBLISHED", alias="ACTIVE") - _client.tool.apps.retrieve_version.return_value = None + _client.tool.app_versions.retrieve.return_value = [] results = loader.create([item]) assert len(results) == 1 response = results[0] - assert isinstance(response, AppResponse) - assert response.external_id == "my-app" + assert isinstance(response, AppVersionResponse) + assert response.app_external_id == "my-app" assert response.version == "1.0.0" assert response.lifecycle_state == "PUBLISHED" assert response.alias == "ACTIVE" - def test_update_calls_ensure_and_upload(self, app_io_with_zip): + def test_update_calls_create_and_upload(self, app_io_with_zip): loader, client = app_io_with_zip item = _make_app_request(version="2.0.0", lifecycle_state="DRAFT", alias=None) # Register zip for 2.0.0 zip_path = loader.zip_path_by_version_id[AppVersionId(app_external_id="my-app", version="1.0.0")] loader.zip_path_by_version_id[AppVersionId(app_external_id="my-app", version="2.0.0")] = zip_path - client.tool.apps.retrieve_version.return_value = None + client.tool.app_versions.retrieve.return_value = [] loader.update([item]) - client.tool.apps.ensure_app.assert_called_once_with(item) - client.tool.apps.upload_version.assert_called_once() + client.tool.apps.create.assert_called_once_with([AppRequest(external_id="my-app", name="My App")]) + client.tool.app_versions.upload.assert_called_once() def test_delete_calls_delete_version_grouped_by_app(self, tmp_path: Path): with monkeypatch_toolkit_client() as client: @@ -179,7 +179,7 @@ def test_delete_calls_delete_version_grouped_by_app(self, tmp_path: Path): ] loader.delete(ids) - client.tool.apps.delete_version.assert_called_once_with("my-app", ids) + client.tool.app_versions.delete.assert_called_once_with(ids) class TestAppIOGetId: @@ -203,11 +203,11 @@ def test_from_dict_raises_when_field_missing(self, item: dict, match: str): @pytest.mark.parametrize( "item", [ - AppRequest(external_id="my-app", version="1.0.0", name="My App"), - AppResponse(external_id="my-app", version="1.0.0", name="My App", lifecycle_state="DRAFT"), + AppVersionRequest(external_id="my-app", version="1.0.0", name="My App"), + AppVersionResponse(app_external_id="my-app", version="1.0.0", lifecycle_state="DRAFT"), ], ) - def test_from_resource_object(self, item: AppRequest | AppResponse): + def test_from_resource_object(self, item: AppVersionRequest | AppVersionResponse): assert AppIO.get_id(item) == AppVersionId(app_external_id="my-app", version="1.0.0") @@ -244,18 +244,19 @@ class TestAppIORetrieveAndIterate: def test_retrieve_returns_matching_responses(self, tmp_path: Path): with monkeypatch_toolkit_client() as client: loader = AppIO.create_loader(client, tmp_path) - expected = _make_app_response() - client.tool.apps.retrieve_version.return_value = expected + version_response = _make_app_response(app_external_id="my-app", version="1.0.0") + client.tool.app_versions.retrieve.return_value = [version_response] ids = [AppVersionId(app_external_id="my-app", version="1.0.0")] result = loader.retrieve(ids) - assert result == [expected] + assert len(result) == 1 + assert result[0].app_external_id == "my-app" def test_retrieve_skips_not_found(self, tmp_path: Path): with monkeypatch_toolkit_client() as client: loader = AppIO.create_loader(client, tmp_path) - client.tool.apps.retrieve_version.return_value = None + client.tool.app_versions.retrieve.return_value = [] ids = [AppVersionId(app_external_id="missing", version="1.0.0")] result = loader.retrieve(ids) @@ -266,7 +267,7 @@ def test_iterate_yields_all_pages(self, tmp_path: Path): with monkeypatch_toolkit_client() as client: loader = AppIO.create_loader(client, tmp_path) page = [_make_app_response()] - client.tool.apps.iterate.return_value = iter([page]) + client.tool.app_versions.iterate.return_value = iter([page]) result = list(loader._iterate()) @@ -278,7 +279,7 @@ def test_delete_empty_list_returns_zero(self, tmp_path: Path): result = loader.delete([]) assert result == 0 - client.tool.apps.delete_version.assert_not_called() + client.tool.app_versions.delete.assert_not_called() class TestAppIODumpResource: @@ -286,11 +287,9 @@ def test_uses_local_name_and_description_when_immutable_drift(self): with monkeypatch_toolkit_client() as client: loader = AppIO.create_loader(client, None) - response = AppResponse( - external_id="my-app", + response = AppVersionResponse( + app_external_id="my-app", version="1.0.0", - name="Old name from CDF", - description=None, lifecycle_state="PUBLISHED", alias="ACTIVE", ) @@ -298,6 +297,7 @@ def test_uses_local_name_and_description_when_immutable_drift(self): dumped = loader.dump_resource(response, local=local) + assert dumped["externalId"] == "my-app" assert dumped["name"] == "New local name" assert dumped["description"] == "New description" @@ -305,10 +305,9 @@ def test_copies_source_path_from_local(self): with monkeypatch_toolkit_client() as client: loader = AppIO.create_loader(client, None) - response = AppResponse( - external_id="my-app", + response = AppVersionResponse( + app_external_id="my-app", version="1.0.0", - name="My App", lifecycle_state="PUBLISHED", alias="ACTIVE", ) diff --git a/tests/test_unit/test_cdf_tk/test_cruds/test_base.py b/tests/test_unit/test_cdf_tk/test_cruds/test_base.py index 39dd566e0d..02834fff75 100644 --- a/tests/test_unit/test_cdf_tk/test_cruds/test_base.py +++ b/tests/test_unit/test_cdf_tk/test_cruds/test_base.py @@ -21,7 +21,7 @@ from pytest import MonkeyPatch from cognite_toolkit._cdf_tk.cdf_toml import CDFToml -from cognite_toolkit._cdf_tk.client.resource_classes.app import AppResponse +from cognite_toolkit._cdf_tk.client.resource_classes.app_version import AppVersionResponse from cognite_toolkit._cdf_tk.client.resource_classes.cognite_file import CogniteFileResponse from cognite_toolkit._cdf_tk.client.resource_classes.filemetadata import FileMetadataResponse from cognite_toolkit._cdf_tk.client.resource_classes.graphql_data_model import GraphQLDataModelResponse @@ -127,7 +127,7 @@ def test_loader_takes_dict( StreamlitResponse, CogniteFileResponse, FileMetadataResponse, - AppResponse, + AppVersionResponse, ]: pytest.skip("Skipped loaders that require secondary files") elif loader.resource_cls in [Edge, Node, Destination]: @@ -172,7 +172,7 @@ def test_loader_takes_list( StreamlitResponse, CogniteFileResponse, FileMetadataResponse, - AppResponse, + AppVersionResponse, ]: pytest.skip("Skipped loaders that require secondary files") elif loader.resource_cls in [Edge, Node, Destination]: From e24d39ead6503ac0e60e003c63f144d08c5cd8e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Fri, 15 May 2026 16:14:29 +0200 Subject: [PATCH 25/26] Refactoring multipart upload --- .../_cdf_tk/client/api/app_versions.py | 39 ++------ .../_cdf_tk/client/http_client/_client.py | 90 ++++++++++++------- 2 files changed, 65 insertions(+), 64 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/client/api/app_versions.py b/cognite_toolkit/_cdf_tk/client/api/app_versions.py index 6e83ec9b23..67c6895e62 100644 --- a/cognite_toolkit/_cdf_tk/client/api/app_versions.py +++ b/cognite_toolkit/_cdf_tk/client/api/app_versions.py @@ -1,32 +1,14 @@ """AppVersionsAPI: Version management for custom apps via the CDF App Hosting API.""" import json -import uuid from collections.abc import Iterable, Sequence -from cognite_toolkit._cdf_tk.client.http_client import HTTPClient, RequestMessage +from cognite_toolkit._cdf_tk.client.http_client import HTTPClient, RequestMessage, ToolkitAPIError from cognite_toolkit._cdf_tk.client.http_client._data_classes import FailedResponse, SuccessResponse from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId from cognite_toolkit._cdf_tk.client.resource_classes.app_version import AppVersionResponse -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 AppVersionsAPI: """Client for the CDF App Hosting Versions API (POST /apphosting/apps/{externalId}/versions/...).""" @@ -43,19 +25,14 @@ def upload( 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, + """POST /apphosting/apps/{externalId}/versions — multipart/form-data upload of the zipped app.""" + result = self._http_client.request_multipart_retries( + url=self._url(f"/apphosting/apps/{external_id}/versions"), + files={"file": ("app.zip", zip_bytes, "application/zip")}, + form_fields={"version": version, "entryPath": entrypoint}, ) - self._http_client.request_single_retries(request).get_success_or_raise(request) + if isinstance(result, FailedResponse): + raise ToolkitAPIError(message=result.body, code=result.status_code) def update(self, external_id: str, version: str, patch: dict) -> None: """POST /apphosting/apps/{externalId}/versions/update — apply a lifecycle/alias patch 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..7e98eefeea 100644 --- a/cognite_toolkit/_cdf_tk/client/http_client/_client.py +++ b/cognite_toolkit/_cdf_tk/client/http_client/_client.py @@ -259,38 +259,26 @@ def _handle_error_single(self, e: Exception, request: RequestMessage) -> Request return FailedRequest(error=error_msg) - def request_raw_retries( + def _execute_raw_with_retries( self, - method: Literal["GET", "POST", "PUT", "DELETE"], + method: str, url: str, - content: bytes | Iterable[bytes], + max_retries: int, + content: bytes | Iterable[bytes] | None = None, + files: dict[str, tuple[str, bytes, str]] | None = None, + data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - max_retries: int | None = None, ) -> SuccessResponse | FailedResponse: - """Send a raw HTTP request with retry logic but without authentication headers. - - 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. - - 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. - max_retries: Maximum number of retries. Defaults to the client's max_retries setting. - - 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 attempt = 0 last_error_code: int = -1 - while attempt <= retries: + while attempt <= max_retries: try: response = self.session.request( method=method, url=url, content=content, + files=files, + data=data, headers=headers, follow_redirects=False, ) @@ -301,22 +289,16 @@ def request_raw_retries( content=response.content, ) last_error_code = response.status_code - # Check if we should retry based on status code if response.status_code in self._retry_status_codes: retry_after = self._get_retry_after_in_header(response) - if retry_after is not None: - time.sleep(retry_after) - else: - time.sleep(self._backoff_time(attempt)) + time.sleep(retry_after if retry_after is not None else self._backoff_time(attempt)) attempt += 1 continue - # Non-retryable error return FailedResponse( status_code=response.status_code, body=response.text, - error=ErrorDetails(code=response.status_code, message=response.text), + error=ErrorDetails.from_response(response), ) - except ( httpx.ReadTimeout, httpx.TimeoutException, @@ -325,22 +307,64 @@ def request_raw_retries( httpx.ConnectTimeout, ) as e: attempt += 1 - if attempt <= retries: + if attempt <= max_retries: time.sleep(self._backoff_time(attempt)) continue 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.", error=ErrorDetails(code=last_error_code, message=f"Request failed after {attempt} attempts."), ) + def request_raw_retries( + self, + method: Literal["GET", "POST", "PUT", "DELETE"], + url: str, + content: bytes | Iterable[bytes], + headers: dict[str, str] | None = None, + max_retries: int | None = None, + ) -> SuccessResponse | FailedResponse: + """Send a raw HTTP request with retry logic but without authentication headers. + + 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. + """ + retries = max_retries if max_retries is not None else self._max_retries + return self._execute_raw_with_retries(method, url, retries, content=content, headers=headers) + + def request_multipart_retries( + self, + url: str, + files: dict[str, tuple[str, bytes, str]], + form_fields: dict[str, str], + api_version: str | None = None, + ) -> SuccessResponse | FailedResponse: + """POST multipart/form-data to a CDF endpoint with auth headers and retry logic. + + Uses httpx's native multipart encoder — Content-Type (with boundary) and + Content-Length are set automatically. Unlike request_raw_retries, CDF auth + headers are included because this method targets CDF endpoints, not signed URLs. + """ + auth_name, auth_value = self.config.credentials.authorization_header() + # Content-Type is intentionally absent — httpx sets it from the multipart body (including boundary). + headers: dict[str, str] = { + "User-Agent": f"httpx/{httpx.__version__} {get_user_agent()}", + auth_name: auth_value, + "accept": "application/json", + "x-cdp-sdk": f"CogniteToolkit:{get_current_toolkit_version()}", + "x-cdp-app": self.config.client_name, + "cdf-version": api_version or self.config.api_subversion, + } + return self._execute_raw_with_retries("POST", url, self._max_retries, files=files, data=form_fields, headers=headers) + def request_items(self, message: ItemsRequest) -> Sequence[ItemsRequest | ItemsResultMessage]: """Send an HTTP request with multiple items and return the response. From 2b6a443f265e772b1496fbdb4166fba36008e29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Schj=C3=B8lberg?= Date: Fri, 15 May 2026 17:26:35 +0200 Subject: [PATCH 26/26] Add validations from cognite/cli --- .../_cdf_tk/resource_ios/_resource_ios/app.py | 73 +++++++++++++--- .../test_cdf_tk/test_cruds/test_app.py | 84 +++++++++++++++++++ 2 files changed, 146 insertions(+), 11 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py b/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py index 6d6f743d0a..2d812c545b 100644 --- a/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py +++ b/cognite_toolkit/_cdf_tk/resource_ios/_resource_ios/app.py @@ -1,4 +1,5 @@ import io +import json import os import zipfile from collections.abc import Hashable, Iterable, Sequence @@ -9,6 +10,7 @@ from cognite_toolkit._cdf_tk.client import ToolkitClient from cognite_toolkit._cdf_tk.client._resource_base import Identifier +from cognite_toolkit._cdf_tk.client.http_client import ToolkitAPIError from cognite_toolkit._cdf_tk.client.identifiers import AppVersionId from cognite_toolkit._cdf_tk.client.resource_classes.app import AppRequest from cognite_toolkit._cdf_tk.client.resource_classes.app_version import AppVersionRequest, AppVersionResponse @@ -18,7 +20,6 @@ AppHostingAcl, ScopeDefinition, ) -from cognite_toolkit._cdf_tk.client.http_client import ToolkitAPIError from cognite_toolkit._cdf_tk.exceptions import ToolkitRequiredValueError, ToolkitValueError from cognite_toolkit._cdf_tk.resource_ios._base_ios import FailedReadExtra, ReadExtra, ResourceIO, SuccessExtra from cognite_toolkit._cdf_tk.utils.hashing import calculate_directory_hash @@ -31,7 +32,7 @@ _LIFECYCLE_ORDER = ["DRAFT", "PUBLISHED", "DEPRECATED", "ARCHIVED"] -def _zip_app_directory(source_dir: Path) -> bytes: +def _zip_app_directory(source_dir: Path, extra_root_files: list[Path]) -> bytes: buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", strict_timestamps=False) as zf: for root, dirs, files in os.walk(source_dir): @@ -42,6 +43,8 @@ def _zip_app_directory(source_dir: Path) -> bytes: for filename in files: file_path = root_path / filename zf.write(file_path, arcname=str(file_path.relative_to(source_dir))) + for extra_file in extra_root_files: + zf.write(extra_file, arcname=extra_file.name) return buffer.getvalue() @@ -158,8 +161,52 @@ def get_extra_files(cls, filepath: Path, identifier: AppVersionId, item: dict[st source_path=app_root, ) return + + package_json = app_root / "package.json" + if not package_json.is_file(): + yield FailedReadExtra( + code="MISSING", + error=( + f"App {app_external_id!r} is missing package.json at {app_root.as_posix()}. " + f"This file is required to deploy to the App Hosting service." + ), + source_path=package_json, + ) + return + + package_lock = app_root / "package-lock.json" + if not package_lock.is_file(): + yield FailedReadExtra( + code="MISSING", + error=( + f"App {app_external_id!r} is missing package-lock.json at {app_root.as_posix()}. " + f"This file is required to deploy to the App Hosting service." + ), + source_path=package_lock, + ) + return + + manifest_json = app_root / "manifest.json" + manifest_file: Path | None = None + if manifest_json.is_file(): + try: + json.loads(manifest_json.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + yield FailedReadExtra( + code="SYNTAX-ERROR", + error=f"App {app_external_id!r} has an invalid manifest.json at {manifest_json.as_posix()}: {error}", + source_path=manifest_json, + ) + return + manifest_file = manifest_json + + # Files already inside source_dir are captured by the recursive walk; only add those outside it. + extra_root_files = [ + f for f in [package_json, package_lock, manifest_file] if f is not None and not f.is_relative_to(source_dir) + ] + source_hash = calculate_directory_hash(source_dir) - zip_bytes = _zip_app_directory(source_dir) + zip_bytes = _zip_app_directory(source_dir, extra_root_files) yield SuccessExtra( source_path=source_dir, source_hash=source_hash, @@ -218,7 +265,9 @@ def _deploy(self, item: AppVersionRequest) -> AppVersionResponse: f"App zip not found for {item.external_id!r} version {item.version!r}. Ensure build was run first." ) try: - self.client.tool.apps.create([AppRequest(external_id=item.external_id, name=item.name, description=item.description)]) + self.client.tool.apps.create( + [AppRequest(external_id=item.external_id, name=item.name, description=item.description)] + ) except ToolkitAPIError as error: if error.code != 409: raise @@ -282,13 +331,15 @@ def retrieve(self, ids: Sequence[AppVersionId]) -> list[AppVersionResponse]: if not version_responses: continue version_response = version_responses[0] - results.append(AppVersionResponse( - app_external_id=version_response.app_external_id, - version=version_response.version, - lifecycle_state=version_response.lifecycle_state, - alias=version_response.alias, - entrypoint=version_response.entrypoint, - )) + results.append( + AppVersionResponse( + app_external_id=version_response.app_external_id, + version=version_response.version, + lifecycle_state=version_response.lifecycle_state, + alias=version_response.alias, + entrypoint=version_response.entrypoint, + ) + ) return results def delete(self, ids: Sequence[AppVersionId]) -> int: diff --git a/tests/test_unit/test_cdf_tk/test_cruds/test_app.py b/tests/test_unit/test_cdf_tk/test_cruds/test_app.py index 17c51430de..f3b951f2ab 100644 --- a/tests/test_unit/test_cdf_tk/test_cruds/test_app.py +++ b/tests/test_unit/test_cdf_tk/test_cruds/test_app.py @@ -325,6 +325,8 @@ def test_yields_zip_with_dist_contents(self, tmp_path: Path): dist_dir.mkdir(parents=True) (dist_dir / "index.html").write_text("") (dist_dir / "bundle.js").write_text("console.log('hi')") + (app_dir / "package.json").write_text("{}") + (app_dir / "package-lock.json").write_text("{}") yaml_file = tmp_path / "my-app.App.yaml" yaml_file.write_text("") @@ -340,11 +342,15 @@ def test_yields_zip_with_dist_contents(self, tmp_path: Path): names = zf.namelist() assert any("index.html" in n for n in names) assert any("bundle.js" in n for n in names) + assert "package.json" in names + assert "package-lock.json" in names def test_falls_back_to_root_without_dist(self, tmp_path: Path): app_dir = tmp_path / "my-app" app_dir.mkdir() (app_dir / "index.html").write_text("") + (app_dir / "package.json").write_text("{}") + (app_dir / "package-lock.json").write_text("{}") yaml_file = tmp_path / "my-app.App.yaml" yaml_file.write_text("") @@ -404,6 +410,8 @@ def test_uses_source_path_field(self, tmp_path: Path): dist_dir = external_dir / "dist" dist_dir.mkdir(parents=True) (dist_dir / "index.html").write_text("") + (external_dir / "package.json").write_text("{}") + (external_dir / "package-lock.json").write_text("{}") modules_dir = tmp_path / "modules" / "my_module" / "apps" modules_dir.mkdir(parents=True) @@ -435,6 +443,8 @@ def test_excludes_node_modules_and_git(self, tmp_path: Path): app_dir = tmp_path / "my-app" app_dir.mkdir() (app_dir / "index.html").write_text("") + (app_dir / "package.json").write_text("{}") + (app_dir / "package-lock.json").write_text("{}") (app_dir / "node_modules").mkdir() (app_dir / "node_modules" / "pkg.js").write_text("module") (app_dir / ".git").mkdir() @@ -452,3 +462,77 @@ def test_excludes_node_modules_and_git(self, tmp_path: Path): assert not any("node_modules" in n for n in names) assert not any(".git" in n for n in names) assert any("index.html" in n for n in names) + + def test_fails_when_package_json_missing(self, tmp_path: Path): + app_dir = tmp_path / "my-app" + dist_dir = app_dir / "dist" + dist_dir.mkdir(parents=True) + (dist_dir / "index.html").write_text("") + (app_dir / "package-lock.json").write_text("{}") + + yaml_file = tmp_path / "my-app.App.yaml" + yaml_file.write_text("") + item = {"externalId": "my-app", "version": "1.0.0", "name": "My App"} + + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(app_external_id="my-app", version="1.0.0"), item)) + + assert len(extras) == 1 + assert isinstance(extras[0], FailedReadExtra) + assert "package.json" in extras[0].error + + def test_fails_when_package_lock_missing(self, tmp_path: Path): + app_dir = tmp_path / "my-app" + dist_dir = app_dir / "dist" + dist_dir.mkdir(parents=True) + (dist_dir / "index.html").write_text("") + (app_dir / "package.json").write_text("{}") + + yaml_file = tmp_path / "my-app.App.yaml" + yaml_file.write_text("") + item = {"externalId": "my-app", "version": "1.0.0", "name": "My App"} + + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(app_external_id="my-app", version="1.0.0"), item)) + + assert len(extras) == 1 + assert isinstance(extras[0], FailedReadExtra) + assert "package-lock.json" in extras[0].error + + def test_fails_when_manifest_json_invalid(self, tmp_path: Path): + app_dir = tmp_path / "my-app" + dist_dir = app_dir / "dist" + dist_dir.mkdir(parents=True) + (dist_dir / "index.html").write_text("") + (app_dir / "package.json").write_text("{}") + (app_dir / "package-lock.json").write_text("{}") + (app_dir / "manifest.json").write_text("not valid json{") + + yaml_file = tmp_path / "my-app.App.yaml" + yaml_file.write_text("") + item = {"externalId": "my-app", "version": "1.0.0", "name": "My App"} + + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(app_external_id="my-app", version="1.0.0"), item)) + + assert len(extras) == 1 + assert isinstance(extras[0], FailedReadExtra) + assert "manifest.json" in extras[0].error + + def test_includes_valid_manifest_json_in_zip(self, tmp_path: Path): + app_dir = tmp_path / "my-app" + dist_dir = app_dir / "dist" + dist_dir.mkdir(parents=True) + (dist_dir / "index.html").write_text("") + (app_dir / "package.json").write_text("{}") + (app_dir / "package-lock.json").write_text("{}") + (app_dir / "manifest.json").write_text('{"name": "My App"}') + + yaml_file = tmp_path / "my-app.App.yaml" + yaml_file.write_text("") + item = {"externalId": "my-app", "version": "1.0.0", "name": "My App"} + + extras = list(AppIO.get_extra_files(yaml_file, AppVersionId(app_external_id="my-app", version="1.0.0"), item)) + + assert len(extras) == 1 + assert extras[0].byte_content is not None + with zipfile.ZipFile(io.BytesIO(extras[0].byte_content)) as zf: + names = zf.namelist() + assert "manifest.json" in names