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