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,850 @@
|
|
|
1
|
+
import builtins
|
|
2
|
+
import copy
|
|
3
|
+
import difflib
|
|
4
|
+
import inspect
|
|
5
|
+
import reprlib
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Collection, Mapping
|
|
8
|
+
from os import PathLike
|
|
9
|
+
from pathlib import Path, PurePath
|
|
10
|
+
from types import ModuleType
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from rich.console import Group, RenderableType
|
|
14
|
+
from rich.markup import escape
|
|
15
|
+
from rich.syntax import Syntax
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
import pyics.util.json_handling as json
|
|
19
|
+
from pyics.app.cli import CLI
|
|
20
|
+
from pyics.app.key_listener import KeyListener
|
|
21
|
+
from pyics.app.prompts import (
|
|
22
|
+
CodeInput,
|
|
23
|
+
PathInput,
|
|
24
|
+
PromptCancelled,
|
|
25
|
+
PromptSession,
|
|
26
|
+
SelectInput,
|
|
27
|
+
TextInput,
|
|
28
|
+
)
|
|
29
|
+
from pyics.data.task import Task
|
|
30
|
+
from pyics.notebook import cells, loader, sections
|
|
31
|
+
from pyics.notebook.sections import Section
|
|
32
|
+
from pyics.tests import Test, check_specs, check_structure
|
|
33
|
+
from pyics.tests.tests import (
|
|
34
|
+
INHERIT,
|
|
35
|
+
Inherit,
|
|
36
|
+
PrintTest,
|
|
37
|
+
SimpleTest,
|
|
38
|
+
SpecTest,
|
|
39
|
+
StructureTest,
|
|
40
|
+
)
|
|
41
|
+
from pyics.util import paths
|
|
42
|
+
from pyics.util.terminal import fullscreen
|
|
43
|
+
|
|
44
|
+
_TOTAL_STEPS = 6
|
|
45
|
+
_INFO = "Creating a new task..."
|
|
46
|
+
_ENTER_TEXT = "Enter text"
|
|
47
|
+
|
|
48
|
+
# Test types the CLI can create. Their constructor parameters are prompted for by
|
|
49
|
+
# ``_prompt_test``, and the first line of their docstring is shown as a hint.
|
|
50
|
+
_TEST_TYPES: list[type[Test]] = [SimpleTest, SpecTest, StructureTest, PrintTest]
|
|
51
|
+
_NO_MORE_TESTS = "Done"
|
|
52
|
+
|
|
53
|
+
# What to do with a file or test of a task that is being edited. Keeping is preselected,
|
|
54
|
+
# so that confirming every prompt leaves the task as it was.
|
|
55
|
+
_KEEP = "Keep"
|
|
56
|
+
_UPDATE = "Update"
|
|
57
|
+
_REPLACE = "Replace"
|
|
58
|
+
_EDIT = "Edit"
|
|
59
|
+
_REMOVE = "Remove"
|
|
60
|
+
_TEST_HINTS = {
|
|
61
|
+
_KEEP: "Keep the test as it is.",
|
|
62
|
+
_EDIT: "Go through the test's parameters, prefilled with its current values.",
|
|
63
|
+
_REMOVE: "Remove the test from the task.",
|
|
64
|
+
}
|
|
65
|
+
_HINT_ACTIONS = {
|
|
66
|
+
_KEEP: "Keep the hint as it is.",
|
|
67
|
+
_EDIT: "Change the hint, prefilled with its current text.",
|
|
68
|
+
_REMOVE: "Remove the hint from the task.",
|
|
69
|
+
}
|
|
70
|
+
# lines of a diff shown when a file on disk differs from its contents in the task file
|
|
71
|
+
_MAX_DIFF_LINES = 30
|
|
72
|
+
# name of the variable that holds a parameter's current value when editing a test, if
|
|
73
|
+
# its repr can't be used to prefill the input
|
|
74
|
+
_CURRENT = "current"
|
|
75
|
+
# the options for the section of a task, and of a test, that isn't one of the
|
|
76
|
+
# notebook's sections
|
|
77
|
+
_NO_SECTION = "No section (whole notebook)"
|
|
78
|
+
_WHOLE_NOTEBOOK = "Whole notebook"
|
|
79
|
+
# the module a notebook with test information runs as (see ``_load_namespace``)
|
|
80
|
+
_INFO_MODULE = "pyics_test_information"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _confirm(s: PromptSession, label: str, *, default: bool = True) -> bool:
|
|
84
|
+
prompt = SelectInput(label, ["yes", "no"])
|
|
85
|
+
prompt.set_answer("yes" if default else "no")
|
|
86
|
+
return s.ask(prompt) == "yes"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _check_file(raw: str) -> Path:
|
|
90
|
+
"""Return ``raw`` as a path (with ``~`` expanded) if it is an existing file, raise
|
|
91
|
+
otherwise."""
|
|
92
|
+
path = Path(raw).expanduser()
|
|
93
|
+
if not path.is_file():
|
|
94
|
+
raise FileNotFoundError(f"no file at {raw!r}")
|
|
95
|
+
return path
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _check_new_file(raw: str, files: Collection[Path]) -> Path:
|
|
99
|
+
"""Return ``raw`` as a path if it is an existing file that isn't one of ``files``,
|
|
100
|
+
raise otherwise."""
|
|
101
|
+
path = _check_file(raw)
|
|
102
|
+
if paths.absolute(path) in {paths.absolute(f) for f in files}:
|
|
103
|
+
raise ValueError(f"{raw!r} is already one of the task's files")
|
|
104
|
+
return path
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _prompt_files(
|
|
108
|
+
s: PromptSession, kind: str, kept: list[Path] | None = None
|
|
109
|
+
) -> list[Path]:
|
|
110
|
+
"""
|
|
111
|
+
Interactively collect a list of existing ``kind`` (e.g. "exercise") file paths, one
|
|
112
|
+
at a time. When editing a task, the list starts with the task's ``kept`` files, and
|
|
113
|
+
adding more is not preselected.
|
|
114
|
+
"""
|
|
115
|
+
files = list(kept or [])
|
|
116
|
+
editing = kept is not None
|
|
117
|
+
|
|
118
|
+
# we do not need a solution file necessarily, nor more than one exercise file.
|
|
119
|
+
if files or kind == "solution":
|
|
120
|
+
label = f"Add another {kind} file?" if files else f"Add a {kind} file?"
|
|
121
|
+
if not _confirm(s, label, default=not editing):
|
|
122
|
+
return files
|
|
123
|
+
|
|
124
|
+
while True:
|
|
125
|
+
files.append(
|
|
126
|
+
s.ask(
|
|
127
|
+
PathInput(f"{kind.capitalize()} file path"),
|
|
128
|
+
lambda raw: _check_new_file(raw, files),
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
if not _confirm(s, f"Add another {kind} file?", default=not editing):
|
|
132
|
+
return files
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _read(path: PathLike) -> str | None:
|
|
136
|
+
"""The contents of the file at ``path``, or None if it can't be read."""
|
|
137
|
+
try:
|
|
138
|
+
return Path(path).read_text(encoding="utf-8")
|
|
139
|
+
except OSError, UnicodeDecodeError:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _as_stored(path: PathLike, text: str | None) -> str | None:
|
|
144
|
+
"""``text``, the contents of the file at ``path``, as a task file stores them:
|
|
145
|
+
normalized for a notebook (see ``cells.normalize``), so that e.g. its outputs don't
|
|
146
|
+
count as changes. As it is if it can't be read as a notebook."""
|
|
147
|
+
if text is None or not cells.is_notebook(path):
|
|
148
|
+
return text
|
|
149
|
+
try:
|
|
150
|
+
return cells.normalize(text)
|
|
151
|
+
except cells.NotebookError:
|
|
152
|
+
return text
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _diff(stored: str, on_disk: str, path: PathLike | None = None) -> RenderableType:
|
|
156
|
+
"""How a file ``on_disk`` differs from its ``stored`` contents, as a unified diff of
|
|
157
|
+
at most ``_MAX_DIFF_LINES`` lines. A notebook (by its ``path``) is compared by the
|
|
158
|
+
text of its cells (see ``cells.as_cell_text``) rather than as JSON."""
|
|
159
|
+
if path is not None and cells.is_notebook(path):
|
|
160
|
+
try:
|
|
161
|
+
stored, on_disk = cells.as_cell_text(stored), cells.as_cell_text(on_disk)
|
|
162
|
+
except cells.NotebookError:
|
|
163
|
+
pass # compared as it is
|
|
164
|
+
else:
|
|
165
|
+
if stored == on_disk:
|
|
166
|
+
return Text(
|
|
167
|
+
"Only its metadata differs (e.g. the tags of cells), not the text "
|
|
168
|
+
"of its cells.",
|
|
169
|
+
style="dim",
|
|
170
|
+
)
|
|
171
|
+
lines = list(
|
|
172
|
+
difflib.unified_diff(
|
|
173
|
+
stored.splitlines(), on_disk.splitlines(), "stored", "on disk", lineterm=""
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
shown = Syntax("\n".join(lines[:_MAX_DIFF_LINES]), "diff", background_color="default")
|
|
177
|
+
if len(lines) <= _MAX_DIFF_LINES:
|
|
178
|
+
return shown
|
|
179
|
+
more = Text(f"... {len(lines) - _MAX_DIFF_LINES} more lines", style="dim")
|
|
180
|
+
return Group(shown, more)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _notebook_sections(path: Path, contents: Mapping[Path, str]) -> dict[str, Section]:
|
|
184
|
+
"""The sections of the notebook at ``path`` as the task file will store it: the
|
|
185
|
+
``contents`` kept for it (by absolute path), if any, else the file on disk. None if
|
|
186
|
+
it can't be read."""
|
|
187
|
+
key = paths.absolute(path)
|
|
188
|
+
text = contents[key] if key in contents else _read(path)
|
|
189
|
+
if text is None:
|
|
190
|
+
return {}
|
|
191
|
+
try:
|
|
192
|
+
return sections.find_sections(cells.parse(text))
|
|
193
|
+
except cells.NotebookError:
|
|
194
|
+
return {}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _section_options(
|
|
198
|
+
found: Mapping[str, Section], current: str | None
|
|
199
|
+
) -> tuple[dict[str, str], dict[str, str]]:
|
|
200
|
+
"""
|
|
201
|
+
The sections ``found`` in a notebook as options to choose from, and the
|
|
202
|
+
``current`` section too if the notebook doesn't have it.
|
|
203
|
+
|
|
204
|
+
Returns
|
|
205
|
+
-------
|
|
206
|
+
tuple[dict[str, str], dict[str, str]]
|
|
207
|
+
The section names by option, and hints by option.
|
|
208
|
+
"""
|
|
209
|
+
options: dict[str, str] = {}
|
|
210
|
+
hints: dict[str, str] = {}
|
|
211
|
+
for name, section in found.items():
|
|
212
|
+
label = name if section.title == name else f"{name} ({section.title})"
|
|
213
|
+
first, last = section.cells[0].number, section.cells[-1].number
|
|
214
|
+
where = f"Cell {first}" if first == last else f"Cells {first} to {last}"
|
|
215
|
+
options[label] = name
|
|
216
|
+
hints[label] = f"{where} of the notebook."
|
|
217
|
+
if current is not None and current not in found:
|
|
218
|
+
label = f"{current} (not found in the notebook)"
|
|
219
|
+
options[label] = current
|
|
220
|
+
hints[label] = (
|
|
221
|
+
"The notebook has no section of this name: its tests fail until a cell "
|
|
222
|
+
f"starts it again, tagged '{sections.TAG_PREFIX}{current}'."
|
|
223
|
+
)
|
|
224
|
+
return options, hints
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _prompt_sections(
|
|
228
|
+
s: PromptSession,
|
|
229
|
+
ex_files: list[Path],
|
|
230
|
+
contents: Mapping[Path, str],
|
|
231
|
+
current: Mapping[Path, str],
|
|
232
|
+
) -> dict[Path, str]:
|
|
233
|
+
"""
|
|
234
|
+
Ask which section of each of the exercise notebooks ``ex_files`` the task is about
|
|
235
|
+
(see ``pyics.notebook.sections``), prefilled with its ``current`` section (by
|
|
236
|
+
absolute path) when editing a task. Not asked for a notebook without sections,
|
|
237
|
+
unless the task has one for it. Sections are looked up in the ``contents`` kept for
|
|
238
|
+
a notebook (by path), else in the file on disk.
|
|
239
|
+
|
|
240
|
+
Returns
|
|
241
|
+
-------
|
|
242
|
+
dict[Path, str]
|
|
243
|
+
The section of each notebook that has one, by its path in ``ex_files``.
|
|
244
|
+
"""
|
|
245
|
+
kept = {paths.absolute(path): text for path, text in contents.items()}
|
|
246
|
+
chosen: dict[Path, str] = {}
|
|
247
|
+
for path in ex_files:
|
|
248
|
+
if not cells.is_notebook(path):
|
|
249
|
+
continue
|
|
250
|
+
found = _notebook_sections(path, kept)
|
|
251
|
+
section = current.get(paths.absolute(path))
|
|
252
|
+
if not found and section is None:
|
|
253
|
+
continue
|
|
254
|
+
options, hints = _section_options(found, section)
|
|
255
|
+
hints[_NO_SECTION] = (
|
|
256
|
+
"Its tests check the whole notebook, unless they have a section of their "
|
|
257
|
+
"own."
|
|
258
|
+
)
|
|
259
|
+
prompt = SelectInput(
|
|
260
|
+
f"Section of the task in {escape(str(path))}",
|
|
261
|
+
[_NO_SECTION, *options],
|
|
262
|
+
hints=hints,
|
|
263
|
+
)
|
|
264
|
+
prompt.set_answer(next((o for o, n in options.items() if n == section), ""))
|
|
265
|
+
choice = s.ask(prompt)
|
|
266
|
+
if choice != _NO_SECTION:
|
|
267
|
+
chosen[path] = options[choice]
|
|
268
|
+
return chosen
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _prompt_test_section(
|
|
272
|
+
s: PromptSession,
|
|
273
|
+
path: Path,
|
|
274
|
+
task_section: str | None,
|
|
275
|
+
found: Mapping[str, Section],
|
|
276
|
+
current: str | None | Inherit,
|
|
277
|
+
) -> str | None | Inherit:
|
|
278
|
+
"""
|
|
279
|
+
Ask for the section of the notebook at ``path`` a test is about: its task's section
|
|
280
|
+
``task_section`` (``INHERIT``, so it follows the task's section), the whole
|
|
281
|
+
notebook (None), or another of the sections ``found`` in it. Prefilled with the
|
|
282
|
+
test's ``current`` section. Not asked if the notebook has no sections, and neither
|
|
283
|
+
the task nor the test has one: the test then inherits its task's section.
|
|
284
|
+
"""
|
|
285
|
+
if not found and task_section is None and current is INHERIT:
|
|
286
|
+
return INHERIT
|
|
287
|
+
task_option = f"The task's section ({task_section or 'none: whole notebook'})"
|
|
288
|
+
choices: dict[str, str | None | Inherit] = {
|
|
289
|
+
task_option: INHERIT,
|
|
290
|
+
_WHOLE_NOTEBOOK: None,
|
|
291
|
+
}
|
|
292
|
+
hints = {
|
|
293
|
+
task_option: "The test checks the section the task is about, also once that "
|
|
294
|
+
"is changed.",
|
|
295
|
+
_WHOLE_NOTEBOOK: "The test checks the whole notebook, whichever section the "
|
|
296
|
+
"task is about.",
|
|
297
|
+
}
|
|
298
|
+
# the task's own section is offered as such, unless the test has it already
|
|
299
|
+
others = {
|
|
300
|
+
name: section
|
|
301
|
+
for name, section in found.items()
|
|
302
|
+
if name != task_section or name == current
|
|
303
|
+
}
|
|
304
|
+
options, section_hints = _section_options(
|
|
305
|
+
others, current if isinstance(current, str) else None
|
|
306
|
+
)
|
|
307
|
+
choices.update(options)
|
|
308
|
+
hints.update(section_hints)
|
|
309
|
+
|
|
310
|
+
prompt = SelectInput("section", list(choices), hints=hints)
|
|
311
|
+
prompt.set_answer(next((o for o, c in choices.items() if c == current), ""))
|
|
312
|
+
return choices[s.ask(prompt)]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _edit_files(
|
|
316
|
+
s: PromptSession, kind: str, current: list[Path], stored: Mapping[Path, str]
|
|
317
|
+
) -> tuple[list[Path], dict[Path, str], dict[Path, Path | None]]:
|
|
318
|
+
"""
|
|
319
|
+
Ask what to do with each of the ``current`` ``kind`` files of a task that is being
|
|
320
|
+
edited, then for more files, like ``_prompt_files``. A file can be kept with the
|
|
321
|
+
contents ``stored`` in the task file, updated to store its (changed) contents on
|
|
322
|
+
disk instead, replaced by another file, or removed from the task.
|
|
323
|
+
|
|
324
|
+
Returns
|
|
325
|
+
-------
|
|
326
|
+
tuple[list[Path], dict[Path, str], dict[Path, Path | None]]
|
|
327
|
+
The files; the stored contents to keep, by path; and what became of each of the
|
|
328
|
+
``current`` files, by its absolute path: the file itself, its replacement, or
|
|
329
|
+
None if it was removed.
|
|
330
|
+
"""
|
|
331
|
+
kept: list[Path] = []
|
|
332
|
+
contents: dict[Path, str] = {}
|
|
333
|
+
moved: dict[Path, Path | None] = {}
|
|
334
|
+
|
|
335
|
+
for i, path in enumerate(current):
|
|
336
|
+
in_task = _as_stored(path, stored.get(path))
|
|
337
|
+
on_disk = _as_stored(path, _read(path))
|
|
338
|
+
|
|
339
|
+
options, hints, details = [], {}, None
|
|
340
|
+
if in_task is not None:
|
|
341
|
+
options.append(_KEEP)
|
|
342
|
+
hints[_KEEP] = "Keep the file, with the contents stored in the task file."
|
|
343
|
+
if on_disk is None:
|
|
344
|
+
details = Text(
|
|
345
|
+
"The file isn't on disk. It is created from the stored contents when "
|
|
346
|
+
"the task is loaded.",
|
|
347
|
+
style="dim",
|
|
348
|
+
)
|
|
349
|
+
if on_disk is not None and on_disk != in_task:
|
|
350
|
+
options.append(_UPDATE)
|
|
351
|
+
hints[_UPDATE] = "Keep the file, and store its contents on disk instead."
|
|
352
|
+
if in_task is not None:
|
|
353
|
+
details = Group(
|
|
354
|
+
Text("The file on disk differs from the stored contents:"),
|
|
355
|
+
_diff(in_task, on_disk, path),
|
|
356
|
+
)
|
|
357
|
+
if in_task is None and on_disk is None:
|
|
358
|
+
details = Text(
|
|
359
|
+
"The file isn't on disk, and the task file stores no contents for it.",
|
|
360
|
+
style="red",
|
|
361
|
+
)
|
|
362
|
+
options += [_REPLACE, _REMOVE]
|
|
363
|
+
hints[_REPLACE] = "Use another file in its place." + (
|
|
364
|
+
" Its tests are moved to the new file." if kind == "exercise" else ""
|
|
365
|
+
)
|
|
366
|
+
hints[_REMOVE] = "Remove the file from the task. The file itself is kept." + (
|
|
367
|
+
" Its tests need to be edited or removed." if kind == "exercise" else ""
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
label = f"{kind.capitalize()} file {escape(str(path))}"
|
|
371
|
+
choice = s.ask(SelectInput(label, options, hints=hints, details=details))
|
|
372
|
+
if choice == _REMOVE:
|
|
373
|
+
moved[paths.absolute(path)] = None
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
new_path = path
|
|
377
|
+
if choice == _REPLACE:
|
|
378
|
+
# not one of the files that are already kept, or still to be
|
|
379
|
+
others = [*kept, *current[i + 1 :]]
|
|
380
|
+
prompt = PathInput(f"New {kind} file path", initial=str(path))
|
|
381
|
+
new_path = s.ask(
|
|
382
|
+
prompt, lambda raw, others=others: _check_new_file(raw, others)
|
|
383
|
+
)
|
|
384
|
+
elif choice == _KEEP:
|
|
385
|
+
contents[path] = stored[path]
|
|
386
|
+
# updated or replaced files are read when the task file is written
|
|
387
|
+
moved[paths.absolute(path)] = new_path
|
|
388
|
+
kept.append(new_path)
|
|
389
|
+
|
|
390
|
+
return _prompt_files(s, kind, kept), contents, moved
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _load_namespace(raw: str) -> tuple[dict[str, Any], str]:
|
|
394
|
+
"""
|
|
395
|
+
Execute the Python file at ``raw``, or run the code cells of the notebook at it (see
|
|
396
|
+
``loader.run_notebook``), and return the variables it defines (excluding dunders and
|
|
397
|
+
modules, and a notebook's stand-ins for ``get_ipython`` and ``display``).
|
|
398
|
+
|
|
399
|
+
Returns
|
|
400
|
+
-------
|
|
401
|
+
tuple[dict[str, Any], str]
|
|
402
|
+
The variables, and the errors of the notebook's cells as a problem to show (see
|
|
403
|
+
``_prompt_param``), or an empty string. An error in a cell doesn't stop the
|
|
404
|
+
notebook's run, but the variables it would have defined are missing.
|
|
405
|
+
"""
|
|
406
|
+
path = _check_file(raw)
|
|
407
|
+
problem = ""
|
|
408
|
+
if cells.is_notebook(path):
|
|
409
|
+
previous = sys.modules.get(_INFO_MODULE)
|
|
410
|
+
try:
|
|
411
|
+
run = loader.run_notebook(path, module_name=_INFO_MODULE)
|
|
412
|
+
finally:
|
|
413
|
+
if previous is None:
|
|
414
|
+
sys.modules.pop(_INFO_MODULE, None)
|
|
415
|
+
else:
|
|
416
|
+
sys.modules[_INFO_MODULE] = previous
|
|
417
|
+
namespace = vars(run.module)
|
|
418
|
+
if run.errors:
|
|
419
|
+
more = f" (and {len(run.errors) - 1} more)" if len(run.errors) > 1 else ""
|
|
420
|
+
problem = f"In the test information file, {run.errors[0].describe()}{more}"
|
|
421
|
+
else:
|
|
422
|
+
namespace = {}
|
|
423
|
+
source = path.read_text(encoding="utf-8")
|
|
424
|
+
exec(compile(source, str(path), "exec"), namespace) # noqa: S102
|
|
425
|
+
return {
|
|
426
|
+
k: v
|
|
427
|
+
for k, v in namespace.items()
|
|
428
|
+
if not k.startswith("__")
|
|
429
|
+
and not isinstance(v, ModuleType)
|
|
430
|
+
# the loader's stand-ins, rather than what the notebook imports or defines
|
|
431
|
+
and getattr(v, "__module__", None) != loader.__name__
|
|
432
|
+
}, problem
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _check(test_cls: type[Test], param: inspect.Parameter, value: Any) -> Any:
|
|
436
|
+
"""Return ``value`` if it can be used for the ``test_cls`` constructor parameter
|
|
437
|
+
``param``, raise otherwise."""
|
|
438
|
+
if test_cls is SpecTest and param.name == "expected":
|
|
439
|
+
# e.g. the solution's function itself, stored as its spec dict. Also validates
|
|
440
|
+
# spec dicts now, rather than when the test is constructed.
|
|
441
|
+
value = check_specs.to_spec_dict(value)
|
|
442
|
+
if test_cls is StructureTest and param.name == "rules":
|
|
443
|
+
value = check_structure.validate_rules(value)
|
|
444
|
+
if param.annotation is str and not isinstance(value, str):
|
|
445
|
+
raise TypeError(f"expected str, got {type(value).__name__}")
|
|
446
|
+
if param.annotation == list[str] and not (
|
|
447
|
+
isinstance(value, list) and all(isinstance(v, str) for v in value)
|
|
448
|
+
):
|
|
449
|
+
raise TypeError(f"expected a list of str, got {reprlib.repr(value)}")
|
|
450
|
+
if param.annotation is bool and not isinstance(value, bool):
|
|
451
|
+
raise TypeError(f"expected True or False, got {reprlib.repr(value)}")
|
|
452
|
+
# checked now, rather than when the task file is written after all other fields
|
|
453
|
+
json.dumps(value)
|
|
454
|
+
return value
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _evaluates_to(code: str, value: Any) -> bool:
|
|
458
|
+
"""Whether ``code`` evaluates to ``value``, e.g. whether a ``repr`` is valid Python
|
|
459
|
+
that can prefill an input for ``value``."""
|
|
460
|
+
try:
|
|
461
|
+
return bool(eval(code, {"__builtins__": builtins}) == value)
|
|
462
|
+
except Exception: # noqa: BLE001
|
|
463
|
+
return False
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _prompt_param(
|
|
467
|
+
s: PromptSession,
|
|
468
|
+
test_cls: type[Test],
|
|
469
|
+
param: inspect.Parameter,
|
|
470
|
+
symbols: dict[str, Any],
|
|
471
|
+
problem: str,
|
|
472
|
+
current: Any = inspect.Parameter.empty,
|
|
473
|
+
) -> Any:
|
|
474
|
+
"""
|
|
475
|
+
Ask for the value of the ``test_cls`` constructor parameter ``param``, prefilled with
|
|
476
|
+
its ``current`` value when editing a test, otherwise with its default: plain text for
|
|
477
|
+
``str`` parameters (on several lines for the expected output of a ``PrintTest``),
|
|
478
|
+
otherwise a Python expression, evaluated with ``symbols`` in scope. ``problem``, if
|
|
479
|
+
not empty, is shown below the input.
|
|
480
|
+
"""
|
|
481
|
+
lines = [Text(problem, style="red")] if problem else []
|
|
482
|
+
editing = current is not param.empty
|
|
483
|
+
value = current if editing else param.default
|
|
484
|
+
has_value = value is not param.empty
|
|
485
|
+
|
|
486
|
+
if param.annotation is str:
|
|
487
|
+
str_symbols = {k: v for k, v in symbols.items() if isinstance(v, str)}
|
|
488
|
+
if str_symbols:
|
|
489
|
+
hints = {k: reprlib.repr(v) for k, v in str_symbols.items()}
|
|
490
|
+
prompt = SelectInput(param.name, [*str_symbols, _ENTER_TEXT], hints=hints)
|
|
491
|
+
if editing: # the current value is prefilled in the text input
|
|
492
|
+
prompt.set_answer(_ENTER_TEXT)
|
|
493
|
+
choice = s.ask(prompt)
|
|
494
|
+
if choice != _ENTER_TEXT:
|
|
495
|
+
return str_symbols[choice]
|
|
496
|
+
|
|
497
|
+
initial = value if isinstance(value, str) else ""
|
|
498
|
+
details = Group(*lines) if lines else None
|
|
499
|
+
multiline = test_cls is PrintTest and param.name == "expected"
|
|
500
|
+
return s.ask(
|
|
501
|
+
TextInput(param.name, multiline=multiline, initial=initial, details=details)
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
initial = repr(value) if has_value else ""
|
|
505
|
+
if has_value and not _evaluates_to(initial, value):
|
|
506
|
+
# e.g. an object whose repr isn't valid Python: prefilled by referencing it
|
|
507
|
+
symbols = {**symbols, _CURRENT: value}
|
|
508
|
+
initial = _CURRENT
|
|
509
|
+
|
|
510
|
+
if test_cls is StructureTest and param.name == "rules":
|
|
511
|
+
concepts = ", ".join(check_structure.CONCEPTS)
|
|
512
|
+
lines.append(Text(f"Concepts: {concepts}", style="dim"))
|
|
513
|
+
if symbols:
|
|
514
|
+
lines.append(Text("Variables in scope:", style="dim"))
|
|
515
|
+
lines.extend(
|
|
516
|
+
Text(f" {k} = {reprlib.repr(v)}", style="dim") for k, v in symbols.items()
|
|
517
|
+
)
|
|
518
|
+
prompt = CodeInput(
|
|
519
|
+
param.name, initial=initial, details=Group(*lines) if lines else None
|
|
520
|
+
)
|
|
521
|
+
scope = {"__builtins__": builtins, **symbols}
|
|
522
|
+
return s.ask(prompt, lambda raw: _check(test_cls, param, eval(raw, dict(scope))))
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _prompt_test(
|
|
526
|
+
s: PromptSession,
|
|
527
|
+
test_cls: type[Test],
|
|
528
|
+
ex_files: list[Path],
|
|
529
|
+
current: Test | None = None,
|
|
530
|
+
*,
|
|
531
|
+
task_sections: Mapping[Path, str] | None = None,
|
|
532
|
+
contents: Mapping[Path, str] | None = None,
|
|
533
|
+
) -> Test:
|
|
534
|
+
"""
|
|
535
|
+
Interactively define a test of type ``test_cls``, by asking for each parameter of its
|
|
536
|
+
constructor. When editing the test ``current``, each one is prefilled with its
|
|
537
|
+
current value.
|
|
538
|
+
|
|
539
|
+
The user may reference a Python file or a notebook first: its variables whose names
|
|
540
|
+
match a parameter are used for it directly, and all of them are in scope for the
|
|
541
|
+
expressions entered for the other parameters. If cells of the notebook raised an
|
|
542
|
+
error, the prompts for the other parameters say so. ``file_path`` is always one of
|
|
543
|
+
``ex_files``.
|
|
544
|
+
|
|
545
|
+
A test of a notebook can be about a ``section`` of it (see ``_prompt_test_section``):
|
|
546
|
+
by default the task's, i.e. its section in ``task_sections`` (by path). Sections are
|
|
547
|
+
looked up in the ``contents`` kept for the notebook (by path), else in the file on
|
|
548
|
+
disk.
|
|
549
|
+
"""
|
|
550
|
+
task_sections = {paths.absolute(p): n for p, n in (task_sections or {}).items()}
|
|
551
|
+
kept = {paths.absolute(p): text for p, text in (contents or {}).items()}
|
|
552
|
+
symbols: dict[str, Any] = {}
|
|
553
|
+
info_problem = ""
|
|
554
|
+
if _confirm(s, "Reference a file with test information?", default=current is None):
|
|
555
|
+
symbols, info_problem = s.ask(
|
|
556
|
+
PathInput("Test information file"), _load_namespace
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
kwargs: dict[str, Any] = {}
|
|
560
|
+
for param in inspect.signature(test_cls).parameters.values():
|
|
561
|
+
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
|
|
562
|
+
continue
|
|
563
|
+
value = getattr(current, param.name, param.empty) if current else param.empty
|
|
564
|
+
|
|
565
|
+
if param.name == "file_path":
|
|
566
|
+
prompt = SelectInput(param.name, [str(f) for f in ex_files])
|
|
567
|
+
if current is not None:
|
|
568
|
+
options = {paths.absolute(f): str(f) for f in ex_files}
|
|
569
|
+
prompt.set_answer(options.get(paths.absolute(current.file_path), ""))
|
|
570
|
+
kwargs[param.name] = Path(s.ask(prompt)).absolute()
|
|
571
|
+
continue
|
|
572
|
+
|
|
573
|
+
if param.name == "section":
|
|
574
|
+
# a test that isn't given one inherits its task's, storing nothing
|
|
575
|
+
path = kwargs["file_path"]
|
|
576
|
+
if cells.is_notebook(path):
|
|
577
|
+
current_section = INHERIT if value is param.empty else value
|
|
578
|
+
section = _prompt_test_section(
|
|
579
|
+
s,
|
|
580
|
+
path,
|
|
581
|
+
task_sections.get(paths.absolute(path)),
|
|
582
|
+
_notebook_sections(path, kept),
|
|
583
|
+
current_section,
|
|
584
|
+
)
|
|
585
|
+
if section is not INHERIT:
|
|
586
|
+
kwargs[param.name] = section
|
|
587
|
+
continue
|
|
588
|
+
|
|
589
|
+
problem = info_problem
|
|
590
|
+
if param.name in symbols:
|
|
591
|
+
try:
|
|
592
|
+
kwargs[param.name] = _check(test_cls, param, symbols[param.name])
|
|
593
|
+
continue
|
|
594
|
+
except (TypeError, ValueError) as err:
|
|
595
|
+
problem = f"'{param.name}' from the test information file: {err}"
|
|
596
|
+
kwargs[param.name] = _prompt_param(s, test_cls, param, symbols, problem, value)
|
|
597
|
+
|
|
598
|
+
return test_cls(**kwargs)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _describe_test(test: Test, problem: str = "") -> RenderableType:
|
|
602
|
+
"""The type and parameters of ``test``, below ``problem`` if it isn't empty."""
|
|
603
|
+
lines = [Text(problem, style="red")] if problem else []
|
|
604
|
+
lines.append(Text(type(test).__name__, style="dim"))
|
|
605
|
+
for param in inspect.signature(type(test)).parameters.values():
|
|
606
|
+
value = getattr(test, param.name, param.empty)
|
|
607
|
+
if value is param.empty or value is INHERIT:
|
|
608
|
+
continue
|
|
609
|
+
text = str(value) if isinstance(value, PurePath) else reprlib.repr(value)
|
|
610
|
+
lines.append(Text(f" {param.name} = {text}", style="dim"))
|
|
611
|
+
return Group(*lines)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _prompt_tests(
|
|
615
|
+
s: PromptSession,
|
|
616
|
+
ex_files: list[Path],
|
|
617
|
+
current: list[Test] | None = None,
|
|
618
|
+
*,
|
|
619
|
+
task_sections: Mapping[Path, str] | None = None,
|
|
620
|
+
contents: Mapping[Path, str] | None = None,
|
|
621
|
+
) -> list[Test]:
|
|
622
|
+
"""
|
|
623
|
+
Interactively define any number of tests, choosing the test type for each. When
|
|
624
|
+
editing a task, its ``current`` tests are kept, edited or removed first, and adding
|
|
625
|
+
more is not preselected. A test whose file isn't one of ``ex_files`` can't be kept
|
|
626
|
+
as it is. ``task_sections`` and ``contents`` are passed on to ``_prompt_test``.
|
|
627
|
+
"""
|
|
628
|
+
tests: list[Test] = []
|
|
629
|
+
ex = {paths.absolute(f) for f in ex_files}
|
|
630
|
+
for i, test in enumerate(current or [], 1):
|
|
631
|
+
options, problem = [_KEEP, _EDIT, _REMOVE], ""
|
|
632
|
+
if paths.absolute(test.file_path) not in ex:
|
|
633
|
+
options.remove(_KEEP)
|
|
634
|
+
problem = (
|
|
635
|
+
f"Its file {test.file_path} isn't one of the task's exercise files, so "
|
|
636
|
+
"the test needs to be edited or removed."
|
|
637
|
+
)
|
|
638
|
+
label = f"Test {i} of {len(current or [])}: {escape(str(test.name))}"
|
|
639
|
+
details = _describe_test(test, problem)
|
|
640
|
+
choice = s.ask(SelectInput(label, options, hints=_TEST_HINTS, details=details))
|
|
641
|
+
if choice == _KEEP:
|
|
642
|
+
tests.append(test)
|
|
643
|
+
elif choice == _EDIT:
|
|
644
|
+
tests.append(
|
|
645
|
+
_prompt_test(
|
|
646
|
+
s,
|
|
647
|
+
type(test),
|
|
648
|
+
ex_files,
|
|
649
|
+
current=test,
|
|
650
|
+
task_sections=task_sections,
|
|
651
|
+
contents=contents,
|
|
652
|
+
)
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
test_types = {cls.__name__: cls for cls in _TEST_TYPES}
|
|
656
|
+
hints = {
|
|
657
|
+
name: (inspect.getdoc(cls) or "").splitlines()[0]
|
|
658
|
+
for name, cls in test_types.items()
|
|
659
|
+
}
|
|
660
|
+
while True:
|
|
661
|
+
hints[_NO_MORE_TESTS] = f"Finish adding tests ({len(tests)} added so far)."
|
|
662
|
+
prompt = SelectInput(
|
|
663
|
+
"Which kind of test do you want to add?",
|
|
664
|
+
[*test_types, _NO_MORE_TESTS],
|
|
665
|
+
hints=hints,
|
|
666
|
+
)
|
|
667
|
+
if current is not None:
|
|
668
|
+
prompt.set_answer(_NO_MORE_TESTS)
|
|
669
|
+
kind = s.ask(prompt)
|
|
670
|
+
if kind == _NO_MORE_TESTS:
|
|
671
|
+
return tests
|
|
672
|
+
tests.append(
|
|
673
|
+
_prompt_test(
|
|
674
|
+
s,
|
|
675
|
+
test_types[kind],
|
|
676
|
+
ex_files,
|
|
677
|
+
task_sections=task_sections,
|
|
678
|
+
contents=contents,
|
|
679
|
+
)
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _repoint(test: Test, moved: Mapping[Path, Path | None]) -> Test:
|
|
684
|
+
"""``test``, or a copy of it testing the file its file was replaced by (see
|
|
685
|
+
``_edit_files``)."""
|
|
686
|
+
new = moved.get(paths.absolute(test.file_path))
|
|
687
|
+
if new is None:
|
|
688
|
+
return test
|
|
689
|
+
test = copy.copy(test) # the task's own test is kept, since flows are replayed
|
|
690
|
+
test.file_path = new
|
|
691
|
+
return test
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _check_hint(hint: str) -> str:
|
|
695
|
+
"""Return ``hint`` if it can be used as a hint, raise otherwise."""
|
|
696
|
+
if not hint.strip():
|
|
697
|
+
raise ValueError("the hint may not be empty")
|
|
698
|
+
return hint
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _prompt_hints(s: PromptSession, current: list[str] | None = None) -> list[str]:
|
|
702
|
+
"""
|
|
703
|
+
Interactively define any number of hints, which the student can reveal one at a
|
|
704
|
+
time, in order. When editing a task, its ``current`` hints are kept, edited or
|
|
705
|
+
removed first. Adding a hint is never preselected, so confirming every prompt adds
|
|
706
|
+
none.
|
|
707
|
+
"""
|
|
708
|
+
hints: list[str] = []
|
|
709
|
+
for i, hint in enumerate(current or [], 1):
|
|
710
|
+
label = f"Hint {i} of {len(current or [])}"
|
|
711
|
+
details = Text(hint, style="dim")
|
|
712
|
+
options = [_KEEP, _EDIT, _REMOVE]
|
|
713
|
+
choice = s.ask(
|
|
714
|
+
SelectInput(label, options, hints=_HINT_ACTIONS, details=details)
|
|
715
|
+
)
|
|
716
|
+
if choice == _KEEP:
|
|
717
|
+
hints.append(hint)
|
|
718
|
+
elif choice == _EDIT:
|
|
719
|
+
prompt = TextInput(f"Hint {len(hints) + 1}", multiline=True, initial=hint)
|
|
720
|
+
hints.append(s.ask(prompt, _check_hint))
|
|
721
|
+
|
|
722
|
+
while _confirm(s, "Add another hint?" if hints else "Add a hint?", default=False):
|
|
723
|
+
prompt = TextInput(f"Hint {len(hints) + 1}", multiline=True)
|
|
724
|
+
hints.append(s.ask(prompt, _check_hint))
|
|
725
|
+
return hints
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _check_name(name: str, taken_names: Collection[str]) -> str:
|
|
729
|
+
"""Return ``name`` if it can be used for a task (or course), raise otherwise."""
|
|
730
|
+
if not name:
|
|
731
|
+
raise ValueError("the name may not be empty")
|
|
732
|
+
if name in taken_names:
|
|
733
|
+
raise ValueError(f"the course already has a task named {name!r}")
|
|
734
|
+
return name
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _task_flow(
|
|
738
|
+
s: PromptSession,
|
|
739
|
+
*,
|
|
740
|
+
taken_names: Collection[str] = (),
|
|
741
|
+
current: Mapping[str, Any] | None = None,
|
|
742
|
+
) -> dict[str, Any]:
|
|
743
|
+
"""
|
|
744
|
+
The full task-creation flow. Replayed by ``PromptSession`` whenever the user goes
|
|
745
|
+
back, so it must ask the same prompts given the same answers. The task name may not
|
|
746
|
+
be one of ``taken_names``.
|
|
747
|
+
|
|
748
|
+
With ``current``, the fields of an existing task (see ``Task.read_task_json``), the
|
|
749
|
+
task is edited instead: every prompt is prefilled with its current value, so that
|
|
750
|
+
confirming all of them leaves the task as it was. Changes to its exercise files
|
|
751
|
+
carry over to its tests: those of a replaced file test the new file, and those of a
|
|
752
|
+
removed file need to be edited or removed. The same goes for the task's sections.
|
|
753
|
+
|
|
754
|
+
Returns
|
|
755
|
+
-------
|
|
756
|
+
dict[str, Any]
|
|
757
|
+
Keyword arguments for ``Task.create_task_json``.
|
|
758
|
+
"""
|
|
759
|
+
s.cli.update_progress(0 / _TOTAL_STEPS)
|
|
760
|
+
initial = current["name"] if current is not None else ""
|
|
761
|
+
name = s.ask(
|
|
762
|
+
TextInput("Task name", initial=initial), lambda n: _check_name(n, taken_names)
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
s.cli.update_progress(1 / _TOTAL_STEPS)
|
|
766
|
+
initial = current["instructions"] if current is not None else ""
|
|
767
|
+
instructions = s.ask(TextInput("Instructions", multiline=True, initial=initial))
|
|
768
|
+
|
|
769
|
+
s.cli.update_progress(2 / _TOTAL_STEPS)
|
|
770
|
+
hints = _prompt_hints(s, current["help"] if current is not None else None)
|
|
771
|
+
|
|
772
|
+
file_contents: dict[Path, str] = {}
|
|
773
|
+
moved: dict[Path, Path | None] = {}
|
|
774
|
+
|
|
775
|
+
s.cli.update_progress(3 / _TOTAL_STEPS)
|
|
776
|
+
if current is None:
|
|
777
|
+
ex_files = _prompt_files(s, "exercise")
|
|
778
|
+
else:
|
|
779
|
+
ex_files, ex_contents, moved = _edit_files(
|
|
780
|
+
s, "exercise", current["ex_files"], current["file_contents"]
|
|
781
|
+
)
|
|
782
|
+
file_contents.update(ex_contents)
|
|
783
|
+
# those of replaced notebooks are offered for the new notebook
|
|
784
|
+
current_sections: dict[Path, str] = {}
|
|
785
|
+
for path, section in (current or {}).get("sections", {}).items():
|
|
786
|
+
new_path = moved.get(paths.absolute(path), path)
|
|
787
|
+
if new_path is not None:
|
|
788
|
+
current_sections[paths.absolute(new_path)] = section
|
|
789
|
+
task_sections = _prompt_sections(s, ex_files, file_contents, current_sections)
|
|
790
|
+
|
|
791
|
+
s.cli.update_progress(4 / _TOTAL_STEPS)
|
|
792
|
+
if current is None:
|
|
793
|
+
sol_files = _prompt_files(s, "solution")
|
|
794
|
+
else:
|
|
795
|
+
sol_files, sol_contents, _ = _edit_files(
|
|
796
|
+
s, "solution", current["sol_files"], current["file_contents"]
|
|
797
|
+
)
|
|
798
|
+
file_contents.update(sol_contents)
|
|
799
|
+
|
|
800
|
+
s.cli.update_progress(5 / _TOTAL_STEPS)
|
|
801
|
+
current_tests = None
|
|
802
|
+
if current is not None:
|
|
803
|
+
current_tests = [_repoint(test, moved) for test in current["tests"]]
|
|
804
|
+
tests = _prompt_tests(
|
|
805
|
+
s,
|
|
806
|
+
ex_files,
|
|
807
|
+
current_tests,
|
|
808
|
+
task_sections=task_sections,
|
|
809
|
+
contents=file_contents,
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
s.cli.update_progress(6 / _TOTAL_STEPS)
|
|
813
|
+
return {
|
|
814
|
+
"name": name,
|
|
815
|
+
"instructions": instructions,
|
|
816
|
+
"ex_files": ex_files,
|
|
817
|
+
"sol_files": sol_files,
|
|
818
|
+
"tests": tests or None,
|
|
819
|
+
"file_contents": file_contents,
|
|
820
|
+
"help": hints,
|
|
821
|
+
"sections": task_sections,
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _create_task(fp: PathLike | None = None) -> None:
|
|
826
|
+
"""
|
|
827
|
+
Console script to interactively create a task file.
|
|
828
|
+
|
|
829
|
+
Parameters
|
|
830
|
+
----------
|
|
831
|
+
fp: PathLike
|
|
832
|
+
Output path for the resulting ``.pyicstask.json`` file.
|
|
833
|
+
"""
|
|
834
|
+
with fullscreen():
|
|
835
|
+
cli = CLI()
|
|
836
|
+
cli()
|
|
837
|
+
cli.update_info(_INFO)
|
|
838
|
+
|
|
839
|
+
try:
|
|
840
|
+
with KeyListener() as q:
|
|
841
|
+
fields = PromptSession(cli, q, info=_INFO).run(_task_flow)
|
|
842
|
+
|
|
843
|
+
if not fp:
|
|
844
|
+
fp = Path(f"./{fields['name']}")
|
|
845
|
+
fp = Task.create_task_json(fp, **fields)
|
|
846
|
+
cli.update_info(f"Task written to {escape(str(fp))}")
|
|
847
|
+
except PromptCancelled:
|
|
848
|
+
cli.update_info("Cancelled, no task was created.")
|
|
849
|
+
finally:
|
|
850
|
+
cli.stop()
|