deterministic-scenario-engine 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deterministic_scenario_engine-1.0.0.dist-info/METADATA +146 -0
- deterministic_scenario_engine-1.0.0.dist-info/RECORD +46 -0
- deterministic_scenario_engine-1.0.0.dist-info/WHEEL +5 -0
- deterministic_scenario_engine-1.0.0.dist-info/entry_points.txt +2 -0
- deterministic_scenario_engine-1.0.0.dist-info/licenses/LICENSE +201 -0
- deterministic_scenario_engine-1.0.0.dist-info/top_level.txt +1 -0
- scenario_engine/__init__.py +41 -0
- scenario_engine/_version.py +3 -0
- scenario_engine/adapters/__init__.py +5 -0
- scenario_engine/adapters/json_file.py +58 -0
- scenario_engine/adapters/sqlalchemy.py +207 -0
- scenario_engine/address.py +37 -0
- scenario_engine/artifacts.py +16 -0
- scenario_engine/canonical.py +109 -0
- scenario_engine/clock.py +22 -0
- scenario_engine/context.py +25 -0
- scenario_engine/control_flow.py +63 -0
- scenario_engine/dsl/__init__.py +31 -0
- scenario_engine/dsl/compiler.py +232 -0
- scenario_engine/dsl/errors.py +23 -0
- scenario_engine/dsl/models.py +74 -0
- scenario_engine/dsl/parser.py +582 -0
- scenario_engine/dsl/runtime.py +211 -0
- scenario_engine/errors.py +13 -0
- scenario_engine/expressions.py +330 -0
- scenario_engine/faults.py +36 -0
- scenario_engine/history.py +38 -0
- scenario_engine/ids.py +36 -0
- scenario_engine/integrations/__init__.py +7 -0
- scenario_engine/integrations/hypothesis.py +103 -0
- scenario_engine/integrations/schemathesis.py +142 -0
- scenario_engine/invariants.py +21 -0
- scenario_engine/manifest.py +102 -0
- scenario_engine/oracle.py +29 -0
- scenario_engine/plugins.py +175 -0
- scenario_engine/provenance.py +27 -0
- scenario_engine/pytest_plugin.py +95 -0
- scenario_engine/reference_packs/__init__.py +1 -0
- scenario_engine/reference_packs/ecommerce.py +57 -0
- scenario_engine/resources.py +163 -0
- scenario_engine/result.py +99 -0
- scenario_engine/rng.py +36 -0
- scenario_engine/runner.py +103 -0
- scenario_engine/state.py +39 -0
- scenario_engine/validation.py +95 -0
- scenario_engine/values.py +66 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from scenario_engine.artifacts import GeneratedArtifact
|
|
8
|
+
from scenario_engine.context import GenerationContext
|
|
9
|
+
from scenario_engine.expressions import (
|
|
10
|
+
Add, Append, BooleanMany, BooleanNot, DerivedRef, Divide, Equal, Expression,
|
|
11
|
+
GreaterThan, GreaterThanOrEqual, Length, LessThan, LessThanOrEqual, Literal,
|
|
12
|
+
LocalRef, Multiply, NotEqual, Record, ScopeRef, StateRef, Subtract, SumField,
|
|
13
|
+
)
|
|
14
|
+
from scenario_engine.runner import StepSpec
|
|
15
|
+
from scenario_engine.expressions import resolve_semantic_path
|
|
16
|
+
from scenario_engine.plugins import PluginRegistry, invoke_plugin
|
|
17
|
+
|
|
18
|
+
from .errors import DSLCompilationError
|
|
19
|
+
from .models import CompiledScenario, CompiledStep, ScenarioDocument
|
|
20
|
+
from .parser import decode_semantic_value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class LogicalIDGenerator:
|
|
25
|
+
slot: str
|
|
26
|
+
|
|
27
|
+
def generate(self, context: GenerationContext) -> Any:
|
|
28
|
+
return context.logical_id(self.slot)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class LiteralGenerator:
|
|
33
|
+
value: Any
|
|
34
|
+
|
|
35
|
+
def generate(self, context: GenerationContext) -> Any:
|
|
36
|
+
return self.value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class IntegerGenerator:
|
|
41
|
+
minimum: int
|
|
42
|
+
maximum: int
|
|
43
|
+
|
|
44
|
+
def generate(self, context: GenerationContext) -> int:
|
|
45
|
+
return context.rng().inclusive_int(self.minimum, self.maximum)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class PluginGenerator:
|
|
50
|
+
name: str
|
|
51
|
+
version: str
|
|
52
|
+
arguments: Mapping[str, Expression]
|
|
53
|
+
registry: PluginRegistry
|
|
54
|
+
state: Mapping[str, Any]
|
|
55
|
+
|
|
56
|
+
def generate(self, context: GenerationContext) -> Any:
|
|
57
|
+
from scenario_engine.expressions import EvaluationEnvironment
|
|
58
|
+
|
|
59
|
+
state = self.state.snapshot() if hasattr(self.state, "snapshot") else self.state
|
|
60
|
+
environment = EvaluationEnvironment(state, MappingProxyType({}), MappingProxyType({}))
|
|
61
|
+
arguments = {name: expression.evaluate(environment) for name, expression in self.arguments.items()}
|
|
62
|
+
return invoke_plugin(self.registry.require(self.name, self.version), context, arguments)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _expression(node: Mapping[str, Any], resources=None, scope=None) -> Expression:
|
|
66
|
+
operator, payload = next(iter(node.items()))
|
|
67
|
+
if operator == "$state":
|
|
68
|
+
return StateRef(payload)
|
|
69
|
+
if operator == "$local":
|
|
70
|
+
return LocalRef(payload)
|
|
71
|
+
if operator == "$derived":
|
|
72
|
+
return DerivedRef(payload)
|
|
73
|
+
if operator == "$literal":
|
|
74
|
+
return Literal(decode_semantic_value(payload))
|
|
75
|
+
if operator == "$resource":
|
|
76
|
+
if resources is None:
|
|
77
|
+
return Literal(payload) # replaced before execution during preparation
|
|
78
|
+
return Literal(resources.lookup(payload))
|
|
79
|
+
if operator == "$scope":
|
|
80
|
+
return ScopeRef(payload) if scope is None else Literal(resolve_semantic_path(scope, payload))
|
|
81
|
+
if operator == "$add":
|
|
82
|
+
return Add(_expression(payload[0], resources, scope), _expression(payload[1], resources, scope))
|
|
83
|
+
if operator == "$mul":
|
|
84
|
+
return Multiply(_expression(payload[0], resources, scope), _expression(payload[1], resources, scope))
|
|
85
|
+
binary = {"$sub": Subtract, "$div": Divide, "$eq": Equal, "$ne": NotEqual,
|
|
86
|
+
"$lt": LessThan, "$lte": LessThanOrEqual, "$gt": GreaterThan,
|
|
87
|
+
"$gte": GreaterThanOrEqual}
|
|
88
|
+
if operator in binary:
|
|
89
|
+
return binary[operator](_expression(payload[0], resources, scope), _expression(payload[1], resources, scope))
|
|
90
|
+
if operator in {"$and", "$or"}:
|
|
91
|
+
return BooleanMany(tuple(_expression(child, resources, scope) for child in payload), operator == "$and")
|
|
92
|
+
if operator == "$not": return BooleanNot(_expression(payload, resources, scope))
|
|
93
|
+
if operator == "$len": return Length(_expression(payload, resources, scope))
|
|
94
|
+
if operator == "$append":
|
|
95
|
+
return Append(_expression(payload["list"], resources, scope), _expression(payload["value"], resources, scope))
|
|
96
|
+
if operator == "$object":
|
|
97
|
+
return Record(MappingProxyType({name: _expression(child, resources, scope) for name, child in payload.items()}))
|
|
98
|
+
if operator == "$sum_field":
|
|
99
|
+
return SumField(_expression(payload["source"], resources, scope), payload["field"])
|
|
100
|
+
raise AssertionError("validated expression operator was not compiled")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _generator(node: Mapping[str, Any], resources=None, scope=None, plugins=None, state=None) -> Any:
|
|
104
|
+
operator, payload = next(iter(node.items()))
|
|
105
|
+
if operator == "$int":
|
|
106
|
+
return IntegerGenerator(payload[0], payload[1])
|
|
107
|
+
if operator == "$id":
|
|
108
|
+
return LogicalIDGenerator(payload)
|
|
109
|
+
if operator == "$literal":
|
|
110
|
+
return LiteralGenerator(decode_semantic_value(payload))
|
|
111
|
+
if operator == "$plugin":
|
|
112
|
+
arguments = MappingProxyType({
|
|
113
|
+
name: _expression(expression, resources, scope)
|
|
114
|
+
for name, expression in payload.get("args", {}).items()
|
|
115
|
+
})
|
|
116
|
+
return PluginGenerator(payload["name"], payload["version"], arguments,
|
|
117
|
+
plugins or PluginRegistry(), state or MappingProxyType({}))
|
|
118
|
+
raise AssertionError("validated generator operator was not compiled")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _emitter(declarations: tuple[Mapping[str, Any], ...], resources=None, scope=None):
|
|
122
|
+
compiled = tuple((declaration["type"], {
|
|
123
|
+
name: _expression(node, resources, scope) for name, node in declaration["fields"].items()
|
|
124
|
+
}) for declaration in declarations)
|
|
125
|
+
|
|
126
|
+
def emit(context, post_state, locals_, derived):
|
|
127
|
+
from scenario_engine.expressions import EvaluationEnvironment
|
|
128
|
+
|
|
129
|
+
env = EvaluationEnvironment(post_state, MappingProxyType({}), MappingProxyType({}))
|
|
130
|
+
artifacts = []
|
|
131
|
+
for index, (artifact_type, fields) in enumerate(compiled):
|
|
132
|
+
address = context.address.child("emit", str(index), artifact_type)
|
|
133
|
+
value = {name: fields[name].evaluate(env) for name in sorted(fields)}
|
|
134
|
+
artifacts.append(GeneratedArtifact(
|
|
135
|
+
artifact_type, artifact_type, value,
|
|
136
|
+
context.ids.derive(address, "artifact"), address,
|
|
137
|
+
))
|
|
138
|
+
return tuple(artifacts)
|
|
139
|
+
|
|
140
|
+
return emit
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _validate_sequence(steps, path):
|
|
144
|
+
ids = [step.step_id for step in steps]
|
|
145
|
+
known = set(ids)
|
|
146
|
+
for index, step in enumerate(steps):
|
|
147
|
+
expected = ids[index + 1] if index + 1 < len(ids) else None
|
|
148
|
+
if step.transition is not None and step.transition not in known:
|
|
149
|
+
raise DSLCompilationError(
|
|
150
|
+
f"{path}[{index}].transition: unknown node ID {step.transition}"
|
|
151
|
+
)
|
|
152
|
+
if step.transition != expected:
|
|
153
|
+
if expected is None:
|
|
154
|
+
message = "final step transition must be null"
|
|
155
|
+
elif step.transition is None:
|
|
156
|
+
message = f"non-final step transition must be {expected}"
|
|
157
|
+
else:
|
|
158
|
+
message = f"linear transition must target immediately following step {expected}"
|
|
159
|
+
raise DSLCompilationError(f"{path}[{index}].transition: {message}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _targets(step):
|
|
163
|
+
if step.call is not None: return (step.call["subflow"],)
|
|
164
|
+
if step.repeat is not None: return (step.repeat["subflow"],)
|
|
165
|
+
if step.branch is not None:
|
|
166
|
+
result = [case["subflow"] for case in step.branch["cases"]]
|
|
167
|
+
if "else" in step.branch: result.append(step.branch["else"]["subflow"])
|
|
168
|
+
return tuple(result)
|
|
169
|
+
return ()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _validate_control(document):
|
|
173
|
+
from scenario_engine.control_flow import SubflowCycleError, UnknownSubflowError
|
|
174
|
+
known = set(document.subflows)
|
|
175
|
+
for sequence_name, steps in (("$", document.steps), *sorted(document.subflows.items())):
|
|
176
|
+
for step in steps:
|
|
177
|
+
for target in _targets(step):
|
|
178
|
+
if target not in known: raise UnknownSubflowError(f"{step.step_id}: unknown subflow {target}")
|
|
179
|
+
graph = {name: sorted({target for step in steps for target in _targets(step)}) for name, steps in document.subflows.items()}
|
|
180
|
+
visiting, complete = [], set()
|
|
181
|
+
def visit(name):
|
|
182
|
+
if name in visiting:
|
|
183
|
+
cycle = visiting[visiting.index(name):] + [name]
|
|
184
|
+
raise SubflowCycleError("subflow call cycle: " + " -> ".join(cycle))
|
|
185
|
+
if name in complete: return
|
|
186
|
+
visiting.append(name)
|
|
187
|
+
for child in graph[name]: visit(child)
|
|
188
|
+
visiting.pop(); complete.add(name)
|
|
189
|
+
for name in sorted(graph): visit(name)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _compile_step(step, resources, scope=None, plugins=None, state=None):
|
|
193
|
+
if step.control_kind is not None:
|
|
194
|
+
return step
|
|
195
|
+
transition = step.transition
|
|
196
|
+
spec = StepSpec(
|
|
197
|
+
step.step_id,
|
|
198
|
+
MappingProxyType({name: _generator(node, resources, scope, plugins, state)
|
|
199
|
+
for name, node in step.generate.items()}),
|
|
200
|
+
MappingProxyType({name: _expression(node, resources, scope) for name, node in step.derive.items()}),
|
|
201
|
+
MappingProxyType({name: _expression(node, resources, scope) for name, node in step.write.items()}),
|
|
202
|
+
step.advance, emit=_emitter(step.emit, resources, scope), transition=lambda state, target=transition: target,
|
|
203
|
+
)
|
|
204
|
+
return CompiledStep(spec, transition)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def compile_document(document: ScenarioDocument, resources=None, plugins=None,
|
|
208
|
+
state=None) -> CompiledScenario:
|
|
209
|
+
_validate_sequence(document.steps, "$.steps")
|
|
210
|
+
for name, steps in document.subflows.items(): _validate_sequence(steps, f"$.subflows.{name}.steps")
|
|
211
|
+
_validate_control(document)
|
|
212
|
+
compiled: list[Any] = []
|
|
213
|
+
for step in document.steps:
|
|
214
|
+
compiled.append(_compile_step(step, resources, plugins=plugins, state=state))
|
|
215
|
+
subflows = MappingProxyType({name: tuple(_compile_step(step, resources, plugins=plugins, state=state)
|
|
216
|
+
for step in steps) for name, steps in document.subflows.items()})
|
|
217
|
+
return CompiledScenario(
|
|
218
|
+
document.scenario_id, document.reference_clock_start, document.initial_state,
|
|
219
|
+
tuple(compiled), compiled[0].step_id, document, resources, subflows,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def compile_constraint(node: Mapping[str, Any], resources) -> Expression:
|
|
224
|
+
return _expression(node, resources)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def compile_expression(node: Mapping[str, Any], resources, scope=None) -> Expression:
|
|
228
|
+
return _expression(node, resources, scope)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def compile_scoped_sequence(steps, resources, scope, plugins=None, state=None):
|
|
232
|
+
return tuple(_compile_step(step, resources, scope, plugins, state) for step in steps)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Deterministic public errors for the Phase 0.1B declarative layer."""
|
|
2
|
+
|
|
3
|
+
from scenario_engine.errors import ScenarioEngineError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DSLError(ScenarioEngineError, ValueError):
|
|
7
|
+
"""Base class for declarative scenario errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DSLParseError(DSLError):
|
|
11
|
+
"""The YAML stream could not be safely loaded."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DSLSchemaError(DSLError):
|
|
15
|
+
"""The loaded document has an invalid shape or value."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnsupportedDSLVersionError(DSLSchemaError):
|
|
19
|
+
"""The document requests an unsupported DSL version."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DSLCompilationError(DSLError):
|
|
23
|
+
"""The document is well-shaped but statically invalid."""
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
from scenario_engine.runner import StepSpec
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def frozen_mapping(value: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
12
|
+
return MappingProxyType(dict(value))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class StepDocument:
|
|
17
|
+
step_id: str
|
|
18
|
+
generate: Mapping[str, Any]
|
|
19
|
+
derive: Mapping[str, Any]
|
|
20
|
+
write: Mapping[str, Any]
|
|
21
|
+
emit: tuple[Mapping[str, Any], ...]
|
|
22
|
+
advance: timedelta
|
|
23
|
+
transition: str | None
|
|
24
|
+
call: Mapping[str, Any] | None = None
|
|
25
|
+
branch: Mapping[str, Any] | None = None
|
|
26
|
+
repeat: Mapping[str, Any] | None = None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def control_kind(self) -> str | None:
|
|
30
|
+
if self.call is not None:
|
|
31
|
+
return "call"
|
|
32
|
+
if self.branch is not None:
|
|
33
|
+
return "branch"
|
|
34
|
+
if self.repeat is not None:
|
|
35
|
+
return "repeat"
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ScenarioDocument:
|
|
41
|
+
dsl_version: int
|
|
42
|
+
scenario_id: str
|
|
43
|
+
reference_clock_start: datetime
|
|
44
|
+
initial_state: Mapping[str, Any]
|
|
45
|
+
steps: tuple[StepDocument, ...]
|
|
46
|
+
resources: Mapping[str, Any] = MappingProxyType({})
|
|
47
|
+
validators: tuple[Mapping[str, Any], ...] = ()
|
|
48
|
+
constraints: tuple[Mapping[str, Any], ...] = ()
|
|
49
|
+
subflows: Mapping[str, tuple[StepDocument, ...]] = MappingProxyType({})
|
|
50
|
+
invariants: tuple[Mapping[str, Any], ...] = ()
|
|
51
|
+
faults: tuple[Mapping[str, Any], ...] = ()
|
|
52
|
+
oracle: Mapping[str, Any] | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class CompiledStep:
|
|
57
|
+
spec: StepSpec
|
|
58
|
+
transition: str | None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def step_id(self) -> str:
|
|
62
|
+
return self.spec.step_id
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class CompiledScenario:
|
|
67
|
+
scenario_id: str
|
|
68
|
+
reference_clock_start: datetime
|
|
69
|
+
initial_state: Mapping[str, Any]
|
|
70
|
+
steps: tuple[CompiledStep, ...]
|
|
71
|
+
start_step: str
|
|
72
|
+
document: ScenarioDocument
|
|
73
|
+
resources: Any = None
|
|
74
|
+
subflows: Mapping[str, tuple[CompiledStep | StepDocument, ...]] = MappingProxyType({})
|