From bf8b8d4b59034957e4ecbd0715b6551b4f240f72 Mon Sep 17 00:00:00 2001 From: Everton Colling Date: Mon, 31 Aug 2026 07:48:28 +0200 Subject: [PATCH 01/13] feat(records): support views as record sources and in property references --- cognite/client/_api/data_modeling/records.py | 31 ++++- .../client/_sync_api/data_modeling/records.py | 33 ++++- .../data_classes/data_modeling/__init__.py | 2 + .../data_classes/data_modeling/_validation.py | 25 ++-- .../data_classes/data_modeling/aggregates.py | 22 ++-- .../client/data_classes/data_modeling/ids.py | 1 + .../data_classes/data_modeling/records.py | 118 +++++++++++++----- cognite/client/data_classes/filters.py | 18 ++- 8 files changed, 189 insertions(+), 61 deletions(-) diff --git a/cognite/client/_api/data_modeling/records.py b/cognite/client/_api/data_modeling/records.py index 4e4219a740..73e89dfa93 100644 --- a/cognite/client/_api/data_modeling/records.py +++ b/cognite/client/_api/data_modeling/records.py @@ -153,6 +153,25 @@ async def ingest( ... ), ... stream_id="my-stream", ... ) + + Ingest a record through a view instead of a container: + + >>> from cognite.client.data_classes.data_modeling.records import RecordViewId + >>> client.data_modeling.records.ingest( + ... RecordWrite( + ... space="my-space", + ... external_id="rec-2", + ... sources=[ + ... RecordSource( + ... source=RecordViewId( + ... space="my-space", external_id="my-view", version="v1" + ... ), + ... properties={"temperature": 22.5}, + ... ) + ... ], + ... ), + ... stream_id="my-stream", + ... ) """ self._warning.warn() item_list: list[RecordWrite] = [items] if isinstance(items, RecordWrite) else list(items) @@ -174,7 +193,7 @@ async def upsert( Creates or fully updates records. Only valid for mutable streams (returns 422 on immutable). When a record with the same ``space + externalId`` already exists it is fully replaced (this endpoint does not do partial property updates); otherwise it is - created. + created. As for ingest, a record source may reference a container or a view. Args: items (RecordWrite | Sequence[RecordWrite]): One or more records to upsert. @@ -228,6 +247,12 @@ async def aggregate( ) -> RecordsAggregation: """`Aggregate records from a stream `_. + Aggregate ``property`` references can address container properties directly or through + a view. Note that when a view is involved, all aggregate property references in the + request combined can address at most one property source: either a single view and + nothing else, or any number of containers. This restriction does not apply to ``filter`` + or ``target_units``. + Args: aggregates (Mapping[str, Aggregate | dict[str, Any]]): Aggregate request tree keyed by client-defined aggregate IDs. @@ -378,7 +403,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 +510,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..fdc1d8768d 100644 --- a/cognite/client/_sync_api/data_modeling/records.py +++ b/cognite/client/_sync_api/data_modeling/records.py @@ -1,6 +1,6 @@ """ =============================================================================== -e50d855222acc9a875059316cd3d6fce +a86494bd2530948f0deb4baeb931bbb0 This file is auto-generated from the Async API modules, - do not edit manually! =============================================================================== """ @@ -114,6 +114,25 @@ def ingest(self, items: RecordWrite | Sequence[RecordWrite], *, stream_id: str) ... ), ... stream_id="my-stream", ... ) + + Ingest a record through a view instead of a container: + + >>> from cognite.client.data_classes.data_modeling.records import RecordViewId + >>> client.data_modeling.records.ingest( + ... RecordWrite( + ... space="my-space", + ... external_id="rec-2", + ... sources=[ + ... RecordSource( + ... source=RecordViewId( + ... space="my-space", external_id="my-view", version="v1" + ... ), + ... properties={"temperature": 22.5}, + ... ) + ... ], + ... ), + ... stream_id="my-stream", + ... ) """ return run_sync(self.__async_client.data_modeling.records.ingest(items=items, stream_id=stream_id)) @@ -126,7 +145,7 @@ def upsert( Creates or fully updates records. Only valid for mutable streams (returns 422 on immutable). When a record with the same ``space + externalId`` already exists it is fully replaced (this endpoint does not do partial property updates); otherwise it is - created. + created. As for ingest, a record source may reference a container or a view. Args: items (RecordWrite | Sequence[RecordWrite]): One or more records to upsert. @@ -177,6 +196,12 @@ def aggregate( """ `Aggregate records from a stream `_. + Aggregate ``property`` references can address container properties directly or through + a view. Note that when a view is involved, all aggregate property references in the + request combined can address at most one property source: either a single view and + nothing else, or any number of containers. This restriction does not apply to ``filter`` + or ``target_units``. + Args: aggregates (Mapping[str, Aggregate | dict[str, Any]]): Aggregate request tree keyed by client-defined aggregate IDs. @@ -321,7 +346,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 +441,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..fb02f10c5e 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,9 +61,7 @@ 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 @@ -69,7 +69,7 @@ def validate_property_path( 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. @@ -77,6 +77,15 @@ def validate_property_path( Returns: list[str]: The validated path as a list. """ + if isinstance(prop, PropertyId): + return list(prop.source.as_property_ref(prop.property)) + if isinstance(prop, tuple) and len(prop) == 2 and isinstance(prop[0], (ContainerId, ViewId)): + if not isinstance(prop[1], str): + raise TypeError( + f"{argument!r} given as a (source, property) tuple must have a string property, " + f"but {prop[1]!r} is of type {type(prop[1]).__name__}. {hint}" + ) + return list(prop[0].as_property_ref(prop[1])) 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}") 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..1ffb610a34 100644 --- a/cognite/client/data_classes/data_modeling/ids.py +++ b/cognite/client/data_classes/data_modeling/ids.py @@ -195,6 +195,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..b813e663b9 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,7 +16,7 @@ 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 @@ -33,7 +33,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. + """Container reference used as a source in a record write or read. Args: space (str): Space that contains the container. @@ -41,22 +41,73 @@ 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'.") + + +RecordSourceIdentifier: TypeAlias = ContainerId | ViewId | tuple[str, str] | tuple[str, str, str] + + +def _load_record_source_id(data: RecordSourceIdentifier | dict[str, Any]) -> RecordContainerId | RecordViewId: + if isinstance(data, RecordViewId): + return data + if isinstance(data, RecordContainerId): + return data + if isinstance(data, ViewId): + if data.version is None: + raise ValueError("A view used as a record source requires an explicit version.") + return RecordViewId(space=data.space, external_id=data.external_id, version=data.version) + if isinstance(data, ContainerId): + return RecordContainerId(space=data.space, external_id=data.external_id) + if isinstance(data, tuple): + if len(data) == 3: + return RecordViewId(space=data[0], external_id=data[1], version=data[2]) + if len(data) == 2: + return RecordContainerId(space=data[0], external_id=data[1]) + raise ValueError(f"Invalid tuple length for record source identifier: {len(data)}, expected 2 or 3.") + if isinstance(data, dict): + source_type = data.get("type") + if source_type == "view" or (source_type is None and "version" in data): + return RecordViewId(space=data["space"], external_id=data["externalId"], version=data["version"]) + if source_type in ("container", None): + return RecordContainerId(space=data["space"], external_id=data["externalId"]) + raise ValueError(f"Record source 'type' must be 'container' or 'view', but was {source_type!r}") + raise TypeError(f"Cannot load record source from {type(data).__name__}") + + 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): Reference to the container or view. + 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 +126,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 +206,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__( @@ -198,14 +251,16 @@ 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() - ] + sources: list[RecordSource] = [] + for space, containers in (self.properties or {}).items(): + for container_or_view, props in containers.items(): + source: RecordContainerId | RecordViewId + if "/" in container_or_view: + view_xid, version = container_or_view.split("/", 1) + source = RecordViewId(space=space, external_id=view_xid, version=version) + else: + source = RecordContainerId(space=space, external_id=container_or_view) + sources.append(RecordSource(source=source, properties=dict(props))) return RecordWrite(space=self.space, external_id=self.external_id, sources=sources) @@ -276,39 +331,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..e458751c48 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 | SequenceNotStr[str] | EnumProperty | tuple[ContainerId | ViewId, str] | PropertyId @dataclass @@ -71,6 +68,15 @@ 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_, PropertyId): + return list(property_.source.as_property_ref(property_.property)) + elif ( + isinstance(property_, tuple) + and len(property_) == 2 + and isinstance(property_[0], (ContainerId, ViewId)) + and isinstance(property_[1], str) + ): + return list(property_[0].as_property_ref(property_[1])) elif isinstance(property_, str): return [to_camel_case(property_) if camel_case else property_] elif isinstance(property_, (list, tuple)): From 7c3908c16288ea579a9ab87c7d0c874c6c324beb Mon Sep 17 00:00:00 2001 From: Everton Colling Date: Mon, 31 Aug 2026 07:48:33 +0200 Subject: [PATCH 02/13] test(records): cover view sources, view property references, and validation errors --- .../test_data_modeling/test_records.py | 399 +++++++++++++++++- .../test_data_classes/test_filters.py | 72 +++- tests/utils.py | 9 +- 3 files changed, 464 insertions(+), 16 deletions(-) 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..d474e8535e 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 @@ -14,6 +14,7 @@ FiltersResult, Max, MetricResult, + Min, MovingFunction, MovingFunctionResult, MovingFunctions, @@ -27,6 +28,7 @@ UnknownResult, ) from cognite.client.data_classes.data_modeling.data_types import UnitReference +from cognite.client.data_classes.data_modeling.ids import ContainerId, PropertyId, ViewId from cognite.client.data_classes.data_modeling.instances import InstanceSort, TypeInformation from cognite.client.data_classes.data_modeling.records import ( Record, @@ -38,6 +40,7 @@ RecordSourceSelector, RecordTargetUnit, RecordTargetUnits, + RecordViewId, RecordWrite, SyncRecord, SyncRecordList, @@ -119,8 +122,8 @@ def write_item() -> RecordWrite: external_id="rec-1", sources=[ RecordSource( - source=RecordContainerId(space="sp", external_id="container-x"), - properties={"temp": 22.5}, + source=ContainerId(space="sp", external_id="container-x"), + properties={"temperature": 22.5, "pressure": 1.013}, ) ], ) @@ -195,7 +198,7 @@ def test_ingest_single_posts_correct_body( "sources": [ { "source": {"type": "container", "space": "sp", "externalId": "container-x"}, - "properties": {"temp": 22.5}, + "properties": {"temperature": 22.5, "pressure": 1.013}, } ], } @@ -241,7 +244,7 @@ def test_upsert_single_posts_correct_body( "sources": [ { "source": {"type": "container", "space": "sp", "externalId": "container-x"}, - "properties": {"temp": 22.5}, + "properties": {"temperature": 22.5, "pressure": 1.013}, } ], } @@ -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, ) @@ -1018,16 +1019,16 @@ def test_record_write_round_trip(self, write_item: RecordWrite) -> None: assert len(loaded.sources) == 1 assert loaded.sources[0].source.space == "sp" assert loaded.sources[0].source.external_id == "container-x" - assert loaded.sources[0].properties == {"temp": 22.5} + assert loaded.sources[0].properties == {"temperature": 22.5, "pressure": 1.013} 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,379 @@ 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] + + +class TestRecordViewId: + def test_init_and_attributes(self) -> None: + view_id = RecordViewId(space="my_space", external_id="my_view", version="v1") + assert view_id.space == "my_space" + assert view_id.external_id == "my_view" + assert view_id.version == "v1" + assert view_id.as_tuple() == ("my_space", "my_view", "v1") + assert view_id.as_source_identifier() == "my_view/v1" + assert view_id.as_property_ref("temp") == ("my_space", "my_view/v1", "temp") + + def test_version_is_required(self) -> None: + # Unlike a general ViewId, record view sources must be fully versioned. + with pytest.raises(TypeError): + RecordViewId(space="my_space", external_id="my_view") # type: ignore[call-arg] + + def test_dump(self) -> None: + view_id = RecordViewId(space="my_space", external_id="my_view", version="v1") + assert view_id.dump() == { + "space": "my_space", + "externalId": "my_view", + "version": "v1", + "type": "view", + } + + def test_load(self) -> None: + raw = {"space": "my_space", "externalId": "my_view", "version": "v1", "type": "view"} + loaded = RecordViewId.load(raw) + assert isinstance(loaded, RecordViewId) + assert loaded.space == "my_space" + assert loaded.external_id == "my_view" + assert loaded.version == "v1" + + def test_load_from_tuple(self) -> None: + loaded = RecordViewId.load(("my_space", "my_view", "v1")) + assert isinstance(loaded, RecordViewId) + assert loaded.as_tuple() == ("my_space", "my_view", "v1") + + +class TestRecordSourceViews: + def test_source_with_record_view_id(self) -> None: + src = RecordSource( + source=RecordViewId("sp", "my_view", "v1"), + properties={"temp": 25.0}, + ) + assert isinstance(src.source, RecordViewId) + assert src.dump() == { + "source": {"space": "sp", "externalId": "my_view", "version": "v1", "type": "view"}, + "properties": {"temp": 25.0}, + } + + def test_source_with_view_id(self) -> None: + src = RecordSource( + source=ViewId("sp", "my_view", "v1"), + properties={"temp": 25.0}, + ) + assert isinstance(src.source, ViewId) + assert src.dump() == { + "source": {"space": "sp", "externalId": "my_view", "version": "v1", "type": "view"}, + "properties": {"temp": 25.0}, + } + + def test_source_with_tuple(self) -> None: + src = RecordSource( + source=("sp", "my_view", "v1"), + properties={"temp": 25.0}, + ) + assert isinstance(src.source, ViewId) + assert src.dump()["source"] == {"space": "sp", "externalId": "my_view", "version": "v1", "type": "view"} + + def test_source_load_view(self) -> None: + raw = { + "source": {"space": "sp", "externalId": "my_view", "version": "v1", "type": "view"}, + "properties": {"temp": 25.0}, + } + loaded = RecordSource._load(raw) + assert isinstance(loaded.source, ViewId) + assert loaded.source.space == "sp" + assert loaded.source.external_id == "my_view" + assert loaded.source.version == "v1" + assert loaded.properties == {"temp": 25.0} + + 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 TestRecordSourceSelectorViews: + def test_selector_with_view_id(self) -> None: + sel = RecordSourceSelector( + source=ViewId("sp", "my_view", "v1"), + properties=["temp", "humidity"], + ) + assert isinstance(sel.source, ViewId) + assert sel.dump() == { + "source": {"space": "sp", "externalId": "my_view", "version": "v1", "type": "view"}, + "properties": ["temp", "humidity"], + } + + def test_selector_with_tuple(self) -> None: + sel = RecordSourceSelector( + source=("sp", "my_view", "v1"), + properties=["*"], + ) + assert isinstance(sel.source, ViewId) + assert sel.dump() == { + "source": {"space": "sp", "externalId": "my_view", "version": "v1", "type": "view"}, + "properties": ["*"], + } + + def test_selector_load_view(self) -> None: + raw = { + "source": {"space": "sp", "externalId": "my_view", "version": "v1", "type": "view"}, + "properties": ["temp"], + } + loaded = RecordSourceSelector._load(raw) + assert isinstance(loaded.source, ViewId) + assert loaded.properties == ["temp"] + + +class TestRecordsAPIViewsOperations: + def test_ingest_with_view_sources( + self, + cognite_client: CogniteClient, + httpx_mock: HTTPXMock, + mock_ingest: None, + stream_id: str, + ) -> None: + write_record = RecordWrite( + space="sp", + external_id="rec-1", + sources=[ + RecordSource( + source=ViewId(space="sp", external_id="my_view", version="v1"), + properties={"temp": 22.5}, + ) + ], + ) + cognite_client.data_modeling.records.ingest(write_record, stream_id=stream_id) + requests = httpx_mock.get_requests() + assert len(requests) == 1 + body = jsgz_load(requests[0].content) + assert body == { + "items": [ + { + "space": "sp", + "externalId": "rec-1", + "sources": [ + { + "source": {"type": "view", "space": "sp", "externalId": "my_view", "version": "v1"}, + "properties": {"temp": 22.5}, + } + ], + } + ] + } + + def test_upsert_with_view_sources( + self, + cognite_client: CogniteClient, + httpx_mock: HTTPXMock, + mock_upsert: None, + stream_id: str, + ) -> None: + write_record = RecordWrite( + space="sp", + external_id="rec-1", + sources=[ + RecordSource( + source=("sp", "my_view", "v1"), + properties={"temp": 23.0}, + ) + ], + ) + cognite_client.data_modeling.records.upsert(write_record, stream_id=stream_id) + requests = httpx_mock.get_requests() + assert len(requests) == 1 + body = jsgz_load(requests[0].content) + assert body == { + "items": [ + { + "space": "sp", + "externalId": "rec-1", + "sources": [ + { + "source": {"type": "view", "space": "sp", "externalId": "my_view", "version": "v1"}, + "properties": {"temp": 23.0}, + } + ], + } + ] + } + + def test_filter_with_view_sources( + self, + cognite_client: CogniteClient, + httpx_mock: HTTPXMock, + mock_filter: None, + stream_id: str, + ) -> None: + cognite_client.data_modeling.records.filter( + stream_id=stream_id, + sources=[RecordSourceSelector(source=ViewId("sp", "my_view", "v1"), properties=["temp"])], + ) + requests = httpx_mock.get_requests() + assert len(requests) == 1 + body = jsgz_load(requests[0].content) + assert body["sources"] == [ + { + "source": {"type": "view", "space": "sp", "externalId": "my_view", "version": "v1"}, + "properties": ["temp"], + } + ] + + def test_sync_with_view_sources( + self, + cognite_client: CogniteClient, + httpx_mock: HTTPXMock, + sync_url_pattern: re.Pattern, + stream_id: str, + ) -> None: + httpx_mock.add_response( + method="POST", + url=sync_url_pattern, + json={ + "items": [], + "nextCursor": "cur-1", + "hasNext": False, + }, + ) + feed = cognite_client.data_modeling.records.sync( + stream_id=stream_id, + initialize_cursor="1d-ago", + sources=[RecordSourceSelector(source=("sp", "my_view", "v1"), properties=["*"])], + ) + list(feed) + requests = httpx_mock.get_requests() + assert len(requests) == 1 + body = jsgz_load(requests[0].content) + assert body["sources"] == [ + { + "source": {"type": "view", "space": "sp", "externalId": "my_view", "version": "v1"}, + "properties": ["*"], + } + ] + + def test_record_as_write_reconstructs_view_sources(self) -> None: + raw_record = { + "space": "sp", + "externalId": "rec-1", + "createdTime": 100, + "lastUpdatedTime": 200, + "properties": { + "sp": { + "my_view/v1": {"temp": 22.5}, + "my_container": {"pressure": 1.0}, + } + }, + } + record = Record._load(raw_record) + write_rec = record.as_write() + assert isinstance(write_rec, RecordWrite) + assert len(write_rec.sources) == 2 + + view_src = next(s for s in write_rec.sources if isinstance(s.source, RecordViewId)) + assert isinstance(view_src.source, RecordViewId) + assert view_src.source.space == "sp" + assert view_src.source.external_id == "my_view" + assert view_src.source.version == "v1" + assert view_src.properties == {"temp": 22.5} + + cnt_src = next(s for s in write_rec.sources if isinstance(s.source, RecordContainerId)) + assert isinstance(cnt_src.source, RecordContainerId) + assert cnt_src.source.space == "sp" + assert cnt_src.source.external_id == "my_container" + assert cnt_src.properties == {"pressure": 1.0} + + +class TestRecordsAggregateWithViews: + def test_aggregates_with_view_property_references(self) -> None: + view = ViewId("my_space", "my_view", "v1") + rec_view = RecordViewId("my_space", "my_view", "v1") + + # Every accepted way of referencing a view property should dump identically: + for property_ in [ + (view, "temperature"), + (rec_view, "temperature"), + view.as_property_ref("temperature"), + PropertyId(view, "temperature"), + ]: + avg = Average(property=property_) + assert avg.dump() == {"avg": {"property": ["my_space", "my_view/v1", "temperature"]}} + + assert Sum((view, "score")).dump() == {"sum": {"property": ["my_space", "my_view/v1", "score"]}} + assert Min((view, "score")).dump() == {"min": {"property": ["my_space", "my_view/v1", "score"]}} + assert Max((view, "score")).dump() == {"max": {"property": ["my_space", "my_view/v1", "score"]}} + assert Count((view, "score")).dump() == {"count": {"property": ["my_space", "my_view/v1", "score"]}} + assert UniqueValues((view, "player")).dump() == { + "uniqueValues": {"property": ["my_space", "my_view/v1", "player"]} + } + assert NumberHistogram((view, "score"), interval=10.0).dump() == { + "numberHistogram": {"property": ["my_space", "my_view/v1", "score"], "interval": 10.0} + } + assert TimeHistogram((view, "ts"), calendar_interval="1d").dump() == { + "timeHistogram": {"property": ["my_space", "my_view/v1", "ts"], "calendarInterval": "1d"} + } + + def test_aggregate_api_call_with_view_aggregates_and_filters( + self, + cognite_client: CogniteClient, + httpx_mock: HTTPXMock, + stream_id: str, + records_base_url: str, + ) -> None: + view = ViewId("my_space", "my_view", "v1") + httpx_mock.add_response( + method="POST", + url=records_base_url + "/aggregate", + json={"aggregates": {"avg_temp": {"avg": 22.5}}}, + ) + res = cognite_client.data_modeling.records.aggregate( + stream_id=stream_id, + aggregates={"avg_temp": Average((view, "temperature"))}, + filter=filters.Equals((view, "status"), "active"), + ) + avg_res = res["avg_temp"] + assert isinstance(avg_res, MetricResult) + assert avg_res.value == 22.5 + requests = httpx_mock.get_requests() + assert len(requests) == 1 + body = jsgz_load(requests[0].content) + assert body == { + "aggregates": { + "avg_temp": { + "avg": { + "property": ["my_space", "my_view/v1", "temperature"], + } + } + }, + "filter": { + "equals": { + "property": ["my_space", "my_view/v1", "status"], + "value": "active", + } + }, + } diff --git a/tests/tests_unit/test_data_classes/test_filters.py b/tests/tests_unit/test_data_classes/test_filters.py index a79aae2adb..9794172599 100644 --- a/tests/tests_unit/test_data_classes/test_filters.py +++ b/tests/tests_unit/test_data_classes/test_filters.py @@ -4,7 +4,20 @@ 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, + ContainsAll, + ContainsAny, + Equals, + Exists, + Filter, + In, + Or, + Prefix, + Range, +) from tests.utils import FakeCogniteResourceGenerator @@ -64,3 +77,60 @@ def test_filter_is_hashable_and_uses_identity() -> None: flt2 = Equals(property=["node", "type"], value="pump") assert hash(flt) != hash(flt2) assert flt != flt2 + + +def test_filter_view_and_container_property_references() -> None: + view = ViewId("my_space", "my_view", "v1") + rec_view = RecordViewId("my_space", "my_view", "v1") + container = ContainerId("my_space", "my_container") + rec_container = RecordContainerId("my_space", "my_container") + + # View as_property_ref + flt = Equals(property=view.as_property_ref("temperature"), value=25.0) + assert list(flt.dump()["equals"]["property"]) == ["my_space", "my_view/v1", "temperature"] + assert flt.dump()["equals"]["value"] == 25.0 + + # RecordViewId as_property_ref + flt = Equals(property=rec_view.as_property_ref("temperature"), value=25.0) + assert list(flt.dump()["equals"]["property"]) == ["my_space", "my_view/v1", "temperature"] + assert flt.dump()["equals"]["value"] == 25.0 + + # (ViewId, str) tuple + flt = Equals(property=(view, "temperature"), value=25.0) + assert flt.dump() == {"equals": {"property": ["my_space", "my_view/v1", "temperature"], "value": 25.0}} + + # (RecordViewId, str) tuple + flt = Equals(property=(rec_view, "temperature"), value=25.0) + assert flt.dump() == {"equals": {"property": ["my_space", "my_view/v1", "temperature"], "value": 25.0}} + + # (ContainerId, str) tuple + flt = Equals(property=(container, "temperature"), value=25.0) + assert flt.dump() == {"equals": {"property": ["my_space", "my_container", "temperature"], "value": 25.0}} + + # (RecordContainerId, str) tuple + flt = Equals(property=(rec_container, "temperature"), value=25.0) + assert flt.dump() == {"equals": {"property": ["my_space", "my_container", "temperature"], "value": 25.0}} + + # PropertyId with ViewId + flt = Equals(property=PropertyId(view, "temperature"), value=25.0) + assert flt.dump() == {"equals": {"property": ["my_space", "my_view/v1", "temperature"], "value": 25.0}} + + # Other filters with (ViewId, str) + assert In(property=(view, "status"), values=["open", "closed"]).dump() == { + "in": {"property": ["my_space", "my_view/v1", "status"], "values": ["open", "closed"]} + } + assert Range(property=(view, "temperature"), gt=20.0).dump() == { + "range": {"property": ["my_space", "my_view/v1", "temperature"], "gt": 20.0} + } + assert Prefix(property=(view, "name"), value="sensor-").dump() == { + "prefix": {"property": ["my_space", "my_view/v1", "name"], "value": "sensor-"} + } + assert ContainsAny(property=(view, "tags"), values=["a", "b"]).dump() == { + "containsAny": {"property": ["my_space", "my_view/v1", "tags"], "values": ["a", "b"]} + } + assert ContainsAll(property=(view, "tags"), values=["a", "b"]).dump() == { + "containsAll": {"property": ["my_space", "my_view/v1", "tags"], "values": ["a", "b"]} + } + assert Exists(property=(view, "temperature")).dump() == { + "exists": {"property": ["my_space", "my_view/v1", "temperature"]} + } 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: From 8a04cf149155a9f826fa54f6dd1d9eb9812b47cc Mon Sep 17 00:00:00 2001 From: Everton Colling Date: Mon, 31 Aug 2026 08:15:47 +0200 Subject: [PATCH 03/13] fix(records): accept snake_case dicts as record sources; fix mypy error in tests --- .../data_classes/data_modeling/records.py | 4 ++-- .../test_data_modeling/test_records.py | 20 ++++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/cognite/client/data_classes/data_modeling/records.py b/cognite/client/data_classes/data_modeling/records.py index b813e663b9..55594a284b 100644 --- a/cognite/client/data_classes/data_modeling/records.py +++ b/cognite/client/data_classes/data_modeling/records.py @@ -81,9 +81,9 @@ def _load_record_source_id(data: RecordSourceIdentifier | dict[str, Any]) -> Rec if isinstance(data, dict): source_type = data.get("type") if source_type == "view" or (source_type is None and "version" in data): - return RecordViewId(space=data["space"], external_id=data["externalId"], version=data["version"]) + return RecordViewId.load(data) if source_type in ("container", None): - return RecordContainerId(space=data["space"], external_id=data["externalId"]) + return RecordContainerId.load(data) raise ValueError(f"Record source 'type' must be 'container' or 'view', but was {source_type!r}") raise TypeError(f"Cannot load record source from {type(data).__name__}") 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 d474e8535e..9be668347e 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 @@ -28,7 +28,7 @@ UnknownResult, ) from cognite.client.data_classes.data_modeling.data_types import UnitReference -from cognite.client.data_classes.data_modeling.ids import ContainerId, PropertyId, ViewId +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, @@ -1297,6 +1297,19 @@ def test_source_load_view(self) -> None: assert loaded.source.version == "v1" assert loaded.properties == {"temp": 25.0} + def test_source_accepts_snake_case_dict(self) -> None: + view_src = RecordSource( + source={"space": "sp", "external_id": "my_view", "version": "v1"}, # type: ignore[arg-type] + properties={"temp": 25.0}, + ) + assert view_src.source == RecordViewId("sp", "my_view", "v1") + + container_src = RecordSource( + source={"space": "sp", "external_id": "my_container"}, # type: ignore[arg-type] + properties={"pressure": 1.0}, + ) + assert container_src.source == RecordContainerId("sp", "my_container") + 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'"): @@ -1508,12 +1521,13 @@ def test_aggregates_with_view_property_references(self) -> None: rec_view = RecordViewId("my_space", "my_view", "v1") # Every accepted way of referencing a view property should dump identically: - for property_ in [ + properties: list[PropertyPath] = [ (view, "temperature"), (rec_view, "temperature"), view.as_property_ref("temperature"), PropertyId(view, "temperature"), - ]: + ] + for property_ in properties: avg = Average(property=property_) assert avg.dump() == {"avg": {"property": ["my_space", "my_view/v1", "temperature"]}} From cf218ee624ab575deb4290449a1df488cef966fa Mon Sep 17 00:00:00 2001 From: Everton Colling <33816483+evertoncolling@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:46:44 +0200 Subject: [PATCH 04/13] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: HÃ¥kon V. Treider --- cognite/client/_api/data_modeling/records.py | 2 +- cognite/client/data_classes/data_modeling/records.py | 2 +- cognite/client/data_classes/filters.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cognite/client/_api/data_modeling/records.py b/cognite/client/_api/data_modeling/records.py index 73e89dfa93..57c01e5225 100644 --- a/cognite/client/_api/data_modeling/records.py +++ b/cognite/client/_api/data_modeling/records.py @@ -154,7 +154,7 @@ async def ingest( ... stream_id="my-stream", ... ) - Ingest a record through a view instead of a container: + Ingest a record through a view: >>> from cognite.client.data_classes.data_modeling.records import RecordViewId >>> client.data_modeling.records.ingest( diff --git a/cognite/client/data_classes/data_modeling/records.py b/cognite/client/data_classes/data_modeling/records.py index 55594a284b..97cc0e89bc 100644 --- a/cognite/client/data_classes/data_modeling/records.py +++ b/cognite/client/data_classes/data_modeling/records.py @@ -33,7 +33,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 or read. + """Identifier for a records container Args: space (str): Space that contains the container. diff --git a/cognite/client/data_classes/filters.py b/cognite/client/data_classes/filters.py index e458751c48..c5c2df0cd5 100644 --- a/cognite/client/data_classes/filters.py +++ b/cognite/client/data_classes/filters.py @@ -18,7 +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 -PropertyReference: TypeAlias = str | SequenceNotStr[str] | EnumProperty | tuple[ContainerId | ViewId, str] | PropertyId +PropertyReference: TypeAlias = str | Sequence[str] | EnumProperty | tuple[ContainerId | ViewId, str] | PropertyId @dataclass From 4a9429276814af2b7dc4201e9d552035f2c5f357 Mon Sep 17 00:00:00 2001 From: Everton Colling Date: Tue, 8 Sep 2026 10:44:50 +0200 Subject: [PATCH 05/13] Add PropertyId.as_property_ref --- .../data_classes/data_modeling/_validation.py | 2 +- .../client/data_classes/data_modeling/ids.py | 4 ++++ cognite/client/data_classes/filters.py | 2 +- .../test_data_models/test_ids.py | 19 ++++++++++++++++++- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/cognite/client/data_classes/data_modeling/_validation.py b/cognite/client/data_classes/data_modeling/_validation.py index fb02f10c5e..547048521f 100644 --- a/cognite/client/data_classes/data_modeling/_validation.py +++ b/cognite/client/data_classes/data_modeling/_validation.py @@ -78,7 +78,7 @@ def validate_property_path(prop: PropertyPath, argument: str = "property", hint: list[str]: The validated path as a list. """ if isinstance(prop, PropertyId): - return list(prop.source.as_property_ref(prop.property)) + return list(prop.as_property_ref()) if isinstance(prop, tuple) and len(prop) == 2 and isinstance(prop[0], (ContainerId, ViewId)): if not isinstance(prop[1], str): raise TypeError( diff --git a/cognite/client/data_classes/data_modeling/ids.py b/cognite/client/data_classes/data_modeling/ids.py index 1ffb610a34..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( diff --git a/cognite/client/data_classes/filters.py b/cognite/client/data_classes/filters.py index c5c2df0cd5..08ffeb2627 100644 --- a/cognite/client/data_classes/filters.py +++ b/cognite/client/data_classes/filters.py @@ -69,7 +69,7 @@ def _dump_property(property_: PropertyReference, camel_case: bool) -> list[str] if isinstance(property_, EnumProperty): return property_.as_reference() elif isinstance(property_, PropertyId): - return list(property_.source.as_property_ref(property_.property)) + return list(property_.as_property_ref()) elif ( isinstance(property_, tuple) and len(property_) == 2 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 From 665a38a359ee0e18dc066db97ae789038a09ff8a Mon Sep 17 00:00:00 2001 From: Everton Colling Date: Tue, 8 Sep 2026 10:44:51 +0200 Subject: [PATCH 06/13] Refactor property validation with match --- .../data_classes/data_modeling/_validation.py | 47 +++++++++---------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/cognite/client/data_classes/data_modeling/_validation.py b/cognite/client/data_classes/data_modeling/_validation.py index 547048521f..3c42a35f52 100644 --- a/cognite/client/data_classes/data_modeling/_validation.py +++ b/cognite/client/data_classes/data_modeling/_validation.py @@ -64,38 +64,35 @@ def validate_data_modeling_identifier(space: str | None, external_id: str | None 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 (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 isinstance(prop, PropertyId): - return list(prop.as_property_ref()) - if isinstance(prop, tuple) and len(prop) == 2 and isinstance(prop[0], (ContainerId, ViewId)): - if not isinstance(prop[1], 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} given as a (source, property) tuple must have a string property, " - f"but {prop[1]!r} is of type {type(prop[1]).__name__}. {hint}" - ) - return list(prop[0].as_property_ref(prop[1])) - 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): - raise TypeError( - f"{argument!r} must be a sequence of strings, but {segment!r} is of type " - f"{type(segment).__name__}. {hint}" + 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 From e9978d93d4729dbdcb5dd94778077400ae44ad5f Mon Sep 17 00:00:00 2001 From: Everton Colling Date: Tue, 8 Sep 2026 10:44:51 +0200 Subject: [PATCH 07/13] Refactor filter property serialization with match --- cognite/client/data_classes/filters.py | 35 +++++++++++--------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/cognite/client/data_classes/filters.py b/cognite/client/data_classes/filters.py index 08ffeb2627..00c7d56e7a 100644 --- a/cognite/client/data_classes/filters.py +++ b/cognite/client/data_classes/filters.py @@ -66,26 +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_, PropertyId): - return list(property_.as_property_ref()) - elif ( - isinstance(property_, tuple) - and len(property_) == 2 - and isinstance(property_[0], (ContainerId, ViewId)) - and isinstance(property_[1], str) - ): - return list(property_[0].as_property_ref(property_[1])) - 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): @@ -1038,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 From 4318af53be49759835216c8b45b3aeb1c254eead Mon Sep 17 00:00:00 2001 From: Everton Colling Date: Tue, 8 Sep 2026 10:44:52 +0200 Subject: [PATCH 08/13] Move record source alias below imports --- cognite/client/data_classes/data_modeling/records.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cognite/client/data_classes/data_modeling/records.py b/cognite/client/data_classes/data_modeling/records.py index 97cc0e89bc..761910d9aa 100644 --- a/cognite/client/data_classes/data_modeling/records.py +++ b/cognite/client/data_classes/data_modeling/records.py @@ -22,6 +22,8 @@ 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 @@ -58,8 +60,6 @@ def __post_init__(self) -> None: raise TypeError("RecordViewId requires an explicit 'version'.") -RecordSourceIdentifier: TypeAlias = ContainerId | ViewId | tuple[str, str] | tuple[str, str, str] - def _load_record_source_id(data: RecordSourceIdentifier | dict[str, Any]) -> RecordContainerId | RecordViewId: if isinstance(data, RecordViewId): From 6fdad03e51ef89b3a6edf3359e9e7cabbf7c4ad7 Mon Sep 17 00:00:00 2001 From: Everton Colling Date: Tue, 8 Sep 2026 10:44:52 +0200 Subject: [PATCH 09/13] Refactor record source loading with match --- .../data_classes/data_modeling/records.py | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/cognite/client/data_classes/data_modeling/records.py b/cognite/client/data_classes/data_modeling/records.py index 761910d9aa..4e8dc14a80 100644 --- a/cognite/client/data_classes/data_modeling/records.py +++ b/cognite/client/data_classes/data_modeling/records.py @@ -62,30 +62,31 @@ def __post_init__(self) -> None: def _load_record_source_id(data: RecordSourceIdentifier | dict[str, Any]) -> RecordContainerId | RecordViewId: - if isinstance(data, RecordViewId): - return data - if isinstance(data, RecordContainerId): - return data - if isinstance(data, ViewId): - if data.version is None: - raise ValueError("A view used as a record source requires an explicit version.") - return RecordViewId(space=data.space, external_id=data.external_id, version=data.version) - if isinstance(data, ContainerId): - return RecordContainerId(space=data.space, external_id=data.external_id) - if isinstance(data, tuple): - if len(data) == 3: - return RecordViewId(space=data[0], external_id=data[1], version=data[2]) - if len(data) == 2: - return RecordContainerId(space=data[0], external_id=data[1]) - raise ValueError(f"Invalid tuple length for record source identifier: {len(data)}, expected 2 or 3.") - if isinstance(data, dict): - source_type = data.get("type") - if source_type == "view" or (source_type is None and "version" in data): + 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) - if source_type in ("container", None): + case dict({"type": "container"}): return RecordContainerId.load(data) - raise ValueError(f"Record source 'type' must be 'container' or 'view', but was {source_type!r}") - raise TypeError(f"Cannot load record source from {type(data).__name__}") + 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__}") class RecordSource(CogniteResource): From 1e6fad81cdc97610f752d8d9e8fefb37f2d3b82f Mon Sep 17 00:00:00 2001 From: Everton Colling Date: Tue, 8 Sep 2026 10:44:53 +0200 Subject: [PATCH 10/13] Extract record source parser --- .../data_classes/data_modeling/records.py | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/cognite/client/data_classes/data_modeling/records.py b/cognite/client/data_classes/data_modeling/records.py index 4e8dc14a80..73bb29329a 100644 --- a/cognite/client/data_classes/data_modeling/records.py +++ b/cognite/client/data_classes/data_modeling/records.py @@ -89,6 +89,14 @@ def _load_record_source_id(data: RecordSourceIdentifier | dict[str, Any]) -> Rec 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 or view source with property values for a record write. @@ -252,16 +260,11 @@ def as_id(self) -> RecordId: def as_write(self) -> RecordWrite: """Reconstruct the :class:`RecordWrite` by grouping read properties back into sources.""" - sources: list[RecordSource] = [] - for space, containers in (self.properties or {}).items(): - for container_or_view, props in containers.items(): - source: RecordContainerId | RecordViewId - if "/" in container_or_view: - view_xid, version = container_or_view.split("/", 1) - source = RecordViewId(space=space, external_id=view_xid, version=version) - else: - source = RecordContainerId(space=space, external_id=container_or_view) - sources.append(RecordSource(source=source, properties=dict(props))) + sources = [ + 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) From e196577adfa9179b8f3145636c83142fca8ec03e Mon Sep 17 00:00:00 2001 From: Everton Colling Date: Tue, 8 Sep 2026 10:44:54 +0200 Subject: [PATCH 11/13] Simplify record view documentation --- cognite/client/_api/data_modeling/records.py | 27 +++++----------- .../client/_sync_api/data_modeling/records.py | 31 ++++++------------- .../data_classes/data_modeling/records.py | 3 +- 3 files changed, 17 insertions(+), 44 deletions(-) diff --git a/cognite/client/_api/data_modeling/records.py b/cognite/client/_api/data_modeling/records.py index 57c01e5225..e6421905d9 100644 --- a/cognite/client/_api/data_modeling/records.py +++ b/cognite/client/_api/data_modeling/records.py @@ -157,21 +157,11 @@ async def ingest( Ingest a record through a view: >>> from cognite.client.data_classes.data_modeling.records import RecordViewId - >>> client.data_modeling.records.ingest( - ... RecordWrite( - ... space="my-space", - ... external_id="rec-2", - ... sources=[ - ... RecordSource( - ... source=RecordViewId( - ... space="my-space", external_id="my-view", version="v1" - ... ), - ... properties={"temperature": 22.5}, - ... ) - ... ], - ... ), - ... stream_id="my-stream", + >>> 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) @@ -193,7 +183,7 @@ async def upsert( Creates or fully updates records. Only valid for mutable streams (returns 422 on immutable). When a record with the same ``space + externalId`` already exists it is fully replaced (this endpoint does not do partial property updates); otherwise it is - created. As for ingest, a record source may reference a container or a view. + created. Args: items (RecordWrite | Sequence[RecordWrite]): One or more records to upsert. @@ -247,11 +237,8 @@ async def aggregate( ) -> RecordsAggregation: """`Aggregate records from a stream `_. - Aggregate ``property`` references can address container properties directly or through - a view. Note that when a view is involved, all aggregate property references in the - request combined can address at most one property source: either a single view and - nothing else, or any number of containers. This restriction does not apply to ``filter`` - or ``target_units``. + 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 diff --git a/cognite/client/_sync_api/data_modeling/records.py b/cognite/client/_sync_api/data_modeling/records.py index fdc1d8768d..edcf4257df 100644 --- a/cognite/client/_sync_api/data_modeling/records.py +++ b/cognite/client/_sync_api/data_modeling/records.py @@ -1,6 +1,6 @@ """ =============================================================================== -a86494bd2530948f0deb4baeb931bbb0 +372c2b6a9ad523f8aa3ad87a5c9a49a7 This file is auto-generated from the Async API modules, - do not edit manually! =============================================================================== """ @@ -115,24 +115,14 @@ def ingest(self, items: RecordWrite | Sequence[RecordWrite], *, stream_id: str) ... stream_id="my-stream", ... ) - Ingest a record through a view instead of a container: + Ingest a record through a view: >>> from cognite.client.data_classes.data_modeling.records import RecordViewId - >>> client.data_modeling.records.ingest( - ... RecordWrite( - ... space="my-space", - ... external_id="rec-2", - ... sources=[ - ... RecordSource( - ... source=RecordViewId( - ... space="my-space", external_id="my-view", version="v1" - ... ), - ... properties={"temperature": 22.5}, - ... ) - ... ], - ... ), - ... stream_id="my-stream", + >>> 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)) @@ -145,7 +135,7 @@ def upsert( Creates or fully updates records. Only valid for mutable streams (returns 422 on immutable). When a record with the same ``space + externalId`` already exists it is fully replaced (this endpoint does not do partial property updates); otherwise it is - created. As for ingest, a record source may reference a container or a view. + created. Args: items (RecordWrite | Sequence[RecordWrite]): One or more records to upsert. @@ -196,11 +186,8 @@ def aggregate( """ `Aggregate records from a stream `_. - Aggregate ``property`` references can address container properties directly or through - a view. Note that when a view is involved, all aggregate property references in the - request combined can address at most one property source: either a single view and - nothing else, or any number of containers. This restriction does not apply to ``filter`` - or ``target_units``. + 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 diff --git a/cognite/client/data_classes/data_modeling/records.py b/cognite/client/data_classes/data_modeling/records.py index 73bb29329a..81f48adec6 100644 --- a/cognite/client/data_classes/data_modeling/records.py +++ b/cognite/client/data_classes/data_modeling/records.py @@ -60,7 +60,6 @@ def __post_init__(self) -> 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(): @@ -101,7 +100,7 @@ class RecordSource(CogniteResource): """Container or view source with property values for a record write. Args: - source (RecordSourceIdentifier): Reference to the container or view. + 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. """ From ed8ae75784583eb941c7ce9fde1d1c082adf7720 Mon Sep 17 00:00:00 2001 From: Everton Colling Date: Tue, 8 Sep 2026 10:44:54 +0200 Subject: [PATCH 12/13] Focus records tests on SDK behavior --- .../test_data_modeling/test_records.py | 433 ++++-------------- 1 file changed, 91 insertions(+), 342 deletions(-) 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 9be668347e..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 @@ -14,7 +14,6 @@ FiltersResult, Max, MetricResult, - Min, MovingFunction, MovingFunctionResult, MovingFunctions, @@ -37,6 +36,7 @@ RecordList, RecordsAggregation, RecordSource, + RecordSourceIdentifier, RecordSourceSelector, RecordTargetUnit, RecordTargetUnits, @@ -122,8 +122,8 @@ def write_item() -> RecordWrite: external_id="rec-1", sources=[ RecordSource( - source=ContainerId(space="sp", external_id="container-x"), - properties={"temperature": 22.5, "pressure": 1.013}, + source=RecordContainerId(space="sp", external_id="container-x"), + properties={"temp": 22.5}, ) ], ) @@ -198,7 +198,7 @@ def test_ingest_single_posts_correct_body( "sources": [ { "source": {"type": "container", "space": "sp", "externalId": "container-x"}, - "properties": {"temperature": 22.5, "pressure": 1.013}, + "properties": {"temp": 22.5}, } ], } @@ -244,7 +244,7 @@ def test_upsert_single_posts_correct_body( "sources": [ { "source": {"type": "container", "space": "sp", "externalId": "container-x"}, - "properties": {"temperature": 22.5, "pressure": 1.013}, + "properties": {"temp": 22.5}, } ], } @@ -1019,7 +1019,7 @@ def test_record_write_round_trip(self, write_item: RecordWrite) -> None: assert len(loaded.sources) == 1 assert loaded.sources[0].source.space == "sp" assert loaded.sources[0].source.external_id == "container-x" - assert loaded.sources[0].properties == {"temperature": 22.5, "pressure": 1.013} + assert loaded.sources[0].properties == {"temp": 22.5} def test_record_source_reference_dump(self) -> None: ref = ContainerId(space="s", external_id="c") @@ -1215,100 +1215,77 @@ 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_init_and_attributes(self) -> None: - view_id = RecordViewId(space="my_space", external_id="my_view", version="v1") - assert view_id.space == "my_space" - assert view_id.external_id == "my_view" - assert view_id.version == "v1" - assert view_id.as_tuple() == ("my_space", "my_view", "v1") - assert view_id.as_source_identifier() == "my_view/v1" - assert view_id.as_property_ref("temp") == ("my_space", "my_view/v1", "temp") +class TestRecordViewId: def test_version_is_required(self) -> None: - # Unlike a general ViewId, record view sources must be fully versioned. with pytest.raises(TypeError): RecordViewId(space="my_space", external_id="my_view") # type: ignore[call-arg] - def test_dump(self) -> None: - view_id = RecordViewId(space="my_space", external_id="my_view", version="v1") - assert view_id.dump() == { - "space": "my_space", - "externalId": "my_view", - "version": "v1", - "type": "view", - } - - def test_load(self) -> None: - raw = {"space": "my_space", "externalId": "my_view", "version": "v1", "type": "view"} - loaded = RecordViewId.load(raw) - assert isinstance(loaded, RecordViewId) - assert loaded.space == "my_space" - assert loaded.external_id == "my_view" - assert loaded.version == "v1" - - def test_load_from_tuple(self) -> None: - loaded = RecordViewId.load(("my_space", "my_view", "v1")) - assert isinstance(loaded, RecordViewId) - assert loaded.as_tuple() == ("my_space", "my_view", "v1") - class TestRecordSourceViews: - def test_source_with_record_view_id(self) -> None: - src = RecordSource( - source=RecordViewId("sp", "my_view", "v1"), - properties={"temp": 25.0}, - ) - assert isinstance(src.source, RecordViewId) - assert src.dump() == { - "source": {"space": "sp", "externalId": "my_view", "version": "v1", "type": "view"}, - "properties": {"temp": 25.0}, - } - - def test_source_with_view_id(self) -> None: - src = RecordSource( - source=ViewId("sp", "my_view", "v1"), - properties={"temp": 25.0}, - ) - assert isinstance(src.source, ViewId) - assert src.dump() == { - "source": {"space": "sp", "externalId": "my_view", "version": "v1", "type": "view"}, - "properties": {"temp": 25.0}, - } - - def test_source_with_tuple(self) -> None: - src = RecordSource( - source=("sp", "my_view", "v1"), - properties={"temp": 25.0}, - ) - assert isinstance(src.source, ViewId) - assert src.dump()["source"] == {"space": "sp", "externalId": "my_view", "version": "v1", "type": "view"} - - def test_source_load_view(self) -> None: - raw = { - "source": {"space": "sp", "externalId": "my_view", "version": "v1", "type": "view"}, - "properties": {"temp": 25.0}, - } - loaded = RecordSource._load(raw) - assert isinstance(loaded.source, ViewId) - assert loaded.source.space == "sp" - assert loaded.source.external_id == "my_view" - assert loaded.source.version == "v1" + @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} - def test_source_accepts_snake_case_dict(self) -> None: - view_src = RecordSource( - source={"space": "sp", "external_id": "my_view", "version": "v1"}, # type: ignore[arg-type] - properties={"temp": 25.0}, - ) - assert view_src.source == RecordViewId("sp", "my_view", "v1") - - container_src = RecordSource( - source={"space": "sp", "external_id": "my_container"}, # type: ignore[arg-type] - properties={"pressure": 1.0}, - ) - assert container_src.source == RecordContainerId("sp", "my_container") + @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": {}} @@ -1324,263 +1301,35 @@ def test_source_rejects_view_without_version(self) -> None: RecordSource(source=ViewId("sp", "my_view"), properties={}) -class TestRecordSourceSelectorViews: - def test_selector_with_view_id(self) -> None: - sel = RecordSourceSelector( - source=ViewId("sp", "my_view", "v1"), - properties=["temp", "humidity"], - ) - assert isinstance(sel.source, ViewId) - assert sel.dump() == { - "source": {"space": "sp", "externalId": "my_view", "version": "v1", "type": "view"}, - "properties": ["temp", "humidity"], - } - - def test_selector_with_tuple(self) -> None: - sel = RecordSourceSelector( - source=("sp", "my_view", "v1"), - properties=["*"], - ) - assert isinstance(sel.source, ViewId) - assert sel.dump() == { - "source": {"space": "sp", "externalId": "my_view", "version": "v1", "type": "view"}, - "properties": ["*"], - } - - def test_selector_load_view(self) -> None: - raw = { - "source": {"space": "sp", "externalId": "my_view", "version": "v1", "type": "view"}, - "properties": ["temp"], - } - loaded = RecordSourceSelector._load(raw) - assert isinstance(loaded.source, ViewId) - assert loaded.properties == ["temp"] - - -class TestRecordsAPIViewsOperations: - def test_ingest_with_view_sources( - self, - cognite_client: CogniteClient, - httpx_mock: HTTPXMock, - mock_ingest: None, - stream_id: str, - ) -> None: - write_record = RecordWrite( - space="sp", - external_id="rec-1", - sources=[ - RecordSource( - source=ViewId(space="sp", external_id="my_view", version="v1"), - properties={"temp": 22.5}, - ) - ], - ) - cognite_client.data_modeling.records.ingest(write_record, stream_id=stream_id) - requests = httpx_mock.get_requests() - assert len(requests) == 1 - body = jsgz_load(requests[0].content) - assert body == { - "items": [ - { - "space": "sp", - "externalId": "rec-1", - "sources": [ - { - "source": {"type": "view", "space": "sp", "externalId": "my_view", "version": "v1"}, - "properties": {"temp": 22.5}, - } - ], - } - ] - } - - def test_upsert_with_view_sources( - self, - cognite_client: CogniteClient, - httpx_mock: HTTPXMock, - mock_upsert: None, - stream_id: str, - ) -> None: - write_record = RecordWrite( - space="sp", - external_id="rec-1", - sources=[ - RecordSource( - source=("sp", "my_view", "v1"), - properties={"temp": 23.0}, - ) - ], - ) - cognite_client.data_modeling.records.upsert(write_record, stream_id=stream_id) - requests = httpx_mock.get_requests() - assert len(requests) == 1 - body = jsgz_load(requests[0].content) - assert body == { - "items": [ - { - "space": "sp", - "externalId": "rec-1", - "sources": [ - { - "source": {"type": "view", "space": "sp", "externalId": "my_view", "version": "v1"}, - "properties": {"temp": 23.0}, - } - ], - } - ] - } - - def test_filter_with_view_sources( - self, - cognite_client: CogniteClient, - httpx_mock: HTTPXMock, - mock_filter: None, - stream_id: str, - ) -> None: - cognite_client.data_modeling.records.filter( - stream_id=stream_id, - sources=[RecordSourceSelector(source=ViewId("sp", "my_view", "v1"), properties=["temp"])], - ) - requests = httpx_mock.get_requests() - assert len(requests) == 1 - body = jsgz_load(requests[0].content) - assert body["sources"] == [ +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"], - } - ] - - def test_sync_with_view_sources( - self, - cognite_client: CogniteClient, - httpx_mock: HTTPXMock, - sync_url_pattern: re.Pattern, - stream_id: str, - ) -> None: - httpx_mock.add_response( - method="POST", - url=sync_url_pattern, - json={ - "items": [], - "nextCursor": "cur-1", - "hasNext": False, + "properties": {"temp": 22.5}, }, - ) - feed = cognite_client.data_modeling.records.sync( - stream_id=stream_id, - initialize_cursor="1d-ago", - sources=[RecordSourceSelector(source=("sp", "my_view", "v1"), properties=["*"])], - ) - list(feed) - requests = httpx_mock.get_requests() - assert len(requests) == 1 - body = jsgz_load(requests[0].content) - assert body["sources"] == [ { - "source": {"type": "view", "space": "sp", "externalId": "my_view", "version": "v1"}, - "properties": ["*"], - } - ] - - def test_record_as_write_reconstructs_view_sources(self) -> None: - raw_record = { - "space": "sp", - "externalId": "rec-1", - "createdTime": 100, - "lastUpdatedTime": 200, - "properties": { - "sp": { - "my_view/v1": {"temp": 22.5}, - "my_container": {"pressure": 1.0}, - } + "source": {"type": "container", "space": "sp", "externalId": "my_container"}, + "properties": {"pressure": 1.0}, }, - } - record = Record._load(raw_record) - write_rec = record.as_write() - assert isinstance(write_rec, RecordWrite) - assert len(write_rec.sources) == 2 - - view_src = next(s for s in write_rec.sources if isinstance(s.source, RecordViewId)) - assert isinstance(view_src.source, RecordViewId) - assert view_src.source.space == "sp" - assert view_src.source.external_id == "my_view" - assert view_src.source.version == "v1" - assert view_src.properties == {"temp": 22.5} - - cnt_src = next(s for s in write_rec.sources if isinstance(s.source, RecordContainerId)) - assert isinstance(cnt_src.source, RecordContainerId) - assert cnt_src.source.space == "sp" - assert cnt_src.source.external_id == "my_container" - assert cnt_src.properties == {"pressure": 1.0} + ] + sources[0].properties["temp"] = 30.0 + assert properties["sp"]["my_view/v1"]["temp"] == 22.5 class TestRecordsAggregateWithViews: - def test_aggregates_with_view_property_references(self) -> None: - view = ViewId("my_space", "my_view", "v1") - rec_view = RecordViewId("my_space", "my_view", "v1") - - # Every accepted way of referencing a view property should dump identically: - properties: list[PropertyPath] = [ - (view, "temperature"), - (rec_view, "temperature"), - view.as_property_ref("temperature"), - PropertyId(view, "temperature"), - ] - for property_ in properties: - avg = Average(property=property_) - assert avg.dump() == {"avg": {"property": ["my_space", "my_view/v1", "temperature"]}} - - assert Sum((view, "score")).dump() == {"sum": {"property": ["my_space", "my_view/v1", "score"]}} - assert Min((view, "score")).dump() == {"min": {"property": ["my_space", "my_view/v1", "score"]}} - assert Max((view, "score")).dump() == {"max": {"property": ["my_space", "my_view/v1", "score"]}} - assert Count((view, "score")).dump() == {"count": {"property": ["my_space", "my_view/v1", "score"]}} - assert UniqueValues((view, "player")).dump() == { - "uniqueValues": {"property": ["my_space", "my_view/v1", "player"]} - } - assert NumberHistogram((view, "score"), interval=10.0).dump() == { - "numberHistogram": {"property": ["my_space", "my_view/v1", "score"], "interval": 10.0} - } - assert TimeHistogram((view, "ts"), calendar_interval="1d").dump() == { - "timeHistogram": {"property": ["my_space", "my_view/v1", "ts"], "calendarInterval": "1d"} - } - - def test_aggregate_api_call_with_view_aggregates_and_filters( - self, - cognite_client: CogniteClient, - httpx_mock: HTTPXMock, - stream_id: str, - records_base_url: str, - ) -> None: - view = ViewId("my_space", "my_view", "v1") - httpx_mock.add_response( - method="POST", - url=records_base_url + "/aggregate", - json={"aggregates": {"avg_temp": {"avg": 22.5}}}, - ) - res = cognite_client.data_modeling.records.aggregate( - stream_id=stream_id, - aggregates={"avg_temp": Average((view, "temperature"))}, - filter=filters.Equals((view, "status"), "active"), - ) - avg_res = res["avg_temp"] - assert isinstance(avg_res, MetricResult) - assert avg_res.value == 22.5 - requests = httpx_mock.get_requests() - assert len(requests) == 1 - body = jsgz_load(requests[0].content) - assert body == { - "aggregates": { - "avg_temp": { - "avg": { - "property": ["my_space", "my_view/v1", "temperature"], - } - } - }, - "filter": { - "equals": { - "property": ["my_space", "my_view/v1", "status"], - "value": "active", - } - }, - } + @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"]}} From 26697e0e0cd7e71704fac42b69a0ae7009149fc3 Mon Sep 17 00:00:00 2001 From: Everton Colling Date: Tue, 8 Sep 2026 10:44:55 +0200 Subject: [PATCH 13/13] Parameterize filter reference tests --- .../test_data_classes/test_filters.py | 75 ++++--------------- 1 file changed, 15 insertions(+), 60 deletions(-) diff --git a/tests/tests_unit/test_data_classes/test_filters.py b/tests/tests_unit/test_data_classes/test_filters.py index 9794172599..aff085b3f7 100644 --- a/tests/tests_unit/test_data_classes/test_filters.py +++ b/tests/tests_unit/test_data_classes/test_filters.py @@ -8,15 +8,11 @@ from cognite.client.data_classes.data_modeling.records import RecordContainerId, RecordViewId from cognite.client.data_classes.filters import ( And, - ContainsAll, - ContainsAny, Equals, - Exists, Filter, In, Or, - Prefix, - Range, + PropertyReference, ) from tests.utils import FakeCogniteResourceGenerator @@ -79,58 +75,17 @@ def test_filter_is_hashable_and_uses_identity() -> None: assert flt != flt2 -def test_filter_view_and_container_property_references() -> None: - view = ViewId("my_space", "my_view", "v1") - rec_view = RecordViewId("my_space", "my_view", "v1") - container = ContainerId("my_space", "my_container") - rec_container = RecordContainerId("my_space", "my_container") - - # View as_property_ref - flt = Equals(property=view.as_property_ref("temperature"), value=25.0) - assert list(flt.dump()["equals"]["property"]) == ["my_space", "my_view/v1", "temperature"] - assert flt.dump()["equals"]["value"] == 25.0 - - # RecordViewId as_property_ref - flt = Equals(property=rec_view.as_property_ref("temperature"), value=25.0) - assert list(flt.dump()["equals"]["property"]) == ["my_space", "my_view/v1", "temperature"] - assert flt.dump()["equals"]["value"] == 25.0 - - # (ViewId, str) tuple - flt = Equals(property=(view, "temperature"), value=25.0) - assert flt.dump() == {"equals": {"property": ["my_space", "my_view/v1", "temperature"], "value": 25.0}} - - # (RecordViewId, str) tuple - flt = Equals(property=(rec_view, "temperature"), value=25.0) - assert flt.dump() == {"equals": {"property": ["my_space", "my_view/v1", "temperature"], "value": 25.0}} - - # (ContainerId, str) tuple - flt = Equals(property=(container, "temperature"), value=25.0) - assert flt.dump() == {"equals": {"property": ["my_space", "my_container", "temperature"], "value": 25.0}} - - # (RecordContainerId, str) tuple - flt = Equals(property=(rec_container, "temperature"), value=25.0) - assert flt.dump() == {"equals": {"property": ["my_space", "my_container", "temperature"], "value": 25.0}} - - # PropertyId with ViewId - flt = Equals(property=PropertyId(view, "temperature"), value=25.0) - assert flt.dump() == {"equals": {"property": ["my_space", "my_view/v1", "temperature"], "value": 25.0}} - - # Other filters with (ViewId, str) - assert In(property=(view, "status"), values=["open", "closed"]).dump() == { - "in": {"property": ["my_space", "my_view/v1", "status"], "values": ["open", "closed"]} - } - assert Range(property=(view, "temperature"), gt=20.0).dump() == { - "range": {"property": ["my_space", "my_view/v1", "temperature"], "gt": 20.0} - } - assert Prefix(property=(view, "name"), value="sensor-").dump() == { - "prefix": {"property": ["my_space", "my_view/v1", "name"], "value": "sensor-"} - } - assert ContainsAny(property=(view, "tags"), values=["a", "b"]).dump() == { - "containsAny": {"property": ["my_space", "my_view/v1", "tags"], "values": ["a", "b"]} - } - assert ContainsAll(property=(view, "tags"), values=["a", "b"]).dump() == { - "containsAll": {"property": ["my_space", "my_view/v1", "tags"], "values": ["a", "b"]} - } - assert Exists(property=(view, "temperature")).dump() == { - "exists": {"property": ["my_space", "my_view/v1", "temperature"]} - } +@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}}