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
pyics/notebook/loader.py
ADDED
|
@@ -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
|