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.
- cpnpy/__init__.py +46 -0
- openprocess/__init__.py +57 -0
- openprocess/analysis/__init__.py +0 -0
- openprocess/analysis/state_space.py +521 -0
- openprocess/analysis/state_space_process.py +251 -0
- openprocess/cli.py +742 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
- openprocess/exercises/pack.md +14 -0
- openprocess/flow/__init__.py +50 -0
- openprocess/flow/box.py +466 -0
- openprocess/flow/boxes/__init__.py +7 -0
- openprocess/flow/boxes/check.py +119 -0
- openprocess/flow/boxes/compare.py +16 -0
- openprocess/flow/boxes/cpn.py +53 -0
- openprocess/flow/boxes/discover.py +124 -0
- openprocess/flow/boxes/filter.py +80 -0
- openprocess/flow/boxes/input.py +124 -0
- openprocess/flow/boxes/output.py +52 -0
- openprocess/flow/boxes/predict.py +186 -0
- openprocess/flow/boxes/science.py +159 -0
- openprocess/flow/boxes/sweeps.py +18 -0
- openprocess/flow/convert.py +187 -0
- openprocess/flow/datasets.py +198 -0
- openprocess/flow/explain.py +115 -0
- openprocess/flow/library.py +222 -0
- openprocess/flow/record.py +385 -0
- openprocess/flow/runner.py +357 -0
- openprocess/flow/sweep.py +92 -0
- openprocess/flow/types.py +290 -0
- openprocess/flow/workflow.py +628 -0
- openprocess/gui/__init__.py +0 -0
- openprocess/gui/app.py +90 -0
- openprocess/gui/arc_editing.py +295 -0
- openprocess/gui/canvas.py +1414 -0
- openprocess/gui/flow/__init__.py +8 -0
- openprocess/gui/flow/canvas.py +854 -0
- openprocess/gui/flow/page.py +972 -0
- openprocess/gui/flow/templates.py +131 -0
- openprocess/gui/flow/viewers.py +665 -0
- openprocess/gui/items.py +1275 -0
- openprocess/gui/learn/answer_boxes.py +978 -0
- openprocess/gui/learn/concealment.py +91 -0
- openprocess/gui/learn/mode.py +1181 -0
- openprocess/gui/panning.py +241 -0
- openprocess/gui/resources/openprocess-icon.png +0 -0
- openprocess/gui/studio/__init__.py +1 -0
- openprocess/gui/studio/__main__.py +3 -0
- openprocess/gui/studio/app.py +4031 -0
- openprocess/gui/studio/charts.py +115 -0
- openprocess/gui/studio/compare_page.py +487 -0
- openprocess/gui/studio/cpn_page.py +1858 -0
- openprocess/gui/studio/definition_view.py +284 -0
- openprocess/gui/studio/derivation_view.py +421 -0
- openprocess/gui/studio/documents.py +152 -0
- openprocess/gui/studio/dotted_chart.py +1401 -0
- openprocess/gui/studio/file_dialogs.py +143 -0
- openprocess/gui/studio/filter_dialog.py +247 -0
- openprocess/gui/studio/graph_builders.py +176 -0
- openprocess/gui/studio/graph_view.py +682 -0
- openprocess/gui/studio/instances.py +413 -0
- openprocess/gui/studio/log_editor.py +675 -0
- openprocess/gui/studio/log_page.py +800 -0
- openprocess/gui/studio/markdown_view.py +127 -0
- openprocess/gui/studio/mathtext.py +260 -0
- openprocess/gui/studio/ml_highlighter.py +75 -0
- openprocess/gui/studio/model_page.py +760 -0
- openprocess/gui/studio/net_comparison.py +124 -0
- openprocess/gui/studio/notes_overlay.py +275 -0
- openprocess/gui/studio/petri_page.py +844 -0
- openprocess/gui/studio/regions_view.py +502 -0
- openprocess/gui/studio/sidebar.py +149 -0
- openprocess/gui/studio/style.py +503 -0
- openprocess/gui/studio/tool_icons.py +134 -0
- openprocess/gui/studio/updates.py +439 -0
- openprocess/gui/studio/widgets.py +899 -0
- openprocess/gui/studio/workers.py +60 -0
- openprocess/gui/studio/workspace.py +447 -0
- openprocess/gui/theme.py +394 -0
- openprocess/gui/tidy.py +86 -0
- openprocess/io/__init__.py +0 -0
- openprocess/io/cpn_reader.py +389 -0
- openprocess/io/cpn_writer.py +357 -0
- openprocess/learn/__init__.py +23 -0
- openprocess/learn/answers.py +188 -0
- openprocess/learn/checks.py +953 -0
- openprocess/learn/computed.py +1180 -0
- openprocess/learn/context.py +145 -0
- openprocess/learn/exam.py +169 -0
- openprocess/learn/exercise-packs.md +325 -0
- openprocess/learn/importer.py +216 -0
- openprocess/learn/notation.py +474 -0
- openprocess/learn/pack.py +511 -0
- openprocess/learn/sheet.py +296 -0
- openprocess/mining/__init__.py +73 -0
- openprocess/mining/analysis.py +689 -0
- openprocess/mining/columns.py +282 -0
- openprocess/mining/compare_nets.py +246 -0
- openprocess/mining/conformance/__init__.py +0 -0
- openprocess/mining/conformance/alignments.py +263 -0
- openprocess/mining/conformance/quality.py +145 -0
- openprocess/mining/conformance/token_replay.py +252 -0
- openprocess/mining/csv_import.py +222 -0
- openprocess/mining/definitions.py +584 -0
- openprocess/mining/dfg.py +187 -0
- openprocess/mining/discovery/__init__.py +0 -0
- openprocess/mining/discovery/alpha.py +168 -0
- openprocess/mining/discovery/heuristics.py +332 -0
- openprocess/mining/discovery/inductive.py +477 -0
- openprocess/mining/discovery/state_regions.py +62 -0
- openprocess/mining/filtering.py +237 -0
- openprocess/mining/footprint.py +183 -0
- openprocess/mining/invariants.py +191 -0
- openprocess/mining/layout.py +279 -0
- openprocess/mining/log.py +364 -0
- openprocess/mining/petrinet.py +354 -0
- openprocess/mining/playout.py +75 -0
- openprocess/mining/pm4py_bridge.py +82 -0
- openprocess/mining/pnml.py +223 -0
- openprocess/mining/processtree.py +216 -0
- openprocess/mining/regions.py +476 -0
- openprocess/mining/stats.py +160 -0
- openprocess/mining/structure.py +374 -0
- openprocess/mining/transition_system.py +409 -0
- openprocess/mining/xes.py +399 -0
- openprocess/ml/__init__.py +0 -0
- openprocess/ml/ast_nodes.py +332 -0
- openprocess/ml/builtins.py +364 -0
- openprocess/ml/colorsets.py +522 -0
- openprocess/ml/errors.py +60 -0
- openprocess/ml/evaluator.py +754 -0
- openprocess/ml/lexer.py +277 -0
- openprocess/ml/multiset.py +417 -0
- openprocess/ml/parser.py +737 -0
- openprocess/ml/values.py +319 -0
- openprocess/model/__init__.py +0 -0
- openprocess/model/declarations.py +617 -0
- openprocess/model/examples.py +98 -0
- openprocess/model/net.py +701 -0
- openprocess/model/plain.py +192 -0
- openprocess/references.py +280 -0
- openprocess/sim/__init__.py +0 -0
- openprocess/sim/binding.py +620 -0
- openprocess/sim/export.py +66 -0
- openprocess/sim/simulator.py +315 -0
- openprocess/teaching/__init__.py +4 -0
- openprocess/teaching/answers.py +4 -0
- openprocess/teaching/checks.py +5 -0
- openprocess/teaching/pack.py +4 -0
- openprocess/teaching/sheet.py +4 -0
- openprocess-0.7.0.dist-info/METADATA +927 -0
- openprocess-0.7.0.dist-info/RECORD +173 -0
- openprocess-0.7.0.dist-info/WHEEL +5 -0
- openprocess-0.7.0.dist-info/entry_points.txt +6 -0
- openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
- 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"]
|