python-interactive-courses 1.0.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.
- pyics/__init__.py +0 -0
- pyics/app/actions.py +179 -0
- pyics/app/cli.py +335 -0
- pyics/app/key_listener.py +133 -0
- pyics/app/prompts.py +439 -0
- pyics/app/pyics.py +454 -0
- pyics/data/__init__.py +4 -0
- pyics/data/course.py +531 -0
- pyics/data/task.py +744 -0
- pyics/distribution/__init__.py +5 -0
- pyics/distribution/archive.py +667 -0
- pyics/distribution/dependencies.py +452 -0
- pyics/distribution/fetch.py +128 -0
- pyics/distribution/pull.py +376 -0
- pyics/distribution/slugs.py +24 -0
- pyics/distribution/sources.py +561 -0
- pyics/notebook/__init__.py +1 -0
- pyics/notebook/cells.py +184 -0
- pyics/notebook/loader.py +590 -0
- pyics/notebook/sections.py +189 -0
- pyics/plugins.py +90 -0
- pyics/scripts/console.py +86 -0
- pyics/scripts/create_course.py +301 -0
- pyics/scripts/create_task.py +850 -0
- pyics/scripts/edit_course.py +133 -0
- pyics/scripts/edit_task.py +109 -0
- pyics/scripts/main.py +118 -0
- pyics/scripts/pack.py +198 -0
- pyics/scripts/pull.py +123 -0
- pyics/scripts/sources.py +118 -0
- pyics/tests/__init__.py +3 -0
- pyics/tests/capture.py +114 -0
- pyics/tests/check_specs.py +528 -0
- pyics/tests/check_structure.py +769 -0
- pyics/tests/compare.py +184 -0
- pyics/tests/pytest_integration.py +228 -0
- pyics/tests/results.py +145 -0
- pyics/tests/tests.py +440 -0
- pyics/tests/trace.py +156 -0
- pyics/util/discovery.py +92 -0
- pyics/util/json_handling.py +182 -0
- pyics/util/logging.py +39 -0
- pyics/util/paths.py +38 -0
- pyics/util/terminal.py +57 -0
- python_interactive_courses-1.0.0.dist-info/METADATA +245 -0
- python_interactive_courses-1.0.0.dist-info/RECORD +49 -0
- python_interactive_courses-1.0.0.dist-info/WHEEL +4 -0
- python_interactive_courses-1.0.0.dist-info/entry_points.txt +3 -0
- python_interactive_courses-1.0.0.dist-info/licenses/LICENSE +21 -0
pyics/data/course.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from fractions import Fraction
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from rich.console import Group, RenderableType
|
|
11
|
+
from rich.padding import Padding
|
|
12
|
+
from rich.rule import Rule
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from pyics.data.task import Task
|
|
17
|
+
from pyics.distribution.dependencies import ModulePolicy, manifest_requirements
|
|
18
|
+
from pyics.util import json_handling as json
|
|
19
|
+
from pyics.util import paths
|
|
20
|
+
from pyics.util.discovery import COURSE_SUFFIX
|
|
21
|
+
from pyics.util.paths import StrPath
|
|
22
|
+
from pyics.util.logging import get_logger
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Optional fields of a course manifest, which describe the course when it is distributed
|
|
26
|
+
# (see ``pyics.distribution``): ``slug`` (the name it is pulled by), ``version``,
|
|
27
|
+
# ``description``, ``tags``, the packages it needs besides pyics (``requires``, PEP 508
|
|
28
|
+
# requirements) and the pyics version it needs (``requires_pyics``).
|
|
29
|
+
METADATA_KEYS = ("slug", "version", "description", "tags", "requires", "requires_pyics")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _hints_view(hints: list[str], total: int) -> RenderableType:
|
|
33
|
+
"""The revealed ``hints`` of a task with ``total`` hints, numbered below a rule."""
|
|
34
|
+
grid = Table.grid(padding=(0, 1))
|
|
35
|
+
grid.add_column(justify="right", style="bright_yellow")
|
|
36
|
+
grid.add_column()
|
|
37
|
+
for i, hint in enumerate(hints, 1):
|
|
38
|
+
grid.add_row(f"{i}.", Text(hint))
|
|
39
|
+
rule = Rule(f"[bright_yellow]Hints {len(hints)}/{total}[/]", style="bright_yellow")
|
|
40
|
+
return Padding(Group(rule, grid), pad=(0, 0, 1, 0))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Course:
|
|
44
|
+
"""
|
|
45
|
+
Course class. Handles creation and storage of courses, as well as safe task loading
|
|
46
|
+
and keeping track of course progress.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
tasks: dict[str, StrPath]
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
name: str,
|
|
56
|
+
tasks: dict[str, StrPath],
|
|
57
|
+
progress: list[int],
|
|
58
|
+
requires: Sequence[str] | None = None,
|
|
59
|
+
check_progress: bool = False,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
name: str
|
|
65
|
+
Name of the course.
|
|
66
|
+
tasks: dict[str, StrPath]
|
|
67
|
+
Ordered mapping of task names to task files.
|
|
68
|
+
progress: list[int]
|
|
69
|
+
Progress of each task (1 if solved), in the order of ``tasks``.
|
|
70
|
+
requires: Sequence[str] | None (optional)
|
|
71
|
+
The packages the course needs besides pyics, as PEP 508 requirements. Its
|
|
72
|
+
task files may make json_tricks import only their modules, besides pyics and
|
|
73
|
+
the standard library (see ``ModulePolicy``).
|
|
74
|
+
check_progress: bool (optional, keyword only)
|
|
75
|
+
If False (default), start right away on the first task that is unsolved
|
|
76
|
+
according to ``progress``. If True, the progress is instead checked from the
|
|
77
|
+
exercise files on disk, task by task in a background thread, and the course
|
|
78
|
+
starts on the first task found unsolved (see ``loading``).
|
|
79
|
+
"""
|
|
80
|
+
self.name = name
|
|
81
|
+
self.tasks = tasks
|
|
82
|
+
self.requires = list(requires or [])
|
|
83
|
+
self.__module_policy = ModulePolicy(self.requires)
|
|
84
|
+
self.__tasks_list = list(self.tasks)
|
|
85
|
+
self.__progress: list[int] = progress
|
|
86
|
+
|
|
87
|
+
self.__current_task: Task | None = None
|
|
88
|
+
self.__task_changed: bool = False
|
|
89
|
+
self.__progress_changed: bool = False
|
|
90
|
+
self.__error: Exception | None = None
|
|
91
|
+
# how many hints have been revealed, by task name, so they stay revealed when a
|
|
92
|
+
# task is left and visited again
|
|
93
|
+
self.__hints_shown: dict[str, int] = {}
|
|
94
|
+
|
|
95
|
+
# the position of the task whose progress is being checked
|
|
96
|
+
self.__checking: int | None = 0 if check_progress else None
|
|
97
|
+
self.__stop_check = threading.Event()
|
|
98
|
+
self.__check_thread: threading.Thread | None = None
|
|
99
|
+
|
|
100
|
+
if check_progress:
|
|
101
|
+
self.__check_thread = threading.Thread(
|
|
102
|
+
target=self.__check_progress, daemon=True
|
|
103
|
+
)
|
|
104
|
+
self.__check_thread.start()
|
|
105
|
+
else:
|
|
106
|
+
# when you boot up a course, always show the next unsolved task
|
|
107
|
+
start = self.__progress.index(0) if 0 in self.__progress else -1
|
|
108
|
+
self.__set_current_task(self.__load_task(self.__tasks_list[start]))
|
|
109
|
+
|
|
110
|
+
def __load_task(self, name: str, *, setup: bool = True) -> Task:
|
|
111
|
+
"""Load the task ``name`` of the course, which may only name the modules the
|
|
112
|
+
course declares for json_tricks to import."""
|
|
113
|
+
return Task(self.tasks[name], setup=setup, module_policy=self.__module_policy)
|
|
114
|
+
|
|
115
|
+
def __set_current_task(self, task: Task) -> None:
|
|
116
|
+
self.__current_task = task
|
|
117
|
+
self.__task_changed = True
|
|
118
|
+
|
|
119
|
+
def __check_progress(self) -> None:
|
|
120
|
+
"""
|
|
121
|
+
DO NOT CALL THIS METHOD DIRECTLY, IT IS RUN BY ``__init__``.
|
|
122
|
+
|
|
123
|
+
Check every task's progress from its exercise files, in order, and start on the
|
|
124
|
+
first unsolved task (the last task if all are solved) as soon as it is found.
|
|
125
|
+
"""
|
|
126
|
+
last: Task | None = None
|
|
127
|
+
for i, name in enumerate(self.__tasks_list):
|
|
128
|
+
if self.__stop_check.is_set():
|
|
129
|
+
break
|
|
130
|
+
self.__checking = i
|
|
131
|
+
try:
|
|
132
|
+
last = self.__load_task(name, setup=False)
|
|
133
|
+
solved = last.check_solved()
|
|
134
|
+
except Exception as err: # noqa: BLE001
|
|
135
|
+
get_logger(__name__).warning(
|
|
136
|
+
f"Can't check the task {name!r} of {self.name!r}", exc_info=True
|
|
137
|
+
)
|
|
138
|
+
if self.__current_task is None:
|
|
139
|
+
self.__error = err
|
|
140
|
+
break
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
if self.__progress[i] != int(solved):
|
|
144
|
+
self.__progress[i] = int(solved)
|
|
145
|
+
self.__progress_changed = True
|
|
146
|
+
if self.__current_task is None and not solved:
|
|
147
|
+
last.setup()
|
|
148
|
+
self.__set_current_task(last)
|
|
149
|
+
else:
|
|
150
|
+
if self.__current_task is None and last is not None:
|
|
151
|
+
last.setup()
|
|
152
|
+
self.__set_current_task(last)
|
|
153
|
+
self.__checking = None
|
|
154
|
+
|
|
155
|
+
def stop_progress_check(self, timeout: float | None = None) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Stop checking the progress after the task that is being checked, and wait for
|
|
158
|
+
that (at most ``timeout`` seconds).
|
|
159
|
+
"""
|
|
160
|
+
self.__stop_check.set()
|
|
161
|
+
if self.__check_thread is not None:
|
|
162
|
+
self.__check_thread.join(timeout)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def tracing(self) -> bool:
|
|
166
|
+
"""Whether the current task's tests are traced (see ``Task.tracing``). Off for
|
|
167
|
+
each task that is moved to, and while the course is loading."""
|
|
168
|
+
return self.__current_task is not None and self.__current_task.tracing
|
|
169
|
+
|
|
170
|
+
def toggle_tracing(self) -> None:
|
|
171
|
+
"""Turn tracing the current task's tests on or off (see ``Task.toggle_tracing``),
|
|
172
|
+
unless the course is loading."""
|
|
173
|
+
if self.__current_task is not None:
|
|
174
|
+
self.__current_task.toggle_tracing()
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def loading(self) -> bool:
|
|
178
|
+
"""Whether the course is still looking for the task to start on."""
|
|
179
|
+
return self.__current_task is None
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def check_status(self) -> str:
|
|
183
|
+
"""What the progress check is doing, or "" once it is done."""
|
|
184
|
+
if self.__checking is None:
|
|
185
|
+
return ""
|
|
186
|
+
return f"Checking your progress... (task {self.__checking + 1}/{len(self.tasks)})"
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def error(self) -> Exception | None:
|
|
190
|
+
"""Why the course can't be started, if its progress check failed before a task
|
|
191
|
+
to start on was found."""
|
|
192
|
+
return self.__error
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def progress(self) -> Fraction:
|
|
196
|
+
return Fraction(sum(self.__progress), len(self.__progress))
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def instructions(self) -> str:
|
|
200
|
+
if self.__current_task is None:
|
|
201
|
+
return ""
|
|
202
|
+
return self.__current_task.instructions
|
|
203
|
+
|
|
204
|
+
def __current_task_name(self) -> str:
|
|
205
|
+
"""The current task's name in the course."""
|
|
206
|
+
return self.__tasks_list[self.__current_task_number()]
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def hints(self) -> list[str]:
|
|
210
|
+
"""The current task's hints that have been revealed (see ``reveal_hint``)."""
|
|
211
|
+
if self.__current_task is None:
|
|
212
|
+
return []
|
|
213
|
+
shown = self.__hints_shown.get(self.__current_task_name(), 0)
|
|
214
|
+
return self.__current_task.help[:shown]
|
|
215
|
+
|
|
216
|
+
def reveal_hint(self) -> bool:
|
|
217
|
+
"""
|
|
218
|
+
Reveal the current task's next hint, if it has one that hasn't been revealed.
|
|
219
|
+
|
|
220
|
+
Returns
|
|
221
|
+
-------
|
|
222
|
+
bool
|
|
223
|
+
Whether a hint has been revealed. False while the course is loading.
|
|
224
|
+
"""
|
|
225
|
+
if self.__current_task is None:
|
|
226
|
+
return False
|
|
227
|
+
name = self.__current_task_name()
|
|
228
|
+
shown = self.__hints_shown.get(name, 0)
|
|
229
|
+
if shown >= len(self.__current_task.help):
|
|
230
|
+
return False
|
|
231
|
+
self.__hints_shown[name] = shown + 1
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def info(self) -> RenderableType:
|
|
236
|
+
"""
|
|
237
|
+
The current task's info, i.e. what is supposed to be displayed in the pyics header.
|
|
238
|
+
"""
|
|
239
|
+
if self.__current_task is None:
|
|
240
|
+
return ""
|
|
241
|
+
return self.__current_task.info
|
|
242
|
+
|
|
243
|
+
# @info.setter
|
|
244
|
+
# def info(self, v: str) -> None:
|
|
245
|
+
# """
|
|
246
|
+
# I don't think this is needed.
|
|
247
|
+
# """
|
|
248
|
+
# self.__current_task.info = v
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def status(self) -> RenderableType:
|
|
252
|
+
"""
|
|
253
|
+
The current task's status, i.e. what is supposed to be displayed in the pyics
|
|
254
|
+
main panel, below its revealed hints.
|
|
255
|
+
"""
|
|
256
|
+
if self.__current_task is None:
|
|
257
|
+
return Text(self.check_status, style="dim")
|
|
258
|
+
if hints := self.hints:
|
|
259
|
+
total = len(self.__current_task.help)
|
|
260
|
+
return Group(_hints_view(hints, total), self.__current_task.status)
|
|
261
|
+
return self.__current_task.status
|
|
262
|
+
|
|
263
|
+
def update_status(self) -> None:
|
|
264
|
+
"""
|
|
265
|
+
Expose the current task's update_status() method.
|
|
266
|
+
"""
|
|
267
|
+
if self.__current_task is not None:
|
|
268
|
+
self.__current_task.update_status()
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def exercise_files(self) -> list[Path]:
|
|
272
|
+
"""
|
|
273
|
+
The current task's exercise file paths.
|
|
274
|
+
"""
|
|
275
|
+
if self.__current_task is None:
|
|
276
|
+
return []
|
|
277
|
+
return self.__current_task.exercise_files
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def sections(self) -> dict[Path, str]:
|
|
281
|
+
"""
|
|
282
|
+
The current task's sections: the section of each of its exercise notebooks that
|
|
283
|
+
it is about, by the notebook's absolute path (see ``Task.sections``).
|
|
284
|
+
"""
|
|
285
|
+
if self.__current_task is None:
|
|
286
|
+
return {}
|
|
287
|
+
return self.__current_task.sections
|
|
288
|
+
|
|
289
|
+
def __current_task_number(self) -> int:
|
|
290
|
+
"""The current task's position in the course."""
|
|
291
|
+
assert self.__current_task is not None
|
|
292
|
+
return self.__tasks_list.index(
|
|
293
|
+
{v: k for k, v in self.tasks.items()}[self.__current_task.path]
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def next(self) -> Task | None:
|
|
297
|
+
"""
|
|
298
|
+
Select the next task. If the current task is unsolved, returns the current task.
|
|
299
|
+
|
|
300
|
+
Returns
|
|
301
|
+
-------
|
|
302
|
+
Task | None
|
|
303
|
+
Next task if the current task is solved, otherwise current task. None while
|
|
304
|
+
the course is loading.
|
|
305
|
+
"""
|
|
306
|
+
if self.__current_task is not None and self.__current_task.solved:
|
|
307
|
+
current_task_number = self.__current_task_number()
|
|
308
|
+
|
|
309
|
+
if current_task_number + 1 < len(self.__tasks_list):
|
|
310
|
+
self.__set_current_task(
|
|
311
|
+
self.__load_task(self.__tasks_list[current_task_number + 1])
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
return self.__current_task
|
|
315
|
+
|
|
316
|
+
def previous(self) -> Task | None:
|
|
317
|
+
"""
|
|
318
|
+
Select the previous task (if available)
|
|
319
|
+
|
|
320
|
+
Returns
|
|
321
|
+
-------
|
|
322
|
+
Task | None
|
|
323
|
+
Previous task if the current task isn't the first task, otherwise current
|
|
324
|
+
task. None while the course is loading.
|
|
325
|
+
"""
|
|
326
|
+
if self.__current_task is None:
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
current_task_number = self.__current_task_number()
|
|
330
|
+
|
|
331
|
+
if current_task_number != 0:
|
|
332
|
+
self.__set_current_task(
|
|
333
|
+
self.__load_task(self.__tasks_list[current_task_number - 1])
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
return self.__current_task
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def task_changed(self) -> bool:
|
|
340
|
+
"""
|
|
341
|
+
Flag to indicate whether the current task has changed, set by next or previous,
|
|
342
|
+
and when the course has started on a task.
|
|
343
|
+
"""
|
|
344
|
+
return self.__task_changed
|
|
345
|
+
|
|
346
|
+
def clear_task_changed(self) -> None:
|
|
347
|
+
"""Expose the option to reset task_changed"""
|
|
348
|
+
self.__task_changed = False
|
|
349
|
+
|
|
350
|
+
def clear_test_results(self) -> None:
|
|
351
|
+
"""Expose the current task's clear_test_results method"""
|
|
352
|
+
if self.__current_task is not None:
|
|
353
|
+
self.__current_task.clear_test_results()
|
|
354
|
+
|
|
355
|
+
def tick(self) -> tuple[bool, bool]:
|
|
356
|
+
"""
|
|
357
|
+
Perform regular actions on the course, or pass them on to the current task. Does
|
|
358
|
+
the following:
|
|
359
|
+
|
|
360
|
+
1. Calls check_file_changes on the current task
|
|
361
|
+
2. Calls run_tests on the current task if files have changed.
|
|
362
|
+
3. Calls check_overlay on the current task, to show the results of a plugin's
|
|
363
|
+
overlay (which never count as progress)
|
|
364
|
+
4. checks if the course progress needs to be updated
|
|
365
|
+
|
|
366
|
+
Returns
|
|
367
|
+
-------
|
|
368
|
+
tuple[bool, bool]
|
|
369
|
+
first position indicates whether new test results from the current task are
|
|
370
|
+
available,
|
|
371
|
+
second position indicates whether the course progress has been updated (i.e.
|
|
372
|
+
whether a task has been newly solved/unsolved, here or by the progress check)
|
|
373
|
+
"""
|
|
374
|
+
new_progress, self.__progress_changed = self.__progress_changed, False
|
|
375
|
+
|
|
376
|
+
if self.__current_task is None:
|
|
377
|
+
return (False, new_progress)
|
|
378
|
+
|
|
379
|
+
files_changed = self.__current_task.check_file_changes()
|
|
380
|
+
|
|
381
|
+
if files_changed:
|
|
382
|
+
self.__current_task.run_tests()
|
|
383
|
+
|
|
384
|
+
self.__current_task.check_overlay()
|
|
385
|
+
|
|
386
|
+
current_task_number = self.__current_task_number()
|
|
387
|
+
solved = int(self.__current_task.solved)
|
|
388
|
+
if self.__progress[current_task_number] != solved:
|
|
389
|
+
self.__progress[current_task_number] = solved
|
|
390
|
+
new_progress = True
|
|
391
|
+
|
|
392
|
+
return (self.__current_task.new_test_results, new_progress)
|
|
393
|
+
|
|
394
|
+
@classmethod
|
|
395
|
+
def load_course_manifest(cls, fp: StrPath, *, check_progress: bool = False) -> Course:
|
|
396
|
+
"""
|
|
397
|
+
Load a course manifest to a ``Course`` object. Task file paths in the manifest
|
|
398
|
+
are relative to the manifest's directory (see ``create_course_manifest``).
|
|
399
|
+
|
|
400
|
+
Parameters
|
|
401
|
+
----------
|
|
402
|
+
fp: StrPath
|
|
403
|
+
Path to the manifest
|
|
404
|
+
check_progress: bool (optional, keyword only)
|
|
405
|
+
Check the progress from the exercise files on disk (see ``Course``). Default
|
|
406
|
+
is False.
|
|
407
|
+
|
|
408
|
+
Returns
|
|
409
|
+
-------
|
|
410
|
+
Course
|
|
411
|
+
The Course object represented in the manifest
|
|
412
|
+
"""
|
|
413
|
+
# a manifest holds plain data only, so reading it never imports a module
|
|
414
|
+
data = json.load_plain(fp)
|
|
415
|
+
|
|
416
|
+
base = Path(fp).parent
|
|
417
|
+
tasks = {k: paths.resolve(v, base) for k, v in data["tasks"].items()}
|
|
418
|
+
progress = list(data.get("progress") or [])[: len(tasks)]
|
|
419
|
+
progress += [0] * (len(tasks) - len(progress))
|
|
420
|
+
requires, _ = manifest_requirements(data)
|
|
421
|
+
|
|
422
|
+
return cls(
|
|
423
|
+
name=data.get("name") or Path(fp).name.removesuffix(COURSE_SUFFIX),
|
|
424
|
+
tasks=tasks,
|
|
425
|
+
progress=progress,
|
|
426
|
+
requires=requires,
|
|
427
|
+
check_progress=check_progress,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
@staticmethod
|
|
431
|
+
def create_course_manifest(
|
|
432
|
+
fp: StrPath,
|
|
433
|
+
task_files: Sequence[StrPath],
|
|
434
|
+
*,
|
|
435
|
+
name: Optional[str] = None,
|
|
436
|
+
use_task_names: bool = True,
|
|
437
|
+
progress: Optional[list[int]] = None,
|
|
438
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
439
|
+
) -> Path:
|
|
440
|
+
"""
|
|
441
|
+
Compose a course manifest from the provided ``task_files``. A course manifest is
|
|
442
|
+
a json file containing the course name, current progress, and an ordered mapping
|
|
443
|
+
of task names and their file paths.
|
|
444
|
+
|
|
445
|
+
Parameters
|
|
446
|
+
----------
|
|
447
|
+
fp: StrPath
|
|
448
|
+
Path to the course manifest. Should have the suffix '.pyicscourse.json'. If
|
|
449
|
+
it doesn't, makes changes to the suffix and logs (if logging is active).
|
|
450
|
+
task_files: Sequence[StrPath]
|
|
451
|
+
List of task files to compose the course. Order is relevant. Given relative
|
|
452
|
+
to the working directory (or absolute), and stored relative to the directory
|
|
453
|
+
of the manifest, so the course keeps working when it is moved together with
|
|
454
|
+
its tasks.
|
|
455
|
+
name: str | None (optional, keyword only)
|
|
456
|
+
Name of the course. If None, use ``fp``'s name without its suffix.
|
|
457
|
+
use_task_names: bool (optional, keyword only)
|
|
458
|
+
Use tasks' names for the mapping in the course manifest. Default is True.
|
|
459
|
+
Setting this to False increases performance (no more json loading of tasks),
|
|
460
|
+
but loses information (only enumerates tasks).
|
|
461
|
+
progress: list[int] | None (optional, keyword only)
|
|
462
|
+
Progress of each task, in the order of ``task_files``. Default is None, i.e.
|
|
463
|
+
no task solved yet.
|
|
464
|
+
metadata: Mapping[str, Any] | None (optional, keyword only)
|
|
465
|
+
The course's optional fields (see ``METADATA_KEYS``), e.g. those of the
|
|
466
|
+
manifest that is rewritten. Other keys are ignored.
|
|
467
|
+
|
|
468
|
+
Returns
|
|
469
|
+
-------
|
|
470
|
+
Path
|
|
471
|
+
The path the manifest was written to, with its suffix normalized to
|
|
472
|
+
'.pyicscourse.json'.
|
|
473
|
+
"""
|
|
474
|
+
fp = Path(fp)
|
|
475
|
+
|
|
476
|
+
match fp.suffixes:
|
|
477
|
+
case []:
|
|
478
|
+
fp = fp.with_suffix(".pyicscourse.json")
|
|
479
|
+
case [_]:
|
|
480
|
+
get_logger(__name__).warning(
|
|
481
|
+
f"Replacing suffix '{fp.suffix}' with '.pyicscourse.json'."
|
|
482
|
+
)
|
|
483
|
+
fp = fp.with_suffix(".pyicscourse.json")
|
|
484
|
+
case [".pyicscourse", ".json"]:
|
|
485
|
+
pass
|
|
486
|
+
case _:
|
|
487
|
+
get_logger(__name__).warning(
|
|
488
|
+
f"File suffixes don't match expected file path. Adding '.pyicscourse.json'."
|
|
489
|
+
)
|
|
490
|
+
fp = fp.with_suffix(fp.suffix + ".pyicscourse.json")
|
|
491
|
+
|
|
492
|
+
manifest = {}
|
|
493
|
+
|
|
494
|
+
manifest["name"] = name or fp.name.removesuffix(".pyicscourse.json")
|
|
495
|
+
|
|
496
|
+
if use_task_names:
|
|
497
|
+
|
|
498
|
+
def get_task_name(fp: StrPath) -> str:
|
|
499
|
+
return json.load_plain(fp)["name"]
|
|
500
|
+
|
|
501
|
+
keys = map(get_task_name, task_files)
|
|
502
|
+
|
|
503
|
+
else:
|
|
504
|
+
width = len(str(len(task_files)))
|
|
505
|
+
keys = [
|
|
506
|
+
f"{manifest['name']}_{str(n).zfill(width)}" for n in range(len(task_files))
|
|
507
|
+
]
|
|
508
|
+
|
|
509
|
+
manifest["tasks"] = {
|
|
510
|
+
k: paths.relative_to(v, fp.parent) for k, v in zip(keys, task_files)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if progress is None:
|
|
514
|
+
progress = [0] * len(task_files)
|
|
515
|
+
elif len(progress) != len(task_files):
|
|
516
|
+
raise ValueError(
|
|
517
|
+
f"got progress for {len(progress)} tasks, but {len(task_files)} tasks"
|
|
518
|
+
)
|
|
519
|
+
manifest["progress"] = list(progress)
|
|
520
|
+
for key in METADATA_KEYS:
|
|
521
|
+
if metadata and metadata.get(key) is not None:
|
|
522
|
+
manifest[key] = metadata[key]
|
|
523
|
+
|
|
524
|
+
# serialized before the file is opened, so a manifest that is being edited isn't
|
|
525
|
+
# left empty if serializing fails
|
|
526
|
+
text = json.dumps(manifest, indent=4)
|
|
527
|
+
os.makedirs(fp.parent, exist_ok=True)
|
|
528
|
+
with open(fp, "w", encoding="utf-8") as f:
|
|
529
|
+
f.write(text)
|
|
530
|
+
|
|
531
|
+
return fp
|