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/cells.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Jupyter notebooks (``.ipynb`` files), read as plain JSON (without ``nbformat``): their
|
|
3
|
+
cells, and their contents normalized for storing them in task files.
|
|
4
|
+
|
|
5
|
+
Only nbformat 4 is supported, which Jupyter has written since 2015. Older notebooks are
|
|
6
|
+
converted by opening and saving them in Jupyter.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pyics.util.paths import StrPath
|
|
15
|
+
|
|
16
|
+
NOTEBOOK_SUFFIX = ".ipynb"
|
|
17
|
+
|
|
18
|
+
# metadata that running a notebook (or looking at its outputs) changes, and that the
|
|
19
|
+
# frontend fills in again: of the notebook, and of its cells. That includes the kernel
|
|
20
|
+
# its author chose (``kernelspec``), which names an environment on their machine
|
|
21
|
+
_RUN_METADATA = ("kernelspec", "language_info", "widgets")
|
|
22
|
+
_RUN_CELL_METADATA = ("execution", "ExecuteTime", "collapsed", "scrolled")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotebookError(ValueError):
|
|
26
|
+
"""A file can't be read as a notebook: it isn't valid JSON, isn't a notebook, or is
|
|
27
|
+
in an nbformat older than 4."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_notebook(path: StrPath) -> bool:
|
|
31
|
+
"""Whether the file at ``path`` is a notebook, by its suffix (in any case)."""
|
|
32
|
+
return Path(path).suffix.lower() == NOTEBOOK_SUFFIX
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Cell:
|
|
37
|
+
"""A cell of a notebook."""
|
|
38
|
+
|
|
39
|
+
# its position among all cells of the notebook, from 0
|
|
40
|
+
index: int
|
|
41
|
+
# "code", "markdown" or "raw"
|
|
42
|
+
cell_type: str
|
|
43
|
+
# its id (nbformat 4.5 and later). Frontends keep it while the cell exists, but it
|
|
44
|
+
# changes e.g. when the cell is copied or split.
|
|
45
|
+
id: str | None
|
|
46
|
+
# its ``metadata.tags``
|
|
47
|
+
tags: tuple[str, ...]
|
|
48
|
+
source: str
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def number(self) -> int:
|
|
52
|
+
"""Its position among all cells, from 1, as messages show it ("cell 3")."""
|
|
53
|
+
return self.index + 1
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse(text: str, *, name: str | None = None) -> dict[str, Any]:
|
|
57
|
+
"""
|
|
58
|
+
The notebook ``text`` as plain data. ``name``, e.g. the notebook's file name, is
|
|
59
|
+
used in messages.
|
|
60
|
+
|
|
61
|
+
Raises
|
|
62
|
+
------
|
|
63
|
+
NotebookError
|
|
64
|
+
If ``text`` isn't a notebook in nbformat 4.
|
|
65
|
+
"""
|
|
66
|
+
what = name or "the notebook"
|
|
67
|
+
try:
|
|
68
|
+
nb = json.loads(text)
|
|
69
|
+
except json.JSONDecodeError as err:
|
|
70
|
+
raise NotebookError(
|
|
71
|
+
f"Can't read {what}: it isn't valid JSON ({err})."
|
|
72
|
+
) from None
|
|
73
|
+
version = nb.get("nbformat") if isinstance(nb, dict) else None
|
|
74
|
+
if not isinstance(version, int) or isinstance(version, bool):
|
|
75
|
+
raise NotebookError(f"Can't read {what}: it isn't a Jupyter notebook.")
|
|
76
|
+
if version < 4:
|
|
77
|
+
raise NotebookError(
|
|
78
|
+
f"Can't read {what}: nbformat {version} isn't supported. Open and save it "
|
|
79
|
+
"in Jupyter to convert it."
|
|
80
|
+
)
|
|
81
|
+
cells = nb.get("cells")
|
|
82
|
+
if not isinstance(cells, list) or not all(isinstance(c, dict) for c in cells):
|
|
83
|
+
raise NotebookError(f"Can't read {what}: its cells aren't a list of cells.")
|
|
84
|
+
return nb
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read(path: StrPath) -> dict[str, Any]:
|
|
88
|
+
"""
|
|
89
|
+
The notebook at ``path`` as plain data (see ``parse``).
|
|
90
|
+
|
|
91
|
+
Raises
|
|
92
|
+
------
|
|
93
|
+
NotebookError
|
|
94
|
+
If the file isn't a notebook in nbformat 4.
|
|
95
|
+
OSError
|
|
96
|
+
If the file can't be read.
|
|
97
|
+
"""
|
|
98
|
+
return parse(Path(path).read_text(encoding="utf-8"), name=Path(path).name)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _source(cell: dict[str, Any]) -> str:
|
|
102
|
+
"""The source of ``cell``, which nbformat stores as a string or a list of lines."""
|
|
103
|
+
source = cell.get("source", "")
|
|
104
|
+
return "".join(source) if isinstance(source, list) else str(source)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cells(nb: dict[str, Any]) -> list[Cell]:
|
|
108
|
+
"""All cells of the notebook ``nb`` (see ``parse``), in order."""
|
|
109
|
+
result = []
|
|
110
|
+
for index, cell in enumerate(nb["cells"]):
|
|
111
|
+
metadata = cell.get("metadata")
|
|
112
|
+
tags = metadata.get("tags") if isinstance(metadata, dict) else None
|
|
113
|
+
cell_id = cell.get("id")
|
|
114
|
+
result.append(
|
|
115
|
+
Cell(
|
|
116
|
+
index=index,
|
|
117
|
+
cell_type=str(cell.get("cell_type", "")),
|
|
118
|
+
id=cell_id if isinstance(cell_id, str) else None,
|
|
119
|
+
tags=tuple(t for t in tags or () if isinstance(t, str)),
|
|
120
|
+
source=_source(cell),
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def code_cells(nb: dict[str, Any]) -> list[Cell]:
|
|
127
|
+
"""The code cells of the notebook ``nb`` (see ``parse``), in order."""
|
|
128
|
+
return [cell for cell in cells(nb) if cell.cell_type == "code"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def normalize(text: str, *, name: str | None = None) -> str:
|
|
132
|
+
"""
|
|
133
|
+
The notebook ``text`` as task files store it: without what running it changes, i.e.
|
|
134
|
+
its outputs, execution counts and the metadata of the run (e.g. execution times),
|
|
135
|
+
and without its kernel (``kernelspec``), so that students choose their own. Sources are stored as lists of lines, and the JSON is written like Jupyter writes
|
|
136
|
+
it, so that a notebook that was merely run, or saved by another frontend, is stored
|
|
137
|
+
the same.
|
|
138
|
+
|
|
139
|
+
``name``, e.g. the notebook's file name, is used in messages.
|
|
140
|
+
|
|
141
|
+
Raises
|
|
142
|
+
------
|
|
143
|
+
NotebookError
|
|
144
|
+
If ``text`` isn't a notebook in nbformat 4.
|
|
145
|
+
"""
|
|
146
|
+
nb = parse(text, name=name)
|
|
147
|
+
for cell in nb["cells"]:
|
|
148
|
+
if cell.get("cell_type") == "code":
|
|
149
|
+
cell["outputs"] = []
|
|
150
|
+
cell["execution_count"] = None
|
|
151
|
+
if isinstance(cell.get("source"), str):
|
|
152
|
+
cell["source"] = cell["source"].splitlines(keepends=True)
|
|
153
|
+
if isinstance(metadata := cell.get("metadata"), dict):
|
|
154
|
+
for key in _RUN_CELL_METADATA:
|
|
155
|
+
metadata.pop(key, None)
|
|
156
|
+
|
|
157
|
+
if not isinstance(metadata := nb.get("metadata"), dict):
|
|
158
|
+
metadata = nb["metadata"] = {}
|
|
159
|
+
for key in _RUN_METADATA:
|
|
160
|
+
metadata.pop(key, None)
|
|
161
|
+
return json.dumps(nb, indent=1, sort_keys=True, ensure_ascii=False) + "\n"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def as_cell_text(text: str) -> str:
|
|
165
|
+
"""
|
|
166
|
+
The cells of the notebook ``text`` as text, e.g. to show how two notebooks differ:
|
|
167
|
+
a ``# %% cell N`` block of each code cell's source, and a ``# %% [markdown] cell N``
|
|
168
|
+
block of each markdown (or raw) cell's lines, commented out. Outputs and metadata
|
|
169
|
+
are left out.
|
|
170
|
+
|
|
171
|
+
Raises
|
|
172
|
+
------
|
|
173
|
+
NotebookError
|
|
174
|
+
If ``text`` isn't a notebook in nbformat 4.
|
|
175
|
+
"""
|
|
176
|
+
blocks = []
|
|
177
|
+
for cell in cells(parse(text)):
|
|
178
|
+
if cell.cell_type == "code":
|
|
179
|
+
header, body = f"# %% cell {cell.number}", cell.source
|
|
180
|
+
else:
|
|
181
|
+
header = f"# %% [{cell.cell_type}] cell {cell.number}"
|
|
182
|
+
body = "\n".join(f"# {line}".rstrip() for line in cell.source.splitlines())
|
|
183
|
+
blocks.append(f"{header}\n{body}".rstrip("\n") + "\n")
|
|
184
|
+
return "\n".join(blocks)
|