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,385 @@
1
+ """The runs whose result the Session does not have (D151): a test the queue
2
+ is running, and one whose last run ended without finishing and left a
3
+ partial result file. The Session's own tier — it holds the readers and
4
+ opens the files; what their snapshots become is `authoring.live_rows`.
5
+
6
+ A run that STARTS takes its test's result out of memory (D150: the run
7
+ cleared it on disk). What the page was laid out by goes with it — the
8
+ scored names its plots derive from, the tracked names, the labels — so the
9
+ run's entry here keeps the comparison and the result it replaced, for the
10
+ LAYOUT alone: no verdict and no sample of them is shown.
11
+
12
+ While it runs, on a backend whose result file grows
13
+ (``Capability.LIVE_RESULTS``), :meth:`LiveRuns.poll` reads the file once a
14
+ second — for the names the open pages asked for (:meth:`LiveRuns.ask`), and
15
+ with none, the time it has reached, which the queue panel's row says. One
16
+ reader per run, polled from the UI loop: two tabs on one test share it.
17
+
18
+ "In progress" is the queue's word, never the reader's: a finished file
19
+ reads ``running`` too. An entry is in progress until the Session says its
20
+ item ended (:meth:`LiveRuns.ended`); then it goes — or, where the run did
21
+ not finish and its file holds a row, stays as a :class:`~.live_rows.Partial`,
22
+ read by the size-based reader's partial read. A partial result is shown:
23
+ nothing here hands it to the comparator or to the store.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections.abc import Callable, Collection
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import TYPE_CHECKING
32
+
33
+ import numpy as np
34
+
35
+ from ..backends.common.live_mat import RUNNING, LiveMatReader
36
+ from ..backends.common.mat_reader import ResultMatFile, read_mat_last_time
37
+ from ..backends.pool import ItemState
38
+ from ..backends.types import Capability, TestResult, VariableResult
39
+ from ..discovery.spec_update import is_literal_variable
40
+ from ..discovery.unit_tests import unit_test_variables
41
+ from .live_rows import (
42
+ NOT_SCORED,
43
+ STARTING_WORD,
44
+ WAITING,
45
+ LiveTrace,
46
+ Partial,
47
+ how_ended,
48
+ progress,
49
+ run_revision,
50
+ spanned,
51
+ stopped_marker,
52
+ )
53
+
54
+ if TYPE_CHECKING:
55
+ from ..backends.pool import QueueItem
56
+ from ..comparison.types import TestComparison
57
+ from ..discovery.test_registry import TestModel
58
+ from .session import Session
59
+
60
+ #: Polls a page's ask outlives: a page that stopped asking (closed, moved to
61
+ #: another test) costs its names two more reads.
62
+ ASK_LIFE = 2
63
+
64
+ Series = tuple[np.ndarray, np.ndarray]
65
+
66
+
67
+ def expected_result(test: TestModel) -> TestResult | None:
68
+ """What a test that never had a result is laid out by while its first
69
+ run is in progress, and after one that left a partial file: the
70
+ variables its model and its spec NAME — the UnitTests variables with
71
+ their expressions, then every ``variables`` entry that names one (a
72
+ pattern names none until a file is matched against it). No sample of
73
+ any: a layout, never a trace. None for a test that names none."""
74
+ named = {v.name: v.expression for v in unit_test_variables(test)}
75
+ for pattern in test.variable_patterns:
76
+ if is_literal_variable(pattern):
77
+ named.setdefault(pattern, "")
78
+ if not named:
79
+ return None
80
+ empty = np.empty(0)
81
+ return TestResult(
82
+ model_id=test.model_id,
83
+ success=True,
84
+ variables=[
85
+ VariableResult(
86
+ index=index, time=empty, values=empty, name=name, expression=text
87
+ )
88
+ for index, (name, text) in enumerate(named.items(), start=1)
89
+ ],
90
+ )
91
+
92
+
93
+ @dataclass
94
+ class _Run:
95
+ """One model's run with no result: in progress, or (``partial``) ended
96
+ without finishing."""
97
+
98
+ item: QueueItem
99
+ #: What the run replaced, for the page's layout.
100
+ comparison: TestComparison | None
101
+ result: TestResult | None
102
+ #: Whether the backend's result file grows while the run is in progress.
103
+ live: bool
104
+ trace: LiveTrace
105
+ #: The ``uirevision`` of this run's figures, its partial result's too.
106
+ revision: str
107
+ reader: LiveMatReader | None = None
108
+ #: The names each page asked for, and the poll it last asked at.
109
+ asked: dict[object, tuple[frozenset[str], int]] = field(default_factory=dict)
110
+ #: The reader's ``run`` whose file the pages were told can be read.
111
+ announced: int | None = None
112
+ partial: Partial | None = None
113
+ #: A partial file, its header parsed once for every name asked of it,
114
+ #: and its series by name; None is a name it lacks.
115
+ file: ResultMatFile | None = None
116
+ series: dict[str, Series | None] = field(default_factory=dict)
117
+
118
+ @property
119
+ def stop_time(self) -> float | None:
120
+ return self.item.test.stop_time
121
+
122
+
123
+ class LiveRuns:
124
+ def __init__(
125
+ self, session: Session, emit: Callable[[str, frozenset[str]], None]
126
+ ) -> None:
127
+ """``emit(state, models)`` says a `queue` event the Session's
128
+ way: a run's file became readable, and its pages re-read what they
129
+ list of it."""
130
+ self._session = session
131
+ self._emit = emit
132
+ self._runs: dict[str, _Run] = {}
133
+ self._polls = 0
134
+ self._starts = 0
135
+
136
+ # -- the Session's calls ---------------------------------------------------
137
+
138
+ def started(self, item: QueueItem) -> None:
139
+ """``item`` started — called BEFORE the Session drops its test's
140
+ result. A test started again after a run that left none (a cancel)
141
+ keeps the layout that run kept; one that never had a result is laid
142
+ out by what it names (`expected_result`)."""
143
+ session = self._session
144
+ project = session.project
145
+ if project is None:
146
+ return
147
+ model_id = item.model_id
148
+ comparison = session.comparisons.get(model_id)
149
+ result = project.results.get(model_id)
150
+ before = self._runs.get(model_id)
151
+ if result is None or not result.success:
152
+ if before is not None:
153
+ comparison, result = before.comparison, before.result
154
+ else:
155
+ result = None # a failed run's: it holds no variable
156
+ if result is None:
157
+ result = expected_result(item.test)
158
+ self._starts += 1
159
+ self._runs[model_id] = _Run(
160
+ item=item,
161
+ comparison=comparison,
162
+ result=result,
163
+ live=self._live(),
164
+ trace=self._trace(),
165
+ revision=run_revision(self._starts),
166
+ )
167
+
168
+ def ended(self, item: QueueItem) -> None:
169
+ """``item`` ended, as the QUEUE says — the reader's state is not
170
+ asked. A finished run's entry goes: the page shows its result. So
171
+ does an unfinished one's that left no row; one that left a partial
172
+ file stays, to be shown and never scored."""
173
+ model_id = item.model_id
174
+ run = self._runs.get(model_id)
175
+ if run is None or run.item is not item:
176
+ return
177
+ run.reader, run.trace, run.asked = None, self._trace(), {}
178
+ path = self._path(run)
179
+ reached = (
180
+ read_mat_last_time(path)
181
+ if item.state is not ItemState.FINISHED and path is not None
182
+ else None
183
+ )
184
+ if reached is None:
185
+ del self._runs[model_id]
186
+ return
187
+ outcome = item.run_result
188
+ run.partial = Partial(
189
+ how_ended(
190
+ cancelled=item.state is ItemState.CANCELLED,
191
+ timed_out=outcome is not None and outcome.timed_out,
192
+ ),
193
+ reached,
194
+ run.stop_time,
195
+ )
196
+
197
+ def clear(self) -> None:
198
+ """The project is closing: nothing of its runs is kept."""
199
+ self._runs.clear()
200
+
201
+ # -- what a page asks ------------------------------------------------------
202
+
203
+ def ask(self, model_id: str, page: object, names: Collection[str]) -> None:
204
+ """``page`` draws ``names`` of the running ``model_id``: the next
205
+ polls read them. Asked again every second it still does."""
206
+ run = self._runs.get(model_id)
207
+ if run is not None and run.partial is None and run.live:
208
+ run.asked[page] = (frozenset(names), self._polls)
209
+
210
+ def poll(self) -> None:
211
+ """Read every run in progress whose file grows, once: the names its
212
+ pages asked for, and how far it has got. On the UI loop."""
213
+ self._polls += 1
214
+ for model_id, run in list(self._runs.items()):
215
+ if run.partial is not None or not run.live:
216
+ continue
217
+ if run.reader is None:
218
+ path = self._path(run)
219
+ if path is None:
220
+ continue # its directory is not keyed yet
221
+ run.reader = LiveMatReader(path, max_rows=run.trace.cap)
222
+ run.asked = {
223
+ page: (names, at)
224
+ for page, (names, at) in run.asked.items()
225
+ if self._polls - at <= ASK_LIFE
226
+ }
227
+ names = frozenset().union(*(n for n, _ in run.asked.values()))
228
+ run.trace.take(run.reader.poll(sorted(names)))
229
+ if run.trace.run != run.announced and run.trace.state == RUNNING:
230
+ # This file's header is whole: its variables can be listed,
231
+ # and a file that replaced another's is listed again.
232
+ run.announced = run.trace.run
233
+ self._emit("live", frozenset({model_id}))
234
+
235
+ def polled(self, model_id: str) -> bool:
236
+ """Whether ``model_id`` is running with a live view."""
237
+ run = self._runs.get(model_id)
238
+ return run is not None and run.partial is None and run.live
239
+
240
+ def moved(self, model_id: str, name: str) -> bool:
241
+ """Whether the last poll brought ``name`` of ``model_id`` a sample."""
242
+ run = self._runs.get(model_id)
243
+ return run is not None and run.partial is None and run.trace.moved(name)
244
+
245
+ # -- what the Session reads ------------------------------------------------
246
+
247
+ def kept(
248
+ self, model_id: str
249
+ ) -> tuple[TestComparison | None, TestResult | None] | None:
250
+ """The comparison and the result ``model_id``'s run replaced — what
251
+ its page is laid out by while the Session has no result of it — or
252
+ None for a model with no entry here."""
253
+ run = self._runs.get(model_id)
254
+ return None if run is None else (run.comparison, run.result)
255
+
256
+ def word(self, model_id: str) -> str | None:
257
+ """What every criterion of ``model_id`` says in place of a verdict:
258
+ waiting while its run is in progress, not scored after one that did
259
+ not finish; None for a model with no entry here."""
260
+ run = self._runs.get(model_id)
261
+ if run is None:
262
+ return None
263
+ return WAITING if run.partial is None else NOT_SCORED
264
+
265
+ def partial(self, model_id: str) -> Partial | None:
266
+ run = self._runs.get(model_id)
267
+ return None if run is None else run.partial
268
+
269
+ def progress(self, item: QueueItem) -> str | None:
270
+ """``t = 42 / 100 s`` (or ``starting``) for a running ``item`` on a
271
+ backend with a live view; None on one without."""
272
+ if not self._live():
273
+ return None
274
+ run = self._runs.get(item.model_id)
275
+ if run is None or run.item is not item or run.partial is not None:
276
+ # Running by the queue's word, its start still on its way to
277
+ # the Session (a tick): nothing is read yet.
278
+ return STARTING_WORD
279
+ return progress(run.trace.state, run.trace.time, run.stop_time)
280
+
281
+ def names(self, model_id: str) -> tuple[str, ...] | None:
282
+ """The variables of ``model_id``'s file where the Session has no
283
+ result of it: a running test's growing file, once its header is
284
+ whole — the reader's own, from the header it reads the rows
285
+ through — and a partial result's, from the one parse its series
286
+ are read through. None before a header is whole, on a backend
287
+ without a live view, and for a model with no entry here."""
288
+ run = self._runs.get(model_id)
289
+ if run is None:
290
+ return None
291
+ if run.partial is not None:
292
+ file = self._file(run)
293
+ return None if file is None else file.names
294
+ if run.announced is None or run.reader is None:
295
+ return None
296
+ return run.reader.names
297
+
298
+ def series(self, model_id: str, name: str) -> tuple[Series | None, int] | None:
299
+ """What ``model_id``'s page draws as ``name``'s Actual in place of a
300
+ result's: ``(series, samples)`` — the samples read so far while its
301
+ run is in progress (none yet: empty; None on a backend without a
302
+ live view), a partial file's after one that did not finish
303
+ (``samples`` 0: the whole series, counted by whoever decimates it).
304
+ None for a model with no entry here."""
305
+ run = self._runs.get(model_id)
306
+ if run is None:
307
+ return None
308
+ if run.partial is None:
309
+ if not run.live:
310
+ return None, 0
311
+ # Empty before the first sample, never absent: the Actual trace
312
+ # keeps its place in the figure from the run's first second, so
313
+ # the legend's choices stay on the traces they were made on.
314
+ series = run.trace.series(name)
315
+ return series or (np.empty(0), np.empty(0)), run.trace.samples(name)
316
+ if name not in run.series:
317
+ # A name at a time, as the page asks — through one parse of the
318
+ # file's header, and with one time array for them all.
319
+ file = self._file(run)
320
+ read = None if file is None else file.series([name])
321
+ run.series[name] = (read or {}).get(name)
322
+ return run.series[name], 0
323
+
324
+ def dress(self, model_id: str, figure: dict, colour: str) -> dict:
325
+ """``figure`` as ``model_id``'s page draws it: one of its run's own
326
+ revision, the time axis spanning the whole run while a live view
327
+ grows into it, and after an unfinished run the marker where it
328
+ stopped, in ``colour``."""
329
+ run = self._runs.get(model_id)
330
+ if run is None or not run.live:
331
+ return figure
332
+ figure = spanned(figure, run.stop_time, run.revision)
333
+ if run.partial is None:
334
+ return figure
335
+ return stopped_marker(figure, run.partial, colour)
336
+
337
+ # -- internals ---------------------------------------------------------------
338
+
339
+ def _live(self) -> bool:
340
+ """Whether the project's backend writes a result file that grows
341
+ while a run is in progress."""
342
+ project = self._session.project
343
+ return (
344
+ project is not None
345
+ and Capability.LIVE_RESULTS in project.backend.capabilities
346
+ )
347
+
348
+ def _trace(self) -> LiveTrace:
349
+ project = self._session.project
350
+ budget = project.config.max_embedded_samples if project is not None else 0
351
+ return LiveTrace(cap=budget if budget and budget > 0 else 1000)
352
+
353
+ def _backend(self):
354
+ project = self._session.project
355
+ assert project is not None # an entry exists only under a project
356
+ return project.backend
357
+
358
+ def _dir(self, run: _Run) -> Path | None:
359
+ """The run's directory, once it is keyed — a moment after its item
360
+ turns RUNNING, its last run's files cleared by then."""
361
+ project = self._session.project
362
+ key = run.item.test_key
363
+ if project is None or key is None:
364
+ return None
365
+ work_dir = project.config.work_dir
366
+ assert work_dir is not None # resolved in Config.__post_init__
367
+ return work_dir / key
368
+
369
+ def _file(self, run: _Run) -> ResultMatFile | None:
370
+ """A partial result's file, opened once; None while it is not
371
+ there (its directory wiped for the next run)."""
372
+ if run.file is None:
373
+ path = self._path(run)
374
+ if path is not None:
375
+ run.file = ResultMatFile(path)
376
+ file = run.file
377
+ return file if file is not None and file.path.exists() else None
378
+
379
+ def _path(self, run: _Run) -> Path | None:
380
+ """The run's growing result file; None on a backend without one,
381
+ and until its directory is keyed."""
382
+ test_dir = self._dir(run)
383
+ if test_dir is None or not run.live:
384
+ return None
385
+ return type(self._backend()).live_result_path(test_dir)
@@ -0,0 +1,127 @@
1
+ """The filter both drawers of the Test page ask (Tests and Variables): a
2
+ needle as a substring, a glob or a typed regex, the regex held to a budget
3
+ of its own CPU and of the UI loop's wall time."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from collections.abc import Callable
9
+ from time import monotonic, thread_time
10
+
11
+ from ..backends.types import is_glob_pattern
12
+
13
+ #: Seconds of the searching thread's CPU (`thread_time`) ONE typed regex
14
+ #: may search, over every name of one filter: the verdict "the pattern took
15
+ #: too long" is the filter's own CPU, so waits for the GIL never make a
16
+ #: plain pattern too long. An ordinary search costs ~2.5 us a name and
17
+ #: reading that clock ~0.85 us more (a system call here; `monotonic` is
18
+ #: ~0.075 us): on an idle machine TRANSFORM's largest result (76,992 names)
19
+ #: takes ~0.27 s, and 0.25 s timed out a plain `T` over 100,000. 1 s leaves
20
+ #: ~3.5x there; a loaded WSL2 host measured up to 0.99 s of thread CPU for
21
+ #: the same filter, its `thread_time` counting time the host took the vCPU
22
+ #: away. The clock is the thread's own because between searches the filter
23
+ #: waits for the GIL on any busy thread of the tool (its overlay index
24
+ #: json.loads for ~15 s after a page opens): in wall time those names took
25
+ #: 0.4-0.9 s beside one or two such threads, two to three times their own
26
+ #: CPU. Each search holds the GIL (``concurrent=False``): released, every
27
+ #: search waited on a busy thread again, 0.3-0.9 ms a name beside one, and
28
+ #: `T` on a wall-clock budget ran out after 1,200-3,600 names.
29
+ #: The package times each search's ``timeout`` with C ``clock()``: on
30
+ #: Linux and macOS the CPU time of the whole process, every thread summed;
31
+ #: on Windows, wall time.
32
+ REGEX_BUDGET = 1.0
33
+ #: Seconds of WALL time (`monotonic`) the same filter may take. It runs on
34
+ #: the UI event loop, so every tab is frozen for its wall time, which the
35
+ #: CPU budget does not bound: beside a thread holding the GIL ~0.2 s at a
36
+ #: time, a backtracking pattern froze the tool 17.4 s on 1 s of its own
37
+ #: CPU, and a plain `T` over TRANSFORM's largest result took 10.7-20.5 s
38
+ #: and never ran out. This is what bounds the freeze every tab sees. It is
39
+ #: checked between searches: a search is given the smaller time left, but
40
+ #: `regex` counts it on ``clock()`` — the process's CPU on Linux and macOS —
41
+ #: so one search is bounded by the CPU left and can pass the wall time left.
42
+ REGEX_WALL_BACKSTOP = 5 * REGEX_BUDGET
43
+
44
+
45
+ def matcher(needle: str, *, regex: bool, case: bool) -> Callable[[str], bool]:
46
+ """What the filter box keeps. A regex (``.*`` on) searches; a glob (a
47
+ ``*`` or ``?`` in it) matches the WHOLE text with ``[ ]`` literal — the
48
+ rule a spec's ``variables`` list uses, so what you type here means what
49
+ it means there (`_glob`); anything else is a substring. ``regex.error``
50
+ for a bad regex, and ``TimeoutError`` from the returned closure once the
51
+ regex has searched `REGEX_BUDGET` seconds of its thread's CPU in all,
52
+ for the drawer to say.
53
+
54
+ A typed regex is the `regex` package's, never `re`'s: `re` has no
55
+ timeout, so a pattern that backtracks runs to its end (`^(a|aa)+$` over
56
+ 60 a's and a ``b``: days), and `regex` stops a search at its
57
+ ``timeout``. Both hold the GIL for a whole search, `regex` because
58
+ ``concurrent=False`` asks it to, and the filter runs on the UI event
59
+ loop, so what bounds the freeze every tab sees is
60
+ `REGEX_WALL_BACKSTOP`'s wall time, not a worker thread; `REGEX_BUDGET`,
61
+ the filter's own CPU, decides a pattern that took too long. It is imported here, not at the
62
+ top: it comes with the ``[author]`` extra, and this module imports
63
+ without it. A glob is not left to a regex either — compiled to one,
64
+ ``*a*a*a*a*a*a*a*b`` over one 60-character name took over 20 s on the
65
+ UI event loop."""
66
+ flags = 0 if case else re.IGNORECASE
67
+ if not needle:
68
+ return lambda text: True
69
+ if regex:
70
+ import regex as regex_package
71
+
72
+ # unbounded: `(a{5000}){5000}` compiles for seconds, or out of memory
73
+ pattern = regex_package.compile(needle, 0 if case else regex_package.IGNORECASE)
74
+ # CPU, then wall; set at the first name, not at compile. The closure
75
+ # is one thread's: `thread_time` readings from two threads cannot be
76
+ # compared, and each drawer builds and uses it within one pass
77
+ # (`VariablesDrawer._build`, `TestsDrawer._kept`).
78
+ deadlines: list[float] = []
79
+
80
+ def search(text: str) -> bool:
81
+ cpu, wall = thread_time(), monotonic()
82
+ if not deadlines:
83
+ deadlines.extend((cpu + REGEX_BUDGET, wall + REGEX_WALL_BACKSTOP))
84
+ left = min(deadlines[0] - cpu, deadlines[1] - wall)
85
+ if left <= 0:
86
+ raise TimeoutError("the pattern took too long")
87
+ return pattern.search(text, timeout=left, concurrent=False) is not None
88
+
89
+ return search
90
+ if is_glob_pattern(needle):
91
+ return _glob(needle, flags)
92
+ folded = needle if case else needle.lower()
93
+ return lambda text: folded in (text if case else text.lower())
94
+
95
+
96
+ def _glob(needle: str, flags: int) -> Callable[[str], bool]:
97
+ """``needle`` matched against the whole text as `_pattern_to_regex`'s
98
+ anchored regex matches it, without backtracking. Split on ``*``, each
99
+ piece is a fixed-length regex (``?`` is ``.``, everything else literal)
100
+ with no quantifier: the first must match at the start, the last at the
101
+ end, and each middle one is found left to right — the leftmost place
102
+ leaves the most text for the rest, so no other place is ever tried. A
103
+ ``*`` spans no newline and ``?`` matches none, as the regex's ``.``
104
+ does not (`test_the_glob_means_what_the_specs_regex_means`)."""
105
+ pieces = [
106
+ re.compile("".join("." if ch == "?" else re.escape(ch) for ch in p), flags)
107
+ for p in needle.split("*")
108
+ ]
109
+ if len(pieces) == 1: # a ``?`` and no ``*``
110
+ (only,) = pieces
111
+ return lambda text: only.fullmatch(text) is not None
112
+ head, *middle, tail = pieces
113
+ width = len(needle.rsplit("*", 1)[1]) # the last piece's, one per character
114
+
115
+ def keep(text: str) -> bool:
116
+ start, end = head.match(text), len(text) - width
117
+ if start is None or end < start.end() or tail.fullmatch(text, end) is None:
118
+ return False
119
+ at = start.end()
120
+ for piece in middle:
121
+ found = piece.search(text, at, end)
122
+ if found is None or text.find("\n", at, found.start()) != -1:
123
+ return False
124
+ at = found.end()
125
+ return text.find("\n", at, end) == -1
126
+
127
+ return keep
@@ -0,0 +1,92 @@
1
+ """The add form's variable picker as data (the plan's K2): every name a
2
+ criterion may score, in three sections — the plot's own series, every
3
+ other variable of the result file, the diagnostics the run recorded —
4
+ filtered on what a row reads as OR its result-file name, at most `CAP`
5
+ rows. The shell draws them (`shell/picker.py`) and knows none of the
6
+ rules, as the Variables drawer draws `variable_rows`."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Mapping, Sequence
11
+ from dataclasses import dataclass
12
+
13
+ from ..backends.types import VariableText
14
+
15
+ #: The drawer's cap (`variables_drawer.ROWS`): a result of 76,992 names
16
+ #: drew 76,992 rows otherwise.
17
+ CAP = 200
18
+ ON_PLOT, OTHER, DIAGNOSTICS = "On this plot", "Other variables", "Diagnostics"
19
+ SECTIONS = (ON_PLOT, OTHER, DIAGNOSTICS)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class PickRow:
24
+ name: str #: the result-file name — what a leaf is written against
25
+ text: str #: what it reads as in the test's scope, else the name
26
+ section: str
27
+ #: the plain label (`VariableText.label`): what the row shows, the
28
+ #: muted name beside it
29
+ label: str
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Picked:
34
+ sections: list[tuple[str, list[PickRow]]]
35
+ shown: int
36
+ total: int
37
+
38
+ @property
39
+ def capped(self) -> str | None:
40
+ """The line under a capped list, else None."""
41
+ if self.shown >= self.total:
42
+ return None
43
+ return f"Showing {self.shown} of {self.total:,} — narrow the search"
44
+
45
+
46
+ def picker_rows(
47
+ *,
48
+ on_plot: Sequence[str],
49
+ names: Sequence[str],
50
+ diagnostics: Sequence[str],
51
+ texts: Mapping[str, VariableText],
52
+ keep: Callable[[str], bool],
53
+ cap: int = CAP,
54
+ ) -> Picked:
55
+ """The sections in the order On this plot / Other variables /
56
+ Diagnostics, each name once (in the first section that has it); an
57
+ empty section is absent. ``names`` is every name of the result file
58
+ (`Session.variable_names`); ``texts`` the test's
59
+ (`Session.variable_texts`), a name it lacks reading as itself;
60
+ ``diagnostics`` what the run RECORDED as
61
+ one — each is listed there and only there, whether or not the file
62
+ listing names it too (a Dymola file holds `CPUtime` as a variable),
63
+ unless the plot draws it: a diagnostic on the plot is listed under On
64
+ this plot, as every series of the plot is.
65
+ ``keep`` is `needle.matcher`'s closure: its ``TimeoutError`` reaches
66
+ the caller, which says it."""
67
+ first = list(dict.fromkeys(on_plot))
68
+ plotted = set(first)
69
+ diag = [d for d in dict.fromkeys(diagnostics) if d not in plotted]
70
+ taken = plotted | set(diag)
71
+ groups = (
72
+ (ON_PLOT, first),
73
+ (OTHER, [n for n in dict.fromkeys(names) if n not in taken]),
74
+ (DIAGNOSTICS, diag),
75
+ )
76
+ out: list[tuple[str, list[PickRow]]] = []
77
+ total = shown = 0
78
+ for title, members in groups:
79
+ rows = []
80
+ for name in members:
81
+ entry = texts.get(name)
82
+ text = entry.text if entry is not None else name
83
+ if not (keep(text) or (text != name and keep(name))):
84
+ continue
85
+ total += 1
86
+ if shown < cap:
87
+ label = entry.label if entry is not None else name
88
+ rows.append(PickRow(name, text, title, label))
89
+ shown += 1
90
+ if rows:
91
+ out.append((title, rows))
92
+ return Picked(out, shown, total)