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,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
|
pyics/scripts/console.py
ADDED
|
@@ -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()
|