juplit
Literate programming for Python — write in notebooks, commit clean Python, keep AI agents fast.
Why juplit
Jupyter notebooks are great for development: you can write prose next to code, run cells incrementally, and explore interactively. But .ipynb files are JSON blobs that create real problems:
- Git history is cluttered — every output change, cell execution count, or metadata tweak shows up as a diff
- AI agents struggle — JSON notebooks are token-heavy and hard to reason over compared to plain Python
- Code review is painful — notebooks don't diff cleanly in pull requests
juplit gives you the best of both worlds. You write in jupytext percent-format .py files — plain Python that AI agents can read and reason over efficiently. You generate .ipynb files locally for interactive Jupyter sessions, but keep them out of git. The .py file is always the source of truth.
Installation
pip install juplit
CLI usage
juplit nb # generate .ipynb from .py files (run after cloning)
juplit sync # sync .py <-> .ipynb after editing
juplit clean # sync then delete all .ipynb files (before AI agent sessions)
juplit skill # print the Claude Code skill file for juplit
Artifact notebooks
Some notebooks exist for their outputs — an analysis run against a live endpoint, where
the plots and tables are the deliverable and nobody will re-run it to review it. Declare
those pairs and juplit stops treating the .ipynb as disposable:
[tool.juplit]
artifact_notebooks = ["experiments/**/*.py"] # .ipynb committed WITH its outputs
!experiments/ablation.ipynb # un-ignore it, or the outputs are never committed
For a declared pair, nb and sync keep the outputs, and clean keeps the file
(--force deletes it anyway). Every cell juplit executes records which version of its
source produced its outputs, so a cell whose code has moved on is reported and blocked
rather than passing silently:
juplit check # pre-commit / CI: reads committed files only, no kernel needed
juplit run nb.py --stale # re-execute just the cells that drifted
Committed does not mean untouched: execution counts, per-run timestamps and progress-bar spam are stripped on every write, so re-running with the same results produces no diff.
The execution loop
juplit can hold a Jupyter kernel alive between CLI calls, with no Jupyter server — so an agent (or you) can try a cell, look at the result, and commit only the version that worked:
juplit cells experiments/ablation.py # index: one line per cell, digests, no base64
juplit view experiments/ablation.py 3-7 # just those cells, with their outputs
juplit kernel start
juplit try 'df.groupby("arm").score.mean()' # prints; writes nothing
juplit add-cell experiments/ablation.py --from-last # the snippet + the outputs you saw
juplit html experiments/ablation.py # standalone page for a non-Python reader
try never writes. run always writes. That is the whole safety rule.
See the artifact notebooks tutorial for the full walkthrough.
Project setup (pyproject.toml)
For a new project, use the cookiecutter template
[project]
dependencies = ["juplit>=0.1.0"]
[dependency-groups]
dev = ["poethepoet>=0.25.0", "pytest>=8.0.0", "ipykernel>=6.0.0", "pre-commit>=3.0.0"]
[tool.poe.tasks]
init = {cmd = "pre-commit install"}
sync = {cmd = "juplit sync"}
nb = {cmd = "juplit nb"}
clean = {cmd = "juplit clean"}
test = {cmd = "pytest"}
[tool.juplit]
notebook_src_dir = "your_module" # directory juplit scans for paired .py files
[tool.jupytext]
formats = "ipynb,py:percent"
[tool.pytest.ini_options]
python_files = ["*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
juplit finds the nearest pyproject.toml by walking up from the current directory, so the CLI works from any subdirectory.
Separating logic from tests with test()
Use test() to gate inline test code so it runs interactively in Jupyter and under pytest, but never on import:
from juplit import test
# %%
def add(a: int, b: int) -> int:
return a + b
# %%
if test():
assert add(1, 2) == 3
assert add(-1, 1) == 0
print("add() tests pass")
pytest picks up these blocks automatically when you configure:
[tool.pytest.ini_options]
python_files = ["*.py"]
No def test_* functions required — just if test(): blocks next to the code they test.
Under pytest, test() is True only for modules inside the run's own
[tool.juplit] notebook_src_dirs (read from the pyproject at pytest's rootdir, by a
pytest plugin juplit installs). A package you install that is itself written with
if test(): blocks therefore does not run them in your test suite. (Since 0.3.0:
earlier versions ran every imported module's blocks under any pytest run.)
You can also mix standard pytest functions with if test(): scaffolding blocks. Because if test(): runs at module scope during pytest collection, variables it sets up are available to def test_* functions:
from juplit import test
# %%
def compute(x: int) -> int:
return x * 2 + 1
# %%
if test():
inputs = [1, 3, -1]
expected = [3, 7, -1]
def test_compute():
for x, e in zip(inputs, expected):
assert compute(x) == e
Paired notebook format
A .py file is recognized as a paired notebook when its header contains:
# ---
# jupyter:
# jupytext:
# formats: ipynb,py:percent
# ...
# ---
Cells are delimited with # %% (code) and # %% [markdown] (prose).
Claude Code integration
Generate a skill file for Claude Code so it understands the juplit workflow:
juplit skill > .claude/skills/juplit-programming.md
For a skill on how to migrate nbdev repos to juplit:
juplit skill_migrate > .claude/skills/juplit-programming-nbdev-migrate.md
Metadata
Release files for juplit 0.3.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| juplit-0.3.1.tar.gz | 47.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| juplit-0.3.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 106.1 kB
Release files / juplit-0.3.1.tar.gz
| Download URL | juplit-0.3.1.tar.gz |
|---|---|
| Size | 47.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4307b83ef18094fb51b825335ff1b2571075763c0a2aedab4549e7fd9f69f1e8
|
|
BLAKE2b-256 checksum How to use checksums |
328005fc4d1b2b39dd9c01be1c91508e1f79109f6922ccefc30e7cf88650935e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.17
|
Release files / juplit-0.3.1-py3-none-any.whl
| Download URL | juplit-0.3.1-py3-none-any.whl |
|---|---|
| Size | 58.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7775844519030639794cc41b3688a6a1b2bd7bddaf129a010c4f2a35e4c41364
|
|
BLAKE2b-256 checksum How to use checksums |
93e07ab1e672ab388110bbdaea43c8b498601ea3bea8d226f94588e35da3078c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.17
|