Skip to content

Commit 6e3e17b

Browse files
Python(feat): add metadata, description, and associated channel support to measurements (#561)
1 parent 8555e0e commit 6e3e17b

8 files changed

Lines changed: 304 additions & 22 deletions

File tree

python/lib/sift_client/_internal/low_level_wrappers/calculated_channels.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -220,16 +220,16 @@ async def list_calculated_channel_versions(
220220
Raises:
221221
ValueError: If neither calculated_channel_id nor client_key is provided.
222222
"""
223-
request_kwargs = {}
223+
request_kwargs: dict[str, Any] = {}
224224
if calculated_channel_id:
225-
request_kwargs = {"calculated_channel_id": calculated_channel_id}
225+
request_kwargs["calculated_channel_id"] = calculated_channel_id
226226
elif client_key:
227-
request_kwargs = {"client_key": client_key}
227+
request_kwargs["client_key"] = client_key
228228
else:
229229
raise ValueError("Either calculated_channel_id or client_key must be provided")
230230

231231
if page_size is not None:
232-
request_kwargs["page_size"] = str(page_size)
232+
request_kwargs["page_size"] = page_size
233233
if page_token is not None:
234234
request_kwargs["page_token"] = page_token
235235
if query_filter is not None:
@@ -239,7 +239,7 @@ async def list_calculated_channel_versions(
239239
if organization_id is not None:
240240
request_kwargs["organization_id"] = organization_id
241241

242-
request = ListCalculatedChannelVersionsRequest(**request_kwargs) # type: ignore # mypy thinks we should pass an int
242+
request = ListCalculatedChannelVersionsRequest(**request_kwargs)
243243
response = await self._grpc_client.get_stub(
244244
CalculatedChannelServiceStub
245245
).ListCalculatedChannelVersions(request)

python/lib/sift_client/_internal/low_level_wrappers/test_results.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,14 @@ def simulate_update_test_measurement_response(
320320
updates["string_expected_value"] = proto.string_bounds.expected_value
321321
else:
322322
updates["string_expected_value"] = None
323+
if "description" in update_mask_paths:
324+
updates["description"] = proto.description if proto.description else None
325+
if "metadata" in update_mask_paths:
326+
from sift_client.util.metadata import metadata_proto_to_dict
327+
328+
updates["metadata"] = metadata_proto_to_dict(proto.metadata) if proto.metadata else None # type: ignore[arg-type]
329+
if "channel_names" in update_mask_paths:
330+
updates["channel_names"] = list(proto.channel_names) if proto.channel_names else None
323331

324332
return existing.model_copy(update=updates)
325333

@@ -1259,6 +1267,9 @@ def _measurement_create_from_simulated(
12591267
unit=simulated.unit,
12601268
numeric_bounds=simulated.numeric_bounds,
12611269
string_expected_value=simulated.string_expected_value,
1270+
description=simulated.description,
1271+
metadata=simulated.metadata,
1272+
channel_names=simulated.channel_names,
12621273
)
12631274

12641275

python/lib/sift_client/_tests/resources/test_test_results.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ def compare_test_measurement_fields(simulated: TestMeasurement, actual: TestMeas
6464
assert simulated.boolean_value == actual.boolean_value
6565
assert simulated.passed == actual.passed
6666
assert simulated.timestamp == actual.timestamp
67+
assert simulated.description == actual.description
68+
assert simulated.metadata == actual.metadata
69+
assert simulated.channel_names == actual.channel_names
6770

6871

6972
def test_client_binding(sift_client):
@@ -281,6 +284,9 @@ def test_create_test_measurements(self, sift_client, tmp_path):
281284
unit="Celsius",
282285
passed=True,
283286
timestamp=step1.start_time,
287+
description="Expected nominal: 25.0C",
288+
metadata={"sensor": "thermocouple_a", "channel_index": 1},
289+
channel_names=["temperature_celsius"],
284290
)
285291

286292
# Create simulated measurement first
@@ -359,6 +365,9 @@ def test_update_test_measurements(self, sift_client, tmp_path):
359365
"passed": False,
360366
"string_expected_value": "1.10.4",
361367
"unit": "C",
368+
"description": "Updated note after recalibration",
369+
"metadata": {"part_number": "PN-002"},
370+
"channel_names": ["firmware_version_channel"],
362371
}
363372

364373
# Test update with log_file first
@@ -384,6 +393,9 @@ def test_update_test_measurements(self, sift_client, tmp_path):
384393
assert measurement2.passed == False
385394
assert measurement2.string_expected_value == "1.10.4"
386395
assert measurement2.unit == "C"
396+
assert measurement2.description == "Updated note after recalibration"
397+
assert measurement2.metadata == {"part_number": "PN-002"}
398+
assert measurement2.channel_names == ["firmware_version_channel"]
387399
# Update the measurement using class function.
388400
measurement4 = measurement4.update(
389401
{

python/lib/sift_client/_tests/sift_types/test_results.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,21 @@
33
from __future__ import annotations
44

55
import tempfile
6+
import warnings
67
from datetime import datetime, timedelta, timezone
78
from unittest.mock import MagicMock, call
89

910
import pytest
11+
from sift.test_reports.v1.test_reports_pb2 import (
12+
TestMeasurement as TestMeasurementProto,
13+
)
1014

15+
from sift_client.sift_types.channel import Channel, ChannelDataType
1116
from sift_client.sift_types.test_report import (
1217
ErrorInfo,
1318
NumericBounds,
1419
TestMeasurement,
20+
TestMeasurementCreate,
1521
TestMeasurementType,
1622
TestReport,
1723
TestStatus,
@@ -84,6 +90,9 @@ def mock_test_measurement(mock_client):
8490
unit="Celsius",
8591
passed=True,
8692
timestamp=simulated_time,
93+
description="Expected nominal: 25.0C",
94+
metadata={"part_number": "PN-001", "serial_number": "SN-42"},
95+
channel_names=["temperature_celsius"],
8796
)
8897
test_measurement._apply_client_to_instance(mock_client)
8998
return test_measurement
@@ -298,3 +307,135 @@ def test_upload_attachment(self, mock_test_report, mock_test_step, mock_client):
298307
),
299308
]
300309
)
310+
311+
def test_measurement_description_truncates_with_warning(self):
312+
"""Description over the server limit is truncated and a UserWarning is raised."""
313+
over_limit = "x" * 2001
314+
with pytest.warns(UserWarning, match="exceeds 2000 characters"):
315+
create = TestMeasurementCreate(
316+
test_step_id="step_123",
317+
name="m",
318+
passed=True,
319+
timestamp=datetime.now(timezone.utc),
320+
numeric_value=1.0,
321+
description=over_limit,
322+
)
323+
assert create.description is not None
324+
assert len(create.description) == 2000
325+
326+
def test_measurement_description_at_limit_is_not_truncated(self):
327+
"""A description exactly at the limit should not warn or truncate."""
328+
at_limit = "x" * 2000
329+
with warnings.catch_warnings():
330+
warnings.simplefilter("error") # any warning fails the test
331+
create = TestMeasurementCreate(
332+
test_step_id="step_123",
333+
name="m",
334+
passed=True,
335+
timestamp=datetime.now(timezone.utc),
336+
numeric_value=1.0,
337+
description=at_limit,
338+
)
339+
assert create.description == at_limit
340+
341+
def test_measurement_channel_names_accepts_strings(self):
342+
"""channel_names accepts a homogeneous list of channel name strings."""
343+
create = TestMeasurementCreate(
344+
test_step_id="step_123",
345+
name="m",
346+
passed=True,
347+
timestamp=datetime.now(timezone.utc),
348+
numeric_value=1.0,
349+
channel_names=["temperature_celsius", "pressure_psi"],
350+
)
351+
assert create.channel_names == ["temperature_celsius", "pressure_psi"]
352+
proto = create.to_proto()
353+
assert list(proto.channel_names) == ["temperature_celsius", "pressure_psi"]
354+
355+
def test_measurement_channel_names_accepts_channels(self):
356+
"""channel_names accepts a homogeneous list of Channel instances; names are extracted at serialization."""
357+
now = datetime.now(timezone.utc)
358+
359+
def _channel(name: str) -> Channel:
360+
return Channel(
361+
proto=MagicMock(),
362+
id_=f"channel_{name}",
363+
name=name,
364+
data_type=ChannelDataType.DOUBLE,
365+
description="",
366+
unit="",
367+
bit_field_elements=[],
368+
enum_types={},
369+
asset_id="asset_1",
370+
created_date=now,
371+
modified_date=now,
372+
created_by_user_id="user1",
373+
modified_by_user_id="user1",
374+
)
375+
376+
create = TestMeasurementCreate(
377+
test_step_id="step_123",
378+
name="m",
379+
passed=True,
380+
timestamp=now,
381+
numeric_value=1.0,
382+
channel_names=[_channel("temperature_celsius"), _channel("pressure_psi")],
383+
)
384+
proto = create.to_proto()
385+
assert list(proto.channel_names) == ["temperature_celsius", "pressure_psi"]
386+
387+
def test_measurement_create_to_proto_writes_new_fields(self):
388+
"""to_proto carries description, metadata, and channel_names onto the proto."""
389+
create = TestMeasurementCreate(
390+
test_step_id="step_123",
391+
name="m",
392+
passed=True,
393+
timestamp=datetime.now(timezone.utc),
394+
numeric_value=1.0,
395+
description="note",
396+
metadata={"pn": "PN-001", "count": 3, "flag": True},
397+
channel_names=["chan_a", "chan_b"],
398+
)
399+
proto = create.to_proto()
400+
assert proto.description == "note"
401+
assert list(proto.channel_names) == ["chan_a", "chan_b"]
402+
proto_keys = {m.key.name for m in proto.metadata}
403+
assert proto_keys == {"pn", "count", "flag"}
404+
405+
def test_measurement_from_proto_round_trips_new_fields(self):
406+
"""A proto with the new fields populated round-trips into TestMeasurement."""
407+
ts = datetime.now(timezone.utc)
408+
source = TestMeasurementCreate(
409+
test_step_id="step_123",
410+
name="m",
411+
passed=True,
412+
timestamp=ts,
413+
numeric_value=1.0,
414+
description="note",
415+
metadata={"pn": "PN-001", "count": 3},
416+
channel_names=["chan_a"],
417+
).to_proto()
418+
source.measurement_id = "measurement_456"
419+
source.test_report_id = "report_789"
420+
421+
measurement = TestMeasurement._from_proto(source)
422+
423+
assert measurement.description == "note"
424+
assert measurement.metadata == {"pn": "PN-001", "count": 3}
425+
assert measurement.channel_names == ["chan_a"]
426+
427+
def test_measurement_from_proto_handles_absent_new_fields(self):
428+
"""Proto with unset description/metadata/channel_names yields None on the model."""
429+
proto = TestMeasurementProto(
430+
measurement_id="measurement_abc",
431+
measurement_type=TestMeasurementType.DOUBLE.value,
432+
name="m",
433+
test_step_id="step_123",
434+
test_report_id="report_789",
435+
passed=True,
436+
)
437+
proto.timestamp.FromDatetime(datetime.now(timezone.utc))
438+
measurement = TestMeasurement._from_proto(proto)
439+
assert measurement.description is None
440+
assert measurement.metadata is None
441+
assert measurement.channel_names is None

python/lib/sift_client/_tests/util/test_test_results_utils.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,25 @@ def test_new_step(self, report_context):
137137
assert test_step.status == TestStatus.PASSED
138138

139139
def test_measurement_update(self, report_context):
140+
expected_description = "Round-trip check for the new fields on TestMeasurement."
141+
expected_metadata = {
142+
"ctx_test_serial": "SN-CTX-001",
143+
"ctx_test_value": 42.5,
144+
"ctx_test_pass": True,
145+
}
146+
expected_channel_names = ["temperature_celsius"]
147+
140148
test_step = None
141149
with report_context.new_step("Test Measure", "Test Measure Description") as new_step:
142150
test_step = new_step.current_step
143-
new_step.measure(name="Test Measurement", value=10, bounds={"min": 0, "max": 10})
151+
new_step.measure(
152+
name="Test Measurement",
153+
value=10,
154+
bounds={"min": 0, "max": 10},
155+
description=expected_description,
156+
metadata=expected_metadata,
157+
channel_names=expected_channel_names,
158+
)
144159
new_step.measure(name="Test Measurement 2", value="string value", bounds="string value")
145160
new_step.measure(name="Test Measurement 3", value=True, bounds="true")
146161

@@ -149,6 +164,9 @@ def test_measurement_update(self, report_context):
149164
assert measurements[0].name == "Test Measurement"
150165
assert measurements[0].numeric_value == 10
151166
assert measurements[0].measurement_type == TestMeasurementType.DOUBLE
167+
assert measurements[0].description == expected_description
168+
assert measurements[0].metadata == expected_metadata
169+
assert measurements[0].channel_names == expected_channel_names
152170
assert measurements[1].name == "Test Measurement 2"
153171
assert measurements[1].string_value == "string value"
154172
assert measurements[1].measurement_type == TestMeasurementType.STRING

0 commit comments

Comments
 (0)