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,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
+ )