Skip to content

Commit 52ed704

Browse files
committed
add metadata, description support
1 parent 8555e0e commit 52ed704

5 files changed

Lines changed: 272 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
@@ -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/sift_types/test_report.py

Lines changed: 57 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

@@ -258,6 +263,19 @@ class TestMeasurementBase(ModelCreateUpdateBase):
258263
numeric_bounds: NumericBounds | None = None
259264
string_expected_value: str | None = None
260265
measurement_type: TestMeasurementType | None = None
266+
description: str | None = None
267+
metadata: dict[str, str | float | bool] | None = None
268+
channel_names: list[str] | list[Channel] | None = None
269+
270+
_to_proto_helpers: ClassVar[dict[str, MappingHelper]] = {
271+
"string_expected_value": MappingHelper(
272+
proto_attr_path="string_bounds.expected_value", update_field="string_bounds"
273+
),
274+
"unit": MappingHelper(proto_attr_path="unit.abbreviated_name", update_field="unit"),
275+
"metadata": MappingHelper(
276+
proto_attr_path="metadata", update_field="metadata", converter=metadata_dict_to_proto
277+
),
278+
}
261279

262280
def _get_proto_class(self) -> type[TestMeasurementProto]:
263281
return TestMeasurementProto
@@ -272,6 +290,18 @@ def _resolve_measurement_type(self) -> TestMeasurementType:
272290
else:
273291
raise ValueError("No measurement value provided")
274292

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

276306
class TestMeasurementUpdate(TestMeasurementBase, ModelUpdate[TestMeasurementProto]):
277307
"""Update model for TestMeasurement."""
@@ -280,13 +310,6 @@ class TestMeasurementUpdate(TestMeasurementBase, ModelUpdate[TestMeasurementProt
280310
passed: bool | None = None
281311
timestamp: datetime | None = None
282312

283-
_to_proto_helpers: ClassVar[dict[str, MappingHelper]] = {
284-
"string_expected_value": MappingHelper(
285-
proto_attr_path="string_bounds.expected_value", update_field="string_bounds"
286-
),
287-
"unit": MappingHelper(proto_attr_path="unit.abbreviated_name", update_field="unit"),
288-
}
289-
290313
def _add_resource_id_to_proto(self, proto_msg: TestMeasurementProto):
291314
if self._resource_id is None:
292315
raise ValueError("Resource ID must be set before adding to proto")
@@ -331,6 +354,17 @@ def to_proto(self) -> TestMeasurementProto:
331354
StringBoundsProto(expected_value=self.string_expected_value)
332355
)
333356

357+
if self.description:
358+
proto.description = self.description
359+
360+
if self.metadata:
361+
proto.metadata.extend(metadata_dict_to_proto(self.metadata))
362+
363+
if self.channel_names:
364+
proto.channel_names.extend(
365+
c.name if isinstance(c, Channel) else c for c in self.channel_names
366+
)
367+
334368
return proto
335369

336370

@@ -349,6 +383,10 @@ class TestMeasurement(BaseType[TestMeasurementProto, "TestMeasurement"]):
349383
string_expected_value: str | None = None
350384
passed: bool
351385
timestamp: datetime
386+
description: str | None = None
387+
metadata: dict[str, str | float | bool] | None = None
388+
channel_names: list[str] | None = None
389+
352390
# Set by the resource layer when this instance was produced from a logging-mode call
353391
_log_file: str | Path | None = None
354392

@@ -386,6 +424,9 @@ def _from_proto(
386424
else None,
387425
passed=proto.passed,
388426
timestamp=proto.timestamp.ToDatetime(tzinfo=timezone.utc),
427+
description=proto.description if proto.description else None,
428+
metadata=metadata_proto_to_dict(proto.metadata) if proto.metadata else None, # type: ignore[arg-type]
429+
channel_names=list(proto.channel_names) if proto.channel_names else None,
389430
_client=sift_client,
390431
)
391432

@@ -416,6 +457,15 @@ def _to_proto(self) -> TestMeasurementProto:
416457
StringBoundsProto(expected_value=self.string_expected_value)
417458
)
418459

460+
if self.description:
461+
proto.description = self.description
462+
463+
if self.metadata:
464+
proto.metadata.extend(metadata_dict_to_proto(self.metadata))
465+
466+
if self.channel_names:
467+
proto.channel_names.extend(self.channel_names)
468+
419469
return proto
420470

421471
def update(

0 commit comments

Comments
 (0)