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,308 @@
|
|
|
1
|
+
"""RFC 6902 JSON-Patch applier with whitelist + unknown-key preservation.
|
|
2
|
+
|
|
3
|
+
Used by ``dstf spec-update`` (through :mod:`spec_update`) and the authoring
|
|
4
|
+
Session to apply small, scoped edits to ``test_spec.json`` without
|
|
5
|
+
clobbering hand-authored fields. The ops arrive in the envelope
|
|
6
|
+
:mod:`patch_envelope` reads; one model's ops look like::
|
|
7
|
+
|
|
8
|
+
[
|
|
9
|
+
{"op": "replace", "path": "/comparison/tolerance", "value": 1e-3},
|
|
10
|
+
{"op": "add", "path": "/comparison/variable_overrides/v/tube_rel",
|
|
11
|
+
"value": 0.02}
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
Paths are RFC 6901 JSON-Pointers **relative to the matched test entry**
|
|
15
|
+
(so `/comparison/...`, `/metrics/...` and `/plots/...`, not `/tests/3/...`). A path
|
|
16
|
+
whitelist constrains which subtrees the applier will touch — an editor
|
|
17
|
+
is not allowed to mutate arbitrary structure.
|
|
18
|
+
|
|
19
|
+
This module is self-contained: no external JSON-Patch / JSON-Pointer
|
|
20
|
+
libraries. The surface is small (replace/add/remove on a single path
|
|
21
|
+
each), so the RFC 6901 unescape rules fit in ~10 lines.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import copy
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from ..storage.ref_index import _atomic_write_text
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PatchError(Exception):
|
|
38
|
+
"""Raised when a patch cannot be applied cleanly."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self, message: str, path: str | None = None, *, reason: str | None = None
|
|
42
|
+
):
|
|
43
|
+
self.path = path
|
|
44
|
+
#: The plain sentence a person reads: ``str()`` keeps the op and the
|
|
45
|
+
#: pointer for the CLI, the tool shows this beside the edit it came
|
|
46
|
+
#: from (`authoring.errors.user_text`).
|
|
47
|
+
self.reason = reason if reason is not None else message
|
|
48
|
+
super().__init__(f"{path}: {message}" if path else message)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_SUPPORTED_OPS = {"replace", "add", "remove"}
|
|
52
|
+
|
|
53
|
+
#: The subtrees of a test entry an editor may touch. One constant, shared by
|
|
54
|
+
#: both entry points below, so the two cannot drift.
|
|
55
|
+
DEFAULT_WHITELIST: tuple[str, ...] = ("/comparison", "/metrics", "/plots")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def apply_patch_to_document(
|
|
59
|
+
data: dict,
|
|
60
|
+
model_id: str,
|
|
61
|
+
patch_ops: list[dict],
|
|
62
|
+
*,
|
|
63
|
+
whitelist: tuple[str, ...] = DEFAULT_WHITELIST,
|
|
64
|
+
) -> dict:
|
|
65
|
+
"""Apply ``patch_ops`` to the entry in the parsed document ``data`` that
|
|
66
|
+
matches ``model_id`` (created if absent), in place. Returns ``data``.
|
|
67
|
+
Raises :class:`PatchError` naming the failing op. The authoring
|
|
68
|
+
Session's in-memory document goes through here; the disk path is
|
|
69
|
+
:func:`apply_patch`."""
|
|
70
|
+
entry = _find_or_create_entry(data, model_id)
|
|
71
|
+
for i, op in enumerate(patch_ops):
|
|
72
|
+
try:
|
|
73
|
+
_apply_one_op(entry, op, whitelist)
|
|
74
|
+
except PatchError as e:
|
|
75
|
+
raise PatchError(
|
|
76
|
+
f"patch_ops[{i}] ({op!r}): {e}",
|
|
77
|
+
path=e.path,
|
|
78
|
+
reason=e.reason,
|
|
79
|
+
) from None
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def apply_patch(
|
|
84
|
+
spec_path: Path,
|
|
85
|
+
model_id: str,
|
|
86
|
+
patch_ops: list[dict],
|
|
87
|
+
*,
|
|
88
|
+
whitelist: tuple[str, ...] = DEFAULT_WHITELIST,
|
|
89
|
+
write: bool = True,
|
|
90
|
+
) -> dict:
|
|
91
|
+
"""Apply ``patch_ops`` to the entry in ``spec_path`` matching ``model_id``.
|
|
92
|
+
|
|
93
|
+
Returns the (mutated) spec dict. When ``write=True`` (default), also
|
|
94
|
+
persists it back to disk preserving key order. Raise :class:`PatchError`
|
|
95
|
+
on any issue; the caller (CLI) surfaces the message and aborts without
|
|
96
|
+
writing if the dry-run path signals invalidity.
|
|
97
|
+
"""
|
|
98
|
+
data = apply_patch_to_document(
|
|
99
|
+
load_spec_document(spec_path), model_id, patch_ops, whitelist=whitelist
|
|
100
|
+
)
|
|
101
|
+
if write:
|
|
102
|
+
spec_path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
# review 2026-07-06 (finding 31): atomic — an interrupted patch must
|
|
104
|
+
# not leave the user's spec truncated.
|
|
105
|
+
_atomic_write_text(spec_path, json.dumps(data, indent=2) + "\n")
|
|
106
|
+
return data
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_spec_document(spec_path: Path) -> dict:
|
|
110
|
+
"""The spec document a patch is applied to: the file's
|
|
111
|
+
(``spec_parser.read_spec_document`` — the one reader, a
|
|
112
|
+
:class:`~dstf.config.ConfigError` for a file that is no spec), or the
|
|
113
|
+
empty document when there is no file yet."""
|
|
114
|
+
from .spec_parser import read_spec_document
|
|
115
|
+
|
|
116
|
+
data = read_spec_document(spec_path)
|
|
117
|
+
return {"tests": []} if data is None else data
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Internals
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _find_or_create_entry(data: dict, model_id: str) -> dict:
|
|
126
|
+
matches = [
|
|
127
|
+
entry
|
|
128
|
+
for entry in data["tests"]
|
|
129
|
+
if isinstance(entry, dict) and entry.get("model") == model_id
|
|
130
|
+
]
|
|
131
|
+
if len(matches) > 1:
|
|
132
|
+
# review 2026-07-06 finding 51: duplicates — first entry wins
|
|
133
|
+
# everywhere (discovery + edit helpers use the same rule); warn so
|
|
134
|
+
# the user notices the dead entries.
|
|
135
|
+
logger.warning(
|
|
136
|
+
"test_spec has %d duplicate entries for model '%s'; "
|
|
137
|
+
"patching the first — remove the duplicates",
|
|
138
|
+
len(matches),
|
|
139
|
+
model_id,
|
|
140
|
+
)
|
|
141
|
+
if matches:
|
|
142
|
+
return matches[0]
|
|
143
|
+
entry = {"model": model_id}
|
|
144
|
+
data["tests"].append(entry)
|
|
145
|
+
return entry
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _apply_one_op(entry: dict, op: dict, whitelist: tuple[str, ...]) -> None:
|
|
149
|
+
op_name = op.get("op")
|
|
150
|
+
path = op.get("path")
|
|
151
|
+
if op_name not in _SUPPORTED_OPS:
|
|
152
|
+
raise PatchError(
|
|
153
|
+
f"unsupported op {op_name!r} (supported: {sorted(_SUPPORTED_OPS)})",
|
|
154
|
+
path=path,
|
|
155
|
+
)
|
|
156
|
+
if not isinstance(path, str) or not path.startswith("/"):
|
|
157
|
+
raise PatchError(
|
|
158
|
+
"'path' must be an RFC 6901 JSON-Pointer starting with /", path=path
|
|
159
|
+
)
|
|
160
|
+
if not any(path == w or path.startswith(w + "/") for w in whitelist):
|
|
161
|
+
raise PatchError(
|
|
162
|
+
f"path not in whitelist {whitelist}",
|
|
163
|
+
path=path,
|
|
164
|
+
)
|
|
165
|
+
tokens = _split_pointer(path)
|
|
166
|
+
if op_name == "remove":
|
|
167
|
+
_remove_at(entry, tokens, path)
|
|
168
|
+
return
|
|
169
|
+
if "value" not in op:
|
|
170
|
+
raise PatchError(f"{op_name!r} requires 'value'", path=path)
|
|
171
|
+
# A copy: the document never shares an object with the op that put it
|
|
172
|
+
# there. Written in by reference, a later op of the same patch edits the
|
|
173
|
+
# earlier op's own value — and the list a caller still holds is then no
|
|
174
|
+
# longer the patch it gave: applied again (a replay, a second document)
|
|
175
|
+
# it does those later ops twice.
|
|
176
|
+
value = copy.deepcopy(op["value"])
|
|
177
|
+
if op_name == "replace":
|
|
178
|
+
_set_at(entry, tokens, value, path, must_exist=True)
|
|
179
|
+
elif op_name == "add":
|
|
180
|
+
_set_at(entry, tokens, value, path, must_exist=False)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _split_pointer(pointer: str) -> list[str]:
|
|
184
|
+
"""RFC 6901: split on '/' after a leading slash; unescape ~1 → / and ~0 → ~."""
|
|
185
|
+
if pointer == "":
|
|
186
|
+
return []
|
|
187
|
+
if not pointer.startswith("/"):
|
|
188
|
+
raise PatchError("JSON-Pointer must start with /", path=pointer)
|
|
189
|
+
raw = pointer[1:].split("/")
|
|
190
|
+
# Order matters: ~1 before ~0 per RFC 6901.
|
|
191
|
+
return [seg.replace("~1", "/").replace("~0", "~") for seg in raw]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _set_at(
|
|
195
|
+
obj: Any, tokens: list[str], value: Any, path: str, *, must_exist: bool
|
|
196
|
+
) -> None:
|
|
197
|
+
if not tokens:
|
|
198
|
+
raise PatchError("empty path points at the entry root; refuse", path=path)
|
|
199
|
+
*parents, last = tokens
|
|
200
|
+
cur = obj
|
|
201
|
+
for i, tok in enumerate(parents):
|
|
202
|
+
next_token = parents[i + 1] if i + 1 < len(parents) else last
|
|
203
|
+
cur = _descend(
|
|
204
|
+
cur, tok, path, create_missing=not must_exist, next_token=next_token
|
|
205
|
+
)
|
|
206
|
+
if isinstance(cur, list):
|
|
207
|
+
idx = _list_index(cur, last, path, allow_append=not must_exist)
|
|
208
|
+
if idx > len(cur) or (must_exist and idx >= len(cur)):
|
|
209
|
+
raise PatchError(
|
|
210
|
+
f"list index {idx} out of range (len {len(cur)})", path=path
|
|
211
|
+
)
|
|
212
|
+
if must_exist:
|
|
213
|
+
cur[idx] = value
|
|
214
|
+
else:
|
|
215
|
+
# review 2026-07-06 finding 49: RFC 6902 `add` on an existing
|
|
216
|
+
# array index INSERTS before it (it used to replace, silently
|
|
217
|
+
# destroying a sibling leaf); idx == len(cur) appends, matching
|
|
218
|
+
# the '-' token.
|
|
219
|
+
cur.insert(idx, value)
|
|
220
|
+
elif isinstance(cur, dict):
|
|
221
|
+
if must_exist and last not in cur:
|
|
222
|
+
raise PatchError(
|
|
223
|
+
f"key {last!r} not present (use 'add' for new keys)", path=path
|
|
224
|
+
)
|
|
225
|
+
cur[last] = value
|
|
226
|
+
else:
|
|
227
|
+
raise PatchError(
|
|
228
|
+
f"cannot set on non-container {type(cur).__name__}",
|
|
229
|
+
path=path,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _remove_at(obj: Any, tokens: list[str], path: str) -> None:
|
|
234
|
+
if not tokens:
|
|
235
|
+
raise PatchError("empty path points at the entry root; refuse", path=path)
|
|
236
|
+
*parents, last = tokens
|
|
237
|
+
cur = obj
|
|
238
|
+
for tok in parents:
|
|
239
|
+
cur = _descend(cur, tok, path, create_missing=False)
|
|
240
|
+
if isinstance(cur, list):
|
|
241
|
+
idx = _list_index(cur, last, path, allow_append=False)
|
|
242
|
+
if idx >= len(cur):
|
|
243
|
+
raise PatchError(f"list index {idx} out of range", path=path)
|
|
244
|
+
del cur[idx]
|
|
245
|
+
elif isinstance(cur, dict):
|
|
246
|
+
if last not in cur:
|
|
247
|
+
raise PatchError(f"key {last!r} not present", path=path)
|
|
248
|
+
del cur[last]
|
|
249
|
+
else:
|
|
250
|
+
raise PatchError(
|
|
251
|
+
f"cannot remove from non-container {type(cur).__name__}",
|
|
252
|
+
path=path,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _descend(
|
|
257
|
+
cur: Any,
|
|
258
|
+
tok: str,
|
|
259
|
+
path: str,
|
|
260
|
+
*,
|
|
261
|
+
create_missing: bool,
|
|
262
|
+
next_token: str | None = None,
|
|
263
|
+
) -> Any:
|
|
264
|
+
if isinstance(cur, list):
|
|
265
|
+
idx = _list_index(cur, tok, path, allow_append=False)
|
|
266
|
+
if idx >= len(cur):
|
|
267
|
+
raise PatchError(f"list index {idx} out of range", path=path)
|
|
268
|
+
return cur[idx]
|
|
269
|
+
if isinstance(cur, dict):
|
|
270
|
+
if tok not in cur:
|
|
271
|
+
if not create_missing:
|
|
272
|
+
raise PatchError(
|
|
273
|
+
f"key {tok!r} not present (use 'add' for new keys)",
|
|
274
|
+
path=path,
|
|
275
|
+
)
|
|
276
|
+
# review 2026-07-06 finding 49: never auto-create a dict where
|
|
277
|
+
# the path expects an ARRAY — a per-leaf op like
|
|
278
|
+
# /metrics/children/0/... against a spec without a metrics tree
|
|
279
|
+
# used to silently create {"children": {"0": ...}}, which the
|
|
280
|
+
# tree validator then rejects. Fail loudly instead.
|
|
281
|
+
if next_token is not None and (next_token == "-" or next_token.isdigit()):
|
|
282
|
+
raise PatchError(
|
|
283
|
+
f"key {tok!r} not present and the next token "
|
|
284
|
+
f"{next_token!r} is an array index — refusing to "
|
|
285
|
+
f"auto-create a dict in place of an array (the test "
|
|
286
|
+
f"entry likely has no explicit 'metrics' tree yet)",
|
|
287
|
+
path=path,
|
|
288
|
+
)
|
|
289
|
+
cur[tok] = {}
|
|
290
|
+
return cur[tok]
|
|
291
|
+
raise PatchError(
|
|
292
|
+
f"cannot descend into {type(cur).__name__}",
|
|
293
|
+
path=path,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _list_index(cur: list, tok: str, path: str, *, allow_append: bool) -> int:
|
|
298
|
+
if tok == "-":
|
|
299
|
+
if not allow_append:
|
|
300
|
+
raise PatchError("'-' (append) not allowed on non-add op", path=path)
|
|
301
|
+
return len(cur)
|
|
302
|
+
try:
|
|
303
|
+
idx = int(tok)
|
|
304
|
+
except ValueError:
|
|
305
|
+
raise PatchError(f"list index {tok!r} must be an integer", path=path) from None
|
|
306
|
+
if idx < 0:
|
|
307
|
+
raise PatchError(f"list index {idx} must be non-negative", path=path)
|
|
308
|
+
return idx
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The JSON-Patch envelope ``dstf spec-update`` reads and the authoring
|
|
2
|
+
Session's ``export`` writes.
|
|
3
|
+
|
|
4
|
+
Two shapes are read — ``{"model": ..., "patch": [...]}`` because files in
|
|
5
|
+
that shape exist (the reporter's export panel wrote them until D125), and
|
|
6
|
+
``{"patches": [{"model": ..., "patch": [...]}, ...]}`` because a tool that
|
|
7
|
+
spans tests needs one. Only the second is written. Old data, not old code.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from ..config import ConfigError
|
|
16
|
+
|
|
17
|
+
_SHAPES = (
|
|
18
|
+
'{"model": "<model id>", "patch": [<RFC 6902 op>, ...]} or '
|
|
19
|
+
'{"patches": [{"model": ..., "patch": [...]}, ...]}'
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ModelPatch:
|
|
25
|
+
"""One model's ops, in order. Paths are relative to the model's entry."""
|
|
26
|
+
|
|
27
|
+
model_id: str
|
|
28
|
+
ops: list[dict]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_envelope(data: object, *, label: str) -> list[ModelPatch]:
|
|
32
|
+
"""Read either envelope form into ``ModelPatch`` groups, in file order.
|
|
33
|
+
Anything else raises :class:`ConfigError` naming both forms."""
|
|
34
|
+
if isinstance(data, dict) and "patches" in data and "model" in data:
|
|
35
|
+
raise _bad(label, "both a 'model' and a 'patches' key — say which")
|
|
36
|
+
if isinstance(data, dict) and "patches" in data:
|
|
37
|
+
groups = data["patches"]
|
|
38
|
+
if not isinstance(groups, list):
|
|
39
|
+
raise _bad(label, f"'patches' of type {type(groups).__name__}")
|
|
40
|
+
return [_group(g, label) for g in groups]
|
|
41
|
+
if isinstance(data, dict) and "model" in data:
|
|
42
|
+
return [_group(data, label)]
|
|
43
|
+
raise _bad(label, "neither a 'model' nor a 'patches' key")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_envelope(groups: Sequence[ModelPatch]) -> dict:
|
|
47
|
+
"""The multi-model form, and only that form."""
|
|
48
|
+
return {"patches": [{"model": g.model_id, "patch": list(g.ops)} for g in groups]}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _group(raw: object, label: str) -> ModelPatch:
|
|
52
|
+
if not isinstance(raw, dict) or not raw.get("model"):
|
|
53
|
+
raise _bad(label, "a group without a 'model'")
|
|
54
|
+
ops = raw.get("patch")
|
|
55
|
+
if not isinstance(ops, list):
|
|
56
|
+
raise _bad(label, f"a 'patch' of type {type(ops).__name__}")
|
|
57
|
+
return ModelPatch(str(raw["model"]), ops)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _bad(label: str, got: str) -> ConfigError:
|
|
61
|
+
return ConfigError(
|
|
62
|
+
f"{label}: not a JSON-Patch envelope — expected {_SHAPES}, got {got}"
|
|
63
|
+
)
|
dstf/discovery/plots.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""The optional ``plots`` key of a ``test_spec.json`` entry, and the plots
|
|
2
|
+
both tiers draw.
|
|
3
|
+
|
|
4
|
+
A test's plots are DERIVED from its criteria tree (:func:`derive_plots`):
|
|
5
|
+
one plot per scored variable or array family, where the tree first names
|
|
6
|
+
it. The key holds only a person's extras — each entry one variable and the
|
|
7
|
+
``context`` series drawn grey beside it. An entry on a scored variable
|
|
8
|
+
decorates that variable's plot; an entry on an unscored one is a WATCH
|
|
9
|
+
plot, after the derived ones, in list order. Absent and ``[]`` both mean no
|
|
10
|
+
extras (``[]`` is kept as the key's value, so a writer can tell it from an
|
|
11
|
+
absent key); ``null`` is refused. Read by both tiers, ignored by scoring.
|
|
12
|
+
The parser refuses what it does not know, so a typo is loud. No I/O here:
|
|
13
|
+
the spec parser, the patch validator, the Session and the report all call
|
|
14
|
+
in.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Collection, Iterable, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
from ..backends.types import is_glob_pattern
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from ..backends.types import TestResult
|
|
27
|
+
from ..comparison.tree_spec import LeafSpec, SpecNode
|
|
28
|
+
from .test_registry import TestModel
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PlotsSpecError(ValueError):
|
|
32
|
+
"""A ``plots`` value the parser refuses; the message starts with where."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class PlotExtra:
|
|
37
|
+
"""One entry of the ``plots`` key: a variable and its context series.
|
|
38
|
+
Scored, it decorates that variable's derived plot; unscored, it is a
|
|
39
|
+
watch plot."""
|
|
40
|
+
|
|
41
|
+
var: str
|
|
42
|
+
context: tuple[str, ...] = ()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, kw_only=True)
|
|
46
|
+
class ResolvedPlot:
|
|
47
|
+
"""One plot as a tier draws it. ``subject`` is one variable or the
|
|
48
|
+
members of one array family (by index); ``context`` the grey, unscored
|
|
49
|
+
series beside it; ``watch`` a plot no criterion names; ``missing`` the
|
|
50
|
+
subject and context names the result lacks."""
|
|
51
|
+
|
|
52
|
+
index: int
|
|
53
|
+
subject: tuple[str, ...]
|
|
54
|
+
context: tuple[str, ...] = ()
|
|
55
|
+
watch: bool = False
|
|
56
|
+
missing: tuple[str, ...] = ()
|
|
57
|
+
kind: str = "time"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def series(self) -> tuple[str, ...]:
|
|
61
|
+
"""Every name the plot draws — the subject, then the context —
|
|
62
|
+
missing ones included: a reader checks ``missing``."""
|
|
63
|
+
return self.subject + self.context
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _unknown(obj: dict, known: set[str], at: str) -> None:
|
|
67
|
+
"""Refuse a key nothing reads, in the words every other part of the
|
|
68
|
+
spec is refused with (``strict_keys.unknown_field``). Imported late:
|
|
69
|
+
the comparison package reaches back into discovery."""
|
|
70
|
+
from ..comparison.strict_keys import unknown_field
|
|
71
|
+
|
|
72
|
+
problem = unknown_field(obj, known)
|
|
73
|
+
if problem is not None:
|
|
74
|
+
raise PlotsSpecError(f"{at}: {problem}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _one_name(value: object, at: str, what: str) -> str:
|
|
78
|
+
if not isinstance(value, str) or not value.strip():
|
|
79
|
+
raise PlotsSpecError(f"{at}: must be a non-empty string")
|
|
80
|
+
if is_glob_pattern(value):
|
|
81
|
+
raise PlotsSpecError(f"{at}: {value!r} is a pattern; {what} one variable")
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_plots(raw: object, *, path: str = "plots") -> tuple[PlotExtra, ...]:
|
|
86
|
+
if not isinstance(raw, list):
|
|
87
|
+
raise PlotsSpecError(f"{path}: must be a list of plot extras")
|
|
88
|
+
extras: list[PlotExtra] = []
|
|
89
|
+
for i, entry in enumerate(raw):
|
|
90
|
+
at = f"{path}[{i}]"
|
|
91
|
+
if not isinstance(entry, dict):
|
|
92
|
+
raise PlotsSpecError(f"{at}: must be an object")
|
|
93
|
+
_unknown(entry, {"var", "context"}, at)
|
|
94
|
+
var = _one_name(entry.get("var"), f"{at}.var", "an entry names")
|
|
95
|
+
if any(e.var == var for e in extras):
|
|
96
|
+
raise PlotsSpecError(f"{at}.var: {var!r} already has an entry")
|
|
97
|
+
context_raw = entry.get("context", [])
|
|
98
|
+
if not isinstance(context_raw, list):
|
|
99
|
+
raise PlotsSpecError(f"{at}.context: must be a list of names")
|
|
100
|
+
context: list[str] = []
|
|
101
|
+
for j, one in enumerate(context_raw):
|
|
102
|
+
c_at = f"{at}.context[{j}]"
|
|
103
|
+
name = _one_name(one, c_at, "a context series names")
|
|
104
|
+
if name == var:
|
|
105
|
+
raise PlotsSpecError(f"{c_at}: {name!r} is the plot's own variable")
|
|
106
|
+
if name in context:
|
|
107
|
+
raise PlotsSpecError(f"{c_at}: {name!r} is already in the context")
|
|
108
|
+
context.append(name)
|
|
109
|
+
extras.append(PlotExtra(var, tuple(context)))
|
|
110
|
+
return tuple(extras)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def plots_to_raw(extras: Sequence[PlotExtra]) -> list[dict]:
|
|
114
|
+
"""The JSON a writer stores: ``context`` only when there is some."""
|
|
115
|
+
return [
|
|
116
|
+
{"var": e.var, **({"context": list(e.context)} if e.context else {})}
|
|
117
|
+
for e in extras
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def tree_leaves(spec: SpecNode | None) -> list[LeafSpec] | None:
|
|
122
|
+
"""The leaves of ``spec`` that name a series of the result, depth
|
|
123
|
+
first; ``None`` for no tree. A stored-scalar leaf (a string
|
|
124
|
+
``quantity``) names a statistic, not a series, and has no plot.
|
|
125
|
+
|
|
126
|
+
Walks the SPEC, never the materialized tree: the Session's plots are
|
|
127
|
+
painted before any baseline is read."""
|
|
128
|
+
if spec is None:
|
|
129
|
+
return None
|
|
130
|
+
from ..comparison.tree_spec import LeafSpec, resolves_a_variable
|
|
131
|
+
|
|
132
|
+
out: list[LeafSpec] = []
|
|
133
|
+
|
|
134
|
+
def walk(node: SpecNode) -> None:
|
|
135
|
+
if isinstance(node, LeafSpec):
|
|
136
|
+
if resolves_a_variable(node):
|
|
137
|
+
out.append(node)
|
|
138
|
+
return
|
|
139
|
+
for child in node.children:
|
|
140
|
+
walk(child)
|
|
141
|
+
|
|
142
|
+
walk(spec)
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def plot_leaves(test: TestModel, compared: Sequence[str]) -> list[LeafSpec] | None:
|
|
147
|
+
"""The leaves a test's plots derive from, in tree order: its authored
|
|
148
|
+
``metrics`` tree — even with nothing compared yet, a test with no
|
|
149
|
+
baseline: the tree names what will be scored — else the implicit tree of
|
|
150
|
+
what was ``compared``; ``None`` with neither (one plot per tracked
|
|
151
|
+
variable, the owner's ruling O2). ONE rule, for the tool's
|
|
152
|
+
`Session.plots` and the report."""
|
|
153
|
+
from ..comparison.tree_spec import synthesize_implicit_tree
|
|
154
|
+
|
|
155
|
+
if test.metric_tree_spec is not None:
|
|
156
|
+
return tree_leaves(test.metric_tree_spec)
|
|
157
|
+
if not compared:
|
|
158
|
+
return None
|
|
159
|
+
return tree_leaves(
|
|
160
|
+
synthesize_implicit_tree(
|
|
161
|
+
list(compared), variable_overrides=test.variable_overrides
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def derive_plots(
|
|
167
|
+
leaves: Sequence[LeafSpec] | None,
|
|
168
|
+
extras: Sequence[PlotExtra],
|
|
169
|
+
*,
|
|
170
|
+
tracked: Sequence[str],
|
|
171
|
+
unit_tests: Collection[str] = (),
|
|
172
|
+
available: Iterable[str] | None = None,
|
|
173
|
+
) -> tuple[ResolvedPlot, ...]:
|
|
174
|
+
"""The plots both tiers draw for one test.
|
|
175
|
+
|
|
176
|
+
``leaves`` are the tree's (:func:`plot_leaves`), in depth-first order;
|
|
177
|
+
``None`` (no tree: nothing scored) derives from the ``tracked`` names
|
|
178
|
+
instead. Their variables fold into array families by RESULT-FILE NAME
|
|
179
|
+
(the :mod:`~dstf.figures.families` rule over the names: ``pipe.T[1]`` ..
|
|
180
|
+
``pipe.T[4]`` are one plot), with no scale check — except the
|
|
181
|
+
``unit_tests`` names, each its own plot whatever its expression (the
|
|
182
|
+
owner's ruling O3: a UnitTests element is a test quantity of its own,
|
|
183
|
+
and `check_atm`'s local ``x`` holds 1 Pa in atm beside 1 atm in Pa).
|
|
184
|
+
One plot per family or lone variable, where the tree first names it — a
|
|
185
|
+
family where the tree first names any member.
|
|
186
|
+
|
|
187
|
+
Each extra on a member of a derived subject adds its context there;
|
|
188
|
+
every other extra is a watch plot, after the derived ones, in list
|
|
189
|
+
order.
|
|
190
|
+
|
|
191
|
+
``available`` is every name the plots can be drawn from — the result
|
|
192
|
+
file's listing and the result's own variables — or ``None`` when it
|
|
193
|
+
cannot be listed: nothing is missing then."""
|
|
194
|
+
from ..figures.families import group_families
|
|
195
|
+
|
|
196
|
+
if leaves is None:
|
|
197
|
+
order = list(dict.fromkeys(n for n in tracked if n))
|
|
198
|
+
else:
|
|
199
|
+
order = list(dict.fromkeys(leaf.variable for leaf in leaves if leaf.variable))
|
|
200
|
+
apart = set(unit_tests)
|
|
201
|
+
family_of: dict[str, tuple[str, ...]] = {
|
|
202
|
+
name: (name,) for name in order if name in apart
|
|
203
|
+
}
|
|
204
|
+
for _, members in group_families([n for n in order if n not in apart]):
|
|
205
|
+
for name in members:
|
|
206
|
+
family_of[name] = tuple(members)
|
|
207
|
+
subjects = list(dict.fromkeys(family_of[name] for name in order))
|
|
208
|
+
member_of = {name: i for i, subject in enumerate(subjects) for name in subject}
|
|
209
|
+
context: list[list[str]] = [[] for _ in subjects]
|
|
210
|
+
watch: list[PlotExtra] = []
|
|
211
|
+
for extra in extras:
|
|
212
|
+
at = member_of.get(extra.var)
|
|
213
|
+
if at is None:
|
|
214
|
+
watch.append(extra)
|
|
215
|
+
continue
|
|
216
|
+
for name in extra.context:
|
|
217
|
+
if name not in subjects[at] and name not in context[at]:
|
|
218
|
+
context[at].append(name)
|
|
219
|
+
have = None if available is None else set(available)
|
|
220
|
+
rows = [(s, tuple(c), False) for s, c in zip(subjects, context)]
|
|
221
|
+
rows += [((e.var,), e.context, True) for e in watch]
|
|
222
|
+
return tuple(
|
|
223
|
+
ResolvedPlot(
|
|
224
|
+
index=i,
|
|
225
|
+
subject=subject,
|
|
226
|
+
context=ctx,
|
|
227
|
+
watch=is_watch,
|
|
228
|
+
missing=tuple(
|
|
229
|
+
n for n in subject + ctx if have is not None and n not in have
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
for i, (subject, ctx, is_watch) in enumerate(rows)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def plots_of_test(
|
|
237
|
+
test: TestModel,
|
|
238
|
+
compared: Sequence[str],
|
|
239
|
+
result: TestResult | None,
|
|
240
|
+
*,
|
|
241
|
+
listed: Iterable[str] | None,
|
|
242
|
+
) -> tuple[ResolvedPlot, ...]:
|
|
243
|
+
"""The plots of one test — ONE rule, called by the tool's
|
|
244
|
+
`Session.plots` and the report's `plot_inputs`, so both tiers draw the
|
|
245
|
+
same list.
|
|
246
|
+
|
|
247
|
+
The leaves are :func:`plot_leaves`', the tracked names
|
|
248
|
+
:func:`tracked_names`', the extras the entry's ``plots`` key. Which
|
|
249
|
+
names are UnitTests elements — never folded (O3) — is asked of every
|
|
250
|
+
leaf AND every tracked name: a leaf the result does not carry is an
|
|
251
|
+
element all the same.
|
|
252
|
+
|
|
253
|
+
``listed`` is the result FILE's names, or ``None`` when it was not
|
|
254
|
+
listed (the report lists it only for a test with extras). A name the
|
|
255
|
+
result in memory carries is never missing — the file's listing may be
|
|
256
|
+
filtered; with no listing, what the result carries is all the plots can
|
|
257
|
+
draw, and a leaf it lacks is missing. With no result either, nothing is
|
|
258
|
+
missing.
|
|
259
|
+
|
|
260
|
+
A run that FAILED has no plots, extras included (O2, literally): what a
|
|
261
|
+
backend wrote before it stopped is a partial file, not the test's
|
|
262
|
+
plots."""
|
|
263
|
+
from .unit_tests import is_unit_test_variable
|
|
264
|
+
|
|
265
|
+
if result is not None and not result.success:
|
|
266
|
+
return ()
|
|
267
|
+
leaves = plot_leaves(test, compared)
|
|
268
|
+
tracked = tracked_names(compared, result)
|
|
269
|
+
names = [*(leaf.variable for leaf in leaves or ()), *tracked]
|
|
270
|
+
available: list[str] | None = None
|
|
271
|
+
if result is not None:
|
|
272
|
+
available = [v.name for v in result.variables if v.name]
|
|
273
|
+
if listed is not None:
|
|
274
|
+
available = [*listed, *(available or ())]
|
|
275
|
+
return derive_plots(
|
|
276
|
+
leaves,
|
|
277
|
+
tuple(test.plots or ()),
|
|
278
|
+
tracked=tracked,
|
|
279
|
+
unit_tests=[n for n in names if is_unit_test_variable(test, n)],
|
|
280
|
+
available=available,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def tracked_names(scored: Sequence[str], result: object) -> list[str]:
|
|
285
|
+
"""The variables a test HAS a plot for by default, in the order both
|
|
286
|
+
tiers draw them: what the comparison scored, then the rest of the loaded
|
|
287
|
+
result's, de-duplicated. ONE rule, called by the tool's `Session.tracked`
|
|
288
|
+
and by the report's `plot_inputs` — the two drew different default
|
|
289
|
+
layouts (the report one plot per scored variable, the tool the union),
|
|
290
|
+
and the owner's ruling is that both tiers draw the same plots.
|
|
291
|
+
|
|
292
|
+
Every name here is the result file's (a UnitTests variable's is
|
|
293
|
+
``<instance>.x[i]``) and nothing relabels it, so a variable the tree
|
|
294
|
+
scores and the result carries is one name and is listed once — listing
|
|
295
|
+
the same trajectory under two spellings drew it twice (the 3b review). A
|
|
296
|
+
result variable with no name has nothing to plot by and is skipped; a
|
|
297
|
+
tree leaf's variable, on the other hand, is listed whether or not the
|
|
298
|
+
result carries it, because ``scored`` is what the test's author wrote.
|
|
299
|
+
|
|
300
|
+
A result of a simulation that FAILED contributes nothing: what a backend
|
|
301
|
+
wrote before it stopped is a partial file, not the test's plots, and the
|
|
302
|
+
page drew a card per half-written variable (the final review).
|
|
303
|
+
|
|
304
|
+
``scored`` comes first even when the tree's order differs from the
|
|
305
|
+
result file's. The result's own come in its order — the spec's
|
|
306
|
+
patterns' order (`resolve_variable_patterns`: explicit names as
|
|
307
|
+
written, a glob's matches in the file's order), never sorted."""
|
|
308
|
+
names = [name for name in scored if name]
|
|
309
|
+
have = set(names)
|
|
310
|
+
out = list(dict.fromkeys(names))
|
|
311
|
+
if not getattr(result, "success", True):
|
|
312
|
+
return out
|
|
313
|
+
for var in getattr(result, "variables", None) or ():
|
|
314
|
+
if not var.name or var.name in have:
|
|
315
|
+
continue
|
|
316
|
+
have.add(var.name)
|
|
317
|
+
out.append(var.name)
|
|
318
|
+
return out
|