dynamic-systems-testing-framework 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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:]))