-
-
Notifications
You must be signed in to change notification settings - Fork 526
Expand file tree
/
Copy pathrunner.py
More file actions
281 lines (232 loc) 路 10.2 KB
/
Copy pathrunner.py
File metadata and controls
281 lines (232 loc) 路 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
"""Scenario runner for executing sequences of scenario components.
This module provides a runner that executes scenarios using the handle() method
pattern, where components yield Interactions or CheckResults and receive
updated Trace objects via the async generator protocol.
"""
import time
from typing import Any, cast
from giskard.core import (
scoped_telemetry,
telemetry_capture,
telemetry_tag,
)
from pydantic.experimental.missing_sentinel import MISSING
from .._telemetry_props import scenario_shape_properties
from ..core import Trace
from ..core.interaction import Interact
from ..core.result import CheckResult, ScenarioResult, TestCaseResult
from ..core.scenario import Scenario, Step
from ..core.testcase import TestCase
from ..core.types import Target
from ..utils.inference import _infer_trace_type
def _validate_multiple_runs(value: int | None) -> int | None:
if value is None:
return None
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError("multiple_runs must be an integer greater than or equal to 1")
if value < 1:
raise ValueError("multiple_runs must be greater than or equal to 1")
return value
def _build_steps[InputType, OutputType, TraceType: Trace[Any, Any]](
scenario: Scenario[InputType, OutputType, TraceType],
target: Target[InputType, OutputType, TraceType] | MISSING,
) -> list[Step[InputType, OutputType, TraceType]]:
"""Build steps with target bound to Interact outputs where needed.
If no target is provided, returns the scenario's steps as-is. Otherwise,
returns new Step objects with interacts that have MISSING outputs
replaced by the given target.
"""
target = target if target is not MISSING else scenario.target
if target is MISSING:
return scenario.steps
steps = []
for step in scenario.steps:
interacts = []
for interact in step.interacts:
if isinstance(interact, Interact) and interact.outputs is MISSING:
interact = interact.model_copy().set_outputs(target)
interacts.append(interact)
steps.append(step.model_copy(update={"interacts": interacts}))
return steps
def _resolve_trace_type[InputType, OutputType, TraceType: Trace[Any, Any]](
scenario: Scenario[InputType, OutputType, TraceType],
run_target: Target[InputType, OutputType, TraceType] | MISSING,
) -> type[TraceType]:
if scenario.trace_type is not None:
return scenario.trace_type
effective_target = run_target if run_target is not MISSING else scenario.target
inferred = _infer_trace_type(effective_target)
return cast(type[TraceType], inferred if inferred is not None else Trace)
class ScenarioRunner:
"""Execute scenarios by running their steps sequentially.
The runner processes each step: first applies interactions to the trace,
then runs checks against the resulting trace. Execution stops on the first
check failure or error.
Each step is processed as follows:
1. **Interacts** (InteractionSpec): Add interactions to the trace.
Specs generate interactions using their `generate()` method. Each yielded
interaction is added to the trace, and the updated trace is sent back to
the generator via `asend()`.
2. **Checks**: Validate the current trace state using their `run()` method.
If a check fails or errors, execution stops immediately.
The runner handles exceptions from checks by converting them into
`CheckResult.error` objects and stopping execution.
For a `multiple_runs` setting greater than 1, the full scenario is executed
at most that many times (fresh trace per attempt); each attempt must pass
for the next to run, otherwise execution stops with that attempt's result.
Examples
--------
```python
runner = ScenarioRunner()
result = await runner.run(scenario)
result = await runner.run(scenario, target=my_sut, return_exception=True)
```
"""
@scoped_telemetry
async def _run_once[InputType, OutputType, TraceType: Trace[Any, Any]](
self,
scenario: Scenario[InputType, OutputType, TraceType],
target: Target[InputType, OutputType, TraceType] | MISSING = MISSING,
return_exception: bool = False,
) -> ScenarioResult[TraceType]:
start_time = time.perf_counter()
telemetry_tag("giskard_component", "scenario_runner")
telemetry_tag("giskard_operation", "scenario_run")
trace_cls = _resolve_trace_type(scenario, target)
trace = cast(TraceType, trace_cls(annotations=scenario.annotations))
steps = _build_steps(scenario, target)
steps_results: list[TestCaseResult] = []
has_target = target is not MISSING
shape_props = scenario_shape_properties(
scenario,
has_target=has_target,
)
telemetry_capture(
"checks_scenario_run_started",
properties=shape_props,
)
for step in steps:
trace = await trace.with_interactions(*step.interacts)
trace_index = len(trace.interactions) - 1 if trace.interactions else None
test_case = TestCase(
trace=trace,
checks=step.checks,
)
step_result = await test_case.run(return_exception)
step_result = step_result.model_copy(update={"trace_index": trace_index})
steps_results.append(step_result)
# Stop on first failure
if not step_result.passed:
break
if len(steps_results) < len(steps):
# Skipped steps own no new interaction; point them at the trace as it stood
# when execution stopped so the index is never left unset.
skipped_trace_index = (
len(trace.interactions) - 1 if trace.interactions else None
)
for i in range(len(steps_results), len(steps)):
step_result = TestCaseResult(
results=[
CheckResult.skip(
message=f"Step {i + 1} was skipped due to previous failure",
details={
"check_kind": check.kind,
"check_name": check.name,
"check_description": check.description,
},
)
for check in steps[i].checks
],
duration_ms=0,
trace_index=skipped_trace_index,
)
steps_results.append(step_result)
end_time = time.perf_counter()
duration_ms = int((end_time - start_time) * 1000)
result = ScenarioResult(
scenario_name=scenario.name,
steps=steps_results,
duration_ms=duration_ms,
final_trace=trace,
tags=list(scenario.tags),
)
telemetry_capture(
"checks_scenario_run_finished",
properties={
**shape_props,
"outcome_status": result.status.value,
"duration_ms": duration_ms,
},
)
return result
async def run[InputType, OutputType, TraceType: Trace[Any, Any]](
self,
scenario: Scenario[InputType, OutputType, TraceType],
target: Target[InputType, OutputType, TraceType] | MISSING = MISSING,
return_exception: bool = False,
multiple_runs: int | None = None,
) -> ScenarioResult[TraceType]:
"""Execute a scenario up to N times, stopping on the first non-passing run.
Each run is executed independently with a fresh trace. The scenario is
run at most ``multiple_runs`` times when every run passes; otherwise
execution stops on the first run whose outcome is not PASS (FAIL, ERROR,
or SKIP). This is not a "retry until success" strategy.
Parameters
----------
scenario : Scenario
The scenario to execute.
target : Target | MISSING, optional
Optional target override used to replace ``MISSING`` interaction outputs.
return_exception : bool
If True, return results even when exceptions occur instead of raising.
multiple_runs : int | None
Optional cap on full scenario executions. When provided, it overrides
the scenario-level `multiple_runs` value.
Returns
-------
ScenarioResult
Results from the last run executed, updated with multi-run metadata.
"""
configured_runs = (
_validate_multiple_runs(multiple_runs) or scenario.multiple_runs
)
start_time = time.perf_counter()
last_result: ScenarioResult[TraceType] | None = None
for attempt in range(1, configured_runs + 1):
result = await self._run_once(
scenario,
target=target,
return_exception=return_exception,
)
last_result = result
if not result.passed:
end_time = time.perf_counter()
return result.model_copy(
update={
"duration_ms": int((end_time - start_time) * 1000),
"multiple_runs": configured_runs,
"runs_executed": attempt,
}
)
if last_result is None: # Defensive: configured_runs validation prevents this.
raise RuntimeError("Scenario did not execute any runs")
end_time = time.perf_counter()
return last_result.model_copy(
update={
"duration_ms": int((end_time - start_time) * 1000),
"multiple_runs": configured_runs,
"runs_executed": configured_runs,
}
)
_default_runner = ScenarioRunner()
def get_runner() -> ScenarioRunner:
"""Return the default process-wide `ScenarioRunner` instance.
This function provides access to a singleton runner instance that is used
by default when executing scenarios and test cases. The same runner instance
is reused across all executions within a process.
Returns
-------
ScenarioRunner
The default scenario runner instance.
"""
return _default_runner