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,590 @@
1
+ """
2
+ Run the code cells of a notebook as a module, like a fresh run of the saved notebook in
3
+ a new kernel, and turn them into Python source, e.g. for ``StructureTest``.
4
+
5
+ Cells go through IPython's ``TransformerManager`` first, so that magics (``%matplotlib
6
+ inline``) and shell commands (``x = !ls``) become calls of ``get_ipython()``. A stand-in
7
+ answers them (see ``_Shell``): magics and shell commands do nothing, except for
8
+ ``%%time``, ``%%timeit`` and ``%%capture`` (and ``%time`` and ``%timeit``), which run
9
+ their code once. Other cell magics (``%%writefile``, ``%%bash``, ...) are skipped.
10
+
11
+ Each code cell is then compiled on its own, under the file name ``<path>:cell N`` (N
12
+ counts all cells from 1), and the code it runs is registered in ``linecache`` as the
13
+ file's source, so that tracebacks and traces show e.g. ``bfs.ipynb:cell 3`` with the
14
+ cell's lines, which keep their numbers. That is the cell's source, apart from magics
15
+ and shell commands, which show up as the calls of ``get_ipython()`` they run: traces
16
+ (``snoop``) only show the source of a file that can be parsed.
17
+
18
+ Errors don't stop a run: the error of each cell is recorded (see ``CellError``), and the
19
+ next cell still runs, so that an unfinished exercise doesn't break the others. Cells
20
+ tagged ``skip-execution`` (e.g. downloads or training) don't run.
21
+ """
22
+
23
+ import ast
24
+ import contextlib
25
+ import functools
26
+ import importlib
27
+ import io
28
+ import linecache
29
+ import os
30
+ import sys
31
+ import threading
32
+ import tokenize
33
+ from collections.abc import Callable, Generator, Iterable
34
+ from dataclasses import dataclass, field
35
+ from types import CodeType, ModuleType
36
+ from typing import Any, NamedTuple
37
+
38
+ from pyics.notebook import cells, sections
39
+ from pyics.notebook.cells import Cell
40
+ from pyics.tests.capture import capture_stdout
41
+ from pyics.tests.trace import Trace
42
+ from pyics.util.paths import StrPath
43
+
44
+ # the tag of cells that aren't run, as nbclient uses it
45
+ SKIP_TAG = "skip-execution"
46
+ # cell magics whose body is Python, which is run once (without timing it, or showing
47
+ # the captured output). The bodies of other cell magics aren't Python, and are skipped.
48
+ _CODE_CELL_MAGICS = frozenset({"time", "timeit", "capture"})
49
+ # line magics whose statement is run once
50
+ _CODE_LINE_MAGICS = frozenset({"time", "timeit"})
51
+ # the module variable holding the value of a cell's final expression while it runs. Its
52
+ # dunder name keeps it out of traces (see ``trace._FrameInfo``).
53
+ _OUT = "__pyics_out__"
54
+
55
+ _local = threading.local()
56
+
57
+
58
+ def last_errors() -> list[CellError]:
59
+ """The errors of the cells of the notebook that was run last in this thread (see
60
+ ``run_notebook``), until they are cleared (see ``clear_last_errors``)."""
61
+ return list(getattr(_local, "errors", ()))
62
+
63
+
64
+ def clear_last_errors() -> None:
65
+ """Forget the errors of the notebook that was run last in this thread, e.g. before
66
+ a test that may run one."""
67
+ _local.errors = []
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class CellError:
72
+ """The error a cell raised when a notebook was run."""
73
+
74
+ cell: Cell
75
+ error: BaseException
76
+
77
+ def describe(self) -> str:
78
+ """The error, e.g. "cell 7 raised NameError: name 'qeue' is not defined"."""
79
+ err = self.error
80
+ if isinstance(err, SyntaxError):
81
+ # its message would name the file, and the line in a form of its own
82
+ what = err.msg + (f" (line {err.lineno})" if err.lineno else "")
83
+ else:
84
+ what = str(err)
85
+ text = f"cell {self.cell.number} raised {type(err).__name__}"
86
+ return f"{text}: {what}" if what else text
87
+
88
+
89
+ @dataclass
90
+ class NotebookRun:
91
+ """A run of a notebook's code cells (see ``run_notebook``)."""
92
+
93
+ # the module the cells ran in
94
+ module: ModuleType
95
+ # the errors of the cells that raised one, in order
96
+ errors: list[CellError] = field(default_factory=list)
97
+ # what each cell that ran wrote to stdout, by the cell's index: printed text, and
98
+ # what Jupyter shows as text, i.e. the ``repr`` of objects passed to ``display``,
99
+ # and of the cell's final expression
100
+ outputs: dict[int, str] = field(default_factory=dict)
101
+ # the notebook that was run, as read by ``cells.read``
102
+ notebook: dict[str, Any] | None = None
103
+
104
+ def output(
105
+ self, of: Iterable[Cell] | None = None, *, section: str | None = None
106
+ ) -> str:
107
+ """
108
+ The output of the cells ``of``, or of the cells of the notebook's section
109
+ ``section`` (see ``pyics.notebook.sections``), or of all cells by default, in
110
+ their order in the notebook.
111
+
112
+ Raises
113
+ ------
114
+ SectionNotFound
115
+ If the notebook that was run has no section ``section``.
116
+ """
117
+ if section is not None:
118
+ if of is not None:
119
+ raise TypeError("give either the cells or the section, not both")
120
+ if self.notebook is None:
121
+ raise ValueError("the notebook that was run isn't known")
122
+ of = sections.get_section(self.notebook, section).cells
123
+ if of is None:
124
+ return "".join(self.outputs[i] for i in sorted(self.outputs))
125
+ indices = sorted({cell.index for cell in of})
126
+ return "".join(self.outputs.get(i, "") for i in indices)
127
+
128
+
129
+ def cell_filename(path: StrPath, cell: Cell) -> str:
130
+ """The file name the code of ``cell`` of the notebook at ``path`` is compiled
131
+ under, e.g. ``.exercises/bfs.ipynb:cell 3``."""
132
+ return f"{os.fspath(path)}:cell {cell.number}"
133
+
134
+
135
+ # --- turning cells into Python ---
136
+
137
+
138
+ @functools.cache
139
+ def _transformer() -> Any:
140
+ # imported when first needed, since importing IPython takes a while
141
+ from IPython.core.inputtransformer2 import TransformerManager
142
+
143
+ return TransformerManager()
144
+
145
+
146
+ def transform(source: str) -> str:
147
+ """
148
+ The cell source ``source`` with IPython's syntax (magics, shell commands, ``x?``)
149
+ turned into Python, as IPython runs it. Lines keep their numbers, apart from lines
150
+ joined by a magic that is continued with ``\\``.
151
+ """
152
+ lines = source.splitlines(keepends=True)
153
+ blank = 0
154
+ while blank < len(lines) and not lines[blank].strip():
155
+ blank += 1
156
+ rest = "".join(lines[blank:])
157
+ try:
158
+ code = _transformer().transform_cell(rest) if rest else ""
159
+ except Exception: # noqa: BLE001
160
+ code = rest # e.g. a tokenizing error, which compiling it reports
161
+ # IPython removes leading blank lines, which would shift the lines that follow
162
+ return "\n" * blank + code
163
+
164
+
165
+ class _CellMagic(NamedTuple):
166
+ """The cell magic a cell starts with, e.g. ``%%capture out``."""
167
+
168
+ # e.g. "capture"
169
+ name: str
170
+ # what follows its name on its line, e.g. "out"
171
+ args: str
172
+ # the cell's lines after it
173
+ body: str
174
+ # the line of the cell it is on, from 1
175
+ line: int
176
+
177
+
178
+ def _cell_magic(source: str) -> _CellMagic | None:
179
+ """The cell magic the cell source ``source`` starts with, if it does."""
180
+ lines = source.splitlines(keepends=True)
181
+ first = next((i for i, line in enumerate(lines) if line.strip()), None)
182
+ if first is None or not lines[first].startswith("%%"):
183
+ return None
184
+ name, _, args = lines[first][2:].strip().partition(" ")
185
+ return _CellMagic(name, args.strip(), "".join(lines[first + 1 :]), first + 1)
186
+
187
+
188
+ def _python_code(source: str) -> str | None:
189
+ """
190
+ The cell source ``source`` as the Python it runs, with its lines at their numbers in
191
+ the cell (see ``transform``). For a cell magic that runs its body (e.g. ``%%time``),
192
+ that is its body, below the magic as a comment. None for a cell magic whose body
193
+ isn't Python (e.g. ``%%bash``), which isn't run.
194
+ """
195
+ if (magic := _cell_magic(source)) is None:
196
+ return transform(source)
197
+ if magic.name not in _CODE_CELL_MAGICS:
198
+ return None
199
+ above = "\n" * (magic.line - 1) + f"# %%{magic.name} {magic.args}".rstrip() + "\n"
200
+ return above + transform(magic.body)
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class NotebookSource:
205
+ """The code cells of a notebook as Python source (see ``python_source``)."""
206
+
207
+ # the code of the cells, one after the other
208
+ source: str
209
+ # the cells whose code is in ``source``, in order
210
+ cells: list[Cell]
211
+ # the cells left out since their code can't be parsed, with the error
212
+ unparsable: list[tuple[Cell, SyntaxError]]
213
+ # the line of ``source`` the code of each of ``cells`` starts at, from 1
214
+ starts: list[int] = field(default_factory=list)
215
+
216
+ def locate(self, line: int) -> str:
217
+ """Where the line ``line`` of ``source`` (from 1) is in the notebook, e.g.
218
+ "cell 3, line 2"."""
219
+ for cell, start in zip(reversed(self.cells), reversed(self.starts)):
220
+ if start <= line:
221
+ return f"cell {cell.number}, line {line - start + 1}"
222
+ return f"line {line}"
223
+
224
+
225
+ def _source_of(code_cells: Iterable[Cell]) -> NotebookSource:
226
+ """``code_cells`` as Python source (see ``python_source``)."""
227
+ parts: list[str] = []
228
+ included: list[Cell] = []
229
+ unparsable: list[tuple[Cell, SyntaxError]] = []
230
+ starts: list[int] = []
231
+ line = 1
232
+ for cell in code_cells:
233
+ code = _python_code(cell.source)
234
+ if code is None:
235
+ continue
236
+ try:
237
+ ast.parse(code)
238
+ except SyntaxError as err:
239
+ unparsable.append((cell, err))
240
+ continue
241
+ # every cell takes at least a line, so that each line is in one cell
242
+ code = code if code.endswith("\n") else code + "\n"
243
+ parts.append(code)
244
+ included.append(cell)
245
+ starts.append(line)
246
+ line += code.count("\n")
247
+ return NotebookSource("".join(parts), included, unparsable, starts)
248
+
249
+
250
+ def python_source(
251
+ path_or_nb: StrPath | dict[str, Any], *, section: str | None = None
252
+ ) -> NotebookSource:
253
+ """
254
+ The code cells of the notebook at ``path_or_nb`` (or of the notebook itself, as read
255
+ by ``cells.parse``) as Python source, e.g. to check its structure: their code one
256
+ after the other, with magics and shell commands turned into Python (see
257
+ ``transform``). The bodies of cell magics that run them (``%%time``,
258
+ ``%%timeit`` and ``%%capture``) are included, the cells of other cell magics (e.g.
259
+ ``%%bash``) and those that can't be parsed are left out. Cells tagged
260
+ ``skip-execution`` are included, as they are part of the notebook's code.
261
+
262
+ With ``section``, only the code cells of the notebook's section of that name (see
263
+ ``pyics.notebook.sections``) are included.
264
+
265
+ Raises
266
+ ------
267
+ NotebookError
268
+ If the notebook isn't a notebook in nbformat 4.
269
+ SectionNotFound
270
+ If the notebook has no section ``section``.
271
+ """
272
+ nb = path_or_nb if isinstance(path_or_nb, dict) else cells.read(path_or_nb)
273
+ if section is None:
274
+ return _source_of(cells.code_cells(nb))
275
+ return _source_of(sections.get_section(nb, section).code_cells)
276
+
277
+
278
+ # --- running cells ---
279
+
280
+
281
+ def _ends_with_semicolon(code: str) -> bool:
282
+ """Whether the last token of ``code``, apart from comments, is ``;``, which hides
283
+ the value of a cell's final expression in Jupyter."""
284
+ try:
285
+ tokens = list(tokenize.generate_tokens(io.StringIO(code).readline))
286
+ except tokenize.TokenError, SyntaxError:
287
+ return False
288
+ ignored = {
289
+ tokenize.ENDMARKER,
290
+ tokenize.NEWLINE,
291
+ tokenize.NL,
292
+ tokenize.COMMENT,
293
+ tokenize.INDENT,
294
+ tokenize.DEDENT,
295
+ }
296
+ last = next((t for t in reversed(tokens) if t.type not in ignored), None)
297
+ return last is not None and last.type == tokenize.OP and last.string == ";"
298
+
299
+
300
+ def _compile(code: str, filename: str) -> CodeType:
301
+ """
302
+ Compile the cell code ``code`` under ``filename``. The value of its final
303
+ expression, which Jupyter shows (as ``Out[n]``), is assigned to ``_OUT``, unless the
304
+ code ends with ``;``.
305
+
306
+ Raises
307
+ ------
308
+ SyntaxError
309
+ If ``code`` can't be compiled.
310
+ """
311
+ tree = ast.parse(code, filename)
312
+ last = tree.body[-1] if tree.body else None
313
+ if isinstance(last, ast.Expr) and not _ends_with_semicolon(code):
314
+ target = ast.Name(_OUT, ast.Store())
315
+ assign = ast.Assign(targets=[target], value=last.value)
316
+ tree.body[-1] = ast.fix_missing_locations(ast.copy_location(assign, last))
317
+ return compile(tree, filename, "exec", dont_inherit=True)
318
+
319
+
320
+ def _register_source(filename: str, source: str) -> None:
321
+ """Register ``source`` as the contents of the file ``filename`` in ``linecache``,
322
+ where tracebacks, ``inspect`` and traces look up source lines. Without a
323
+ modification time, ``linecache.checkcache`` keeps it."""
324
+ lines = source.splitlines(keepends=True)
325
+ if lines and not lines[-1].endswith("\n"):
326
+ lines[-1] += "\n"
327
+ linecache.cache[filename] = (len(source), None, lines, filename)
328
+
329
+
330
+ def _display(*objs: object, **kwargs: object) -> None:
331
+ """Stands in for IPython's ``display``: shows each object as its ``repr``, as
332
+ Jupyter shows objects as text."""
333
+ for obj in objs:
334
+ print(repr(obj))
335
+
336
+
337
+ class _ThreadLocalDisplay:
338
+ """
339
+ ``IPython.display.display`` replacement that shows the objects of a thread that
340
+ runs a notebook's cells like ``_display``. Without a shell, IPython's own prints
341
+ their ``str`` instead, so that notebooks that import ``display`` would output
342
+ something else than those that use the one Jupyter provides. Other threads are
343
+ unaffected, e.g. the one that runs a kernel's cells.
344
+ """
345
+
346
+ def __init__(self, real: Callable[..., Any]):
347
+ self._real = real
348
+ functools.update_wrapper(self, real)
349
+
350
+ def __call__(self, *objs: object, **kwargs: Any) -> Any:
351
+ if getattr(_local, "text_display", False):
352
+ return _display(*objs, **kwargs)
353
+ return self._real(*objs, **kwargs)
354
+
355
+
356
+ _install_lock = threading.Lock()
357
+ # the modules that notebooks import IPython's ``display`` from
358
+ _DISPLAY_MODULES = ("IPython.core.display_functions", "IPython.display")
359
+
360
+
361
+ def _ensure_display_installed() -> None:
362
+ with _install_lock:
363
+ for name in _DISPLAY_MODULES:
364
+ module = importlib.import_module(name)
365
+ if not isinstance(module.display, _ThreadLocalDisplay):
366
+ module.display = _ThreadLocalDisplay(module.display)
367
+
368
+
369
+ @contextlib.contextmanager
370
+ def _text_display() -> Generator[None]:
371
+ """Show the objects this thread passes to IPython's own ``display``, which
372
+ notebooks get by ``from IPython.display import display``, like ``_display``."""
373
+ _ensure_display_installed()
374
+ previous = getattr(_local, "text_display", False)
375
+ _local.text_display = True
376
+ try:
377
+ yield
378
+ finally:
379
+ _local.text_display = previous
380
+
381
+
382
+ class _CapturedIO:
383
+ """What ``%%capture <name>`` assigns to ``<name>``: the output of the cell."""
384
+
385
+ def __init__(self, stdout: str):
386
+ self.stdout = stdout
387
+ self.stderr = ""
388
+ self.outputs: list[object] = []
389
+
390
+ def show(self) -> None:
391
+ print(self.stdout, end="")
392
+
393
+ __call__ = show
394
+
395
+
396
+ class _Shell:
397
+ """
398
+ Stands in for IPython's shell, which the code of cells calls as ``get_ipython()``
399
+ for magics and shell commands (see ``transform``): they do nothing, and shell
400
+ commands have no output. The magics ``%%time``, ``%%timeit`` and ``%%capture`` (and
401
+ ``%time`` and ``%timeit``) run their code once instead, without timing it or
402
+ showing the captured output.
403
+ """
404
+
405
+ def __init__(self, namespace: dict[str, Any], trace: Trace | None):
406
+ self.user_ns = namespace
407
+ self._trace = trace
408
+
409
+ def execute(self, code: CodeType) -> Any:
410
+ """Run ``code`` (see ``_compile``), and return the value of its final
411
+ expression. With a trace, ``code`` is watched by it."""
412
+ if self._trace is not None:
413
+ self._trace.watch(code)
414
+ try:
415
+ exec(code, self.user_ns) # noqa: S102
416
+ return self.user_ns.get(_OUT)
417
+ finally:
418
+ self.user_ns.pop(_OUT, None)
419
+
420
+ def run_magic_body(self, name: str, args: str, run: Callable[[], Any]) -> Any:
421
+ """Run the body of the cell magic ``%%<name> <args>`` with ``run``, which
422
+ returns the value of its final expression, if the magic runs its body, and
423
+ return that value as the magic's."""
424
+ if name not in _CODE_CELL_MAGICS:
425
+ return None
426
+ if name != "capture":
427
+ return run()
428
+ with capture_stdout() as buffer:
429
+ if (value := run()) is not None:
430
+ print(repr(value))
431
+ if names := [arg for arg in args.split() if not arg.startswith("-")]:
432
+ self.user_ns[names[0]] = _CapturedIO(buffer.getvalue())
433
+ return None
434
+
435
+ def run_line_magic(self, magic_name: str, line: str, _stack_depth: int = 1) -> Any:
436
+ if magic_name not in _CODE_LINE_MAGICS:
437
+ return None
438
+ # compiled at the line of the magic in the code that calls it, i.e. the cell
439
+ caller = sys._getframe(1)
440
+ code = "\n" * (caller.f_lineno - 1) + transform(line)
441
+ return self.execute(_compile(code, caller.f_code.co_filename))
442
+
443
+ def run_cell_magic(self, magic_name: str, line: str, cell: str) -> Any:
444
+ # compiled at the lines below the magic in the code that calls it
445
+ caller = sys._getframe(1)
446
+ code = "\n" * caller.f_lineno + transform(cell)
447
+ filename = caller.f_code.co_filename
448
+ return self.run_magic_body(
449
+ magic_name, line, lambda: self.execute(_compile(code, filename))
450
+ )
451
+
452
+ def magic(self, line: str) -> None:
453
+ return None
454
+
455
+ def system(self, cmd: str) -> None:
456
+ return None
457
+
458
+ def getoutput(self, cmd: str, split: bool = True, depth: int = 0) -> Any:
459
+ from IPython.utils.text import SList
460
+
461
+ return SList() if split else ""
462
+
463
+
464
+ @contextlib.contextmanager
465
+ def _non_interactive_plots() -> Generator[None]:
466
+ """
467
+ Draw plots with matplotlib's non-interactive Agg backend, so that e.g.
468
+ ``plt.show()`` doesn't open a window or block. Figures are closed afterwards, as
469
+ Jupyter closes them once they are shown.
470
+ """
471
+ previous = os.environ.get("MPLBACKEND")
472
+ os.environ["MPLBACKEND"] = "Agg"
473
+ if (matplotlib := sys.modules.get("matplotlib")) is not None:
474
+ # if this fails, plots may block, but the cells still run
475
+ with contextlib.suppress(Exception):
476
+ matplotlib.use("Agg") # also switches pyplot's backend, if it is imported
477
+ try:
478
+ yield
479
+ finally:
480
+ if previous is None:
481
+ os.environ.pop("MPLBACKEND", None)
482
+ else:
483
+ os.environ["MPLBACKEND"] = previous
484
+ if (pyplot := sys.modules.get("matplotlib.pyplot")) is not None:
485
+ with contextlib.suppress(Exception):
486
+ pyplot.close("all")
487
+
488
+
489
+ class _Runner:
490
+ """Runs the code cells of the notebook at ``path`` in ``run.module``."""
491
+
492
+ def __init__(self, path: StrPath, run: NotebookRun, trace: Trace | None):
493
+ self.path = path
494
+ self.run = run
495
+ namespace = run.module.__dict__
496
+ self.shell = _Shell(namespace, trace)
497
+ namespace["get_ipython"] = lambda: self.shell
498
+ namespace["display"] = _display
499
+
500
+ def run_cells(self, code_cells: Iterable[Cell]) -> None:
501
+ for cell in code_cells:
502
+ if SKIP_TAG in cell.tags:
503
+ continue
504
+ exited = False
505
+ # each cell's output on its own; it never reaches the console
506
+ with capture_stdout() as buffer:
507
+ try:
508
+ self._run_cell(cell)
509
+ except SystemExit:
510
+ exited = True # like a script that exits
511
+ except Exception as err: # noqa: BLE001
512
+ self.run.errors.append(CellError(cell, err))
513
+ self.run.outputs[cell.index] = buffer.getvalue()
514
+ if exited:
515
+ return
516
+
517
+ def _run_cell(self, cell: Cell) -> None:
518
+ code = _python_code(cell.source)
519
+ if code is None:
520
+ return # e.g. %%writefile, which is skipped
521
+ filename = cell_filename(self.path, cell)
522
+ # the code that runs, which traces can only show if it can be parsed.
523
+ # Registered before compiling, so that syntax errors can show their line, too.
524
+ _register_source(filename, code)
525
+ compiled = _compile(code, filename)
526
+ if (magic := _cell_magic(cell.source)) is None:
527
+ value = self.shell.execute(compiled)
528
+ else:
529
+ run = functools.partial(self.shell.execute, compiled)
530
+ value = self.shell.run_magic_body(magic.name, magic.args, run)
531
+ if value is not None:
532
+ print(repr(value))
533
+
534
+
535
+ def run_notebook(
536
+ path: StrPath,
537
+ *,
538
+ as_main: bool = False,
539
+ trace: Trace | None = None,
540
+ module_name: str = "current_solution",
541
+ ) -> NotebookRun:
542
+ """
543
+ Run the code cells of the notebook at ``path`` in order, in a new module, which is
544
+ registered in ``sys.modules`` as ``module_name``. With ``as_main``, it is run as a
545
+ script, i.e. with ``__name__ == "__main__"``.
546
+
547
+ An error in a cell is recorded, and the next cell still runs. ``SystemExit`` ends
548
+ the run, like a script that exits. Cells tagged ``skip-execution`` don't run. The
549
+ module has ``display``, which prints the ``repr`` of objects (and so does the one
550
+ cells import from ``IPython.display``), and ``get_ipython()`` for magics (see
551
+ ``transform``). Each cell's output is captured on its own (see
552
+ ``NotebookRun.outputs``), so ``input()`` answers given by ``provide_input`` show up
553
+ in the output of the cell that asks for them.
554
+
555
+ With ``trace``, the cells' code is watched by it (see ``Trace.watch``), so that the
556
+ calls into it that are run by ``trace`` are traced. A script is traced as a whole.
557
+
558
+ The errors of the run are also available as ``last_errors()``.
559
+
560
+ Raises
561
+ ------
562
+ NotebookError
563
+ If the file isn't a notebook in nbformat 4.
564
+ """
565
+ nb = cells.read(path)
566
+ module = ModuleType(module_name)
567
+ if as_main:
568
+ module.__name__ = "__main__"
569
+ sys.modules[module_name] = module
570
+
571
+ run = NotebookRun(module, notebook=nb)
572
+ runner = _Runner(path, run, trace)
573
+ code_cells = cells.code_cells(nb)
574
+ try:
575
+ with _non_interactive_plots(), _text_display():
576
+ if trace is not None and as_main:
577
+ trace.run(lambda: runner.run_cells(code_cells))
578
+ else:
579
+ runner.run_cells(code_cells)
580
+ finally:
581
+ _local.errors = list(run.errors)
582
+ return run
583
+
584
+
585
+ def load_module(
586
+ path: StrPath, *, as_main: bool = False, trace: Trace | None = None
587
+ ) -> ModuleType:
588
+ """The module the code cells of the notebook at ``path`` ran in, as the module
589
+ ``current_solution`` (see ``run_notebook``)."""
590
+ return run_notebook(path, as_main=as_main, trace=trace).module