Skip to content

Commit 7ce839c

Browse files
fix: handle missing id in TimeSeriesID for broken subscription references (#2426)
1 parent 7c6f363 commit 7ce839c

File tree

2 files changed

+91
-10
lines changed

2 files changed

+91
-10
lines changed

cognite/client/data_classes/datapoints_subscriptions.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -263,34 +263,46 @@ class TimeSeriesID(CogniteResource):
263263
A TimeSeries Identifier to uniquely identify a time series.
264264
265265
Args:
266-
id (int): A server-generated ID for the object.
266+
id (int | None): A server-generated ID for the object. May be None if the time series
267+
reference is broken (e.g., the time series was deleted or its external_id was changed).
267268
external_id (ExternalId | None): The external ID provided by the client. Must be unique for the resource type.
268269
instance_id (NodeId | None): The ID of an instance in Cognite Data Models.
269270
"""
270271

271-
def __init__(self, id: int, external_id: ExternalId | None = None, instance_id: NodeId | None = None) -> None:
272+
def __init__(
273+
self, id: int | None = None, external_id: ExternalId | None = None, instance_id: NodeId | None = None
274+
) -> None:
272275
self.id = id
273276
self.external_id = external_id
274277
self.instance_id = instance_id
275278

279+
@property
280+
def is_resolved(self) -> bool:
281+
"""Returns True if this reference points to an existing time series (i.e., has an id)."""
282+
return self.id is not None
283+
276284
def __repr__(self) -> str:
277-
identifier = f"id={self.id}"
285+
parts = []
286+
if self.id is not None:
287+
parts.append(f"id={self.id}")
278288
if self.external_id is not None:
279-
identifier += f", external_id={self.external_id}"
280-
elif self.instance_id is not None:
281-
identifier += f", instance_id={self.instance_id!r}"
282-
return f"TimeSeriesID({identifier})"
289+
parts.append(f"external_id={self.external_id}")
290+
if self.instance_id is not None:
291+
parts.append(f"instance_id={self.instance_id!r}")
292+
return f"TimeSeriesID({', '.join(parts)})"
283293

284294
@classmethod
285295
def _load(cls, resource: dict, cognite_client: CogniteClient | None = None) -> TimeSeriesID:
286296
return cls(
287-
id=resource["id"],
297+
id=resource.get("id"),
288298
external_id=resource.get("externalId"),
289299
instance_id=NodeId.load(resource["instanceId"]) if "instanceId" in resource else None,
290300
)
291301

292302
def dump(self, camel_case: bool = True) -> dict[str, Any]:
293-
resource: dict[str, Any] = {"id": self.id}
303+
resource: dict[str, Any] = {}
304+
if self.id is not None:
305+
resource["id"] = self.id
294306
if self.external_id is not None:
295307
resource["externalId" if camel_case else "external_id"] = self.external_id
296308
if self.instance_id is not None:

tests/tests_unit/test_data_classes/test_datapoint_subscriptions.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import pytest
22

33
from cognite.client.data_classes import filters
4-
from cognite.client.data_classes.datapoints_subscriptions import DatapointSubscription, DataPointSubscriptionWrite
4+
from cognite.client.data_classes.datapoints_subscriptions import (
5+
DatapointSubscription,
6+
DataPointSubscriptionWrite,
7+
TimeSeriesID,
8+
TimeSeriesIDList,
9+
)
510

611

712
class TestDataPointSubscription:
@@ -27,3 +32,67 @@ def test_handles_null_timeseries_count(self) -> None:
2732
}
2833
)
2934
assert sub.time_series_count is None
35+
36+
37+
class TestTimeSeriesID:
38+
def test_load_with_all_fields(self) -> None:
39+
ts_id = TimeSeriesID._load({"id": 123, "externalId": "my_ts"})
40+
assert ts_id.id == 123
41+
assert ts_id.external_id == "my_ts"
42+
assert ts_id.instance_id is None
43+
assert ts_id.is_resolved is True
44+
45+
def test_load_with_missing_id_broken_reference(self) -> None:
46+
"""Test that TimeSeriesID can be loaded when 'id' is missing (broken reference scenario).
47+
48+
This happens when a time series's external_id is changed or the time series is deleted,
49+
but the subscription still references the old external_id.
50+
"""
51+
ts_id = TimeSeriesID._load({"externalId": "deleted_or_renamed_ts"})
52+
assert ts_id.id is None
53+
assert ts_id.external_id == "deleted_or_renamed_ts"
54+
assert ts_id.instance_id is None
55+
assert ts_id.is_resolved is False
56+
57+
def test_load_with_instance_id_only(self) -> None:
58+
"""Test loading a TimeSeriesID with only an instance_id (broken reference)."""
59+
ts_id = TimeSeriesID._load({"instanceId": {"space": "my_space", "externalId": "my_node"}})
60+
assert ts_id.id is None
61+
assert ts_id.external_id is None
62+
assert ts_id.instance_id is not None
63+
assert ts_id.instance_id.space == "my_space"
64+
assert ts_id.instance_id.external_id == "my_node"
65+
assert ts_id.is_resolved is False
66+
67+
def test_repr_with_id(self) -> None:
68+
ts_id = TimeSeriesID(id=123, external_id="my_ts")
69+
assert repr(ts_id) == "TimeSeriesID(id=123, external_id=my_ts)"
70+
71+
def test_repr_without_id(self) -> None:
72+
ts_id = TimeSeriesID(external_id="broken_ref")
73+
assert repr(ts_id) == "TimeSeriesID(external_id=broken_ref)"
74+
75+
def test_dump_with_id(self) -> None:
76+
ts_id = TimeSeriesID(id=123, external_id="my_ts")
77+
dumped = ts_id.dump()
78+
assert dumped == {"id": 123, "externalId": "my_ts"}
79+
80+
def test_dump_without_id(self) -> None:
81+
"""Test that dump excludes 'id' when it's None."""
82+
ts_id = TimeSeriesID(external_id="broken_ref")
83+
dumped = ts_id.dump()
84+
assert dumped == {"externalId": "broken_ref"}
85+
assert "id" not in dumped
86+
87+
def test_time_series_id_list_with_broken_references(self) -> None:
88+
"""Test that TimeSeriesIDList can handle a mix of resolved and broken references."""
89+
items = [
90+
{"id": 123, "externalId": "ts_1"},
91+
{"externalId": "broken_ref"}, # No id - broken reference
92+
{"id": 456, "externalId": "ts_2"},
93+
]
94+
ts_list = TimeSeriesIDList._load(items)
95+
assert len(ts_list) == 3
96+
assert ts_list[0].is_resolved is True
97+
assert ts_list[1].is_resolved is False
98+
assert ts_list[2].is_resolved is True

0 commit comments

Comments
 (0)