Skip to content

Commit 0134f5b

Browse files
Python(feat): add step metadata support (#563)
1 parent b0ba21f commit 0134f5b

6 files changed

Lines changed: 118 additions & 7 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ def simulate_update_test_step_response(
203203
from datetime import timezone
204204

205205
from sift_client.sift_types.test_report import ErrorInfo, TestStatus
206+
from sift_client.util.metadata import metadata_proto_to_dict
206207

207208
update_mask_paths = set(request.update_mask.paths)
208209
proto = request.test_step
@@ -226,6 +227,8 @@ def simulate_update_test_step_response(
226227
)
227228
else:
228229
updates["error_info"] = None
230+
if "metadata" in update_mask_paths:
231+
updates["metadata"] = metadata_proto_to_dict(proto.metadata) if proto.metadata else None # type: ignore[arg-type]
229232

230233
return existing.model_copy(update=updates)
231234

@@ -1247,6 +1250,7 @@ def _step_create_from_simulated(
12471250
parent_step_id=real_parent_step_id,
12481251
description=simulated.description,
12491252
error_info=simulated.error_info,
1253+
metadata=simulated.metadata,
12501254
)
12511255

12521256
@staticmethod

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def compare_test_step_fields(simulated: TestStep, actual: TestStep) -> None:
5353
assert simulated.status == actual.status
5454
assert simulated.start_time == actual.start_time
5555
assert simulated.end_time == actual.end_time
56+
assert simulated.metadata == actual.metadata
5657

5758

5859
def compare_test_measurement_fields(simulated: TestMeasurement, actual: TestMeasurement) -> None:
@@ -135,6 +136,7 @@ def test_create_test_steps(self, sift_client, tmp_path):
135136
status=TestStatus.PASSED,
136137
start_time=simulated_time,
137138
end_time=simulated_time + timedelta(seconds=10),
139+
metadata={"phase": "init", "iteration": 1},
138140
)
139141

140142
# Create simulated step first
@@ -256,10 +258,14 @@ def test_update_test_steps(self, sift_client, tmp_path):
256258

257259
# Update the step using class function.
258260
step3_1 = step3_1.update(
259-
{"description": "Error demo w/ updated description"},
261+
{
262+
"description": "Error demo w/ updated description",
263+
"metadata": {"phase": "validation", "retry": 2},
264+
},
260265
)
261266
assert step3.status == TestStatus.PASSED
262267
assert step3_1.description == "Error demo w/ updated description"
268+
assert step3_1.metadata == {"phase": "validation", "retry": 2}
263269

264270
def test_create_test_measurements(self, sift_client, tmp_path):
265271
step1 = self.test_steps.get("step1")

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
from sift.test_reports.v1.test_reports_pb2 import (
1212
TestMeasurement as TestMeasurementProto,
1313
)
14+
from sift.test_reports.v1.test_reports_pb2 import (
15+
TestStep as TestStepProto,
16+
)
1417

1518
from sift_client.sift_types.channel import Channel, ChannelDataType
1619
from sift_client.sift_types.test_report import (
@@ -22,6 +25,7 @@
2225
TestReport,
2326
TestStatus,
2427
TestStep,
28+
TestStepCreate,
2529
TestStepType,
2630
)
2731

@@ -70,6 +74,7 @@ def mock_test_step(mock_client):
7074
error_code=1,
7175
error_message="Demo error message",
7276
),
77+
metadata={"fixture": "step", "iteration": 1.0},
7378
)
7479
test_step._apply_client_to_instance(mock_client)
7580
return test_step
@@ -439,3 +444,54 @@ def test_measurement_from_proto_handles_absent_new_fields(self):
439444
assert measurement.description is None
440445
assert measurement.metadata is None
441446
assert measurement.channel_names is None
447+
448+
def test_step_create_to_proto_writes_metadata(self):
449+
"""TestStepCreate.to_proto carries metadata onto the proto."""
450+
now = datetime.now(timezone.utc)
451+
create = TestStepCreate(
452+
test_report_id="report_789",
453+
name="Step",
454+
step_type=TestStepType.ACTION,
455+
step_path="1",
456+
status=TestStatus.IN_PROGRESS,
457+
start_time=now,
458+
end_time=now,
459+
metadata={"pn": "PN-001", "count": 3, "flag": True},
460+
)
461+
proto = create.to_proto()
462+
proto_keys = {m.key.name for m in proto.metadata}
463+
assert proto_keys == {"pn", "count", "flag"}
464+
465+
def test_step_from_proto_round_trips_metadata(self):
466+
"""A proto with metadata populated round-trips into TestStep."""
467+
now = datetime.now(timezone.utc)
468+
source = TestStepCreate(
469+
test_report_id="report_789",
470+
name="Step",
471+
step_type=TestStepType.ACTION,
472+
step_path="1",
473+
status=TestStatus.IN_PROGRESS,
474+
start_time=now,
475+
end_time=now,
476+
metadata={"pn": "PN-001", "count": 3},
477+
).to_proto()
478+
source.test_step_id = "step_456"
479+
480+
step = TestStep._from_proto(source)
481+
482+
assert step.metadata == {"pn": "PN-001", "count": 3}
483+
484+
def test_step_from_proto_handles_absent_metadata(self):
485+
"""Proto with unset metadata yields None on the model."""
486+
proto = TestStepProto(
487+
test_step_id="step_abc",
488+
test_report_id="report_789",
489+
name="Step",
490+
step_type=TestStepType.ACTION.value,
491+
step_path="1",
492+
status=TestStatus.IN_PROGRESS.value,
493+
)
494+
proto.start_time.FromDatetime(datetime.now(timezone.utc))
495+
proto.end_time.FromDatetime(datetime.now(timezone.utc))
496+
step = TestStep._from_proto(proto)
497+
assert step.metadata is None

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,12 @@ def test_new_step(self, report_context):
6464
prefix = f"{'.'.join(first_step_path_parts[:-1])}."
6565
second_step_path = f"{prefix}{int(first_step_path_parts[-1]) + 1}"
6666
test_step = None
67+
expected_step_metadata = {"phase": "setup", "retry": 1, "instrumented": True}
68+
expected_substep_metadata = {"phase": "assert"}
6769
# Test NewStep as a context manager directly
68-
with NewStep(report_context, "Test Step", "Test Description") as new_step:
70+
with NewStep(
71+
report_context, "Test Step", "Test Description", metadata=expected_step_metadata
72+
) as new_step:
6973
test_step = new_step.current_step
7074
assert test_step.test_report_id == report_context.report.id_
7175
assert test_step.name == "Test Step"
@@ -76,8 +80,11 @@ def test_new_step(self, report_context):
7680
assert test_step.step_path == first_step_path
7781
assert test_step.step_type == TestStepType.ACTION
7882
assert test_step.error_info == None
83+
assert test_step.metadata == expected_step_metadata
7984

80-
with new_step.substep("Substep", "Substep Description") as substep:
85+
with new_step.substep(
86+
"Substep", "Substep Description", metadata=expected_substep_metadata
87+
) as substep:
8188
current_substep = substep.current_step
8289
assert current_substep.test_report_id == report_context.report.id_
8390
assert current_substep.name == "Substep"
@@ -89,6 +96,7 @@ def test_new_step(self, report_context):
8996
assert current_substep.status == TestStatus.IN_PROGRESS
9097
assert current_substep.step_type == TestStepType.ACTION
9198
assert current_substep.error_info == None
99+
assert current_substep.metadata == expected_substep_metadata
92100

93101
with substep.substep(
94102
"nested substep", "Nested substep Description"

python/lib/sift_client/sift_types/test_report.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ class TestStepBase(ModelCreateUpdateBase):
8686
parent_step_id: str | None = None
8787
description: str | None = None
8888
error_info: ErrorInfo | None = None
89+
metadata: dict[str, str | float | bool] | None = None
90+
91+
_to_proto_helpers: ClassVar[dict[str, MappingHelper]] = {
92+
"metadata": MappingHelper(
93+
proto_attr_path="metadata", update_field="metadata", converter=metadata_dict_to_proto
94+
),
95+
}
8996

9097
def _get_proto_class(self) -> type[TestStepProto]:
9198
return TestStepProto
@@ -140,6 +147,9 @@ def to_proto(self) -> TestStepProto:
140147
if self.error_info:
141148
proto.error_info.CopyFrom(self.error_info._to_proto())
142149

150+
if self.metadata:
151+
proto.metadata.extend(metadata_dict_to_proto(self.metadata))
152+
143153
return proto
144154

145155

@@ -156,6 +166,7 @@ class TestStep(BaseType[TestStepProto, "TestStep"], FileAttachmentsMixin):
156166
start_time: datetime
157167
end_time: datetime
158168
error_info: ErrorInfo | None = None
169+
metadata: dict[str, str | float | bool] | None = None
159170
# Set by the resource layer when this instance was produced from a logging-mode call
160171
_log_file: str | Path | None = None
161172

@@ -176,6 +187,7 @@ def _from_proto(cls, proto: TestStepProto, sift_client: SiftClient | None = None
176187
error_info=ErrorInfo._from_proto(proto.error_info, sift_client)
177188
if proto.HasField("error_info")
178189
else None,
190+
metadata=metadata_proto_to_dict(proto.metadata) if proto.metadata else None, # type: ignore[arg-type]
179191
_client=sift_client,
180192
)
181193

@@ -202,6 +214,9 @@ def _to_proto(self) -> TestStepProto:
202214
if self.error_info:
203215
proto.error_info.CopyFrom(self.error_info._to_proto())
204216

217+
if self.metadata:
218+
proto.metadata.extend(metadata_dict_to_proto(self.metadata))
219+
205220
return proto
206221

207222
def update(

python/lib/sift_client/util/test_results/context_manager.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,14 +211,19 @@ def __exit__(self, exc_type, exc_value, traceback):
211211
return True
212212

213213
def new_step(
214-
self, name: str, description: str | None = None, assertion_as_fail_not_error: bool = True
214+
self,
215+
name: str,
216+
description: str | None = None,
217+
assertion_as_fail_not_error: bool = True,
218+
metadata: dict[str, str | float | bool] | None = None,
215219
) -> NewStep:
216220
"""Alias to return a new step context manager from this report context. Use create_step for actually creating a TestStep in the current context."""
217221
return NewStep(
218222
self,
219223
name=name,
220224
description=description,
221225
assertion_as_fail_not_error=assertion_as_fail_not_error,
226+
metadata=metadata,
222227
)
223228

224229
def get_next_step_path(self) -> str:
@@ -229,12 +234,20 @@ def get_next_step_path(self) -> str:
229234
prefix = f"{step_path}." if step_path else ""
230235
return f"{prefix}{next_step_number}"
231236

232-
def create_step(self, name: str, description: str | None = None) -> TestStep:
237+
def create_step(
238+
self,
239+
name: str,
240+
description: str | None = None,
241+
metadata: dict[str, str | float | bool] | None = None,
242+
) -> TestStep:
233243
"""Create a new step in the report context.
234244
235245
Args:
236246
name: The name of the step.
237247
description: The description of the step.
248+
metadata: [Optional] Structured key/value metadata to attach to the step. For
249+
metadata shared across every step in a report, prefer the `metadata` attribute
250+
of the enclosing `TestReport`.
238251
239252
Returns:
240253
The created step.
@@ -253,6 +266,7 @@ def create_step(self, name: str, description: str | None = None) -> TestStep:
253266
end_time=datetime.now(timezone.utc),
254267
description=description,
255268
parent_step_id=parent_step.id_ if parent_step else None,
269+
metadata=metadata,
256270
),
257271
log_file=self.log_file,
258272
)
@@ -324,6 +338,7 @@ def __init__(
324338
name: str,
325339
description: str | None = None,
326340
assertion_as_fail_not_error: bool = True,
341+
metadata: dict[str, str | float | bool] | None = None,
327342
):
328343
"""Initialize a new step context.
329344
@@ -332,10 +347,11 @@ def __init__(
332347
name: The name of the step.
333348
description: The description of the step.
334349
assertion_as_fail_not_error: Mark steps with assertion errors as failed instead of error+traceback (some users want assertions to work as simple failures especially when using pytest).
350+
metadata: [Optional] Structured key/value metadata to attach to the step.
335351
"""
336352
self.report_context = report_context
337353
self.client = report_context.client
338-
self.current_step = self.report_context.create_step(name, description)
354+
self.current_step = self.report_context.create_step(name, description, metadata=metadata)
339355
self.assertion_as_fail_not_error = assertion_as_fail_not_error
340356

341357
def __enter__(self):
@@ -602,10 +618,16 @@ def report_outcome(self, name: str, result: bool, reason: str | None = None) ->
602618
self.report_context.record_step_outcome(result, substep.current_step)
603619
return result
604620

605-
def substep(self, name: str, description: str | None = None) -> NewStep:
621+
def substep(
622+
self,
623+
name: str,
624+
description: str | None = None,
625+
metadata: dict[str, str | float | bool] | None = None,
626+
) -> NewStep:
606627
"""Alias to return a new step context manager from the current step. The ReportContext will manage nesting of steps."""
607628
return self.report_context.new_step(
608629
name=name,
609630
description=description,
610631
assertion_as_fail_not_error=self.assertion_as_fail_not_error,
632+
metadata=metadata,
611633
)

0 commit comments

Comments
 (0)