openprocess 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cpnpy/__init__.py +46 -0
- openprocess/__init__.py +57 -0
- openprocess/analysis/__init__.py +0 -0
- openprocess/analysis/state_space.py +521 -0
- openprocess/analysis/state_space_process.py +251 -0
- openprocess/cli.py +742 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
- openprocess/exercises/pack.md +14 -0
- openprocess/flow/__init__.py +50 -0
- openprocess/flow/box.py +466 -0
- openprocess/flow/boxes/__init__.py +7 -0
- openprocess/flow/boxes/check.py +119 -0
- openprocess/flow/boxes/compare.py +16 -0
- openprocess/flow/boxes/cpn.py +53 -0
- openprocess/flow/boxes/discover.py +124 -0
- openprocess/flow/boxes/filter.py +80 -0
- openprocess/flow/boxes/input.py +124 -0
- openprocess/flow/boxes/output.py +52 -0
- openprocess/flow/boxes/predict.py +186 -0
- openprocess/flow/boxes/science.py +159 -0
- openprocess/flow/boxes/sweeps.py +18 -0
- openprocess/flow/convert.py +187 -0
- openprocess/flow/datasets.py +198 -0
- openprocess/flow/explain.py +115 -0
- openprocess/flow/library.py +222 -0
- openprocess/flow/record.py +385 -0
- openprocess/flow/runner.py +357 -0
- openprocess/flow/sweep.py +92 -0
- openprocess/flow/types.py +290 -0
- openprocess/flow/workflow.py +628 -0
- openprocess/gui/__init__.py +0 -0
- openprocess/gui/app.py +90 -0
- openprocess/gui/arc_editing.py +295 -0
- openprocess/gui/canvas.py +1414 -0
- openprocess/gui/flow/__init__.py +8 -0
- openprocess/gui/flow/canvas.py +854 -0
- openprocess/gui/flow/page.py +972 -0
- openprocess/gui/flow/templates.py +131 -0
- openprocess/gui/flow/viewers.py +665 -0
- openprocess/gui/items.py +1275 -0
- openprocess/gui/learn/answer_boxes.py +978 -0
- openprocess/gui/learn/concealment.py +91 -0
- openprocess/gui/learn/mode.py +1181 -0
- openprocess/gui/panning.py +241 -0
- openprocess/gui/resources/openprocess-icon.png +0 -0
- openprocess/gui/studio/__init__.py +1 -0
- openprocess/gui/studio/__main__.py +3 -0
- openprocess/gui/studio/app.py +4031 -0
- openprocess/gui/studio/charts.py +115 -0
- openprocess/gui/studio/compare_page.py +487 -0
- openprocess/gui/studio/cpn_page.py +1858 -0
- openprocess/gui/studio/definition_view.py +284 -0
- openprocess/gui/studio/derivation_view.py +421 -0
- openprocess/gui/studio/documents.py +152 -0
- openprocess/gui/studio/dotted_chart.py +1401 -0
- openprocess/gui/studio/file_dialogs.py +143 -0
- openprocess/gui/studio/filter_dialog.py +247 -0
- openprocess/gui/studio/graph_builders.py +176 -0
- openprocess/gui/studio/graph_view.py +682 -0
- openprocess/gui/studio/instances.py +413 -0
- openprocess/gui/studio/log_editor.py +675 -0
- openprocess/gui/studio/log_page.py +800 -0
- openprocess/gui/studio/markdown_view.py +127 -0
- openprocess/gui/studio/mathtext.py +260 -0
- openprocess/gui/studio/ml_highlighter.py +75 -0
- openprocess/gui/studio/model_page.py +760 -0
- openprocess/gui/studio/net_comparison.py +124 -0
- openprocess/gui/studio/notes_overlay.py +275 -0
- openprocess/gui/studio/petri_page.py +844 -0
- openprocess/gui/studio/regions_view.py +502 -0
- openprocess/gui/studio/sidebar.py +149 -0
- openprocess/gui/studio/style.py +503 -0
- openprocess/gui/studio/tool_icons.py +134 -0
- openprocess/gui/studio/updates.py +439 -0
- openprocess/gui/studio/widgets.py +899 -0
- openprocess/gui/studio/workers.py +60 -0
- openprocess/gui/studio/workspace.py +447 -0
- openprocess/gui/theme.py +394 -0
- openprocess/gui/tidy.py +86 -0
- openprocess/io/__init__.py +0 -0
- openprocess/io/cpn_reader.py +389 -0
- openprocess/io/cpn_writer.py +357 -0
- openprocess/learn/__init__.py +23 -0
- openprocess/learn/answers.py +188 -0
- openprocess/learn/checks.py +953 -0
- openprocess/learn/computed.py +1180 -0
- openprocess/learn/context.py +145 -0
- openprocess/learn/exam.py +169 -0
- openprocess/learn/exercise-packs.md +325 -0
- openprocess/learn/importer.py +216 -0
- openprocess/learn/notation.py +474 -0
- openprocess/learn/pack.py +511 -0
- openprocess/learn/sheet.py +296 -0
- openprocess/mining/__init__.py +73 -0
- openprocess/mining/analysis.py +689 -0
- openprocess/mining/columns.py +282 -0
- openprocess/mining/compare_nets.py +246 -0
- openprocess/mining/conformance/__init__.py +0 -0
- openprocess/mining/conformance/alignments.py +263 -0
- openprocess/mining/conformance/quality.py +145 -0
- openprocess/mining/conformance/token_replay.py +252 -0
- openprocess/mining/csv_import.py +222 -0
- openprocess/mining/definitions.py +584 -0
- openprocess/mining/dfg.py +187 -0
- openprocess/mining/discovery/__init__.py +0 -0
- openprocess/mining/discovery/alpha.py +168 -0
- openprocess/mining/discovery/heuristics.py +332 -0
- openprocess/mining/discovery/inductive.py +477 -0
- openprocess/mining/discovery/state_regions.py +62 -0
- openprocess/mining/filtering.py +237 -0
- openprocess/mining/footprint.py +183 -0
- openprocess/mining/invariants.py +191 -0
- openprocess/mining/layout.py +279 -0
- openprocess/mining/log.py +364 -0
- openprocess/mining/petrinet.py +354 -0
- openprocess/mining/playout.py +75 -0
- openprocess/mining/pm4py_bridge.py +82 -0
- openprocess/mining/pnml.py +223 -0
- openprocess/mining/processtree.py +216 -0
- openprocess/mining/regions.py +476 -0
- openprocess/mining/stats.py +160 -0
- openprocess/mining/structure.py +374 -0
- openprocess/mining/transition_system.py +409 -0
- openprocess/mining/xes.py +399 -0
- openprocess/ml/__init__.py +0 -0
- openprocess/ml/ast_nodes.py +332 -0
- openprocess/ml/builtins.py +364 -0
- openprocess/ml/colorsets.py +522 -0
- openprocess/ml/errors.py +60 -0
- openprocess/ml/evaluator.py +754 -0
- openprocess/ml/lexer.py +277 -0
- openprocess/ml/multiset.py +417 -0
- openprocess/ml/parser.py +737 -0
- openprocess/ml/values.py +319 -0
- openprocess/model/__init__.py +0 -0
- openprocess/model/declarations.py +617 -0
- openprocess/model/examples.py +98 -0
- openprocess/model/net.py +701 -0
- openprocess/model/plain.py +192 -0
- openprocess/references.py +280 -0
- openprocess/sim/__init__.py +0 -0
- openprocess/sim/binding.py +620 -0
- openprocess/sim/export.py +66 -0
- openprocess/sim/simulator.py +315 -0
- openprocess/teaching/__init__.py +4 -0
- openprocess/teaching/answers.py +4 -0
- openprocess/teaching/checks.py +5 -0
- openprocess/teaching/pack.py +4 -0
- openprocess/teaching/sheet.py +4 -0
- openprocess-0.7.0.dist-info/METADATA +927 -0
- openprocess-0.7.0.dist-info/RECORD +173 -0
- openprocess-0.7.0.dist-info/WHEEL +5 -0
- openprocess-0.7.0.dist-info/entry_points.txt +6 -0
- openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
- openprocess-0.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,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"]
|