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,665 @@
1
+ """What the side panel shows for a box: its result, how it got there, its
2
+ code, and its settings.
3
+
4
+ :func:`result_widget` picks a viewer by the result's type: the net drawing
5
+ (with *Edit a copy*), a log's figures and variants (with *Open as log*),
6
+ a transition system, a process tree, a process map, a footprint, a replay
7
+ or alignment table, scores as meters, a table, a figure, a report.
8
+ :func:`how_widget` lays out a box's :class:`~openprocess.flow.explain.Explanation`
9
+ in order: notes as lines, derivation steps as a two-column table, shown
10
+ values each in their own viewer. :func:`code_widget` shows the source with
11
+ a small Python highlighter. :class:`SettingsWidget` makes one control per
12
+ setting from the box's spec and reports changes.
13
+
14
+ Every tab can be opened in its own window (:func:`pop_out`), for a figure
15
+ for a paper, a replay table with a hundred rows, or an algorithm of five
16
+ hundred lines.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from html import escape
22
+ from pathlib import Path
23
+
24
+ from PySide6.QtCore import QRegularExpression, Qt, QTimer, Signal
25
+ from PySide6.QtGui import QColor, QDesktopServices, QFont, QSyntaxHighlighter, QTextCharFormat
26
+ from PySide6.QtWidgets import (
27
+ QAbstractItemView, QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFileDialog, QHBoxLayout, QLabel,
28
+ QLayout, QLineEdit, QPlainTextEdit, QProgressBar, QSizePolicy, QSpinBox, QTableWidget, QTableWidgetItem,
29
+ QVBoxLayout, QWidget,
30
+ )
31
+
32
+ from ...flow.box import BoxSpec, Called, Setting, algorithm_calls
33
+ from ...flow.explain import Explanation
34
+ from ...flow.runner import Result
35
+ from ...flow.sweep import Sweep, is_sweep
36
+ from ...flow.types import (AlignmentResult, CPNet, DFG, Dataset, EventLog, Figure, Footprint, PetriNet,
37
+ Predictions, Predictor, ProcessTree, Regions, ReplayResult, Scores, SimpleLog,
38
+ Table, Text, TransitionSystem)
39
+ from .. import theme
40
+ from ..studio import style
41
+ from ..studio.graph_builders import dfg_specs, petri_net_specs
42
+ from ..studio.graph_view import GraphView
43
+ from ..studio.widgets import Card, StatTile, button, footprint_table, hbox, label, scroll, vbox
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Small helpers
48
+ # ---------------------------------------------------------------------------
49
+ def _table(columns: list[str], rows: list[list], max_rows: int = 500) -> QTableWidget:
50
+ table = QTableWidget(min(len(rows), max_rows), len(columns))
51
+ table.setHorizontalHeaderLabels([str(c) for c in columns])
52
+ table.verticalHeader().setVisible(False)
53
+ table.setEditTriggers(QAbstractItemView.NoEditTriggers)
54
+ table.setSelectionBehavior(QAbstractItemView.SelectRows)
55
+ table.setAlternatingRowColors(True)
56
+ table.setShowGrid(False)
57
+ for r, row in enumerate(rows[:max_rows]):
58
+ for c, value in enumerate(row):
59
+ text = f"{value:.4f}" if isinstance(value, float) else "" if value is None else str(value)
60
+ item = QTableWidgetItem(text)
61
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
62
+ item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
63
+ table.setItem(r, c, item)
64
+ table.resizeColumnsToContents()
65
+ table.horizontalHeader().setStretchLastSection(True)
66
+ height = table.horizontalHeader().height() + sum(table.rowHeight(i) for i in range(table.rowCount())) + 6
67
+ table.setMinimumHeight(min(max(height, 60), 420))
68
+ table.setMaximumHeight(min(max(height, 60), 420))
69
+ return table
70
+
71
+
72
+ def _graph(nodes, edges, positions=None, layer_gap: float = 48.0, height: int = 260) -> GraphView:
73
+ view = GraphView()
74
+ view.setMinimumHeight(height)
75
+ view.graph.populate(nodes, edges, positions, layer_gap=layer_gap)
76
+ QTimer.singleShot(0, view.fit)
77
+ return view
78
+
79
+
80
+ def _meter(name: str, value) -> QWidget:
81
+ row = QWidget()
82
+ layout = QHBoxLayout(row)
83
+ layout.setContentsMargins(0, 0, 0, 0)
84
+ layout.setSpacing(8)
85
+ caption = label(name[:1].upper() + name[1:])
86
+ caption.setMinimumWidth(110)
87
+ layout.addWidget(caption)
88
+ if isinstance(value, (int, float)) and not isinstance(value, bool) and 0 <= float(value) <= 1:
89
+ bar = QProgressBar()
90
+ bar.setRange(0, 1000)
91
+ bar.setValue(int(float(value) * 1000))
92
+ bar.setTextVisible(False)
93
+ bar.setFixedHeight(8)
94
+ layout.addWidget(bar, 1)
95
+ number = label(f"{float(value):.3f}")
96
+ number.setStyleSheet("font-weight: 600;")
97
+ number.setMinimumWidth(44)
98
+ number.setAlignment(Qt.AlignRight)
99
+ layout.addWidget(number)
100
+ else:
101
+ text = label(str(value))
102
+ good = str(value).lower().startswith(("sound", "yes", "true"))
103
+ bad = str(value).lower().startswith(("not", "no", "false"))
104
+ if good or bad:
105
+ text.setStyleSheet(f"font-weight: 600; color: {style.STATUS['good' if good else 'critical']};")
106
+ layout.addWidget(text, 1)
107
+ return row
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Result viewers
112
+ # ---------------------------------------------------------------------------
113
+ def result_widget(value, page=None) -> QWidget:
114
+ """A viewer for ``value``. ``page`` (the workflow page) receives the
115
+ *Open as …* requests; None gives a read-only viewer."""
116
+ host = QWidget()
117
+ layout = QVBoxLayout(host)
118
+ layout.setContentsMargins(0, 0, 0, 0)
119
+ layout.setSpacing(8)
120
+
121
+ def add(widget) -> None:
122
+ if isinstance(widget, QLayout):
123
+ layout.addLayout(widget)
124
+ else:
125
+ layout.addWidget(widget)
126
+
127
+ from ...mining.discovery.alpha import AlphaResult
128
+ from ...mining.discovery.heuristics import HeuristicsResult
129
+ from ...mining.discovery.inductive import InductiveResult
130
+ from ...mining.discovery.state_regions import RegionResult
131
+ if isinstance(value, (AlphaResult, InductiveResult, HeuristicsResult)):
132
+ from ..studio.derivation_view import derivation_view
133
+ add(derivation_view(value)) # the typeset derivation the Discover tab shows
134
+ elif isinstance(value, RegionResult):
135
+ from ..studio.regions_view import RegionsPanel
136
+ add(RegionsPanel(value, show_net=False, flat=True))
137
+ elif isinstance(value, PetriNet):
138
+ nodes, edges = petri_net_specs(value, show_place_names=len(value.places) <= 40)
139
+ positions = None
140
+ if all(p.position for p in value.places.values()) and all(t.position for t in value.transitions.values()):
141
+ positions = {p.id: p.position for p in value.places.values()}
142
+ positions |= {t.id: t.position for t in value.transitions.values()}
143
+ add(_graph(nodes, edges, positions))
144
+ add(label(value.summary() + (f" · {value.info['algorithm']}" if value.info.get("algorithm") else ""),
145
+ "muted", wrap=True))
146
+ if page is not None:
147
+ add(hbox(button("Open as model", lambda: page.open_as_model(value),
148
+ tooltip="Open it as a model page: token game, analysis, conformance"),
149
+ button("✎ Edit a copy", lambda: page.edit_copy(value),
150
+ tooltip="Open a copy on the net canvas, to play and change it"), None))
151
+ elif isinstance(value, EventLog):
152
+ from ...mining.stats import format_duration, summarise
153
+ summary = summarise(value)
154
+ tiles = hbox(StatTile("Cases", f"{summary.case_count:,}"), StatTile("Events", f"{summary.event_count:,}"),
155
+ StatTile("Variants", f"{summary.variant_count:,}"),
156
+ StatTile("Activities", f"{len(summary.activities):,}"), spacing=8)
157
+ add_layout = QWidget()
158
+ add_layout.setLayout(tiles)
159
+ add(add_layout)
160
+ rows = [["⟨" + ", ".join(v.sequence) + "⟩", v.count] for v in summary.variants[:200]]
161
+ add(_table(["variant", "cases"], rows))
162
+ if summary.start:
163
+ add(label(f"From {summary.start:%Y-%m-%d} to {summary.end:%Y-%m-%d}; median case duration "
164
+ f"{format_duration(summary.median_case_duration)}", "muted", wrap=True))
165
+ if page is not None:
166
+ add(hbox(button("Open as log", lambda: page.open_as_log(value),
167
+ tooltip="Open it as a log page: dotted chart, process map, filters"), None))
168
+ elif isinstance(value, SimpleLog):
169
+ from ...mining.log import format_simple_log
170
+ text = QPlainTextEdit(format_simple_log(value))
171
+ text.setReadOnly(True)
172
+ text.setMaximumHeight(120)
173
+ add(text)
174
+ elif isinstance(value, TransitionSystem):
175
+ from ..studio.regions_view import TransitionSystemView
176
+ view = TransitionSystemView(value)
177
+ add(view)
178
+ add(label(f"{len(value.states)} states, {len(value.transitions)} transitions"
179
+ + (f" ({value.abstraction})" if value.abstraction else ""), "muted", wrap=True))
180
+ elif isinstance(value, ProcessTree):
181
+ from ..studio.derivation_view import TreeView, tree_formula, tree_specs
182
+ nodes, edges, positions = tree_specs(value)
183
+ view = TreeView()
184
+ view.graph.populate(nodes, edges, positions, layer_gap=60)
185
+ QTimer.singleShot(0, view.fit_height)
186
+ add(view)
187
+ formula = label(tree_formula(value), wrap=True)
188
+ formula.setTextFormat(Qt.RichText)
189
+ add(formula)
190
+ elif isinstance(value, DFG):
191
+ nodes, edges = dfg_specs(value)
192
+ add(_graph(nodes, edges, layer_gap=90))
193
+ add(label(f"{len(value.activities)} activities, {len(value.edges)} arcs, {value.trace_count} cases",
194
+ "muted"))
195
+ elif isinstance(value, Footprint):
196
+ add(footprint_table(value, compact=len(value.activities) > 8))
197
+ elif isinstance(value, ReplayResult):
198
+ rows = [["⟨" + ", ".join(t.trace) + "⟩", t.count, t.produced, t.consumed, t.missing, t.remaining,
199
+ round(t.fitness, 4)] for t in value.traces]
200
+ add(_table(["variant", "cases", "p", "c", "m", "r", "fitness"], rows))
201
+ add(label(f"Fitness {value.fitness:.4f}: p = {value.produced}, c = {value.consumed}, "
202
+ f"m = {value.missing}, r = {value.remaining}; {value.fitting_traces} of "
203
+ f"{value.trace_count} cases fit.", "muted", wrap=True))
204
+ elif isinstance(value, AlignmentResult):
205
+ rows = [["⟨" + ", ".join(a.trace) + "⟩", a.count, a.cost, round(a.fitness, 4),
206
+ " ".join(f"({m.log}|{m.model})" for m in a.moves)] for a in value.alignments]
207
+ add(_table(["variant", "cases", "cost", "fitness", "alignment"], rows))
208
+ add(label(f"Average fitness {value.average_fitness:.4f}; {value.fitting_traces} of "
209
+ f"{value.trace_count} cases align without a deviation.", "muted", wrap=True))
210
+ elif isinstance(value, Regions):
211
+ rows = [[value.name_of(r), value.region_text(r), "minimal" if r in value.minimal else ""]
212
+ for r in value.regions[:200]]
213
+ add(_table(["region", "states", ""], rows))
214
+ add(label(f"{len(value.regions)} regions, {len(value.minimal)} minimal; "
215
+ f"elementary: {value.elementary}", "muted", wrap=True))
216
+ elif isinstance(value, Scores):
217
+ box = QWidget()
218
+ meters = QVBoxLayout(box)
219
+ meters.setContentsMargins(0, 0, 0, 0)
220
+ meters.setSpacing(6)
221
+ for name, metric in value.metrics.items():
222
+ meters.addWidget(_meter(name, metric))
223
+ add(label(value.model, "cardTitle"))
224
+ add(box)
225
+ if value.context:
226
+ add(label("Settings: " + ", ".join(f"{k} = {v}" for k, v in value.context.items()), "muted", wrap=True))
227
+ if value.note:
228
+ add(label(value.note, "muted", wrap=True))
229
+ elif isinstance(value, Table):
230
+ add(label(value.name, "cardTitle"))
231
+ add(_table(value.columns, value.rows))
232
+ if value.samples:
233
+ add(label(f"{len(value.samples)} samples behind these rows (for Compare samples and Plot).", "muted",
234
+ wrap=True))
235
+ if value.note:
236
+ add(label(value.note, "muted", wrap=True))
237
+ elif isinstance(value, Figure):
238
+ shown = False
239
+ if value.svg:
240
+ try:
241
+ from PySide6.QtSvgWidgets import QSvgWidget
242
+ svg = QSvgWidget()
243
+ svg.load(value.svg.encode("utf-8"))
244
+ svg.setMinimumHeight(260)
245
+ svg.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
246
+ size = svg.renderer().defaultSize()
247
+ if size.width() > 0:
248
+ svg.setMaximumHeight(int(min(600, max(260, size.height() * 1.4))))
249
+ add(svg)
250
+ shown = True
251
+ except ImportError:
252
+ pass
253
+ if not shown and value.png:
254
+ from PySide6.QtGui import QPixmap
255
+ pixmap = QPixmap()
256
+ pixmap.loadFromData(value.png)
257
+ picture = QLabel()
258
+ picture.setPixmap(pixmap.scaledToWidth(560, Qt.SmoothTransformation))
259
+ add(picture)
260
+ shown = True
261
+ if not shown:
262
+ add(label("The figure has no SVG or PNG form.", "muted"))
263
+ if value.caption:
264
+ add(label(value.caption, "muted", wrap=True))
265
+ if page is not None:
266
+ add(hbox(button("Save as SVG…", lambda: page.save_figure(value)), None))
267
+ elif isinstance(value, Text):
268
+ text = QPlainTextEdit(value.text)
269
+ text.setReadOnly(True)
270
+ text.setFont(theme.mono_font(11))
271
+ text.setMinimumHeight(200)
272
+ add(text)
273
+ elif isinstance(value, Dataset):
274
+ add(label(f"{len(value)} rows × {len(value.feature_names)} features · label: {value.label} "
275
+ f"({value.kind})", "muted", wrap=True))
276
+ rows = [[*(list(row) if not hasattr(row, "tolist") else row.tolist()), y]
277
+ for row, y in zip(value.X[:50], value.y[:50])]
278
+ add(_table([*value.feature_names, value.label], rows))
279
+ elif isinstance(value, Predictions):
280
+ rows = [[str(v), str(t), "✓" if v == t else ""] for v, t in zip(value.values[:100], value.dataset.y[:100])]
281
+ add(_table(["predicted", "actual", ""], rows))
282
+ add(label(f"{len(value.values)} predictions by {value.name}", "muted"))
283
+ elif isinstance(value, Predictor):
284
+ add(label(f"{getattr(value, 'name', type(value).__name__)}: a fitted model. Connect it to Predict.",
285
+ "muted", wrap=True))
286
+ elif isinstance(value, CPNet):
287
+ add(label(f"{value.name}: {sum(1 for _ in value.all_places())} places, "
288
+ f"{sum(1 for _ in value.all_transitions())} transitions, {len(value.pages)} page(s)",
289
+ "muted", wrap=True))
290
+ if page is not None:
291
+ add(hbox(button("Open as coloured net", lambda: page.open_as_cpn(value)), None))
292
+ elif value is None:
293
+ add(label("This box gives nothing back (it saves, or shows).", "muted", wrap=True))
294
+ else:
295
+ text = QPlainTextEdit(repr(value)[:5000])
296
+ text.setReadOnly(True)
297
+ text.setMaximumHeight(160)
298
+ add(label(type(value).__name__, "muted"))
299
+ add(text)
300
+ return host
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # How
305
+ # ---------------------------------------------------------------------------
306
+ def how_widget(explanation: Explanation, page=None) -> QWidget:
307
+ host = QWidget()
308
+ layout = QVBoxLayout(host)
309
+ layout.setContentsMargins(0, 0, 0, 0)
310
+ layout.setSpacing(10)
311
+ if explanation.empty:
312
+ layout.addWidget(label("This box has nothing to add: its result is all there is.", "muted", wrap=True))
313
+ return host
314
+ t = style.tokens()
315
+ for entry in explanation.entries:
316
+ if entry[0] == "note":
317
+ text = label("· " + entry[1], "muted" if not entry[1].startswith("Warning") else None, wrap=True,
318
+ selectable=True)
319
+ if entry[1].startswith("Warning"):
320
+ text.setStyleSheet(f"color: {style.STATUS['warning']};")
321
+ layout.addWidget(text)
322
+ elif entry[0] == "steps":
323
+ rows = []
324
+ for title, content in entry[1]:
325
+ rows.append(f"<tr><td valign='top' style='padding: 4px 10px 4px 0; color: {t.text_muted}; "
326
+ f"white-space: nowrap'><b>{escape(title)}</b></td>"
327
+ f"<td style='padding: 4px 0; font-family: {theme.mono_font(11).family()}; "
328
+ f"font-size: 11.5px'>{escape(content)}</td></tr>")
329
+ table = QLabel(f"<table cellspacing='0' width='100%'>{''.join(rows)}</table>")
330
+ table.setTextFormat(Qt.RichText)
331
+ table.setWordWrap(True)
332
+ table.setTextInteractionFlags(Qt.TextSelectableByMouse)
333
+ layout.addWidget(table)
334
+ elif entry[0] == "show":
335
+ caption, value = entry[1], entry[2]
336
+ if caption:
337
+ layout.addWidget(label(caption.upper(), "sectionLabel"))
338
+ layout.addWidget(result_widget(value, page))
339
+ return host
340
+
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # Code
344
+ # ---------------------------------------------------------------------------
345
+ class PythonHighlighter(QSyntaxHighlighter):
346
+ KEYWORDS = ("def", "return", "if", "elif", "else", "for", "in", "while", "import", "from", "as", "class",
347
+ "raise", "try", "except", "finally", "with", "lambda", "not", "and", "or", "is", "None", "True",
348
+ "False", "yield", "pass", "global", "del", "assert")
349
+
350
+ def __init__(self, document) -> None:
351
+ super().__init__(document)
352
+ t = style.tokens()
353
+ self.rules = []
354
+ keyword = QTextCharFormat()
355
+ keyword.setForeground(QColor(t.accent))
356
+ keyword.setFontWeight(QFont.DemiBold)
357
+ self.rules.append((QRegularExpression(r"\b(" + "|".join(self.KEYWORDS) + r")\b"), keyword))
358
+ decorator = QTextCharFormat()
359
+ decorator.setForeground(QColor(style.categorical(1)))
360
+ self.rules.append((QRegularExpression(r"^\s*@\w+(\([^)]*\))?"), decorator))
361
+ string = QTextCharFormat()
362
+ string.setForeground(QColor(style.STATUS["good"]))
363
+ self.rules.append((QRegularExpression(r"(f|r|b)?\"[^\"\n]*\"|(f|r|b)?'[^'\n]*'"), string))
364
+ comment = QTextCharFormat()
365
+ comment.setForeground(QColor(t.text_muted))
366
+ comment.setFontItalic(True)
367
+ self.rules.append((QRegularExpression(r"#[^\n]*"), comment))
368
+ self.docstring = QTextCharFormat()
369
+ self.docstring.setForeground(QColor(style.categorical(3)))
370
+ self.triple = QRegularExpression('"""')
371
+
372
+ def highlightBlock(self, text: str) -> None: # noqa: N802
373
+ for pattern, fmt in self.rules:
374
+ match = pattern.globalMatch(text)
375
+ while match.hasNext():
376
+ found = match.next()
377
+ self.setFormat(found.capturedStart(), found.capturedLength(), fmt)
378
+ # Docstrings span lines: state 1 = inside one.
379
+ self.setCurrentBlockState(0)
380
+ start = 0 if self.previousBlockState() == 1 else text.find('"""')
381
+ while start >= 0:
382
+ end = text.find('"""', start + (3 if self.previousBlockState() != 1 or start else 0))
383
+ if end < 0:
384
+ self.setCurrentBlockState(1)
385
+ self.setFormat(start, len(text) - start, self.docstring)
386
+ break
387
+ self.setFormat(start, end + 3 - start, self.docstring)
388
+ start = text.find('"""', end + 3)
389
+
390
+
391
+ def _code_view(source: str, compact: bool, placeholder: str = "(the source is not available)") -> QPlainTextEdit:
392
+ """Read-only, highlighted Python; in the panel sized to the code (120–420 px)."""
393
+ editor = QPlainTextEdit(source or placeholder)
394
+ editor.setReadOnly(True)
395
+ editor.setFont(theme.mono_font(11))
396
+ editor.setLineWrapMode(QPlainTextEdit.NoWrap)
397
+ PythonHighlighter(editor.document())
398
+ lines = (source or "").count("\n") + 1
399
+ if compact:
400
+ editor.setMinimumHeight(min(max(lines * 18 + 20, 120), 420))
401
+ editor.setMaximumHeight(min(max(lines * 18 + 20, 120), 420))
402
+ return editor
403
+
404
+
405
+ def _open_in_editor(file: str) -> None:
406
+ QDesktopServices.openUrl(__import__("PySide6.QtCore", fromlist=["QUrl"]).QUrl.fromLocalFile(file))
407
+
408
+
409
+ def show_file(called: Called, parent=None) -> QDialog:
410
+ """The whole file an algorithm lives in, in a window of its own, scrolled to
411
+ the function: the module's docstring at its top says what the algorithm
412
+ does and where it comes from."""
413
+ from PySide6.QtGui import QTextCursor
414
+
415
+ def make() -> QWidget:
416
+ host = QWidget()
417
+ layout = QVBoxLayout(host)
418
+ layout.setContentsMargins(0, 0, 0, 0)
419
+ layout.setSpacing(8)
420
+ try:
421
+ text = Path(called.file).read_text(encoding="utf-8", errors="replace")
422
+ except OSError as error:
423
+ text = f"(could not read {called.file}: {error})"
424
+ layout.addWidget(label(f"{called.where} · the whole file; <b>{escape(called.name)}</b> starts at "
425
+ f"line {called.line}", "muted", wrap=True, selectable=True))
426
+ editor = _code_view(text, compact=False)
427
+ editor.setMinimumHeight(560)
428
+ layout.addWidget(editor, 1)
429
+ if Path(called.file).is_file():
430
+ layout.addLayout(hbox(button("Open in your editor", lambda: _open_in_editor(called.file)), None))
431
+
432
+ def scroll_to_function() -> None:
433
+ block = editor.document().findBlockByNumber(max(called.line - 1, 0))
434
+ cursor = QTextCursor(block)
435
+ editor.setTextCursor(cursor)
436
+ editor.centerCursor()
437
+ QTimer.singleShot(0, scroll_to_function)
438
+ return host
439
+ return pop_out(f"{Path(called.file).name} · {called.name}", make, parent)
440
+
441
+
442
+ def _follows(called: Called) -> QLabel | None:
443
+ """The published work(s) the algorithm follows, from the app's References
444
+ (*Help ▸ References*), so the code can be checked against its source."""
445
+ import re
446
+ from ...references import reference, topics_for_module
447
+ lines = []
448
+ for topic in topics_for_module(called.module):
449
+ works = []
450
+ for key in topic.sources:
451
+ work = reference(key)
452
+ if work is None:
453
+ continue
454
+ citation = re.sub(r"\*(.+?)\*", r"<i>\1</i>", escape(work.citation))
455
+ works.append(f"<a href=\"{work.url}\">{citation}</a>" if work.url else citation)
456
+ if works:
457
+ lines.append(f"<b>Follows</b> ({escape(topic.name)}): " + " · ".join(works))
458
+ if not lines:
459
+ return None
460
+ text = label("<br>".join(lines), "muted", wrap=True)
461
+ text.setObjectName("algorithmFollows")
462
+ text.setTextFormat(Qt.RichText)
463
+ text.setOpenExternalLinks(True)
464
+ return text
465
+
466
+
467
+ def code_widget(spec: BoxSpec, page=None, compact: bool = True) -> QWidget:
468
+ """The box's own source and, under it, the code of every algorithm it
469
+ calls (see :func:`openprocess.flow.box.algorithm_calls`): the box is a thin
470
+ wrapper, so the code to read when assessing correctness is the latter."""
471
+ host = QWidget()
472
+ layout = QVBoxLayout(host)
473
+ layout.setContentsMargins(0, 0, 0, 0)
474
+ layout.setSpacing(8)
475
+ calls = algorithm_calls(spec)
476
+ where = spec.file if spec.custom else f"openprocess/flow/boxes/{spec.module.rsplit('.', 1)[-1]}.py"
477
+ if calls:
478
+ layout.addWidget(label("THE BOX", "sectionLabel"))
479
+ layout.addWidget(label(f"{where}:{spec.line}" if spec.line else where, "muted", wrap=True, selectable=True))
480
+ layout.addWidget(_code_view(spec.source, compact), 1)
481
+ actions = []
482
+ if spec.file and Path(spec.file).is_file():
483
+ actions.append(button("Open in your editor", lambda: _open_in_editor(spec.file),
484
+ tooltip="Open the file in the editor your system uses for .py files; the box "
485
+ "reloads when you save"))
486
+ if spec.needs:
487
+ actions.append(label("Needs " + ", ".join(spec.needs)
488
+ + ("" if spec.available else " (not installed: the box is greyed out)"), "muted"))
489
+ if actions:
490
+ layout.addLayout(hbox(*actions, None))
491
+ if calls:
492
+ layout.addSpacing(6)
493
+ layout.addWidget(label("THE ALGORITHM", "sectionLabel"))
494
+ layout.addWidget(label("The box only wraps these: the actual work is in the functions it calls, "
495
+ "shown here in the order they are called. <i>Whole file</i> opens the module "
496
+ "they live in, whose docstring explains the algorithm and names its source.",
497
+ "muted", wrap=True))
498
+ for called in calls:
499
+ title = label(f"<b>{escape(called.name)}</b> · {escape(called.where)}", "muted", wrap=True,
500
+ selectable=True)
501
+ whole = button("Whole file ⤢", lambda _checked=False, c=called: show_file(c, host.window()),
502
+ tooltip="The module this function lives in, in a window of its own")
503
+ whole.setObjectName("algorithmFile")
504
+ layout.addLayout(hbox(title, None, whole))
505
+ follows = _follows(called)
506
+ if follows:
507
+ layout.addWidget(follows)
508
+ layout.addWidget(_code_view(called.source, compact))
509
+ layout.addWidget(label("The function is the box: call it from a script or a notebook and it runs the same way.",
510
+ "muted", wrap=True))
511
+ return host
512
+
513
+
514
+ # ---------------------------------------------------------------------------
515
+ # Settings
516
+ # ---------------------------------------------------------------------------
517
+ class SettingsWidget(QWidget):
518
+ """One control per setting of a box; ``changed`` carries the setting's
519
+ name and its new value (a :class:`Sweep` when swept)."""
520
+
521
+ changed = Signal(str, object)
522
+
523
+ def __init__(self, spec: BoxSpec, settings: dict, folder: Path | None = None, parent=None) -> None:
524
+ super().__init__(parent)
525
+ self.spec, self.folder = spec, folder
526
+ layout = QVBoxLayout(self)
527
+ layout.setContentsMargins(0, 0, 0, 0)
528
+ layout.setSpacing(10)
529
+ if not spec.settings:
530
+ layout.addWidget(label("This box has no settings: its function takes only its inputs.", "muted",
531
+ wrap=True))
532
+ self.controls: dict[str, QWidget] = {}
533
+ for setting in spec.settings:
534
+ layout.addWidget(self._row(setting, settings.get(setting.name, setting.default)))
535
+ layout.addStretch(1)
536
+
537
+ def _row(self, setting: Setting, value) -> QWidget:
538
+ row = QWidget()
539
+ column = QVBoxLayout(row)
540
+ column.setContentsMargins(0, 0, 0, 0)
541
+ column.setSpacing(3)
542
+ title = label(setting.name.replace("_", " "))
543
+ title.setStyleSheet("font-weight: 500;")
544
+ column.addWidget(title)
545
+ swept = is_sweep(value)
546
+ control: QWidget
547
+ if setting.kind == "choice":
548
+ control = QComboBox()
549
+ for choice in setting.choices:
550
+ control.addItem(str(choice), choice)
551
+ control.setCurrentIndex(max(0, list(setting.choices).index(value) if value in setting.choices else 0))
552
+ control.currentIndexChanged.connect(lambda _i, s=setting, c=control: self.changed.emit(s.name, c.currentData()))
553
+ elif setting.kind == "bool":
554
+ control = QCheckBox("on")
555
+ control.setChecked(bool(value) if not swept else bool(setting.default))
556
+ control.toggled.connect(lambda on, s=setting: self.changed.emit(s.name, on))
557
+ elif setting.kind == "int":
558
+ control = QSpinBox()
559
+ control.setRange(-1_000_000, 1_000_000_000)
560
+ control.setValue(int(value) if not swept else int(setting.default))
561
+ control.valueChanged.connect(lambda v, s=setting: self.changed.emit(s.name, v))
562
+ elif setting.kind == "float":
563
+ control = QDoubleSpinBox()
564
+ control.setRange(-1e9, 1e9)
565
+ control.setDecimals(3)
566
+ control.setSingleStep(0.05)
567
+ control.setValue(float(value) if not swept else float(setting.default))
568
+ control.valueChanged.connect(lambda v, s=setting: self.changed.emit(s.name, v))
569
+ elif setting.kind == "path":
570
+ control = QComboBox()
571
+ control.setEditable(True)
572
+ for name in self._files():
573
+ control.addItem(name)
574
+ control.setCurrentText("" if value in (None, "") else str(value))
575
+ control.lineEdit().editingFinished.connect(
576
+ lambda s=setting, c=control: self.changed.emit(s.name, c.currentText()))
577
+ control.activated.connect(lambda _i, s=setting, c=control: self.changed.emit(s.name, c.currentText()))
578
+ else:
579
+ control = QLineEdit(str(value) if not swept else "")
580
+ control.editingFinished.connect(lambda s=setting, c=control: self.changed.emit(s.name, c.text()))
581
+ self.controls[setting.name] = control
582
+ column.addWidget(control)
583
+ if setting.kind in ("int", "float"):
584
+ sweep_row = QHBoxLayout()
585
+ sweep_row.setSpacing(6)
586
+ sweep_box = QCheckBox("Sweep")
587
+ sweep_box.setToolTip("Run the boxes after this one once per value, e.g. 0..0.5 step 0.1 or 1, 2, 3")
588
+ sweep_edit = QLineEdit(value.describe().replace(" … ", "..") if swept else "")
589
+ sweep_edit.setPlaceholderText("0..0.5 step 0.1 or 1, 2, 3")
590
+ sweep_edit.setVisible(swept)
591
+ sweep_box.setChecked(swept)
592
+ if swept:
593
+ sweep_edit.setText(", ".join(str(v) for v in value.values) if len(value.values) <= 12
594
+ else f"{value.values[0]}..{value.values[-1]} step {value.values[1] - value.values[0]}")
595
+
596
+ def toggle(on, s=setting, e=sweep_edit, c=control):
597
+ e.setVisible(on)
598
+ c.setEnabled(not on)
599
+ if not on:
600
+ self.changed.emit(s.name, c.value())
601
+ elif e.text().strip():
602
+ self._sweep(s, e)
603
+ sweep_box.toggled.connect(toggle)
604
+ sweep_edit.editingFinished.connect(lambda s=setting, e=sweep_edit: self._sweep(s, e))
605
+ control.setEnabled(not swept)
606
+ sweep_row.addWidget(sweep_box)
607
+ sweep_row.addWidget(sweep_edit, 1)
608
+ column.addLayout(sweep_row)
609
+ if setting.help:
610
+ column.addWidget(label(setting.help, "muted", wrap=True))
611
+ return row
612
+
613
+ def _sweep(self, setting: Setting, edit: QLineEdit) -> None:
614
+ try:
615
+ sweep = Sweep.parse(edit.text())
616
+ except ValueError as error:
617
+ edit.setToolTip(str(error))
618
+ edit.setStyleSheet(f"border: 1px solid {style.STATUS['critical']};")
619
+ return
620
+ edit.setStyleSheet("")
621
+ self.changed.emit(setting.name, sweep)
622
+
623
+ def _files(self) -> list[str]:
624
+ if self.folder is None or not Path(self.folder).is_dir():
625
+ return []
626
+ names = []
627
+ for path in sorted(Path(self.folder).rglob("*")):
628
+ if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in (
629
+ ".xes", ".gz", ".csv", ".txt", ".pnml", ".cpn") and len(names) < 200:
630
+ names.append(str(path.relative_to(self.folder)))
631
+ return names
632
+
633
+
634
+ # ---------------------------------------------------------------------------
635
+ # A tab in its own window
636
+ # ---------------------------------------------------------------------------
637
+ _windows: list[QDialog] = []
638
+
639
+
640
+ def pop_out(title: str, make_widget, parent=None) -> QDialog:
641
+ """Show ``make_widget()`` in a window of its own (non-modal, resizable)."""
642
+ dialog = QDialog(parent)
643
+ dialog.setWindowTitle(title)
644
+ dialog.setModal(False)
645
+ dialog.resize(960, 700)
646
+ dialog.setLayout(vbox(scroll(make_widget()), margins=(16, 16, 16, 16)))
647
+ dialog.show()
648
+ _windows.append(dialog)
649
+ dialog.finished.connect(lambda _r: _windows.remove(dialog) if dialog in _windows else None)
650
+ return dialog
651
+
652
+
653
+ def status_text(result: Result | None) -> tuple[str, str]:
654
+ """(chip text, status word) for a box's result."""
655
+ if result is None:
656
+ return "Waiting", "muted"
657
+ if result.status == "done":
658
+ return (f"Done in {result.duration * 1000:.0f} ms" + (" (kept)" if result.cached else "")), "good"
659
+ if result.status == "running":
660
+ return "Running…", "accent"
661
+ if result.status == "failed":
662
+ return "Failed", "critical"
663
+ if result.status == "blocked":
664
+ return result.message or "Not allowed to run yet", "warning"
665
+ return result.message or "Waiting", "muted"