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