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,376 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pulling courses: downloading a course archive, from one of the sources (see
|
|
3
|
+
``sources``) or from a URL or path, checking it (see ``archive``), and extracting it to
|
|
4
|
+
a directory of its own, e.g. ``./search-algorithms``.
|
|
5
|
+
|
|
6
|
+
That directory holds an origin file, ``.pyics-origin.json``, which records where the
|
|
7
|
+
course came from and which files were pulled, so that the course can be updated (see
|
|
8
|
+
``update``): an update replaces the files that were pulled, but never the exercise
|
|
9
|
+
files that hold the student's work, nor any other file the student created.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import tempfile
|
|
17
|
+
import urllib.parse
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from pyics.distribution import archive, sources
|
|
23
|
+
from pyics.distribution.archive import ORIGIN_FILE, CourseInfo
|
|
24
|
+
from pyics.distribution.dependencies import Problem, check, describe, install_hint
|
|
25
|
+
from pyics.distribution.fetch import FetchError, fetch, is_url, scheme, to_url
|
|
26
|
+
from pyics.distribution.slugs import slugify
|
|
27
|
+
from pyics.distribution.sources import CourseEntry, Source, SourceError
|
|
28
|
+
from pyics.util import paths
|
|
29
|
+
from pyics.util.json_handling import load_plain
|
|
30
|
+
|
|
31
|
+
ORIGIN_VERSION = 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PullError(Exception):
|
|
35
|
+
"""A course can't be pulled or updated."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Pulled:
|
|
40
|
+
"""A course that was pulled (see ``pull``) or updated (see ``update``)."""
|
|
41
|
+
|
|
42
|
+
# where it was pulled to
|
|
43
|
+
directory: Path
|
|
44
|
+
manifest_path: Path
|
|
45
|
+
info: CourseInfo
|
|
46
|
+
# the requirements of the course that aren't met
|
|
47
|
+
problems: list[Problem] = field(default_factory=list)
|
|
48
|
+
# problems with the sources that were looked in
|
|
49
|
+
warnings: list[str] = field(default_factory=list)
|
|
50
|
+
# for an update: the version it was updated from
|
|
51
|
+
old_version: str = ""
|
|
52
|
+
# for an update: whether it was up to date already, so nothing was changed
|
|
53
|
+
up_to_date: bool = False
|
|
54
|
+
# for an update: the files of the new version that weren't written, since they
|
|
55
|
+
# would have replaced the student's files
|
|
56
|
+
kept: list[str] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class _Download:
|
|
61
|
+
data: bytes
|
|
62
|
+
url: str
|
|
63
|
+
sha256: str
|
|
64
|
+
source: Source | None = None
|
|
65
|
+
entry: CourseEntry | None = None
|
|
66
|
+
warnings: list[str] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_direct(spec: str) -> bool:
|
|
70
|
+
"""Whether ``spec`` names a course archive (a URL, or the path of a file) rather than
|
|
71
|
+
a course in the sources."""
|
|
72
|
+
return is_url(spec) or spec.endswith(".zip") or Path(spec).is_file()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _requirement_error(title: str, problems: list[Problem]) -> PullError:
|
|
76
|
+
return PullError(f"{title} needs {describe(problems)}. {install_hint(problems)}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _fetch_archive(url: str, *, allow_http: bool) -> _Download:
|
|
80
|
+
data = fetch(url, max_bytes=archive.MAX_ARCHIVE_BYTES, allow_http=allow_http)
|
|
81
|
+
return _Download(data, url, hashlib.sha256(data).hexdigest())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _download_entry(
|
|
85
|
+
source: Source, entry: CourseEntry, warnings: list[str] | None = None
|
|
86
|
+
) -> _Download:
|
|
87
|
+
"""Download the archive of ``entry`` of ``source``, checking it against its hash."""
|
|
88
|
+
if problems := check(requires_pyics=entry.requires_pyics):
|
|
89
|
+
raise _requirement_error(entry.title, problems)
|
|
90
|
+
data = fetch(
|
|
91
|
+
entry.url,
|
|
92
|
+
max_bytes=archive.MAX_ARCHIVE_BYTES,
|
|
93
|
+
allow_http=source.allow_http,
|
|
94
|
+
allow_file=scheme(source.url) == "file",
|
|
95
|
+
)
|
|
96
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
97
|
+
if digest != entry.sha256:
|
|
98
|
+
raise PullError(
|
|
99
|
+
f"The archive of {entry.slug!r} doesn't match its hash in the index of "
|
|
100
|
+
f"{source.name!r}: it was changed or damaged on the way. Try again, or ask "
|
|
101
|
+
"whoever maintains the source to rebuild its index."
|
|
102
|
+
)
|
|
103
|
+
return _Download(data, entry.url, digest, source, entry, warnings or [])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _unpack(
|
|
107
|
+
download: _Download, parent: Path, fallback_slug: str
|
|
108
|
+
) -> tuple[Path, list[str], archive.CheckedCourse]:
|
|
109
|
+
"""Extract ``download`` to a new temporary directory in ``parent`` and check it.
|
|
110
|
+
The directory is removed again if the course doesn't pass."""
|
|
111
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
tmp = Path(tempfile.mkdtemp(prefix=".pyics-pull-", dir=parent))
|
|
113
|
+
try:
|
|
114
|
+
files = archive.extract(download.data, tmp)
|
|
115
|
+
checked = archive.check_course(tmp, fallback_slug)
|
|
116
|
+
if problems := check(requires_pyics=checked.info.requires_pyics):
|
|
117
|
+
raise _requirement_error(checked.info.title, problems)
|
|
118
|
+
except BaseException:
|
|
119
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
120
|
+
raise
|
|
121
|
+
return tmp, files, checked
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _write_origin(
|
|
125
|
+
directory: Path, download: _Download, info: CourseInfo, files: list[str]
|
|
126
|
+
) -> None:
|
|
127
|
+
origin: dict[str, Any] = {
|
|
128
|
+
"version": ORIGIN_VERSION,
|
|
129
|
+
"source": download.source.name if download.source else None,
|
|
130
|
+
"index": download.source.url if download.source else None,
|
|
131
|
+
"slug": download.entry.slug if download.entry else info.slug,
|
|
132
|
+
"url": download.url,
|
|
133
|
+
"course_version": info.version,
|
|
134
|
+
"sha256": download.sha256,
|
|
135
|
+
"files": files,
|
|
136
|
+
}
|
|
137
|
+
text = json.dumps(origin, indent=4) + "\n"
|
|
138
|
+
(directory / ORIGIN_FILE).write_text(text, encoding="utf-8")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _check_dest(dest: Path) -> None:
|
|
142
|
+
"""Raise if a course can't be pulled to ``dest``: only to a new or empty
|
|
143
|
+
directory."""
|
|
144
|
+
if not dest.exists() or (dest.is_dir() and not any(dest.iterdir())):
|
|
145
|
+
return
|
|
146
|
+
if (dest / ORIGIN_FILE).is_file():
|
|
147
|
+
raise PullError(
|
|
148
|
+
f"The course was pulled to {dest} already: update it with: pyics update "
|
|
149
|
+
f"{dest}"
|
|
150
|
+
)
|
|
151
|
+
raise PullError(f"{dest} exists already: pull the course elsewhere with --dest")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def pull(
|
|
155
|
+
spec: str,
|
|
156
|
+
dest: Path | None = None,
|
|
157
|
+
*,
|
|
158
|
+
cwd: Path | None = None,
|
|
159
|
+
allow_http: bool = False,
|
|
160
|
+
) -> Pulled:
|
|
161
|
+
"""
|
|
162
|
+
Pull the course ``spec``: a course in the sources (its slug, or
|
|
163
|
+
``<source>/<slug>``, see ``sources.resolve``), or the URL or path of a course
|
|
164
|
+
archive.
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
spec: str
|
|
169
|
+
The course.
|
|
170
|
+
dest: Path | None
|
|
171
|
+
The directory to pull it to, which must be new or empty. By default, the
|
|
172
|
+
course's slug in ``cwd``.
|
|
173
|
+
cwd: Path | None
|
|
174
|
+
The directory to pull it to by default. The working directory by default.
|
|
175
|
+
allow_http: bool
|
|
176
|
+
Allow downloading an archive given by URL over plain http.
|
|
177
|
+
|
|
178
|
+
Raises
|
|
179
|
+
------
|
|
180
|
+
PullError, ArchiveError
|
|
181
|
+
If it can't be pulled.
|
|
182
|
+
"""
|
|
183
|
+
cwd = paths.absolute(cwd if cwd is not None else Path.cwd())
|
|
184
|
+
dest = paths.absolute(dest) if dest is not None else None
|
|
185
|
+
try:
|
|
186
|
+
if is_direct(spec):
|
|
187
|
+
if dest is not None:
|
|
188
|
+
_check_dest(dest)
|
|
189
|
+
url = to_url(spec)
|
|
190
|
+
download = _fetch_archive(url, allow_http=allow_http)
|
|
191
|
+
name = Path(urllib.parse.unquote(urllib.parse.urlsplit(url).path)).stem
|
|
192
|
+
fallback_slug = slugify(name)
|
|
193
|
+
else:
|
|
194
|
+
resolved = sources.resolve(spec)
|
|
195
|
+
fallback_slug = resolved.entry.slug
|
|
196
|
+
dest = dest or cwd / fallback_slug
|
|
197
|
+
_check_dest(dest)
|
|
198
|
+
download = _download_entry(
|
|
199
|
+
resolved.source, resolved.entry, resolved.warnings
|
|
200
|
+
)
|
|
201
|
+
except (FetchError, SourceError) as err:
|
|
202
|
+
raise PullError(str(err)) from err
|
|
203
|
+
|
|
204
|
+
tmp, files, checked = _unpack(download, dest.parent if dest else cwd, fallback_slug)
|
|
205
|
+
try:
|
|
206
|
+
if dest is None:
|
|
207
|
+
dest = cwd / checked.info.slug
|
|
208
|
+
_check_dest(dest)
|
|
209
|
+
_write_origin(tmp, download, checked.info, files)
|
|
210
|
+
if dest.exists():
|
|
211
|
+
dest.rmdir() # empty, see _check_dest
|
|
212
|
+
os.replace(tmp, dest)
|
|
213
|
+
except BaseException:
|
|
214
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
215
|
+
raise
|
|
216
|
+
return Pulled(
|
|
217
|
+
directory=dest,
|
|
218
|
+
manifest_path=dest / checked.manifest_path.relative_to(tmp),
|
|
219
|
+
info=checked.info,
|
|
220
|
+
problems=check(checked.info.requires),
|
|
221
|
+
warnings=download.warnings,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def find_origin(directory: Path) -> tuple[Path, dict[str, Any]]:
|
|
226
|
+
"""
|
|
227
|
+
The pulled course that ``directory`` is in: the directory it was pulled to, and
|
|
228
|
+
its origin file's contents.
|
|
229
|
+
|
|
230
|
+
Raises
|
|
231
|
+
------
|
|
232
|
+
PullError
|
|
233
|
+
If ``directory`` isn't in a pulled course.
|
|
234
|
+
"""
|
|
235
|
+
directory = paths.absolute(directory)
|
|
236
|
+
for candidate in [directory, *directory.parents]:
|
|
237
|
+
fp = candidate / ORIGIN_FILE
|
|
238
|
+
if not fp.is_file():
|
|
239
|
+
continue
|
|
240
|
+
try:
|
|
241
|
+
origin = json.loads(fp.read_text(encoding="utf-8"))
|
|
242
|
+
if not isinstance(origin, dict) or not isinstance(
|
|
243
|
+
origin.get("files"), list
|
|
244
|
+
):
|
|
245
|
+
raise ValueError("it has no files")
|
|
246
|
+
except ValueError as err:
|
|
247
|
+
raise PullError(f"{fp} can't be read: {err}") from err
|
|
248
|
+
return candidate, origin
|
|
249
|
+
raise PullError(
|
|
250
|
+
f"{directory} isn't a pulled course: there is no {ORIGIN_FILE} in it, nor "
|
|
251
|
+
"above it"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _origin_source(origin: dict[str, Any], allow_http: bool) -> Source | None:
|
|
256
|
+
"""The source the course of ``origin`` was pulled from: the one of that name, or,
|
|
257
|
+
if it has been removed, its index as it was then."""
|
|
258
|
+
name = origin.get("source")
|
|
259
|
+
if not name:
|
|
260
|
+
return None
|
|
261
|
+
for source in sources.load_sources():
|
|
262
|
+
if source.name == name:
|
|
263
|
+
return source
|
|
264
|
+
if isinstance(origin.get("index"), str):
|
|
265
|
+
return Source(name, origin["index"], allow_http)
|
|
266
|
+
raise PullError(f"The course was pulled from the source {name!r}, which is gone")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _remove_empty_dirs(path: Path, root: Path) -> None:
|
|
270
|
+
"""Remove the directories of ``path`` below ``root`` that are left empty."""
|
|
271
|
+
for parent in path.parents:
|
|
272
|
+
if parent == root or not parent.is_relative_to(root):
|
|
273
|
+
return
|
|
274
|
+
try:
|
|
275
|
+
parent.rmdir()
|
|
276
|
+
except OSError: # not empty
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def update(
|
|
281
|
+
directory: Path = Path("."), *, allow_http: bool = False, force: bool = False
|
|
282
|
+
) -> Pulled:
|
|
283
|
+
"""
|
|
284
|
+
Update the pulled course that ``directory`` is in (see ``find_origin``) to the
|
|
285
|
+
latest version in its source, or at the URL it was pulled from.
|
|
286
|
+
|
|
287
|
+
The files that were pulled are replaced, and those the new version no longer has
|
|
288
|
+
are removed, but the course's exercise files, which hold the student's work, are
|
|
289
|
+
never touched, nor are other files that weren't pulled.
|
|
290
|
+
|
|
291
|
+
Parameters
|
|
292
|
+
----------
|
|
293
|
+
directory: Path
|
|
294
|
+
The course, or a directory in it.
|
|
295
|
+
allow_http: bool
|
|
296
|
+
Allow downloading over plain http, for a course pulled by URL.
|
|
297
|
+
force: bool
|
|
298
|
+
Update even if the latest version is the one that was pulled, e.g. to restore
|
|
299
|
+
a deleted task file.
|
|
300
|
+
|
|
301
|
+
Raises
|
|
302
|
+
------
|
|
303
|
+
PullError, ArchiveError
|
|
304
|
+
If it can't be updated.
|
|
305
|
+
"""
|
|
306
|
+
root, origin = find_origin(directory)
|
|
307
|
+
slug = str(origin.get("slug") or root.name)
|
|
308
|
+
old_version = str(origin.get("course_version") or "")
|
|
309
|
+
try:
|
|
310
|
+
source = _origin_source(origin, allow_http)
|
|
311
|
+
if source is not None:
|
|
312
|
+
index, data = sources.fetch_index(source.url, allow_http=source.allow_http)
|
|
313
|
+
sources.cache_index(source, data)
|
|
314
|
+
entry = index.courses.get(slug)
|
|
315
|
+
if entry is None:
|
|
316
|
+
raise PullError(f"The source {source.name!r} no longer has {slug!r}")
|
|
317
|
+
latest = entry.sha256
|
|
318
|
+
elif isinstance(origin.get("url"), str):
|
|
319
|
+
entry = None
|
|
320
|
+
download = _fetch_archive(origin["url"], allow_http=allow_http)
|
|
321
|
+
latest = download.sha256
|
|
322
|
+
else:
|
|
323
|
+
raise PullError(f"{root / ORIGIN_FILE} doesn't say where it came from")
|
|
324
|
+
|
|
325
|
+
if latest == origin.get("sha256") and not force:
|
|
326
|
+
manifest_path = archive.find_manifest(root)
|
|
327
|
+
info = archive.course_info(load_plain(manifest_path), slug)
|
|
328
|
+
return Pulled(
|
|
329
|
+
root,
|
|
330
|
+
manifest_path,
|
|
331
|
+
info,
|
|
332
|
+
check(info.requires),
|
|
333
|
+
old_version=old_version,
|
|
334
|
+
up_to_date=True,
|
|
335
|
+
)
|
|
336
|
+
if source is not None and entry is not None:
|
|
337
|
+
download = _download_entry(source, entry)
|
|
338
|
+
except (FetchError, SourceError) as err:
|
|
339
|
+
raise PullError(str(err)) from err
|
|
340
|
+
|
|
341
|
+
tmp, files, checked = _unpack(download, root.parent, slug)
|
|
342
|
+
try:
|
|
343
|
+
# the student's work: the exercise files of the old and new version, and
|
|
344
|
+
# whatever wasn't pulled
|
|
345
|
+
protected = archive.exercise_files(root)
|
|
346
|
+
protected |= {root / p.relative_to(tmp) for p in checked.exercise_files}
|
|
347
|
+
old_files = {
|
|
348
|
+
f
|
|
349
|
+
for f in origin["files"]
|
|
350
|
+
if isinstance(f, str) and paths.absolute(root / f).is_relative_to(root)
|
|
351
|
+
}
|
|
352
|
+
kept: list[str] = []
|
|
353
|
+
for f in files:
|
|
354
|
+
target = root / f
|
|
355
|
+
if target in protected or (target.exists() and f not in old_files):
|
|
356
|
+
kept.append(f)
|
|
357
|
+
continue
|
|
358
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
359
|
+
os.replace(tmp / f, target)
|
|
360
|
+
for f in sorted(old_files - set(files)):
|
|
361
|
+
target = paths.absolute(root / f)
|
|
362
|
+
if target in protected or not target.is_file():
|
|
363
|
+
continue
|
|
364
|
+
target.unlink()
|
|
365
|
+
_remove_empty_dirs(target, root)
|
|
366
|
+
_write_origin(root, download, checked.info, [f for f in files if f not in kept])
|
|
367
|
+
finally:
|
|
368
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
369
|
+
return Pulled(
|
|
370
|
+
directory=root,
|
|
371
|
+
manifest_path=root / checked.manifest_path.relative_to(tmp),
|
|
372
|
+
info=checked.info,
|
|
373
|
+
problems=check(checked.info.requires),
|
|
374
|
+
old_version=old_version,
|
|
375
|
+
kept=kept,
|
|
376
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Slugs: the names courses are pulled by and sources are known by, e.g.
|
|
3
|
+
``search-algorithms``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
SLUG = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def slugify(text: str) -> str:
|
|
12
|
+
"""``text`` as a slug, e.g. ``search-algorithms`` for "Search Algorithms". Empty if
|
|
13
|
+
``text`` has no letters or digits."""
|
|
14
|
+
return re.sub(r"[^a-z0-9._]+", "-", text.lower()).strip("-._")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check_slug(slug: str, what: str = "slug") -> str:
|
|
18
|
+
"""Return ``slug`` if it is a valid slug, raise ``ValueError`` otherwise."""
|
|
19
|
+
if not isinstance(slug, str) or not SLUG.match(slug):
|
|
20
|
+
raise ValueError(
|
|
21
|
+
f"{slug!r} is not a valid {what}: use lowercase letters, digits, '.', '_' "
|
|
22
|
+
"and '-', starting with a letter or digit"
|
|
23
|
+
)
|
|
24
|
+
return slug
|