diff --git a/cognite/client/_api/data_modeling/views.py b/cognite/client/_api/data_modeling/views.py index 675c3121fc..4c982e5057 100644 --- a/cognite/client/_api/data_modeling/views.py +++ b/cognite/client/_api/data_modeling/views.py @@ -3,22 +3,39 @@ import asyncio from collections import defaultdict from collections.abc import AsyncIterator, Sequence -from typing import TYPE_CHECKING, Literal, cast, overload +from typing import TYPE_CHECKING, Any, Literal, cast, overload from cognite.client._api_client import APIClient from cognite.client._constants import DATA_MODELING_DEFAULT_LIMIT_READ +from cognite.client.data_classes._base import CogniteResource from cognite.client.data_classes.data_modeling.ids import ( ViewId, ViewIdentifier, _load_identifier, ) -from cognite.client.data_classes.data_modeling.views import View, ViewApply, ViewFilter, ViewList +from cognite.client.data_classes.data_modeling.views import ( + RecordViewApply, + View, + ViewApply, + ViewFilter, + ViewList, + ViewUsedFor, +) +from cognite.client.utils._experimental import FeaturePreviewWarning if TYPE_CHECKING: from cognite.client import AsyncCogniteClient from cognite.client.config import ClientConfig +class _ViewOrRecordViewApplyAdapter(CogniteResource): + @classmethod + def _load(cls, resource: dict[str, Any]) -> ViewApply | RecordViewApply: # type: ignore[override] + if "streamId" in resource: + return RecordViewApply._load(resource) + return ViewApply._load(resource) + + class ViewsAPI(APIClient): _RESOURCE_PATH = "/models/views" @@ -27,6 +44,13 @@ def __init__(self, config: ClientConfig, api_version: str | None, cognite_client self._DELETE_LIMIT = 100 self._RETRIEVE_LIMIT = 100 self._CREATE_LIMIT = 100 + self._warn_on_record_views = FeaturePreviewWarning( + api_maturity="alpha", sdk_maturity="alpha", feature_name="Views for Records" + ) + + def _record_views_alpha_headers(self) -> dict[str, str]: + self._warn_on_record_views.warn() + return self._alpha_version_header() def _get_semaphore(self, operation: Literal["read_schema", "write_schema"]) -> asyncio.BoundedSemaphore: from cognite.client import global_config @@ -45,6 +69,7 @@ def __call__( include_inherited_properties: bool = True, all_versions: bool = False, include_global: bool = False, + used_for: ViewUsedFor | Sequence[ViewUsedFor] | None = None, ) -> AsyncIterator[View]: ... @overload @@ -56,6 +81,7 @@ def __call__( include_inherited_properties: bool = True, all_versions: bool = False, include_global: bool = False, + used_for: ViewUsedFor | Sequence[ViewUsedFor] | None = None, ) -> AsyncIterator[ViewList]: ... async def __call__( @@ -66,6 +92,7 @@ async def __call__( include_inherited_properties: bool = True, all_versions: bool = False, include_global: bool = False, + used_for: ViewUsedFor | Sequence[ViewUsedFor] | None = None, ) -> AsyncIterator[View] | AsyncIterator[ViewList]: """Iterate over views @@ -78,11 +105,15 @@ async def __call__( include_inherited_properties (bool): Whether to include properties inherited from views this view implements. all_versions (bool): Whether to return all versions. If false, only the newest version is returned, which is determined based on the 'createdTime' field. include_global (bool): Whether to include global views. + used_for (ViewUsedFor | Sequence[ViewUsedFor] | None): Only return views used for the given + type(s). Passing "record" is an alpha feature, subject to breaking + changes without prior notice. Yields: View | ViewList: yields View one by one if chunk_size is not specified, else ViewList objects. """ # noqa: DOC404 - filter_ = ViewFilter(space, include_inherited_properties, all_versions, include_global) + filter_ = ViewFilter(space, include_inherited_properties, all_versions, include_global, used_for) + headers = self._record_views_alpha_headers() if filter_.used_for and "record" in filter_.used_for else None async for item in self._list_generator( list_cls=ViewList, resource_cls=View, @@ -90,6 +121,7 @@ async def __call__( chunk_size=chunk_size, limit=limit, filter=filter_.dump(camel_case=True), + headers=headers, semaphore=self._get_semaphore("read_schema"), ): yield item @@ -174,6 +206,7 @@ async def list( include_inherited_properties: bool = True, all_versions: bool = False, include_global: bool = False, + used_for: ViewUsedFor | Sequence[ViewUsedFor] | None = None, ) -> ViewList: """`List views `_. @@ -183,6 +216,9 @@ async def list( include_inherited_properties (bool): Whether to include properties inherited from views this view implements. all_versions (bool): Whether to return all versions. If false, only the newest version is returned, which is determined based on the 'createdTime' field. include_global (bool): Whether to include global views. + used_for (ViewUsedFor | Sequence[ViewUsedFor] | None): Only return views used for the given + type(s). Passing "record" is an alpha feature, subject to breaking + changes without prior notice. Returns: ViewList: List of requested views @@ -206,7 +242,8 @@ async def list( >>> for view_list in client.data_modeling.views(chunk_size=10): ... view_list # do something with the views """ - filter_ = ViewFilter(space, include_inherited_properties, all_versions, include_global) + filter_ = ViewFilter(space, include_inherited_properties, all_versions, include_global, used_for) + headers = self._record_views_alpha_headers() if filter_.used_for and "record" in filter_.used_for else None return await self._list( list_cls=ViewList, @@ -214,20 +251,21 @@ async def list( method="GET", limit=limit, filter=filter_.dump(camel_case=True), + headers=headers, override_semaphore=self._get_semaphore("read_schema"), ) @overload - async def apply(self, view: Sequence[ViewApply]) -> ViewList: ... + async def apply(self, view: Sequence[ViewApply | RecordViewApply]) -> ViewList: ... @overload - async def apply(self, view: ViewApply) -> View: ... + async def apply(self, view: ViewApply | RecordViewApply) -> View: ... - async def apply(self, view: ViewApply | Sequence[ViewApply]) -> View | ViewList: + async def apply(self, view: ViewApply | RecordViewApply | Sequence[ViewApply | RecordViewApply]) -> View | ViewList: """`Create or update (upsert) one or more views `_. Args: - view (ViewApply | Sequence[ViewApply]): View(s) to create or update. + view (ViewApply | RecordViewApply | Sequence[ViewApply | RecordViewApply]): View(s) to create or update. Returns: View | ViewList: Created view(s) @@ -311,11 +349,35 @@ async def apply(self, view: ViewApply | Sequence[ViewApply]) -> View | ViewList: ... }, ... ) >>> res = client.data_modeling.views.apply([work_order_view, asset_view]) + + Create a record-backed view; stream must already exists. Note: this is an alpha feature, subject to breaking changes without prior notice: + + >>> from cognite.client.data_classes.data_modeling import ( + ... ContainerId, + ... MappedPropertyApply, + ... RecordViewApply, + ... ) + >>> record_view = RecordViewApply( + ... space="mySpace", + ... external_id="myRecordView", + ... version="v1", + ... stream_id="myStream", + ... properties={ + ... "title": MappedPropertyApply( + ... container=ContainerId("mySpace", "myRecordContainer"), + ... container_property_identifier="title", + ... ), + ... }, + ... ) + >>> res = client.data_modeling.views.apply(record_view) """ + views = [view] if isinstance(view, (ViewApply, RecordViewApply)) else list(view) + headers = self._record_views_alpha_headers() if any(isinstance(v, RecordViewApply) for v in views) else None return await self._create_multiple( list_cls=ViewList, resource_cls=View, - items=view, - input_resource_cls=ViewApply, + items=view if isinstance(view, (ViewApply, RecordViewApply)) else views, + input_resource_cls=_ViewOrRecordViewApplyAdapter, + headers=headers, override_semaphore=self._get_semaphore("write_schema"), ) diff --git a/cognite/client/_sync_api/data_modeling/views.py b/cognite/client/_sync_api/data_modeling/views.py index 2514546abb..e857c41b3b 100644 --- a/cognite/client/_sync_api/data_modeling/views.py +++ b/cognite/client/_sync_api/data_modeling/views.py @@ -1,6 +1,6 @@ """ =============================================================================== -d8f86e74e224daebaf4d984d88cf1842 +4d3cd92683260890d7f9e627e7acccdc This file is auto-generated from the Async API modules, - do not edit manually! =============================================================================== """ @@ -14,7 +14,13 @@ from cognite.client._constants import DATA_MODELING_DEFAULT_LIMIT_READ from cognite.client._sync_api_client import SyncAPIClient from cognite.client.data_classes.data_modeling.ids import ViewId, ViewIdentifier -from cognite.client.data_classes.data_modeling.views import View, ViewApply, ViewList +from cognite.client.data_classes.data_modeling.views import ( + RecordViewApply, + View, + ViewApply, + ViewList, + ViewUsedFor, +) from cognite.client.utils._async_helpers import SyncIterator, run_sync if TYPE_CHECKING: @@ -36,6 +42,7 @@ def __call__( include_inherited_properties: bool = True, all_versions: bool = False, include_global: bool = False, + used_for: ViewUsedFor | Sequence[ViewUsedFor] | None = None, ) -> Iterator[View]: ... @overload @@ -47,6 +54,7 @@ def __call__( include_inherited_properties: bool = True, all_versions: bool = False, include_global: bool = False, + used_for: ViewUsedFor | Sequence[ViewUsedFor] | None = None, ) -> Iterator[ViewList]: ... def __call__( @@ -57,6 +65,7 @@ def __call__( include_inherited_properties: bool = True, all_versions: bool = False, include_global: bool = False, + used_for: ViewUsedFor | Sequence[ViewUsedFor] | None = None, ) -> Iterator[View] | Iterator[ViewList]: """ Iterate over views @@ -70,6 +79,9 @@ def __call__( include_inherited_properties (bool): Whether to include properties inherited from views this view implements. all_versions (bool): Whether to return all versions. If false, only the newest version is returned, which is determined based on the 'createdTime' field. include_global (bool): Whether to include global views. + used_for (ViewUsedFor | Sequence[ViewUsedFor] | None): Only return views used for the given + type(s). Passing "record" is an alpha feature, subject to breaking + changes without prior notice. Yields: View | ViewList: yields View one by one if chunk_size is not specified, else ViewList objects. @@ -82,6 +94,7 @@ def __call__( include_inherited_properties=include_inherited_properties, all_versions=all_versions, include_global=include_global, + used_for=used_for, ) ) # type: ignore [misc] @@ -144,6 +157,7 @@ def list( include_inherited_properties: bool = True, all_versions: bool = False, include_global: bool = False, + used_for: ViewUsedFor | Sequence[ViewUsedFor] | None = None, ) -> ViewList: """ `List views `_. @@ -154,6 +168,9 @@ def list( include_inherited_properties (bool): Whether to include properties inherited from views this view implements. all_versions (bool): Whether to return all versions. If false, only the newest version is returned, which is determined based on the 'createdTime' field. include_global (bool): Whether to include global views. + used_for (ViewUsedFor | Sequence[ViewUsedFor] | None): Only return views used for the given + type(s). Passing "record" is an alpha feature, subject to breaking + changes without prior notice. Returns: ViewList: List of requested views @@ -184,21 +201,22 @@ def list( include_inherited_properties=include_inherited_properties, all_versions=all_versions, include_global=include_global, + used_for=used_for, ) ) @overload - def apply(self, view: Sequence[ViewApply]) -> ViewList: ... + def apply(self, view: Sequence[ViewApply | RecordViewApply]) -> ViewList: ... @overload - def apply(self, view: ViewApply) -> View: ... + def apply(self, view: ViewApply | RecordViewApply) -> View: ... - def apply(self, view: ViewApply | Sequence[ViewApply]) -> View | ViewList: + def apply(self, view: ViewApply | RecordViewApply | Sequence[ViewApply | RecordViewApply]) -> View | ViewList: """ `Create or update (upsert) one or more views `_. Args: - view (ViewApply | Sequence[ViewApply]): View(s) to create or update. + view (ViewApply | RecordViewApply | Sequence[ViewApply | RecordViewApply]): View(s) to create or update. Returns: View | ViewList: Created view(s) @@ -282,5 +300,26 @@ def apply(self, view: ViewApply | Sequence[ViewApply]) -> View | ViewList: ... }, ... ) >>> res = client.data_modeling.views.apply([work_order_view, asset_view]) + + Create a record-backed view; stream must already exists. Note: this is an alpha feature, subject to breaking changes without prior notice: + + >>> from cognite.client.data_classes.data_modeling import ( + ... ContainerId, + ... MappedPropertyApply, + ... RecordViewApply, + ... ) + >>> record_view = RecordViewApply( + ... space="mySpace", + ... external_id="myRecordView", + ... version="v1", + ... stream_id="myStream", + ... properties={ + ... "title": MappedPropertyApply( + ... container=ContainerId("mySpace", "myRecordContainer"), + ... container_property_identifier="title", + ... ), + ... }, + ... ) + >>> res = client.data_modeling.views.apply(record_view) """ return run_sync(self.__async_client.data_modeling.views.apply(view=view)) diff --git a/cognite/client/data_classes/data_modeling/__init__.py b/cognite/client/data_classes/data_modeling/__init__.py index a53c1714f3..1d7261b0fc 100644 --- a/cognite/client/data_classes/data_modeling/__init__.py +++ b/cognite/client/data_classes/data_modeling/__init__.py @@ -157,6 +157,7 @@ MultiEdgeConnectionApply, MultiReverseDirectRelation, MultiReverseDirectRelationApply, + RecordViewApply, SingleHopConnectionDefinition, View, ViewApply, @@ -265,6 +266,7 @@ "RecordSourceSelector", "RecordTargetUnit", "RecordTargetUnits", + "RecordViewApply", "RecordWrite", "RecordWriteList", "RequiresConstraint", diff --git a/cognite/client/data_classes/data_modeling/views.py b/cognite/client/data_classes/data_modeling/views.py index 121613545c..565bf44041 100644 --- a/cognite/client/data_classes/data_modeling/views.py +++ b/cognite/client/data_classes/data_modeling/views.py @@ -1,6 +1,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from collections.abc import Sequence from dataclasses import asdict, dataclass, field from typing import Any, Literal, TypeAlias, TypeVar, cast @@ -25,6 +26,8 @@ from cognite.client.data_classes.filters import Filter from cognite.client.utils._text import convert_all_keys_to_camel_case_recursive, to_camel_case, to_snake_case +ViewUsedFor: TypeAlias = Literal["node", "edge", "all", "record"] + class ViewCore(DataModelingSchemaResource["ViewApply"], ABC): def __init__( @@ -102,6 +105,10 @@ def __init__( @classmethod def _load(cls, resource: dict[str, Any]) -> Self: + if "streamId" in resource: + raise ValueError( + "Looks like this resource is a record view (has 'streamId'); use RecordViewApply.load(...) instead." + ) properties = ( {k: ViewPropertyApply.load(v) for k, v in resource["properties"].items()} if "properties" in resource @@ -144,6 +151,103 @@ def referenced_containers(self) -> set[ContainerId]: return referenced_containers +class RecordViewApply(CogniteResource): + """A view backed by a Streams & Records stream. Write only version. + + .. admonition:: Alpha feature + + Views for Records is an alpha feature, subject to breaking changes without prior notice. + + Args: + space (str): The workspace for the view, a unique identifier for the space. + external_id (str): Combined with the space is the unique identifier of the view. + version (str): DMS version. + stream_id (str | Sequence[str]): External id of the records stream this view targets. Accepts a maximum of 1 stream. + description (str | None): Textual description of the view + name (str | None): Human readable name for the view. + filter (Filter | None): A filter Domain Specific Language (DSL) used to create advanced filter queries. + Views for Records only support a subset of the standard filter set. + implements (list[ViewId] | None): References to other record views from where this view will inherit properties. + properties (dict[str, MappedPropertyApply] | None): Mapped properties of the view. Only mapped properties + (no connections) referencing ``usedFor="record"`` containers are supported. + """ + + def __init__( + self, + space: str, + external_id: str, + version: str, + stream_id: str | Sequence[str], + description: str | None = None, + name: str | None = None, + filter: Filter | None = None, + implements: list[ViewId] | None = None, + properties: dict[str, MappedPropertyApply] | None = None, + ) -> None: + validate_data_modeling_identifier(space, external_id) + self.space = space + self.external_id = external_id + self.version = version + self.description = description + self.name = name + self.filter = filter + self.implements: list[ViewId] = implements or [] + self.properties = properties + self.stream_id = [stream_id] if isinstance(stream_id, str) else list(stream_id) + + def as_id(self) -> ViewId: + return ViewId(space=self.space, external_id=self.external_id, version=self.version) + + def as_property_ref(self, property: str) -> tuple[str, str, str]: + return self.as_id().as_property_ref(property) + + @classmethod + def _load(cls, resource: dict[str, Any]) -> Self: + properties: dict[str, MappedPropertyApply] | None = ( + {k: MappedPropertyApply.load(v) for k, v in resource["properties"].items()} + if "properties" in resource + else None + ) + implements = [ViewId.load(v) for v in resource["implements"]] if "implements" in resource else None + return cls( + space=resource["space"], + external_id=resource["externalId"], + version=resource["version"], + stream_id=resource["streamId"], + description=resource.get("description"), + name=resource.get("name"), + filter=Filter._load_if(resource.get("filter")), + implements=implements, + properties=properties, + ) + + def dump(self, camel_case: bool = True) -> dict[str, Any]: + output = super().dump(camel_case) + if self.implements: + output["implements"] = [v.dump(camel_case) for v in self.implements] + if self.filter is not None: + output["filter"] = self.filter.dump() + if self.properties: + output["properties"] = {k: v.dump(camel_case) for k, v in self.properties.items()} + output["streamId" if camel_case else "stream_id"] = self.stream_id + return output + + def as_write(self) -> RecordViewApply: + return self + + def referenced_containers(self) -> set[ContainerId]: + """Helper function to get the set of containers referenced by this view. + + Returns: + set[ContainerId]: The set of containers referenced by this view. + """ + referenced_containers = set() + for prop in (self.properties or {}).values(): + if isinstance(prop, MappedPropertyApply): + referenced_containers.add(prop.container) + return referenced_containers + + class View(ViewCore): """A group of properties. Read only version. @@ -159,8 +263,10 @@ class View(ViewCore): filter (Filter | None): A filter Domain Specific Language (DSL) used to create advanced filter queries. implements (list[ViewId] | None): References to the views from where this view will inherit properties and edges. writable (bool): Whether the view supports write operations. - used_for (Literal['node', 'edge', 'all']): Does this view apply to nodes, edges or both. + used_for (ViewUsedFor): Does this view apply to nodes, edges, both, or records. is_global (bool): Whether this is a global view. + stream_id (list[str] | None): External id(s) of the records stream(s) this view targets, if this is a + record-backed view. Views for Records is an alpha feature, subject to breaking changes without prior notice. """ def __init__( @@ -176,8 +282,9 @@ def __init__( filter: Filter | None, implements: list[ViewId] | None, writable: bool, - used_for: Literal["node", "edge", "all"], + used_for: ViewUsedFor, is_global: bool, + stream_id: list[str] | None = None, ) -> None: super().__init__( space, @@ -194,6 +301,7 @@ def __init__( self.properties = properties self.last_updated_time = last_updated_time self.created_time = created_time + self.stream_id = stream_id @classmethod def _load(cls, resource: dict) -> View: @@ -211,8 +319,14 @@ def _load(cls, resource: dict) -> View: used_for=resource["usedFor"], is_global=resource["isGlobal"], properties={k: ViewProperty.load(v) for k, v in resource.get("properties", {}).items()}, + stream_id=resource.get("streamId"), ) + @property + def is_record_view(self) -> bool: + """Whether this view is used for Records""" + return self.used_for == "record" + def dump(self, camel_case: bool = True) -> dict[str, Any]: output = super().dump(camel_case) if "properties" in output: @@ -221,11 +335,14 @@ def dump(self, camel_case: bool = True) -> dict[str, Any]: return output def as_apply(self) -> ViewApply: - """Convert to a view applies. + """Convert to a view apply. Returns: ViewApply: The view apply. """ + if self.is_record_view: + raise ValueError("This view is a record view, use as_record_view_apply() instead.") + properties: dict[str, ViewPropertyApply] | None = None if self.properties: for k, v in self.properties.items(): @@ -256,6 +373,43 @@ def as_apply(self) -> ViewApply: properties=properties, ) + def as_record_view_apply(self) -> RecordViewApply: + """Convert to a record view apply. + + Returns: + RecordViewApply: The record view apply. + + Raises: + ValueError: If this view is not a record view. + """ + if not self.is_record_view: + raise ValueError("This view is not a record view, use as_apply() instead.") + + if not self.stream_id: + raise ValueError("This record view has no stream_id set; cannot convert to RecordViewApply.") + + properties: dict[str, MappedPropertyApply] | None = None + if self.properties: + properties = {} + for k, v in self.properties.items(): + if not isinstance(v, MappedProperty): + raise NotImplementedError( + f"Unsupported conversion to record view apply for property type {type(v)}" + ) + properties[k] = v.as_apply() + + return RecordViewApply( + space=self.space, + external_id=self.external_id, + version=self.version, + stream_id=self.stream_id, + description=self.description, + name=self.name, + filter=self.filter, + implements=self.implements, + properties=properties, + ) + def as_write(self) -> ViewApply: return self.as_apply() @@ -330,13 +484,16 @@ def referenced_containers(self) -> set[ContainerId]: class ViewFilter(CogniteFilter): - """Represent the filer arguments for the list endpoint. + """Represent the filter arguments for the list endpoint. Args: space (str | None): The space to query include_inherited_properties (bool): Whether to include properties inherited from views this view implements. all_versions (bool): Whether to return all versions. If false, only the newest version is returned, which is determined based on the 'createdTime' field. include_global (bool): Whether to include global views. + used_for (ViewUsedFor | Sequence[ViewUsedFor] | None): Only return views used for the given type(s). + Defaults to the node, edge and all (excluding record views). Pass "record" + to include record views. """ def __init__( @@ -345,11 +502,24 @@ def __init__( include_inherited_properties: bool = True, all_versions: bool = False, include_global: bool = False, + used_for: ViewUsedFor | Sequence[ViewUsedFor] | None = None, ) -> None: self.space = space self.include_inherited_properties = include_inherited_properties self.all_versions = all_versions self.include_global = include_global + self.used_for = self._parse_used_for(used_for) + + @staticmethod + def _parse_used_for(used_for: ViewUsedFor | Sequence[ViewUsedFor] | None) -> Sequence[ViewUsedFor] | None: + if used_for is None: + return None + elif isinstance(used_for, str): + return [used_for] + elif isinstance(used_for, Sequence): + return cast("Sequence[ViewUsedFor]", used_for) + else: + raise TypeError(f"Invalid value for 'used_for': {used_for!r}") class ViewProperty(CogniteResource, ABC): diff --git a/tests/tests_unit/test_api/test_data_modeling/test_views.py b/tests/tests_unit/test_api/test_data_modeling/test_views.py index 2a7919c513..54d0675d1f 100644 --- a/tests/tests_unit/test_api/test_data_modeling/test_views.py +++ b/tests/tests_unit/test_api/test_data_modeling/test_views.py @@ -1,10 +1,23 @@ from __future__ import annotations +import re +from urllib.parse import parse_qs, urlparse + import pytest +from pytest_httpx import HTTPXMock -from cognite.client import AsyncCogniteClient -from cognite.client.data_classes.data_modeling import ViewList +from cognite.client import AsyncCogniteClient, CogniteClient +from cognite.client.data_classes.data_modeling import ( + ContainerId, + MappedPropertyApply, + RecordViewApply, + View, + ViewApply, + ViewList, +) +from cognite.client.exceptions import CogniteAPIError from tests.tests_unit.test_api.test_data_modeling.conftest import make_test_view +from tests.utils import get_url, jsgz_load class TestViewsRetrieveLatest: @@ -35,3 +48,162 @@ def test_different_external_ids(self, async_client: AsyncCogniteClient, views: V def test_different_spaces(self, async_client: AsyncCogniteClient, views: ViewList) -> None: result = async_client.data_modeling.views._get_latest_views(views) assert result == ViewList([views[1], views[3], views[5]]) + + +VIEW_RESPONSE = { + "space": "sp", + "externalId": "v", + "version": "v1", + "createdTime": 1, + "lastUpdatedTime": 2, + "writable": True, + "usedFor": "all", + "isGlobal": False, + "properties": {}, +} + +RECORD_VIEW_RESPONSE = { + "space": "sp", + "externalId": "rv", + "version": "v1", + "streamId": ["my-stream"], + "createdTime": 1, + "lastUpdatedTime": 2, + "writable": True, + "usedFor": "record", + "isGlobal": False, + "properties": {}, +} + + +def make_record_view_apply(stream_id: str | list[str] = "my-stream") -> RecordViewApply: + return RecordViewApply( + space="sp", + external_id="rv", + version="v1", + stream_id=stream_id, + properties={ + "title": MappedPropertyApply( + container=ContainerId("sp", "recordContainer"), container_property_identifier="title" + ) + }, + ) + + +class TestViewsApiForRecordViews: + @pytest.fixture + def views_url_pattern(self, async_client: AsyncCogniteClient) -> re.Pattern: + return re.compile("^" + re.escape(get_url(async_client.data_modeling.views, "/models/views"))) + + def test_apply_single_record_view( + self, + cognite_client: CogniteClient, + httpx_mock: HTTPXMock, + views_url_pattern: re.Pattern, + ) -> None: + record_view = make_record_view_apply() + httpx_mock.add_response( + method="POST", url=views_url_pattern, status_code=200, json={"items": [RECORD_VIEW_RESPONSE]} + ) + + result = cognite_client.data_modeling.views.apply(record_view) + + assert isinstance(result, View) + assert not isinstance(result, ViewList) + + def test_apply_record_view_failure( + self, + cognite_client: CogniteClient, + httpx_mock: HTTPXMock, + views_url_pattern: re.Pattern, + ) -> None: + real_error_message = ( + "Cannot update view 'sp:rv/v1', Referenced container does not exist: 'sp:recordContainer/v1'." + ) + record_view = make_record_view_apply() + httpx_mock.add_response( + method="POST", url=views_url_pattern, status_code=400, json={"error": {"message": real_error_message}} + ) + + with pytest.raises(CogniteAPIError) as error: + cognite_client.data_modeling.views.apply(record_view) + + assert error.value.message == real_error_message + assert error.value.failed == [record_view] + assert error.value.code == 400 + + def test_apply_mixed_batch_warns_and_sends_alpha_header( + self, + cognite_client: CogniteClient, + async_client: AsyncCogniteClient, + httpx_mock: HTTPXMock, + views_url_pattern: re.Pattern, + ) -> None: + plain_view = ViewApply(space="sp", external_id="v", version="v1") + record_view = make_record_view_apply() + httpx_mock.add_response( + method="POST", + url=views_url_pattern, + status_code=200, + json={"items": [VIEW_RESPONSE, RECORD_VIEW_RESPONSE]}, + ) + + with pytest.warns(FutureWarning, match="Views for Records"): + cognite_client.data_modeling.views.apply([plain_view, record_view]) + + request = httpx_mock.get_requests()[0] + assert request.headers["cdf-version"] == f"{async_client.config.api_subversion}-alpha" + body = jsgz_load(request.content) + assert body["items"][1]["streamId"] == ["my-stream"] + + def test_apply_plain_views_does_not_warn_or_send_alpha_header( + self, + cognite_client: CogniteClient, + async_client: AsyncCogniteClient, + httpx_mock: HTTPXMock, + views_url_pattern: re.Pattern, + recwarn: pytest.WarningsRecorder, + ) -> None: + plain_view = ViewApply(space="sp", external_id="v", version="v1") + httpx_mock.add_response(method="POST", url=views_url_pattern, status_code=200, json={"items": [VIEW_RESPONSE]}) + + cognite_client.data_modeling.views.apply(plain_view) + + assert not any("Views for Records" in str(w.message) for w in recwarn.list) + request = httpx_mock.get_requests()[0] + assert request.headers["cdf-version"] == async_client.config.api_subversion + + def test_list_used_for_mixed_warns_and_sends_alpha_header( + self, + cognite_client: CogniteClient, + async_client: AsyncCogniteClient, + httpx_mock: HTTPXMock, + views_url_pattern: re.Pattern, + ) -> None: + httpx_mock.add_response(method="GET", url=views_url_pattern, status_code=200, json={"items": []}) + + with pytest.warns(FutureWarning, match="Views for Records"): + cognite_client.data_modeling.views.list(used_for=["node", "record"]) + + request = httpx_mock.get_requests()[0] + assert request.headers["cdf-version"] == f"{async_client.config.api_subversion}-alpha" + qs = parse_qs(urlparse(str(request.url)).query) + assert qs.get("usedFor") == ["node", "record"] + + def test_list_default_does_not_warn_or_send_alpha_header( + self, + cognite_client: CogniteClient, + async_client: AsyncCogniteClient, + httpx_mock: HTTPXMock, + views_url_pattern: re.Pattern, + recwarn: pytest.WarningsRecorder, + ) -> None: + httpx_mock.add_response(method="GET", url=views_url_pattern, status_code=200, json={"items": []}) + + cognite_client.data_modeling.views.list() + + assert not any("Views for Records" in str(w.message) for w in recwarn.list) + request = httpx_mock.get_requests()[0] + assert request.headers["cdf-version"] == async_client.config.api_subversion + qs = parse_qs(urlparse(str(request.url)).query) + assert "usedFor" not in qs diff --git a/tests/tests_unit/test_data_classes/test_data_models/test_views.py b/tests/tests_unit/test_data_classes/test_data_models/test_views.py index 3dc53d7336..771413c6ab 100644 --- a/tests/tests_unit/test_data_classes/test_data_models/test_views.py +++ b/tests/tests_unit/test_data_classes/test_data_models/test_views.py @@ -1,13 +1,143 @@ from __future__ import annotations +import re + import pytest from cognite.client.data_classes._base import UnknownCogniteResource -from cognite.client.data_classes.data_modeling import View, ViewApply, ViewId +from cognite.client.data_classes.data_modeling import ( + ContainerId, + MappedPropertyApply, + RecordViewApply, + View, + ViewApply, + ViewFilter, + ViewId, +) from cognite.client.data_classes.data_modeling.containers import PropertyConstraintState from cognite.client.data_classes.data_modeling.views import MappedProperty, ViewProperty, ViewPropertyApply +def make_record_view_apply(stream_id: str | list[str] = "my-stream") -> RecordViewApply: + return RecordViewApply( + space="sp", + external_id="rv", + version="v1", + stream_id=stream_id, + properties={ + "title": MappedPropertyApply( + container=ContainerId("sp", "recordContainer"), container_property_identifier="title" + ) + }, + ) + + +def make_test_view(space: str, external_id: str, version: str, created_time: int = 1) -> View: + return View( + space, + external_id, + version, + created_time=created_time, + properties={}, + last_updated_time=2, + description="", + name="", + filter=None, + implements=None, + writable=False, + used_for="all", + is_global=False, + ) + + +class TestRecordViewApplyDataClass: + @pytest.mark.parametrize( + "stream_id", + [ + pytest.param("my-stream", id="singular"), + pytest.param(["my-stream"], id="sequence"), + ], + ) + def test_accepts_singular_or_sequence_stream_id(self, stream_id: str | list[str]) -> None: + view = make_record_view_apply(stream_id=stream_id) + assert view.stream_id == ["my-stream"] + + dumped = view.dump() + assert dumped["streamId"] == ["my-stream"] + + def test_view_apply_load_rejects_record_view_payload(self) -> None: + dumped = make_record_view_apply().dump() + with pytest.raises(ValueError, match=re.escape("RecordViewApply.load")): + ViewApply._load(dumped) + + +class TestViewRecordFields: + def test_load_dump_round_trip_with_stream_id(self) -> None: + payload = { + "space": "sp", + "externalId": "rv", + "version": "v1", + "streamId": ["my-stream"], + "createdTime": 1, + "lastUpdatedTime": 2, + "writable": True, + "usedFor": "record", + "isGlobal": False, + "properties": {}, + } + view = View._load(payload) + assert view.stream_id == ["my-stream"] + assert view.used_for == "record" + assert view.is_record_view is True + assert view.dump() == {**payload, "implements": []} + + def test_load_without_stream_id_leaves_it_none(self) -> None: + view = make_test_view("sp", "v", "v1") + assert view.stream_id is None + assert view.is_record_view is False + assert "streamId" not in view.dump() + + def test_as_apply_raises_when_used_for_record(self) -> None: + view = make_test_view("sp", "rv", "v1") + view.used_for = "record" + + with pytest.raises(ValueError, match="as_record_view_apply"): + view.as_apply() + + def test_as_record_view_apply_returns_record_view_apply(self) -> None: + view = make_test_view("sp", "rv", "v1") + view.stream_id = ["my-stream"] + view.used_for = "record" + + result = view.as_record_view_apply() + + assert isinstance(result, RecordViewApply) + assert result.stream_id == ["my-stream"] + assert result.space == "sp" + assert result.external_id == "rv" + + def test_as_record_view_apply_raises_when_stream_id_not_set(self) -> None: + view = make_test_view("sp", "v", "v1") + + with pytest.raises(ValueError, match="not a record view"): + view.as_record_view_apply() + + +class TestViewFilterUsedFor: + def test_used_for_omitted_by_default(self) -> None: + assert "usedFor" not in ViewFilter().dump() + + def test_used_for_singular_str(self) -> None: + assert ViewFilter(used_for="record").dump()["usedFor"] == ["record"] + + def test_used_for_sequence(self) -> None: + assert ViewFilter(used_for=["node", "record"]).dump()["usedFor"] == ["node", "record"] + + def test_used_for_rejects_invalid_type(self) -> None: + with pytest.raises(TypeError, match="Invalid value for 'used_for'"): + ViewFilter(used_for=123) # type: ignore[arg-type] + + class TestView: def test_as_property_ref(self) -> None: params = dict( diff --git a/tests/utils.py b/tests/utils.py index 24651c73ad..edb023c0bd 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -49,6 +49,7 @@ Select, SelectSync, ) +from cognite.client.data_classes.data_modeling.views import View from cognite.client.data_classes.datapoint_aggregates import ( ALL_SORTED_DP_AGGS, INT_AGGREGATES, @@ -477,6 +478,11 @@ def create_instance(self, resource_cls: type[T_Object], skip_defaulted_args: boo elif issubclass(resource_cls, ListablePropertyType): if not keyword_arguments.get("is_list"): keyword_arguments.pop("max_list_size", None) + elif resource_cls is View: + # A View with used_for "record" can only be converted via + # as_record_view_apply(), not as_write()/as_apply() (reserved for regular views): + used_for = self._random.choice(["node", "edge", "all"]) + keyword_arguments["used_for"] = used_for elif resource_cls is SimulatorRoutineStepArguments: keyword_arguments = {"data": {"reference_id": self._random_string(50), "arg2": self._random_string(50)}} elif resource_cls is SimulationRunWrite: