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.
Files changed (49) hide show
  1. pyics/__init__.py +0 -0
  2. pyics/app/actions.py +179 -0
  3. pyics/app/cli.py +335 -0
  4. pyics/app/key_listener.py +133 -0
  5. pyics/app/prompts.py +439 -0
  6. pyics/app/pyics.py +454 -0
  7. pyics/data/__init__.py +4 -0
  8. pyics/data/course.py +531 -0
  9. pyics/data/task.py +744 -0
  10. pyics/distribution/__init__.py +5 -0
  11. pyics/distribution/archive.py +667 -0
  12. pyics/distribution/dependencies.py +452 -0
  13. pyics/distribution/fetch.py +128 -0
  14. pyics/distribution/pull.py +376 -0
  15. pyics/distribution/slugs.py +24 -0
  16. pyics/distribution/sources.py +561 -0
  17. pyics/notebook/__init__.py +1 -0
  18. pyics/notebook/cells.py +184 -0
  19. pyics/notebook/loader.py +590 -0
  20. pyics/notebook/sections.py +189 -0
  21. pyics/plugins.py +90 -0
  22. pyics/scripts/console.py +86 -0
  23. pyics/scripts/create_course.py +301 -0
  24. pyics/scripts/create_task.py +850 -0
  25. pyics/scripts/edit_course.py +133 -0
  26. pyics/scripts/edit_task.py +109 -0
  27. pyics/scripts/main.py +118 -0
  28. pyics/scripts/pack.py +198 -0
  29. pyics/scripts/pull.py +123 -0
  30. pyics/scripts/sources.py +118 -0
  31. pyics/tests/__init__.py +3 -0
  32. pyics/tests/capture.py +114 -0
  33. pyics/tests/check_specs.py +528 -0
  34. pyics/tests/check_structure.py +769 -0
  35. pyics/tests/compare.py +184 -0
  36. pyics/tests/pytest_integration.py +228 -0
  37. pyics/tests/results.py +145 -0
  38. pyics/tests/tests.py +440 -0
  39. pyics/tests/trace.py +156 -0
  40. pyics/util/discovery.py +92 -0
  41. pyics/util/json_handling.py +182 -0
  42. pyics/util/logging.py +39 -0
  43. pyics/util/paths.py +38 -0
  44. pyics/util/terminal.py +57 -0
  45. python_interactive_courses-1.0.0.dist-info/METADATA +245 -0
  46. python_interactive_courses-1.0.0.dist-info/RECORD +49 -0
  47. python_interactive_courses-1.0.0.dist-info/WHEEL +4 -0
  48. python_interactive_courses-1.0.0.dist-info/entry_points.txt +3 -0
  49. python_interactive_courses-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ """
2
+ Distributing courses: packing them into archives, publishing them in an index on a web
3
+ server (a source), and pulling them from the sources the user has added, along with
4
+ checking the packages they need besides pyics.
5
+ """
@@ -0,0 +1,667 @@
1
+ """
2
+ Course archives: zip files that hold one course each, as written by ``pack``. An
3
+ archive holds the course manifest and its task files (with the exercise and solution
4
+ code they store), at the same places relative to each other as where they were packed,
5
+ and any other files the course needs, e.g. data its exercises read. Exercise and
6
+ solution files aren't included: they are created from the task files once the course is
7
+ started.
8
+
9
+ Archives are checked before any of their contents is used: ``extract`` refuses entries
10
+ that would end up outside the directory they are extracted to, and ``check_course``
11
+ refuses courses whose task files would write files outside it, or make json_tricks
12
+ import modules the course doesn't declare.
13
+ """
14
+
15
+ import io
16
+ import os
17
+ import stat
18
+ import tempfile
19
+ import zipfile
20
+ from collections.abc import Iterable, Mapping
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path, PurePath
23
+ from typing import Any
24
+
25
+ from pyics.distribution.dependencies import (
26
+ ModulePolicy,
27
+ manifest_requirements,
28
+ pip_install_lines,
29
+ )
30
+ from pyics.distribution.slugs import check_slug, slugify
31
+ from pyics.notebook import cells
32
+ from pyics.util import json_handling as json
33
+ from pyics.util import paths
34
+ from pyics.util.discovery import COURSE_SUFFIX, SKIP_DIRS, TASK_SUFFIX, find_files
35
+
36
+ # written to the directory a course is pulled to (see ``pyics.distribution.pull``)
37
+ ORIGIN_FILE = ".pyics-origin.json"
38
+ MAX_ARCHIVE_BYTES = 100 * 2**20
39
+ MAX_UNPACKED_BYTES = 500 * 2**20
40
+ MAX_MEMBERS = 10_000
41
+ # how deep below the root of an archive its manifest may be
42
+ MANIFEST_DEPTH = 2
43
+ # never packed when including directories
44
+ _SKIP_FILES = {ORIGIN_FILE, ".pyics.log", ".DS_Store"}
45
+ _SKIP_SUFFIXES = (".pyc", ".pyo")
46
+ # fixed, so that packing the same course twice gives the same archive (and hash)
47
+ _ZIP_DATE = (1980, 1, 1, 0, 0, 0)
48
+
49
+
50
+ class ArchiveError(Exception):
51
+ """A course can't be packed, or an archive isn't a valid course."""
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class CourseInfo:
56
+ """What a course manifest says about the course (see ``course_info``)."""
57
+
58
+ title: str
59
+ slug: str
60
+ version: str = ""
61
+ description: str = ""
62
+ tags: list[str] = field(default_factory=list)
63
+ requires: list[str] = field(default_factory=list)
64
+ requires_pyics: str | None = None
65
+
66
+
67
+ def course_info(manifest: Mapping[str, Any], fallback_slug: str = "") -> CourseInfo:
68
+ """
69
+ The title, slug and other metadata of a course ``manifest``. Its slug is its
70
+ ``slug`` field, or else derived from its name, or else ``fallback_slug``.
71
+
72
+ Raises
73
+ ------
74
+ ArchiveError
75
+ If a field is invalid.
76
+ """
77
+ name = manifest.get("name")
78
+ title = name if isinstance(name, str) and name else fallback_slug
79
+ try:
80
+ slug = manifest.get("slug") or slugify(title) or fallback_slug
81
+ check_slug(slug, "course slug")
82
+ requires, requires_pyics = manifest_requirements(manifest)
83
+ except ValueError as err:
84
+ raise ArchiveError(f"The course manifest is invalid: {err}") from err
85
+ tags = manifest.get("tags") or []
86
+ if not isinstance(tags, list) or not all(isinstance(t, str) for t in tags):
87
+ raise ArchiveError("The course manifest is invalid: its tags must be strings")
88
+ description = manifest.get("description") or ""
89
+ if not isinstance(description, str):
90
+ raise ArchiveError("The course manifest is invalid: its description isn't text")
91
+ return CourseInfo(
92
+ title=title,
93
+ slug=slug,
94
+ version=str(manifest.get("version") or ""),
95
+ description=description,
96
+ tags=tags,
97
+ requires=requires,
98
+ requires_pyics=requires_pyics,
99
+ )
100
+
101
+
102
+ def _task_files_paths(data: Mapping[str, Any], base: Path) -> tuple[list[Path], ...]:
103
+ """
104
+ The paths a task file's ``data`` (as read by ``json_handling.loads_plain``) refers
105
+ to, resolved relative to its directory ``base``.
106
+
107
+ Returns
108
+ -------
109
+ tuple[list[Path], list[Path], list[Path]]
110
+ Its exercise files, its solution files, and all paths it refers to (e.g. the
111
+ files its tests test).
112
+ """
113
+
114
+ def resolved(key: str) -> list[Path]:
115
+ files = data.get(key) or []
116
+ if not isinstance(files, list) or not all(
117
+ isinstance(f, (str, PurePath)) for f in files
118
+ ):
119
+ raise ArchiveError(f"The {key} of a task file aren't paths")
120
+ return [paths.resolve(f, base) for f in files]
121
+
122
+ exercises, solutions = resolved("ex_files"), resolved("sol_files")
123
+ referenced: list[Path] = [*exercises, *solutions]
124
+
125
+ def walk(obj: Any) -> None:
126
+ if isinstance(obj, PurePath):
127
+ referenced.append(paths.resolve(obj, base))
128
+ elif isinstance(obj, dict):
129
+ for key, value in obj.items():
130
+ if key == "file_path" and isinstance(value, str):
131
+ referenced.append(paths.resolve(value, base))
132
+ else:
133
+ walk(value)
134
+ elif isinstance(obj, (list, tuple, set, frozenset)):
135
+ for value in obj:
136
+ walk(value)
137
+
138
+ walk(data)
139
+ return exercises, solutions, referenced
140
+
141
+
142
+ # --- packing ---
143
+
144
+
145
+ @dataclass
146
+ class CourseFiles:
147
+ """A course to be packed, as read by ``read_course``. Paths are absolute."""
148
+
149
+ manifest_path: Path
150
+ # as read by ``json_handling.loads_plain``
151
+ manifest: dict[str, Any]
152
+ # the task files, mapped to their data as read by ``json_handling.loads_plain``
153
+ tasks: dict[Path, dict[str, Any]]
154
+ exercise_files: set[Path]
155
+ solution_files: set[Path]
156
+ # all paths the task files refer to, including the exercise and solution files
157
+ referenced: set[Path]
158
+
159
+ @property
160
+ def info(self) -> CourseInfo:
161
+ return course_info(self.manifest, slugify(self.manifest_path.name))
162
+
163
+
164
+ def read_course(manifest_path: Path) -> CourseFiles:
165
+ """
166
+ Read the course of the manifest at ``manifest_path`` and its task files, as plain
167
+ data (see ``json_handling.loads_plain``).
168
+
169
+ Raises
170
+ ------
171
+ ArchiveError
172
+ If the manifest or one of its task files can't be read.
173
+ """
174
+ manifest_path = paths.absolute(manifest_path)
175
+ if not manifest_path.name.endswith(COURSE_SUFFIX):
176
+ raise ArchiveError(
177
+ f"{manifest_path} is not a course manifest (*{COURSE_SUFFIX})"
178
+ )
179
+ try:
180
+ manifest = json.load_plain(manifest_path)
181
+ except Exception as err: # noqa: BLE001
182
+ raise ArchiveError(
183
+ f"Can't read the course manifest {manifest_path}: {err}"
184
+ ) from err
185
+ tasks_map = manifest.get("tasks") if isinstance(manifest, dict) else None
186
+ if not isinstance(tasks_map, dict) or not tasks_map:
187
+ raise ArchiveError(f"The course manifest {manifest_path} has no tasks")
188
+
189
+ course = CourseFiles(manifest_path, manifest, {}, set(), set(), set())
190
+ for name, rel in tasks_map.items():
191
+ path = paths.absolute(paths.resolve(rel, manifest_path.parent))
192
+ try:
193
+ data = json.load_plain(path)
194
+ if not isinstance(data, dict):
195
+ raise TypeError("it isn't a task")
196
+ except Exception as err: # noqa: BLE001
197
+ raise ArchiveError(f"Can't read the task file of {name!r}: {err}") from err
198
+ course.tasks[path] = data
199
+ exercises, solutions, referenced = _task_files_paths(data, path.parent)
200
+ course.exercise_files.update(map(paths.absolute, exercises))
201
+ course.solution_files.update(map(paths.absolute, solutions))
202
+ course.referenced.update(map(paths.absolute, referenced))
203
+ return course
204
+
205
+
206
+ def differing_notebooks(
207
+ tasks: Mapping[Path, Mapping[str, Any]],
208
+ ) -> dict[Path, list[Path]]:
209
+ """
210
+ The exercise notebooks that several of the ``tasks`` share, but store different
211
+ contents for (compared normalized, see ``cells.normalize``). The first of them to
212
+ be set up creates the notebook, so which contents students get depends on the task
213
+ they start first.
214
+
215
+ Parameters
216
+ ----------
217
+ tasks: Mapping[Path, Mapping[str, Any]]
218
+ Task files by absolute path, mapped to their data as read by
219
+ ``json_handling.loads_plain``. Those whose exercise files can't be read are
220
+ left out.
221
+
222
+ Returns
223
+ -------
224
+ dict[Path, list[Path]]
225
+ The notebooks' absolute paths, mapped to the task files that share them.
226
+ """
227
+ stored: dict[Path, dict[Path, str]] = {}
228
+ for task_file, data in tasks.items():
229
+ try:
230
+ exercises, _, _ = _task_files_paths(data, task_file.parent)
231
+ except ArchiveError:
232
+ continue
233
+ codes = data.get("exercise_code")
234
+ for path, text in zip(exercises, codes if isinstance(codes, list) else []):
235
+ if cells.is_notebook(path) and isinstance(text, str):
236
+ try:
237
+ text = cells.normalize(text)
238
+ except cells.NotebookError:
239
+ pass # compared as it is
240
+ stored.setdefault(paths.absolute(path), {})[task_file] = text
241
+ return {
242
+ notebook: list(contents)
243
+ for notebook, contents in stored.items()
244
+ if len(set(contents.values())) > 1
245
+ }
246
+
247
+
248
+ def pip_installs(
249
+ tasks: Mapping[Path, Mapping[str, Any]],
250
+ ) -> list[tuple[Path, int, str]]:
251
+ """
252
+ The lines that install packages (see ``dependencies.pip_install_lines``) in the
253
+ exercise and solution notebooks the ``tasks`` store, once each, e.g. those of a
254
+ notebook that several tasks share.
255
+
256
+ Parameters
257
+ ----------
258
+ tasks: Mapping[Path, Mapping[str, Any]]
259
+ Task files by absolute path, mapped to their data as read by
260
+ ``json_handling.loads_plain``.
261
+
262
+ Returns
263
+ -------
264
+ list[tuple[Path, int, str]]
265
+ The notebook's absolute path, the number of the line's cell, and the line.
266
+ """
267
+ found: dict[tuple[Path, int, str], None] = {} # ordered set
268
+ for task_file, data in tasks.items():
269
+ try:
270
+ exercises, solutions, _ = _task_files_paths(data, task_file.parent)
271
+ except ArchiveError:
272
+ continue
273
+ for files, key in ((exercises, "exercise_code"), (solutions, "solution_code")):
274
+ codes = data.get(key)
275
+ for path, text in zip(files, codes if isinstance(codes, list) else []):
276
+ if cells.is_notebook(path) and isinstance(text, str):
277
+ for number, line in pip_install_lines(text):
278
+ found[(paths.absolute(path), number, line)] = None
279
+ return list(found)
280
+
281
+
282
+ def _included_files(
283
+ include: Iterable[Path], course: CourseFiles, exclude: set[Path]
284
+ ) -> list[Path]:
285
+ """The files of ``include`` (files, or directories with their files below), without
286
+ the files of ``course`` and the files in ``exclude``, nor caches and the like."""
287
+ course_files = {course.manifest_path, *course.tasks}
288
+ student_files = course.exercise_files | course.solution_files
289
+ found: dict[Path, None] = {} # ordered set
290
+
291
+ def add(path: Path) -> None:
292
+ if path in course_files or path in exclude or path.name in _SKIP_FILES:
293
+ return
294
+ if path in student_files or path.suffix in _SKIP_SUFFIXES:
295
+ return
296
+ if path.name.endswith(COURSE_SUFFIX):
297
+ raise ArchiveError(f"Can't include {path}: it is another course's manifest")
298
+ found[path] = None
299
+
300
+ for item in include:
301
+ item = paths.absolute(item)
302
+ if item.is_file():
303
+ add(item)
304
+ elif item.is_dir():
305
+ for dirpath, dirnames, filenames in os.walk(item):
306
+ dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
307
+ for filename in sorted(filenames):
308
+ add(Path(dirpath) / filename)
309
+ else:
310
+ raise ArchiveError(f"Can't include {item}: it doesn't exist")
311
+ return list(found)
312
+
313
+
314
+ def course_root(course: CourseFiles, included: Iterable[Path] = ()) -> Path:
315
+ """The directory an archive of ``course`` is packed relative to: the innermost one
316
+ that holds the manifest, the task files, the files they refer to, and the
317
+ ``included`` files."""
318
+ dirs = [
319
+ course.manifest_path.parent,
320
+ *(p.parent for p in course.tasks),
321
+ *(p.parent for p in course.referenced),
322
+ *(p.parent for p in included),
323
+ ]
324
+ try:
325
+ return Path(os.path.commonpath(dirs))
326
+ except ValueError as err: # e.g. on different drives on Windows
327
+ raise ArchiveError(
328
+ f"The course's files have no common directory: {err}"
329
+ ) from err
330
+
331
+
332
+ def local_modules(course: CourseFiles) -> set[str]:
333
+ """The modules that are part of ``course`` itself: its exercise and solution files,
334
+ and the modules next to them and in its root directory, which its code may import
335
+ without them being installed."""
336
+ modules = {p.stem for p in course.exercise_files | course.solution_files}
337
+ dirs = {course_root(course), *(p.parent for p in course.exercise_files)}
338
+ for directory in dirs:
339
+ if not directory.is_dir():
340
+ continue
341
+ for child in directory.iterdir():
342
+ if child.suffix == ".py" or (child / "__init__.py").is_file():
343
+ modules.add(child.stem)
344
+ return modules
345
+
346
+
347
+ def _write_zip(out: Path, members: Mapping[str, bytes]) -> None:
348
+ """Write ``members`` (by archive path) to the zip file ``out``, reproducibly and as a
349
+ whole."""
350
+ out.parent.mkdir(parents=True, exist_ok=True)
351
+ fd, tmp = tempfile.mkstemp(dir=out.parent, prefix=f".{out.name}.", suffix=".tmp")
352
+ os.close(fd)
353
+ try:
354
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zf:
355
+ for name in sorted(members):
356
+ info = zipfile.ZipInfo(name, date_time=_ZIP_DATE)
357
+ info.create_system = 3 # unix, so that the permissions are read
358
+ info.external_attr = 0o644 << 16
359
+ info.compress_type = zipfile.ZIP_DEFLATED
360
+ zf.writestr(info, members[name])
361
+ os.replace(tmp, out)
362
+ except BaseException:
363
+ Path(tmp).unlink(missing_ok=True)
364
+ raise
365
+
366
+
367
+ def pack(
368
+ course: CourseFiles,
369
+ out: Path,
370
+ *,
371
+ include: Iterable[Path] = (),
372
+ no_solutions: bool = False,
373
+ ) -> list[str]:
374
+ """
375
+ Pack ``course`` into the archive ``out``: its manifest, without the progress made
376
+ on it, its task files, and the ``include``d files (or the files in the ``include``d
377
+ directories, see ``_included_files``).
378
+
379
+ Parameters
380
+ ----------
381
+ course: CourseFiles
382
+ The course, as read by ``read_course``.
383
+ out: Path
384
+ Where to write the archive.
385
+ include: Iterable[Path]
386
+ Other files the course needs, e.g. data its exercises read, or directories of
387
+ them. Exercise and solution files are never included.
388
+ no_solutions: bool
389
+ Leave the solutions out of the task files, e.g. for a course that is graded.
390
+
391
+ Returns
392
+ -------
393
+ list[str]
394
+ The paths of the files in the archive.
395
+ """
396
+ out = paths.absolute(out)
397
+ included = _included_files(include, course, exclude={out})
398
+ root = course_root(course, included)
399
+
400
+ def name(path: Path) -> str:
401
+ return path.relative_to(root).as_posix()
402
+
403
+ members: dict[str, bytes] = {}
404
+ manifest = dict(course.manifest)
405
+ manifest["progress"] = [0] * len(course.tasks)
406
+ members[name(course.manifest_path)] = json.dumps(manifest, indent=4).encode()
407
+ for path, data in course.tasks.items():
408
+ if no_solutions:
409
+ data = {**data, "sol_files": [], "solution_code": []}
410
+ members[name(path)] = json.dumps(data, indent=4).encode()
411
+ for path in included:
412
+ members[name(path)] = path.read_bytes()
413
+ _write_zip(out, members)
414
+ return sorted(members)
415
+
416
+
417
+ def update_manifest(manifest_path: Path, changes: Mapping[str, Any]) -> None:
418
+ """Set the fields ``changes`` in the course manifest at ``manifest_path``, removing
419
+ those set to None, and keep the rest as it is."""
420
+ manifest = json.load_plain(manifest_path)
421
+ for key, value in changes.items():
422
+ if value is None:
423
+ manifest.pop(key, None)
424
+ else:
425
+ manifest[key] = value
426
+ text = json.dumps(manifest, indent=4)
427
+ Path(manifest_path).write_text(text, encoding="utf-8")
428
+
429
+
430
+ # --- checking and extracting ---
431
+
432
+
433
+ def _check_member(info: zipfile.ZipInfo) -> list[str]:
434
+ """The parts of the path of the archive entry ``info``, if it may be extracted:
435
+ a relative path that stays in the directory it is extracted to, and no link."""
436
+ name = info.filename
437
+ parts = name.rstrip("/").split("/")
438
+ if (
439
+ not name
440
+ or "\\" in name
441
+ or "\x00" in name
442
+ or name.startswith("/")
443
+ or ":" in parts[0] # a drive, e.g. C:
444
+ or any(part in ("", ".", "..") for part in parts)
445
+ ):
446
+ raise ArchiveError(f"The archive has an entry with an unsafe path: {name!r}")
447
+ if stat.S_ISLNK(info.external_attr >> 16):
448
+ raise ArchiveError(f"The archive has a link, which isn't supported: {name!r}")
449
+ if info.flag_bits & 0x1:
450
+ raise ArchiveError(f"The archive has an encrypted entry: {name!r}")
451
+ return parts
452
+
453
+
454
+ def _open_zip(data: bytes | Path) -> zipfile.ZipFile:
455
+ try:
456
+ return zipfile.ZipFile(io.BytesIO(data) if isinstance(data, bytes) else data)
457
+ except (zipfile.BadZipFile, OSError) as err:
458
+ raise ArchiveError(f"It isn't a zip archive: {err}") from err
459
+
460
+
461
+ def _checked_members(zf: zipfile.ZipFile) -> list[tuple[zipfile.ZipInfo, list[str]]]:
462
+ """The entries of ``zf`` with the parts of their paths, once all of them have been
463
+ checked (see ``_check_member``), and their number and size are within limits."""
464
+ infos = zf.infolist()
465
+ if len(infos) > MAX_MEMBERS:
466
+ raise ArchiveError(f"The archive has more than {MAX_MEMBERS} entries")
467
+ members, seen, total = [], set(), 0
468
+ for info in infos:
469
+ parts = _check_member(info)
470
+ key = "/".join(parts).lower() # the same file on case-insensitive systems
471
+ if key in seen:
472
+ raise ArchiveError(f"The archive has {info.filename!r} twice")
473
+ seen.add(key)
474
+ total += info.file_size
475
+ if total > MAX_UNPACKED_BYTES:
476
+ raise ArchiveError(
477
+ f"The archive unpacks to more than {MAX_UNPACKED_BYTES // 2**20} MB"
478
+ )
479
+ members.append((info, parts))
480
+ return members
481
+
482
+
483
+ def read_archive(data: bytes | Path) -> CourseInfo:
484
+ """
485
+ What the manifest of the course archive ``data`` (its contents, or its path) says
486
+ about the course, without extracting it. Its slug falls back to the archive's file
487
+ name.
488
+
489
+ Raises
490
+ ------
491
+ ArchiveError
492
+ If it isn't a course archive.
493
+ """
494
+ with _open_zip(data) as zf:
495
+ members = _checked_members(zf)
496
+ manifests = [
497
+ info
498
+ for info, parts in members
499
+ if parts[-1].endswith(COURSE_SUFFIX) and len(parts) <= MANIFEST_DEPTH + 1
500
+ ]
501
+ if len(manifests) != 1:
502
+ raise ArchiveError(
503
+ f"The archive has {len(manifests)} course manifests instead of one"
504
+ )
505
+ try:
506
+ manifest = json.loads_plain(zf.read(manifests[0]).decode("utf-8"))
507
+ except Exception as err: # noqa: BLE001
508
+ raise ArchiveError(f"Its course manifest can't be read: {err}") from err
509
+ if not isinstance(manifest, dict):
510
+ raise ArchiveError("Its course manifest isn't a course manifest")
511
+ fallback = slugify(Path(data).stem) if isinstance(data, Path) else ""
512
+ return course_info(manifest, fallback)
513
+
514
+
515
+ def extract(data: bytes, dest: Path) -> list[str]:
516
+ """
517
+ Extract the archive ``data`` to the directory ``dest``, once all its entries have
518
+ been checked to stay in it (see ``_checked_members``).
519
+
520
+ Returns
521
+ -------
522
+ list[str]
523
+ The paths of the extracted files, relative to ``dest``.
524
+ """
525
+ files: list[str] = []
526
+ with _open_zip(data) as zf:
527
+ members = _checked_members(zf)
528
+ try:
529
+ for info, parts in members:
530
+ target = dest.joinpath(*parts)
531
+ if info.is_dir():
532
+ target.mkdir(parents=True, exist_ok=True)
533
+ continue
534
+ target.parent.mkdir(parents=True, exist_ok=True)
535
+ with zf.open(info) as src, open(target, "xb") as dst:
536
+ # reads at most the size the entry declares
537
+ while chunk := src.read(2**20):
538
+ dst.write(chunk)
539
+ files.append("/".join(parts))
540
+ except (OSError, zipfile.BadZipFile, EOFError) as err:
541
+ raise ArchiveError(f"The archive can't be extracted: {err}") from err
542
+ return sorted(files)
543
+
544
+
545
+ @dataclass(frozen=True)
546
+ class CheckedCourse:
547
+ """A course that passed ``check_course``."""
548
+
549
+ manifest_path: Path
550
+ info: CourseInfo
551
+ # the exercise files its tasks create, which hold the student's work
552
+ exercise_files: frozenset[Path]
553
+
554
+
555
+ def _inside(root: Path, path: Path, what: str) -> Path:
556
+ path = paths.absolute(path)
557
+ if not path.is_relative_to(paths.absolute(root)):
558
+ raise ArchiveError(f"{what} is outside of the course's directory: {path}")
559
+ return path
560
+
561
+
562
+ def find_manifest(root: Path) -> Path:
563
+ """The single course manifest in the directory ``root``, at most
564
+ ``MANIFEST_DEPTH`` directories below it."""
565
+ manifests = list(find_files(root, MANIFEST_DEPTH, COURSE_SUFFIX))
566
+ if len(manifests) != 1:
567
+ raise ArchiveError(
568
+ f"The course has {len(manifests)} course manifests instead of one"
569
+ )
570
+ return root / manifests[0]
571
+
572
+
573
+ def check_course(root: Path, fallback_slug: str = "") -> CheckedCourse:
574
+ """
575
+ Check the course in the directory ``root``, e.g. an extracted archive, before it
576
+ is used:
577
+
578
+ * it has a single course manifest, holding plain data only (see ``course_info``),
579
+ * its task files are in ``root``, and all files they refer to (exercise and
580
+ solution files, which are written when the course is started, and the files
581
+ their tests test) are, too, and aren't the course's own files,
582
+ * its task files name only modules for json_tricks to import that the course
583
+ declares (see ``ModulePolicy``).
584
+
585
+ Raises
586
+ ------
587
+ ArchiveError
588
+ If it doesn't pass.
589
+ """
590
+ manifest_path = find_manifest(root)
591
+ try:
592
+ manifest = json.load_plain(manifest_path)
593
+ except Exception as err: # noqa: BLE001
594
+ raise ArchiveError(f"The course manifest can't be read: {err}") from err
595
+ if not isinstance(manifest, dict):
596
+ raise ArchiveError("The course manifest isn't a course manifest")
597
+ tags = json.scan_tags(manifest)
598
+ if tags.modules or tags.packages:
599
+ raise ArchiveError("The course manifest holds objects, rather than plain data")
600
+ info = course_info(manifest, fallback_slug)
601
+ tasks_map = manifest.get("tasks")
602
+ if not isinstance(tasks_map, dict) or not tasks_map:
603
+ raise ArchiveError("The course manifest has no tasks")
604
+
605
+ task_paths: dict[str, Path] = {}
606
+ for name, rel in tasks_map.items():
607
+ if not isinstance(rel, (str, PurePath)):
608
+ raise ArchiveError(f"The task {name!r} has no task file")
609
+ path = _inside(
610
+ root, paths.resolve(rel, manifest_path.parent), f"The task {name!r}"
611
+ )
612
+ if not path.name.endswith(TASK_SUFFIX) or not path.is_file():
613
+ raise ArchiveError(f"The task file of {name!r} is missing: {path}")
614
+ task_paths[name] = path
615
+ own_files = {paths.absolute(manifest_path), paths.absolute(root / ORIGIN_FILE)}
616
+ own_files |= set(task_paths.values())
617
+
618
+ policy = ModulePolicy(info.requires)
619
+ exercise_files: set[Path] = set()
620
+ for name, path in task_paths.items():
621
+ try:
622
+ data = json.load_plain(path)
623
+ if not isinstance(data, dict):
624
+ raise TypeError("it isn't a task")
625
+ except Exception as err: # noqa: BLE001
626
+ raise ArchiveError(
627
+ f"The task file of {name!r} can't be read: {err}"
628
+ ) from err
629
+ for module in sorted(json.scan_tags(data).modules, key=str):
630
+ if not policy.allows(module):
631
+ raise ArchiveError(
632
+ f"The task {name!r} uses the module {module!r}, which the course "
633
+ "doesn't declare in its requires"
634
+ )
635
+ exercises, solutions, referenced = _task_files_paths(data, path.parent)
636
+ for file in referenced:
637
+ _inside(root, file, f"A file of the task {name!r}")
638
+ for file in exercises + solutions:
639
+ if paths.absolute(file) in own_files:
640
+ raise ArchiveError(
641
+ f"The task {name!r} would overwrite a file of the course: {file}"
642
+ )
643
+ exercise_files.update(paths.absolute(file) for file in exercises)
644
+ return CheckedCourse(manifest_path, info, frozenset(exercise_files))
645
+
646
+
647
+ def exercise_files(root: Path) -> set[Path]:
648
+ """The exercise files of the courses in the directory ``root``, as far as their
649
+ manifests and task files can be read."""
650
+ found: set[Path] = set()
651
+ for rel in find_files(root, MANIFEST_DEPTH, COURSE_SUFFIX):
652
+ manifest_path = root / rel
653
+ try:
654
+ manifest = json.load_plain(manifest_path)
655
+ task_paths = [
656
+ paths.resolve(v, manifest_path.parent)
657
+ for v in manifest["tasks"].values()
658
+ ]
659
+ except Exception: # noqa: BLE001
660
+ continue
661
+ for path in task_paths:
662
+ try:
663
+ exercises, _, _ = _task_files_paths(json.load_plain(path), path.parent)
664
+ except Exception: # noqa: BLE001
665
+ continue
666
+ found.update(paths.absolute(file) for file in exercises)
667
+ return found