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
|
File without changes
|
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
"""JSON-driven Recognizer (PTA.2).
|
|
2
|
+
|
|
3
|
+
Lets users declare a custom test-recognition convention in `testing.json`
|
|
4
|
+
without writing Python. Schema:
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"name": "<unique recognizer name>",
|
|
8
|
+
"applies_to": ["modelica"], # Config.source_type values
|
|
9
|
+
"paths_include": ["Examples/*"], # optional fnmatch globs on the
|
|
10
|
+
"paths_exclude": ["**/Internal/*"], # source-relative file path
|
|
11
|
+
"match": {
|
|
12
|
+
"type": "component-instantiation" | "extends" | "class-name-glob"
|
|
13
|
+
| "all-of" | "any-of",
|
|
14
|
+
... # match-type-specific keys
|
|
15
|
+
},
|
|
16
|
+
"fields": {
|
|
17
|
+
"<TestModel-field-name>": {
|
|
18
|
+
"from": "parameter" | "constant" | "annotation"
|
|
19
|
+
| "experiment-annotation",
|
|
20
|
+
... # source-specific keys
|
|
21
|
+
},
|
|
22
|
+
...
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Modelica match types (registered via `@_register_match`):
|
|
27
|
+
- `component-instantiation` (`component_name`): match any Modelica class
|
|
28
|
+
that instantiates a component of the given class. Accepts the fully
|
|
29
|
+
qualified name and any tail-suffix (Modelica's import rules).
|
|
30
|
+
- `extends` (`class_pattern`): match any class with `extends X` where X
|
|
31
|
+
matches the given fnmatch glob (e.g. `*Icons.Example`).
|
|
32
|
+
- `class-name-glob` (`class_pattern`): match on the class's own fully
|
|
33
|
+
qualified name (`within` + class name).
|
|
34
|
+
- `all-of` / `any-of` (`matchers`): compose child match specs. `all-of`
|
|
35
|
+
requires every child and aggregates their contexts; `any-of` takes the
|
|
36
|
+
first child that matches.
|
|
37
|
+
|
|
38
|
+
Field sources (registered via `@_register_field`):
|
|
39
|
+
- `parameter` (`name`, optional `shape`): extract from the matched
|
|
40
|
+
component's parameter list. Only valid where the spec contains a
|
|
41
|
+
`component-instantiation` leaf. `shape`: `scalar` (default), `array`,
|
|
42
|
+
`array_floats`.
|
|
43
|
+
- `constant` (`value`): use the literal value verbatim.
|
|
44
|
+
- `annotation` (`annotation`, `name`, optional `shape`): extract a key
|
|
45
|
+
from an arbitrary annotation block (e.g. `__MyVendor_Test`).
|
|
46
|
+
- `experiment-annotation` (`name`): extract from the standard Modelica
|
|
47
|
+
`experiment(...)` annotation. Case-insensitive first letter (handles
|
|
48
|
+
`StopTime` vs. `stopTime`).
|
|
49
|
+
|
|
50
|
+
`_MATCH_TYPE_ALLOWED_FIELD_SOURCES` gates which sources a given match type
|
|
51
|
+
admits, validated at parse time.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import fnmatch
|
|
57
|
+
import logging
|
|
58
|
+
import re
|
|
59
|
+
from collections.abc import Callable
|
|
60
|
+
from dataclasses import dataclass
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
from typing import Any
|
|
63
|
+
|
|
64
|
+
from .mo_parser import (
|
|
65
|
+
_extract_balanced_braces,
|
|
66
|
+
_extract_model_name,
|
|
67
|
+
_extract_within,
|
|
68
|
+
_find_experiment_span,
|
|
69
|
+
_parse_float_list,
|
|
70
|
+
_parse_x_expressions,
|
|
71
|
+
_strip_modelica_literals,
|
|
72
|
+
)
|
|
73
|
+
from .recognizer import Recognizer, RecognizerResult
|
|
74
|
+
|
|
75
|
+
logger = logging.getLogger(__name__)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RecognizerSpecError(ValueError):
|
|
79
|
+
"""Raised when a JSON recognizer spec is malformed."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Fields a JSON spec can write into a RecognizerResult. Keep in sync with
|
|
83
|
+
# RecognizerResult dataclass (model_id and source_file are filled implicitly;
|
|
84
|
+
# unit_tests_instance is NOT spec-writable — it comes from the matched
|
|
85
|
+
# component, see JsonRecognizer.recognize).
|
|
86
|
+
_VALID_FIELDS = frozenset(
|
|
87
|
+
{
|
|
88
|
+
"n_vars",
|
|
89
|
+
"x_expressions",
|
|
90
|
+
"x_raw",
|
|
91
|
+
"x_reference",
|
|
92
|
+
"error_expected",
|
|
93
|
+
"stop_time",
|
|
94
|
+
"tolerance",
|
|
95
|
+
"method",
|
|
96
|
+
"number_of_intervals",
|
|
97
|
+
"output_interval",
|
|
98
|
+
# PTA.4 — richer-contract fields
|
|
99
|
+
"simulate_only",
|
|
100
|
+
"requested_baselines",
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Subset of _VALID_FIELDS that RecognizerResult types as a number. A spec may
|
|
105
|
+
# only write a number into these — see _numeric_or_none. The int subset gets
|
|
106
|
+
# an exact-integer check: n_vars=2.5 is spec garbage, not a count.
|
|
107
|
+
_NUMERIC_FIELDS = frozenset(
|
|
108
|
+
{
|
|
109
|
+
"n_vars",
|
|
110
|
+
"error_expected",
|
|
111
|
+
"stop_time",
|
|
112
|
+
"tolerance",
|
|
113
|
+
"number_of_intervals",
|
|
114
|
+
"output_interval",
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
_INT_FIELDS = frozenset({"n_vars", "number_of_intervals"})
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _numeric_or_none(model_id: str, field_name: str, value: Any) -> Any:
|
|
121
|
+
"""Return ``value`` if it is a number, else None after warning.
|
|
122
|
+
|
|
123
|
+
``_coerce_scalar`` is best-effort and falls back to the raw string for
|
|
124
|
+
anything it cannot parse (``StopTime=0.5*3600``). Setting that string on a
|
|
125
|
+
float-typed field pushes the failure into a backend far from its cause, so
|
|
126
|
+
leave the field unset instead — the annotation / test_spec layer can still
|
|
127
|
+
supply a value.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def _reject() -> None:
|
|
131
|
+
logger.warning(
|
|
132
|
+
"%s: ignoring non-numeric recognizer value for %s=%r",
|
|
133
|
+
model_id,
|
|
134
|
+
field_name,
|
|
135
|
+
value,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# bool is an int subclass AND float(True) == 1.0 — a bare float()
|
|
139
|
+
# fallback would silently turn `true` into 1.0 (A5 review finding 2).
|
|
140
|
+
if isinstance(value, bool):
|
|
141
|
+
_reject()
|
|
142
|
+
return None
|
|
143
|
+
if isinstance(value, (int, float)):
|
|
144
|
+
num: float = value
|
|
145
|
+
else:
|
|
146
|
+
try:
|
|
147
|
+
num = float(value)
|
|
148
|
+
except (TypeError, ValueError):
|
|
149
|
+
_reject()
|
|
150
|
+
return None
|
|
151
|
+
if field_name in _INT_FIELDS:
|
|
152
|
+
if num != int(num):
|
|
153
|
+
_reject()
|
|
154
|
+
return None
|
|
155
|
+
return int(num)
|
|
156
|
+
return num
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Match contexts
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class ComponentMatch:
|
|
166
|
+
"""A component-instantiation match. Carries the parameter-list text so
|
|
167
|
+
the `parameter` field source can extract from it."""
|
|
168
|
+
|
|
169
|
+
component_class: str
|
|
170
|
+
instance_name: str
|
|
171
|
+
param_text: str
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class ExtendsMatch:
|
|
176
|
+
"""An extends match."""
|
|
177
|
+
|
|
178
|
+
extended_class: str
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass
|
|
182
|
+
class ClassNameMatch:
|
|
183
|
+
"""A class-name-glob match (PTA-follow.3)."""
|
|
184
|
+
|
|
185
|
+
qualified_name: str
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class AnnotationMatch:
|
|
190
|
+
"""A generic-annotation match: the matched annotation's param text."""
|
|
191
|
+
|
|
192
|
+
annotation_name: str
|
|
193
|
+
param_text: str
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass
|
|
197
|
+
class CompositeMatch:
|
|
198
|
+
"""A composite of one or more sub-matches (PTA-follow.2: all-of / any-of).
|
|
199
|
+
|
|
200
|
+
Field-source interpreters that need a specific leaf-match shape (e.g.,
|
|
201
|
+
`parameter` needs a ComponentMatch) traverse the composite via
|
|
202
|
+
:func:`_find_match`.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
matches: list
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
MatchContext = ComponentMatch | ExtendsMatch | CompositeMatch
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _find_match(match: MatchContext, type_):
|
|
212
|
+
"""Return the first sub-match of the given type, traversing composites."""
|
|
213
|
+
if isinstance(match, type_):
|
|
214
|
+
return match
|
|
215
|
+
if isinstance(match, CompositeMatch):
|
|
216
|
+
for m in match.matches:
|
|
217
|
+
found = _find_match(m, type_)
|
|
218
|
+
if found is not None:
|
|
219
|
+
return found
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# Match-type → field-sources allowed mapping, validated at parse time.
|
|
224
|
+
# `parameter` requires a parameter list, which only `component-instantiation`
|
|
225
|
+
# matches provide. Composite types (all-of / any-of) inherit the union of
|
|
226
|
+
# allowed sources across their children — see _allowed_sources_for.
|
|
227
|
+
_MATCH_TYPE_ALLOWED_FIELD_SOURCES = {
|
|
228
|
+
"component-instantiation": frozenset(
|
|
229
|
+
{"parameter", "constant", "experiment-annotation", "annotation"}
|
|
230
|
+
),
|
|
231
|
+
"extends": frozenset({"constant", "experiment-annotation", "annotation"}),
|
|
232
|
+
"class-name-glob": frozenset({"constant", "experiment-annotation", "annotation"}),
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _collect_leaf_match_types(match_spec: dict) -> set[str]:
|
|
237
|
+
"""Walk a match spec and return the set of leaf match types it contains."""
|
|
238
|
+
t = match_spec.get("type")
|
|
239
|
+
if t in ("all-of", "any-of"):
|
|
240
|
+
out: set[str] = set()
|
|
241
|
+
for child in match_spec.get("matchers", []):
|
|
242
|
+
out |= _collect_leaf_match_types(child)
|
|
243
|
+
return out
|
|
244
|
+
return {t} if t else set()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _allowed_sources_for(match_spec: dict) -> frozenset[str]:
|
|
248
|
+
"""Union of field sources allowed for any leaf match type in the spec."""
|
|
249
|
+
leaf_types = _collect_leaf_match_types(match_spec)
|
|
250
|
+
out: set[str] = set()
|
|
251
|
+
for t in leaf_types:
|
|
252
|
+
out |= _MATCH_TYPE_ALLOWED_FIELD_SOURCES.get(t, frozenset())
|
|
253
|
+
return frozenset(out)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# Match interpreter registry
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
_MatchFn = Callable[[str, dict], MatchContext | None]
|
|
261
|
+
_MATCH_INTERPRETERS: dict[str, _MatchFn] = {}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _register_match(type_name: str):
|
|
265
|
+
def deco(fn: _MatchFn) -> _MatchFn:
|
|
266
|
+
_MATCH_INTERPRETERS[type_name] = fn
|
|
267
|
+
return fn
|
|
268
|
+
|
|
269
|
+
return deco
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@_register_match("component-instantiation")
|
|
273
|
+
def _match_component_instantiation(content: str, spec: dict) -> ComponentMatch | None:
|
|
274
|
+
full = spec["component_name"]
|
|
275
|
+
segments = full.split(".")
|
|
276
|
+
# Try fully qualified, then progressively shorter tail-suffixes — most
|
|
277
|
+
# specific first so longer matches win when alternatives overlap.
|
|
278
|
+
tails = [".".join(segments[i:]) for i in range(len(segments))]
|
|
279
|
+
pattern_str = "|".join(re.escape(t) for t in tails)
|
|
280
|
+
pattern = re.compile(rf"\b(?:{pattern_str})\s+(\w+)\s*\(", re.DOTALL)
|
|
281
|
+
# Strip comments + string literals so prose mentioning the component
|
|
282
|
+
# class doesn't false-trigger. The stripper is 1:1 (offset-preserving),
|
|
283
|
+
# so the balanced-paren scan runs on stripped text but the param text is
|
|
284
|
+
# sliced from the ORIGINAL content — review 2026-07-06 finding 50:
|
|
285
|
+
# string-valued parameters (algorithm="Dassl") were unextractable from
|
|
286
|
+
# the stripped text.
|
|
287
|
+
stripped = _strip_modelica_literals(content)
|
|
288
|
+
m = pattern.search(stripped)
|
|
289
|
+
if not m:
|
|
290
|
+
return None
|
|
291
|
+
instance_name = m.group(1)
|
|
292
|
+
matched_class = m.group(0).rsplit(instance_name, 1)[0].strip()
|
|
293
|
+
paren_open = m.end() - 1
|
|
294
|
+
depth = 0
|
|
295
|
+
end = paren_open
|
|
296
|
+
for i in range(paren_open, len(stripped)):
|
|
297
|
+
if stripped[i] == "(":
|
|
298
|
+
depth += 1
|
|
299
|
+
elif stripped[i] == ")":
|
|
300
|
+
depth -= 1
|
|
301
|
+
if depth == 0:
|
|
302
|
+
end = i
|
|
303
|
+
break
|
|
304
|
+
return ComponentMatch(
|
|
305
|
+
component_class=matched_class,
|
|
306
|
+
instance_name=instance_name,
|
|
307
|
+
param_text=content[paren_open + 1 : end],
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@_register_match("extends")
|
|
312
|
+
def _match_extends(content: str, spec: dict) -> ExtendsMatch | None:
|
|
313
|
+
pattern_glob = spec["class_pattern"]
|
|
314
|
+
# Strip comments + string literals first — prose like "extends
|
|
315
|
+
# ModelicaTestingLib.Icons.Example" inside a doc annotation would
|
|
316
|
+
# otherwise register as a real extends, misidentifying icon classes
|
|
317
|
+
# as tests.
|
|
318
|
+
stripped = _strip_modelica_literals(content)
|
|
319
|
+
for m in re.finditer(r"\bextends\s+([\w.]+)", stripped):
|
|
320
|
+
extended = m.group(1)
|
|
321
|
+
if fnmatch.fnmatchcase(extended, pattern_glob):
|
|
322
|
+
return ExtendsMatch(extended_class=extended)
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# NOTE: the comment/string stripper is shared with mo_parser
|
|
327
|
+
# (``_strip_modelica_literals``) — review 2026-07-06 findings 48/50: the
|
|
328
|
+
# module used to carry its own copy whose replacements shifted offsets,
|
|
329
|
+
# breaking original-text extraction of string-valued parameters.
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@_register_match("class-name-glob")
|
|
333
|
+
def _match_class_name_glob(content: str, spec: dict) -> ClassNameMatch | None:
|
|
334
|
+
"""Match a class whose fully qualified name matches an fnmatch glob."""
|
|
335
|
+
pattern_glob = spec["class_pattern"]
|
|
336
|
+
within = _extract_within(content)
|
|
337
|
+
class_name = _extract_model_name(content)
|
|
338
|
+
if not class_name:
|
|
339
|
+
return None
|
|
340
|
+
qualified = f"{within}.{class_name}" if within else class_name
|
|
341
|
+
if fnmatch.fnmatchcase(qualified, pattern_glob):
|
|
342
|
+
return ClassNameMatch(qualified_name=qualified)
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@_register_match("all-of")
|
|
347
|
+
def _match_all_of(content: str, spec: dict) -> CompositeMatch | None:
|
|
348
|
+
"""All child matchers must match; result aggregates all child contexts."""
|
|
349
|
+
matches = []
|
|
350
|
+
for child_spec in spec.get("matchers", []):
|
|
351
|
+
child_fn = _MATCH_INTERPRETERS.get(child_spec.get("type"))
|
|
352
|
+
if child_fn is None:
|
|
353
|
+
return None
|
|
354
|
+
result = child_fn(content, child_spec)
|
|
355
|
+
if result is None:
|
|
356
|
+
return None
|
|
357
|
+
matches.append(result)
|
|
358
|
+
return CompositeMatch(matches=matches) if matches else None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@_register_match("any-of")
|
|
362
|
+
def _match_any_of(content: str, spec: dict) -> CompositeMatch | None:
|
|
363
|
+
"""First matching child wins; result wraps just that child."""
|
|
364
|
+
for child_spec in spec.get("matchers", []):
|
|
365
|
+
child_fn = _MATCH_INTERPRETERS.get(child_spec.get("type"))
|
|
366
|
+
if child_fn is None:
|
|
367
|
+
continue
|
|
368
|
+
result = child_fn(content, child_spec)
|
|
369
|
+
if result is not None:
|
|
370
|
+
return CompositeMatch(matches=[result])
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
# Field-source interpreter registry
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
_FieldFn = Callable[[str, MatchContext, dict], Any]
|
|
379
|
+
_FIELD_INTERPRETERS: dict[str, _FieldFn] = {}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _register_field(source_name: str):
|
|
383
|
+
def deco(fn: _FieldFn) -> _FieldFn:
|
|
384
|
+
_FIELD_INTERPRETERS[source_name] = fn
|
|
385
|
+
return fn
|
|
386
|
+
|
|
387
|
+
return deco
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _coerce_scalar(raw: str) -> Any:
|
|
391
|
+
"""Best-effort coerce a raw token to bool / int / float / str."""
|
|
392
|
+
raw = raw.strip().strip('"')
|
|
393
|
+
if not raw:
|
|
394
|
+
return None
|
|
395
|
+
if raw == "true":
|
|
396
|
+
return True
|
|
397
|
+
if raw == "false":
|
|
398
|
+
return False
|
|
399
|
+
try:
|
|
400
|
+
if "." in raw or "e" in raw or "E" in raw:
|
|
401
|
+
return float(raw)
|
|
402
|
+
return int(raw)
|
|
403
|
+
except ValueError:
|
|
404
|
+
try:
|
|
405
|
+
return float(raw)
|
|
406
|
+
except ValueError:
|
|
407
|
+
return raw
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _extract_param_value(param_text: str, name: str, shape: str) -> Any:
|
|
411
|
+
"""Extract a parameter value of the given shape from a parameter list."""
|
|
412
|
+
if shape == "array":
|
|
413
|
+
m = re.search(rf"\b{re.escape(name)}\s*=\s*\{{", param_text)
|
|
414
|
+
if not m:
|
|
415
|
+
return None
|
|
416
|
+
raw = _extract_balanced_braces(param_text, m.end() - 1)
|
|
417
|
+
return _parse_x_expressions(raw)
|
|
418
|
+
if shape == "array_floats":
|
|
419
|
+
m = re.search(rf"\b{re.escape(name)}\s*=\s*\{{", param_text)
|
|
420
|
+
if not m:
|
|
421
|
+
return None
|
|
422
|
+
raw = _extract_balanced_braces(param_text, m.end() - 1)
|
|
423
|
+
return _parse_float_list(raw)
|
|
424
|
+
# scalar — try quoted string first, then bare token
|
|
425
|
+
m = re.search(rf'\b{re.escape(name)}\s*=\s*"([^"]*)"', param_text)
|
|
426
|
+
if m:
|
|
427
|
+
return m.group(1)
|
|
428
|
+
m = re.search(rf"\b{re.escape(name)}\s*=\s*([^,\s)]+)", param_text)
|
|
429
|
+
if not m:
|
|
430
|
+
return None
|
|
431
|
+
return _coerce_scalar(m.group(1))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@_register_field("parameter")
|
|
435
|
+
def _field_parameter(content: str, match: MatchContext, spec: dict) -> Any:
|
|
436
|
+
cm = _find_match(match, ComponentMatch)
|
|
437
|
+
if cm is None:
|
|
438
|
+
return None # composite that didn't include a component-instantiation
|
|
439
|
+
return _extract_param_value(
|
|
440
|
+
cm.param_text, spec["name"], spec.get("shape", "scalar")
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@_register_field("constant")
|
|
445
|
+
def _field_constant(content: str, match: MatchContext, spec: dict) -> Any:
|
|
446
|
+
return spec["value"]
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@_register_field("annotation")
|
|
450
|
+
def _field_annotation(content: str, match: MatchContext, spec: dict) -> Any:
|
|
451
|
+
"""Extract a parameter from an arbitrary Modelica annotation block.
|
|
452
|
+
|
|
453
|
+
Spec: ``{"from": "annotation", "annotation": "<name>", "name": "<key>"}``
|
|
454
|
+
where ``annotation`` is the annotation block name (e.g. ``__MyVendor_Test``)
|
|
455
|
+
and ``name`` is the parameter inside it.
|
|
456
|
+
"""
|
|
457
|
+
ann_name = spec["annotation"]
|
|
458
|
+
# Scan stripped text (a commented-out annotation must not match; paren
|
|
459
|
+
# chars inside strings must not break the balance scan) but slice the
|
|
460
|
+
# ORIGINAL content so string values survive — review 2026-07-06
|
|
461
|
+
# findings 48/50.
|
|
462
|
+
stripped = _strip_modelica_literals(content)
|
|
463
|
+
m = re.search(
|
|
464
|
+
rf"\b{re.escape(ann_name)}\s*\(",
|
|
465
|
+
stripped,
|
|
466
|
+
)
|
|
467
|
+
if not m:
|
|
468
|
+
return None
|
|
469
|
+
# Extract balanced parentheses
|
|
470
|
+
paren_open = m.end() - 1
|
|
471
|
+
depth = 0
|
|
472
|
+
end = paren_open
|
|
473
|
+
for i in range(paren_open, len(stripped)):
|
|
474
|
+
if stripped[i] == "(":
|
|
475
|
+
depth += 1
|
|
476
|
+
elif stripped[i] == ")":
|
|
477
|
+
depth -= 1
|
|
478
|
+
if depth == 0:
|
|
479
|
+
end = i
|
|
480
|
+
break
|
|
481
|
+
param_text = content[paren_open + 1 : end]
|
|
482
|
+
return _extract_param_value(param_text, spec["name"], spec.get("shape", "scalar"))
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@_register_field("experiment-annotation")
|
|
486
|
+
def _field_experiment_annotation(content: str, match: MatchContext, spec: dict) -> Any:
|
|
487
|
+
# review 2026-07-06 finding 48: locate experiment(...) on stripped text
|
|
488
|
+
# (comments must not shadow the real annotation) with a balanced-paren
|
|
489
|
+
# scan (no [^)]* truncation); extract values from the ORIGINAL span so
|
|
490
|
+
# quoted strings survive.
|
|
491
|
+
span = _find_experiment_span(_strip_modelica_literals(content))
|
|
492
|
+
if span is None:
|
|
493
|
+
return None
|
|
494
|
+
name = spec["name"]
|
|
495
|
+
if not name:
|
|
496
|
+
return None
|
|
497
|
+
pt = content[span[0] : span[1]]
|
|
498
|
+
shape = spec.get("shape", "scalar")
|
|
499
|
+
# Try the user-given name, then alt-case first letter — Modelica writes
|
|
500
|
+
# both StopTime and stopTime interchangeably.
|
|
501
|
+
candidates = [name]
|
|
502
|
+
if name[0].isalpha():
|
|
503
|
+
alt = name[0].swapcase() + name[1:]
|
|
504
|
+
if alt != name:
|
|
505
|
+
candidates.append(alt)
|
|
506
|
+
for candidate in candidates:
|
|
507
|
+
value = _extract_param_value(pt, candidate, shape)
|
|
508
|
+
if value is not None:
|
|
509
|
+
return value
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# ---------------------------------------------------------------------------
|
|
514
|
+
# JsonRecognizer + parser
|
|
515
|
+
# ---------------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class JsonRecognizer(Recognizer):
|
|
519
|
+
"""Recognizer configured by a JSON spec."""
|
|
520
|
+
|
|
521
|
+
def __init__(self, spec: dict):
|
|
522
|
+
self.name: str = spec["name"]
|
|
523
|
+
self.applies_to = frozenset(spec.get("applies_to", ["modelica"]))
|
|
524
|
+
self._match_spec: dict = spec["match"]
|
|
525
|
+
self._field_specs: dict = spec.get("fields", {})
|
|
526
|
+
# PTA-follow.1: optional path filters. Globs match against the path
|
|
527
|
+
# relative to the discovery base (config.library_dir for modelica).
|
|
528
|
+
self._paths_include: list[str] = list(spec.get("paths_include", []))
|
|
529
|
+
self._paths_exclude: list[str] = list(spec.get("paths_exclude", []))
|
|
530
|
+
|
|
531
|
+
def applies_to_path(self, source_file: Path, base: Path) -> bool:
|
|
532
|
+
if not self._paths_include and not self._paths_exclude:
|
|
533
|
+
return True
|
|
534
|
+
try:
|
|
535
|
+
rel = source_file.relative_to(base).as_posix()
|
|
536
|
+
except ValueError:
|
|
537
|
+
# File isn't under base — exclude conservatively.
|
|
538
|
+
return False
|
|
539
|
+
if self._paths_include and not any(
|
|
540
|
+
fnmatch.fnmatchcase(rel, g) for g in self._paths_include
|
|
541
|
+
):
|
|
542
|
+
return False
|
|
543
|
+
return not any(fnmatch.fnmatchcase(rel, g) for g in self._paths_exclude)
|
|
544
|
+
|
|
545
|
+
def recognize(self, source_file: Path) -> RecognizerResult | None:
|
|
546
|
+
try:
|
|
547
|
+
content = source_file.read_text(encoding="utf-8", errors="replace")
|
|
548
|
+
except OSError:
|
|
549
|
+
return None
|
|
550
|
+
match_fn = _MATCH_INTERPRETERS[self._match_spec["type"]]
|
|
551
|
+
match_ctx = match_fn(content, self._match_spec)
|
|
552
|
+
if match_ctx is None:
|
|
553
|
+
return None
|
|
554
|
+
within = _extract_within(content)
|
|
555
|
+
model_name = _extract_model_name(content)
|
|
556
|
+
if not model_name:
|
|
557
|
+
return None
|
|
558
|
+
# Defensive: skip if the matched component class IS the class being
|
|
559
|
+
# defined here (the bundled recognizer hits this when the UnitTests
|
|
560
|
+
# component's documentation contains example usage; user-defined
|
|
561
|
+
# recognizers can hit the same on their template's defining file).
|
|
562
|
+
cm = _find_match(match_ctx, ComponentMatch)
|
|
563
|
+
if cm is not None:
|
|
564
|
+
short_class = cm.component_class.rsplit(".", 1)[-1]
|
|
565
|
+
if short_class == model_name:
|
|
566
|
+
return None
|
|
567
|
+
model_id = f"{within}.{model_name}" if within else model_name
|
|
568
|
+
result = RecognizerResult(model_id=model_id, source_file=source_file)
|
|
569
|
+
from_component: set[str] = set()
|
|
570
|
+
for field_name, field_spec in self._field_specs.items():
|
|
571
|
+
field_fn = _FIELD_INTERPRETERS[field_spec["from"]]
|
|
572
|
+
value = field_fn(content, match_ctx, field_spec)
|
|
573
|
+
if value is None:
|
|
574
|
+
continue
|
|
575
|
+
if field_name in _NUMERIC_FIELDS:
|
|
576
|
+
value = _numeric_or_none(model_id, field_name, value)
|
|
577
|
+
if value is None:
|
|
578
|
+
continue
|
|
579
|
+
setattr(result, field_name, value)
|
|
580
|
+
if field_spec["from"] == "parameter":
|
|
581
|
+
from_component.add(field_name)
|
|
582
|
+
# The instance name is the matched component's, never the spec's: a
|
|
583
|
+
# recognizer that takes the tracked variables out of a component —
|
|
584
|
+
# how MANY of them or WHICH expressions, either is enough — names
|
|
585
|
+
# that component's instance too, because the result file names them
|
|
586
|
+
# ``<instance>.x[i]``. Keyed off the count as well as the
|
|
587
|
+
# expressions: a recognizer that reads only ``n_vars`` off the
|
|
588
|
+
# component left the instance unset, and every one of its variables
|
|
589
|
+
# was then looked for under the default ``unitTests.x[i]``.
|
|
590
|
+
#
|
|
591
|
+
# It is the SOURCE of those two fields that decides, not their
|
|
592
|
+
# value. A recognizer whose ``n_vars`` is a ``constant`` says
|
|
593
|
+
# nothing about where the variables live, and the component it
|
|
594
|
+
# matched on may be any component at all — matching
|
|
595
|
+
# ``Modelica.Blocks.Sources.Sine sine`` to find tests named the
|
|
596
|
+
# instance ``sine``, and every variable of every such test went
|
|
597
|
+
# looking for ``sine.x[i]`` and was missing.
|
|
598
|
+
if cm is not None and from_component & {"n_vars", "x_expressions"}:
|
|
599
|
+
result.unit_tests_instance = cm.instance_name
|
|
600
|
+
return result
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _validate_match_spec(rec_name: str, match: dict) -> None:
|
|
604
|
+
"""Validate a (possibly composite) match spec, raising on error."""
|
|
605
|
+
if not isinstance(match, dict) or "type" not in match:
|
|
606
|
+
raise RecognizerSpecError(
|
|
607
|
+
f"recognizer '{rec_name}' has malformed match (missing 'type')"
|
|
608
|
+
)
|
|
609
|
+
mtype = match["type"]
|
|
610
|
+
if mtype not in _MATCH_INTERPRETERS:
|
|
611
|
+
raise RecognizerSpecError(
|
|
612
|
+
f"recognizer '{rec_name}' has unknown match type '{mtype}'. "
|
|
613
|
+
f"Known: {sorted(_MATCH_INTERPRETERS)}"
|
|
614
|
+
)
|
|
615
|
+
if mtype in ("all-of", "any-of"):
|
|
616
|
+
children = match.get("matchers")
|
|
617
|
+
if not isinstance(children, list) or not children:
|
|
618
|
+
raise RecognizerSpecError(
|
|
619
|
+
f"recognizer '{rec_name}' match.{mtype} requires 'matchers' (non-empty list)"
|
|
620
|
+
)
|
|
621
|
+
for child in children:
|
|
622
|
+
_validate_match_spec(rec_name, child)
|
|
623
|
+
return
|
|
624
|
+
if mtype == "component-instantiation" and "component_name" not in match:
|
|
625
|
+
raise RecognizerSpecError(
|
|
626
|
+
f"recognizer '{rec_name}' match.component-instantiation requires 'component_name'"
|
|
627
|
+
)
|
|
628
|
+
if mtype == "extends" and "class_pattern" not in match:
|
|
629
|
+
raise RecognizerSpecError(
|
|
630
|
+
f"recognizer '{rec_name}' match.extends requires 'class_pattern'"
|
|
631
|
+
)
|
|
632
|
+
if mtype == "class-name-glob" and "class_pattern" not in match:
|
|
633
|
+
raise RecognizerSpecError(
|
|
634
|
+
f"recognizer '{rec_name}' match.class-name-glob requires 'class_pattern'"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def parse_recognizer_spec(spec: dict) -> Recognizer:
|
|
639
|
+
"""Validate and parse a JSON recognizer spec into a Recognizer instance."""
|
|
640
|
+
if not isinstance(spec, dict):
|
|
641
|
+
raise RecognizerSpecError(
|
|
642
|
+
f"recognizer spec must be a dict, got {type(spec).__name__}"
|
|
643
|
+
)
|
|
644
|
+
if "name" not in spec:
|
|
645
|
+
raise RecognizerSpecError("recognizer spec missing required field 'name'")
|
|
646
|
+
name = spec["name"]
|
|
647
|
+
if "match" not in spec:
|
|
648
|
+
raise RecognizerSpecError(f"recognizer '{name}' missing required field 'match'")
|
|
649
|
+
match = spec["match"]
|
|
650
|
+
_validate_match_spec(name, match)
|
|
651
|
+
match_type = match["type"]
|
|
652
|
+
fields = spec.get("fields", {})
|
|
653
|
+
if not isinstance(fields, dict):
|
|
654
|
+
raise RecognizerSpecError(f"recognizer '{name}' fields must be a dict")
|
|
655
|
+
allowed_sources = _allowed_sources_for(match)
|
|
656
|
+
for fname, fspec in fields.items():
|
|
657
|
+
if fname not in _VALID_FIELDS:
|
|
658
|
+
raise RecognizerSpecError(
|
|
659
|
+
f"recognizer '{name}' targets unknown field '{fname}'. "
|
|
660
|
+
f"Known: {sorted(_VALID_FIELDS)}"
|
|
661
|
+
)
|
|
662
|
+
if not isinstance(fspec, dict) or "from" not in fspec:
|
|
663
|
+
raise RecognizerSpecError(
|
|
664
|
+
f"recognizer '{name}' field '{fname}' missing 'from'"
|
|
665
|
+
)
|
|
666
|
+
source = fspec["from"]
|
|
667
|
+
if source not in _FIELD_INTERPRETERS:
|
|
668
|
+
raise RecognizerSpecError(
|
|
669
|
+
f"recognizer '{name}' field '{fname}' uses unknown source '{source}'. "
|
|
670
|
+
f"Known: {sorted(_FIELD_INTERPRETERS)}"
|
|
671
|
+
)
|
|
672
|
+
if source not in allowed_sources:
|
|
673
|
+
raise RecognizerSpecError(
|
|
674
|
+
f"recognizer '{name}' field '{fname}' source '{source}' isn't compatible "
|
|
675
|
+
f"with match type '{match_type}'. Allowed for this match type: "
|
|
676
|
+
f"{sorted(allowed_sources)}"
|
|
677
|
+
)
|
|
678
|
+
if source == "parameter" and "name" not in fspec:
|
|
679
|
+
raise RecognizerSpecError(
|
|
680
|
+
f"recognizer '{name}' field '{fname}' (source=parameter) requires 'name'"
|
|
681
|
+
)
|
|
682
|
+
if source == "constant" and "value" not in fspec:
|
|
683
|
+
raise RecognizerSpecError(
|
|
684
|
+
f"recognizer '{name}' field '{fname}' (source=constant) requires 'value'"
|
|
685
|
+
)
|
|
686
|
+
if source == "experiment-annotation" and "name" not in fspec:
|
|
687
|
+
raise RecognizerSpecError(
|
|
688
|
+
f"recognizer '{name}' field '{fname}' "
|
|
689
|
+
f"(source=experiment-annotation) requires 'name'"
|
|
690
|
+
)
|
|
691
|
+
if source == "annotation":
|
|
692
|
+
if "annotation" not in fspec:
|
|
693
|
+
raise RecognizerSpecError(
|
|
694
|
+
f"recognizer '{name}' field '{fname}' "
|
|
695
|
+
f"(source=annotation) requires 'annotation'"
|
|
696
|
+
)
|
|
697
|
+
if "name" not in fspec:
|
|
698
|
+
raise RecognizerSpecError(
|
|
699
|
+
f"recognizer '{name}' field '{fname}' "
|
|
700
|
+
f"(source=annotation) requires 'name'"
|
|
701
|
+
)
|
|
702
|
+
return JsonRecognizer(spec)
|