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 ADDED
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.3
2
+ Name: juplit
3
+ Version: 0.0.1
4
+ Summary: Jupytext percent-format notebook workflow manager
5
+ Requires-Dist: jupytext>=1.16.0
6
+ Requires-Dist: cyclopts>=2.0.0
7
+ Requires-Python: >=3.12
@@ -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,4 @@
1
+ from juplit.tasks import clean_notebooks, generate_notebooks, sync_notebooks
2
+ from juplit.testing import test
3
+
4
+ __all__ = ["sync_notebooks", "generate_notebooks", "clean_notebooks", "test"]
@@ -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_*"]