Notebook ecosystem (Jupyter, Marimo, Voila, JupyterHub, kernels)¶
Reference page covering the moving parts behind interactive notebooks: which frontends render notebooks, what kernels run the code, how pip install inside a notebook actually behaves, and the install-method quirks (e.g. when jupyter meta-CLI is on PATH and when only jupyter-lab is). For what this dotfiles repo specifically installs, see the "In this repo" section below.
Frontends — UIs that render notebooks¶
| Frontend | Notebook format | Multi-doc UI | Reactive | Multi-language | Use it for |
|---|---|---|---|---|---|
| JupyterLab | .ipynb |
Yes (tabs, splits, file browser) | No (manual run) | Yes (any kernel) | General data science, multi-language, plugin-rich |
| Jupyter Notebook v7+ | .ipynb |
No (single doc per tab) | No | Yes | "Classic" UI feel; rebuilt on JupyterLab JS components since v7 (2023) |
| marimo | .py (PEP 723) |
Yes | Yes (DAG-based) | Python only | Reproducibility, git-friendly diffs, deterministic execution |
| Voila | .ipynb (rendered) |
N/A — single-app server | N/A | Inherits from kernel | Turning a notebook into a read-only/interactive dashboard for end-users |
| JupyterHub | N/A — multi-user spawner | N/A | N/A | Inherits | Institutional / classroom: each user gets their own Jupyter server |
Key distinctions:
- JupyterLab vs Jupyter Notebook: since Notebook v7 (2023) they're separate PyPI packages (
jupyterlab,notebook) but built on the same JupyterLab JS components. They install side-by-side cleanly in one Python env. v6 ofnotebookwas the legacy classic codebase — that's deprecated. - Marimo is not Jupyter. It's a separate frontend AND separate execution model (reactive, DAG-based, no hidden cell-order state). It stores notebooks as plain
.pyfiles with PEP 723 metadata, not.ipynb. Themarimo-jupyter-extensionis a JupyterLab plugin that lets you launch marimo sessions from inside JupyterLab — it must be installed in the JupyterLab env, not the marimo env. - Voila and JupyterHub are layers, not full UIs. Voila wraps a single notebook into a no-code dashboard. JupyterHub spawns per-user JupyterLab/Notebook instances behind auth.
Kernels — what actually runs the code¶
A kernel is a small server process that owns a language runtime and speaks the Jupyter messaging protocol. The frontend (Lab/Notebook/Voila) sends code; the kernel executes; results stream back. This decoupling is why Jupyter is multi-language.
Kernel discovery paths (in priority order):
~/.local/share/jupyter/kernels/<name>/— user-level (preferred for most installs)<sys.prefix>/share/jupyter/kernels/<name>/— env-local (e.g.~/.local/share/uv/tools/jupyterlab/share/jupyter/kernels/)/usr/local/share/jupyter/kernels/,/usr/share/jupyter/kernels/— system-wide
Each kernel directory has a kernel.json with the launch command. You can list them with jupyter kernelspec list.
Common kernels and how to install them:
| Language | Kernel | Install | Notes |
|---|---|---|---|
| Python | ipykernel |
python -m ipykernel install --user --name <name> --display-name "<label>" |
Auto-runs against the python that invokes it — register one per project venv |
| .NET (C#/F#/PowerShell) | Microsoft.dotnet-interactive |
dotnet tool install -g Microsoft.dotnet-interactive && dotnet interactive jupyter install |
Installs three kernels: .net-csharp, .net-fsharp, .net-pwsh |
| Rust | evcxr_jupyter |
cargo install evcxr_jupyter && evcxr_jupyter --install |
REPL-style execution; supports :dep for crate deps |
| C++ | xeus-cling (legacy) / xeus-cpp (active) |
conda only: mamba install -c conda-forge xeus-cling |
Hard to install outside conda — depends on patched LLVM/Cling. xeus-cpp is the modern fork on Clang-Repl |
| Julia | IJulia |
From Julia REPL: using Pkg; Pkg.add("IJulia") |
Auto-registers itself |
| R | IRkernel |
In R: install.packages("IRkernel"); IRkernel::installspec() |
|
| JavaScript / TypeScript | ijavascript / tslab |
npm i -g ijavascript && ijsinstall |
Node.js REPL; tslab adds TypeScript support |
| Bash | bash_kernel |
pip install bash_kernel && python -m bash_kernel.install |
|
| Go | gophernotes |
See repo — go install + manual kernel.json copy |
Once a kernel is registered at the user level (~/.local/share/jupyter/kernels/), it's visible to every Jupyter frontend on the host — JupyterLab, Notebook, and Voila all see the same kernels. You don't need to reinstall the kernel per-frontend.
In this repo¶
The dotfiles install two notebook frontends via uv tool (defined in dot_ansible/roles/python_uv_tools/defaults/main.yml):
| Tool | Primary package | Extras / --with |
--with-executables-from |
Exposes |
|---|---|---|---|---|
marimo |
marimo[recommended,mcp] |
httpx[socks] |
— | marimo (binary) — completion auto-loads via dot_config/shell/29_marimo.sh |
jupyterlab |
jupyterlab |
marimo[sandbox], marimo-jupyter-extension, ipykernel, ipywidgets |
notebook, jupyter-core |
jupyter-lab, jupyter-notebook, jupyter (meta-CLI), jlpm, jupyter-labextension, jupyter-labhub, jupyter-migrate, jupyter-troubleshoot — all four invocation forms work: jupyter-lab / jupyter lab / jupyter-notebook / jupyter notebook |
The two are separate uv tool envs (each lives under ~/.local/share/uv/tools/<name>/), so their dependency trees don't collide. Marimo's reactive-DAG runtime never touches Jupyter; the JupyterLab env hosts both Lab and Notebook frontends behind one shared message protocol.
The --with-executables-from flag requires uv ≥ 0.8.5 — the role auto-upgrades older uv before installing. Full bootstrap + dispatch matrix: docs/this_repo/uv-bootstrap.md.
Verifying the install¶
After chezmoi apply (or ansible-playbook ... --tags python_uv_tools), every shim should be a symlink under ~/.local/bin/ pointing into the JupyterLab tool env:
$ ls -la ~/.local/bin/ | grep -E '(jupyter|jlpm)'
jlpm → ~/.local/share/uv/tools/jupyterlab/bin/jlpm
jupyter → ~/.local/share/uv/tools/jupyterlab/bin/jupyter # meta-CLI (from jupyter-core)
jupyter-lab → ~/.local/share/uv/tools/jupyterlab/bin/jupyter-lab # JupyterLab itself
jupyter-labextension → ~/.local/share/uv/tools/jupyterlab/bin/jupyter-labextension
jupyter-labhub → ~/.local/share/uv/tools/jupyterlab/bin/jupyter-labhub
jupyter-migrate → ~/.local/share/uv/tools/jupyterlab/bin/jupyter-migrate
jupyter-notebook → ~/.local/share/uv/tools/jupyterlab/bin/jupyter-notebook # Notebook 7 (from notebook pkg)
jupyter-troubleshoot → ~/.local/share/uv/tools/jupyterlab/bin/jupyter-troubleshoot
$ uv tool list | grep -A8 '^jupyterlab'
jupyterlab v4.5.7
- jlpm
- jupyter
- jupyter-lab
- jupyter-labextension
- jupyter-labhub
- jupyter-migrate
- jupyter-notebook
- jupyter-troubleshoot
Quick smoke tests for each entry point:
$ jupyter --version # the meta-CLI dispatcher prints all bundled core packages
Selected Jupyter core packages...
IPython : 9.x
ipykernel : 7.x
ipywidgets : 8.x
jupyter_client : 8.x
jupyter_core : 5.x
jupyter_server : 2.x
jupyterlab : 4.x
nbclient : 0.x
nbconvert : 7.x
$ jupyter-lab --version # → 4.5.x
$ jupyter-notebook --version # → 7.5.x (Notebook 7, the rebuilt classic UI)
$ jupyter kernelspec list # initially: just `python3` (the env's bundled ipykernel)
The jupyter kernelspec list output is the canonical check that Jupyter can find Python kernels. Other-language kernels register themselves into ~/.local/share/jupyter/kernels/ and show up the same way (see Kernels above).
Marimo first-run config: dot_config/marimo/create_marimo.toml.tmpl (uses chezmoi create_ prefix — seeded once, then user-owned). Vim mode is gated on the enableVimMode chezmoi prompt, see vim-mode.md.
What's not installed by this repo (but covered below for reference):
- Voila — install ad-hoc via
uv tool install voilaif you need to share a notebook as a dashboard. Not bundled because it's a deployment-time tool, not a daily driver. - JupyterHub — server-side, multi-user. Not a per-user dotfile concern.
- Language kernels other than Python/ipykernel — install on demand via the recipes above.
The jupyter meta-CLI vs standalone binaries¶
jupyter (the command you type) is a dispatcher from jupyter-core, not its own application. It scans $PATH and <sys.prefix>/bin/ for executables named jupyter-<subcommand> and forwards arguments. So jupyter lab literally execs jupyter-lab. Both invocation forms are equivalent — the dispatcher is just sugar.
You can see the dispatch table at runtime with jupyter --help:
$ jupyter --help
usage: jupyter [-h] [--version] [--config-dir] [--data-dir] [--runtime-dir]
[--paths] [--json] [--debug]
[subcommand]
Jupyter: Interactive Computing
…
Available subcommands:
console events execute kernel kernelspec lab labextension labhub
migrate nbconvert notebook run server troubleshoot trust
Every Available subcommand corresponds to an executable named jupyter-<sub> somewhere on $PATH (for this repo, all eight live in ~/.local/share/uv/tools/jupyterlab/bin/ with symlinks in ~/.local/bin/). jupyter lab → jupyter-lab → JupyterLab process. jupyter kernelspec list → jupyter-kernelspec list → kernelspec discovery. The dispatcher itself does almost nothing besides find-and-exec — most subcommand-discovery logic lives in jupyter_core.command.list_subcommands(), which globs $PATH.
This matters for how you install things:
| Install method | jupyter |
jupyter-lab |
jupyter-notebook |
jupyter kernelspec |
|---|---|---|---|---|
pip install jupyterlab notebook (in a venv) |
✓ | ✓ | ✓ | ✓ |
pip install jupyter (meta-package) |
✓ | ✓ | ✓ | ✓ — pulls all components |
uv tool install jupyterlab |
✗ | ✓ | ✗ | ✗ |
uv tool install jupyterlab --with notebook |
✗ | ✓ | ✗ (installed but hidden) | ✗ |
uv tool install jupyterlab --with-executables-from notebook --with-executables-from jupyter-core ← this repo |
✓ | ✓ | ✓ | ✓ |
uv tool install jupyter (meta-package) |
✗ | ✗ | ✗ | ✗ — empty stub, no scripts |
Why uv tool is different from pip: uv tool install <pkg> is designed to expose only the primary package's [project.scripts] entry points to ~/.local/bin. --with <dep> adds runtime deps but hides their executables. The opt-in flag --with-executables-from <pkg> installs and exposes the executables of an extra package — that's how this repo gets jupyter-notebook and the jupyter dispatcher onto PATH from a single uv tool install jupyterlab invocation. The flag was added in uv 0.8.5 (PR astral-sh/uv#14014) — older uv would error with unexpected argument '--with-executables-from'. The python_uv_tools ansible role auto-upgrades uv before installing; full dispatch matrix in docs/this_repo/uv-bootstrap.md.
Don't use uv tool install jupyter (the meta-package). Inspecting its PyPI metadata: provides_extra=[], [project.scripts]={}. It's an empty stub that just declares deps on notebook + jupyterlab + ipykernel + .... uv would install the deps in an env but expose zero binaries because the primary package has no entry points of its own.
Troubleshooting: jupyter: command not found after chezmoi apply¶
If jupyter-lab works but bare jupyter (or jupyter-notebook) is missing, the most common cause is a partial install from before this repo grew the --with-executables-from support: an older uv tool install jupyterlab left ~/.local/bin/jupyter-lab in place, and the role's creates: ~/.local/bin/{{ item.binary }} idempotency check then short-circuits — never re-running the install with the new flags.
Verify and fix:
# Check what jupyterlab actually exposes today
uv tool list | grep -A8 '^jupyterlab'
# If only `jupyter-lab` (and `jlpm` / `jupyter-labextension` / etc.) appear,
# but no `jupyter` or `jupyter-notebook` — force a clean reinstall:
uv tool uninstall jupyterlab
chezmoi apply
# (or: ansible-playbook ~/.ansible/playbooks/macos.yml --tags python_uv_tools)
# Verify all 8 shims appear:
ls ~/.local/bin/ | grep -E '(jupyter|jlpm)'
The role's creates: check is intentional — it keeps re-applies cheap. But it does mean changes to the with_executables_from: list in defaults/main.yml only affect fresh installs; existing tool envs need an explicit uv tool uninstall before re-applying. This is the same pattern as any creates:-gated ansible task, but worth knowing about for jupyterlab specifically because the symptom (jupyter missing while jupyter-lab works) is non-obvious.
Where does !pip install <pkg> actually go?¶
This is a frequent confusion. The short version: !pip install runs pip in a subshell using the kernel's interpreter, so it installs into whichever Python env is hosting the running kernel — which may not be where you think.
For this repo's setup, when you launch a notebook with the default Python kernel (the one provided by ipykernel shipped inside ~/.local/share/uv/tools/jupyterlab/):
!pip install pandas→ installspandasinto~/.local/share/uv/tools/jupyterlab/lib/python3.X/site-packages/- This works — the next cell can
import pandasimmediately - But it's fragile: if you later run
uv tool upgrade jupyterlaboruv tool install --reinstall jupyterlab ..., uv may rebuild the env from scratch and your ad-hoc package is gone - It also pollutes the UI env — the JupyterLab env was meant to host Lab/Notebook itself, not your project's analysis libs
Better: %pip magic instead of !pip¶
This is a Jupyter magic — same effect, but Jupyter-aware: it prints a warning if it detects the kernel's env doesn't match the running shell, suggests a kernel restart after install, and uses sys.executable so you can't hit the wrong Python.
Best: register a project kernel from a project venv¶
For anything beyond throwaway exploration, give each project its own venv with ipykernel and register it as a kernel:
cd ~/projects/my-analysis
uv venv .venv
source .venv/bin/activate
uv pip install ipykernel pandas matplotlib scikit-learn
python -m ipykernel install --user --name my-analysis --display-name "Python (my-analysis)"
Then in JupyterLab → kernel picker (top-right) → "Python (my-analysis)". The kernel spec lives in ~/.local/share/jupyter/kernels/my-analysis/kernel.json and is visible to every Jupyter frontend on the host (Lab, Notebook, Voila).
Now !pip install <new-pkg> inside a cell installs into .venv/, not into the JupyterLab tool env. Project deps stay in the project, and you can blow away .venv without breaking JupyterLab.
To remove a registered kernel: jupyter kernelspec uninstall my-analysis.
Best (marimo): PEP 723 sandbox¶
Marimo sidesteps the kernel-registration problem entirely. With sandbox mode (--sandbox flag, or set as default via [runtime] sandbox in marimo.toml), each notebook declares its deps inline as a PEP 723 script-metadata block at the top of the .py file:
# /// script
# requires-python = ">=3.11"
# dependencies = ["pandas", "matplotlib", "scikit-learn"]
# ///
import marimo as mo
# ...
When opened, marimo asks uv to materialize an isolated venv for that notebook. No global kernel registration, no !pip install ambiguity, no env pollution — and the notebook is self-contained and reproducible.
The marimo-jupyter-extension bundled in this repo lets you launch marimo notebooks from inside JupyterLab, picking the env per-notebook from PEP 723 metadata. That's why both marimo[sandbox] and the extension are pinned as --with deps on the JupyterLab uv tool.
Quick decision matrix¶
| You want to... | Use |
|---|---|
| Quickly explore data, deterministic re-runs | marimo (sandbox mode) |
| Multi-language exploration (Rust + Python + Julia) | JupyterLab + per-language kernels |
| Classic single-doc UI feel | jupyter notebook (Notebook v7 in this repo) |
| Turn a finished notebook into a no-code dashboard | Voila (uv tool install voila) |
| Multi-user / classroom deployment | JupyterHub (server-side, out of scope here) |
| Reproducible, diff-friendly notebooks with version control | marimo (.py files, no opaque .ipynb JSON) |
| Persist project deps independently of the UI | Project venv + ipykernel registered kernel |
See also¶
- llm.md — local LLM tools (LiteLLM, Ollama, models) often used alongside notebooks
- vim-mode.md — marimo's
[keymap] presetis gated onenableVimMode - zsh/zsh-completions.md — marimo shell completion auto-loads
- chezmoi-prefixes.md — why marimo's config uses
create_(seed-once) instead ofmodify_(overlay) prefix - Upstream docs: JupyterLab · marimo · Voila · JupyterHub · PEP 723