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.
Files changed (86) hide show
  1. script_widget-1.0.1/CHANGELOG.md +6 -0
  2. script_widget-1.0.1/CLAUDE.md +353 -0
  3. {script_widget-0.1.19/src/script_widget.egg-info → script_widget-1.0.1}/PKG-INFO +1 -1
  4. script_widget-1.0.1/docs/pages/example.ipynb +737 -0
  5. script_widget-1.0.1/docs/pages/overview.ipynb +418 -0
  6. {script_widget-0.1.19 → script_widget-1.0.1}/pyproject.toml +1 -1
  7. script_widget-1.0.1/src/script_widget/executor.py +259 -0
  8. script_widget-1.0.1/src/script_widget/widget.py +425 -0
  9. {script_widget-0.1.19 → script_widget-1.0.1/src/script_widget.egg-info}/PKG-INFO +1 -1
  10. script_widget-1.0.1/test/test_executor.py +165 -0
  11. {script_widget-0.1.19 → script_widget-1.0.1}/test/test_widget.py +13 -4
  12. script_widget-0.1.19/CHANGELOG.md +0 -0
  13. script_widget-0.1.19/CLAUDE.md +0 -210
  14. script_widget-0.1.19/docs/pages/example.ipynb +0 -5104
  15. script_widget-0.1.19/docs/pages/overview.ipynb +0 -85
  16. script_widget-0.1.19/src/script_widget/executor.py +0 -167
  17. script_widget-0.1.19/src/script_widget/widget.py +0 -256
  18. script_widget-0.1.19/test/test_executor.py +0 -131
  19. {script_widget-0.1.19 → script_widget-1.0.1}/.claude/agents/code-api-consistency-reviewer.md +0 -0
  20. {script_widget-0.1.19 → script_widget-1.0.1}/.claude/agents/code-frontend-reviewer.md +0 -0
  21. {script_widget-0.1.19 → script_widget-1.0.1}/.claude/agents/code-python-reviewer.md +0 -0
  22. {script_widget-0.1.19 → script_widget-1.0.1}/.claude/agents/code-theming-reviewer.md +0 -0
  23. {script_widget-0.1.19 → script_widget-1.0.1}/.claude/agents/code-ui-heuristics-reviewer.md +0 -0
  24. {script_widget-0.1.19 → script_widget-1.0.1}/.claude/commands/review-apply.md +0 -0
  25. {script_widget-0.1.19 → script_widget-1.0.1}/.claude/commands/review-init.md +0 -0
  26. {script_widget-0.1.19 → script_widget-1.0.1}/.claude/commands/review.md +0 -0
  27. {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/Dockerfile +0 -0
  28. {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/README.md +0 -0
  29. {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/devcontainer.json +0 -0
  30. {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/init-firewall.sh +0 -0
  31. {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/mcp-setup/generate-config.sh +0 -0
  32. {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/mcp-setup/install.sh +0 -0
  33. {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/mcp-setup/pixi.toml +0 -0
  34. {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/mcp-setup/servers.txt +0 -0
  35. {script_widget-0.1.19 → script_widget-1.0.1}/.devcontainer/mcp-setup/test-servers.sh +0 -0
  36. {script_widget-0.1.19 → script_widget-1.0.1}/.gitattributes +0 -0
  37. {script_widget-0.1.19 → script_widget-1.0.1}/.github/workflows/conda-release.yml +0 -0
  38. {script_widget-0.1.19 → script_widget-1.0.1}/.github/workflows/pypi-release.yml +0 -0
  39. {script_widget-0.1.19 → script_widget-1.0.1}/.github/workflows/quarto-publish.yml +0 -0
  40. {script_widget-0.1.19 → script_widget-1.0.1}/.gitignore +0 -0
  41. {script_widget-0.1.19 → script_widget-1.0.1}/.vscode/vscode.css +0 -0
  42. {script_widget-0.1.19 → script_widget-1.0.1}/LICENSE +0 -0
  43. {script_widget-0.1.19 → script_widget-1.0.1}/README.md +0 -0
  44. {script_widget-0.1.19 → script_widget-1.0.1}/conda-build/README.md +0 -0
  45. {script_widget-0.1.19 → script_widget-1.0.1}/conda-build/build_env.yaml +0 -0
  46. {script_widget-0.1.19 → script_widget-1.0.1}/conda-build/meta.yaml +0 -0
  47. {script_widget-0.1.19 → script_widget-1.0.1}/data/data_table.csv +0 -0
  48. {script_widget-0.1.19 → script_widget-1.0.1}/docs/.gitignore +0 -0
  49. {script_widget-0.1.19 → script_widget-1.0.1}/docs/README.md +0 -0
  50. {script_widget-0.1.19 → script_widget-1.0.1}/docs/_extensions/machow/interlinks/.gitignore +0 -0
  51. {script_widget-0.1.19 → script_widget-1.0.1}/docs/_extensions/machow/interlinks/_extension.yml +0 -0
  52. {script_widget-0.1.19 → script_widget-1.0.1}/docs/_extensions/machow/interlinks/interlinks.lua +0 -0
  53. {script_widget-0.1.19 → script_widget-1.0.1}/docs/_inv/griffe_objects.txt +0 -0
  54. {script_widget-0.1.19 → script_widget-1.0.1}/docs/_inv/numpy_objects.txt +0 -0
  55. {script_widget-0.1.19 → script_widget-1.0.1}/docs/_inv/python_objects.txt +0 -0
  56. {script_widget-0.1.19 → script_widget-1.0.1}/docs/_quarto.yml +0 -0
  57. {script_widget-0.1.19 → script_widget-1.0.1}/docs/api/ExerciseOutputWidget.qmd +0 -0
  58. {script_widget-0.1.19 → script_widget-1.0.1}/docs/api/ExerciseResult.qmd +0 -0
  59. {script_widget-0.1.19 → script_widget-1.0.1}/docs/api/_sidebar.yml +0 -0
  60. {script_widget-0.1.19 → script_widget-1.0.1}/docs/api/_styles-quartodoc.css +0 -0
  61. {script_widget-0.1.19 → script_widget-1.0.1}/docs/api/index.qmd +0 -0
  62. {script_widget-0.1.19 → script_widget-1.0.1}/docs/api/register_exercise_magic.qmd +0 -0
  63. {script_widget-0.1.19 → script_widget-1.0.1}/docs/api/run_exercise.qmd +0 -0
  64. {script_widget-0.1.19 → script_widget-1.0.1}/docs/autodoc.mustache +0 -0
  65. {script_widget-0.1.19 → script_widget-1.0.1}/docs/custom.scss +0 -0
  66. {script_widget-0.1.19 → script_widget-1.0.1}/docs/objects.txt +0 -0
  67. {script_widget-0.1.19 → script_widget-1.0.1}/docs/styles.css +0 -0
  68. {script_widget-0.1.19 → script_widget-1.0.1}/pixi.lock +0 -0
  69. {script_widget-0.1.19 → script_widget-1.0.1}/review-kit/README.md +0 -0
  70. {script_widget-0.1.19 → script_widget-1.0.1}/review-kit/TEMPLATE_AUTHORS.md +0 -0
  71. {script_widget-0.1.19 → script_widget-1.0.1}/review-kit/USAGE.md +0 -0
  72. {script_widget-0.1.19 → script_widget-1.0.1}/scripts/bump_version.py +0 -0
  73. {script_widget-0.1.19 → script_widget-1.0.1}/scripts/docs-build-render.sh +0 -0
  74. {script_widget-0.1.19 → script_widget-1.0.1}/scripts/docs-run-notebooks.sh +0 -0
  75. {script_widget-0.1.19 → script_widget-1.0.1}/scripts/git-commit.sh +0 -0
  76. {script_widget-0.1.19 → script_widget-1.0.1}/scripts/github-release.sh +0 -0
  77. {script_widget-0.1.19 → script_widget-1.0.1}/scripts/rename.py +0 -0
  78. {script_widget-0.1.19 → script_widget-1.0.1}/scripts/tmux-project.sh +0 -0
  79. {script_widget-0.1.19 → script_widget-1.0.1}/setup.cfg +0 -0
  80. {script_widget-0.1.19 → script_widget-1.0.1}/src/script_widget/__init__.py +0 -0
  81. {script_widget-0.1.19 → script_widget-1.0.1}/src/script_widget.egg-info/SOURCES.txt +0 -0
  82. {script_widget-0.1.19 → script_widget-1.0.1}/src/script_widget.egg-info/dependency_links.txt +0 -0
  83. {script_widget-0.1.19 → script_widget-1.0.1}/src/script_widget.egg-info/requires.txt +0 -0
  84. {script_widget-0.1.19 → script_widget-1.0.1}/src/script_widget.egg-info/top_level.txt +0 -0
  85. {script_widget-0.1.19 → script_widget-1.0.1}/test/conftest.py +0 -0
  86. {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`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: script-widget
3
- Version: 0.1.19
3
+ Version: 1.0.1
4
4
  Summary: Jupyter widget that runs a cell in a fresh, isolated namespace (the %%exercise cell magic)
5
5
  Author-email: Kasper Munch <kaspermunch@birc.au.dk>
6
6
  License: MIT