Skip to content

Commit 0472cd3

Browse files
committed
add metadata, description support
1 parent 51fc66a commit 0472cd3

5 files changed

Lines changed: 271 additions & 9 deletions

File tree

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
@@ -63,6 +63,9 @@ def compare_test_measurement_fields(simulated: TestMeasurement, actual: TestMeas
6363
assert simulated.boolean_value == actual.boolean_value
6464
assert simulated.passed == actual.passed
6565
assert simulated.timestamp == actual.timestamp
66+
assert simulated.description == actual.description
67+
assert simulated.metadata == actual.metadata
68+
assert simulated.channel_names == actual.channel_names
6669

6770

6871
def test_client_binding(sift_client):
@@ -280,6 +283,9 @@ def test_create_test_measurements(self, sift_client, tmp_path):
280283
unit="Celsius",
281284
passed=True,
282285
timestamp=step1.start_time,
286+
description="Expected nominal: 25.0C",
287+
metadata={"sensor": "thermocouple_a", "channel_index": 1},
288+
channel_names=["temperature_celsius"],
283289
)
284290

285291
# Create simulated measurement first
@@ -358,6 +364,9 @@ def test_update_test_measurements(self, sift_client, tmp_path):
358364
"passed": False,
359365
"string_expected_value": "1.10.4",
360366
"unit": "C",
367+
"description": "Updated note after recalibration",
368+
"metadata": {"part_number": "PN-002"},
369+
"channel_names": ["firmware_version_channel"],
361370
}
362371

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

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
@@ -271,3 +280,135 @@ def test_upload_attachment(self, mock_test_report, mock_test_step, mock_client):
271280
),
272281
]
273282
)
283+
284+
def test_measurement_description_truncates_with_warning(self):
285+
"""Description over the server limit is truncated and a UserWarning is raised."""
286+
over_limit = "x" * 2001
287+
with pytest.warns(UserWarning, match="exceeds 2000 characters"):
288+
create = TestMeasurementCreate(
289+
test_step_id="step_123",
290+
name="m",
291+
passed=True,
292+
timestamp=datetime.now(timezone.utc),
293+
numeric_value=1.0,
294+
description=over_limit,
295+
)
296+
assert create.description is not None
297+
assert len(create.description) == 2000
298+
299+
def test_measurement_description_at_limit_is_not_truncated(self):
300+
"""A description exactly at the limit should not warn or truncate."""
301+
at_limit = "x" * 2000
302+
with warnings.catch_warnings():
303+
warnings.simplefilter("error") # any warning fails the test
304+
create = TestMeasurementCreate(
305+
test_step_id="step_123",
306+
name="m",
307+
passed=True,
308+
timestamp=datetime.now(timezone.utc),
309+
numeric_value=1.0,
310+
description=at_limit,
311+
)
312+
assert create.description == at_limit
313+
314+
def test_measurement_channel_names_accepts_strings(self):
315+
"""channel_names accepts a homogeneous list of channel name strings."""
316+
create = TestMeasurementCreate(
317+
test_step_id="step_123",
318+
name="m",
319+
passed=True,
320+
timestamp=datetime.now(timezone.utc),
321+
numeric_value=1.0,
322+
channel_names=["temperature_celsius", "pressure_psi"],
323+
)
324+
assert create.channel_names == ["temperature_celsius", "pressure_psi"]
325+
proto = create.to_proto()
326+
assert list(proto.channel_names) == ["temperature_celsius", "pressure_psi"]
327+
328+
def test_measurement_channel_names_accepts_channels(self):
329+
"""channel_names accepts a homogeneous list of Channel instances; names are extracted at serialization."""
330+
now = datetime.now(timezone.utc)
331+
332+
def _channel(name: str) -> Channel:
333+
return Channel(
334+
proto=MagicMock(),
335+
id_=f"channel_{name}",
336+
name=name,
337+
data_type=ChannelDataType.DOUBLE,
338+
description="",
339+
unit="",
340+
bit_field_elements=[],
341+
enum_types={},
342+
asset_id="asset_1",
343+
created_date=now,
344+
modified_date=now,
345+
created_by_user_id="user1",
346+
modified_by_user_id="user1",
347+
)
348+
349+
create = TestMeasurementCreate(
350+
test_step_id="step_123",
351+
name="m",
352+
passed=True,
353+
timestamp=now,
354+
numeric_value=1.0,
355+
channel_names=[_channel("temperature_celsius"), _channel("pressure_psi")],
356+
)
357+
proto = create.to_proto()
358+
assert list(proto.channel_names) == ["temperature_celsius", "pressure_psi"]
359+
360+
def test_measurement_create_to_proto_writes_new_fields(self):
361+
"""to_proto carries description, metadata, and channel_names onto the proto."""
362+
create = TestMeasurementCreate(
363+
test_step_id="step_123",
364+
name="m",
365+
passed=True,
366+
timestamp=datetime.now(timezone.utc),
367+
numeric_value=1.0,
368+
description="note",
369+
metadata={"pn": "PN-001", "count": 3, "flag": True},
370+
channel_names=["chan_a", "chan_b"],
371+
)
372+
proto = create.to_proto()
373+
assert proto.description == "note"
374+
assert list(proto.channel_names) == ["chan_a", "chan_b"]
375+
proto_keys = {m.key.name for m in proto.metadata}
376+
assert proto_keys == {"pn", "count", "flag"}
377+
378+
def test_measurement_from_proto_round_trips_new_fields(self):
379+
"""A proto with the new fields populated round-trips into TestMeasurement."""
380+
ts = datetime.now(timezone.utc)
381+
source = TestMeasurementCreate(
382+
test_step_id="step_123",
383+
name="m",
384+
passed=True,
385+
timestamp=ts,
386+
numeric_value=1.0,
387+
description="note",
388+
metadata={"pn": "PN-001", "count": 3},
389+
channel_names=["chan_a"],
390+
).to_proto()
391+
source.measurement_id = "measurement_456"
392+
source.test_report_id = "report_789"
393+
394+
measurement = TestMeasurement._from_proto(source)
395+
396+
assert measurement.description == "note"
397+
assert measurement.metadata == {"pn": "PN-001", "count": 3}
398+
assert measurement.channel_names == ["chan_a"]
399+
400+
def test_measurement_from_proto_handles_absent_new_fields(self):
401+
"""Proto with unset description/metadata/channel_names yields None on the model."""
402+
proto = TestMeasurementProto(
403+
measurement_id="measurement_abc",
404+
measurement_type=TestMeasurementType.DOUBLE.value,
405+
name="m",
406+
test_step_id="step_123",
407+
test_report_id="report_789",
408+
passed=True,
409+
)
410+
proto.timestamp.FromDatetime(datetime.now(timezone.utc))
411+
measurement = TestMeasurement._from_proto(proto)
412+
assert measurement.description is None
413+
assert measurement.metadata is None
414+
assert measurement.channel_names is None

python/lib/sift_client/sift_types/test_report.py

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from __future__ import annotations
22

3+
import warnings
34
from datetime import datetime, timezone
45
from enum import Enum
56
from typing import TYPE_CHECKING, ClassVar
67

8+
from pydantic import field_validator
79
from sift.test_reports.v1.test_reports_pb2 import (
810
CreateTestReportRequest as CreateTestReportRequestProto,
911
)
@@ -34,8 +36,11 @@
3436
ModelUpdate,
3537
)
3638
from sift_client.sift_types._mixins.file_attachments import FileAttachmentsMixin
39+
from sift_client.sift_types.channel import Channel
3740
from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict
3841

42+
_MEASUREMENT_DESCRIPTION_MAX_LEN = 2000
43+
3944
if TYPE_CHECKING:
4045
from pathlib import Path
4146

@@ -254,6 +259,19 @@ class TestMeasurementBase(ModelCreateUpdateBase):
254259
numeric_bounds: NumericBounds | None = None
255260
string_expected_value: str | None = None
256261
measurement_type: TestMeasurementType | None = None
262+
description: str | None = None
263+
metadata: dict[str, str | float | bool] | None = None
264+
channel_names: list[str] | list[Channel] | None = None
265+
266+
_to_proto_helpers: ClassVar[dict[str, MappingHelper]] = {
267+
"string_expected_value": MappingHelper(
268+
proto_attr_path="string_bounds.expected_value", update_field="string_bounds"
269+
),
270+
"unit": MappingHelper(proto_attr_path="unit.abbreviated_name", update_field="unit"),
271+
"metadata": MappingHelper(
272+
proto_attr_path="metadata", update_field="metadata", converter=metadata_dict_to_proto
273+
),
274+
}
257275

258276
def _get_proto_class(self) -> type[TestMeasurementProto]:
259277
return TestMeasurementProto
@@ -268,6 +286,18 @@ def _resolve_measurement_type(self) -> TestMeasurementType:
268286
else:
269287
raise ValueError("No measurement value provided")
270288

289+
@field_validator("description", mode="after")
290+
@classmethod
291+
def _truncate_description(cls, value: str | None) -> str | None:
292+
if value is None or len(value) <= _MEASUREMENT_DESCRIPTION_MAX_LEN:
293+
return value
294+
warnings.warn(
295+
f"TestMeasurement description exceeds {_MEASUREMENT_DESCRIPTION_MAX_LEN} "
296+
f"characters ({len(value)}); truncating to fit the server limit.",
297+
stacklevel=2,
298+
)
299+
return value[:_MEASUREMENT_DESCRIPTION_MAX_LEN]
300+
271301

272302
class TestMeasurementUpdate(TestMeasurementBase, ModelUpdate[TestMeasurementProto]):
273303
"""Update model for TestMeasurement."""
@@ -276,13 +306,6 @@ class TestMeasurementUpdate(TestMeasurementBase, ModelUpdate[TestMeasurementProt
276306
passed: bool | None = None
277307
timestamp: datetime | None = None
278308

279-
_to_proto_helpers: ClassVar[dict[str, MappingHelper]] = {
280-
"string_expected_value": MappingHelper(
281-
proto_attr_path="string_bounds.expected_value", update_field="string_bounds"
282-
),
283-
"unit": MappingHelper(proto_attr_path="unit.abbreviated_name", update_field="unit"),
284-
}
285-
286309
def _add_resource_id_to_proto(self, proto_msg: TestMeasurementProto):
287310
if self._resource_id is None:
288311
raise ValueError("Resource ID must be set before adding to proto")
@@ -327,6 +350,17 @@ def to_proto(self) -> TestMeasurementProto:
327350
StringBoundsProto(expected_value=self.string_expected_value)
328351
)
329352

353+
if self.description:
354+
proto.description = self.description
355+
356+
if self.metadata:
357+
proto.metadata.extend(metadata_dict_to_proto(self.metadata))
358+
359+
if self.channel_names:
360+
proto.channel_names.extend(
361+
c.name if isinstance(c, Channel) else c for c in self.channel_names
362+
)
363+
330364
return proto
331365

332366

@@ -345,6 +379,9 @@ class TestMeasurement(BaseType[TestMeasurementProto, "TestMeasurement"]):
345379
string_expected_value: str | None = None
346380
passed: bool
347381
timestamp: datetime
382+
description: str | None = None
383+
metadata: dict[str, str | float | bool] | None = None
384+
channel_names: list[str] | None = None
348385

349386
@classmethod
350387
def _from_proto(
@@ -380,6 +417,9 @@ def _from_proto(
380417
else None,
381418
passed=proto.passed,
382419
timestamp=proto.timestamp.ToDatetime(tzinfo=timezone.utc),
420+
description=proto.description if proto.description else None,
421+
metadata=metadata_proto_to_dict(proto.metadata) if proto.metadata else None, # type: ignore[arg-type]
422+
channel_names=list(proto.channel_names) if proto.channel_names else None,
383423
_client=sift_client,
384424
)
385425

@@ -410,6 +450,15 @@ def _to_proto(self) -> TestMeasurementProto:
410450
StringBoundsProto(expected_value=self.string_expected_value)
411451
)
412452

453+
if self.description:
454+
proto.description = self.description
455+
456+
if self.metadata:
457+
proto.metadata.extend(metadata_dict_to_proto(self.metadata))
458+
459+
if self.channel_names:
460+
proto.channel_names.extend(self.channel_names)
461+
413462
return proto
414463

415464
def update(

0 commit comments

Comments
 (0)