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,582 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from decimal import Decimal, InvalidOperation
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from yaml.events import AliasEvent, ScalarEvent
|
|
12
|
+
from yaml.resolver import Resolver
|
|
13
|
+
|
|
14
|
+
from scenario_engine.values import MISSING, normalize
|
|
15
|
+
|
|
16
|
+
from .errors import DSLParseError, DSLSchemaError, UnsupportedDSLVersionError
|
|
17
|
+
from .models import ScenarioDocument, StepDocument
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_REQUIRED_TOP_KEYS = {"dsl_version", "scenario", "clock", "initial_state", "steps"}
|
|
21
|
+
_TOP_KEYS = _REQUIRED_TOP_KEYS | {"resources", "validators", "constraints", "subflows", "invariants", "faults", "oracle"}
|
|
22
|
+
_STEP_KEYS = {"id", "generate", "derive", "write", "emit", "advance", "transition"}
|
|
23
|
+
_NODE_KEYS = _STEP_KEYS | {"call", "branch", "repeat"}
|
|
24
|
+
_EXPRESSION_OPERATORS = {
|
|
25
|
+
"$state", "$local", "$derived", "$literal", "$add", "$mul", "$append",
|
|
26
|
+
"$object", "$sum_field", "$resource", "$sub", "$div", "$eq", "$ne",
|
|
27
|
+
"$lt", "$lte", "$gt", "$gte", "$and", "$or", "$not", "$len", "$scope",
|
|
28
|
+
}
|
|
29
|
+
_GENERATOR_OPERATORS = {"$int", "$id", "$literal", "$plugin"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _DSLLoader(yaml.SafeLoader):
|
|
33
|
+
"""Safe loader with the deliberately narrow DSL 1.0 scalar contract."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# PyYAML's YAML 1.1 resolver treats yes/no/on/off as booleans and timestamps as
|
|
37
|
+
# datetime objects. DSL 1.0 only has true/false booleans, decimal integers,
|
|
38
|
+
# explicit semantic datetimes, and the standard null spellings.
|
|
39
|
+
_DSLLoader.yaml_implicit_resolvers = {
|
|
40
|
+
key: list(resolvers) for key, resolvers in Resolver.yaml_implicit_resolvers.items()
|
|
41
|
+
}
|
|
42
|
+
for first, resolvers in tuple(_DSLLoader.yaml_implicit_resolvers.items()):
|
|
43
|
+
_DSLLoader.yaml_implicit_resolvers[first] = [
|
|
44
|
+
(tag, regexp) for tag, regexp in resolvers
|
|
45
|
+
if tag not in {"tag:yaml.org,2002:bool", "tag:yaml.org,2002:int", "tag:yaml.org,2002:timestamp"}
|
|
46
|
+
]
|
|
47
|
+
_DSLLoader.add_implicit_resolver(
|
|
48
|
+
"tag:yaml.org,2002:bool", re.compile(r"^(?:true|false)$"), list("tf")
|
|
49
|
+
)
|
|
50
|
+
_DSLLoader.add_implicit_resolver(
|
|
51
|
+
"tag:yaml.org,2002:int", re.compile(r"^(?:0|-?[1-9][0-9]*)$"), list("-0123456789")
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _reject_ambiguous_yaml_constructs(text: str) -> None:
|
|
56
|
+
"""Reject graph features and merge keys before semantic construction."""
|
|
57
|
+
try:
|
|
58
|
+
for event in yaml.parse(text, Loader=_DSLLoader):
|
|
59
|
+
if isinstance(event, AliasEvent):
|
|
60
|
+
raise DSLParseError("YAML aliases are not supported by DSL 1.0")
|
|
61
|
+
if isinstance(event, ScalarEvent) and event.value == "<<" and event.implicit[0]:
|
|
62
|
+
raise DSLParseError("YAML merge keys are not supported by DSL 1.0")
|
|
63
|
+
except DSLParseError:
|
|
64
|
+
raise
|
|
65
|
+
except yaml.YAMLError as error:
|
|
66
|
+
mark = getattr(error, "problem_mark", None)
|
|
67
|
+
location = f" at line {mark.line + 1}, column {mark.column + 1}" if mark else ""
|
|
68
|
+
raise DSLParseError(f"YAML safe-load failed{location}") from None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _construct_strict_mapping(loader: _DSLLoader, node: yaml.MappingNode, deep: bool = False) -> dict[Any, Any]:
|
|
72
|
+
loader.flatten_mapping(node)
|
|
73
|
+
result: dict[Any, Any] = {}
|
|
74
|
+
for key_node, value_node in node.value:
|
|
75
|
+
key = loader.construct_object(key_node, deep=deep)
|
|
76
|
+
try:
|
|
77
|
+
duplicate = key in result
|
|
78
|
+
except TypeError:
|
|
79
|
+
duplicate = False
|
|
80
|
+
if duplicate:
|
|
81
|
+
raise DSLParseError(f"duplicate YAML mapping key {key!r}")
|
|
82
|
+
try:
|
|
83
|
+
result[key] = loader.construct_object(value_node, deep=deep)
|
|
84
|
+
except TypeError:
|
|
85
|
+
raise DSLParseError("unhashable YAML mapping key") from None
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_DSLLoader.add_constructor(
|
|
90
|
+
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_strict_mapping
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fail(path: str, message: str) -> None:
|
|
95
|
+
raise DSLSchemaError(f"{path}: {message}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _mapping(value: Any, path: str) -> Mapping[str, Any]:
|
|
99
|
+
if not isinstance(value, Mapping):
|
|
100
|
+
_fail(path, "expected mapping")
|
|
101
|
+
if not all(isinstance(key, str) for key in value):
|
|
102
|
+
_fail(path, "mapping keys must be strings")
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _only_keys(value: Mapping[str, Any], allowed: set[str], path: str) -> None:
|
|
107
|
+
unknown = sorted(set(value) - allowed)
|
|
108
|
+
if unknown:
|
|
109
|
+
_fail(path, "unknown key(s): " + ", ".join(unknown))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def decode_semantic_value(value: Any, path: str = "value") -> Any:
|
|
113
|
+
if isinstance(value, float):
|
|
114
|
+
_fail(path, "Python float semantic values are forbidden; use $decimal")
|
|
115
|
+
if value is None or isinstance(value, (str, bool)):
|
|
116
|
+
return value
|
|
117
|
+
if isinstance(value, int):
|
|
118
|
+
return value
|
|
119
|
+
if isinstance(value, list):
|
|
120
|
+
return [decode_semantic_value(item, f"{path}[{index}]") for index, item in enumerate(value)]
|
|
121
|
+
if isinstance(value, Mapping):
|
|
122
|
+
mapping = _mapping(value, path)
|
|
123
|
+
wrapper_keys = [key for key in mapping if key.startswith("$")]
|
|
124
|
+
if wrapper_keys:
|
|
125
|
+
if len(mapping) != 1 or len(wrapper_keys) != 1:
|
|
126
|
+
_fail(path, "typed semantic wrapper must have exactly one key")
|
|
127
|
+
operator, payload = next(iter(mapping.items()))
|
|
128
|
+
if operator == "$decimal":
|
|
129
|
+
if not isinstance(payload, str) or not payload:
|
|
130
|
+
_fail(path, "$decimal requires a non-empty string")
|
|
131
|
+
try:
|
|
132
|
+
result = Decimal(payload)
|
|
133
|
+
except InvalidOperation:
|
|
134
|
+
_fail(path, "invalid decimal string")
|
|
135
|
+
if not result.is_finite():
|
|
136
|
+
_fail(path, "$decimal must be finite")
|
|
137
|
+
return result
|
|
138
|
+
if operator == "$datetime":
|
|
139
|
+
return _parse_datetime(payload, path + ".$datetime")
|
|
140
|
+
if operator == "$duration":
|
|
141
|
+
duration = _mapping(payload, path + ".$duration")
|
|
142
|
+
_only_keys(duration, {"seconds"}, path + ".$duration")
|
|
143
|
+
if set(duration) != {"seconds"}:
|
|
144
|
+
_fail(path + ".$duration", "seconds is required")
|
|
145
|
+
seconds = duration["seconds"]
|
|
146
|
+
if isinstance(seconds, bool) or not isinstance(seconds, int):
|
|
147
|
+
_fail(path + ".$duration.seconds", "expected integer")
|
|
148
|
+
return timedelta(seconds=seconds)
|
|
149
|
+
if operator == "$missing":
|
|
150
|
+
if payload is not True:
|
|
151
|
+
_fail(path + ".$missing", "expected true")
|
|
152
|
+
return MISSING
|
|
153
|
+
_fail(path, f"unknown semantic wrapper {operator}")
|
|
154
|
+
return {key: decode_semantic_value(item, f"{path}.{key}") for key, item in mapping.items()}
|
|
155
|
+
_fail(path, f"unsupported YAML value {type(value).__name__}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _parse_datetime(value: Any, path: str) -> datetime:
|
|
159
|
+
if not isinstance(value, str) or not value:
|
|
160
|
+
_fail(path, "expected ISO-8601 string")
|
|
161
|
+
text = value[:-1] + "+00:00" if value.endswith(("Z", "z")) else value
|
|
162
|
+
try:
|
|
163
|
+
result = datetime.fromisoformat(text)
|
|
164
|
+
except ValueError:
|
|
165
|
+
_fail(path, "invalid ISO-8601 datetime")
|
|
166
|
+
if result.tzinfo is None or result.utcoffset() is None:
|
|
167
|
+
_fail(path, "datetime must be timezone-aware")
|
|
168
|
+
return result.astimezone(timezone.utc)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _symbol(value: Any, path: str) -> None:
|
|
172
|
+
if not isinstance(value, str) or not value:
|
|
173
|
+
_fail(path, "expected non-empty top-level symbolic name")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _validate_expression(node: Any, path: str, *, emission: bool = False,
|
|
177
|
+
constraint: bool = False, control: bool = False) -> None:
|
|
178
|
+
mapping = _mapping(node, path)
|
|
179
|
+
if len(mapping) != 1:
|
|
180
|
+
_fail(path, "expression must contain exactly one operator")
|
|
181
|
+
operator, payload = next(iter(mapping.items()))
|
|
182
|
+
if operator not in _EXPRESSION_OPERATORS:
|
|
183
|
+
_fail(path, f"unknown expression operator {operator}")
|
|
184
|
+
if emission and operator not in {"$state", "$literal", "$resource"}:
|
|
185
|
+
_fail(path, f"{operator} is not allowed in emission fields")
|
|
186
|
+
if constraint and operator in {"$state", "$local", "$derived"}:
|
|
187
|
+
_fail(path, f"{operator} is not allowed in constraints")
|
|
188
|
+
if control and operator in {"$local", "$derived"}:
|
|
189
|
+
_fail(path, f"{operator} is not allowed at a control boundary")
|
|
190
|
+
if operator in {"$state", "$local", "$derived", "$resource", "$scope"}:
|
|
191
|
+
_symbol(payload, path + "." + operator)
|
|
192
|
+
if operator == "$scope" and any(not segment for segment in payload.split(".")):
|
|
193
|
+
_fail(path + ".$scope", "invalid dot-separated path")
|
|
194
|
+
elif operator == "$literal":
|
|
195
|
+
decode_semantic_value(payload, path + ".$literal")
|
|
196
|
+
elif operator in {"$add", "$mul", "$sub", "$div", "$eq", "$ne", "$lt", "$lte", "$gt", "$gte"}:
|
|
197
|
+
if not isinstance(payload, list) or len(payload) != 2:
|
|
198
|
+
_fail(path + "." + operator, "expected exactly two expressions")
|
|
199
|
+
for index, child in enumerate(payload):
|
|
200
|
+
_validate_expression(child, f"{path}.{operator}[{index}]", emission=emission, constraint=constraint, control=control)
|
|
201
|
+
elif operator in {"$and", "$or"}:
|
|
202
|
+
if not isinstance(payload, list) or not payload:
|
|
203
|
+
_fail(path + "." + operator, "expected one or more expressions")
|
|
204
|
+
for index, child in enumerate(payload):
|
|
205
|
+
_validate_expression(child, f"{path}.{operator}[{index}]", constraint=constraint, control=control)
|
|
206
|
+
elif operator in {"$not", "$len"}:
|
|
207
|
+
_validate_expression(payload, path + "." + operator, constraint=constraint, control=control)
|
|
208
|
+
elif operator == "$append":
|
|
209
|
+
body = _mapping(payload, path + ".$append")
|
|
210
|
+
_only_keys(body, {"list", "value"}, path + ".$append")
|
|
211
|
+
if set(body) != {"list", "value"}:
|
|
212
|
+
_fail(path + ".$append", "list and value are required")
|
|
213
|
+
_validate_expression(body["list"], path + ".$append.list", emission=emission, control=control)
|
|
214
|
+
_validate_expression(body["value"], path + ".$append.value", emission=emission, control=control)
|
|
215
|
+
elif operator == "$object":
|
|
216
|
+
body = _mapping(payload, path + ".$object")
|
|
217
|
+
for name, child in body.items():
|
|
218
|
+
_symbol(name, path + ".$object key")
|
|
219
|
+
_validate_expression(child, f"{path}.$object.{name}", emission=emission, control=control)
|
|
220
|
+
elif operator == "$sum_field":
|
|
221
|
+
body = _mapping(payload, path + ".$sum_field")
|
|
222
|
+
_only_keys(body, {"source", "field"}, path + ".$sum_field")
|
|
223
|
+
if set(body) != {"source", "field"}:
|
|
224
|
+
_fail(path + ".$sum_field", "source and field are required")
|
|
225
|
+
_validate_expression(body["source"], path + ".$sum_field.source", emission=emission, control=control)
|
|
226
|
+
_symbol(body["field"], path + ".$sum_field.field")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _validate_generator(node: Any, path: str) -> None:
|
|
230
|
+
mapping = _mapping(node, path)
|
|
231
|
+
if len(mapping) != 1:
|
|
232
|
+
_fail(path, "generator must contain exactly one operator")
|
|
233
|
+
operator, payload = next(iter(mapping.items()))
|
|
234
|
+
if operator not in _GENERATOR_OPERATORS:
|
|
235
|
+
_fail(path, f"unknown generator operator {operator}")
|
|
236
|
+
if operator == "$int":
|
|
237
|
+
if not isinstance(payload, list) or len(payload) != 2:
|
|
238
|
+
_fail(path + ".$int", "expected exactly two inclusive bounds")
|
|
239
|
+
if any(isinstance(bound, bool) or not isinstance(bound, int) for bound in payload):
|
|
240
|
+
_fail(path + ".$int", "bounds must be integers, not booleans")
|
|
241
|
+
if payload[0] > payload[1]:
|
|
242
|
+
_fail(path + ".$int", "lower bound must not exceed upper bound")
|
|
243
|
+
elif operator == "$id":
|
|
244
|
+
_symbol(payload, path + ".$id")
|
|
245
|
+
elif operator == "$plugin":
|
|
246
|
+
body = _mapping(payload, path + ".$plugin")
|
|
247
|
+
_only_keys(body, {"name", "version", "args"}, path + ".$plugin")
|
|
248
|
+
if not {"name", "version"} <= set(body):
|
|
249
|
+
_fail(path + ".$plugin", "name and version are required")
|
|
250
|
+
_symbol(body["name"], path + ".$plugin.name")
|
|
251
|
+
if not isinstance(body["version"], str) or not body["version"].strip():
|
|
252
|
+
_fail(path + ".$plugin.version", "expected non-empty string")
|
|
253
|
+
arguments = _mapping(body.get("args", {}), path + ".$plugin.args")
|
|
254
|
+
for name, expression in arguments.items():
|
|
255
|
+
_symbol(name, path + ".$plugin.args key")
|
|
256
|
+
_validate_expression(expression, f"{path}.$plugin.args.{name}", control=True)
|
|
257
|
+
else:
|
|
258
|
+
decode_semantic_value(payload, path + ".$literal")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _decode_resource(value: Any, path: str) -> Any:
|
|
262
|
+
if isinstance(value, Mapping):
|
|
263
|
+
mapping = _mapping(value, path)
|
|
264
|
+
operators = [key for key in mapping if key.startswith("$")]
|
|
265
|
+
if operators:
|
|
266
|
+
if len(mapping) != 1:
|
|
267
|
+
_fail(path, "resource operator/wrapper must contain exactly one key")
|
|
268
|
+
operator, payload = next(iter(mapping.items()))
|
|
269
|
+
if operator in {"$input", "$ref"}:
|
|
270
|
+
_symbol(payload, path + "." + operator)
|
|
271
|
+
if any(not segment for segment in payload.split(".")):
|
|
272
|
+
_fail(path + "." + operator, "invalid dot-separated path")
|
|
273
|
+
return MappingProxyType({operator: payload})
|
|
274
|
+
if operator == "$literal":
|
|
275
|
+
return decode_semantic_value(payload, path + ".$literal")
|
|
276
|
+
return decode_semantic_value(mapping, path)
|
|
277
|
+
return MappingProxyType({key: _decode_resource(mapping[key], f"{path}.{key}") for key in mapping})
|
|
278
|
+
if isinstance(value, list):
|
|
279
|
+
return tuple(_decode_resource(item, f"{path}[{index}]") for index, item in enumerate(value))
|
|
280
|
+
return decode_semantic_value(value, path)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _parse_validators(value: Any) -> tuple[Mapping[str, Any], ...]:
|
|
284
|
+
if not isinstance(value, list):
|
|
285
|
+
_fail("$.validators", "expected ordered list")
|
|
286
|
+
result: list[Mapping[str, Any]] = []
|
|
287
|
+
seen: set[str] = set()
|
|
288
|
+
type_names = {"integer", "decimal", "boolean", "string", "null", "datetime",
|
|
289
|
+
"duration", "logical_id", "list", "map", "missing"}
|
|
290
|
+
for index, raw in enumerate(value):
|
|
291
|
+
path = f"$.validators[{index}]"; item = _mapping(raw, path)
|
|
292
|
+
base = {"id", "resource", "kind"}
|
|
293
|
+
if not base <= set(item): _fail(path, "id, resource, and kind are required")
|
|
294
|
+
_symbol(item["id"], path + ".id"); _symbol(item["resource"], path + ".resource")
|
|
295
|
+
if item["id"] in seen: _fail(path + ".id", f"duplicate validator ID {item['id']}")
|
|
296
|
+
seen.add(item["id"]); kind = item["kind"]
|
|
297
|
+
if kind not in {"required", "type", "range", "length", "one_of"}:
|
|
298
|
+
_fail(path + ".kind", "unknown validator kind")
|
|
299
|
+
allowed = base | ({"type"} if kind == "type" else {"min", "max"} if kind in {"range", "length"} else {"values"} if kind == "one_of" else set())
|
|
300
|
+
_only_keys(item, allowed, path)
|
|
301
|
+
parsed = dict(item)
|
|
302
|
+
if kind == "type":
|
|
303
|
+
parsed["type"] = "null" if item.get("type") is None else item.get("type")
|
|
304
|
+
if parsed["type"] not in type_names: _fail(path + ".type", "unsupported semantic type")
|
|
305
|
+
if kind in {"range", "length"}:
|
|
306
|
+
if not ({"min", "max"} & set(item)): _fail(path, "at least one bound is required")
|
|
307
|
+
for bound in {"min", "max"} & set(item):
|
|
308
|
+
parsed[bound] = decode_semantic_value(item[bound], path + "." + bound)
|
|
309
|
+
valid = type(parsed[bound]) in ((int, Decimal) if kind == "range" else (int,))
|
|
310
|
+
if not valid or (kind == "length" and parsed[bound] < 0): _fail(path + "." + bound, "invalid bound")
|
|
311
|
+
if "min" in parsed and "max" in parsed and Decimal(parsed["min"]) > Decimal(parsed["max"]): _fail(path, "minimum exceeds maximum")
|
|
312
|
+
if kind == "one_of":
|
|
313
|
+
if not isinstance(item.get("values"), list) or not item["values"]: _fail(path + ".values", "expected non-empty list")
|
|
314
|
+
parsed["values"] = tuple(decode_semantic_value(candidate, path + ".values") for candidate in item["values"])
|
|
315
|
+
result.append(MappingProxyType(parsed))
|
|
316
|
+
return tuple(result)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _parse_constraints(value: Any) -> tuple[Mapping[str, Any], ...]:
|
|
320
|
+
if not isinstance(value, list): _fail("$.constraints", "expected ordered list")
|
|
321
|
+
result: list[Mapping[str, Any]] = []; seen: set[str] = set()
|
|
322
|
+
for index, raw in enumerate(value):
|
|
323
|
+
path = f"$.constraints[{index}]"; item = _mapping(raw, path)
|
|
324
|
+
_only_keys(item, {"id", "check", "message"}, path)
|
|
325
|
+
if not {"id", "check"} <= set(item): _fail(path, "id and check are required")
|
|
326
|
+
_symbol(item["id"], path + ".id")
|
|
327
|
+
if item["id"] in seen: _fail(path + ".id", f"duplicate constraint ID {item['id']}")
|
|
328
|
+
seen.add(item["id"])
|
|
329
|
+
if "message" in item and not isinstance(item["message"], str): _fail(path + ".message", "expected string")
|
|
330
|
+
_validate_expression(item["check"], path + ".check", constraint=True)
|
|
331
|
+
result.append(MappingProxyType(dict(item)))
|
|
332
|
+
return tuple(result)
|
|
333
|
+
|
|
334
|
+
def _parse_invariants(value):
|
|
335
|
+
if not isinstance(value, list): _fail("$.invariants", "expected ordered list")
|
|
336
|
+
result, seen = [], set()
|
|
337
|
+
for index, raw in enumerate(value):
|
|
338
|
+
path=f"$.invariants[{index}]"; item=_mapping(raw,path); _only_keys(item,{"id","check"},path)
|
|
339
|
+
if set(item)!={"id","check"}: _fail(path,"id and check are required")
|
|
340
|
+
_symbol(item["id"],path+".id")
|
|
341
|
+
if item["id"] in seen: _fail(path+".id",f"duplicate invariant ID {item['id']}")
|
|
342
|
+
seen.add(item["id"]); _validate_expression(item["check"],path+".check")
|
|
343
|
+
def refs(node):
|
|
344
|
+
if isinstance(node, Mapping): return set(node)&{"$local","$derived","$scope"} | set().union(*(refs(v) for v in node.values()))
|
|
345
|
+
if isinstance(node,list): return set().union(*(refs(v) for v in node))
|
|
346
|
+
return set()
|
|
347
|
+
forbidden=refs(item["check"])
|
|
348
|
+
if forbidden: _fail(path+".check",f"{sorted(forbidden)[0]} is not allowed in invariants")
|
|
349
|
+
result.append(MappingProxyType(dict(item)))
|
|
350
|
+
return tuple(result)
|
|
351
|
+
|
|
352
|
+
def _id_list(value,path):
|
|
353
|
+
if not isinstance(value,list) or any(not isinstance(x,str) or not x for x in value): _fail(path,"expected list of non-empty IDs")
|
|
354
|
+
return tuple(value)
|
|
355
|
+
|
|
356
|
+
def _expect(value,path):
|
|
357
|
+
body=_mapping(value,path); _only_keys(body,{"constraints","invariants"},path)
|
|
358
|
+
return MappingProxyType({"constraints":_id_list(body.get("constraints",[]),path+".constraints"),"invariants":_id_list(body.get("invariants",[]),path+".invariants")})
|
|
359
|
+
|
|
360
|
+
def _parse_faults(value, steps):
|
|
361
|
+
if not isinstance(value,list): _fail("$.faults","expected ordered list")
|
|
362
|
+
executable={s.step_id:s for s in steps if s.control_kind is None}; result=[]; seen=set()
|
|
363
|
+
for index,raw in enumerate(value):
|
|
364
|
+
path=f"$.faults[{index}]"; item=_mapping(raw,path); _only_keys(item,{"id","enabled","at","selector","operator","expect","strict_unexpected"},path)
|
|
365
|
+
if not {"id","at","operator"}<=set(item): _fail(path,"id, at, and operator are required")
|
|
366
|
+
_symbol(item["id"],path+".id")
|
|
367
|
+
if item["id"] in seen: _fail(path+".id","duplicate fault ID")
|
|
368
|
+
seen.add(item["id"]); enabled=item.get("enabled",False); strict=item.get("strict_unexpected",True)
|
|
369
|
+
if type(enabled) is not bool or type(strict) is not bool: _fail(path,"enabled and strict_unexpected must be boolean")
|
|
370
|
+
at=item["at"]
|
|
371
|
+
if at not in {"before_validation","before_step"}: _fail(path+".at","unsupported fault hook")
|
|
372
|
+
op=_mapping(item["operator"],path+".operator")
|
|
373
|
+
if len(op)!=1: _fail(path+".operator","exactly one operator required")
|
|
374
|
+
name,body=next(iter(op.items())); allowed={"before_validation":{"override_resource"},"before_step":{"override_write","override_local","suppress_emissions"}}[at]
|
|
375
|
+
if name not in allowed: _fail(path+".operator","unsupported operator at hook")
|
|
376
|
+
parsed={"id":item["id"],"enabled":enabled,"at":at,"operator":MappingProxyType(dict(op)),"expect":_expect(item.get("expect",{}),path+".expect"),"strict_unexpected":strict}
|
|
377
|
+
if at=="before_step":
|
|
378
|
+
selector=_mapping(item.get("selector"),path+".selector"); _only_keys(selector,{"step","subflow_path","repetition_indexes"},path+".selector")
|
|
379
|
+
step=selector.get("step"); _symbol(step,path+".selector.step")
|
|
380
|
+
if step not in executable: _fail(path+".selector.step",f"unknown executable step {step}")
|
|
381
|
+
if "subflow_path" in selector and (not isinstance(selector["subflow_path"],list) or any(not isinstance(x,str) or not x for x in selector["subflow_path"])): _fail(path+".selector.subflow_path","expected control-node ID list")
|
|
382
|
+
if "repetition_indexes" in selector and (not isinstance(selector["repetition_indexes"],list) or any(type(x) is not int or x<0 for x in selector["repetition_indexes"])): _fail(path+".selector.repetition_indexes","expected nonnegative integer list")
|
|
383
|
+
body=_mapping(body,path+f".operator.{name}") if name!="suppress_emissions" else body
|
|
384
|
+
if name=="override_write":
|
|
385
|
+
_only_keys(body,{"path","value"},path); target=body.get("path")
|
|
386
|
+
if target not in executable[step].write: _fail(path,"override_write target is not declared by step")
|
|
387
|
+
_validate_expression(body["value"],path+".value",control=True)
|
|
388
|
+
elif name=="override_local":
|
|
389
|
+
_only_keys(body,{"name","value"},path); target=body.get("name")
|
|
390
|
+
if target not in executable[step].generate: _fail(path,"override_local target is not generated by step")
|
|
391
|
+
_validate_expression(body["value"],path+".value",control=True)
|
|
392
|
+
elif body is not True: _fail(path,"suppress_emissions requires true")
|
|
393
|
+
parsed["selector"]=MappingProxyType({k:tuple(v) if isinstance(v,list) else v for k,v in selector.items()})
|
|
394
|
+
else:
|
|
395
|
+
body=_mapping(body,path+".operator.override_resource"); _only_keys(body,{"path","value"},path)
|
|
396
|
+
_symbol(body.get("path"),path+".path"); _validate_expression(body["value"],path+".value",constraint=True)
|
|
397
|
+
result.append(MappingProxyType(parsed))
|
|
398
|
+
return tuple(result)
|
|
399
|
+
|
|
400
|
+
def _parse_oracle(value):
|
|
401
|
+
if value is None: return None
|
|
402
|
+
body=_mapping(value,"$.oracle"); _only_keys(body,{"expected","strict_unexpected"},"$.oracle")
|
|
403
|
+
strict=body.get("strict_unexpected",True)
|
|
404
|
+
if type(strict) is not bool: _fail("$.oracle.strict_unexpected","expected boolean")
|
|
405
|
+
return MappingProxyType({"expected":_expect(body.get("expected",{}),"$.oracle.expected"),"strict_unexpected":strict})
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _with(value: Any, path: str) -> Mapping[str, Any]:
|
|
409
|
+
bindings = _mapping(value, path)
|
|
410
|
+
for name, expression in bindings.items():
|
|
411
|
+
_symbol(name, path + " key")
|
|
412
|
+
_validate_expression(expression, f"{path}.{name}", control=True)
|
|
413
|
+
return MappingProxyType(dict(bindings))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _target(value: Any, path: str) -> Mapping[str, Any]:
|
|
417
|
+
target = _mapping(value, path)
|
|
418
|
+
_only_keys(target, {"subflow", "with"}, path)
|
|
419
|
+
if "subflow" not in target:
|
|
420
|
+
_fail(path, "subflow is required")
|
|
421
|
+
_symbol(target["subflow"], path + ".subflow")
|
|
422
|
+
return MappingProxyType({"subflow": target["subflow"], "with": _with(target.get("with", {}), path + ".with")})
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _parse_node(raw: Any, path: str, seen: set[str]) -> StepDocument:
|
|
426
|
+
node = _mapping(raw, path)
|
|
427
|
+
_only_keys(node, _NODE_KEYS, path)
|
|
428
|
+
if "id" not in node or "transition" not in node:
|
|
429
|
+
_fail(path, "id and transition are required")
|
|
430
|
+
step_id = node["id"]
|
|
431
|
+
_symbol(step_id, path + ".id")
|
|
432
|
+
if step_id in seen:
|
|
433
|
+
_fail(path + ".id", f"duplicate step ID {step_id} (global node ID)")
|
|
434
|
+
seen.add(step_id)
|
|
435
|
+
controls = [kind for kind in ("call", "branch", "repeat") if kind in node]
|
|
436
|
+
executable = any(key in node for key in ("generate", "derive", "write", "emit", "advance"))
|
|
437
|
+
if len(controls) > 1 or (controls and executable):
|
|
438
|
+
_fail(path, "node must be exactly one executable, call, branch, or repeat node")
|
|
439
|
+
transition = node["transition"]
|
|
440
|
+
if transition is not None:
|
|
441
|
+
_symbol(transition, path + ".transition")
|
|
442
|
+
if controls:
|
|
443
|
+
kind = controls[0]
|
|
444
|
+
body = _mapping(node[kind], path + "." + kind)
|
|
445
|
+
if kind == "call":
|
|
446
|
+
parsed = _target(body, path + ".call")
|
|
447
|
+
elif kind == "branch":
|
|
448
|
+
_only_keys(body, {"cases", "else"}, path + ".branch")
|
|
449
|
+
cases_raw = body.get("cases")
|
|
450
|
+
if not isinstance(cases_raw, list) or not cases_raw:
|
|
451
|
+
_fail(path + ".branch.cases", "expected non-empty ordered list")
|
|
452
|
+
cases = []
|
|
453
|
+
for index, raw_case in enumerate(cases_raw):
|
|
454
|
+
case_path = f"{path}.branch.cases[{index}]"
|
|
455
|
+
case = _mapping(raw_case, case_path)
|
|
456
|
+
_only_keys(case, {"when", "subflow", "with"}, case_path)
|
|
457
|
+
if not {"when", "subflow"} <= set(case):
|
|
458
|
+
_fail(case_path, "when and subflow are required")
|
|
459
|
+
_validate_expression(case["when"], case_path + ".when", control=True)
|
|
460
|
+
target = _target({key: case[key] for key in case if key != "when"}, case_path)
|
|
461
|
+
cases.append(MappingProxyType({"when": case["when"], **target}))
|
|
462
|
+
parsed_dict: dict[str, Any] = {"cases": tuple(cases)}
|
|
463
|
+
if "else" in body:
|
|
464
|
+
parsed_dict["else"] = _target(body["else"], path + ".branch.else")
|
|
465
|
+
parsed = MappingProxyType(parsed_dict)
|
|
466
|
+
else:
|
|
467
|
+
_only_keys(body, {"count", "max", "subflow", "with", "index_as"}, path + ".repeat")
|
|
468
|
+
if not {"count", "max", "subflow"} <= set(body):
|
|
469
|
+
_fail(path + ".repeat", "count, max, and subflow are required")
|
|
470
|
+
_validate_expression(body["count"], path + ".repeat.count", control=True)
|
|
471
|
+
maximum = body["max"]
|
|
472
|
+
if isinstance(maximum, bool) or not isinstance(maximum, int) or not 0 <= maximum <= 100:
|
|
473
|
+
_fail(path + ".repeat.max", "expected literal integer from 0 through 100")
|
|
474
|
+
target = _target({key: body[key] for key in ("subflow", "with") if key in body}, path + ".repeat")
|
|
475
|
+
parsed_dict = {"count": body["count"], "max": maximum, **target}
|
|
476
|
+
if "index_as" in body:
|
|
477
|
+
_symbol(body["index_as"], path + ".repeat.index_as")
|
|
478
|
+
if body["index_as"] in target["with"]:
|
|
479
|
+
_fail(path + ".repeat.index_as", "collides with explicit with binding")
|
|
480
|
+
parsed_dict["index_as"] = body["index_as"]
|
|
481
|
+
parsed = MappingProxyType(parsed_dict)
|
|
482
|
+
empty = MappingProxyType({})
|
|
483
|
+
return StepDocument(step_id, empty, empty, empty, (), timedelta(0), transition,
|
|
484
|
+
parsed if kind == "call" else None,
|
|
485
|
+
parsed if kind == "branch" else None,
|
|
486
|
+
parsed if kind == "repeat" else None)
|
|
487
|
+
generate = _mapping(node.get("generate", {}), path + ".generate")
|
|
488
|
+
derive = _mapping(node.get("derive", {}), path + ".derive")
|
|
489
|
+
write = _mapping(node.get("write", {}), path + ".write")
|
|
490
|
+
for section_name, section, validator in (("generate", generate, _validate_generator), ("derive", derive, _validate_expression), ("write", write, _validate_expression)):
|
|
491
|
+
for name, expression in section.items():
|
|
492
|
+
_symbol(name, f"{path}.{section_name} key")
|
|
493
|
+
validator(expression, f"{path}.{section_name}.{name}")
|
|
494
|
+
emit_raw = node.get("emit", [])
|
|
495
|
+
if not isinstance(emit_raw, list):
|
|
496
|
+
_fail(path + ".emit", "expected ordered list")
|
|
497
|
+
emissions = []
|
|
498
|
+
for index, raw_emission in enumerate(emit_raw):
|
|
499
|
+
emit_path = f"{path}.emit[{index}]"; emission = _mapping(raw_emission, emit_path)
|
|
500
|
+
_only_keys(emission, {"type", "fields"}, emit_path)
|
|
501
|
+
if set(emission) != {"type", "fields"}: _fail(emit_path, "type and fields are required")
|
|
502
|
+
_symbol(emission["type"], emit_path + ".type"); fields = _mapping(emission["fields"], emit_path + ".fields")
|
|
503
|
+
for name, expression in fields.items():
|
|
504
|
+
_symbol(name, emit_path + ".fields key"); _validate_expression(expression, f"{emit_path}.fields.{name}", emission=True)
|
|
505
|
+
emissions.append(MappingProxyType({"type": emission["type"], "fields": MappingProxyType(dict(fields))}))
|
|
506
|
+
advance = _mapping(node.get("advance", {"seconds": 0}), path + ".advance")
|
|
507
|
+
_only_keys(advance, {"seconds"}, path + ".advance")
|
|
508
|
+
seconds = advance.get("seconds")
|
|
509
|
+
if isinstance(seconds, bool) or not isinstance(seconds, int) or seconds < 0: _fail(path + ".advance.seconds", "expected nonnegative integer")
|
|
510
|
+
return StepDocument(step_id, MappingProxyType(dict(generate)), MappingProxyType(dict(derive)), MappingProxyType(dict(write)), tuple(emissions), timedelta(seconds=seconds), transition)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def parse_yaml(text: str) -> ScenarioDocument:
|
|
514
|
+
_reject_ambiguous_yaml_constructs(text)
|
|
515
|
+
try:
|
|
516
|
+
loaded = yaml.load(text, Loader=_DSLLoader)
|
|
517
|
+
except DSLParseError:
|
|
518
|
+
raise
|
|
519
|
+
except yaml.YAMLError as error:
|
|
520
|
+
mark = getattr(error, "problem_mark", None)
|
|
521
|
+
location = f" at line {mark.line + 1}, column {mark.column + 1}" if mark else ""
|
|
522
|
+
raise DSLParseError(f"YAML safe-load failed{location}") from None
|
|
523
|
+
root = _mapping(loaded, "$")
|
|
524
|
+
_only_keys(root, _TOP_KEYS, "$")
|
|
525
|
+
missing = sorted(_REQUIRED_TOP_KEYS - set(root))
|
|
526
|
+
if missing:
|
|
527
|
+
_fail("$", "missing required key(s): " + ", ".join(missing))
|
|
528
|
+
version = root["dsl_version"]
|
|
529
|
+
if isinstance(version, bool) or not isinstance(version, int) or version != 1:
|
|
530
|
+
raise UnsupportedDSLVersionError("$.dsl_version: supported version is integer 1")
|
|
531
|
+
scenario_id = root["scenario"]
|
|
532
|
+
_symbol(scenario_id, "$.scenario")
|
|
533
|
+
clock = _mapping(root["clock"], "$.clock")
|
|
534
|
+
_only_keys(clock, {"start"}, "$.clock")
|
|
535
|
+
if set(clock) != {"start"}:
|
|
536
|
+
_fail("$.clock", "start is required")
|
|
537
|
+
reference = _parse_datetime(clock["start"], "$.clock.start")
|
|
538
|
+
initial_raw = _mapping(root["initial_state"], "$.initial_state")
|
|
539
|
+
initial = decode_semantic_value(initial_raw, "$.initial_state")
|
|
540
|
+
try:
|
|
541
|
+
normalize(initial)
|
|
542
|
+
except (TypeError, ValueError) as error:
|
|
543
|
+
_fail("$.initial_state", str(error))
|
|
544
|
+
resources_raw = _mapping(root.get("resources", {}), "$.resources")
|
|
545
|
+
if "resources" in root and not resources_raw:
|
|
546
|
+
_fail("$.resources", "declared resource mapping must be non-empty")
|
|
547
|
+
resources: dict[str, Any] = {}
|
|
548
|
+
for name, raw_resource in resources_raw.items():
|
|
549
|
+
_symbol(name, "$.resources key")
|
|
550
|
+
resources[name] = _decode_resource(raw_resource, f"$.resources.{name}")
|
|
551
|
+
validators = _parse_validators(root.get("validators", []))
|
|
552
|
+
if "constraints" in root and not root["constraints"]:
|
|
553
|
+
_fail("$.constraints", "declared constraint list must be non-empty")
|
|
554
|
+
constraints = _parse_constraints(root.get("constraints", []))
|
|
555
|
+
steps_raw = root["steps"]
|
|
556
|
+
if not isinstance(steps_raw, list) or not steps_raw: _fail("$.steps", "expected non-empty ordered list")
|
|
557
|
+
seen: set[str] = set()
|
|
558
|
+
steps = [_parse_node(raw, f"$.steps[{index}]", seen) for index, raw in enumerate(steps_raw)]
|
|
559
|
+
subflows_raw = _mapping(root.get("subflows", {}), "$.subflows")
|
|
560
|
+
if "subflows" in root and not subflows_raw: _fail("$.subflows", "declared subflow mapping must be non-empty")
|
|
561
|
+
subflows: dict[str, tuple[StepDocument, ...]] = {}
|
|
562
|
+
for name, raw_subflow in subflows_raw.items():
|
|
563
|
+
_symbol(name, "$.subflows key")
|
|
564
|
+
definition = _mapping(raw_subflow, f"$.subflows.{name}")
|
|
565
|
+
_only_keys(definition, {"steps"}, f"$.subflows.{name}")
|
|
566
|
+
body = definition.get("steps")
|
|
567
|
+
if not isinstance(body, list) or not body: _fail(f"$.subflows.{name}.steps", "expected non-empty ordered list")
|
|
568
|
+
subflows[name] = tuple(_parse_node(raw, f"$.subflows.{name}.steps[{index}]", seen) for index, raw in enumerate(body))
|
|
569
|
+
all_steps=tuple(steps)+tuple(step for flow in subflows.values() for step in flow)
|
|
570
|
+
invariants=_parse_invariants(root.get("invariants",[]))
|
|
571
|
+
faults=_parse_faults(root.get("faults",[]),all_steps)
|
|
572
|
+
oracle=_parse_oracle(root.get("oracle"))
|
|
573
|
+
return ScenarioDocument(1, scenario_id, reference, MappingProxyType(dict(initial)), tuple(steps),
|
|
574
|
+
MappingProxyType(resources), validators, constraints, MappingProxyType(subflows), invariants, faults, oracle)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def parse_yaml_file(path: str | Path) -> ScenarioDocument:
|
|
578
|
+
try:
|
|
579
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
580
|
+
except (OSError, UnicodeError) as error:
|
|
581
|
+
raise DSLParseError(f"unable to read YAML document: {error}") from None
|
|
582
|
+
return parse_yaml(text)
|