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
dstf/config.py
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
"""Configuration constants and path resolution for the testing system."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import platform
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# Default simulation parameters (Dymola defaults)
|
|
14
|
+
DEFAULT_STOP_TIME = 1.0
|
|
15
|
+
DEFAULT_TOLERANCE = 1e-4
|
|
16
|
+
DEFAULT_METHOD = "Dassl"
|
|
17
|
+
DEFAULT_NUMBER_OF_INTERVALS = 500
|
|
18
|
+
|
|
19
|
+
# Comparison defaults
|
|
20
|
+
DEFAULT_COMPARISON_TOLERANCE = 1e-4
|
|
21
|
+
|
|
22
|
+
# `baseline_points` for every point the run wrote — the default (D153).
|
|
23
|
+
ALL_POINTS = "all"
|
|
24
|
+
|
|
25
|
+
# Config filename looked for near the package or in working directory
|
|
26
|
+
CONFIG_FILENAME = "testing.json"
|
|
27
|
+
|
|
28
|
+
# Maps a user-supplied backend name prefix to its canonical family:
|
|
29
|
+
# "Dymola 2026x" -> "Dymola". NOT the set of registered backends.
|
|
30
|
+
BACKEND_FAMILIES = {
|
|
31
|
+
"Dymola": "Dymola",
|
|
32
|
+
"OpenModelica": "OpenModelica",
|
|
33
|
+
"FMPy": "FMPy",
|
|
34
|
+
"Julia": "Julia",
|
|
35
|
+
# review 2026-07-06 finding 67: "Python" was missing, so the documented
|
|
36
|
+
# versioned-name convention ("Python 3.12") failed backend detection
|
|
37
|
+
# with "Unsupported backend".
|
|
38
|
+
"Python": "Python",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Binary names by backend, used for PATH-lookup fallback when testing.json
|
|
42
|
+
# doesn't list an explicit path. Backend-name lowercase happens to match for
|
|
43
|
+
# Dymola; OpenModelica's binary is ``omc`` (not ``openmodelica``). An empty
|
|
44
|
+
# string marks backends that don't ship a standalone binary (FMPy is a
|
|
45
|
+
# Python library — backend_path is unused for it).
|
|
46
|
+
BACKEND_BINARY_NAMES = {
|
|
47
|
+
"Dymola": "dymola",
|
|
48
|
+
"OpenModelica": "omc",
|
|
49
|
+
"FMPy": "",
|
|
50
|
+
"Julia": "julia",
|
|
51
|
+
"Python": "python",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _detect_backend(backend_name: str) -> str:
|
|
56
|
+
"""Determine the canonical backend family from a named entry.
|
|
57
|
+
|
|
58
|
+
E.g., "Dymola 2025" -> "Dymola", "OpenModelica 1.23" -> "OpenModelica"
|
|
59
|
+
"""
|
|
60
|
+
for prefix, backend in BACKEND_FAMILIES.items():
|
|
61
|
+
if backend_name.startswith(prefix):
|
|
62
|
+
return backend
|
|
63
|
+
return backend_name
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def detect_os() -> str:
|
|
67
|
+
"""Detect the current OS for reference result partitioning."""
|
|
68
|
+
system = platform.system().lower()
|
|
69
|
+
return {"linux": "linux", "darwin": "macos", "windows": "windows"}.get(
|
|
70
|
+
system, system
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def find_package_dir(start: Path | None = None) -> Path:
|
|
75
|
+
"""Find a Modelica package directory containing package.mo.
|
|
76
|
+
|
|
77
|
+
If start is a directory containing package.mo, returns it directly.
|
|
78
|
+
Otherwise walks up looking for a directory that contains a subdirectory
|
|
79
|
+
with package.mo.
|
|
80
|
+
"""
|
|
81
|
+
if start is None:
|
|
82
|
+
start = Path.cwd()
|
|
83
|
+
path = start.resolve()
|
|
84
|
+
|
|
85
|
+
# Check if start itself is a package dir
|
|
86
|
+
if (path / "package.mo").exists():
|
|
87
|
+
return path
|
|
88
|
+
|
|
89
|
+
# Walk up looking for a child dir with package.mo
|
|
90
|
+
for _ in range(10):
|
|
91
|
+
for child in path.iterdir():
|
|
92
|
+
if child.is_dir() and (child / "package.mo").exists():
|
|
93
|
+
return child
|
|
94
|
+
path = path.parent
|
|
95
|
+
|
|
96
|
+
raise FileNotFoundError(
|
|
97
|
+
f"Could not find a Modelica package from {start}. "
|
|
98
|
+
"Expected a directory containing package.mo."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_package_name(package_dir: Path) -> str:
|
|
103
|
+
"""Read the Modelica package name from package.mo."""
|
|
104
|
+
pkg_file = package_dir / "package.mo"
|
|
105
|
+
if not pkg_file.exists():
|
|
106
|
+
raise FileNotFoundError(f"No package.mo in {package_dir}")
|
|
107
|
+
text = pkg_file.read_text(encoding="utf-8", errors="replace")
|
|
108
|
+
m = re.search(r"package\s+(\w+)", text)
|
|
109
|
+
if m:
|
|
110
|
+
return m.group(1)
|
|
111
|
+
raise ValueError(f"Could not parse package name from {pkg_file}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ConfigError(ValueError):
|
|
115
|
+
"""A testing.json problem the user must fix (malformed, unreadable, or a
|
|
116
|
+
missing explicit --config path)."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def baseline_points_refused(named: str, value: object) -> str | None:
|
|
120
|
+
"""Why ``value`` is no ``baseline_points`` — in ``testing.json`` or on a
|
|
121
|
+
``test_spec.json`` entry, ``named`` saying which — or ``None``. It is
|
|
122
|
+
``"all"`` or a whole number of 2 or more: a thinned baseline keeps both
|
|
123
|
+
ends of its run. ``true`` is no number, and neither is ``2000.0``."""
|
|
124
|
+
if isinstance(value, str) and value == ALL_POINTS:
|
|
125
|
+
return None
|
|
126
|
+
if isinstance(value, int) and not isinstance(value, bool) and value >= 2:
|
|
127
|
+
return None
|
|
128
|
+
return (
|
|
129
|
+
f"{named} is {json.dumps(value, default=str)}: the points a baseline "
|
|
130
|
+
f'keeps are "{ALL_POINTS}" or a whole number of 2 or more'
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
#: testing.json keys renamed when the "simulator" vocabulary became Backend.
|
|
135
|
+
#: Reading one fails loudly: an untouched config still using these would
|
|
136
|
+
#: otherwise keep PARSING while every setting in it silently did nothing,
|
|
137
|
+
#: which a user experiences as "the tool ignores my config" with no
|
|
138
|
+
#: diagnostic. Naming the replacement is a diagnostic, not a deprecation
|
|
139
|
+
#: path — the old key never works again, it just says so.
|
|
140
|
+
_RENAMED_CONFIG_KEYS = {
|
|
141
|
+
"simulator": "backend",
|
|
142
|
+
"simulators": "backends",
|
|
143
|
+
"simulator_setup": "backend_setup",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def load_config_file(path: Path) -> dict:
|
|
148
|
+
"""Load a testing.json config file.
|
|
149
|
+
|
|
150
|
+
A missing file returns ``{}`` (no config there). An EXISTING file that
|
|
151
|
+
cannot be read or parsed raises :class:`ConfigError` naming the file —
|
|
152
|
+
review 2026-07-06 finding 11: silently returning ``{}`` let the
|
|
153
|
+
auto-create branch overwrite the user's testing.json.
|
|
154
|
+
"""
|
|
155
|
+
if path.is_dir():
|
|
156
|
+
path = path / CONFIG_FILENAME
|
|
157
|
+
if not path.exists():
|
|
158
|
+
return {}
|
|
159
|
+
try:
|
|
160
|
+
text = path.read_text(encoding="utf-8")
|
|
161
|
+
except OSError as e:
|
|
162
|
+
raise ConfigError(f"Cannot read config file {path}: {e}") from e
|
|
163
|
+
try:
|
|
164
|
+
data = json.loads(text)
|
|
165
|
+
except json.JSONDecodeError as e:
|
|
166
|
+
raise ConfigError(f"Malformed JSON in config file {path}: {e}") from e
|
|
167
|
+
if isinstance(data, dict):
|
|
168
|
+
stale = [k for k in _RENAMED_CONFIG_KEYS if k in data]
|
|
169
|
+
if stale:
|
|
170
|
+
renames = ", ".join(f"{k!r} -> {_RENAMED_CONFIG_KEYS[k]!r}" for k in stale)
|
|
171
|
+
raise ConfigError(
|
|
172
|
+
f"{path} uses renamed key(s): {renames}. The old names are no "
|
|
173
|
+
f"longer read; rename them so the settings take effect."
|
|
174
|
+
)
|
|
175
|
+
return data
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _create_default_config(config_dir: Path, library_name: str) -> dict:
|
|
179
|
+
"""Create a default testing.json and return its contents."""
|
|
180
|
+
config = {
|
|
181
|
+
"backend": "Dymola",
|
|
182
|
+
"backends": {},
|
|
183
|
+
"dependencies": [],
|
|
184
|
+
}
|
|
185
|
+
config_path = config_dir / CONFIG_FILENAME
|
|
186
|
+
# review 2026-07-06 finding 19: the target dir may not exist yet (e.g.
|
|
187
|
+
# first run with a fresh --reference-root) — create it before writing.
|
|
188
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
# LF on every platform: testing.json is a surface
|
|
190
|
+
# users version-control, so it must not depend on the writing OS.
|
|
191
|
+
config_path.write_text(
|
|
192
|
+
json.dumps(config, indent=2) + "\n", encoding="utf-8", newline="\n"
|
|
193
|
+
)
|
|
194
|
+
logger.info("Created default %s at %s", CONFIG_FILENAME, config_path)
|
|
195
|
+
print(f"Created default {CONFIG_FILENAME} at {config_path}")
|
|
196
|
+
return config
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _resolve_backend_path(
|
|
200
|
+
backends_config: dict[str, list[str]],
|
|
201
|
+
backend_name: str,
|
|
202
|
+
) -> str | None:
|
|
203
|
+
"""Resolve a backend's executable path from the backends config.
|
|
204
|
+
|
|
205
|
+
Looks up the named entry and returns the first path that exists on disk.
|
|
206
|
+
Falls back to checking if the name is on PATH.
|
|
207
|
+
"""
|
|
208
|
+
paths = backends_config.get(backend_name, [])
|
|
209
|
+
for p in paths:
|
|
210
|
+
path = Path(p)
|
|
211
|
+
if path.is_absolute() and path.exists():
|
|
212
|
+
return str(path)
|
|
213
|
+
found = shutil.which(p)
|
|
214
|
+
if found:
|
|
215
|
+
return found
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _auto_detect_backend(
|
|
220
|
+
backends_config: dict[str, list[str]],
|
|
221
|
+
) -> tuple[str, str] | None:
|
|
222
|
+
"""Pick the first backend in ``backends_config`` whose binary resolves.
|
|
223
|
+
|
|
224
|
+
Resolution order per candidate entry:
|
|
225
|
+
1. Any path in its ``backends`` list that exists on disk or resolves
|
|
226
|
+
on ``PATH`` (via :func:`_resolve_backend_path`).
|
|
227
|
+
2. If the list contains no absolute paths (empty or bare-binary names
|
|
228
|
+
only), fall back to the backend's canonical binary
|
|
229
|
+
(``BACKEND_BINARY_NAMES``) on ``PATH``.
|
|
230
|
+
|
|
231
|
+
The fallback is gated on "no absolute paths given" because a list of
|
|
232
|
+
OS-specific absolute paths (e.g. ``C:\\Program Files\\...Dymola.exe``)
|
|
233
|
+
is a strong signal that the user wants *that* install — if none of the
|
|
234
|
+
absolute paths resolve, treating some same-named binary on PATH as
|
|
235
|
+
equivalent would silently run the wrong simulator on a mixed-OS setup
|
|
236
|
+
(Linux WSL often has a ``dymola`` symlink that points at a binary that
|
|
237
|
+
isn't usable the same way).
|
|
238
|
+
|
|
239
|
+
Iteration follows dict insertion order so users can express preference
|
|
240
|
+
(list Dymola first on Windows; list OpenModelica first on Linux) —
|
|
241
|
+
"first available wins" across both Windows and Linux machines with the
|
|
242
|
+
same ``testing.json``. Returns ``(backend_name, resolved_path)`` or
|
|
243
|
+
``None`` if no entry resolves.
|
|
244
|
+
"""
|
|
245
|
+
for name, paths in backends_config.items():
|
|
246
|
+
path = _resolve_backend_path(backends_config, name)
|
|
247
|
+
if path:
|
|
248
|
+
return name, path
|
|
249
|
+
# No explicit entry resolved. Only fall back to a generic PATH
|
|
250
|
+
# lookup when the user hasn't pinned this backend to platform-
|
|
251
|
+
# specific paths. An entirely-pinned list is a no-fallback
|
|
252
|
+
# instruction. We detect "looks like a path" via a Windows-drive
|
|
253
|
+
# regex OR POSIX-absolute — pathlib's ``is_absolute`` alone returns
|
|
254
|
+
# False for ``C:\...`` on Linux (PosixPath doesn't recognize
|
|
255
|
+
# Windows drive letters).
|
|
256
|
+
has_pinned_path = any(_looks_like_path(p) for p in paths)
|
|
257
|
+
if has_pinned_path:
|
|
258
|
+
continue
|
|
259
|
+
backend = _detect_backend(name)
|
|
260
|
+
binary = BACKEND_BINARY_NAMES.get(backend, backend.lower())
|
|
261
|
+
if binary:
|
|
262
|
+
found = shutil.which(binary)
|
|
263
|
+
if found:
|
|
264
|
+
return name, found
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _looks_like_path(entry: str) -> bool:
|
|
272
|
+
"""True if ``entry`` is an OS-specific path (not just a bare binary name).
|
|
273
|
+
|
|
274
|
+
POSIX-absolute (``/usr/bin/omc``) or Windows-drive-rooted
|
|
275
|
+
(``C:\\Program Files\\...``) count as pinned paths; bare names like
|
|
276
|
+
``"omc"`` or ``"dymola.exe"`` do not.
|
|
277
|
+
"""
|
|
278
|
+
if entry.startswith("/"):
|
|
279
|
+
return True
|
|
280
|
+
return bool(_WINDOWS_DRIVE_RE.match(entry))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def dependency_is_path(entry: str) -> bool:
|
|
284
|
+
"""True if a ``dependencies`` entry is SPELLED as a filesystem path: a
|
|
285
|
+
separator, a ``.mo`` suffix, or a ``.``/``~`` prefix. Every entry of a
|
|
286
|
+
resolved ``Config.dependencies`` that names a path is (a relative one
|
|
287
|
+
was made absolute); a bare library name ("Modelica") is not — the
|
|
288
|
+
backend loads it by name."""
|
|
289
|
+
return (
|
|
290
|
+
"/" in entry
|
|
291
|
+
or "\\" in entry
|
|
292
|
+
or entry.endswith(".mo")
|
|
293
|
+
or entry.startswith((".", "~"))
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _dependency_looks_like_path(entry: str, base_dir: Path) -> bool:
|
|
298
|
+
"""True if a ``dependencies`` entry denotes a filesystem path.
|
|
299
|
+
|
|
300
|
+
review 2026-07-06 finding 17: bare library names ("Modelica") must NOT
|
|
301
|
+
be absolutized — backends load them by name. Path signals: how it is
|
|
302
|
+
spelled (:func:`dependency_is_path`), or an existing file/dir relative
|
|
303
|
+
to the config file's directory.
|
|
304
|
+
"""
|
|
305
|
+
if dependency_is_path(entry):
|
|
306
|
+
return True
|
|
307
|
+
try:
|
|
308
|
+
return (base_dir / entry).exists()
|
|
309
|
+
except OSError:
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass
|
|
314
|
+
class Config:
|
|
315
|
+
"""Runtime configuration for the testing system.
|
|
316
|
+
|
|
317
|
+
The primary input is source_path — for source_type == "modelica" this is
|
|
318
|
+
the directory containing package.mo for the library being tested; for FMU
|
|
319
|
+
/ Julia / data-file sources it generalizes to that source location.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
# Source type: "modelica" (default, `source_path` points at a package.mo dir),
|
|
323
|
+
# "fmu" (Phase 2), "julia" / "simulink" / "data-file" (future). See docs/vision.md.
|
|
324
|
+
# Discovery + backend selection gate on this.
|
|
325
|
+
source_type: str = "modelica"
|
|
326
|
+
|
|
327
|
+
# Path to the source location for the library being tested.
|
|
328
|
+
# For source_type == "modelica": the directory containing package.mo.
|
|
329
|
+
# For other source types: the FMU directory / Julia script / CSV file / etc.
|
|
330
|
+
source_path: Path | None = None
|
|
331
|
+
|
|
332
|
+
# Reference results location (can be a separate repo/directory)
|
|
333
|
+
reference_root: Path | None = None
|
|
334
|
+
|
|
335
|
+
# Backend selection. ``None`` means "not explicitly chosen" — the
|
|
336
|
+
# post-init resolution then consults testing.json's ``backend`` key,
|
|
337
|
+
# then auto-detect from the ``backends`` map, then falls back to
|
|
338
|
+
# ``"Dymola"`` as the historical default. A non-None value (typically
|
|
339
|
+
# from a CLI ``--backend`` flag) is treated as authoritative and is
|
|
340
|
+
# NOT overridden by anything from testing.json.
|
|
341
|
+
backend_name: str | None = None
|
|
342
|
+
backend_path: str | None = None
|
|
343
|
+
show_ide: bool = False
|
|
344
|
+
backend_setup: list[str] = field(
|
|
345
|
+
default_factory=list
|
|
346
|
+
) # Commands run after loading libraries
|
|
347
|
+
|
|
348
|
+
# OS override (auto-detected if not set)
|
|
349
|
+
os_name: str | None = None
|
|
350
|
+
|
|
351
|
+
# Paths to dependency library roots
|
|
352
|
+
dependencies: list[str] = field(default_factory=list)
|
|
353
|
+
|
|
354
|
+
# Simulation / comparison
|
|
355
|
+
parallel: int = 1
|
|
356
|
+
batch_size: int | None = (
|
|
357
|
+
None # tests per Dymola session; None = ceil(total/parallel) (one big batch per worker)
|
|
358
|
+
)
|
|
359
|
+
# Global comparison tolerance. None means "not explicitly set" — resolved
|
|
360
|
+
# in __post_init__ as CLI/constructor arg > testing.json `tolerance` key >
|
|
361
|
+
# DEFAULT_COMPARISON_TOLERANCE, with is-not-None semantics so an explicit
|
|
362
|
+
# 0.0 is honored (review 2026-07-06 finding 12). Always a float after
|
|
363
|
+
# construction.
|
|
364
|
+
tolerance: float | None = None
|
|
365
|
+
default_points: bool = False
|
|
366
|
+
timeout: int = 60
|
|
367
|
+
# Seconds a persistent worker's start may take (testing.json
|
|
368
|
+
# `worker_startup_timeout`). None means "not set": each backend's own
|
|
369
|
+
# default applies (`Worker.startup_timeout`: 600 s on every backend).
|
|
370
|
+
worker_startup_timeout: float | None = None
|
|
371
|
+
|
|
372
|
+
# The run's results will be read WHILE it is in progress (the authoring
|
|
373
|
+
# tool showing a running test). Set by that caller only: there is no
|
|
374
|
+
# testing.json key and no CLI flag, so a `dstf run` is never changed by
|
|
375
|
+
# it. A backend whose result file would otherwise reach the disk late
|
|
376
|
+
# pays to write it promptly — OpenModelica passes `-mat_sync=1`, measured
|
|
377
|
+
# at +3 % run time on a local disk and +59 % for an output-heavy run on a
|
|
378
|
+
# Windows mount. Dymola appends as it goes and needs nothing.
|
|
379
|
+
live_results: bool = False
|
|
380
|
+
|
|
381
|
+
# Output
|
|
382
|
+
work_dir: Path | None = None
|
|
383
|
+
|
|
384
|
+
# Optional override for Dymola's Python interface archive (dymola.egg or dymola-*.whl).
|
|
385
|
+
# If unset, auto-discovers under platform install roots.
|
|
386
|
+
dymola_interface_path: Path | None = None
|
|
387
|
+
|
|
388
|
+
# Test spec file (external test definitions)
|
|
389
|
+
test_spec_file: Path | None = None
|
|
390
|
+
|
|
391
|
+
# Diagnostic variables: auto-captured from simulation, shown in reports but not compared
|
|
392
|
+
diagnostic_variables: list[str] = field(
|
|
393
|
+
default_factory=lambda: ["CPUtime", "EventCounter"]
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Phase 6.0 — interactive.html payload budget. LTTB-decimates trajectories
|
|
397
|
+
# embedded for Plotly rendering; full-resolution arrays remain on disk in
|
|
398
|
+
# comparison_data.json. Only affects the HTML visual; pass/fail scoring,
|
|
399
|
+
# stored baselines, and the data sidecar are unaffected. Default 1000 keeps
|
|
400
|
+
# a 50-var × 5000-sample test under the ~5 MB budget given today's
|
|
401
|
+
# per-variable time-array duplication; the follow-up dedup (idea #47)
|
|
402
|
+
# will let this rise to 2000 at the same budget.
|
|
403
|
+
max_embedded_samples: int = 1000
|
|
404
|
+
|
|
405
|
+
# The points a stored baseline keeps of its run (D153, testing.json
|
|
406
|
+
# `baseline_points`): "all" — every row the simulator wrote — or a whole
|
|
407
|
+
# number of 2 or more, which thins the run to at most that many. The
|
|
408
|
+
# project's value: a test_spec.json entry's own `baseline_points` wins
|
|
409
|
+
# (`TestModel.baseline_points`).
|
|
410
|
+
# Scoring reads the run AT the baseline's time points, so this is also
|
|
411
|
+
# the resolution at which a regression can be seen.
|
|
412
|
+
baseline_points: int | str = ALL_POINTS
|
|
413
|
+
|
|
414
|
+
# The bundled theme (D148) the dashboard and the report pages are
|
|
415
|
+
# generated in and `dstf author` opens with: a NAME of
|
|
416
|
+
# reporting.theme.THEMES — no path, no inline colours, no CLI flag.
|
|
417
|
+
theme: str = "default"
|
|
418
|
+
|
|
419
|
+
# User-provided recognizers (PTA.3) — parsed from testing.json's
|
|
420
|
+
# "recognizers" list via discovery.json_recognizer.parse_recognizer_spec.
|
|
421
|
+
# Stored on Config so they're scoped to this run (no module-registry leak
|
|
422
|
+
# across multiple Config instances or between tests).
|
|
423
|
+
recognizers: list = field(default_factory=list)
|
|
424
|
+
|
|
425
|
+
# Names of bundled recognizers to disable (PTA.3). E.g.,
|
|
426
|
+
# ["modelica:bundled-unit-tests"] when the user has only custom-convention
|
|
427
|
+
# tests and wants the bundled UnitTests recognizer off.
|
|
428
|
+
disabled_bundled: list[str] = field(default_factory=list)
|
|
429
|
+
|
|
430
|
+
# testing.json's `backends` map, kept whole for the authoring shell's
|
|
431
|
+
# backend select (brief §3.6); the executable-path resolution below reads
|
|
432
|
+
# the same map.
|
|
433
|
+
backends: dict[str, list[str]] = field(default_factory=dict)
|
|
434
|
+
|
|
435
|
+
# Config file path
|
|
436
|
+
config_file: Path | None = None
|
|
437
|
+
|
|
438
|
+
# Resolved (set during __post_init__)
|
|
439
|
+
library_name: str | None = None
|
|
440
|
+
|
|
441
|
+
def __post_init__(self):
|
|
442
|
+
# Resolve reference root early — needed for config file search
|
|
443
|
+
if self.reference_root is not None:
|
|
444
|
+
self.reference_root = Path(self.reference_root).resolve()
|
|
445
|
+
|
|
446
|
+
# Load config file first — it may provide source_path
|
|
447
|
+
file_config = {}
|
|
448
|
+
config_found_dir = None
|
|
449
|
+
if self.config_file:
|
|
450
|
+
config_path = Path(self.config_file).resolve()
|
|
451
|
+
if config_path.is_dir():
|
|
452
|
+
config_path = config_path / CONFIG_FILENAME
|
|
453
|
+
if not config_path.is_file():
|
|
454
|
+
# review 2026-07-06 finding 14: an explicit --config that
|
|
455
|
+
# doesn't exist must fail loudly, not silently fall through
|
|
456
|
+
# to auto-detection (and a freshly written default config).
|
|
457
|
+
raise ConfigError(f"Config file not found: {config_path}")
|
|
458
|
+
config_found_dir = config_path.parent
|
|
459
|
+
file_config = load_config_file(config_path)
|
|
460
|
+
else:
|
|
461
|
+
# Build search dirs from what we know so far
|
|
462
|
+
search_dirs = [Path.cwd()]
|
|
463
|
+
if self.reference_root is not None:
|
|
464
|
+
search_dirs.insert(0, self.reference_root)
|
|
465
|
+
if self.source_path is not None:
|
|
466
|
+
pkg = Path(self.source_path).resolve()
|
|
467
|
+
search_dirs.insert(0, pkg) # package dir
|
|
468
|
+
search_dirs.insert(0, pkg.parent) # repo root
|
|
469
|
+
for search_dir in search_dirs:
|
|
470
|
+
# review 2026-07-06 findings 11/19: "found" means the FILE
|
|
471
|
+
# exists, not that it parsed to a non-empty dict — an existing
|
|
472
|
+
# (even empty) testing.json must stop the search and suppress
|
|
473
|
+
# the auto-create-defaults branch.
|
|
474
|
+
if (search_dir / CONFIG_FILENAME).is_file():
|
|
475
|
+
file_config = load_config_file(search_dir)
|
|
476
|
+
config_found_dir = search_dir.resolve()
|
|
477
|
+
break
|
|
478
|
+
|
|
479
|
+
# Source type peek — controls whether we look for a Modelica package.
|
|
480
|
+
# Done before source_path resolution so non-modelica backends (FMU,
|
|
481
|
+
# data-file, ...) can skip the package.mo lookup entirely.
|
|
482
|
+
source_type_hint = file_config.get("source_type", self.source_type)
|
|
483
|
+
|
|
484
|
+
if source_type_hint == "modelica":
|
|
485
|
+
# Resolve source path — CLI arg > config file > auto-detect.
|
|
486
|
+
# "no package.mo anywhere" is a user error (wrong --source-path,
|
|
487
|
+
# wrong cwd, or source_type should have been "fmu"/"julia"), so
|
|
488
|
+
# find_package_dir's FileNotFoundError becomes a ConfigError and
|
|
489
|
+
# main() prints one line instead of a traceback.
|
|
490
|
+
try:
|
|
491
|
+
if self.source_path is not None:
|
|
492
|
+
self.source_path = Path(self.source_path).resolve()
|
|
493
|
+
if not (self.source_path / "package.mo").exists():
|
|
494
|
+
self.source_path = find_package_dir(self.source_path)
|
|
495
|
+
elif "source_path" in file_config:
|
|
496
|
+
base_dir = config_found_dir or Path.cwd()
|
|
497
|
+
self.source_path = (base_dir / file_config["source_path"]).resolve()
|
|
498
|
+
if not (self.source_path / "package.mo").exists():
|
|
499
|
+
self.source_path = find_package_dir(self.source_path)
|
|
500
|
+
else:
|
|
501
|
+
self.source_path = find_package_dir()
|
|
502
|
+
except OSError as e:
|
|
503
|
+
raise ConfigError(
|
|
504
|
+
f"{e} Set 'source_path' in testing.json, pass --source-path, "
|
|
505
|
+
f"or set 'source_type' if this library isn't Modelica."
|
|
506
|
+
) from e
|
|
507
|
+
|
|
508
|
+
# Read library name from package.mo
|
|
509
|
+
if self.library_name is None:
|
|
510
|
+
try:
|
|
511
|
+
self.library_name = read_package_name(self.source_path)
|
|
512
|
+
except (FileNotFoundError, ValueError) as e:
|
|
513
|
+
raise ConfigError(str(e)) from e
|
|
514
|
+
|
|
515
|
+
# The parent of the package dir is where testing.json typically lives
|
|
516
|
+
repo_root = self.source_path.parent
|
|
517
|
+
else:
|
|
518
|
+
# Non-modelica source: no package.mo to discover. The "library"
|
|
519
|
+
# is conceptually the FMU set / data set / Julia project / etc.
|
|
520
|
+
# described by the config. Resolve source_path here too so
|
|
521
|
+
# backends that need it (JuliaBackend for --project=) find the
|
|
522
|
+
# right directory. CLI arg > config file > config dir > cwd.
|
|
523
|
+
if self.source_path is not None:
|
|
524
|
+
self.source_path = Path(self.source_path).resolve()
|
|
525
|
+
elif "source_path" in file_config:
|
|
526
|
+
base_dir = config_found_dir or Path.cwd()
|
|
527
|
+
self.source_path = (base_dir / file_config["source_path"]).resolve()
|
|
528
|
+
elif config_found_dir is not None:
|
|
529
|
+
self.source_path = config_found_dir
|
|
530
|
+
repo_root = config_found_dir or (
|
|
531
|
+
self.source_path if self.source_path else Path.cwd()
|
|
532
|
+
)
|
|
533
|
+
if self.library_name is None:
|
|
534
|
+
self.library_name = file_config.get("library_name") or (
|
|
535
|
+
config_found_dir.name if config_found_dir else repo_root.name
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# If no config was found yet (source_path wasn't available for search),
|
|
539
|
+
# try repo_root now — existence-based, same as the search loop above.
|
|
540
|
+
if config_found_dir is None and (repo_root / CONFIG_FILENAME).is_file():
|
|
541
|
+
file_config = load_config_file(repo_root)
|
|
542
|
+
config_found_dir = repo_root.resolve()
|
|
543
|
+
|
|
544
|
+
# Auto-create testing.json ONLY if no config file was found anywhere
|
|
545
|
+
# (review 2026-07-06 findings 11/19: a found config — even one that
|
|
546
|
+
# parses to {} — must never be overwritten with defaults).
|
|
547
|
+
if config_found_dir is None:
|
|
548
|
+
config_dir = self.reference_root if self.reference_root else repo_root
|
|
549
|
+
file_config = _create_default_config(config_dir, self.library_name)
|
|
550
|
+
|
|
551
|
+
# Source type (optional; default "modelica" from dataclass).
|
|
552
|
+
# testing.json may set: "source_type": "fmu" | "julia" | ... (future)
|
|
553
|
+
if "source_type" in file_config:
|
|
554
|
+
self.source_type = file_config["source_type"]
|
|
555
|
+
|
|
556
|
+
# OS detection
|
|
557
|
+
if self.os_name is None:
|
|
558
|
+
self.os_name = file_config.get("os", detect_os())
|
|
559
|
+
|
|
560
|
+
# Backend selection. Resolution order, first match wins:
|
|
561
|
+
# 1. Explicit constructor / CLI value (self.backend_name is not None)
|
|
562
|
+
# 2. testing.json explicit "backend" key
|
|
563
|
+
# 3. testing.json "backends" map auto-detect (first entry whose
|
|
564
|
+
# binary resolves on the current machine / PATH)
|
|
565
|
+
# 4. Default "Dymola" (historical fallback)
|
|
566
|
+
if self.backend_name is None:
|
|
567
|
+
if "backend" in file_config:
|
|
568
|
+
self.backend_name = file_config["backend"]
|
|
569
|
+
else:
|
|
570
|
+
backends_config = file_config.get("backends", {})
|
|
571
|
+
detected = _auto_detect_backend(backends_config)
|
|
572
|
+
if detected:
|
|
573
|
+
self.backend_name = detected[0]
|
|
574
|
+
# review 2026-07-06 finding 18: auto-detect may fill the
|
|
575
|
+
# simulator NAME, but must never clobber an explicitly
|
|
576
|
+
# supplied backend_path (CLI --backend-path).
|
|
577
|
+
if self.backend_path is None:
|
|
578
|
+
self.backend_path = detected[1]
|
|
579
|
+
else:
|
|
580
|
+
self.backend_name = "Dymola"
|
|
581
|
+
|
|
582
|
+
# Show IDE
|
|
583
|
+
if not self.show_ide and "show_ide" in file_config:
|
|
584
|
+
self.show_ide = file_config["show_ide"]
|
|
585
|
+
|
|
586
|
+
# Backend setup commands
|
|
587
|
+
if not self.backend_setup and "backend_setup" in file_config:
|
|
588
|
+
self.backend_setup = file_config["backend_setup"]
|
|
589
|
+
|
|
590
|
+
# Diagnostic variables
|
|
591
|
+
if "diagnostic_variables" in file_config:
|
|
592
|
+
self.diagnostic_variables = file_config["diagnostic_variables"]
|
|
593
|
+
|
|
594
|
+
# Global comparison tolerance (review 2026-07-06 finding 12: the
|
|
595
|
+
# documented testing.json `tolerance` key was never read). Precedence:
|
|
596
|
+
# explicit constructor/CLI value > file > default, is-not-None
|
|
597
|
+
# semantics so an explicit 0.0 anywhere is honored.
|
|
598
|
+
# The project's base tolerance, as a test's: ONE finite number
|
|
599
|
+
# (`is_base_tolerance`). Anything else is this config error, not a
|
|
600
|
+
# TypeError/OverflowError traceback — nor `"0.5"` or `true` read as
|
|
601
|
+
# a number by `float()`. `null` is unset.
|
|
602
|
+
from .comparison.sides import base_tolerance_refused, is_base_tolerance
|
|
603
|
+
|
|
604
|
+
if self.tolerance is None:
|
|
605
|
+
tolerance, named = file_config.get("tolerance"), "testing.json tolerance"
|
|
606
|
+
else:
|
|
607
|
+
tolerance, named = self.tolerance, "--tolerance"
|
|
608
|
+
if tolerance is None:
|
|
609
|
+
self.tolerance = DEFAULT_COMPARISON_TOLERANCE
|
|
610
|
+
elif is_base_tolerance(tolerance):
|
|
611
|
+
self.tolerance = float(tolerance)
|
|
612
|
+
else:
|
|
613
|
+
raise ConfigError(base_tolerance_refused(named, tolerance))
|
|
614
|
+
|
|
615
|
+
# A worker start's bound: one positive finite number of seconds.
|
|
616
|
+
startup = file_config.get("worker_startup_timeout")
|
|
617
|
+
if startup is not None:
|
|
618
|
+
if (
|
|
619
|
+
isinstance(startup, bool)
|
|
620
|
+
or not isinstance(startup, int | float)
|
|
621
|
+
or not 0 < startup < float("inf")
|
|
622
|
+
):
|
|
623
|
+
raise ConfigError(
|
|
624
|
+
f"testing.json worker_startup_timeout must be a positive "
|
|
625
|
+
f"number of seconds, got {startup!r}"
|
|
626
|
+
)
|
|
627
|
+
self.worker_startup_timeout = float(startup)
|
|
628
|
+
|
|
629
|
+
# Reporter payload budget
|
|
630
|
+
if "max_embedded_samples" in file_config:
|
|
631
|
+
self.max_embedded_samples = int(file_config["max_embedded_samples"])
|
|
632
|
+
|
|
633
|
+
# A baseline's points: "all" or a whole number of 2 or more,
|
|
634
|
+
# wherever it was said. `null` is no value of it: absent is the
|
|
635
|
+
# only way to leave it unset.
|
|
636
|
+
if "baseline_points" in file_config:
|
|
637
|
+
self.baseline_points = file_config["baseline_points"]
|
|
638
|
+
named = "testing.json baseline_points"
|
|
639
|
+
else:
|
|
640
|
+
named = "baseline_points"
|
|
641
|
+
refused = baseline_points_refused(named, self.baseline_points)
|
|
642
|
+
if refused is not None:
|
|
643
|
+
raise ConfigError(refused)
|
|
644
|
+
|
|
645
|
+
# The theme: one of the bundled names, wherever it was said.
|
|
646
|
+
from .reporting.theme import THEMES
|
|
647
|
+
|
|
648
|
+
if "theme" in file_config:
|
|
649
|
+
self.theme, named = file_config["theme"], "testing.json theme"
|
|
650
|
+
else:
|
|
651
|
+
named = "theme"
|
|
652
|
+
if not isinstance(self.theme, str) or self.theme not in THEMES:
|
|
653
|
+
raise ConfigError(
|
|
654
|
+
f"{named} is {json.dumps(self.theme, default=str)}: the bundled themes are "
|
|
655
|
+
f"{', '.join(THEMES)}"
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
# User-provided recognizers (PTA.3) — declarative JSON specs become
|
|
659
|
+
# Recognizer instances stored on Config. CLI-provided recognizers
|
|
660
|
+
# take precedence; testing.json fills in if Config wasn't given any.
|
|
661
|
+
if not self.recognizers and "recognizers" in file_config:
|
|
662
|
+
from .discovery.json_recognizer import (
|
|
663
|
+
RecognizerSpecError,
|
|
664
|
+
parse_recognizer_spec,
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
where = (
|
|
668
|
+
config_found_dir / CONFIG_FILENAME if config_found_dir else "the config"
|
|
669
|
+
)
|
|
670
|
+
specs = file_config["recognizers"]
|
|
671
|
+
if not isinstance(specs, list):
|
|
672
|
+
raise ConfigError(
|
|
673
|
+
f"'recognizers' in {where} must be a list, "
|
|
674
|
+
f"got {type(specs).__name__}"
|
|
675
|
+
)
|
|
676
|
+
try:
|
|
677
|
+
self.recognizers = [parse_recognizer_spec(spec) for spec in specs]
|
|
678
|
+
except RecognizerSpecError as e:
|
|
679
|
+
# A malformed recognizer is a testing.json problem the user
|
|
680
|
+
# must fix — one clean line and exit 2, not a traceback.
|
|
681
|
+
raise ConfigError(f"Invalid recognizer in {where}: {e}") from e
|
|
682
|
+
if not self.disabled_bundled and "disable_bundled" in file_config:
|
|
683
|
+
self.disabled_bundled = list(file_config["disable_bundled"])
|
|
684
|
+
|
|
685
|
+
if not self.backends:
|
|
686
|
+
self.backends = {
|
|
687
|
+
name: list(paths)
|
|
688
|
+
for name, paths in (file_config.get("backends") or {}).items()
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
# Resolve backend executable path
|
|
692
|
+
if self.backend_path is None:
|
|
693
|
+
backends_config = file_config.get("backends", {})
|
|
694
|
+
if backends_config:
|
|
695
|
+
resolved = _resolve_backend_path(backends_config, self.backend_name)
|
|
696
|
+
if resolved:
|
|
697
|
+
self.backend_path = resolved
|
|
698
|
+
else:
|
|
699
|
+
logger.warning(
|
|
700
|
+
"No working path found for backend '%s' in testing.json's 'backends' map. "
|
|
701
|
+
"Falling back to PATH lookup.",
|
|
702
|
+
self.backend_name,
|
|
703
|
+
)
|
|
704
|
+
if self.backend_path is None:
|
|
705
|
+
backend = self.backend
|
|
706
|
+
binary = BACKEND_BINARY_NAMES.get(backend, backend.lower())
|
|
707
|
+
if binary:
|
|
708
|
+
self.backend_path = shutil.which(binary) or binary
|
|
709
|
+
|
|
710
|
+
# Reference root (may already be set from CLI arg)
|
|
711
|
+
if self.reference_root is None:
|
|
712
|
+
ref_path = file_config.get("reference_root")
|
|
713
|
+
if ref_path:
|
|
714
|
+
base_dir = config_found_dir or repo_root
|
|
715
|
+
self.reference_root = (base_dir / ref_path).resolve()
|
|
716
|
+
else:
|
|
717
|
+
# Default: if testing.json lives in a ReferenceResults dir, use that;
|
|
718
|
+
# otherwise fall back to <repo>/Resources/ReferenceResults
|
|
719
|
+
if config_found_dir and config_found_dir.name == "ReferenceResults":
|
|
720
|
+
self.reference_root = config_found_dir
|
|
721
|
+
else:
|
|
722
|
+
self.reference_root = repo_root / "Resources" / "ReferenceResults"
|
|
723
|
+
|
|
724
|
+
# Dependencies — resolve relative paths from config file location.
|
|
725
|
+
# review 2026-07-06 finding 17: only absolutize entries that LOOK
|
|
726
|
+
# like paths. Bare library names (the documented
|
|
727
|
+
# ``"dependencies": ["Modelica"]`` form) must pass through untouched
|
|
728
|
+
# so the OpenModelica backend's classify_dependency routes them to
|
|
729
|
+
# ``loadModel(Modelica)`` instead of ``loadFile(<bogus path>)`` —
|
|
730
|
+
# absolutizing them also suppressed the MSL auto-injection.
|
|
731
|
+
# Dymola note: its backends load dependencies via
|
|
732
|
+
# ``openModel(<dir>/package.mo)`` and cannot load by bare name, but
|
|
733
|
+
# Dymola auto-loads MSL natively, so "Modelica" should simply be
|
|
734
|
+
# omitted there; a bare name reaching the Dymola backend fails at
|
|
735
|
+
# startup with a clear message, exactly as the absolutized bogus
|
|
736
|
+
# path did before this fix (no regression).
|
|
737
|
+
if not self.dependencies and "dependencies" in file_config:
|
|
738
|
+
base_dir = config_found_dir or repo_root
|
|
739
|
+
resolved_deps = []
|
|
740
|
+
for dep in file_config["dependencies"]:
|
|
741
|
+
if not _dependency_looks_like_path(dep, base_dir):
|
|
742
|
+
resolved_deps.append(dep) # bare library name
|
|
743
|
+
continue
|
|
744
|
+
dep_path = Path(dep)
|
|
745
|
+
if not dep_path.is_absolute():
|
|
746
|
+
dep_path = (base_dir / dep_path).resolve()
|
|
747
|
+
resolved_deps.append(str(dep_path))
|
|
748
|
+
self.dependencies = resolved_deps
|
|
749
|
+
|
|
750
|
+
# Test spec file — resolve relative to where testing.json was found
|
|
751
|
+
if self.test_spec_file is None:
|
|
752
|
+
spec_path = file_config.get("test_spec")
|
|
753
|
+
if spec_path:
|
|
754
|
+
base_dir = config_found_dir or repo_root
|
|
755
|
+
self.test_spec_file = (base_dir / spec_path).resolve()
|
|
756
|
+
else:
|
|
757
|
+
self.test_spec_file = Path(self.test_spec_file).resolve()
|
|
758
|
+
|
|
759
|
+
# Work directory
|
|
760
|
+
if self.work_dir is None:
|
|
761
|
+
work = file_config.get("work_dir")
|
|
762
|
+
if work:
|
|
763
|
+
work_path = Path(work)
|
|
764
|
+
if not work_path.is_absolute():
|
|
765
|
+
# review 2026-07-06 finding 15: a relative work_dir
|
|
766
|
+
# resolves against the config file's directory (like
|
|
767
|
+
# test_spec / dependencies), not the CWD — `compare`
|
|
768
|
+
# from another directory must find the same results.
|
|
769
|
+
work_path = (config_found_dir or repo_root) / work_path
|
|
770
|
+
self.work_dir = work_path.resolve()
|
|
771
|
+
else:
|
|
772
|
+
backend_dir = self.backend_name.replace(" ", "_")
|
|
773
|
+
self.work_dir = (
|
|
774
|
+
Path.cwd()
|
|
775
|
+
/ "testing_output"
|
|
776
|
+
/ self.library_name
|
|
777
|
+
/ backend_dir
|
|
778
|
+
/ self.os_name
|
|
779
|
+
)
|
|
780
|
+
else:
|
|
781
|
+
self.work_dir = Path(self.work_dir).resolve()
|
|
782
|
+
|
|
783
|
+
@property
|
|
784
|
+
def backend(self) -> str:
|
|
785
|
+
"""The canonical backend family (e.g., 'Dymola' from 'Dymola 2025')."""
|
|
786
|
+
assert self.backend_name is not None # resolved in __post_init__
|
|
787
|
+
return _detect_backend(self.backend_name)
|
|
788
|
+
|
|
789
|
+
@property
|
|
790
|
+
def library_dir(self) -> Path:
|
|
791
|
+
"""Path to the library's top-level package directory (contains package.mo).
|
|
792
|
+
|
|
793
|
+
Modelica-source convenience accessor; for source_type == "modelica" this
|
|
794
|
+
is identical to ``source_path``.
|
|
795
|
+
"""
|
|
796
|
+
assert self.source_path is not None # resolved in __post_init__
|
|
797
|
+
return self.source_path
|
|
798
|
+
|
|
799
|
+
@property
|
|
800
|
+
def reference_dir(self) -> Path:
|
|
801
|
+
"""Reference results directory, partitioned by backend and OS."""
|
|
802
|
+
assert self.reference_root is not None # resolved in __post_init__
|
|
803
|
+
assert self.os_name is not None # resolved in __post_init__
|
|
804
|
+
return self.reference_root / self.backend / self.os_name
|
|
805
|
+
|
|
806
|
+
@property
|
|
807
|
+
def spec_path(self) -> Path:
|
|
808
|
+
"""Where ``test_spec.json`` lives: the configured file, else
|
|
809
|
+
``<reference_root>/test_spec.json``. The one spelling of a fallback
|
|
810
|
+
that ``add``, ``spec-update`` and the review loop each used to
|
|
811
|
+
carry; the authoring Session reads the same property."""
|
|
812
|
+
if self.test_spec_file is not None:
|
|
813
|
+
return self.test_spec_file
|
|
814
|
+
assert self.reference_root is not None # resolved in __post_init__
|
|
815
|
+
return self.reference_root / "test_spec.json"
|