dynamic-systems-testing-framework 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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)
|