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
dstf/backends/pool.py ADDED
@@ -0,0 +1,602 @@
1
+ """The simulation pool: a queue of tests that stays open.
2
+
3
+ ``Backend.run_tests`` runs one fixed list and closes its workers. The
4
+ authoring tool queues tests while others run, takes one back before it
5
+ starts, stops one mid-simulation and wants each result the moment its test
6
+ ends — so this is the same dispatch kept open: first in, first out, a test
7
+ never queued twice, workers that live until :meth:`SimulationPool.close`.
8
+
9
+ How ONE test runs is not decided here. Each pool thread drives an
10
+ :class:`~dstf.backends.base.ItemRunner` it gets from the backend
11
+ (``Backend.item_runner``): a persistent worker with its restart accounting
12
+ for Dymola / OpenModelica / Julia — the object ``run_tests`` drives, so a
13
+ test fails, retries and restarts identically in both — and the backend's own
14
+ single-test path for a backend without workers. What happens around the run
15
+ on disk (the test's key, its wipe, its recorded verdict, its result read
16
+ back) is the :class:`ItemPath` the pool is given;
17
+ :func:`dstf.pipeline.open_pool` builds the pool with the run pipeline's.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import collections
23
+ import dataclasses
24
+ import logging
25
+ import threading
26
+ import time
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass, field
29
+ from enum import Enum
30
+ from typing import TYPE_CHECKING, Protocol
31
+
32
+ from .base import ItemRunner, no_worker_message
33
+ from .types import Capability, TestResult, TestRunResult
34
+
35
+ if TYPE_CHECKING:
36
+ from ..discovery.library_state import LibraryState
37
+ from ..discovery.test_registry import TestModel
38
+ from .base import Backend
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ class ItemState(str, Enum):
44
+ """Where a :class:`QueueItem` is. The last four are final."""
45
+
46
+ WAITING = "waiting"
47
+ RUNNING = "running"
48
+ #: Its simulation ran and its result was read back (``item.result``).
49
+ FINISHED = "finished"
50
+ #: Its simulation failed or timed out, its result could not be read, or
51
+ #: no worker could start (``item.message`` says which).
52
+ FAILED = "failed"
53
+ #: Stopped while running, by :meth:`SimulationPool.cancel` or by
54
+ #: :meth:`SimulationPool.close`. Recorded as cancelled; no result.
55
+ CANCELLED = "cancelled"
56
+ #: Taken out while waiting, by :meth:`SimulationPool.remove` or by
57
+ #: :meth:`SimulationPool.close`. It never ran and nothing was recorded.
58
+ REMOVED = "removed"
59
+
60
+
61
+ class Refused(RuntimeError):
62
+ """The pool did not do what was asked; ``str()`` is the sentence why."""
63
+
64
+
65
+ @dataclass(eq=False)
66
+ class QueueItem:
67
+ """One submitted test and what became of it. The pool writes the fields
68
+ (``test`` too: :meth:`SimulationPool.update`); a caller reads them —
69
+ after ``on_end``, all of them are final."""
70
+
71
+ test: TestModel
72
+ #: The source files as swept at the Queue click that submitted it: it
73
+ #: runs on a worker that has read them as they were then, or later.
74
+ #: ``None``: nothing is asked of the worker (a source no worker keeps
75
+ #: loaded).
76
+ library: LibraryState | None = None
77
+ state: ItemState = ItemState.WAITING
78
+ #: The test's key in the work dir, once it started.
79
+ test_key: str | None = None
80
+ #: The worker (pool thread) it ran on.
81
+ worker_id: int | None = None
82
+ #: What the backend's run returned; ``None`` while it has not ended, and
83
+ #: for an item that never ran.
84
+ run_result: TestRunResult | None = None
85
+ #: The result read back; ``None`` unless the item FINISHED or its
86
+ #: simulation FAILED (then it carries the failure).
87
+ result: TestResult | None = None
88
+ #: The backend's message for a FAILED item, ``"Cancelled"`` for a
89
+ #: cancelled one.
90
+ message: str | None = None
91
+ #: Wall-clock (``time.time()``) stamps.
92
+ submitted_at: float = field(default_factory=time.time)
93
+ started_at: float | None = None
94
+ ended_at: float | None = None
95
+ # A stop was asked of it while RUNNING (cancel or close): a run that then
96
+ # does not succeed is cancelled, whatever the backend called it.
97
+ _stop_asked: bool = field(default=False, repr=False)
98
+
99
+ @property
100
+ def model_id(self) -> str:
101
+ return self.test.model_id
102
+
103
+ @property
104
+ def stopping(self) -> bool:
105
+ """A stop was asked of it and it has not ended: still RUNNING, on
106
+ its way to CANCELLED (or FINISHED, where its simulation got there
107
+ first). For as long as the backend takes — a simulation that does
108
+ not look at the request runs on until its worker's grace is over
109
+ and the worker is killed."""
110
+ return self._stop_asked and self.state is ItemState.RUNNING
111
+
112
+
113
+ class ItemPath(Protocol):
114
+ """What happens around one item's run, on disk."""
115
+
116
+ def begin(self, test: TestModel) -> str:
117
+ """Before the run: the test's key, with its state from an earlier
118
+ run cleared."""
119
+ ...
120
+
121
+ def end(
122
+ self, test: TestModel, test_key: str, run_result: TestRunResult
123
+ ) -> TestResult | None:
124
+ """After the run: record what it came to, and read the result back
125
+ (``None`` for a cancelled run)."""
126
+ ...
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class StartingWorker:
131
+ """A worker that is not ready for a test right now: being brought up —
132
+ its first start, or a restart after a death — or up and reading the
133
+ library again for the item it is about to run (``reload``)."""
134
+
135
+ worker_id: int
136
+ #: The step the start (or the reload) is in, in the backend's own words
137
+ #: (``loadFile("/lib/package.mo")``, ``its launch``); ``""`` when the
138
+ #: backend names none.
139
+ phase: str
140
+ #: Seconds since this start (or reload) began.
141
+ seconds: float
142
+ #: Whether it is being brought BACK: a worker that died is restarted
143
+ #: under the item it is to run, which is running meanwhile; a first
144
+ #: start has no item yet.
145
+ restart: bool = False
146
+ #: Whether it is up and reading the library again: its files changed
147
+ #: since it read them, and its item is running meanwhile.
148
+ reload: bool = False
149
+
150
+
151
+ @dataclass(eq=False)
152
+ class _Slot:
153
+ """One pool thread and the runner it drives."""
154
+
155
+ id: int
156
+ thread: threading.Thread | None = None
157
+ runner: ItemRunner | None = None
158
+ item: QueueItem | None = None
159
+
160
+
161
+ class SimulationPool:
162
+ """A FIFO queue of tests over a backend's runners.
163
+
164
+ ``submit`` appends and returns the item — with the state of the source
165
+ files it is to run against, which a live worker that read them earlier
166
+ reads again first; ``update`` hands a waiting item its test as it
167
+ stands now; ``remove`` takes a waiting item back;
168
+ ``cancel`` stops a running one where the backend can
169
+ (:attr:`Capability.CANCEL`); ``close`` ends everything. All five are
170
+ safe from any thread and return promptly (``close`` waits for the
171
+ threads).
172
+
173
+ **Callbacks.** ``on_start(item)`` once when an item starts running,
174
+ ``on_end(item)`` once when it has ended — FINISHED, FAILED or CANCELLED,
175
+ its result read back and every field final. Both run on a POOL thread,
176
+ never the caller's: the one that ran the item, ``on_start`` before
177
+ ``on_end``, outside the pool's lock; callbacks of different items may
178
+ run at the same time on different threads. A callback may call
179
+ ``submit`` / ``remove`` / ``cancel`` and set ``workers``; it must not
180
+ call ``close`` (which joins the thread it is on). An item that never
181
+ started gets no ``on_start``, and gets ``on_end`` only when the POOL
182
+ failed it (no worker could start; on the thread of the last worker to
183
+ leave) — not when the caller removed it or closed the pool.
184
+
185
+ **Workers.** None exists until the first ``submit``; then as many as
186
+ there are items to run, up to ``workers`` (``Config.parallel`` unless
187
+ given). Setting ``workers`` higher starts more as items need them;
188
+ lower lets the surplus go as each finishes its item. ``starting`` lists
189
+ the ones still coming up, and the ones reading the library again, with
190
+ the phase each is in. They live until
191
+ ``close`` — which the owner must call: the pool's threads are daemons
192
+ and do not keep a dying process open, but nothing else ends the
193
+ workers' tool processes.
194
+
195
+ **A backend that cannot start.** A worker whose start fails or which
196
+ cannot be restarted leaves. When none is left, every waiting item FAILS
197
+ with the backend's own message (``No OpenModelica worker could start —
198
+ worker 0: ...``), ``stopped`` holds that message and ``submit`` refuses
199
+ with it: a new pool is the retry.
200
+ """
201
+
202
+ def __init__(
203
+ self,
204
+ backend: Backend,
205
+ path: ItemPath,
206
+ *,
207
+ on_start: Callable[[QueueItem], None] | None = None,
208
+ on_end: Callable[[QueueItem], None] | None = None,
209
+ workers: int | None = None,
210
+ ):
211
+ """Raises ``RuntimeError`` when *backend* cannot run one test at a
212
+ time (``Backend.prepare_item_runners``)."""
213
+ self.backend = backend
214
+ #: The pool's :class:`ItemPath`, for what it offers beyond the two
215
+ #: steps (the run pipeline's writes ``status.json`` when asked).
216
+ self.path = path
217
+ self._on_start = on_start
218
+ self._on_end = on_end
219
+ self._cond = threading.Condition()
220
+ self._waiting: collections.deque[QueueItem] = collections.deque()
221
+ #: Waiting or running, by model id — the "never queued twice" rule.
222
+ self._active: dict[str, QueueItem] = {}
223
+ self._slots: dict[int, _Slot] = {}
224
+ self._target = max(
225
+ 1, int(backend.config.parallel if workers is None else workers)
226
+ )
227
+ self._closing = False
228
+ self._stopped: str | None = None
229
+ #: Why each worker that left for good did, by worker id.
230
+ self._failures: dict[int, str] = {}
231
+ #: The latest state an item was submitted with: what a worker that
232
+ #: starts NOW reads at least.
233
+ self._library: LibraryState | None = None
234
+ backend.prepare_item_runners()
235
+
236
+ # -- the caller's side ---------------------------------------------
237
+
238
+ @property
239
+ def workers(self) -> int:
240
+ """How many workers the pool may run at once."""
241
+ return self._target
242
+
243
+ @workers.setter
244
+ def workers(self, count: int) -> None:
245
+ with self._cond:
246
+ self._target = max(1, int(count))
247
+ self._spawn_slots()
248
+ self._cond.notify_all()
249
+
250
+ @property
251
+ def can_cancel(self) -> bool:
252
+ """Whether a running item can be stopped on this backend."""
253
+ return Capability.CANCEL in self.backend.capabilities
254
+
255
+ @property
256
+ def stopped(self) -> str | None:
257
+ """The message the pool stopped with, or ``None`` while it takes
258
+ items."""
259
+ return self._stopped
260
+
261
+ @property
262
+ def waiting(self) -> tuple[QueueItem, ...]:
263
+ """The waiting items, next to run first."""
264
+ with self._cond:
265
+ return tuple(self._waiting)
266
+
267
+ @property
268
+ def running(self) -> tuple[QueueItem, ...]:
269
+ """The running items, by worker id."""
270
+ with self._cond:
271
+ return tuple(
272
+ slot.item
273
+ for _, slot in sorted(self._slots.items())
274
+ if slot.item is not None
275
+ )
276
+
277
+ @property
278
+ def starting(self) -> tuple[StartingWorker, ...]:
279
+ """The workers being brought up right now and the ones reading the
280
+ library again, by worker id — empty once each is ready or has
281
+ failed. A large library is minutes of load in a live tool: this is
282
+ what says so while the items wait."""
283
+ with self._cond:
284
+ runners = [
285
+ (slot_id, slot.runner, slot.item is not None)
286
+ for slot_id, slot in sorted(self._slots.items())
287
+ if slot.runner is not None
288
+ ]
289
+ found = []
290
+ for slot_id, runner, restart in runners:
291
+ state = runner.starting()
292
+ if state is not None:
293
+ found.append(StartingWorker(slot_id, *state, restart=restart))
294
+ continue
295
+ state = runner.reloading()
296
+ if state is not None:
297
+ found.append(StartingWorker(slot_id, *state, reload=True))
298
+ return tuple(found)
299
+
300
+ def submit(self, test: TestModel, library: LibraryState | None = None) -> QueueItem:
301
+ """Append *test*. Raises :class:`Refused` when it is already waiting
302
+ or running, and when the pool is closed or stopped.
303
+
304
+ *library* is the state of the source files as the caller swept them
305
+ for this submit (one state per Queue click, shared by its items).
306
+ Before the item runs, a worker that read the files at an older
307
+ state reads them again (``ItemRunner.run``); one that read them at
308
+ this state or a later one does nothing."""
309
+ with self._cond:
310
+ if self._closing:
311
+ raise Refused("the simulation queue is closed")
312
+ if self._stopped is not None:
313
+ raise Refused(self._stopped)
314
+ active = self._active.get(test.model_id)
315
+ if active is not None:
316
+ doing = "running" if active.state is ItemState.RUNNING else "queued"
317
+ raise Refused(f"{test.model_id} is already {doing}")
318
+ item = QueueItem(test, library)
319
+ self._note(library)
320
+ self._active[test.model_id] = item
321
+ self._waiting.append(item)
322
+ self._spawn_slots()
323
+ self._cond.notify()
324
+ return item
325
+
326
+ def update(
327
+ self, item: QueueItem, test: TestModel, library: LibraryState | None = None
328
+ ) -> bool:
329
+ """Hand a waiting *item* the test as it stands now — the same model,
330
+ its settings as they are at this call — keeping its place: an item
331
+ runs with the last test it was given before it started. ``False``
332
+ once it has started (or ended): nothing is changed.
333
+
334
+ Its library state stays the one it was submitted with — a test's
335
+ settings follow the spec, the files it was queued against do not —
336
+ unless *library* is given and is a later one: a click that read
337
+ the test's file again hands the item what it read, and the state
338
+ it read it at. A state never moves back."""
339
+ if test.model_id != item.model_id:
340
+ raise ValueError(f"{test.model_id} is not {item.model_id}")
341
+ with self._cond:
342
+ if item.state is not ItemState.WAITING:
343
+ return False
344
+ item.test = test
345
+ if library is not None and (
346
+ item.library is None or library.serial > item.library.serial
347
+ ):
348
+ item.library = library
349
+ self._note(library)
350
+ return True
351
+
352
+ def _note(self, library: LibraryState | None) -> None:
353
+ """Under the lock: *library* is a state an item carries."""
354
+ if library is not None and (
355
+ self._library is None or library.serial > self._library.serial
356
+ ):
357
+ self._library = library
358
+
359
+ def remove(self, item: QueueItem) -> bool:
360
+ """Take a waiting *item* out. ``False`` once it has started (or
361
+ ended): nothing is changed."""
362
+ with self._cond:
363
+ if item.state is not ItemState.WAITING:
364
+ return False
365
+ self._waiting.remove(item)
366
+ del self._active[item.model_id]
367
+ item.state, item.ended_at = ItemState.REMOVED, time.time()
368
+ return True
369
+
370
+ def cancel(self, item: QueueItem) -> bool:
371
+ """Ask a running *item*'s simulation to stop: ``True`` when it was
372
+ asked. It then ends CANCELLED — unless its simulation had already
373
+ finished, which stays the result it is, FINISHED (``on_end`` follows
374
+ from its thread either way). ``False`` when it is not running —
375
+ waiting (``remove`` it) or already ended. Raises :class:`Refused`
376
+ when it runs on a backend that cannot stop a test."""
377
+ with self._cond:
378
+ if item.state is not ItemState.RUNNING:
379
+ return False
380
+ if not self.can_cancel:
381
+ raise Refused(
382
+ f"{self.backend.label} cannot stop a running simulation: "
383
+ f"{item.model_id} runs to its end, or to its timeout"
384
+ )
385
+ item._stop_asked = True
386
+ slot = self._slots[item.worker_id] # type: ignore[index]
387
+ runner, test_key = slot.runner, item.test_key
388
+ # Before its key exists the item has not reached its runner: the
389
+ # flag set above is what its thread sees. After, the runner expects
390
+ # the item (announced under this lock), so the stop reaches its run
391
+ # — and no other: by now the item may have ended and the same test
392
+ # be running again, under the same key.
393
+ if runner is not None and test_key is not None:
394
+ runner.cancel(item)
395
+ return True
396
+
397
+ def close(self) -> None:
398
+ """End the pool: waiting items are REMOVED, running ones stopped
399
+ (CANCELLED, with their ``on_end``), every worker closed and every
400
+ pool thread joined. A backend that cannot stop a test finishes the
401
+ one it is running first. Idempotent; not callable from a callback.
402
+ """
403
+ with self._cond:
404
+ if any(
405
+ slot.thread is threading.current_thread()
406
+ for slot in self._slots.values()
407
+ ):
408
+ raise RuntimeError("SimulationPool.close() called from a pool callback")
409
+ self._closing = True
410
+ for item in self._waiting:
411
+ del self._active[item.model_id]
412
+ item.state, item.ended_at = ItemState.REMOVED, time.time()
413
+ self._waiting.clear()
414
+ slots = list(self._slots.values())
415
+ stops = []
416
+ for slot in slots:
417
+ if slot.item is not None:
418
+ slot.item._stop_asked = True
419
+ if slot.runner is not None:
420
+ key = slot.item.test_key if slot.item is not None else None
421
+ stops.append((slot.runner, key))
422
+ self._cond.notify_all()
423
+ for runner, test_key in stops:
424
+ try:
425
+ runner.stop(test_key)
426
+ except Exception:
427
+ logger.exception("pool: stopping worker %s raised", runner.worker_id)
428
+ for slot in slots:
429
+ if slot.thread is not None:
430
+ slot.thread.join()
431
+
432
+ # -- the pool's threads --------------------------------------------
433
+
434
+ def _spawn_slots(self) -> None:
435
+ """Under the lock: one thread per item there is to run, up to the
436
+ target."""
437
+ busy = sum(1 for slot in self._slots.values() if slot.item is not None)
438
+ want = min(self._target, len(self._waiting) + busy)
439
+ while len(self._slots) < want:
440
+ slot = _Slot(next(i for i in range(want) if i not in self._slots))
441
+ slot.thread = threading.Thread(
442
+ target=self._slot_loop,
443
+ args=(slot,),
444
+ name=f"{self.backend.label.lower().replace(' ', '-')}-pool-{slot.id}",
445
+ # An owner that dies without close() must not be held open
446
+ # by an idle queue. Its workers' tool processes are then
447
+ # left running: close() is what ends them.
448
+ daemon=True,
449
+ )
450
+ self._slots[slot.id] = slot
451
+ slot.thread.start()
452
+
453
+ def _slot_loop(self, slot: _Slot) -> None:
454
+ left_because: str | None = None
455
+ try:
456
+ try:
457
+ runner = self.backend.item_runner(slot.id)
458
+ with self._cond:
459
+ slot.runner = runner
460
+ if self._closing:
461
+ return
462
+ # Read BEFORE the start reads the files: each is then
463
+ # read as it was at this state or later. (A click
464
+ # during the start is a later state, and the worker
465
+ # reads the library again for its items.)
466
+ library = self._library
467
+ runner.start()
468
+ runner.library = library
469
+ except Exception as exc:
470
+ left_because = str(exc) or type(exc).__name__
471
+ return
472
+ with self._cond:
473
+ self._failures.pop(slot.id, None)
474
+ while True:
475
+ item = self._take(slot)
476
+ if item is None:
477
+ return
478
+ self._run_item(slot, runner, item)
479
+ if runner.retired:
480
+ left_because = runner.retired
481
+ return
482
+ finally:
483
+ if slot.runner is not None:
484
+ try:
485
+ slot.runner.close()
486
+ except Exception:
487
+ logger.exception("pool: closing worker %s raised", slot.id)
488
+ self._slot_left(slot, left_because)
489
+
490
+ def _take(self, slot: _Slot) -> QueueItem | None:
491
+ """The next item for *slot*, marked RUNNING — or ``None`` when the
492
+ slot is to leave (the pool closing or stopped, or more workers than
493
+ the target)."""
494
+ with self._cond:
495
+ while True:
496
+ if self._closing or self._stopped is not None:
497
+ return None
498
+ if len(self._slots) > self._target:
499
+ del self._slots[slot.id]
500
+ return None
501
+ if self._waiting:
502
+ item = self._waiting.popleft()
503
+ item.state, item.worker_id = ItemState.RUNNING, slot.id
504
+ item.started_at = time.time()
505
+ slot.item = item
506
+ return item
507
+ self._cond.wait()
508
+
509
+ def _slot_left(self, slot: _Slot, because: str | None) -> None:
510
+ """A thread is gone. One that left because its worker could not
511
+ start, or could not be brought back, says why; when it was the last,
512
+ the pool stops and the waiting items fail with the reasons."""
513
+ with self._cond:
514
+ if self._slots.get(slot.id) is slot:
515
+ del self._slots[slot.id]
516
+ if because is not None:
517
+ self._failures[slot.id] = because
518
+ if self._slots or because is None or self._closing:
519
+ return
520
+ self._stopped = no_worker_message(self.backend.label, self._failures)
521
+ failed = list(self._waiting)
522
+ self._waiting.clear()
523
+ for item in failed:
524
+ del self._active[item.model_id]
525
+ item.state, item.message = ItemState.FAILED, self._stopped
526
+ item.ended_at = time.time()
527
+ self._cond.notify_all()
528
+ for item in failed:
529
+ self._fire(self._on_end, item)
530
+
531
+ def _run_item(self, slot: _Slot, runner: ItemRunner, item: QueueItem) -> None:
532
+ self._fire(self._on_start, item)
533
+ run_result: TestRunResult | None = None
534
+ result: TestResult | None = None
535
+ error: str | None = None
536
+ try:
537
+ test_key = self.path.begin(item.test)
538
+ with self._cond:
539
+ item.test_key = test_key
540
+ stopped_early = item._stop_asked
541
+ if not stopped_early:
542
+ runner.expect(test_key, item)
543
+ if stopped_early:
544
+ run_result = TestRunResult(
545
+ model_id=item.model_id,
546
+ test_key=test_key,
547
+ success=False,
548
+ error_message="Cancelled",
549
+ cancelled=True,
550
+ )
551
+ else:
552
+ run_result = runner.run(item.test, test_key, item.library)
553
+ if item._stop_asked and not run_result.success:
554
+ # Whatever a stopped run reports — a failed record, a dead
555
+ # pipe — is the stop's doing, not a failure of the test.
556
+ run_result = dataclasses.replace(
557
+ run_result, cancelled=True, error_message="Cancelled"
558
+ )
559
+ result = self.path.end(item.test, test_key, run_result)
560
+ except Exception as exc:
561
+ logger.exception("pool: running %s raised", item.model_id)
562
+ error = f"{type(exc).__name__}: {exc}"
563
+ with self._cond:
564
+ item.run_result, item.result = run_result, result
565
+ if error is not None or run_result is None:
566
+ item.state, item.message = ItemState.FAILED, error
567
+ elif run_result.cancelled:
568
+ item.state, item.message = ItemState.CANCELLED, "Cancelled"
569
+ elif run_result.success and result is not None and result.success:
570
+ item.state = ItemState.FINISHED
571
+ else:
572
+ item.state = ItemState.FAILED
573
+ item.message = (
574
+ (result.error_message if result is not None else None)
575
+ or run_result.error_message
576
+ or "Simulation failed"
577
+ )
578
+ item.ended_at = time.time()
579
+ del self._active[item.model_id]
580
+ slot.item = None
581
+ self._fire(self._on_end, item)
582
+
583
+ def _fire(
584
+ self, callback: Callable[[QueueItem], None] | None, item: QueueItem
585
+ ) -> None:
586
+ """A callback that raises costs its own effect, never the thread."""
587
+ if callback is None:
588
+ return
589
+ try:
590
+ callback(item)
591
+ except Exception:
592
+ logger.exception("pool: a callback raised for %s", item.model_id)
593
+
594
+
595
+ __all__ = [
596
+ "ItemPath",
597
+ "ItemState",
598
+ "QueueItem",
599
+ "Refused",
600
+ "SimulationPool",
601
+ "StartingWorker",
602
+ ]