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.
Files changed (46) hide show
  1. deterministic_scenario_engine-1.0.0.dist-info/METADATA +146 -0
  2. deterministic_scenario_engine-1.0.0.dist-info/RECORD +46 -0
  3. deterministic_scenario_engine-1.0.0.dist-info/WHEEL +5 -0
  4. deterministic_scenario_engine-1.0.0.dist-info/entry_points.txt +2 -0
  5. deterministic_scenario_engine-1.0.0.dist-info/licenses/LICENSE +201 -0
  6. deterministic_scenario_engine-1.0.0.dist-info/top_level.txt +1 -0
  7. scenario_engine/__init__.py +41 -0
  8. scenario_engine/_version.py +3 -0
  9. scenario_engine/adapters/__init__.py +5 -0
  10. scenario_engine/adapters/json_file.py +58 -0
  11. scenario_engine/adapters/sqlalchemy.py +207 -0
  12. scenario_engine/address.py +37 -0
  13. scenario_engine/artifacts.py +16 -0
  14. scenario_engine/canonical.py +109 -0
  15. scenario_engine/clock.py +22 -0
  16. scenario_engine/context.py +25 -0
  17. scenario_engine/control_flow.py +63 -0
  18. scenario_engine/dsl/__init__.py +31 -0
  19. scenario_engine/dsl/compiler.py +232 -0
  20. scenario_engine/dsl/errors.py +23 -0
  21. scenario_engine/dsl/models.py +74 -0
  22. scenario_engine/dsl/parser.py +582 -0
  23. scenario_engine/dsl/runtime.py +211 -0
  24. scenario_engine/errors.py +13 -0
  25. scenario_engine/expressions.py +330 -0
  26. scenario_engine/faults.py +36 -0
  27. scenario_engine/history.py +38 -0
  28. scenario_engine/ids.py +36 -0
  29. scenario_engine/integrations/__init__.py +7 -0
  30. scenario_engine/integrations/hypothesis.py +103 -0
  31. scenario_engine/integrations/schemathesis.py +142 -0
  32. scenario_engine/invariants.py +21 -0
  33. scenario_engine/manifest.py +102 -0
  34. scenario_engine/oracle.py +29 -0
  35. scenario_engine/plugins.py +175 -0
  36. scenario_engine/provenance.py +27 -0
  37. scenario_engine/pytest_plugin.py +95 -0
  38. scenario_engine/reference_packs/__init__.py +1 -0
  39. scenario_engine/reference_packs/ecommerce.py +57 -0
  40. scenario_engine/resources.py +163 -0
  41. scenario_engine/result.py +99 -0
  42. scenario_engine/rng.py +36 -0
  43. scenario_engine/runner.py +103 -0
  44. scenario_engine/state.py +39 -0
  45. scenario_engine/validation.py +95 -0
  46. 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({})