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,385 @@
1
+ """The ``.cpnflow`` file: a workflow, and the record that makes it reproducible.
2
+
3
+ A workflow file is JSON: the boxes, their settings and positions, the
4
+ connections, the groups, and a **record**:
5
+
6
+ * the versions of OpenProcess, Python and the optional libraries;
7
+ * the environment: every installed package and its version (the lock);
8
+ * a fingerprint (SHA-256) of every input file and every custom box file;
9
+ * every seed;
10
+ * a fingerprint of every box's result from the last run.
11
+
12
+ **Re-run** compares the record with what is on disk now and says exactly
13
+ what differs before running. ``openprocess run --check`` re-runs headless and
14
+ fails if a result's fingerprint differs from the recorded one: run it in a
15
+ paper's CI and the passing badge is the reproducibility claim.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import datetime as _dt
21
+ import hashlib
22
+ import importlib.metadata
23
+ import json
24
+ import platform
25
+ import sys
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from .. import __version__
31
+ from ..mining.log import EventLog, SimpleLog, format_simple_log
32
+ from ..mining.petrinet import PetriNet
33
+ from .runner import DONE, Run
34
+ from .types import Dataset, Figure, Predictions, Scores, Table, Text
35
+ from .workflow import Workflow
36
+
37
+ FORMAT = "cpnflow/1"
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Fingerprints
42
+ # ---------------------------------------------------------------------------
43
+ def sha256_bytes(data: bytes) -> str:
44
+ return hashlib.sha256(data).hexdigest()
45
+
46
+
47
+ def sha256_text(text: str) -> str:
48
+ return sha256_bytes(text.encode("utf-8"))
49
+
50
+
51
+ def sha256_file(path: str | Path) -> str:
52
+ digest = hashlib.sha256()
53
+ with open(path, "rb") as handle:
54
+ for chunk in iter(lambda: handle.read(1 << 20), b""):
55
+ digest.update(chunk)
56
+ return digest.hexdigest()
57
+
58
+
59
+ def fingerprint(value: Any) -> str:
60
+ """A short code that changes if the value changes (as far as it can be
61
+ told from the value's content, not its identity)."""
62
+ if isinstance(value, EventLog):
63
+ digest = hashlib.sha256()
64
+ for trace in value:
65
+ digest.update(trace.case_id.encode("utf-8", "replace"))
66
+ for event in trace:
67
+ stamp = event.timestamp
68
+ digest.update(f"|{event.activity}|{stamp.isoformat() if stamp else ''}".encode("utf-8", "replace"))
69
+ digest.update(b"\n")
70
+ return digest.hexdigest()
71
+ if isinstance(value, SimpleLog):
72
+ return sha256_text(format_simple_log(value))
73
+ if isinstance(value, PetriNet):
74
+ from ..mining.pnml import pnml_string
75
+ return sha256_text(pnml_string(value))
76
+ if isinstance(value, Scores):
77
+ return sha256_text(json.dumps([value.model, _rounded(value.metrics)], sort_keys=True, default=str))
78
+ if isinstance(value, Table):
79
+ return sha256_text(json.dumps([value.columns, _rounded(value.rows)], sort_keys=True, default=str))
80
+ if isinstance(value, Figure):
81
+ return sha256_text(value.svg or "") if value.svg else sha256_bytes(value.png or b"")
82
+ if isinstance(value, Text):
83
+ return sha256_text(value.text)
84
+ if isinstance(value, Dataset):
85
+ return sha256_text(json.dumps([_rounded(_rows(value.X)), [str(y) for y in value.y]], default=str))
86
+ if isinstance(value, Predictions):
87
+ return sha256_text(json.dumps([str(v) for v in value.values]))
88
+ if isinstance(value, (int, float, str, bool, list, dict, tuple)) or value is None:
89
+ return sha256_text(json.dumps(_rounded(value), sort_keys=True, default=str))
90
+ steps = getattr(value, "steps", None)
91
+ if callable(steps):
92
+ try:
93
+ return sha256_text(json.dumps(steps(), default=str))
94
+ except Exception: # noqa: BLE001
95
+ pass
96
+ for attribute in ("net", "tree", "ts"):
97
+ inner = getattr(value, attribute, None)
98
+ if inner is not None and inner is not value:
99
+ return fingerprint(inner)
100
+ return sha256_text(repr(value))
101
+
102
+
103
+ def _rows(x):
104
+ try:
105
+ return x.tolist()
106
+ except AttributeError:
107
+ return [list(row) for row in x]
108
+
109
+
110
+ def _rounded(value):
111
+ if isinstance(value, float):
112
+ return round(value, 9)
113
+ if isinstance(value, dict):
114
+ return {k: _rounded(v) for k, v in value.items()}
115
+ if isinstance(value, (list, tuple)):
116
+ return [_rounded(v) for v in value]
117
+ return value
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # The environment
122
+ # ---------------------------------------------------------------------------
123
+ def versions() -> dict[str, str]:
124
+ out = {"openprocess": __version__, "python": platform.python_version()}
125
+ for name in ("pm4py", "numpy", "pandas", "scipy", "matplotlib", "sklearn", "torch"):
126
+ try:
127
+ out[name] = importlib.metadata.version("scikit-learn" if name == "sklearn" else name)
128
+ except importlib.metadata.PackageNotFoundError:
129
+ pass
130
+ return out
131
+
132
+
133
+ def environment() -> dict[str, Any]:
134
+ """Everything installed, for the lock: ``{"packages": {"numpy": "1.26.4", ...}}``."""
135
+ packages = {}
136
+ for dist in importlib.metadata.distributions():
137
+ name = dist.metadata["Name"]
138
+ if name:
139
+ packages[name.lower()] = dist.version
140
+ return {"platform": platform.platform(), "python": sys.version.split()[0],
141
+ "packages": dict(sorted(packages.items()))}
142
+
143
+
144
+ def requirements_lock(record: "Record") -> str:
145
+ """A ``requirements.lock`` from a record's environment."""
146
+ packages = record.environment.get("packages", {})
147
+ return "\n".join(f"{name}=={version}" for name, version in sorted(packages.items())) + "\n"
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # The record
152
+ # ---------------------------------------------------------------------------
153
+ @dataclass
154
+ class Record:
155
+ versions: dict[str, str] = field(default_factory=dict)
156
+ environment: dict[str, Any] = field(default_factory=dict)
157
+ #: ``{"node": ..., "setting": ..., "file": ..., "sha256": ...}`` per file setting.
158
+ inputs: list[dict[str, str]] = field(default_factory=list)
159
+ custom_boxes: list[dict[str, str]] = field(default_factory=list)
160
+ seeds: dict[str, Any] = field(default_factory=dict)
161
+ #: ``node id`` -> result fingerprint (variant 0), from the last run.
162
+ results: dict[str, str] = field(default_factory=dict)
163
+ saved: str = ""
164
+
165
+ def to_dict(self) -> dict:
166
+ return {"versions": self.versions, "environment": self.environment, "inputs": self.inputs,
167
+ "custom_boxes": self.custom_boxes, "seeds": self.seeds, "results": self.results,
168
+ "saved": self.saved}
169
+
170
+ @classmethod
171
+ def from_dict(cls, data: dict | None) -> "Record":
172
+ data = data or {}
173
+ return cls(data.get("versions", {}), data.get("environment", {}), data.get("inputs", []),
174
+ data.get("custom_boxes", []), data.get("seeds", {}), data.get("results", {}),
175
+ data.get("saved", ""))
176
+
177
+
178
+ def make_record(workflow: Workflow, run: Run | None = None, folder: str | Path | None = None,
179
+ lock: bool = True) -> Record:
180
+ """What the workflow depends on right now."""
181
+ record = Record(versions=versions(), environment=environment() if lock else {},
182
+ saved=_dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"))
183
+ for node in workflow.nodes.values():
184
+ spec = workflow.spec(node)
185
+ for setting in spec.settings:
186
+ value = node.settings.get(setting.name)
187
+ if setting.kind == "path" and value:
188
+ path = _resolve(value, folder)
189
+ record.inputs.append({"node": node.id, "setting": setting.name,
190
+ "file": _relative(path, folder),
191
+ "sha256": sha256_file(path) if path.is_file() else "missing"})
192
+ if setting.name == "seed" and value is not None:
193
+ record.seeds[node.id] = value if not hasattr(value, "values") else list(value.values)
194
+ if spec.custom and spec.file:
195
+ file = Path(spec.file)
196
+ entry = {"file": _relative(file, folder), "sha256": sha256_file(file) if file.is_file() else "missing"}
197
+ if entry not in record.custom_boxes:
198
+ record.custom_boxes.append(entry)
199
+ if run is not None:
200
+ for (node_id, variant), result in run.results.items():
201
+ if variant == 0 and result.status == DONE:
202
+ record.results[node_id] = fingerprint(result.value)
203
+ return record
204
+
205
+
206
+ def _resolve(value, folder) -> Path:
207
+ path = Path(value)
208
+ if not path.is_absolute() and folder is not None:
209
+ path = Path(folder) / path
210
+ return path
211
+
212
+
213
+ def _relative(path: Path, folder) -> str:
214
+ if folder is not None:
215
+ try:
216
+ return str(path.resolve().relative_to(Path(folder).resolve()))
217
+ except ValueError:
218
+ pass
219
+ return str(path)
220
+
221
+
222
+ def differences(record: Record, workflow: Workflow, folder: str | Path | None = None) -> list[str]:
223
+ """What differs between the record and the world now, in plain words."""
224
+ now = make_record(workflow, None, folder, lock=False)
225
+ out = []
226
+ for then in record.inputs:
227
+ current = next((i for i in now.inputs if i["node"] == then["node"] and i["setting"] == then["setting"]), None)
228
+ if current is None:
229
+ out.append(f"“{then['file']}” is no longer read by the workflow.")
230
+ elif current["sha256"] == "missing":
231
+ out.append(f"“{current['file']}” is missing.")
232
+ elif current["sha256"] != then["sha256"]:
233
+ out.append(f"“{current['file']}” has changed since the workflow was saved "
234
+ f"(fingerprint {then['sha256'][:8]}… is now {current['sha256'][:8]}…).")
235
+ for then in record.custom_boxes:
236
+ current = next((c for c in now.custom_boxes if c["file"] == then["file"]), None)
237
+ if current is None:
238
+ out.append(f"The box file “{then['file']}” is not used any more.")
239
+ elif current["sha256"] != then["sha256"]:
240
+ out.append(f"The code in “{then['file']}” has changed.")
241
+ for node_id, seed in record.seeds.items():
242
+ if node_id in now.seeds and now.seeds[node_id] != seed:
243
+ out.append(f"The seed of “{workflow.title(node_id)}” is {now.seeds[node_id]}, it was {seed}.")
244
+ for name in ("openprocess", "python"):
245
+ then, current = record.versions.get(name), now.versions.get(name)
246
+ if then and current and then != current:
247
+ out.append(f"{name} is {current}; the workflow was saved with {then}.")
248
+ return out
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Files
253
+ # ---------------------------------------------------------------------------
254
+ def save(workflow: Workflow, path: str | Path, run: Run | None = None,
255
+ folder: str | Path | None = None, lock: bool = True) -> Record:
256
+ """Write the workflow and its record to ``path``; returns the record."""
257
+ path = Path(path)
258
+ folder = folder if folder is not None else path.parent
259
+ record = make_record(workflow, run, folder, lock)
260
+ data = {"format": FORMAT, **workflow.to_dict(), "record": record.to_dict()}
261
+ text = json.dumps(data, indent=2, ensure_ascii=False, default=str)
262
+ temporary = path.with_name(path.name + ".tmp")
263
+ temporary.write_text(text, encoding="utf-8")
264
+ temporary.replace(path)
265
+ return record
266
+
267
+
268
+ def load(path: str | Path, library=None) -> tuple[Workflow, Record]:
269
+ """Read a ``.cpnflow`` file."""
270
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
271
+ if data.get("format", FORMAT).split("/")[0] != "cpnflow":
272
+ raise ValueError(f"{path} is not a workflow file")
273
+ workflow = Workflow.from_dict(data, library)
274
+ return workflow, Record.from_dict(data.get("record"))
275
+
276
+
277
+ def check(workflow: Workflow, record: Record, run: Run) -> list[str]:
278
+ """After re-running: which results differ from the recorded ones."""
279
+ out = []
280
+ for node_id, then in record.results.items():
281
+ result = run.result(node_id, 0)
282
+ if result is None:
283
+ continue
284
+ if result.status != DONE:
285
+ out.append(f"“{workflow.title(node_id)}” did not finish: {result.error or result.message}")
286
+ elif fingerprint(result.value) != then:
287
+ out.append(f"“{workflow.title(node_id)}” gives a different result than recorded.")
288
+ return out
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Export experiment: everything in one zip, for a paper's supplementary material
293
+ # ---------------------------------------------------------------------------
294
+ def export_experiment(workflow: Workflow, run: Run | None, target: str | Path,
295
+ folder: str | Path | None = None, library=None) -> Path:
296
+ """A zip with the workflow file and its record, the input files, the
297
+ custom box files, every result as a file (CSV, PNML, SVG, XES), a
298
+ ``requirements.lock`` and a ``README.md`` that says what was run."""
299
+ import io
300
+ import zipfile
301
+ target = Path(target)
302
+ folder = Path(folder) if folder is not None else target.parent
303
+ record = make_record(workflow, run, folder)
304
+ data = {"format": FORMAT, **workflow.to_dict(), "record": record.to_dict()}
305
+ name = workflow.name or "workflow"
306
+ lines = [f"# {name}", "", f"Exported {record.saved} with OpenProcess {record.versions.get('openprocess', '')}, "
307
+ f"Python {record.versions.get('python', '')}.", "",
308
+ "Run it again with `openprocess run " + f"{name}.cpnflow --check`: it fails if any result differs "
309
+ "from the recorded one. `requirements.lock` is the environment it ran in.", "", "## Boxes", ""]
310
+ for node in workflow.order():
311
+ spec = workflow.spec(node)
312
+ settings = ", ".join(f"{k} = {v}" for k, v in node.settings.items()) or "no settings"
313
+ fed = ", ".join(f"{p} ← {workflow.title(src)}" for p, sources in workflow.inputs_of(node).items()
314
+ for src, _ in sources) or "no inputs"
315
+ result = run.result(node) if run else None
316
+ verdict = ""
317
+ if result is not None and result.status == DONE:
318
+ verdict = " → " + _describe(result.value)
319
+ lines.append(f"- **{workflow.title(node)}** (`{spec.id}`): {settings}; {fed}{verdict}")
320
+ lines += ["", "## Inputs", ""] + [f"- `{i['file']}` sha256 `{i['sha256']}`" for i in record.inputs] + ["", "## Your boxes", ""] + [f"- `{b['file']}` sha256 `{b['sha256']}`" for b in record.custom_boxes]
321
+ with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as zipped:
322
+ zipped.writestr(f"{name}.cpnflow", json.dumps(data, indent=2, ensure_ascii=False, default=str))
323
+ zipped.writestr("requirements.lock", requirements_lock(record))
324
+ zipped.writestr("README.md", "\n".join(lines) + "\n")
325
+ for item in record.inputs:
326
+ path = _resolve(item["file"], folder)
327
+ if path.is_file():
328
+ zipped.write(path, f"inputs/{Path(item['file']).name}")
329
+ for item in record.custom_boxes:
330
+ path = _resolve(item["file"], folder)
331
+ if path.is_file():
332
+ zipped.write(path, f"boxes/{path.name}")
333
+ if run is not None:
334
+ for node in workflow.order():
335
+ value = run.value(node)
336
+ stem = "results/" + f"{node.id} {workflow.title(node)}".replace("/", "-")
337
+ payload = _as_file(value, stem)
338
+ if payload is not None:
339
+ file_name, content = payload
340
+ zipped.writestr(file_name, content)
341
+ return target
342
+
343
+
344
+ def _describe(value) -> str:
345
+ if isinstance(value, Scores):
346
+ return ", ".join(f"{k} {v:.3f}" if isinstance(v, float) else f"{k} {v}" for k, v in value.metrics.items())
347
+ if isinstance(value, Table):
348
+ return f"table {len(value.rows)} × {len(value.columns)}"
349
+ if isinstance(value, EventLog):
350
+ return f"{len(value)} cases, {value.event_count} events"
351
+ if isinstance(value, PetriNet):
352
+ return value.summary()
353
+ return type(value).__name__ if value is not None else "done"
354
+
355
+
356
+ def _as_file(value, stem: str):
357
+ """(name, bytes or text) for a result worth a file of its own."""
358
+ if isinstance(value, Table):
359
+ import csv
360
+ import io
361
+ buffer = io.StringIO()
362
+ writer = csv.writer(buffer)
363
+ writer.writerow(value.columns)
364
+ writer.writerows(value.rows)
365
+ return f"{stem}.csv", buffer.getvalue()
366
+ if isinstance(value, Scores):
367
+ return _as_file(Table.from_scores([value]), stem)
368
+ if isinstance(value, PetriNet):
369
+ from ..mining.pnml import pnml_string
370
+ return f"{stem}.pnml", pnml_string(value)
371
+ if isinstance(value, Figure):
372
+ if value.svg:
373
+ return f"{stem}.svg", value.svg
374
+ if value.png:
375
+ return f"{stem}.png", value.png
376
+ if isinstance(value, Text):
377
+ return f"{stem}.txt", value.text
378
+ if isinstance(value, EventLog) and len(value) <= 20_000:
379
+ from ..mining.xes import xes_string
380
+ return f"{stem}.xes", xes_string(value)
381
+ return None
382
+
383
+
384
+ __all__ = ["FORMAT", "Record", "check", "differences", "environment", "export_experiment", "fingerprint",
385
+ "load", "make_record", "requirements_lock", "save", "sha256_file", "sha256_text", "versions"]