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,628 @@
|
|
|
1
|
+
"""A workflow: boxes, their settings and the connections between them.
|
|
2
|
+
|
|
3
|
+
A :class:`Workflow` is a directed acyclic graph. A :class:`Node` is one use
|
|
4
|
+
of a box, with its own settings and a position on the canvas; an
|
|
5
|
+
:class:`Edge` joins an output of one node to an input of another. The class
|
|
6
|
+
checks what the canvas also refuses: an edge only between an output and an
|
|
7
|
+
input of the same type, no cycles, and only one edge into an input that
|
|
8
|
+
takes a single connection.
|
|
9
|
+
|
|
10
|
+
Python and the canvas are two views of one workflow. A function decorated
|
|
11
|
+
with ``@workflow`` *records* the boxes it calls instead of running them::
|
|
12
|
+
|
|
13
|
+
@workflow
|
|
14
|
+
def compare_discovery():
|
|
15
|
+
log = open_log("orders.xes")
|
|
16
|
+
model = inductive_miner(log, noise=0.2)
|
|
17
|
+
return check_fit(model, log)
|
|
18
|
+
|
|
19
|
+
wf = compare_discovery.workflow # a Workflow, ready to run or draw
|
|
20
|
+
print(to_python(wf)) # and back to source
|
|
21
|
+
|
|
22
|
+
Groups (several boxes shown as one, with their own canvas) are kept here as
|
|
23
|
+
data; their connection points are derived, not declared: an input of a
|
|
24
|
+
member fed from outside the group, an output used outside it.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import contextvars
|
|
30
|
+
import itertools
|
|
31
|
+
import keyword
|
|
32
|
+
import re
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import Any, Callable
|
|
35
|
+
|
|
36
|
+
from .box import Box, BoxSpec, Port
|
|
37
|
+
from .sweep import Sweep, is_sweep
|
|
38
|
+
from .types import type_info
|
|
39
|
+
|
|
40
|
+
_ids = itertools.count(1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def fresh_id(prefix: str = "n") -> str:
|
|
44
|
+
return f"{prefix}{next(_ids)}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Node:
|
|
49
|
+
"""One box on the canvas with its settings."""
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
box: str # the box's id in the library
|
|
53
|
+
settings: dict[str, Any] = field(default_factory=dict)
|
|
54
|
+
position: tuple[float, float] = (0.0, 0.0)
|
|
55
|
+
#: A name the user gave it (empty: the box's name).
|
|
56
|
+
title: str = ""
|
|
57
|
+
|
|
58
|
+
def swept(self) -> list[tuple[str, Sweep]]:
|
|
59
|
+
return [(name, value) for name, value in self.settings.items() if is_sweep(value)]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Edge:
|
|
64
|
+
source: str # node id
|
|
65
|
+
output: str # output port name
|
|
66
|
+
target: str
|
|
67
|
+
input: str # input port name
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Group:
|
|
72
|
+
"""Several nodes shown as one box, with their own canvas."""
|
|
73
|
+
|
|
74
|
+
id: str
|
|
75
|
+
name: str
|
|
76
|
+
members: list[str] = field(default_factory=list)
|
|
77
|
+
position: tuple[float, float] = (0.0, 0.0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class WorkflowError(ValueError):
|
|
81
|
+
"""A connection or a structure the canvas would refuse."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Workflow:
|
|
85
|
+
"""Nodes, edges and groups, with the checks the canvas relies on."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, name: str = "workflow", library=None) -> None:
|
|
88
|
+
from .library import standard_library
|
|
89
|
+
self.name = name
|
|
90
|
+
self.library = library or standard_library()
|
|
91
|
+
self.nodes: dict[str, Node] = {}
|
|
92
|
+
self.edges: list[Edge] = []
|
|
93
|
+
self.groups: dict[str, Group] = {}
|
|
94
|
+
|
|
95
|
+
# -- building ---------------------------------------------------------------
|
|
96
|
+
def spec(self, node: Node | str) -> BoxSpec:
|
|
97
|
+
node = self.nodes[node] if isinstance(node, str) else node
|
|
98
|
+
return self.library.get(node.box)
|
|
99
|
+
|
|
100
|
+
def add(self, box: Box | BoxSpec | str, settings: dict[str, Any] | None = None,
|
|
101
|
+
position: tuple[float, float] = (0.0, 0.0), id: str | None = None,
|
|
102
|
+
title: str = "") -> Node:
|
|
103
|
+
"""Put a box on the canvas, with its default settings unless given."""
|
|
104
|
+
spec = self.library.resolve(box)
|
|
105
|
+
node_id = id or fresh_id()
|
|
106
|
+
if node_id in self.nodes:
|
|
107
|
+
raise WorkflowError(f"There is already a box with id {node_id!r}")
|
|
108
|
+
values = spec.defaults()
|
|
109
|
+
for key, value in (settings or {}).items():
|
|
110
|
+
setting = spec.setting(key)
|
|
111
|
+
if setting is None:
|
|
112
|
+
raise WorkflowError(f"{spec.name} has no setting {key!r}")
|
|
113
|
+
values[key] = value if is_sweep(value) else setting.coerce(value)
|
|
114
|
+
node = Node(node_id, spec.id, values, tuple(position), title)
|
|
115
|
+
self.nodes[node_id] = node
|
|
116
|
+
return node
|
|
117
|
+
|
|
118
|
+
def set(self, node: Node | str, **settings: Any) -> None:
|
|
119
|
+
node = self.nodes[node if isinstance(node, str) else node.id] # always this workflow's node
|
|
120
|
+
spec = self.spec(node)
|
|
121
|
+
for key, value in settings.items():
|
|
122
|
+
setting = spec.setting(key)
|
|
123
|
+
if setting is None:
|
|
124
|
+
raise WorkflowError(f"{spec.name} has no setting {key!r}")
|
|
125
|
+
node.settings[key] = value if is_sweep(value) else setting.coerce(value)
|
|
126
|
+
|
|
127
|
+
def can_connect(self, source: Node | str, target: Node | str, input: str | None = None,
|
|
128
|
+
output: str = "out") -> tuple[bool, str]:
|
|
129
|
+
"""Whether an edge may be made, and if not, why (plain words)."""
|
|
130
|
+
source = self.nodes[source] if isinstance(source, str) else source
|
|
131
|
+
target = self.nodes[target] if isinstance(target, str) else target
|
|
132
|
+
if source.id == target.id:
|
|
133
|
+
return False, "A box cannot feed itself"
|
|
134
|
+
src_spec, dst_spec = self.spec(source), self.spec(target)
|
|
135
|
+
out_port = src_spec.output(output)
|
|
136
|
+
if out_port is None:
|
|
137
|
+
return False, f"{src_spec.name} has no output {output!r}"
|
|
138
|
+
if input is None:
|
|
139
|
+
fitting = [p for p in dst_spec.inputs if p.accepts(out_port)]
|
|
140
|
+
if not fitting:
|
|
141
|
+
return False, (f"{dst_spec.name} takes {_list(p.type_name for p in dst_spec.inputs) or 'nothing'}, "
|
|
142
|
+
f"not a {out_port.type_name.lower()}")
|
|
143
|
+
input = fitting[0].name
|
|
144
|
+
in_port = dst_spec.input(input)
|
|
145
|
+
if in_port is None:
|
|
146
|
+
return False, f"{dst_spec.name} has no input {input!r}"
|
|
147
|
+
if not in_port.accepts(out_port):
|
|
148
|
+
return False, f"“{in_port.label or in_port.name}” takes a {in_port.type_name.lower()}, not a {out_port.type_name.lower()}"
|
|
149
|
+
if source.id in self.descendants([target.id]):
|
|
150
|
+
return False, "That would make a loop"
|
|
151
|
+
if any(e.source == source.id and e.output == output and e.target == target.id and e.input == input
|
|
152
|
+
for e in self.edges):
|
|
153
|
+
return False, "They are connected already"
|
|
154
|
+
return True, input
|
|
155
|
+
|
|
156
|
+
def connect(self, source: Node | str, target: Node | str, input: str | None = None,
|
|
157
|
+
output: str = "out") -> Edge:
|
|
158
|
+
"""Join an output to an input. A single-connection input that was
|
|
159
|
+
already fed is re-fed from the new source (as dropping a wire does)."""
|
|
160
|
+
ok, detail = self.can_connect(source, target, input, output)
|
|
161
|
+
if not ok:
|
|
162
|
+
raise WorkflowError(detail)
|
|
163
|
+
source_id = source if isinstance(source, str) else source.id
|
|
164
|
+
target_id = target if isinstance(target, str) else target.id
|
|
165
|
+
port = self.spec(target_id).input(detail)
|
|
166
|
+
if not port.many:
|
|
167
|
+
self.edges = [e for e in self.edges if not (e.target == target_id and e.input == detail)]
|
|
168
|
+
edge = Edge(source_id, output, target_id, detail)
|
|
169
|
+
self.edges.append(edge)
|
|
170
|
+
return edge
|
|
171
|
+
|
|
172
|
+
def disconnect(self, edge: Edge) -> None:
|
|
173
|
+
self.edges = [e for e in self.edges if e != edge]
|
|
174
|
+
|
|
175
|
+
def remove(self, node: Node | str) -> None:
|
|
176
|
+
node_id = node if isinstance(node, str) else node.id
|
|
177
|
+
self.nodes.pop(node_id, None)
|
|
178
|
+
self.edges = [e for e in self.edges if node_id not in (e.source, e.target)]
|
|
179
|
+
for group in list(self.groups.values()):
|
|
180
|
+
group.members = [m for m in group.members if m != node_id]
|
|
181
|
+
if not group.members:
|
|
182
|
+
del self.groups[group.id]
|
|
183
|
+
|
|
184
|
+
# -- groups ---------------------------------------------------------------------
|
|
185
|
+
def group(self, members: list[str], name: str = "Group", id: str | None = None) -> Group:
|
|
186
|
+
for m in members:
|
|
187
|
+
if m not in self.nodes:
|
|
188
|
+
raise WorkflowError(f"No box {m!r}")
|
|
189
|
+
if self.group_of(m) is not None:
|
|
190
|
+
raise WorkflowError("A box can be in one group only")
|
|
191
|
+
group = Group(id or fresh_id("g"), name, list(members))
|
|
192
|
+
xs = [self.nodes[m].position[0] for m in members]
|
|
193
|
+
ys = [self.nodes[m].position[1] for m in members]
|
|
194
|
+
group.position = (min(xs), min(ys))
|
|
195
|
+
self.groups[group.id] = group
|
|
196
|
+
return group
|
|
197
|
+
|
|
198
|
+
def ungroup(self, group: Group | str) -> None:
|
|
199
|
+
group_id = group if isinstance(group, str) else group.id
|
|
200
|
+
self.groups.pop(group_id, None)
|
|
201
|
+
|
|
202
|
+
def group_of(self, node_id: str) -> Group | None:
|
|
203
|
+
return next((g for g in self.groups.values() if node_id in g.members), None)
|
|
204
|
+
|
|
205
|
+
def group_ports(self, group: Group) -> tuple[list[tuple[str, str, Port]], list[tuple[str, str, Port]]]:
|
|
206
|
+
"""The derived inputs and outputs: ``(node, port name, Port)`` each."""
|
|
207
|
+
inside = set(group.members)
|
|
208
|
+
inputs, outputs = [], []
|
|
209
|
+
for member in group.members:
|
|
210
|
+
spec = self.spec(member)
|
|
211
|
+
for port in spec.inputs:
|
|
212
|
+
feeders = [e for e in self.edges if e.target == member and e.input == port.name]
|
|
213
|
+
if not feeders or any(e.source not in inside for e in feeders):
|
|
214
|
+
inputs.append((member, port.name, port))
|
|
215
|
+
for port in spec.outputs:
|
|
216
|
+
users = [e for e in self.edges if e.source == member and e.output == port.name]
|
|
217
|
+
if not users or any(e.target not in inside for e in users):
|
|
218
|
+
outputs.append((member, port.name, port))
|
|
219
|
+
return inputs, outputs
|
|
220
|
+
|
|
221
|
+
# -- structure ------------------------------------------------------------------
|
|
222
|
+
def inputs_of(self, node: Node | str) -> dict[str, list[tuple[str, str]]]:
|
|
223
|
+
"""For every input port: the ``(node, output)`` pairs feeding it."""
|
|
224
|
+
node_id = node if isinstance(node, str) else node.id
|
|
225
|
+
spec = self.spec(node_id)
|
|
226
|
+
result = {p.name: [] for p in spec.inputs}
|
|
227
|
+
for e in self.edges:
|
|
228
|
+
if e.target == node_id and e.input in result:
|
|
229
|
+
result[e.input].append((e.source, e.output))
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
def descendants(self, ids) -> set[str]:
|
|
233
|
+
out, stack = set(ids), list(ids)
|
|
234
|
+
while stack:
|
|
235
|
+
current = stack.pop()
|
|
236
|
+
for e in self.edges:
|
|
237
|
+
if e.source == current and e.target not in out:
|
|
238
|
+
out.add(e.target)
|
|
239
|
+
stack.append(e.target)
|
|
240
|
+
return out
|
|
241
|
+
|
|
242
|
+
def ancestors(self, ids) -> set[str]:
|
|
243
|
+
out, stack = set(ids), list(ids)
|
|
244
|
+
while stack:
|
|
245
|
+
current = stack.pop()
|
|
246
|
+
for e in self.edges:
|
|
247
|
+
if e.target == current and e.source not in out:
|
|
248
|
+
out.add(e.source)
|
|
249
|
+
stack.append(e.source)
|
|
250
|
+
return out
|
|
251
|
+
|
|
252
|
+
def order(self) -> list[Node]:
|
|
253
|
+
"""The nodes in an order every box's inputs are ready, left to right
|
|
254
|
+
on ties. Raises on a cycle."""
|
|
255
|
+
indegree = {n: 0 for n in self.nodes}
|
|
256
|
+
for e in self.edges:
|
|
257
|
+
indegree[e.target] += 1
|
|
258
|
+
ready = sorted((n for n, d in indegree.items() if d == 0),
|
|
259
|
+
key=lambda n: (self.nodes[n].position[0], self.nodes[n].position[1]))
|
|
260
|
+
out = []
|
|
261
|
+
while ready:
|
|
262
|
+
current = ready.pop(0)
|
|
263
|
+
out.append(self.nodes[current])
|
|
264
|
+
for e in self.edges:
|
|
265
|
+
if e.source == current:
|
|
266
|
+
indegree[e.target] -= 1
|
|
267
|
+
if indegree[e.target] == 0:
|
|
268
|
+
ready.append(e.target)
|
|
269
|
+
ready.sort(key=lambda n: (self.nodes[n].position[0], self.nodes[n].position[1]))
|
|
270
|
+
if len(out) != len(self.nodes):
|
|
271
|
+
raise WorkflowError("The workflow has a loop")
|
|
272
|
+
return out
|
|
273
|
+
|
|
274
|
+
def swept(self) -> list[tuple[str, str, Sweep]]:
|
|
275
|
+
"""Every swept setting as ``(node id, setting name, Sweep)``."""
|
|
276
|
+
return [(node.id, name, sweep) for node in self.order() for name, sweep in node.swept()]
|
|
277
|
+
|
|
278
|
+
def validate(self) -> list[str]:
|
|
279
|
+
"""What stops the workflow from running, in plain words (empty: nothing)."""
|
|
280
|
+
problems = []
|
|
281
|
+
try:
|
|
282
|
+
self.order()
|
|
283
|
+
except WorkflowError as error:
|
|
284
|
+
problems.append(str(error))
|
|
285
|
+
for node in self.nodes.values():
|
|
286
|
+
spec = self.spec(node)
|
|
287
|
+
fed = self.inputs_of(node)
|
|
288
|
+
for port in spec.inputs:
|
|
289
|
+
if not fed[port.name] and not port.optional:
|
|
290
|
+
problems.append(f"{self.title(node)}: connect a {port.type_name.lower()} to “{port.label or port.name}”")
|
|
291
|
+
if not spec.available:
|
|
292
|
+
problems.append(f"{self.title(node)}: {spec.unavailable_reason}")
|
|
293
|
+
return problems
|
|
294
|
+
|
|
295
|
+
def title(self, node: Node | str) -> str:
|
|
296
|
+
node = self.nodes[node] if isinstance(node, str) else node
|
|
297
|
+
return node.title or self.spec(node).name
|
|
298
|
+
|
|
299
|
+
# -- files ----------------------------------------------------------------------------
|
|
300
|
+
def to_dict(self) -> dict:
|
|
301
|
+
return {
|
|
302
|
+
"name": self.name,
|
|
303
|
+
"boxes": [{"id": n.id, "box": n.box, "settings": {k: (v.to_json() if is_sweep(v) else _json_value(v))
|
|
304
|
+
for k, v in n.settings.items()},
|
|
305
|
+
"position": list(n.position), **({"title": n.title} if n.title else {})}
|
|
306
|
+
for n in self.nodes.values()],
|
|
307
|
+
"connections": [{"from": e.source, "output": e.output, "to": e.target, "input": e.input}
|
|
308
|
+
for e in self.edges],
|
|
309
|
+
"groups": [{"id": g.id, "name": g.name, "boxes": list(g.members), "position": list(g.position)}
|
|
310
|
+
for g in self.groups.values()],
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
@classmethod
|
|
314
|
+
def from_dict(cls, data: dict, library=None) -> "Workflow":
|
|
315
|
+
wf = cls(data.get("name", "workflow"), library)
|
|
316
|
+
for item in data.get("boxes", []):
|
|
317
|
+
settings = {k: Sweep(tuple(v["sweep"])) if isinstance(v, dict) and "sweep" in v else v
|
|
318
|
+
for k, v in item.get("settings", {}).items()}
|
|
319
|
+
try:
|
|
320
|
+
wf.add(item["box"], settings, tuple(item.get("position", (0, 0))), id=item["id"],
|
|
321
|
+
title=item.get("title", ""))
|
|
322
|
+
except KeyError:
|
|
323
|
+
raise WorkflowError(f"The workflow uses a box that is not available: {item['box']}")
|
|
324
|
+
for item in data.get("connections", []):
|
|
325
|
+
wf.connect(item["from"], item["to"], item["input"], item.get("output", "out"))
|
|
326
|
+
for item in data.get("groups", []):
|
|
327
|
+
group = wf.group(item["boxes"], item.get("name", "Group"), id=item.get("id"))
|
|
328
|
+
group.position = tuple(item.get("position", group.position))
|
|
329
|
+
return wf
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _json_value(value):
|
|
333
|
+
from pathlib import Path
|
|
334
|
+
return str(value) if isinstance(value, Path) else value
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _list(items) -> str:
|
|
338
|
+
items = list(items)
|
|
339
|
+
return ", ".join(items[:-1]) + (" or " if len(items) > 1 else "") + items[-1] if items else ""
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
# Recording: a Python function as a workflow
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
@dataclass(frozen=True)
|
|
346
|
+
class Ref:
|
|
347
|
+
"""Stands for an output while a function is being recorded."""
|
|
348
|
+
|
|
349
|
+
node: str
|
|
350
|
+
output: str = "out"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class _Builder:
|
|
354
|
+
def __init__(self, workflow: Workflow) -> None:
|
|
355
|
+
self.workflow = workflow
|
|
356
|
+
self.column = 0
|
|
357
|
+
|
|
358
|
+
def call(self, box: Box, args: tuple, kwargs: dict) -> Any:
|
|
359
|
+
spec = box.spec
|
|
360
|
+
if getattr(box, "composite", None) is not None:
|
|
361
|
+
# A @workflow function used as a box: record what it calls, as a group.
|
|
362
|
+
before = set(self.workflow.nodes)
|
|
363
|
+
value = box.composite(*args, **kwargs)
|
|
364
|
+
new = [n for n in self.workflow.nodes if n not in before and self.workflow.group_of(n) is None]
|
|
365
|
+
if len(new) >= 2:
|
|
366
|
+
self.workflow.group(new, spec.name)
|
|
367
|
+
return value
|
|
368
|
+
bound = _bind(spec, args, kwargs)
|
|
369
|
+
settings = {k: v for k, v in bound.items() if spec.setting(k) is not None}
|
|
370
|
+
node = self.workflow.add(spec, settings, position=(self.column * 230.0, 0.0))
|
|
371
|
+
self.column += 1
|
|
372
|
+
for port in spec.inputs:
|
|
373
|
+
value = bound.get(port.name)
|
|
374
|
+
if value is None:
|
|
375
|
+
continue
|
|
376
|
+
refs = value if port.many else [value]
|
|
377
|
+
for ref in refs:
|
|
378
|
+
if not isinstance(ref, Ref):
|
|
379
|
+
raise WorkflowError(
|
|
380
|
+
f"{spec.name}: {port.name!r} must come from another box while recording "
|
|
381
|
+
f"(got {type(ref).__name__})")
|
|
382
|
+
self.workflow.connect(ref.node, node, port.name, ref.output)
|
|
383
|
+
if not spec.outputs:
|
|
384
|
+
return None
|
|
385
|
+
if spec.result_type is not None:
|
|
386
|
+
return spec.result_type(**{p.name: Ref(node.id, p.name) for p in spec.outputs})
|
|
387
|
+
return Ref(node.id, "out")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _bind(spec: BoxSpec, args: tuple, kwargs: dict) -> dict[str, Any]:
|
|
391
|
+
import inspect
|
|
392
|
+
signature = inspect.signature(spec.function)
|
|
393
|
+
bound = signature.bind_partial(*args, **kwargs)
|
|
394
|
+
return dict(bound.arguments)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
_recording: contextvars.ContextVar[_Builder | None] = contextvars.ContextVar(
|
|
398
|
+
"openprocess.flow.recording", default=None)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def recording() -> _Builder | None:
|
|
402
|
+
return _recording.get()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def record(function: Callable, name: str | None = None, library=None) -> Workflow:
|
|
406
|
+
"""Run ``function`` in record mode and return the workflow it drew."""
|
|
407
|
+
wf = Workflow(name or function.__name__.replace("_", " "), library)
|
|
408
|
+
token = _recording.set(_Builder(wf))
|
|
409
|
+
try:
|
|
410
|
+
function()
|
|
411
|
+
finally:
|
|
412
|
+
_recording.reset(token)
|
|
413
|
+
_tidy_positions(wf)
|
|
414
|
+
return wf
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class Recorded:
|
|
418
|
+
"""A ``@workflow`` function: still callable, with ``.workflow`` drawn on
|
|
419
|
+
first use. One with typed parameters is also a *composite box*: in the
|
|
420
|
+
box list it is one box, on the canvas it can be opened as a group."""
|
|
421
|
+
|
|
422
|
+
def __init__(self, fn: Callable, name: str | None, group: str) -> None:
|
|
423
|
+
self.fn = fn
|
|
424
|
+
self.name = name or fn.__name__.replace("_", " ")
|
|
425
|
+
self.group_name = group
|
|
426
|
+
self._workflow: Workflow | None = None
|
|
427
|
+
self.__name__ = fn.__name__
|
|
428
|
+
self.__doc__ = fn.__doc__
|
|
429
|
+
self.__wrapped__ = fn
|
|
430
|
+
self.__module__ = fn.__module__
|
|
431
|
+
|
|
432
|
+
@property
|
|
433
|
+
def workflow(self) -> Workflow:
|
|
434
|
+
if self._workflow is None:
|
|
435
|
+
self._workflow = record(self.fn, self.name)
|
|
436
|
+
return self._workflow
|
|
437
|
+
|
|
438
|
+
def __call__(self, *args, **kwargs):
|
|
439
|
+
builder = recording()
|
|
440
|
+
if builder is not None:
|
|
441
|
+
box = self.as_box()
|
|
442
|
+
if box is not None:
|
|
443
|
+
return builder.call(box, args, kwargs)
|
|
444
|
+
return self.fn(*args, **kwargs)
|
|
445
|
+
|
|
446
|
+
def as_box(self) -> Box | None:
|
|
447
|
+
"""The function as a box (None when its parameters are not typed as a box's)."""
|
|
448
|
+
from .box import BoxError, make_spec
|
|
449
|
+
try:
|
|
450
|
+
spec = make_spec(self.fn, name=self.name, group=self.group_name)
|
|
451
|
+
except BoxError:
|
|
452
|
+
return None
|
|
453
|
+
if not spec.inputs and not spec.settings:
|
|
454
|
+
return None
|
|
455
|
+
box = Box(self.fn, spec)
|
|
456
|
+
box.composite = self.fn
|
|
457
|
+
return box
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def workflow(function: Callable | None = None, *, name: str | None = None, group: str = "Yours"):
|
|
461
|
+
"""``@workflow``: the function is recorded when first asked for, as
|
|
462
|
+
``function.workflow``; calling the function still runs it normally. A
|
|
463
|
+
``@workflow`` function with typed parameters is also a box (a group on the
|
|
464
|
+
canvas), listed in ``group``."""
|
|
465
|
+
def decorate(fn: Callable) -> Recorded:
|
|
466
|
+
return Recorded(fn, name, group)
|
|
467
|
+
return decorate(function) if function is not None else decorate
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _tidy_positions(wf: Workflow) -> None:
|
|
471
|
+
"""Columns by longest path from a source, rows by order: readable, not pretty."""
|
|
472
|
+
depth: dict[str, int] = {}
|
|
473
|
+
for node in wf.order():
|
|
474
|
+
feeders = [e.source for e in wf.edges if e.target == node.id]
|
|
475
|
+
depth[node.id] = max((depth[f] + 1 for f in feeders), default=0)
|
|
476
|
+
rows: dict[int, int] = {}
|
|
477
|
+
for node in wf.order():
|
|
478
|
+
column = depth[node.id]
|
|
479
|
+
node.position = (column * 230.0, rows.get(column, 0) * 110.0)
|
|
480
|
+
rows[column] = rows.get(column, 0) + 1
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# ---------------------------------------------------------------------------
|
|
484
|
+
# The workflow as Python
|
|
485
|
+
# ---------------------------------------------------------------------------
|
|
486
|
+
def _identifier(text: str) -> str:
|
|
487
|
+
text = re.sub(r"[^0-9a-zA-Z_]+", "_", text.strip().lower()).strip("_") or "workflow"
|
|
488
|
+
if text[0].isdigit():
|
|
489
|
+
text = "w_" + text
|
|
490
|
+
return text + "_" if keyword.iskeyword(text) else text
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _call_lines(wf: Workflow, nodes: list[Node], names: dict, used: dict, imports: set, customs: set,
|
|
494
|
+
types_used: set, params: dict | None = None) -> list[str]:
|
|
495
|
+
"""The box calls for ``nodes`` (in order), naming each result."""
|
|
496
|
+
lines = []
|
|
497
|
+
params = params or {}
|
|
498
|
+
|
|
499
|
+
def fresh(base: str) -> str:
|
|
500
|
+
used[base] = used.get(base, 0) + 1
|
|
501
|
+
return base if used[base] == 1 else f"{base}_{used[base]}"
|
|
502
|
+
|
|
503
|
+
for node in nodes:
|
|
504
|
+
spec = wf.spec(node)
|
|
505
|
+
function = spec.function.__name__
|
|
506
|
+
(customs if spec.custom else imports).add((spec.module, function))
|
|
507
|
+
fed = wf.inputs_of(node)
|
|
508
|
+
args = []
|
|
509
|
+
for port in spec.inputs:
|
|
510
|
+
sources = [names.get(s, params.get((node.id, port.name))) for s in fed[port.name]]
|
|
511
|
+
sources = [s for s in sources if s]
|
|
512
|
+
if not sources and (node.id, port.name) in params:
|
|
513
|
+
sources = [params[(node.id, port.name)]]
|
|
514
|
+
if port.many:
|
|
515
|
+
args.append(f"{port.name}=[{', '.join(sources)}]")
|
|
516
|
+
elif sources:
|
|
517
|
+
args.append(f"{port.name}={sources[0]}")
|
|
518
|
+
for setting in spec.settings:
|
|
519
|
+
value = node.settings.get(setting.name, setting.default)
|
|
520
|
+
if is_sweep(value):
|
|
521
|
+
args.append(f"{setting.name}=Sweep({list(value.values)!r})")
|
|
522
|
+
elif value != setting.default:
|
|
523
|
+
args.append(f"{setting.name}={value!r}")
|
|
524
|
+
call = f"{function}({', '.join(args)})"
|
|
525
|
+
if not spec.outputs:
|
|
526
|
+
lines.append(call)
|
|
527
|
+
elif spec.result_type is not None:
|
|
528
|
+
var = fresh(_identifier(spec.name))
|
|
529
|
+
lines.append(f"{var} = {call}")
|
|
530
|
+
for port in spec.outputs:
|
|
531
|
+
names[(node.id, port.name)] = f"{var}.{port.name}"
|
|
532
|
+
types_used.add(port.info.python if port.info else "Any")
|
|
533
|
+
else:
|
|
534
|
+
port = spec.outputs[0]
|
|
535
|
+
var = fresh(_identifier(port.info.key if port.info else "value"))
|
|
536
|
+
names[(node.id, "out")] = var
|
|
537
|
+
types_used.add(port.info.python if port.info else "Any")
|
|
538
|
+
lines.append(f"{var} = {call}")
|
|
539
|
+
return lines
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def group_to_python(wf: Workflow, group: Group) -> str:
|
|
543
|
+
"""A group as a ``@workflow`` function: its outside inputs are the
|
|
544
|
+
parameters, its outside outputs the return value."""
|
|
545
|
+
inputs, outputs = wf.group_ports(group)
|
|
546
|
+
params, seen, signature = {}, {}, []
|
|
547
|
+
for node_id, port_name, port in inputs:
|
|
548
|
+
base = _identifier(port.label or port.name)
|
|
549
|
+
seen[base] = seen.get(base, 0) + 1
|
|
550
|
+
name = base if seen[base] == 1 else f"{base}_{seen[base]}"
|
|
551
|
+
params[(node_id, port_name)] = name
|
|
552
|
+
signature.append(f"{name}: {port.info.python if port.info else 'Any'}")
|
|
553
|
+
names, used, imports, customs, types_used = {}, {}, set(), set(), set()
|
|
554
|
+
members = [n for n in wf.order() if n.id in group.members]
|
|
555
|
+
lines = _call_lines(wf, members, names, used, imports, customs, types_used, params)
|
|
556
|
+
returns = [names.get((node_id, port_name), "None") for node_id, port_name, _ in outputs]
|
|
557
|
+
if returns:
|
|
558
|
+
lines.append("return " + ", ".join(returns))
|
|
559
|
+
if len(outputs) == 1:
|
|
560
|
+
return_type = outputs[0][2].info.python if outputs[0][2].info else "Any"
|
|
561
|
+
elif outputs:
|
|
562
|
+
return_type = "tuple[" + ", ".join(o[2].info.python if o[2].info else "Any" for o in outputs) + "]"
|
|
563
|
+
else:
|
|
564
|
+
return_type = "None"
|
|
565
|
+
body = "\n".join(" " + line for line in lines) or " pass"
|
|
566
|
+
return (f"@workflow(name={group.name!r})\ndef {_identifier(group.name)}({', '.join(signature)}) -> {return_type}:\n"
|
|
567
|
+
f"{body}\n")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def to_python(wf: Workflow) -> str:
|
|
571
|
+
"""The workflow as a ``@workflow`` function that would record it back;
|
|
572
|
+
every group becomes a function of its own, called once."""
|
|
573
|
+
names, used, imports, customs, types_used = {}, {}, set(), set(), set()
|
|
574
|
+
lines, group_defs = [], []
|
|
575
|
+
done_groups: set[str] = set()
|
|
576
|
+
for node in wf.order():
|
|
577
|
+
group = wf.group_of(node.id)
|
|
578
|
+
if group is None:
|
|
579
|
+
lines.extend(_call_lines(wf, [node], names, used, imports, customs, types_used))
|
|
580
|
+
continue
|
|
581
|
+
if group.id in done_groups:
|
|
582
|
+
continue
|
|
583
|
+
done_groups.add(group.id)
|
|
584
|
+
group_defs.append(group_to_python(wf, group))
|
|
585
|
+
inputs, outputs = wf.group_ports(group)
|
|
586
|
+
args = []
|
|
587
|
+
for node_id, port_name, port in inputs:
|
|
588
|
+
sources = [names.get(s) for s in wf.inputs_of(node_id)[port_name]]
|
|
589
|
+
sources = [s for s in sources if s]
|
|
590
|
+
if sources:
|
|
591
|
+
args.append(f"[{', '.join(sources)}]" if port.many else sources[0])
|
|
592
|
+
else:
|
|
593
|
+
args.append("None")
|
|
594
|
+
function = _identifier(group.name)
|
|
595
|
+
if not outputs:
|
|
596
|
+
lines.append(f"{function}({', '.join(args)})")
|
|
597
|
+
else:
|
|
598
|
+
targets = []
|
|
599
|
+
for node_id, port_name, port in outputs:
|
|
600
|
+
base = _identifier(port.info.key if port.info else "value")
|
|
601
|
+
used[base] = used.get(base, 0) + 1
|
|
602
|
+
var = base if used[base] == 1 else f"{base}_{used[base]}"
|
|
603
|
+
names[(node_id, port_name)] = var
|
|
604
|
+
targets.append(var)
|
|
605
|
+
lines.append(f"{', '.join(targets)} = {function}({', '.join(args)})")
|
|
606
|
+
for node_id, _, _ in inputs:
|
|
607
|
+
pass
|
|
608
|
+
# Spec imports of the members are collected by group_to_python; redo for the header.
|
|
609
|
+
_call_lines(wf, [n for n in wf.order() if n.id in group.members], {}, {}, imports, customs, set(),
|
|
610
|
+
{(n, p): "x" for n, p, _ in inputs})
|
|
611
|
+
header = ["from openprocess.flow import workflow" + (", Sweep" if wf.swept() else "")
|
|
612
|
+
+ (", " + ", ".join(sorted(types_used)) if types_used and group_defs else "")]
|
|
613
|
+
by_module: dict[str, list[str]] = {}
|
|
614
|
+
for module, function in sorted(imports):
|
|
615
|
+
by_module.setdefault(module, []).append(function)
|
|
616
|
+
for module, functions in by_module.items():
|
|
617
|
+
header.append(f"from {module} import {', '.join(functions)}")
|
|
618
|
+
for module, function in sorted(customs):
|
|
619
|
+
header.append(f"from {module} import {function}")
|
|
620
|
+
body = "\n".join(" " + line for line in lines) or " pass"
|
|
621
|
+
parts = "\n".join(header) + "\n\n\n"
|
|
622
|
+
for definition in group_defs:
|
|
623
|
+
parts += definition + "\n\n"
|
|
624
|
+
return parts + f"@workflow(name={wf.name!r})\ndef {_identifier(wf.name)}():\n{body}\n"
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
__all__ = ["Edge", "Group", "Node", "Recorded", "Ref", "Workflow", "WorkflowError", "fresh_id",
|
|
628
|
+
"group_to_python", "record", "recording", "to_python", "workflow"]
|
|
File without changes
|