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,216 @@
|
|
|
1
|
+
"""Turning a past exam into a pack: the guided import.
|
|
2
|
+
|
|
3
|
+
Paste the exam's text (or give the file); :func:`split_questions` finds the
|
|
4
|
+
questions and their parts by their numbering, :func:`guess_type` picks an
|
|
5
|
+
answer block for each part from how it is worded, and :func:`write_pack`
|
|
6
|
+
writes the skeleton: one exercise folder per question with a
|
|
7
|
+
``question.md`` whose blocks are ready for the author to finish. Each
|
|
8
|
+
block carries a ``TODO`` where the author must act: drop in the net or the
|
|
9
|
+
log as a file, replace a guessed ``compute:`` with the right one, or write a
|
|
10
|
+
``solution:`` for an open question. ``openprocess exercises check`` then lists
|
|
11
|
+
what is still missing.
|
|
12
|
+
|
|
13
|
+
The import never computes answers itself: it cannot know the net or the log
|
|
14
|
+
the exam showed as a picture. Its job is the structure, so that the author
|
|
15
|
+
types nothing twice.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
#: The start of a question: "1.", "Question 2", "Q3", "2)" at the start of a line.
|
|
25
|
+
_QUESTION = re.compile(r"^\s*(?:question|q|exercise|opgave)?\s*(\d{1,2})\s*[.):]\s*(.*)$", re.I)
|
|
26
|
+
#: The start of a part: "a.", "(a)", "a)", "1a".
|
|
27
|
+
_PART = re.compile(r"^\s*\(?(?:\d{1,2})?([a-z])[.)]\s+(.*)$", re.I)
|
|
28
|
+
#: "(2 points)", "[3 pt]", "(1.5 punten)"
|
|
29
|
+
_POINTS = re.compile(r"[\(\[]\s*(\d+(?:[.,]\d+)?)\s*(?:points?|pts?|punten|p)\s*[\)\]]", re.I)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Part:
|
|
34
|
+
letter: str
|
|
35
|
+
text: str
|
|
36
|
+
points: float | None = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def kind(self) -> str:
|
|
40
|
+
return guess_type(self.text)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Question:
|
|
45
|
+
number: int
|
|
46
|
+
title: str
|
|
47
|
+
intro: str = ""
|
|
48
|
+
parts: list[Part] = field(default_factory=list)
|
|
49
|
+
points: float | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _points(text: str) -> tuple[float | None, str]:
|
|
53
|
+
match = _POINTS.search(text)
|
|
54
|
+
if not match:
|
|
55
|
+
return None, text
|
|
56
|
+
return float(match.group(1).replace(",", ".")), (text[:match.start()] + text[match.end():]).strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def split_questions(text: str) -> list[Question]:
|
|
60
|
+
"""The questions of an exam, each with its lettered parts."""
|
|
61
|
+
questions: list[Question] = []
|
|
62
|
+
current: Question | None = None
|
|
63
|
+
part: Part | None = None
|
|
64
|
+
for raw in text.splitlines():
|
|
65
|
+
line = raw.rstrip()
|
|
66
|
+
question = _QUESTION.match(line)
|
|
67
|
+
if question and (current is None or int(question.group(1)) == current.number + 1):
|
|
68
|
+
points, title = _points(question.group(2).strip())
|
|
69
|
+
current = Question(int(question.group(1)), title or f"Question {question.group(1)}",
|
|
70
|
+
points=points)
|
|
71
|
+
questions.append(current)
|
|
72
|
+
part = None
|
|
73
|
+
continue
|
|
74
|
+
if current is None:
|
|
75
|
+
continue
|
|
76
|
+
found = _PART.match(line)
|
|
77
|
+
expected = chr(ord(current.parts[-1].letter) + 1) if current.parts else "a"
|
|
78
|
+
if found and found.group(1).lower() == expected:
|
|
79
|
+
points, body = _points(found.group(2).strip())
|
|
80
|
+
part = Part(expected, body, points)
|
|
81
|
+
current.parts.append(part)
|
|
82
|
+
continue
|
|
83
|
+
if part is not None:
|
|
84
|
+
part.text = (part.text + "\n" + line).strip()
|
|
85
|
+
else:
|
|
86
|
+
current.intro = (current.intro + "\n" + line).strip()
|
|
87
|
+
return questions
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
#: Wording → block type, first match wins.
|
|
91
|
+
_CUES: list[tuple[re.Pattern, str]] = [
|
|
92
|
+
(re.compile(r"\b(draw|model|construct|design|complete|repair|change)\b.*\b(net|petri)", re.I | re.S), "net"),
|
|
93
|
+
(re.compile(r"\b(draw|give|construct)\b.*\b(reachability|marking) graph|transition system\b", re.I | re.S), "ts"),
|
|
94
|
+
(re.compile(r"\bfootprint\b|\brelation(s)? between\b|\b(causal|parallel|choice) relation", re.I), "footprint"),
|
|
95
|
+
(re.compile(r"\b(incidence|matrix|matrices)\b", re.I), "matrix"),
|
|
96
|
+
(re.compile(r"\balignment\b.*\b(optimal|sub-?optimal|not an alignment)", re.I | re.S), "choice"),
|
|
97
|
+
(re.compile(r"\b(give|write|construct)\b.*\balignment\b", re.I | re.S), "alignment"),
|
|
98
|
+
(re.compile(r"\bprocess tree\b", re.I), "tree"),
|
|
99
|
+
(re.compile(r"\b(cut)\b.*\binductive\b|\binductive\b.*\bcut\b", re.I | re.S), "cut"),
|
|
100
|
+
(re.compile(r"\b(sublog|split the log|projected log)\b", re.I), "log"),
|
|
101
|
+
(re.compile(r"\b(produced|consumed|missing|remaining)\b", re.I), "replay"),
|
|
102
|
+
(re.compile(r"\b(rank|order)\b.*\b(models?|nets?)\b", re.I | re.S), "ranking"),
|
|
103
|
+
(re.compile(r"\b(firing sequence|trace)\b.*\b(deadlock|dead marking|not in the log|never)", re.I | re.S), "trace"),
|
|
104
|
+
(re.compile(r"\b(reachable|terminal|dead) markings\b", re.I), "markings"),
|
|
105
|
+
(re.compile(r"\bmarking\b.*\b(after|reached|firing)", re.I | re.S), "marking"),
|
|
106
|
+
(re.compile(r"\(P\s*,\s*T\s*,\s*F|\bformalise\b|\bformalize\b|\bpreset\b|\bpostset\b", re.I), "tuple"),
|
|
107
|
+
(re.compile(r"\bwhich of the following\b|\bchoose\b|\bselect\b|\bmultiple[- ]choice\b", re.I), "choice"),
|
|
108
|
+
(re.compile(r"\bhow many\b|\bcompute\b|\bcalculate\b|\bfitness\b|\bnumber of\b|\bprobability\b", re.I), "number"),
|
|
109
|
+
(re.compile(r"\b(is|are|does|do|can|could|has|have|will)\b\s+(the|this|it|there|every|each|any)\b.*\?", re.I | re.S), "yesno"),
|
|
110
|
+
(re.compile(r"\b(true or false|yes or no)\b", re.I), "yesno"),
|
|
111
|
+
(re.compile(r"\b(give|list|which|what are|determine)\b.*\b(set|activities|transitions|places|states|regions|pairs)\b", re.I | re.S), "set"),
|
|
112
|
+
(re.compile(r"\b(explain|why|argue|motivate|describe|discuss)\b", re.I), "open"),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def guess_type(text: str) -> str:
|
|
117
|
+
"""The answer block a question's wording suggests (``open`` when unsure)."""
|
|
118
|
+
for pattern, kind in _CUES:
|
|
119
|
+
if pattern.search(text):
|
|
120
|
+
return kind
|
|
121
|
+
return "open"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_COMPUTE_HINT = {
|
|
125
|
+
"yesno": "compute: sound # TODO: the property asked (sound, live, bounded, dead(t)…)",
|
|
126
|
+
"number": "compute: fitness # TODO: the figure asked (fitness, deadlocks, bound, cases…)",
|
|
127
|
+
"set": "compute: alpha.T_I # TODO: the set asked (dead transitions, enabled([p1]), net.T…)",
|
|
128
|
+
"footprint": "pairs: (a, b), (b, c) # TODO: only the pairs the exam asks (or remove for all)",
|
|
129
|
+
"matrix": "compute: incidence # TODO: incidence, language.M or language.M'",
|
|
130
|
+
"ts": "compute: reachability graph",
|
|
131
|
+
"markings": "compute: reachable # TODO: reachable or terminal",
|
|
132
|
+
"marking": "compute: fire(a, [p1]) # TODO: the sequence and the start marking",
|
|
133
|
+
"tuple": "",
|
|
134
|
+
"tree": "compute: im.tree",
|
|
135
|
+
"cut": "compute: im.cut # TODO: im.cut(2) for a deeper step",
|
|
136
|
+
"log": "compute: im.split(1) # TODO: which part of which cut",
|
|
137
|
+
"replay": "",
|
|
138
|
+
"alignment": "trace: a, b, c # TODO: the trace to align",
|
|
139
|
+
"ranking": "over: m1.pnml, m2.pnml # TODO: the nets to rank, as files\nby: fitness",
|
|
140
|
+
"trace": "ends: deadlock # TODO: any, final, deadlock, stuck or improper",
|
|
141
|
+
"net": "answer: answer.pnml # TODO: draw the model answer and save it as answer.pnml\nsound: yes",
|
|
142
|
+
"choice": "- [ ] TODO: the first option\n- [ ] TODO: the second option",
|
|
143
|
+
"open": "solution: TODO: write the model answer here",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def block_for(part: Part) -> str:
|
|
148
|
+
"""The answer block (as text) for a part of a question."""
|
|
149
|
+
kind = part.kind
|
|
150
|
+
lines = [f"type: {kind}"]
|
|
151
|
+
hint = _COMPUTE_HINT.get(kind, "")
|
|
152
|
+
if hint:
|
|
153
|
+
lines.extend(hint.split("\n"))
|
|
154
|
+
if part.points is not None:
|
|
155
|
+
lines.append(f"points: {part.points:g}")
|
|
156
|
+
if kind != "open" and "solution" not in hint:
|
|
157
|
+
lines.append("solution: TODO: the grading scheme's answer, in words")
|
|
158
|
+
return "```answer\n" + "\n".join(lines) + "\n```"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def question_markdown(question: Question) -> str:
|
|
162
|
+
"""``question.md`` for one question of the exam."""
|
|
163
|
+
points = question.points if question.points is not None else \
|
|
164
|
+
(sum(p.points for p in question.parts if p.points) or None)
|
|
165
|
+
title = f"# {question.number} · {question.title}" + (f" ({points:g} points)" if points else "")
|
|
166
|
+
out = []
|
|
167
|
+
# The question's points, when its parts do not add up to them (none given,
|
|
168
|
+
# or a different total): front matter keeps the exam's total right.
|
|
169
|
+
if points and sum(p.points or 0 for p in question.parts) != points:
|
|
170
|
+
out += ["---", f"points: {points:g}", "---"]
|
|
171
|
+
out += [title, ""]
|
|
172
|
+
if question.intro:
|
|
173
|
+
out += [question.intro, "", "> **TODO:** the exam showed a net or a log here: recreate it as "
|
|
174
|
+
"`net.pnml` / `log.txt` in this folder so the app can compute the answers.", ""]
|
|
175
|
+
for part in question.parts:
|
|
176
|
+
out += [f"**{part.letter}.** {part.text}", "", block_for(part), ""]
|
|
177
|
+
if not question.parts:
|
|
178
|
+
out += [block_for(Part("a", question.intro or question.title)), ""]
|
|
179
|
+
return "\n".join(out).rstrip() + "\n"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def write_pack(text: str, target: str | Path, title: str = "Exam") -> list[Path]:
|
|
183
|
+
"""Write the skeleton pack for an exam's text; the files written."""
|
|
184
|
+
target = Path(target)
|
|
185
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
questions = split_questions(text)
|
|
187
|
+
if not questions:
|
|
188
|
+
raise ValueError("no questions found: number them 1., 2., … with parts a., b., …")
|
|
189
|
+
written = []
|
|
190
|
+
pack_file = target / "pack.md"
|
|
191
|
+
if not pack_file.exists():
|
|
192
|
+
total = sum(q.points or sum(p.points for p in q.parts if p.points) or 0 for q in questions)
|
|
193
|
+
pack_file.write_text("---\nexam: yes\ntime: 180\n---\n" + f"# {title}\n\n"
|
|
194
|
+
f"{len(questions)} questions" + (f", {total:g} points" if total else "")
|
|
195
|
+
+ ". Made with *Make a pack from an exam*: every `TODO` in the "
|
|
196
|
+
"questions is for the author to finish, then run "
|
|
197
|
+
"`openprocess exercises check` on this folder.\n", encoding="utf-8")
|
|
198
|
+
written.append(pack_file)
|
|
199
|
+
for question in questions:
|
|
200
|
+
name = re.sub(r"[^\w\s\-·]+", "", question.title).strip() or f"Question {question.number}"
|
|
201
|
+
folder = target / f"{question.number} {name[:60]}"
|
|
202
|
+
folder.mkdir(exist_ok=True)
|
|
203
|
+
path = folder / "question.md"
|
|
204
|
+
path.write_text(question_markdown(question), encoding="utf-8")
|
|
205
|
+
written.append(path)
|
|
206
|
+
return written
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def todo_list(target: str | Path) -> list[str]:
|
|
210
|
+
"""Every ``TODO`` left in a pack, with its file and line."""
|
|
211
|
+
found = []
|
|
212
|
+
for path in sorted(Path(target).rglob("question.md")):
|
|
213
|
+
for number, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
|
|
214
|
+
if "TODO" in line:
|
|
215
|
+
found.append(f"{path.parent.name}/question.md:{number}: {line.strip()}")
|
|
216
|
+
return found
|