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,341 @@
1
+ """Baseline-role commands (D66): companion, soft-check, import-baseline."""
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from ..config import ConfigError
7
+ from .common import _build_config
8
+
9
+ # Positionals each action needs, in CLI order. The parser declares them all
10
+ # ``nargs="?"`` because they're per-action — argparse can't express "required
11
+ # for add/freeze/remove, optional for list" — so the requirement is enforced
12
+ # here and reported as a usage error (exit 2), not a TypeError traceback out
13
+ # of the store call.
14
+ _COMPANION_REQUIRED = {
15
+ "add": ("model_id", "name", "path"),
16
+ "freeze": ("model_id", "name"),
17
+ "remove": ("model_id", "name"),
18
+ "list": (),
19
+ }
20
+ _SOFT_CHECK_REQUIRED = {
21
+ "remove": ("model_id", "name"),
22
+ "list": (),
23
+ }
24
+
25
+
26
+ def _require_positionals(
27
+ args: argparse.Namespace,
28
+ command: str,
29
+ required: dict[str, tuple[str, ...]],
30
+ ) -> None:
31
+ """Raise :class:`ConfigError` naming the missing positionals for
32
+ ``<command> <action>``. No-op when everything the action needs is set."""
33
+ names = required.get(args.action, ())
34
+ missing = [n for n in names if getattr(args, n, None) is None]
35
+ if not missing:
36
+ return
37
+ usage = " ".join(f"<{n}>" for n in names)
38
+ raise ConfigError(
39
+ f"{command} {args.action}: missing required argument(s): "
40
+ f"{', '.join(missing)}. Usage: dstf {command} {args.action} {usage}"
41
+ )
42
+
43
+
44
+ class _MapError(Exception):
45
+ """A ``--map`` the user must fix before anything is registered."""
46
+
47
+
48
+ def _parse_variable_map(raw: list[str] | None) -> dict[str, str]:
49
+ """``["x=sensor_a", ...]`` → ``{"x": "sensor_a"}``.
50
+
51
+ Two test variables MAY name one companion variable — that draws the
52
+ one curve on both plots. One test variable named twice is a typo: the
53
+ second would silently win, so it is refused instead.
54
+ """
55
+ mapping: dict[str, str] = {}
56
+ for item in raw or []:
57
+ test_var, sep, companion_var = item.partition("=")
58
+ if not sep or not test_var.strip() or not companion_var.strip():
59
+ raise _MapError(
60
+ f"--map {item!r} is not TEST_VAR=COMPANION_VAR — one test "
61
+ f"variable name, '=', one companion variable name"
62
+ )
63
+ test_var, companion_var = test_var.strip(), companion_var.strip()
64
+ if test_var in mapping:
65
+ raise _MapError(
66
+ f"--map names the test variable {test_var!r} twice "
67
+ f"({mapping[test_var]!r} and {companion_var!r}); a variable "
68
+ f"draws one companion curve"
69
+ )
70
+ mapping[test_var] = companion_var
71
+ return mapping
72
+
73
+
74
+ def _check_variable_map(
75
+ store,
76
+ model_id: str,
77
+ path: Path,
78
+ fmt: str,
79
+ mapping: dict[str, str],
80
+ ) -> None:
81
+ """Refuse a mapping naming something neither file has.
82
+
83
+ A wrong name draws nothing and says nothing, months later, in a
84
+ report — so both sides are checked here, where both files are open:
85
+ the left against the test's stored baseline, the right against the
86
+ companion's own data. A path that does not resolve is left to
87
+ ``add_companion``, which reports it as the registration error it is,
88
+ and so is a model with no stored baseline at all: there is nothing to
89
+ check the left-hand names against, and "no primary baseline" is the
90
+ error the user needs, not an empty list of the variables one holds.
91
+ """
92
+ if not mapping:
93
+ return
94
+ from ..reporting.overlay_loader import companion_variable_names
95
+
96
+ reference = store.get_reference(model_id)
97
+ if reference is None:
98
+ return
99
+ known = {
100
+ entry.get("name")
101
+ for entry in reference.get("variables") or []
102
+ if isinstance(entry, dict)
103
+ }
104
+ unknown = sorted(v for v in mapping if v not in known)
105
+ if unknown:
106
+ raise _MapError(
107
+ f"--map names {', '.join(repr(v) for v in unknown)}, which the "
108
+ f"baseline of {model_id} does not hold. Its variables are: "
109
+ f"{', '.join(sorted(n for n in known if n))}"
110
+ )
111
+
112
+ if not path.exists():
113
+ return
114
+ try:
115
+ companion_vars = set(companion_variable_names(path, fmt))
116
+ except Exception as e:
117
+ raise _MapError(f"cannot read {path} to check --map against it: {e}") from e
118
+ missing = sorted(set(mapping.values()) - companion_vars)
119
+ if missing:
120
+ raise _MapError(
121
+ f"--map names {', '.join(repr(v) for v in missing)}, which "
122
+ f"{path} does not hold. Its variables are: "
123
+ f"{', '.join(sorted(companion_vars))}"
124
+ )
125
+
126
+
127
+ def _print_companions(companions: dict) -> None:
128
+ """One line per companion, plus one per mapped variable under it."""
129
+ for name, co in companions.items():
130
+ extra = co.path if co.kind == "external" else co.data_file
131
+ print(f" {name:<30} kind={co.kind:<8} format={co.format:<5} {extra}")
132
+ for test_var, companion_var in co.variables.items():
133
+ print(f" {'':<30} {test_var} -> {companion_var}")
134
+
135
+
136
+ def cmd_companion(args: argparse.Namespace) -> int:
137
+ """Manage plot-only companion references (D66).
138
+
139
+ Companions are experimental / analytical / rig data shown as plot
140
+ overlays but never scored against. Four actions:
141
+ add <model> <name> <path> [--format csv|json] [--map TEST=COMPANION]
142
+ list [<model>]
143
+ freeze <model> <name>
144
+ remove <model> <name>
145
+
146
+ ``--map`` is repeatable and records which of the companion's variables
147
+ draws on which of the test's — the answer the report reads instead of
148
+ guessing from spellings. Two test variables may name one companion
149
+ variable; one test variable may not be named twice. Only ``add``
150
+ writes the record, so only ``add`` accepts ``--map``.
151
+ """
152
+ _require_positionals(args, "companion", _COMPANION_REQUIRED)
153
+ if args.action != "add" and getattr(args, "variable_map", None):
154
+ # argparse cannot scope an option to one value of a positional
155
+ # choice, so the refusal lives here — silently ignoring it would
156
+ # leave the user believing a mapping had been recorded.
157
+ raise ConfigError(
158
+ f"companion {args.action}: --map is only accepted by "
159
+ f"`companion add`, which writes the mapping. To change one, "
160
+ f"re-add the companion with the --map you want."
161
+ )
162
+ config = _build_config(args)
163
+ from ..storage.reference_store import ReferenceStore
164
+ from ..storage.roles import infer_companion_format
165
+
166
+ store = ReferenceStore(config)
167
+ action = args.action
168
+
169
+ if action == "add":
170
+ path = Path(args.path)
171
+ fmt = getattr(args, "format", None) or infer_companion_format(str(path))
172
+ try:
173
+ mapping = _parse_variable_map(getattr(args, "variable_map", None))
174
+ _check_variable_map(store, args.model_id, path, fmt, mapping)
175
+ ok = store.add_companion(
176
+ args.model_id,
177
+ args.name,
178
+ path=path,
179
+ format=getattr(args, "format", None),
180
+ variables=mapping,
181
+ )
182
+ except (_MapError, ValueError, FileNotFoundError) as e:
183
+ print(f"Error: {e}")
184
+ return 1
185
+ print(
186
+ f"Registered companion {args.name!r} → {args.path}"
187
+ if ok
188
+ else f"Failed to register companion {args.name!r}"
189
+ )
190
+ return 0 if ok else 1
191
+
192
+ if action == "list":
193
+ model_id = getattr(args, "model_id", None)
194
+ if model_id:
195
+ companions = store.get_companions(model_id)
196
+ if not companions:
197
+ print(f"No companions registered for {model_id}")
198
+ return 0
199
+ print(f"Companions for {model_id}:")
200
+ _print_companions(companions)
201
+ return 0
202
+ # All models
203
+ total = 0
204
+ for _tid, entry in sorted(store.index.all_tests().items()):
205
+ if entry["status"] == "obsolete":
206
+ continue
207
+ companions = store.get_companions(entry["model_id"])
208
+ if companions:
209
+ print(f"{entry['model_id']}:")
210
+ _print_companions(companions)
211
+ total += len(companions)
212
+ if total == 0:
213
+ print("No companions registered.")
214
+ return 0
215
+
216
+ if action == "freeze":
217
+ try:
218
+ ok = store.freeze_companion(args.model_id, args.name)
219
+ except FileNotFoundError as e:
220
+ print(f"Error: {e}")
221
+ return 1
222
+ if ok:
223
+ print(f"Frozen companion {args.name!r} for {args.model_id}")
224
+ else:
225
+ print(f"Companion {args.name!r} is already frozen or does not exist")
226
+ return 0
227
+
228
+ if action == "remove":
229
+ ok = store.remove_companion(args.model_id, args.name)
230
+ if ok:
231
+ print(f"Removed companion {args.name!r} from {args.model_id}")
232
+ else:
233
+ print(f"Companion {args.name!r} not found on {args.model_id}")
234
+ return 0 if ok else 1
235
+
236
+ return 1
237
+
238
+
239
+ def cmd_soft_check(args: argparse.Namespace) -> int:
240
+ """Manage soft_check baselines (advisory cross-checks)."""
241
+ _require_positionals(args, "soft-check", _SOFT_CHECK_REQUIRED)
242
+ config = _build_config(args)
243
+ from ..storage.reference_store import ReferenceStore
244
+
245
+ store = ReferenceStore(config)
246
+ action = args.action
247
+
248
+ if action == "list":
249
+ model_id = getattr(args, "model_id", None)
250
+ if model_id:
251
+ checks = store.get_soft_checks(model_id)
252
+ if not checks:
253
+ print(f"No soft_checks registered for {model_id}")
254
+ return 0
255
+ print(f"Soft_checks for {model_id}:")
256
+ for name, bl in checks.items():
257
+ origin = (
258
+ bl.provenance.get("source") or bl.provenance.get("origin") or "-"
259
+ )
260
+ print(f" {name:<30} n_vars={len(bl.variables):<4} origin={origin}")
261
+ return 0
262
+ total = 0
263
+ for _tid, entry in sorted(store.index.all_tests().items()):
264
+ if entry["status"] == "obsolete":
265
+ continue
266
+ checks = store.get_soft_checks(entry["model_id"])
267
+ if checks:
268
+ print(f"{entry['model_id']}:")
269
+ for name, bl in checks.items():
270
+ origin = (
271
+ bl.provenance.get("source")
272
+ or bl.provenance.get("origin")
273
+ or "-"
274
+ )
275
+ print(f" {name:<30} n_vars={len(bl.variables):<4} origin={origin}")
276
+ total += len(checks)
277
+ if total == 0:
278
+ print("No soft_checks registered.")
279
+ return 0
280
+
281
+ if action == "remove":
282
+ ok = store.remove_soft_check(args.model_id, args.name)
283
+ if ok:
284
+ print(f"Removed soft_check {args.name!r} from {args.model_id}")
285
+ else:
286
+ print(f"Soft_check {args.name!r} not found on {args.model_id}")
287
+ return 0 if ok else 1
288
+
289
+ return 1
290
+
291
+
292
+ def cmd_import_baseline(args: argparse.Namespace) -> int:
293
+ """Import another regression system's primary as a soft_check (D66).
294
+
295
+ Reads a reference-shape JSON from ``source_path`` (same schema a
296
+ ``ref_NNNN.json`` file uses at its top level) and stores it under
297
+ the local model's soft_checks with the given name.
298
+ """
299
+ import json as json_mod
300
+
301
+ config = _build_config(args)
302
+ from ..storage.reference_store import ReferenceStore, read_reference_file
303
+
304
+ store = ReferenceStore(config)
305
+ source = Path(args.import_source)
306
+ if not source.exists():
307
+ print(f"File not found: {source}")
308
+ return 1
309
+ try:
310
+ data = read_reference_file(source)
311
+ except (json_mod.JSONDecodeError, OSError) as e:
312
+ print(f"Failed to read {source}: {e}")
313
+ return 1
314
+
315
+ time = data.get("time") or []
316
+ variables = data.get("variables") or []
317
+ if not time or not variables:
318
+ print("Source file missing 'time' or 'variables' — cannot import")
319
+ return 1
320
+
321
+ provenance = dict(data.get("provenance") or {})
322
+ provenance.setdefault("origin", "import-baseline")
323
+ provenance.setdefault("imported_from", str(source))
324
+
325
+ try:
326
+ ok = store.add_soft_check(
327
+ args.model_id,
328
+ args.name,
329
+ time=time,
330
+ variables=variables,
331
+ diagnostics=data.get("diagnostics"),
332
+ simulation=data.get("simulation"),
333
+ statistics=data.get("statistics"),
334
+ provenance=provenance,
335
+ )
336
+ except (ValueError, FileNotFoundError) as e:
337
+ print(f"Error: {e}")
338
+ return 1
339
+ if ok:
340
+ print(f"Imported {source} → soft_check {args.name!r} on {args.model_id}")
341
+ return 0 if ok else 1
dstf/cli/common.py ADDED
@@ -0,0 +1,231 @@
1
+ """Shared CLI plumbing: config building, test filtering, review-category
2
+ parsing. Everything here has callers in more than one command module."""
3
+
4
+ import argparse
5
+ import fnmatch
6
+ import sys
7
+ from collections.abc import Iterable
8
+ from pathlib import Path
9
+
10
+ from ..comparison.types import TestComparison
11
+ from ..config import Config, ConfigError
12
+ from ..discovery.test_registry import TestModel
13
+ from ..reporting.status import (
14
+ FAIL,
15
+ NO_REF,
16
+ NOT_RUN,
17
+ PASS,
18
+ SIM_FAIL,
19
+ classify,
20
+ exit_code,
21
+ nothing_ran,
22
+ )
23
+
24
+
25
+ def _build_config(args: argparse.Namespace) -> Config:
26
+ """Build Config from parsed CLI arguments."""
27
+ kwargs = {}
28
+ if args.source_path:
29
+ kwargs["source_path"] = Path(args.source_path)
30
+ if hasattr(args, "config") and args.config:
31
+ kwargs["config_file"] = Path(args.config)
32
+ if hasattr(args, "reference_root") and args.reference_root:
33
+ kwargs["reference_root"] = Path(args.reference_root)
34
+ if hasattr(args, "backend") and args.backend:
35
+ kwargs["backend_name"] = args.backend
36
+ if hasattr(args, "backend_path") and args.backend_path:
37
+ kwargs["backend_path"] = args.backend_path
38
+ if hasattr(args, "show_ide") and args.show_ide:
39
+ kwargs["show_ide"] = True
40
+ if hasattr(args, "work_dir") and args.work_dir:
41
+ kwargs["work_dir"] = Path(args.work_dir)
42
+ if hasattr(args, "parallel") and args.parallel:
43
+ kwargs["parallel"] = args.parallel
44
+ if hasattr(args, "batch_size") and args.batch_size is not None:
45
+ kwargs["batch_size"] = args.batch_size
46
+ if hasattr(args, "dymola_interface") and args.dymola_interface:
47
+ kwargs["dymola_interface_path"] = Path(args.dymola_interface)
48
+ # `is not None`, NOT truthiness — `--tolerance 0` is the strictest
49
+ # possible setting and Config/comparator honor an explicit 0.0
50
+ # (comparator.py resolve chain, config.py file-tolerance precedence).
51
+ if hasattr(args, "tolerance") and args.tolerance is not None:
52
+ kwargs["tolerance"] = args.tolerance
53
+ if hasattr(args, "default_points") and args.default_points:
54
+ kwargs["default_points"] = args.default_points
55
+ if hasattr(args, "timeout") and args.timeout:
56
+ kwargs["timeout"] = args.timeout
57
+ if hasattr(args, "test_spec") and args.test_spec:
58
+ kwargs["test_spec_file"] = Path(args.test_spec)
59
+ return Config(**kwargs)
60
+
61
+
62
+ def _split_filter_list(spec: str) -> list[str]:
63
+ """Split a comma-separated filter list on commas NOT inside ``[]``.
64
+
65
+ review 2026-07-06 finding 64: naive comma-splitting broke fnmatch
66
+ character classes like ``Test[A,B]*``. Small scanner: a comma inside an
67
+ open ``[`` class belongs to the class. No escape mechanism — an
68
+ unclosed ``[`` makes the rest of the string one pattern (fnmatch treats
69
+ an unmatched ``[`` as a literal anyway).
70
+ """
71
+ parts: list[str] = []
72
+ buf: list[str] = []
73
+ in_class = False
74
+ for ch in spec:
75
+ if ch == "," and not in_class:
76
+ parts.append("".join(buf))
77
+ buf = []
78
+ continue
79
+ if ch == "[" and not in_class:
80
+ in_class = True
81
+ elif ch == "]" and in_class:
82
+ in_class = False
83
+ buf.append(ch)
84
+ parts.append("".join(buf))
85
+ return [p.strip() for p in parts if p.strip()]
86
+
87
+
88
+ def _resolve_filter_patterns(spec: str) -> list[str]:
89
+ """Resolve a --filter argument into a list of glob patterns.
90
+
91
+ Accepts:
92
+ - "Foo.Bar.*" — single glob
93
+ - "Foo.A,Foo.B,Foo.C" — comma-separated globs (commas inside []
94
+ character classes are part of the class)
95
+ - "@path/to/file.txt" — file with one pattern per line; '#' starts a comment
96
+ """
97
+ if spec.startswith("@"):
98
+ path = Path(spec[1:])
99
+ if not path.exists():
100
+ # ConfigError, not FileNotFoundError: a mistyped @file is a user
101
+ # error and main() turns it into one clean "Config error: ..."
102
+ # line and exit 2, never a traceback.
103
+ raise ConfigError(f"Filter file not found: {path}")
104
+ try:
105
+ text = path.read_text(encoding="utf-8")
106
+ except OSError as e:
107
+ raise ConfigError(f"Cannot read filter file {path}: {e}") from e
108
+ patterns = []
109
+ for raw in text.splitlines():
110
+ line = raw.split("#", 1)[0].strip()
111
+ if line:
112
+ patterns.append(line)
113
+ return patterns
114
+ return _split_filter_list(spec)
115
+
116
+
117
+ def _filter_tests(
118
+ tests: list[TestModel],
119
+ pattern: str | None = None,
120
+ package: str | None = None,
121
+ ) -> list[TestModel]:
122
+ """Filter tests by glob/list/file on model_id, plus optional package prefix."""
123
+ filtered = tests
124
+ if package:
125
+ # review 2026-07-06 finding 65: match the package itself or children
126
+ # across a dot boundary — "MyLib.Fluid" must not also select
127
+ # "MyLib.FluidExperimental.*" (raw startswith did).
128
+ filtered = [
129
+ t
130
+ for t in filtered
131
+ if t.model_id == package or t.model_id.startswith(package + ".")
132
+ ]
133
+ if pattern:
134
+ patterns = _resolve_filter_patterns(pattern)
135
+ # review 2026-07-06 finding 63: fnmatchcase — fnmatch.fnmatch is
136
+ # case-insensitive on Windows, so the same filter selected different
137
+ # tests across the two OS partitions this project maintains.
138
+ filtered = [
139
+ t
140
+ for t in filtered
141
+ if any(fnmatch.fnmatchcase(t.model_id, p) for p in patterns)
142
+ ]
143
+ return filtered
144
+
145
+
146
+ def _find_orphan_manifest_entries(config, all_tests: list) -> dict[str, str]:
147
+ """Return {test_key: model_id} for manifest entries whose model_id is no
148
+ longer in the discovered tests. These usually indicate renamed/removed
149
+ models — they don't break correctness (compare_all skips them) but their
150
+ work dirs and report dirs accumulate on disk.
151
+ """
152
+ manifest_path = config.work_dir / "batch_manifest.json"
153
+ if not manifest_path.exists():
154
+ return {}
155
+ from ..backends import BatchManifest
156
+
157
+ manifest = BatchManifest.load(manifest_path)
158
+ discovered = {t.model_id for t in all_tests}
159
+ return {
160
+ tk: entry["model_id"]
161
+ for tk, entry in manifest.manifest.items()
162
+ if entry["model_id"] not in discovered
163
+ }
164
+
165
+
166
+ _VALID_REVIEW_FILTERS = {
167
+ "all",
168
+ "failed",
169
+ "no-baseline",
170
+ "warnings",
171
+ "sim-failed",
172
+ "passed",
173
+ }
174
+
175
+
176
+ def _exit_code(comparisons: Iterable[TestComparison]) -> int:
177
+ """`run` and `compare`'s exit code on every route (``exit_code``), and
178
+ when it is 1 because nothing ran, ONE stderr line saying so: a junit or
179
+ html report, or ``--report``'s pages, exited 1 without a word (D145
180
+ §5). With no test in scope at all there is nothing to count, and the
181
+ line says that instead of "(0 NOT RUN)"."""
182
+ comps = list(comparisons)
183
+ if not comps:
184
+ print("Nothing ran: no test in scope.", file=sys.stderr)
185
+ elif nothing_ran(comps):
186
+ print(
187
+ f"Nothing ran: no test in scope has a result ({len(comps)} "
188
+ f"{NOT_RUN}) — exit 1.",
189
+ file=sys.stderr,
190
+ )
191
+ return exit_code(comps)
192
+
193
+
194
+ def _parse_review_filter(filter_str: str) -> set[str]:
195
+ """Parse and validate the review filter string."""
196
+ filters = {f.strip() for f in filter_str.split(",") if f.strip()}
197
+ invalid = filters - _VALID_REVIEW_FILTERS
198
+ if invalid:
199
+ raise argparse.ArgumentTypeError(
200
+ f"Invalid review filter(s): {', '.join(sorted(invalid))}. "
201
+ f"Valid: {', '.join(sorted(_VALID_REVIEW_FILTERS - {'all'}))}"
202
+ )
203
+ if "all" in filters:
204
+ return {"all"}
205
+ return filters
206
+
207
+
208
+ def _should_review(comp, filters: set[str]) -> bool:
209
+ """Check if a comparison matches the active review filters (`-i` and
210
+ `--rerun`). A category selects by the ONE classifier's status
211
+ (``reporting.status.classify``), the status every surface shows."""
212
+ if "all" in filters:
213
+ return True
214
+ status = classify(comp)
215
+ # NOT RUN too: nothing succeeded (D145).
216
+ if "sim-failed" in filters and status in (SIM_FAIL, NOT_RUN):
217
+ return True
218
+ # A test the comparison FAILED is "failed" and nothing else, baseline or
219
+ # not, so "no-baseline" does not select it: one the comparison refused,
220
+ # or one with no reference that fails a baseline-free criterion (range /
221
+ # declared events / declared peaks — review 2026-07-06, finding 5).
222
+ # "failed" selects both. The review loop then offers the second
223
+ # [a]ccept — accepting it writes a reference — and the first none.
224
+ if "no-baseline" in filters and not comp.has_reference and status != FAIL:
225
+ return True
226
+ if "failed" in filters and status == FAIL:
227
+ return True
228
+ if "warnings" in filters and comp.warnings:
229
+ return True
230
+ # NO_REF too: nothing was scored, and `compare_one` marks it passed.
231
+ return bool("passed" in filters and status in (PASS, NO_REF) and not comp.warnings)