juplit 0.0.1__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.
- juplit-0.0.1/PKG-INFO +7 -0
- juplit-0.0.1/juplit/SKILL.md +181 -0
- juplit-0.0.1/juplit/SKILL_migrate_from_nbdev.md +141 -0
- juplit-0.0.1/juplit/__init__.py +4 -0
- juplit-0.0.1/juplit/_dev.py +15 -0
- juplit-0.0.1/juplit/cli.py +56 -0
- juplit-0.0.1/juplit/tasks.py +239 -0
- juplit-0.0.1/juplit/testing.py +27 -0
- juplit-0.0.1/pyproject.toml +69 -0
juplit-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# Juplit: Literate Programming Workflow for Python Projects
|
|
2
|
+
|
|
3
|
+
## What juplit is for
|
|
4
|
+
|
|
5
|
+
juplit lets you do **literate programming** — writing code alongside explanations, examples, and tests in Jupyter notebook cells — while keeping your repository clean and AI-agent-friendly.
|
|
6
|
+
|
|
7
|
+
The problem it solves:
|
|
8
|
+
- Jupyter `.ipynb` files are JSON blobs: hard to diff in git, noisy in PRs, and token-heavy when AI agents need to read them
|
|
9
|
+
- But interactive notebook development is genuinely useful: you can run cells incrementally, see outputs inline, and mix prose with code
|
|
10
|
+
|
|
11
|
+
**juplit's solution**: `.py` files in jupytext percent format are the source of truth. `.ipynb` files are generated on demand for interactive use and are gitignored. AI agents and humans read `.py` files; Jupyter reads `.ipynb` files.
|
|
12
|
+
|
|
13
|
+
## File format
|
|
14
|
+
|
|
15
|
+
Every paired notebook `.py` file starts with a jupytext header:
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
# ---
|
|
19
|
+
# jupyter:
|
|
20
|
+
# jupytext:
|
|
21
|
+
# formats: ipynb,py:percent
|
|
22
|
+
# text_representation:
|
|
23
|
+
# extension: .py
|
|
24
|
+
# format_name: percent
|
|
25
|
+
# format_version: '1.3'
|
|
26
|
+
# jupytext_version: 1.16.0
|
|
27
|
+
# kernelspec:
|
|
28
|
+
# display_name: Python 3
|
|
29
|
+
# language: python
|
|
30
|
+
# name: python3
|
|
31
|
+
# ---
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The key line is `formats: ipynb,py:percent` — this is what marks a file as a paired notebook.
|
|
35
|
+
|
|
36
|
+
### Cell delimiters
|
|
37
|
+
|
|
38
|
+
| Syntax | Meaning |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `# %%` | Code cell |
|
|
41
|
+
| `# %% [markdown]` | Markdown cell (content is `#`-prefixed comments) |
|
|
42
|
+
|
|
43
|
+
### Markdown cells
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# %% [markdown]
|
|
47
|
+
# # Module Title
|
|
48
|
+
#
|
|
49
|
+
# Description of what this module does.
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Separating logic from tests with `test()`
|
|
53
|
+
|
|
54
|
+
Import `test` from juplit to gate test code so it runs interactively and under pytest, but **never on import**:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from juplit import test
|
|
58
|
+
|
|
59
|
+
# %%
|
|
60
|
+
def add(a: int, b: int) -> int:
|
|
61
|
+
return a + b
|
|
62
|
+
|
|
63
|
+
# %%
|
|
64
|
+
if test():
|
|
65
|
+
assert add(1, 2) == 3
|
|
66
|
+
assert add(-1, 1) == 0
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`test()` returns `True` when:
|
|
70
|
+
- The module is run as `__main__` (interactive Jupyter cell execution)
|
|
71
|
+
- `pytest` is active
|
|
72
|
+
|
|
73
|
+
It returns `False` on normal import, so test code never runs in production.
|
|
74
|
+
|
|
75
|
+
## Poe commands
|
|
76
|
+
|
|
77
|
+
| Command | What it does |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `poe sync` | Sync `.py` ↔ `.ipynb` — run after cloning and after editing `.py` files |
|
|
80
|
+
| `poe clean` | Sync then delete all `.ipynb` files — use before AI agent sessions |
|
|
81
|
+
| `poe init` | Install git pre-commit hooks |
|
|
82
|
+
| `poe test` | Run pytest across all `.py` files |
|
|
83
|
+
| `poe docs` | Sync notebooks then serve docs locally for preview |
|
|
84
|
+
| `poe docs-deploy` | Sync notebooks then deploy docs to GitHub Pages |
|
|
85
|
+
|
|
86
|
+
## Workflow
|
|
87
|
+
|
|
88
|
+
### First-time setup after cloning
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
uv sync # install dependencies
|
|
92
|
+
poe init # install git hooks (includes juplit sync on commit)
|
|
93
|
+
poe sync # generate .ipynb notebooks from .py files
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Editing code (as an AI agent or in an editor)
|
|
97
|
+
|
|
98
|
+
1. Edit the `.py` file directly
|
|
99
|
+
2. Run `poe sync` to propagate changes to `.ipynb`
|
|
100
|
+
3. Commit the `.py` file — `.ipynb` is gitignored
|
|
101
|
+
|
|
102
|
+
### Before handing off to an AI agent
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
poe clean # removes all .ipynb files so agents only see .py files
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Creating a new paired notebook file
|
|
109
|
+
|
|
110
|
+
1. Create a `.py` file with the jupytext header (copy from an existing file)
|
|
111
|
+
2. Add cells using `# %%` and `# %% [markdown]` delimiters
|
|
112
|
+
3. Run `poe sync` to generate the paired `.ipynb`
|
|
113
|
+
|
|
114
|
+
Minimal template:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
# ---
|
|
118
|
+
# jupyter:
|
|
119
|
+
# jupytext:
|
|
120
|
+
# formats: ipynb,py:percent
|
|
121
|
+
# text_representation:
|
|
122
|
+
# extension: .py
|
|
123
|
+
# format_name: percent
|
|
124
|
+
# format_version: '1.3'
|
|
125
|
+
# jupytext_version: 1.16.0
|
|
126
|
+
# ---
|
|
127
|
+
|
|
128
|
+
# %% [markdown]
|
|
129
|
+
# # Module Name
|
|
130
|
+
#
|
|
131
|
+
# Brief description.
|
|
132
|
+
|
|
133
|
+
# %%
|
|
134
|
+
from juplit import test
|
|
135
|
+
|
|
136
|
+
# %%
|
|
137
|
+
def my_function(x):
|
|
138
|
+
return x
|
|
139
|
+
|
|
140
|
+
# %%
|
|
141
|
+
if test():
|
|
142
|
+
assert my_function(1) == 1
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Configuration in pyproject.toml
|
|
146
|
+
|
|
147
|
+
```toml
|
|
148
|
+
[project]
|
|
149
|
+
dependencies = ["juplit>=0.1.0"]
|
|
150
|
+
|
|
151
|
+
[dependency-groups]
|
|
152
|
+
dev = ["poethepoet>=0.25.0", "pytest>=8.0.0", "ipykernel>=6.0.0", "pre-commit>=3.0.0"]
|
|
153
|
+
|
|
154
|
+
[tool.poe.tasks]
|
|
155
|
+
init = {cmd = "pre-commit install"}
|
|
156
|
+
sync = {cmd = "juplit sync"}
|
|
157
|
+
clean = {cmd = "juplit clean"}
|
|
158
|
+
test = {cmd = "pytest"}
|
|
159
|
+
docs = {sequence = [{ref = "sync"}, {cmd = "mkdocs serve"}]}
|
|
160
|
+
docs-deploy = {sequence = [{ref = "sync"}, {cmd = "mkdocs gh-deploy --force"}]}
|
|
161
|
+
|
|
162
|
+
[tool.juplit]
|
|
163
|
+
notebook_src_dirs = ["your_module_name", "docs"] # dirs scanned for paired .py files
|
|
164
|
+
|
|
165
|
+
[tool.jupytext]
|
|
166
|
+
formats = "ipynb,py:percent"
|
|
167
|
+
|
|
168
|
+
[tool.pytest.ini_options]
|
|
169
|
+
python_files = ["*.py"]
|
|
170
|
+
python_classes = ["Test*"]
|
|
171
|
+
python_functions = ["test_*"]
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Key conventions
|
|
175
|
+
|
|
176
|
+
- **Edit `.py` files only** — `.ipynb` is generated, never manually edited
|
|
177
|
+
- **One logical idea per cell** — keep cells small and focused
|
|
178
|
+
- **Gate all test code with `if test():`** — never let test side effects run on import
|
|
179
|
+
- **Markdown goes in `# %% [markdown]` cells** using `#`-prefixed comment lines
|
|
180
|
+
- **`_tasks.py` itself** uses `formats: py:percent` (no `ipynb` pairing) since it is a pure utility, not a notebook
|
|
181
|
+
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Migrating from nbdev to juplit
|
|
2
|
+
|
|
3
|
+
This guide describes how to migrate a project that uses nbdev (Jupyter-based literate programming with `#|export` directives) to juplit's percent-format workflow.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
nbdev notebooks use special directives inside cells to control what gets exported:
|
|
8
|
+
- `#|export` — cell is exported to the Python module
|
|
9
|
+
- `#|hide` — cell is hidden in docs (usually tests/setup)
|
|
10
|
+
- No directive — cell is shown in docs but not exported (usually examples/tests)
|
|
11
|
+
|
|
12
|
+
In juplit, **all cells are regular Python** and everything in the file is importable. Test/example code is gated with `if test():` instead of being in non-exported cells.
|
|
13
|
+
|
|
14
|
+
## Migration steps
|
|
15
|
+
|
|
16
|
+
### 1. Initialize a new juplit project with cookiecutter
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install cookiecutter juplit
|
|
20
|
+
cookiecutter gh:DeanLight/juplit_template
|
|
21
|
+
cd <new_project_slug>
|
|
22
|
+
uv sync
|
|
23
|
+
poe init
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### 2. For each nbdev notebook, create a paired .py file
|
|
27
|
+
|
|
28
|
+
For each `.ipynb` in the nbdev `nbs/` directory, create a corresponding `.py` file in the new module directory. Use this conversion rule for each cell:
|
|
29
|
+
|
|
30
|
+
**Markdown cells** → `# %% [markdown]` cell (keep content as `#`-prefixed comments):
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
# %% [markdown]
|
|
34
|
+
# # Module Title
|
|
35
|
+
#
|
|
36
|
+
# Cell content here.
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Code cells with `#|export`** → regular `# %%` code cell (strip the directive):
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
# nbdev: → # juplit:
|
|
43
|
+
# #|export # %%
|
|
44
|
+
# def my_func(x): def my_func(x):
|
|
45
|
+
# return x return x
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Code cells without `#|export`** (examples, tests, `#|hide` cells) → `# %%` cell wrapped in `if test():`
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
# nbdev (no #|export): → # juplit:
|
|
52
|
+
# assert my_func(1) == 1 # %%
|
|
53
|
+
# if test():
|
|
54
|
+
# assert my_func(1) == 1
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 3. File header
|
|
58
|
+
|
|
59
|
+
Every converted file needs the jupytext header at the top, followed by the `test` import:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
# ---
|
|
63
|
+
# jupyter:
|
|
64
|
+
# jupytext:
|
|
65
|
+
# formats: ipynb,py:percent
|
|
66
|
+
# text_representation:
|
|
67
|
+
# extension: .py
|
|
68
|
+
# format_name: percent
|
|
69
|
+
# format_version: '1.3'
|
|
70
|
+
# jupytext_version: 1.16.0
|
|
71
|
+
# kernelspec:
|
|
72
|
+
# display_name: Python 3
|
|
73
|
+
# language: python
|
|
74
|
+
# name: python3
|
|
75
|
+
# ---
|
|
76
|
+
|
|
77
|
+
# %%
|
|
78
|
+
from juplit import test
|
|
79
|
+
# (other imports your module needs)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 4. Generate notebooks and verify
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
poe sync # generates .ipynb from .py files
|
|
86
|
+
poe test # run tests to verify nothing broke
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 5. Update imports in the rest of the project
|
|
90
|
+
|
|
91
|
+
nbdev exports from a generated module path. After migration, the module is the `.py` files directly. Update any `from nbs.xx_module import ...` to `from your_module import ...`.
|
|
92
|
+
|
|
93
|
+
## Example conversion
|
|
94
|
+
|
|
95
|
+
**Before (nbdev `nbs/00_core.ipynb`):**
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
# Cell 1 — markdown
|
|
99
|
+
# # Core module
|
|
100
|
+
|
|
101
|
+
# Cell 2 — #|export
|
|
102
|
+
# #|export
|
|
103
|
+
# def add(a, b):
|
|
104
|
+
# return a + b
|
|
105
|
+
|
|
106
|
+
# Cell 3 — no directive (test shown in docs)
|
|
107
|
+
# assert add(1, 2) == 3
|
|
108
|
+
|
|
109
|
+
# Cell 4 — #|hide (hidden test)
|
|
110
|
+
# #|hide
|
|
111
|
+
# assert add(-1, 1) == 0
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**After (juplit `your_module/core.py`):**
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
# ---
|
|
118
|
+
# jupyter:
|
|
119
|
+
# jupytext:
|
|
120
|
+
# formats: ipynb,py:percent
|
|
121
|
+
# ...
|
|
122
|
+
# ---
|
|
123
|
+
|
|
124
|
+
# %% [markdown]
|
|
125
|
+
# # Core module
|
|
126
|
+
|
|
127
|
+
# %%
|
|
128
|
+
from juplit import test
|
|
129
|
+
|
|
130
|
+
# %%
|
|
131
|
+
def add(a, b):
|
|
132
|
+
return a + b
|
|
133
|
+
|
|
134
|
+
# %%
|
|
135
|
+
if test():
|
|
136
|
+
assert add(1, 2) == 3
|
|
137
|
+
|
|
138
|
+
# %%
|
|
139
|
+
if test():
|
|
140
|
+
assert add(-1, 1) == 0
|
|
141
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Internal dev utilities for the juplit package itself."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def check_env(env_name: str) -> None:
|
|
8
|
+
"""Assert that an environment variable is set; exit with an error if not.
|
|
9
|
+
|
|
10
|
+
Used as a poe task guard before publishing to PyPI.
|
|
11
|
+
"""
|
|
12
|
+
if not os.environ.get(env_name):
|
|
13
|
+
print(f"Error: environment variable {env_name!r} is not set.", file=sys.stderr)
|
|
14
|
+
print(f" Set it with: export {env_name}=<your-token>", file=sys.stderr)
|
|
15
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""juplit CLI — notebook workflow commands."""
|
|
2
|
+
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
|
|
5
|
+
import cyclopts
|
|
6
|
+
|
|
7
|
+
from juplit.tasks import clean_notebooks, generate_notebooks, sync_notebooks
|
|
8
|
+
|
|
9
|
+
app = cyclopts.App(
|
|
10
|
+
name="juplit",
|
|
11
|
+
help="Jupytext percent-format notebook workflow manager.",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command
|
|
16
|
+
def sync() -> None:
|
|
17
|
+
"""Sync .py <-> .ipynb for all paired percent-format notebooks."""
|
|
18
|
+
sync_notebooks()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command
|
|
22
|
+
def nb() -> None:
|
|
23
|
+
"""Generate .ipynb files from .py percent-format files (run after cloning)."""
|
|
24
|
+
generate_notebooks()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command
|
|
28
|
+
def clean() -> None:
|
|
29
|
+
"""Sync notebooks then delete all .ipynb files (keeps workspace clean for AI agents)."""
|
|
30
|
+
clean_notebooks()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command
|
|
34
|
+
def skill() -> None:
|
|
35
|
+
"""Print the juplit skill file for use with Claude Code.
|
|
36
|
+
|
|
37
|
+
Pipe the output into your project's .claude/skills/ directory:
|
|
38
|
+
|
|
39
|
+
juplit skill > .claude/skills/juplit-programming.md
|
|
40
|
+
"""
|
|
41
|
+
print(files("juplit").joinpath("SKILL.md").read_text(), end="")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command
|
|
45
|
+
def skill_migrate() -> None:
|
|
46
|
+
"""Print the nbdev-to-juplit migration skill file for use with Claude Code.
|
|
47
|
+
|
|
48
|
+
Pipe the output into your project's .claude/skills/ directory:
|
|
49
|
+
|
|
50
|
+
juplit skill-migrate > .claude/skills/juplit-migrate.md
|
|
51
|
+
"""
|
|
52
|
+
print(files("juplit").joinpath("SKILL_migrate_from_nbdev.md").read_text(), end="")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main() -> None:
|
|
56
|
+
app()
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Core notebook workflow tasks for juplit.
|
|
2
|
+
|
|
3
|
+
These functions back both the `poe` task targets and the `juplit` CLI commands.
|
|
4
|
+
They can also be imported and called directly from Python.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
import tomllib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _find_pyproject_toml() -> Path | None:
|
|
15
|
+
"""Walk up from cwd to find the nearest pyproject.toml."""
|
|
16
|
+
current = Path.cwd()
|
|
17
|
+
for parent in [current, *current.parents]:
|
|
18
|
+
candidate = parent / "pyproject.toml"
|
|
19
|
+
if candidate.exists():
|
|
20
|
+
return candidate
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_src_dirs() -> list[Path]:
|
|
25
|
+
"""Read notebook_src_dirs (or legacy notebook_src_dir) from [tool.juplit]."""
|
|
26
|
+
toml_path = _find_pyproject_toml()
|
|
27
|
+
root = toml_path.parent if toml_path is not None else Path.cwd()
|
|
28
|
+
if toml_path is not None:
|
|
29
|
+
try:
|
|
30
|
+
with open(toml_path, "rb") as f:
|
|
31
|
+
config = tomllib.load(f)
|
|
32
|
+
juplit_cfg = config.get("tool", {}).get("juplit", {})
|
|
33
|
+
dirs = juplit_cfg.get("notebook_src_dirs") or juplit_cfg.get("notebook_src_dir")
|
|
34
|
+
if dirs:
|
|
35
|
+
if isinstance(dirs, str):
|
|
36
|
+
dirs = [dirs]
|
|
37
|
+
return [root / d for d in dirs]
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
40
|
+
return [root / "src"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_paired_notebook(path: Path) -> bool:
|
|
44
|
+
"""Return True if the file is a percent-format notebook paired with an ipynb.
|
|
45
|
+
|
|
46
|
+
Checks the jupytext header for both 'ipynb' and 'py:percent' in the formats
|
|
47
|
+
line, so plain py files and non-paired notebooks are excluded.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
content = path.read_text()
|
|
51
|
+
except OSError:
|
|
52
|
+
return False
|
|
53
|
+
for line in content.splitlines():
|
|
54
|
+
if not line.startswith("#"):
|
|
55
|
+
break
|
|
56
|
+
stripped = line.lstrip("# ").strip()
|
|
57
|
+
if stripped.startswith("formats:"):
|
|
58
|
+
formats = stripped[len("formats:"):].strip()
|
|
59
|
+
return "ipynb" in formats and "py:percent" in formats
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _find_py_files() -> list[Path]:
|
|
64
|
+
result = []
|
|
65
|
+
for src_dir in _get_src_dirs():
|
|
66
|
+
if src_dir.exists():
|
|
67
|
+
result.extend(src_dir.rglob("*.py"))
|
|
68
|
+
return sorted(result)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _find_percent_notebook_py_files() -> list[Path]:
|
|
72
|
+
return [f for f in _find_py_files() if _is_paired_notebook(f)]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fmt(label: str, names: list[str]) -> str:
|
|
76
|
+
return f"{len(names)} {label}: {', '.join(names)}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Hash-based change tracking ────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def _hash_file(path: Path) -> str:
|
|
82
|
+
try:
|
|
83
|
+
return hashlib.md5(path.read_bytes()).hexdigest()
|
|
84
|
+
except OSError:
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _state_path() -> Path:
|
|
89
|
+
toml_path = _find_pyproject_toml()
|
|
90
|
+
root = toml_path.parent if toml_path is not None else Path.cwd()
|
|
91
|
+
return root / ".sync_hashes.json"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _load_hashes() -> dict[str, str]:
|
|
95
|
+
p = _state_path()
|
|
96
|
+
if p.exists():
|
|
97
|
+
try:
|
|
98
|
+
return json.loads(p.read_text())
|
|
99
|
+
except Exception:
|
|
100
|
+
return {}
|
|
101
|
+
return {}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _save_hashes(files: list[Path]) -> None:
|
|
105
|
+
state = {f.name: _hash_file(f) for f in files if f.exists()}
|
|
106
|
+
_state_path().write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── Jupytext runner ───────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def _run_jupytext(args: list[str], files: list[Path]) -> tuple[dict[str, list[str]], list[str]]:
|
|
112
|
+
"""Run jupytext and classify files by comparing hashes against the last sync.
|
|
113
|
+
|
|
114
|
+
Returns (groups, errors) where groups has keys: updated, unchanged, skipped.
|
|
115
|
+
"""
|
|
116
|
+
prev_hashes = _load_hashes()
|
|
117
|
+
|
|
118
|
+
result = subprocess.run(
|
|
119
|
+
["jupytext"] + args + [str(f) for f in files],
|
|
120
|
+
capture_output=True,
|
|
121
|
+
text=True,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
skipped_names: set[str] = set()
|
|
125
|
+
errors: list[str] = []
|
|
126
|
+
|
|
127
|
+
for line in result.stderr.splitlines():
|
|
128
|
+
low = line.lower()
|
|
129
|
+
if "warning" in low and "not a paired" in low:
|
|
130
|
+
words = line.split()
|
|
131
|
+
try:
|
|
132
|
+
idx = next(i for i, w in enumerate(words) if w == "Warning:")
|
|
133
|
+
skipped_names.add(Path(words[idx + 1]).name)
|
|
134
|
+
except (StopIteration, IndexError):
|
|
135
|
+
pass
|
|
136
|
+
elif "error" in low:
|
|
137
|
+
errors.append(line)
|
|
138
|
+
|
|
139
|
+
if result.returncode != 0 and not errors:
|
|
140
|
+
errors.append(result.stderr.strip() or f"jupytext exited with code {result.returncode}")
|
|
141
|
+
|
|
142
|
+
updated: list[str] = []
|
|
143
|
+
unchanged: list[str] = []
|
|
144
|
+
skipped: list[str] = []
|
|
145
|
+
|
|
146
|
+
for f in files:
|
|
147
|
+
if f.name in skipped_names:
|
|
148
|
+
skipped.append(f.name)
|
|
149
|
+
elif _hash_file(f) != prev_hashes.get(f.name, ""):
|
|
150
|
+
updated.append(f.name)
|
|
151
|
+
else:
|
|
152
|
+
unchanged.append(f.name)
|
|
153
|
+
|
|
154
|
+
_save_hashes(files)
|
|
155
|
+
return {"updated": updated, "unchanged": unchanged, "skipped": skipped}, errors
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── Public tasks ─────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
def sync_notebooks() -> None:
|
|
161
|
+
"""Sync `.py` and `.ipynb` files for all paired percent-format notebooks.
|
|
162
|
+
|
|
163
|
+
Walks the configured `notebook_src_dirs` (from `[tool.juplit]` in
|
|
164
|
+
`pyproject.toml`) and calls `jupytext --sync` on every `.py` file that has
|
|
165
|
+
a jupytext percent-format header pairing it with an `.ipynb`.
|
|
166
|
+
|
|
167
|
+
Prints a summary of updated, unchanged, and skipped files.
|
|
168
|
+
Raises `SystemExit(1)` if jupytext reports any errors.
|
|
169
|
+
"""
|
|
170
|
+
files = _find_percent_notebook_py_files()
|
|
171
|
+
if not files:
|
|
172
|
+
print("No percent notebook .py files found.")
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
groups, errors = _run_jupytext(["--sync"], files)
|
|
176
|
+
if groups["updated"]:
|
|
177
|
+
print(_fmt("sync updated", groups["updated"]))
|
|
178
|
+
if groups["unchanged"]:
|
|
179
|
+
print(_fmt("sync unchanged", groups["unchanged"]))
|
|
180
|
+
if groups["skipped"]:
|
|
181
|
+
print(_fmt("sync skipped (not paired)", groups["skipped"]))
|
|
182
|
+
if not any(groups.values()):
|
|
183
|
+
print("Sync: nothing to do")
|
|
184
|
+
for err in errors:
|
|
185
|
+
print(f"sync error: {err}")
|
|
186
|
+
if errors:
|
|
187
|
+
raise SystemExit(1)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def generate_notebooks() -> None:
|
|
191
|
+
"""Generate `.ipynb` files from `.py` percent-format files.
|
|
192
|
+
|
|
193
|
+
Calls `jupytext --to notebook` on every paired `.py` file found in the
|
|
194
|
+
configured `notebook_src_dirs`. Use this after cloning a repo where only
|
|
195
|
+
the `.py` sources are committed.
|
|
196
|
+
|
|
197
|
+
Prints a summary of created/updated, unchanged, and skipped files.
|
|
198
|
+
Raises `SystemExit(1)` if jupytext reports any errors.
|
|
199
|
+
"""
|
|
200
|
+
files = _find_percent_notebook_py_files()
|
|
201
|
+
if not files:
|
|
202
|
+
print("No percent notebook .py files found.")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
groups, errors = _run_jupytext(["--to", "notebook"], files)
|
|
206
|
+
if groups["updated"]:
|
|
207
|
+
print(_fmt("nb created/updated", groups["updated"]))
|
|
208
|
+
if groups["unchanged"]:
|
|
209
|
+
print(_fmt("nb unchanged", groups["unchanged"]))
|
|
210
|
+
if groups["skipped"]:
|
|
211
|
+
print(_fmt("nb skipped", groups["skipped"]))
|
|
212
|
+
if not any(groups.values()):
|
|
213
|
+
print("Notebooks: nothing to do")
|
|
214
|
+
for err in errors:
|
|
215
|
+
print(f"nb error: {err}")
|
|
216
|
+
if errors:
|
|
217
|
+
raise SystemExit(1)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def clean_notebooks() -> None:
|
|
221
|
+
"""Sync then delete all `.ipynb` files from the source directories.
|
|
222
|
+
|
|
223
|
+
First calls `sync_notebooks()` to flush any unsaved changes from the
|
|
224
|
+
`.ipynb` files back into their paired `.py` sources, then removes every
|
|
225
|
+
`.ipynb` found under `notebook_src_dirs`. Keeps the working directory
|
|
226
|
+
clean for AI agents and CI environments that only need the `.py` sources.
|
|
227
|
+
|
|
228
|
+
Prints a summary of removed files.
|
|
229
|
+
"""
|
|
230
|
+
sync_notebooks()
|
|
231
|
+
removed = []
|
|
232
|
+
for src_dir in _get_src_dirs():
|
|
233
|
+
for f in src_dir.rglob("*.ipynb"):
|
|
234
|
+
removed.append(f.name)
|
|
235
|
+
f.unlink()
|
|
236
|
+
if removed:
|
|
237
|
+
print(_fmt("clean removed", sorted(removed)))
|
|
238
|
+
else:
|
|
239
|
+
print("clean: nothing to remove")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""test() helper — separates exportable logic from inline tests."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test() -> bool:
|
|
7
|
+
"""Return True when the calling module is run directly or under pytest.
|
|
8
|
+
|
|
9
|
+
Use this to gate test code in percent-format notebook files so that tests
|
|
10
|
+
run interactively (in Jupyter) and under pytest, but never on import.
|
|
11
|
+
|
|
12
|
+
Example::
|
|
13
|
+
|
|
14
|
+
# %%
|
|
15
|
+
from juplit import test
|
|
16
|
+
|
|
17
|
+
# %%
|
|
18
|
+
def add(a, b):
|
|
19
|
+
return a + b
|
|
20
|
+
|
|
21
|
+
# %%
|
|
22
|
+
if test():
|
|
23
|
+
assert add(1, 2) == 3
|
|
24
|
+
"""
|
|
25
|
+
frame = sys._getframe(1)
|
|
26
|
+
caller_name = frame.f_globals.get("__name__", "")
|
|
27
|
+
return caller_name == "__main__" or "pytest" in sys.modules
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "juplit"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Jupytext percent-format notebook workflow manager"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"jupytext>=1.16.0",
|
|
8
|
+
"cyclopts>=2.0.0",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
juplit = "juplit.cli:main"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["uv_build>=0.9.21,<0.10.0"]
|
|
16
|
+
build-backend = "uv_build"
|
|
17
|
+
|
|
18
|
+
[tool.uv.build-backend]
|
|
19
|
+
module-root = ""
|
|
20
|
+
|
|
21
|
+
[tool.uv.build-backend.wheel]
|
|
22
|
+
include = ["juplit/SKILL.md", "juplit/SKILL_migrate_from_nbdev.md"]
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"poethepoet>=0.25.0",
|
|
27
|
+
"pytest>=8.0.0",
|
|
28
|
+
"ipykernel>=6.0.0",
|
|
29
|
+
"pre-commit>=3.0.0",
|
|
30
|
+
"mkdocs>=1.5.0",
|
|
31
|
+
"mkdocs-material>=9.0.0",
|
|
32
|
+
"mkdocs-jupyter>=0.24.0",
|
|
33
|
+
"mkdocstrings[python]>=0.24.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.poe.tasks]
|
|
37
|
+
init = {cmd = "pre-commit install", help = "Install git hooks"}
|
|
38
|
+
sync = {script = "juplit.tasks:sync_notebooks", help = "Sync .py<->ipynb for all paired notebooks"}
|
|
39
|
+
nb = {script = "juplit.tasks:generate_notebooks", help = "Generate .ipynb from .py files (run after cloning)"}
|
|
40
|
+
clean = {script = "juplit.tasks:clean_notebooks", help = "Sync then delete all .ipynb files"}
|
|
41
|
+
test = {cmd = "pytest", help = "Run tests"}
|
|
42
|
+
docs = {sequence = [{ref = "sync"}, {cmd = "mkdocs serve"}], help = "Sync notebooks then serve docs locally for preview"}
|
|
43
|
+
docs-build = {sequence = [{ref = "sync"}, {cmd = "mkdocs build"}], help = "Sync notebooks then build docs site"}
|
|
44
|
+
docs-deploy = {sequence = [{ref = "sync"}, {cmd = "mkdocs gh-deploy --force"}], help = "Sync notebooks then deploy docs to GitHub Pages"}
|
|
45
|
+
|
|
46
|
+
[tool.poe.tasks.pypi]
|
|
47
|
+
help = "Build the package and publish to PyPI"
|
|
48
|
+
sequence = [
|
|
49
|
+
{ref = "_check_env UV_PUBLISH_TOKEN"},
|
|
50
|
+
{cmd = "rm -rf dist/*"},
|
|
51
|
+
{cmd = "uv build"},
|
|
52
|
+
{cmd = "uv publish"},
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.poe.tasks._check_env]
|
|
56
|
+
help = "Assert an env var is set before proceeding"
|
|
57
|
+
script = "juplit._dev:check_env"
|
|
58
|
+
args = [{name = "env_name", positional = true}]
|
|
59
|
+
|
|
60
|
+
[tool.juplit]
|
|
61
|
+
notebook_src_dirs = ["juplit", "docs"]
|
|
62
|
+
|
|
63
|
+
[tool.jupytext]
|
|
64
|
+
formats = "ipynb,py:percent"
|
|
65
|
+
|
|
66
|
+
[tool.pytest.ini_options]
|
|
67
|
+
python_files = ["*.py"]
|
|
68
|
+
python_classes = ["Test*"]
|
|
69
|
+
python_functions = ["test_*"]
|