openprocess 0.7.0__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 (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,357 @@
1
+ """Running a workflow: in order, only what changed, each box on its own.
2
+
3
+ The :class:`Runner` walks the workflow in topological order. Every box's
4
+ result is kept under a **key** made of the box's code, its settings and the
5
+ keys of its inputs, so
6
+
7
+ * changing one setting re-runs only the boxes after it,
8
+ * re-opening a workflow whose inputs are unchanged runs nothing at all,
9
+ * the same box with the same inputs anywhere in any workflow is computed once.
10
+
11
+ An exception stays in its box: the box fails with the message and the
12
+ traceback, the boxes after it wait, and the rest keeps its results.
13
+
14
+ Sweeps (:mod:`.sweep`): the boxes after a swept setting run once per value;
15
+ a box that takes any number of inputs collects the results of every value.
16
+
17
+ The runner is plain Python; the app calls :meth:`Runner.run` on a worker
18
+ thread and listens to ``on_status``. ``stop`` (a ``threading.Event``) is
19
+ checked between boxes. A box marked ``heavy=True`` can be run in a
20
+ separate process with :func:`run_in_process`, which can be killed.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import copy
26
+ import hashlib
27
+ import json
28
+ import threading
29
+ import time
30
+ import traceback
31
+ from dataclasses import dataclass, field
32
+ from typing import Any, Callable
33
+
34
+ from .box import BoxSpec, Port
35
+ from .convert import can_convert, convert
36
+ from .explain import Explanation, collect_result, explaining
37
+ from .sweep import assignments, is_sweep
38
+ from .workflow import Node, Workflow
39
+
40
+ WAITING, RUNNING, DONE, FAILED, IDLE, BLOCKED, SKIPPED = (
41
+ "waiting", "running", "done", "failed", "idle", "blocked", "skipped")
42
+
43
+
44
+ @dataclass
45
+ class Result:
46
+ """What one box produced in one run (one per sweep variant)."""
47
+
48
+ node: str
49
+ status: str = WAITING
50
+ value: Any = None
51
+ values: dict[str, Any] = field(default_factory=dict) # by output name
52
+ error: str = ""
53
+ traceback: str = ""
54
+ message: str = "" # why it is idle
55
+ duration: float = 0.0
56
+ explanation: Explanation = field(default_factory=Explanation)
57
+ key: str = ""
58
+ cached: bool = False
59
+ variant: int = 0
60
+
61
+ @property
62
+ def ok(self) -> bool:
63
+ return self.status == DONE
64
+
65
+
66
+ @dataclass
67
+ class Run:
68
+ """Everything a run of a workflow produced."""
69
+
70
+ workflow: Workflow
71
+ #: ``(node id, variant)`` -> Result. Variant 0 is the only one without sweeps.
72
+ results: dict[tuple[str, int], Result] = field(default_factory=dict)
73
+ #: The swept settings of each variant: ``{(node, setting): value}``.
74
+ variants: list[dict[tuple[str, str], Any]] = field(default_factory=list)
75
+ started: float = 0.0
76
+ finished: float = 0.0
77
+ stopped: bool = False
78
+
79
+ def result(self, node: Node | str, variant: int = 0) -> Result | None:
80
+ node_id = node if isinstance(node, str) else node.id
81
+ return self.results.get((node_id, variant))
82
+
83
+ def value(self, node: Node | str, output: str = "out", variant: int = 0) -> Any:
84
+ result = self.result(node, variant)
85
+ if result is None or not result.ok:
86
+ return None
87
+ return result.values.get(output, result.value)
88
+
89
+ def status(self, node: Node | str, variant: int = 0) -> str:
90
+ result = self.result(node, variant)
91
+ return result.status if result else WAITING
92
+
93
+ def failed(self) -> list[Result]:
94
+ return [r for r in self.results.values() if r.status == FAILED]
95
+
96
+ @property
97
+ def ok(self) -> bool:
98
+ return all(r.status == DONE for r in self.results.values())
99
+
100
+ def summary(self) -> str:
101
+ done = sum(1 for r in self.results.values() if r.status == DONE)
102
+ return (f"{done} of {len(self.results)} boxes done"
103
+ + (f", {len(self.failed())} failed" if self.failed() else "")
104
+ + (f", {len(self.variants)} sweep variants" if len(self.variants) > 1 else ""))
105
+
106
+
107
+ class Cache:
108
+ """Results by key. In memory; the app keeps one per folder."""
109
+
110
+ def __init__(self, limit: int = 500) -> None:
111
+ self.entries: dict[str, tuple[dict[str, Any], Explanation]] = {}
112
+ self.limit = limit
113
+
114
+ def get(self, key: str):
115
+ return self.entries.get(key)
116
+
117
+ def put(self, key: str, values: dict[str, Any], explanation: Explanation) -> None:
118
+ if len(self.entries) >= self.limit:
119
+ del self.entries[next(iter(self.entries))]
120
+ self.entries[key] = (values, explanation)
121
+
122
+ def clear(self) -> None:
123
+ self.entries.clear()
124
+
125
+
126
+ def settings_key(settings: dict[str, Any]) -> str:
127
+ return json.dumps({k: (str(v) if not isinstance(v, (int, float, bool, str, type(None), list, dict)) else v)
128
+ for k, v in sorted(settings.items())}, sort_keys=True, default=str)
129
+
130
+
131
+ class Runner:
132
+ def __init__(self, library=None, cache: Cache | None = None, folder=None) -> None:
133
+ """``folder`` is where a workflow's relative file settings point (its
134
+ own folder in the app); without it they are taken as they are."""
135
+ from .library import standard_library
136
+ self.library = library or standard_library()
137
+ self.cache = cache or Cache()
138
+ self.folder = folder
139
+ self._per_variant: set[str] = set()
140
+
141
+ # -- the public call ----------------------------------------------------------------
142
+ def run(self, workflow: Workflow, changed: list[str] | None = None, previous: Run | None = None,
143
+ stop: threading.Event | None = None,
144
+ on_status: Callable[[Result], None] | None = None) -> Run:
145
+ """Run the workflow (all of it, or from ``changed`` on, reusing
146
+ ``previous`` for the rest). Returns a :class:`Run`."""
147
+ run = Run(workflow, started=time.time())
148
+ order = workflow.order()
149
+ swept = workflow.swept()
150
+ run.variants = assignments(swept)
151
+ swept_nodes = {node for node, _, _ in swept}
152
+ under_sweep = workflow.descendants(swept_nodes) if swept_nodes else set()
153
+ collectors = {n.id for n in order if n.id in under_sweep
154
+ and any(p.many for p in workflow.spec(n).inputs)
155
+ and self._fed_from(workflow, n, under_sweep)}
156
+ after_collectors = workflow.descendants(collectors) if collectors else set()
157
+ per_variant = under_sweep - after_collectors
158
+ self._per_variant = per_variant
159
+ dirty = workflow.descendants(changed) if changed is not None else set(workflow.nodes)
160
+ if previous is None or previous.variants != run.variants:
161
+ dirty = set(workflow.nodes)
162
+ notify = on_status or (lambda r: None)
163
+
164
+ def reuse(node_id: str, variant: int) -> bool:
165
+ if node_id in dirty or previous is None:
166
+ return False
167
+ old = previous.result(node_id, variant)
168
+ if old is None:
169
+ return False
170
+ kept = copy.copy(old)
171
+ kept.cached = True
172
+ run.results[(node_id, variant)] = kept
173
+ return True
174
+
175
+ for variant_index, assignment in enumerate(run.variants):
176
+ if variant_index > 0 and not per_variant:
177
+ break
178
+ for node in order:
179
+ if node.id in after_collectors or node.id in collectors:
180
+ continue
181
+ if node.id not in per_variant and variant_index > 0:
182
+ continue
183
+ if stop is not None and stop.is_set():
184
+ run.stopped = True
185
+ run.finished = time.time()
186
+ return run
187
+ if reuse(node.id, variant_index):
188
+ continue
189
+ result = self._run_node(workflow, node, run, variant_index, assignment, notify)
190
+ run.results[(node.id, variant_index)] = result
191
+ notify(result)
192
+ for node in order:
193
+ if node.id not in collectors and node.id not in after_collectors:
194
+ continue
195
+ if stop is not None and stop.is_set():
196
+ run.stopped = True
197
+ break
198
+ if reuse(node.id, 0):
199
+ continue
200
+ result = self._run_node(workflow, node, run, 0, {}, notify, collect=node.id in collectors)
201
+ run.results[(node.id, 0)] = result
202
+ notify(result)
203
+ run.finished = time.time()
204
+ return run
205
+
206
+ @staticmethod
207
+ def _fed_from(workflow: Workflow, node: Node, nodes: set[str]) -> bool:
208
+ return any(e.source in nodes for e in workflow.edges if e.target == node.id)
209
+
210
+ # -- one box ---------------------------------------------------------------------------
211
+ def _run_node(self, workflow: Workflow, node: Node, run: Run, variant: int,
212
+ assignment: dict, notify, collect: bool = False) -> Result:
213
+ spec = workflow.spec(node)
214
+ result = Result(node.id, variant=variant)
215
+ settings = dict(node.settings)
216
+ for (node_id, name), value in assignment.items():
217
+ if node_id == node.id:
218
+ settings[name] = value
219
+ if any(is_sweep(v) for v in settings.values()): # a swept node outside its variants
220
+ for name, value in list(settings.items()):
221
+ if is_sweep(value):
222
+ settings[name] = value.values[0]
223
+ if not spec.available:
224
+ result.status, result.message = BLOCKED, spec.unavailable_reason
225
+ return result
226
+ if self.folder is not None:
227
+ from pathlib import Path
228
+ for setting in spec.settings:
229
+ value = settings.get(setting.name)
230
+ if setting.kind == "path" and value and not Path(value).is_absolute():
231
+ settings[setting.name] = Path(self.folder) / value
232
+ # Gather inputs
233
+ inputs: dict[str, Any] = {}
234
+ keys: list[str] = []
235
+ fed = workflow.inputs_of(node)
236
+ for port in spec.inputs:
237
+ sources = fed[port.name]
238
+ if not sources:
239
+ if port.optional:
240
+ inputs[port.name] = [] if port.many else None
241
+ continue
242
+ result.status = IDLE
243
+ result.message = f"Connect a {port.type_name.lower()} to “{port.label or port.name}”"
244
+ return result
245
+ values = []
246
+ for source_id, output in sources:
247
+ variants = (range(len(run.variants)) if collect and source_id in self._per_variant
248
+ else [variant if source_id in self._per_variant else 0])
249
+ for v in variants:
250
+ upstream = run.result(source_id, v) or run.result(source_id, 0)
251
+ if upstream is None or upstream.status != DONE:
252
+ if port.many and upstream is not None and upstream.status in (IDLE, FAILED, BLOCKED) and collect:
253
+ continue
254
+ result.status = IDLE
255
+ why = {FAILED: "failed", BLOCKED: "is not allowed to run", IDLE: "is waiting"}.get(
256
+ upstream.status if upstream else WAITING, "has not run")
257
+ result.message = f"Waiting for “{workflow.title(source_id)}” ({why})"
258
+ return result
259
+ value = upstream.values.get(output, upstream.value)
260
+ values.append(self._fit(value, port))
261
+ keys.append(upstream.key + ":" + output)
262
+ inputs[port.name] = values if port.many else values[0]
263
+ result.key = hashlib.sha256(
264
+ (spec.fingerprint + "|" + settings_key(settings) + "|" + "|".join(keys)).encode()).hexdigest()
265
+ cached = self.cache.get(result.key)
266
+ if cached is not None:
267
+ result.values, result.explanation = cached
268
+ result.value = result.values.get("out", next(iter(result.values.values()), None))
269
+ result.status, result.cached = DONE, True
270
+ return result
271
+ # Run it
272
+ result.status = RUNNING
273
+ notify(result)
274
+ started = time.perf_counter()
275
+ with explaining() as explanation:
276
+ try:
277
+ value = spec.function(**inputs, **settings)
278
+ except Exception as error: # noqa: BLE001 - whatever the box raised stays in the box
279
+ result.status = FAILED
280
+ result.error = "".join(traceback.format_exception_only(type(error), error)).strip()
281
+ result.traceback = traceback.format_exc()
282
+ result.duration = time.perf_counter() - started
283
+ result.explanation = explanation
284
+ return result
285
+ result.duration = time.perf_counter() - started
286
+ values = self._outputs(spec, value)
287
+ for out in values.values():
288
+ if node.id in self._per_variant:
289
+ self._tag(out, assignment, spec)
290
+ collect_result(out, explanation)
291
+ result.values, result.value, result.explanation = values, values.get("out", value), explanation
292
+ result.status = DONE
293
+ self.cache.put(result.key, values, explanation)
294
+ return result
295
+
296
+ @staticmethod
297
+ def _outputs(spec: BoxSpec, value) -> dict[str, Any]:
298
+ if spec.result_type is not None:
299
+ return {p.name: getattr(value, p.name) for p in spec.outputs}
300
+ if not spec.outputs:
301
+ return {}
302
+ return {"out": value}
303
+
304
+ @staticmethod
305
+ def _fit(value, port: Port):
306
+ """Convert a value to the input's type when a converter exists."""
307
+ if port.type is None or value is None or isinstance(value, port.type):
308
+ return value
309
+ if can_convert(value, port.type):
310
+ return convert(value, port.type)
311
+ return value
312
+
313
+ @staticmethod
314
+ def _tag(value, assignment: dict, spec: BoxSpec) -> None:
315
+ """Scores and tables made under a sweep remember the swept values."""
316
+ context = getattr(value, "context", None)
317
+ if isinstance(context, dict) and assignment:
318
+ for (_node, name), v in assignment.items():
319
+ context.setdefault(name, v)
320
+
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # Heavy boxes in a process that can be killed
324
+ # ---------------------------------------------------------------------------
325
+ def _in_process(box_id: str, inputs: dict, settings: dict, queue) -> None: # pragma: no cover - child
326
+ try:
327
+ from .library import standard_library
328
+ spec = standard_library().get(box_id)
329
+ with explaining() as explanation:
330
+ value = spec.function(**inputs, **settings)
331
+ queue.put(("ok", value, explanation))
332
+ except Exception as error: # noqa: BLE001
333
+ queue.put(("error", "".join(traceback.format_exception_only(type(error), error)).strip(),
334
+ traceback.format_exc()))
335
+
336
+
337
+ def run_in_process(spec: BoxSpec, inputs: dict, settings: dict, timeout: float | None = None):
338
+ """Run one box in a separate process (standard-library boxes only, since
339
+ the child imports the box by id). Returns ``(value, explanation)`` or
340
+ raises ``RuntimeError`` with the child's error."""
341
+ import multiprocessing
342
+ queue = multiprocessing.Queue()
343
+ process = multiprocessing.Process(target=_in_process, args=(spec.id, inputs, settings, queue))
344
+ process.start()
345
+ try:
346
+ outcome = queue.get(timeout=timeout)
347
+ except Exception: # noqa: BLE001 - queue.Empty or a dead child
348
+ process.kill()
349
+ raise RuntimeError(f"{spec.name} did not finish in time and was stopped")
350
+ process.join()
351
+ if outcome[0] == "error":
352
+ raise RuntimeError(outcome[1])
353
+ return outcome[1], outcome[2]
354
+
355
+
356
+ __all__ = ["BLOCKED", "Cache", "DONE", "FAILED", "IDLE", "RUNNING", "Result", "Run", "Runner",
357
+ "WAITING", "run_in_process", "settings_key"]
@@ -0,0 +1,92 @@
1
+ """Sweeps: run the boxes after a setting once per value.
2
+
3
+ A sweep is a setting marked as a range or a list rather than a loop drawn on
4
+ the canvas::
5
+
6
+ node.settings["noise"] = Sweep.parse("0..0.5 step 0.1")
7
+ node.settings["seed"] = Sweep([0, 1, 2, 3, 4])
8
+
9
+ The runner runs every box after a swept box once per value (the cartesian
10
+ product when several settings are swept), and a box that takes *any
11
+ number* of inputs (Compare, Plot, Sweep table) collects the results of all
12
+ the values. Every :class:`~.types.Scores` and :class:`~.types.Table` made
13
+ under a sweep carries the values in its ``context``, so a stacked table
14
+ tells them apart.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import itertools
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+ _RANGE = re.compile(r"^\s*(-?[\d.]+)\s*\.\.\s*(-?[\d.]+)\s*(?:step\s+(-?[\d.]+))?\s*$")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Sweep:
29
+ """The values a setting takes, one run each."""
30
+
31
+ values: tuple = field(default_factory=tuple)
32
+
33
+ @classmethod
34
+ def parse(cls, text: str) -> "Sweep":
35
+ """``"0..0.5 step 0.1"``, ``"1..5"`` (step 1) or ``"a, b, c"``."""
36
+ match = _RANGE.match(text)
37
+ if match:
38
+ start, stop, step = (float(match.group(1)), float(match.group(2)),
39
+ float(match.group(3) or 1))
40
+ if step == 0 or (stop - start) * step < 0:
41
+ raise ValueError(f"The range {text!r} never reaches its end")
42
+ values, value, count = [], start, 0
43
+ while (value <= stop + 1e-9 if step > 0 else value >= stop - 1e-9) and count < 10_000:
44
+ values.append(round(value, 10))
45
+ value += step
46
+ count += 1
47
+ if all(float(v).is_integer() for v in values) and all(
48
+ "." not in part for part in (match.group(1), match.group(2), match.group(3) or "")):
49
+ values = [int(v) for v in values]
50
+ return cls(tuple(values))
51
+ parts = [p.strip() for p in text.split(",") if p.strip()]
52
+ if not parts:
53
+ raise ValueError("A sweep needs at least one value")
54
+ return cls(tuple(_number(p) for p in parts))
55
+
56
+ def describe(self) -> str:
57
+ values = self.values
58
+ if len(values) > 6:
59
+ return f"{values[0]} … {values[-1]} ({len(values)} values)"
60
+ return ", ".join(str(v) for v in values)
61
+
62
+ def __len__(self) -> int:
63
+ return len(self.values)
64
+
65
+ def to_json(self) -> dict:
66
+ return {"sweep": list(self.values)}
67
+
68
+
69
+ def _number(text: str):
70
+ try:
71
+ return int(text)
72
+ except ValueError:
73
+ try:
74
+ return float(text)
75
+ except ValueError:
76
+ return text
77
+
78
+
79
+ def is_sweep(value: Any) -> bool:
80
+ return isinstance(value, Sweep)
81
+
82
+
83
+ def assignments(swept: list[tuple[str, str, Sweep]]) -> list[dict[tuple[str, str], Any]]:
84
+ """Every combination of values: ``[{(node, setting): value, ...}, ...]``."""
85
+ if not swept:
86
+ return [{}]
87
+ keys = [(node, setting) for node, setting, _ in swept]
88
+ return [dict(zip(keys, combination))
89
+ for combination in itertools.product(*(s.values for _, _, s in swept))]
90
+
91
+
92
+ __all__ = ["Sweep", "assignments", "is_sweep"]