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,60 @@
|
|
|
1
|
+
"""Run slow computations off the GUI thread.
|
|
2
|
+
|
|
3
|
+
Alignments, state spaces and large imports can take seconds. Running them
|
|
4
|
+
on the GUI thread would freeze the window (the spinning beach ball), so they
|
|
5
|
+
run on Qt's thread pool and report back through signals, which Qt delivers
|
|
6
|
+
on the GUI thread -- the only thread allowed to touch widgets.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
run_in_background(lambda: align_log(net, log), on_done, on_error)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import traceback
|
|
16
|
+
|
|
17
|
+
from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Signals(QObject):
|
|
21
|
+
done = Signal(object)
|
|
22
|
+
failed = Signal(str)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Task(QRunnable):
|
|
26
|
+
def __init__(self, function) -> None:
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.function = function
|
|
29
|
+
self.signals = _Signals()
|
|
30
|
+
|
|
31
|
+
def run(self) -> None:
|
|
32
|
+
try:
|
|
33
|
+
result = self.function()
|
|
34
|
+
except Exception as error: # noqa: BLE001 - report anything to the UI
|
|
35
|
+
detail = "".join(traceback.format_exception_only(type(error), error)).strip()
|
|
36
|
+
self.signals.failed.emit(detail)
|
|
37
|
+
else:
|
|
38
|
+
self.signals.done.emit(result)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Keep references: a QRunnable's signal object must outlive the thread.
|
|
42
|
+
_running: set[_Task] = set()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_in_background(function, on_done, on_error=None) -> None:
|
|
46
|
+
task = _Task(function)
|
|
47
|
+
_running.add(task)
|
|
48
|
+
|
|
49
|
+
def finished(result) -> None:
|
|
50
|
+
_running.discard(task)
|
|
51
|
+
on_done(result)
|
|
52
|
+
|
|
53
|
+
def failed(message: str) -> None:
|
|
54
|
+
_running.discard(task)
|
|
55
|
+
if on_error is not None:
|
|
56
|
+
on_error(message)
|
|
57
|
+
|
|
58
|
+
task.signals.done.connect(finished)
|
|
59
|
+
task.signals.failed.connect(failed)
|
|
60
|
+
QThreadPool.globalInstance().start(task)
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Working in a folder such as "Week 2".
|
|
2
|
+
|
|
3
|
+
Opening a folder (File ▸ Open Folder…) makes the sidebar list every event
|
|
4
|
+
log and Petri net in it, opened or not, and keeps the folder and the app in
|
|
5
|
+
step. Internally the open folder is a :class:`Workspace`; the app only ever
|
|
6
|
+
calls it a folder. This module is the part that does not need Qt:
|
|
7
|
+
|
|
8
|
+
* :meth:`Workspace.tree` / :meth:`Workspace.files` -- the subfolders and the
|
|
9
|
+
files in the folder the app can open;
|
|
10
|
+
* :meth:`Workspace.load_state` / :meth:`Workspace.save_state` -- which of
|
|
11
|
+
them were open, so the folder comes back as you left it;
|
|
12
|
+
* :meth:`Workspace.settings` / :meth:`Workspace.update_settings` -- choices
|
|
13
|
+
made for this folder (the sidebar's view, expanded subfolders, what to do
|
|
14
|
+
with files opened from elsewhere);
|
|
15
|
+
* :func:`unique_path` and :func:`atomic_write` -- naming and writing the
|
|
16
|
+
files the app creates in the folder;
|
|
17
|
+
* :func:`exercise_files` -- whether a folder is an *exercise* (it has a
|
|
18
|
+
``question`` file) and which of its files play which part.
|
|
19
|
+
|
|
20
|
+
The state lives in a small hidden JSON file, ``.openprocess``, inside the folder;
|
|
21
|
+
the folder's scratch notes (✎ Notes) in another, ``.openprocess notes.md``.
|
|
22
|
+
Paths in it are relative to the folder, so the workspace still works after
|
|
23
|
+
the folder is moved, renamed or synced to another computer (iCloud, a USB
|
|
24
|
+
stick, a Git repository).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Callable, Iterator
|
|
34
|
+
|
|
35
|
+
#: What the app can open, by extension, and the sidebar section it goes in.
|
|
36
|
+
KINDS = {
|
|
37
|
+
".xes": "log",
|
|
38
|
+
".xes.gz": "log",
|
|
39
|
+
".csv": "log",
|
|
40
|
+
".pnml": "petri",
|
|
41
|
+
".cpn": "cpn",
|
|
42
|
+
".cpnflow": "workflow",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#: Text files the app opens, by the end of their name: a log in textbook
|
|
46
|
+
#: notation (``log.txt``, ``L1.log.txt``) and a transition system (``ts.txt``,
|
|
47
|
+
#: ``exam.ts.txt``). Other ``.txt`` files are not listed.
|
|
48
|
+
TEXT_KINDS = {"log.txt": "log", "ts.txt": "ts"}
|
|
49
|
+
|
|
50
|
+
#: Extensions of more than one part, longest first.
|
|
51
|
+
COMPOUND_SUFFIXES = (".xes.gz", ".log.txt", ".ts.txt")
|
|
52
|
+
|
|
53
|
+
#: The hidden file holding the workspace's state.
|
|
54
|
+
STATE_FILE = ".openprocess"
|
|
55
|
+
|
|
56
|
+
#: The hidden file holding the folder's scratch notes (the ✎ Notes overlay).
|
|
57
|
+
NOTES_FILE = ".openprocess notes.md"
|
|
58
|
+
|
|
59
|
+
#: The same files as CPNpy (before 0.7) wrote them: renamed when the folder is opened.
|
|
60
|
+
OLD_FILES = {".cpnpy": STATE_FILE, ".cpnpy notes.md": NOTES_FILE}
|
|
61
|
+
|
|
62
|
+
#: How deep into subfolders to look ("Week 2/logs/boarding.xes" is depth 1).
|
|
63
|
+
MAX_DEPTH = 3
|
|
64
|
+
|
|
65
|
+
#: Stop listing after this many files, so opening a huge folder by mistake
|
|
66
|
+
#: (your home folder, say) does not freeze the window.
|
|
67
|
+
MAX_FILES = 500
|
|
68
|
+
|
|
69
|
+
#: At most this many subfolders are listed (and watched for changes).
|
|
70
|
+
MAX_FOLDERS = 200
|
|
71
|
+
|
|
72
|
+
#: Folders never looked into: tools' caches and the like.
|
|
73
|
+
SKIPPED_FOLDERS = {"__pycache__", "node_modules", "build", "dist", "venv", ".venv"}
|
|
74
|
+
|
|
75
|
+
#: iCloud Drive's "Optimise Mac Storage" replaces a file that is only in
|
|
76
|
+
#: iCloud by a hidden placeholder: ``Week 2.xes`` becomes ``.Week 2.xes.icloud``.
|
|
77
|
+
ICLOUD_SUFFIX = ".icloud"
|
|
78
|
+
|
|
79
|
+
#: Characters macOS, Windows or Linux do not allow in a file name.
|
|
80
|
+
FORBIDDEN_CHARACTERS = set('/\\:*?"<>|')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def file_kind(path: Path) -> str | None:
|
|
84
|
+
"""``"log"``, ``"petri"``, ``"cpn"``, ``"ts"`` or ``None`` for a file the app cannot open."""
|
|
85
|
+
name = path.name.lower()
|
|
86
|
+
if name.endswith(".xes.gz"):
|
|
87
|
+
return KINDS[".xes.gz"]
|
|
88
|
+
for ending, kind in TEXT_KINDS.items():
|
|
89
|
+
if name == ending or name.endswith("." + ending):
|
|
90
|
+
return kind
|
|
91
|
+
return KINDS.get(path.suffix.lower())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def file_suffix(name: str) -> str:
|
|
95
|
+
"""The whole extension: ``log.xes.gz`` gives ``.xes.gz`` (not just ``.gz``),
|
|
96
|
+
``L1.log.txt`` gives ``.log.txt``."""
|
|
97
|
+
lower = name.lower()
|
|
98
|
+
for suffix in COMPOUND_SUFFIXES:
|
|
99
|
+
if lower.endswith(suffix) and len(name) > len(suffix):
|
|
100
|
+
return name[-len(suffix):]
|
|
101
|
+
return os.path.splitext(name)[1]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def file_stem(name: str) -> str:
|
|
105
|
+
"""The file name without :func:`file_suffix`."""
|
|
106
|
+
return name[: len(name) - len(file_suffix(name))]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def safe_file_name(name: str) -> str:
|
|
110
|
+
"""``name`` usable as a file name: forbidden characters become "-"."""
|
|
111
|
+
cleaned = "".join("-" if c in FORBIDDEN_CHARACTERS or ord(c) < 32 else c for c in name)
|
|
112
|
+
cleaned = cleaned.strip().strip(".")
|
|
113
|
+
return cleaned or "Untitled"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def unique_path(folder: str | Path, file_name: str) -> Path:
|
|
117
|
+
"""``folder/file_name``, or the first free ``"name 2.ext"``, ``"name 3.ext"``, …
|
|
118
|
+
|
|
119
|
+
The numbering is Finder's ("Keep both"), so ``Wilma 50.xes`` becomes
|
|
120
|
+
``Wilma 50 2.xes``. An iCloud placeholder counts as taken.
|
|
121
|
+
"""
|
|
122
|
+
folder = Path(folder)
|
|
123
|
+
stem, suffix = file_stem(file_name), file_suffix(file_name)
|
|
124
|
+
|
|
125
|
+
def taken(name: str) -> bool:
|
|
126
|
+
return (folder / name).exists() or (folder / f".{name}{ICLOUD_SUFFIX}").exists()
|
|
127
|
+
|
|
128
|
+
candidate, number = file_name, 2
|
|
129
|
+
while taken(candidate):
|
|
130
|
+
candidate = f"{stem} {number}{suffix}"
|
|
131
|
+
number += 1
|
|
132
|
+
return folder / candidate
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def atomic_write(path: str | Path, write: Callable[[Path], Any]) -> None:
|
|
136
|
+
"""Write a file so that a crash never leaves it half-written.
|
|
137
|
+
|
|
138
|
+
``write`` writes to a hidden temporary file next to ``path`` (with the
|
|
139
|
+
same extension, so writers that look at it still work), which then
|
|
140
|
+
replaces ``path`` in one step.
|
|
141
|
+
"""
|
|
142
|
+
path = Path(path)
|
|
143
|
+
temporary = path.with_name(f".~{path.name}")
|
|
144
|
+
try:
|
|
145
|
+
write(temporary)
|
|
146
|
+
os.replace(temporary, path)
|
|
147
|
+
except BaseException:
|
|
148
|
+
try:
|
|
149
|
+
temporary.unlink()
|
|
150
|
+
except OSError:
|
|
151
|
+
pass
|
|
152
|
+
raise
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def made_by_openprocess(path: str | Path) -> bool:
|
|
156
|
+
"""True when a ``.cpn`` file was written by OpenProcess (not by CPN Tools, say)."""
|
|
157
|
+
try:
|
|
158
|
+
with open(path, "rb") as handle:
|
|
159
|
+
head = handle.read(4096)
|
|
160
|
+
except OSError:
|
|
161
|
+
return False
|
|
162
|
+
return b'tool="OpenProcess"' in head
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def display_name(relative: str) -> str:
|
|
166
|
+
"""The name shown in the sidebar: the path inside the folder, without extension.
|
|
167
|
+
|
|
168
|
+
``"order handling.pnml"`` → ``"order handling"``;
|
|
169
|
+
``"logs/boarding.xes.gz"`` → ``"logs/boarding"``.
|
|
170
|
+
"""
|
|
171
|
+
lower = relative.lower()
|
|
172
|
+
base = lower.rpartition("/")[2]
|
|
173
|
+
if base in TEXT_KINDS: # "log.txt" → "log"
|
|
174
|
+
return relative[:-4]
|
|
175
|
+
extensions = list(KINDS) + list(COMPOUND_SUFFIXES)
|
|
176
|
+
for extension in sorted(extensions, key=len, reverse=True): # ".xes.gz" before ".gz"
|
|
177
|
+
if lower.endswith(extension):
|
|
178
|
+
return relative[: len(relative) - len(extension)]
|
|
179
|
+
return relative
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class WorkspaceFile:
|
|
184
|
+
"""One file in the workspace that the app can open."""
|
|
185
|
+
|
|
186
|
+
path: Path # absolute
|
|
187
|
+
relative: str # inside the folder, with "/" separators
|
|
188
|
+
kind: str # "log", "petri", "cpn" or "ts"
|
|
189
|
+
#: Only in iCloud for now ("Optimise Mac Storage"): ``path`` does not exist
|
|
190
|
+
#: yet, a placeholder (:attr:`cloud_placeholder`) stands in for it.
|
|
191
|
+
in_cloud: bool = False
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def name(self) -> str:
|
|
195
|
+
return display_name(self.relative)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def cloud_placeholder(self) -> Path:
|
|
199
|
+
return self.path.with_name(f".{self.path.name}{ICLOUD_SUFFIX}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass
|
|
203
|
+
class WorkspaceFolder:
|
|
204
|
+
"""A folder in the workspace (the workspace's own folder too), with its contents."""
|
|
205
|
+
|
|
206
|
+
path: Path
|
|
207
|
+
relative: str # "" for the workspace's own folder
|
|
208
|
+
folders: list["WorkspaceFolder"] = field(default_factory=list)
|
|
209
|
+
files: list[WorkspaceFile] = field(default_factory=list)
|
|
210
|
+
#: True (on the top folder) when :data:`MAX_FILES` or :data:`MAX_FOLDERS`
|
|
211
|
+
#: cut the listing short.
|
|
212
|
+
truncated: bool = False
|
|
213
|
+
#: The folder is an exercise (see :func:`exercise_files`).
|
|
214
|
+
exercise: bool = False
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def name(self) -> str:
|
|
218
|
+
return self.path.name
|
|
219
|
+
|
|
220
|
+
def walk(self) -> Iterator["WorkspaceFolder"]:
|
|
221
|
+
"""This folder, then every subfolder (depth first, in order)."""
|
|
222
|
+
yield self
|
|
223
|
+
for folder in self.folders:
|
|
224
|
+
yield from folder.walk()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class Workspace:
|
|
228
|
+
"""A folder opened as a workspace."""
|
|
229
|
+
|
|
230
|
+
def __init__(self, folder: str | Path) -> None:
|
|
231
|
+
self.folder = Path(folder).expanduser().resolve()
|
|
232
|
+
self._carry_over_old_files()
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def name(self) -> str:
|
|
236
|
+
return self.folder.name or str(self.folder)
|
|
237
|
+
|
|
238
|
+
def exists(self) -> bool:
|
|
239
|
+
return self.folder.is_dir()
|
|
240
|
+
|
|
241
|
+
# -- the files ---------------------------------------------------------------
|
|
242
|
+
def tree(self) -> WorkspaceFolder:
|
|
243
|
+
"""The folder's subfolders and the files the app can open in them.
|
|
244
|
+
|
|
245
|
+
Folders come before files, both sorted by name (as Finder's list
|
|
246
|
+
view does), and empty folders are included, so a folder just made
|
|
247
|
+
shows up before anything is put in it. Hidden files and folders
|
|
248
|
+
(starting with ".") are skipped, as Finder does, and so are the folders
|
|
249
|
+
in :data:`SKIPPED_FOLDERS` and anything deeper than :data:`MAX_DEPTH`.
|
|
250
|
+
Files that are only in iCloud are listed with ``in_cloud=True``.
|
|
251
|
+
"""
|
|
252
|
+
root = WorkspaceFolder(self.folder, "")
|
|
253
|
+
budget = {"files": MAX_FILES, "folders": MAX_FOLDERS}
|
|
254
|
+
|
|
255
|
+
def fill(folder: WorkspaceFolder, depth: int) -> None:
|
|
256
|
+
try:
|
|
257
|
+
entries = sorted(os.scandir(folder.path), key=lambda e: e.name.casefold())
|
|
258
|
+
except OSError:
|
|
259
|
+
return
|
|
260
|
+
names = {entry.name for entry in entries}
|
|
261
|
+
folder.exercise = _question_file(names) is not None
|
|
262
|
+
subfolders = []
|
|
263
|
+
for entry in entries:
|
|
264
|
+
name = entry.name
|
|
265
|
+
try:
|
|
266
|
+
is_folder = entry.is_dir()
|
|
267
|
+
except OSError:
|
|
268
|
+
continue
|
|
269
|
+
if is_folder:
|
|
270
|
+
if not name.startswith(".") and name not in SKIPPED_FOLDERS \
|
|
271
|
+
and depth < MAX_DEPTH:
|
|
272
|
+
subfolders.append(entry)
|
|
273
|
+
continue
|
|
274
|
+
in_cloud = False
|
|
275
|
+
if name.startswith("."):
|
|
276
|
+
# ".Week 2.xes.icloud" stands for "Week 2.xes", still in iCloud.
|
|
277
|
+
if not name.endswith(ICLOUD_SUFFIX) or name[1:-len(ICLOUD_SUFFIX)] in names:
|
|
278
|
+
continue
|
|
279
|
+
name, in_cloud = name[1:-len(ICLOUD_SUFFIX)], True
|
|
280
|
+
if not name or name.startswith("."):
|
|
281
|
+
continue
|
|
282
|
+
path = folder.path / name
|
|
283
|
+
kind = file_kind(path)
|
|
284
|
+
if kind is None:
|
|
285
|
+
continue
|
|
286
|
+
if folder.exercise and name.lower() in ANSWER_FILES:
|
|
287
|
+
continue # the model answer is not a file to open by accident
|
|
288
|
+
if budget["files"] <= 0:
|
|
289
|
+
root.truncated = True
|
|
290
|
+
return
|
|
291
|
+
budget["files"] -= 1
|
|
292
|
+
relative = path.relative_to(self.folder).as_posix()
|
|
293
|
+
folder.files.append(WorkspaceFile(path, relative, kind, in_cloud))
|
|
294
|
+
# A placeholder's own name (".Week 2.xes.icloud") sorted it first.
|
|
295
|
+
folder.files.sort(key=lambda f: f.path.name.casefold())
|
|
296
|
+
for entry in subfolders:
|
|
297
|
+
if budget["folders"] <= 0:
|
|
298
|
+
root.truncated = True
|
|
299
|
+
return
|
|
300
|
+
budget["folders"] -= 1
|
|
301
|
+
path = Path(entry.path)
|
|
302
|
+
child = WorkspaceFolder(path, path.relative_to(self.folder).as_posix())
|
|
303
|
+
folder.folders.append(child)
|
|
304
|
+
fill(child, depth + 1)
|
|
305
|
+
|
|
306
|
+
fill(root, 0)
|
|
307
|
+
return root
|
|
308
|
+
|
|
309
|
+
def files(self, tree: WorkspaceFolder | None = None) -> list[WorkspaceFile]:
|
|
310
|
+
"""Every file the app can open, by path (a folder's files before its subfolders).
|
|
311
|
+
|
|
312
|
+
iCloud placeholders are left out; see :meth:`tree` for those.
|
|
313
|
+
"""
|
|
314
|
+
tree = tree or self.tree()
|
|
315
|
+
return [f for folder in tree.walk() for f in folder.files if not f.in_cloud]
|
|
316
|
+
|
|
317
|
+
def directories(self, tree: WorkspaceFolder | None = None) -> list[Path]:
|
|
318
|
+
"""The folders whose changes matter (for a file system watcher)."""
|
|
319
|
+
return [folder.path for folder in (tree or self.tree()).walk()]
|
|
320
|
+
|
|
321
|
+
def contains(self, path: str | Path) -> bool:
|
|
322
|
+
try:
|
|
323
|
+
Path(path).resolve().relative_to(self.folder)
|
|
324
|
+
except (ValueError, OSError):
|
|
325
|
+
return False
|
|
326
|
+
return True
|
|
327
|
+
|
|
328
|
+
def relative(self, path: str | Path) -> str:
|
|
329
|
+
"""``path`` relative to the folder if it is inside it, else absolute."""
|
|
330
|
+
resolved = Path(path).resolve()
|
|
331
|
+
try:
|
|
332
|
+
return resolved.relative_to(self.folder).as_posix()
|
|
333
|
+
except ValueError:
|
|
334
|
+
return str(resolved)
|
|
335
|
+
|
|
336
|
+
def absolute(self, stored: str) -> Path:
|
|
337
|
+
"""The inverse of :meth:`relative`."""
|
|
338
|
+
path = Path(stored)
|
|
339
|
+
return path if path.is_absolute() else self.folder / path
|
|
340
|
+
|
|
341
|
+
def _carry_over_old_files(self) -> None:
|
|
342
|
+
"""A folder last opened by CPNpy keeps its state and notes under the new names."""
|
|
343
|
+
for old, new in OLD_FILES.items():
|
|
344
|
+
source, target = self.folder / old, self.folder / new
|
|
345
|
+
if source.is_file() and not target.exists():
|
|
346
|
+
try:
|
|
347
|
+
os.replace(source, target)
|
|
348
|
+
except OSError:
|
|
349
|
+
pass
|
|
350
|
+
|
|
351
|
+
# -- what was open -----------------------------------------------------------
|
|
352
|
+
@property
|
|
353
|
+
def state_path(self) -> Path:
|
|
354
|
+
return self.folder / STATE_FILE
|
|
355
|
+
|
|
356
|
+
def _read(self) -> dict[str, Any]:
|
|
357
|
+
try:
|
|
358
|
+
data = json.loads(self.state_path.read_text(encoding="utf-8"))
|
|
359
|
+
except (OSError, ValueError):
|
|
360
|
+
return {}
|
|
361
|
+
return data if isinstance(data, dict) else {}
|
|
362
|
+
|
|
363
|
+
def _write(self, data: dict[str, Any], create: bool = True) -> None:
|
|
364
|
+
"""Write the state file, unless it would not change. Never fails."""
|
|
365
|
+
data = {"about": "OpenProcess: the files that were open in this folder. Safe to delete.",
|
|
366
|
+
"version": 1, **{k: v for k, v in data.items() if k not in ("about", "version")}}
|
|
367
|
+
text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
368
|
+
try:
|
|
369
|
+
if self.state_path.exists():
|
|
370
|
+
if self.state_path.read_text(encoding="utf-8") == text:
|
|
371
|
+
return # unchanged: do not touch the file (or its sync)
|
|
372
|
+
elif not create:
|
|
373
|
+
return # nothing worth a file yet
|
|
374
|
+
self.state_path.write_text(text, encoding="utf-8")
|
|
375
|
+
except OSError:
|
|
376
|
+
pass # a read-only folder is just not written
|
|
377
|
+
|
|
378
|
+
def load_state(self) -> dict[str, Any]:
|
|
379
|
+
"""``{"open": [{"path": …, …options}], "selected": path | None}``; empty if none."""
|
|
380
|
+
data = self._read()
|
|
381
|
+
entries = [e for e in data.get("open", []) if isinstance(e, dict) and e.get("path")]
|
|
382
|
+
for entry in entries:
|
|
383
|
+
entry["path"] = str(self.absolute(entry["path"]))
|
|
384
|
+
selected = data.get("selected")
|
|
385
|
+
return {"open": entries,
|
|
386
|
+
"selected": str(self.absolute(selected)) if selected else None}
|
|
387
|
+
|
|
388
|
+
def save_state(self, entries: list[dict[str, Any]], selected: str | None) -> None:
|
|
389
|
+
"""Remember the open files. Never fails: a read-only folder just is not written."""
|
|
390
|
+
data = self._read()
|
|
391
|
+
data["open"] = [dict(entry, path=self.relative(entry["path"])) for entry in entries]
|
|
392
|
+
data["selected"] = self.relative(selected) if selected else None
|
|
393
|
+
self._write(data, create=bool(entries))
|
|
394
|
+
|
|
395
|
+
# -- choices made for this folder ----------------------------------------------
|
|
396
|
+
def settings(self) -> dict[str, Any]:
|
|
397
|
+
"""``{"view": "folder" | "kind", "expanded": [relative, …], "import": …}``."""
|
|
398
|
+
settings = self._read().get("settings", {})
|
|
399
|
+
return settings if isinstance(settings, dict) else {}
|
|
400
|
+
|
|
401
|
+
def update_settings(self, **changes: Any) -> None:
|
|
402
|
+
"""Change some settings (``None`` removes one)."""
|
|
403
|
+
data = self._read()
|
|
404
|
+
settings = dict(data.get("settings") or {})
|
|
405
|
+
for key, value in changes.items():
|
|
406
|
+
if value is None:
|
|
407
|
+
settings.pop(key, None)
|
|
408
|
+
else:
|
|
409
|
+
settings[key] = value
|
|
410
|
+
if settings == data.get("settings"):
|
|
411
|
+
return
|
|
412
|
+
data["settings"] = settings
|
|
413
|
+
data.setdefault("open", [])
|
|
414
|
+
data.setdefault("selected", None)
|
|
415
|
+
self._write(data)
|
|
416
|
+
|
|
417
|
+
# -- scratch notes -------------------------------------------------------------
|
|
418
|
+
@property
|
|
419
|
+
def notes_path(self) -> Path:
|
|
420
|
+
return self.folder / NOTES_FILE
|
|
421
|
+
|
|
422
|
+
def load_notes(self) -> str:
|
|
423
|
+
try:
|
|
424
|
+
return self.notes_path.read_text(encoding="utf-8")
|
|
425
|
+
except OSError:
|
|
426
|
+
return ""
|
|
427
|
+
|
|
428
|
+
def save_notes(self, text: str) -> None:
|
|
429
|
+
"""Keep the notes (an empty pad leaves no file behind). Raises OSError."""
|
|
430
|
+
if not text.strip():
|
|
431
|
+
try:
|
|
432
|
+
self.notes_path.unlink()
|
|
433
|
+
except FileNotFoundError:
|
|
434
|
+
pass
|
|
435
|
+
return
|
|
436
|
+
atomic_write(self.notes_path, lambda path: path.write_text(text, encoding="utf-8"))
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# ---------------------------------------------------------------------------
|
|
440
|
+
# Exercises
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
# An exercise is a folder with a question file; the details live with the
|
|
443
|
+
# rest of the exercise code (no Qt there, so the command line can use it).
|
|
444
|
+
from ...learn.pack import ( # noqa: E402,F401 - re-exported
|
|
445
|
+
ANSWER_FILES, MY_ANSWER, MY_ANSWERS, QUESTION_FILES, ExerciseFiles, _question_file,
|
|
446
|
+
exercise_files,
|
|
447
|
+
)
|