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,511 @@
1
+ """Exercise packs: folders of exercises, and the student's progress in them.
2
+
3
+ A *pack* is an ordinary folder::
4
+
5
+ Process Mining — Week 3/
6
+ pack.md optional: the pack's title and introduction
7
+ 1 Petri nets/ optional: chapters are just subfolders
8
+ Exercise 1.1 Order handling/
9
+ question.md the worksheet (see sheet.py)
10
+ answer.pnml files the answer blocks refer to
11
+ 2 Discovery/
12
+ Exercise 2.1 The alpha-algorithm/
13
+ question.md
14
+ log.txt a given log, in the course's notation
15
+
16
+ Any folder with a ``question.md`` (or ``question.pdf`` / ``.png``) is an
17
+ exercise. Exercises and chapters are listed in name order, numbers sorted
18
+ as numbers (Exercise 2 before Exercise 10).
19
+
20
+ The student's work is stored next to the question: ``my answers.json``
21
+ (every typed answer and whether it was right), ``my answer.pnml`` for a
22
+ drawn net, ``my workflow.cpnflow`` for a built workflow, and ``my notes.md``
23
+ for scratch notes. Delete those files to start an exercise again.
24
+
25
+ ``pack.md`` may start with front matter (see :mod:`.sheet`): ``exam: yes``
26
+ makes the pack an exam (no hints, no *Show answer*, a ``time:`` in
27
+ minutes and a ``deadline:``), ``seed: student`` gives every student their
28
+ own variant, ``boxes:`` lists the boxes a workflow in the pack may use.
29
+ The student's name for variants is kept in the pack as ``my name.txt``.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import re
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+
40
+ from .sheet import Sheet, SheetError, Task, parse_sheet
41
+
42
+ #: The question, in the order the files are looked for.
43
+ QUESTION_FILES = ("question.md", "question.pdf", "question.png", "question.jpg",
44
+ "question.jpeg", "question.txt")
45
+ #: Files that hold the answer (left out of the sidebar in an exercise).
46
+ ANSWER_FILES = {"answer.pnml", "answer.md"}
47
+ #: The file OpenProcess creates for your own net.
48
+ MY_ANSWER = "my answer.pnml"
49
+ #: Your typed answers and how they did.
50
+ MY_ANSWERS = "my answers.json"
51
+ #: Your scratch notes (the Notes pane).
52
+ MY_NOTES = "my notes.md"
53
+ #: Your workflow, when an exercise asks for one.
54
+ MY_WORKFLOW = "my workflow.cpnflow"
55
+ #: The optional title page of a pack.
56
+ PACK_FILE = "pack.md"
57
+ #: The student's name, for variants (in the pack's folder).
58
+ MY_NAME = "my name.txt"
59
+ #: When an exam was started (in the pack's folder).
60
+ MY_EXAM = "my exam.json"
61
+
62
+ MAX_DEPTH = 3
63
+
64
+
65
+ def natural_key(text: str) -> list:
66
+ """Sort "Exercise 2" before "Exercise 10"."""
67
+ return [int(part) if part.isdigit() else part.casefold()
68
+ for part in re.split(r"(\d+)", text)]
69
+
70
+
71
+ def _question_file(names) -> str | None:
72
+ lower = {name.lower(): name for name in names}
73
+ for candidate in QUESTION_FILES:
74
+ if candidate in lower:
75
+ return lower[candidate]
76
+ return None
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class ExerciseFiles:
81
+ """The parts of an exercise folder (None: the folder has no such file).
82
+
83
+ =================== ================================================
84
+ ``question`` ``question.md`` (or ``.pdf`` / ``.png``): the worksheet
85
+ ``net`` ``net.pnml``: a given net
86
+ ``log`` ``log.xes``, ``log.csv`` or ``log.txt`` (textbook notation)
87
+ ``ts`` ``ts.txt``: a given transition system
88
+ ``answer_net`` ``answer.pnml``: a model answer net
89
+ ``answer_text`` ``answer.md``: the worked answer, hidden until asked for
90
+ ``my_answer`` ``my answer.pnml``: your net (it may not exist yet)
91
+ =================== ================================================
92
+ """
93
+
94
+ folder: Path
95
+ question: Path
96
+ net: Path | None = None
97
+ log: Path | None = None
98
+ ts: Path | None = None
99
+ answer_net: Path | None = None
100
+ answer_text: Path | None = None
101
+
102
+ @property
103
+ def name(self) -> str:
104
+ return self.folder.name
105
+
106
+ @property
107
+ def my_answer(self) -> Path:
108
+ return self.folder / MY_ANSWER
109
+
110
+ @property
111
+ def needs_a_net(self) -> bool:
112
+ """Is drawing (or editing) a net part of the exercise?"""
113
+ return self.net is not None or self.answer_net is not None
114
+
115
+ def file(self, name: str) -> Path | None:
116
+ """A file of the exercise named in an answer block (None: not there)."""
117
+ path = (self.folder / name).resolve()
118
+ try:
119
+ path.relative_to(self.folder.resolve())
120
+ except ValueError:
121
+ return None # only files inside the exercise
122
+ return path if path.is_file() else None
123
+
124
+
125
+ def exercise_files(folder: str | Path) -> ExerciseFiles | None:
126
+ """The exercise in ``folder``, or None when it has no ``question`` file."""
127
+ folder = Path(folder)
128
+ try:
129
+ names = [entry.name for entry in os.scandir(folder) if entry.is_file()]
130
+ except OSError:
131
+ return None
132
+ question = _question_file(names)
133
+ if question is None:
134
+ return None
135
+ lower = {name.lower(): folder / name for name in names}
136
+
137
+ def first(*candidates: str) -> Path | None:
138
+ return next((lower[c] for c in candidates if c in lower), None)
139
+
140
+ return ExerciseFiles(folder, folder / question, net=first("net.pnml"),
141
+ log=first("log.xes", "log.xes.gz", "log.csv", "log.txt"),
142
+ ts=first("ts.txt"), answer_net=first("answer.pnml"),
143
+ answer_text=first("answer.md", "answer.txt"))
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # One exercise
148
+ # ---------------------------------------------------------------------------
149
+ class Exercise:
150
+ """An exercise folder with its worksheet."""
151
+
152
+ def __init__(self, files: ExerciseFiles, pack_settings: dict | None = None) -> None:
153
+ self.files = files
154
+ self.folder = files.folder
155
+ self.error: str | None = None
156
+ #: The pack's front matter (exam, seed, boxes…), for settings the sheet does not give.
157
+ self.pack_settings: dict[str, str] = dict(pack_settings or {})
158
+ self.sheet = self._read_sheet()
159
+
160
+ def setting(self, key: str, default: str | None = None) -> str | None:
161
+ """A setting of the exercise's front matter, else of the pack's."""
162
+ value = self.sheet.settings.get(key)
163
+ if value is None:
164
+ value = self.pack_settings.get(key)
165
+ return default if value is None else value
166
+
167
+ def student(self) -> str | None:
168
+ """The student's name (``my name.txt`` in the pack), for variants."""
169
+ for folder in [self.folder, *self.folder.parents]:
170
+ path = folder / MY_NAME
171
+ if path.is_file():
172
+ try:
173
+ return path.read_text(encoding="utf-8").strip()
174
+ except OSError:
175
+ return None
176
+ if (folder / PACK_FILE).is_file():
177
+ break
178
+ return None
179
+
180
+ @property
181
+ def points(self) -> float:
182
+ """The points of the whole exercise (its blocks', or ``points:`` in front)."""
183
+ front = self.sheet.settings.get("points")
184
+ if front:
185
+ try:
186
+ return float(front)
187
+ except ValueError:
188
+ pass
189
+ return self.sheet.points
190
+
191
+ @classmethod
192
+ def at(cls, folder: str | Path) -> "Exercise | None":
193
+ files = exercise_files(folder)
194
+ return cls(files) if files is not None else None
195
+
196
+ @property
197
+ def title(self) -> str:
198
+ return self.sheet.title if self.sheet.title != "Exercise" else self.folder.name
199
+
200
+ def _read_sheet(self) -> Sheet:
201
+ question = self.files.question
202
+ if question.suffix.lower() == ".md":
203
+ try:
204
+ sheet = parse_sheet(question.read_text(encoding="utf-8", errors="replace"))
205
+ except SheetError as error:
206
+ self.error = f"{question.name}: {error}"
207
+ sheet = Sheet(self.folder.name, [question.read_text(
208
+ encoding="utf-8", errors="replace")])
209
+ elif question.suffix.lower() == ".txt":
210
+ sheet = Sheet(self.folder.name, ["```\n" + question.read_text(
211
+ encoding="utf-8", errors="replace") + "\n```"])
212
+ else:
213
+ sheet = Sheet(self.folder.name, []) # a PDF or picture: shown as it is
214
+ if not sheet.tasks:
215
+ parts = split_parts(sheet.blocks[0]) if len(sheet.blocks) == 1 else []
216
+ if len(parts) >= 2:
217
+ sheet.blocks = self._boxes_for_parts(parts)
218
+ else:
219
+ sheet.blocks += self._tasks_from_files()
220
+ return sheet
221
+
222
+ def _boxes_for_parts(self, parts: list[tuple[str, str]]) -> list:
223
+ """An exercise written before answer blocks, in lettered parts (a., b., …):
224
+ a box under each part. The part that asks for a net to be drawn or
225
+ changed gets the net editor; the others a text box, with that part of
226
+ ``answer.md`` as its model answer."""
227
+ files = self.files
228
+ solutions = {}
229
+ if files.answer_text is not None:
230
+ text = files.answer_text.read_text(encoding="utf-8", errors="replace")
231
+ solutions = {letter: body for letter, body in split_parts(text) if letter}
232
+ has_net = files.net is not None or files.answer_net is not None
233
+ drawing = [letter for letter, body in parts if letter and DRAWING.search(body)]
234
+ net_part = drawing[-1] if has_net and drawing else None
235
+ blocks: list = []
236
+ for letter, body in parts:
237
+ blocks.append(body)
238
+ if not letter:
239
+ continue
240
+ if letter == net_part:
241
+ settings = {"answer": files.answer_net.name} if files.answer_net else {}
242
+ if letter in solutions:
243
+ settings["solution"] = solutions[letter]
244
+ blocks.append(Task("net", letter, settings=settings))
245
+ else:
246
+ settings = {"solution": solutions[letter]} if letter in solutions else {}
247
+ blocks.append(Task("open", letter, settings=settings))
248
+ if has_net and net_part is None:
249
+ blocks += self._tasks_from_files()[:1]
250
+ return blocks
251
+
252
+ def _tasks_from_files(self) -> list[Task]:
253
+ """An exercise written before answer blocks: a net to draw if there is
254
+ an answer net, else one box for your answer with ``answer.md`` behind it."""
255
+ files = self.files
256
+ if files.answer_net is not None:
257
+ return [Task("net", "net", settings={"answer": files.answer_net.name})]
258
+ if files.net is not None:
259
+ return [Task("net", "net", settings={})]
260
+ if files.answer_text is not None:
261
+ return [Task("open", "answer", settings={
262
+ "solution": files.answer_text.read_text(encoding="utf-8", errors="replace")})]
263
+ return []
264
+
265
+ # -- your work ---------------------------------------------------------------------------
266
+ def net_tasks(self) -> list[Task]:
267
+ return [t for t in self.sheet.tasks if t.type == "net"]
268
+
269
+ @property
270
+ def workflow_path(self) -> Path:
271
+ """Where the workflow you build is saved."""
272
+ return self.folder / MY_WORKFLOW
273
+
274
+ def answer_net_path(self, task: Task) -> Path:
275
+ """Where your net for ``task`` is saved: ``my answer.pnml``, and for a second
276
+ net in the same exercise ``my answer (d).pnml``."""
277
+ nets = self.net_tasks()
278
+ if not nets or task is nets[0]:
279
+ return self.folder / MY_ANSWER
280
+ return self.folder / f"my answer ({task.id}).pnml"
281
+
282
+ def start_net(self, task: Task) -> Path | None:
283
+ """The net ``task`` starts from: its ``start:`` file, else the given net."""
284
+ start = task.get("start")
285
+ if start is not None:
286
+ return None if start.lower() in ("empty", "") else self.files.file(start)
287
+ return self.files.net
288
+
289
+ @property
290
+ def progress_file(self) -> Path:
291
+ return self.folder / MY_ANSWERS
292
+
293
+ def load_progress(self) -> dict:
294
+ try:
295
+ data = json.loads(self.progress_file.read_text(encoding="utf-8"))
296
+ except (OSError, ValueError):
297
+ return {}
298
+ tasks = data.get("tasks", {}) if isinstance(data, dict) else {}
299
+ return tasks if isinstance(tasks, dict) else {}
300
+
301
+ def save_progress(self, tasks: dict) -> None:
302
+ text = json.dumps({"version": 1, "exercise": self.title, "tasks": tasks},
303
+ ensure_ascii=False, indent=2)
304
+ temporary = self.progress_file.with_name("." + MY_ANSWERS + ".tmp")
305
+ temporary.write_text(text, encoding="utf-8")
306
+ os.replace(temporary, self.progress_file)
307
+
308
+ @property
309
+ def notes_file(self) -> Path:
310
+ return self.folder / MY_NOTES
311
+
312
+ def load_notes(self) -> str:
313
+ try:
314
+ return self.notes_file.read_text(encoding="utf-8")
315
+ except OSError:
316
+ return ""
317
+
318
+ def save_notes(self, text: str) -> None:
319
+ """Keep the notes (an empty pad leaves no file behind)."""
320
+ if not text.strip():
321
+ try:
322
+ self.notes_file.unlink()
323
+ except OSError:
324
+ pass
325
+ return
326
+ temporary = self.notes_file.with_name("." + MY_NOTES + ".tmp")
327
+ temporary.write_text(text, encoding="utf-8")
328
+ os.replace(temporary, self.notes_file)
329
+
330
+ def summary(self, progress: dict | None = None) -> "ProgressSummary":
331
+ progress = self.load_progress() if progress is None else progress
332
+ tasks = self.sheet.tasks
333
+ done = [t for t in tasks if progress.get(t.id, {}).get("status")
334
+ in ("correct", "done")]
335
+ tried = [t for t in tasks if progress.get(t.id, {}).get("status")]
336
+ earned = 0.0
337
+ for task in tasks:
338
+ entry = progress.get(task.id, {})
339
+ if entry.get("status") == "correct":
340
+ earned += task.points
341
+ elif entry.get("status") == "done":
342
+ earned += task.points
343
+ elif entry.get("status") == "partial":
344
+ earned += task.points * float(entry.get("share", 0) or 0)
345
+ return ProgressSummary(len(tasks), len(done), len(tried), self.points, earned)
346
+
347
+ def reset(self) -> None:
348
+ """Start again: your answers, nets, workflow and notes go."""
349
+ for path in [self.progress_file, self.notes_file, self.workflow_path] + \
350
+ [self.answer_net_path(t) for t in self.net_tasks()]:
351
+ try:
352
+ path.unlink()
353
+ except OSError:
354
+ pass
355
+
356
+
357
+ @dataclass
358
+ class ProgressSummary:
359
+ total: int
360
+ done: int
361
+ tried: int
362
+ #: The exercise's points, and what the answers so far earn.
363
+ points: float = 0.0
364
+ earned: float = 0.0
365
+
366
+ @property
367
+ def state(self) -> str:
368
+ """``"done"``, ``"started"`` or ``"new"``."""
369
+ if self.total and self.done == self.total:
370
+ return "done"
371
+ return "started" if self.tried else "new"
372
+
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # A pack
376
+ # ---------------------------------------------------------------------------
377
+ @dataclass
378
+ class Pack:
379
+ root: Path
380
+ title: str
381
+ intro: str = ""
382
+ exercises: list[Exercise] = field(default_factory=list)
383
+ #: The pack's front matter: exam, time, deadline, seed, generate, boxes.
384
+ settings: dict[str, str] = field(default_factory=dict)
385
+
386
+ @property
387
+ def is_exam(self) -> bool:
388
+ return (self.settings.get("exam") or "").strip().lower() in ("yes", "true", "1", "on")
389
+
390
+ @property
391
+ def points(self) -> float:
392
+ return sum(e.points for e in self.exercises)
393
+
394
+ @property
395
+ def name_file(self) -> Path:
396
+ return self.root / MY_NAME
397
+
398
+ def student(self) -> str | None:
399
+ try:
400
+ return self.name_file.read_text(encoding="utf-8").strip() or None
401
+ except OSError:
402
+ return None
403
+
404
+ def set_student(self, name: str) -> None:
405
+ self.name_file.write_text(name.strip() + "\n", encoding="utf-8")
406
+
407
+ @property
408
+ def wants_name(self) -> bool:
409
+ """Does the pack vary per student (so it needs the student's name)?"""
410
+ seed = (self.settings.get("seed") or "").strip().lower()
411
+ return seed in ("student", "name", "per student") or any(
412
+ (e.sheet.settings.get("seed") or "").strip().lower() in ("student", "name", "per student")
413
+ for e in self.exercises)
414
+
415
+ def chapter(self, exercise: Exercise) -> str:
416
+ """The subfolder the exercise is in ("" at the top of the pack)."""
417
+ relative = exercise.folder.resolve().relative_to(self.root.resolve())
418
+ return " › ".join(relative.parts[:-1])
419
+
420
+ def chapters(self) -> list[tuple[str, list[Exercise]]]:
421
+ grouped: list[tuple[str, list[Exercise]]] = []
422
+ for exercise in self.exercises:
423
+ name = self.chapter(exercise)
424
+ if not grouped or grouped[-1][0] != name:
425
+ grouped.append((name, []))
426
+ grouped[-1][1].append(exercise)
427
+ return grouped
428
+
429
+ def index(self, folder: str | Path) -> int | None:
430
+ key = Path(folder).resolve()
431
+ return next((i for i, e in enumerate(self.exercises) if e.folder.resolve() == key),
432
+ None)
433
+
434
+
435
+ #: The start of a lettered part: "a.", "a)", "**a.**", "(a)".
436
+ PART = re.compile(r"^\s*(?:\*\*)?\(?([a-h])[.)](?:\*\*)?\s+", re.M)
437
+ #: A part that asks for a net to be drawn or changed.
438
+ DRAWING = re.compile(r"\b(draw|change|repair|fix|model|construct)\b.*\bnet\b|"
439
+ r"\bnet\b.*\b(draw|change|repair|fix)\b", re.I | re.S)
440
+
441
+
442
+ def split_parts(text: str) -> list[tuple[str, str]]:
443
+ """Markdown split at its lettered parts: ``[("", intro), ("a", "a. …"), …]``.
444
+ A part starts a paragraph with its letter; the letters must run a, b, c…"""
445
+ paragraphs = re.split(r"(\n\s*\n)", text)
446
+ parts: list[tuple[str, str]] = [("", "")]
447
+ expected = "a"
448
+ for paragraph in paragraphs:
449
+ match = PART.match(paragraph)
450
+ if match and match.group(1) == expected:
451
+ parts.append((expected, paragraph))
452
+ expected = chr(ord(expected) + 1)
453
+ else:
454
+ letter, body = parts[-1]
455
+ parts[-1] = (letter, body + paragraph)
456
+ parts = [(letter, body.strip()) for letter, body in parts]
457
+ if not parts[0][1]:
458
+ parts = parts[1:]
459
+ return parts if len(parts) > 1 or (parts and parts[0][0]) else []
460
+
461
+
462
+ def find_exercises(root: Path, depth: int = 0) -> list[Path]:
463
+ if exercise_files(root) is not None:
464
+ return [root]
465
+ if depth >= MAX_DEPTH:
466
+ return []
467
+ try:
468
+ folders = sorted((p for p in root.iterdir() if p.is_dir()
469
+ and not p.name.startswith(".")), key=lambda p: natural_key(p.name))
470
+ except OSError:
471
+ return []
472
+ found: list[Path] = []
473
+ for folder in folders:
474
+ found += find_exercises(folder, depth + 1)
475
+ return found
476
+
477
+
478
+ def pack_root(folder: str | Path, boundary: str | Path | None = None) -> Path:
479
+ """The pack an exercise belongs to: the nearest folder above it with a
480
+ ``pack.md``; else ``boundary`` (the folder open in the app) when the
481
+ exercise is inside it; else the exercise's chapter folder."""
482
+ folder = Path(folder).resolve()
483
+ for parent in [folder] + list(folder.parents):
484
+ if (parent / PACK_FILE).is_file():
485
+ return parent
486
+ if boundary is not None and parent == Path(boundary).resolve():
487
+ return parent
488
+ return folder.parent if exercise_files(folder) is not None else folder
489
+
490
+
491
+ def load_pack(root: str | Path) -> Pack:
492
+ from .sheet import parse_front_matter
493
+ root = Path(root).resolve()
494
+ title, intro, settings = root.name, "", {}
495
+ for name in (PACK_FILE, "README.md"):
496
+ path = root / name
497
+ if path.is_file():
498
+ text = path.read_text(encoding="utf-8", errors="replace")
499
+ try:
500
+ settings, text = parse_front_matter(text)
501
+ except SheetError:
502
+ settings = {}
503
+ lines = text.strip().splitlines()
504
+ if lines and lines[0].startswith("# "):
505
+ title, intro = lines[0][2:].strip(), "\n".join(lines[1:]).strip()
506
+ elif name == PACK_FILE:
507
+ intro = text.strip()
508
+ break
509
+ title = settings.get("title") or title
510
+ exercises = [Exercise(exercise_files(folder), settings) for folder in find_exercises(root)]
511
+ return Pack(root, title, intro, exercises, settings)