xnotebook 1.0.0__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 (35) hide show
  1. xnotebook-1.0.0/.gitignore +9 -0
  2. xnotebook-1.0.0/PKG-INFO +151 -0
  3. xnotebook-1.0.0/README.md +134 -0
  4. xnotebook-1.0.0/hatch_build.py +32 -0
  5. xnotebook-1.0.0/pyproject.toml +40 -0
  6. xnotebook-1.0.0/tests/test_cli.py +49 -0
  7. xnotebook-1.0.0/tests/test_integration.py +227 -0
  8. xnotebook-1.0.0/tests/test_mounts.py +74 -0
  9. xnotebook-1.0.0/tests/test_proxy.py +299 -0
  10. xnotebook-1.0.0/tests/widget_state.schema.json +82 -0
  11. xnotebook-1.0.0/web/build.mjs +45 -0
  12. xnotebook-1.0.0/web/package-lock.json +1219 -0
  13. xnotebook-1.0.0/web/package.json +20 -0
  14. xnotebook-1.0.0/web/src/deps.ts +337 -0
  15. xnotebook-1.0.0/web/src/executor.ts +277 -0
  16. xnotebook-1.0.0/web/src/formats.ts +111 -0
  17. xnotebook-1.0.0/web/src/host.ts +66 -0
  18. xnotebook-1.0.0/web/src/index.html +9 -0
  19. xnotebook-1.0.0/web/src/kernel.worker.ts +377 -0
  20. xnotebook-1.0.0/web/src/orchestrator.ts +245 -0
  21. xnotebook-1.0.0/web/src/solve.ts +131 -0
  22. xnotebook-1.0.0/web/src/types.ts +65 -0
  23. xnotebook-1.0.0/web/src/widgets.ts +138 -0
  24. xnotebook-1.0.0/web/tsconfig.json +14 -0
  25. xnotebook-1.0.0/xnotebook/__init__.py +7 -0
  26. xnotebook-1.0.0/xnotebook/__main__.py +5 -0
  27. xnotebook-1.0.0/xnotebook/api.py +133 -0
  28. xnotebook-1.0.0/xnotebook/cache.py +124 -0
  29. xnotebook-1.0.0/xnotebook/cdp.py +297 -0
  30. xnotebook-1.0.0/xnotebook/chromium.py +444 -0
  31. xnotebook-1.0.0/xnotebook/cli.py +301 -0
  32. xnotebook-1.0.0/xnotebook/firewall.py +95 -0
  33. xnotebook-1.0.0/xnotebook/mounts.py +129 -0
  34. xnotebook-1.0.0/xnotebook/proxy.py +511 -0
  35. xnotebook-1.0.0/xnotebook/session.py +271 -0
@@ -0,0 +1,9 @@
1
+ node_modules/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ web/node_modules/
7
+ xnotebook/_web/
8
+ *.out.ipynb
9
+ .pytest_cache/
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.5
2
+ Name: xnotebook
3
+ Version: 1.0.0
4
+ Summary: Run notebooks and scripts on sandboxed xeus wasm kernels (emscripten-forge) inside headless Chromium
5
+ License-Expression: BSD-3-Clause
6
+ Classifier: Framework :: Jupyter
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Topic :: Security
9
+ Requires-Python: >=3.9
10
+ Requires-Dist: typer>=0.12
11
+ Requires-Dist: typing-extensions>=4.6
12
+ Provides-Extra: test
13
+ Requires-Dist: jsonschema>=4; extra == 'test'
14
+ Requires-Dist: nbformat>=5; extra == 'test'
15
+ Requires-Dist: pytest>=7; extra == 'test'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # xnotebook
19
+
20
+ Run Jupyter notebooks and scripts on **xeus wasm kernels** with **emscripten-forge**
21
+ packages, sandboxed inside headless Chromium.
22
+
23
+ ```console
24
+ $ pip install xnotebook
25
+ $ xnb analysis.ipynb # writes analysis.out.ipynb
26
+ $ xnb script.py -d numpy # prints outputs as they come
27
+ ```
28
+
29
+ The command is `xnb` (`xnotebook` works too), and the Python module is `xnotebook`.
30
+
31
+ The first run downloads a pinned, sha256-verified `chrome-headless-shell`
32
+ (about 120 MB) into the cache. No conda, Node or system browser is needed.
33
+
34
+ ## Dependencies
35
+
36
+ Three sources are merged, and a later source wins for the same package:
37
+
38
+ 1. an environment file: `-e environment.yaml`
39
+ 2. the input file itself:
40
+ - notebook metadata `metadata.xnb`, which uses the environment.yaml schema:
41
+ ```json
42
+ "metadata": {"xnb": {"channels": ["conda-forge"], "dependencies": ["numpy", {"pip": ["six"]}]}}
43
+ ```
44
+ - or, in a `.py` script, a [PEP 723](https://peps.python.org/pep-0723/) block, where
45
+ `dependencies` are pip packages and `[tool.xnb]` holds conda packages and channels:
46
+ ```python
47
+ # /// script
48
+ # dependencies = ["six"]
49
+ # [tool.xnb]
50
+ # dependencies = ["numpy"]
51
+ # ///
52
+ ```
53
+ 3. the CLI: `-d numpy -d "pandas>=2" --pip six -c conda-forge`
54
+
55
+ Channels default to `https://prefix.dev/emscripten-forge-4x` and `conda-forge`. The
56
+ kernel comes from `--kernel`, then the notebook's `kernelspec`, then the file extension:
57
+
58
+ | kernel | package | extension |
59
+ | --- | --- | --- |
60
+ | `xpython` | xeus-python | `.py` |
61
+ | `xr` | xeus-r | `.R` |
62
+ | `xlua` | xeus-lua | `.lua` |
63
+ | `xjavascript` | xeus-javascript | `.js` |
64
+
65
+ Solved environments are cached as locks. `--lock-out lock.json` saves the lock, and
66
+ `--lock lock.json` reuses it without solving.
67
+
68
+ ## Widgets
69
+
70
+ ipywidgets state is saved in `metadata.widgets`
71
+ (`application/vnd.jupyter.widget-state+json`), like `jupyter nbconvert --execute` does.
72
+ JupyterLab, nbviewer and Voila then render the widgets with the values they had at the
73
+ end of the run. Closed widgets are dropped. Use `--no-widget-state` to turn this off.
74
+ See `demo/widgets_demo.ipynb`.
75
+
76
+ Scripts are split into cells at `# %%` markers (percent format). The comment prefix
77
+ follows the language, so Lua uses `-- %%`.
78
+
79
+ ## Security model
80
+
81
+ Notebook code runs in a Web Worker inside Chromium:
82
+
83
+ - **Files:** the kernel sees only an in-memory filesystem, so the host disk is out of
84
+ reach. `--mount src:/dst` copies files in. `:rw` mounts are written back after the
85
+ run, and only as regular files under `src`: no symlinks, no `..`, with size caps.
86
+ - **Network:** none for notebook code.
87
+ - Packages are fetched *before* any package or user code runs. Everything goes through
88
+ a local caching proxy that verifies each file against the lock's sha256.
89
+ - Then the run is **sealed**. The host firewall (CDP `Fetch` interception) denies every
90
+ request, the proxy refuses everything, and the kernel worker also has
91
+ `connect-src 'none'`.
92
+ - Chromium's own DNS is disabled.
93
+ - **Processes and environment:** not available from a browser worker.
94
+ - **OS sandbox:**
95
+ - Chromium's OS sandbox is used when the system allows it. Otherwise xnb warns and
96
+ runs with `--no-sandbox`, and `--strict` refuses to run at all.
97
+ - On Windows (and with `XNB_CDP_TRANSPORT=ws`), DevTools uses a loopback WebSocket
98
+ port instead of a pipe.
99
+
100
+ ## CLI
101
+
102
+ ```
103
+ xnb FILE [-o OUT|-] [--inplace] [-q]
104
+ [-e ENV.yaml] [-d SPEC]... [--pip SPEC]... [-c CHANNEL]... [--kernel NAME]
105
+ [--lock FILE] [--lock-out FILE]
106
+ [--mount SRC:DST[:ro|rw]]... [--cwd DIR] [--stdin FILE]
107
+ [--timeout S] [--cell-timeout S] [--max-memory MB] [--allow-errors] [--no-widget-state] [-v]
108
+ [--offline] [--refresh] [--strict] [--browser-path PATH] [--cache-dir DIR] [--debug]
109
+ xnb setup [--from chrome-headless-shell.zip]
110
+ xnb cache {info,clean,prune}
111
+ ```
112
+
113
+ Exit codes: 0 on success, 1 when a cell fails or times out, 2 for usage or setup
114
+ errors.
115
+
116
+ ## Python API
117
+
118
+ ```python
119
+ import xnotebook
120
+
121
+ nb = xnotebook.run("analysis.ipynb", deps=["numpy"], cell_timeout=60) # returns the executed nbformat dict
122
+ res = xnotebook.run(nb_dict, allow_errors=True, return_result=True) # includes status, failedCell, stats
123
+ ```
124
+
125
+ ## Cache
126
+
127
+ The cache lives in `~/.cache/xnb` by default (`$XNB_CACHE_DIR` overrides it):
128
+
129
+ - `chromium/`: the pinned browser;
130
+ - `pkgs/<sha256>`: packages, fetched lazily and immutable;
131
+ - `repodata/`: metadata, with ETag and a 1 h TTL;
132
+ - `locks/`: solved environments.
133
+
134
+ `--offline` uses only the cache, and `--refresh` re-solves and revalidates.
135
+
136
+ ## Development
137
+
138
+ ```console
139
+ $ cd web && npm ci && npm run build && npm test # bundle -> xnotebook/_web
140
+ $ pip install -e ".[test]"
141
+ $ pytest # browser tests are skipped without Chromium
142
+ $ python tools/pin_chromium.py 154.0.8037.57 # hashes for chromium.PINNED
143
+ ```
144
+
145
+ ## Known limitations
146
+
147
+ - **Widgets:** the final ipywidgets state is saved as a static snapshot. Python callbacks
148
+ don't run when the saved notebook is opened.
149
+ - **pip:** pure-Python wheels only.
150
+ - **xeus-lua:** each top-level line is evaluated on its own, so `local` variables don't
151
+ persist across lines. Use globals.
@@ -0,0 +1,134 @@
1
+ # xnotebook
2
+
3
+ Run Jupyter notebooks and scripts on **xeus wasm kernels** with **emscripten-forge**
4
+ packages, sandboxed inside headless Chromium.
5
+
6
+ ```console
7
+ $ pip install xnotebook
8
+ $ xnb analysis.ipynb # writes analysis.out.ipynb
9
+ $ xnb script.py -d numpy # prints outputs as they come
10
+ ```
11
+
12
+ The command is `xnb` (`xnotebook` works too), and the Python module is `xnotebook`.
13
+
14
+ The first run downloads a pinned, sha256-verified `chrome-headless-shell`
15
+ (about 120 MB) into the cache. No conda, Node or system browser is needed.
16
+
17
+ ## Dependencies
18
+
19
+ Three sources are merged, and a later source wins for the same package:
20
+
21
+ 1. an environment file: `-e environment.yaml`
22
+ 2. the input file itself:
23
+ - notebook metadata `metadata.xnb`, which uses the environment.yaml schema:
24
+ ```json
25
+ "metadata": {"xnb": {"channels": ["conda-forge"], "dependencies": ["numpy", {"pip": ["six"]}]}}
26
+ ```
27
+ - or, in a `.py` script, a [PEP 723](https://peps.python.org/pep-0723/) block, where
28
+ `dependencies` are pip packages and `[tool.xnb]` holds conda packages and channels:
29
+ ```python
30
+ # /// script
31
+ # dependencies = ["six"]
32
+ # [tool.xnb]
33
+ # dependencies = ["numpy"]
34
+ # ///
35
+ ```
36
+ 3. the CLI: `-d numpy -d "pandas>=2" --pip six -c conda-forge`
37
+
38
+ Channels default to `https://prefix.dev/emscripten-forge-4x` and `conda-forge`. The
39
+ kernel comes from `--kernel`, then the notebook's `kernelspec`, then the file extension:
40
+
41
+ | kernel | package | extension |
42
+ | --- | --- | --- |
43
+ | `xpython` | xeus-python | `.py` |
44
+ | `xr` | xeus-r | `.R` |
45
+ | `xlua` | xeus-lua | `.lua` |
46
+ | `xjavascript` | xeus-javascript | `.js` |
47
+
48
+ Solved environments are cached as locks. `--lock-out lock.json` saves the lock, and
49
+ `--lock lock.json` reuses it without solving.
50
+
51
+ ## Widgets
52
+
53
+ ipywidgets state is saved in `metadata.widgets`
54
+ (`application/vnd.jupyter.widget-state+json`), like `jupyter nbconvert --execute` does.
55
+ JupyterLab, nbviewer and Voila then render the widgets with the values they had at the
56
+ end of the run. Closed widgets are dropped. Use `--no-widget-state` to turn this off.
57
+ See `demo/widgets_demo.ipynb`.
58
+
59
+ Scripts are split into cells at `# %%` markers (percent format). The comment prefix
60
+ follows the language, so Lua uses `-- %%`.
61
+
62
+ ## Security model
63
+
64
+ Notebook code runs in a Web Worker inside Chromium:
65
+
66
+ - **Files:** the kernel sees only an in-memory filesystem, so the host disk is out of
67
+ reach. `--mount src:/dst` copies files in. `:rw` mounts are written back after the
68
+ run, and only as regular files under `src`: no symlinks, no `..`, with size caps.
69
+ - **Network:** none for notebook code.
70
+ - Packages are fetched *before* any package or user code runs. Everything goes through
71
+ a local caching proxy that verifies each file against the lock's sha256.
72
+ - Then the run is **sealed**. The host firewall (CDP `Fetch` interception) denies every
73
+ request, the proxy refuses everything, and the kernel worker also has
74
+ `connect-src 'none'`.
75
+ - Chromium's own DNS is disabled.
76
+ - **Processes and environment:** not available from a browser worker.
77
+ - **OS sandbox:**
78
+ - Chromium's OS sandbox is used when the system allows it. Otherwise xnb warns and
79
+ runs with `--no-sandbox`, and `--strict` refuses to run at all.
80
+ - On Windows (and with `XNB_CDP_TRANSPORT=ws`), DevTools uses a loopback WebSocket
81
+ port instead of a pipe.
82
+
83
+ ## CLI
84
+
85
+ ```
86
+ xnb FILE [-o OUT|-] [--inplace] [-q]
87
+ [-e ENV.yaml] [-d SPEC]... [--pip SPEC]... [-c CHANNEL]... [--kernel NAME]
88
+ [--lock FILE] [--lock-out FILE]
89
+ [--mount SRC:DST[:ro|rw]]... [--cwd DIR] [--stdin FILE]
90
+ [--timeout S] [--cell-timeout S] [--max-memory MB] [--allow-errors] [--no-widget-state] [-v]
91
+ [--offline] [--refresh] [--strict] [--browser-path PATH] [--cache-dir DIR] [--debug]
92
+ xnb setup [--from chrome-headless-shell.zip]
93
+ xnb cache {info,clean,prune}
94
+ ```
95
+
96
+ Exit codes: 0 on success, 1 when a cell fails or times out, 2 for usage or setup
97
+ errors.
98
+
99
+ ## Python API
100
+
101
+ ```python
102
+ import xnotebook
103
+
104
+ nb = xnotebook.run("analysis.ipynb", deps=["numpy"], cell_timeout=60) # returns the executed nbformat dict
105
+ res = xnotebook.run(nb_dict, allow_errors=True, return_result=True) # includes status, failedCell, stats
106
+ ```
107
+
108
+ ## Cache
109
+
110
+ The cache lives in `~/.cache/xnb` by default (`$XNB_CACHE_DIR` overrides it):
111
+
112
+ - `chromium/`: the pinned browser;
113
+ - `pkgs/<sha256>`: packages, fetched lazily and immutable;
114
+ - `repodata/`: metadata, with ETag and a 1 h TTL;
115
+ - `locks/`: solved environments.
116
+
117
+ `--offline` uses only the cache, and `--refresh` re-solves and revalidates.
118
+
119
+ ## Development
120
+
121
+ ```console
122
+ $ cd web && npm ci && npm run build && npm test # bundle -> xnotebook/_web
123
+ $ pip install -e ".[test]"
124
+ $ pytest # browser tests are skipped without Chromium
125
+ $ python tools/pin_chromium.py 154.0.8037.57 # hashes for chromium.PINNED
126
+ ```
127
+
128
+ ## Known limitations
129
+
130
+ - **Widgets:** the final ipywidgets state is saved as a static snapshot. Python callbacks
131
+ don't run when the saved notebook is opened.
132
+ - **pip:** pure-Python wheels only.
133
+ - **xeus-lua:** each top-level line is evaluated on its own, so `local` variables don't
134
+ persist across lines. Use globals.
@@ -0,0 +1,32 @@
1
+ """Build the TypeScript web bundle (web/ -> xnotebook/_web) before packaging."""
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
9
+
10
+
11
+ class WebBundleHook(BuildHookInterface):
12
+ PLUGIN_NAME = "custom"
13
+
14
+ def initialize(self, version, build_data):
15
+ root = Path(self.root)
16
+ out = root / "xnotebook" / "_web"
17
+ web = root / "web"
18
+ if os.environ.get("XNB_SKIP_WEB_BUILD") and (out / "index.html").exists():
19
+ return
20
+ if not web.exists():
21
+ if (out / "index.html").exists():
22
+ return # building from an sdist that already contains the bundle
23
+ raise RuntimeError("web/ sources missing and no prebuilt xnotebook/_web bundle")
24
+ npm = shutil.which("npm")
25
+ if npm is None:
26
+ if (out / "index.html").exists():
27
+ self.app.display_warning("npm not found; using the existing xnotebook/_web bundle")
28
+ return
29
+ raise RuntimeError("npm is required to build the web bundle")
30
+ if not (web / "node_modules").exists():
31
+ subprocess.run([npm, "ci" if (web / "package-lock.json").exists() else "install"], cwd=web, check=True)
32
+ subprocess.run([npm, "run", "build"], cwd=web, check=True)
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "xnotebook"
7
+ dynamic = ["version"]
8
+ description = "Run notebooks and scripts on sandboxed xeus wasm kernels (emscripten-forge) inside headless Chromium"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "BSD-3-Clause"
12
+ dependencies = [
13
+ "typer>=0.12",
14
+ "typing_extensions>=4.6",
15
+ ]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Framework :: Jupyter",
19
+ "Topic :: Security",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ test = ["pytest>=7", "nbformat>=5", "jsonschema>=4"]
24
+
25
+ [project.scripts]
26
+ xnb = "xnotebook.cli:main"
27
+ xnotebook = "xnotebook.cli:main"
28
+
29
+ [tool.hatch.version]
30
+ path = "xnotebook/__init__.py"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["xnotebook"]
34
+ artifacts = ["xnotebook/_web/**"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["xnotebook", "web/src", "web/build.mjs", "web/package.json", "web/package-lock.json", "web/tsconfig.json", "hatch_build.py", "tests", "README.md", "PLAN.md"]
38
+
39
+ [tool.hatch.build.hooks.custom]
40
+ path = "hatch_build.py"
@@ -0,0 +1,49 @@
1
+ import re
2
+
3
+ from xnotebook.cli import main
4
+
5
+ ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
6
+
7
+
8
+ def plain(text: str) -> str:
9
+ """Help output is coloured when the environment forces it (e.g. on CI)."""
10
+ return ANSI.sub("", text)
11
+
12
+
13
+ def test_help(capsys):
14
+ assert main(["--help"]) == 0
15
+ out = plain(capsys.readouterr().out)
16
+ assert "run" in out and "setup" in out and "cache" in out
17
+
18
+
19
+ def test_run_help_is_default_command(capsys):
20
+ assert main(["run", "--help"]) == 0
21
+ out = plain(capsys.readouterr().out)
22
+ for flag in ("--env", "--dep", "--pip", "--mount", "--offline", "--strict", "--cell-timeout"):
23
+ assert flag in out
24
+
25
+
26
+ def test_version(capsys):
27
+ assert main(["--version"]) == 0
28
+ assert "xnb" in capsys.readouterr().out
29
+
30
+
31
+ def test_missing_file(capsys):
32
+ assert main(["does-not-exist.ipynb"]) == 2
33
+ assert "no such file" in capsys.readouterr().err
34
+
35
+
36
+ def test_inplace_requires_notebook(tmp_path, capsys):
37
+ f = tmp_path / "x.py"
38
+ f.write_text("print(1)")
39
+ assert main([str(f), "--inplace"]) == 2
40
+
41
+
42
+ def test_bad_option(capsys):
43
+ assert main(["x.ipynb", "--no-such-flag"]) == 2
44
+ assert "no-such-flag" in plain(capsys.readouterr().err)
45
+
46
+
47
+ def test_cache_info(tmp_path, capsys):
48
+ assert main(["cache", "info", "--cache-dir", str(tmp_path)]) == 0
49
+ assert str(tmp_path) in capsys.readouterr().out
@@ -0,0 +1,227 @@
1
+ """End-to-end runs in headless Chromium (network needed on a cold cache).
2
+
3
+ Skipped unless Chromium is available (installed in the xnb cache, or XNB_CHROMIUM_PATH).
4
+ Set XNB_CACHE_DIR to share a warm cache between runs.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import textwrap
10
+
11
+ import pytest
12
+
13
+ import xnotebook
14
+ from xnotebook import chromium
15
+ from xnotebook.cache import Cache
16
+ from xnotebook.session import WEB_ROOT
17
+
18
+
19
+ def _have_browser() -> bool:
20
+ if not (WEB_ROOT / "index.html").exists():
21
+ return False
22
+ if os.environ.get("XNB_CHROMIUM_PATH"):
23
+ return True
24
+ try:
25
+ return chromium.installed_path(Cache()).exists()
26
+ except chromium.ChromiumError:
27
+ return False
28
+
29
+
30
+ pytestmark = pytest.mark.skipif(not _have_browser(), reason="Chromium or web bundle not available")
31
+
32
+
33
+ def nb(*cells, **metadata):
34
+ return {
35
+ "nbformat": 4,
36
+ "nbformat_minor": 5,
37
+ "metadata": {"kernelspec": {"name": "xpython", "display_name": "Python"}, **metadata},
38
+ "cells": [
39
+ {"cell_type": "code", "source": textwrap.dedent(c), "metadata": {}, "outputs": [], "execution_count": None}
40
+ for c in cells
41
+ ],
42
+ }
43
+
44
+
45
+ def texts(cell):
46
+ out = []
47
+ for o in cell["outputs"]:
48
+ if o["output_type"] == "stream":
49
+ out.append(o["text"])
50
+ elif o["output_type"] in ("execute_result", "display_data"):
51
+ out.append(o["data"].get("text/plain", ""))
52
+ elif o["output_type"] == "error":
53
+ out.append(f"{o['ename']}: {o['evalue']}")
54
+ return "".join(out)
55
+
56
+
57
+ def test_basic_notebook(tmp_path):
58
+ stdin = tmp_path / "stdin.txt"
59
+ stdin.write_text("World\n")
60
+ result = xnotebook.run(
61
+ nb(
62
+ "print('hello'); 1 + 1",
63
+ "import numpy as np\nint(np.arange(3).sum())",
64
+ "from IPython.display import display, HTML\nh = display(HTML('<b>v1</b>'), display_id=True)\nh.update(HTML('<b>v2</b>'))",
65
+ "import asyncio\nawait asyncio.sleep(0.05)\nprint('awaited')",
66
+ "print('hi', input())",
67
+ "1/0",
68
+ "print('not reached')",
69
+ xnb={"dependencies": ["numpy"]},
70
+ ),
71
+ stdin=str(stdin),
72
+ return_result=True,
73
+ )
74
+ out = result["notebook"]
75
+ cells = out["cells"]
76
+ assert texts(cells[0]) == "hello\n2"
77
+ assert texts(cells[1]) == "3"
78
+ assert cells[2]["outputs"][0]["data"]["text/html"] == "<b>v2</b>"
79
+ assert texts(cells[3]) == "awaited\n"
80
+ assert texts(cells[4]) == "hi World\n"
81
+ assert cells[5]["outputs"][0]["ename"] == "ZeroDivisionError"
82
+ assert cells[6]["outputs"] == [] and cells[6]["execution_count"] is None
83
+ assert result["status"] == "error" and result["failedCell"] == 5
84
+ assert out["metadata"]["language_info"]["name"] == "python"
85
+ assert result["stats"]["requests_after_seal"] == 0
86
+ try:
87
+ import nbformat
88
+
89
+ nbformat.validate(nbformat.from_dict(out))
90
+ except ImportError:
91
+ pass
92
+
93
+
94
+ def test_escape_attempts_blocked():
95
+ probe = """
96
+ import js
97
+ r = js.eval('''(async () => {
98
+ const real = WorkerGlobalScope.prototype.fetch;
99
+ const out = [];
100
+ const proxied = location.href.replace('kernel.worker.js', 'u/' + encodeURIComponent('https://prefix.dev/'));
101
+ for (const u of ['https://example.com/', proxied, location.href]) {
102
+ try { await real.call(self, u); out.push('ESCAPED ' + u); } catch (e) { out.push('blocked'); }
103
+ }
104
+ try { const x = new XMLHttpRequest(); x.open('GET', 'https://example.com', false); x.send(); out.push('ESCAPED xhr'); }
105
+ catch (e) { out.push('blocked'); }
106
+ try { importScripts('https://example.com/x.js'); out.push('ESCAPED importScripts'); } catch (e) { out.push('blocked'); }
107
+ const w = new Worker(URL.createObjectURL(new Blob(["fetch('https://example.com').then(()=>postMessage('ESCAPED nested'),()=>postMessage('blocked'))"])));
108
+ out.push(await new Promise(res => { w.onmessage = e => res(e.data); w.onerror = () => res('blocked'); }));
109
+ const ws = new WebSocket('wss://example.com');
110
+ out.push(await new Promise(res => { ws.onopen = () => res('ESCAPED ws'); ws.onerror = () => res('blocked'); ws.onclose = () => res('blocked'); }));
111
+ out.push(typeof __xnbSend === 'undefined' ? 'blocked' : 'ESCAPED binding');
112
+ return out.join(',');
113
+ })()''')
114
+ print(await r)
115
+ """
116
+ fs = """
117
+ import os
118
+ try:
119
+ open('/etc/passwd').read(); print('ESCAPED passwd')
120
+ except OSError:
121
+ print('blocked')
122
+ print('HOME' in os.environ and os.environ['HOME'] != os.path.expanduser('~root'))
123
+ """
124
+ result = xnotebook.run(nb(probe, fs), allow_errors=True, return_result=True)
125
+ cells = result["notebook"]["cells"]
126
+ assert "ESCAPED" not in texts(cells[0]), texts(cells[0])
127
+ assert set(texts(cells[0]).strip().split(",")) == {"blocked"}
128
+ assert texts(cells[1]).startswith("blocked")
129
+ assert result["stats"]["requests_after_seal"] == 0
130
+ assert result["stats"]["proxy_blocked_after_seal"] == 0
131
+
132
+
133
+ def test_script_pep723_mounts_and_lock(tmp_path):
134
+ data = tmp_path / "data"
135
+ data.mkdir()
136
+ (data / "in.txt").write_text("input")
137
+ out = tmp_path / "out"
138
+ out.mkdir()
139
+ script = tmp_path / "s.py"
140
+ script.write_text(
141
+ textwrap.dedent(
142
+ """\
143
+ # /// script
144
+ # dependencies = ["six"]
145
+ # ///
146
+ # %%
147
+ import six
148
+ print(six.__name__, open('/data/in.txt').read())
149
+ # %%
150
+ open('/data/in.txt', 'w').write('changed')
151
+ open('/out/result.txt', 'w').write('from kernel')
152
+ """
153
+ )
154
+ )
155
+ lock_out = tmp_path / "lock.json"
156
+ result = xnotebook.run(
157
+ script,
158
+ mounts=[f"{data}:/data", f"{out}:/out:rw"],
159
+ lock_out=lock_out,
160
+ return_result=True,
161
+ )
162
+ cells = result["notebook"]["cells"]
163
+ assert texts(cells[0]) == "six input\n"
164
+ assert (data / "in.txt").read_text() == "input" # ro: untouched
165
+ assert (out / "result.txt").read_text() == "from kernel"
166
+ lock = json.loads(lock_out.read_text())
167
+ assert any(p.startswith("six-") for p in lock["pipPackages"])
168
+ # Reuse the lock offline: everything comes from the cache
169
+ again = xnotebook.run(script, lock=lock_out, offline=True, mounts=[f"{data}:/data", f"{out}:/out:rw"], return_result=True)
170
+ assert again["status"] == "ok"
171
+ assert again["stats"]["upstream_fetches"] == 0
172
+
173
+
174
+ def test_cell_timeout(tmp_path):
175
+ result = xnotebook.run(nb("print('a')", "while True: pass", "print('b')"), cell_timeout=3, return_result=True)
176
+ assert result["status"] == "timeout" and result["failedCell"] == 1
177
+ assert result["notebook"]["cells"][1]["outputs"][-1]["ename"] == "TimeoutError"
178
+ assert result["notebook"]["cells"][2]["outputs"] == []
179
+
180
+
181
+ def test_lua_kernel(tmp_path):
182
+ f = tmp_path / "hello.lua"
183
+ f.write_text('print("lua", 1 + 2)\n')
184
+ result = xnotebook.run(f, return_result=True)
185
+ assert texts(result["notebook"]["cells"][0]) == "lua 3\n"
186
+ assert result["notebook"]["metadata"]["kernelspec"]["name"] == "xlua"
187
+
188
+
189
+ def test_widget_state_saved():
190
+ import pathlib
191
+
192
+ result = xnotebook.run(
193
+ nb(
194
+ "import ipywidgets as w\ns = w.IntSlider(value=3)\ns",
195
+ "s.value = 7\ntmp = w.Checkbox()\ndisplay(tmp)\ntmp.close()",
196
+ "w.Image(value=b'\\x89PNG\\r\\n', format='png')",
197
+ xnb={"dependencies": ["ipywidgets"]},
198
+ ),
199
+ return_result=True,
200
+ )
201
+ out = result["notebook"]
202
+ doc = out["metadata"]["widgets"]["application/vnd.jupyter.widget-state+json"]
203
+ views = [
204
+ o["data"]["application/vnd.jupyter.widget-view+json"]["model_id"]
205
+ for c in out["cells"]
206
+ for o in c["outputs"]
207
+ if "application/vnd.jupyter.widget-view+json" in o.get("data", {})
208
+ ]
209
+ slider, checkbox, image = views
210
+ assert doc["state"][slider]["state"]["value"] == 7
211
+ assert checkbox not in doc["state"]
212
+ assert doc["state"][image]["buffers"][0]["path"] == ["value"]
213
+ try:
214
+ import jsonschema
215
+ except ImportError:
216
+ return
217
+ schema = json.loads((pathlib.Path(__file__).parent / "widget_state.schema.json").read_text())
218
+ jsonschema.validate(doc, schema)
219
+
220
+
221
+ def test_widget_state_disabled():
222
+ result = xnotebook.run(
223
+ nb("import ipywidgets as w\nw.IntSlider()", xnb={"dependencies": ["ipywidgets"]}),
224
+ widget_state=False,
225
+ return_result=True,
226
+ )
227
+ assert "widgets" not in result["notebook"]["metadata"]