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.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- 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
|
+
}
|