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,189 @@
1
+ """
2
+ Sections of a notebook, which link a task to the part of a notebook it is about, e.g.
3
+ when several tasks share one (lecture-style) notebook.
4
+
5
+ A section starts at its **anchor** cell, and runs up to just before the next section's
6
+ anchor (or to the end of the notebook). A cell is the anchor of the section ``<name>``
7
+
8
+ - if it is tagged ``pyics:<name>`` (in its ``metadata.tags``), or
9
+ - if its first line is the marker ``# pyics: <name>`` (a code cell) or
10
+ ``<!-- pyics: <name> -->`` (a markdown cell).
11
+
12
+ Tags are Jupyter's own way to mark cells, but are hidden in most frontends. Markers are
13
+ visible everywhere, and students can restore one they deleted. Authors usually anchor a
14
+ section on the exercise's description (a markdown cell), which students have no reason
15
+ to change: the cells they add below it belong to the section.
16
+
17
+ Section names consist of letters, digits, ``_``, ``.`` and ``-`` (see ``NAME_PATTERN``),
18
+ e.g. ``ex2``. If several cells start a section of the same name (e.g. after an anchor
19
+ cell was copied), the first of them does, and the others are ordinary cells of the
20
+ section they are in. A cell can start several sections, which then have the same cells.
21
+ Cells above the first anchor belong to no section.
22
+ """
23
+
24
+ import re
25
+ from dataclasses import dataclass
26
+ from typing import Any
27
+
28
+ from pyics.notebook import cells
29
+ from pyics.notebook.cells import Cell
30
+
31
+ # the prefix of the tags that start a section: ``pyics:<name>`` in ``metadata.tags``
32
+ TAG_PREFIX = "pyics:"
33
+ # what a section's name may consist of: letters, digits, "_", "." and "-"
34
+ NAME_PATTERN = r"[\w.-]+"
35
+
36
+ _NAME = re.compile(NAME_PATTERN)
37
+ # the marker on the first line of a code cell, and of a markdown cell
38
+ _CODE_MARKER = re.compile(rf"#\s*pyics:\s*({NAME_PATTERN})\s*")
39
+ _MARKDOWN_MARKER = re.compile(rf"<!--\s*pyics:\s*({NAME_PATTERN})\s*-->\s*")
40
+ # markdown headings (CommonMark): an ATX heading ("## Title", with an optional closing
41
+ # sequence of "#"), the underline of a setext heading ("Title" above "=====" or
42
+ # "-----"), and the fence of a code block, whose lines aren't headings
43
+ _ATX_HEADING = re.compile(r" {0,3}#{1,6}(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*")
44
+ _SETEXT_UNDERLINE = re.compile(r" {0,3}(?:=+|-+)[ \t]*")
45
+ _FENCE = re.compile(r" {0,3}(`{3,}|~{3,})")
46
+
47
+
48
+ class SectionNotFound(LookupError):
49
+ """A notebook has no section of a name, e.g. because the cell that started it was
50
+ removed, or its tag changed."""
51
+
52
+ def __init__(self, name: str):
53
+ self.name = name
54
+ super().__init__(
55
+ f"Section '{name}' not found: the cell that starts it (tagged "
56
+ f"'{TAG_PREFIX}{name}', or starting with the marker '# pyics: {name}') was "
57
+ "removed or its tag changed."
58
+ )
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Section:
63
+ """A section of a notebook (see the module's docstring)."""
64
+
65
+ name: str
66
+ # the first markdown heading of its anchor cell (as written, without the "#"
67
+ # markers), or its name if that cell has no heading
68
+ title: str
69
+ # the cell that starts it
70
+ anchor: Cell
71
+ # its cells, from its anchor to just before the next section's anchor, in order
72
+ cells: list[Cell]
73
+
74
+ @property
75
+ def code_cells(self) -> list[Cell]:
76
+ """Its code cells, in order."""
77
+ return [cell for cell in self.cells if cell.cell_type == "code"]
78
+
79
+
80
+ def is_name(name: object) -> bool:
81
+ """Whether ``name`` can be the name of a section (see ``NAME_PATTERN``)."""
82
+ return isinstance(name, str) and _NAME.fullmatch(name) is not None
83
+
84
+
85
+ def check_name(name: object) -> str:
86
+ """
87
+ Return ``name`` if it can be the name of a section, raise otherwise.
88
+
89
+ Raises
90
+ ------
91
+ ValueError
92
+ If ``name`` isn't a string of letters, digits, "_", "." and "-".
93
+ """
94
+ if not is_name(name):
95
+ raise ValueError(
96
+ f"{name!r} can't be the name of a section: it may only consist of letters, "
97
+ "digits, '_', '.' and '-'"
98
+ )
99
+ assert isinstance(name, str)
100
+ return name
101
+
102
+
103
+ def anchor_names(cell: Cell) -> list[str]:
104
+ """The names of the sections ``cell`` starts by its tags and its marker (see the
105
+ module's docstring), in this order, whether or not an earlier cell starts them."""
106
+ names = [
107
+ tag.removeprefix(TAG_PREFIX)
108
+ for tag in cell.tags
109
+ if tag.startswith(TAG_PREFIX) and is_name(tag.removeprefix(TAG_PREFIX))
110
+ ]
111
+ first_line = cell.source.split("\n", 1)[0].strip()
112
+ marker = {"code": _CODE_MARKER, "markdown": _MARKDOWN_MARKER}.get(cell.cell_type)
113
+ if marker is not None and (match := marker.fullmatch(first_line)):
114
+ names.append(match[1])
115
+ return list(dict.fromkeys(names))
116
+
117
+
118
+ def heading(cell: Cell) -> str | None:
119
+ """The text of the first heading of the markdown cell ``cell``, as written but
120
+ without the "#" markers, or None if it has none (or isn't a markdown cell)."""
121
+ if cell.cell_type != "markdown":
122
+ return None
123
+ fence: str | None = None
124
+ previous = ""
125
+ for line in cell.source.splitlines():
126
+ if (opening := _FENCE.match(line)) is not None:
127
+ if fence is None:
128
+ fence = opening[1][0] * 3
129
+ elif opening[1].startswith(fence):
130
+ fence = None
131
+ previous = ""
132
+ continue
133
+ if fence is not None:
134
+ continue
135
+ if (atx := _ATX_HEADING.fullmatch(line)) is not None:
136
+ if text := (atx[1] or "").strip():
137
+ return text
138
+ elif _SETEXT_UNDERLINE.fullmatch(line) and _paragraph(previous):
139
+ return previous.strip()
140
+ previous = line
141
+ return None
142
+
143
+
144
+ def _paragraph(line: str) -> bool:
145
+ """Whether ``line`` can be the text of a setext heading, rather than e.g. blank, a
146
+ list item, or a marker."""
147
+ text = line.strip()
148
+ return bool(text) and not text.startswith(("#", "<", "-", "*", "+", ">", "|"))
149
+
150
+
151
+ def _find(all_cells: list[Cell]) -> dict[str, Section]:
152
+ """The sections of a notebook of ``all_cells`` (see ``find_sections``)."""
153
+ # the position of each anchor among all cells, and the sections it starts
154
+ anchors: list[tuple[int, list[str]]] = []
155
+ seen: set[str] = set()
156
+ for position, cell in enumerate(all_cells):
157
+ # a name taken by an earlier cell doesn't make this one an anchor
158
+ if names := [name for name in anchor_names(cell) if name not in seen]:
159
+ seen.update(names)
160
+ anchors.append((position, names))
161
+
162
+ found: dict[str, Section] = {}
163
+ for i, (start, names) in enumerate(anchors):
164
+ end = anchors[i + 1][0] if i + 1 < len(anchors) else len(all_cells)
165
+ anchor = all_cells[start]
166
+ title = heading(anchor)
167
+ for name in names:
168
+ found[name] = Section(name, title or name, anchor, all_cells[start:end])
169
+ return found
170
+
171
+
172
+ def find_sections(nb: dict[str, Any]) -> dict[str, Section]:
173
+ """The sections of the notebook ``nb`` (as read by ``cells.parse``), by name, in the
174
+ order they start in."""
175
+ return _find(cells.cells(nb))
176
+
177
+
178
+ def get_section(nb: dict[str, Any], name: str) -> Section:
179
+ """
180
+ The section ``name`` of the notebook ``nb`` (as read by ``cells.parse``).
181
+
182
+ Raises
183
+ ------
184
+ SectionNotFound
185
+ If the notebook has no section ``name``.
186
+ """
187
+ if (section := find_sections(nb).get(name)) is None:
188
+ raise SectionNotFound(name)
189
+ return section
pyics/plugins.py ADDED
@@ -0,0 +1,90 @@
1
+ """
2
+ Plugins: packages installed next to pyics that extend it through pluggy hooks (see
3
+ ``HookSpecs``). A plugin registers a module or object with ``hookimpl``-marked functions
4
+ as an entry point of the group "pyics", e.g. in its ``pyproject.toml``::
5
+
6
+ [project.entry-points.pyics]
7
+ jupyter = "pyics_jupyter.plugin"
8
+
9
+ e.g. ``pyics-jupyter``, which shows the results of tests run in a notebook's kernel
10
+ whenever a cell runs.
11
+ """
12
+
13
+ import functools
14
+ from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING, Protocol
16
+
17
+ import pluggy
18
+ from rich.console import RenderableType
19
+
20
+ from pyics.tests.results import TestResult
21
+
22
+ if TYPE_CHECKING:
23
+ from pyics.data.task import Task
24
+
25
+ hookspec = pluggy.HookspecMarker("pyics")
26
+ hookimpl = pluggy.HookimplMarker("pyics")
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Overlay:
31
+ """What a task's status shows instead of the results of its saved files (see
32
+ ``ResultsOverlay.apply``)."""
33
+
34
+ # the results to show, e.g. the saved ones, of which some are replaced by newer
35
+ # ones, relabelled by their nodeid (e.g. "::adds (live)")
36
+ results: list[TestResult]
37
+ # lines shown below the result summary, e.g. where the results come from
38
+ lines: list[RenderableType] = field(default_factory=list)
39
+
40
+
41
+ class ResultsOverlay(Protocol):
42
+ """
43
+ Results of a task's tests that the TUI shows over those of its saved files, e.g.
44
+ from runs on code that isn't saved yet (see ``HookSpecs.pyics_results_overlay``).
45
+ They never count as progress: whether a task is solved only depends on its saved
46
+ files.
47
+ """
48
+
49
+ def poll(self) -> bool:
50
+ """Whether there are new results since the last call, so that the task's
51
+ status is updated (see ``Task.check_overlay``). Called at every tick of the
52
+ TUI, so it must be cheap."""
53
+ ...
54
+
55
+ def apply(self, saved: list[TestResult], saved_time: float | None) -> Overlay:
56
+ """
57
+ What the task's status shows, given the results of its ``saved`` files, which
58
+ were saved at ``saved_time`` (a ``time.time()``, see
59
+ ``Task.test_results_time``).
60
+ """
61
+ ...
62
+
63
+
64
+ class HookSpecs:
65
+ """The hooks pyics calls, which plugins implement with ``hookimpl``."""
66
+
67
+ @hookspec(firstresult=True)
68
+ def pyics_results_overlay(
69
+ self,
70
+ # quoted, as pluggy reads the signature, which evaluates the annotations
71
+ task: "Task", # noqa: UP037
72
+ ) -> ResultsOverlay | None:
73
+ """
74
+ The results overlay of ``task``, or None if the plugin has none for it. Called
75
+ once for each task the TUI shows, on the TUI's thread. The first plugin that
76
+ returns an overlay wins.
77
+ """
78
+
79
+
80
+ @functools.cache
81
+ def manager() -> pluggy.PluginManager:
82
+ """
83
+ The plugin manager, which loads the installed plugins (the entry points of the group
84
+ "pyics") the first time it is asked for. Tests register their own plugins with it,
85
+ and forget it with ``manager.cache_clear()``.
86
+ """
87
+ plugin_manager = pluggy.PluginManager("pyics")
88
+ plugin_manager.add_hookspecs(HookSpecs)
89
+ plugin_manager.load_setuptools_entrypoints("pyics")
90
+ return plugin_manager
@@ -0,0 +1,86 @@
1
+ """
2
+ Plain console input and output for the scripts that don't run in the fullscreen
3
+ interface, e.g. ``pull``.
4
+ """
5
+
6
+ import sys
7
+ from collections.abc import Mapping, Sequence
8
+ from pathlib import Path
9
+ from typing import NoReturn
10
+
11
+ from pyics.distribution import dependencies
12
+ from pyics.distribution.dependencies import Problem
13
+ from pyics.util import paths
14
+
15
+ TRUST_WARNING = (
16
+ "Courses run code on your computer: their tests run your code together with "
17
+ "theirs. Only use courses from people you trust."
18
+ )
19
+
20
+
21
+ def fail(message: object) -> NoReturn:
22
+ """Exit with ``message`` as the error."""
23
+ sys.exit(f"error: {message}")
24
+
25
+
26
+ def confirm(question: str, *, default: bool = False) -> bool:
27
+ """Ask a yes/no ``question``. Without an answer (e.g. without a terminal to ask
28
+ in), the ``default``."""
29
+ suffix = " [Y/n] " if default else " [y/N] "
30
+ try:
31
+ answer = input(question + suffix).strip().lower()
32
+ except EOFError:
33
+ print()
34
+ return default
35
+ if not answer:
36
+ return default
37
+ return answer in ("y", "yes")
38
+
39
+
40
+ def warn_differing_notebooks(
41
+ differing: Mapping[Path, Sequence[Path]], base: Path
42
+ ) -> None:
43
+ """Warn that tasks share notebooks, but store different contents for them (see
44
+ ``archive.differing_notebooks``). Paths are shown relative to ``base``."""
45
+ for notebook, task_files in differing.items():
46
+ tasks = ", ".join(paths.relative_to(f, base).as_posix() for f in task_files)
47
+ print(
48
+ f"Warning: the tasks {tasks} share the notebook "
49
+ f"{paths.relative_to(notebook, base).as_posix()}, but store different "
50
+ "contents for it. Students get the contents of the task they start first: "
51
+ "store the same notebook in all of them (choose Update for it in "
52
+ "edit_task)."
53
+ )
54
+
55
+
56
+ def report_problems(title: str, problems: Sequence[Problem]) -> None:
57
+ """Tell which requirements of the course ``title`` aren't met, and how to meet
58
+ them."""
59
+ if problems:
60
+ print(f"{title} needs {dependencies.describe(problems)}.")
61
+ print(dependencies.install_hint(problems))
62
+
63
+
64
+ def install_requirements(
65
+ problems: Sequence[Problem], *, yes: bool = False
66
+ ) -> list[Problem]:
67
+ """
68
+ Install the requirements of ``problems`` that can be installed, if the user agrees
69
+ (or ``yes``).
70
+
71
+ Returns
72
+ -------
73
+ list[Problem]
74
+ The problems that remain.
75
+ """
76
+ requirements = [p.requirement for p in problems if p.installable]
77
+ if not requirements:
78
+ return list(problems)
79
+ command = dependencies.format_command(dependencies.install_command(requirements))
80
+ if not yes and not confirm(f"Install {', '.join(requirements)} with: {command}?"):
81
+ return list(problems)
82
+ if dependencies.install(requirements) != 0:
83
+ print(f"Installing {', '.join(requirements)} failed.")
84
+ return dependencies.check([p.requirement for p in problems if p.installable]) + [
85
+ p for p in problems if not p.installable
86
+ ]
@@ -0,0 +1,301 @@
1
+ from collections.abc import Mapping
2
+ from os import PathLike
3
+ from pathlib import Path
4
+ from typing import Any, NamedTuple
5
+
6
+ from rich.console import Group
7
+ from rich.markup import escape
8
+ from rich.text import Text
9
+
10
+ import pyics.util.json_handling as json
11
+ from pyics.app.cli import CLI
12
+ from pyics.app.key_listener import KeyListener
13
+ from pyics.app.prompts import (
14
+ PathInput,
15
+ PromptCancelled,
16
+ PromptSession,
17
+ ReorderInput,
18
+ SelectInput,
19
+ TextInput,
20
+ )
21
+ from pyics.data.course import Course
22
+ from pyics.data.task import Task
23
+ from pyics.scripts.create_task import _EDIT, _KEEP, _REMOVE, _check_name, _task_flow
24
+ from pyics.util import paths
25
+ from pyics.util.discovery import TASK_SUFFIX, find_task_files
26
+ from pyics.util.terminal import fullscreen
27
+
28
+ _TOTAL_STEPS = 3
29
+ _INFO = "Creating a new course..."
30
+
31
+ _ADD_EXISTING = "Add an existing task"
32
+ _ADD_NEW = "Create a new task"
33
+ _NO_MORE_TASKS = "Done"
34
+
35
+ _TASK_HINTS = {
36
+ _KEEP: "Keep the task as it is.",
37
+ _EDIT: "Go through the task-creation flow, prefilled with the task. Its task file is "
38
+ "rewritten once the course is done.",
39
+ _REMOVE: "Remove the task from the course. Its task file is kept.",
40
+ }
41
+
42
+ # A task of the course: its file path, and for tasks created or edited in the flow (not
43
+ # yet written), the keyword arguments for ``Task.create_task_json``.
44
+ _Entry = tuple[Path, dict[str, Any] | None]
45
+
46
+
47
+ class _CourseTask(NamedTuple):
48
+ """A task of a course that is being edited."""
49
+
50
+ path: Path
51
+ # the task's name, or its name in the course manifest if its task file can't be read
52
+ name: str
53
+ # the task's fields (see ``Task.read_task_json``), None if its task file can't be read
54
+ fields: dict[str, Any] | None
55
+ # names the task can't be renamed to: those of the other tasks in other courses that
56
+ # include it
57
+ taken_elsewhere: frozenset[str] = frozenset()
58
+ # why the task file can't be read
59
+ problem: str = ""
60
+
61
+
62
+ def _course_tasks(fp: Path, manifest: Mapping[str, Any]) -> dict[str, Path]:
63
+ """The tasks of the course ``manifest`` read from ``fp``, by name, with their task
64
+ files' absolute paths."""
65
+ base = Path(fp).parent
66
+ return {
67
+ k: paths.absolute(paths.resolve(v, base)) for k, v in manifest["tasks"].items()
68
+ }
69
+
70
+
71
+ def _names_taken_elsewhere(
72
+ task_file: PathLike, courses: Mapping[Path, Mapping[str, Any]]
73
+ ) -> frozenset[str]:
74
+ """The names of the other tasks in the ``courses`` (see ``find_courses``) that
75
+ include ``task_file``, which the task can't be renamed to."""
76
+ task = paths.absolute(task_file)
77
+ taken: set[str] = set()
78
+ for fp, manifest in courses.items():
79
+ tasks = _course_tasks(fp, manifest)
80
+ if task in tasks.values():
81
+ taken.update(k for k, v in tasks.items() if v != task)
82
+ return frozenset(taken)
83
+
84
+
85
+ def _rename_in_courses(
86
+ task_file: PathLike, name: str, courses: dict[Path, dict[str, Any]]
87
+ ) -> list[Path]:
88
+ """
89
+ Rename the task ``task_file`` to ``name`` in the ``courses`` (see ``find_courses``)
90
+ that include it under another name, keeping its position in them, and rewrite their
91
+ manifests. ``courses`` is updated to match.
92
+
93
+ Returns
94
+ -------
95
+ list[Path]
96
+ The rewritten manifests.
97
+ """
98
+ task = paths.absolute(task_file)
99
+ renamed: list[Path] = []
100
+ for fp, manifest in courses.items():
101
+ tasks = _course_tasks(fp, manifest)
102
+ if not any(v == task and k != name for k, v in tasks.items()):
103
+ continue
104
+ manifest["tasks"] = {
105
+ name if tasks[k] == task else k: v for k, v in manifest["tasks"].items()
106
+ }
107
+ # serialized before the file is opened, so it isn't left empty if that fails
108
+ text = json.dumps(manifest, indent=4)
109
+ with open(fp, "w", encoding="utf-8") as f:
110
+ f.write(text)
111
+ renamed.append(fp)
112
+ return renamed
113
+
114
+
115
+ def _prompt_task_file(
116
+ s: PromptSession, found: dict[Path, str], tasks: dict[str, _Entry]
117
+ ) -> Path:
118
+ """Ask for one of the ``found`` task files that isn't in the course yet. Its task name
119
+ must be new to the course, too."""
120
+ selected = {paths.absolute(p) for p, _ in tasks.values()}
121
+ labels = {
122
+ f"{n} ({p})": p for p, n in found.items() if paths.absolute(p) not in selected
123
+ }
124
+
125
+ def check(label: str) -> Path:
126
+ _check_name(found[labels[label]], tasks)
127
+ return labels[label]
128
+
129
+ return s.ask(SelectInput("Task file", list(labels)), check)
130
+
131
+
132
+ def _check_path(raw: str, tasks: Mapping[str, _Entry]) -> Path:
133
+ """Return ``raw`` as a path if a new task file can be written there, raise
134
+ otherwise."""
135
+ path = Path(raw)
136
+ if not path.name or path.is_dir():
137
+ raise ValueError(f"{raw!r} is not a file path")
138
+ if paths.absolute(path) in {paths.absolute(p) for p, _ in tasks.values()}:
139
+ raise ValueError(f"the course already has a task at {raw!r}")
140
+ return path
141
+
142
+
143
+ def _prompt_tasks(
144
+ s: PromptSession, found: dict[Path, str], current: list[_CourseTask] | None = None
145
+ ) -> dict[str, _Entry]:
146
+ """
147
+ Interactively collect at least one task, each either one of the ``found`` task files
148
+ or created on the fly with the task-creation flow. When editing a course, its
149
+ ``current`` tasks are kept, edited or removed first, and adding more is not
150
+ preselected.
151
+
152
+ Returns
153
+ -------
154
+ dict[str, _Entry]
155
+ The tasks by name, in the order they were added.
156
+ """
157
+ tasks: dict[str, _Entry] = {}
158
+ for i, task in enumerate(current or [], 1):
159
+ s.cli.update_progress(1 / _TOTAL_STEPS)
160
+
161
+ options, problem = [_KEEP, _EDIT, _REMOVE], task.problem
162
+ if task.fields is None:
163
+ options = [_REMOVE]
164
+ elif task.name in tasks:
165
+ options = [_EDIT, _REMOVE]
166
+ problem = (
167
+ f"Another task of the course is named {task.name!r}, so the task needs "
168
+ "to be renamed or removed."
169
+ )
170
+ lines = [Text(problem, style="red")] if problem else []
171
+ lines.append(Text(str(task.path), style="dim"))
172
+
173
+ label = f"Task {i} of {len(current or [])}: {escape(task.name)}"
174
+ details = Group(*lines)
175
+ choice = s.ask(SelectInput(label, options, hints=_TASK_HINTS, details=details))
176
+ if choice == _REMOVE:
177
+ continue
178
+
179
+ fields = None
180
+ if choice == _EDIT:
181
+ # not the name of another task of the course, including those still to come
182
+ # (which are most likely kept), nor of one in another course with the task
183
+ taken = {*tasks, *(t.name for t in (current or [])[i:])}
184
+ taken |= task.taken_elsewhere
185
+ fields = _task_flow(s, taken_names=taken, current=task.fields)
186
+ tasks[fields["name"] if fields else task.name] = (task.path, fields)
187
+
188
+ hints = {
189
+ _ADD_EXISTING: "Pick one of the task files found below the working directory.",
190
+ _ADD_NEW: "Define a new task, written to a task file once the course is done.",
191
+ }
192
+ while True:
193
+ # the task-creation flow moves the progress bar through its own steps
194
+ s.cli.update_progress(1 / _TOTAL_STEPS)
195
+
196
+ # offered only while some found task file isn't in the course yet
197
+ selected = {paths.absolute(p) for p, _ in tasks.values()}
198
+ unselected = any(paths.absolute(p) not in selected for p in found)
199
+ options = [_ADD_EXISTING] if unselected else []
200
+ options.append(_ADD_NEW)
201
+ if tasks: # a course can't be empty
202
+ options.append(_NO_MORE_TASKS)
203
+ hints[_NO_MORE_TASKS] = f"Finish adding tasks ({len(tasks)} added so far)."
204
+
205
+ prompt = SelectInput("How do you want to add a task?", options, hints=hints)
206
+ if current is not None:
207
+ prompt.set_answer(_NO_MORE_TASKS)
208
+ kind = s.ask(prompt)
209
+ if kind == _NO_MORE_TASKS:
210
+ return tasks
211
+
212
+ if kind == _ADD_EXISTING:
213
+ path = _prompt_task_file(s, found, tasks)
214
+ tasks[found[path]] = (path, None)
215
+ continue
216
+
217
+ fields = _task_flow(s, taken_names=tasks)
218
+ prompt = PathInput(
219
+ "Task file path", initial=f"./{fields['name']}{TASK_SUFFIX}"
220
+ )
221
+ tasks[fields["name"]] = (s.ask(prompt, lambda p: _check_path(p, tasks)), fields)
222
+
223
+
224
+ def _course_flow(
225
+ s: PromptSession,
226
+ found: dict[Path, str],
227
+ current: Mapping[str, Any] | None = None,
228
+ ) -> dict[str, Any]:
229
+ """
230
+ The full course-creation flow. Replayed by ``PromptSession`` whenever the user goes
231
+ back, so it must ask the same prompts given the same answers, and must not write any
232
+ files.
233
+
234
+ With ``current``, the ``name`` and ``tasks`` (as ``_CourseTask``) of an existing
235
+ course, the course is edited instead: every prompt is prefilled with its current
236
+ value, so that confirming all of them leaves the course as it was.
237
+
238
+ Returns
239
+ -------
240
+ dict[str, Any]
241
+ The course ``name``, and its ``tasks`` as ``_Entry`` tuples, in course order.
242
+ """
243
+ s.cli.update_progress(0 / _TOTAL_STEPS)
244
+ prompt = TextInput("Course name", initial=current["name"] if current else "")
245
+ name = s.ask(prompt, lambda n: _check_name(n, ()))
246
+
247
+ tasks = _prompt_tasks(s, found, current["tasks"] if current else None)
248
+
249
+ s.cli.update_progress(2 / _TOTAL_STEPS)
250
+ order = list(tasks)
251
+ if len(order) > 1:
252
+ order = s.ask(ReorderInput("Task order", order))
253
+
254
+ s.cli.update_progress(3 / _TOTAL_STEPS)
255
+ return {"name": name, "tasks": [tasks[n] for n in order]}
256
+
257
+
258
+ def _create_course(fp: PathLike | None = None, max_depth: int = 3) -> None:
259
+ """
260
+ Console script to collect task files and construct a course manifest from them.
261
+ Tasks are either picked from the task files below the working directory, or created
262
+ on the fly.
263
+
264
+ Parameters
265
+ ----------
266
+ fp: PathLike
267
+ Output path for the resulting ``.pyicscourse.json`` file.
268
+ max_depth: int
269
+ How many directory levels below the working directory are searched for task
270
+ files, so the search stays short when run in e.g. the home directory.
271
+ """
272
+ with fullscreen():
273
+ cli = CLI()
274
+ cli()
275
+ cli.update_info("Searching for task files...")
276
+
277
+ try:
278
+ # searched once up front, since the flow is replayed whenever the user
279
+ # goes back
280
+ found = find_task_files(Path.cwd(), max_depth)
281
+ cli.update_info(_INFO)
282
+
283
+ with KeyListener() as q:
284
+ fields = PromptSession(cli, q, info=_INFO).run(
285
+ lambda s: _course_flow(s, found)
286
+ )
287
+
288
+ task_files: list[PathLike] = []
289
+ for path, task_fields in fields["tasks"]:
290
+ if task_fields is not None:
291
+ path = Task.create_task_json(path, **task_fields)
292
+ task_files.append(path.absolute())
293
+
294
+ fp = Course.create_course_manifest(
295
+ fp or Path(f"./{fields['name']}"), task_files, name=fields["name"]
296
+ )
297
+ cli.update_info(f"Course written to {escape(str(fp))}")
298
+ except PromptCancelled:
299
+ cli.update_info("Cancelled, no course was created.")
300
+ finally:
301
+ cli.stop()