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,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