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
pyics/data/task.py ADDED
@@ -0,0 +1,744 @@
1
+ import copy
2
+ import os
3
+ from collections.abc import Mapping, Sequence
4
+ from os import PathLike
5
+ from pathlib import Path
6
+ import threading
7
+ import time
8
+ from typing import Any, Union
9
+
10
+ import pytest
11
+ from rich.console import Group, RenderableType
12
+ from rich.padding import Padding
13
+ from rich.rule import Rule
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+
17
+ import pyics.util.json_handling as json
18
+ from pyics import plugins
19
+ from pyics.distribution.dependencies import ModulePolicy, check_task_data
20
+ from pyics.notebook import cells
21
+ from pyics.notebook import sections as notebook_sections
22
+ from pyics.util import paths, terminal
23
+ from pyics.util.paths import StrPath
24
+ from pyics.util.logging import get_logger
25
+ from pyics.tests import Test
26
+ from pyics.tests.pytest_integration import LiveTestCollectionPlugin
27
+ from pyics.tests.results import TestResult
28
+
29
+ # pytest runs aren't thread-safe (e.g. the tests import the exercise file as the module
30
+ # ``current_solution``), so all tasks' runs are serialized
31
+ _pytest_lock = threading.Lock()
32
+
33
+
34
+ def wait_for_test_runs(timeout: float = -1) -> None:
35
+ """
36
+ Wait until no task's tests are being run (at most ``timeout`` seconds, if given).
37
+ A run restores the terminal mode it started in, so e.g. the terminal mode must not be
38
+ changed back to cooked before a run started in raw mode has finished.
39
+ """
40
+ if _pytest_lock.acquire(timeout=timeout):
41
+ _pytest_lock.release()
42
+
43
+
44
+ def _failure_text(result: TestResult) -> Text:
45
+ """The failure message of ``result``, highlighted if it comes from a ``PyicsItem``"""
46
+ return result.message_text
47
+
48
+
49
+ def _failure_trace(result: TestResult) -> Text | None:
50
+ """The trace of the failed test of ``result``, or why there is none, if it was run
51
+ with tracing (see ``Task.tracing``)"""
52
+ return result.trace_text
53
+
54
+
55
+ def _traceable(test: Test) -> bool:
56
+ """Whether ``test`` can be traced, and its author allows it (see ``Test.run``)"""
57
+ return test.supports_trace and getattr(test, "traceable", True)
58
+
59
+
60
+ def _path_list(files: StrPath | Sequence[StrPath] | None) -> list[StrPath]:
61
+ """``files`` as a list of paths, whether given as one path, several or None."""
62
+ if files is None:
63
+ return []
64
+ if isinstance(files, (str, PathLike)):
65
+ return [files]
66
+ return list(files)
67
+
68
+
69
+ def _load_task_json(fp: StrPath, module_policy: ModulePolicy | None = None) -> dict:
70
+ """
71
+ Read and decode the task file at ``fp``, once it has been checked that decoding it
72
+ imports only modules ``module_policy`` allows (if given), which are installed (see
73
+ ``check_task_data``).
74
+ """
75
+ with open(fp, "r", encoding="utf-8") as f:
76
+ text = f.read()
77
+ check_task_data(json.loads_plain(text), fp, module_policy)
78
+ return json.loads(text)
79
+
80
+
81
+ def _hint_list(hints: str | Sequence[str] | None) -> list[str]:
82
+ """``hints`` as a list of hints, whether given as one hint, several or None."""
83
+ if hints is None:
84
+ return []
85
+ if isinstance(hints, str):
86
+ return [hints]
87
+ return list(hints)
88
+
89
+
90
+ def _read_sections(raw_json: Mapping[str, Any], base: Path) -> dict[Path, str]:
91
+ """The ``sections`` of a task file read from the directory ``base``, by notebook
92
+ path resolved relative to ``base`` (see ``create_task_json``). Entries that aren't a
93
+ path and the name of a section are left out."""
94
+ stored = raw_json.get("sections")
95
+ if not isinstance(stored, Mapping):
96
+ return {}
97
+ return {
98
+ paths.resolve(path, base): name
99
+ for path, name in stored.items()
100
+ if isinstance(path, str) and notebook_sections.is_name(name)
101
+ }
102
+
103
+
104
+ class Task:
105
+ """Unified Task Representation"""
106
+
107
+ __status: RenderableType
108
+ __info: RenderableType
109
+ __solved: bool = False
110
+ __new_test_results: bool = False
111
+ __tracing: bool = False
112
+ # whether the current test results were traced
113
+ __traced: bool = False
114
+
115
+ def __init__(
116
+ self,
117
+ task_file: StrPath,
118
+ *,
119
+ setup: bool = True,
120
+ module_policy: ModulePolicy | None = None,
121
+ ):
122
+ """
123
+ Parses a task from a task json-file.
124
+
125
+ File paths in the task file are relative to the task file's directory (see
126
+ ``create_task_json``).
127
+
128
+ Parameters
129
+ ----------
130
+ task_file: StrPath
131
+ Path to the task file
132
+ setup: bool (optional, keyword only)
133
+ Create the task's files and run its tests (see ``setup``). Default is True.
134
+ If False, the task can only be checked (see ``check_solved``) until ``setup``
135
+ is called.
136
+ module_policy: ModulePolicy | None (optional, keyword only)
137
+ The modules the task file may name to decode the objects stored in it, i.e.
138
+ those its course declares (see ``ModulePolicy``). If None, any module.
139
+
140
+ Raises
141
+ ------
142
+ ModuleNotAllowedError
143
+ If the task file names a module ``module_policy`` doesn't allow.
144
+ MissingDependencyError
145
+ If the task needs a package that isn't installed.
146
+ """
147
+ self.__path = task_file
148
+ raw_json = _load_task_json(task_file, module_policy)
149
+
150
+ base = Path(task_file).parent
151
+
152
+ self.name: str = raw_json.get("name", "Task")
153
+ self.instructions: str = raw_json.get("instructions", "")
154
+ # revealed one at a time, in order (see ``Course.reveal_hint``)
155
+ self.help: list[str] = _hint_list(raw_json.get("help"))
156
+
157
+ self.exercise_files: list[Path] = [
158
+ paths.resolve(fp, base) for fp in raw_json.get("ex_files", [])
159
+ ]
160
+ self.solution_files: list[Path] = [
161
+ paths.resolve(fp, base) for fp in raw_json.get("sol_files", [])
162
+ ]
163
+ # the section of each exercise notebook the task is about, by the notebook's
164
+ # absolute path (see ``pyics.notebook.sections``). Its tests inherit it, unless
165
+ # they have a section of their own (see ``Test.resolved_section``).
166
+ self.sections: dict[Path, str] = {
167
+ paths.absolute(path): name
168
+ for path, name in _read_sections(raw_json, base).items()
169
+ }
170
+
171
+ # kept until the files are created; the code does not need to be in memory after
172
+ self.__code: tuple[list[str], list[str]] | None = (
173
+ raw_json["exercise_code"],
174
+ raw_json["solution_code"],
175
+ )
176
+
177
+ self.tests: list[Test] = raw_json["tests"]
178
+ for test in self.tests:
179
+ test.file_path = paths.resolve(test.file_path, base)
180
+
181
+ self.__info = self.instructions
182
+ self.__status = Text("tests pending...", style="dim")
183
+ self.test_results: list[TestResult] = []
184
+ self.__tested = False
185
+
186
+ self._tests_lock = threading.Lock()
187
+ self._tests_pending = False
188
+ # when the version of the exercise files that ``test_results`` come from was
189
+ # saved: the latest of their modification times when the run started
190
+ self.test_results_time: float | None = None
191
+ # a plugin's overlay of the results (see ``check_overlay``), asked for once
192
+ self.__overlay: plugins.ResultsOverlay | None = None
193
+ self.__overlay_asked = False
194
+ # the status is updated by test runs and by the overlay, in different threads
195
+ self.__status_lock = threading.RLock()
196
+
197
+ self.last_mtimes: dict[Path, float] = {}
198
+ if setup:
199
+ self.setup()
200
+
201
+ def setup(self) -> None:
202
+ """
203
+ Create the task's files (keeping existing exercise files, see ``create_files``)
204
+ and run its tests, unless they have already been run by ``check_solved``. Only
205
+ does something the first time it is called.
206
+ """
207
+ if self.__code is None:
208
+ return
209
+ self.create_files(*self.__code)
210
+ self.__code = None
211
+
212
+ # files checked by check_solved keep the time they were checked at, so changes
213
+ # made since are still picked up
214
+ self.last_mtimes = {
215
+ f: self.last_mtimes.get(f, os.path.getmtime(f))
216
+ for f in self.exercise_files
217
+ if os.path.exists(f)
218
+ }
219
+ if not self.__tested:
220
+ self.run_tests()
221
+
222
+ def check_solved(self) -> bool:
223
+ """
224
+ Run the tests synchronously and return whether the task is solved, without
225
+ creating any files. A task whose exercise files don't all exist is unsolved, and
226
+ its tests aren't run.
227
+ """
228
+ if not all(os.path.exists(f) for f in self.exercise_files):
229
+ return False
230
+
231
+ mtimes = {f: os.path.getmtime(f) for f in self.exercise_files}
232
+ with self._tests_lock:
233
+ self._tests_pending = True
234
+ self.__run_tests_locked()
235
+ # changes made from here on are picked up by check_file_changes
236
+ self.last_mtimes = mtimes
237
+ return self.solved
238
+
239
+ @property
240
+ def new_test_results(self) -> bool:
241
+ return self.__new_test_results
242
+
243
+ def clear_test_results(self) -> None:
244
+ """
245
+ Set new_test_results to False, indicating that the new results have been
246
+ processed.
247
+ """
248
+ self.__new_test_results = False
249
+
250
+ @property
251
+ def path(self) -> StrPath:
252
+ return self.__path
253
+
254
+ @property
255
+ def info(self) -> RenderableType:
256
+ """The task info, i.e. what is supposed to be displayed in the pyics header"""
257
+ return self.__info
258
+
259
+ @info.setter
260
+ def info(self, v: str) -> None:
261
+ """The task info, i.e. what is supposed to be displayed in the pyics header"""
262
+ self.__info = v
263
+
264
+ @property
265
+ def status(self) -> RenderableType:
266
+ """The task status, i.e. what is supposed to be displayed in the pyics main panel"""
267
+ return self.__status
268
+
269
+ def update_status(self) -> None:
270
+ """Update the status from the test reports, as the results overlay shows them
271
+ if there is one (see ``check_overlay``)"""
272
+ with self.__status_lock:
273
+ self.__update_status()
274
+
275
+ def __update_status(self) -> None:
276
+ # progress only counts the results of the saved files, never the overlay's
277
+ saved = [r.outcome == "passed" for r in self.test_results if r.when == "call"]
278
+ self.__solved = bool(saved) and round(100 * sum(saved) / len(saved)) == 100
279
+
280
+ results, lines = self.test_results, []
281
+ if self.__overlay is not None:
282
+ try:
283
+ overlay = self.__overlay.apply(self.test_results, self.test_results_time)
284
+ results, lines = overlay.results, overlay.lines
285
+ except Exception: # noqa: BLE001
286
+ get_logger(__name__).warning("The results overlay failed", exc_info=True)
287
+
288
+ # filter tests for only call tests
289
+ relevant_reports = [r for r in results if r.when == "call"]
290
+ passed = [r.outcome == "passed" for r in relevant_reports]
291
+ failed_reports = [r for r in relevant_reports if r.outcome == "failed"]
292
+ pass_rate = round(100 * sum(passed) / len(passed)) if passed else 0
293
+
294
+ # result summary, box=None so the row reaches the panel's own border
295
+ summary = Table(expand=True, show_header=False, box=None, padding=0)
296
+ summary.add_column()
297
+ summary.add_column(justify="right")
298
+ summary.add_row(
299
+ "".join("[bright_green].[/]" if ok else "[bright_red]F[/]" for ok in passed),
300
+ f"[{'bright_green' if pass_rate == 100 else 'bright_red'}]{pass_rate}%[/]",
301
+ )
302
+
303
+ sections: list[RenderableType] = [summary, *lines]
304
+ if self.__traced:
305
+ sections.append(
306
+ Text("Tracing failed tests with snoop, press t to stop.", style="dim")
307
+ )
308
+ elif failed_reports and any(_traceable(t) for t in self.tests):
309
+ sections.append(
310
+ Text("Press t to trace failed tests with snoop.", style="dim")
311
+ )
312
+
313
+ if failed_reports:
314
+ failures = [(r.nodeid, _failure_text(r)) for r in failed_reports]
315
+ traces = [_failure_trace(r) for r in failed_reports]
316
+
317
+ # one rule + traceback per failure, mirroring pytest's own failure sections
318
+ sections.append(
319
+ Padding(Rule("[white]FAILURES[/]", style="white"), pad=(1, 0, 0, 0))
320
+ )
321
+ for (nodeid, text), trace in zip(failures, traces):
322
+ failure: list[RenderableType] = [
323
+ Padding(
324
+ Rule(Text(nodeid, style="bold bright_red"), style="bright_red"),
325
+ pad=(1, 0, 0, 0),
326
+ ),
327
+ Text.assemble(text, style="red"),
328
+ ]
329
+ if trace is not None:
330
+ failure += [
331
+ Padding(
332
+ Rule(Text("trace", style="dim"), style="dim"),
333
+ pad=(1, 0, 0, 0),
334
+ ),
335
+ trace,
336
+ ]
337
+ sections.append(Group(*failure))
338
+
339
+ # pytest's "short test summary info", one line per failed test
340
+ sections.append(
341
+ Padding(
342
+ Rule("[bright_cyan]short test summary info[/]", style="bright_cyan"),
343
+ pad=(1, 0, 0, 0),
344
+ )
345
+ )
346
+ sections += [
347
+ Text.assemble(
348
+ f"FAILED {nodeid} - ",
349
+ Text(" ").join(text.split()),
350
+ style="bright_red",
351
+ )
352
+ for nodeid, text in failures
353
+ ]
354
+
355
+ if relevant_reports:
356
+ n_failed, n_passed = len(failed_reports), sum(passed)
357
+ duration = sum(r.duration for r in relevant_reports)
358
+
359
+ counts = Text()
360
+ if n_failed:
361
+ counts.append(f"{n_failed} failed", style="bold bright_red")
362
+ if n_failed and n_passed:
363
+ counts.append(", ", style="white")
364
+ if n_passed:
365
+ counts.append(f"{n_passed} passed", style="bright_green")
366
+ counts.append(
367
+ f" in {duration:.2f}s", style="bright_red" if n_failed else "bright_green"
368
+ )
369
+ sections.append(Padding(counts, pad=(1, 0, 0, 0)))
370
+
371
+ self.__status = Group(*sections)
372
+
373
+ @property
374
+ def solved(self) -> bool:
375
+ return self.__solved
376
+
377
+ @property
378
+ def tracing(self) -> bool:
379
+ """
380
+ Whether the tests are traced with ``snoop`` when they are run, to show the trace
381
+ of each failed test below its failure (see ``Test.run``). Off for a new task.
382
+ """
383
+ return self.__tracing
384
+
385
+ def toggle_tracing(self) -> None:
386
+ """Turn ``tracing`` on or off, and run the tests again to show the change."""
387
+ self.__tracing = not self.__tracing
388
+ self.run_tests()
389
+
390
+ def check_overlay(self) -> bool:
391
+ """
392
+ Ask the plugins for an overlay of the task's results the first time (see
393
+ ``plugins.ResultsOverlay``), and from then on, whether it has new results, to
394
+ update the status if it has. Cheap enough to call at every tick of the TUI;
395
+ without plugins, it does nothing after the first call.
396
+
397
+ Returns
398
+ -------
399
+ bool
400
+ True if the overlay has new results
401
+ """
402
+ changed = False
403
+ if not self.__overlay_asked:
404
+ self.__overlay_asked = True
405
+ try:
406
+ overlay = plugins.manager().hook.pyics_results_overlay(task=self)
407
+ except Exception: # noqa: BLE001
408
+ get_logger(__name__).warning("A plugin failed", exc_info=True)
409
+ overlay = None
410
+ self.__overlay = overlay
411
+ # the status may have been drawn without it
412
+ changed = overlay is not None
413
+ if self.__overlay is None:
414
+ return False
415
+ try:
416
+ changed = self.__overlay.poll() or changed
417
+ except Exception: # noqa: BLE001
418
+ # asked again at the next tick
419
+ get_logger(__name__).debug("The results overlay failed", exc_info=True)
420
+ if changed:
421
+ with self.__status_lock:
422
+ # until the files have been tested, the status says the tests are
423
+ # pending
424
+ if self.__tested:
425
+ self.update_status()
426
+ self.__new_test_results = True
427
+ return changed
428
+
429
+ def check_file_changes(self) -> bool:
430
+ """
431
+ Check whether an exercise file has been changed by monitoring the last modified
432
+ timestamp.
433
+
434
+ Returns
435
+ -------
436
+ bool
437
+ True if any exercise file has been changed
438
+ """
439
+ changed = map(self._check_file_changed, self.exercise_files)
440
+ return any(changed)
441
+
442
+ def _check_file_changed(self, filepath: Path) -> bool:
443
+ """
444
+ Check whether a single exercise file has been changed by monitoring the last
445
+ modified timestamp.
446
+
447
+ Returns
448
+ -------
449
+ bool
450
+ True if the file has been changed, False otherwise
451
+ """
452
+ if not os.path.exists(filepath):
453
+ return False
454
+
455
+ current_mtime = os.path.getmtime(filepath)
456
+ if current_mtime > self.last_mtimes.get(filepath, 0):
457
+ self.last_mtimes[filepath] = current_mtime
458
+ return True
459
+
460
+ return False
461
+
462
+ def run_tests(self) -> None:
463
+ """
464
+ Runs all tests in self.tests using pytest and stores the unfiltered reports in
465
+ self.test_results.
466
+
467
+ Starts self.__run_tests as a daemon thread.
468
+ """
469
+ self._tests_pending = True
470
+ if not self._tests_lock.acquire(blocking=False):
471
+ return
472
+ threading.Thread(target=self.__run_tests, daemon=True).start()
473
+
474
+ def __run_tests(self) -> None:
475
+ """
476
+ DO NOT CALL THIS METHOD DIRECTLY. CALL run_tests INSTEAD.
477
+
478
+ Runs all tests in self.tests synchronously using pytest and stores the unfiltered
479
+ reports in self.test_results.
480
+ """
481
+ try:
482
+ self.__run_tests_locked()
483
+ finally:
484
+ self._tests_lock.release()
485
+
486
+ def __run_tests_locked(self) -> None:
487
+ """Run the tests while they are pending. ``self._tests_lock`` must be held."""
488
+ while self._tests_pending:
489
+ self._tests_pending = False
490
+ tracing = self.__tracing
491
+ plugin = LiveTestCollectionPlugin(
492
+ self.tests, tracing=tracing, sections=self.sections
493
+ )
494
+ saved_at = self.__saved_at()
495
+
496
+ with _pytest_lock, terminal.preserve_terminal_mode():
497
+ pytest.main(["-p", "no:terminal", "--capture=no"], plugins=[plugin])
498
+
499
+ # log reports
500
+ logger = get_logger(__name__)
501
+ for result in plugin.results:
502
+ logger.debug(f"{result.nodeid} {result.when} {result.outcome}")
503
+ logger.debug(_failure_text(result).plain)
504
+
505
+ with self.__status_lock:
506
+ self.test_results = plugin.results
507
+ self.test_results_time = saved_at
508
+ self.__traced = tracing
509
+ self.__tested = True
510
+ self.update_status()
511
+ self.__new_test_results = True
512
+
513
+ def __saved_at(self) -> float:
514
+ """When the exercise files were last saved: the latest of their modification
515
+ times, or now if none can be told (see ``test_results_time``)."""
516
+ times = []
517
+ for f in self.exercise_files:
518
+ try:
519
+ times.append(os.path.getmtime(f))
520
+ except OSError:
521
+ pass
522
+ return max(times, default=time.time())
523
+
524
+ def create_files(
525
+ self,
526
+ exercise_code: list[str],
527
+ solution_code: list[str],
528
+ *,
529
+ overwrite: bool = False,
530
+ ) -> None:
531
+ """
532
+ Create the exercise and solution files. Assumes that filepaths in
533
+ self.exercise_files and self.solution_files are in the same order as the code
534
+ lists, respectively.
535
+
536
+ Existing exercise files are kept (they hold the student's work) unless
537
+ ``overwrite`` is set. Solution files are always rewritten. Missing parent
538
+ directories are created.
539
+
540
+ Parameters
541
+ ----------
542
+ exercise_code: list[str]
543
+ List of file-contents for the exercise files
544
+ solution_code: list[str]
545
+ List of file-contents for the solution files
546
+ overwrite: bool (optional, keyword only)
547
+ Reset existing exercise files to the exercise code. Default is False.
548
+ """
549
+
550
+ for fp, code in zip(self.exercise_files, exercise_code):
551
+ if not overwrite and fp.exists():
552
+ continue
553
+ fp.parent.mkdir(parents=True, exist_ok=True)
554
+ fp.write_text(code, encoding="utf-8")
555
+
556
+ for fp, code in zip(self.solution_files, solution_code):
557
+ fp.parent.mkdir(parents=True, exist_ok=True)
558
+ fp.write_text(code, encoding="utf-8")
559
+
560
+ @staticmethod
561
+ def read_task_json(fp: StrPath) -> dict[str, Any]:
562
+ """
563
+ Read a task file without loading the task, i.e. without creating its files or
564
+ running its tests. Inverse of ``create_task_json``: returns its keyword
565
+ arguments, so ``Task.create_task_json(fp, **Task.read_task_json(fp))`` rewrites
566
+ the task as it is.
567
+
568
+ File paths are resolved relative to the task file's directory, like in
569
+ ``__init__``.
570
+
571
+ Parameters
572
+ ----------
573
+ fp: StrPath
574
+ Path to the task file
575
+
576
+ Returns
577
+ -------
578
+ dict[str, Any]
579
+ ``name``, ``instructions``, ``ex_files``, ``sol_files``, ``tests``,
580
+ ``help`` and ``sections`` (by notebook path, resolved like ``ex_files``),
581
+ and the contents stored for the exercise and solution files as
582
+ ``file_contents``, by path.
583
+ """
584
+ raw_json = _load_task_json(fp)
585
+
586
+ base = Path(fp).parent
587
+
588
+ ex_files = [paths.resolve(f, base) for f in raw_json.get("ex_files", [])]
589
+ sol_files = [paths.resolve(f, base) for f in raw_json.get("sol_files", [])]
590
+
591
+ file_contents = dict(zip(ex_files, raw_json.get("exercise_code", [])))
592
+ file_contents.update(zip(sol_files, raw_json.get("solution_code", [])))
593
+
594
+ tests: list[Test] = raw_json.get("tests") or []
595
+ for test in tests:
596
+ test.file_path = paths.resolve(test.file_path, base)
597
+
598
+ return {
599
+ "name": raw_json.get("name", "Task"),
600
+ "instructions": raw_json.get("instructions", ""),
601
+ "ex_files": ex_files,
602
+ "sol_files": sol_files,
603
+ "tests": tests,
604
+ "file_contents": file_contents,
605
+ "help": _hint_list(raw_json.get("help")),
606
+ "sections": _read_sections(raw_json, base),
607
+ }
608
+
609
+ @staticmethod
610
+ def create_task_json(
611
+ fp: StrPath,
612
+ name: str,
613
+ instructions: str,
614
+ ex_files: StrPath | Sequence[StrPath] | None = None,
615
+ sol_files: StrPath | Sequence[StrPath] | None = None,
616
+ tests: Union[Test, list[Test], None] = None,
617
+ file_contents: Mapping[StrPath, str] | None = None,
618
+ help: str | Sequence[str] | None = None,
619
+ sections: Mapping[StrPath, str] | None = None,
620
+ ) -> Path:
621
+ """
622
+ Create a task_json from all relevant information as it would naturally be available
623
+ when designing a task.
624
+
625
+ File paths (``ex_files``, ``sol_files`` and the tests' ``file_path``) are given
626
+ relative to the working directory (or absolute), and stored relative to the
627
+ directory of the task file, so the task keeps working when it is moved together
628
+ with its files.
629
+
630
+ Parameters
631
+ ----------
632
+ fp: StrPath
633
+ Desired output path
634
+ name: str
635
+ Name of the task
636
+ instructions: str
637
+ Instructions for the task
638
+ ex_files: StrPath | Sequence[StrPath] | None
639
+ Filename or list of filenames of exercise files.
640
+ sol_files: StrPath | Sequence[StrPath] | None
641
+ Filename or list of filenames of solution files.
642
+ tests: Union[Test, list[Test]]
643
+ Filename or list of filenames containing serialized tests
644
+ file_contents: Mapping[StrPath, str] | None
645
+ Contents to store for some of the exercise and solution files, by path,
646
+ instead of reading these files, e.g. the contents already stored in the task
647
+ file when editing it (see ``read_task_json``). Other files are read. The
648
+ contents of notebooks are stored normalized (see ``cells.normalize``), e.g.
649
+ without their outputs.
650
+ help: str | Sequence[str] | None
651
+ Hint or list of hints for the task, which the student can reveal one at a
652
+ time, in order.
653
+ sections: Mapping[StrPath, str] | None
654
+ The section the task is about (see ``pyics.notebook.sections``) of some of
655
+ its exercise notebooks, by path. Its tests inherit it, unless they have a
656
+ section of their own. Stored as ``sections``, by the notebook's path
657
+ relative to the task file (with forward slashes), and left out if empty.
658
+ Older versions of pyics ignore it.
659
+
660
+ Returns
661
+ -------
662
+ Path
663
+ The path the task file was written to, with its suffix normalized to
664
+ '.pyicstask.json'.
665
+
666
+ Raises
667
+ ------
668
+ ValueError
669
+ If a path of ``sections`` isn't one of the exercise notebooks, or a name in
670
+ it can't be the name of a section.
671
+ """
672
+ fp = Path(fp)
673
+ base = fp.parent
674
+ task: dict = {}
675
+
676
+ task["name"] = name
677
+ task["instructions"] = instructions
678
+ task["help"] = _hint_list(help)
679
+
680
+ ex_files = _path_list(ex_files)
681
+ sol_files = _path_list(sol_files)
682
+ task["ex_files"] = [paths.relative_to(f, base) for f in ex_files]
683
+ task["sol_files"] = [paths.relative_to(f, base) for f in sol_files]
684
+
685
+ notebooks = {paths.absolute(f) for f in ex_files if cells.is_notebook(f)}
686
+ stored_sections = {}
687
+ for path, section in (sections or {}).items():
688
+ if paths.absolute(path) not in notebooks:
689
+ raise ValueError(
690
+ f"{path} has a section, but isn't one of the exercise notebooks"
691
+ )
692
+ key = paths.relative_to(path, base).as_posix()
693
+ stored_sections[key] = notebook_sections.check_name(section)
694
+ if stored_sections:
695
+ task["sections"] = stored_sections
696
+
697
+ contents = {paths.absolute(f): c for f, c in (file_contents or {}).items()}
698
+
699
+ def read(f: StrPath) -> str:
700
+ if (key := paths.absolute(f)) in contents:
701
+ text = contents[key]
702
+ else:
703
+ text = Path(f).read_text(encoding="utf-8")
704
+ if cells.is_notebook(f):
705
+ # without outputs, which would leak the solution's, and bloat the file
706
+ return cells.normalize(text, name=Path(f).name)
707
+ return text
708
+
709
+ task["exercise_code"] = [read(f) for f in ex_files]
710
+ task["solution_code"] = [read(f) for f in sol_files]
711
+
712
+ tests = tests if isinstance(tests, list) else [tests] if tests is not None else []
713
+ task["tests"] = []
714
+ for test in tests:
715
+ # copied, so the caller's test keeps its path
716
+ test = copy.copy(test)
717
+ test.file_path = paths.relative_to(test.file_path, base)
718
+ task["tests"].append(test)
719
+
720
+ os.makedirs(base, exist_ok=True)
721
+
722
+ match fp.suffixes:
723
+ case []:
724
+ fp = fp.with_suffix(".pyicstask.json")
725
+ case [_]:
726
+ get_logger(__name__).warning(
727
+ f"Replacing suffix '{fp.suffix}' with '.pyicstask.json'."
728
+ )
729
+ fp = fp.with_suffix(".pyicstask.json")
730
+ case [".pyicstask", ".json"]:
731
+ pass
732
+ case _:
733
+ get_logger(__name__).warning(
734
+ f"File suffixes don't match expected file path. Adding '.pyicstask.json'."
735
+ )
736
+ fp = fp.with_suffix(fp.suffix + ".pyicstask.json")
737
+
738
+ # serialized before the file is opened, so a task file that is being edited
739
+ # isn't left empty if serializing fails
740
+ text = json.dumps(task, indent=4)
741
+ with open(fp, "w", encoding="utf-8") as f:
742
+ f.write(text)
743
+
744
+ return fp