diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8721fa04..830ccfe3be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ Changes are grouped as follows - `Fixed` for any bug fixes. - `Security` in case of vulnerabilities. +## [7.54.0] - 2024-07-12 +### Added +- In the `client.data_modeling.instances` the methods `.search`, `.retrieve`,`.list`, `.query`, and `.sync` now + support the `include_typing` parameter. This parameter is used to include typing information in the response, + that can be accessed via the `.typing` attribute on the result object. + ## [7.53.4] - 2024-07-11 ### Added - `FilesAPI.upload_bytes` and `FilesAPI.upload` are updated to be compatible with Private Link projects. diff --git a/cognite/client/_api/data_modeling/instances.py b/cognite/client/_api/data_modeling/instances.py index b5a27c6cdd..ba872aeda6 100644 --- a/cognite/client/_api/data_modeling/instances.py +++ b/cognite/client/_api/data_modeling/instances.py @@ -61,6 +61,7 @@ T_Edge, T_Node, TargetUnit, + TypeInformation, ) from cognite.client.data_classes.data_modeling.query import ( Query, @@ -111,11 +112,21 @@ def __init__(self, instance_cls: type) -> None: self._list_cls = NodeList if issubclass(instance_cls, TypedNode) else EdgeList def __call__(self, items: Any, cognite_client: CogniteClient | None = None) -> Any: - return self._list_cls(items, cognite_client) + return self._list_cls(items, None, cognite_client) def _load(self, data: str | dict, cognite_client: CogniteClient | None = None) -> T_Node | T_Edge: data = load_yaml_or_json(data) if isinstance(data, str) else data - return self._list_cls([self._instance_cls._load(item) for item in data], cognite_client) # type: ignore[return-value, attr-defined] + return self._list_cls([self._instance_cls._load(item) for item in data], None, cognite_client) # type: ignore[return-value, attr-defined] + + @classmethod + def _load_raw_api_response(self, responses: list[dict[str, Any]], cognite_client: CogniteClient) -> T_Node | T_Edge: + typing = next((TypeInformation._load(resp["typing"]) for resp in responses if "typing" in resp), None) + resources = [ + self._instance_cls._load(item, cognite_client=cognite_client) # type: ignore[attr-defined] + for response in responses + for item in response["items"] + ] + return self._list_cls(resources, typing, cognite_client=cognite_client) # type: ignore[return-value] class _NodeOrEdgeApplyResultList(CogniteResourceList): @@ -248,18 +259,29 @@ def __call__( resource_cls, list_cls = _NodeOrEdgeResourceAdapter, EdgeList else: raise ValueError(f"Invalid instance type: {instance_type}") - - return cast( - Union[Iterator[Edge], Iterator[EdgeList], Iterator[Node], Iterator[NodeList]], - self._list_generator( - list_cls=list_cls, - resource_cls=resource_cls, + if not include_typing: + return cast( + Union[Iterator[Edge], Iterator[EdgeList], Iterator[Node], Iterator[NodeList]], + self._list_generator( + list_cls=list_cls, + resource_cls=resource_cls, + method="POST", + chunk_size=chunk_size, + limit=limit, + filter=filter.dump(camel_case_property=False) if isinstance(filter, Filter) else filter, + other_params=other_params, + ), + ) + return ( + list_cls._load_raw_api_response([raw], self._cognite_client) # type: ignore[attr-defined] + for raw in self._list_generator_raw_responses( method="POST", + settings_forcing_raw_response_loading=[f"{include_typing=}"], chunk_size=chunk_size, limit=limit, filter=filter.dump(camel_case_property=False) if isinstance(filter, Filter) else filter, other_params=other_params, - ), + ) ) def __iter__(self) -> Iterator[Node]: @@ -550,6 +572,15 @@ def _retrieve_typed( class _NodeOrEdgeList(CogniteResourceList): _RESOURCE = (node_cls, edge_cls) # type: ignore[assignment] + def __init__( + self, + resources: list[Node | Edge], + typing: TypeInformation | None, + cognite_client: CogniteClient | None, + ): + super().__init__(resources, cognite_client) + self.typing = typing + @classmethod def _load( cls, resource_list: Iterable[dict[str, Any]], cognite_client: CogniteClient | None = None @@ -558,7 +589,19 @@ def _load( node_cls._load(data) if data["instanceType"] == "node" else edge_cls._load(data) for data in resource_list ] - return cls(resources, None) + return cls(resources, None, None) + + @classmethod + def _load_raw_api_response( + cls, responses: list[dict[str, Any]], cognite_client: CogniteClient + ) -> _NodeOrEdgeList: + typing = next((TypeInformation._load(resp["typing"]) for resp in responses if "typing" in resp), None) + resources = [ + node_cls._load(data) if data["instanceType"] == "node" else edge_cls._load(data) + for response in responses + for data in response["items"] + ] + return cls(resources, typing, cognite_client) # type: ignore[arg-type] res = self._retrieve_multiple( # type: ignore[call-overload] list_cls=_NodeOrEdgeList, @@ -566,11 +609,12 @@ def _load( identifiers=identifiers, other_params=other_params, executor=ConcurrencySettings.get_data_modeling_executor(), + settings_forcing_raw_response_loading=[f"{include_typing=}"] if include_typing else None, ) return InstancesResult[T_Node, T_Edge]( - nodes=NodeList([node for node in res if isinstance(node, Node)]), - edges=EdgeList([edge for edge in res if isinstance(edge, Edge)]), + nodes=NodeList([node for node in res if isinstance(node, Node)], typing=res.typing), + edges=EdgeList([edge for edge in res if isinstance(edge, Edge)], typing=res.typing), ) @staticmethod @@ -935,6 +979,7 @@ def search( target_units: list[TargetUnit] | None = None, space: str | SequenceNotStr[str] | None = None, filter: Filter | dict[str, Any] | None = None, + include_typing: bool = False, limit: int = DEFAULT_LIMIT_READ, sort: Sequence[InstanceSort | dict] | InstanceSort | dict | None = None, ) -> NodeList[Node]: ... @@ -949,6 +994,7 @@ def search( target_units: list[TargetUnit] | None = None, space: str | SequenceNotStr[str] | None = None, filter: Filter | dict[str, Any] | None = None, + include_typing: bool = False, limit: int = DEFAULT_LIMIT_READ, sort: Sequence[InstanceSort | dict] | InstanceSort | dict | None = None, ) -> EdgeList[Edge]: ... @@ -963,6 +1009,7 @@ def search( target_units: list[TargetUnit] | None = None, space: str | SequenceNotStr[str] | None = None, filter: Filter | dict[str, Any] | None = None, + include_typing: bool = False, limit: int = DEFAULT_LIMIT_READ, sort: Sequence[InstanceSort | dict] | InstanceSort | dict | None = None, ) -> NodeList[T_Node]: ... @@ -977,6 +1024,7 @@ def search( target_units: list[TargetUnit] | None = None, space: str | SequenceNotStr[str] | None = None, filter: Filter | dict[str, Any] | None = None, + include_typing: bool = False, limit: int = DEFAULT_LIMIT_READ, sort: Sequence[InstanceSort | dict] | InstanceSort | dict | None = None, ) -> EdgeList[T_Edge]: ... @@ -990,6 +1038,7 @@ def search( target_units: list[TargetUnit] | None = None, space: str | SequenceNotStr[str] | None = None, filter: Filter | dict[str, Any] | None = None, + include_typing: bool = False, limit: int = DEFAULT_LIMIT_READ, sort: Sequence[InstanceSort | dict] | InstanceSort | dict | None = None, ) -> NodeList[T_Node] | EdgeList[T_Edge]: @@ -1003,6 +1052,7 @@ def search( target_units (list[TargetUnit] | None): Properties to convert to another unit. The API can only convert to another unit if a unit has been defined as part of the type on the underlying container being queried. space (str | SequenceNotStr[str] | None): Restrict instance search to the given space (or list of spaces). filter (Filter | dict[str, Any] | None): Advanced filtering of instances. + include_typing (bool): Whether to include typing information. limit (int): Maximum number of instances to return. Defaults to 25. sort (Sequence[InstanceSort | dict] | InstanceSort | dict | None): How you want the listed instances information ordered. @@ -1056,6 +1106,8 @@ def search( body = {"view": view.dump(camel_case=True), "query": query, "instanceType": instance_type_str, "limit": limit} if properties: body["properties"] = properties + if include_typing: + body["includeTyping"] = include_typing if filter: body["filter"] = filter.dump(camel_case_property=False) if isinstance(filter, Filter) else filter if target_units: @@ -1069,8 +1121,9 @@ def search( body["sort"] = [self._dump_instance_sort(s) for s in sorts] res = self._post(url_path=self._RESOURCE_PATH + "/search", json=body) - items = res.json()["items"] - return list_cls([resource_cls._load(item) for item in items], cognite_client=None) + result = res.json() + typing = TypeInformation._load(result["typing"]) if "typing" in result else None + return list_cls([resource_cls._load(item) for item in result["items"]], typing, cognite_client=None) @overload def aggregate( @@ -1296,7 +1349,7 @@ def histogram( else: return [HistogramValue.load(item["aggregates"][0]) for item in res.json()["items"]] - def query(self, query: Query) -> QueryResult: + def query(self, query: Query, include_typing: bool = False) -> QueryResult: """`Advanced query interface for nodes/edges. `_ The Data Modelling API exposes an advanced query interface. The query interface supports parameterization, @@ -1304,6 +1357,7 @@ def query(self, query: Query) -> QueryResult: Args: query (Query): Query. + include_typing (bool): Should we return property type information as part of the result? Returns: QueryResult: The resulting nodes and/or edges from the query. @@ -1332,15 +1386,16 @@ def query(self, query: Query) -> QueryResult: ... ) >>> res = client.data_modeling.instances.query(query) """ - return self._query_or_sync(query, "query") + return self._query_or_sync(query, "query", include_typing) - def sync(self, query: Query) -> QueryResult: + def sync(self, query: Query, include_typing: bool = False) -> QueryResult: """`Subscription to changes for nodes/edges. `_ Subscribe to changes for nodes and edges in a project, matching a supplied filter. Args: query (Query): Query. + include_typing (bool): Should we return property type information as part of the result? Returns: QueryResult: The resulting nodes and/or edges from the query. @@ -1375,16 +1430,20 @@ def sync(self, query: Query) -> QueryResult: In the last example, the res_new will only contain the actors that have been added with the new movie. """ - return self._query_or_sync(query, "sync") + return self._query_or_sync(query, "sync", include_typing=include_typing) - def _query_or_sync(self, query: Query, endpoint: Literal["query", "sync"]) -> QueryResult: + def _query_or_sync(self, query: Query, endpoint: Literal["query", "sync"], include_typing: bool) -> QueryResult: body = query.dump(camel_case=True) + if include_typing: + body["includeTyping"] = include_typing result = self._post(url_path=self._RESOURCE_PATH + f"/{endpoint}", json=body) json_payload = result.json() default_by_reference = query.instance_type_by_result_expression() - results = QueryResult.load(json_payload["items"], default_by_reference, json_payload["nextCursor"]) + results = QueryResult.load( + json_payload["items"], default_by_reference, json_payload["nextCursor"], json_payload.get("typing") + ) return results @@ -1526,6 +1585,7 @@ def list( limit=limit, filter=filter.dump(camel_case_property=False) if isinstance(filter, Filter) else filter, other_params=other_params, + settings_forcing_raw_response_loading=[f"{include_typing=}"] if include_typing else [], ), ) diff --git a/cognite/client/_version.py b/cognite/client/_version.py index 097391b4ee..3af8e923cd 100644 --- a/cognite/client/_version.py +++ b/cognite/client/_version.py @@ -1,4 +1,4 @@ from __future__ import annotations -__version__ = "7.53.4" +__version__ = "7.54.0" __api_subversion__ = "20230101" diff --git a/cognite/client/data_classes/_base.py b/cognite/client/data_classes/_base.py index 00a4ceeca4..154f866289 100644 --- a/cognite/client/data_classes/_base.py +++ b/cognite/client/data_classes/_base.py @@ -411,6 +411,17 @@ def _load_raw_api_response(cls, responses: list[dict[str, Any]], cognite_client: # an implementation of this method raise NotImplementedError + def dump_raw(self, camel_case: bool = True) -> dict[str, Any]: + """This method dumps the list with extra information in addition to the items. + + Args: + camel_case (bool): Use camelCase for attribute names. Defaults to True. + + Returns: + dict[str, Any]: A dictionary representation of the list. + """ + return {"items": [resource.dump(camel_case) for resource in self.data]} + T_CogniteResourceList = TypeVar("T_CogniteResourceList", bound=CogniteResourceList) diff --git a/cognite/client/data_classes/data_modeling/instances.py b/cognite/client/data_classes/data_modeling/instances.py index bbf95a207b..b5291e426d 100644 --- a/cognite/client/data_classes/data_modeling/instances.py +++ b/cognite/client/data_classes/data_modeling/instances.py @@ -3,7 +3,7 @@ import threading import warnings from abc import ABC, abstractmethod -from collections import defaultdict +from collections import UserDict, defaultdict from collections.abc import Iterable from dataclasses import dataclass from datetime import date, datetime @@ -46,6 +46,7 @@ ) from cognite.client.data_classes.data_modeling.data_types import ( DirectRelationReference, + PropertyType, UnitReference, UnitSystemReference, ) @@ -56,6 +57,7 @@ ViewId, ViewIdentifier, ) +from cognite.client.utils._auxiliary import flatten_dict from cognite.client.utils._importing import local_import from cognite.client.utils._text import convert_all_keys_to_snake_case @@ -998,6 +1000,15 @@ def to_pandas( # type: ignore [override] class NodeList(DataModelingInstancesList[NodeApply, T_Node]): _RESOURCE = Node # type: ignore[assignment] + def __init__( + self, + resources: Collection[Any], + typing: TypeInformation | None = None, + cognite_client: CogniteClient | None = None, + ) -> None: + super().__init__(resources, cognite_client) + self.typing = typing + def as_ids(self) -> list[NodeId]: """ Convert the list of nodes to a list of node ids. @@ -1011,12 +1022,34 @@ def as_write(self) -> NodeApplyList: """Returns this NodeList as a NodeApplyList""" return NodeApplyList([node.as_write() for node in self]) + @classmethod + def _load_raw_api_response(cls, responses: list[dict[str, Any]], cognite_client: CogniteClient) -> Self: + typing = next((TypeInformation._load(resp["typing"]) for resp in responses if "typing" in resp), None) + resources = [ + cls._RESOURCE._load(item, cognite_client=cognite_client) # type: ignore[has-type] + for response in responses + for item in response.get("items", []) + ] + return cls(resources, typing, cognite_client=cognite_client) + + def dump_raw(self, camel_case: bool = True) -> dict[str, Any]: + output: dict[str, Any] = { + "items": self.dump(camel_case), + } + if self.typing: + output["typing"] = self.typing.dump(camel_case) + return output + class NodeListWithCursor(NodeList[T_Node]): def __init__( - self, resources: Collection[Any], cursor: str | None, cognite_client: CogniteClient | None = None + self, + resources: Collection[Any], + cursor: str | None, + typing: TypeInformation | None = None, + cognite_client: CogniteClient | None = None, ) -> None: - super().__init__(resources, cognite_client) + super().__init__(resources, typing, cognite_client) self.cursor = cursor def extend(self, other: NodeListWithCursor) -> None: # type: ignore[override] @@ -1063,6 +1096,15 @@ def as_ids(self) -> list[EdgeId]: class EdgeList(DataModelingInstancesList[EdgeApply, T_Edge]): _RESOURCE = Edge # type: ignore[assignment] + def __init__( + self, + resources: Collection[Any], + typing: TypeInformation | None = None, + cognite_client: CogniteClient | None = None, + ) -> None: + super().__init__(resources, cognite_client) + self.typing = typing + def as_ids(self) -> list[EdgeId]: """ Convert the list of edges to a list of edge ids. @@ -1076,12 +1118,34 @@ def as_write(self) -> EdgeApplyList: """Returns this EdgeList as a EdgeApplyList""" return EdgeApplyList([edge.as_write() for edge in self], cognite_client=self._get_cognite_client()) + @classmethod + def _load_raw_api_response(cls, responses: list[dict[str, Any]], cognite_client: CogniteClient) -> Self: + typing = next((TypeInformation._load(resp["typing"]) for resp in responses if "typing" in resp), None) + resources = [ + cls._RESOURCE._load(item, cognite_client=cognite_client) # type: ignore[has-type] + for response in responses + for item in response.get("items", []) + ] + return cls(resources, typing, cognite_client=cognite_client) + + def dump_raw(self, camel_case: bool = True) -> dict[str, Any]: + output: dict[str, Any] = { + "items": self.dump(camel_case), + } + if self.typing: + output["typing"] = self.typing.dump(camel_case) + return output + class EdgeListWithCursor(EdgeList): def __init__( - self, resources: Collection[Any], cursor: str | None, cognite_client: CogniteClient | None = None + self, + resources: Collection[Any], + cursor: str | None, + typing: TypeInformation | None = None, + cognite_client: CogniteClient | None = None, ) -> None: - super().__init__(resources, cognite_client) + super().__init__(resources, typing, cognite_client) self.cursor = cursor def extend(self, other: EdgeListWithCursor) -> None: # type: ignore[override] @@ -1192,3 +1256,144 @@ def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = if "externalId" in resource["unit"] else UnitSystemReference.load(resource["unit"]), ) + + +@dataclass +class TypePropertyDefinition(CogniteObject): + type: PropertyType + nullable: bool = True + auto_increment: bool = False + immutable: bool = False + default_value: str | int | dict | None = None + name: str | None = None + description: str | None = None + + def dump(self, camel_case: bool = True, return_flat_dict: bool = False) -> dict[str, Any]: + output: dict[str, Any] = {} + if return_flat_dict: + dumped = flatten_dict(self.type.dump(camel_case), ("type",), sep=".") + output.update({key: value for key, value in dumped.items()}) + else: + output["type"] = self.type.dump(camel_case) + output.update( + { + "nullable": self.nullable, + "autoIncrement": self.auto_increment, + "defaultValue": self.default_value, + "name": self.name, + "description": self.description, + "immutable": self.immutable, + } + ) + return output + + @classmethod + def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> TypePropertyDefinition: + return cls( + type=PropertyType.load(resource["type"]), + nullable=resource.get("nullable"), # type: ignore[arg-type] + immutable=resource.get("immutable"), # type: ignore[arg-type] + auto_increment=resource.get("autoIncrement"), # type: ignore[arg-type] + default_value=resource.get("defaultValue"), + name=resource.get("name"), + description=resource.get("description"), + ) + + +class TypeInformation(UserDict, CogniteObject): + def __init__(self, data: dict[str, dict[str, dict[str, TypePropertyDefinition]]] | None = None) -> None: + super().__init__(data or {}) + + def dump(self, camel_case: bool = True) -> dict[str, Any]: + output: dict[str, Any] = {} + for space_name, space_data in self.items(): + output.setdefault(space_name, {}) + for view_or_container_id, view_data in space_data.items(): + output[space_name].setdefault(view_or_container_id, {}) + for type_name, type_data in view_data.items(): + output[space_name][view_or_container_id][type_name] = type_data.dump(camel_case) + return output + + @classmethod + def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> TypeInformation: + return cls( + { + space_name: { + view_or_container_id: { + type_name: TypePropertyDefinition.load(type_data) + for type_name, type_data in view_data.items() + if isinstance(type_data, dict) + } + for view_or_container_id, view_data in space_data.items() + if isinstance(view_data, dict) + } + for space_name, space_data in resource.items() + if isinstance(space_data, dict) + } + ) + + def to_pandas(self) -> pd.DataFrame: + pd = local_import("pandas") + + index_names = "space_name", "view_or_container" + if not self: + df = pd.DataFrame(index=pd.MultiIndex(levels=([], []), codes=([], []), names=index_names)) + else: + df = pd.DataFrame.from_dict( + { + (space_name, view_or_container_id): { + "identifier": type_name, + **type_data.dump(camel_case=False, return_flat_dict=True), + } + for space_name, space_data in self.data.items() + for view_or_container_id, view_data in space_data.items() + for type_name, type_data in view_data.items() + }, + orient="index", + ) + df.index.names = index_names + return df + + def _repr_html_(self) -> str: + return self.to_pandas()._repr_html_() + + @overload + def __getitem__(self, item: str) -> dict[str, dict[str, TypePropertyDefinition]]: ... + + @overload + def __getitem__(self, item: tuple[str, str]) -> dict[str, TypePropertyDefinition]: ... + + @overload + def __getitem__(self, item: tuple[str, str, str]) -> TypePropertyDefinition: ... + + def __getitem__( + self, item: str | tuple[str, str] | tuple[str, str, str] + ) -> dict[str, dict[str, TypePropertyDefinition]] | dict[str, TypePropertyDefinition] | TypePropertyDefinition: + if isinstance(item, str): + return super().__getitem__(item) + elif isinstance(item, tuple) and len(item) == 2: + return super().__getitem__(item[0])[item[1]] + elif isinstance(item, tuple) and len(item) == 3: + return super().__getitem__(item[0])[item[1]][item[2]] + else: + raise ValueError(f"Invalid key: {item}") + + def __setitem__(self, key: str | tuple[str, str] | tuple[str, str, str], value: Any) -> None: + if isinstance(key, str): + super().__setitem__(key, value) + elif isinstance(key, tuple) and len(key) == 2: + super().__setitem__(key[0], {key[1]: value}) + elif isinstance(key, tuple) and len(key) == 3: + super().__setitem__(key[0], {key[1]: {key[2]: value}}) + else: + raise ValueError(f"Invalid key: {key}") + + def __delitem__(self, key: str | tuple[str, str] | tuple[str, str, str]) -> None: + if isinstance(key, str): + super().__delitem__(key) + elif isinstance(key, tuple) and len(key) == 2: + del self[key[0]][key[1]] + elif isinstance(key, tuple) and len(key) == 3: + del self[key[0]][key[1]][key[2]] + else: + raise ValueError(f"Invalid key: {key}") diff --git a/cognite/client/data_classes/data_modeling/query.py b/cognite/client/data_classes/data_modeling/query.py index 341ee5d819..9f7a8601ff 100644 --- a/cognite/client/data_classes/data_modeling/query.py +++ b/cognite/client/data_classes/data_modeling/query.py @@ -17,6 +17,7 @@ NodeListWithCursor, PropertyValue, TargetUnit, + TypeInformation, ) from cognite.client.data_classes.data_modeling.views import View from cognite.client.data_classes.filters import Filter @@ -395,16 +396,19 @@ def load( resource: dict[str, Any], instance_list_type_by_result_expression_name: dict[str, type[NodeListWithCursor] | type[EdgeListWithCursor]], cursors: dict[str, Any], + typing: dict[str, Any] | None = None, ) -> QueryResult: instance = cls() + typing_nodes = TypeInformation._load(typing["nodes"]) if typing and "nodes" in typing else None + typing_edges = TypeInformation._load(typing["edges"]) if typing and "edges" in typing else None for key, values in resource.items(): cursor = cursors.get(key) if not values: instance[key] = instance_list_type_by_result_expression_name[key]([], cursor) elif values[0]["instanceType"] == "node": - instance[key] = NodeListWithCursor([Node._load(node) for node in values], cursor) + instance[key] = NodeListWithCursor([Node._load(node) for node in values], cursor, typing_nodes) elif values[0]["instanceType"] == "edge": - instance[key] = EdgeListWithCursor([Edge._load(edge) for edge in values], cursor) + instance[key] = EdgeListWithCursor([Edge._load(edge) for edge in values], cursor, typing_edges) else: raise ValueError(f"Unexpected instance type {values[0].get('instanceType')}") diff --git a/cognite/client/utils/_auxiliary.py b/cognite/client/utils/_auxiliary.py index 642b9b322d..86232de21b 100644 --- a/cognite/client/utils/_auxiliary.py +++ b/cognite/client/utils/_auxiliary.py @@ -252,3 +252,13 @@ def load_resource(dct: dict[str, Any], cls: type[T_CogniteResource], key: str) - def unpack_items_in_payload(payload: dict[str, dict[str, Any]]) -> list: return payload["json"]["items"] + + +def flatten_dict(d: dict[str, Any], parent_keys: tuple[str, ...], sep: str = ".") -> dict[str, Any]: + items: list[tuple[str, Any]] = [] + for key, value in d.items(): + if isinstance(value, dict): + items.extend(flatten_dict(value, (*parent_keys, key)).items()) + else: + items.append((sep.join((*parent_keys, key)), value)) + return dict(items) diff --git a/pyproject.toml b/pyproject.toml index 23112b4b63..a924345874 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "cognite-sdk" -version = "7.53.4" +version = "7.54.0" description = "Cognite Python SDK" readme = "README.md" documentation = "https://cognite-sdk-python.readthedocs-hosted.com" diff --git a/tests/tests_integration/test_api/test_data_modeling/test_instances.py b/tests/tests_integration/test_api/test_data_modeling/test_instances.py index b788607d26..32a6e2a7ca 100644 --- a/tests/tests_integration/test_api/test_data_modeling/test_instances.py +++ b/tests/tests_integration/test_api/test_data_modeling/test_instances.py @@ -883,21 +883,49 @@ def test_retrieve_in_units( node = node_with_1_1_pressure_in_bar source = SourceSelector(unit_view.as_id(), target_units=[TargetUnit("pressure", UnitReference("pressure:pa"))]) - retrieved = cognite_client.data_modeling.instances.retrieve(node.as_id(), sources=[source]) + retrieved = cognite_client.data_modeling.instances.retrieve(node.as_id(), sources=[source], include_typing=True) assert retrieved.nodes assert math.isclose(cast(float, retrieved.nodes[0]["pressure"]), 1.1 * 1e5) + assert retrieved.nodes.typing + type_ = cast(Float64, retrieved.nodes.typing[unit_view.as_property_ref("pressure")].type) + assert type_.unit is not None + assert type_.unit.external_id == "pressure:pa" + def test_list_in_units( self, cognite_client: CogniteClient, node_with_1_1_pressure_in_bar: NodeApply, unit_view: View ) -> None: source = SourceSelector(unit_view.as_id(), target_units=[TargetUnit("pressure", UnitReference("pressure:pa"))]) is_node = filters.Equals(["node", "externalId"], node_with_1_1_pressure_in_bar.external_id) - listed = cognite_client.data_modeling.instances.list(instance_type="node", filter=is_node, sources=[source]) + listed = cognite_client.data_modeling.instances.list( + instance_type="node", filter=is_node, sources=[source], include_typing=True + ) assert listed assert len(listed) == 1 assert math.isclose(cast(float, listed[0]["pressure"]), 1.1 * 1e5) + assert listed.typing + type_ = cast(Float64, listed.typing[unit_view.as_property_ref("pressure")].type) + assert type_.unit is not None + assert type_.unit.external_id == "pressure:pa" + + def test_iterate_in_units( + self, cognite_client: CogniteClient, node_with_1_1_pressure_in_bar: NodeApply, unit_view: View + ) -> None: + source = SourceSelector(unit_view.as_id(), target_units=[TargetUnit("pressure", UnitReference("pressure:pa"))]) + is_node = filters.Equals(["node", "externalId"], node_with_1_1_pressure_in_bar.external_id) + iterator = cognite_client.data_modeling.instances( + chunk_size=1, sources=[source], include_typing=True, filter=is_node + ) + first_iter = next(iterator) + assert isinstance(first_iter, NodeList) + assert len(first_iter) == 1 + assert first_iter.typing + type_ = cast(Float64, first_iter.typing[unit_view.as_property_ref("pressure")].type) + assert type_.unit is not None + assert type_.unit.external_id == "pressure:pa" + def test_search_in_units( self, cognite_client: CogniteClient, node_with_1_1_pressure_in_bar: NodeApply, unit_view: View ) -> None: @@ -905,13 +933,18 @@ def test_search_in_units( is_node = filters.Equals(["node", "externalId"], node_with_1_1_pressure_in_bar.external_id) searched = cognite_client.data_modeling.instances.search( - view=unit_view.as_id(), query="", filter=is_node, target_units=target_units + view=unit_view.as_id(), query="", filter=is_node, target_units=target_units, include_typing=True ) assert searched assert len(searched) == 1 assert math.isclose(cast(float, searched[0]["pressure"]), 1.1 * 1e5) + assert searched.typing + type_ = cast(Float64, searched.typing[unit_view.as_property_ref("pressure")].type) + assert type_.unit is not None + assert type_.unit.external_id == "pressure:pa" + def test_aggregate_in_units( self, cognite_client: CogniteClient, node_with_1_1_pressure_in_bar: NodeApply, unit_view: View ) -> None: @@ -938,12 +971,17 @@ def test_query_in_units( with_={"nodes": NodeResultSetExpression(filter=is_node, limit=1)}, select={"nodes": Select([SourceSelector(unit_view.as_id(), ["pressure"], target_units)])}, ) - queried = cognite_client.data_modeling.instances.query(query) + queried = cognite_client.data_modeling.instances.query(query, include_typing=True) assert queried assert len(queried["nodes"]) == 1 assert math.isclose(queried["nodes"][0]["pressure"], 1.1 * 1e5) + assert queried["nodes"].typing + type_ = cast(Float64, queried["nodes"].typing[unit_view.as_property_ref("pressure")].type) + assert type_.unit is not None + assert type_.unit.external_id == "pressure:pa" + @pytest.mark.usefixtures("primitive_nullable_view") def test_write_typed_node(self, cognite_client: CogniteClient, integration_test_space: Space) -> None: space = integration_test_space.space diff --git a/tests/tests_unit/test_data_classes/test_data_models/test_instances.py b/tests/tests_unit/test_data_classes/test_data_models/test_instances.py index e8817bf9d8..fa357fcad7 100644 --- a/tests/tests_unit/test_data_classes/test_data_models/test_instances.py +++ b/tests/tests_unit/test_data_classes/test_data_models/test_instances.py @@ -11,6 +11,7 @@ Edge, EdgeApply, EdgeList, + Float64, Node, NodeApply, NodeId, @@ -19,7 +20,13 @@ NodeOrEdgeData, ViewId, ) -from cognite.client.data_classes.data_modeling.instances import EdgeListWithCursor, Instance +from cognite.client.data_classes.data_modeling.data_types import UnitReference +from cognite.client.data_classes.data_modeling.instances import ( + EdgeListWithCursor, + Instance, + TypeInformation, + TypePropertyDefinition, +) class TestEdgeApply: @@ -437,3 +444,45 @@ def test_expand_properties__list_class_empty_properties( ) assert "properties" not in expanded_with_empty_properties.columns + + +class TestTypeInformation: + @pytest.mark.dsl + def test_to_pandas(self) -> None: + import pandas as pd + + info = TypeInformation( + { + "my_space": { + "view_id/v1": { + "pressure": TypePropertyDefinition( + type=Float64(unit=UnitReference(external_id="pressure:pa")), + nullable=True, + auto_increment=False, + ), + } + } + } + ) + expected = pd.DataFrame.from_dict( + { + ("my_space", "view_id/v1"): { + "identifier": "pressure", + "type.list": False, + "type.unit.external_id": "pressure:pa", + "type.type": "float64", + "nullable": True, + "autoIncrement": False, + "defaultValue": None, + "name": None, + "description": None, + "immutable": False, + } + }, + orient="index", + ) + expected.index.names = "space_name", "view_or_container" + + df = info.to_pandas() + + pd.testing.assert_frame_equal(df, expected)