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,794 @@
|
|
|
1
|
+
"""Parse Modelica .mo files to extract UnitTests block info and experiment annotations."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class UnitTestInfo:
|
|
13
|
+
"""Information extracted from a UnitTests block in a .mo file."""
|
|
14
|
+
|
|
15
|
+
n: int = 1
|
|
16
|
+
#: The component's instance name in the model — its variables are
|
|
17
|
+
#: ``<instance>.x[i]`` in the result file.
|
|
18
|
+
instance: str = "unitTests"
|
|
19
|
+
x_expressions: list[str] = field(default_factory=list)
|
|
20
|
+
x_raw: str = "" # Raw text of x={...} for complex cases (cat, etc.)
|
|
21
|
+
x_reference: list[float] | None = None
|
|
22
|
+
error_expected: float = 1e-6
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ExperimentInfo:
|
|
27
|
+
"""Simulation parameters from experiment() annotation."""
|
|
28
|
+
|
|
29
|
+
stop_time: float | None = None
|
|
30
|
+
tolerance: float | None = None
|
|
31
|
+
method: str | None = None
|
|
32
|
+
number_of_intervals: int | None = None
|
|
33
|
+
output_interval: float | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class MoParseResult:
|
|
38
|
+
"""Full parse result from a .mo file."""
|
|
39
|
+
|
|
40
|
+
model_id: str # Fully qualified Modelica path
|
|
41
|
+
mo_file: Path
|
|
42
|
+
unit_test: UnitTestInfo | None = None
|
|
43
|
+
experiment: ExperimentInfo | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_CLASS_DECL_RE = re.compile(r"\b(?:model|block|class|package)\s+(\w+)")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _extract_within(text: str) -> str:
|
|
50
|
+
"""Extract the 'within' clause to get the parent package path.
|
|
51
|
+
|
|
52
|
+
Operates on comment/string-stripped text — review 2026-07-06 finding 48:
|
|
53
|
+
a ``within`` mentioned in a comment must not shadow the real clause.
|
|
54
|
+
"""
|
|
55
|
+
stripped = _strip_modelica_literals(text)
|
|
56
|
+
m = re.search(r"\bwithin\s+([\w.]+)\s*;", stripped)
|
|
57
|
+
return m.group(1) if m else ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _extract_model_name(text: str, source: object = "") -> str:
|
|
61
|
+
"""Extract the first class-like declaration's name.
|
|
62
|
+
|
|
63
|
+
Operates on comment/string-stripped text with a word-boundary anchor —
|
|
64
|
+
review 2026-07-06 finding 48: the word "model" in a comment above the
|
|
65
|
+
class must not shadow the real declaration.
|
|
66
|
+
|
|
67
|
+
TODO(finding 55): single-file multi-class storage is NOT supported —
|
|
68
|
+
only the FIRST top-level class is parsed. When a second top-level class
|
|
69
|
+
is detected (a class declaration after the first class's ``end Name;``),
|
|
70
|
+
a warning names what was skipped. ``source`` is optional context (file
|
|
71
|
+
path) for that warning.
|
|
72
|
+
"""
|
|
73
|
+
stripped = _strip_modelica_literals(text)
|
|
74
|
+
m = _CLASS_DECL_RE.search(stripped)
|
|
75
|
+
if not m:
|
|
76
|
+
return ""
|
|
77
|
+
name = m.group(1)
|
|
78
|
+
# review 2026-07-06 finding 55: warn about sibling top-level classes the
|
|
79
|
+
# parser skips instead of silently dropping their tests.
|
|
80
|
+
end_m = re.search(rf"\bend\s+{re.escape(name)}\s*;", stripped[m.end() :])
|
|
81
|
+
if end_m:
|
|
82
|
+
extra = _CLASS_DECL_RE.search(stripped, m.end() + end_m.end())
|
|
83
|
+
if extra:
|
|
84
|
+
logger.warning(
|
|
85
|
+
"%s: multiple top-level classes in one file are not supported; "
|
|
86
|
+
"parsed %r, skipped %r (and any further classes)",
|
|
87
|
+
source or "<unknown file>",
|
|
88
|
+
name,
|
|
89
|
+
extra.group(1),
|
|
90
|
+
)
|
|
91
|
+
return name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _extract_balanced_braces(text: str, start: int) -> str:
|
|
95
|
+
"""Extract text within balanced braces starting from position of '{'."""
|
|
96
|
+
depth = 0
|
|
97
|
+
i = start
|
|
98
|
+
while i < len(text):
|
|
99
|
+
if text[i] == "{":
|
|
100
|
+
depth += 1
|
|
101
|
+
elif text[i] == "}":
|
|
102
|
+
depth -= 1
|
|
103
|
+
if depth == 0:
|
|
104
|
+
return text[start + 1 : i]
|
|
105
|
+
i += 1
|
|
106
|
+
return text[start + 1 :]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_WS_RUN_RE = re.compile(r"\s+")
|
|
110
|
+
_WS_AFTER_OPEN_RE = re.compile(r"([\[({])\s+")
|
|
111
|
+
_WS_BEFORE_CLOSE_RE = re.compile(r"\s+([\])},])")
|
|
112
|
+
#: A comma that is not the expression's last character: exactly one space
|
|
113
|
+
#: follows it in the normal form. A comma directly before a closing bracket
|
|
114
|
+
#: needs no exception here — the space this puts after it is taken back by
|
|
115
|
+
#: ``_WS_BEFORE_CLOSE_RE``, which runs after.
|
|
116
|
+
_COMMA_RE = re.compile(r",\s*(?!$)")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def normalize_expression(text: str) -> str:
|
|
120
|
+
"""One expression in its normal form, without its source layout.
|
|
121
|
+
|
|
122
|
+
A ``.mo`` file wraps a long expression over two lines, or spells an
|
|
123
|
+
index ``[1,2]`` where the result file spells it ``[1, 2]``; that is the
|
|
124
|
+
file's formatting, not part of what the expression names. The normal
|
|
125
|
+
form: whitespace runs become one space; none is kept directly after an
|
|
126
|
+
opening bracket or directly before a closing bracket or a comma; and
|
|
127
|
+
every comma is followed by exactly one space — which is how Dymola's own
|
|
128
|
+
result names read (``a[1, 2]``). So ``a.b[\\n 1, 1]``,
|
|
129
|
+
``a.b[ 1 ,1 ]`` and ``a.b[1,1]`` are one expression, and a source
|
|
130
|
+
reformat does not read as a changed expression on compare.
|
|
131
|
+
|
|
132
|
+
Normalizing an expression already in normal form changes nothing.
|
|
133
|
+
|
|
134
|
+
OUT OF SCOPE: a comma inside a string literal within an expression —
|
|
135
|
+
the rule is textual and would put a space inside the literal. On the
|
|
136
|
+
``.mo`` UnitTests path no literal can reach here: ``_parse_unit_tests``
|
|
137
|
+
blanks comments and string literals before it looks for ``x=`` at all.
|
|
138
|
+
A JSON recognizer's ``parameter`` source slices the ORIGINAL text, so
|
|
139
|
+
one could reach here that way; no ``x={...}`` in the libraries this
|
|
140
|
+
parses contains one.
|
|
141
|
+
"""
|
|
142
|
+
text = _WS_RUN_RE.sub(" ", text.strip())
|
|
143
|
+
text = _COMMA_RE.sub(", ", text)
|
|
144
|
+
text = _WS_AFTER_OPEN_RE.sub(r"\1", text)
|
|
145
|
+
return _WS_BEFORE_CLOSE_RE.sub(r"\1", text)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _parse_x_expressions(raw: str) -> list[str]:
|
|
149
|
+
"""Parse x={expr1, expr2, ...} into individual expressions.
|
|
150
|
+
|
|
151
|
+
Handles simple comma-separated variables but returns raw text for
|
|
152
|
+
complex cases like cat() or array comprehensions. Every expression
|
|
153
|
+
returned is normalized (:func:`normalize_expression`).
|
|
154
|
+
"""
|
|
155
|
+
# If it contains cat( or 'for', it's complex — return as single raw entry
|
|
156
|
+
stripped = raw.strip()
|
|
157
|
+
if "cat(" in stripped or " for " in stripped:
|
|
158
|
+
return [normalize_expression(stripped)]
|
|
159
|
+
|
|
160
|
+
# Split by commas, respecting nested brackets/parens
|
|
161
|
+
expressions = []
|
|
162
|
+
depth = 0
|
|
163
|
+
current = []
|
|
164
|
+
for ch in stripped:
|
|
165
|
+
if ch in "({[":
|
|
166
|
+
depth += 1
|
|
167
|
+
current.append(ch)
|
|
168
|
+
elif ch in ")}]":
|
|
169
|
+
depth -= 1
|
|
170
|
+
current.append(ch)
|
|
171
|
+
elif ch == "," and depth == 0:
|
|
172
|
+
expr = normalize_expression("".join(current))
|
|
173
|
+
if expr:
|
|
174
|
+
expressions.append(expr)
|
|
175
|
+
current = []
|
|
176
|
+
else:
|
|
177
|
+
current.append(ch)
|
|
178
|
+
last = normalize_expression("".join(current))
|
|
179
|
+
if last:
|
|
180
|
+
expressions.append(last)
|
|
181
|
+
|
|
182
|
+
return expressions
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _take_argument(text: str, start: int) -> str:
|
|
186
|
+
"""The rest of one argument of a parameter list, from ``start``.
|
|
187
|
+
|
|
188
|
+
Ends at the next top-level comma or at the end of the list (the closing
|
|
189
|
+
bracket that was never opened inside it), nesting respected — so
|
|
190
|
+
``x=a.b[1].c, x_reference={1}`` yields ``a.b[1].c`` and ``x=a.b[1, :]``
|
|
191
|
+
yields the whole index, where a regex over bracket contents stopped at
|
|
192
|
+
``a.b[1]`` and ``a.b``.
|
|
193
|
+
"""
|
|
194
|
+
depth = 0
|
|
195
|
+
for i in range(start, len(text)):
|
|
196
|
+
c = text[i]
|
|
197
|
+
if c in "([{":
|
|
198
|
+
depth += 1
|
|
199
|
+
elif c in ")]}":
|
|
200
|
+
if depth == 0:
|
|
201
|
+
return text[start:i]
|
|
202
|
+
depth -= 1
|
|
203
|
+
elif c == "," and depth == 0:
|
|
204
|
+
return text[start:i]
|
|
205
|
+
return text[start:]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _parse_float_list(raw: str) -> list[float] | None:
|
|
209
|
+
"""Parse a simple {val1, val2, ...} list of floats."""
|
|
210
|
+
try:
|
|
211
|
+
parts = raw.strip().split(",")
|
|
212
|
+
return [float(p.strip()) for p in parts if p.strip()]
|
|
213
|
+
except ValueError:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _strip_modelica_literals(text: str) -> str:
|
|
218
|
+
"""Blank comments + string literals from Modelica source (1:1, newlines
|
|
219
|
+
kept, so offsets into the result index the original — findings 48/50 slice
|
|
220
|
+
the original for string-valued params the blanking removed).
|
|
221
|
+
|
|
222
|
+
Single-pass lexer, NOT a sequence of independent regexes. The old 3-pass
|
|
223
|
+
approach (block-comment, then line-comment, then string) was unsound
|
|
224
|
+
because the three languages interleave: a ``//`` inside a string literal
|
|
225
|
+
(``"modelica://..."`` — ubiquitous in Modelica annotation URIs) was eaten
|
|
226
|
+
by the line-comment pass, blanking the string's closing quote and
|
|
227
|
+
desyncing every following string. On TRANSFORM's InverseParameterization
|
|
228
|
+
that swallowed the real ``experiment(StopTime=10)`` — StopTime silently
|
|
229
|
+
defaulted to 1.0 and the sim ran a tenth of its length (regression found
|
|
230
|
+
2026-07-07). A stateful scan is the only correct model: at each position we
|
|
231
|
+
are in exactly one of code / string / line-comment / block-comment /
|
|
232
|
+
quoted name, and the delimiters that matter depend on which.
|
|
233
|
+
|
|
234
|
+
A quoted name (``'a"b'``, ``'in ner'``) is CODE and is kept as it is,
|
|
235
|
+
but what it holds delimits nothing: a ``"``, ``//`` or ``/*`` inside one
|
|
236
|
+
starts no string and no comment. ``model 'a"b'`` was read as a string
|
|
237
|
+
running to the ``"`` of its own ``end 'a"b';``. It ends at its closing
|
|
238
|
+
``'`` — or at the end of its line, which no quoted name crosses, so an
|
|
239
|
+
apostrophe astray in code costs one line and not the rest of the file.
|
|
240
|
+
"""
|
|
241
|
+
out: list[str] = []
|
|
242
|
+
i, n = 0, len(text)
|
|
243
|
+
NORMAL, STRING, LINE, BLOCK, QUOTED = 0, 1, 2, 3, 4
|
|
244
|
+
state = NORMAL
|
|
245
|
+
while i < n:
|
|
246
|
+
c = text[i]
|
|
247
|
+
two = text[i : i + 2]
|
|
248
|
+
if state == NORMAL:
|
|
249
|
+
if two == "//":
|
|
250
|
+
out.append(" ")
|
|
251
|
+
i += 2
|
|
252
|
+
state = LINE
|
|
253
|
+
elif two == "/*":
|
|
254
|
+
out.append(" ")
|
|
255
|
+
i += 2
|
|
256
|
+
state = BLOCK
|
|
257
|
+
elif c == '"':
|
|
258
|
+
out.append(" ")
|
|
259
|
+
i += 1
|
|
260
|
+
state = STRING
|
|
261
|
+
else:
|
|
262
|
+
out.append(c)
|
|
263
|
+
i += 1
|
|
264
|
+
if c == "'":
|
|
265
|
+
state = QUOTED
|
|
266
|
+
elif state == QUOTED:
|
|
267
|
+
if c == "\\" and i + 1 < n and text[i + 1] != "\n":
|
|
268
|
+
out.append(text[i : i + 2]) # an escape: `\'` closes nothing
|
|
269
|
+
i += 2
|
|
270
|
+
else:
|
|
271
|
+
out.append(c)
|
|
272
|
+
i += 1
|
|
273
|
+
if c in "'\n":
|
|
274
|
+
state = NORMAL
|
|
275
|
+
elif state == STRING:
|
|
276
|
+
if c == "\\" and i + 1 < n:
|
|
277
|
+
# escape: blank the backslash + the escaped char together, so a
|
|
278
|
+
# ``\"`` inside the string is NOT read as the closing quote.
|
|
279
|
+
out.append(" " if text[i + 1] != "\n" else " \n")
|
|
280
|
+
i += 2
|
|
281
|
+
elif c == '"':
|
|
282
|
+
out.append(" ")
|
|
283
|
+
i += 1
|
|
284
|
+
state = NORMAL
|
|
285
|
+
else:
|
|
286
|
+
out.append("\n" if c == "\n" else " ")
|
|
287
|
+
i += 1
|
|
288
|
+
elif state == LINE:
|
|
289
|
+
if c == "\n":
|
|
290
|
+
out.append("\n")
|
|
291
|
+
i += 1
|
|
292
|
+
state = NORMAL
|
|
293
|
+
else:
|
|
294
|
+
out.append(" ")
|
|
295
|
+
i += 1
|
|
296
|
+
else: # BLOCK (Modelica block comments do not nest)
|
|
297
|
+
if two == "*/":
|
|
298
|
+
out.append(" ")
|
|
299
|
+
i += 2
|
|
300
|
+
state = NORMAL
|
|
301
|
+
else:
|
|
302
|
+
out.append("\n" if c == "\n" else " ")
|
|
303
|
+
i += 1
|
|
304
|
+
return "".join(out)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _parse_unit_tests(text: str, source: object = "") -> UnitTestInfo | None:
|
|
308
|
+
"""Extract UnitTests block parameters from model text."""
|
|
309
|
+
# Match the UnitTests declaration — may span multiple lines
|
|
310
|
+
# Patterns: "UnitTests unitTests(" or "ErrorAnalysis.UnitTests unitTests("
|
|
311
|
+
# Strip comments + strings first so documentation prose that mentions
|
|
312
|
+
# ``UnitTests(...)`` for example purposes doesn't misidentify the
|
|
313
|
+
# surrounding model as a test.
|
|
314
|
+
text = _strip_modelica_literals(text)
|
|
315
|
+
# The leading ``\b`` is what makes this the UnitTests component and not
|
|
316
|
+
# any class whose name merely ends in it (``MyUnitTests bar(`` was read
|
|
317
|
+
# as an instance named ``bar``). A dotted prefix still matches: the
|
|
318
|
+
# optional ``Utilities.ErrorAnalysis.`` group, and any longer prefix
|
|
319
|
+
# (``TRANSFORM.Utilities.ErrorAnalysis.``) because the ``.`` before
|
|
320
|
+
# ``UnitTests`` is itself a word boundary.
|
|
321
|
+
pattern = re.compile(
|
|
322
|
+
r"\b(?:Utilities\.ErrorAnalysis\.)?UnitTests\s+(\w+)\s*\(", re.DOTALL
|
|
323
|
+
)
|
|
324
|
+
match = pattern.search(text)
|
|
325
|
+
if not match:
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
# Find the end of the parameter list — match balanced parentheses
|
|
329
|
+
start = match.end() - 1 # position of the '('
|
|
330
|
+
depth = 0
|
|
331
|
+
end = start
|
|
332
|
+
for i in range(start, len(text)):
|
|
333
|
+
if text[i] == "(":
|
|
334
|
+
depth += 1
|
|
335
|
+
elif text[i] == ")":
|
|
336
|
+
depth -= 1
|
|
337
|
+
if depth == 0:
|
|
338
|
+
end = i
|
|
339
|
+
break
|
|
340
|
+
|
|
341
|
+
param_text = text[start + 1 : end]
|
|
342
|
+
|
|
343
|
+
# group(1) is the component's INSTANCE name (the word after the class),
|
|
344
|
+
# not the class: its variables are ``<instance>.x[i]``.
|
|
345
|
+
info = UnitTestInfo(instance=match.group(1))
|
|
346
|
+
|
|
347
|
+
# Extract n=
|
|
348
|
+
m = re.search(r"\bn\s*=\s*(\d+)", param_text)
|
|
349
|
+
if m:
|
|
350
|
+
info.n = int(m.group(1))
|
|
351
|
+
|
|
352
|
+
# Extract x={...}
|
|
353
|
+
m = re.search(r"\bx\s*=\s*\{", param_text)
|
|
354
|
+
if m:
|
|
355
|
+
brace_start = m.end() - 1
|
|
356
|
+
raw = _extract_balanced_braces(param_text, brace_start)
|
|
357
|
+
info.x_raw = raw
|
|
358
|
+
info.x_expressions = _parse_x_expressions(raw)
|
|
359
|
+
elif re.search(r"\bx\s*=\s*(?:cat|fill|zeros|ones|linspace)\s*\(", param_text):
|
|
360
|
+
# x=cat(1, ...) or x=fill(...) etc. without outer braces
|
|
361
|
+
m2 = re.search(r"\bx\s*=\s*((?:cat|fill|zeros|ones|linspace)\s*\()", param_text)
|
|
362
|
+
if m2:
|
|
363
|
+
paren_start = param_text.index("(", m2.start())
|
|
364
|
+
depth2 = 0
|
|
365
|
+
end2 = paren_start
|
|
366
|
+
for i in range(paren_start, len(param_text)):
|
|
367
|
+
if param_text[i] == "(":
|
|
368
|
+
depth2 += 1
|
|
369
|
+
elif param_text[i] == ")":
|
|
370
|
+
depth2 -= 1
|
|
371
|
+
if depth2 == 0:
|
|
372
|
+
end2 = i
|
|
373
|
+
break
|
|
374
|
+
raw = param_text[m2.start() + 2 : end2 + 1] # include "func(...)"
|
|
375
|
+
info.x_raw = raw.strip()
|
|
376
|
+
info.x_expressions = [normalize_expression(raw)]
|
|
377
|
+
else:
|
|
378
|
+
# x=varName, x=some.qualified.name, x=a.b[1].c, x=a.b[1, :] — a bare
|
|
379
|
+
# expression. It runs to the end of ITS argument; a bracket regex
|
|
380
|
+
# truncated it (``a.b[1].c`` became ``a.b[1]``, ``a.b[1, :]`` became
|
|
381
|
+
# ``a.b``), which then rode into the baseline as the expression.
|
|
382
|
+
m2 = re.search(r"\bx\s*=\s*", param_text)
|
|
383
|
+
if m2:
|
|
384
|
+
raw = _take_argument(param_text, m2.end()).strip()
|
|
385
|
+
if raw:
|
|
386
|
+
info.x_raw = raw
|
|
387
|
+
info.x_expressions = [normalize_expression(raw)]
|
|
388
|
+
|
|
389
|
+
# Extract x_reference={...}
|
|
390
|
+
m = re.search(r"\bx_reference\s*=\s*\{", param_text)
|
|
391
|
+
if m:
|
|
392
|
+
brace_start = m.end() - 1
|
|
393
|
+
raw = _extract_balanced_braces(param_text, brace_start)
|
|
394
|
+
info.x_reference = _parse_float_list(raw)
|
|
395
|
+
|
|
396
|
+
# Extract errorExpected= (finding 56 applies here too: an expression like
|
|
397
|
+
# ``1e-3*2`` must warn and keep the default, not silently truncate)
|
|
398
|
+
error_expected = _parse_number(
|
|
399
|
+
param_text, r"\berrorExpected", source, "errorExpected"
|
|
400
|
+
)
|
|
401
|
+
if error_expected is not None:
|
|
402
|
+
info.error_expected = error_expected
|
|
403
|
+
|
|
404
|
+
return info
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _find_experiment_span(stripped: str) -> tuple[int, int] | None:
|
|
408
|
+
"""Locate the ``experiment(...)`` parameter text in stripped source.
|
|
409
|
+
|
|
410
|
+
Returns (start, end) offsets of the text BETWEEN the balanced outer
|
|
411
|
+
parentheses, or None when no experiment annotation exists. Balanced-paren
|
|
412
|
+
scan instead of ``[^)]*`` — review 2026-07-06 finding 48: a nested group
|
|
413
|
+
like ``__Dymola_Tuning(...)`` must not truncate the parameter list.
|
|
414
|
+
Offsets are valid in the original text too (1:1 stripping).
|
|
415
|
+
"""
|
|
416
|
+
m = re.search(r"\bexperiment\s*\(", stripped)
|
|
417
|
+
if not m:
|
|
418
|
+
return None
|
|
419
|
+
open_pos = m.end() - 1
|
|
420
|
+
depth = 0
|
|
421
|
+
for i in range(open_pos, len(stripped)):
|
|
422
|
+
if stripped[i] == "(":
|
|
423
|
+
depth += 1
|
|
424
|
+
elif stripped[i] == ")":
|
|
425
|
+
depth -= 1
|
|
426
|
+
if depth == 0:
|
|
427
|
+
return (open_pos + 1, i)
|
|
428
|
+
return (open_pos + 1, len(stripped)) # unbalanced — take the rest
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _parse_number(
|
|
432
|
+
param_text: str,
|
|
433
|
+
key_pattern: str,
|
|
434
|
+
source: object,
|
|
435
|
+
field_name: str,
|
|
436
|
+
) -> float | None:
|
|
437
|
+
"""Extract ``<key> = <number>`` from an annotation parameter list.
|
|
438
|
+
|
|
439
|
+
review 2026-07-06 finding 56: accept plain numbers incl. scientific
|
|
440
|
+
notation only; an expression (``0.5*3600``) or garbage capture logs a
|
|
441
|
+
warning naming the file + field and is skipped — it must neither
|
|
442
|
+
silently truncate (old ``[0-9eE.+-]+`` regex kept just ``0.5``) nor
|
|
443
|
+
crash discovery with an uncaught ValueError.
|
|
444
|
+
"""
|
|
445
|
+
m = re.search(rf"{key_pattern}\s*=\s*([^,]+)", param_text)
|
|
446
|
+
if not m:
|
|
447
|
+
return None
|
|
448
|
+
raw = m.group(1).strip()
|
|
449
|
+
try:
|
|
450
|
+
return float(raw)
|
|
451
|
+
except ValueError:
|
|
452
|
+
logger.warning(
|
|
453
|
+
"%s: ignoring non-numeric experiment %s=%r",
|
|
454
|
+
source or "<unknown file>",
|
|
455
|
+
field_name,
|
|
456
|
+
raw,
|
|
457
|
+
)
|
|
458
|
+
return None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _parse_experiment(text: str, source: object = "") -> ExperimentInfo | None:
|
|
462
|
+
"""Extract experiment() annotation parameters.
|
|
463
|
+
|
|
464
|
+
Scans comment/string-stripped text (review 2026-07-06 finding 48: a
|
|
465
|
+
``// experiment(StopTime=5)`` comment must not shadow the real
|
|
466
|
+
annotation) but recovers string values (``__Dymola_Algorithm="..."``)
|
|
467
|
+
from the ORIGINAL text via the offset-preserving stripper.
|
|
468
|
+
"""
|
|
469
|
+
stripped = _strip_modelica_literals(text)
|
|
470
|
+
span = _find_experiment_span(stripped)
|
|
471
|
+
if span is None:
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
start, end = span
|
|
475
|
+
param_text = stripped[start:end] # numeric fields: comments stripped
|
|
476
|
+
param_orig = text[start:end] # string fields: literals intact
|
|
477
|
+
info = ExperimentInfo()
|
|
478
|
+
|
|
479
|
+
# StopTime (Modelica standard) or stopTime
|
|
480
|
+
info.stop_time = _parse_number(param_text, r"\b[Ss]topTime", source, "StopTime")
|
|
481
|
+
|
|
482
|
+
# Tolerance
|
|
483
|
+
info.tolerance = _parse_number(param_text, r"\b[Tt]olerance", source, "Tolerance")
|
|
484
|
+
|
|
485
|
+
# __Dymola_Algorithm or method — quoted string, read from original text
|
|
486
|
+
m2 = re.search(r'__Dymola_Algorithm\s*=\s*"([^"]+)"', param_orig)
|
|
487
|
+
if m2:
|
|
488
|
+
info.method = m2.group(1)
|
|
489
|
+
|
|
490
|
+
# __Dymola_NumberOfIntervals or numberOfIntervals
|
|
491
|
+
m2 = re.search(r"(?:__Dymola_)?[Nn]umberOfIntervals\s*=\s*(\d+)", param_text)
|
|
492
|
+
if m2:
|
|
493
|
+
info.number_of_intervals = int(m2.group(1))
|
|
494
|
+
|
|
495
|
+
# Interval (output interval length) — standard Modelica
|
|
496
|
+
interval = _parse_number(param_text, r"(?<!\w)[Ii]nterval", source, "Interval")
|
|
497
|
+
if interval is not None:
|
|
498
|
+
info.output_interval = interval
|
|
499
|
+
|
|
500
|
+
return info
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def parse_mo_file(path: Path) -> MoParseResult | None:
|
|
504
|
+
"""Parse a .mo file and extract UnitTests and experiment info.
|
|
505
|
+
|
|
506
|
+
Returns None if the file cannot be parsed or has no UnitTests block.
|
|
507
|
+
"""
|
|
508
|
+
try:
|
|
509
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
510
|
+
except OSError:
|
|
511
|
+
return None
|
|
512
|
+
|
|
513
|
+
unit_test = _parse_unit_tests(text, source=path)
|
|
514
|
+
if unit_test is None:
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
within = _extract_within(text)
|
|
518
|
+
model_name = _extract_model_name(text, source=path)
|
|
519
|
+
if not model_name:
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
# Skip the UnitTests component definition itself — it contains example
|
|
523
|
+
# usage in documentation that the parser would pick up as a false positive
|
|
524
|
+
if model_name == "UnitTests":
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
model_id = f"{within}.{model_name}" if within else model_name
|
|
528
|
+
experiment = _parse_experiment(text, source=path)
|
|
529
|
+
|
|
530
|
+
return MoParseResult(
|
|
531
|
+
model_id=model_id,
|
|
532
|
+
mo_file=path,
|
|
533
|
+
unit_test=unit_test,
|
|
534
|
+
experiment=experiment,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
539
|
+
# A class by its name: where it is stored, and its own experiment annotation
|
|
540
|
+
# ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@dataclass
|
|
544
|
+
class LocatedClass:
|
|
545
|
+
"""A class of the library found by its qualified name: the file that
|
|
546
|
+
holds it and its own ``experiment(...)`` annotation (None when it has
|
|
547
|
+
none)."""
|
|
548
|
+
|
|
549
|
+
mo_file: Path
|
|
550
|
+
experiment: ExperimentInfo | None
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@dataclass(frozen=True)
|
|
554
|
+
class _ClassSpan:
|
|
555
|
+
"""One class declaration in stripped source: ``start`` at its keyword,
|
|
556
|
+
``body`` just past its name, ``end`` past its ``end Name;`` — or, for a
|
|
557
|
+
``short`` class (``model A = B(...) annotation(...);``), past the ``;``
|
|
558
|
+
that ends its one statement."""
|
|
559
|
+
|
|
560
|
+
name: str
|
|
561
|
+
start: int
|
|
562
|
+
body: int
|
|
563
|
+
end: int
|
|
564
|
+
short: bool = False
|
|
565
|
+
#: How many brackets of the scanned text are open around a short class.
|
|
566
|
+
#: 0 is a declaration of that text; one written inside a modification
|
|
567
|
+
#: (``Pipe p(redeclare model HT = Ideal);``) is no class of the body the
|
|
568
|
+
#: statement stands in (:func:`_named`).
|
|
569
|
+
depth: int = 0
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
#: A Modelica name: an identifier, or a quoted one (``'in ner'``).
|
|
573
|
+
_NAME = r"(\w+|'(?:[^'\\]|\\.)*')"
|
|
574
|
+
|
|
575
|
+
#: A class declaration or an ``end Name;``, in comment/string-stripped text.
|
|
576
|
+
#: The kinds are :data:`_CLASS_DECL_RE`'s — the ones that can hold a model.
|
|
577
|
+
#: Group 2 marks a SHORT class (``model A = B(...)``), which has no body and
|
|
578
|
+
#: no ``end``; ``model extends A`` is named by what it extends.
|
|
579
|
+
_CLASS_TOKEN_RE = re.compile(
|
|
580
|
+
rf"\b(?:model|block|class|package)\s+(?:extends\s+)?{_NAME}(\s*=)?"
|
|
581
|
+
rf"|\bend\s+{_NAME}\s*;"
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
_OPENERS, _CLOSERS = "([{", ")]}"
|
|
585
|
+
#: A bracket of stripped source (group 1) — or a quoted name, which is code
|
|
586
|
+
#: there and whose brackets open and close nothing (``Real 'a(b' = 1;``). It
|
|
587
|
+
#: ends as the lexer ends it: at its closing ``'`` or at the end of its line.
|
|
588
|
+
_BRACKET_RE = re.compile(r"'(?:\\[^\n]|[^'\n])*'?|([()\[\]{}])")
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _statement_end(stripped: str, start: int, end: int) -> int:
|
|
592
|
+
"""Just past the ``;`` that ends the statement at ``start`` — the first
|
|
593
|
+
one outside every bracket — or ``end`` when there is none. A short
|
|
594
|
+
class written INSIDE a modification (``A(redeclare model M = B
|
|
595
|
+
annotation(...))``) ends before the bracket that closes the list it
|
|
596
|
+
sits in: what follows that bracket is the enclosing statement's."""
|
|
597
|
+
depth = 0
|
|
598
|
+
for i in range(start, end):
|
|
599
|
+
c = stripped[i]
|
|
600
|
+
if c in _OPENERS:
|
|
601
|
+
depth += 1
|
|
602
|
+
elif c in _CLOSERS:
|
|
603
|
+
if depth == 0:
|
|
604
|
+
return i
|
|
605
|
+
depth -= 1
|
|
606
|
+
elif depth == 0 and c == ";":
|
|
607
|
+
return i + 1
|
|
608
|
+
return end
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _direct_classes(stripped: str, start: int, end: int) -> list[_ClassSpan]:
|
|
612
|
+
"""The classes declared DIRECTLY in ``stripped[start:end]`` — a file, or
|
|
613
|
+
one class's body — in source order; a class nested deeper belongs to
|
|
614
|
+
the one that holds it.
|
|
615
|
+
|
|
616
|
+
A stack of open declarations, closed by the ``end`` that names the top
|
|
617
|
+
one: ``end if;`` / ``end for;`` and the ``end`` of a kind not tracked
|
|
618
|
+
here (a function, a record) name nothing on it and close nothing. A
|
|
619
|
+
short class opens nothing: it is one statement, and its span runs to
|
|
620
|
+
that statement's ``;`` — it carries an ``annotation(...)`` of its own,
|
|
621
|
+
which is not the enclosing class's.
|
|
622
|
+
|
|
623
|
+
A short class written inside a bracket — a modification's ``redeclare
|
|
624
|
+
model HT = Ideal`` — is in the list with the ``depth`` it stands at: its
|
|
625
|
+
text is not the enclosing class's own (its annotation neither), and it
|
|
626
|
+
is no class of this text by name. Taken for one, it hid the class ``HT``
|
|
627
|
+
declared after it, which was then found with no annotation — so did a
|
|
628
|
+
class redeclared in a sibling short class's modification.
|
|
629
|
+
"""
|
|
630
|
+
out: list[_ClassSpan] = []
|
|
631
|
+
stack: list[tuple[str, int, int]] = []
|
|
632
|
+
depth, at = 0, start
|
|
633
|
+
for m in _CLASS_TOKEN_RE.finditer(stripped, start, end):
|
|
634
|
+
for found in _BRACKET_RE.finditer(stripped, at, m.start()):
|
|
635
|
+
if found.group(1):
|
|
636
|
+
depth += 1 if found.group(1) in _OPENERS else -1
|
|
637
|
+
at = m.end()
|
|
638
|
+
closed = m.group(3)
|
|
639
|
+
if closed is not None:
|
|
640
|
+
if stack and stack[-1][0] == closed:
|
|
641
|
+
name, opened, body = stack.pop()
|
|
642
|
+
if not stack:
|
|
643
|
+
out.append(_ClassSpan(name, opened, body, m.end()))
|
|
644
|
+
elif m.group(2) is None:
|
|
645
|
+
stack.append((m.group(1), m.start(), m.end()))
|
|
646
|
+
elif not stack:
|
|
647
|
+
after = _statement_end(stripped, m.end(), end)
|
|
648
|
+
out.append(_ClassSpan(m.group(1), m.start(), m.end(), after, True, depth))
|
|
649
|
+
return out
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def find_class(
|
|
653
|
+
library_dir: Path, library_name: str, model_id: str
|
|
654
|
+
) -> LocatedClass | None:
|
|
655
|
+
"""Locate the class ``model_id`` in the library's files and read its
|
|
656
|
+
own ``experiment(...)`` annotation — for a test that names a model no
|
|
657
|
+
recognizer found (declared in ``test_spec.json`` only).
|
|
658
|
+
|
|
659
|
+
A Modelica class is stored as its own file (``A/B/C.mo``), as a
|
|
660
|
+
directory (``A/B/C/package.mo``), or inside the file of a class that
|
|
661
|
+
holds it (``A/B/package.mo``, ``A/B.mo``, ... up to the library's
|
|
662
|
+
``package.mo``). Those are tried from the most specific: a handful of
|
|
663
|
+
path probes per name, never a walk of the library. A file is trusted
|
|
664
|
+
only when its ``within`` clause and its class say it is the one looked
|
|
665
|
+
for. None when the name is not a class of this library (another
|
|
666
|
+
library's model, a component). A short class (``model S = L.Rod
|
|
667
|
+
annotation(...);``) is one, read like any other: its own annotation,
|
|
668
|
+
not the one of the class it is made from.
|
|
669
|
+
|
|
670
|
+
The annotation is the class's OWN: the text of the classes nested in
|
|
671
|
+
it — a short class's one statement and a class of a quoted name
|
|
672
|
+
included — is blanked first, so a nested model's ``experiment`` — which
|
|
673
|
+
the source spells before the enclosing class's annotation — is not read
|
|
674
|
+
as the parent's. It is then read by :func:`_parse_experiment`, the reader
|
|
675
|
+
a scanned test's goes through.
|
|
676
|
+
"""
|
|
677
|
+
names = model_id.split(".")
|
|
678
|
+
if len(names) < 2 or names[0] != library_name:
|
|
679
|
+
return None
|
|
680
|
+
inner = names[1:]
|
|
681
|
+
for k in range(len(inner), -1, -1):
|
|
682
|
+
owner, nested = inner[:k], inner[k:]
|
|
683
|
+
folder = library_dir.joinpath(*owner)
|
|
684
|
+
candidates = [folder / "package.mo"]
|
|
685
|
+
if owner:
|
|
686
|
+
candidates.insert(0, folder.parent / f"{owner[-1]}.mo")
|
|
687
|
+
for path in candidates:
|
|
688
|
+
if not path.is_file():
|
|
689
|
+
continue
|
|
690
|
+
found = _class_in_file(path, [library_name, *owner], nested)
|
|
691
|
+
if found is not None:
|
|
692
|
+
return found
|
|
693
|
+
return None
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _class_in_file(
|
|
697
|
+
path: Path, top: list[str], nested: list[str]
|
|
698
|
+
) -> LocatedClass | None:
|
|
699
|
+
"""The class ``nested`` deep inside the file's top class ``top`` (the
|
|
700
|
+
top class itself for an empty ``nested``), or None when the file does
|
|
701
|
+
not declare it."""
|
|
702
|
+
try:
|
|
703
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
704
|
+
except OSError:
|
|
705
|
+
return None
|
|
706
|
+
if _extract_within(text) != ".".join(top[:-1]):
|
|
707
|
+
return None
|
|
708
|
+
stripped = _strip_modelica_literals(text)
|
|
709
|
+
span = _named(_direct_classes(stripped, 0, len(stripped)), top[-1])
|
|
710
|
+
for name in nested:
|
|
711
|
+
if span is None:
|
|
712
|
+
return None
|
|
713
|
+
# A short class has no body: the classes of its own are the ones
|
|
714
|
+
# its modification redeclares, one bracket into its statement.
|
|
715
|
+
inside = _direct_classes(stripped, span.body, span.end)
|
|
716
|
+
span = _named(inside, name, depth=1 if span.short else 0)
|
|
717
|
+
if span is None:
|
|
718
|
+
return None
|
|
719
|
+
# Blanked 1:1, so `_parse_experiment`'s offsets into the original text
|
|
720
|
+
# (its string-valued fields) still hold.
|
|
721
|
+
own = list(text[span.start : span.end])
|
|
722
|
+
for child in _direct_classes(stripped, span.body, span.end):
|
|
723
|
+
for i in range(child.start - span.start, child.end - span.start):
|
|
724
|
+
if own[i] != "\n":
|
|
725
|
+
own[i] = " "
|
|
726
|
+
return LocatedClass(
|
|
727
|
+
mo_file=path, experiment=_parse_experiment("".join(own), source=path)
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _named(spans: list[_ClassSpan], name: str, depth: int = 0) -> _ClassSpan | None:
|
|
732
|
+
"""The class of that name declared at ``depth`` — a short class too:
|
|
733
|
+
``model S = L.Rod annotation(experiment(StopTime=13));`` is a class of
|
|
734
|
+
the library with an annotation of its own, in its one statement. (It
|
|
735
|
+
was passed over, and a run said of ``Examples/ShortOne.mo`` that the
|
|
736
|
+
model was "not found under source_path".) It has no body: nothing is
|
|
737
|
+
found inside it but the classes its own modification redeclares.
|
|
738
|
+
|
|
739
|
+
``depth`` is 0 in a file and in a class's body: a ``redeclare model X
|
|
740
|
+
= …`` inside a component's or an ``extends`` clause's modification
|
|
741
|
+
there is no class ``X`` of that body."""
|
|
742
|
+
return next((s for s in spans if s.name == name and s.depth == depth), None)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
# ---------------------------------------------------------------------------
|
|
746
|
+
# Bundled recognizer (PTA.1)
|
|
747
|
+
# ---------------------------------------------------------------------------
|
|
748
|
+
|
|
749
|
+
from .recognizer import ( # noqa: E402 (late import: registers the bundled recognizer after its deps are defined)
|
|
750
|
+
Recognizer,
|
|
751
|
+
RecognizerResult,
|
|
752
|
+
register,
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
class BundledModelicaUnitTestsRecognizer(Recognizer):
|
|
757
|
+
"""The default Modelica recognizer.
|
|
758
|
+
|
|
759
|
+
Matches any class that instantiates the ``UnitTests`` component (with
|
|
760
|
+
parameters ``n``, ``x={...}``, optional ``error_expected``) plus the
|
|
761
|
+
standard ``experiment(...)`` annotation. Wraps :func:`parse_mo_file` and
|
|
762
|
+
translates its ``MoParseResult`` into the framework-neutral
|
|
763
|
+
:class:`RecognizerResult` shape.
|
|
764
|
+
"""
|
|
765
|
+
|
|
766
|
+
name = "modelica:bundled-unit-tests"
|
|
767
|
+
applies_to = frozenset({"modelica"})
|
|
768
|
+
|
|
769
|
+
def recognize(self, source_file: Path) -> RecognizerResult | None:
|
|
770
|
+
parsed = parse_mo_file(source_file)
|
|
771
|
+
if parsed is None:
|
|
772
|
+
return None
|
|
773
|
+
ut = parsed.unit_test
|
|
774
|
+
exp = parsed.experiment
|
|
775
|
+
return RecognizerResult(
|
|
776
|
+
model_id=parsed.model_id,
|
|
777
|
+
source_file=parsed.mo_file,
|
|
778
|
+
n_vars=ut.n if ut else None,
|
|
779
|
+
unit_tests_instance=ut.instance if ut else None,
|
|
780
|
+
x_expressions=list(ut.x_expressions) if ut else [],
|
|
781
|
+
x_raw=ut.x_raw if ut else "",
|
|
782
|
+
x_reference=list(ut.x_reference)
|
|
783
|
+
if ut and ut.x_reference is not None
|
|
784
|
+
else None,
|
|
785
|
+
error_expected=ut.error_expected if ut else None,
|
|
786
|
+
stop_time=exp.stop_time if exp else None,
|
|
787
|
+
tolerance=exp.tolerance if exp else None,
|
|
788
|
+
method=exp.method if exp else None,
|
|
789
|
+
number_of_intervals=exp.number_of_intervals if exp else None,
|
|
790
|
+
output_interval=exp.output_interval if exp else None,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
register(BundledModelicaUnitTestsRecognizer())
|