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