dynamic-systems-testing-framework 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,144 @@
1
+ """Queue (D150; brief §3.6): SUITE and TEST placements. The selected tests
2
+ are appended to the Session's simulation queue — one per tool process,
3
+ first in, first out — as an ``Enqueue``.
4
+
5
+ The capability starts nothing itself: the Session owns the queue and the
6
+ pool it runs on, reads each result back as its test ends and rescores that
7
+ test alone. A test already queued or running is not queued twice, and the
8
+ caption says how many of the selection that leaves out.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from ...backends.pool import ItemState
16
+ from ..capabilities import Emphasis, Enqueue, Panel, Placement, plural
17
+ from ..situation import Situation
18
+ from ..spec_document import edit_counts
19
+
20
+ if TYPE_CHECKING:
21
+ from ..session import SessionView
22
+
23
+ # NO module-level `nicegui` import: `import dstf.authoring` runs
24
+ # register_builtins(), and the Session must import without the extra.
25
+
26
+ #: The states of a test the queue already holds.
27
+ _HELD = (ItemState.WAITING, ItemState.RUNNING)
28
+
29
+
30
+ def _models(s: SessionView) -> tuple[list[str], list[str]]:
31
+ """The selected models in project order — the order the queue takes —
32
+ as ``(to queue, already queued or running)``."""
33
+ project = s.project
34
+ if project is None:
35
+ return [], []
36
+ selection = s.selection.tests
37
+ new: list[str] = []
38
+ held: list[str] = []
39
+ for test in project.tests:
40
+ if test.model_id in selection:
41
+ (held if s.queue_state(test.model_id) in _HELD else new).append(
42
+ test.model_id
43
+ )
44
+ return new, held
45
+
46
+
47
+ class EnqueueTests:
48
+ """The selected tests appended to the simulation queue. Named for what
49
+ it does, not "queue": that word is the shell's for the queue itself,
50
+ and the shell names no capability (D129)."""
51
+
52
+ name = "enqueue"
53
+ label = "Queue"
54
+ placements = frozenset({Placement.SUITE, Placement.TEST})
55
+ #: drawn as this icon where a bar draws icons (the Suite toolbar)
56
+ icon = "playlist_add"
57
+ #: the Test page's strip: the next action when there is no result to
58
+ #: read, a criterion names a variable the last run did not record, or
59
+ #: its last run was cancelled or could not start; in the ⋮ while edits
60
+ #: are pending (their next action is Save) and while the queue holds
61
+ #: the test (the strip's own Remove or Cancel is the action then)
62
+ emphasis = {
63
+ Situation.SIM_FAIL: Emphasis.NEXT,
64
+ Situation.NO_RESULT: Emphasis.NEXT,
65
+ Situation.CANCELLED: Emphasis.NEXT,
66
+ Situation.START_FAILED: Emphasis.NEXT,
67
+ Situation.EDITS: Emphasis.MENU,
68
+ Situation.EDITS_NEW: Emphasis.NEXT,
69
+ Situation.NEW_VARIABLES: Emphasis.NEXT,
70
+ Situation.QUEUED: Emphasis.MENU,
71
+ Situation.RUNNING: Emphasis.MENU,
72
+ }
73
+
74
+ def available(self, s: SessionView) -> bool | str:
75
+ if s.project is None:
76
+ return "no project open"
77
+ if not s.selection.tests:
78
+ return "Queue needs a selection"
79
+ refusal = s.queue_refusal
80
+ if refusal is not None:
81
+ # The backend cannot run one test at a time, or cannot be built
82
+ # here: its own sentence, from the first attempt on.
83
+ return refusal
84
+ new, held = _models(s)
85
+ if not new and held:
86
+ if len(held) == 1:
87
+ doing = (
88
+ "running"
89
+ if s.queue_state(held[0]) is ItemState.RUNNING
90
+ else "queued"
91
+ )
92
+ return f"{held[0]} is already {doing}"
93
+ return f"all {len(held)} selected tests are already queued or running"
94
+ return True
95
+
96
+ def caption(self, s: SessionView) -> str:
97
+ """The count, how many of the selection the queue already holds
98
+ ("Queue 3 (2 already queued)") and the selection's unsaved edits
99
+ (brief §3.6: a run uses the spec with pending edits applied, and
100
+ the button says so) — as they stand when each test STARTS, which is
101
+ when the queue takes a test's settings. One test whose last run in
102
+ the queue was cancelled or failed reads "Queue again". No backend:
103
+ the header shows it (owner ruling, 2026-09-26); the panel names
104
+ it."""
105
+ new, held = _models(s)
106
+ again = (ItemState.CANCELLED, ItemState.FAILED)
107
+ if len(new) == 1 and not held and s.queue_state(new[0]) in again:
108
+ return "Queue again"
109
+ notes = []
110
+ if held:
111
+ notes.append(f"{len(held)} already queued")
112
+ models = set(new)
113
+ counts = edit_counts(s.pending)
114
+ edits = sum(counts[model_id] for model_id in models)
115
+ if edits:
116
+ notes.append(
117
+ f"with {plural(edits, 'unsaved edit')}, as they stand when each starts"
118
+ )
119
+ return f"Queue {len(new)}" + (f" ({'; '.join(notes)})" if notes else "")
120
+
121
+ def panel(self, s: SessionView, ui_panel: Panel) -> None:
122
+ from nicegui import ui
123
+
124
+ project = s.project
125
+ assert project is not None # `available` greys the button without one
126
+ ui.label(f"runs through {project.config.backend_name}").classes("text-xs").mark(
127
+ "queue-backend"
128
+ )
129
+ new, held = _models(s)
130
+ for model_id in new:
131
+ ui.label(model_id).classes("text-xs")
132
+ for model_id in held:
133
+ ui.label(f"{model_id} · already in the queue").classes(
134
+ "text-xs text-secondary"
135
+ )
136
+ ui.button(self.caption(s), on_click=lambda: ui_panel.submit()).props(
137
+ "dense no-caps"
138
+ )
139
+
140
+ def act(self, s: SessionView, **inputs: Any) -> Enqueue:
141
+ if s.project is None:
142
+ raise ValueError("no project open")
143
+ new, _held = _models(s)
144
+ return Enqueue(models=tuple(new))
@@ -0,0 +1,463 @@
1
+ """The capability contract (two-tier design, §4.6).
2
+
3
+ A capability is a plug-in the authoring shell draws at the placements it
4
+ names: it reads a :class:`SessionView`, builds its controls in a
5
+ :class:`Panel` the shell provides, and returns an :class:`Outcome`. **The Session applies outcomes;
6
+ a capability applies nothing.** It holds a view only, so the enforcement
7
+ is structural. Registration goes through the single ``dstf.plugins``
8
+ entry-point group: a plug-in module calls :func:`register_capability` on
9
+ import exactly as it calls ``register_metric``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable, Hashable, Sequence
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
19
+
20
+ if TYPE_CHECKING:
21
+ from ..storage.reference_store import ReferenceStore
22
+ from .session import SessionView
23
+ from .spec_document import PatchOp
24
+
25
+
26
+ class Placement(Enum):
27
+ """Where a capability attaches (brief §3.2; D129): the Suite page's
28
+ batch bar, the Test page's status strip, a plot card's criteria header,
29
+ the open criterion's editor, a tree node's place in the tree, a row of
30
+ the Statistics card and a statistics criterion's editor there. A capability
31
+ names the frozenset it attaches at; the shell draws it at each and knows
32
+ no capability by name. The tab strip this replaces listed one tab per
33
+ ``Scope``; the scopes' selection rules (TESTS needs a selection, LEAF a
34
+ leaf) are now what ``available`` answers for the page's selection.
35
+
36
+ SUITE and TEST are a button carrying the capability's ``caption``, its
37
+ panel in a dialog; VARIABLE, CRITERION, NODE and STATISTIC are inline —
38
+ the panel is built straight into the plot's criteria header (for the
39
+ plot's first tracked variable, its rows opening under the header), the
40
+ criterion's editor, beside a tree node as that node's controls, or at
41
+ the end of a Statistics row no criterion reads yet (the selection's
42
+ ``quantity`` is the row's), with no button and no heading.
43
+ STATISTIC_CRITERION is the editor of a criterion scored through a
44
+ statistic, under its Statistics row: CRITERION's with no plot, so a
45
+ capability that needs one is not offered there. TREE_CRITERION is the
46
+ editor of any criterion opened under its row in the criteria tree
47
+ (U5): CRITERION's with no plot, like STATISTIC_CRITERION — the plot's
48
+ own editor is a link away. FAMILY_CRITERION is the editor of a folded
49
+ criteria row (D152): one criterion on several elements of an array,
50
+ every one of them named by the selection's ``leaf_paths`` (``leaf_path``
51
+ the first) — CRITERION's with no plot again, since nothing is dragged
52
+ for twenty criteria at once, so a capability that edits ONE leaf is not
53
+ offered there. At VARIABLE and
54
+ CRITERION ``Panel.figure`` is the plot's hook, subscribed for as long as
55
+ the panel is drawn; elsewhere a subscription raises."""
56
+
57
+ SUITE = "suite"
58
+ TEST = "test"
59
+ VARIABLE = "variable"
60
+ CRITERION = "criterion"
61
+ NODE = "node"
62
+ STATISTIC = "statistic"
63
+ STATISTIC_CRITERION = "statistic-criterion"
64
+ TREE_CRITERION = "tree-criterion"
65
+ FAMILY_CRITERION = "family-criterion"
66
+
67
+
68
+ class Emphasis(Enum):
69
+ """How a bar that is told the page's situation draws a capability
70
+ (``Capability.emphasis``): NEXT is the one next action, drawn filled;
71
+ MENU goes into the bar's ⋮ menu. A situation a capability does not
72
+ name draws it quiet."""
73
+
74
+ NEXT = "next"
75
+ MENU = "menu"
76
+
77
+
78
+ def plural(count: int, noun: str, many: str | None = None) -> str:
79
+ """``"2 tests"``, ``"1 test"``: the count and its noun for a caption. A
80
+ regular plural adds ``s``; ``many`` spells one that does not
81
+ (``plural(2, "new entry", "new entries")``)."""
82
+ if count == 1:
83
+ return f"{count} {noun}"
84
+ return f"{count} {many}" if many is not None else f"{count} {noun}s"
85
+
86
+
87
+ @dataclass(frozen=True, kw_only=True)
88
+ class Patch:
89
+ """Ops to stage; the Session rescores their models."""
90
+
91
+ ops: list[PatchOp]
92
+
93
+
94
+ @dataclass(frozen=True, kw_only=True)
95
+ class StoreWrite:
96
+ """A write against the reference store; results reload afterwards.
97
+ ``models`` names what it touches: a queued or running one refuses it.
98
+
99
+ ``run`` returns the one line the queue panel shows — what the write did,
100
+ including what it refused and why — or ``None`` when there is nothing
101
+ to say. A partial write has no other way to report its skips, and
102
+ raising after some baselines were stored would mislead."""
103
+
104
+ run: Callable[[ReferenceStore], str | None]
105
+ models: frozenset[str]
106
+
107
+
108
+ @dataclass(frozen=True, kw_only=True)
109
+ class Enqueue:
110
+ """Tests for the simulation queue (D150), in the order to run them. The
111
+ Session appends them — a test already queued or running is skipped —
112
+ and each result is read back and rescored as its test ends. A queued
113
+ test runs with the spec as it stands when it STARTS, pending edits
114
+ applied."""
115
+
116
+ models: tuple[str, ...]
117
+
118
+
119
+ @dataclass(frozen=True, kw_only=True)
120
+ class Artifact:
121
+ """Something written outside the spec and the store; shown as a link."""
122
+
123
+ paths: list[Path]
124
+ label: str
125
+
126
+
127
+ @dataclass(frozen=True, kw_only=True)
128
+ class OpenProject:
129
+ config_path: Path
130
+ work_dir: Path | None = None # the run's work dir, when not the default
131
+
132
+
133
+ Outcome = Patch | StoreWrite | Enqueue | Artifact | OpenProject | None
134
+
135
+
136
+ class FigureHook(Protocol):
137
+ """ONE plot card's figure, as a capability may subscribe to it. There is
138
+ a hook per card — the plot a VARIABLE panel (its criteria header) or a
139
+ CRITERION editor sits under — not one figure for the page.
140
+
141
+ ``on(kind, handler)`` calls ``handler(primitive_id, payload)``:
142
+
143
+ - ``plotly_click`` / ``plotly_selected``: the id of the first point whose
144
+ trace ``meta.kind`` is ``"handle"``, else of the first point
145
+ (``customdata``, then ``meta.id``, then ``curveNumber``), with the event
146
+ payload — under x-unified hover a click lists every trace at that x.
147
+ - ``plotly_relayout`` moving shapes: once PER SHAPE, the payload being
148
+ that shape's keys without their ``shapes[N].`` prefix
149
+ (``{"y0": 6.0, "y1": 6.0}`` for a line; ``{"xanchor": 4.0, "yanchor":
150
+ 2.99, "path": "M …"}`` for a ring), in data coordinates.
151
+ Plotly's payload never names the shape; the shell resolves the id from
152
+ the rendered figure's ``layout.shapes[N].name``, ``""`` when it cannot.
153
+ The shell does not interpret positions: the capability does, from the
154
+ handle's primitive type (a Marker's ring moves only by
155
+ ``xanchor``/``yanchor``).
156
+ A relayout mixing shape keys with others is a shape relayout.
157
+ - Any other relayout (a zoom): once, with id ``""`` and the raw payload.
158
+ - ``dstf_move`` (D149): a handle HELD on the plot, at most about 15
159
+ times a second — once per shape, with the payload its release would
160
+ carry (so a capability reads both with one function) plus ``drag``,
161
+ the number of the drag it is of; or once with id ``""`` and
162
+ ``{"end": True}`` for a drag that ended without a release. A move is
163
+ a preview: nothing is to be staged from it. One that arrives after
164
+ its drag's release is dropped by the shell.
165
+ - ``dstf_click`` (D149): a click on the plot that is no drag, no
166
+ double-click and on no draggable shape — id ``""`` and ``{"xaxis",
167
+ "yaxis", "x", "y", "xrange", "yrange", "ctrl", "shift", "alt",
168
+ "meta"}``: the axes of the panel under the pointer, the position in
169
+ them and what they showed.
170
+ - A release's and a move's per-shape payload also carries ``visible``,
171
+ ``{"x": span, "y": span}``: what the shape's axes showed at the
172
+ press, which a placed value is snapped by.
173
+
174
+ Handler semantics: each shape index is dispatched in turn, ascending,
175
+ from its own snapshot of the handlers, taken when that shape's dispatch
176
+ starts. A handler registered since the snapshot is not called for that
177
+ shape; one removed since the snapshot (its panel rebuilt) is skipped,
178
+ checked by identity. A handler registered twice is called twice. A
179
+ handler that raises ENDS the relayout: later handlers and later shapes
180
+ are not dispatched, the exception propagates, and the shell still
181
+ re-sends the figure so the plot snaps back."""
182
+
183
+ def on(self, kind: str, handler: Callable[[str, dict], None]) -> None: ...
184
+
185
+
186
+ class Panel(Protocol):
187
+ """A container the capability owns entirely (the shell's
188
+ ``ShellPanel``). ``figure`` is the plot the panel is drawn beside, as a
189
+ capability subscribes to it — what Edit on plot reads its drags from.
190
+
191
+ ``kept`` is a dict of the panel's own that OUTLIVES the panel: what its
192
+ controls show that the spec does not hold — a required cell cleared to
193
+ be retyped, a tolerance with one side typed — and that a rebuild from
194
+ the spec alone would lose. A panel is rebuilt by edits it did not make
195
+ (a drag on the plot, another panel's submit), and is never told: it
196
+ writes there as its controls change and reads it back when it is
197
+ built. The host hands the same dict to every build of this capability's
198
+ panel for the same node of the same test, re-keys it when a tree edit
199
+ moves the node, and drops it — the panel is then rebuilt on an empty
200
+ one — with the node, and when an edit that is not this tab's lands on
201
+ the test: a Discard, a Save, another tab's. The record is the browser
202
+ tab's: it outlives the page. The criterion editor under a plot keeps
203
+ one; every other host — a dialog, a row, the criterion editors under a
204
+ tree row and under a Statistics row — hands a fresh dict to every
205
+ build (`tests/test_capability_conformance.py` holds both)."""
206
+
207
+ figure: FigureHook
208
+ kept: dict[Any, Any]
209
+
210
+ @property
211
+ def can_place(self) -> bool:
212
+ """Whether this panel is beside a plot whose clicks it may take
213
+ (D149): the criterion editor under a plot, and no other host."""
214
+ ...
215
+
216
+ def place_button(self, pointer: Hashable, label: str) -> None:
217
+ """Draw a ⌖ for what this panel calls ``pointer`` — into the
218
+ current slot; a number field's ``append`` slot is the place — that
219
+ ARMS it (D149): the next plain click on the plot is handed to
220
+ `on_place` with that ``pointer``. ``label`` is what the hint line
221
+ says is being set. Draw one only where a click can set the thing in
222
+ the state it is in. The builtins name a field by its keys into the
223
+ leaf (``("tolerance", "upper")``) and a table's row by its table
224
+ and index (``("points", 3)``). One is armed on a page at a time;
225
+ pressing ⌖ again, Esc, closing the editor, `disarm` and any edit of
226
+ the test disarm. Draws nothing where `can_place` is false."""
227
+ ...
228
+
229
+ def disarm(self) -> None:
230
+ """Nothing of this editor is armed any more: for a panel to call
231
+ when what a ⌖ named has moved under it without an edit saying so —
232
+ a table's row added or removed, its fields swapped for another
233
+ mode's. A no-op where `can_place` is false."""
234
+ ...
235
+
236
+ def on_place(self, handler: Callable[[Hashable, dict], bool]) -> None:
237
+ """``handler(pointer, click)`` is called when the plot is clicked
238
+ while a field THIS panel drew a ⌖ for is armed — ``click`` is the
239
+ ``dstf_click`` payload with ``label``, the armed ⌖'s, beside it. It
240
+ returns whether the click went through
241
+ (`submit`'s answer): then the field is disarmed; refused, it stays
242
+ armed, and the refusal is said under the plot. Registers nothing
243
+ where the panel is beside no plot."""
244
+ ...
245
+
246
+ def preview(self, leaf: dict | None, revision: int = 0) -> None:
247
+ """Hand every panel of this editor the leaf a handle held on the
248
+ plot WOULD write (D149), or None when it is over — each shows it in
249
+ its own fields (`on_preview`), and nothing is staged. ``revision``
250
+ is the drag's number, the same for every move of one drag. A no-op
251
+ where the panel is beside no plot."""
252
+ ...
253
+
254
+ def on_preview(self, handler: Callable[[dict | None, int], None]) -> None:
255
+ """``handler(leaf, revision)`` is called with what any panel of
256
+ this editor handed `preview`. Registers nothing where the panel is
257
+ beside no plot."""
258
+ ...
259
+
260
+ def submit(self, **inputs: Any) -> bool:
261
+ """Hand the capability's inputs to the shell, which calls ``act`` and
262
+ applies the outcome; an exception shows with the panel — above an
263
+ inline one, in the dialog of a placement that is a button. Returns
264
+ whether it went through: ``False`` when ``act`` or the apply
265
+ raised. A form row closes on ``True`` and only then — refused, it
266
+ stays open with what was typed, and the refusal shows under the
267
+ node's row, above the form."""
268
+ ...
269
+
270
+ def row(self, title: str, build: Callable[[Callable[[], None]], None]) -> None:
271
+ """Open a form row below the row this panel is drawn in, titled
272
+ ``title``. ``build(close)`` draws its content — fields, a sentence,
273
+ its buttons; ``close`` takes the row away — after a ``submit`` that
274
+ went through, never before it (a refused act keeps the row and
275
+ what was typed in it). ONE thing is open at a time on a page: this
276
+ closes any other row and the criterion editor. While it is open,
277
+ ``build`` is called AGAIN, into an emptied row, when an edit staged
278
+ elsewhere moves the node the panel is for — a form never shows a
279
+ value the node no longer has — and it closes with the host, when
280
+ its row is hidden, and when its node is gone. In a dialog (a
281
+ placement that is a button) the form is drawn in the dialog, below
282
+ the panel. A panel with no row to open one under — the criterion
283
+ editor — raises."""
284
+ ...
285
+
286
+ def retitle(self, title: str) -> None:
287
+ """Say the title of the form row this panel opened again — a form
288
+ whose subject follows a choice made in it (the add form's `Add a
289
+ criterion for pipe.T[1:20]` follows its Indices). The row keeps it
290
+ when the page builds it again. A no-op while no row of this
291
+ panel's is open, and where a form is drawn in a dialog."""
292
+ ...
293
+
294
+ def lead(self, build: Callable[[], None]) -> None:
295
+ """Draw ``build()`` in the node's lead place — beside its status,
296
+ where its kind is read (a combinator's kind dropdown). The place is
297
+ emptied first, and with the panel; while it holds something the
298
+ row's text leaves the kind out. A row without one (a leaf's, a
299
+ criteria row's) raises, like ``row`` where there is no row."""
300
+ ...
301
+
302
+ def reveal(self, leaf_path: str) -> None:
303
+ """Show that criterion's editor once the page has drawn it — after
304
+ an add, the new criterion. A no-op where no page reveals criteria:
305
+ a dialog, the criterion editor."""
306
+ ...
307
+
308
+ @property
309
+ def can_move(self) -> bool:
310
+ """Whether the host offers moving the node this panel is drawn for
311
+ (U4, V2): a tree card's row, and nowhere else — a plot's criteria
312
+ row hosts the same NODE placement and moves nothing."""
313
+ ...
314
+
315
+ def movable(
316
+ self,
317
+ on_drop: Callable[[str, str], None],
318
+ zones: Callable[[], dict[str, dict[str, str | None]]] | None = None,
319
+ ) -> bool:
320
+ """Take part in the host's drag (U4) where it offers moving
321
+ (`can_move`); elsewhere register nothing and return False.
322
+ ``on_drop(src_path, zone)`` is called when a node is dropped on
323
+ this panel's row — ``zone`` is ``before`` (the row's top half) or
324
+ ``onto`` (its bottom half; the root's whole row). ``zones`` makes
325
+ the row draggable: called once at the start of a drag from it, it
326
+ answers ``{row path: {zone: reason}}`` — ``None`` allowed, ``""``
327
+ where the node already is, else the sentence refusing it — which
328
+ the page draws over every row while dragging, with no round trip
329
+ to the server."""
330
+ ...
331
+
332
+ def on_click(self, handler: Callable[[], None]) -> bool:
333
+ """What a click on the row this panel is drawn in does (U5, Q6):
334
+ ``handler()``, called when the row itself is clicked — or, from the
335
+ keyboard, Enter or Space on it — never a control inside it. Offered
336
+ by the tree card's GROUP rows (a combinator's) and nowhere else
337
+ (`can_move`'s rule): a criterion's row — a plot's criteria row or
338
+ the tree's — has a click of its own, its editor. Elsewhere it
339
+ registers nothing and returns False. One per row; a later call
340
+ replaces it, and it goes with the panel."""
341
+ ...
342
+
343
+ def moved(self, node_path: str, text: str, paths: dict[str, str]) -> None:
344
+ """A move just went through (U4, V4): every open editor and form row
345
+ on the page closes NOW — two criteria on one variable trading
346
+ places move no shape, and an editor left open would show the other
347
+ one — and once the tree is redrawn ``node_path``'s row is brought
348
+ into view with ``text`` as one line under it, until the next form,
349
+ editor or edit. ``paths`` is every node the move kept, its path
350
+ before -> after (`Moved.paths`): what the page keys by a node's
351
+ path follows it, as for `edited`. A no-op where no page shows the
352
+ tree."""
353
+ ...
354
+
355
+ def edited(self, paths: dict[str, str]) -> None:
356
+ """Any other edit of the tree just went through: ``paths`` is every
357
+ node it kept, its path before -> after (`tree_move.path_map`) — an
358
+ add or a remove shifts the siblings after it, a wrap pushes its node
359
+ one level down, an unwrap lifts its children. What the page keys by
360
+ a node's path follows it (the tree card's folded groups, U5 R1);
361
+ a node not in it is gone. A no-op where no page shows the tree."""
362
+ ...
363
+
364
+
365
+ @runtime_checkable
366
+ class Capability(Protocol):
367
+ """A plug-in the shell draws at its placements. It MAY also declare
368
+ ``icon`` (a Material icon name): a bar that draws icons (the Suite
369
+ toolbar) draws it as that icon, its caption the tooltip; one without
370
+ keeps its text button. Not a protocol member — a runtime-checkable
371
+ protocol requires every member, and most capabilities have no icon —
372
+ so the shell reads it with ``getattr``.
373
+
374
+ It MAY also declare ``emphasis``, read the same way: a mapping from a
375
+ :class:`~dstf.authoring.situation.Situation` to :attr:`Emphasis.NEXT`
376
+ — drawn filled, the page's one next action — or :attr:`Emphasis.MENU`
377
+ — drawn in the bar's ⋮ menu; a situation it does not name draws it
378
+ quiet. Only a bar the page tells its situation reads it (the Test
379
+ page's status strip); every other bar draws today's outlined button."""
380
+
381
+ name: str
382
+ label: str
383
+ placements: frozenset[Placement]
384
+
385
+ def available(self, s: SessionView) -> bool | str:
386
+ """``True``, or ``False`` / the sentence saying why it is unavailable
387
+ and what would enable it ("Accept needs a selection", "FakeLib.A is
388
+ already queued"). The greyed button carries it where the
389
+ placement draws one; at CRITERION, NODE, STATISTIC and
390
+ STATISTIC_CRITERION it is shown in the panel's place."""
391
+ ...
392
+
393
+ def caption(self, s: SessionView) -> str:
394
+ """The button's label: its object, its effect and a count
395
+ ("Accept 2 results as baselines"). At zero it still says what it
396
+ would do; ``available`` then carries the sentence. The inline
397
+ placements (CRITERION, NODE, STATISTIC, STATISTIC_CRITERION) draw
398
+ no button and never read it."""
399
+ ...
400
+
401
+ def panel(self, s: SessionView, ui: Panel) -> None: ...
402
+
403
+ def act(self, s: SessionView, **inputs: Any) -> Outcome | Sequence[Outcome]:
404
+ """What this capability's inputs DO, as an outcome the Session
405
+ applies — a Patch, a StoreWrite, an Enqueue, an Artifact, an OpenProject,
406
+ or a sequence of them.
407
+
408
+ Returning ``None`` means nothing happened: the host applies
409
+ nothing, leaves a dialog open (there is nothing to close over) and
410
+ rebuilds no panel. It is a real answer — every plot sends
411
+ ``{"autosize": true}`` at mount, and a subscribing panel answers
412
+ that with None — but it is a silent one, so a capability that
413
+ returns None on purpose says why in its own panel."""
414
+ ...
415
+
416
+
417
+ _CAPABILITIES: dict[str, Capability] = {}
418
+
419
+
420
+ def register_capability(cap: Capability) -> Capability:
421
+ """Register ``cap`` under its name; returns it. Loud on a duplicate —
422
+ two capabilities sharing a name would make the shell's button order
423
+ depend on import order — and on a shape the shell cannot draw."""
424
+ if not isinstance(cap, Capability):
425
+ missing = [
426
+ a
427
+ for a in (
428
+ "name",
429
+ "label",
430
+ "placements",
431
+ "available",
432
+ "caption",
433
+ "panel",
434
+ "act",
435
+ )
436
+ if not hasattr(cap, a)
437
+ ]
438
+ raise TypeError(
439
+ f"{cap!r} is not a Capability: missing {', '.join(missing) or 'a method'}"
440
+ )
441
+ placements = cap.placements
442
+ if (
443
+ not isinstance(placements, frozenset)
444
+ or not placements
445
+ or not all(isinstance(p, Placement) for p in placements)
446
+ ):
447
+ raise TypeError(
448
+ f"capability {cap.name!r}: placements must be a non-empty frozenset of "
449
+ f"Placement, got {placements!r}"
450
+ )
451
+ if cap.name in _CAPABILITIES:
452
+ raise ValueError(f"capability {cap.name!r} is already registered")
453
+ _CAPABILITIES[cap.name] = cap
454
+ return cap
455
+
456
+
457
+ def registered_capabilities() -> tuple[Capability, ...]:
458
+ """Every registered capability, in registration order."""
459
+ return tuple(_CAPABILITIES.values())
460
+
461
+
462
+ def _reset_for_tests() -> None:
463
+ _CAPABILITIES.clear()
@@ -0,0 +1,49 @@
1
+ """What the tool says when an action fails.
2
+
3
+ The errors a person can cause — a patch the validator refuses, a drag a
4
+ criterion cannot take, a move the tree refuses, a value out of range, a
5
+ locked spec, a test the queue holds — read as their plain reason: the location is
6
+ already on the screen, beside the row or the plot it came from. The
7
+ validator's refusal of a tree of warnings read "PatchError: validator
8
+ rejected the patched tree: metrics: tree has no leaf targeting 'primary'
9
+ ..."; it now reads the sentence alone.
10
+ Anything else is a bug and keeps its class name, so it can be reported.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from ..backends.pool import Refused as PoolRefused
16
+ from ..comparison.handles import HandleEditError
17
+ from ..config import ConfigError
18
+ from ..discovery.patch_apply import PatchError
19
+ from .session import QueueError, StaleSpecError
20
+ from .tree_move import MoveRefused
21
+ from .unscorable import Refused as EditorRefused
22
+
23
+ #: What a person's own edit raises. Shown as its reason and never logged.
24
+ USER_ERRORS: tuple[type[BaseException], ...] = (
25
+ PatchError,
26
+ HandleEditError,
27
+ ConfigError,
28
+ # The simulation queue's refusals: the Session's and the pool's own.
29
+ QueueError,
30
+ PoolRefused,
31
+ # A move the tree refuses — a Move to… menu or a drag map gone stale
32
+ # under an edit staged since offers one — is said, not a bug.
33
+ MoveRefused,
34
+ # A value an editor will not stage: typed on the way to a good one.
35
+ EditorRefused,
36
+ # The spec file changed on disk under the tool: a write from the
37
+ # loaded one is refused, with the way out in the sentence.
38
+ StaleSpecError,
39
+ )
40
+
41
+
42
+ def user_text(e: BaseException) -> str:
43
+ """``e`` as the tool shows it: its ``reason`` (else its text) for a
44
+ person's own error or a ValueError, the class name first for anything
45
+ else. A ValueError also covers a JSONDecodeError or a numpy shape error
46
+ from a bug, so a caller that shows one also logs it (see `USER_ERRORS`)."""
47
+ if isinstance(e, (*USER_ERRORS, ValueError)):
48
+ return str(getattr(e, "reason", None) or e)
49
+ return f"{type(e).__name__}: {e}"