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,528 @@
|
|
|
1
|
+
"""The shell as a process (roadmap §2; D129): one Session, one thread that
|
|
2
|
+
opens the project, one queue every Session event crosses on its way to the
|
|
3
|
+
pages, one app-level timer that drains it, the pages a browser has open,
|
|
4
|
+
and the child processes "Open in another backend…" starts. It knows no
|
|
5
|
+
capability by name: `placed` lists what is registered at a placement with
|
|
6
|
+
each one's answer to `available`. Nothing here is a page — the pages are
|
|
7
|
+
`suite.py` and `test_page.py`, one object per browser tab."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import queue
|
|
13
|
+
import socket
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Protocol
|
|
21
|
+
from urllib.parse import unquote
|
|
22
|
+
|
|
23
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
24
|
+
from nicegui import app, ui
|
|
25
|
+
|
|
26
|
+
from ...backends.pool import ItemState
|
|
27
|
+
from ...cli.common import _filter_tests
|
|
28
|
+
from ...reporting.theme import level_variables
|
|
29
|
+
from ..capabilities import Capability, Placement, plural, registered_capabilities
|
|
30
|
+
from ..errors import user_text
|
|
31
|
+
from ..queue_rows import stop_reason
|
|
32
|
+
from ..session import Selection, Session, SessionView
|
|
33
|
+
from ..spec_document import edit_count
|
|
34
|
+
from .actions import log_unless_users
|
|
35
|
+
from .style import QUASAR, STYLE
|
|
36
|
+
from .tab_theme import TabTheme
|
|
37
|
+
from .tabstate import TabState
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
TICK = 0.1 # seconds; a Session event reaches every page within one
|
|
42
|
+
#: How long the shutdown waits for a drained queue's dashboard render still
|
|
43
|
+
#: running (1.6 s over TRANSFORM's work dir with --report sidecars).
|
|
44
|
+
SHUTDOWN_WAIT = 5.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Page(Protocol):
|
|
48
|
+
"""What the shell fans events out to: a live page and its client."""
|
|
49
|
+
|
|
50
|
+
client: Any # nicegui.Client; `is_deleted` prunes the page
|
|
51
|
+
|
|
52
|
+
def handle(self, event: dict[str, Any]) -> None: ...
|
|
53
|
+
|
|
54
|
+
def flush(self) -> None:
|
|
55
|
+
"""After every event of one tick: send what the page coalesced (the
|
|
56
|
+
Suite table sends one transaction per tick, not one per event)."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class LoadState:
|
|
62
|
+
"""Where the open stands, kept by the shell from the Session's `loading`
|
|
63
|
+
events (decision 9) and read by every page's progress line."""
|
|
64
|
+
|
|
65
|
+
phase: str = "idle" # idle | scan | results | done | failed
|
|
66
|
+
done: int = 0
|
|
67
|
+
total: int = 0
|
|
68
|
+
cached: bool = False
|
|
69
|
+
#: The last open's failure, or a warning from an open that completed —
|
|
70
|
+
#: one model's scoring raised; a reopen that could not build its backend
|
|
71
|
+
#: and left the project as it was.
|
|
72
|
+
error: str | None = None
|
|
73
|
+
#: Models announced with `error=True`: scored by nobody, drawn as such.
|
|
74
|
+
failed_models: list[str] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def in_progress(self) -> bool:
|
|
78
|
+
return self.phase in ("scan", "results")
|
|
79
|
+
|
|
80
|
+
def text(self) -> str:
|
|
81
|
+
"""The progress line (brief §3.8: "scanning library… N tests found")."""
|
|
82
|
+
if self.phase == "scan":
|
|
83
|
+
if self.cached:
|
|
84
|
+
return f"library scan from cache: {plural(self.total, 'test')}"
|
|
85
|
+
if self.total:
|
|
86
|
+
return f"scanning library… {self.done} of {self.total}"
|
|
87
|
+
return "scanning library…"
|
|
88
|
+
if self.phase == "results":
|
|
89
|
+
return f"scoring results… {self.done} of {self.total}"
|
|
90
|
+
if self.phase == "failed":
|
|
91
|
+
return f"could not open the project: {self.error}"
|
|
92
|
+
if self.error:
|
|
93
|
+
return f"warning: {self.error}"
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Shell:
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
config_path: Path,
|
|
101
|
+
*,
|
|
102
|
+
work_dir: Path | None = None,
|
|
103
|
+
filter: str | None = None,
|
|
104
|
+
package: str | None = None,
|
|
105
|
+
show_browser: bool = True,
|
|
106
|
+
) -> None:
|
|
107
|
+
self.config_path = Path(config_path)
|
|
108
|
+
self.work_dir = work_dir
|
|
109
|
+
self.filter, self.package = filter, package
|
|
110
|
+
self.show_browser = show_browser
|
|
111
|
+
# What other threads post — the simulation pool's callbacks, the
|
|
112
|
+
# rescan's merge: Session mutations that must run on the UI loop.
|
|
113
|
+
# `tick` runs them.
|
|
114
|
+
self._posted: queue.Queue[Callable[[], None]] = queue.Queue()
|
|
115
|
+
self.session = Session(post=self._posted.put)
|
|
116
|
+
# Every Session event, from whichever thread emitted it, crosses here
|
|
117
|
+
# and is fanned out to the pages on the UI loop by `tick`.
|
|
118
|
+
self._events: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
119
|
+
#: The models an open named before its scan finished (its `known`
|
|
120
|
+
#: event), until the merge's `spec` event: the tests while an open
|
|
121
|
+
#: is under way.
|
|
122
|
+
self.known: list[str] = []
|
|
123
|
+
for event in (
|
|
124
|
+
"spec",
|
|
125
|
+
"results",
|
|
126
|
+
"queue",
|
|
127
|
+
"notice",
|
|
128
|
+
"loading",
|
|
129
|
+
"known",
|
|
130
|
+
"overlays",
|
|
131
|
+
):
|
|
132
|
+
self.session.on(event, self._events.put)
|
|
133
|
+
self.pages: list[Page] = []
|
|
134
|
+
self.load = LoadState()
|
|
135
|
+
self._open_thread: threading.Thread | None = None
|
|
136
|
+
self.children: list[subprocess.Popen[bytes]] = []
|
|
137
|
+
#: (capability name, what) already logged: `available` / `caption` are read
|
|
138
|
+
#: on every refresh, so a raising one is logged once, not per refresh.
|
|
139
|
+
self._reported: set[tuple[str, str]] = set()
|
|
140
|
+
self._mounted = False
|
|
141
|
+
self._timer: Any = None
|
|
142
|
+
self._ticks = 0
|
|
143
|
+
|
|
144
|
+
# -- the open ------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def open_async(self, backend: str | None = None) -> None:
|
|
147
|
+
"""Open the project — or reopen it on `backend` (brief §3.6) — on a
|
|
148
|
+
thread; the pages follow the `loading` events and the `opened`
|
|
149
|
+
event this shell adds at the end. Refused while an open runs.
|
|
150
|
+
`Session.open` itself refuses while edits are pending or the queue
|
|
151
|
+
holds a test, and that refusal arrives as the `opened` event's error."""
|
|
152
|
+
if self._open_thread is not None and self._open_thread.is_alive():
|
|
153
|
+
raise RuntimeError("the project is still opening")
|
|
154
|
+
self.load = LoadState(phase="scan")
|
|
155
|
+
thread = threading.Thread(
|
|
156
|
+
target=self._open_worker, args=(backend,), name="dstf-open", daemon=True
|
|
157
|
+
)
|
|
158
|
+
self._open_thread = thread
|
|
159
|
+
thread.start()
|
|
160
|
+
|
|
161
|
+
def _open_worker(self, backend: str | None) -> None:
|
|
162
|
+
error: str | None = None
|
|
163
|
+
try:
|
|
164
|
+
self.session.open(self.config_path, backend=backend, work_dir=self.work_dir)
|
|
165
|
+
except Exception as e: # shown on the page (decision 8), never lost on a thread
|
|
166
|
+
# A person's own — a testing.json or a spec edited by hand
|
|
167
|
+
# while the tool is open, then reopened — is its sentence on
|
|
168
|
+
# the page and no traceback in the console; a bug's is logged
|
|
169
|
+
# and keeps its class name (`user_text`).
|
|
170
|
+
log_unless_users(e, f"opening {self.config_path}")
|
|
171
|
+
error = user_text(e)
|
|
172
|
+
self._events.put({"event": "opened", "error": error})
|
|
173
|
+
|
|
174
|
+
# -- the loop --------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def start(self) -> None:
|
|
177
|
+
"""The app-level timer: one per process, once the event loop runs. A
|
|
178
|
+
stopped app nulls its timers' callbacks (`Timer._cleanup`), so a
|
|
179
|
+
Shell that outlives one app run gets a new timer with the next."""
|
|
180
|
+
if self._timer is None or self._timer.callback is None:
|
|
181
|
+
self._timer = app.timer(TICK, self.tick)
|
|
182
|
+
|
|
183
|
+
def register(self, page: Page) -> None:
|
|
184
|
+
self.pages.append(page)
|
|
185
|
+
|
|
186
|
+
def tick(self) -> None:
|
|
187
|
+
"""Run what worker threads posted — each on its own: one that
|
|
188
|
+
raises is logged, and the rest of the tick follows — then hand every
|
|
189
|
+
queued Session event to every live page, then let each flush what it
|
|
190
|
+
coalesced — a page whose client is gone is dropped.
|
|
191
|
+
Once a second while a test runs or a worker is being started, the
|
|
192
|
+
running tests' growing result files are read (`Session.live_runs`,
|
|
193
|
+
D151) and a `poll` event follows: the clock a running row's words
|
|
194
|
+
and a starting worker's seconds are repainted by, and a running
|
|
195
|
+
test's page draws what was read by. A run's start and end are the queue's callbacks,
|
|
196
|
+
never a file's (D150)."""
|
|
197
|
+
while True:
|
|
198
|
+
try:
|
|
199
|
+
fn = self._posted.get_nowait()
|
|
200
|
+
except queue.Empty:
|
|
201
|
+
break
|
|
202
|
+
try:
|
|
203
|
+
fn()
|
|
204
|
+
except Exception: # one call's failure is not the others' wait
|
|
205
|
+
logger.exception("a posted call failed")
|
|
206
|
+
events: list[dict[str, Any]] = []
|
|
207
|
+
while True:
|
|
208
|
+
try:
|
|
209
|
+
events.append(self._events.get_nowait())
|
|
210
|
+
except queue.Empty:
|
|
211
|
+
break
|
|
212
|
+
self._ticks += 1
|
|
213
|
+
every = max(1, round(1.0 / TICK)) # once a second, whatever the tick
|
|
214
|
+
if self._ticks % every == 0 and self.clocked():
|
|
215
|
+
try:
|
|
216
|
+
self.session.live_runs.poll()
|
|
217
|
+
except Exception: # a read that failed is not the tick's end
|
|
218
|
+
logger.exception("the live poll failed")
|
|
219
|
+
events.append({"event": "poll"})
|
|
220
|
+
# `is_deleted` is set by the browser's disconnect (after NiceGUI's
|
|
221
|
+
# reconnect timeout); a page navigated away from in the user
|
|
222
|
+
# simulation is never deleted, so tests pick a page by its client.
|
|
223
|
+
self.pages = [p for p in self.pages if not p.client.is_deleted]
|
|
224
|
+
for event in _folded(events):
|
|
225
|
+
self._absorb(event)
|
|
226
|
+
for page in list(self.pages):
|
|
227
|
+
try:
|
|
228
|
+
page.handle(event)
|
|
229
|
+
except Exception: # one page's failure is not another's
|
|
230
|
+
logger.exception("a page failed on %r", event.get("event"))
|
|
231
|
+
for page in list(self.pages):
|
|
232
|
+
try:
|
|
233
|
+
page.flush()
|
|
234
|
+
except Exception:
|
|
235
|
+
logger.exception("a page failed to flush")
|
|
236
|
+
|
|
237
|
+
def _absorb(self, event: dict[str, Any]) -> None:
|
|
238
|
+
"""`LoadState` from the events (decision 9)."""
|
|
239
|
+
kind = event["event"]
|
|
240
|
+
load = self.load
|
|
241
|
+
if kind == "known":
|
|
242
|
+
self.known = [str(m) for m in event.get("models", ())]
|
|
243
|
+
return
|
|
244
|
+
if kind == "spec":
|
|
245
|
+
# The merge (the project's tests now), or a close: either way
|
|
246
|
+
# the open's names are done with.
|
|
247
|
+
self.known = []
|
|
248
|
+
if kind == "loading":
|
|
249
|
+
phase = event.get("phase")
|
|
250
|
+
if phase == "scan":
|
|
251
|
+
if load.phase != "scan":
|
|
252
|
+
# An open this shell did not start (an `OpenProject`
|
|
253
|
+
# outcome runs `Session.open` on the UI loop): a fresh
|
|
254
|
+
# state, so the previous project's failures do not carry.
|
|
255
|
+
load = self.load = LoadState(phase="scan")
|
|
256
|
+
load.cached = bool(event.get("cached"))
|
|
257
|
+
load.done = int(event.get("done", 0))
|
|
258
|
+
load.total = int(event.get("total", 0))
|
|
259
|
+
elif phase == "results":
|
|
260
|
+
if not load.in_progress:
|
|
261
|
+
# A reload after the open — a store write's — announces
|
|
262
|
+
# the same events, and no `done` follows them: read
|
|
263
|
+
# here, it left the shell "still loading" for good (the
|
|
264
|
+
# Task 5 review's probe).
|
|
265
|
+
return
|
|
266
|
+
load.phase = "results"
|
|
267
|
+
load.done, load.total = int(event["done"]), int(event["total"])
|
|
268
|
+
if event.get("error"):
|
|
269
|
+
load.failed_models.append(str(event["model"]))
|
|
270
|
+
elif phase == "done":
|
|
271
|
+
load.phase = "done"
|
|
272
|
+
elif kind == "spec" and load.phase == "scan":
|
|
273
|
+
# The merge is done: the scan is over and the results are next.
|
|
274
|
+
load.phase = "results"
|
|
275
|
+
load.done, load.total = 0, len(event.get("models", ()))
|
|
276
|
+
elif kind == "opened":
|
|
277
|
+
error = event.get("error")
|
|
278
|
+
if error is None:
|
|
279
|
+
return
|
|
280
|
+
if self.session.project is not None:
|
|
281
|
+
# A project is open: this one, complete after a scoring
|
|
282
|
+
# failure, or the previous one after a refused reopen.
|
|
283
|
+
load.phase, load.error = "done", error
|
|
284
|
+
else:
|
|
285
|
+
load.phase, load.error = "failed", error
|
|
286
|
+
|
|
287
|
+
# -- what the pages read -------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def placed(
|
|
290
|
+
self, placement: Placement, view: SessionView
|
|
291
|
+
) -> list[tuple[Capability, bool | str]]:
|
|
292
|
+
"""Every registered capability that attaches at `placement`, with
|
|
293
|
+
`available`'s answer for `view`, in registration order. An
|
|
294
|
+
`available` that raises answers with its exception: read on every
|
|
295
|
+
refresh of every page, it must never break the page (a plug-in
|
|
296
|
+
that raised here blanked the Suite page — the Task 7 review)."""
|
|
297
|
+
placed: list[tuple[Capability, bool | str]] = []
|
|
298
|
+
for cap in registered_capabilities():
|
|
299
|
+
if placement not in cap.placements:
|
|
300
|
+
continue
|
|
301
|
+
try:
|
|
302
|
+
answer: bool | str = cap.available(view)
|
|
303
|
+
except Exception as e: # the plug-in's, shown as its button's reason
|
|
304
|
+
answer = f"{type(e).__name__}: {e}"
|
|
305
|
+
self.report_once(cap.name, "available", e)
|
|
306
|
+
placed.append((cap, answer))
|
|
307
|
+
return placed
|
|
308
|
+
|
|
309
|
+
def report_once(self, name: str, what: str, error: Exception) -> None:
|
|
310
|
+
"""Log a capability's raising `available` / `caption` with its
|
|
311
|
+
traceback, once per capability and method: the button's why-line
|
|
312
|
+
is the user's signal, the log is the plug-in author's."""
|
|
313
|
+
if (name, what) in self._reported:
|
|
314
|
+
return
|
|
315
|
+
self._reported.add((name, what))
|
|
316
|
+
logger.warning(
|
|
317
|
+
"capability %r: %s raised %s: %s",
|
|
318
|
+
name,
|
|
319
|
+
what,
|
|
320
|
+
type(error).__name__,
|
|
321
|
+
error,
|
|
322
|
+
exc_info=error,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def view_for(self, selection: Selection) -> SessionView:
|
|
326
|
+
return SessionView(self.session, selection)
|
|
327
|
+
|
|
328
|
+
def visible_tests(self) -> list[str]:
|
|
329
|
+
"""The project's tests after the CLI's `--filter` / `--package` —
|
|
330
|
+
while an open is under way, the models it named, filtered the same
|
|
331
|
+
way (`_filter_tests` reads nothing but the model id)."""
|
|
332
|
+
project = self.session.project
|
|
333
|
+
if self.known:
|
|
334
|
+
tests: list[Any] = [_Named(m) for m in self.known]
|
|
335
|
+
elif project is not None:
|
|
336
|
+
tests = list(project.tests)
|
|
337
|
+
else:
|
|
338
|
+
return []
|
|
339
|
+
return [t.model_id for t in _filter_tests(tests, self.filter, self.package)]
|
|
340
|
+
|
|
341
|
+
def running(self) -> bool:
|
|
342
|
+
"""Whether the queue is running a test now."""
|
|
343
|
+
session = self.session
|
|
344
|
+
return any(session.item_state(i) is ItemState.RUNNING for i in session.queue)
|
|
345
|
+
|
|
346
|
+
def clocked(self) -> bool:
|
|
347
|
+
"""Whether anything of the queue moves by the second — what the
|
|
348
|
+
`poll` is for: a test is running, or a worker is being started for
|
|
349
|
+
one that waits (a library's load is minutes)."""
|
|
350
|
+
return self.running() or bool(self.session.starting_workers)
|
|
351
|
+
|
|
352
|
+
def busy_reason(self) -> str | None:
|
|
353
|
+
"""Why a Save, a Discard or a backend change is refused now, or None:
|
|
354
|
+
the open thread is still writing. The queue refuses none of the
|
|
355
|
+
first two (D150): a queued or running test stays editable."""
|
|
356
|
+
if self.load.in_progress:
|
|
357
|
+
return "the project is still loading"
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
def stop_reasons(self) -> list[str]:
|
|
361
|
+
"""What Stop server would lose (brief §3.3, §3.6): pending edits, and
|
|
362
|
+
the tests the queue still holds."""
|
|
363
|
+
reasons: list[str] = []
|
|
364
|
+
n = edit_count(self.session.spec.pending)
|
|
365
|
+
if n:
|
|
366
|
+
reasons.append(f"{plural(n, 'unsaved edit')} will be lost")
|
|
367
|
+
held = stop_reason(self.session)
|
|
368
|
+
if held is not None:
|
|
369
|
+
reasons.append(f"{held} — stop anyway?")
|
|
370
|
+
return reasons
|
|
371
|
+
|
|
372
|
+
def stop(self) -> None:
|
|
373
|
+
"""Stop server: the queue first — its waiting tests removed, its
|
|
374
|
+
running ones stopped, every worker's simulator process ended — then
|
|
375
|
+
the children (decision 7), then NiceGUI."""
|
|
376
|
+
self.session.stop_queue()
|
|
377
|
+
self.terminate_children()
|
|
378
|
+
app.shutdown()
|
|
379
|
+
|
|
380
|
+
def shutdown(self) -> None:
|
|
381
|
+
"""NiceGUI's shutdown (Stop server, a Ctrl-C): the queue's pool ends
|
|
382
|
+
— a Ctrl-C leaves no simulator process behind either — then a
|
|
383
|
+
drained queue's dashboard render still running off the UI loop
|
|
384
|
+
lands (it is on a daemon thread the process's exit would end
|
|
385
|
+
mid-write), then the children end (decision 7)."""
|
|
386
|
+
self.session.stop_queue()
|
|
387
|
+
if not self.session.wait_background(SHUTDOWN_WAIT):
|
|
388
|
+
logger.warning(
|
|
389
|
+
"exiting with the queue's dashboard render still running after %.0f s",
|
|
390
|
+
SHUTDOWN_WAIT,
|
|
391
|
+
)
|
|
392
|
+
self.terminate_children()
|
|
393
|
+
|
|
394
|
+
def terminate_children(self) -> None:
|
|
395
|
+
for child in self.children:
|
|
396
|
+
if child.poll() is None:
|
|
397
|
+
child.terminate()
|
|
398
|
+
|
|
399
|
+
def spawn(self, backend: str) -> str:
|
|
400
|
+
"""`Open in another backend…` (brief §3.6): a second `dstf author` on
|
|
401
|
+
`backend`, on a free port, from the same config — with this
|
|
402
|
+
process's `--work-dir`, `--filter` and `--package` when it got them
|
|
403
|
+
(decision 6: `--work-dir` means "this work dir"). The child opens its
|
|
404
|
+
own browser tab unless this process was started with `--no-browser`,
|
|
405
|
+
so no popup for the browser to block; the URL is returned for the
|
|
406
|
+
dialog's link. `terminate_children` ends what this started
|
|
407
|
+
(decision 7)."""
|
|
408
|
+
port = _free_port()
|
|
409
|
+
cmd = [
|
|
410
|
+
sys.executable,
|
|
411
|
+
"-m",
|
|
412
|
+
"dstf",
|
|
413
|
+
"--config",
|
|
414
|
+
str(self.config_path),
|
|
415
|
+
"author",
|
|
416
|
+
"--backend",
|
|
417
|
+
backend,
|
|
418
|
+
"--port",
|
|
419
|
+
str(port),
|
|
420
|
+
]
|
|
421
|
+
if self.work_dir is not None:
|
|
422
|
+
cmd += ["--work-dir", str(self.work_dir)]
|
|
423
|
+
if self.filter:
|
|
424
|
+
cmd += ["--filter", self.filter]
|
|
425
|
+
if self.package:
|
|
426
|
+
cmd += ["--package", self.package]
|
|
427
|
+
if not self.show_browser:
|
|
428
|
+
cmd.append("--no-browser")
|
|
429
|
+
self.children.append(subprocess.Popen(cmd))
|
|
430
|
+
return f"http://127.0.0.1:{port}/"
|
|
431
|
+
|
|
432
|
+
# -- the pages ---------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
def page_setup(
|
|
435
|
+
self, state: TabState, switched: Callable[[], None] | None = None
|
|
436
|
+
) -> TabTheme:
|
|
437
|
+
"""Every page starts here: the Quasar palette and the token
|
|
438
|
+
stylesheet from `reporting.theme`, in the tab's theme (`TabTheme`,
|
|
439
|
+
which ``switched`` is handed to), the shell's own on top — one token
|
|
440
|
+
table for the dashboard, the report and the tool (roadmap §1
|
|
441
|
+
predicate 2; the body font is this shell's to apply)."""
|
|
442
|
+
theme = TabTheme(self, state, switched)
|
|
443
|
+
ui.add_css(level_variables() + STYLE + QUASAR)
|
|
444
|
+
return theme
|
|
445
|
+
|
|
446
|
+
def mount(self) -> None:
|
|
447
|
+
"""The routes beside the index: the Suite table's two scripts, the
|
|
448
|
+
Test page's grips module, the dashboard's files, resolved against the OPEN project's work dir per
|
|
449
|
+
request so a reopen follows, and the Test page. Once per process; `index` calls it too, so a
|
|
450
|
+
simulation that mounted only the root page gets them on its first
|
|
451
|
+
build."""
|
|
452
|
+
if self._mounted:
|
|
453
|
+
return
|
|
454
|
+
self._mounted = True
|
|
455
|
+
|
|
456
|
+
# The Suite table's scripts: its own and the typed filters the
|
|
457
|
+
# dashboard inlines (one module, two surfaces).
|
|
458
|
+
from .suite_grid import ASSETS, FILTERS_JS, HERE
|
|
459
|
+
|
|
460
|
+
app.add_static_file(
|
|
461
|
+
local_file=HERE / "suite_grid.js", url_path=f"{ASSETS}/suite_grid.js"
|
|
462
|
+
)
|
|
463
|
+
app.add_static_file(local_file=FILTERS_JS, url_path=f"{ASSETS}/filters.js")
|
|
464
|
+
# The Test page's: the tolerance grips' hover (`grips.js`).
|
|
465
|
+
from .plot_card import GRIPS_JS, GRIPS_URL
|
|
466
|
+
|
|
467
|
+
app.add_static_file(local_file=GRIPS_JS, url_path=GRIPS_URL)
|
|
468
|
+
|
|
469
|
+
@app.get("/dashboard/{path:path}")
|
|
470
|
+
def _dashboard(path: str) -> Any:
|
|
471
|
+
project = self.session.project
|
|
472
|
+
if project is None:
|
|
473
|
+
return JSONResponse({"error": "no project open"}, status_code=409)
|
|
474
|
+
work_dir = project.config.work_dir
|
|
475
|
+
assert work_dir is not None # resolved in Config.__post_init__
|
|
476
|
+
target = (work_dir / path).resolve()
|
|
477
|
+
if work_dir.resolve() not in target.parents or not target.is_file():
|
|
478
|
+
return JSONResponse({"error": "not found"}, status_code=404)
|
|
479
|
+
return FileResponse(target)
|
|
480
|
+
|
|
481
|
+
@ui.page("/test/{model_id}")
|
|
482
|
+
async def _test(model_id: str) -> None:
|
|
483
|
+
from .test_page import TestPage
|
|
484
|
+
|
|
485
|
+
self.start()
|
|
486
|
+
await TestPage(self).build(unquote(model_id))
|
|
487
|
+
|
|
488
|
+
async def index(self) -> None:
|
|
489
|
+
"""The Suite page at `/`: `ui.run(shell.index, ...)` mounts it, the
|
|
490
|
+
tests `user_simulation(root=shell.index)`."""
|
|
491
|
+
from .suite import SuitePage
|
|
492
|
+
|
|
493
|
+
self.mount()
|
|
494
|
+
self.start()
|
|
495
|
+
await SuitePage(self).build()
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _is_scan_step(event: dict[str, Any]) -> bool:
|
|
499
|
+
return event["event"] == "loading" and event.get("phase") == "scan"
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _folded(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
503
|
+
"""``events`` with each run of adjacent scan steps reduced to its last:
|
|
504
|
+
a step carries the running count, so the last says all the run does.
|
|
505
|
+
One by one, every step synced an open Test page in full and re-read
|
|
506
|
+
`status.json` in its header: one tick over 3,269 queued steps
|
|
507
|
+
(TRANSFORM's .mo count, a TRANSFORM-sized `status.json`, one Test page)
|
|
508
|
+
held the UI loop about 13 s, and 0.02 s folded."""
|
|
509
|
+
out: list[dict[str, Any]] = []
|
|
510
|
+
for event in events:
|
|
511
|
+
if out and _is_scan_step(event) and _is_scan_step(out[-1]):
|
|
512
|
+
out[-1] = event
|
|
513
|
+
else:
|
|
514
|
+
out.append(event)
|
|
515
|
+
return out
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@dataclass(frozen=True)
|
|
519
|
+
class _Named:
|
|
520
|
+
"""A model id where `_filter_tests` wants a test."""
|
|
521
|
+
|
|
522
|
+
model_id: str
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _free_port() -> int:
|
|
526
|
+
with socket.socket() as s:
|
|
527
|
+
s.bind(("127.0.0.1", 0))
|
|
528
|
+
return int(s.getsockname()[1])
|