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,198 @@
|
|
|
1
|
+
"""Public event logs by name, with their fingerprints.
|
|
2
|
+
|
|
3
|
+
"BPI Challenge 2012" in two papers should mean the same bytes. This module
|
|
4
|
+
knows the standard logs by name, with where they are published (a DOI on
|
|
5
|
+
4TU.ResearchData), their file name and, once fetched, their SHA-256. The
|
|
6
|
+
*Open dataset* box fetches a log once into a shared cache, checks the hash,
|
|
7
|
+
and records the dataset's name and hash in the workflow file.
|
|
8
|
+
|
|
9
|
+
Downloading
|
|
10
|
+
-----------
|
|
11
|
+
4TU serves the files behind a DOI landing page, so this module does not
|
|
12
|
+
guess download addresses: :func:`fetch` looks in the cache first, then at
|
|
13
|
+
``url`` when the entry (or the user's own ``datasets.json``) has one, and
|
|
14
|
+
otherwise says where to download the file by hand and where to put it. The
|
|
15
|
+
hash is recorded on first use and checked on every later one, so a file
|
|
16
|
+
swapped afterwards is noticed.
|
|
17
|
+
|
|
18
|
+
Add your own entries in ``~/.openprocess/datasets/datasets.json``::
|
|
19
|
+
|
|
20
|
+
{"my log": {"file": "my_log.xes.gz", "url": "https://…", "sha256": null,
|
|
21
|
+
"description": "...", "citation": "..."}}
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import shutil
|
|
30
|
+
import urllib.request
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Callable
|
|
34
|
+
|
|
35
|
+
from ..mining.log import EventLog
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class DatasetInfo:
|
|
40
|
+
name: str
|
|
41
|
+
file: str
|
|
42
|
+
doi: str = ""
|
|
43
|
+
url: str = ""
|
|
44
|
+
sha256: str | None = None
|
|
45
|
+
description: str = ""
|
|
46
|
+
citation: str = ""
|
|
47
|
+
size: str = ""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def page(self) -> str:
|
|
51
|
+
return f"https://doi.org/{self.doi}" if self.doi else self.url
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
#: The standard logs, by the name people use for them.
|
|
55
|
+
REGISTRY: dict[str, DatasetInfo] = {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _add(name, file, doi, description, citation="", size=""):
|
|
59
|
+
REGISTRY[name] = DatasetInfo(name, file, doi, "", None, description, citation, size)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_add("BPI Challenge 2012", "BPI_Challenge_2012.xes.gz", "10.4121/uuid:3926db30-f712-4394-aebc-75976070e91f",
|
|
63
|
+
"Loan applications at a Dutch financial institute: 13,087 cases, 262,200 events.",
|
|
64
|
+
"van Dongen, B.F. (2012). BPI Challenge 2012. 4TU.ResearchData.", "29 MB")
|
|
65
|
+
_add("BPI Challenge 2013 incidents", "BPI_Challenge_2013_incidents.xes.gz",
|
|
66
|
+
"10.4121/uuid:500573e6-accc-4b0c-9576-aa5468b10cee",
|
|
67
|
+
"Volvo IT incident management: 7,554 cases, 65,533 events.",
|
|
68
|
+
"Steeman, W. (2013). BPI Challenge 2013, incidents. 4TU.ResearchData.", "3 MB")
|
|
69
|
+
_add("BPI Challenge 2017", "BPI Challenge 2017.xes.gz", "10.4121/uuid:5f3067df-f10b-45da-b98b-86ae4c7a310b",
|
|
70
|
+
"Loan applications, the 2012 process five years on: 31,509 cases, 1,202,267 events.",
|
|
71
|
+
"van Dongen, B.F. (2017). BPI Challenge 2017. 4TU.ResearchData.", "70 MB")
|
|
72
|
+
_add("BPI Challenge 2019", "BPI_Challenge_2019.xes", "10.4121/uuid:d06aff4b-79f0-45e6-8ec8-e19730c248f1",
|
|
73
|
+
"Purchase order handling at a paints and coatings company: 251,734 cases, 1,595,923 events.",
|
|
74
|
+
"van Dongen, B.F. (2019). BPI Challenge 2019. 4TU.ResearchData.", "1.5 GB")
|
|
75
|
+
_add("BPI Challenge 2020 domestic declarations", "DomesticDeclarations.xes.gz",
|
|
76
|
+
"10.4121/uuid:3f422315-ed9d-4882-891f-e180a5b4d6c9",
|
|
77
|
+
"Travel expense declarations at TU/e: 10,500 cases, 56,437 events.",
|
|
78
|
+
"van Dongen, B.F. (2020). BPI Challenge 2020: Domestic Declarations. 4TU.ResearchData.", "2 MB")
|
|
79
|
+
_add("Sepsis cases", "Sepsis Cases - Event Log.xes.gz", "10.4121/uuid:915d2bfb-7e84-49ad-a286-dc35f063a460",
|
|
80
|
+
"Sepsis patients in a Dutch hospital: 1,050 cases, 15,214 events.",
|
|
81
|
+
"Mannhardt, F. (2016). Sepsis Cases - Event Log. 4TU.ResearchData.", "1 MB")
|
|
82
|
+
_add("Road traffic fine management", "Road_Traffic_Fine_Management_Process.xes.gz",
|
|
83
|
+
"10.4121/uuid:270fd440-1057-4fb9-89a9-b699b47990f5",
|
|
84
|
+
"Fines by an Italian police force: 150,370 cases, 561,470 events.",
|
|
85
|
+
"de Leoni, M., Mannhardt, F. (2015). Road Traffic Fine Management Process. 4TU.ResearchData.", "8 MB")
|
|
86
|
+
_add("Hospital billing", "Hospital Billing - Event Log.xes.gz", "10.4121/uuid:76c46b83-c930-4798-a1c9-4be94dfeb741",
|
|
87
|
+
"Billing of medical services in a hospital: 100,000 cases, 451,359 events.",
|
|
88
|
+
"Mannhardt, F. (2017). Hospital Billing - Event Log. 4TU.ResearchData.", "17 MB")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cache_dir() -> Path:
|
|
92
|
+
folder = os.environ.get("OPENPROCESS_DATASETS") or os.environ.get("CPNPY_DATASETS")
|
|
93
|
+
if not folder:
|
|
94
|
+
folder = Path.home() / ".openprocess" / "datasets"
|
|
95
|
+
old = Path.home() / ".cpnpy" / "datasets" # CPNpy, before 0.7
|
|
96
|
+
if old.is_dir() and not folder.exists():
|
|
97
|
+
folder = old
|
|
98
|
+
folder = Path(folder)
|
|
99
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
return folder
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _user_entries() -> dict[str, DatasetInfo]:
|
|
104
|
+
path = cache_dir() / "datasets.json"
|
|
105
|
+
if not path.is_file():
|
|
106
|
+
return {}
|
|
107
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
108
|
+
return {name: DatasetInfo(name, item["file"], item.get("doi", ""), item.get("url", ""),
|
|
109
|
+
item.get("sha256"), item.get("description", ""), item.get("citation", ""),
|
|
110
|
+
item.get("size", "")) for name, item in data.items()}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _hashes_path() -> Path:
|
|
114
|
+
return cache_dir() / "hashes.json"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def recorded_hashes() -> dict[str, str]:
|
|
118
|
+
path = _hashes_path()
|
|
119
|
+
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def names() -> list[str]:
|
|
123
|
+
return sorted(set(REGISTRY) | set(_user_entries()))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def info(name: str) -> DatasetInfo:
|
|
127
|
+
entries = {**REGISTRY, **_user_entries()}
|
|
128
|
+
key = next((k for k in entries if k.lower() == name.lower()), None)
|
|
129
|
+
if key is None:
|
|
130
|
+
raise KeyError(f"No dataset called {name!r}. Known: " + ", ".join(names()))
|
|
131
|
+
entry = entries[key]
|
|
132
|
+
if entry.sha256 is None:
|
|
133
|
+
entry.sha256 = recorded_hashes().get(entry.name)
|
|
134
|
+
return entry
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def sha256_file(path: Path) -> str:
|
|
138
|
+
digest = hashlib.sha256()
|
|
139
|
+
with open(path, "rb") as handle:
|
|
140
|
+
for chunk in iter(lambda: handle.read(1 << 20), b""):
|
|
141
|
+
digest.update(chunk)
|
|
142
|
+
return digest.hexdigest()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class DatasetMissing(FileNotFoundError):
|
|
146
|
+
"""The file is not in the cache and cannot be downloaded by itself."""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def fetch(name: str, downloader: Callable[[str, Path], None] | None = None) -> Path:
|
|
150
|
+
"""The dataset's file in the cache, downloading it if a URL is known.
|
|
151
|
+
|
|
152
|
+
Raises :class:`DatasetMissing` with instructions when it is not there
|
|
153
|
+
and there is no URL, and ``ValueError`` when the file's hash differs
|
|
154
|
+
from the recorded one.
|
|
155
|
+
"""
|
|
156
|
+
entry = info(name)
|
|
157
|
+
path = cache_dir() / entry.file
|
|
158
|
+
if not path.is_file():
|
|
159
|
+
if not entry.url:
|
|
160
|
+
raise DatasetMissing(
|
|
161
|
+
f"{entry.name} is not in the cache. Download “{entry.file}” from {entry.page} "
|
|
162
|
+
f"and put it in {cache_dir()}, or add a direct url for it in "
|
|
163
|
+
f"{cache_dir() / 'datasets.json'}.")
|
|
164
|
+
downloader = downloader or _download
|
|
165
|
+
temporary = path.with_name(path.name + ".part")
|
|
166
|
+
downloader(entry.url, temporary)
|
|
167
|
+
temporary.replace(path)
|
|
168
|
+
digest = sha256_file(path)
|
|
169
|
+
if entry.sha256 and digest != entry.sha256:
|
|
170
|
+
raise ValueError(f"{entry.file} does not match the recorded fingerprint "
|
|
171
|
+
f"({entry.sha256[:12]}… expected, {digest[:12]}… found). "
|
|
172
|
+
f"Delete it from {cache_dir()} to fetch it again.")
|
|
173
|
+
if not entry.sha256:
|
|
174
|
+
hashes = recorded_hashes()
|
|
175
|
+
hashes[entry.name] = digest
|
|
176
|
+
_hashes_path().write_text(json.dumps(hashes, indent=2), encoding="utf-8")
|
|
177
|
+
return path
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _download(url: str, target: Path) -> None:
|
|
181
|
+
with urllib.request.urlopen(url) as response, open(target, "wb") as handle: # noqa: S310
|
|
182
|
+
shutil.copyfileobj(response, handle)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def open_dataset(name: str) -> EventLog:
|
|
186
|
+
"""Fetch (if needed) and read the dataset as an :class:`EventLog`."""
|
|
187
|
+
from ..mining.xes import read_xes
|
|
188
|
+
entry = info(name)
|
|
189
|
+
path = fetch(name)
|
|
190
|
+
log = read_xes(path)
|
|
191
|
+
log.attributes.setdefault("concept:name", entry.name)
|
|
192
|
+
log.attributes["openprocess:dataset"] = entry.name
|
|
193
|
+
log.attributes["openprocess:sha256"] = entry.sha256 or recorded_hashes().get(entry.name, "")
|
|
194
|
+
return log
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
__all__ = ["REGISTRY", "DatasetInfo", "DatasetMissing", "cache_dir", "fetch", "info", "names",
|
|
198
|
+
"open_dataset", "recorded_hashes"]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""How a box got to its result: notes, intermediate values and steps.
|
|
2
|
+
|
|
3
|
+
A box shows what it did, not just what it produced. Two mechanisms, both
|
|
4
|
+
optional:
|
|
5
|
+
|
|
6
|
+
* **A result with steps.** If a box's result has a ``steps()`` method that
|
|
7
|
+
returns ``(title, content)`` rows, they are shown; ``AlphaResult`` already
|
|
8
|
+
has one (the eight steps). A ``warnings`` attribute becomes notes.
|
|
9
|
+
* **A trace of calls.** While it runs, a box may call :func:`note`,
|
|
10
|
+
:func:`show` and :func:`steps`::
|
|
11
|
+
|
|
12
|
+
flow.note("Split on XOR: {a, e} | {b, c}")
|
|
13
|
+
flow.show(dfg, "Filtered DFG")
|
|
14
|
+
|
|
15
|
+
Everything goes into the :class:`Explanation` of the box that is running,
|
|
16
|
+
in order. Outside a running box the calls do nothing, so a box works as a
|
|
17
|
+
plain function in a script.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import contextvars
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
_current: contextvars.ContextVar["Explanation | None"] = contextvars.ContextVar(
|
|
27
|
+
"openprocess.flow.explanation", default=None)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Explanation:
|
|
32
|
+
"""What a box reported while it ran, in order."""
|
|
33
|
+
|
|
34
|
+
#: ``(title, content)`` rows, as the α-algorithm's eight steps.
|
|
35
|
+
steps: list[tuple[str, str]] = field(default_factory=list)
|
|
36
|
+
notes: list[str] = field(default_factory=list)
|
|
37
|
+
#: Intermediate values with a label, each shown in its own viewer.
|
|
38
|
+
shows: list[tuple[str, Any]] = field(default_factory=list)
|
|
39
|
+
#: Every entry in the order it was made: ("note", text), ("show", label, value), ("steps", rows).
|
|
40
|
+
entries: list[tuple] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def empty(self) -> bool:
|
|
44
|
+
return not (self.steps or self.notes or self.shows)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def current() -> Explanation | None:
|
|
48
|
+
"""The explanation of the box running now, or None outside a box."""
|
|
49
|
+
return _current.get()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def note(text: str) -> None:
|
|
53
|
+
"""Record one line of what is happening."""
|
|
54
|
+
explanation = _current.get()
|
|
55
|
+
if explanation is not None:
|
|
56
|
+
explanation.notes.append(str(text))
|
|
57
|
+
explanation.entries.append(("note", str(text)))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def show(value: Any, label: str = "") -> Any:
|
|
61
|
+
"""Record an intermediate value (a DFG, a tree, a table) to be shown
|
|
62
|
+
under *How*; returns the value, so it can wrap an expression."""
|
|
63
|
+
explanation = _current.get()
|
|
64
|
+
if explanation is not None:
|
|
65
|
+
explanation.shows.append((label, value))
|
|
66
|
+
explanation.entries.append(("show", label, value))
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def steps(rows) -> None:
|
|
71
|
+
"""Record ``(title, content)`` rows: the derivation as the book writes it."""
|
|
72
|
+
explanation = _current.get()
|
|
73
|
+
if explanation is not None:
|
|
74
|
+
rows = [(str(title), str(content)) for title, content in rows]
|
|
75
|
+
explanation.steps.extend(rows)
|
|
76
|
+
explanation.entries.append(("steps", rows))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class explaining:
|
|
80
|
+
"""``with explaining() as explanation:`` runs a box and collects what it
|
|
81
|
+
reports."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, explanation: Explanation | None = None) -> None:
|
|
84
|
+
self.explanation = explanation or Explanation()
|
|
85
|
+
self._token = None
|
|
86
|
+
|
|
87
|
+
def __enter__(self) -> Explanation:
|
|
88
|
+
self._token = _current.set(self.explanation)
|
|
89
|
+
return self.explanation
|
|
90
|
+
|
|
91
|
+
def __exit__(self, *_exc) -> None:
|
|
92
|
+
_current.reset(self._token)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def collect_result(value: Any, explanation: Explanation) -> None:
|
|
96
|
+
"""Add what a result object can say about itself: its ``steps()`` and
|
|
97
|
+
its ``warnings``. Only when the box did not record steps itself."""
|
|
98
|
+
if not explanation.steps:
|
|
99
|
+
method = getattr(value, "steps", None)
|
|
100
|
+
if callable(method):
|
|
101
|
+
try:
|
|
102
|
+
rows = method()
|
|
103
|
+
if isinstance(rows, list):
|
|
104
|
+
steps_rows = [(str(t), str(c)) for t, c in rows]
|
|
105
|
+
explanation.steps.extend(steps_rows)
|
|
106
|
+
explanation.entries.append(("steps", steps_rows))
|
|
107
|
+
except Exception: # noqa: BLE001 - a result's own method; never fatal
|
|
108
|
+
pass
|
|
109
|
+
warnings = getattr(value, "warnings", None)
|
|
110
|
+
if isinstance(warnings, list):
|
|
111
|
+
for warning in warnings:
|
|
112
|
+
text = f"Warning: {warning}"
|
|
113
|
+
if text not in explanation.notes:
|
|
114
|
+
explanation.notes.append(text)
|
|
115
|
+
explanation.entries.append(("note", text))
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Where boxes come from: OpenProcess's own, a folder's ``boxes/``, and packages.
|
|
2
|
+
|
|
3
|
+
A :class:`Library` holds :class:`~.box.BoxSpec` objects by id. The
|
|
4
|
+
standard library is OpenProcess's own boxes (``openprocess.flow.boxes``). On top of
|
|
5
|
+
it, :meth:`Library.load_folder` reads every ``.py`` file in a folder (the
|
|
6
|
+
open folder's ``boxes/`` subfolder in the app) and
|
|
7
|
+
:meth:`Library.load_entry_points` every installed package that declares a
|
|
8
|
+
``openprocess.boxes`` entry point, so a research group can publish a box pack on
|
|
9
|
+
PyPI.
|
|
10
|
+
|
|
11
|
+
A file with a mistake does not stop the others: it is kept as a
|
|
12
|
+
:class:`BrokenBox` with the reason in plain words, and the app lists it
|
|
13
|
+
greyed out. A box whose library is missing (``needs="pandas"``) is listed
|
|
14
|
+
too, greyed out, with "needs pandas".
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import importlib
|
|
21
|
+
import importlib.metadata
|
|
22
|
+
import importlib.util
|
|
23
|
+
import sys
|
|
24
|
+
import traceback
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Iterable
|
|
28
|
+
|
|
29
|
+
from .box import Box, BoxError, BoxSpec
|
|
30
|
+
|
|
31
|
+
#: The modules of the standard library, in the order the box list shows their groups.
|
|
32
|
+
STANDARD_MODULES = ("input", "filter", "discover", "check", "compare", "output",
|
|
33
|
+
"science", "predict", "cpn", "sweeps")
|
|
34
|
+
GROUP_ORDER = ("Input", "Filter", "Discover", "Check", "Compare", "Output", "Science",
|
|
35
|
+
"Predict", "Coloured nets", "Sweep", "Yours")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class BrokenBox:
|
|
40
|
+
"""A box file that could not be loaded, and why."""
|
|
41
|
+
|
|
42
|
+
file: str
|
|
43
|
+
reason: str
|
|
44
|
+
name: str = ""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def id(self) -> str:
|
|
48
|
+
return f"broken:{self.file}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Library:
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
self.specs: dict[str, BoxSpec] = {}
|
|
54
|
+
self.broken: list[BrokenBox] = []
|
|
55
|
+
#: Where each custom box file came from, with its content hash (for the record).
|
|
56
|
+
self.files: dict[str, str] = {}
|
|
57
|
+
|
|
58
|
+
# -- lookup -------------------------------------------------------------------
|
|
59
|
+
def get(self, box_id: str) -> BoxSpec:
|
|
60
|
+
try:
|
|
61
|
+
return self.specs[box_id]
|
|
62
|
+
except KeyError:
|
|
63
|
+
raise KeyError(f"No box {box_id!r}") from None
|
|
64
|
+
|
|
65
|
+
def resolve(self, box) -> BoxSpec:
|
|
66
|
+
"""A :class:`Box`, a :class:`BoxSpec`, an id or a bare function name."""
|
|
67
|
+
if isinstance(box, Box):
|
|
68
|
+
if box.spec.id not in self.specs:
|
|
69
|
+
self.register(box)
|
|
70
|
+
return box.spec
|
|
71
|
+
if isinstance(box, BoxSpec):
|
|
72
|
+
if box.id not in self.specs:
|
|
73
|
+
self.specs[box.id] = box
|
|
74
|
+
return box
|
|
75
|
+
if box in self.specs:
|
|
76
|
+
return self.specs[box]
|
|
77
|
+
matches = [s for s in self.specs.values() if s.function.__name__ == box or s.name == box]
|
|
78
|
+
if len(matches) == 1:
|
|
79
|
+
return matches[0]
|
|
80
|
+
if not matches:
|
|
81
|
+
raise KeyError(f"No box {box!r}")
|
|
82
|
+
raise KeyError(f"Several boxes are called {box!r}: " + ", ".join(m.id for m in matches))
|
|
83
|
+
|
|
84
|
+
def __contains__(self, box_id: str) -> bool:
|
|
85
|
+
return box_id in self.specs
|
|
86
|
+
|
|
87
|
+
def __iter__(self):
|
|
88
|
+
return iter(self.specs.values())
|
|
89
|
+
|
|
90
|
+
def __len__(self) -> int:
|
|
91
|
+
return len(self.specs)
|
|
92
|
+
|
|
93
|
+
def by_group(self) -> dict[str, list[BoxSpec]]:
|
|
94
|
+
groups: dict[str, list[BoxSpec]] = {}
|
|
95
|
+
for spec in self.specs.values():
|
|
96
|
+
groups.setdefault(spec.group, []).append(spec)
|
|
97
|
+
ordered = {g: groups[g] for g in GROUP_ORDER if g in groups}
|
|
98
|
+
ordered.update({g: specs for g, specs in groups.items() if g not in ordered})
|
|
99
|
+
return ordered
|
|
100
|
+
|
|
101
|
+
# -- registering -----------------------------------------------------------------
|
|
102
|
+
def register(self, *boxes: Box | BoxSpec, custom: bool | None = None) -> None:
|
|
103
|
+
for item in boxes:
|
|
104
|
+
spec = item.spec if isinstance(item, Box) else item
|
|
105
|
+
if custom is not None:
|
|
106
|
+
spec.custom = custom
|
|
107
|
+
self.specs[spec.id] = spec
|
|
108
|
+
|
|
109
|
+
def register_module(self, module, custom: bool = False) -> list[BoxSpec]:
|
|
110
|
+
"""Register the boxes *defined* in ``module`` (a box it merely imports,
|
|
111
|
+
such as a standard box used by a saved group, stays as it is)."""
|
|
112
|
+
from .workflow import Recorded
|
|
113
|
+
own = module.__name__
|
|
114
|
+
|
|
115
|
+
def defined_here(value) -> bool:
|
|
116
|
+
fn = value.fn if isinstance(value, Recorded) else value.spec.function
|
|
117
|
+
return getattr(fn, "__module__", own) == own
|
|
118
|
+
|
|
119
|
+
found = [value for value in vars(module).values() if isinstance(value, Box) and defined_here(value)]
|
|
120
|
+
for value in vars(module).values():
|
|
121
|
+
if isinstance(value, Recorded) and defined_here(value): # a @workflow function with typed parameters
|
|
122
|
+
box = value.as_box()
|
|
123
|
+
if box is not None:
|
|
124
|
+
box.spec.id = f"{module.__name__}.{value.__name__}"
|
|
125
|
+
found.append(box)
|
|
126
|
+
found.sort(key=lambda b: b.spec.line)
|
|
127
|
+
self.register(*found, custom=custom)
|
|
128
|
+
return [b.spec for b in found]
|
|
129
|
+
|
|
130
|
+
def load_standard(self) -> "Library":
|
|
131
|
+
for name in STANDARD_MODULES:
|
|
132
|
+
module = importlib.import_module(f"openprocess.flow.boxes.{name}")
|
|
133
|
+
self.register_module(module)
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
def load_file(self, path: str | Path) -> list[BoxSpec]:
|
|
137
|
+
"""Load one box file. Raises nothing: a mistake becomes a BrokenBox."""
|
|
138
|
+
path = Path(path)
|
|
139
|
+
text = path.read_bytes()
|
|
140
|
+
self.files[str(path)] = hashlib.sha256(text).hexdigest()
|
|
141
|
+
module_name = "openprocess_boxes_" + hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:12]
|
|
142
|
+
self.broken = [b for b in self.broken if b.file != str(path)]
|
|
143
|
+
for spec_id in [s for s, spec in self.specs.items() if spec.file == str(path)]:
|
|
144
|
+
del self.specs[spec_id]
|
|
145
|
+
try:
|
|
146
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
147
|
+
module = importlib.util.module_from_spec(spec)
|
|
148
|
+
sys.modules[module_name] = module
|
|
149
|
+
spec.loader.exec_module(module)
|
|
150
|
+
except BoxError as error:
|
|
151
|
+
self.broken.append(BrokenBox(str(path), str(error), path.stem))
|
|
152
|
+
return []
|
|
153
|
+
except SyntaxError as error:
|
|
154
|
+
self.broken.append(BrokenBox(str(path), f"line {error.lineno}: {error.msg}", path.stem))
|
|
155
|
+
return []
|
|
156
|
+
except Exception as error: # noqa: BLE001 - whatever the file did
|
|
157
|
+
detail = "".join(traceback.format_exception_only(type(error), error)).strip()
|
|
158
|
+
self.broken.append(BrokenBox(str(path), detail, path.stem))
|
|
159
|
+
return []
|
|
160
|
+
specs = self.register_module(module, custom=True)
|
|
161
|
+
for s in specs:
|
|
162
|
+
s.file = str(path)
|
|
163
|
+
s.group = s.group or "Yours"
|
|
164
|
+
if not specs:
|
|
165
|
+
self.broken.append(BrokenBox(str(path), "the file has no @box function", path.stem))
|
|
166
|
+
return specs
|
|
167
|
+
|
|
168
|
+
def load_folder(self, folder: str | Path) -> list[BoxSpec]:
|
|
169
|
+
"""Every ``.py`` file directly in ``folder`` (``boxes/`` in the app)."""
|
|
170
|
+
folder = Path(folder)
|
|
171
|
+
if not folder.is_dir():
|
|
172
|
+
return []
|
|
173
|
+
loaded = []
|
|
174
|
+
for path in sorted(folder.glob("*.py")):
|
|
175
|
+
if path.name.startswith("_"):
|
|
176
|
+
continue
|
|
177
|
+
loaded.extend(self.load_file(path))
|
|
178
|
+
return loaded
|
|
179
|
+
|
|
180
|
+
def load_entry_points(self, group: str = "openprocess.boxes") -> list[BoxSpec]:
|
|
181
|
+
"""Boxes from installed packages that declare ``[project.entry-points."openprocess.boxes"]``."""
|
|
182
|
+
loaded = []
|
|
183
|
+
try:
|
|
184
|
+
points = importlib.metadata.entry_points(group=group)
|
|
185
|
+
except TypeError: # Python 3.9 style
|
|
186
|
+
points = importlib.metadata.entry_points().get(group, [])
|
|
187
|
+
for point in points:
|
|
188
|
+
try:
|
|
189
|
+
module = point.load()
|
|
190
|
+
except Exception as error: # noqa: BLE001
|
|
191
|
+
self.broken.append(BrokenBox(point.value, f"package {point.name}: {error}", point.name))
|
|
192
|
+
continue
|
|
193
|
+
specs = self.register_module(module, custom=True)
|
|
194
|
+
for s in specs:
|
|
195
|
+
s.group = s.group or point.name
|
|
196
|
+
loaded.extend(specs)
|
|
197
|
+
return loaded
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_standard: Library | None = None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def standard_library() -> Library:
|
|
204
|
+
"""OpenProcess's own boxes (loaded once)."""
|
|
205
|
+
global _standard
|
|
206
|
+
if _standard is None:
|
|
207
|
+
_standard = Library().load_standard()
|
|
208
|
+
return _standard
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def library_for(folder: str | Path | None = None, packages: bool = True) -> Library:
|
|
212
|
+
"""The standard boxes plus a folder's ``boxes/`` and installed packages."""
|
|
213
|
+
library = Library().load_standard()
|
|
214
|
+
if packages:
|
|
215
|
+
library.load_entry_points()
|
|
216
|
+
if folder is not None:
|
|
217
|
+
boxes = Path(folder) / "boxes" if (Path(folder) / "boxes").is_dir() else Path(folder)
|
|
218
|
+
library.load_folder(boxes)
|
|
219
|
+
return library
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
__all__ = ["BrokenBox", "GROUP_ORDER", "Library", "library_for", "standard_library"]
|