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