dynamic-systems-testing-framework 1.0.1__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 (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,538 @@
1
+ """Parse external test specification files (test_spec.json)."""
2
+
3
+ import json
4
+ import logging
5
+ import math
6
+ from collections.abc import Callable, Iterable
7
+ from pathlib import Path
8
+
9
+ from ..config import ConfigError, baseline_points_refused
10
+ from ..storage.ref_index import _atomic_write_text
11
+ from . import spec_shape
12
+ from .test_registry import TestModel
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class EntryKeyError(ValueError):
18
+ """A key in a test entry that nothing reads: ``where`` it sits
19
+ (``tests[Lib.M].simulation``) and the ``message`` that names it."""
20
+
21
+ def __init__(self, where: str, message: str) -> None:
22
+ self.where = where
23
+ self.message = message
24
+ super().__init__(f"{where}: {message}")
25
+
26
+
27
+ def check_entry_keys(entry: dict, model_id: str) -> None:
28
+ """Refuse a key of ``entry`` that nothing reads (``strict_keys``): one
29
+ of its own, of its ``simulation`` or ``comparison`` block, or of a flat
30
+ override — which is one metric's params, as a leaf's are. The
31
+ ``metrics`` tree and ``plots`` have their own parsers, each as strict.
32
+
33
+ ``"simulaton": {"stop_time": 100}`` used to be skipped without a word
34
+ and the test ran for the default second. The known keys are
35
+ ``spec_shape``'s — the table ``export-schema`` publishes.
36
+
37
+ And refuse a block that is no object where one is read — ``simulation``,
38
+ ``comparison``, ``variable_overrides`` and each override in it; a
39
+ ``null`` too: absent is the only "not set". ``"simulation":
40
+ "stop_time=5"`` was a warning and a run on defaults,
41
+ ``"variable_overrides": [1]`` and ``{"x": "tube"}`` a traceback — the
42
+ second one at compare.
43
+
44
+ And refuse a value the parser reads that is not what it reads there:
45
+ ``variables`` that is no list of strings (``"x"`` was a warning and a
46
+ test tracking nothing, ``[1]`` a traceback at compare), a ``source``
47
+ that is no string, a ``simulation`` value of another type than
48
+ ``spec_shape.SIMULATION`` gives its key (``"stop_time": "abc"`` was a
49
+ warning and a run to the default second; ``"100"`` and ``true`` were
50
+ converted, ``2.5`` intervals cut to 2). ``null`` there stays "not set".
51
+
52
+ Raises :class:`EntryKeyError`.
53
+ """
54
+ from ..comparison.sides import _as_json
55
+ from ..comparison.strict_keys import unknown_field, unread_override_key
56
+
57
+ here = f"tests[{model_id}]"
58
+
59
+ def refuse(where: str, keys: Iterable[str], known: Iterable[str]) -> None:
60
+ problem = unknown_field(keys, known)
61
+ if problem is not None:
62
+ raise EntryKeyError(where, problem)
63
+
64
+ def block(where: str, holder: dict, key: str) -> dict | None:
65
+ """``holder[key]`` when it is there; an object, or refused."""
66
+ if key not in holder:
67
+ return None
68
+ value = holder[key]
69
+ if not isinstance(value, dict):
70
+ raise EntryKeyError(
71
+ f"{where}.{key}", f"must be an object, got {_as_json(value)}"
72
+ )
73
+ return value
74
+
75
+ refuse(here, entry, spec_shape.ENTRY)
76
+ if "variables" in entry:
77
+ variables = entry["variables"]
78
+ if not isinstance(variables, list):
79
+ raise EntryKeyError(
80
+ f"{here}.variables",
81
+ f"must be a list of names or patterns, got {_as_json(variables)}",
82
+ )
83
+ for i, pattern in enumerate(variables):
84
+ if not isinstance(pattern, str):
85
+ raise EntryKeyError(
86
+ f"{here}.variables[{i}]",
87
+ f"must be a name or a pattern, got {_as_json(pattern)}",
88
+ )
89
+ if "source" in entry and not isinstance(entry["source"], str):
90
+ raise EntryKeyError(
91
+ f"{here}.source", f"must be a path, got {_as_json(entry['source'])}"
92
+ )
93
+ simulation = block(here, entry, "simulation")
94
+ if simulation is not None:
95
+ refuse(f"{here}.simulation", simulation, spec_shape.SIMULATION)
96
+ for key, value in simulation.items():
97
+ # `null` is "not set", as this module's own example writes it.
98
+ if value is None:
99
+ continue
100
+ kind = spec_shape.SIMULATION[key]["type"]
101
+ if not _HOLDS[kind](value):
102
+ raise EntryKeyError(
103
+ f"{here}.simulation.{key}",
104
+ f"must be {_SAID[kind]}, got {_as_json(value)}",
105
+ )
106
+ comparison = block(here, entry, "comparison")
107
+ if comparison is None:
108
+ return
109
+ refuse(f"{here}.comparison", comparison, spec_shape.COMPARISON)
110
+ overrides = block(f"{here}.comparison", comparison, "variable_overrides")
111
+ if overrides is None:
112
+ return
113
+ for variable, override in overrides.items():
114
+ where = f"{here}.comparison.variable_overrides[{variable}]"
115
+ if not isinstance(override, dict):
116
+ raise EntryKeyError(where, f"must be an object, got {_as_json(override)}")
117
+ found = unread_override_key(override)
118
+ if found is not None:
119
+ mode, problem = found
120
+ raise EntryKeyError(where, f"{mode} · {variable}: {problem}")
121
+
122
+
123
+ def _is_number(value: object) -> bool:
124
+ """A JSON number that is one: no bool, no NaN, no infinity — and no
125
+ integer too large for a float (a 1 and 400 zeros), which no simulator
126
+ can be handed: ``math.isfinite`` raises on it."""
127
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
128
+ return False
129
+ try:
130
+ return math.isfinite(value)
131
+ except OverflowError:
132
+ return False
133
+
134
+
135
+ #: A JSON-Schema ``type`` of ``spec_shape.SIMULATION`` → whether a value is
136
+ #: one, and how a refusal says it. An integral float is a whole number, as
137
+ #: it is to JSON Schema.
138
+ _HOLDS: dict[str, Callable[[object], bool]] = {
139
+ "number": _is_number,
140
+ "integer": lambda v: _is_number(v) and float(v).is_integer(), # type: ignore[arg-type]
141
+ "string": lambda v: isinstance(v, str),
142
+ }
143
+ _SAID = {"number": "a number", "integer": "a whole number", "string": "a string"}
144
+
145
+
146
+ def check_root_keys(data: object, label: str) -> list:
147
+ """The spec document's ``tests`` list, or :class:`ConfigError` naming
148
+ ``label`` when the document cannot hold one: a root that is no object,
149
+ a root key nothing reads (``spec_shape.ROOT``), a ``tests`` that is no
150
+ list. Each used to be a spec of NO tests — an error in the log, or
151
+ nothing at all for ``{"Tests": [...]}`` — and the command went on
152
+ without them, exit 0."""
153
+ from ..comparison.sides import _as_json
154
+ from ..comparison.strict_keys import unknown_field
155
+
156
+ if not isinstance(data, dict):
157
+ raise ConfigError(f"{label}: must be an object, got {_as_json(data)}")
158
+ problem = unknown_field(data, spec_shape.ROOT)
159
+ if problem is not None:
160
+ raise ConfigError(f"{label}: {problem}")
161
+ tests = data.get("tests", [])
162
+ if not isinstance(tests, list):
163
+ raise ConfigError(f"{label}: tests: must be a list, got {_as_json(tests)}")
164
+ return tests
165
+
166
+
167
+ def spec_document_from_bytes(raw: bytes, label: str) -> dict:
168
+ """The spec document a file's bytes hold — its root object, ``tests``
169
+ there (empty when the file names none) — or :class:`ConfigError` naming
170
+ ``label``: bytes that are no UTF-8 text, text that is no JSON, a root
171
+ that cannot hold a ``tests`` list (:func:`check_root_keys`).
172
+
173
+ THE one decoder of a ``test_spec.json``. Every reader comes through it —
174
+ discovery, ``spec-update`` and the edit helpers (:func:`read_spec_document`)
175
+ and the authoring Session, which hashes the same bytes — so a file is
176
+ read by all of them or by none, with one sentence. ``discover`` /
177
+ ``run`` / ``compare`` logged ``Failed to read test spec`` and went on
178
+ with a spec of no tests (exit 1, ``No tests found.``), and a UTF-16 or
179
+ binary file was a traceback out of every command but ``dstf author``.
180
+
181
+ ``utf-8-sig``: a spec a Windows editor saved keeps a BOM, and a BOM is
182
+ not a reason to refuse a file that otherwise parses."""
183
+ try:
184
+ text = raw.decode("utf-8-sig")
185
+ except UnicodeDecodeError as e:
186
+ raise ConfigError(f"{label}: not UTF-8 text ({e})") from e
187
+ if not text.strip():
188
+ raise ConfigError(f"{label}: the file is empty")
189
+ try:
190
+ data = json.loads(text)
191
+ except json.JSONDecodeError as e:
192
+ raise ConfigError(f"{label}: not JSON ({e})") from e
193
+ tests = check_root_keys(data, label)
194
+ assert isinstance(data, dict) # check_root_keys
195
+ data.setdefault("tests", tests)
196
+ return data
197
+
198
+
199
+ def read_spec_document(spec_path: Path) -> dict | None:
200
+ """The spec document in ``spec_path`` (:func:`spec_document_from_bytes`),
201
+ or ``None`` when there is no such file — a project without a spec file
202
+ has no spec tests. A file that is there and cannot be opened is a
203
+ :class:`ConfigError` with the system's reason."""
204
+ try:
205
+ raw = spec_path.read_bytes()
206
+ except FileNotFoundError:
207
+ return None
208
+ except OSError as e:
209
+ raise ConfigError(f"{spec_path}: {e}") from e
210
+ return spec_document_from_bytes(raw, str(spec_path))
211
+
212
+
213
+ def _load_spec_for_edit(spec_path: Path) -> dict:
214
+ """Load a spec about to be modified in place.
215
+
216
+ A missing file is a new spec (``{"tests": []}``). An EXISTING file that
217
+ cannot be read or parsed raises :class:`ConfigError`
218
+ (:func:`read_spec_document`): the edit helpers below load-modify-write,
219
+ so swallowing the parse error and carrying on with an empty dict
220
+ replaced the user's hand-authored spec with a near-empty one on a
221
+ single JSON typo.
222
+ """
223
+ data = read_spec_document(spec_path)
224
+ return {"tests": []} if data is None else data
225
+
226
+
227
+ def _write_spec(spec_path: Path, data: dict) -> None:
228
+ """Persist an edited spec atomically — an interrupted write must not
229
+ leave the user's spec truncated."""
230
+ spec_path.parent.mkdir(parents=True, exist_ok=True)
231
+ _atomic_write_text(spec_path, json.dumps(data, indent=2) + "\n")
232
+
233
+
234
+ def _spec_number(section: dict, key: str, conv):
235
+ """``conv(section[key])``, or None when absent or ``null`` — "not set"
236
+ (this module's own docstring shows ``"output_interval": null``). The
237
+ value is a number: :func:`check_entry_keys` refused anything else."""
238
+ raw = section.get(key)
239
+ return None if raw is None else conv(raw)
240
+
241
+
242
+ def _entries(data: dict) -> list:
243
+ """The spec's tests list, tolerating a missing/malformed key."""
244
+ tests = data.get("tests")
245
+ return tests if isinstance(tests, list) else []
246
+
247
+
248
+ def _find_entry_indices(data: dict, model_id: str) -> list[int]:
249
+ """Indices of dict entries matching ``model_id``, warning on duplicates.
250
+
251
+ review 2026-07-06 finding 51: duplicate model entries — the FIRST wins
252
+ everywhere (discovery, patch_apply, and these edit helpers), each site
253
+ warning so the user notices the dead entries.
254
+ """
255
+ matches = [
256
+ i
257
+ for i, e in enumerate(_entries(data))
258
+ if isinstance(e, dict) and e.get("model") == model_id
259
+ ]
260
+ if len(matches) > 1:
261
+ logger.warning(
262
+ "test_spec has %d duplicate entries for model '%s'; "
263
+ "using the first — remove the duplicates",
264
+ len(matches),
265
+ model_id,
266
+ )
267
+ return matches
268
+
269
+
270
+ def parse_test_spec(spec_path: Path) -> list[TestModel]:
271
+ """Parse a test_spec.json file into TestModel entries.
272
+
273
+ Format:
274
+ {
275
+ "tests": [
276
+ {
277
+ "model": "MyLib.Examples.PipeTest",
278
+ "variables": ["pipe.T[1]", "medium.T*"],
279
+ "simulation": {
280
+ "stop_time": 100,
281
+ "tolerance": 1e-4,
282
+ "method": "Dassl",
283
+ "number_of_intervals": 500,
284
+ "output_interval": null,
285
+ "timeout": 120
286
+ },
287
+ "comparison": {
288
+ "tolerance": 0.05,
289
+ "variable_overrides": {
290
+ "pipe.T[1]": {"tolerance": 0.1}
291
+ }
292
+ },
293
+ "baseline_points": 2000
294
+ }
295
+ ]
296
+ }
297
+
298
+ Minimal entry (all defaults):
299
+ {"model": "MyLib.Examples.Simple", "variables": ["x"]}
300
+
301
+ Variable patterns:
302
+ - Explicit: "pipe.T[1]" — exact variable name
303
+ - Glob: "medium.T*" — matched against .mat variable names after simulation
304
+ - Empty list [] — simulate only, no variable comparison
305
+ - Wildcard ["*"] — track all variables
306
+
307
+ Reads the file (:func:`read_spec_document`: a file that is no spec is
308
+ a :class:`ConfigError`, a missing one a spec of no tests), then hands
309
+ the document to :func:`spec_models_from_document`; the authoring
310
+ Session calls that directly with its in-memory document.
311
+ """
312
+ data = read_spec_document(spec_path)
313
+ if data is None:
314
+ return []
315
+ return spec_models_from_document(
316
+ data, base_dir=spec_path.parent, label=str(spec_path)
317
+ )
318
+
319
+
320
+ def spec_models_from_document(
321
+ data: object, *, base_dir: Path, label: str
322
+ ) -> list[TestModel]:
323
+ """TestModels from an already-parsed spec document.
324
+
325
+ ``base_dir`` anchors each entry's relative ``source``; ``label`` names the
326
+ document in log messages and in the ``ConfigError`` a bad spec raises —
327
+ a root that holds no ``tests`` list (:func:`check_root_keys`), an entry
328
+ key nothing reads, a bad ``metrics`` tree. Not named ``test_...``:
329
+ pytest collects such a name when a test module imports it.
330
+ """
331
+ from ..comparison.sides import _as_json
332
+
333
+ tests_data = check_root_keys(data, label)
334
+
335
+ tests = []
336
+ for i, entry in enumerate(tests_data):
337
+ # An entry that cannot be a test is an error that names its place,
338
+ # like a key nothing reads: it was a warning and a test skipped,
339
+ # which a run of 300 others does not show.
340
+ if not isinstance(entry, dict):
341
+ raise ConfigError(
342
+ f"{label}: tests[{i}]: must be an object, got {_as_json(entry)}"
343
+ )
344
+ model_id = entry.get("model")
345
+ if model_id is None or model_id == "":
346
+ raise ConfigError(f'{label}: tests[{i}]: has no "model"')
347
+ if not isinstance(model_id, str):
348
+ raise ConfigError(
349
+ f"{label}: tests[{i}].model: must be a model's name, "
350
+ f"got {_as_json(model_id)}"
351
+ )
352
+
353
+ # A key nothing reads is an error that names it, like a malformed
354
+ # tree: one "Config error: ..." line and exit 2.
355
+ try:
356
+ check_entry_keys(entry, model_id)
357
+ except EntryKeyError as e:
358
+ raise ConfigError(f"{label}: {e}") from e
359
+
360
+ variables = entry.get("variables", []) # a list of strings: ditto
361
+
362
+ parts = model_id.rsplit(".", 1)
363
+ source_package = parts[0] if len(parts) > 1 else ""
364
+ short_name = parts[-1]
365
+
366
+ # Optional "source" field: path (relative to spec file) to the
367
+ # simulation source — .jl for Julia, .py for Python, .fmu for FMPy.
368
+ # Modelica tests omit this and source_file stays empty (the .mo
369
+ # lives in the package discovered via source_package).
370
+ source_file = Path("")
371
+ source_rel = entry.get("source")
372
+ if source_rel:
373
+ source_path_resolved = (base_dir / source_rel).resolve()
374
+ if not source_path_resolved.exists():
375
+ logger.warning(
376
+ "Test '%s' references missing source file: %s",
377
+ model_id,
378
+ source_path_resolved,
379
+ )
380
+ source_file = source_path_resolved
381
+
382
+ test = TestModel(
383
+ model_id=model_id,
384
+ source_file=source_file,
385
+ source_package=source_package,
386
+ short_name=short_name,
387
+ n_vars=0, # Will be resolved after simulation for pattern-based
388
+ variable_patterns=variables,
389
+ source="spec",
390
+ )
391
+
392
+ # Simulation settings. Fields the spec actually sets are stamped with
393
+ # provenance "test_spec" here (review 2026-07-06 finding 57) — unset
394
+ # fields stay None and get provenance "default" when
395
+ # TestModel.finalize_defaults runs at the end of discover_tests.
396
+ sim = entry.get("simulation", {}) # an object: `check_entry_keys`
397
+ stop_time = _spec_number(sim, "stop_time", float)
398
+ if stop_time is not None:
399
+ test.stop_time = stop_time
400
+ test.field_sources["stop_time"] = "test_spec"
401
+ tolerance = _spec_number(sim, "tolerance", float)
402
+ if tolerance is not None:
403
+ test.tolerance = tolerance
404
+ test.field_sources["tolerance"] = "test_spec"
405
+ if sim.get("method") is not None:
406
+ test.method = str(sim["method"])
407
+ test.field_sources["method"] = "test_spec"
408
+ n_intervals = _spec_number(sim, "number_of_intervals", int)
409
+ if n_intervals is not None:
410
+ test.number_of_intervals = n_intervals
411
+ test.field_sources["number_of_intervals"] = "test_spec"
412
+ output_interval = _spec_number(sim, "output_interval", float)
413
+ if output_interval is not None:
414
+ test.output_interval = output_interval
415
+ test.field_sources["output_interval"] = "test_spec"
416
+ timeout = _spec_number(sim, "timeout", int)
417
+ if timeout is not None:
418
+ test.timeout = timeout
419
+
420
+ # Comparison settings
421
+ comp = entry.get("comparison", {}) # an object: `check_entry_keys`
422
+ # The base tolerance is carried as written, number or not. One that
423
+ # is no number (a lower/upper pair, a string, a list) refuses the
424
+ # test at compare (`refused_base_tolerance`); dropped here like a simulation
425
+ # value, the test was scored — and could pass — at the project's.
426
+ raw_tolerance = comp.get("tolerance")
427
+ if raw_tolerance is not None:
428
+ from ..comparison.sides import is_base_tolerance
429
+
430
+ test.comparison_tolerance = (
431
+ float(raw_tolerance)
432
+ if is_base_tolerance(raw_tolerance)
433
+ else raw_tolerance
434
+ )
435
+ if "variable_overrides" in comp:
436
+ test.variable_overrides = comp["variable_overrides"]
437
+
438
+ # Phase 3.2: optional MetricTree spec. Parse-only — Phase 3.3 wires
439
+ # it into compare_test. Errors here are raised so a malformed spec
440
+ # is loud at discovery, not silently ignored at compare time.
441
+ if "metrics" in entry: # null is refused: absent is the only "no tree"
442
+ from ..comparison.tree_spec import MetricSpecError, parse_metric_tree
443
+
444
+ try:
445
+ test.metric_tree_spec = parse_metric_tree(
446
+ entry["metrics"],
447
+ _path=f"tests[{model_id}].metrics",
448
+ )
449
+ except MetricSpecError as e:
450
+ # Still loud at discovery (never silently ignored), but as a
451
+ # user error: one "Config error: ..." line and exit 2 rather
452
+ # than a traceback out of the parser.
453
+ raise ConfigError(f"{label}: {e}") from e
454
+
455
+ if "plots" in entry: # null is refused: absent is the only default
456
+ from .plots import PlotsSpecError, parse_plots
457
+
458
+ try:
459
+ test.plots = parse_plots(
460
+ entry["plots"], path=f"tests[{model_id}].plots"
461
+ )
462
+ except PlotsSpecError as e:
463
+ raise ConfigError(f"{label}: {e}") from e
464
+
465
+ # The test's own baseline points (D153), over the project's. Refused
466
+ # like `plots`, null included: a value that is neither form would
467
+ # otherwise store this test's baseline at the project's setting.
468
+ if "baseline_points" in entry:
469
+ refused = baseline_points_refused(
470
+ f"tests[{model_id}].baseline_points", entry["baseline_points"]
471
+ )
472
+ if refused is not None:
473
+ raise ConfigError(f"{label}: {refused}")
474
+ test.baseline_points = entry["baseline_points"]
475
+
476
+ tests.append(test)
477
+
478
+ return tests
479
+
480
+
481
+ def add_to_test_spec(
482
+ spec_path: Path,
483
+ model_id: str,
484
+ variables: list[str],
485
+ overwrite: bool = False,
486
+ ) -> bool:
487
+ """Add or update a test entry in test_spec.json.
488
+
489
+ Returns True if the entry was added/updated, False if it already existed
490
+ and overwrite was not set.
491
+ """
492
+ data = _load_spec_for_edit(spec_path)
493
+
494
+ # Check if model already exists (non-dict entries skipped — finding 53;
495
+ # duplicates: first wins with a warning — finding 51)
496
+ matches = _find_entry_indices(data, model_id)
497
+
498
+ if matches:
499
+ if not overwrite:
500
+ return False
501
+ # review 2026-07-06 finding 52: overwrite replaces only
502
+ # model + variables — hand-authored comparison/simulation/metrics
503
+ # and notes must survive.
504
+ entry = data["tests"][matches[0]]
505
+ entry["model"] = model_id
506
+ entry["variables"] = variables
507
+ else:
508
+ data["tests"].append({"model": model_id, "variables": variables})
509
+
510
+ _write_spec(spec_path, data)
511
+ return True
512
+
513
+
514
+ def update_test_variables(
515
+ spec_path: Path,
516
+ model_id: str,
517
+ additional_patterns: list[str],
518
+ ) -> None:
519
+ """Add variable patterns to an existing test entry, or create a new one."""
520
+ data = _load_spec_for_edit(spec_path)
521
+
522
+ # Find existing entry (non-dict entries skipped, duplicates warned —
523
+ # review 2026-07-06 findings 53/51)
524
+ matches = _find_entry_indices(data, model_id)
525
+ if matches:
526
+ entry = data["tests"][matches[0]]
527
+ existing = set(entry.get("variables", []))
528
+ existing.update(additional_patterns)
529
+ entry["variables"] = sorted(existing)
530
+ else:
531
+ data["tests"].append(
532
+ {
533
+ "model": model_id,
534
+ "variables": sorted(additional_patterns),
535
+ }
536
+ )
537
+
538
+ _write_spec(spec_path, data)
@@ -0,0 +1,122 @@
1
+ """The shape of ``test_spec.json`` — its root and one entry: the keys, and
2
+ what each holds.
3
+
4
+ ONE table, read twice. The spec parser refuses a key that is not in
5
+ it — ``"simulaton"`` used to be skipped in silence, and the test ran on
6
+ defaults — and ``export-schema`` publishes it with ``additionalProperties:
7
+ false``. So the grammar an editor checks and the one the CLI enforces
8
+ cannot name different keys.
9
+
10
+ Each value is the key's JSON-Schema fragment (draft 2020-12). ``metrics``
11
+ refers to the tree grammar, which :mod:`dstf.reporting.schema_export`
12
+ builds from the metric registry.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ #: One variable of the result: not blank, not a pattern.
20
+ _PLOT_NAME = {"type": "string", "pattern": "^[^*?]*[^*?\\s][^*?]*$"}
21
+
22
+ #: A person's notes. Read by nothing, by design — the documented place for
23
+ #: free text beside an entry or its comparison settings, so that no other
24
+ #: unread key has to be tolerated to leave room for one. ``metadata`` is an
25
+ #: object of any shape.
26
+ NOTES: dict[str, Any] = {
27
+ "description": {"type": "string"},
28
+ "info": {"type": "string"},
29
+ "metadata": {"type": "object"},
30
+ }
31
+
32
+ #: The ``simulation`` block: every field ``spec_parser`` reads from it.
33
+ SIMULATION: dict[str, Any] = {
34
+ "stop_time": {"type": "number"},
35
+ "tolerance": {"type": "number"},
36
+ "method": {"type": "string"},
37
+ "number_of_intervals": {"type": "integer"},
38
+ "output_interval": {"type": "number"},
39
+ "timeout": {"type": "integer"},
40
+ }
41
+
42
+ #: The ``comparison`` block. Each ``variable_overrides`` entry is one
43
+ #: metric's flat params (``#/$defs/override``): ``mode`` and that mode's
44
+ #: fields.
45
+ COMPARISON: dict[str, Any] = {
46
+ "tolerance": {"type": "number"},
47
+ "variable_overrides": {
48
+ "type": "object",
49
+ "additionalProperties": {"$ref": "#/$defs/override"},
50
+ },
51
+ **NOTES,
52
+ }
53
+
54
+ #: A test entry's own keys.
55
+ ENTRY: dict[str, Any] = {
56
+ "model": {"type": "string"},
57
+ "source": {
58
+ "type": "string",
59
+ "description": (
60
+ "Path to the simulation source file "
61
+ "(.jl / .py / .fmu), relative to the spec file."
62
+ ),
63
+ },
64
+ "variables": {"type": "array", "items": {"type": "string"}},
65
+ "simulation": {
66
+ "type": "object",
67
+ "properties": SIMULATION,
68
+ "additionalProperties": False,
69
+ },
70
+ "comparison": {
71
+ "type": "object",
72
+ "properties": COMPARISON,
73
+ "additionalProperties": False,
74
+ },
75
+ "metrics": {"$ref": "#/$defs/tree_node"},
76
+ "plots": {
77
+ "type": "array",
78
+ "description": (
79
+ "A person's plot extras, read by the report and the authoring "
80
+ "tool and ignored by scoring. A test's plots are derived from "
81
+ "its criteria tree; an entry on a scored variable adds grey "
82
+ "context series to its plot, an entry on an unscored one is a "
83
+ "watch plot. Absent or []: no extras."
84
+ ),
85
+ "items": {
86
+ "type": "object",
87
+ "properties": {
88
+ # One variable, not blank, not a pattern — what
89
+ # `parse_plots` accepts.
90
+ "var": _PLOT_NAME,
91
+ "context": {
92
+ "type": "array",
93
+ "uniqueItems": True,
94
+ "items": _PLOT_NAME,
95
+ },
96
+ },
97
+ "required": ["var"],
98
+ "additionalProperties": False,
99
+ },
100
+ },
101
+ "baseline_points": {
102
+ "description": (
103
+ "The points this test's stored baseline keeps of its run: "
104
+ '"all" — every point the simulator wrote — or a whole '
105
+ "number of 2 or more, which thins a longer run to at most "
106
+ "that many. Overrides testing.json's baseline_points; "
107
+ "absent, that one applies."
108
+ ),
109
+ # What `config.baseline_points_refused` accepts — but for
110
+ # `2000.0`, an integer to JSON Schema and refused there.
111
+ "oneOf": [{"const": "all"}, {"type": "integer", "minimum": 2}],
112
+ },
113
+ **NOTES,
114
+ }
115
+
116
+ #: The spec file's own keys: the tests, and the ``$schema`` an editor reads
117
+ #: to check the file as it is typed (``export-schema`` writes the schema to
118
+ #: point it at). ``{"Tests": [...]}`` used to be a spec of no tests.
119
+ ROOT: dict[str, Any] = {
120
+ "$schema": {"type": "string"},
121
+ "tests": {"type": "array", "items": {"$ref": "#/$defs/test_entry"}},
122
+ }