Skip to content

Commit 5876d2b

Browse files
committed
[CDF-27549] Add Dune App resource behind alpha flag apps
Apps live under apps/ with kind App: zip sources like functions, upload via Files API init to /dune-apps/ and PUT zip (Streamlit-style flow). Gated by hidden alpha flag apps in cdf.toml.
1 parent c256034 commit 5876d2b

File tree

16 files changed

+653
-3
lines changed

16 files changed

+653
-3
lines changed

cognite_toolkit/_cdf_tk/builders/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from cognite_toolkit._cdf_tk.tk_warnings import ToolkitWarning
55

6+
from ._app import AppBuilder
67
from ._base import Builder, DefaultBuilder, get_resource_crud
78
from ._datamodels import DataModelBuilder
89
from ._file import FileBuilder
@@ -27,6 +28,7 @@ def create_builder(
2728

2829
_BUILDER_BY_RESOURCE_FOLDER = {_builder._resource_folder: _builder for _builder in Builder.__subclasses__()}
2930
__all__ = [
31+
"AppBuilder",
3032
"Builder",
3133
"DataModelBuilder",
3234
"DefaultBuilder",
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import shutil
2+
from collections.abc import Callable, Iterable, Sequence
3+
from pathlib import Path
4+
5+
from cognite_toolkit._cdf_tk.builders._base import Builder
6+
from cognite_toolkit._cdf_tk.cruds import AppCRUD
7+
from cognite_toolkit._cdf_tk.data_classes import (
8+
BuildDestinationFile,
9+
BuildSourceFile,
10+
BuiltResourceList,
11+
ModuleLocation,
12+
)
13+
from cognite_toolkit._cdf_tk.exceptions import ToolkitFileExistsError, ToolkitNotADirectoryError, ToolkitValueError
14+
from cognite_toolkit._cdf_tk.tk_warnings import (
15+
FileReadWarning,
16+
HighSeverityWarning,
17+
LowSeverityWarning,
18+
ToolkitWarning,
19+
WarningList,
20+
)
21+
22+
23+
class AppBuilder(Builder):
24+
_resource_folder = AppCRUD.folder_name
25+
26+
def __init__(self, build_dir: Path, warn: Callable[[ToolkitWarning], None]) -> None:
27+
super().__init__(build_dir, warn=warn)
28+
29+
def build(
30+
self,
31+
source_files: list[BuildSourceFile],
32+
module: ModuleLocation,
33+
console: Callable[[str], None] | None = None,
34+
) -> Iterable[BuildDestinationFile | Sequence[ToolkitWarning]]:
35+
for source_file in source_files:
36+
if source_file.loaded is None:
37+
continue
38+
if source_file.source.path.parent.parent != module.dir:
39+
continue
40+
41+
loader, warning = self._get_loader(source_file.source.path)
42+
if loader is None:
43+
if warning is not None:
44+
yield [warning]
45+
continue
46+
47+
warnings = WarningList[FileReadWarning]()
48+
if loader is AppCRUD:
49+
warnings = self.copy_app_directory_to_build(source_file)
50+
51+
destination_path = self._create_destination_path(source_file.source.path, loader.kind)
52+
53+
yield BuildDestinationFile(
54+
path=destination_path,
55+
loaded=source_file.loaded,
56+
loader=loader,
57+
source=source_file.source,
58+
extra_sources=None,
59+
warnings=warnings,
60+
)
61+
62+
def validate_directory(
63+
self,
64+
built_resources: BuiltResourceList,
65+
module: ModuleLocation,
66+
) -> WarningList[ToolkitWarning]:
67+
warnings = WarningList[ToolkitWarning]()
68+
has_config_files = any(resource.kind == AppCRUD.kind for resource in built_resources)
69+
if has_config_files:
70+
return warnings
71+
config_files_misplaced = [
72+
file
73+
for file in module.source_paths_by_resource_folder[AppCRUD.folder_name]
74+
if AppCRUD.is_supported_file(file)
75+
]
76+
if config_files_misplaced:
77+
for yaml_source_path in config_files_misplaced:
78+
required_location = module.dir / AppCRUD.folder_name / yaml_source_path.name
79+
warning = LowSeverityWarning(
80+
f"The required App resource configuration file "
81+
f"was not found in {required_location.as_posix()!r}. "
82+
f"The file {yaml_source_path.as_posix()!r} is currently "
83+
f"considered part of the App's artifacts and "
84+
f"will not be processed by the Toolkit.",
85+
)
86+
warnings.append(warning)
87+
return warnings
88+
89+
def copy_app_directory_to_build(self, source_file: BuildSourceFile) -> WarningList[FileReadWarning]:
90+
raw_content = source_file.loaded
91+
if raw_content is None:
92+
raise ToolkitValueError("App source file should be a YAML file.")
93+
raw_apps = raw_content if isinstance(raw_content, list) else [raw_content]
94+
warnings = WarningList[FileReadWarning]()
95+
for raw_app in raw_apps:
96+
app_external_id = raw_app.get("appExternalId")
97+
if not app_external_id:
98+
warnings.append(
99+
HighSeverityWarning(
100+
f"App in {source_file.source.path.as_posix()!r} has no appExternalId defined. "
101+
f"This is used to match the app to the app directory.",
102+
),
103+
)
104+
continue
105+
if not raw_app.get("version"):
106+
warnings.append(
107+
HighSeverityWarning(
108+
f"App {app_external_id} in {source_file.source.path.as_posix()!r} has no version defined.",
109+
),
110+
)
111+
112+
app_directory = source_file.source.path.with_name(app_external_id)
113+
114+
if not app_directory.is_dir():
115+
raise ToolkitNotADirectoryError(
116+
f"App directory not found for appExternalId {app_external_id} defined in {source_file.source.path.as_posix()!r}.",
117+
)
118+
119+
destination = self.build_dir / self.resource_folder / app_external_id
120+
if destination.exists():
121+
raise ToolkitFileExistsError(
122+
f"App {app_external_id!r} is duplicated. If this is unexpected, ensure you have a clean build directory.",
123+
)
124+
shutil.copytree(app_directory, destination, ignore=shutil.ignore_patterns("__pycache__"))
125+
126+
return warnings

cognite_toolkit/_cdf_tk/client/_toolkit_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from .api.agents import AgentsAPI
1111
from .api.annotations import AnnotationsAPI
12+
from .api.apps import AppsAPI
1213
from .api.assets import AssetsAPI
1314
from .api.canvas import IndustrialCanvasAPI
1415
from .api.cognite_files import CogniteFilesAPI
@@ -61,6 +62,7 @@ class ToolAPI:
6162
def __init__(self, http_client: HTTPClient, console: Console) -> None:
6263
self.http_client = http_client
6364
self.agents = AgentsAPI(http_client)
65+
self.apps = AppsAPI(http_client)
6466
self.annotations = AnnotationsAPI(http_client)
6567
self.assets = AssetsAPI(http_client)
6668
self.cognite_files = CogniteFilesAPI(http_client)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Apps API: Dune apps as classic files under /dune-apps/ (same pattern as Streamlit + /files)."""
2+
3+
from collections.abc import Iterable, Sequence
4+
from typing import Literal
5+
6+
from cognite_toolkit._cdf_tk.client.cdf_client import CDFResourceAPI, PagedResponse, ResponseItems
7+
from cognite_toolkit._cdf_tk.client.cdf_client.api import Endpoint
8+
from cognite_toolkit._cdf_tk.client.http_client import (
9+
HTTPClient,
10+
ItemsSuccessResponse,
11+
RequestMessage,
12+
SuccessResponse,
13+
)
14+
from cognite_toolkit._cdf_tk.client.identifiers import ExternalId
15+
from cognite_toolkit._cdf_tk.client.request_classes.filters import DuneAppFilter
16+
from cognite_toolkit._cdf_tk.client.resource_classes.app import AppRequest, AppResponse
17+
18+
19+
class AppsAPI(CDFResourceAPI[AppResponse]):
20+
"""Dune apps are file metadata objects under ``/dune-apps/`` with a zip uploaded to ``uploadUrl``."""
21+
22+
def __init__(self, http_client: HTTPClient) -> None:
23+
super().__init__(
24+
http_client=http_client,
25+
method_endpoint_map={
26+
"create": Endpoint(method="POST", path="/files", item_limit=1, concurrency_max_workers=1),
27+
"retrieve": Endpoint(method="POST", path="/files/byids", item_limit=1000, concurrency_max_workers=1),
28+
"update": Endpoint(method="POST", path="/files/update", item_limit=1000, concurrency_max_workers=1),
29+
"delete": Endpoint(method="POST", path="/files/delete", item_limit=1000, concurrency_max_workers=1),
30+
"list": Endpoint(method="POST", path="/files/list", item_limit=1000),
31+
},
32+
)
33+
34+
def _validate_page_response(self, response: SuccessResponse | ItemsSuccessResponse) -> PagedResponse[AppResponse]:
35+
return PagedResponse[AppResponse].model_validate_json(response.body)
36+
37+
def _reference_response(self, response: SuccessResponse) -> ResponseItems[ExternalId]:
38+
return ResponseItems[ExternalId].model_validate_json(response.body)
39+
40+
def create(self, items: Sequence[AppRequest], overwrite: bool = False) -> list[AppResponse]:
41+
endpoint = self._method_endpoint_map["create"]
42+
results: list[AppResponse] = []
43+
for item in items:
44+
request = RequestMessage(
45+
endpoint_url=self._make_url(endpoint.path),
46+
method=endpoint.method,
47+
body_content=item.dump(),
48+
parameters={"overwrite": overwrite},
49+
)
50+
response = self._http_client.request_single_retries(request)
51+
result = response.get_success_or_raise(request)
52+
results.append(AppResponse.model_validate_json(result.body))
53+
return results
54+
55+
def retrieve(self, items: Sequence[ExternalId], ignore_unknown_ids: bool = False) -> list[AppResponse]:
56+
return self._request_item_response(
57+
items, method="retrieve", extra_body={"ignoreUnknownIds": ignore_unknown_ids}
58+
)
59+
60+
def update(self, items: Sequence[AppRequest], mode: Literal["patch", "replace"] = "replace") -> list[AppResponse]:
61+
return self._update(items, mode=mode)
62+
63+
def delete(self, items: Sequence[ExternalId], ignore_unknown_ids: bool = False) -> None:
64+
self._request_no_response(items, "delete", extra_body={"ignoreUnknownIds": ignore_unknown_ids})
65+
66+
def paginate(
67+
self,
68+
filter: DuneAppFilter | None = None,
69+
limit: int = 100,
70+
cursor: str | None = None,
71+
) -> PagedResponse[AppResponse]:
72+
return self._paginate(
73+
cursor=cursor,
74+
limit=limit,
75+
body={"filter": (filter or DuneAppFilter()).dump()},
76+
)
77+
78+
def iterate(
79+
self,
80+
filter: DuneAppFilter | None = None,
81+
limit: int | None = 100,
82+
) -> Iterable[list[AppResponse]]:
83+
return self._iterate(
84+
limit=limit,
85+
body={"filter": (filter or DuneAppFilter()).dump()},
86+
)
87+
88+
def list(
89+
self,
90+
filter: DuneAppFilter | None = None,
91+
limit: int | None = 100,
92+
) -> list[AppResponse]:
93+
return self._list(
94+
limit=limit,
95+
body={"filter": (filter or DuneAppFilter()).dump()},
96+
)

cognite_toolkit/_cdf_tk/client/request_classes/filters.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from pydantic_core.core_schema import ValidationInfo
66

77
from cognite_toolkit._cdf_tk.client.identifiers import ExternalId, InternalId, ViewId
8+
from cognite_toolkit._cdf_tk.client.resource_classes import app as app_resources
89
from cognite_toolkit._cdf_tk.client.resource_classes import streamlit_
910
from cognite_toolkit._cdf_tk.client.resource_classes.annotation import AnnotationStatus, AnnotationType
1011
from cognite_toolkit._cdf_tk.client.resource_classes.data_modeling import NodeId
@@ -56,6 +57,15 @@ def dump(self, camel_case: bool = True) -> dict[str, Any]:
5657
return body
5758

5859

60+
class DuneAppFilter(ClassicFilter):
61+
"""List filter for Dune app zips stored under ``/dune-apps/``."""
62+
63+
def dump(self, camel_case: bool = True) -> dict[str, Any]:
64+
body = self.model_dump(mode="json", by_alias=camel_case, exclude_unset=True)
65+
body["directoryPrefix"] = app_resources.DUNE_APPS_DIRECTORY
66+
return body
67+
68+
5969
class TransformationFilter(Filter):
6070
data_set_ids: list[ExternalId | InternalId] | None = None
6171

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
from typing import Any, Literal
2+
3+
from pydantic import Field, model_validator
4+
5+
from cognite_toolkit._cdf_tk.client._resource_base import (
6+
BaseModelObject,
7+
ResponseResource,
8+
UpdatableRequestResource,
9+
)
10+
from cognite_toolkit._cdf_tk.client.identifiers import ExternalId
11+
12+
DUNE_APPS_DIRECTORY = "/dune-apps/"
13+
14+
15+
class AppShared(BaseModelObject):
16+
"""Fields shared between app write/read models (under /dune-apps/)."""
17+
18+
app_external_id: str = Field(description="Logical app id; directory name in the module.")
19+
version: str
20+
name: str
21+
description: str | None = None
22+
published: bool | None = True
23+
data_set_id: int | None = None
24+
cognite_toolkit_app_hash: str | None = Field(None, alias="cdf-toolkit-app-hash")
25+
26+
27+
class AppRequest(AppShared, UpdatableRequestResource):
28+
"""Create/update body for Files API init; zip is uploaded separately to uploadUrl."""
29+
30+
@property
31+
def external_id(self) -> str:
32+
return f"{self.app_external_id}-{self.version}"
33+
34+
def as_id(self) -> ExternalId:
35+
return ExternalId(external_id=self.external_id)
36+
37+
def dump(
38+
self, camel_case: bool = True, exclude_extra: bool = False, context: Literal["api", "toolkit"] = "api"
39+
) -> dict[str, Any]:
40+
if context == "toolkit":
41+
dumped = super().dump(camel_case=camel_case, exclude_extra=exclude_extra)
42+
dumped.pop("externalId", None)
43+
dumped.pop("external_id", None)
44+
return dumped
45+
feid = self.external_id
46+
return {
47+
"externalId" if camel_case else "external_id": feid,
48+
"name": f"{feid}.zip",
49+
"dataSetId" if camel_case else "data_set_id": self.data_set_id,
50+
"directory": DUNE_APPS_DIRECTORY,
51+
"metadata": self._as_metadata(),
52+
}
53+
54+
def _as_metadata(self) -> dict[str, str]:
55+
metadata: dict[str, str] = {
56+
"published": str(bool(self.published)).lower(),
57+
"name": self.name,
58+
"description": self.description or "",
59+
"externalId": self.external_id,
60+
"version": self.version,
61+
"appExternalId": self.app_external_id,
62+
}
63+
if self.cognite_toolkit_app_hash:
64+
metadata["cdf-toolkit-app-hash"] = self.cognite_toolkit_app_hash
65+
return metadata
66+
67+
def as_update(self, mode: Literal["patch", "replace"]) -> dict[str, Any]:
68+
update_data: dict[str, Any] = {
69+
"metadata": {"set": self._as_metadata()},
70+
"directory": {"set": DUNE_APPS_DIRECTORY},
71+
}
72+
if self.data_set_id is not None:
73+
update_data["dataSetId"] = {"set": self.data_set_id}
74+
elif mode == "replace":
75+
update_data["dataSetId"] = {"setNull": True}
76+
77+
return {
78+
"externalId": self.external_id,
79+
"update": update_data,
80+
}
81+
82+
83+
class AppResponse(AppShared, ResponseResource[AppRequest]):
84+
"""File metadata for a Dune app after create/retrieve (metadata merged to top level)."""
85+
86+
external_id: str
87+
id: int
88+
created_time: int
89+
last_updated_time: int
90+
uploaded_time: int | None = None
91+
uploaded: bool
92+
upload_url: str | None = None
93+
94+
@classmethod
95+
def request_cls(cls) -> type[AppRequest]:
96+
return AppRequest
97+
98+
@model_validator(mode="before")
99+
@classmethod
100+
def move_metadata(cls, values: dict[str, Any]) -> dict[str, Any]:
101+
if "metadata" not in values:
102+
return values
103+
values_copy = values.copy()
104+
metadata = values_copy.pop("metadata")
105+
values_copy.update(metadata)
106+
return values_copy

0 commit comments

Comments
 (0)