script-widget 0.1.19__tar.gz → 1.0.1__tar.gz
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.
- script_widget-1.0.1/CHANGELOG.md +6 -0
- script_widget-1.0.1/CLAUDE.md +353 -0
- {script_widget-0.1.19/src/script_widget.egg-info → script_widget-1.0.1}/PKG-INFO +1 -1
- script_widget-1.0.1/docs/pages/example.ipynb +737 -0
- script_widget-1.0.1/docs/pages/overview.ipynb +418 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/pyproject.toml +1 -1
- script_widget-1.0.1/src/script_widget/executor.py +259 -0
- script_widget-1.0.1/src/script_widget/widget.py +425 -0
- {script_widget-0.1.19 → script_widget-1.0.1/src/script_widget.egg-info}/PKG-INFO +1 -1
- script_widget-1.0.1/test/test_executor.py +165 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/test/test_widget.py +13 -4
- script_widget-0.1.19/CHANGELOG.md +0 -0
- script_widget-0.1.19/CLAUDE.md +0 -210
- script_widget-0.1.19/docs/pages/example.ipynb +0 -5104
- script_widget-0.1.19/docs/pages/overview.ipynb +0 -85
- script_widget-0.1.19/src/script_widget/executor.py +0 -167
- script_widget-0.1.19/src/script_widget/widget.py +0 -256
- script_widget-0.1.19/test/test_executor.py +0 -131
- {script_widget-0.1.19 → script_widget-1.0.1}/.claude/agents/code-api-consistency-reviewer.md +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.claude/agents/code-frontend-reviewer.md +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.claude/agents/code-python-reviewer.md +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.claude/agents/code-theming-reviewer.md +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.claude/agents/code-ui-heuristics-reviewer.md +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.claude/commands/review-apply.md +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.claude/commands/review-init.md +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.claude/commands/review.md +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/Dockerfile +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/README.md +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/devcontainer.json +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/init-firewall.sh +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/mcp-setup/generate-config.sh +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/mcp-setup/install.sh +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/mcp-setup/pixi.toml +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/mcp-setup/servers.txt +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/mcp-setup/test-servers.sh +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.gitattributes +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.github/workflows/conda-release.yml +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.github/workflows/pypi-release.yml +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.github/workflows/quarto-publish.yml +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.gitignore +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/.vscode/vscode.css +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/LICENSE +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/README.md +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/conda-build/README.md +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/conda-build/build_env.yaml +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/conda-build/meta.yaml +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/data/data_table.csv +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/.gitignore +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/README.md +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/_extensions/machow/interlinks/.gitignore +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/_extensions/machow/interlinks/_extension.yml +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/_extensions/machow/interlinks/interlinks.lua +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/_inv/griffe_objects.txt +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/_inv/numpy_objects.txt +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/_inv/python_objects.txt +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/_quarto.yml +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/api/ExerciseOutputWidget.qmd +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/api/ExerciseResult.qmd +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/api/_sidebar.yml +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/api/_styles-quartodoc.css +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/api/index.qmd +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/api/register_exercise_magic.qmd +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/api/run_exercise.qmd +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/autodoc.mustache +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/custom.scss +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/objects.txt +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/docs/styles.css +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/pixi.lock +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/review-kit/README.md +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/review-kit/TEMPLATE_AUTHORS.md +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/review-kit/USAGE.md +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/scripts/bump_version.py +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/scripts/docs-build-render.sh +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/scripts/docs-run-notebooks.sh +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/scripts/git-commit.sh +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/scripts/github-release.sh +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/scripts/rename.py +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/scripts/tmux-project.sh +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/setup.cfg +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/src/script_widget/__init__.py +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/src/script_widget.egg-info/SOURCES.txt +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/src/script_widget.egg-info/dependency_links.txt +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/src/script_widget.egg-info/requires.txt +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/src/script_widget.egg-info/top_level.txt +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/test/conftest.py +0 -0
- {script_widget-0.1.19 → script_widget-1.0.1}/test/test_frontend.py +0 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
|
|
2
|
+
## [v0.1.19] - 2026-07-20
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
- [`81d59dc`](https://github.com/munch-group/script-widget/commit/81d59dc5a032272d97617688453bf67151d64c47) - pin conda recipe python to requires-python range *(commit by [@kaspermunch](https://github.com/kaspermunch))*
|
|
5
|
+
|
|
6
|
+
[v0.1.19]: https://github.com/munch-group/script-widget/compare/v0.1.18...v0.1.19
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working
|
|
4
|
+
with code in this repository.
|
|
5
|
+
|
|
6
|
+
Project context for `script-widget` -- a Jupyter cell magic that runs a cell
|
|
7
|
+
in a fresh, fully isolated subprocess, built on
|
|
8
|
+
[anywidget](https://anywidget.dev).
|
|
9
|
+
|
|
10
|
+
## What this is
|
|
11
|
+
|
|
12
|
+
Tag a cell `%%exercise` and its body runs in a brand-new Python interpreter
|
|
13
|
+
process, spawned fresh and discarded after exactly one run -- nothing the
|
|
14
|
+
cell defines, imports, or mutates persists past that one run, and nothing
|
|
15
|
+
the notebook has already defined or imported is visible to it at all, not
|
|
16
|
+
even via a `sys.modules` cache hit: `import matplotlib.pyplot as plt`
|
|
17
|
+
inside the cell gets a genuinely fresh module, not the notebook's own live
|
|
18
|
+
object, so a mutation like `plt.rcParams['lines.linewidth'] = 5` inside the
|
|
19
|
+
cell can never leak back out. Everything the cell produced -- stdout,
|
|
20
|
+
stderr, rich display output, any exception -- is shown in a shaded
|
|
21
|
+
`ExerciseOutputWidget` box below the cell instead of the notebook's normal
|
|
22
|
+
(unstyled) output area.
|
|
23
|
+
|
|
24
|
+
The repo was scaffolded from the `munch-group` Python-library template
|
|
25
|
+
(pixi environment, quartodoc docs, conda/PyPI release automation) -- the
|
|
26
|
+
same template `puzzle-widget`/`steps-widget`/`turtle-widget` use. Like
|
|
27
|
+
`puzzle-widget`, there is no separate console-script entry point (no CLI
|
|
28
|
+
use case) -- the whole package is the widget + magic, and the exec/capture
|
|
29
|
+
logic is split out into its own `executor.py` for the same reason
|
|
30
|
+
`puzzle-widget` splits out `checker.py`: it's independently useful and
|
|
31
|
+
(mostly) unit-testable without a live IPython shell.
|
|
32
|
+
|
|
33
|
+
Note: the package and repo are still named `script_widget`/`script-widget`
|
|
34
|
+
even though the magic itself is `%%exercise` -- that mismatch is
|
|
35
|
+
deliberate, not leftover from a partial rename (a full package/repo rename
|
|
36
|
+
would touch the repo's identity: folder location, PyPI/conda package name,
|
|
37
|
+
GitHub URLs -- out of scope for a magic-name change alone).
|
|
38
|
+
|
|
39
|
+
## Package layout
|
|
40
|
+
|
|
41
|
+
The package is `script_widget` under `src/`:
|
|
42
|
+
|
|
43
|
+
- `src/script_widget/executor.py` -- the engine. `run_exercise(source)`
|
|
44
|
+
spawns a subprocess (`multiprocessing.get_context("spawn")`, one
|
|
45
|
+
disposable process per call, never reused -- see "Architecture" below
|
|
46
|
+
for why not `fork`) and hands it off to `_child_main`, which runs
|
|
47
|
+
entirely *inside* that subprocess: `ast.parse`s the cell body, execs
|
|
48
|
+
every statement but the last in a fresh, empty namespace, then handles
|
|
49
|
+
the last statement like a real notebook cell (a bare expression's value
|
|
50
|
+
gets displayed, an assignment still runs but produces no output), via
|
|
51
|
+
`_run_with_ipython` against a throwaway IPython shell the subprocess
|
|
52
|
+
builds for itself (`_make_headless_shell`, same `IPython.testing.
|
|
53
|
+
globalipapp` mechanism the test suite uses for headless testing).
|
|
54
|
+
Capture goes through `IPython.utils.capture.capture_output` (stdout,
|
|
55
|
+
stderr, and rich display output all at once) and `ip.events.trigger(
|
|
56
|
+
"post_execute")` is called after running so the subprocess's own
|
|
57
|
+
`%matplotlib inline` flush hook fires exactly as it would after a normal
|
|
58
|
+
cell. The result -- an `ExerciseResult(stdout, stderr, outputs, error,
|
|
59
|
+
traceback)` -- is sent back to the parent process over a
|
|
60
|
+
`multiprocessing.Pipe`; `run_exercise` never raises, whether the cell hit
|
|
61
|
+
a `SyntaxError`, a runtime exception (reported via `.error`/`.traceback`,
|
|
62
|
+
with `.traceback` from `ip.InteractiveTB.structured_traceback()`/
|
|
63
|
+
`.stb2text()` -- see "Colored tracebacks" under Architecture), or the
|
|
64
|
+
subprocess dying outright (reported as a generic `.error` with the
|
|
65
|
+
child's exit code).
|
|
66
|
+
- `src/script_widget/widget.py` -- `ExerciseOutputWidget` (the
|
|
67
|
+
`anywidget.AnyWidget`) + the embedded `_ESM`/`_CSS` frontend strings +
|
|
68
|
+
`register_exercise_magic()`, which registers both `%%exercise` (the
|
|
69
|
+
canonical name) and `%%script` (a plain alias -- same handler function
|
|
70
|
+
registered under a second `magic_name`, not a separate implementation).
|
|
71
|
+
The widget's traits are copied from an
|
|
72
|
+
`ExerciseResult` once at construction and never change afterward (unlike
|
|
73
|
+
`puzzle_widget.PuzzleWidget`, there's no interactive re-checking), so the
|
|
74
|
+
frontend's `render()` draws once from the model's initial state with no
|
|
75
|
+
change listeners. The embedded CSS's class prefix (`sw-*`) tracks the
|
|
76
|
+
package name (`script_widget`), not the magic name -- it's an internal
|
|
77
|
+
styling namespace, not user-facing. The box itself is always a plain gray
|
|
78
|
+
(no color/header change on success vs. failure); stdout and stderr are
|
|
79
|
+
merged into one white `.sw-output` block rather than two separately
|
|
80
|
+
colored ones.
|
|
81
|
+
- `src/script_widget/__init__.py` -- re-exports `ExerciseResult`/
|
|
82
|
+
`run_exercise`/`ExerciseOutputWidget`/`register_exercise_magic` from
|
|
83
|
+
`executor.py`/`widget.py`. Importing `script_widget` therefore both
|
|
84
|
+
exposes the public API and registers the cell magic as a side effect.
|
|
85
|
+
- `test/` -- headless pytest suite: `test_executor.py` (the engine -- every
|
|
86
|
+
test spawns a real subprocess now, so there's no more no-shell/with-shell
|
|
87
|
+
split; each `run_exercise` call pays real process-startup latency, which
|
|
88
|
+
is why the suite got noticeably slower after the subprocess-isolation
|
|
89
|
+
redesign), `test_widget.py` (`ExerciseOutputWidget` + the magic --
|
|
90
|
+
registration wiring is tested against a hand-rolled fake shell, not a
|
|
91
|
+
real one, to stay deterministic and isolated from other tests' IPython
|
|
92
|
+
singleton state), `test_frontend.py` (`node --check` on the extracted
|
|
93
|
+
`_ESM`, skipped if `node` isn't on `PATH`).
|
|
94
|
+
- `docs/` -- Quarto + quartodoc site (`docs/pages/*.ipynb` prose,
|
|
95
|
+
`docs/api/*.qmd` API ref -- the checked-in `.qmd` files are placeholders;
|
|
96
|
+
run `pixi run api` to regenerate them from the live docstrings).
|
|
97
|
+
- `pyproject.toml` -- packaging metadata **and** the pixi workspace (deps +
|
|
98
|
+
task runner).
|
|
99
|
+
- `conda-build/`, `.github/workflows/` -- conda/PyPI release on tag push.
|
|
100
|
+
- `scripts/` -- version-bump / docs-build / release helpers invoked by pixi
|
|
101
|
+
tasks.
|
|
102
|
+
|
|
103
|
+
## Architecture
|
|
104
|
+
|
|
105
|
+
### Why a subprocess, not just a fresh namespace
|
|
106
|
+
|
|
107
|
+
An earlier design ran the cell in a fresh, empty *namespace* but in the
|
|
108
|
+
**same process** as the notebook kernel. That gave reproducibility (a cell
|
|
109
|
+
can't read some already-defined notebook variable it didn't import) but
|
|
110
|
+
not real isolation: Python caches loaded modules in `sys.modules`
|
|
111
|
+
process-wide, so `import matplotlib.pyplot as plt` inside the cell was a
|
|
112
|
+
cache hit handing back the exact same live module object the notebook
|
|
113
|
+
already had -- meaning `plt.rcParams['lines.linewidth'] = 5` inside
|
|
114
|
+
`%%exercise` silently mutated the notebook's own matplotlib state too. That
|
|
115
|
+
was a real, reported problem (a cell described as "isolated" wasn't, for
|
|
116
|
+
anything reachable through an already-imported module), and there's no way
|
|
117
|
+
to fix it while staying in-process: `sys.modules` is inherently one cache
|
|
118
|
+
per interpreter, not per namespace, and even `importlib.reload()` replaces
|
|
119
|
+
a module *in place* (same object identity, still visible everywhere).
|
|
120
|
+
Genuinely fresh imports require a genuinely fresh interpreter -- hence
|
|
121
|
+
`run_exercise` now spawns a real subprocess per cell (see "Package layout"
|
|
122
|
+
above) instead of merely `exec`-ing into an empty dict.
|
|
123
|
+
|
|
124
|
+
Two rejected alternatives, and why: bypassing `sys.modules` for the cell's
|
|
125
|
+
own imports without a new process was considered and dropped -- libraries
|
|
126
|
+
like numpy/matplotlib/pandas/torch keep process-wide global C-level state
|
|
127
|
+
and often aren't safe to load a second time in the same interpreter (real
|
|
128
|
+
risk of crashes or silent misbehavior for exactly the libraries most likely
|
|
129
|
+
to appear in an exercise cell). Forking the live kernel process (`os.fork()`
|
|
130
|
+
or `multiprocessing`'s default `fork` start method on Linux) was also
|
|
131
|
+
dropped: forking a process with live background threads (which a running
|
|
132
|
+
Jupyter kernel always has -- zmq's io threads, heartbeat, etc.) is a
|
|
133
|
+
well-documented hazard that can leave the child in a half-broken state or
|
|
134
|
+
destabilize the parent kernel. `run_exercise` explicitly uses
|
|
135
|
+
`multiprocessing.get_context("spawn")` -- always a brand-new interpreter
|
|
136
|
+
booted from scratch, never a copy-on-write fork of the live kernel -- which
|
|
137
|
+
is slower per cell (a fresh interpreter, plus re-importing whatever the
|
|
138
|
+
cell needs, from under a second to a couple of seconds for heavy libraries)
|
|
139
|
+
but is the only one of the three options that's actually safe.
|
|
140
|
+
|
|
141
|
+
The tradeoff that *is* still real and intentional, same as before: a plain
|
|
142
|
+
notebook variable with no import path (a dataframe built inline, a model
|
|
143
|
+
trained ad hoc and only ever bound to a local name) is **not** visible to
|
|
144
|
+
`%%exercise`, full stop -- there is no fallback, and now not even an
|
|
145
|
+
already-imported *module* is shared, since the subprocess has its own
|
|
146
|
+
`sys.modules` from the moment it boots.
|
|
147
|
+
|
|
148
|
+
### The matplotlib-figure-flush trick
|
|
149
|
+
|
|
150
|
+
`capture_output`'s display-publisher swap captures explicit `display(...)`
|
|
151
|
+
calls and the trailing expression's auto-display, but **not** a bare
|
|
152
|
+
`plt.plot(...)` with no `.show()`/`display()` call -- that auto-flush
|
|
153
|
+
normally happens via a `post_execute` hook the inline backend registers on
|
|
154
|
+
the shell, which never fires for a bare `exec()` call outside `ip.
|
|
155
|
+
run_cell`. `_run_with_ipython` calls `ip.events.trigger("post_execute")`
|
|
156
|
+
itself, still inside the `capture_output` block, right after running the
|
|
157
|
+
cell -- this fires whatever hook `_make_headless_shell`'s own
|
|
158
|
+
`%matplotlib inline` call just registered *in the subprocess*, not
|
|
159
|
+
anything belonging to the notebook's own matplotlib session (there isn't
|
|
160
|
+
one shared with the subprocess to reuse). It's called in a `finally`, so a
|
|
161
|
+
figure drawn before an exception still gets flushed and shown, exactly
|
|
162
|
+
like a real cell -- but note the figure renders with a pristine default
|
|
163
|
+
backend/rcParams, not whatever runtime configuration the notebook's own
|
|
164
|
+
matplotlib session happens to be carrying.
|
|
165
|
+
|
|
166
|
+
### Colored tracebacks that show the cell's own source
|
|
167
|
+
|
|
168
|
+
A `%%exercise` traceback is meant to look exactly like what a student would
|
|
169
|
+
see running the same code as a plain script or in a real notebook cell --
|
|
170
|
+
not a plain `traceback.format_exc()` dump, and not cluttered with
|
|
171
|
+
`script_widget`'s own internal frames. Three things make that true:
|
|
172
|
+
|
|
173
|
+
- **`_make_headless_shell` forces `shell.colors = "neutral"`.** Without
|
|
174
|
+
this, there are no ANSI codes to highlight in the first place --
|
|
175
|
+
`IPython.testing.globalipapp`'s shell is a `TerminalInteractiveShell`,
|
|
176
|
+
which (reasonably, for a *real* terminal) auto-detects "nocolor" when
|
|
177
|
+
there's no tty, exactly the case in a subprocess with no real terminal
|
|
178
|
+
attached. A real Jupyter kernel's shell (`ZMQInteractiveShell`) doesn't do
|
|
179
|
+
that detection -- it defaults to "neutral" (color on) unconditionally,
|
|
180
|
+
since its frontend always renders ANSI regardless of ttys -- so this line
|
|
181
|
+
is what makes the subprocess's shell match a real kernel's default
|
|
182
|
+
instead of silently downgrading to plain text.
|
|
183
|
+
`test_traceback_carries_ansi_color_codes` catches a regression here.
|
|
184
|
+
- **The cell's source is registered with `linecache` before it's compiled.**
|
|
185
|
+
`_run_with_ipython` calls `ip.compile.cache(source)` -- the exact
|
|
186
|
+
mechanism a real notebook cell uses on itself -- to get a filename (like
|
|
187
|
+
`<ipython-input-0-...>`) that's already wired into `linecache`, and
|
|
188
|
+
compiles the cell under *that* filename instead of a bare synthetic one
|
|
189
|
+
like `"<exercise>"`. Skip this and a traceback frame has no source
|
|
190
|
+
`structured_traceback` can look up: it renders as a bare `File
|
|
191
|
+
"<exercise>", line N` with no code shown beneath it at all -- not merely
|
|
192
|
+
uncolored, genuinely blank, since there's nothing there to highlight.
|
|
193
|
+
- **`tb_offset=2` strips `script_widget`'s own frames.** The call chain
|
|
194
|
+
between "where the `try` lives" and "the cell's own code" is exactly two
|
|
195
|
+
functions deep (`_run_with_ipython` -> `_last_line_value`, both frames
|
|
196
|
+
showing up in `sys.exc_info()`'s traceback if left alone) --
|
|
197
|
+
`ip.InteractiveTB.structured_traceback(*sys.exc_info(), tb_offset=2)`
|
|
198
|
+
drops exactly those two, same idea as `InteractiveShell` itself passing
|
|
199
|
+
`tb_offset=1` to drop its own one wrapper frame (`init_traceback_handlers`'s
|
|
200
|
+
comment: "we always want to remove the topmost item in the traceback,
|
|
201
|
+
which is our own internal code"). Get this number wrong and either an
|
|
202
|
+
executor.py frame leaks into what the student sees, or (overshoot) the
|
|
203
|
+
outermost frame of their *own* code gets silently eaten too --
|
|
204
|
+
`test_traceback_does_not_leak_internal_executor_frames` pins both ends
|
|
205
|
+
down (asserts neither `_last_line_value` nor `executor.py` appear, and
|
|
206
|
+
that the cell's own lines still do).
|
|
207
|
+
|
|
208
|
+
With all three in place, `ip.InteractiveTB.structured_traceback(...)` +
|
|
209
|
+
`.stb2text(...)` -- exactly the call `InteractiveShell` itself makes before
|
|
210
|
+
printing an exception, run against the subprocess's own throwaway shell
|
|
211
|
+
(`_make_headless_shell`) -- returns text with real ANSI SGR escape codes
|
|
212
|
+
(IPython 9's traceback formatter is pygments-based, so source-context lines
|
|
213
|
+
carry full syntax highlighting via 256-color codes, not just the 16 basic
|
|
214
|
+
ones used for the exception header/filename/line number). The frontend has
|
|
215
|
+
no ANSI-rendering library to lean on (`_ESM` is dependency-free by design),
|
|
216
|
+
so `widget.py`'s `_ESM` embeds a small SGR-code interpreter
|
|
217
|
+
(`ansiToHtml`/`applySGR`/`xterm256`) that turns those escape codes into
|
|
218
|
+
colored `<span>`s, using the exact hex values JupyterLab itself uses for
|
|
219
|
+
`.ansi-*-fg` so the result matches a real notebook's palette. Text with no
|
|
220
|
+
escape codes at all (a plain-text `SyntaxError`, caught in `_child_main`
|
|
221
|
+
before the subprocess's shell -- and hence its `ip.compile.cache` -- is
|
|
222
|
+
even built) round-trips through the same function as a single unstyled
|
|
223
|
+
text node.
|
|
224
|
+
|
|
225
|
+
## Gotchas
|
|
226
|
+
|
|
227
|
+
- **Every `%%exercise` run costs a real process spawn now.** Since the
|
|
228
|
+
subprocess-isolation redesign, `run_exercise` always pays interpreter
|
|
229
|
+
startup + re-importing whatever the cell needs (and IPython itself,
|
|
230
|
+
inside `_make_headless_shell`) -- noticeably slower than the old
|
|
231
|
+
in-process `exec`, and the whole reason `pixi run test` got much slower
|
|
232
|
+
too (every `test_executor.py` test spawns one). This is the accepted
|
|
233
|
+
cost of genuine isolation, not a bug to "optimize away" by reintroducing
|
|
234
|
+
a shared/reused process.
|
|
235
|
+
- **`multiprocessing.get_context("spawn")` is deliberate -- don't switch to
|
|
236
|
+
the default context or to `fork`.** The default start method is `fork`
|
|
237
|
+
on Linux; forking a live, multi-threaded Jupyter kernel process is a
|
|
238
|
+
known way to corrupt or hang it (see "Why a subprocess" under
|
|
239
|
+
Architecture). Always go through the explicit `spawn` context.
|
|
240
|
+
- **A matplotlib figure drawn inside `%%exercise` uses a pristine
|
|
241
|
+
backend/rcParams, not the notebook's own.** The subprocess calls
|
|
242
|
+
`%matplotlib inline` on its own throwaway shell; it has no access to
|
|
243
|
+
whatever the notebook's actual matplotlib session has been configured to
|
|
244
|
+
at runtime (custom rcParams set earlier in the notebook, a non-default
|
|
245
|
+
backend, etc.) -- only whatever `matplotlibrc`-style config loads fresh
|
|
246
|
+
from disk.
|
|
247
|
+
- **A subprocess that dies outright is still reported, not left hanging.**
|
|
248
|
+
`_run_isolated` treats `EOFError` from `parent_conn.recv()` (the child
|
|
249
|
+
closed its pipe end without sending anything -- crashed, killed, etc.)
|
|
250
|
+
as a normal failure mode: it returns an `ExerciseResult` with a generic
|
|
251
|
+
`.error` naming the child's exit code, same contract as a caught
|
|
252
|
+
exception (`run_exercise` still never raises).
|
|
253
|
+
- **`multiprocessing.Process` inherits the subprocess's stdout/stderr fds
|
|
254
|
+
-- it doesn't pipe them.** Anything printed *outside* the
|
|
255
|
+
`capture_output()` block in `_run_with_ipython` goes straight to the
|
|
256
|
+
real kernel's stdout, bypassing `ExerciseResult` entirely: it won't show
|
|
257
|
+
up in the widget's own output box, and (worse, since it's an
|
|
258
|
+
unpredictable race against the kernel's iopub message flushing) it can
|
|
259
|
+
show up oddly delayed, attached to whatever cell happens to be executing
|
|
260
|
+
when it's finally flushed -- not necessarily the `%%exercise` cell that
|
|
261
|
+
produced it. `_make_headless_shell`'s own `%matplotlib inline` setup call
|
|
262
|
+
hits this: it unconditionally prints "No event loop hook running." (inline
|
|
263
|
+
figures need no real GUI event loop, but `enable_gui` prints that
|
|
264
|
+
regardless), so it's wrapped in its own `contextlib.redirect_stdout`
|
|
265
|
+
right there, rather than relying on the later `capture_output()` block to
|
|
266
|
+
catch it. `test_matplotlib_setup_does_not_leak_to_real_stdout` (using
|
|
267
|
+
pytest's `capfd`, which -- unlike `capsys` -- catches file-descriptor
|
|
268
|
+
-level writes from a real subprocess) guards this.
|
|
269
|
+
- **No IPython input transforms inside the cell body.** `%%exercise` parses
|
|
270
|
+
with plain `ast.parse`/`exec`, not `ip.run_cell`, so line magics
|
|
271
|
+
(`%time`), shell escapes (`!ls`), and `?`-help don't work inside the cell
|
|
272
|
+
-- plain Python only.
|
|
273
|
+
- **`IPython.testing.globalipapp`'s shell restricts rich display to
|
|
274
|
+
`text/plain` by default.** `TerminalInteractiveShell` (reasonably, for a
|
|
275
|
+
real terminal) sets `display_formatter.active_types = ["text/plain"]`,
|
|
276
|
+
which silently drops `image/png` etc. even though the matplotlib-inline
|
|
277
|
+
formatter is registered and would produce real PNG bytes if called
|
|
278
|
+
directly. `_make_headless_shell` explicitly widens `active_types` back
|
|
279
|
+
to `format_types` for exactly this reason -- forget this (e.g. if this
|
|
280
|
+
function gets refactored) and figure capture silently degrades to a
|
|
281
|
+
`text/plain` repr of the `Figure` object, not a missing output.
|
|
282
|
+
- **The package/magic name mismatch is intentional, not a leftover.** The
|
|
283
|
+
package stays `script_widget`; the magic is `%%exercise`. Don't "fix"
|
|
284
|
+
this by renaming the package -- that's a separate, deliberately-deferred
|
|
285
|
+
decision (see "What this is" above).
|
|
286
|
+
|
|
287
|
+
## Environment & commands
|
|
288
|
+
|
|
289
|
+
Pixi-managed (config in `pyproject.toml` under `[tool.pixi.*]`; channels
|
|
290
|
+
`conda-forge` + `munch-group`; platforms `osx-arm64`, `linux-64`). Python
|
|
291
|
+
`>=3.9,<3.14`. Key deps: `anywidget` (0.11.x), `traitlets`, `ipython`,
|
|
292
|
+
`nodejs` (for the `node --check` frontend test), `matplotlib-base`
|
|
293
|
+
(dev/test only, for the figure-capture tests), `quarto`/`quartodoc`,
|
|
294
|
+
`pytest`.
|
|
295
|
+
|
|
296
|
+
- Dev install: `pixi run install-dev` (`pip install --no-build-isolation
|
|
297
|
+
--force-reinstall --no-deps .` -- a real, **non-editable** install, despite
|
|
298
|
+
the task name; re-run it after every `src/` edit or `import script_widget`
|
|
299
|
+
picks up the stale previously-installed copy, not your change).
|
|
300
|
+
- Run tests: `pixi run test` (== `pytest test/`).
|
|
301
|
+
- Run a single test: `pixi run pytest test/test_executor.py::test_exception_is_caught_and_reported`
|
|
302
|
+
(or plain `pytest ...` -- `test/conftest.py` puts `src/` on `sys.path`, so
|
|
303
|
+
this works even without `install-dev`).
|
|
304
|
+
- Try the widget: open a notebook, `import script_widget`, then
|
|
305
|
+
```
|
|
306
|
+
%%exercise
|
|
307
|
+
import matplotlib.pyplot as plt
|
|
308
|
+
plt.plot([1, 2, 3])
|
|
309
|
+
```
|
|
310
|
+
- JS syntax check after editing the embedded frontend string:
|
|
311
|
+
```bash
|
|
312
|
+
python -c "from script_widget import widget as m; open('/tmp/e.mjs','w').write(m._ESM)"
|
|
313
|
+
node --check /tmp/e.mjs
|
|
314
|
+
```
|
|
315
|
+
(also `test_frontend.py::test_esm_is_syntactically_valid_js`, run
|
|
316
|
+
automatically by `pixi run test` whenever `node` is on `PATH`).
|
|
317
|
+
- Build docs: `pixi run api` (quartodoc API pages), then `pixi run docs`
|
|
318
|
+
(execute the doc notebooks in place).
|
|
319
|
+
- Release: `pixi run bump` / `release` / `version` drive
|
|
320
|
+
`scripts/bump_version.py` + a tag push, which triggers the conda/PyPI
|
|
321
|
+
workflows.
|
|
322
|
+
- Code review: `/review <target-file>` fans out specialist subagents
|
|
323
|
+
(Python, frontend, API consistency, UI heuristics, theming) into a
|
|
324
|
+
prioritized report under `.claude/review-reports/`; `/review-apply`
|
|
325
|
+
applies that report's findings in small, test-gated, one-commit-per-batch
|
|
326
|
+
steps. See `review-kit/README.md` for the full workflow.
|
|
327
|
+
|
|
328
|
+
## Distribution
|
|
329
|
+
|
|
330
|
+
Both `.github/workflows/conda-release.yml` and `pypi-release.yml` trigger on
|
|
331
|
+
version tag pushes (`vX.Y[.Z][.rcN]`), building on `macOS-latest` and
|
|
332
|
+
`ubuntu-latest` (no `win-64` in this workspace's platform list) and pure
|
|
333
|
+
Python wheel to PyPI, mirroring `puzzle-widget`'s setup.
|
|
334
|
+
|
|
335
|
+
## Testing approach
|
|
336
|
+
|
|
337
|
+
- `executor.run_exercise` tests all go through the same real-subprocess
|
|
338
|
+
path now (no more no-shell/with-shell split) -- `test_executor.py`
|
|
339
|
+
exercises stdout/stderr capture, the trailing-expression/`display()`
|
|
340
|
+
paths, matplotlib figure flush (including flush-on-exception), and the
|
|
341
|
+
isolation guarantee itself (mutating a freshly-imported matplotlib's
|
|
342
|
+
`rcParams` inside the cell must not affect the test process's own
|
|
343
|
+
already-imported copy). Each test pays real process-spawn latency --
|
|
344
|
+
this is why the suite is seconds, not milliseconds, and that's expected.
|
|
345
|
+
- `ExerciseOutputWidget` is tested by constructing it directly from an
|
|
346
|
+
`ExerciseResult` -- no magic/subprocess involved.
|
|
347
|
+
- `register_exercise_magic`'s registration wiring is tested against a
|
|
348
|
+
hand-rolled fake shell rather than a real `InteractiveShell`, to stay
|
|
349
|
+
deterministic and avoid spawning anything.
|
|
350
|
+
- The actual shaded-box rendering isn't (and can't be, headlessly)
|
|
351
|
+
exercised by the pytest suite -- `test_frontend.py` only checks the
|
|
352
|
+
extracted `_ESM` parses as valid JS. Manually verify the rendered output
|
|
353
|
+
against a live notebook after editing `_ESM`/`_CSS`.
|