diff --git a/cognite/client/_api/data_modeling/records.py b/cognite/client/_api/data_modeling/records.py index 4e4219a740..e6421905d9 100644 --- a/cognite/client/_api/data_modeling/records.py +++ b/cognite/client/_api/data_modeling/records.py @@ -153,6 +153,15 @@ async def ingest( ... ), ... stream_id="my-stream", ... ) + + Ingest a record through a view: + + >>> from cognite.client.data_classes.data_modeling.records import RecordViewId + >>> source = RecordSource( + ... RecordViewId("my-space", "my-view", "v1"), {"temperature": 22.5} + ... ) + >>> record = RecordWrite("my-space", "rec-2", sources=[source]) + >>> client.data_modeling.records.ingest(record, stream_id="my-stream") """ self._warning.warn() item_list: list[RecordWrite] = [items] if isinstance(items, RecordWrite) else list(items) @@ -228,6 +237,9 @@ async def aggregate( ) -> RecordsAggregation: """`Aggregate records from a stream `_. + Aggregate properties may reference multiple containers or a single view, but cannot + mix views and containers. This restriction does not apply to filters or target units. + Args: aggregates (Mapping[str, Aggregate | dict[str, Any]]): Aggregate request tree keyed by client-defined aggregate IDs. @@ -378,7 +390,7 @@ async def filter( last_updated_time (TimeRange | None): Filter by last-updated time. **Required for immutable streams** (must include a lower bound). filter (Filter | None): Filter expression (see :mod:`cognite.client.data_classes.filters`). - sources (Sequence[RecordSourceSelector] | None): Which container properties to return. + sources (Sequence[RecordSourceSelector] | None): Which container or view properties to return. sort (Sequence[InstanceSort] | InstanceSort | None): Sort specification(s); up to 5. limit (int): Maximum number of records to return (1-1000). This endpoint returns a single page and does not paginate, so a larger limit is an error rather than a silent cap. @@ -485,7 +497,7 @@ async def sync( cursor (str | None): Resume from a cursor from a previously yielded chunk. Mutually exclusive with ``initialize_cursor``. filter (Filter | None): Filter expression (see :mod:`cognite.client.data_classes.filters`). - sources (Sequence[RecordSourceSelector] | None): Which container properties to return. + sources (Sequence[RecordSourceSelector] | None): Which container or view properties to return. target_units (RecordTargetUnits | Sequence[RecordTargetUnit] | None): Properties to convert to another unit. chunk_size (int): Number of records per yielded chunk, between 1 and 1000. Defaults to 1000. diff --git a/cognite/client/_sync_api/data_modeling/records.py b/cognite/client/_sync_api/data_modeling/records.py index 7dbeca18ee..edcf4257df 100644 --- a/cognite/client/_sync_api/data_modeling/records.py +++ b/cognite/client/_sync_api/data_modeling/records.py @@ -1,6 +1,6 @@ """ =============================================================================== -e50d855222acc9a875059316cd3d6fce +372c2b6a9ad523f8aa3ad87a5c9a49a7 This file is auto-generated from the Async API modules, - do not edit manually! =============================================================================== """ @@ -114,6 +114,15 @@ def ingest(self, items: RecordWrite | Sequence[RecordWrite], *, stream_id: str) ... ), ... stream_id="my-stream", ... ) + + Ingest a record through a view: + + >>> from cognite.client.data_classes.data_modeling.records import RecordViewId + >>> source = RecordSource( + ... RecordViewId("my-space", "my-view", "v1"), {"temperature": 22.5} + ... ) + >>> record = RecordWrite("my-space", "rec-2", sources=[source]) + >>> client.data_modeling.records.ingest(record, stream_id="my-stream") """ return run_sync(self.__async_client.data_modeling.records.ingest(items=items, stream_id=stream_id)) @@ -177,6 +186,9 @@ def aggregate( """ `Aggregate records from a stream `_. + Aggregate properties may reference multiple containers or a single view, but cannot + mix views and containers. This restriction does not apply to filters or target units. + Args: aggregates (Mapping[str, Aggregate | dict[str, Any]]): Aggregate request tree keyed by client-defined aggregate IDs. @@ -321,7 +333,7 @@ def filter( last_updated_time (TimeRange | None): Filter by last-updated time. **Required for immutable streams** (must include a lower bound). filter (Filter | None): Filter expression (see :mod:`cognite.client.data_classes.filters`). - sources (Sequence[RecordSourceSelector] | None): Which container properties to return. + sources (Sequence[RecordSourceSelector] | None): Which container or view properties to return. sort (Sequence[InstanceSort] | InstanceSort | None): Sort specification(s); up to 5. limit (int): Maximum number of records to return (1-1000). This endpoint returns a single page and does not paginate, so a larger limit is an error rather than a silent cap. @@ -416,7 +428,7 @@ def sync( cursor (str | None): Resume from a cursor from a previously yielded chunk. Mutually exclusive with ``initialize_cursor``. filter (Filter | None): Filter expression (see :mod:`cognite.client.data_classes.filters`). - sources (Sequence[RecordSourceSelector] | None): Which container properties to return. + sources (Sequence[RecordSourceSelector] | None): Which container or view properties to return. target_units (RecordTargetUnits | Sequence[RecordTargetUnit] | None): Properties to convert to another unit. chunk_size (int): Number of records per yielded chunk, between 1 and 1000. Defaults to 1000. diff --git a/cognite/client/data_classes/data_modeling/__init__.py b/cognite/client/data_classes/data_modeling/__init__.py index 6aa9f4002e..b74aee20e2 100644 --- a/cognite/client/data_classes/data_modeling/__init__.py +++ b/cognite/client/data_classes/data_modeling/__init__.py @@ -67,6 +67,7 @@ EdgeId, NodeId, PropertyId, + PropertyPath, VersionedDataModelingId, ViewId, ViewIdentifier, @@ -229,6 +230,7 @@ "NodeResultSetExpressionSync", "PropertyId", "PropertyOptions", + "PropertyPath", "PropertyType", "Query", "QueryResult", diff --git a/cognite/client/data_classes/data_modeling/_validation.py b/cognite/client/data_classes/data_modeling/_validation.py index 3024008d59..3c42a35f52 100644 --- a/cognite/client/data_classes/data_modeling/_validation.py +++ b/cognite/client/data_classes/data_modeling/_validation.py @@ -1,6 +1,7 @@ from __future__ import annotations -from cognite.client.utils.useful_types import SequenceNotStr, is_sequence_not_str +from cognite.client.data_classes.data_modeling.ids import ContainerId, PropertyId, PropertyPath, ViewId +from cognite.client.utils.useful_types import is_sequence_not_str RESERVED_EXTERNAL_IDS = frozenset( { @@ -46,9 +47,10 @@ PROPERTY_PATH_HINT = ( - "A property is addressed by its path: [space, container_external_id, property_id], e.g. " - '["my_space", "my_container", "temperature"]. Endpoints that allow top level properties take ' - 'them as a single segment, e.g. ["lastUpdatedTime"].' + "A property is addressed by its path: [space, container_external_id, property_id] or " + '[space, "view_external_id/version", property_id], e.g. ["my_space", "my_container", "temperature"] ' + 'or ["my_space", "my_view/v1", "temperature"], or by calling view_or_container.as_property_ref("temperature"). ' + 'Endpoints that allow top level properties take them as a single segment, e.g. ["lastUpdatedTime"].' ) @@ -59,34 +61,38 @@ def validate_data_modeling_identifier(space: str | None, external_id: str | None raise ValueError(f"The external ID: {external_id!r} is reserved. Please use another ID.") -def validate_property_path( - prop: SequenceNotStr[str], argument: str = "property", hint: str = PROPERTY_PATH_HINT -) -> list[str]: +def validate_property_path(prop: PropertyPath, argument: str = "property", hint: str = PROPERTY_PATH_HINT) -> list[str]: """Validate a property path and return it as a list of segments. - A bare string is a sequence of characters, so passing one where a sequence of strings is - expected silently produces one segment per character instead of failing; reject it here. - Paths are short, at most three segments, so every segment is type checked. - Args: - prop (SequenceNotStr[str]): The user-provided property path. + prop (PropertyPath): The user-provided property path, (source, property) tuple, or PropertyId. argument (str): Name of the argument, used in the error message. - hint (str): Actionable follow-up appended to the error message. Defaults to describing a - fully qualified property path, which is what most arguments taking one expect. + hint (str): Guidance appended to validation errors. Returns: list[str]: The validated path as a list. """ - if not is_sequence_not_str(prop): - got = f"the string {prop!r}" if isinstance(prop, str) else type(prop).__name__ - raise TypeError(f"{argument!r} must be a sequence of strings, not {got}. {hint}") - path = list(prop) - if not path: - raise ValueError(f"{argument!r} must not be empty. {hint}") - for segment in path: - if not isinstance(segment, str): + match prop: + case PropertyId(): + return list(prop.as_property_ref()) + case tuple([ContainerId() | ViewId() as source, str(prop_name)]): + return list(source.as_property_ref(prop_name)) + case tuple([ContainerId() | ViewId(), invalid_prop]): raise TypeError( - f"{argument!r} must be a sequence of strings, but {segment!r} is of type " - f"{type(segment).__name__}. {hint}" + f"{argument!r} given as a (source, property) tuple must have a string property, " + f"but {invalid_prop!r} is of type {type(invalid_prop).__name__}. {hint}" ) - return path + case _ if not is_sequence_not_str(prop): + got = f"the string {prop!r}" if isinstance(prop, str) else type(prop).__name__ + raise TypeError(f"{argument!r} must be a sequence of strings, not {got}. {hint}") + case _: + path = list(prop) + if not path: + raise ValueError(f"{argument!r} must not be empty. {hint}") + for segment in path: + if not isinstance(segment, str): + raise TypeError( + f"{argument!r} must be a sequence of strings, but {segment!r} is of type " + f"{type(segment).__name__}. {hint}" + ) + return path diff --git a/cognite/client/data_classes/data_modeling/aggregates.py b/cognite/client/data_classes/data_modeling/aggregates.py index e8fc1f0398..f63d7fa4ac 100644 --- a/cognite/client/data_classes/data_modeling/aggregates.py +++ b/cognite/client/data_classes/data_modeling/aggregates.py @@ -29,9 +29,9 @@ from cognite.client.data_classes._base import CogniteResource from cognite.client.data_classes.data_modeling._validation import validate_property_path +from cognite.client.data_classes.data_modeling.ids import PropertyPath from cognite.client.data_classes.filters import Filter from cognite.client.utils._text import convert_all_keys_to_snake_case, to_snake_case -from cognite.client.utils.useful_types import SequenceNotStr def _dump_aggregate_value(value: Any) -> Any: @@ -107,11 +107,11 @@ def _load(cls, resource: dict[str, Any]) -> Aggregate: class Average(Aggregate): - """Average aggregate over a container property.""" + """Average aggregate over a container or view property.""" _aggregate_name = "avg" - def __init__(self, property: SequenceNotStr[str]) -> None: + def __init__(self, property: PropertyPath) -> None: self.property = validate_property_path(property) def _dump_body(self) -> dict[str, Any]: @@ -123,7 +123,7 @@ class Count(Aggregate): _aggregate_name = "count" - def __init__(self, property: SequenceNotStr[str] | None = None) -> None: + def __init__(self, property: PropertyPath | None = None) -> None: self.property = validate_property_path(property) if property is not None else None def _dump_body(self) -> dict[str, Any]: @@ -135,7 +135,7 @@ class Min(Aggregate): _aggregate_name = "min" - def __init__(self, property: SequenceNotStr[str]) -> None: + def __init__(self, property: PropertyPath) -> None: self.property = validate_property_path(property) def _dump_body(self) -> dict[str, Any]: @@ -147,7 +147,7 @@ class Max(Aggregate): _aggregate_name = "max" - def __init__(self, property: SequenceNotStr[str]) -> None: + def __init__(self, property: PropertyPath) -> None: self.property = validate_property_path(property) def _dump_body(self) -> dict[str, Any]: @@ -155,11 +155,11 @@ def _dump_body(self) -> dict[str, Any]: class Sum(Aggregate): - """Sum aggregate over a container property.""" + """Sum aggregate over a container or view property.""" _aggregate_name = "sum" - def __init__(self, property: SequenceNotStr[str]) -> None: + def __init__(self, property: PropertyPath) -> None: self.property = validate_property_path(property) def _dump_body(self) -> dict[str, Any]: @@ -173,7 +173,7 @@ class UniqueValues(Aggregate): def __init__( self, - property: SequenceNotStr[str], + property: PropertyPath, aggregates: Mapping[str, Aggregate | dict[str, Any]] | None = None, size: int | None = None, ): @@ -197,7 +197,7 @@ class NumberHistogram(Aggregate): def __init__( self, - property: SequenceNotStr[str], + property: PropertyPath, interval: float, aggregates: Mapping[str, Aggregate | dict[str, Any]] | None = None, hard_bounds: Mapping[str, float] | None = None, @@ -223,7 +223,7 @@ class TimeHistogram(Aggregate): def __init__( self, - property: SequenceNotStr[str], + property: PropertyPath, *, calendar_interval: str | None = None, fixed_interval: str | None = None, diff --git a/cognite/client/data_classes/data_modeling/ids.py b/cognite/client/data_classes/data_modeling/ids.py index 21fd8f9bdf..8a69793e0d 100644 --- a/cognite/client/data_classes/data_modeling/ids.py +++ b/cognite/client/data_classes/data_modeling/ids.py @@ -152,6 +152,10 @@ class PropertyId(CogniteResource): source: ViewId | ContainerId property: str + def as_property_ref(self) -> tuple[str, str, str]: + """Return the fully qualified property path as a tuple of three strings.""" + return self.source.as_property_ref(self.property) + @classmethod def _load(cls, resource: dict[str, Any]) -> Self: return cls( @@ -195,6 +199,7 @@ def version(self) -> str | None: ... ContainerIdentifier = ContainerId | tuple[str, str] ConstraintIdentifier = tuple[ContainerId, str] IndexIdentifier = tuple[ContainerId, str] +PropertyPath = SequenceNotStr[str] | tuple[ContainerId | ViewId, str] | PropertyId ViewIdentifier = ViewId | tuple[str, str] | tuple[str, str, str] DataModelIdentifier = DataModelId | tuple[str, str] | tuple[str, str, str] NodeIdentifier = NodeId | tuple[str, str, str] diff --git a/cognite/client/data_classes/data_modeling/records.py b/cognite/client/data_classes/data_modeling/records.py index 2a4a4b0448..81f48adec6 100644 --- a/cognite/client/data_classes/data_modeling/records.py +++ b/cognite/client/data_classes/data_modeling/records.py @@ -3,7 +3,7 @@ from collections.abc import Sequence from copy import deepcopy from dataclasses import dataclass -from typing import Any, Literal +from typing import Any, Literal, TypeAlias from typing_extensions import Self @@ -16,12 +16,14 @@ from cognite.client.data_classes.data_modeling import aggregates as aggs from cognite.client.data_classes.data_modeling._validation import validate_property_path from cognite.client.data_classes.data_modeling.data_types import UnitReference, UnitSystemReference -from cognite.client.data_classes.data_modeling.ids import ContainerId +from cognite.client.data_classes.data_modeling.ids import ContainerId, PropertyPath, ViewId from cognite.client.data_classes.data_modeling.instances import TypeInformation from cognite.client.utils._identifier import IdentifierSequenceCore from cognite.client.utils._identifier import RecordId as RecordId # explicit re-export from cognite.client.utils.useful_types import SequenceNotStr +RecordSourceIdentifier: TypeAlias = ContainerId | ViewId | tuple[str, str] | tuple[str, str, str] + class RecordIdSequence(IdentifierSequenceCore[RecordId]): @classmethod @@ -33,7 +35,7 @@ def load(cls, items: RecordId | Sequence[RecordId]) -> RecordIdSequence: @dataclass(frozen=True) class RecordContainerId(ContainerId): - """Container reference used as a source in a record write. + """Identifier for a records container Args: space (str): Space that contains the container. @@ -41,22 +43,79 @@ class RecordContainerId(ContainerId): """ +@dataclass(frozen=True) +class RecordViewId(ViewId): + """View reference used as a source in a record write or read. + + Args: + space (str): Space that contains the view. + external_id (str): External ID of the view. + version (str): Version of the view. + """ + + version: str + + def __post_init__(self) -> None: + if self.version is None: + raise TypeError("RecordViewId requires an explicit 'version'.") + + +def _load_record_source_id(data: RecordSourceIdentifier | dict[str, Any]) -> RecordContainerId | RecordViewId: + match data: + case RecordViewId() | RecordContainerId(): + return data + case ViewId(): + if data.version is None: + raise ValueError("A view requires an explicit version.") + return RecordViewId(space=data.space, external_id=data.external_id, version=data.version) + case ContainerId(): + return RecordContainerId(space=data.space, external_id=data.external_id) + case tuple([space, external_id, version]): + return RecordViewId(space=space, external_id=external_id, version=version) + case tuple([space, external_id]): + return RecordContainerId(space=space, external_id=external_id) + case tuple(): + raise ValueError(f"Invalid tuple length for record source identifier: {len(data)}, expected 2 or 3.") + case dict({"type": "view"}): + return RecordViewId.load(data) + case dict({"type": "container"}): + return RecordContainerId.load(data) + case dict() if data.get("type") is None: + return RecordViewId.load(data) if "version" in data else RecordContainerId.load(data) + case dict(): + raise ValueError(f"Record source 'type' must be 'container' or 'view', but was {data.get('type')!r}") + case _: + raise TypeError(f"Cannot load record source from {type(data).__name__}") + + +def _parse_records_source(space: str, identifier: str) -> RecordContainerId | RecordViewId: + match identifier.split("/", 1): + case [view_xid, version]: + return RecordViewId(space=space, external_id=view_xid, version=version) + case _: + return RecordContainerId(space=space, external_id=identifier) + + class RecordSource(CogniteResource): - """Container source with property values for a record write. + """Container or view source with property values for a record write. Args: - source (RecordContainerId): Reference to the container. - properties (dict[str, Any]): The data to write to the source container. + source (RecordSourceIdentifier): Container or view ID or a (space, external_id[, version]) tuple. + properties (dict[str, Any]): The data to write to the source container or view. """ - def __init__(self, source: RecordContainerId, properties: dict[str, Any]) -> None: - self.source = source + def __init__( + self, + source: RecordSourceIdentifier, + properties: dict[str, Any], + ) -> None: + self.source = _load_record_source_id(source) self.properties = properties @classmethod def _load(cls, resource: dict[str, Any]) -> Self: return cls( - source=RecordContainerId.load(resource["source"]), + source=_load_record_source_id(resource["source"]), properties=resource["properties"], ) @@ -75,7 +134,7 @@ class RecordWrite(WriteableCogniteResource["RecordWrite"]): Args: space (str): Space the record belongs to. external_id (str): External ID of the record (1-256 chars, no null bytes). - sources (list[RecordSource]): Container property values to write (1-100 sources). + sources (list[RecordSource]): Container or view property values to write (1-100 sources). """ def __init__(self, space: str, external_id: str, sources: list[RecordSource]) -> None: @@ -155,7 +214,9 @@ class Record(WriteableCogniteResource["RecordWrite"]): created_time (int): Creation time in milliseconds since epoch. last_updated_time (int): Last updated time in milliseconds since epoch. properties (dict[str, dict[str, dict[str, Any]]] | None): Property values keyed by - ``{space: {container_external_id: {property_id: value}}}``. + ``{space: {source_identifier: {property_id: value}}}``, where the source identifier + is ``container_external_id`` for a container and ``view_external_id/version`` for + a view. """ def __init__( @@ -199,12 +260,9 @@ def as_id(self) -> RecordId: def as_write(self) -> RecordWrite: """Reconstruct the :class:`RecordWrite` by grouping read properties back into sources.""" sources = [ - RecordSource( - source=RecordContainerId(space=space, external_id=container), - properties=dict(props), - ) - for space, containers in (self.properties or {}).items() - for container, props in containers.items() + RecordSource(source=_parse_records_source(space, identifier), properties=dict(props)) + for space, sources in (self.properties or {}).items() + for identifier, props in sources.items() ] return RecordWrite(space=self.space, external_id=self.external_id, sources=sources) @@ -276,39 +334,44 @@ def dump(self, camel_case: bool = True) -> dict[str, Any]: class RecordSourceSelector(CogniteResource): - """Selects which container properties to return for a record. + """Selects which container or view properties to return for a record. Args: - source (RecordContainerId): The container to select properties from. + source (RecordSourceIdentifier): The container or view to select properties from. properties (SequenceNotStr[str]): Property identifiers to return; use ``["*"]`` to return all. """ - def __init__(self, source: RecordContainerId, properties: SequenceNotStr[str]) -> None: - self.source = source + def __init__( + self, + source: RecordSourceIdentifier, + properties: SequenceNotStr[str], + ) -> None: + self.source = _load_record_source_id(source) self.properties = validate_property_path( properties, "properties", - 'Properties are the container property identifiers to return, e.g. ["temperature"], or ["*"] for all.', + 'Properties are the container or view property identifiers to return, e.g. ["temperature"], or ["*"] for all.', ) @classmethod def _load(cls, resource: dict[str, Any]) -> Self: - return cls(source=RecordContainerId.load(resource["source"]), properties=resource["properties"]) + return cls(source=_load_record_source_id(resource["source"]), properties=resource["properties"]) def dump(self, camel_case: bool = True) -> dict[str, Any]: return {"source": self.source.dump(camel_case=camel_case), "properties": self.properties} class RecordTargetUnit(CogniteResource): - """A target unit conversion for one Records container property. + """A target unit conversion for one Records container or view property. Args: - property (SequenceNotStr[str]): Fully qualified container property path: - ``[space, container_external_id, property_id]``. + property (PropertyPath): Fully qualified container or view property path: + ``[space, container_external_id, property_id]`` or + ``[space, "view_external_id/version", property_id]``. unit (UnitReference | UnitSystemReference): Target unit or target unit system. """ - def __init__(self, property: SequenceNotStr[str], unit: UnitReference | UnitSystemReference) -> None: + def __init__(self, property: PropertyPath, unit: UnitReference | UnitSystemReference) -> None: self.property = validate_property_path(property) self.unit = unit diff --git a/cognite/client/data_classes/filters.py b/cognite/client/data_classes/filters.py index 65cbc07d6b..00c7d56e7a 100644 --- a/cognite/client/data_classes/filters.py +++ b/cognite/client/data_classes/filters.py @@ -4,12 +4,13 @@ from abc import ABC, abstractmethod from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Literal, NoReturn, TypeAlias, cast, final +from typing import Any, Literal, NoReturn, TypeAlias, cast, final from typing_extensions import Self from cognite.client.data_classes._base import EnumProperty from cognite.client.data_classes.data_modeling.data_types import DirectRelationReference +from cognite.client.data_classes.data_modeling.ids import ContainerId, PropertyId, ViewId from cognite.client.data_classes.labels import Label from cognite.client.data_classes.shared import Geometry from cognite.client.utils._auxiliary import all_concrete_subclasses @@ -17,11 +18,7 @@ from cognite.client.utils._text import convert_all_keys_to_camel_case, to_camel_case from cognite.client.utils.useful_types import SequenceNotStr, is_sequence_not_str -if TYPE_CHECKING: - from cognite.client.data_classes.data_modeling.ids import ContainerId, ViewId - - -PropertyReference: TypeAlias = str | SequenceNotStr[str] | EnumProperty +PropertyReference: TypeAlias = str | Sequence[str] | EnumProperty | tuple[ContainerId | ViewId, str] | PropertyId @dataclass @@ -69,17 +66,21 @@ def _load_filter_value(value: Any) -> FilterValue | FilterValueList: def _dump_property(property_: PropertyReference, camel_case: bool) -> list[str] | tuple[str, ...]: - if isinstance(property_, EnumProperty): - return property_.as_reference() - elif isinstance(property_, str): - return [to_camel_case(property_) if camel_case else property_] - elif isinstance(property_, (list, tuple)): - if len(property_) == 1: - return [to_camel_case(property_[0])] if camel_case else property_ - else: + match property_: + case EnumProperty(): + return property_.as_reference() + case PropertyId(): + return list(property_.as_property_ref()) + case tuple([ContainerId() | ViewId() as source, str() as prop_name]): + return list(source.as_property_ref(prop_name)) + case str(): + return [to_camel_case(property_) if camel_case else property_] + case list() | tuple(): + if len(property_) == 1 and camel_case: + return [to_camel_case(property_[0])] return property_ - else: - raise ValueError(f"Invalid property format {property_}") + case _: + raise ValueError(f"Invalid property format {property_}") class Filter(ABC): @@ -1032,7 +1033,7 @@ def __init__(self, property: SequenceNotStr[str]) -> None: "The IsNull filter is a Data Modeling filter and expected a sequence of str to describe the property, " f"like ['node', 'space'] or ['my-space', 'my-view/version', 'my-property'], got: {property}" ) - super().__init__(Exists(property)) + super().__init__(Exists(cast(Sequence[str], property))) self._filter_name = Not._filter_name @classmethod diff --git a/tests/tests_unit/test_api/test_data_modeling/test_records.py b/tests/tests_unit/test_api/test_data_modeling/test_records.py index 2e9a27b7aa..5ff8d84d62 100644 --- a/tests/tests_unit/test_api/test_data_modeling/test_records.py +++ b/tests/tests_unit/test_api/test_data_modeling/test_records.py @@ -27,6 +27,7 @@ UnknownResult, ) from cognite.client.data_classes.data_modeling.data_types import UnitReference +from cognite.client.data_classes.data_modeling.ids import ContainerId, PropertyId, PropertyPath, ViewId from cognite.client.data_classes.data_modeling.instances import InstanceSort, TypeInformation from cognite.client.data_classes.data_modeling.records import ( Record, @@ -35,9 +36,11 @@ RecordList, RecordsAggregation, RecordSource, + RecordSourceIdentifier, RecordSourceSelector, RecordTargetUnit, RecordTargetUnits, + RecordViewId, RecordWrite, SyncRecord, SyncRecordList, @@ -627,7 +630,7 @@ def test_list_sources_body_shape( ) -> None: cognite_client.data_modeling.records.filter( stream_id=stream_id, - sources=[RecordSourceSelector(RecordContainerId(space="sp", external_id="container-x"), ["*"])], + sources=[RecordSourceSelector(ContainerId(space="sp", external_id="container-x"), ["*"])], ) body = jsgz_load(httpx_mock.get_requests()[0].content) assert body["sources"] == [ @@ -986,9 +989,7 @@ def test_sync_body_shape_with_filter_and_sources( initialize_cursor="2m-ago", filter=filters.Equals(property=["sp", "container-x", "temp"], value=22.5), sources=[ - RecordSourceSelector( - source=RecordContainerId(space="sp", external_id="container-x"), properties=["*"] - ) + RecordSourceSelector(source=ContainerId(space="sp", external_id="container-x"), properties=["*"]) ], chunk_size=5, ) @@ -1021,13 +1022,13 @@ def test_record_write_round_trip(self, write_item: RecordWrite) -> None: assert loaded.sources[0].properties == {"temp": 22.5} def test_record_source_reference_dump(self) -> None: - ref = RecordContainerId(space="s", external_id="c") + ref = ContainerId(space="s", external_id="c") d = ref.dump() assert d == {"type": "container", "space": "s", "externalId": "c"} def test_record_source_dump(self) -> None: src = RecordSource( - source=RecordContainerId(space="s", external_id="c"), + source=ContainerId(space="s", external_id="c"), properties={"x": 1}, ) d = src.dump() @@ -1082,7 +1083,7 @@ def test_time_range_dump_omits_none(self) -> None: assert TimeRange().dump() == {} def test_record_source_selector_dump(self) -> None: - selector = RecordSourceSelector(RecordContainerId(space="sp", external_id="c"), ["temp", "pressure"]) + selector = RecordSourceSelector(ContainerId(space="sp", external_id="c"), ["temp", "pressure"]) assert selector.dump() == { "source": {"type": "container", "space": "sp", "externalId": "c"}, "properties": ["temp", "pressure"], @@ -1193,9 +1194,142 @@ def test_record_target_unit_rejects_bare_string_property(self) -> None: def test_record_source_selector_rejects_bare_string_properties(self) -> None: with pytest.raises(TypeError, match="'properties' must be a sequence of strings"): - RecordSourceSelector(RecordContainerId(space="sp", external_id="c"), "temp") # type: ignore[arg-type] + RecordSourceSelector(ContainerId(space="sp", external_id="c"), "temp") # type: ignore[arg-type] def test_record_source_selector_rejects_no_properties(self) -> None: # The API requires minItems: 1 for properties. with pytest.raises(ValueError, match="'properties' must not be empty"): - RecordSourceSelector(RecordContainerId(space="sp", external_id="c"), []) + RecordSourceSelector(ContainerId(space="sp", external_id="c"), []) + + def test_record_target_unit_accepts_view_property_reference(self) -> None: + target_unit = RecordTargetUnit( + (ViewId("sp", "my_view", "v1"), "pressure"), + UnitReference("pressure:pa"), + ) + assert target_unit.dump() == { + "property": ["sp", "my_view/v1", "pressure"], + "unit": {"externalId": "pressure:pa"}, + } + + def test_source_property_tuple_rejects_non_string_property(self) -> None: + with pytest.raises(TypeError, match="must have a string property"): + RecordTargetUnit((ViewId("sp", "my_view", "v1"), 42), UnitReference("pressure:pa")) # type: ignore[arg-type] + + @pytest.mark.parametrize("property_", [[ViewId("sp", "v", "v1"), "temp"], [], ["sp", 42], b"temp"]) + def test_target_unit_rejects_invalid_path(self, property_: object) -> None: + with pytest.raises((TypeError, ValueError), match="'property' must"): + RecordTargetUnit(property_, UnitReference("pressure:pa")) # type: ignore[arg-type] + + +class TestRecordViewId: + def test_version_is_required(self) -> None: + with pytest.raises(TypeError): + RecordViewId(space="my_space", external_id="my_view") # type: ignore[call-arg] + + +class TestRecordSourceViews: + @pytest.mark.parametrize( + "source, expected", + [ + (RecordViewId("sp", "my_view", "v1"), RecordViewId("sp", "my_view", "v1")), + (ViewId("sp", "my_view", "v1"), RecordViewId("sp", "my_view", "v1")), + (("sp", "my_view", "v1"), RecordViewId("sp", "my_view", "v1")), + (RecordContainerId("sp", "my_container"), RecordContainerId("sp", "my_container")), + (ContainerId("sp", "my_container"), RecordContainerId("sp", "my_container")), + (("sp", "my_container"), RecordContainerId("sp", "my_container")), + ], + ids=["record-view", "view", "view-tuple", "record-container", "container", "container-tuple"], + ) + def test_source_identifiers( + self, source: RecordSourceIdentifier, expected: RecordContainerId | RecordViewId + ) -> None: + resource = RecordSource(source, {"temp": 25.0}) + assert resource.source == expected + assert resource.dump() == {"source": expected.dump(), "properties": {"temp": 25.0}} + if isinstance(source, (RecordContainerId, RecordViewId)): + assert resource.source is source + + def test_selector_view_round_trip(self) -> None: + raw = {"source": {"type": "view", "space": "sp", "externalId": "v", "version": "v1"}, "properties": ["temp"]} + selector = RecordSourceSelector._load(raw) + assert selector.source == RecordViewId("sp", "v", "v1") + assert selector.dump() == raw + + @pytest.mark.parametrize( + "raw, expected", + [ + ({"space": "sp", "externalId": "v", "version": "v1", "type": "view"}, RecordViewId("sp", "v", "v1")), + ({"space": "sp", "external_id": "v", "version": "v1"}, RecordViewId("sp", "v", "v1")), + ({"space": "sp", "externalId": "c", "type": "container"}, RecordContainerId("sp", "c")), + ({"space": "sp", "external_id": "c"}, RecordContainerId("sp", "c")), + ], + ids=["view-camel-case", "view-snake-case", "container-camel-case", "container-snake-case"], + ) + def test_source_dictionary_formats(self, raw: dict, expected: RecordContainerId | RecordViewId) -> None: + loaded = RecordSource._load({"source": raw, "properties": {"temp": 25.0}}) + assert loaded.source == expected + assert loaded.properties == {"temp": 25.0} + + @pytest.mark.parametrize("source", [["sp", "my_view", "v1"], "sp"]) + def test_source_rejects_non_identifiers(self, source: object) -> None: + with pytest.raises(TypeError, match="Cannot load record source"): + RecordSource(source, {}) # type: ignore[arg-type] + + @pytest.mark.parametrize( + "source", + [ + {"type": "view", "space": "sp", "externalId": "v"}, + {"space": "sp", "externalId": "v", "version": None}, + ("sp", "v", None), + ], + ) + def test_source_rejects_missing_version(self, source: object) -> None: + with pytest.raises(TypeError, match="requires an explicit 'version'"): + RecordSource(source, {}) # type: ignore[arg-type] + + def test_source_load_rejects_unknown_type(self) -> None: + raw = {"source": {"space": "sp", "externalId": "x", "type": "node"}, "properties": {}} + with pytest.raises(ValueError, match="must be 'container' or 'view', but was 'node'"): + RecordSource._load(raw) + + def test_source_rejects_wrong_tuple_length(self) -> None: + with pytest.raises(ValueError, match="Invalid tuple length"): + RecordSource(source=("sp",), properties={}) # type: ignore[arg-type] + + def test_source_rejects_view_without_version(self) -> None: + with pytest.raises(ValueError, match="requires an explicit version"): + RecordSource(source=ViewId("sp", "my_view"), properties={}) + + +class TestRecordViewConversion: + def test_as_write_reconstructs_sources(self) -> None: + properties = {"sp": {"my_view/v1": {"temp": 22.5}, "my_container": {"pressure": 1.0}}} + record = Record("sp", "rec-1", 100, 200, properties) + sources = record.as_write().sources + assert [source.dump() for source in sources] == [ + { + "source": {"type": "view", "space": "sp", "externalId": "my_view", "version": "v1"}, + "properties": {"temp": 22.5}, + }, + { + "source": {"type": "container", "space": "sp", "externalId": "my_container"}, + "properties": {"pressure": 1.0}, + }, + ] + sources[0].properties["temp"] = 30.0 + assert properties["sp"]["my_view/v1"]["temp"] == 22.5 + + +class TestRecordsAggregateWithViews: + @pytest.mark.parametrize( + "property_", + [ + (ViewId("my_space", "my_view", "v1"), "temperature"), + (RecordViewId("my_space", "my_view", "v1"), "temperature"), + ("my_space", "my_view/v1", "temperature"), + PropertyId(ViewId("my_space", "my_view", "v1"), "temperature"), + ], + ids=["view-tuple", "record-view-tuple", "path", "property-id"], + ) + def test_aggregate_property_references(self, property_: PropertyPath) -> None: + assert Average(property_).dump() == {"avg": {"property": ["my_space", "my_view/v1", "temperature"]}} diff --git a/tests/tests_unit/test_data_classes/test_data_models/test_ids.py b/tests/tests_unit/test_data_classes/test_data_models/test_ids.py index e4a50713da..4bba367f1b 100644 --- a/tests/tests_unit/test_data_classes/test_data_models/test_ids.py +++ b/tests/tests_unit/test_data_classes/test_data_models/test_ids.py @@ -4,7 +4,13 @@ import pytest -from cognite.client.data_classes.data_modeling.ids import ContainerId, ViewId, _load_identifier, _load_space_identifier +from cognite.client.data_classes.data_modeling.ids import ( + ContainerId, + PropertyId, + ViewId, + _load_identifier, + _load_space_identifier, +) class TestContainerReference: @@ -79,3 +85,14 @@ def test_load_space_identifier( assert identifier.is_singleton() == expected_is_singleton, ( f"Expected {expected_is_singleton} but got {identifier.is_singleton()}" ) + + +@pytest.mark.parametrize( + "source, expected", + [ + (ContainerId("sp", "container"), ("sp", "container", "temp")), + (ViewId("sp", "view", "v1"), ("sp", "view/v1", "temp")), + ], +) +def test_property_id_as_property_ref(source: ContainerId | ViewId, expected: tuple[str, str, str]) -> None: + assert PropertyId(source, "temp").as_property_ref() == expected diff --git a/tests/tests_unit/test_data_classes/test_filters.py b/tests/tests_unit/test_data_classes/test_filters.py index a79aae2adb..aff085b3f7 100644 --- a/tests/tests_unit/test_data_classes/test_filters.py +++ b/tests/tests_unit/test_data_classes/test_filters.py @@ -4,7 +4,16 @@ import pytest -from cognite.client.data_classes.filters import And, Equals, Filter, In, Or +from cognite.client.data_classes.data_modeling.ids import ContainerId, PropertyId, ViewId +from cognite.client.data_classes.data_modeling.records import RecordContainerId, RecordViewId +from cognite.client.data_classes.filters import ( + And, + Equals, + Filter, + In, + Or, + PropertyReference, +) from tests.utils import FakeCogniteResourceGenerator @@ -64,3 +73,19 @@ def test_filter_is_hashable_and_uses_identity() -> None: flt2 = Equals(property=["node", "type"], value="pump") assert hash(flt) != hash(flt2) assert flt != flt2 + + +@pytest.mark.parametrize( + "property_, expected", + [ + ((ViewId("sp", "view", "v1"), "temp"), ["sp", "view/v1", "temp"]), + ((RecordViewId("sp", "view", "v1"), "temp"), ["sp", "view/v1", "temp"]), + ((ContainerId("sp", "container"), "temp"), ["sp", "container", "temp"]), + ((RecordContainerId("sp", "container"), "temp"), ["sp", "container", "temp"]), + (PropertyId(ViewId("sp", "view", "v1"), "temp"), ["sp", "view/v1", "temp"]), + (PropertyId(ContainerId("sp", "container"), "temp"), ["sp", "container", "temp"]), + ], + ids=["view", "record-view", "container", "record-container", "view-property-id", "container-property-id"], +) +def test_filter_source_property_references(property_: PropertyReference, expected: list[str]) -> None: + assert Equals(property_, 25.0).dump() == {"equals": {"property": expected, "value": 25.0}} diff --git a/tests/utils.py b/tests/utils.py index c42112f4e3..c2a59fb533 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -78,6 +78,7 @@ from cognite.client.utils._concurrency import CRUDConcurrency from cognite.client.utils._pandas_helpers import timestamp_dtype_unit from cognite.client.utils._text import random_string +from cognite.client.utils.useful_types import SequenceNotStr REPO_ROOT = Path(__file__).resolve().parent.parent @@ -536,7 +537,13 @@ def create_value(self, type_: Any, var_name: str | None = None) -> Any: return self.create_value(first_not_none) elif container_type is typing.Literal: return self._random.choice(args) - elif container_type in [list, typing.Sequence, collections.abc.Sequence, collections.abc.Collection]: + elif container_type in [ + list, + typing.Sequence, + collections.abc.Sequence, + collections.abc.Collection, + SequenceNotStr, + ]: return [self.create_value(first_not_none) for _ in range(3)] elif container_type in [dict, collections.abc.MutableMapping, collections.abc.Mapping]: if first_not_none is None: