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,133 @@
1
+ import sys
2
+ from collections.abc import Mapping
3
+ from os import PathLike
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from rich.markup import escape
8
+
9
+ import pyics.util.json_handling as json
10
+ from pyics.app.cli import CLI
11
+ from pyics.app.key_listener import KeyListener
12
+ from pyics.app.prompts import PromptCancelled, PromptSession
13
+ from pyics.data.course import Course
14
+ from pyics.data.task import Task
15
+ from pyics.scripts.create_course import (
16
+ _course_flow,
17
+ _CourseTask,
18
+ _names_taken_elsewhere,
19
+ _rename_in_courses,
20
+ )
21
+ from pyics.util import paths
22
+ from pyics.util.discovery import COURSE_SUFFIX, find_courses, find_task_files
23
+ from pyics.util.terminal import fullscreen
24
+
25
+
26
+ def _read_course(
27
+ fp: Path,
28
+ manifest: Mapping[str, Any],
29
+ courses: Mapping[Path, Mapping[str, Any]],
30
+ ) -> tuple[dict[str, Any], dict[Path, int]]:
31
+ """
32
+ Read the course ``manifest`` (read from ``fp``) and its tasks' task files for
33
+ ``_course_flow``. The tasks can't be renamed to the names of the other tasks in the
34
+ other ``courses`` (see ``find_courses``) that include them.
35
+
36
+ Returns
37
+ -------
38
+ tuple[dict[str, Any], dict[Path, int]]
39
+ The course's ``name`` and ``tasks`` (as ``_CourseTask``), and the progress of
40
+ its tasks, by their task files' absolute paths.
41
+ """
42
+ tasks: list[_CourseTask] = []
43
+ progress: dict[Path, int] = {}
44
+ solved = manifest.get("progress", [])
45
+ for i, (key, rel) in enumerate(manifest["tasks"].items()):
46
+ path = paths.resolve(rel, fp.parent)
47
+ progress[paths.absolute(path)] = solved[i] if i < len(solved) else 0
48
+ try:
49
+ fields = Task.read_task_json(path)
50
+ except Exception as err: # noqa: BLE001
51
+ problem = f"The task file can't be read ({err}), so it can only be removed."
52
+ tasks.append(_CourseTask(path, key, None, problem=problem))
53
+ continue
54
+ taken = _names_taken_elsewhere(path, courses)
55
+ tasks.append(_CourseTask(path, fields["name"], fields, taken))
56
+ return {"name": manifest.get("name", ""), "tasks": tasks}, progress
57
+
58
+
59
+ def _edit_course(course_file: PathLike, max_depth: int = 3) -> None:
60
+ """
61
+ Console script to interactively edit a course manifest, going through the
62
+ course-creation flow with every prompt prefilled from the course. Its tasks can be
63
+ kept, edited (rewriting their task files) or removed, and more added like when
64
+ creating a course. The manifest is rewritten in place, and each task keeps its
65
+ progress.
66
+
67
+ The other course manifests below the working directory that include a task that is
68
+ renamed are kept consistent with it, like in ``edit_task``.
69
+
70
+ Parameters
71
+ ----------
72
+ course_file: PathLike
73
+ The ``.pyicscourse.json`` file to edit.
74
+ max_depth: int
75
+ How many directory levels below the working directory are searched for task
76
+ files and course manifests, so the search stays short when run in e.g. the home
77
+ directory.
78
+ """
79
+ course_file = Path(course_file)
80
+ if not course_file.name.endswith(COURSE_SUFFIX):
81
+ sys.exit(f"error: {course_file} is not a course manifest (*{COURSE_SUFFIX})")
82
+ try:
83
+ manifest = json.load_plain(course_file)
84
+ if not isinstance(manifest["tasks"], dict):
85
+ raise TypeError("its tasks are not a mapping of names to task files")
86
+ except Exception as err: # noqa: BLE001
87
+ sys.exit(f"error: can't read the course manifest {course_file}: {err}")
88
+
89
+ with fullscreen():
90
+ cli = CLI()
91
+ cli()
92
+ cli.update_info("Searching for task files and course manifests...")
93
+
94
+ try:
95
+ # searched once up front, since the flow is replayed whenever the user
96
+ # goes back
97
+ found = find_task_files(Path.cwd(), max_depth)
98
+ courses = find_courses(Path.cwd(), max_depth)
99
+ courses.pop(paths.absolute(course_file), None) # rewritten as a whole
100
+ current, progress = _read_course(course_file, manifest, courses)
101
+ info = f"Editing the course {escape(str(current['name']))}..."
102
+ cli.update_info(info)
103
+
104
+ with KeyListener() as q:
105
+ fields = PromptSession(cli, q, info=info).run(
106
+ lambda s: _course_flow(s, found, current)
107
+ )
108
+
109
+ task_files: list[PathLike] = []
110
+ renamed: dict[Path, None] = {} # ordered set
111
+ for path, task_fields in fields["tasks"]:
112
+ if task_fields is not None:
113
+ path = Task.create_task_json(path, **task_fields)
114
+ for fp in _rename_in_courses(path, task_fields["name"], courses):
115
+ renamed[fp] = None
116
+ task_files.append(path.absolute())
117
+
118
+ fp = Course.create_course_manifest(
119
+ course_file,
120
+ task_files,
121
+ name=fields["name"],
122
+ progress=[progress.get(paths.absolute(p), 0) for p in task_files],
123
+ metadata=manifest, # e.g. its requires, which aren't edited here
124
+ )
125
+ message = f"Course written to {fp}"
126
+ if renamed:
127
+ message += f", and tasks renamed in {', '.join(map(str, renamed))}"
128
+ except PromptCancelled:
129
+ message = "Cancelled, the course was not changed."
130
+ finally:
131
+ cli.stop()
132
+ # after leaving the alternate screen, so that it stays visible
133
+ print(message)
@@ -0,0 +1,109 @@
1
+ import sys
2
+ from collections.abc import Mapping
3
+ from os import PathLike
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from rich.markup import escape
8
+
9
+ import pyics.util.json_handling as json
10
+ from pyics.app.cli import CLI
11
+ from pyics.app.key_listener import KeyListener
12
+ from pyics.app.prompts import PromptCancelled, PromptSession
13
+ from pyics.data.task import Task
14
+ from pyics.distribution import archive
15
+ from pyics.scripts.console import warn_differing_notebooks
16
+ from pyics.scripts.create_course import (
17
+ _course_tasks,
18
+ _names_taken_elsewhere,
19
+ _rename_in_courses,
20
+ )
21
+ from pyics.scripts.create_task import _task_flow
22
+ from pyics.util import paths
23
+ from pyics.util.discovery import TASK_SUFFIX, find_courses
24
+ from pyics.util.terminal import fullscreen
25
+
26
+
27
+ def _differing_notebooks(
28
+ task_file: PathLike, courses: Mapping[Path, Mapping[str, Any]]
29
+ ) -> dict[Path, list[Path]]:
30
+ """The exercise notebooks of the task ``task_file`` that other tasks of the
31
+ ``courses`` (see ``find_courses``) that include it share, but store different
32
+ contents for (see ``archive.differing_notebooks``)."""
33
+ task = paths.absolute(task_file)
34
+ tasks: dict[Path, dict[str, Any]] = {}
35
+ for fp, manifest in courses.items():
36
+ task_files = _course_tasks(fp, manifest).values()
37
+ if task not in task_files:
38
+ continue
39
+ for path in task_files:
40
+ try:
41
+ data = tasks.get(path) or json.load_plain(path)
42
+ except Exception: # noqa: BLE001
43
+ continue # its course can't be loaded either
44
+ if isinstance(data, dict):
45
+ tasks[path] = data
46
+ return {
47
+ notebook: sharing
48
+ for notebook, sharing in archive.differing_notebooks(tasks).items()
49
+ if task in sharing
50
+ }
51
+
52
+
53
+ def _edit_task(task_file: PathLike, max_depth: int = 3) -> None:
54
+ """
55
+ Console script to interactively edit a task file, going through the task-creation
56
+ flow with every prompt prefilled from the task. The task file is rewritten in place.
57
+
58
+ The course manifests below the working directory that include the task are kept
59
+ consistent with it: if the task is renamed, so is its entry in them, and its new
60
+ name can't be that of another task in one of them. If other tasks in them share a
61
+ notebook with the task, but store different contents for it, that is warned about.
62
+
63
+ Parameters
64
+ ----------
65
+ task_file: PathLike
66
+ The ``.pyicstask.json`` file to edit.
67
+ max_depth: int
68
+ How many directory levels below the working directory are searched for course
69
+ manifests, so the search stays short when run in e.g. the home directory.
70
+ """
71
+ task_file = Path(task_file)
72
+ if not task_file.name.endswith(TASK_SUFFIX):
73
+ sys.exit(f"error: {task_file} is not a task file (*{TASK_SUFFIX})")
74
+ try:
75
+ current = Task.read_task_json(task_file)
76
+ except Exception as err: # noqa: BLE001
77
+ sys.exit(f"error: can't read the task file {task_file}: {err}")
78
+
79
+ with fullscreen():
80
+ cli = CLI()
81
+ cli()
82
+ cli.update_info("Searching for course manifests...")
83
+
84
+ try:
85
+ # searched once up front, since the flow is replayed whenever the user
86
+ # goes back
87
+ courses = find_courses(Path.cwd(), max_depth)
88
+ taken = _names_taken_elsewhere(task_file, courses)
89
+ info = f"Editing the task {escape(str(current['name']))}..."
90
+ cli.update_info(info)
91
+
92
+ with KeyListener() as q:
93
+ fields = PromptSession(cli, q, info=info).run(
94
+ lambda s: _task_flow(s, taken_names=taken, current=current)
95
+ )
96
+
97
+ Task.create_task_json(task_file, **fields)
98
+ message = f"Task written to {task_file}"
99
+ if renamed := _rename_in_courses(task_file, fields["name"], courses):
100
+ message += f", and renamed in {', '.join(map(str, renamed))}"
101
+ differing = _differing_notebooks(task_file, courses)
102
+ except PromptCancelled:
103
+ message = "Cancelled, the task was not changed."
104
+ differing = {}
105
+ finally:
106
+ cli.stop()
107
+ # after leaving the alternate screen, so that it stays visible
108
+ print(message)
109
+ warn_differing_notebooks(differing, Path.cwd())
pyics/scripts/main.py ADDED
@@ -0,0 +1,118 @@
1
+ import inspect
2
+ from os import PathLike
3
+ from pathlib import Path
4
+
5
+ import jsonargparse
6
+ from jsonargparse import ActionYesNo
7
+
8
+ from pyics.app.cli import CLI
9
+ from pyics.util.logging import setup_logging
10
+ from pyics.util.terminal import fullscreen
11
+ from pyics.app.pyics import PyIC
12
+ from pyics.scripts.create_course import _create_course
13
+ from pyics.scripts.create_task import _create_task
14
+ from pyics.scripts.edit_course import _edit_course
15
+ from pyics.scripts.edit_task import _edit_task
16
+ from pyics.scripts.pack import _build_index, _pack
17
+ from pyics.scripts.pull import _pull, _update
18
+ from pyics.scripts.sources import _add_source, _remove_source, _search, _sources
19
+
20
+
21
+ def main(*, log_level: str | int = 50, log_file: PathLike = Path("./.pyics.log")):
22
+ # This is necessary because otherwise pytest.main() imports it and then subsequently
23
+ # breaks our shutdown mechanic by taking control of tty.
24
+ try:
25
+ import readline
26
+ except ImportError:
27
+ pass
28
+
29
+ setup_logging(log_level, log_file)
30
+
31
+ with fullscreen():
32
+ cli = CLI()
33
+
34
+ pyics = PyIC(cli)
35
+
36
+ try:
37
+ pyics()
38
+ pyics.thread.join()
39
+ finally:
40
+ cli.stop()
41
+ # after leaving the alternate screen, so that it stays visible
42
+ if pyics.exit_message:
43
+ print(pyics.exit_message)
44
+
45
+
46
+ # Subcommands of the ``pyics`` script, run as e.g. ``pyics create_task``. Without one,
47
+ # the app itself is run.
48
+ _SUBCOMMANDS = {
49
+ "create_task": (_create_task, "Interactively create a task file."),
50
+ "create_course": (_create_course, "Interactively create a course manifest."),
51
+ "edit_task": (_edit_task, "Interactively edit a task file."),
52
+ "edit_course": (_edit_course, "Interactively edit a course manifest."),
53
+ "add_source": (_add_source, "Add a course library by the URL of its index."),
54
+ "remove_source": (_remove_source, "Remove a course library."),
55
+ "sources": (_sources, "List the course libraries."),
56
+ "search": (_search, "Find courses in the course libraries."),
57
+ "pull": (_pull, "Pull a course by its name, or a course archive by URL or path."),
58
+ "update": (_update, "Update a pulled course, keeping your work."),
59
+ "pack": (_pack, "Pack a course into an archive, to distribute it."),
60
+ "build_index": (_build_index, "Write the index of a course library."),
61
+ }
62
+
63
+
64
+ # parameters of subcommands that are given as optional positional arguments, e.g.
65
+ # ``pyics search bfs`` rather than ``pyics search --term bfs``
66
+ _OPTIONAL_POSITIONALS = {"term", "directory"}
67
+
68
+
69
+ def _add_subcommand_arguments(parser: jsonargparse.ArgumentParser, fn) -> None:
70
+ """
71
+ Add the parameters of ``fn`` to ``parser``: those without a default, and those of
72
+ ``_OPTIONAL_POSITIONALS``, as positional arguments, boolean ones as flags, e.g.
73
+ ``--yes`` (and ``--no_yes``) rather than ``--yes true``, and lists of strings as
74
+ options that take one or more values, e.g. ``--tags a b`` or ``--tags a --tags b``.
75
+ """
76
+ params = inspect.signature(fn).parameters
77
+ flags = {name for name, param in params.items() if param.annotation is bool}
78
+ lists = {
79
+ name
80
+ for name, param in params.items()
81
+ if param.annotation in (list[str], list[str] | None)
82
+ }
83
+ positionals = _OPTIONAL_POSITIONALS & set(params)
84
+ parser.add_function_arguments(
85
+ fn, as_group=False, as_positional=True, skip=flags | lists | positionals
86
+ )
87
+ for name, param in params.items():
88
+ if name in positionals:
89
+ parser.add_argument(
90
+ name, nargs="?", type=param.annotation, default=param.default
91
+ )
92
+ elif name in flags:
93
+ parser.add_argument(f"--{name}", action=ActionYesNo, default=param.default)
94
+ elif name in lists:
95
+ parser.add_argument(
96
+ f"--{name}", nargs="+", action="extend", type=str, default=param.default
97
+ )
98
+
99
+
100
+ def run():
101
+ parser = jsonargparse.ArgumentParser(prog="pyics")
102
+ parser.add_function_arguments(main, as_group=False)
103
+ subcommands = parser.add_subcommands(required=False)
104
+ for name, (fn, help) in _SUBCOMMANDS.items():
105
+ subparser = jsonargparse.ArgumentParser()
106
+ _add_subcommand_arguments(subparser, fn)
107
+ subcommands.add_subcommand(name, subparser, help=help)
108
+
109
+ cfg = parser.parse_args()
110
+ if cfg.subcommand is None:
111
+ main(log_level=cfg.log_level, log_file=cfg.log_file)
112
+ else:
113
+ fn, _ = _SUBCOMMANDS[cfg.subcommand]
114
+ fn(**cfg[cfg.subcommand].as_dict())
115
+
116
+
117
+ if __name__ == "__main__":
118
+ run()
pyics/scripts/pack.py ADDED
@@ -0,0 +1,198 @@
1
+ from os import PathLike
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from pyics.distribution import archive, dependencies
6
+ from pyics.distribution.archive import ArchiveError
7
+ from pyics.distribution.slugs import check_slug
8
+ from pyics.distribution.sources import SourceError, build_index
9
+ from pyics.scripts.console import confirm, fail, warn_differing_notebooks
10
+ from pyics.util import paths
11
+
12
+
13
+ def _text(value: Any) -> str:
14
+ """A field of a manifest as text, e.g. a list as its items."""
15
+ if isinstance(value, list):
16
+ return ", ".join(map(str, value))
17
+ return str(value)
18
+
19
+
20
+ def _declare_requirements(
21
+ course: archive.CourseFiles, requires: list[str], *, yes: bool
22
+ ) -> list[str] | None:
23
+ """
24
+ Find the packages ``course`` uses that its ``requires`` don't declare, and offer to
25
+ add them (or add them if ``yes``). The lines of its notebooks that install packages
26
+ are shown as hints (see ``archive.pip_installs``).
27
+
28
+ Returns
29
+ -------
30
+ list[str] | None
31
+ Its requires with them added, or None if there are none, or they aren't added.
32
+ """
33
+ base = course.manifest_path.parent
34
+ tasks = {
35
+ paths.relative_to(path, base).as_posix(): data
36
+ for path, data in course.tasks.items()
37
+ }
38
+ used = dependencies.used_modules(tasks, archive.local_modules(course))
39
+ missing = dependencies.undeclared(used, requires)
40
+ if not missing:
41
+ return None
42
+ print("The course uses packages that its requires don't declare:")
43
+ for distribution, files in sorted(missing.items()):
44
+ installed = dependencies.installed_version(distribution)
45
+ note = "" if installed else ", not installed here"
46
+ print(f" {distribution} (in {', '.join(sorted(files))}{note})")
47
+ if installs := archive.pip_installs(course.tasks):
48
+ print("Its notebooks install packages with:")
49
+ for notebook, cell, line in installs:
50
+ where = paths.relative_to(notebook, base).as_posix()
51
+ print(f" {where} (cell {cell}): {line}")
52
+ if yes or confirm("Add them to its requires?", default=True):
53
+ return [*requires, *sorted(missing)]
54
+ print("Students will have to find out themselves which packages to install.")
55
+ return None
56
+
57
+
58
+ def _pack(
59
+ manifest: PathLike,
60
+ out: PathLike | None = None,
61
+ version: str | None = None,
62
+ description: str | None = None,
63
+ slug: str | None = None,
64
+ tags: list[str] | None = None,
65
+ include: list[str] | None = None,
66
+ no_solutions: bool = False,
67
+ yes: bool = False,
68
+ ) -> None:
69
+ """
70
+ Console script to pack a course into an archive, which can be pulled by its path or
71
+ URL, or published in an index (see ``build_index``). The archive holds the course
72
+ manifest and its task files, but not the progress made on the course, nor the
73
+ exercise and solution files, which are created when the course is started.
74
+
75
+ The given version, description, slug and tags are stored in the manifest, so the
76
+ next pack reuses them. So are the packages the course uses that its requires don't
77
+ declare yet, once confirmed (with the ``%pip install`` lines of its notebooks as
78
+ hints). Tasks that share a notebook, but store different contents for it, are
79
+ warned about.
80
+
81
+ Parameters
82
+ ----------
83
+ manifest: PathLike
84
+ The course manifest (``.pyicscourse.json``).
85
+ out: PathLike | None
86
+ Where to write the archive, or the directory to write it to. By default,
87
+ ``./<slug>-<version>.zip``.
88
+ version: str | None
89
+ The course's version, e.g. ``1.2.0``. Give each version you publish a new one,
90
+ so that pulled courses can be updated.
91
+ description: str | None
92
+ A short description, shown when searching for courses.
93
+ slug: str | None
94
+ The name the course is pulled by, e.g. ``search-algorithms``. By default,
95
+ derived from its name when it is first packed.
96
+ tags: list[str] | None
97
+ Tags to find the course by.
98
+ include: list[str] | None
99
+ Other files the course needs, e.g. data its exercises read, or directories of
100
+ them.
101
+ no_solutions: bool
102
+ Leave the solutions out, e.g. for a course that is graded.
103
+ yes: bool
104
+ Don't ask for confirmation.
105
+ """
106
+ try:
107
+ course = archive.read_course(Path(manifest))
108
+ warn_differing_notebooks(
109
+ archive.differing_notebooks(course.tasks), course.manifest_path.parent
110
+ )
111
+ info = course.info
112
+ changes: dict[str, Any] = {}
113
+ if version is not None:
114
+ changes["version"] = version
115
+ if description is not None:
116
+ changes["description"] = description
117
+ if tags is not None:
118
+ changes["tags"] = tags
119
+ if slug is not None:
120
+ changes["slug"] = check_slug(slug, "course slug")
121
+ elif not course.manifest.get("slug"):
122
+ # fixed once the course is published, so that renaming it doesn't change
123
+ # the name it is pulled and updated by
124
+ changes["slug"] = info.slug
125
+ if not course.manifest.get("requires_pyics"):
126
+ changes["requires_pyics"] = f">={dependencies.pyics_version()}"
127
+ requires = _declare_requirements(course, info.requires, yes=yes)
128
+ if requires is not None:
129
+ changes["requires"] = requires
130
+
131
+ if changes:
132
+ archive.update_manifest(course.manifest_path, changes)
133
+ course = archive.read_course(course.manifest_path)
134
+ info = course.info
135
+ print(
136
+ f"Updated {course.manifest_path.name}: "
137
+ + ", ".join(f"{key} {_text(value)}" for key, value in changes.items())
138
+ )
139
+
140
+ name = f"{info.slug}-{info.version}.zip" if info.version else f"{info.slug}.zip"
141
+ out = Path(out) if out is not None else Path(name)
142
+ if out.is_dir():
143
+ out = out / name
144
+ files = archive.pack(
145
+ course,
146
+ out,
147
+ include=[Path(i) for i in include or []],
148
+ no_solutions=no_solutions,
149
+ )
150
+ except (ArchiveError, ValueError, OSError) as err:
151
+ fail(err)
152
+
153
+ version_text = f" {info.version}" if info.version else ""
154
+ print(f"Packed {info.title}{version_text} into {out} ({len(files)} files).")
155
+ if not info.version:
156
+ print("It has no version: give it one with --version, so it can be updated.")
157
+ print("Publish it in an index with: pyics build_index <directory of archives>")
158
+
159
+
160
+ def _build_index(
161
+ directory: PathLike = Path("."),
162
+ out: PathLike | None = None,
163
+ name: str | None = None,
164
+ ) -> None:
165
+ """
166
+ Console script to write the index of a course library (a source): of the course
167
+ archives (see ``pack``) in a directory and its subdirectories, the newest version
168
+ of each course. Upload the directory to a web server, and add the index's URL
169
+ with ``pyics add_source``.
170
+
171
+ Parameters
172
+ ----------
173
+ directory: PathLike
174
+ The directory of the archives. The working directory by default.
175
+ out: PathLike | None
176
+ Where to write the index. By default, ``index.json`` in ``directory``.
177
+ name: str | None
178
+ The library's name, e.g. "Uni Bamberg AI courses". By default, the name of the
179
+ index it replaces, or else of ``directory``.
180
+ """
181
+ try:
182
+ fp, index = build_index(
183
+ Path(directory), Path(out) if out is not None else None, name=name
184
+ )
185
+ except (SourceError, OSError) as err:
186
+ fail(err)
187
+ courses = index["courses"]
188
+ listed = ", ".join(
189
+ f"{slug} {entry['version']}".rstrip() for slug, entry in courses.items()
190
+ )
191
+ count = f"{len(courses)} course" + ("" if len(courses) == 1 else "s")
192
+ print(f"Wrote the index {index['name']!r} of {count} to {fp}.")
193
+ if listed:
194
+ print(f"Courses: {listed}")
195
+ print(
196
+ "Upload the directory to a web server, and add the index's URL with: "
197
+ "pyics add_source <url>"
198
+ )
pyics/scripts/pull.py ADDED
@@ -0,0 +1,123 @@
1
+ from os import PathLike
2
+ from pathlib import Path
3
+
4
+ from pyics.distribution.archive import ArchiveError
5
+ from pyics.distribution.fetch import is_url
6
+ from pyics.distribution.pull import Pulled, PullError, is_direct, pull, update
7
+ from pyics.scripts.console import (
8
+ TRUST_WARNING,
9
+ confirm,
10
+ fail,
11
+ install_requirements,
12
+ report_problems,
13
+ )
14
+ from pyics.util import paths
15
+
16
+ # how many directories below the working directory the course selector looks
17
+ _SELECTOR_DEPTH = 2
18
+
19
+
20
+ def _where_to_start(pulled: Pulled) -> str:
21
+ """How to start the course ``pulled`` from the working directory."""
22
+ try:
23
+ rel = pulled.manifest_path.relative_to(paths.absolute(Path.cwd()))
24
+ except ValueError:
25
+ rel = None
26
+ if rel is not None and len(rel.parent.parts) <= _SELECTOR_DEPTH:
27
+ return "Start it with: pyics"
28
+ return f"Start it by running pyics in {pulled.directory}"
29
+
30
+
31
+ def _finish(pulled: Pulled, *, install_deps: bool, yes: bool) -> None:
32
+ """Report the course's unmet requirements, installing them if asked to."""
33
+ problems = pulled.problems
34
+ if problems and install_deps:
35
+ problems = install_requirements(problems, yes=yes)
36
+ report_problems(pulled.info.title, problems)
37
+
38
+
39
+ def _pull(
40
+ course: str,
41
+ dest: PathLike | None = None,
42
+ install_deps: bool = False,
43
+ allow_http: bool = False,
44
+ yes: bool = False,
45
+ ) -> None:
46
+ """
47
+ Console script to pull a course into a directory of its own, ``./<name>`` by
48
+ default: a course from the course libraries (see ``add_source``) by its name, or
49
+ ``<source>/<name>`` if several have one of that name, or a course archive by its
50
+ URL or path.
51
+
52
+ Parameters
53
+ ----------
54
+ course: str
55
+ The course.
56
+ dest: PathLike | None
57
+ The directory to pull it into, which must be new or empty.
58
+ install_deps: bool
59
+ Install the packages the course needs that aren't installed (after asking).
60
+ allow_http: bool
61
+ Allow downloading a course archive by URL over plain http, which isn't secure.
62
+ yes: bool
63
+ Don't ask for confirmation.
64
+ """
65
+ if is_direct(course) and is_url(course):
66
+ print(TRUST_WARNING)
67
+ if not yes and not confirm(f"Pull the course at {course}?"):
68
+ print("Cancelled, no course was pulled.")
69
+ return
70
+ try:
71
+ pulled = pull(course, Path(dest) if dest else None, allow_http=allow_http)
72
+ except (PullError, ArchiveError, OSError) as err:
73
+ fail(err)
74
+ for warning in pulled.warnings:
75
+ print(f"warning: {warning}")
76
+ version = f" {pulled.info.version}" if pulled.info.version else ""
77
+ print(f"Pulled {pulled.info.title}{version} into {pulled.directory}.")
78
+ _finish(pulled, install_deps=install_deps, yes=yes)
79
+ print(_where_to_start(pulled))
80
+
81
+
82
+ def _update(
83
+ directory: PathLike = Path("."),
84
+ install_deps: bool = False,
85
+ allow_http: bool = False,
86
+ force: bool = False,
87
+ yes: bool = False,
88
+ ) -> None:
89
+ """
90
+ Console script to update a pulled course to its latest version. Your exercise files
91
+ are kept as they are, and so is your progress.
92
+
93
+ Parameters
94
+ ----------
95
+ directory: PathLike
96
+ The course's directory, or a directory in it. The working directory by default.
97
+ install_deps: bool
98
+ Install the packages the course needs that aren't installed (after asking).
99
+ allow_http: bool
100
+ Allow downloading a course pulled by URL over plain http, which isn't secure.
101
+ force: bool
102
+ Update the course even if it is up to date, e.g. to restore its files.
103
+ yes: bool
104
+ Don't ask for confirmation.
105
+ """
106
+ try:
107
+ updated = update(Path(directory), allow_http=allow_http, force=force)
108
+ except (PullError, ArchiveError, OSError) as err:
109
+ fail(err)
110
+ title = updated.info.title
111
+ if updated.up_to_date:
112
+ version = f" ({updated.info.version})" if updated.info.version else ""
113
+ print(f"{title} is up to date{version}.")
114
+ else:
115
+ old, new = updated.old_version, updated.info.version
116
+ versions = f" from {old} to {new}" if old and new and old != new else ""
117
+ print(f"Updated {title}{versions}. Your exercise files were kept.")
118
+ if updated.kept:
119
+ print(
120
+ "These files of the new version weren't written, to keep yours: "
121
+ + ", ".join(updated.kept)
122
+ )
123
+ _finish(updated, install_deps=install_deps, yes=yes)