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,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
|
+
]
|
openprocess/flow/box.py
ADDED
|
@@ -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
|
+
"""
|