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