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
dstf/cli/main.py ADDED
@@ -0,0 +1,561 @@
1
+ """Argument-parser construction, the subcommand dispatch table, and the
2
+ process entry points."""
3
+
4
+ import argparse
5
+ import sys
6
+
7
+ from ..config import ConfigError
8
+ from .author_cmd import cmd_author
9
+ from .baseline_cmds import cmd_companion, cmd_import_baseline, cmd_soft_check
10
+ from .common import _parse_review_filter
11
+ from .diag_cmds import (
12
+ cmd_check_dymola,
13
+ cmd_check_julia,
14
+ cmd_check_openmodelica,
15
+ cmd_discover,
16
+ cmd_export,
17
+ cmd_manifest,
18
+ )
19
+ from .run_cmds import cmd_compare, cmd_run
20
+ from .spec_cmds import cmd_add, cmd_export_schema, cmd_spec_update
21
+
22
+
23
+ def _review_filter_arg(value: str) -> str:
24
+ """argparse ``type=`` callback for ``-i`` / ``--rerun`` category lists.
25
+
26
+ review 2026-07-06 finding 58: validate at parse time so a typo like
27
+ ``-i sim_failed`` aborts in under a second with the valid-categories
28
+ message (argparse exit code 2) — not after the full simulation run.
29
+ """
30
+ _parse_review_filter(value) # raises argparse.ArgumentTypeError on typos
31
+ return value
32
+
33
+
34
+ def _add_global_flags(parser: argparse.ArgumentParser, *, subcommand: bool) -> None:
35
+ """Attach the config-locating flags every subcommand needs.
36
+
37
+ Called twice for each flag: once on the top-level parser (so
38
+ ``dstf --config X run`` works) and once on a parent parser that every
39
+ subparser inherits (so ``dstf run --config X`` works too). Both
40
+ positions must work — the flag-before-subcommand-only form was the
41
+ single biggest first-contact stumble.
42
+
43
+ *subcommand* switches the default to :data:`argparse.SUPPRESS`, and that
44
+ is load-bearing: ``_SubParsersAction`` parses the subcommand's arguments
45
+ into a fresh namespace and then copies **every** attribute of it onto the
46
+ top-level namespace. A plain ``default=None`` on the subparser copy would
47
+ therefore erase a value given before the subcommand. SUPPRESS leaves the
48
+ attribute unset when the flag isn't passed there, so the top-level value
49
+ survives; the top-level copy (``subcommand=False``) is what guarantees
50
+ the attribute exists at all.
51
+ """
52
+ default = argparse.SUPPRESS if subcommand else None
53
+ parser.add_argument(
54
+ "--source-path",
55
+ type=str,
56
+ default=default,
57
+ help="Path to the source for the library under test (Modelica package dir containing package.mo, FMU dir, ...). Default: auto-detect from cwd.",
58
+ )
59
+ parser.add_argument(
60
+ "--config",
61
+ type=str,
62
+ default=default,
63
+ help="Path to testing.json config file (default: look near package directory)",
64
+ )
65
+ parser.add_argument(
66
+ "--reference-root",
67
+ type=str,
68
+ default=default,
69
+ help="Path to reference results root (default: <library>/Resources/ReferenceResults)",
70
+ )
71
+ parser.add_argument(
72
+ "--test-spec",
73
+ type=str,
74
+ default=default,
75
+ help="Path to test_spec.json (external test definitions)",
76
+ )
77
+ parser.add_argument(
78
+ "--dymola-interface",
79
+ type=str,
80
+ default=default,
81
+ help="Path to Dymola's Python interface archive (dymola.egg or dymola-*.whl) "
82
+ "or the directory containing it. Overrides auto-discovery.",
83
+ )
84
+
85
+
86
+ def build_arg_parser() -> argparse.ArgumentParser:
87
+ """Construct the top-level CLI parser. Pulled out of :func:`main` so
88
+ tests can introspect subcommands and flags without dispatching, and
89
+ so :func:`main` stays a thin parse-and-dispatch shell.
90
+ """
91
+ from .. import __version__ as _dstf_version
92
+
93
+ parser = argparse.ArgumentParser(
94
+ prog="dstf",
95
+ description="Dynamic Systems Testing Framework — regression & unit testing for time-dependent system behavior",
96
+ )
97
+ parser.add_argument(
98
+ "--version",
99
+ "-V",
100
+ action="version",
101
+ version=f"%(prog)s {_dstf_version}",
102
+ help="Print the installed dstf version and exit.",
103
+ )
104
+ _add_global_flags(parser, subcommand=False)
105
+
106
+ # Inherited by every subparser so the global flags are accepted after the
107
+ # subcommand as well as before it.
108
+ global_parent = argparse.ArgumentParser(add_help=False)
109
+ _add_global_flags(global_parent, subcommand=True)
110
+
111
+ # Parent parsers for flag groups shared across subcommands. argparse
112
+ # merges these via parents=[...] on add_parser; each subcommand inherits
113
+ # only the groups that apply, keeping --help output identical to the
114
+ # pre-refactor inline form while eliminating duplication.
115
+ filter_parent = argparse.ArgumentParser(add_help=False)
116
+ filter_parent.add_argument(
117
+ "--filter",
118
+ type=str,
119
+ help="Glob, comma-separated list, or @file (one pattern per line) — "
120
+ "matches against model_id. Commas inside [] character classes are "
121
+ "part of the class, not separators (e.g. 'Test[A,B]*' is one pattern).",
122
+ )
123
+ filter_parent.add_argument("--package", type=str, help="Filter by package prefix")
124
+
125
+ compare_parent = argparse.ArgumentParser(add_help=False)
126
+ compare_parent.add_argument(
127
+ "--tolerance", type=float, help="Override comparison tolerance"
128
+ )
129
+ compare_parent.add_argument(
130
+ "--default-points",
131
+ action="store_true",
132
+ help="Use points mode (with empty points list) as the default for variables without an explicit ``mode`` override.",
133
+ )
134
+
135
+ report_parent = argparse.ArgumentParser(add_help=False)
136
+ report_parent.add_argument(
137
+ "--report-format",
138
+ choices=["console", "junit", "html"],
139
+ default="console",
140
+ help="Output format for test report",
141
+ )
142
+ report_parent.add_argument(
143
+ "--output",
144
+ "-o",
145
+ type=str,
146
+ default=None,
147
+ help="Destination for --report-format junit/html "
148
+ "(default: test-results.xml / test-report.html in the CWD). "
149
+ "Ignored by the console format.",
150
+ )
151
+ report_parent.add_argument(
152
+ "--report",
153
+ action="store_true",
154
+ help="Render per-test interactive deep-dive reports under "
155
+ "<work_dir>/reports/<test>/. The unified <work_dir>/dashboard.html "
156
+ "always renders regardless of this flag — it just doesn't link to "
157
+ "per-test pages without --report.",
158
+ )
159
+ report_parent.add_argument(
160
+ "--no-browser",
161
+ action="store_true",
162
+ help="Open no browser tab on the dashboard or a report page; the "
163
+ "file:// link is still printed.",
164
+ )
165
+
166
+ subparsers = parser.add_subparsers(dest="command", help="Command to run")
167
+
168
+ # discover
169
+ subparsers.add_parser(
170
+ "discover",
171
+ parents=[global_parent, filter_parent],
172
+ help="Discover and list all test models",
173
+ )
174
+
175
+ # run
176
+ p_run = subparsers.add_parser(
177
+ "run",
178
+ parents=[global_parent, filter_parent, compare_parent, report_parent],
179
+ help="Run tests (simulate via the configured backend + compare against references)",
180
+ )
181
+ # review 2026-07-06 finding 61: review-then-accept (-i) and
182
+ # accept-everything (--accept) are mutually exclusive intents — make
183
+ # combining them a hard argparse error instead of silently letting
184
+ # --accept win and skipping the review the user asked for.
185
+ accept_or_review = p_run.add_mutually_exclusive_group()
186
+ accept_or_review.add_argument(
187
+ "-i",
188
+ "--interactive",
189
+ nargs="?",
190
+ const="all",
191
+ default=None,
192
+ metavar="FILTER",
193
+ type=_review_filter_arg, # review 2026-07-06 finding 58: parse-time check
194
+ help="Interactive review. Optional filter: failed, no-baseline, "
195
+ "warnings, sim-failed, passed (comma-separated). Default: all.",
196
+ )
197
+ accept_or_review.add_argument(
198
+ "--accept",
199
+ action="store_true",
200
+ help="Accept results as new baseline references",
201
+ )
202
+ p_run.add_argument("--backend", type=str, help="Backend name (e.g., 'Dymola 2025')")
203
+ p_run.add_argument(
204
+ "--backend-path",
205
+ type=str,
206
+ help="Absolute path to the backend's executable (overrides config)",
207
+ )
208
+ p_run.add_argument(
209
+ "--show-ide",
210
+ action="store_true",
211
+ help="Show Dymola GUI instead of running headless",
212
+ )
213
+ p_run.add_argument("--work-dir", type=str, help="Working directory for output")
214
+ p_run.add_argument(
215
+ "--parallel",
216
+ type=int,
217
+ help="Number of concurrent SIMULATION workers (one simulator process each, where the backend drives one). "
218
+ "Note: on `compare` the same flag means report-rendering threads.",
219
+ )
220
+ p_run.add_argument(
221
+ "--batch-size",
222
+ type=int,
223
+ dest="batch_size",
224
+ help="(--batch only) Tests per Dymola session (default: all-per-worker). Small values (3-5) give better load balancing and crash isolation but reload the library more often.",
225
+ )
226
+ p_run.add_argument(
227
+ "--batch",
228
+ action="store_true",
229
+ help="Use the legacy batched script backend (Dymola .mos / OpenModelica subprocess) instead of the default persistent-worker mode. A backend whose persistent preflight fails (Dymola DymolaInterface or OMPython missing) aborts with a message pointing here — there is no silent fallback.",
230
+ )
231
+ p_run.add_argument(
232
+ "--timeout",
233
+ type=int,
234
+ default=None,
235
+ help="Per-test timeout in seconds (default: 60)",
236
+ )
237
+ p_run.add_argument(
238
+ "--merge",
239
+ action="store_true",
240
+ help="When used with --filter, expand the read/compare/report scope to "
241
+ "include all tests in the persistent batch manifest (not just the "
242
+ "filtered subset). Lets you rerun a few tests but still see a full "
243
+ "report covering the whole suite.",
244
+ )
245
+ p_run.add_argument(
246
+ "--rerun",
247
+ nargs="?",
248
+ const="failed",
249
+ default=None,
250
+ metavar="CATEGORIES",
251
+ type=_review_filter_arg, # review 2026-07-06 finding 58: parse-time check
252
+ help="Rerun tests selected by status from the prior run. Categories: "
253
+ "failed, no-baseline, warnings, sim-failed, passed (comma-separated). "
254
+ "Default: failed. Implies --merge.",
255
+ )
256
+
257
+ # compare
258
+ p_compare = subparsers.add_parser(
259
+ "compare",
260
+ parents=[global_parent, filter_parent, compare_parent, report_parent],
261
+ help="Compare last results against references",
262
+ )
263
+ p_compare.add_argument(
264
+ "--backend",
265
+ type=str,
266
+ help="Backend name (e.g., 'Dymola 2025'). Affects which "
267
+ "<reference_root>/<Backend>/<os>/ partition is read.",
268
+ )
269
+ p_compare.add_argument(
270
+ "--backend-path",
271
+ type=str,
272
+ help="Absolute path to the backend's executable (overrides config). "
273
+ "Mainly affects which backend identity is used for partition lookup.",
274
+ )
275
+ p_compare.add_argument(
276
+ "--work-dir", type=str, help="Working directory containing prior run outputs"
277
+ )
278
+ p_compare.add_argument(
279
+ "--parallel",
280
+ type=int,
281
+ help="Threads for --report RENDERING (Plotly serialization + JSON sidecar dump). "
282
+ "Comparison itself is fast; this is the post-comparison report suite. "
283
+ "Note: on `run` the same flag means simulation workers. Default: 1.",
284
+ )
285
+ p_compare.add_argument(
286
+ "--accept",
287
+ action="store_true",
288
+ help="Accept the last run's on-disk results as new baselines WITHOUT "
289
+ "re-simulating. Skips tests the run recorded as failed/timed_out.",
290
+ )
291
+
292
+ # author — the authoring tool (NiceGUI shell over the Session)
293
+ p_author = subparsers.add_parser(
294
+ "author",
295
+ parents=[global_parent, filter_parent],
296
+ help="Open the authoring tool on this project",
297
+ )
298
+ p_author.add_argument(
299
+ "--port",
300
+ type=int,
301
+ default=None,
302
+ help="Port to serve the shell on (default: 8080). It binds 127.0.0.1 only.",
303
+ )
304
+ p_author.add_argument(
305
+ "--no-browser",
306
+ action="store_true",
307
+ help="Serve without opening a browser tab (headless launch).",
308
+ )
309
+ p_author.add_argument(
310
+ "--work-dir", type=str, help="Working directory containing prior run outputs"
311
+ )
312
+ p_author.add_argument(
313
+ "--backend",
314
+ type=str,
315
+ help="Backend name (e.g., 'Dymola 2025'). Affects which "
316
+ "<reference_root>/<Backend>/<os>/ partition is read.",
317
+ )
318
+
319
+ # export
320
+ p_export = subparsers.add_parser(
321
+ "export",
322
+ parents=[global_parent],
323
+ help="Export reference data",
324
+ )
325
+ p_export.add_argument(
326
+ "--format",
327
+ choices=["json", "csv"],
328
+ default="json",
329
+ help="Export format",
330
+ )
331
+ p_export.add_argument("--output", type=str, help="Output file path")
332
+
333
+ # manifest
334
+ p_manifest = subparsers.add_parser(
335
+ "manifest", parents=[global_parent], help="Manage the test manifest"
336
+ )
337
+ p_manifest.add_argument(
338
+ "action",
339
+ choices=["show", "dump", "cleanup"],
340
+ help="'show': display all references and their status. "
341
+ "'dump': write ref ID to model ID mapping to work directory. "
342
+ "'cleanup': remove reference files with status 'obsolete' "
343
+ "(dry-run by default; pass --apply to delete).",
344
+ )
345
+ p_manifest.add_argument(
346
+ "--show-obsolete",
347
+ action="store_true",
348
+ help="Also show obsolete entries (show only)",
349
+ )
350
+ p_manifest.add_argument(
351
+ "--orphans",
352
+ action="store_true",
353
+ help="cleanup: target orphan batch_manifest entries (models no longer "
354
+ "in discovery) instead of obsolete reference files.",
355
+ )
356
+ p_manifest.add_argument(
357
+ "--apply",
358
+ action="store_true",
359
+ help="cleanup: actually delete. EVERY cleanup path is a dry-run "
360
+ "without this flag — it lists what it would remove and exits.",
361
+ )
362
+
363
+ # add
364
+ p_add = subparsers.add_parser(
365
+ "add", parents=[global_parent], help="Add a test to test_spec.json"
366
+ )
367
+ p_add.add_argument(
368
+ "model_id",
369
+ type=str,
370
+ help="Fully qualified Modelica model path (e.g., 'MyLib.Examples.Test')",
371
+ )
372
+ p_add.add_argument(
373
+ "--variables",
374
+ nargs="*",
375
+ default=None,
376
+ help="Variable patterns to track (e.g., 'pipe.T*' 'pump.m_flow'). "
377
+ "Omit for simulate-only.",
378
+ )
379
+
380
+ # spec-update
381
+ p_spec_update = subparsers.add_parser(
382
+ "spec-update",
383
+ parents=[global_parent],
384
+ help="Apply a JSON-Patch envelope to test_spec.json",
385
+ )
386
+ p_spec_update.add_argument(
387
+ "json_file",
388
+ type=str,
389
+ help="Path to the patch envelope (e.g. spec_patch.json from dstf author's Export)",
390
+ )
391
+
392
+ # companion — manage plot-only overlays (D66 baseline-role split)
393
+ p_companion = subparsers.add_parser(
394
+ "companion",
395
+ parents=[global_parent],
396
+ help="Manage plot-only companion references (external data shown as overlays)",
397
+ )
398
+ p_companion.add_argument(
399
+ "action",
400
+ choices=["add", "list", "freeze", "remove"],
401
+ help="'add': register an external file pointer. 'list': show companions. "
402
+ "'freeze': copy external data into ref storage. 'remove': deregister.",
403
+ )
404
+ p_companion.add_argument(
405
+ "model_id",
406
+ nargs="?",
407
+ help="Model ID (required for add/freeze/remove; optional for list)",
408
+ )
409
+ p_companion.add_argument(
410
+ "name", nargs="?", help="Companion name (required for add/freeze/remove)"
411
+ )
412
+ p_companion.add_argument(
413
+ "path", nargs="?", help="Path to data file (required for add)"
414
+ )
415
+ p_companion.add_argument(
416
+ "--format",
417
+ choices=["csv", "json"],
418
+ default=None,
419
+ help="Data format (auto-detected from extension if omitted)",
420
+ )
421
+ p_companion.add_argument(
422
+ "--map",
423
+ dest="variable_map",
424
+ action="append",
425
+ metavar="TEST_VAR=COMPANION_VAR",
426
+ default=None,
427
+ help="'add' only: which of the companion's variables draws on which "
428
+ "of the test's. Repeat per variable. Both names are checked against "
429
+ "the stored baseline and the companion file. Two test variables "
430
+ "may name one companion variable (one curve, drawn twice). "
431
+ "Without any --map the reporter falls back on matching spellings.",
432
+ )
433
+
434
+ # soft-check — manage advisory cross-check baselines (D66)
435
+ p_soft = subparsers.add_parser(
436
+ "soft-check",
437
+ parents=[global_parent],
438
+ help="Manage soft_check baselines (advisory cross-checks; warn-only scoring)",
439
+ )
440
+ p_soft.add_argument(
441
+ "action",
442
+ choices=["list", "remove"],
443
+ help="'list': show soft_checks. 'remove': deregister. "
444
+ "Note: there is no `soft-check add` — by design, soft_checks are "
445
+ "added only via `import-baseline` (D66 baseline-role split: "
446
+ "soft_checks come from another regression system's primary, not "
447
+ "from local user input).",
448
+ )
449
+ p_soft.add_argument("model_id", nargs="?")
450
+ p_soft.add_argument("name", nargs="?")
451
+
452
+ # import-baseline — import another system's primary as a soft_check (D66)
453
+ p_import = subparsers.add_parser(
454
+ "import-baseline",
455
+ parents=[global_parent],
456
+ help="Import another regression system's primary as a soft_check",
457
+ )
458
+ p_import.add_argument(
459
+ "model_id", type=str, help="Local model ID to attach the soft_check to"
460
+ )
461
+ p_import.add_argument(
462
+ "name",
463
+ type=str,
464
+ help="Soft_check name (how MetricTree leaves will reference it)",
465
+ )
466
+ p_import.add_argument(
467
+ # The dest must NOT be "source_path": that name is also the global
468
+ # --source-path option, and _build_config would treat the ref JSON
469
+ # as the library root (find_package_dir then iterdir()s a file —
470
+ # NotADirectoryError; found live during W-6, D103). metavar keeps
471
+ # the user-facing name.
472
+ "import_source",
473
+ metavar="source_path",
474
+ type=str,
475
+ help="Path to source reference JSON (flat ref file shape)",
476
+ )
477
+
478
+ # export-schema — emit JSON-Schema for test_spec.json (6.4.5)
479
+ p_schema = subparsers.add_parser(
480
+ "export-schema",
481
+ parents=[global_parent],
482
+ help="Emit a JSON-Schema describing the test_spec.json shape",
483
+ )
484
+ p_schema.add_argument(
485
+ "--output",
486
+ "-o",
487
+ type=str,
488
+ default=None,
489
+ help="Write schema to this file instead of stdout",
490
+ )
491
+
492
+ # check-* — backend diagnostic commands (symmetric across Dymola / OM / Julia)
493
+ subparsers.add_parser(
494
+ "check-dymola",
495
+ parents=[global_parent],
496
+ help="Locate and load Dymola's Python interface; report what was found",
497
+ )
498
+ subparsers.add_parser(
499
+ "check-openmodelica",
500
+ parents=[global_parent],
501
+ help="Probe OMPython + the `omc` binary; report whether the OM stack is ready",
502
+ )
503
+ subparsers.add_parser(
504
+ "check-julia",
505
+ parents=[global_parent],
506
+ help="Probe the `julia` binary on PATH; report version and readiness",
507
+ )
508
+
509
+ return parser
510
+
511
+
512
+ # Subcommand → handler dispatch table. Lives at module scope so tests can
513
+ # assert the registered commands without re-running `main()`. Update both
514
+ # the parser (in :func:`build_arg_parser`) and this dict when adding a
515
+ # subcommand — symmetry is checked by :data:`_COMMANDS_PARSER_PARITY` test.
516
+ _COMMANDS = {
517
+ "discover": cmd_discover,
518
+ "run": cmd_run,
519
+ "compare": cmd_compare,
520
+ "author": cmd_author,
521
+ "export": cmd_export,
522
+ "manifest": cmd_manifest,
523
+ "add": cmd_add,
524
+ "spec-update": cmd_spec_update,
525
+ "companion": cmd_companion,
526
+ "soft-check": cmd_soft_check,
527
+ "import-baseline": cmd_import_baseline,
528
+ "export-schema": cmd_export_schema,
529
+ "check-dymola": cmd_check_dymola,
530
+ "check-openmodelica": cmd_check_openmodelica,
531
+ "check-julia": cmd_check_julia,
532
+ }
533
+
534
+
535
+ def main(argv: list[str] | None = None) -> int:
536
+ parser = build_arg_parser()
537
+ args = parser.parse_args(argv)
538
+ if args.command is None:
539
+ parser.print_help()
540
+ return 1
541
+ try:
542
+ # Before dispatch so every subcommand sees the same registries —
543
+ # a plug-in backend must be resolvable by `discover`, `run` and
544
+ # `export-schema` alike. A broken plug-in raises ConfigError and
545
+ # lands on the clean exit-2 path below.
546
+ from ..plugins import load_plugins
547
+
548
+ load_plugins()
549
+ return _COMMANDS[args.command](args)
550
+ except ConfigError as exc:
551
+ # review 2026-07-06 leftover A: a testing.json problem (malformed,
552
+ # unreadable, missing explicit --config) is a user error — print one
553
+ # clean line and exit 2 (argparse's usage-error code), never a
554
+ # traceback.
555
+ print(f"Config error: {exc}", file=sys.stderr)
556
+ return 2
557
+
558
+
559
+ def main_entry() -> None:
560
+ """Entry point for the console_scripts wrapper."""
561
+ sys.exit(main())