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,702 @@
1
+ """Capabilities at a placement (brief §3.2; DESIGN.md "Buttons", "Disabled
2
+ states explain themselves"). `ActionBar` hosts SUITE and TEST: one button
3
+ per registered capability whose `placements` names this one, labelled by
4
+ the capability's `caption`, disabled with the sentence `available` gave
5
+ beside it; a click opens the bar's dialog hosting the capability's
6
+ `panel`, whose `submit` runs `act` and hands the outcome to the Session.
7
+ The bar knows no capability by name. The dialog is not rebuilt while open
8
+ (decision 4): a confirm list belongs to the moment of the click; an act
9
+ that applied something closes it, a raising one shows the error in it and
10
+ changes nothing, and one that returned nothing leaves it open — it changed
11
+ nothing to close over. There is no plot at either placement: a dialog's
12
+ `panel.figure` raises on a subscription.
13
+
14
+ `InlineHost` hosts the same contract the other way (brief §3.2): no button
15
+ and no dialog — every capability placed here has its panel built straight
16
+ into a slot the page owns, which is how the criterion editor, a tree
17
+ node's controls and a plot's criteria header (VARIABLE) are drawn without
18
+ the shell knowing a capability by name.
19
+ A host given a `RowTarget` lends its panels the page's form row under the
20
+ row it is drawn in (`Panel.row`) and the page's `reveal`; a bar's dialog
21
+ draws a panel's row inside the dialog, below the panel."""
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from collections.abc import Callable, Hashable, Sequence
27
+ from functools import partial
28
+ from typing import TYPE_CHECKING, Any
29
+
30
+ from nicegui import ui
31
+
32
+ from ..capabilities import Capability, Emphasis, Outcome, Placement
33
+ from ..capabilities import FigureHook as FigureHookProtocol
34
+ from ..errors import USER_ERRORS, user_text
35
+ from ..session import Selection, StaleSpecError
36
+ from .figure_hook import FigureHook
37
+ from .placing import PLACE, PREVIEW, Placing
38
+ from .widgets import Action, IconAction, MenuAction
39
+
40
+ if TYPE_CHECKING:
41
+ from .shell import Shell
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ LOADING = "batch actions unlock when loading finishes"
46
+
47
+
48
+ def log_unless_users(e: BaseException, name: str) -> None:
49
+ """Log a failed act unless a person's own edit raised it: `user_text`
50
+ shows a ValueError's text alone, and a JSONDecodeError or a numpy shape
51
+ error from a bug is one — it must still reach the log. A value an
52
+ editor REFUSES is a person's own (`unscorable.Refused`)."""
53
+ if not isinstance(e, USER_ERRORS):
54
+ logger.exception("%s failed", name)
55
+
56
+
57
+ #: A form row's content: ``build(close)`` draws it; ``close`` takes it away.
58
+ RowBuild = Callable[[Callable[[], None]], None]
59
+
60
+
61
+ class _NoFigure:
62
+ """`Panel.figure` where there is no plot (SUITE, TEST, a tree row): a
63
+ subscription here is a contract misuse, and loud (decision 3)."""
64
+
65
+ def on(self, kind: str, handler: Callable[[str, dict], None]) -> None:
66
+ raise RuntimeError(f"no figure at this placement to subscribe {kind!r} to")
67
+
68
+
69
+ class ShellPanel:
70
+ """The container a capability owns (§4.6 "Panel"): the dialog's slot,
71
+ the figure hook, and `submit` — the one way an outcome reaches the
72
+ Session."""
73
+
74
+ figure: FigureHookProtocol
75
+
76
+ def __init__(
77
+ self,
78
+ submit: Callable[..., bool],
79
+ figure: FigureHookProtocol,
80
+ row: Callable[[str, RowBuild], None] | None = None,
81
+ reveal: Callable[[str], None] | None = None,
82
+ lead: ui.element | None = None,
83
+ target: RowTarget | None = None,
84
+ fail: Callable[[BaseException], None] | None = None,
85
+ kept: dict[Any, Any] | None = None,
86
+ placing: Placing | None = None,
87
+ name: str = "",
88
+ ) -> None:
89
+ self._submit, self.figure = submit, figure
90
+ #: The plot's armed field and ⌖ buttons, where the panel is beside
91
+ #: a plot (the criterion editor under one), and the capability the
92
+ #: panel is — a ⌖ is its panel's.
93
+ self._placing, self._name = placing, name
94
+ #: `Panel.kept`: the host's, where it keeps one; else this build's.
95
+ self.kept: dict[Any, Any] = {} if kept is None else kept
96
+ #: Says a refusal the way a failed `submit` does (a drop refused
97
+ #: before it reaches the act); None where nothing is said.
98
+ self._fail = fail
99
+ self._row, self._reveal, self._lead = row, reveal, lead
100
+ #: The row this panel is drawn in, where there is one.
101
+ self._target = target
102
+
103
+ @property
104
+ def can_move(self) -> bool:
105
+ return self._target is not None and self._target.drag
106
+
107
+ def movable(
108
+ self,
109
+ on_drop: Callable[[str, str], None],
110
+ zones: Callable[[], dict[str, dict[str, str | None]]] | None = None,
111
+ ) -> bool:
112
+ target = self._target
113
+ if target is None or not target.drag:
114
+ return False
115
+ target.on_drop, target.zones = on_drop, zones
116
+ if self._fail is not None:
117
+ target.refuse = self._fail
118
+ if zones is not None and target.grip is not None:
119
+ target.grip.classes(remove="dstf-grip-off")
120
+ return True
121
+
122
+ def on_click(self, handler: Callable[[], None]) -> bool:
123
+ # The tree card's group rows are clicked (U5, Q6); a criterion's
124
+ # row, on a plot or in the tree, has a click of its own — its
125
+ # editor — and takes no handler.
126
+ target = self._target
127
+ if target is None or not target.clickable:
128
+ return False
129
+ target.click = handler
130
+ return True
131
+
132
+ def moved(self, node_path: str, text: str, paths: dict[str, str]) -> None:
133
+ # Quiet where there is no page (a dialog): the contract's no-op.
134
+ if self._target is not None:
135
+ self._target.page.moved(node_path, text, paths)
136
+
137
+ def edited(self, paths: dict[str, str]) -> None:
138
+ # Quiet where there is no page (a dialog): the contract's no-op.
139
+ if self._target is not None:
140
+ self._target.page.edited(paths)
141
+
142
+ def submit(self, **inputs: Any) -> bool:
143
+ return self._submit(**inputs)
144
+
145
+ # Quiet where there is no plot (a dialog, a tree row, a Statistics
146
+ # row): the contract's no-ops.
147
+ @property
148
+ def can_place(self) -> bool:
149
+ return self._placing is not None
150
+
151
+ def place_button(self, pointer: Hashable, label: str) -> None:
152
+ if self._placing is not None:
153
+ self._placing.button(self._name, pointer, label)
154
+
155
+ def disarm(self) -> None:
156
+ if self._placing is not None:
157
+ self._placing.disarm()
158
+
159
+ def on_place(self, handler: Callable[[Hashable, dict], bool]) -> None:
160
+ placing, name = self._placing, self._name
161
+ if placing is None:
162
+ return
163
+
164
+ def placed(_primitive: str, click: dict) -> None:
165
+ armed = placing.armed
166
+ if armed is None or armed.owner != name:
167
+ return # another panel's field, or none
168
+ if handler(armed.pointer, {**click, "label": armed.label}):
169
+ placing.disarm()
170
+
171
+ self.figure.on(PLACE, placed)
172
+
173
+ def preview(self, leaf: dict | None, revision: int = 0) -> None:
174
+ if self._placing is not None:
175
+ said = {"leaf": leaf, "revision": revision}
176
+ self._placing.hook.emit(PREVIEW, said)
177
+
178
+ def on_preview(self, handler: Callable[[dict | None, int], None]) -> None:
179
+ if self._placing is not None:
180
+ self.figure.on(
181
+ PREVIEW,
182
+ lambda _primitive, said: handler(said["leaf"], said["revision"]),
183
+ )
184
+
185
+ def row(self, title: str, build: RowBuild) -> None:
186
+ # Loud, like `_NoFigure`: a placement that draws no row asked for
187
+ # one is a contract misuse, not a form to drop silently.
188
+ if self._row is None:
189
+ raise RuntimeError("no row at this placement")
190
+ self._row(title, build)
191
+
192
+ def retitle(self, title: str) -> None:
193
+ # Quiet where there is no page's row (a dialog): the contract's no-op.
194
+ if self._target is not None:
195
+ self._target.page.retitle(self._target, title)
196
+
197
+ def lead(self, build: Callable[[], None]) -> None:
198
+ # Loud, like `row`: a placement with no lead place asked for one.
199
+ if self._lead is None:
200
+ raise RuntimeError("no lead place at this placement")
201
+ self._lead.clear()
202
+ with self._lead:
203
+ build()
204
+
205
+ def reveal(self, leaf_path: str) -> None:
206
+ # Quiet where there is no page (a dialog): the contract's no-op.
207
+ if self._reveal is not None:
208
+ self._reveal(leaf_path)
209
+
210
+
211
+ class RowTarget:
212
+ """Where an InlineHost's panels open their rows: a slot the page owns
213
+ under one row, and the page's one-open-at-a-time rule. ``key`` names the
214
+ slot page-wide (``tree:<path>``, ``crit:<index>:<path>`` or
215
+ ``plot:<index>``); ``node`` is the node path the forms act on — what the
216
+ page watches to rebuild an open row. ``index`` is the plot the row is
217
+ on (None for a tree row): the plot a criterion revealed from this row
218
+ opens on, when that plot holds it."""
219
+
220
+ def __init__(
221
+ self, page: Any, key: str, row: ui.row, node: str, index: int | None
222
+ ) -> None:
223
+ self.page, self.key, self.row, self.node = page, key, row, node
224
+ self.index = index
225
+ #: The slot under the row, set by the row's owner once it exists:
226
+ #: the host is built INSIDE the row, before the slot below it is.
227
+ self.slot: ui.column | None = None
228
+ #: Hides the errors the host drawn in this row shows; set by that
229
+ #: host. The page calls it when a form row opens or closes under
230
+ #: THIS row, and for no other row's.
231
+ self.hush: Callable[[], None] = lambda: None
232
+ #: The row's lead place, beside its status, where a panel draws
233
+ #: what the node's kind is read from (`Panel.lead`); None in a row
234
+ #: that has none (a criteria row). Emptied with the host.
235
+ self.lead: ui.element | None = None
236
+ #: Whether a node drawn in this row may be moved from it (U4, V2):
237
+ #: set by the tree card, and by no other row's owner.
238
+ self.drag = False
239
+ #: The row's drag handle (the tree card's, hidden until a panel
240
+ #: makes the row draggable; None on the root's), and what a panel
241
+ #: registered through `Panel.movable`: a drop on the row, the zones
242
+ #: of a drag from it.
243
+ self.grip: ui.element | None = None
244
+ self.on_drop: Callable[[str, str], None] | None = None
245
+ self.zones: Callable[[], dict[str, dict[str, str | None]]] | None = None
246
+ #: Says a drop refused before it reached the panel's act, the way
247
+ #: that panel says its own refusals (`TreeCard.dropped`). Set with
248
+ #: `on_drop`, by `Panel.movable` — every panel in a row has a way
249
+ #: to say a refusal (`InlineHost`) — and read only where `on_drop`
250
+ #: is set: no default, which nothing could reach.
251
+ self.refuse: Callable[[BaseException], None]
252
+ #: Whether a click on this row is the panels' to say (U5, Q6): set
253
+ #: by the tree card on a group's row, and by no other row's owner. What a panel
254
+ #: registered for it (`Panel.on_click`), or None.
255
+ self.clickable = False
256
+ self.click: Callable[[], None] | None = None
257
+
258
+ def open(self, title: str, build: RowBuild) -> None:
259
+ self.page.open_row(self, title, build)
260
+
261
+
262
+ class ActionBar:
263
+ """The bar and its one dialog, at SUITE or TEST."""
264
+
265
+ def __init__(
266
+ self,
267
+ shell: Shell,
268
+ placement: Placement,
269
+ selection_of: Callable[[], Selection],
270
+ *,
271
+ marker: str,
272
+ icons: bool = False,
273
+ emphasis: Callable[[Capability], Emphasis | None] | None = None,
274
+ apply: Callable[[Outcome | Sequence[Outcome]], None] | None = None,
275
+ stale: Callable[[StaleSpecError], None] | None = None,
276
+ ) -> None:
277
+ self.shell, self.placement, self.selection_of = shell, placement, selection_of
278
+ self.marker = marker
279
+ #: Where an act refused for a spec changed on disk is said: the
280
+ #: page's header (`Header.stale`), the dialog Save's own refusal
281
+ #: uses — with Reload in it — in place of a line in this bar's.
282
+ self.stale = stale
283
+ #: How an act's outcome is applied: the Session's own, unless the
284
+ #: page the bar is on applies it (`TestPage.apply`, which notes the
285
+ #: `spec` revisions its own acts produced).
286
+ self.apply = apply
287
+ #: Draw each capability that declares an ``icon`` (a Material icon
288
+ #: name) as an icon button; one that declares none keeps its text.
289
+ self.icons = icons
290
+ #: How the page's situation draws each capability: NEXT filled,
291
+ #: MENU in the ⋮ menu, None quiet. Without it every button is
292
+ #: today's outlined one.
293
+ self.emphasis = emphasis
294
+ self.errors: dict[
295
+ str, str
296
+ ] = {} # capability name -> the error its dialog shows
297
+ self.actions: dict[str, Action | IconAction | MenuAction] = {}
298
+ #: What the buttons were built from: each placed capability's name
299
+ #: and its emphasis. A move in either rebuilds them.
300
+ self.built: list[tuple[str, Emphasis | None]] = []
301
+ self.dialog = ui.dialog() # one element, rebuilt per open
302
+ self.container = (
303
+ ui.row().classes("dstf-actions items-center gap-3 wrap").mark(marker)
304
+ )
305
+ self.refresh()
306
+
307
+ def clear(self) -> None:
308
+ """No buttons, and none built from: the next `refresh` draws them
309
+ afresh (a page whose test has gone)."""
310
+ self.dialog.close()
311
+ self.container.clear()
312
+ self.actions, self.built = {}, []
313
+
314
+ def refresh(self) -> None:
315
+ """Captions and availability from the current selection; the buttons
316
+ are rebuilt only when the capabilities placed here, or the emphasis
317
+ the page's situation gives them, changed."""
318
+ view = self.shell.view_for(self.selection_of())
319
+ placed = self.shell.placed(self.placement, view)
320
+ emphasis = self.emphasis
321
+ built = [
322
+ (cap.name, emphasis(cap) if emphasis is not None else None)
323
+ for cap, _ in placed
324
+ ]
325
+ if built != self.built:
326
+ self._build([cap for cap, _ in placed], [e for _, e in built])
327
+ self.built = built
328
+ loading = self.shell.load.in_progress
329
+ for cap, avail in placed:
330
+ if loading:
331
+ reason: str | None = LOADING
332
+ elif avail is True:
333
+ reason = None
334
+ else:
335
+ reason = avail if isinstance(avail, str) else "not available"
336
+ try:
337
+ caption = cap.caption(view)
338
+ except Exception as e: # the label is the fallback; the page draws
339
+ caption = cap.label
340
+ self.shell.report_once(cap.name, "caption", e)
341
+ self.actions[cap.name].set(caption, reason)
342
+
343
+ def _build(self, caps: list[Capability], emphases: list[Emphasis | None]) -> None:
344
+ self.container.clear()
345
+ self.actions = {}
346
+ menu = [cap for cap, e in zip(caps, emphases) if e is Emphasis.MENU]
347
+ with self.container:
348
+ for cap, e in zip(caps, emphases):
349
+ if e is Emphasis.MENU:
350
+ continue
351
+ marker = f"action:{cap.name}"
352
+ icon = getattr(cap, "icon", None) if self.icons else None
353
+ if icon is not None:
354
+ self.actions[cap.name] = IconAction(
355
+ partial(self.open, cap), icon=icon, marker=marker
356
+ )
357
+ continue
358
+ if self.emphasis is None:
359
+ props = "outline dense no-caps"
360
+ elif e is Emphasis.NEXT:
361
+ props = "unelevated dense no-caps color=primary"
362
+ else:
363
+ props = "flat dense no-caps"
364
+ self.actions[cap.name] = Action(
365
+ partial(self.open, cap), props=props, marker=marker
366
+ )
367
+ if menu:
368
+ # Named, with no tooltip: a tooltip covered the open menu's
369
+ # first item. It says whether its menu is open, as the
370
+ # header's ⋮ does.
371
+ more = (
372
+ ui.button(icon="more_vert")
373
+ .props('flat dense round aria-label="More actions"')
374
+ .props('aria-haspopup="menu" aria-expanded="false"')
375
+ .mark("action-more")
376
+ )
377
+ with more, ui.menu() as opened:
378
+ opened.on_value_change(
379
+ lambda e, more=more: more.props(
380
+ f'aria-expanded="{"true" if e.value else "false"}"'
381
+ )
382
+ )
383
+ for cap in menu:
384
+ self.actions[cap.name] = MenuAction(
385
+ partial(self.open, cap),
386
+ marker=f"menu-action:{cap.name}",
387
+ label=cap.label,
388
+ )
389
+
390
+ def open(self, cap: Capability) -> None:
391
+ """The capability's panel, in the dialog, over the selection as it is
392
+ at this click."""
393
+ view = self.shell.view_for(self.selection_of())
394
+
395
+ def close() -> None:
396
+ self.dialog.close()
397
+
398
+ self.dialog.clear()
399
+ with self.dialog, ui.card().classes("dstf-dialog"):
400
+ ui.label(cap.label).classes("text-h6")
401
+ error = ui.label(self.errors.get(cap.name, "")).classes("text-negative")
402
+ error.mark(f"error:{cap.name}")
403
+ error.set_visibility(bool(error.text))
404
+ slot = ui.column().classes("w-full").mark(f"panel:{cap.name}")
405
+ # The form row's column, below the panel, exists BEFORE the
406
+ # panel runs: a panel may open its row while it is built, and
407
+ # a column made after it raised NameError from `row` (the Task
408
+ # 5 review).
409
+ form = ui.column().classes("w-full gap-2")
410
+
411
+ def submit(**inputs: Any) -> bool:
412
+ try:
413
+ outcome = cap.act(view, **inputs)
414
+ (self.apply or self.shell.session.apply)(outcome)
415
+ except Exception as e: # shown here; a raising act changes nothing
416
+ if isinstance(e, StaleSpecError) and self.stale is not None:
417
+ # Said where Save's own stale-file refusal is, with
418
+ # the way out beside it: this dialog closes for it.
419
+ close()
420
+ self.stale(e)
421
+ return False
422
+ log_unless_users(e, cap.name)
423
+ self.errors[cap.name] = user_text(e)
424
+ error.set_text(self.errors[cap.name])
425
+ error.set_visibility(True)
426
+ return False
427
+ self.errors.pop(cap.name, None)
428
+ if outcome is not None:
429
+ # Like `InlineHost.on_applied`: an act returning None
430
+ # changed nothing. Every plot sends `{"autosize": true}`
431
+ # at mount, and a subscribing panel answering it with
432
+ # None must not close the dialog it sits in.
433
+ close()
434
+ return True
435
+
436
+ def row(title: str, build: RowBuild) -> None:
437
+ """A dialog has no row to open one under: the form is drawn
438
+ in the dialog, below the panel, one at a time."""
439
+ form.clear()
440
+
441
+ def close_row() -> None:
442
+ form.clear()
443
+
444
+ with form:
445
+ ui.label(title).classes("text-subtitle2")
446
+ build(close_row)
447
+
448
+ with slot:
449
+ try:
450
+ cap.panel(view, ShellPanel(submit, _NoFigure(), row=row))
451
+ except Exception as e: # shown in the slot, never breaks the page
452
+ ui.label(f"{type(e).__name__}: {e}").classes("text-negative")
453
+ ui.button("Close", on_click=close).props("flat dense no-caps").mark(
454
+ f"close:{cap.name}"
455
+ )
456
+ self.dialog.open()
457
+
458
+
459
+ class InlineHost:
460
+ """Every capability placed at `placement`, its panel built inline in
461
+ this host's column — no button, no heading (brief §3.2: Edit criteria
462
+ and Edit on plot read as one editor; a node's controls are a panel).
463
+ An unavailable capability keeps its place and says why (brief §3.3) —
464
+ once per sentence: a second capability refused for the same reason
465
+ draws no line of its own.
466
+ `build()` rebuilds from the selection as it is now; `clear()` empties
467
+ the column and drops this host's figure handlers and the form row its
468
+ panels opened. A raising act shows above that capability's panel — in
469
+ a host drawn in a row, on a line of its own under the row — and
470
+ changes nothing. The error belongs to that row: the next success
471
+ hides it in place, and so does a form row opening or closing under
472
+ THAT row (`hush`), or the host rebuilding; another row's form does
473
+ not. The panel is not rebuilt, so a field being typed in survives.
474
+ `on_applied` is called with that capability's name when its act
475
+ returned an outcome, and only then: an act returning ``None`` changed
476
+ nothing, and the rebuild it would trigger discards a half-typed row
477
+ for nothing. `follow()` is how a host that is never rebuilt keeps up
478
+ with `available`: a capability's answer can move without the tree's
479
+ shape moving (until D150 a job's lock did, on every run) — and with
480
+ ``kept``, when what a panel kept there was dropped.
481
+ ``kept(name)`` is `Panel.kept` for capability ``name``'s panel in this
482
+ host: the dict its owner keeps across this host's rebuilds. Without
483
+ one, each build of a panel gets a dict of its own.
484
+ ``on_said`` hears what this host's error lines say, as they say it:
485
+ the text of a refused act, and ``None`` when an act went through (its
486
+ line hidden). Whoever shows that text somewhere else too — a plot, for
487
+ a drag — shows this one, not a second wording."""
488
+
489
+ def __init__(
490
+ self,
491
+ shell: Shell,
492
+ placement: Placement,
493
+ selection_of: Callable[[], Selection],
494
+ *,
495
+ figure: FigureHook | None,
496
+ marker: str,
497
+ on_applied: Callable[[str], None] | None = None,
498
+ row: RowTarget | None = None,
499
+ apply: Callable[[Outcome | Sequence[Outcome]], None] | None = None,
500
+ kept: Callable[[str], dict[Any, Any]] | None = None,
501
+ on_said: Callable[[str | None], None] | None = None,
502
+ placing: Placing | None = None,
503
+ ) -> None:
504
+ self.shell, self.placement, self.selection_of = shell, placement, selection_of
505
+ self.figure, self.marker, self.on_applied = figure, marker, on_applied
506
+ self.on_said = on_said
507
+ #: The plot's ⌖ buttons and armed field (D149), lent to every panel
508
+ #: of this host; None for a host beside no plot.
509
+ self.placing = placing
510
+ self.row = row
511
+ self.kept = kept
512
+ #: capability name -> the `Panel.kept` its panel was built with
513
+ self.handed: dict[str, dict[Any, Any]] = {}
514
+ #: As `ActionBar.apply`: the page's, where the host is on one.
515
+ self.apply = apply
516
+ if row is not None:
517
+ row.hush = self.hush
518
+ self.errors: dict[str, ui.label] = {}
519
+ self.cells: dict[str, ui.column] = {} # capability name -> its place
520
+ #: capability name -> the `available` answer its place was built on
521
+ self.answers: dict[str, bool | str] = {}
522
+ self.column = ui.column().classes("w-full gap-2").mark(marker)
523
+ # A click inside the editor is the editor's: without this, a click on
524
+ # a node's wrap icon also reached the row that hosts it and opened its
525
+ # editor (the review, in Chromium).
526
+ self.column.on("click", js_handler="(e) => e.stopPropagation()")
527
+
528
+ def _owner(self, name: str) -> str:
529
+ return f"{self.marker}:{name}"
530
+
531
+ def hush(self) -> None:
532
+ """Hide every error this host shows, as a form row opens or closes
533
+ under this host's row: a row's error goes with the row. A refused
534
+ act's error stayed up after its form was reopened (the Task 6
535
+ review)."""
536
+ for label in self.errors.values():
537
+ if not label.is_deleted:
538
+ label.set_visibility(False)
539
+
540
+ def clear(self) -> None:
541
+ if self.figure is not None:
542
+ for name in self.cells:
543
+ self.figure.clear(self._owner(name))
544
+ if self.row is not None:
545
+ # A form row lives only as long as the host whose panel opened
546
+ # it: rebuilt to its why-line (under the single job's lock,
547
+ # before D150), the host said "rerun is running" while its
548
+ # row stayed open with live buttons, and an
549
+ # act from it staged nothing and showed nothing — the error
550
+ # label had gone with the rebuild (the Task 5 review).
551
+ self.row.page.drop_row(self.row)
552
+ # Its error labels live in the row's slot, not in the column.
553
+ for label in self.errors.values():
554
+ if not label.is_deleted:
555
+ label.delete()
556
+ # And what its panels drew in the row's lead place: a host
557
+ # rebuilt to its why-line leaves the kind to the row's text.
558
+ if self.row.lead is not None and not self.row.lead.is_deleted:
559
+ self.row.lead.clear()
560
+ # And what they registered for the drag: a host rebuilt to its
561
+ # why-line takes no drop and offers no handle.
562
+ self.row.on_drop = self.row.zones = None
563
+ # And its click: a host rebuilt to its why-line opens no form.
564
+ self.row.click = None
565
+ if self.row.grip is not None and not self.row.grip.is_deleted:
566
+ self.row.grip.classes(add="dstf-grip-off")
567
+ self.column.clear()
568
+ self.errors, self.cells, self.answers, self.handed = {}, {}, {}, {}
569
+
570
+ def build(self, skip: str | None = None) -> None:
571
+ """Rebuild every capability's place — but ``skip``'s, left untouched
572
+ (the panel whose own submit this follows: rebuilding it would destroy
573
+ the control being typed in). A changed set of capabilities rebuilds
574
+ them all. ``skip`` misleads in one state: a skipped capability that
575
+ has become UNAVAILABLE keeps its stale panel and gets no why-line
576
+ until the next full ``build()``."""
577
+ view = self.shell.view_for(self.selection_of())
578
+ placed = self.shell.placed(self.placement, view)
579
+ if skip is None or [cap.name for cap, _ in placed] != list(self.cells):
580
+ self.clear()
581
+ skip = None
582
+ # The why-lines drawn so far. Edit criteria and Edit on plot are both
583
+ # refused on a test the Session has no tree for, and the editor
584
+ # read the same sentence twice (the review).
585
+ said: set[str] = set()
586
+ for cap, avail in placed:
587
+ if cap.name == skip:
588
+ continue
589
+ if cap.name not in self.cells:
590
+ with self.column:
591
+ self.cells[cap.name] = ui.column().classes("w-full gap-1")
592
+ elif self.figure is not None:
593
+ self.figure.clear(self._owner(cap.name))
594
+ cell = self.cells[cap.name]
595
+ cell.clear()
596
+ with cell:
597
+ self._build_one(cap, avail, view, said)
598
+ # Not the skipped capability's: its place still shows the answer
599
+ # it was built on, and `follow` is what brings it back in line.
600
+ self.answers[cap.name] = avail
601
+ if self.placing is not None:
602
+ # A field whose ⌖ this build did not draw again is not armed.
603
+ self.placing.settle()
604
+
605
+ def follow(self) -> None:
606
+ """Rebuild when an `available` answer has moved since this host was
607
+ built, and only then (the Task 4 review): a row that is repainted
608
+ rather than rebuilt otherwise keeps the answer of the moment it was
609
+ drawn — under the single job's lock (before D150) a tree opened
610
+ during a rerun kept "rerun is running on …" after the job ended,
611
+ and one opened before it kept controls the lock refused. A full
612
+ build resets the error labels, which is
613
+ why hiding a stale one is not this method's job.
614
+
615
+ Also when what a panel KEPT (`Panel.kept`) has been dropped under
616
+ it — a Discard, a Save, another tab's edit: its controls still show
617
+ what the dropped dict held (a cell left blank), and nothing would
618
+ bring it back at the next rebuild."""
619
+ view = self.shell.view_for(self.selection_of())
620
+ answers = {
621
+ cap.name: avail for cap, avail in self.shell.placed(self.placement, view)
622
+ }
623
+ kept = self.kept
624
+ dropped = kept is not None and any(
625
+ held and kept(name) is not held for name, held in self.handed.items()
626
+ )
627
+ if answers != self.answers or dropped:
628
+ self.build()
629
+
630
+ def _build_one(
631
+ self, cap: Capability, avail: bool | str, view: Any, said: set[str]
632
+ ) -> None:
633
+ slot = self.row.slot if self.row is not None else None
634
+ if slot is not None:
635
+ # A host drawn in a row puts its error on a line of its own in
636
+ # the row's slot, under the icons: in the host, a long refusal
637
+ # widened the host and pushed its icons left of every other
638
+ # row's (the Task 6 review, in Chromium).
639
+ with slot:
640
+ error = ui.label("").classes("text-negative text-xs self-end")
641
+ else:
642
+ error = ui.label("").classes("text-negative text-xs")
643
+ error.mark(f"error:{cap.name}")
644
+ error.set_visibility(False)
645
+ self.errors[cap.name] = error
646
+ if avail is not True:
647
+ sentence = avail if isinstance(avail, str) else "not available"
648
+ if sentence not in said:
649
+ said.add(sentence)
650
+ ui.label(sentence).classes("dstf-why").mark(f"why:{cap.name}")
651
+ return
652
+
653
+ def fail(e: BaseException) -> None:
654
+ log_unless_users(e, cap.name)
655
+ error.set_text(user_text(e))
656
+ error.set_visibility(True)
657
+ if self.on_said is not None:
658
+ self.on_said(error.text)
659
+ if self.row is not None:
660
+ # Said under its row: a tree row inside a folded group is
661
+ # shown (U5, R2).
662
+ self.row.page.unfold_to(self.row)
663
+
664
+ def submit(**inputs: Any) -> bool:
665
+ try:
666
+ outcome = cap.act(view, **inputs)
667
+ (self.apply or self.shell.session.apply)(outcome)
668
+ except Exception as e: # shown, and changes nothing
669
+ fail(e)
670
+ return False
671
+ error.set_visibility(False)
672
+ if self.on_said is not None:
673
+ self.on_said(None)
674
+ if outcome is not None and self.on_applied is not None:
675
+ self.on_applied(cap.name)
676
+ return True
677
+
678
+ figure: FigureHookProtocol = _NoFigure()
679
+ if self.figure is not None:
680
+ figure = self.figure.owned(self._owner(cap.name))
681
+ self.handed[cap.name] = {} if self.kept is None else self.kept(cap.name)
682
+ with ui.column().classes("w-full gap-2").mark(f"panel:{cap.name}"):
683
+ try:
684
+ cap.panel(
685
+ view,
686
+ ShellPanel(
687
+ submit,
688
+ figure,
689
+ row=None if self.row is None else self.row.open,
690
+ reveal=None
691
+ if self.row is None
692
+ else partial(self.row.page.reveal, index=self.row.index),
693
+ lead=None if self.row is None else self.row.lead,
694
+ target=self.row,
695
+ fail=fail,
696
+ kept=self.handed[cap.name],
697
+ placing=self.placing,
698
+ name=cap.name,
699
+ ),
700
+ )
701
+ except Exception as e: # shown in the slot, never breaks the page
702
+ ui.label(f"{type(e).__name__}: {e}").classes("text-negative")