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
|
@@ -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
|