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.
- xnotebook-1.0.0/.gitignore +9 -0
- xnotebook-1.0.0/PKG-INFO +151 -0
- xnotebook-1.0.0/README.md +134 -0
- xnotebook-1.0.0/hatch_build.py +32 -0
- xnotebook-1.0.0/pyproject.toml +40 -0
- xnotebook-1.0.0/tests/test_cli.py +49 -0
- xnotebook-1.0.0/tests/test_integration.py +227 -0
- xnotebook-1.0.0/tests/test_mounts.py +74 -0
- xnotebook-1.0.0/tests/test_proxy.py +299 -0
- xnotebook-1.0.0/tests/widget_state.schema.json +82 -0
- xnotebook-1.0.0/web/build.mjs +45 -0
- xnotebook-1.0.0/web/package-lock.json +1219 -0
- xnotebook-1.0.0/web/package.json +20 -0
- xnotebook-1.0.0/web/src/deps.ts +337 -0
- xnotebook-1.0.0/web/src/executor.ts +277 -0
- xnotebook-1.0.0/web/src/formats.ts +111 -0
- xnotebook-1.0.0/web/src/host.ts +66 -0
- xnotebook-1.0.0/web/src/index.html +9 -0
- xnotebook-1.0.0/web/src/kernel.worker.ts +377 -0
- xnotebook-1.0.0/web/src/orchestrator.ts +245 -0
- xnotebook-1.0.0/web/src/solve.ts +131 -0
- xnotebook-1.0.0/web/src/types.ts +65 -0
- xnotebook-1.0.0/web/src/widgets.ts +138 -0
- xnotebook-1.0.0/web/tsconfig.json +14 -0
- xnotebook-1.0.0/xnotebook/__init__.py +7 -0
- xnotebook-1.0.0/xnotebook/__main__.py +5 -0
- xnotebook-1.0.0/xnotebook/api.py +133 -0
- xnotebook-1.0.0/xnotebook/cache.py +124 -0
- xnotebook-1.0.0/xnotebook/cdp.py +297 -0
- xnotebook-1.0.0/xnotebook/chromium.py +444 -0
- xnotebook-1.0.0/xnotebook/cli.py +301 -0
- xnotebook-1.0.0/xnotebook/firewall.py +95 -0
- xnotebook-1.0.0/xnotebook/mounts.py +129 -0
- xnotebook-1.0.0/xnotebook/proxy.py +511 -0
- xnotebook-1.0.0/xnotebook/session.py +271 -0
xnotebook-1.0.0/PKG-INFO
ADDED
|
@@ -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"]
|