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,295 @@
1
+ """In-memory index over ref_NNNN.json files, plus the atomic-write primitive.
2
+
3
+ The ref files are the source of truth — there is no persistent manifest.
4
+ :class:`RefIndex` builds the model_id ↔ test_id mapping by scanning them and
5
+ owns ID allocation (O_EXCL claim + per-partition high-water mark) so IDs are
6
+ never reused. :func:`_atomic_write_text` lives here because ID allocation is
7
+ its most safety-critical caller; every other JSON write in the package uses it
8
+ too.
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import re
15
+ import time
16
+ import uuid
17
+ from pathlib import Path
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ # Pattern matching ref_NNNN.json filenames
23
+ _REF_FILE_PATTERN = re.compile(r"^ref_(\d{4,})\.json$")
24
+
25
+ #: Per-partition high-water mark for allocated test IDs. A dot-file, so the
26
+ #: ``ref_*.json`` scan glob never picks it up.
27
+ _ID_HIGH_WATER_FILE = ".id_high_water.json"
28
+
29
+
30
+ def _atomic_write_text(path: Path, text: str, *, newline: str = "\n") -> None:
31
+ """Atomically replace ``path`` with ``text``.
32
+
33
+ review 2026-07-06 (finding 31): all reference writes were plain
34
+ ``write_text`` — a Ctrl-C mid-accept corrupted the baseline, the next
35
+ scan silently dropped it, and ``next_id()`` re-allocated its ID to a
36
+ different model. Same tmp-file + replace pattern as
37
+ ``dashboard_render._atomic_write`` (unique tmp name in the same
38
+ directory, retry loop for Windows file locking), except this variant
39
+ always raises on failure — a silently stale baseline is a
40
+ data-integrity bug, not a cosmetic one.
41
+
42
+ ``newline`` is passed through to :func:`open` and defaults to ``"\\n"``,
43
+ NOT to :func:`open`'s ``None``: with ``None`` Python
44
+ translates ``\\n`` to ``os.linesep``, so a baseline accepted on Windows
45
+ landed CRLF and the same file accepted from WSL landed LF. The reference
46
+ tree is a surface other people version-control; a cross-platform accept
47
+ turned a one-value edit into a 6,881-line whole-file diff, hiding the
48
+ real change inside it. Every JSON write in the package therefore gets LF
49
+ on every platform, and a new caller cannot reintroduce the bug by
50
+ forgetting a keyword. The one CSV caller still passes ``""``
51
+ explicitly, which is now belt-and-braces rather than load-bearing:
52
+ neither ``""`` nor ``"\\n"`` translates on write, so the ``\\r\\n`` that
53
+ :mod:`csv` embeds survives either way. It stays because it states the
54
+ intent at the call site and because it is what keeps that caller correct
55
+ if this default is ever revisited.
56
+ """
57
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
58
+ try:
59
+ with tmp.open("w", encoding="utf-8", newline=newline) as f:
60
+ f.write(text)
61
+ last_err: OSError | None = None
62
+ for delay in (0, 0.05, 0.1, 0.2, 0.5):
63
+ if delay:
64
+ time.sleep(delay)
65
+ try:
66
+ tmp.replace(path)
67
+ return
68
+ except OSError as e:
69
+ last_err = e
70
+ assert last_err is not None
71
+ raise last_err
72
+ finally:
73
+ try:
74
+ if tmp.exists():
75
+ tmp.unlink()
76
+ except OSError:
77
+ pass
78
+
79
+
80
+ class RefIndex:
81
+ """In-memory index mapping model IDs to ref files.
82
+
83
+ Built by scanning ref_NNNN.json files in the reference directory.
84
+ No persistent manifest file — the ref files are the source of truth.
85
+ """
86
+
87
+ def __init__(self, ref_dir: Path):
88
+ self.ref_dir = ref_dir
89
+ self._by_model: dict[str, str] = {} # model_id -> test_id
90
+ self._by_id: dict[str, dict] = {} # test_id -> {model_id, status}
91
+ self._loaded = False
92
+
93
+ def _scan(self):
94
+ """Scan ref files and build the in-memory index."""
95
+ self._by_model.clear()
96
+ self._by_id.clear()
97
+
98
+ if not self.ref_dir.exists():
99
+ self._loaded = True
100
+ return
101
+
102
+ for ref_file in sorted(self.ref_dir.glob("ref_*.json")):
103
+ m = _REF_FILE_PATTERN.match(ref_file.name)
104
+ if not m:
105
+ continue
106
+ test_id = m.group(1)
107
+ try:
108
+ # Whole-file read + parse. Only model_id/status are used, and
109
+ # they sit at the top, but json has no partial-parse entry
110
+ # point — a header-only read is idea #64, not done here.
111
+ text = ref_file.read_text(encoding="utf-8")
112
+ data = json.loads(text)
113
+ except (json.JSONDecodeError, OSError) as e:
114
+ logger.warning("Skipping unreadable ref file %s: %s", ref_file.name, e)
115
+ continue
116
+
117
+ model_id = data.get("model_id")
118
+ if not model_id:
119
+ logger.warning("Ref file %s has no model_id — skipping", ref_file.name)
120
+ continue
121
+
122
+ status = data.get("status", "active")
123
+ self._by_id[test_id] = {"model_id": model_id, "status": status}
124
+ if status != "obsolete":
125
+ existing = self._by_model.get(model_id)
126
+ if existing is not None and existing != test_id:
127
+ # review 2026-07-06 (finding 36): two active ref files
128
+ # claim the same model_id. Keep the LOWEST-numbered one —
129
+ # deterministic, and the oldest is the established
130
+ # baseline — and warn loudly instead of silent last-wins.
131
+ keep = min(existing, test_id, key=int)
132
+ drop = max(existing, test_id, key=int)
133
+ logger.warning(
134
+ "Duplicate model_id %r in %s and %s — keeping the "
135
+ "lowest-numbered file (%s); mark the other obsolete "
136
+ "or delete it.",
137
+ model_id,
138
+ RefIndex.ref_filename(keep),
139
+ RefIndex.ref_filename(drop),
140
+ RefIndex.ref_filename(keep),
141
+ )
142
+ self._by_model[model_id] = keep
143
+ else:
144
+ self._by_model[model_id] = test_id
145
+
146
+ self._loaded = True
147
+
148
+ def ensure_loaded(self) -> None:
149
+ """Scan now if not yet scanned. Lazy by default; `Session.open`
150
+ calls it before its project is live, so the UI loop — which reads
151
+ the index the moment the project is set — never scans (every ref
152
+ file read whole) and never races the open thread's scan."""
153
+ if not self._loaded:
154
+ self._scan()
155
+
156
+ def get_id(self, model_id: str) -> str | None:
157
+ """Look up the numeric ID for a model. Returns None if not found."""
158
+ self.ensure_loaded()
159
+ return self._by_model.get(model_id)
160
+
161
+ def get_model_id(self, test_id: str) -> str | None:
162
+ """Look up the model ID for a numeric test ID."""
163
+ self.ensure_loaded()
164
+ entry = self._by_id.get(test_id)
165
+ if entry and entry["status"] != "obsolete":
166
+ return entry["model_id"]
167
+ return None
168
+
169
+ def _read_high_water(self) -> int:
170
+ """Return the persisted high-water mark, or 0 when there is none.
171
+
172
+ Absent / unreadable file → 0, i.e. fall back to scan-only allocation.
173
+ Partitions written before the high-water file existed keep working.
174
+ """
175
+ hw_file = self.ref_dir / _ID_HIGH_WATER_FILE
176
+ try:
177
+ data = json.loads(hw_file.read_text(encoding="utf-8"))
178
+ return int(data["high_water"])
179
+ except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError):
180
+ return 0
181
+
182
+ def _bump_high_water(self, test_id: str) -> None:
183
+ """Raise the persisted high-water mark to cover ``test_id``.
184
+
185
+ Concurrent bumps are last-writer-wins on the atomic replace, so a
186
+ plain read-check-write could REGRESS the mark: P1 (claimed 0005)
187
+ reads 4, P2 (claimed 0006) writes 6, P1 overwrites with 5 (A5
188
+ review finding 1; a write-then-verify retry does not close it —
189
+ the higher writer can verify before the lower writer's overwrite).
190
+ Instead every bump folds in a FRESH disk scan: a competitor
191
+ O_EXCL-claims its ref file BEFORE bumping (register's claim
192
+ ordering), so any mark we could clobber corresponds to a ref file
193
+ already visible on disk, and writing ``max(claimed, scan, stored)``
194
+ makes last-writer-wins harmless.
195
+ """
196
+ value = int(test_id)
197
+ try:
198
+ self.ref_dir.mkdir(parents=True, exist_ok=True)
199
+ scan_max = max(
200
+ (
201
+ int(m.group(1))
202
+ for m in (
203
+ _REF_FILE_PATTERN.match(p.name)
204
+ for p in self.ref_dir.glob("ref_*.json")
205
+ )
206
+ if m
207
+ ),
208
+ default=0,
209
+ )
210
+ target = max(value, scan_max)
211
+ if self._read_high_water() >= target:
212
+ return
213
+ _atomic_write_text(
214
+ self.ref_dir / _ID_HIGH_WATER_FILE,
215
+ json.dumps({"high_water": target}, indent=2) + "\n",
216
+ )
217
+ except OSError as e:
218
+ # Losing the mark only costs us the "never reused" guarantee for
219
+ # this partition — never a failed accept.
220
+ logger.warning("Could not persist test-ID high-water mark: %s", e)
221
+
222
+ def next_id(self) -> str:
223
+ """Return the next available numeric ID.
224
+
225
+ IDs are never reused: the next ID clears both the highest ref file
226
+ currently on disk *and* the persisted high-water mark, so deleting the
227
+ highest-numbered ref (``manifest cleanup`` after it was marked
228
+ obsolete) does not hand its ID to a different model in a later run.
229
+ """
230
+ self.ensure_loaded()
231
+ max_id = max((int(k) for k in self._by_id), default=0)
232
+ return f"{max(max_id, self._read_high_water()) + 1:04d}"
233
+
234
+ def register(self, model_id: str) -> str:
235
+ """Register a model and return its ID. Reuses existing ID if already registered.
236
+
237
+ review 2026-07-06 (findings 32+33): new IDs are claimed by
238
+ *exclusively creating* the ref file on disk (``open(..., "x")``).
239
+ A stale in-memory index (e.g. after ``cleanup_obsolete`` freed the
240
+ highest ID) or a concurrent ``--accept`` in another process could
241
+ both hand out an already-taken ID — the O_EXCL create makes the
242
+ claim atomic; on collision we rescan and move to the next free ID
243
+ instead of silently overwriting another model's baseline.
244
+ """
245
+ existing = self.get_id(model_id)
246
+ if existing is not None:
247
+ return existing
248
+
249
+ self.ref_dir.mkdir(parents=True, exist_ok=True)
250
+ candidate = int(self.next_id())
251
+ while True:
252
+ test_id = f"{candidate:04d}"
253
+ ref_file = self.ref_dir / self.ref_filename(test_id)
254
+ try:
255
+ with open(ref_file, "x", encoding="utf-8") as f:
256
+ # Minimal valid claim stub — store_reference atomically
257
+ # replaces it with the full baseline immediately after.
258
+ json.dump(
259
+ {"model_id": model_id, "test_id": test_id, "status": "active"},
260
+ f,
261
+ )
262
+ except FileExistsError:
263
+ # ID already taken on disk (concurrent writer, or a file our
264
+ # stale index missed). Rescan, then move past the collision —
265
+ # the max() guard guarantees progress even when the colliding
266
+ # file is unreadable and therefore invisible to next_id().
267
+ self._scan()
268
+ claimed = self.get_id(model_id)
269
+ if claimed is not None:
270
+ return claimed # another process registered this model
271
+ candidate = max(candidate + 1, int(self.next_id()))
272
+ continue
273
+ self._by_id[test_id] = {"model_id": model_id, "status": "active"}
274
+ self._by_model[model_id] = test_id
275
+ self._bump_high_water(test_id)
276
+ return test_id
277
+
278
+ def active_tests(self) -> dict[str, str]:
279
+ """Return dict of test_id -> model_id for all active (non-obsolete, non-skip) tests."""
280
+ self.ensure_loaded()
281
+ return {
282
+ tid: entry["model_id"]
283
+ for tid, entry in self._by_id.items()
284
+ if entry["status"] not in ("obsolete",)
285
+ }
286
+
287
+ def all_tests(self) -> dict[str, dict]:
288
+ """Return dict of test_id -> {model_id, status} for all tests."""
289
+ self.ensure_loaded()
290
+ return dict(self._by_id)
291
+
292
+ @staticmethod
293
+ def ref_filename(test_id: str) -> str:
294
+ """Reference filename for a given numeric test ID."""
295
+ return f"ref_{test_id}.json"