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,395 @@
|
|
|
1
|
+
"""Backend-agnostic live progress reporter.
|
|
2
|
+
|
|
3
|
+
Writes `status.json` and `dashboard.html` to the work directory on every
|
|
4
|
+
state change so a user can watch progress live while tests run.
|
|
5
|
+
|
|
6
|
+
Event contract:
|
|
7
|
+
register(test_key, model_id) — before any work starts
|
|
8
|
+
on_start(test_key, worker_id) — test/batch begins
|
|
9
|
+
on_finish(test_key, success, elapsed, detail, timed_out)
|
|
10
|
+
|
|
11
|
+
Thread-safe. Writes are atomic (temp-file + replace).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from dataclasses import asdict, dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
#: A test's ``phase`` once its run has ended. It used to keep the last
|
|
24
|
+
#: phase it was seen in: a finished run's status.json said every test was
|
|
25
|
+
#: still "finalizing".
|
|
26
|
+
DONE_PHASE = "done"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class TestStatus:
|
|
31
|
+
test_key: str
|
|
32
|
+
model_id: str
|
|
33
|
+
#: What became of the test's SIMULATION: queued | running | passed |
|
|
34
|
+
#: failed | timed_out | cancelled. ``passed`` says the simulation
|
|
35
|
+
#: finished — NOT that the comparison passed: that is ``verdict``.
|
|
36
|
+
status: str = "queued"
|
|
37
|
+
#: The comparison's verdict, as the console prints it (``PASS``,
|
|
38
|
+
#: ``FAIL``, ``SIM_FAIL``, ``NO_REF``, ``NOT RUN``), once a ``run`` or
|
|
39
|
+
#: ``compare`` has scored the test (`stamp_verdicts`); None until then.
|
|
40
|
+
verdict: str | None = None
|
|
41
|
+
started_at: float | None = None # time.monotonic — for accurate elapsed computation
|
|
42
|
+
started_wall: float | None = (
|
|
43
|
+
None # time.time (epoch) — for JS to compute live "running for Ns"
|
|
44
|
+
)
|
|
45
|
+
elapsed: float | None = None
|
|
46
|
+
detail: str | None = None
|
|
47
|
+
worker_id: int | None = None
|
|
48
|
+
report_dir: str | None = (
|
|
49
|
+
None # e.g., "ref_0042" or "test_0005" — matches generate_report_suite naming
|
|
50
|
+
)
|
|
51
|
+
#: Where a RUNNING test is: "translating" / "simulating" / ... — and
|
|
52
|
+
#: ``DONE_PHASE`` once it has ended, however it ended. None before it
|
|
53
|
+
#: starts.
|
|
54
|
+
phase: str | None = None
|
|
55
|
+
# Per-field provenance, plumbed from TestModel.field_sources via register().
|
|
56
|
+
# Surfaced in the dashboard's Resolution column.
|
|
57
|
+
field_sources: dict = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ProgressReporter:
|
|
61
|
+
"""Thread-safe live progress tracker.
|
|
62
|
+
|
|
63
|
+
Writes status.json + dashboard.html to work_dir on every state change.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
work_dir: Path,
|
|
69
|
+
total: int,
|
|
70
|
+
rerun_prefix: str | None = None,
|
|
71
|
+
metadata: dict | None = None,
|
|
72
|
+
theme: str = "default",
|
|
73
|
+
):
|
|
74
|
+
self.work_dir = work_dir
|
|
75
|
+
self.total = total
|
|
76
|
+
# The bundled theme the live page is rendered in (Config.theme).
|
|
77
|
+
self.theme = theme
|
|
78
|
+
# Stashed for inclusion in status.json so the dashboard's rerun-
|
|
79
|
+
# command builder produces an absolute-path command that works
|
|
80
|
+
# from any CWD. Computed by callers via dashboard_render.build_
|
|
81
|
+
# rerun_prefix(config).
|
|
82
|
+
self.rerun_prefix = rerun_prefix
|
|
83
|
+
# Run-level provenance (backend family/configured name/os/version). Serialized into
|
|
84
|
+
# status.json under "metadata" and rendered as a banner by the
|
|
85
|
+
# reporters. None → the "metadata" key stays null (backward compat with
|
|
86
|
+
# snapshots written before this field existed). See run_metadata.py.
|
|
87
|
+
self._metadata = dict(metadata) if metadata else None
|
|
88
|
+
self._lock = threading.Lock()
|
|
89
|
+
self._write_lock = threading.Lock()
|
|
90
|
+
self._tests: dict[str, TestStatus] = {}
|
|
91
|
+
self._start_time = time.monotonic()
|
|
92
|
+
# Wall-clock anchor (epoch seconds). Sent into status.json so the
|
|
93
|
+
# dashboard JS can compute live "elapsed = now - start_wall" between
|
|
94
|
+
# meta-refresh ticks (otherwise the header elapsed appears frozen at
|
|
95
|
+
# whatever it was when the last test state-change wrote the snapshot).
|
|
96
|
+
self._start_wall = time.time()
|
|
97
|
+
# A finished run's own wall time, where its rows were `load`ed.
|
|
98
|
+
self._loaded_elapsed: float | None = None
|
|
99
|
+
# Set by finalize: the run is over, and nothing writes again.
|
|
100
|
+
self._closed = False
|
|
101
|
+
|
|
102
|
+
def update_metadata(self, **fields: object) -> None:
|
|
103
|
+
"""Patch run-level metadata after the fact (e.g. a backend that only
|
|
104
|
+
learns its ``tool_version`` once its worker is live) and re-emit the
|
|
105
|
+
snapshot. No-op when no base metadata was supplied — we never fabricate
|
|
106
|
+
a provenance block from a late partial update.
|
|
107
|
+
"""
|
|
108
|
+
with self._lock:
|
|
109
|
+
if self._metadata is None:
|
|
110
|
+
return
|
|
111
|
+
self._metadata.update(fields)
|
|
112
|
+
self._write()
|
|
113
|
+
|
|
114
|
+
def register(
|
|
115
|
+
self,
|
|
116
|
+
test_key: str,
|
|
117
|
+
model_id: str,
|
|
118
|
+
report_dir: str | None = None,
|
|
119
|
+
field_sources: dict | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
with self._lock:
|
|
122
|
+
self._tests[test_key] = TestStatus(
|
|
123
|
+
test_key=test_key,
|
|
124
|
+
model_id=model_id,
|
|
125
|
+
report_dir=report_dir,
|
|
126
|
+
field_sources=field_sources or {},
|
|
127
|
+
)
|
|
128
|
+
self._write()
|
|
129
|
+
|
|
130
|
+
def on_start(self, test_key: str, worker_id: int | None = None) -> None:
|
|
131
|
+
with self._lock:
|
|
132
|
+
ts = self._tests.get(test_key)
|
|
133
|
+
if ts is not None:
|
|
134
|
+
ts.status = "running"
|
|
135
|
+
ts.started_at = time.monotonic()
|
|
136
|
+
ts.started_wall = time.time()
|
|
137
|
+
ts.worker_id = worker_id
|
|
138
|
+
ts.phase = None
|
|
139
|
+
self._write()
|
|
140
|
+
|
|
141
|
+
def on_phase(self, test_key: str, phase: str) -> None:
|
|
142
|
+
with self._lock:
|
|
143
|
+
ts = self._tests.get(test_key)
|
|
144
|
+
if ts is not None and ts.status == "running":
|
|
145
|
+
ts.phase = phase
|
|
146
|
+
self._write()
|
|
147
|
+
|
|
148
|
+
def on_finish(
|
|
149
|
+
self,
|
|
150
|
+
test_key: str,
|
|
151
|
+
success: bool,
|
|
152
|
+
elapsed: float,
|
|
153
|
+
detail: str | None = None,
|
|
154
|
+
timed_out: bool = False,
|
|
155
|
+
) -> None:
|
|
156
|
+
with self._lock:
|
|
157
|
+
ts = self._tests.get(test_key)
|
|
158
|
+
if ts is not None:
|
|
159
|
+
if timed_out:
|
|
160
|
+
ts.status = "timed_out"
|
|
161
|
+
elif success:
|
|
162
|
+
ts.status = "passed"
|
|
163
|
+
else:
|
|
164
|
+
ts.status = "failed"
|
|
165
|
+
ts.elapsed = elapsed
|
|
166
|
+
ts.detail = detail
|
|
167
|
+
ts.phase = DONE_PHASE
|
|
168
|
+
self._write()
|
|
169
|
+
|
|
170
|
+
def on_result(
|
|
171
|
+
self,
|
|
172
|
+
test_key: str,
|
|
173
|
+
success: bool,
|
|
174
|
+
elapsed: float,
|
|
175
|
+
detail: str | None = None,
|
|
176
|
+
timed_out: bool = False,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""End the row of a test whose run has ended, for the caller that
|
|
179
|
+
holds the run's RESULT: as :meth:`on_finish` ends it — unless the
|
|
180
|
+
backend already did, whose words then stand.
|
|
181
|
+
|
|
182
|
+
A backend returns a failed result without starting anything for a
|
|
183
|
+
test it cannot run (no source file, no FMU), and says nothing here
|
|
184
|
+
on the way: such a row stayed ``"status": "queued"``, ``"phase":
|
|
185
|
+
null`` in a finished run's status.json, beside its verdict
|
|
186
|
+
``SIM_FAIL``. The run loop that collects every result calls this,
|
|
187
|
+
so no path of a backend's can leave a row open."""
|
|
188
|
+
with self._lock:
|
|
189
|
+
ts = self._tests.get(test_key)
|
|
190
|
+
if ts is None or ts.phase == DONE_PHASE:
|
|
191
|
+
return
|
|
192
|
+
self.on_finish(test_key, success, elapsed, detail=detail, timed_out=timed_out)
|
|
193
|
+
|
|
194
|
+
def load(
|
|
195
|
+
self, tests: list[TestStatus], *, started_wall: float, ended_wall: float
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Take rows that are already final, whole, with no write per row —
|
|
198
|
+
a queue that drained reports itself in one snapshot. ``finalize``
|
|
199
|
+
writes it. The run they report began at ``started_wall`` and ended
|
|
200
|
+
at ``ended_wall`` (epoch seconds): its snapshot's ``start_wall``
|
|
201
|
+
and ``elapsed`` are theirs, not this reporter's own — it was made
|
|
202
|
+
after the run, and counted 0.0 s for one of a minute."""
|
|
203
|
+
with self._lock:
|
|
204
|
+
self._tests = {t.test_key: t for t in tests}
|
|
205
|
+
self._start_wall = started_wall
|
|
206
|
+
self._loaded_elapsed = max(0.0, ended_wall - started_wall)
|
|
207
|
+
|
|
208
|
+
def _snapshot(self) -> dict:
|
|
209
|
+
with self._lock:
|
|
210
|
+
tests = [asdict(t) for t in self._tests.values()]
|
|
211
|
+
metadata = dict(self._metadata) if self._metadata else None
|
|
212
|
+
counts = {"queued": 0, "running": 0, "passed": 0, "failed": 0, "timed_out": 0}
|
|
213
|
+
for t in tests:
|
|
214
|
+
counts[t["status"]] = counts.get(t["status"], 0) + 1
|
|
215
|
+
elapsed = (
|
|
216
|
+
time.monotonic() - self._start_time
|
|
217
|
+
if self._loaded_elapsed is None
|
|
218
|
+
else self._loaded_elapsed
|
|
219
|
+
)
|
|
220
|
+
done = counts["passed"] + counts["failed"] + counts["timed_out"]
|
|
221
|
+
eta = None
|
|
222
|
+
if done > 0 and done < self.total:
|
|
223
|
+
rate = done / elapsed if elapsed > 0 else 0
|
|
224
|
+
remaining = self.total - done
|
|
225
|
+
eta = remaining / rate if rate > 0 else None
|
|
226
|
+
return {
|
|
227
|
+
"total": self.total,
|
|
228
|
+
"elapsed": elapsed,
|
|
229
|
+
"eta_seconds": eta,
|
|
230
|
+
"counts": counts,
|
|
231
|
+
"tests": tests,
|
|
232
|
+
"rerun_prefix": self.rerun_prefix,
|
|
233
|
+
"start_wall": self._start_wall,
|
|
234
|
+
"updated_at": time.time(),
|
|
235
|
+
"metadata": metadata,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
def _write(self, *, closing: bool = False) -> None:
|
|
239
|
+
# Serialize all file writes — without this, two threads can race on
|
|
240
|
+
# the same tmp filename and `replace` fails on Windows when the file
|
|
241
|
+
# is still open by another thread.
|
|
242
|
+
# review 2026-07-06 (finding 25): snapshot INSIDE the write lock —
|
|
243
|
+
# a snapshot taken outside could be written after a newer one,
|
|
244
|
+
# regressing status.json / the live dashboard.
|
|
245
|
+
with self._write_lock:
|
|
246
|
+
# After finalize, the command that ended the run owns the page:
|
|
247
|
+
# an event from an exec thread the backend abandoned (a timed-out
|
|
248
|
+
# worker) would render it live and unscored again. Checked under
|
|
249
|
+
# the lock, so no write can land after finalize's.
|
|
250
|
+
if self._closed:
|
|
251
|
+
return
|
|
252
|
+
snapshot = self._snapshot()
|
|
253
|
+
self._write_json(snapshot)
|
|
254
|
+
self._render_dashboard()
|
|
255
|
+
self._closed = closing
|
|
256
|
+
|
|
257
|
+
def _atomic_write(self, path: Path, text: str) -> None:
|
|
258
|
+
# Unique tmp name per write so concurrent writers never share a tmp
|
|
259
|
+
# file even if a higher-level lock is bypassed.
|
|
260
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
261
|
+
tmp.write_text(text, encoding="utf-8")
|
|
262
|
+
# Retry replace on Windows — antivirus, search indexer, browser
|
|
263
|
+
# previews, and Explorer can briefly lock either the tmp or the
|
|
264
|
+
# target file and cause WinError 5/32. Short backoff usually clears it.
|
|
265
|
+
last_err: OSError | None = None
|
|
266
|
+
for delay in (0, 0.05, 0.1, 0.2, 0.5):
|
|
267
|
+
if delay:
|
|
268
|
+
time.sleep(delay)
|
|
269
|
+
try:
|
|
270
|
+
tmp.replace(path)
|
|
271
|
+
return
|
|
272
|
+
except OSError as e:
|
|
273
|
+
last_err = e
|
|
274
|
+
# Give up: clean up tmp and silently drop this update so progress
|
|
275
|
+
# writes never crash the run. Worst case the dashboard misses one tick.
|
|
276
|
+
try:
|
|
277
|
+
tmp.unlink()
|
|
278
|
+
except OSError:
|
|
279
|
+
pass
|
|
280
|
+
# Re-raise on the very first write so a misconfiguration is visible,
|
|
281
|
+
# but suppress later transient failures.
|
|
282
|
+
if not (path.parent / path.name).exists():
|
|
283
|
+
raise last_err
|
|
284
|
+
|
|
285
|
+
def _write_json(self, snapshot: dict) -> None:
|
|
286
|
+
path = self.work_dir / "status.json"
|
|
287
|
+
self._atomic_write(path, json.dumps(snapshot, indent=2, default=str))
|
|
288
|
+
|
|
289
|
+
def _render_dashboard(self) -> None:
|
|
290
|
+
"""Defer HTML rendering to dashboard_render.
|
|
291
|
+
|
|
292
|
+
ProgressReporter's job is to keep status.json fresh + own atomic
|
|
293
|
+
writes; the page rendering lives in reporting/dashboard_render.py
|
|
294
|
+
so the live and final pages share one template.
|
|
295
|
+
"""
|
|
296
|
+
from ..reporting.dashboard_render import render_live
|
|
297
|
+
|
|
298
|
+
render_live(self.work_dir, theme=self.theme)
|
|
299
|
+
|
|
300
|
+
def finalize(self) -> None:
|
|
301
|
+
"""Write the last status.json and render dashboard.html — LIVE.
|
|
302
|
+
|
|
303
|
+
The simulations are done; the compare phase is not. A static page
|
|
304
|
+
here is where a watching browser stops, every row reading
|
|
305
|
+
SIMULATED while the verdicts are computed and never loaded. The
|
|
306
|
+
command that ends the run renders the final page: with the
|
|
307
|
+
comparisons it holds (``run``, ``run -i``, ``compare``), or
|
|
308
|
+
without them when it scores nothing (``run --accept``, an aborted
|
|
309
|
+
run, the tool's queue as it drains). Nothing this reporter hears after it is
|
|
310
|
+
written.
|
|
311
|
+
"""
|
|
312
|
+
self._write(closing=True)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def stamp_verdicts(work_dir: Path, verdicts: Mapping[str, str | None]) -> None:
|
|
316
|
+
"""Set each test's ``verdict`` in ``status.json`` — ``{model_id: word}``,
|
|
317
|
+
None to clear one — beside its simulation ``status``, and leave every
|
|
318
|
+
other key as it is. For the command that scored the run, after the
|
|
319
|
+
reporter that wrote the snapshot has closed (and for a ``compare``,
|
|
320
|
+
which has no reporter at all). No snapshot, or no test of it named:
|
|
321
|
+
nothing is written."""
|
|
322
|
+
snapshot = read_status(work_dir)
|
|
323
|
+
if snapshot is None:
|
|
324
|
+
return
|
|
325
|
+
changed = False
|
|
326
|
+
for row in snapshot.get("tests") or []:
|
|
327
|
+
if isinstance(row, dict) and row.get("model_id") in verdicts:
|
|
328
|
+
verdict = verdicts[row["model_id"]]
|
|
329
|
+
if row.get("verdict") != verdict or "verdict" not in row:
|
|
330
|
+
row["verdict"] = verdict
|
|
331
|
+
changed = True
|
|
332
|
+
if not changed:
|
|
333
|
+
return
|
|
334
|
+
from ..storage.ref_index import _atomic_write_text
|
|
335
|
+
|
|
336
|
+
_atomic_write_text(
|
|
337
|
+
work_dir / "status.json", json.dumps(snapshot, indent=2, default=str)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def read_status(work_dir: Path) -> dict | None:
|
|
342
|
+
"""The last snapshot ``ProgressReporter`` wrote to *work_dir*, or ``None``
|
|
343
|
+
when there is none to read.
|
|
344
|
+
|
|
345
|
+
The one reader of ``status.json``: the dashboard renderer, the backends'
|
|
346
|
+
verdict fallback, the per-test banner and the authoring shell's job panel
|
|
347
|
+
all come through here. Never raises — the shell calls it from a UI timer,
|
|
348
|
+
and an unreadable snapshot is a page with no progress rows, not a
|
|
349
|
+
traceback. ``ValueError`` covers both a malformed document
|
|
350
|
+
(``JSONDecodeError``) and bytes that are not UTF-8
|
|
351
|
+
(``UnicodeDecodeError``). One behaviour changed when the three former
|
|
352
|
+
readers folded into this one: a document that is valid JSON but not an
|
|
353
|
+
object (``[]``, ``null``, a bare string) now reads as no snapshot, where
|
|
354
|
+
each of them raised ``AttributeError`` on ``.get``. Every caller
|
|
355
|
+
subscripts the snapshot by name, so a non-object is no more usable than an
|
|
356
|
+
unparseable one.
|
|
357
|
+
"""
|
|
358
|
+
p = work_dir / "status.json"
|
|
359
|
+
if not p.exists():
|
|
360
|
+
return None
|
|
361
|
+
try:
|
|
362
|
+
snapshot = json.loads(p.read_text(encoding="utf-8"))
|
|
363
|
+
except (ValueError, OSError):
|
|
364
|
+
return None
|
|
365
|
+
return snapshot if isinstance(snapshot, dict) else None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def recorded_backend(work_dir: Path) -> tuple[str | None, str | None]:
|
|
369
|
+
"""``(label, warning)``: the backend the last run in *work_dir* used, per
|
|
370
|
+
``status.json``'s metadata, and a warning for the caller to show when the
|
|
371
|
+
snapshot predates run provenance.
|
|
372
|
+
|
|
373
|
+
``label`` is ``None`` when the work dir has no snapshot, an unreadable
|
|
374
|
+
one, or one written before provenance was stamped (``metadata: null``).
|
|
375
|
+
``warning`` is set only when metadata exists but names no backend —
|
|
376
|
+
written before the Backend rename (D123), when the key was "simulator".
|
|
377
|
+
The guard this function exists for (D120) cannot fire then, so
|
|
378
|
+
``compare`` would fall back to environment auto-detect and could score,
|
|
379
|
+
say, an OpenModelica work dir against the Dymola partition. Reading the
|
|
380
|
+
old key would be a compatibility path; saying so is not.
|
|
381
|
+
"""
|
|
382
|
+
snap = read_status(work_dir)
|
|
383
|
+
if snap is None:
|
|
384
|
+
return None, None
|
|
385
|
+
meta = snap.get("metadata") or {}
|
|
386
|
+
label = meta.get("backend_name")
|
|
387
|
+
if isinstance(label, str) and label:
|
|
388
|
+
return label, None
|
|
389
|
+
if meta:
|
|
390
|
+
return None, (
|
|
391
|
+
f"Warning: {work_dir / 'status.json'} predates the Backend rename "
|
|
392
|
+
"and does not record which backend produced it. Pass --backend "
|
|
393
|
+
"explicitly, or re-run instead of comparing."
|
|
394
|
+
)
|
|
395
|
+
return None, None
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Python subprocess backend (D80).
|
|
2
|
+
|
|
3
|
+
Mirrors the Julia D77 pattern: framework-shipped driver script loads the
|
|
4
|
+
user's ``.py`` file, calls ``simulate(stop_time, tolerance)``, and writes
|
|
5
|
+
a JSON result. Primary motivation is validating that the backend
|
|
6
|
+
abstraction is truly language/simulator-agnostic — see the ConstantCsv
|
|
7
|
+
example under ``examples/python/PythonTestingLib/`` which uses zero ODE
|
|
8
|
+
machinery.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from . import backend # noqa: F401 (import triggers @register side effect)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Python / subprocess backend (D80).
|
|
2
|
+
|
|
3
|
+
Invokes a shipped driver script (``run_test.py``) via the configured
|
|
4
|
+
Python interpreter. The driver loads the user's ``.py`` file — which
|
|
5
|
+
must define ``simulate(stop_time, tolerance) -> dict`` — executes it,
|
|
6
|
+
and writes a JSON result. The run/read machinery is the shared JSON-driver
|
|
7
|
+
skeleton (:class:`~..common.json_driver.JsonDriverBackend`); only the Python
|
|
8
|
+
specifics live here.
|
|
9
|
+
|
|
10
|
+
Batch-only for the MVP; each test spawns one Python subprocess. Startup
|
|
11
|
+
cost is roughly 30-100 ms per test (importlib + any user-file imports);
|
|
12
|
+
for trivial tests this dominates over the actual ``simulate()`` call.
|
|
13
|
+
A persistent-worker path (long-lived Python subprocess with stdin-JSON
|
|
14
|
+
dispatch) is a future enhancement if per-test overhead becomes an issue
|
|
15
|
+
— mirrors the D77 → D78 Julia progression.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from ...config import Config
|
|
27
|
+
from .. import register
|
|
28
|
+
from ..common.json_driver import JsonDriverBackend
|
|
29
|
+
from ..types import Capability, DatasetType
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Shipped driver script.
|
|
35
|
+
_DRIVER_PATH = Path(__file__).resolve().parent / "run_test.py"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class PythonConfig:
|
|
40
|
+
"""Python-specific settings extracted from the unified :class:`Config`.
|
|
41
|
+
|
|
42
|
+
``python_binary`` is the absolute path to the Python interpreter that
|
|
43
|
+
will run user test scripts. It must have whatever packages the user's
|
|
44
|
+
scripts import (scipy, pandas, ...). The framework does not manage
|
|
45
|
+
the user's environment — pick a ``python`` that has their deps.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
python_binary: Path
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_config(cls, config: Config) -> PythonConfig:
|
|
52
|
+
resolved: Path | None = None
|
|
53
|
+
if config.backend_path:
|
|
54
|
+
p = Path(config.backend_path).expanduser()
|
|
55
|
+
if p.exists():
|
|
56
|
+
resolved = p
|
|
57
|
+
if resolved is None:
|
|
58
|
+
for name in ("python", "python3"):
|
|
59
|
+
on_path = shutil.which(name)
|
|
60
|
+
if on_path:
|
|
61
|
+
resolved = Path(on_path)
|
|
62
|
+
break
|
|
63
|
+
if resolved is None:
|
|
64
|
+
raise RuntimeError(
|
|
65
|
+
"Python binary not found. Ensure 'python' or 'python3' is on "
|
|
66
|
+
"PATH, or set an explicit path under testing.json's "
|
|
67
|
+
'\'backends\' map, e.g. {"Python": ["/path/to/venv/bin/python"]}.'
|
|
68
|
+
)
|
|
69
|
+
return cls(python_binary=resolved)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@register("Python")
|
|
73
|
+
class PythonBackend(JsonDriverBackend):
|
|
74
|
+
"""Subprocess-per-test Python backend."""
|
|
75
|
+
|
|
76
|
+
capabilities = frozenset(
|
|
77
|
+
{
|
|
78
|
+
Capability.BATCH_FALLBACK,
|
|
79
|
+
# Each test is its own subprocess: killing it stops the test.
|
|
80
|
+
Capability.CANCEL,
|
|
81
|
+
# Deliberately absent:
|
|
82
|
+
# PERSISTENT_WORKERS — stdin-driven long-lived Python process deferred.
|
|
83
|
+
# FMU_EXPORT — not meaningful for arbitrary Python scripts.
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
produced_datasets = frozenset({DatasetType.TIME_SERIES})
|
|
87
|
+
artifact_files = (
|
|
88
|
+
("result.json", "Simulation result (time + variables)"),
|
|
89
|
+
("python_stdout.txt", "Python stdout"),
|
|
90
|
+
("python_stderr.txt", "Python stderr"),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
driver_path = _DRIVER_PATH
|
|
94
|
+
tool_label = "Python"
|
|
95
|
+
action_label = "execution"
|
|
96
|
+
stdio_prefix = "python"
|
|
97
|
+
source_suffix = ".py"
|
|
98
|
+
|
|
99
|
+
def __init__(self, config: Config):
|
|
100
|
+
super().__init__(config)
|
|
101
|
+
self.python_config = PythonConfig.from_config(config)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def tool_binary(self) -> Path:
|
|
105
|
+
return self.python_config.python_binary
|
|
106
|
+
|
|
107
|
+
def build_command(
|
|
108
|
+
self,
|
|
109
|
+
binary: Path,
|
|
110
|
+
driver: Path,
|
|
111
|
+
user_file: Path,
|
|
112
|
+
stop_time: float,
|
|
113
|
+
tolerance: float,
|
|
114
|
+
result_path: Path,
|
|
115
|
+
) -> list[str]:
|
|
116
|
+
return [
|
|
117
|
+
str(binary),
|
|
118
|
+
str(driver),
|
|
119
|
+
str(user_file),
|
|
120
|
+
str(stop_time),
|
|
121
|
+
str(tolerance),
|
|
122
|
+
str(result_path),
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
def describe_tool_version(self) -> str | None:
|
|
126
|
+
"""Version of the *configured* interpreter (``python_config.
|
|
127
|
+
python_binary``) that actually runs each test's subprocess — not this
|
|
128
|
+
host's Python, which may differ. Probed via ``--version`` with a short
|
|
129
|
+
timeout; ``None`` on any failure."""
|
|
130
|
+
try:
|
|
131
|
+
out = subprocess.run(
|
|
132
|
+
[str(self.python_config.python_binary), "--version"],
|
|
133
|
+
capture_output=True,
|
|
134
|
+
text=True,
|
|
135
|
+
timeout=10,
|
|
136
|
+
)
|
|
137
|
+
# Older CPython prints the banner to stderr, newer to stdout.
|
|
138
|
+
banner = (out.stdout or out.stderr or "").strip()
|
|
139
|
+
return banner or None
|
|
140
|
+
except Exception:
|
|
141
|
+
return None
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Framework-shipped Python driver for a single test (D80).
|
|
2
|
+
|
|
3
|
+
Invoked by PythonBackend as:
|
|
4
|
+
python run_test.py <user_file> <stop_time> <tolerance> <result_path>
|
|
5
|
+
|
|
6
|
+
The user file must define:
|
|
7
|
+
simulate(stop_time: float, tolerance: float) -> dict
|
|
8
|
+
|
|
9
|
+
returning a dict with:
|
|
10
|
+
"time": list[float] (monotonically non-decreasing)
|
|
11
|
+
"variables": dict[str, list[float]] (each same length as "time")
|
|
12
|
+
|
|
13
|
+
Result file is JSON:
|
|
14
|
+
{"success": true, "time": [...], "variables": {...}}
|
|
15
|
+
or on failure:
|
|
16
|
+
{"success": false, "error": "...", "time": [], "variables": {}}
|
|
17
|
+
|
|
18
|
+
Exceptions are caught so the framework always gets a structured result
|
|
19
|
+
rather than a process-level crash.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import importlib.util
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
import traceback
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_user_module(user_file: Path):
|
|
32
|
+
# Allow sibling imports from the user's file directory.
|
|
33
|
+
parent = str(user_file.parent.resolve())
|
|
34
|
+
if parent not in sys.path:
|
|
35
|
+
sys.path.insert(0, parent)
|
|
36
|
+
spec = importlib.util.spec_from_file_location("_user_test", user_file)
|
|
37
|
+
if spec is None or spec.loader is None:
|
|
38
|
+
raise ImportError(f"Cannot load {user_file}")
|
|
39
|
+
module = importlib.util.module_from_spec(spec)
|
|
40
|
+
spec.loader.exec_module(module)
|
|
41
|
+
return module
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write_failure(result_path: Path, message: str) -> None:
|
|
45
|
+
result_path.write_text(
|
|
46
|
+
json.dumps(
|
|
47
|
+
{
|
|
48
|
+
"success": False,
|
|
49
|
+
"error": message,
|
|
50
|
+
"time": [],
|
|
51
|
+
"variables": {},
|
|
52
|
+
}
|
|
53
|
+
),
|
|
54
|
+
encoding="utf-8",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main(argv: list[str]) -> int:
|
|
59
|
+
if len(argv) != 4:
|
|
60
|
+
print(
|
|
61
|
+
"Usage: run_test.py <user_file> <stop_time> <tolerance> <result_path>",
|
|
62
|
+
file=sys.stderr,
|
|
63
|
+
)
|
|
64
|
+
return 2
|
|
65
|
+
|
|
66
|
+
user_file = Path(argv[0])
|
|
67
|
+
try:
|
|
68
|
+
stop_time = float(argv[1])
|
|
69
|
+
tolerance = float(argv[2])
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
print(f"Invalid numeric arg: {exc}", file=sys.stderr)
|
|
72
|
+
return 2
|
|
73
|
+
result_path = Path(argv[3])
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
module = _load_user_module(user_file)
|
|
77
|
+
if not hasattr(module, "simulate"):
|
|
78
|
+
raise AttributeError(
|
|
79
|
+
f"{user_file.name} must define simulate(stop_time, tolerance)"
|
|
80
|
+
)
|
|
81
|
+
payload = module.simulate(stop_time, tolerance)
|
|
82
|
+
if (
|
|
83
|
+
not isinstance(payload, dict)
|
|
84
|
+
or "time" not in payload
|
|
85
|
+
or "variables" not in payload
|
|
86
|
+
):
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"simulate() must return a dict with 'time' and 'variables' keys"
|
|
89
|
+
)
|
|
90
|
+
if not isinstance(payload["variables"], dict):
|
|
91
|
+
raise ValueError("'variables' must be a dict of name -> list[float]")
|
|
92
|
+
|
|
93
|
+
# review 2026-07-06 (finding 29): validate value TYPES, not just list
|
|
94
|
+
# lengths — a simulate() returning strings used to pass the run and
|
|
95
|
+
# then blow up np.asarray during read_results, aborting every test's
|
|
96
|
+
# read phase. Fail here, cleanly, naming the offending variable.
|
|
97
|
+
raw_time = list(payload["time"])
|
|
98
|
+
try:
|
|
99
|
+
time_list = [float(v) for v in raw_time]
|
|
100
|
+
except (TypeError, ValueError) as exc:
|
|
101
|
+
raise ValueError(f"'time' contains a non-numeric value: {exc}") from exc
|
|
102
|
+
variables = {}
|
|
103
|
+
for k, v in payload["variables"].items():
|
|
104
|
+
name = str(k)
|
|
105
|
+
raw_values = list(v)
|
|
106
|
+
if len(raw_values) != len(time_list):
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"variable '{name}' has {len(raw_values)} samples; "
|
|
109
|
+
f"expected {len(time_list)} to match 'time'"
|
|
110
|
+
)
|
|
111
|
+
try:
|
|
112
|
+
variables[name] = [float(x) for x in raw_values]
|
|
113
|
+
except (TypeError, ValueError) as exc:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"variable '{name}' contains a non-numeric value: {exc}"
|
|
116
|
+
) from exc
|
|
117
|
+
|
|
118
|
+
result_path.write_text(
|
|
119
|
+
json.dumps(
|
|
120
|
+
{
|
|
121
|
+
"success": True,
|
|
122
|
+
"time": time_list,
|
|
123
|
+
"variables": variables,
|
|
124
|
+
}
|
|
125
|
+
),
|
|
126
|
+
encoding="utf-8",
|
|
127
|
+
)
|
|
128
|
+
print(f"OK: {len(variables)} variables, {len(time_list)} time points")
|
|
129
|
+
return 0
|
|
130
|
+
except BaseException:
|
|
131
|
+
tb = traceback.format_exc()
|
|
132
|
+
print(f"FAIL:\n{tb}", file=sys.stderr)
|
|
133
|
+
try:
|
|
134
|
+
_write_failure(result_path, tb)
|
|
135
|
+
except Exception:
|
|
136
|
+
return 2
|
|
137
|
+
return 1
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
sys.exit(main(sys.argv[1:]))
|