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