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,36 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <pnml>
3
+ <net id="net1" type="http://www.pnml.org/version-2009/grammar/pnmlcoremodel">
4
+ <name><text>m1</text></name>
5
+ <page id="page1">
6
+ <place id="i"><name><text>i</text></name><initialMarking><text>1</text></initialMarking><graphics><position x="0.0" y="90.0"/><dimension x="40" y="40"/></graphics></place>
7
+ <place id="p1"><name><text>p1</text></name><graphics><position x="240.0" y="0.0"/><dimension x="40" y="40"/></graphics></place>
8
+ <place id="p2"><name><text>p2</text></name><graphics><position x="240.0" y="180.0"/><dimension x="40" y="40"/></graphics></place>
9
+ <place id="p3"><name><text>p3</text></name><graphics><position x="480.0" y="0.0"/><dimension x="40" y="40"/></graphics></place>
10
+ <place id="p4"><name><text>p4</text></name><graphics><position x="480.0" y="180.0"/><dimension x="40" y="40"/></graphics></place>
11
+ <place id="o"><name><text>o</text></name><graphics><position x="720.0" y="90.0"/><dimension x="40" y="40"/></graphics></place>
12
+ <transition id="a"><name><text>a</text></name><graphics><position x="120.0" y="90.0"/><dimension x="40" y="40"/></graphics></transition>
13
+ <transition id="b"><name><text>b</text></name><graphics><position x="360.0" y="0.0"/><dimension x="40" y="40"/></graphics></transition>
14
+ <transition id="c"><name><text>c</text></name><graphics><position x="360.0" y="180.0"/><dimension x="40" y="40"/></graphics></transition>
15
+ <transition id="d"><name><text>d</text></name><graphics><position x="600.0" y="90.0"/><dimension x="40" y="40"/></graphics></transition>
16
+ <transition id="e"><name><text>e</text></name><graphics><position x="360.0" y="90.0"/><dimension x="40" y="40"/></graphics></transition>
17
+ <arc id="arc1" source="i" target="a"></arc>
18
+ <arc id="arc2" source="a" target="p1"></arc>
19
+ <arc id="arc3" source="a" target="p2"></arc>
20
+ <arc id="arc4" source="p1" target="b"></arc>
21
+ <arc id="arc5" source="p2" target="c"></arc>
22
+ <arc id="arc6" source="b" target="p3"></arc>
23
+ <arc id="arc7" source="c" target="p4"></arc>
24
+ <arc id="arc8" source="p3" target="d"></arc>
25
+ <arc id="arc9" source="p4" target="d"></arc>
26
+ <arc id="arc10" source="d" target="o"></arc>
27
+ <arc id="arc11" source="p1" target="e"></arc>
28
+ <arc id="arc12" source="p2" target="e"></arc>
29
+ <arc id="arc13" source="e" target="p3"></arc>
30
+ <arc id="arc14" source="e" target="p4"></arc>
31
+ </page>
32
+ <finalmarkings><marking>
33
+ <place idref="o"><text>1</text></place>
34
+ </marking></finalmarkings>
35
+ </net>
36
+ </pnml>
@@ -0,0 +1,69 @@
1
+ # Exercise 7.1 — Replay, alignments and workflows
2
+
3
+ The log $L = [\langle a,b,c,d \rangle^3, \langle a,c,b,d \rangle^2, \langle
4
+ a,e,d \rangle]$ and the net $N$ (the given net on the right) are the ones
5
+ the α-algorithm gives for Exercise 6.1. Three candidate models of $L$ are in
6
+ the exercise's folder as well: *m1.pnml*, *m2.pnml* and *m3.pnml*. Open them
7
+ with *Open Folder* in the ⋯ menu if you want to look at them.
8
+
9
+ **a.** Replay every variant of $L$ on $N$ with **token-based replay**. For
10
+ each trace, fill in the tokens **p**roduced, **c**onsumed, **m**issing and
11
+ **r**emaining. Count the token put in the source place at the start and the
12
+ one taken from the sink at the end. (3 points)
13
+
14
+ ```answer
15
+ type: replay
16
+ compute: replay
17
+ points: 3
18
+ hint: A trace that fits has m = 0 and r = 0, and then p = c.
19
+ ```
20
+
21
+ **b.** What is the **fitness** of $L$ on $N$ (token-based)?
22
+
23
+ ```answer
24
+ type: number
25
+ compute: fitness
26
+ tolerance: 0.005
27
+ hint: fitness = ½ (1 − m/c) + ½ (1 − r/p), summed over the log.
28
+ ```
29
+
30
+ **c.** The trace $\langle a, b, d \rangle$ is not in $L$. Give an **optimal
31
+ alignment** of it with $N$: the log moves on the first line, the model moves
32
+ on the second, `≫` (or `>>`) where there is no move.
33
+
34
+ ```answer
35
+ type: alignment
36
+ trace: a, b, d
37
+ answer: optimal
38
+ points: 2
39
+ hint: Which activity has to happen in the model between b and d? That is a
40
+ model move: ≫ above, the activity below.
41
+ ```
42
+
43
+ **d.** Rank the three candidate models *m1*, *m2* and *m3* by their fitness
44
+ on $L$, best first.
45
+
46
+ ```answer
47
+ type: ranking
48
+ over: m1.pnml, m2.pnml, m3.pnml
49
+ by: fitness
50
+ hint: m2 is a plain sequence; m3 lets b, c and e replace each other.
51
+ ```
52
+
53
+ **e.** Build a **workflow** in the Workflow tab on the right that discovers a
54
+ model from $L$ with the **Inductive Miner** and checks how well it fits with
55
+ **Check fit**. Its fitness should be 1. (2 points)
56
+
57
+ ```answer
58
+ type: workflow
59
+ needs: inductive_miner, check_fit
60
+ result: Check fit.metrics.fitness
61
+ answer: 1
62
+ tolerance: 0.01
63
+ points: 2
64
+ hint: Drag the Inductive Miner box from Discover and Check fit from Check;
65
+ wire the log into both, and the miner's model into Check fit.
66
+ ```
67
+
68
+ The net's Conformance tab and the log's Discover tab are hidden until you
69
+ reveal them.
@@ -0,0 +1,14 @@
1
+ # OpenProcess demo exercises
2
+
3
+ Seven short worksheets: Petri nets, soundness, the α-algorithm, regions,
4
+ markings and matrices, the Inductive Miner, and conformance checking with a
5
+ workflow. Answer in the boxes and press **Check**: most answers are checked
6
+ straight away, and the app never shows you a result you are meant to work out
7
+ until you ask for it.
8
+
9
+ Your answers are saved in each exercise's folder (*my answers.json*, and
10
+ *my answer.pnml* for a net you draw, *my workflow.cpnflow* for a workflow
11
+ you build), so you can stop and carry on later.
12
+
13
+ To write your own pack, copy this folder and change the `question.md` files:
14
+ see *Help ▸ Writing Exercise Packs*.
@@ -0,0 +1,50 @@
1
+ """Workflows: boxes, their connections, and running them reproducibly.
2
+
3
+ A box is a Python function with type hints::
4
+
5
+ from openprocess.flow import box, EventLog, PetriNet
6
+
7
+ @box(group="Discover")
8
+ def my_miner(log: EventLog, threshold: float = 0.5) -> PetriNet:
9
+ \"\"\"The help text.\"\"\"
10
+ ...
11
+
12
+ A workflow is boxes wired together, built on the canvas, in a file, or in
13
+ Python::
14
+
15
+ from openprocess.flow import Workflow, Runner, save
16
+ from openprocess.flow.boxes.input import typed_log
17
+ from openprocess.flow.boxes.discover import inductive_miner
18
+ from openprocess.flow.boxes.check import check_fit
19
+
20
+ wf = Workflow("demo")
21
+ log = wf.add(typed_log, {"text": "[<a,b,c,d>^3, <a,c,b,d>^2, <a,e,d>]"})
22
+ model = wf.add(inductive_miner)
23
+ fit = wf.add(check_fit)
24
+ wf.connect(log, model); wf.connect(model, fit, "model"); wf.connect(log, fit, "log")
25
+ run = Runner().run(wf)
26
+ print(run.value(fit).metrics)
27
+ save(wf, "demo.cpnflow", run) # with the record that makes it reproducible
28
+
29
+ See ``docs/workflows.md``.
30
+ """
31
+
32
+ from .box import Box, BoxError, BoxSpec, Port, Setting, box
33
+ from .explain import Explanation, current, note, show, steps
34
+ from .library import Library, library_for, standard_library
35
+ from .record import check, differences, load, save
36
+ from .runner import Cache, Result, Run, Runner
37
+ from .sweep import Sweep
38
+ from .types import (AlignmentResult, Any, CPNet, DFG, Dataset, EventLog, Figure, Footprint, Marking,
39
+ PetriNet, Predictions, Predictor, ProcessTree, Regions, ReplayResult, Scores,
40
+ SimpleLog, Table, Text, TransitionSystem, register_type)
41
+ from .workflow import Edge, Group, Node, Workflow, WorkflowError, to_python, workflow
42
+
43
+ __all__ = [
44
+ "AlignmentResult", "Any", "Box", "BoxError", "BoxSpec", "CPNet", "Cache", "DFG", "Dataset", "Edge",
45
+ "EventLog", "Explanation", "Figure", "Footprint", "Group", "Library", "Marking", "Node", "PetriNet",
46
+ "Port", "Predictions", "Predictor", "ProcessTree", "Regions", "ReplayResult", "Result", "Run",
47
+ "Runner", "Scores", "Setting", "SimpleLog", "Sweep", "Table", "Text", "TransitionSystem", "Workflow",
48
+ "WorkflowError", "box", "check", "current", "differences", "library_for", "load", "note",
49
+ "register_type", "save", "show", "standard_library", "steps", "to_python", "workflow",
50
+ ]
@@ -0,0 +1,466 @@
1
+ """``@box``: a Python function as a box on the workflow canvas.
2
+
3
+ Writing a box is writing a function with type hints, nothing more::
4
+
5
+ from openprocess.flow import box, EventLog, TransitionSystem
6
+
7
+ @box(group="Discover")
8
+ def learned_states(log: EventLog, clusters: int = 8, seed: int = 0) -> TransitionSystem:
9
+ \"\"\"Builds a transition system by clustering prefix embeddings.\"\"\"
10
+ ...
11
+
12
+ What the decorator reads, and nothing else:
13
+
14
+ ================================================ ===============================================
15
+ parameters whose type is a OpenProcess type input connection points, named after the parameter
16
+ ``list[Type]`` an input that takes any number of connections
17
+ ``Type | None`` (or a default of ``None``) an optional input
18
+ the return type the output; a dataclass of typed fields gives several
19
+ ``int``, ``float``, ``bool``, ``str`` with a default a setting, with the default as its value
20
+ ``Literal["a", "b"]`` a setting that is a choice
21
+ ``Path`` a setting that is a file in the folder
22
+ the docstring the help text
23
+ ``inspect.getsource`` the Code tab
24
+ ================================================ ===============================================
25
+
26
+ A box has no base class and no registration call: the decorator returns a
27
+ :class:`Box`, which is still the function (call it from a script or a
28
+ notebook and it runs the same way) with a :class:`BoxSpec` attached. A
29
+ mistake in the hints (an unknown type, a setting without a default) raises
30
+ :class:`BoxError` with a plain message at import time, which the loader
31
+ turns into a greyed-out box with the reason.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import dataclasses
37
+ import hashlib
38
+ import inspect
39
+ import importlib.util
40
+ import types
41
+ import typing
42
+ from dataclasses import dataclass, field
43
+ from pathlib import Path
44
+ from typing import Any, Callable, Literal, Union, get_args, get_origin, get_type_hints
45
+
46
+ from .types import TypeInfo, is_known, type_info
47
+
48
+ SETTING_TYPES = (int, float, bool, str)
49
+
50
+
51
+ class BoxError(ValueError):
52
+ """The function cannot be a box; the message says why in plain words."""
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Port:
57
+ """A connection point: an input (a parameter) or an output."""
58
+
59
+ name: str
60
+ type: type | None # None for typing.Any
61
+ many: bool = False # takes any number of connections (a list)
62
+ optional: bool = False
63
+ label: str = ""
64
+
65
+ @property
66
+ def info(self) -> TypeInfo | None:
67
+ return type_info(self.type) if self.type is not None else None
68
+
69
+ @property
70
+ def key(self) -> str:
71
+ info = self.info
72
+ return info.key if info else "any"
73
+
74
+ @property
75
+ def type_name(self) -> str:
76
+ info = self.info
77
+ if info:
78
+ return info.name
79
+ return "Anything" if self.type is None else getattr(self.type, "__name__", str(self.type))
80
+
81
+ def accepts(self, other: "Port") -> bool:
82
+ """Can an output ``other`` be connected to this input?"""
83
+ if self.type is None or other.type is None:
84
+ return self.type is other.type and other.type is None or self.type is other.type
85
+ return issubclass(other.type, self.type) or self.type is other.type
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Setting:
90
+ """A parameter that is not an input: shown as a control in the side panel."""
91
+
92
+ name: str
93
+ kind: str # "int", "float", "bool", "str", "choice", "path"
94
+ default: Any
95
+ choices: tuple = ()
96
+ help: str = ""
97
+
98
+ def coerce(self, value: Any) -> Any:
99
+ """A value as the GUI or a file gives it, in the setting's type."""
100
+ if self.kind == "int":
101
+ return int(value)
102
+ if self.kind == "float":
103
+ return float(value)
104
+ if self.kind == "bool":
105
+ return value if isinstance(value, bool) else str(value).lower() in ("1", "true", "yes", "on")
106
+ if self.kind == "choice":
107
+ if self.choices and value not in self.choices:
108
+ for choice in self.choices:
109
+ if str(choice) == str(value):
110
+ return choice
111
+ raise ValueError(f"{self.name!r} must be one of {', '.join(map(str, self.choices))}")
112
+ return value
113
+ if self.kind == "path":
114
+ return Path(value) if value not in (None, "") else None
115
+ return str(value)
116
+
117
+
118
+ @dataclass
119
+ class BoxSpec:
120
+ """Everything the app knows about a box, read from the function."""
121
+
122
+ id: str # "module.function", unique in a library
123
+ name: str # plain name shown on the canvas
124
+ group: str
125
+ function: Callable
126
+ inputs: list[Port] = field(default_factory=list)
127
+ outputs: list[Port] = field(default_factory=list)
128
+ settings: list[Setting] = field(default_factory=list)
129
+ help: str = ""
130
+ source: str = ""
131
+ file: str = ""
132
+ line: int = 0
133
+ module: str = ""
134
+ needs: tuple[str, ...] = () # modules that must be importable
135
+ heavy: bool = False # run it where it can be stopped (a process)
136
+ custom: bool = False # from the folder's boxes/ or a package, not OpenProcess's own
137
+ result_type: type | None = None # a dataclass of outputs, when there are several
138
+
139
+ def setting(self, name: str) -> Setting | None:
140
+ return next((s for s in self.settings if s.name == name), None)
141
+
142
+ def input(self, name: str) -> Port | None:
143
+ return next((p for p in self.inputs if p.name == name), None)
144
+
145
+ def output(self, name: str = "out") -> Port | None:
146
+ return next((p for p in self.outputs if p.name == name), None)
147
+
148
+ def defaults(self) -> dict[str, Any]:
149
+ return {s.name: s.default for s in self.settings}
150
+
151
+ @property
152
+ def fingerprint(self) -> str:
153
+ """Changes when the code changes: part of every cache key."""
154
+ return hashlib.sha256((self.source or self.id).encode("utf-8")).hexdigest()
155
+
156
+ def missing(self) -> list[str]:
157
+ """The modules in ``needs`` that are not installed."""
158
+ return [m for m in self.needs if importlib.util.find_spec(m.split(".")[0]) is None]
159
+
160
+ @property
161
+ def available(self) -> bool:
162
+ return not self.missing()
163
+
164
+ @property
165
+ def unavailable_reason(self) -> str:
166
+ missing = self.missing()
167
+ return f"needs {', '.join(missing)}" if missing else ""
168
+
169
+ def describe(self) -> str:
170
+ """One paragraph for the command line."""
171
+ ins = ", ".join(f"{p.name}: {p.type_name}{' (any number)' if p.many else ''}" for p in self.inputs) or "nothing"
172
+ outs = ", ".join(p.type_name for p in self.outputs) or "nothing"
173
+ settings = ", ".join(f"{s.name}={s.default!r}" for s in self.settings)
174
+ first = (self.help or "").strip().split("\n")[0]
175
+ lines = [f"{self.name} [{self.group}] {self.id}", f" takes {ins}; gives {outs}"]
176
+ if settings:
177
+ lines.append(f" settings: {settings}")
178
+ if first:
179
+ lines.append(f" {first}")
180
+ if not self.available:
181
+ lines.append(f" unavailable: {self.unavailable_reason}")
182
+ return "\n".join(lines)
183
+
184
+
185
+ class Box:
186
+ """The function, with its :class:`BoxSpec`. Calling it calls the
187
+ function; inside a ``@workflow`` being recorded it adds a node instead."""
188
+
189
+ def __init__(self, function: Callable, spec: BoxSpec) -> None:
190
+ self.function = function
191
+ self.spec = spec
192
+ self.__wrapped__ = function
193
+ self.__name__ = function.__name__
194
+ self.__qualname__ = getattr(function, "__qualname__", function.__name__)
195
+ self.__doc__ = function.__doc__
196
+ self.__module__ = function.__module__
197
+
198
+ def __call__(self, *args, **kwargs):
199
+ from .workflow import recording
200
+ builder = recording()
201
+ if builder is not None:
202
+ return builder.call(self, args, kwargs)
203
+ return self.function(*args, **kwargs)
204
+
205
+ def __repr__(self) -> str:
206
+ return f"<box {self.spec.id}>"
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Reading the signature
211
+ # ---------------------------------------------------------------------------
212
+ def _unwrap_optional(annotation):
213
+ """``X | None`` -> (X, True); anything else -> (annotation, False)."""
214
+ origin = get_origin(annotation)
215
+ if origin is Union or origin is types.UnionType:
216
+ args = [a for a in get_args(annotation) if a is not type(None)]
217
+ if len(args) == 1 and len(get_args(annotation)) == 2:
218
+ return args[0], True
219
+ return annotation, False
220
+
221
+
222
+ def _port_from(name: str, annotation, default, has_default: bool) -> Port | None:
223
+ """An input port for a parameter, or None when it is a setting."""
224
+ annotation, optional = _unwrap_optional(annotation)
225
+ if has_default and default is None:
226
+ optional = True
227
+ origin = get_origin(annotation)
228
+ if origin in (list, tuple, typing.List):
229
+ args = get_args(annotation)
230
+ inner = args[0] if args else None
231
+ if inner is not None and (is_known(inner) or inner is Any):
232
+ return Port(name, None if inner is Any else inner, many=True,
233
+ optional=optional or has_default, label=name.replace("_", " "))
234
+ return None
235
+ if annotation is Any:
236
+ return Port(name, None, optional=optional, label=name.replace("_", " "))
237
+ if isinstance(annotation, type) and is_known(annotation):
238
+ return Port(name, annotation, optional=optional, label=name.replace("_", " "))
239
+ return None
240
+
241
+
242
+ def _setting_from(name: str, annotation, default, has_default: bool, doc_hints: dict) -> Setting:
243
+ annotation, _ = _unwrap_optional(annotation)
244
+ if get_origin(annotation) is Literal:
245
+ choices = get_args(annotation)
246
+ if not has_default:
247
+ default = choices[0]
248
+ return Setting(name, "choice", default, tuple(choices), doc_hints.get(name, ""))
249
+ if annotation is Path or annotation == "Path":
250
+ return Setting(name, "path", default if has_default else None, help=doc_hints.get(name, ""))
251
+ if annotation is bool:
252
+ kind = "bool"
253
+ elif annotation is int:
254
+ kind = "int"
255
+ elif annotation is float:
256
+ kind = "float"
257
+ elif annotation is str:
258
+ kind = "str"
259
+ else:
260
+ raise BoxError(
261
+ f"parameter {name!r} has type {_type_text(annotation)}, which is neither a OpenProcess "
262
+ f"type (an input) nor int, float, bool, str, Literal[...] or Path (a setting)")
263
+ if not has_default:
264
+ raise BoxError(f"setting {name!r} needs a default value (e.g. {name}: {kind} = ...)")
265
+ if kind == "float" and isinstance(default, int) and not isinstance(default, bool):
266
+ default = float(default)
267
+ return Setting(name, kind, default, help=doc_hints.get(name, ""))
268
+
269
+
270
+ def _type_text(annotation) -> str:
271
+ return getattr(annotation, "__name__", None) or str(annotation)
272
+
273
+
274
+ def _outputs_from(annotation) -> tuple[list[Port], type | None]:
275
+ if annotation is inspect.Signature.empty:
276
+ raise BoxError("the function needs a return type hint (-> PetriNet), or -> None")
277
+ if annotation is None or annotation is type(None):
278
+ return [], None
279
+ annotation, _ = _unwrap_optional(annotation)
280
+ if annotation is Any:
281
+ return [Port("out", None)], None
282
+ if isinstance(annotation, type) and is_known(annotation):
283
+ return [Port("out", annotation)], None
284
+ if isinstance(annotation, type) and dataclasses.is_dataclass(annotation):
285
+ ports = []
286
+ hints = get_type_hints(annotation)
287
+ for f in dataclasses.fields(annotation):
288
+ inner, _ = _unwrap_optional(hints.get(f.name, f.type))
289
+ if not (isinstance(inner, type) and is_known(inner)) and inner is not Any:
290
+ raise BoxError(f"result field {f.name!r} has type {_type_text(inner)}, "
291
+ f"which is not a OpenProcess type")
292
+ ports.append(Port(f.name, None if inner is Any else inner, label=f.name.replace("_", " ")))
293
+ if not ports:
294
+ raise BoxError(f"result class {annotation.__name__} has no fields")
295
+ return ports, annotation
296
+ raise BoxError(f"the return type {_type_text(annotation)} is not a OpenProcess type "
297
+ f"(nor a dataclass of them)")
298
+
299
+
300
+ def _doc_hints(doc: str) -> dict[str, str]:
301
+ """``name: text`` lines of a docstring, as help for settings."""
302
+ hints: dict[str, str] = {}
303
+ for line in (doc or "").splitlines():
304
+ stripped = line.strip()
305
+ if ":" in stripped and not stripped.startswith(":"):
306
+ key, _, text = stripped.partition(":")
307
+ if key.isidentifier() and text.strip():
308
+ hints[key] = text.strip()
309
+ return hints
310
+
311
+
312
+ def _plain_name(function_name: str) -> str:
313
+ words = function_name.replace("_", " ").strip()
314
+ return words[:1].upper() + words[1:]
315
+
316
+
317
+ def make_spec(function: Callable, name: str | None = None, group: str = "Other",
318
+ needs: str | tuple[str, ...] | None = None, heavy: bool = False,
319
+ custom: bool = False, id: str | None = None, localns: dict | None = None) -> BoxSpec:
320
+ """Read a function's signature into a :class:`BoxSpec` (raises :class:`BoxError`).
321
+
322
+ ``localns`` resolves names in string annotations (``from __future__ import
323
+ annotations``) that are local to where the function was defined."""
324
+ try:
325
+ hints = get_type_hints(function, localns=localns)
326
+ except Exception as error: # noqa: BLE001 - a bad annotation of any kind
327
+ raise BoxError(f"could not read the type hints: {error}") from error
328
+ signature = inspect.signature(function)
329
+ doc = inspect.getdoc(function) or ""
330
+ doc_hints = _doc_hints(doc)
331
+ inputs: list[Port] = []
332
+ settings: list[Setting] = []
333
+ for parameter in signature.parameters.values():
334
+ if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD):
335
+ raise BoxError(f"*{parameter.name} is not allowed: every parameter must be named")
336
+ if parameter.name not in hints:
337
+ raise BoxError(f"parameter {parameter.name!r} needs a type hint")
338
+ has_default = parameter.default is not parameter.empty
339
+ port = _port_from(parameter.name, hints[parameter.name], parameter.default, has_default)
340
+ if port is not None:
341
+ inputs.append(port)
342
+ else:
343
+ settings.append(_setting_from(parameter.name, hints[parameter.name],
344
+ parameter.default, has_default, doc_hints))
345
+ outputs, result_type = _outputs_from(hints.get("return", signature.return_annotation))
346
+ try:
347
+ source = inspect.getsource(function)
348
+ except (OSError, TypeError):
349
+ source = ""
350
+ try:
351
+ file = inspect.getsourcefile(function) or ""
352
+ line = inspect.getsourcelines(function)[1]
353
+ except (OSError, TypeError):
354
+ file, line = "", 0
355
+ if isinstance(needs, str):
356
+ needs = (needs,)
357
+ module = function.__module__ or ""
358
+ return BoxSpec(id=id or f"{module}.{function.__name__}", name=name or _plain_name(function.__name__),
359
+ group=group, function=function, inputs=inputs, outputs=outputs, settings=settings,
360
+ help=doc, source=source, file=file, line=line, module=module,
361
+ needs=tuple(needs or ()), heavy=heavy, custom=custom, result_type=result_type)
362
+
363
+
364
+ @dataclass(frozen=True)
365
+ class Called:
366
+ """A function a box calls that holds the actual work: the algorithm the
367
+ box is a thin wrapper around (see :func:`algorithm_calls`)."""
368
+
369
+ name: str # "alpha_miner"
370
+ module: str # "openprocess.mining.discovery.alpha"
371
+ file: str
372
+ line: int
373
+ source: str
374
+
375
+ @property
376
+ def where(self) -> str:
377
+ """The file as shown: ``openprocess/mining/discovery/alpha.py:86``."""
378
+ path = Path(self.file)
379
+ parts = path.parts
380
+ shown = "/".join(parts[parts.index("openprocess"):]) if "openprocess" in parts else path.name
381
+ return shown + (f":{self.line}" if self.line else "")
382
+
383
+
384
+ def _resolve(expression, namespace: dict):
385
+ """The object a call's target names, through the box's globals: ``name``
386
+ or ``module.attribute``; None when it is not a global (a parameter, a
387
+ result's method)."""
388
+ import ast
389
+ if isinstance(expression, ast.Name):
390
+ return namespace.get(expression.id)
391
+ if isinstance(expression, ast.Attribute):
392
+ base = _resolve(expression.value, namespace)
393
+ if isinstance(base, (types.ModuleType, type)):
394
+ return getattr(base, expression.attr, None)
395
+ return None
396
+
397
+
398
+ def algorithm_calls(spec: BoxSpec) -> list[Called]:
399
+ """The functions the box's code calls that are defined outside the
400
+ workflow framework and the standard library, in the order they are
401
+ called: the algorithms the box delegates to. The α-algorithm box, for
402
+ example, calls ``openprocess.mining.discovery.alpha.alpha_miner``; that is
403
+ the code to read when assessing correctness, so the Code tab shows it
404
+ under the box's own few lines."""
405
+ import ast
406
+ import sysconfig
407
+ import textwrap
408
+ if not spec.source:
409
+ return []
410
+ try:
411
+ tree = ast.parse(textwrap.dedent(spec.source))
412
+ except SyntaxError:
413
+ return []
414
+ namespace = getattr(spec.function, "__globals__", {})
415
+ stdlib = sysconfig.get_paths().get("stdlib", "")
416
+ calls: list[tuple[int, int, Called]] = []
417
+ seen: set[tuple[str, str]] = set()
418
+ for node in ast.walk(tree):
419
+ if not isinstance(node, ast.Call):
420
+ continue
421
+ target = _resolve(node.func, namespace)
422
+ if target is None:
423
+ continue
424
+ target = inspect.unwrap(target)
425
+ if not inspect.isfunction(target):
426
+ continue # classes, builtins and C code are not algorithms
427
+ module = getattr(target, "__module__", "") or ""
428
+ if not module or module.startswith("openprocess.flow") or module == "builtins":
429
+ continue
430
+ key = (module, target.__qualname__)
431
+ if key in seen:
432
+ continue
433
+ try:
434
+ file = inspect.getsourcefile(target) or ""
435
+ lines, line = inspect.getsourcelines(target)
436
+ except (OSError, TypeError):
437
+ continue
438
+ if stdlib and file.startswith(stdlib):
439
+ continue
440
+ seen.add(key)
441
+ calls.append((node.lineno, node.col_offset, Called(target.__name__, module, file, line, "".join(lines))))
442
+ return [called for _line, _column, called in sorted(calls, key=lambda item: item[:2])]
443
+
444
+
445
+ def box(function: Callable | None = None, *, name: str | None = None, group: str = "Other",
446
+ needs: str | tuple[str, ...] | None = None, heavy: bool = False):
447
+ """Turn a function into a box. Use it bare (``@box``) or with options
448
+ (``@box(name="α-algorithm", group="Discover", needs="pandas")``).
449
+
450
+ ``needs`` names modules the box imports; without them the box is listed
451
+ greyed out with "needs pandas" instead of failing at run time. ``heavy``
452
+ marks a box that may take minutes, so the app runs it where it can be
453
+ stopped.
454
+ """
455
+ import sys
456
+ # Names local to the defining scope (a result dataclass defined in a function).
457
+ frame = sys._getframe(1)
458
+ localns = dict(frame.f_locals) if frame.f_code.co_name != "<module>" else None
459
+
460
+ def decorate(fn: Callable) -> Box:
461
+ spec = make_spec(fn, name=name, group=group, needs=needs, heavy=heavy, localns=localns)
462
+ return Box(fn, spec)
463
+ return decorate(function) if function is not None else decorate
464
+
465
+
466
+ __all__ = ["Box", "BoxError", "BoxSpec", "Port", "Setting", "box", "make_spec"]
@@ -0,0 +1,7 @@
1
+ """OpenProcess's own boxes: thin wrappers around the engine, one module per group.
2
+
3
+ Every box here is written the way a box of your own would be (see
4
+ ``docs/workflows.md``): a function with type hints and a docstring, a
5
+ ``flow.steps`` or ``flow.show`` call where a paper would put a figure, and
6
+ nothing else. Read them as examples.
7
+ """