Using juplit¶
juplit lets you do literate programming in Python — writing code, tests, and explanations together in Jupyter notebooks — while keeping your repository clean and readable for both humans and AI agents.
This notebook walks through the core workflow end to end.
Installation¶
Add juplit to your project with uv (recommended) or pip:
uv add juplit
# or
pip install juplit
For a new project, use the cookiecutter template to get the full scaffolding:
pip install cookiecutter juplit
cookiecutter gh:DeanLight/juplit_template
cd <new_project_slug>
uv sync
poe init # install git pre-commit hooks
poe sync # generate .ipynb files from .py sources
The paired file format¶
Every juplit source file is a .py file in jupytext percent format that pairs
with a .ipynb notebook. The .py file is committed; the .ipynb is generated
on demand and gitignored.
A minimal paired file looks like this:
# ---
# jupyter:
# jupytext:
# formats: ipynb,py:percent
# ---
# %% [markdown]
# # My Module
# %%
from juplit import test
# %%
def greet(name: str) -> str:
return f"Hello, {name}!"
# %%
if test():
assert greet("world") == "Hello, world!"
The key line is formats: ipynb,py:percent — it marks the file as paired.
The test() guard¶
Import test from juplit to gate code that should run interactively and under
pytest, but never on plain 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 passed")
test() returns True when:
- Running as
__main__(interactive Jupyter cell execution) pytestis active
It returns False on normal import — so test assertions never run in production.
Using def test_* functions with test() scaffolding¶
You can also use standard pytest-style functions. Use if test(): blocks to
set up shared fixtures at module level — they run during pytest collection,
so the variables are in scope when the test functions execute:
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
The if test(): block scaffolds the test data; test_compute is a normal
pytest-collected function that uses it.
poe commands¶
| Command | What it does |
|---|---|
poe sync |
Sync .py ↔ .ipynb — run after editing .py files |
poe nb |
Generate .ipynb from .py — run after cloning |
poe clean |
Sync then delete all .ipynb files — use before AI sessions |
poe test |
Run pytest across all .py files |
poe docs |
Sync notebooks then serve docs locally |
poe docs-deploy |
Sync notebooks then deploy to GitHub Pages |
Day-to-day workflow¶
After cloning¶
uv sync # install dependencies
poe init # install git pre-commit hooks
poe nb # generate .ipynb from .py sources
Editing code¶
- Open the
.pyfile in your editor (or open the paired.ipynbin Jupyter) - Make your changes
- Run
poe syncto keep.pyand.ipynbin step - Commit the
.pyfile —.ipynbis gitignored
Before handing off to an AI agent¶
poe clean # removes .ipynb so the agent only sees .py files
AI agents work with .py files directly — they're plain text, diff cleanly,
and don't carry notebook metadata noise.
pyproject.toml configuration¶
[tool.juplit]
notebook_src_dirs = ["your_module", "docs"] # dirs scanned for paired .py files
[tool.jupytext]
formats = "ipynb,py:percent"
[tool.pytest.ini_options]
python_files = ["*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
Using the skill with Claude Code¶
juplit ships skill files that teach Claude Code the workflow. Install them once per project:
mkdir -p .claude/skills
juplit skill > .claude/skills/juplit-programming.md
juplit skill-migrate > .claude/skills/juplit-migrate.md
After that, Claude Code will automatically follow the juplit conventions when creating or editing paired notebooks in your project.