├── .github
└── workflows
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── .python-version
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── assets
├── logo.ai
└── logo.svg
├── pyproject.toml
├── src
└── juv
│ ├── __init__.py
│ ├── _add.py
│ ├── _cat.py
│ ├── _clear.py
│ ├── _edit.py
│ ├── _exec.py
│ ├── _export.py
│ ├── _init.py
│ ├── _lock.py
│ ├── _nbutils.py
│ ├── _pep723.py
│ ├── _remove.py
│ ├── _run.py
│ ├── _run_managed.py
│ ├── _run_replace.py
│ ├── _run_template.py
│ ├── _stamp.py
│ ├── _tree.py
│ ├── _utils.py
│ ├── _uv.py
│ ├── _venv.py
│ ├── _version.py
│ ├── py.typed
│ └── static
│ └── setup.py
├── tests
├── test_e2e.py
└── test_juv.py
└── uv.lock
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 |
13 | Lint:
14 | runs-on: macos-14
15 | steps:
16 | - uses: actions/checkout@v4
17 | - uses: astral-sh/setup-uv@v5
18 | with:
19 | version: "0.6.x"
20 | - run: |
21 | uv run ruff format --check
22 | uv run ruff check
23 |
24 | Test:
25 | name: Python ${{ matrix.python-version }} / ${{ matrix.os }}
26 | runs-on: ${{ matrix.os }}
27 | strategy:
28 | fail-fast: false
29 | matrix:
30 | os:
31 | - ubuntu-latest
32 | - macos-latest
33 | - windows-latest
34 | python-version:
35 | - "3.8"
36 | - "3.9"
37 | - "3.10"
38 | - "3.11"
39 | - "3.12"
40 | - "3.13"
41 | steps:
42 | - uses: actions/checkout@v4
43 | - uses: astral-sh/setup-uv@v5
44 | with:
45 | version: "0.6.x"
46 | - run: uv run pytest
47 | env:
48 | UV_PYTHON: ${{ matrix.python-version }}
49 |
50 | e2e:
51 | name: Test e2e / ${{ matrix.os }}
52 | runs-on: ${{ matrix.os }}
53 | if: false # disable for now
54 | strategy:
55 | fail-fast: false
56 | matrix:
57 | os:
58 | - ubuntu-latest
59 | - macos-latest
60 | - windows-latest
61 | steps:
62 | - uses: actions/checkout@v4
63 | - uses: astral-sh/setup-uv@v5
64 | with:
65 | version: "0.6.x"
66 |
67 | - run: |
68 | uv run --all-groups playwright install chromium
69 |
70 | - run: |
71 | uv run --all-groups pytest -rA --capture=no -vv
72 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | # This is a manual workflow for releasing new version of the package.
4 | #
5 | # It will:
6 | # - Build the distribution
7 | # - Publish it to PyPI
8 | # - Create a new release on GitHub
9 | #
10 | # The version (and tag) of the release is determined by the version in the
11 | # `pyproject.toml` file.
12 | on:
13 | workflow_dispatch:
14 |
15 | jobs:
16 | release:
17 | runs-on: ubuntu-latest
18 |
19 | permissions:
20 | contents: write # Required for making the Release
21 | id-token: write # Required for PyPI Trusted Publishing
22 |
23 | steps:
24 | - uses: actions/checkout@v4
25 | - uses: astral-sh/setup-uv@v5
26 | with:
27 | version: "0.6.x"
28 |
29 | - name: Build distribution
30 | run: uv build
31 |
32 | - name: Publish to PyPI
33 | uses: pypa/gh-action-pypi-publish@release/v1
34 | with:
35 | skip-existing: true
36 | verbose: true
37 |
38 | - name: Create GitHub Release
39 | env:
40 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
41 | run: |
42 | VERSION=$(uv run --quiet juv version --output-format=json | jq -r '.version')
43 | gh release create v$VERSION
44 | git fetch --tags
45 | awk '/^## / {if (p++) exit} p && NR > 2' CHANGELOG.md > release-notes.md
46 | gh release edit v$VERSION --notes-file release-notes.md
47 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .ipynb_checkpoints
2 | __pycache__
3 | .cache
4 | .venv
5 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.13
2 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 0.4.2
2 |
3 | ### Enhancements
4 |
5 | - Correctly resolve Jupyter data/config dirs on Windows ([#89](https://github.com/manzt/juv/pull/89))
6 |
7 | ### Other changes
8 |
9 | - Move `juv run` Jupyter setup logic to static module ([#88](https://github.com/manzt/juv/pull/88))
10 |
11 | ### Contributors
12 | - [@manzt](https://github.com/manzt)
13 |
14 | ## 0.4.1
15 |
16 | ### Bug fixes
17 |
18 | - Manually clean up temp file in `juv run` on Windows ([#87](https://github.com/manzt/juv/pull/87))
19 |
20 | ### Contributors
21 | - [@manzt](https://github.com/manzt)
22 |
23 | ## 0.4.0
24 |
25 | ### Release Notes
26 |
27 | This release is considered **breaking** since it sets a minimum bound on `uv`
28 | dependency to v0.6.7 or later. This could potentially affect environments where
29 | both `juv` and `uv` are Python dependencies, and there is an upper bound on the
30 | `uv` version (unlikely).
31 |
32 | There are no intentional breaking changes to `juv` commands.
33 |
34 | ### Enhancements
35 |
36 | - Add explicit `juv sync` command ([#84](https://github.com/manzt/juv/pull/84))
37 |
38 | ### Breaking changes
39 |
40 | - Replace `juv venv` internals with `uv sync --script` ([#84](https://github.com/manzt/juv/pull/84))
41 |
42 | ### Contributors
43 | - [@manzt](https://github.com/manzt)
44 |
45 | ## 0.3.4
46 |
47 | ### Bug fixes
48 |
49 | - Support stdin `--requirements` with `juv add` ([#83](https://github.com/manzt/juv/pull/83))
50 |
51 | ### Contributors
52 | - [@manzt](https://github.com/manzt)
53 |
54 | ## 0.3.3
55 |
56 | ### Enhancements
57 |
58 | - Extend `juv venv` regular python scripts as well ([#82](https://github.com/manzt/juv/pull/82))
59 |
60 | Allows for passing a script with inline script metadata to `juv venv`.
61 |
62 | ```sh
63 | uv init --script foo.py
64 | uv add --script foo.py attrs
65 | juv venv --from=foo.py
66 | # Using CPython 3.13.0
67 | # Creating virtual environment at: .venv
68 | # Activate with: source .venv/bin/activate
69 | # Using Python 3.13.0 environment at: .venv
70 | # Resolved 1 package in 0.62ms
71 | # Installed 1 package in 1ms
72 | # + attrs==25.1.0
73 | ```
74 |
75 | Useful for quickly creating a `.venv` for a standalone script, which can be used by other tools like text editors or IDEs.
76 |
77 | ### Contributors
78 | - [@manzt](https://github.com/manzt)
79 |
80 | ## 0.3.2
81 |
82 | ### Enhancements
83 |
84 | - Add `juv venv` to support exporting explicit notebook environments ([#80](https://github.com/manzt/juv/pull/80))
85 |
86 | Some editors and environments are missing the benefits of standalone notebooks because **juv** manages virtual environments transparently within `juv run`. To improve compatibility with other tools (e.g., editors & IDEs), this release adds `juv venv` to *export* a virtual environment with all a notebook's specified dependencies (and `ipykernel`):
87 |
88 | ```sh
89 | juv venv --from=Untitled.ipynb
90 | # Using CPython 3.13.0
91 | # Creating virtual environment at: .venv
92 | # Activate with: source .venv/bin/activate
93 | ```
94 |
95 | The resulting environment (i.e., `.venv`) can be selected in an editor like VS Code to run the notebook.
96 |
97 | To create a virtual environment with *only* the locked dependencies (i.e., without `ipykernel`), use the `--no-kernel` flag:
98 |
99 | ```sh
100 | juv venv --from=Untitled.ipynb --no-kernel
101 | ```
102 |
103 | ### Contributors
104 | - [@manzt](https://github.com/manzt)
105 |
106 | ## 0.3.1
107 |
108 | ### Enhancements
109 |
110 | - Add `--index` and `--default-index` flags to `juv add` ([#76](https://github.com/manzt/juv/pull/76))
111 |
112 | ### Contributors
113 | - [@manzt](https://github.com/manzt)
114 |
115 | ## 0.3.0
116 |
117 | ### Release Notes
118 |
119 | This release adds support for generating lockfiles from Jupyter notebooks using
120 | inline metadata, as defined in PEP 723.
121 |
122 | By default, notebooks remain unlocked. To lock a notebook, run `juv lock /path/to/notebook.ipynb`,
123 | which generates and embeds a lockfile in the notebook's metadata under the
124 | `"uv.lock"` key. The lockfile is respected and updated automatically when using
125 | `juv run`, `uv add`, or `uv remove`.
126 |
127 | Additional commands:
128 |
129 | - **`juv export`**: Outputs an alternative lockfile format (requirements.txt
130 | style) to stdout.
131 | - **`uv tree`**: Displays the dependency tree for a script.
132 |
133 | Both commands work with notebooks, whether locked or unlocked.
134 |
135 | This release is considered **breaking** due to the lockfile support, which
136 | requires a minimum `uv` 0.5.18 and modifies execution.
137 |
138 | ### Breaking changes
139 |
140 | - Upgrade minimum uv to v0.5 ([#63](https://github.com/manzt/juv/pull/63))
141 | - Respect lockfile in `run` ([#67](https://github.com/manzt/juv/pull/67))
142 |
143 | ### Enhancements
144 |
145 | - Add `--clear` flag to `lock` to clear lockfile metadata ([#69](https://github.com/manzt/juv/pull/69))
146 | - Add `export` command ([#70](https://github.com/manzt/juv/pull/70))
147 | - Add `lock` command ([#64](https://github.com/manzt/juv/pull/64))
148 | - Add `tree` command ([#68](https://github.com/manzt/juv/pull/68))
149 | - Sync lockfile during `add` command ([#65](https://github.com/manzt/juv/pull/65))
150 | - Sync lockfile during `remove` command ([#66](https://github.com/manzt/juv/pull/66))
151 |
152 | ### Bug fixes
153 |
154 | - Require at least one package for `add` and `remove` ([#73](https://github.com/manzt/juv/pull/73))
155 | - Support relative paths in the `run` command ([#72](https://github.com/manzt/juv/pull/72))
156 |
157 | ### Contributors
158 | - [@manzt](https://github.com/manzt)
159 |
160 | ## 0.2.28
161 |
162 | ### Release Notes
163 |
164 | This release adds `juv remove` to remove packages from a notebook or script.
165 | Dependencies are removed from the PEP-723 inline metadata. The command follows
166 | uv's semantics. See the [uv
167 | docs](https://docs.astral.sh/uv/reference/cli/#uv-remove) for more information.
168 |
169 | ```sh
170 | uvx juv init
171 | uvx juv add Untitled.ipynb 'numpy>=1.0.0' 'polars' # adds 'numpy>=1.0.0' 'polars'
172 | uvx juv remove Untitled.ipynb numpy # removes 'numpy>=1.0.0'
173 | ```
174 |
175 | ### Other changes
176 |
177 | - Add `remove` command ([#59](https://github.com/manzt/juv/pull/59))
178 |
179 | ### Contributors
180 | - [@manzt](https://github.com/manzt)
181 |
182 | ## 0.2.27
183 |
184 | ### Enhancements
185 |
186 | - Force UTF-8 encoding when reading/writing text ([#56](https://github.com/manzt/juv/pull/56))
187 |
188 | ### Bug fixes
189 |
190 | - Use TemporaryDirectoryIgnoreErrors in replacement template ([#57](https://github.com/manzt/juv/pull/57))
191 |
192 | ### Contributors
193 | - [@manzt](https://github.com/manzt)
194 |
195 | ## 0.2.26
196 |
197 | ### Enhancements
198 |
199 | - Support windows with `juv run` ([#54](https://github.com/manzt/juv/pull/54))
200 |
201 | ### Contributors
202 | - [@ATL2001](https://github.com/ATL2001)
203 |
204 | ## 0.2.25
205 |
206 | ### Enhancements
207 |
208 | - Bubble up uv errors from `juv add --pin` ([#52](https://github.com/manzt/juv/pull/52))
209 | - Add `kernelspec` metadata to new notebooks ([#53](https://github.com/manzt/juv/pull/53))
210 |
211 | ## 0.2.24
212 |
213 | ### Release Notes
214 |
215 | This release adds `--pin` flag to `juv add` to have package specifiers resolved to an exact version at the time of the command, and subsequently pinned in the notebook/script.
216 |
217 | ```sh
218 | uvx juv init
219 | uvx juv add Untitled.ipynb 'numpy>=1.0.0' 'polars' # adds 'numpy>=1' 'polars'
220 | uvx juv add Untitled.ipynb numpy polars --pin # adds 'numpy==2.1.3' 'polars==1.13.1'
221 | ```
222 |
223 | This same behavior can be achieved without juv for regular scripts with a unix pipe:
224 |
225 | ```sh
226 | echo 'numpy\npolars' | uv pip compile --no-deps - | grep '==' | xargs uv add --script foo.py
227 | ```
228 |
229 | But alternatively you can use `juv add` for the same thing:
230 |
231 | ```sh
232 | uv init --script foo.py
233 | uvx juv add foo.py numpy polars --pin
234 | ```
235 |
236 | ### Enhancements
237 |
238 | - Add support for regular Python script in `juv add` ([#51](https://github.com/manzt/juv/pull/51))
239 | - Add `--pin` flag for `juv add` ([#51](https://github.com/manzt/juv/pull/51))
240 |
241 | ## 0.2.23
242 |
243 | ### Release Notes
244 |
245 | `uv` supports time-based dependency resolution via [`exclude-newer`](https://simonwillison.net/2024/May/10/uv-pip-install-exclude-newer/),
246 | allowing packages to be resolved as they existed at a specific moment in time.
247 |
248 | This feature greatly enhances the reproducibility of one-off scripts and notebooks without needing a lockfile.
249 | However, `exclude-newer` requires a full RFC 3339 timestamp (e.g., 2020-03-05T00:00:00-05:00), which can be tedious to manage manually.
250 |
251 | This release introduces `juv stamp`, a command that provides a high-level,
252 | ergonomic API for pinning and unpinning various relevant timestamps in **both
253 | standalone Python scripts and Jupyter notebooks**:
254 |
255 | ```sh
256 | # Stamp a notebook
257 | juv init foo.ipynb
258 | juv stamp foo.ipynb
259 |
260 | # Stamp with a specific time
261 | juv stamp foo.ipynb --time "2020-03-05T00:00:00-05:00"
262 | juv stamp foo.ipynb --date 2022-01-03
263 |
264 | # Use Git revisions
265 | juv stamp foo.ipynb --rev e20c99
266 | juv stamp foo.ipynb --latest
267 |
268 | # Clear the pinned timestamp
269 | juv stamp foo.ipynb --clear
270 | ```
271 |
272 | ```sh
273 | # For Python scripts
274 | uv init --script foo.py
275 | uv add --script foo.py polars anywidget
276 | uvx juv stamp foo.py
277 | ```
278 |
279 | ### Enhancements
280 |
281 | - Add `juv stamp` for time-based dependency resolution pinning ([#50](https://github.com/manzt/juv/pull/50))
282 |
283 | ## 0.2.22
284 |
285 | ### Enhancements
286 |
287 | - Clear widgets metadata in `clear` ([#49](https://github.com/manzt/juv/pull/49))
288 |
289 | ## 0.2.21
290 |
291 | ### Enhancements
292 |
293 | - Upgrade uv to v0.5.0 ([#47](https://github.com/manzt/juv/pull/47))
294 |
295 | ## 0.2.20
296 |
297 | ### Enhancements
298 |
299 | - Add `--pager` flag for `juv cat` ([#45](https://github.com/manzt/juv/pull/45))
300 |
301 | ### Other changes
302 |
303 | - Refactor environment vars to also accept flags ([#46](https://github.com/manzt/juv/pull/46))
304 |
305 | ## 0.2.19
306 |
307 | ### Enhancements
308 |
309 | - Add `--check` flag for `juv clear` ([#44](https://github.com/manzt/juv/pull/44))
310 |
311 | ### Bug fixes
312 |
313 | - Use managed temp dir for `JUPYTER_DATA_DIR` ([#43](https://github.com/manzt/juv/pull/43))
314 |
315 | ## 0.2.18
316 |
317 | ### Bug fixes
318 |
319 | - Change directories prior to running uv ([#41](https://github.com/manzt/juv/pull/41))
320 |
321 | ## 0.2.17
322 |
323 | ### Release Notes
324 |
325 | This release adds some nice cli flags to `juv add` for configuring various kinds of dependency sources:
326 |
327 | Include "extra" dependency groups with `--extra`:
328 |
329 | ```sh
330 | juv add Untitled.ipynb --extra dev anywidget # adds `anywidget[dev]`
331 | ```
332 |
333 | Treat a local source as editable with `--editable`:
334 |
335 | ```sh
336 | juv add Untitled.ipynb --editable ./path/to/packages
337 | ```
338 |
339 | Add a git source at a specific revision (i.e., commit), tag, or branch:
340 |
341 | ```sh
342 | juv add Untitled.ipynb git+https://github.com/encode/httpx --tag 0.27.0
343 | juv add Untitled.ipynb git+https://github.com/encode/httpx --branch master
344 | juv add Untitled.ipynb git+https://github.com/encode/httpx --rev 326b9431c761e1ef1e00b9f760d1f654c8db48c6
345 | ```
346 |
347 | ### Enhancements
348 |
349 | - Support `--editable` sources for `add` ([#39](https://github.com/manzt/juv/pull/39))
350 | - Support `add --extra` ([#38](https://github.com/manzt/juv/pull/38))
351 | - Support git sources with `add` ([#40](https://github.com/manzt/juv/pull/40))
352 | - Add help information for command line flags ([#40](https://github.com/manzt/juv/pull/40))
353 |
354 | ## 0.2.16
355 |
356 | ### Enhancements
357 |
358 | - Refactor `run` to use isolated scripts ([#37](https://github.com/manzt/juv/pull/37))
359 | - Respect inline requires-python for python request ([#36](https://github.com/manzt/juv/pull/36))
360 |
361 | ## 0.2.15
362 |
363 | ### Enhancements
364 |
365 | - Support forwarding flags to underlying Jupyter front end ([#35](https://github.com/manzt/juv/pull/35))
366 |
367 | ## 0.2.14
368 |
369 | ### Enhancements
370 |
371 | - Replace `cat --format` with `cat --script` ([#33](https://github.com/manzt/juv/pull/33))
372 | - Include `id` metadata for markdown editing for better diffing ([#34](https://github.com/manzt/juv/pull/34))
373 |
374 | ### Bug fixes
375 |
376 | - Fix so that cells are diffed by longest ([#32](https://github.com/manzt/juv/pull/32))
377 |
378 | ## 0.2.13
379 |
380 | ### Enhancements
381 |
382 | - Add `cat` command ([#28](https://github.com/manzt/juv/pull/28))
383 | - Require editing in markdown for better diffs ([#31](https://github.com/manzt/juv/pull/31))
384 |
385 | ## 0.2.12
386 |
387 | ### Bug fixes
388 |
389 | - Strip content for editor ([#27](https://github.com/manzt/juv/pull/27))
390 |
391 | ## 0.2.11
392 |
393 | ### Enhancements
394 |
395 | - Add `exec` command ([#23](https://github.com/manzt/juv/pull/23))
396 | - Hide notebook metadata in `edit` ([#26](https://github.com/manzt/juv/pull/26))
397 |
398 | ### Other changes
399 |
400 | - Add `edit` command for quick editing in default editor ([#24](https://github.com/manzt/juv/pull/24))
401 | - More consistent clear message ([#25](https://github.com/manzt/juv/pull/25))
402 |
403 | ## 0.2.10
404 |
405 | ### Enhancements
406 |
407 | - Allow specifying directories for `clear` ([#22](https://github.com/manzt/juv/pull/22))
408 |
409 | ## 0.2.9
410 |
411 | ### Enhancements
412 |
413 | - Add `clear` command ([#20](https://github.com/manzt/juv/pull/20))
414 |
415 | ## 0.2.8
416 |
417 | ### Enhancements
418 |
419 | - Add `--output-format` flag for `version` command ([#18](https://github.com/manzt/juv/pull/18))
420 |
421 | ## 0.2.7
422 |
423 | ### Enhancements
424 |
425 | - Add new empty cell to new notebooks ([#15](https://github.com/manzt/juv/pull/15))
426 |
427 | ## 0.2.6
428 |
429 | ### Other changes
430 |
431 | - Add PyPI shield to README ([#14](https://github.com/manzt/juv/pull/14))
432 |
433 | ## 0.2.5
434 |
435 | ### Breaking changes
436 |
437 | - Switch to click CLI ([#6](https://github.com/manzt/juv/pull/6))
438 |
439 | ### Enhancements
440 |
441 | - Add `--with` flag to init ([#8](https://github.com/manzt/juv/pull/8))
442 | - Add `add`/`init` commands ([#2](https://github.com/manzt/juv/pull/2))
443 | - Add managed run mode via `JUV_RUN_MODE=managed` env ([#9](https://github.com/manzt/juv/pull/9))
444 | - Make nicer CLI output text ([#5](https://github.com/manzt/juv/pull/5))
445 | - Use jupytext for creating notebooks ([#1](https://github.com/manzt/juv/pull/1))
446 |
447 | ### Bug fixes
448 | - Support Python 3.8 and test on ubuntu ([#11](https://github.com/manzt/juv/pull/11))
449 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to juv
2 |
3 | **juv** welcomes contributions in the form of bug reports, feature requests,
4 | and pull requests.
5 |
6 | For small changes, feel free to open a PR directly. For larger changes, please
7 | open an issue first to discuss the proposed changes.
8 |
9 | ## Prerequisites
10 |
11 | **juv** is a Python package that uses [uv](https://github.com/astral-sh/uv) for
12 | development. Please make sure you have it installed before contributing.
13 |
14 | ## Development
15 |
16 | After cloning the repo, you can run an editable install of **juv** with `uv run`:
17 |
18 | ```sh
19 | uv run juv
20 | ```
21 |
22 | Prior to opening a PR, ensure that your code has been auto-formatted and that
23 | it passes both lint and test validation checks.
24 |
25 | ```sh
26 | uv run ruff format # auto-format code
27 | uv run ruff check # check lint validation
28 | uv run pytest # run tests
29 | ```
30 |
31 | ## Release Process
32 |
33 | To release a new version of **juv**, run the following:
34 |
35 | ```sh
36 | uvx --from 'rooster-blue>=0.0.9' rooster release # [--bump major|minor|patch]
37 | uv sync # sync version in lockfile
38 | ```
39 |
40 | This will bump the version of **juv** and update the CHANGELOG.md. Changelog
41 | entries are based on the commit messages and use GitHub labels to categorize
42 | the release sections.
43 |
44 | Then open a PR with the title: `vX.Y.Z`. Once the PR is merged, we will make a
45 | release by manually running the release.yml workflow.
46 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Trevor Manz
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
juv
5 |
6 |
7 | A toolkit for reproducible Jupyter notebooks, powered by uv.
8 |
9 |
10 |
11 | - 🗂️ Create, manage, and run Jupyter notebooks with their dependencies
12 | - 📌 Pin dependencies with [PEP 723 - inline script metadata](https://peps.python.org/pep-0723)
13 | - 🚀 Launch ephemeral sessions for multiple front ends (e.g., JupyterLab, Notebook, NbClassic)
14 | - ⚡ Powered by [uv](https://docs.astral.sh/uv/) for fast dependency management
15 |
16 | ## Installation
17 |
18 | **juv** is published to the Python Package Index (PyPI) and can be installed
19 | globally with `uv` or `pipx` (recommended):
20 |
21 | ```sh
22 | uv tool install juv
23 | # or pipx install juv
24 | ```
25 |
26 | You can also use the [`uvx`](https://docs.astral.sh/uv/guides/tools/) command
27 | to invoke it without installing:
28 |
29 | ```sh
30 | uvx juv
31 | ```
32 |
33 | ## Usage
34 |
35 | **juv** should feel familar for `uv` users. The goal is to extend its
36 | dependencies management to Jupyter notebooks.
37 |
38 | ```sh
39 | # Create a notebook
40 | juv init notebook.ipynb
41 | juv init --python=3.9 notebook.ipynb # specify a minimum Python version
42 |
43 | # Add dependencies to the notebook
44 | juv add notebook.ipynb pandas numpy
45 | juv add notebook.ipynb --requirements=requirements.txt
46 |
47 | # Pin a timestamp to constrain dependency resolution to a specific date
48 | juv stamp notebook.ipynb # now
49 |
50 | # Launch the notebook
51 | juv run notebook.ipynb
52 | juv run --with=polars notebook.ipynb # additional dependencies for this session (not saved)
53 | juv run --jupyter=notebook@6.4.0 notebook.ipynb # pick a specific Jupyter frontend
54 | juv run --jupyter=nbclassic notebook.ipynb -- --no-browser # pass additional arguments to Jupyter
55 |
56 | # JUV_JUPYTER env var to set preferred Jupyter frontend (default: lab)
57 | export JUV_JUPYTER=nbclassic
58 | juv run notebook.ipynb
59 |
60 | # Lock the dependencies of a notebook
61 | # The lockfile is respected (and updated) when using `juv run`/`juv add`/`juv remove`
62 | juv lock Untitled.ipynb
63 | # Print the lockfile
64 | cat Untitled.ipynb | jq -r '.metadata["uv.lock"]'
65 |
66 | # See dependency tree of notebook
67 | juv tree Untitled.ipynb
68 |
69 | # Export a lockfile in a pip-compatable format
70 | juv export Untitled.ipynb
71 | ```
72 |
73 | If a script is provided to `run`, it will be converted to a notebook before
74 | launching the Jupyter session.
75 |
76 | ```sh
77 | uvx juv run script.py
78 | # Converted script to notebook `script.ipynb`
79 | # Launching Jupyter session...
80 | ```
81 |
82 | ### Exporting virtual environments
83 |
84 | **juv** manages notebooks with dependencies and runs them in a Jupyter UI using
85 | _ephemeral_ virtual environments. To make these environments available to other
86 | tools, use `juv venv` to export a virtual environment with a kernel.
87 |
88 | ```sh
89 | juv venv --from=Untitled.ipynb
90 | # Using CPython 3.13.0
91 | # Creating virtual environment at: .venv
92 | # Activate with: source .venv/bin/activate
93 | ```
94 |
95 | Most editors (e.g., VS Code) allow selecting this environment for running
96 | notebooks and enabling features like autocomplete and type checking. To omit
97 | adding `ipykernel` to the exported enviroment, you can add `--no-kernel` flag:
98 |
99 | ```sh
100 | juv venv --from=Untitled.ipynb --no-kernel
101 | ```
102 |
103 | > [!NOTE]
104 | > We **do not** recommend modifying this environment directly (e.g., with `pip`
105 | > or `uv`, see below). Instead, recreate it by running `juv venv` again
106 | > whenever you update dependencies to keep it up to date.
107 |
108 | ### Other Jupyter front ends (e.g., VS Code)
109 |
110 | **juv** has a [VS Code
111 | extension](https://marketplace.visualstudio.com/items?itemName=manzt.juv) that
112 | provides a more integrated experience. Notebooks created with the `juv` CLI can
113 | be run with the extension and vice versa.
114 |
115 | ## Motivation
116 |
117 | _Rethinking the "getting started" guide for notebooks_
118 |
119 | Jupyter notebooks are the de facto standard for data science, yet they suffer
120 | from a [reproducibility
121 | crisis](https://leomurta.github.io/papers/pimentel2019a.pdf).
122 |
123 | This issue does not stem from a fundamental lack of care for reproducibility.
124 | Rather, our tools limit us from easily falling into the [pit of
125 | success](https://blog.codinghorror.com/falling-into-the-pit-of-success) with
126 | notebooks - in particular, managing dependencies.
127 |
128 | Notebooks are much like one-off Python scripts and therefore do not benefit
129 | from the same dependency management as packages. Being a "good steward" of
130 | notebooks requires discipline (due to the manual nature of virtual
131 | environments) and knowledge of Python packaging - a somewhat unreasonable
132 | expectation for domain experts who are focused on solving problems, not
133 | software engineering.
134 |
135 | You will often find a "getting started" guide in the wild like this:
136 |
137 | ```sh
138 | python -m venv venv
139 | source venv/bin/activate
140 | pip install -r requirements.txt # or just pip install pandas numpy, etc
141 | jupyter lab
142 | ```
143 |
144 | Four lines of code, where a few things can go wrong. _What version of Python?_
145 | _What package version(s)?_ _What if we forget to activate the environment?_
146 |
147 | The gold standard for "getting started" is a **single command** (i.e, no
148 | guide).
149 |
150 | ```sh
151 | run notebook.ipynb
152 | ```
153 |
154 | However, this ideal has remained elusive for Jupyter notebooks. Why?
155 |
156 | - **Virtual environments are a leaky abstraction** deeply ingrained in the
157 | Python psyche: _create_, _activate_, _install_, _run_. Their historical "cost"
158 | has forced us to treat them as entities that must be managed explicitly. In
159 | fact, an entire ecosystem of tooling and best practices are oriented around
160 | long-lived environments, rather than something more ephemeral. End users
161 | separately _create_ and then _mutate_ virtual environments with low-level tools
162 | like `pip`. The manual nature and overhead of these steps encourages sharing
163 | environments across projects - a nightmare for reproducibility.
164 |
165 | - **Only Python packages could historically specify their dependencies**. Data
166 | science code often lives in notebooks rather than packages, with no way to
167 | specify dependencies for standalone scripts without external files like
168 | `requirements.txt`.
169 |
170 | *Aligning of the stars*
171 |
172 | Two key ideas have changed my perspective on this problem and inspired **juv**:
173 |
174 | - **Virtual environments are now "cheap"**. A year ago, they were a necessary
175 | evil. [uv](https://peps.python.org/pep-0723/) is such a departure from the
176 | status quo that it forces us to rethink best practices. Environments are now
177 | created faster than JupyterLab starts - why keep them around at all?
178 |
179 | - **PEP 723**. [Inline script metadata](https://peps.python.org/pep-0723/)
180 | introduces a standard for specifying dependencies for standalone Python
181 | scripts. A single file can now contain everything needed to run it, without
182 | relying on external files like `requirements.txt` or `pyproject.toml`.
183 |
184 | So, what if:
185 |
186 | - _Environments were disposable by default?_
187 | - _Notebooks could specify their own dependencies?_
188 |
189 | This is the vision of **juv**
190 |
191 | > [!NOTE]
192 | > Dependency management is just one challenge for notebook reproducibility
193 | > (non-linear execution being another). **juv** aims to solve this specific
194 | > pain point for the existing ecosystem. I'm personally excited for initiatives
195 | > that [rethink notebooks](https://marimo.io/blog/lessons-learned) from the
196 | > ground up, making a tool like **juv** obsolete.
197 |
198 | ## How
199 |
200 | [PEP 723 (inline script metadata)](https://peps.python.org/pep-0723) allows
201 | specifying dependencies as comments within Python scripts, enabling
202 | self-contained, reproducible execution. This feature could significantly
203 | improve reproducibility in the data science ecosystem, since many analyses are
204 | shared as standalone code (not packages). However, _a lot_ of data science code
205 | lives in notebooks (`.ipynb` files), not Python scripts (`.py` files).
206 |
207 | **juv** bridges this gap by:
208 |
209 | - Extending PEP 723-style metadata support from `uv` to Jupyter notebooks
210 | - Launching Jupyter sessions for various notebook front ends (e.g., JupyterLab, Notebook, NbClassic) with the specified dependencies
211 |
212 | It's a simple Python script that parses the notebook and starts a Jupyter
213 | session with the specified dependencies (piggybacking on `uv`'s existing
214 | functionality).
215 |
216 | ## Alternatives
217 |
218 | `juv` is opinionated and might not suit your preferences. That's ok! `uv` is
219 | super extensible, and I recommend reading the wonderful
220 | [documentation](https://docs.astral.sh/uv) to learn about its primitives.
221 |
222 | For example, you can achieve a similar workflow using the `--with-requirements`
223 | flag:
224 |
225 | ```sh
226 | uvx --with-requirements=requirements.txt --from=jupyter-core --with=jupyterlab jupyter lab notebook.ipynb
227 | ```
228 |
229 | While slightly more verbose and breaking self-containment, this approach
230 | totally works and saves you from installing another dependency.
231 |
232 | There is also an [experimental rewrite](https://github.com/manzt/juv-rs) in
233 | Rust.
234 |
235 | ## Contributing
236 |
237 | **juv** welcomes contributions in the form of bug reports, feature requests,
238 | and pull requests. See the [CONTRIBUTING.md](./CONTRIBUTING.md) for more
239 | information.
240 |
--------------------------------------------------------------------------------
/assets/logo.ai:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/manzt/juv/30c6da32ccb1fbf3e4760ed1e4726b386feac61a/assets/logo.ai
--------------------------------------------------------------------------------
/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["hatchling"]
3 | build-backend = "hatchling.build"
4 |
5 | [project]
6 | name = "juv"
7 | version = "0.4.2"
8 | description = "A toolkit for reproducible Jupyter notebooks, powered by uv."
9 | readme = "README.md"
10 | authors = [{ name = "Trevor Manz", email = "trevor.j.manz@gmail.com" }]
11 | requires-python = ">=3.8"
12 | license = { text = "MIT" }
13 | dependencies = [
14 | "click>=8.1.7",
15 | "jupytext>=1.16.4",
16 | "rich>=13.9.2",
17 | "tomlkit>=0.13.2",
18 | "whenever>=0.6.12; python_version >= '3.9'",
19 | "uv>=0.6.7",
20 | ]
21 | classifiers = [
22 | "Environment :: Console",
23 | "Intended Audience :: Developers",
24 | "Operating System :: OS Independent",
25 | "License :: OSI Approved :: MIT License",
26 | "Programming Language :: Python",
27 | "Programming Language :: Python :: 3.8",
28 | "Programming Language :: Python :: 3.9",
29 | "Programming Language :: Python :: 3.10",
30 | "Programming Language :: Python :: 3.11",
31 | "Programming Language :: Python :: 3.12",
32 | "Programming Language :: Python :: 3.13",
33 | "Programming Language :: Python :: 3 :: Only",
34 | ]
35 |
36 | [project.scripts]
37 | juv = "juv:main"
38 |
39 | [dependency-groups]
40 | dev = ["inline-snapshot>=0.13.3", "pytest>=8.3.3", "ruff>=0.7.0"]
41 | e2e = ["pytest-playwright>=0.5.2"]
42 |
43 | [tool.ruff]
44 | line-length = 88
45 | target-version = "py38"
46 |
47 | [tool.ruff.lint]
48 | select = ["ALL"]
49 | ignore = [
50 | "TD", # TODOS
51 | "FIX", # Fixable issue
52 | "S404", # Allow imports of subprocess
53 | "D203", # Conflict with formatter
54 | "COM812",
55 | "ISC001",
56 | "D213",
57 | ]
58 |
59 | [tool.ruff.lint.per-file-ignores]
60 | "tests/*.py" = [
61 | "D", # No docstrings in tests
62 | "S101", # Use of assert
63 | "SLF001", # Access private member
64 | "W291", # Trailing whitespace
65 | "INP001", # Allow implicit namespace
66 | "E501", # Allow long lines (snapshots)
67 | "PLC2701", # Import private member
68 | ]
69 |
70 | [tool.rooster]
71 | version_tag_prefix = "v"
72 | major_labels = []
73 | minor_labels = ["breaking"]
74 | changelog_ignore_labels = ["internal"]
75 | changelog_sections.breaking = "Breaking changes"
76 | changelog_sections.enhancement = "Enhancements"
77 | changelog_sections.bug = "Bug fixes"
78 | changelog_sections.documentation = "Documentation"
79 | changelog_sections.__unknown__ = "Other changes"
80 | changelog_contributors = true
81 |
--------------------------------------------------------------------------------
/src/juv/__init__.py:
--------------------------------------------------------------------------------
1 | """Create, manage, and run reproducible Jupyter notebooks."""
2 |
3 | from __future__ import annotations
4 |
5 | import os
6 | import sys
7 | from pathlib import Path
8 |
9 | import click
10 | import rich
11 | from rich.console import Console
12 |
13 |
14 | @click.group()
15 | @click.version_option()
16 | def cli() -> None:
17 | """Create, manage, and run reproducible Jupyter notebooks."""
18 |
19 |
20 | @cli.command()
21 | @click.option(
22 | "--output-format",
23 | type=click.Choice(["json", "text"]),
24 | help="Output format [default: text]",
25 | )
26 | def version(output_format: str | None) -> None:
27 | """Display juv's version."""
28 | from ._version import __version__
29 |
30 | if output_format == "json":
31 | sys.stdout.write(f'{{"version": "{__version__}"}}\n')
32 | else:
33 | sys.stdout.write(f"juv {__version__}\n")
34 |
35 |
36 | @cli.command()
37 | @click.argument("file", type=click.Path(exists=False), required=False)
38 | @click.option("--with", "with_args", type=click.STRING, multiple=True, hidden=True)
39 | @click.option(
40 | "--python",
41 | "-p",
42 | type=click.STRING,
43 | required=False,
44 | help="The Python interpreter to use to determine the minimum supported Python version. [env: UV_PYTHON=]", # noqa: E501
45 | )
46 | def init(
47 | file: str | None,
48 | with_args: tuple[str, ...],
49 | python: str | None,
50 | ) -> None:
51 | """Initialize a new notebook."""
52 | from ._init import init
53 |
54 | path = init(
55 | path=Path(file) if file else None,
56 | python=python,
57 | packages=[p for w in with_args for p in w.split(",")],
58 | )
59 | path = os.path.relpath(path.resolve(), Path.cwd())
60 | rich.print(f"Initialized notebook at `[cyan]{path}[/cyan]`")
61 |
62 |
63 | @cli.command()
64 | @click.argument("file", type=click.Path(exists=True), required=True)
65 | @click.option(
66 | "--requirements",
67 | "-r",
68 | type=click.STRING,
69 | required=False,
70 | help="Add all packages listed in the given `requirements.txt` file.",
71 | )
72 | @click.option(
73 | "--extra",
74 | "extras",
75 | type=click.STRING,
76 | multiple=True,
77 | help="Extras to enable for the dependency. May be provided more than once.",
78 | )
79 | @click.option("--editable", is_flag=True, help="Add the requirements as editable.")
80 | @click.option(
81 | "--tag", type=click.STRING, help="Tag to use when adding a dependency from Git."
82 | )
83 | @click.option(
84 | "--branch",
85 | type=click.STRING,
86 | help="Branch to use when adding a dependency from Git.",
87 | )
88 | @click.option(
89 | "--rev", type=click.STRING, help="Commit to use when adding a dependency from Git."
90 | )
91 | @click.option(
92 | "--pin", is_flag=True, help="Resolve package specifiers to exact versions and pin."
93 | )
94 | @click.option(
95 | "--exclude-newer",
96 | type=click.STRING,
97 | help=(
98 | "Limit candidate packages to those that were uploaded prior to the given date "
99 | "[env: UV_EXCLUDE_NEWER=]"
100 | ),
101 | )
102 | @click.option(
103 | "--index",
104 | type=click.STRING,
105 | help="The URLs to use when resolving dependencies, in addition to the default "
106 | "index [env: UV_INDEX=]",
107 | )
108 | @click.option(
109 | "--default-index",
110 | type=click.STRING,
111 | help="The URL of the default package index (by default: ) "
112 | "[env: UV_DEFAULT_INDEX=]",
113 | )
114 | @click.argument("packages", nargs=-1, required=False)
115 | def add( # noqa: PLR0913
116 | *,
117 | file: str,
118 | requirements: str | None,
119 | extras: tuple[str, ...],
120 | packages: tuple[str, ...],
121 | tag: str | None,
122 | branch: str | None,
123 | rev: str | None,
124 | editable: bool,
125 | pin: bool,
126 | exclude_newer: str | None,
127 | index: str | None,
128 | default_index: str | None,
129 | ) -> None:
130 | """Add dependencies to a notebook or script."""
131 | from ._add import add
132 |
133 | if requirements is None and len(packages) == 0:
134 | msg = "Must provide one of --requirements or PACKAGES."
135 | raise click.UsageError(msg)
136 |
137 | try:
138 | add(
139 | path=Path(file),
140 | packages=packages,
141 | requirements=requirements,
142 | extras=extras,
143 | editable=editable,
144 | tag=tag,
145 | branch=branch,
146 | rev=rev,
147 | pin=pin,
148 | exclude_newer=exclude_newer,
149 | index=index,
150 | default_index=default_index,
151 | )
152 | path = os.path.relpath(Path(file).resolve(), Path.cwd())
153 | rich.print(f"Updated `[cyan]{path}[/cyan]`")
154 | except RuntimeError as e:
155 | rich.print(e, file=sys.stderr)
156 | sys.exit(1)
157 |
158 |
159 | @cli.command()
160 | @click.argument("file", type=click.Path(exists=True), required=True)
161 | @click.option(
162 | "--jupyter",
163 | required=False,
164 | help="The Jupyter frontend to use. [env: JUV_JUPYTER=]",
165 | default=lambda: os.environ.get("JUV_JUPYTER", "lab"),
166 | )
167 | @click.option(
168 | "--with",
169 | "with_args",
170 | type=click.STRING,
171 | multiple=True,
172 | help="Run with the given packages installed.",
173 | )
174 | @click.option(
175 | "--python",
176 | "-p",
177 | type=click.STRING,
178 | required=False,
179 | help="The Python interpreter to use for the run environment. [env: UV_PYTHON=]",
180 | )
181 | @click.option(
182 | "--mode",
183 | type=click.Choice(["replace", "managed", "dry"]),
184 | default=lambda: os.environ.get("JUV_RUN_MODE", "replace"),
185 | hidden=True,
186 | )
187 | @click.argument(
188 | "jupyter_args", nargs=-1, type=click.UNPROCESSED
189 | ) # Capture all args after --
190 | def run( # noqa: PLR0913
191 | *,
192 | file: str,
193 | jupyter: str,
194 | with_args: tuple[str, ...],
195 | python: str | None,
196 | jupyter_args: tuple[str, ...],
197 | mode: str,
198 | ) -> None:
199 | """Launch a notebook or script in a Jupyter front end."""
200 | from ._run import run
201 |
202 | run(
203 | path=Path(file),
204 | jupyter=jupyter,
205 | python=python,
206 | with_args=with_args,
207 | jupyter_args=jupyter_args,
208 | mode=mode,
209 | )
210 |
211 |
212 | @cli.command()
213 | @click.argument("files", nargs=-1, type=click.Path(exists=True), required=True)
214 | @click.option(
215 | "--check",
216 | is_flag=True,
217 | help="Check if the notebooks are cleared.",
218 | )
219 | def clear(*, files: list[str], check: bool) -> None: # noqa: C901
220 | """Clear notebook cell outputs.
221 |
222 | Supports multiple files and glob patterns (e.g., *.ipynb, notebooks/*.ipynb)
223 | """
224 | from ._clear import clear, is_cleared
225 |
226 | paths = []
227 | for arg in files:
228 | path = Path(arg)
229 | to_check = path.glob("*.ipynb") if path.is_dir() else [path]
230 |
231 | for path in to_check:
232 | if not path.is_file():
233 | continue
234 |
235 | if path.suffix != ".ipynb":
236 | rich.print(
237 | f"[bold yellow]Warning:[/bold yellow] Skipping "
238 | f"`[cyan]{path}[/cyan]` because it is not a notebook",
239 | file=sys.stderr,
240 | )
241 | continue
242 |
243 | paths.append(path)
244 |
245 | if check:
246 | any_cleared = False
247 | for path in paths:
248 | if not is_cleared(path):
249 | rich.print(path.resolve().absolute(), file=sys.stderr)
250 | any_cleared = True
251 |
252 | if any_cleared:
253 | rich.print(
254 | "Some notebooks are not cleared. "
255 | "Use `[green b]juv clear[/green b]` to fix.",
256 | file=sys.stderr,
257 | )
258 | sys.exit(1)
259 |
260 | rich.print("All notebooks are cleared", file=sys.stderr)
261 | return
262 |
263 | if len(paths) == 1:
264 | clear(paths[0])
265 | path = os.path.relpath(paths[0].resolve(), Path.cwd())
266 | rich.print(f"Cleared output from `[cyan]{path}[/cyan]`", file=sys.stderr)
267 | return
268 |
269 | for path in paths:
270 | clear(path)
271 | rich.print(path.resolve().absolute(), file=sys.stderr)
272 |
273 | rich.print(f"Cleared output from {len(paths)} notebooks", file=sys.stderr)
274 |
275 |
276 | @cli.command()
277 | @click.argument("notebook", type=click.Path(exists=True), required=True)
278 | @click.option(
279 | "--editor",
280 | type=click.STRING,
281 | required=False,
282 | help="The editor to use. [env: EDITOR=]",
283 | )
284 | def edit(*, notebook: str, editor: str | None) -> None:
285 | """Quick edit a notebook as markdown."""
286 | from ._edit import EditorAbortedError, edit
287 |
288 | if editor is None:
289 | editor = os.environ.get("EDITOR")
290 |
291 | if editor is None:
292 | msg = (
293 | "No editor specified. Please set the EDITOR environment variable "
294 | "or use the --editor option."
295 | )
296 | rich.print(f"[bold red]error[/bold red]: {msg}", file=sys.stderr)
297 | return
298 |
299 | path = Path(notebook)
300 | if path.suffix != ".ipynb":
301 | rich.print(
302 | f"[bold red]error[/bold red]: `[cyan]{path}[/cyan]` is not a notebook",
303 | file=sys.stderr,
304 | )
305 | return
306 |
307 | try:
308 | edit(path=path, editor=editor)
309 | rich.print(f"Edited `[cyan]{notebook}[/cyan]`", file=sys.stderr)
310 | except EditorAbortedError as e:
311 | rich.print(f"[bold red]error[/bold red]: {e}", file=sys.stderr)
312 |
313 |
314 | def upgrade_legacy_jupyter_command(args: list[str]) -> None:
315 | """Check legacy command usage and upgrade to 'run' with deprecation notice."""
316 | if len(args) >= 2: # noqa: PLR2004
317 | command = args[1]
318 | if command.startswith(("lab", "notebook", "nbclassic")):
319 | rich.print(
320 | f"[bold]warning[/bold]: The command '{command}' is deprecated. "
321 | f"Please use 'run' with `--jupyter={command}` "
322 | f"or set JUV_JUPYTER={command}",
323 | file=sys.stderr,
324 | )
325 | os.environ["JUV_JUPYTER"] = command
326 | args[1] = "run"
327 |
328 |
329 | @cli.command("exec")
330 | @click.argument("notebook", type=click.Path(exists=True), required=True)
331 | @click.option(
332 | "--python",
333 | "-p",
334 | type=click.STRING,
335 | required=False,
336 | help="The Python interpreter to use for the exec environment. [env: UV_PYTHON=]",
337 | )
338 | @click.option(
339 | "--with",
340 | "with_args",
341 | type=click.STRING,
342 | multiple=True,
343 | help="Run with the given packages installed.",
344 | )
345 | @click.option("--quiet", is_flag=True)
346 | def exec_(
347 | *, notebook: str, python: str | None, with_args: tuple[str, ...], quiet: bool
348 | ) -> None:
349 | """Execute a notebook as a script."""
350 | from ._exec import exec_
351 |
352 | exec_(path=Path(notebook), python=python, with_args=with_args, quiet=quiet)
353 |
354 |
355 | @cli.command()
356 | @click.argument("notebook", type=click.Path(exists=True), required=True)
357 | @click.option("--script", is_flag=True)
358 | @click.option(
359 | "--pager",
360 | type=click.STRING,
361 | help="The pager to use.",
362 | default=lambda: os.environ.get("JUV_PAGER"),
363 | hidden=True,
364 | )
365 | def cat(*, notebook: str, script: bool, pager: str | None) -> None:
366 | """Print notebook contents to stdout."""
367 | from ._cat import cat
368 |
369 | path = Path(notebook)
370 | if path.suffix != ".ipynb":
371 | rich.print(
372 | f"[bold red]error[/bold red]: `[cyan]{path}[/cyan]` is not a notebook",
373 | file=sys.stderr,
374 | )
375 | sys.exit(1)
376 |
377 | cat(path=path, script=script, pager=pager)
378 |
379 |
380 | @cli.command()
381 | @click.argument("file", type=click.Path(exists=True))
382 | @click.option(
383 | "--timestamp",
384 | help="An RFC 3339 timestamp (e.g., 2006-12-02T02:07:43Z).",
385 | )
386 | @click.option(
387 | "--date",
388 | help=(
389 | "A local ISO 8601 date (e.g., 2006-12-02). "
390 | "Resolves to midnight system's time zone."
391 | ),
392 | )
393 | @click.option("--rev", help="A Git revision to stamp the file with.")
394 | @click.option("--latest", is_flag=True, help="Use the latest Git revision.")
395 | @click.option("--clear", is_flag=True, help="Clear the `exclude-newer` field.")
396 | def stamp( # noqa: PLR0913
397 | *,
398 | file: str,
399 | timestamp: str | None,
400 | date: str | None,
401 | rev: str | None,
402 | latest: bool,
403 | clear: bool,
404 | ) -> None:
405 | """Stamp a notebook or script with a reproducible timestamp."""
406 | if sys.version_info < (3, 9):
407 | rich.print(
408 | "[bold red]error[/bold red] "
409 | "Python 3.9 or latest is required for `juv stamp`",
410 | )
411 | sys.exit(1)
412 |
413 | from ._stamp import CreateAction, DeleteAction, UpdateAction, stamp
414 |
415 | console = Console(file=sys.stderr, highlight=False)
416 | path = Path(file)
417 |
418 | # time, rev, latest, and clear are mutually exclusive
419 | if sum([bool(timestamp), bool(rev), bool(date), latest, clear]) > 1:
420 | console.print(
421 | "[bold red]Error:[/bold red] "
422 | "Only one of --timestamp, --date, --rev, --latest, or --clear may be used",
423 | )
424 | sys.exit(1)
425 |
426 | try:
427 | action = stamp(
428 | path=path,
429 | timestamp=timestamp,
430 | rev=rev,
431 | latest=latest,
432 | clear=clear,
433 | date=date,
434 | )
435 | except ValueError as e:
436 | console.print(f"[bold red]error[/bold red]: {e.args[0]}")
437 | sys.exit(1)
438 |
439 | path = os.path.relpath(path.resolve(), Path.cwd())
440 |
441 | if isinstance(action, DeleteAction):
442 | if action.previous is None:
443 | # there was no previosu timestamp, so ok but no-op
444 | console.print(f"No timestamp found in `[cyan]{path}[/cyan]`")
445 | else:
446 | console.print(
447 | f"Removed [green]{action.previous}[/green] from `[cyan]{path}[/cyan]`",
448 | )
449 | elif isinstance(action, CreateAction):
450 | console.print(
451 | f"Stamped `[cyan]{path}[/cyan]` with [green]{action.value}[/green]",
452 | )
453 | elif isinstance(action, UpdateAction):
454 | console.print(
455 | f"Updated `[cyan]{path}[/cyan]` with [green]{action.value}[/green]",
456 | )
457 |
458 |
459 | @cli.command()
460 | @click.argument("file", type=click.Path(exists=True), required=True)
461 | @click.argument("packages", nargs=-1, required=True)
462 | def remove(
463 | *,
464 | file: str,
465 | packages: tuple[str, ...],
466 | ) -> None:
467 | """Remove dependencies from a notebook."""
468 | from ._remove import remove
469 |
470 | try:
471 | remove(
472 | path=Path(file),
473 | packages=packages,
474 | )
475 | path = os.path.relpath(Path(file).resolve(), Path.cwd())
476 | rich.print(f"Updated `[cyan]{path}[/cyan]`")
477 | except RuntimeError as e:
478 | rich.print(e, file=sys.stderr)
479 | sys.exit(1)
480 |
481 |
482 | @cli.command()
483 | @click.argument("file", type=click.Path(exists=True), required=True)
484 | @click.option("--clear", is_flag=True, help="Clear the lockfile contents.")
485 | def lock(
486 | *,
487 | file: str,
488 | clear: bool,
489 | ) -> None:
490 | """Update the notebooks's lockfile."""
491 | from ._lock import lock
492 |
493 | try:
494 | lock(path=Path(file), clear=clear)
495 | path = os.path.relpath(Path(file).resolve(), Path.cwd())
496 | if clear:
497 | rich.print(f"Cleared lockfile `[cyan]{path}[/cyan]`")
498 | else:
499 | rich.print(f"Locked `[cyan]{path}[/cyan]`")
500 | except RuntimeError as e:
501 | rich.print(e, file=sys.stderr)
502 | sys.exit(1)
503 |
504 |
505 | @cli.command()
506 | @click.argument("file", type=click.Path(exists=True), required=True)
507 | def tree(
508 | *,
509 | file: str,
510 | ) -> None:
511 | """Display the notebook's dependency tree."""
512 | from ._tree import tree
513 |
514 | tree(path=Path(file))
515 |
516 |
517 | @cli.command()
518 | @click.argument("file", type=click.Path(exists=True), required=True)
519 | def export(
520 | *,
521 | file: str,
522 | ) -> None:
523 | """Export the notebook's lockfile to an alternate format."""
524 | from ._export import export
525 |
526 | export(path=Path(file))
527 |
528 |
529 | @cli.command()
530 | @click.option(
531 | "--from",
532 | "from_",
533 | type=click.Path(exists=True),
534 | required=True,
535 | help="The notebook or script from which to derive the virtual environment.",
536 | )
537 | @click.option(
538 | "--python",
539 | "-p",
540 | type=click.STRING,
541 | required=False,
542 | help="The Python interpreter to use to determine the minimum supported Python version. [env: UV_PYTHON=]", # noqa: E501
543 | )
544 | @click.option(
545 | "--no-kernel", is_flag=True, help="Exclude `ipykernel` from the enviroment."
546 | )
547 | @click.argument(
548 | "path",
549 | required=False,
550 | )
551 | def venv(
552 | *,
553 | from_: str,
554 | python: str | None,
555 | no_kernel: bool,
556 | path: str | None,
557 | ) -> None:
558 | """Create a virtual enviroment from a notebook."""
559 | from ._venv import venv
560 |
561 | try:
562 | venv(
563 | source=Path(from_),
564 | python=python,
565 | path=Path.cwd() / ".venv" if path is None else Path(path),
566 | no_kernel=no_kernel,
567 | )
568 | except RuntimeError as e:
569 | rich.print(e, file=sys.stderr)
570 | sys.exit(1)
571 |
572 |
573 | @cli.command()
574 | @click.option(
575 | "--target",
576 | type=click.Path(exists=False),
577 | required=False,
578 | help="Path to virtual environment to sync. Falls back to `.venv` in current directory.", # noqa: E501
579 | )
580 | @click.option(
581 | "--active",
582 | is_flag=True,
583 | help="Sync dependencies to the active virtual environment. Overrides --target.",
584 | )
585 | @click.option(
586 | "--python",
587 | "-p",
588 | type=click.STRING,
589 | required=False,
590 | help="The Python interpreter to use to determine the minimum supported Python version. [env: UV_PYTHON=]", # noqa: E501
591 | )
592 | @click.option(
593 | "--no-kernel", is_flag=True, help="Exclude `ipykernel` from the environment."
594 | )
595 | @click.argument(
596 | "path",
597 | required=True,
598 | )
599 | def sync(
600 | *,
601 | target: str | None,
602 | active: bool,
603 | python: str | None,
604 | no_kernel: bool,
605 | path: str,
606 | ) -> None:
607 | """Sync a virtual enviroment for a notebook."""
608 | from ._venv import venv
609 |
610 | if target is not None and active:
611 | msg = "Provide either --target or --active, but not both."
612 | raise click.UsageError(msg)
613 |
614 | if active:
615 | venv_target = Path(os.environ.get("VIRTUAL_ENV", Path.cwd() / ".venv"))
616 | elif target:
617 | venv_target = Path(target)
618 | else:
619 | venv_target = Path.cwd() / ".venv"
620 |
621 | try:
622 | venv(
623 | source=Path(path),
624 | python=python,
625 | path=venv_target,
626 | no_kernel=no_kernel,
627 | )
628 | except RuntimeError as e:
629 | rich.print(e, file=sys.stderr)
630 | sys.exit(1)
631 |
632 |
633 | def main() -> None:
634 | """Run the CLI."""
635 | upgrade_legacy_jupyter_command(sys.argv)
636 | cli()
637 |
--------------------------------------------------------------------------------
/src/juv/_add.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import tempfile
4 | import typing
5 | from pathlib import Path
6 |
7 | import jupytext
8 | from jupytext.pandoc import subprocess
9 | from uv import find_uv_bin
10 |
11 | from ._nbutils import code_cell, write_ipynb
12 | from ._pep723 import includes_inline_metadata
13 | from ._utils import find
14 | from ._uv import uv
15 |
16 |
17 | def uv_pip_compile(
18 | packages: typing.Sequence[str],
19 | requirements: str | None,
20 | *,
21 | no_deps: bool,
22 | exclude_newer: str | None,
23 | ) -> list[str]:
24 | """Use `pip compile` to generate exact versions of packages."""
25 | requirements_txt = (
26 | "" if requirements is None else Path(requirements).read_text(encoding="utf-8")
27 | )
28 |
29 | # just append the packages on to the requirements
30 | for package in packages:
31 | if package not in requirements_txt:
32 | requirements_txt += f"{package}\n"
33 |
34 | result = subprocess.run(
35 | [
36 | find_uv_bin(),
37 | "pip",
38 | "compile",
39 | *(["--no-deps"] if no_deps else []),
40 | *([f"--exclude-newer={exclude_newer}"] if exclude_newer else []),
41 | "-",
42 | ],
43 | input=requirements_txt.encode("utf-8"),
44 | stdout=subprocess.PIPE,
45 | stderr=subprocess.PIPE,
46 | check=False,
47 | )
48 |
49 | if result.returncode != 0:
50 | raise RuntimeError(result.stderr.decode())
51 |
52 | # filter only for the exact versions
53 | return [pkg for pkg in result.stdout.decode().split("\n") if "==" in pkg]
54 |
55 |
56 | def uv_script( # noqa: PLR0913
57 | script: Path | str,
58 | *,
59 | packages: typing.Sequence[str],
60 | requirements: str | None,
61 | extras: typing.Sequence[str] | None,
62 | editable: bool,
63 | branch: str | None,
64 | rev: str | None,
65 | tag: str | None,
66 | exclude_newer: str | None,
67 | index: str | None,
68 | default_index: str | None,
69 | ) -> None:
70 | uv(
71 | [
72 | "add",
73 | *(["--requirements", requirements] if requirements else []),
74 | *([f"--extra={extra}" for extra in extras or []]),
75 | *(["--editable"] if editable else []),
76 | *([f"--tag={tag}"] if tag else []),
77 | *([f"--branch={branch}"] if branch else []),
78 | *([f"--rev={rev}"] if rev else []),
79 | *([f"--exclude-newer={exclude_newer}"] if exclude_newer else []),
80 | *([f"--index={index}"] if index else []),
81 | *([f"--default-index={default_index}"] if default_index else []),
82 | "--script",
83 | str(script),
84 | *packages,
85 | ],
86 | check=True,
87 | )
88 |
89 |
90 | def add_notebook( # noqa: PLR0913
91 | path: Path,
92 | *,
93 | packages: typing.Sequence[str],
94 | requirements: str | None,
95 | extras: typing.Sequence[str] | None,
96 | editable: bool,
97 | branch: str | None,
98 | rev: str | None,
99 | tag: str | None,
100 | exclude_newer: str | None,
101 | index: str | None,
102 | default_index: str | None,
103 | ) -> None:
104 | notebook = jupytext.read(path, fmt="ipynb")
105 | lockfile_contents = notebook.get("metadata", {}).get("uv.lock")
106 |
107 | # need a reference so we can modify the cell["source"]
108 | cell = find(
109 | lambda cell: (
110 | cell["cell_type"] == "code"
111 | and includes_inline_metadata("".join(cell["source"]))
112 | ),
113 | notebook["cells"],
114 | )
115 |
116 | if cell is None:
117 | notebook["cells"].insert(0, code_cell("", hidden=True))
118 | cell = notebook["cells"][0]
119 |
120 | with tempfile.NamedTemporaryFile(
121 | mode="w+",
122 | delete=True,
123 | suffix=".py",
124 | dir=path.parent,
125 | encoding="utf-8",
126 | ) as f:
127 | lockfile = Path(f"{f.name}.lock")
128 |
129 | f.write(cell["source"].strip())
130 | f.flush()
131 |
132 | if lockfile_contents:
133 | lockfile.write_text(lockfile_contents)
134 |
135 | uv_script(
136 | script=f.name,
137 | packages=packages,
138 | requirements=requirements,
139 | extras=extras,
140 | editable=editable,
141 | branch=branch,
142 | rev=rev,
143 | tag=tag,
144 | exclude_newer=exclude_newer,
145 | index=index,
146 | default_index=default_index,
147 | )
148 | f.seek(0)
149 | cell["source"] = f.read().strip()
150 |
151 | if lockfile.exists():
152 | notebook["metadata"]["uv.lock"] = lockfile.read_text(encoding="utf-8")
153 | lockfile.unlink(missing_ok=True)
154 |
155 | write_ipynb(notebook, path.with_suffix(".ipynb"))
156 |
157 |
158 | def add( # noqa: PLR0913
159 | *,
160 | path: Path,
161 | packages: typing.Sequence[str],
162 | requirements: str | None = None,
163 | extras: typing.Sequence[str] | None = None,
164 | tag: str | None = None,
165 | branch: str | None = None,
166 | rev: str | None = None,
167 | pin: bool = False,
168 | editable: bool = False,
169 | exclude_newer: str | None = None,
170 | index: str | None = None,
171 | default_index: str | None = None,
172 | ) -> None:
173 | if pin:
174 | packages = uv_pip_compile(
175 | packages, requirements, exclude_newer=exclude_newer, no_deps=True
176 | )
177 | requirements = None
178 |
179 | (add_notebook if path.suffix == ".ipynb" else uv_script)(
180 | path,
181 | packages=packages,
182 | requirements=requirements,
183 | extras=extras,
184 | editable=editable,
185 | branch=branch,
186 | rev=rev,
187 | tag=tag,
188 | exclude_newer=exclude_newer,
189 | index=index,
190 | default_index=default_index,
191 | )
192 |
--------------------------------------------------------------------------------
/src/juv/_cat.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import re
4 | import sys
5 | from typing import TYPE_CHECKING
6 |
7 | import jupytext
8 |
9 | if TYPE_CHECKING:
10 | from pathlib import Path
11 |
12 |
13 | def strip_markdown_header(content: str) -> tuple[str, str]:
14 | # Match content between first set of --- markers
15 | match = re.match(r"^---\n.*?\n---\n(.*)$", content, re.DOTALL)
16 | if match:
17 | header = content[: content.find(match.group(1))]
18 | return header, match.group(1)
19 | return "", content
20 |
21 |
22 | def strip_python_frontmatter_comment(content: str) -> tuple[str, str]:
23 | """Remove frontmatter comment block from beginning of Python script.
24 |
25 | Looks for content between # --- markers at start of file.
26 |
27 | Args:
28 | content: Full content of Python file
29 |
30 | Returns:
31 | tuple[str, str]: (frontmatter, remaining_content)
32 |
33 | """
34 | lines = content.splitlines(keepends=True)
35 | if not lines or lines[0].strip() != "# ---":
36 | return "", content
37 |
38 | for i, line in enumerate(lines[1:], 1):
39 | if line.strip() == "# ---":
40 | return "".join(lines[: i + 1]), "".join(lines[i + 1 :])
41 |
42 | return "", content
43 |
44 |
45 | def notebook_contents(nb: Path | dict, *, script: bool) -> str:
46 | fmt = "py:percent" if script else "md"
47 | notebook = nb if isinstance(nb, dict) else jupytext.read(nb)
48 | contents = jupytext.writes(notebook, fmt=fmt)
49 | if script:
50 | _, contents = strip_python_frontmatter_comment(contents)
51 | else:
52 | _, contents = strip_markdown_header(contents)
53 | return contents.lstrip()
54 |
55 |
56 | def cat(path: Path, *, script: bool, pager: str | None = None) -> None:
57 | code = notebook_contents(path, script=script)
58 |
59 | if not pager:
60 | sys.stdout.write(code)
61 | return
62 |
63 | import os
64 | import subprocess
65 |
66 | command = [pager]
67 |
68 | # special case bat to apply syntax highlighting
69 | if pager == "bat":
70 | ext = "md" if not script else "py"
71 | command.extend(
72 | [
73 | f"--language={ext}",
74 | f"--file-name={path.stem}.{ext}",
75 | ]
76 | )
77 |
78 | subprocess.run( # noqa: PLW1510, S603
79 | command,
80 | input=code.encode(),
81 | stdout=sys.stdout,
82 | stderr=sys.stderr,
83 | env=os.environ,
84 | )
85 |
--------------------------------------------------------------------------------
/src/juv/_clear.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | import nbformat
6 |
7 | if TYPE_CHECKING:
8 | from pathlib import Path
9 |
10 |
11 | def clear(path: Path) -> None:
12 | nb = nbformat.read(path, nbformat.NO_CONVERT)
13 | # clear cells
14 | for cell in nb.cells:
15 | if cell.cell_type == "code":
16 | cell.outputs = []
17 | cell.execution_count = None
18 | # clear widgets metadata
19 | nb.metadata.pop("widgets", None)
20 | nbformat.write(nb, path)
21 |
22 |
23 | def is_cleared(path: Path) -> bool:
24 | """Check if a notebook has been cleared.
25 |
26 | A notebook is considered cleared if:
27 | - It does not have any outputs or execution counts in code cells.
28 | - It does not have any widgets metadata.
29 |
30 | Parameters
31 | ----------
32 | path : Path
33 | Path to the notebook file.
34 |
35 | Returns
36 | -------
37 | bool
38 | True if the notebook is cleared, False otherwise
39 |
40 | """
41 | nb = nbformat.read(path, nbformat.NO_CONVERT)
42 | if "widgets" in nb.metadata:
43 | return False
44 | for cell in filter(lambda cell: cell.cell_type == "code", nb.cells):
45 | if cell.outputs or cell.execution_count is not None:
46 | return False
47 | return True
48 |
--------------------------------------------------------------------------------
/src/juv/_edit.py:
--------------------------------------------------------------------------------
1 | import subprocess
2 | import tempfile
3 | from pathlib import Path
4 |
5 | import jupytext
6 |
7 | from ._cat import notebook_contents
8 |
9 |
10 | class EditorAbortedError(Exception):
11 | """Exception raised when the editor exits abnormally."""
12 |
13 |
14 | def open_editor(contents: str, suffix: str, editor: str) -> str:
15 | """Open an editor with the given contents and return the modified text.
16 |
17 | Args:
18 | contents: Initial text content
19 | suffix: File extension for temporary file
20 | editor: Editor command to use
21 |
22 | Returns:
23 | str: Modified text content
24 |
25 | Raises:
26 | EditorAbortedError: If editor exits abnormally
27 |
28 | """
29 | with tempfile.NamedTemporaryFile(
30 | suffix=suffix, mode="w+", delete=False, encoding="utf-8"
31 | ) as tf:
32 | if contents:
33 | tf.write(contents)
34 | tf.flush()
35 | tpath = Path(tf.name)
36 | try:
37 | if any(code in editor.lower() for code in ["code", "vscode"]):
38 | cmd = [editor, "--wait", tpath]
39 | else:
40 | cmd = [editor, tpath]
41 |
42 | result = subprocess.run(cmd, check=False) # noqa: S603
43 | if result.returncode != 0:
44 | msg = f"Editor exited with code {result.returncode}"
45 | raise EditorAbortedError(msg)
46 | return tpath.read_text(encoding="utf-8")
47 | finally:
48 | tpath.unlink()
49 |
50 |
51 | def edit(path: Path, editor: str) -> None:
52 | """Edit a Jupyter notebook as markdown.
53 |
54 | Args:
55 | path: Path to notebook file
56 | editor: Editor command to use
57 |
58 | """
59 | prev_notebook = jupytext.read(path, fmt="ipynb")
60 |
61 | # Create a mapping of cell IDs to previous cells
62 | prev_cells: dict[str, dict] = {}
63 | for update in prev_notebook["cells"]:
64 | if "id" not in update:
65 | continue
66 | update["metadata"]["id"] = update["id"]
67 | prev_cells[update["id"]] = update
68 |
69 | code = notebook_contents(path, script=False)
70 | text = open_editor(code, suffix=".md", editor=editor)
71 | new_notebook = jupytext.reads(text.strip(), fmt="md")
72 |
73 | # Update the previous notebook cells with the new ones
74 | cells = []
75 | for update in new_notebook["cells"]:
76 | prev = prev_cells.get(update["metadata"].pop("id", None))
77 | update["metadata"].pop("lines_to_next_cell", None)
78 | if prev is None:
79 | cells.append(update)
80 | continue
81 | prev.update(
82 | {
83 | "cell_type": update["cell_type"],
84 | "source": update["source"],
85 | "metadata": update["metadata"],
86 | }
87 | )
88 | cells.append(prev)
89 |
90 | prev_notebook["cells"] = cells
91 | path.write_text(jupytext.writes(prev_notebook, fmt="ipynb"))
92 |
--------------------------------------------------------------------------------
/src/juv/_exec.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | import subprocess
5 | import sys
6 | import typing
7 |
8 | import jupytext
9 | from uv import find_uv_bin
10 |
11 | if typing.TYPE_CHECKING:
12 | from pathlib import Path
13 |
14 |
15 | def exec_(
16 | path: Path,
17 | python: str | None,
18 | with_args: typing.Sequence[str],
19 | *,
20 | quiet: bool,
21 | ) -> None:
22 | target = path.resolve()
23 | notebook = jupytext.read(target)
24 |
25 | # change to the target's directory
26 | os.chdir(target.parent)
27 |
28 | subprocess.run( # noqa: S603
29 | [
30 | os.fsdecode(find_uv_bin()),
31 | "run",
32 | *([f"--python={python}"] if python else []),
33 | *(["--with=" + ",".join(with_args)] if with_args else []),
34 | *(["--quiet"] if quiet else []),
35 | "-",
36 | ],
37 | input=jupytext.writes(notebook, fmt="py").encode(),
38 | check=True,
39 | stdout=sys.stdout,
40 | stderr=sys.stderr,
41 | )
42 |
--------------------------------------------------------------------------------
/src/juv/_export.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import tempfile
3 | from pathlib import Path
4 |
5 | import jupytext
6 |
7 | from ._nbutils import code_cell, write_ipynb
8 | from ._pep723 import includes_inline_metadata
9 | from ._utils import find
10 | from ._uv import uv
11 |
12 |
13 | def export(
14 | path: Path,
15 | *,
16 | frozen: bool = False,
17 | ) -> None:
18 | contents = export_to_string(path, frozen=frozen)
19 | sys.stdout.write(contents)
20 |
21 |
22 | def export_to_string(
23 | path: Path,
24 | *,
25 | frozen: bool = False,
26 | ) -> str:
27 | notebook = jupytext.read(path, fmt="ipynb")
28 | lockfile_contents = notebook.get("metadata", {}).get("uv.lock")
29 |
30 | # need a reference so we can modify the cell["source"]
31 | cell = find(
32 | lambda cell: (
33 | cell["cell_type"] == "code"
34 | and includes_inline_metadata("".join(cell["source"]))
35 | ),
36 | notebook["cells"],
37 | )
38 |
39 | if cell is None:
40 | notebook["cells"].insert(0, code_cell("", hidden=True))
41 | cell = notebook["cells"][0]
42 |
43 | with tempfile.NamedTemporaryFile(
44 | mode="w+",
45 | delete=True,
46 | suffix=".py",
47 | dir=path.parent,
48 | encoding="utf-8",
49 | ) as f:
50 | lockfile = Path(f"{f.name}.lock")
51 |
52 | f.write(cell["source"].strip())
53 | f.flush()
54 |
55 | if lockfile_contents:
56 | lockfile.write_text(lockfile_contents)
57 |
58 | result = uv(["export", "--script", f.name], check=True)
59 | contents = result.stdout.decode("utf-8")
60 |
61 | if not frozen and lockfile.exists():
62 | notebook.metadata["uv.lock"] = lockfile.read_text(encoding="utf-8")
63 | write_ipynb(notebook, path)
64 | lockfile.unlink(missing_ok=True)
65 |
66 | return contents
67 |
--------------------------------------------------------------------------------
/src/juv/_init.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import sys
4 | import tempfile
5 | import typing
6 | from pathlib import Path
7 |
8 | import rich
9 |
10 | from ._nbutils import code_cell, new_notebook, write_ipynb
11 | from ._uv import uv
12 |
13 |
14 | def new_notebook_with_inline_metadata(
15 | directory: Path,
16 | python: str | None = None,
17 | ) -> dict:
18 | """Create a new notebook with inline metadata.
19 |
20 | Parameters
21 | ----------
22 | directory : pathlib.Path
23 | A directory for uv to run `uv init` in. This is used so that we can
24 | defer the selection of Python (if not specified) to uv.
25 | python : str, optional
26 | A version of the Python interpreter. Provided as `--python` to uv if specified.
27 |
28 | Returns
29 | -------
30 | dict
31 | A new notebook with a single code cell containing the contents of the
32 | script generated by `uv init`.
33 |
34 | """
35 | with tempfile.NamedTemporaryFile(
36 | mode="w+",
37 | suffix=".py",
38 | delete=True,
39 | dir=directory,
40 | encoding="utf-8",
41 | ) as f:
42 | uv(
43 | ["init", *(["--python", python] if python else []), "--script", f.name],
44 | check=True,
45 | )
46 | contents = f.read().strip()
47 | return new_notebook(cells=[code_cell(contents, hidden=True), code_cell("")])
48 |
49 |
50 | def get_first_non_conflicting_untitled_ipynb(directory: Path) -> Path:
51 | if not (directory / "Untitled.ipynb").exists():
52 | return directory / "Untitled.ipynb"
53 |
54 | for i in range(1, 100):
55 | if not (directory / f"Untitled{i}.ipynb").exists():
56 | return directory / f"Untitled{i}.ipynb"
57 |
58 | msg = "Could not find an available UntitledX.ipynb"
59 | raise ValueError(msg)
60 |
61 |
62 | def init(
63 | path: Path | None,
64 | python: str | None,
65 | packages: typing.Sequence[str] | None = None,
66 | ) -> Path:
67 | """Initialize a new notebook.
68 |
69 | Parameters
70 | ----------
71 | path : pathlib.Path | None
72 | The path to the new notebook. If None, a new Untitled.ipynb is created.
73 | python : str | None
74 | The version of Python to use. Passed as `--python` to uv.
75 | packages : Sequence[str]
76 | A list of packages to install in the new notebook.
77 |
78 | Returns
79 | -------
80 | pathlib.Path
81 | The path to the new notebook.
82 |
83 | """
84 | packages = packages or []
85 | if not path:
86 | path = get_first_non_conflicting_untitled_ipynb(Path.cwd())
87 |
88 | if path.suffix != ".ipynb":
89 | rich.print("File must have a `[cyan].ipynb[/cyan]` extension.", file=sys.stderr)
90 | sys.exit(1)
91 |
92 | notebook = new_notebook_with_inline_metadata(path.parent, python)
93 | write_ipynb(notebook, path)
94 |
95 | if len(packages) > 0:
96 | from ._add import add
97 |
98 | add(path=path, packages=packages)
99 |
100 | return path
101 |
--------------------------------------------------------------------------------
/src/juv/_lock.py:
--------------------------------------------------------------------------------
1 | import tempfile
2 | from pathlib import Path
3 |
4 | import jupytext
5 |
6 | from ._nbutils import code_cell, write_ipynb
7 | from ._pep723 import includes_inline_metadata
8 | from ._utils import find
9 | from ._uv import uv
10 |
11 |
12 | def lock(*, path: Path, clear: bool) -> None:
13 | notebook = jupytext.read(path, fmt="ipynb")
14 |
15 | if clear:
16 | notebook.get("metadata", {}).pop("uv.lock", None)
17 | write_ipynb(notebook, path)
18 | return
19 |
20 | cell = find(
21 | lambda cell: (
22 | cell["cell_type"] == "code"
23 | and includes_inline_metadata("".join(cell["source"]))
24 | ),
25 | notebook["cells"],
26 | )
27 |
28 | if cell is None:
29 | notebook["cells"].insert(0, code_cell("", hidden=True))
30 | cell = notebook["cells"][0]
31 |
32 | with tempfile.NamedTemporaryFile(
33 | mode="w+",
34 | delete=True,
35 | suffix=".py",
36 | dir=path.parent,
37 | encoding="utf-8",
38 | ) as temp_file:
39 | temp_file.write(cell["source"].strip())
40 | temp_file.flush()
41 |
42 | uv(["lock", "--script", temp_file.name], check=True)
43 |
44 | lock_file = Path(f"{temp_file.name}.lock")
45 |
46 | notebook["metadata"]["uv.lock"] = lock_file.read_text(encoding="utf-8")
47 |
48 | lock_file.unlink(missing_ok=True)
49 |
50 | write_ipynb(notebook, path.with_suffix(".ipynb"))
51 |
--------------------------------------------------------------------------------
/src/juv/_nbutils.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | import jupytext
6 | import nbformat.v4.nbbase as nb
7 |
8 | if TYPE_CHECKING:
9 | from pathlib import Path
10 |
11 |
12 | def code_cell(source: str, *, hidden: bool = False) -> dict:
13 | kwargs = {}
14 | if hidden:
15 | kwargs["metadata"] = {"jupyter": {"source_hidden": hidden}}
16 |
17 | return nb.new_code_cell(source, **kwargs)
18 |
19 |
20 | def new_notebook(cells: list[dict]) -> dict:
21 | notebook = nb.new_notebook(cells=cells)
22 | if "kernelspec" not in notebook.metadata:
23 | notebook.metadata.kernelspec = {
24 | "display_name": "Python 3",
25 | "language": "python",
26 | "name": "python3",
27 | }
28 | return notebook
29 |
30 |
31 | def write_ipynb(nb: dict, file: Path) -> None:
32 | file.write_text(jupytext.writes(nb, fmt="ipynb"))
33 |
--------------------------------------------------------------------------------
/src/juv/_pep723.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import re
4 |
5 | REGEX = r"(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)+)^# ///$"
6 |
7 |
8 | def parse_inline_script_metadata(script: str) -> str | None:
9 | """Parse PEP 723 metadata from an inline script."""
10 | name = "script"
11 | matches = list(
12 | filter(lambda m: m.group("type") == name, re.finditer(REGEX, script)),
13 | )
14 | if len(matches) > 1:
15 | msg = f"Multiple {name} blocks found"
16 | raise ValueError(msg)
17 | if len(matches) == 1:
18 | return "".join(
19 | line[2:] if line.startswith("# ") else line[1:]
20 | for line in matches[0].group("content").splitlines(keepends=True)
21 | )
22 | return None
23 |
24 |
25 | def extract_inline_meta(script: str) -> tuple[str | None, str]:
26 | """Extract PEP 723 metadata from an inline script.
27 |
28 | Parameters
29 | ----------
30 | script : str
31 | A Python script that may contain a PEP 723 metadata block
32 |
33 | Returns
34 | -------
35 | tuple[str | None, str]
36 | The extracted metadata block and the script with the metadata block removed
37 |
38 | """
39 | if match := re.search(REGEX, script):
40 | meta_comment = match.group(0)
41 | return meta_comment, script.replace(meta_comment, "").strip()
42 | return None, script
43 |
44 |
45 | def includes_inline_metadata(script: str) -> bool:
46 | return re.search(REGEX, script) is not None
47 |
--------------------------------------------------------------------------------
/src/juv/_remove.py:
--------------------------------------------------------------------------------
1 | import tempfile
2 | import typing
3 | from pathlib import Path
4 |
5 | import jupytext
6 |
7 | from ._nbutils import code_cell, write_ipynb
8 | from ._pep723 import includes_inline_metadata
9 | from ._utils import find
10 | from ._uv import uv
11 |
12 |
13 | def remove(
14 | path: Path,
15 | *,
16 | packages: typing.Sequence[str],
17 | ) -> None:
18 | notebook = jupytext.read(path, fmt="ipynb")
19 | lockfile_contents = notebook.get("metadata", {}).get("uv.lock")
20 |
21 | # need a reference so we can modify the cell["source"]
22 | cell = find(
23 | lambda cell: (
24 | cell["cell_type"] == "code"
25 | and includes_inline_metadata("".join(cell["source"]))
26 | ),
27 | notebook["cells"],
28 | )
29 |
30 | if cell is None:
31 | notebook["cells"].insert(0, code_cell("", hidden=True))
32 | cell = notebook["cells"][0]
33 |
34 | with tempfile.NamedTemporaryFile(
35 | mode="w+",
36 | delete=True,
37 | suffix=".py",
38 | dir=path.parent,
39 | encoding="utf-8",
40 | ) as f:
41 | lockfile = Path(f"{f.name}.lock")
42 | f.write(cell["source"].strip())
43 | f.flush()
44 |
45 | if lockfile_contents:
46 | lockfile.write_text(lockfile_contents)
47 |
48 | uv(
49 | [
50 | "remove",
51 | "--script",
52 | str(f.name),
53 | *packages,
54 | ],
55 | check=True,
56 | )
57 | f.seek(0)
58 | cell["source"] = f.read().strip()
59 |
60 | if lockfile.exists():
61 | notebook["metadata"]["uv.lock"] = lockfile.read_text(encoding="utf-8")
62 | lockfile.unlink(missing_ok=True)
63 |
64 | write_ipynb(notebook, path.with_suffix(".ipynb"))
65 |
--------------------------------------------------------------------------------
/src/juv/_run.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | import typing
5 |
6 | import jupytext
7 | import rich
8 |
9 | from ._nbutils import code_cell, write_ipynb
10 | from ._pep723 import extract_inline_meta
11 | from ._run_template import Runtime, prepare_run_script_and_uv_run_args
12 |
13 | if typing.TYPE_CHECKING:
14 | from pathlib import Path
15 |
16 |
17 | def load_script_notebook(fp: Path) -> dict:
18 | script = fp.read_text(encoding="utf-8")
19 | # we could read the whole thing with jupytext,
20 | # but is nice to ensure the script meta is at the top in it's own
21 | # cell (that we can hide by default in JupyterLab)
22 | inline_meta, script = extract_inline_meta(script)
23 | notebook = jupytext.reads(script.strip())
24 | if inline_meta:
25 | inline_meta_cell = code_cell(inline_meta.strip(), hidden=True)
26 | notebook["cells"].insert(0, inline_meta_cell)
27 | return notebook
28 |
29 |
30 | def to_notebook(fp: Path) -> tuple[str | None, dict]:
31 | if fp.suffix == ".py":
32 | nb = load_script_notebook(fp)
33 | elif fp.suffix == ".ipynb":
34 | nb = jupytext.read(fp, fmt="ipynb")
35 | else:
36 | msg = f"Unsupported file extension: {fp.suffix}"
37 | raise ValueError(msg)
38 |
39 | for cell in filter(lambda c: c["cell_type"] == "code", nb.get("cells", [])):
40 | meta, _ = extract_inline_meta("".join(cell["source"]))
41 | if meta:
42 | return meta, nb
43 |
44 | return None, nb
45 |
46 |
47 | def run( # noqa: PLR0913
48 | *,
49 | path: Path,
50 | jupyter: str,
51 | python: str | None,
52 | with_args: typing.Sequence[str],
53 | jupyter_args: typing.Sequence[str],
54 | mode: str,
55 | ) -> None:
56 | """Launch a notebook or script."""
57 | runtime = Runtime.try_from_specifier(jupyter)
58 | meta, nb = to_notebook(path)
59 | lockfile_contents = nb.get("metadata", {}).get("uv.lock")
60 |
61 | if path.suffix == ".py":
62 | path = path.with_suffix(".ipynb")
63 | write_ipynb(nb, path)
64 | rich.print(
65 | f"Converted script to notebook `[cyan]{path.resolve().absolute()}[/cyan]`",
66 | )
67 |
68 | target = path.resolve()
69 |
70 | script, args = prepare_run_script_and_uv_run_args(
71 | runtime=runtime,
72 | target=target,
73 | meta=meta or "",
74 | python=python,
75 | with_args=with_args,
76 | jupyter_args=jupyter_args,
77 | no_project=True,
78 | mode=mode,
79 | )
80 |
81 | # change to the directory of the script/notebook before running uv
82 | os.chdir(target.parent)
83 |
84 | if mode == "dry":
85 | print(f"uv {' '.join(args)}") # noqa: T201
86 |
87 | elif mode == "managed":
88 | from ._run_managed import run as run_managed
89 |
90 | run_managed(
91 | script=script,
92 | args=args,
93 | filename=str(path),
94 | lockfile_contents=lockfile_contents,
95 | dir=target.parent,
96 | )
97 | else:
98 | from ._run_replace import run as run_replace
99 |
100 | run_replace(
101 | script=script,
102 | args=args,
103 | lockfile_contents=lockfile_contents,
104 | dir=target.parent,
105 | )
106 |
--------------------------------------------------------------------------------
/src/juv/_run_managed.py:
--------------------------------------------------------------------------------
1 | """Experimental UI wrapper that provides a minimal, consistent terminal interface.
2 |
3 | Manages the Jupyter process lifecycle (rather than replacing the process)
4 | and displays formatted URLs, while handling graceful shutdown.
5 | Supports Jupyter Lab, Notebook, and NBClassic variants.
6 | """
7 |
8 | from __future__ import annotations
9 |
10 | import atexit
11 | import os
12 | import re
13 | import signal
14 | import subprocess
15 | import tempfile
16 | import time
17 | from pathlib import Path
18 | from queue import Queue
19 | from threading import Thread
20 |
21 | from rich.console import Console
22 | from uv import find_uv_bin
23 |
24 | from ._version import __version__
25 |
26 |
27 | def extract_url(log_line: str) -> str:
28 | match = re.search(r"http://[^\s]+", log_line)
29 | return "" if not match else match.group(0)
30 |
31 |
32 | def format_url(url: str, path: str) -> str:
33 | if "?" in url:
34 | url, query = url.split("?", 1)
35 | url = url.removesuffix("/tree")
36 | return format_url(url, path) + f"[dim]?{query}[/dim]"
37 | url = url.removesuffix("/tree")
38 | return f"[cyan]{re.sub(r':\d+', r'[b]\g<0>[/b]', url)}{path}[/cyan]"
39 |
40 |
41 | def process_output(
42 | console: Console,
43 | filename: str,
44 | output_queue: Queue,
45 | ) -> None:
46 | status = console.status("Running uv...", spinner="dots")
47 | status.start()
48 | start = time.time()
49 |
50 | name_version: None | tuple[str, str] = None
51 |
52 | while name_version is None:
53 | line = output_queue.get()
54 | if line.startswith("Reading inline script"):
55 | continue
56 |
57 | if line.startswith("JUV_MANGED="):
58 | name_version = line[len("JUV_MANGED=") :].split(",")
59 | else:
60 | console.print(line)
61 |
62 | jupyter, version = name_version
63 |
64 | path = {
65 | "jupyterlab": f"/tree/{filename}",
66 | "notebook": f"/notebooks/{filename}",
67 | "nbclassic": f"/notebooks/{filename}",
68 | }[jupyter]
69 |
70 | def display(url: str) -> None:
71 | end = time.time()
72 | elapsed_ms = (end - start) * 1000
73 |
74 | time_str = (
75 | f"[b]{elapsed_ms:.0f}[/b] ms"
76 | if elapsed_ms < 1000 # noqa: PLR2004
77 | else f"[b]{elapsed_ms / 1000:.1f}[/b] s"
78 | )
79 |
80 | console.print(
81 | f"""
82 | [green][b]juv[/b] v{__version__}[/green] [dim]ready in[/dim] [white]{time_str}[/white]
83 |
84 | [green b]➜[/green b] [b]Local:[/b] {url}
85 | [dim][green b]➜[/green b] [b]Jupyter:[/b] {jupyter} v{version}[/dim]
86 | """,
87 | highlight=False,
88 | no_wrap=True,
89 | )
90 |
91 | url = None
92 | server_started = False
93 |
94 | while url is None:
95 | line = output_queue.get()
96 |
97 | if line.startswith("[") and not server_started:
98 | status.update("Jupyter server started", spinner="dots")
99 | server_started = True
100 |
101 | if "http://" in line:
102 | url = format_url(extract_url(line), path)
103 |
104 | status.stop()
105 | display(url)
106 |
107 |
108 | def run(
109 | script: str,
110 | args: list[str],
111 | filename: str,
112 | lockfile_contents: str | None,
113 | dir: Path, # noqa: A002
114 | ) -> None:
115 | console = Console()
116 | output_queue = Queue()
117 |
118 | with tempfile.NamedTemporaryFile(
119 | mode="w+",
120 | delete=False,
121 | suffix=".py",
122 | dir=dir,
123 | prefix="juv.tmp.",
124 | encoding="utf-8",
125 | ) as f:
126 | script_path = Path(f.name)
127 | lockfile = Path(f"{f.name}.lock")
128 | f.write(script)
129 | f.flush()
130 | env = os.environ.copy()
131 |
132 | if lockfile_contents:
133 | lockfile.write_text(lockfile_contents)
134 | env["JUV_LOCKFILE_PATH"] = str(lockfile)
135 |
136 | process = subprocess.Popen( # noqa: S603
137 | [os.fsdecode(find_uv_bin()), *args, f.name],
138 | stdout=subprocess.PIPE,
139 | stderr=subprocess.STDOUT,
140 | preexec_fn=os.setsid, # noqa: PLW1509
141 | text=True,
142 | env=env,
143 | )
144 |
145 | output_thread = Thread(
146 | target=process_output,
147 | args=(console, filename, output_queue),
148 | )
149 | output_thread.start()
150 |
151 | try:
152 | while True and process.stdout:
153 | line = process.stdout.readline()
154 | if not line and process.poll() is not None:
155 | break
156 | output_queue.put(line)
157 | except KeyboardInterrupt:
158 | with console.status("Shutting down..."):
159 | os.killpg(os.getpgid(process.pid), signal.SIGTERM)
160 | finally:
161 | lockfile.unlink(missing_ok=True)
162 | output_queue.put(None)
163 | output_thread.join()
164 |
165 | # ensure the process is fully cleaned up before deleting script
166 | process.wait()
167 | # unlink after process has exited
168 | atexit.register(lambda: script_path.unlink(missing_ok=True))
169 |
--------------------------------------------------------------------------------
/src/juv/_run_replace.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import atexit
4 | import os
5 | import signal
6 | import subprocess
7 | import sys
8 | import tempfile
9 | from pathlib import Path
10 |
11 | from uv import find_uv_bin
12 |
13 | IS_WINDOWS = sys.platform.startswith("win")
14 |
15 |
16 | def run(script: str, args: list[str], lockfile_contents: str | None, dir: Path) -> None: # noqa: A002
17 | with tempfile.NamedTemporaryFile(
18 | mode="w+",
19 | delete=False,
20 | suffix=".py",
21 | dir=dir,
22 | prefix="juv.tmp.",
23 | encoding="utf-8",
24 | ) as f:
25 | script_path = Path(f.name)
26 | lockfile = Path(f"{f.name}.lock")
27 | f.write(script)
28 | f.flush()
29 |
30 | env = os.environ.copy()
31 |
32 | if lockfile_contents:
33 | # Write the contents so UV picks it up
34 | lockfile.write_text(lockfile_contents)
35 | # Forward path to underlying process.
36 | # We rewrite the lockfile entry (if necessary) within that process.
37 | env["JUV_LOCKFILE_PATH"] = str(lockfile)
38 |
39 | if not IS_WINDOWS:
40 | process = subprocess.Popen( # noqa: S603
41 | [os.fsdecode(find_uv_bin()), *args, f.name],
42 | stdout=sys.stdout,
43 | stderr=sys.stderr,
44 | preexec_fn=os.setsid, # noqa: PLW1509
45 | env=env,
46 | )
47 | else:
48 | process = subprocess.Popen( # noqa: S603
49 | [os.fsdecode(find_uv_bin()), *args, f.name],
50 | stdout=sys.stdout,
51 | stderr=sys.stderr,
52 | creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
53 | env=env,
54 | )
55 |
56 | try:
57 | process.wait()
58 | except KeyboardInterrupt:
59 | if not IS_WINDOWS:
60 | os.killpg(os.getpgid(process.pid), signal.SIGTERM)
61 | else:
62 | os.kill(process.pid, signal.SIGTERM)
63 | finally:
64 | lockfile.unlink(missing_ok=True)
65 |
66 | # ensure the process is fully cleaned up before deleting script
67 | process.wait()
68 | atexit.register(lambda: script_path.unlink(missing_ok=True))
69 |
--------------------------------------------------------------------------------
/src/juv/_run_template.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import pathlib
4 | import typing
5 | from dataclasses import dataclass
6 |
7 | RuntimeName = typing.Literal["notebook", "lab", "nbclassic"]
8 |
9 |
10 | def is_notebook_kind(kind: str) -> typing.TypeGuard[RuntimeName]:
11 | return kind in {"notebook", "lab", "nbclassic"}
12 |
13 |
14 | @dataclass
15 | class Runtime:
16 | name: RuntimeName
17 | version: str | None = None
18 |
19 | @classmethod
20 | def try_from_specifier(cls, value: str) -> Runtime:
21 | if "@" in value:
22 | parts = value.split("@")
23 | elif "==" in value:
24 | parts = value.split("==")
25 | else:
26 | parts = [value]
27 |
28 | if len(parts) == 2 and is_notebook_kind(parts[0]): # noqa: PLR2004
29 | return Runtime(parts[0], parts[1])
30 |
31 | if len(parts) == 1 and is_notebook_kind(parts[0]):
32 | return Runtime(parts[0])
33 |
34 | msg = f"Invalid runtime specifier: {value}"
35 | raise ValueError(msg)
36 |
37 | def script_template(self) -> str:
38 | if self.name == "lab":
39 | return LAB
40 | if self.name == "notebook":
41 | if self.version and self.version.startswith("6"):
42 | return NOTEBOOK_6
43 | return NOTEBOOK
44 | if self.name == "nbclassic":
45 | return NBCLASSIC
46 | msg = f"Invalid self: {self.name}"
47 | raise ValueError(msg)
48 |
49 | def as_with_arg(self) -> str:
50 | # lab is actually jupyterlab
51 | with_ = "jupyterlab" if self.name == "lab" else self.name
52 |
53 | # append version if present
54 | if self.version:
55 | with_ += f"=={self.version}"
56 |
57 | # notebook v6 requires setuptools
58 | if self.name == "notebook" and self.version:
59 | with_ += ",setuptools"
60 |
61 | return with_
62 |
63 |
64 | LAB = """
65 | {meta}
66 | from jupyterlab.labapp import main
67 |
68 | {setup}
69 | setup(r"{notebook}", jupyter="jupyterlab", run_mode="{run_mode}")
70 |
71 | sys.argv = ["jupyter-lab", r"{notebook}", *{args}]
72 | main()
73 | """
74 |
75 | NOTEBOOK = """
76 | {meta}
77 | from notebook.app import main
78 |
79 | {setup}
80 | setup(r"{notebook}", jupyter="notebook", run_mode="{run_mode}")
81 |
82 | sys.argv = ["jupyter-notebook", r"{notebook}", *{args}]
83 | main()
84 | """
85 |
86 | NOTEBOOK_6 = """
87 | {meta}
88 | from notebook.notebookapp import main
89 |
90 | {setup}
91 | setup(r"{notebook}", jupyter="notebook", run_mode="{run_mode}")
92 |
93 | sys.argv = ["jupyter-notebook", r"{notebook}", *{args}]
94 | main()
95 | """
96 |
97 | NBCLASSIC = """
98 | {meta}
99 | from nbclassic.notebookapp import main
100 |
101 | {setup}
102 | setup(r"{notebook}", jupyter="nbclassic", run_mode="{run_mode}")
103 |
104 | sys.argv = ["jupyter-nbclassic", r"{notebook}", *{args}]
105 | main()
106 | """
107 |
108 |
109 | def prepare_run_script_and_uv_run_args( # noqa: PLR0913
110 | *,
111 | runtime: Runtime,
112 | meta: str,
113 | target: pathlib.Path,
114 | python: str | None,
115 | with_args: typing.Sequence[str],
116 | jupyter_args: typing.Sequence[str],
117 | no_project: bool,
118 | mode: str,
119 | ) -> tuple[str, list[str]]:
120 | script = (
121 | "# This is a temporary script generated by `juv run`.\n\n"
122 | + runtime.script_template().format(
123 | meta=meta,
124 | notebook=target,
125 | args=jupyter_args,
126 | setup=(pathlib.Path(__file__).parent / "static" / "setup.py").read_text(),
127 | run_mode=mode,
128 | )
129 | )
130 | args = [
131 | "run",
132 | *(["--no-project"] if no_project else []),
133 | *([f"--python={python}"] if python else []),
134 | f"--with={runtime.as_with_arg()}",
135 | *(["--with=" + ",".join(with_args)] if with_args else []),
136 | "--script",
137 | ]
138 | return script, args
139 |
--------------------------------------------------------------------------------
/src/juv/_stamp.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | import subprocess
5 | import typing
6 | from contextlib import suppress
7 | from dataclasses import dataclass
8 |
9 | import jupytext
10 | import tomlkit
11 | from whenever import Date, OffsetDateTime, SystemDateTime, ZonedDateTime
12 |
13 | from ._nbutils import write_ipynb
14 | from ._pep723 import (
15 | extract_inline_meta,
16 | includes_inline_metadata,
17 | parse_inline_script_metadata,
18 | )
19 |
20 | if typing.TYPE_CHECKING:
21 | from pathlib import Path
22 |
23 |
24 | @dataclass
25 | class DeleteAction:
26 | previous: str | None
27 |
28 |
29 | @dataclass
30 | class CreateAction:
31 | value: str
32 |
33 |
34 | @dataclass
35 | class UpdateAction:
36 | previous: str
37 | value: str
38 |
39 |
40 | Action = typing.Union[DeleteAction, CreateAction, UpdateAction]
41 |
42 |
43 | def parse_timestamp(date_str: str) -> OffsetDateTime:
44 | with suppress(ValueError):
45 | return OffsetDateTime.parse_common_iso(date_str)
46 |
47 | try:
48 | return OffsetDateTime.parse_rfc3339(date_str)
49 | except ValueError as err:
50 | msg = f"'{date_str}' could not be parsed as a valid timestamp."
51 | raise ValueError(msg) from err
52 |
53 |
54 | def parse_date(date_str: str) -> OffsetDateTime:
55 | """Parse a common ISO 8601 date string (using the system's local timezone).
56 |
57 | Defaults to midnight in the local timezone.
58 | """
59 | try:
60 | date = Date.parse_common_iso(date_str).add(days=1)
61 | except ValueError as err:
62 | msg = f"'{date_str}' could not be parsed as a valid date."
63 | raise ValueError(msg) from err
64 |
65 | if "JUV_TZ" in os.environ:
66 | # used in tests
67 | dt = ZonedDateTime(date.year, date.month, date.day, tz=os.environ["JUV_TZ"])
68 | else:
69 | dt = SystemDateTime(date.year, date.month, date.day)
70 |
71 | return dt.to_fixed_offset()
72 |
73 |
74 | def get_git_timestamp(rev: str) -> OffsetDateTime:
75 | """Get the ISO 8601 timestamp of a Git revision."""
76 | ts = subprocess.check_output( # noqa: S603
77 | ["git", "show", "-s", "--format=%cI", rev], # noqa: S607
78 | text=True,
79 | )
80 | return OffsetDateTime.parse_rfc3339(ts.strip())
81 |
82 |
83 | def update_inline_metadata(
84 | script: str, dt: OffsetDateTime | None
85 | ) -> tuple[str, Action]:
86 | meta_comment, _ = extract_inline_meta(script)
87 |
88 | if meta_comment is None:
89 | msg = "No PEP 723 metadata block found."
90 | raise ValueError(msg)
91 |
92 | toml = parse_inline_script_metadata(meta_comment)
93 |
94 | if toml is None:
95 | msg = "No TOML metadata found in the PEP 723 metadata block."
96 | raise ValueError(msg)
97 |
98 | meta = tomlkit.parse(toml)
99 | tool = meta.get("tool")
100 | if tool is None:
101 | tool = meta["tool"] = tomlkit.table()
102 |
103 | uv = tool.get("uv")
104 | if uv is None:
105 | uv = tool["uv"] = tomlkit.table()
106 |
107 | if dt is None:
108 | action = DeleteAction(previous=uv.pop("exclude-newer", None))
109 | if not uv:
110 | tool.pop("uv")
111 | if not tool:
112 | meta.pop("tool")
113 | else:
114 | previous = uv.get("exclude-newer", None)
115 | current = dt.format_common_iso()
116 | uv["exclude-newer"] = current
117 | action = (
118 | CreateAction(value=current)
119 | if previous is None
120 | else UpdateAction(previous=previous, value=current)
121 | )
122 |
123 | new_toml = tomlkit.dumps(meta).strip()
124 | new_meta_comment = "\n".join(
125 | [
126 | "# /// script",
127 | *[f"# {line}" if line else "#" for line in new_toml.splitlines()],
128 | "# ///",
129 | ]
130 | )
131 | return script.replace(meta_comment, new_meta_comment), action
132 |
133 |
134 | def stamp( # noqa: PLR0913
135 | path: Path,
136 | *,
137 | timestamp: str | None,
138 | latest: bool,
139 | rev: str | None,
140 | clear: bool,
141 | date: str | None,
142 | ) -> Action:
143 | """Update the 'uv.tool.exclude-newer' metadata in a script or notebook."""
144 | # Determine the timestamp to use
145 | action = None
146 | if clear:
147 | dt = None
148 | elif latest:
149 | dt = get_git_timestamp("HEAD")
150 | elif rev:
151 | dt = get_git_timestamp(rev)
152 | elif timestamp:
153 | dt = parse_timestamp(timestamp)
154 | elif date:
155 | dt = parse_date(date)
156 | else:
157 | # Default to the current time
158 | dt = SystemDateTime.now().to_fixed_offset()
159 |
160 | if path.suffix == ".ipynb":
161 | nb = jupytext.read(path)
162 |
163 | for cell in filter(lambda c: c.cell_type == "code", nb.cells):
164 | source = "".join(cell.source)
165 | if includes_inline_metadata(source):
166 | source, action = update_inline_metadata(source, dt)
167 | cell.source = source.splitlines(keepends=True)
168 | break
169 |
170 | if action is None:
171 | msg = "No PEP 723 metadata block found."
172 | raise ValueError(msg)
173 |
174 | write_ipynb(nb, path)
175 | return action
176 |
177 | script, action = update_inline_metadata(path.read_text(encoding="utf-8"), dt)
178 | path.write_text(script)
179 | return action
180 |
--------------------------------------------------------------------------------
/src/juv/_tree.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import tempfile
3 | from pathlib import Path
4 |
5 | import jupytext
6 |
7 | from ._nbutils import code_cell, write_ipynb
8 | from ._pep723 import includes_inline_metadata
9 | from ._utils import find
10 | from ._uv import uv
11 |
12 |
13 | def tree(
14 | path: Path,
15 | ) -> None:
16 | notebook = jupytext.read(path, fmt="ipynb")
17 | lockfile_contents = notebook.get("metadata", {}).get("uv.lock")
18 |
19 | # need a reference so we can modify the cell["source"]
20 | cell = find(
21 | lambda cell: (
22 | cell["cell_type"] == "code"
23 | and includes_inline_metadata("".join(cell["source"]))
24 | ),
25 | notebook["cells"],
26 | )
27 |
28 | if cell is None:
29 | notebook["cells"].insert(0, code_cell("", hidden=True))
30 | cell = notebook["cells"][0]
31 |
32 | with tempfile.NamedTemporaryFile(
33 | mode="w+",
34 | delete=True,
35 | suffix=".py",
36 | dir=path.parent,
37 | encoding="utf-8",
38 | ) as f:
39 | lockfile = Path(f"{f.name}.lock")
40 |
41 | f.write(cell["source"].strip())
42 | f.flush()
43 |
44 | if lockfile_contents:
45 | lockfile.write_text(lockfile_contents)
46 |
47 | result = uv(["tree", "--script", f.name], check=True)
48 |
49 | sys.stdout.write(result.stdout.decode("utf-8"))
50 |
51 | if lockfile.exists():
52 | notebook.metadata["uv.lock"] = lockfile.read_text(encoding="utf-8")
53 | write_ipynb(notebook, path)
54 | lockfile.unlink(missing_ok=True)
55 |
--------------------------------------------------------------------------------
/src/juv/_utils.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import typing
4 |
5 | T = typing.TypeVar("T")
6 |
7 |
8 | def find(cb: typing.Callable[[T], bool], items: list[T]) -> T | None:
9 | """Find the first item in a list that satisfies a condition.
10 |
11 | Parameters
12 | ----------
13 | cb : Callable[[T], bool]
14 | The condition to satisfy.
15 | items : list[T]
16 | The list to search.
17 |
18 | Returns
19 | -------
20 | T | None
21 | The first item that satisfies the condition, or None if no item does.
22 |
23 | """
24 | return next((item for item in items if cb(item)), None)
25 |
--------------------------------------------------------------------------------
/src/juv/_uv.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | import subprocess
5 |
6 | from uv import find_uv_bin
7 |
8 |
9 | def uv(
10 | args: list[str],
11 | *,
12 | check: bool,
13 | env: dict | None = None,
14 | ) -> subprocess.CompletedProcess:
15 | """Invoke a uv subprocess and return the result.
16 |
17 | Parameters
18 | ----------
19 | args : list[str]
20 | The arguments to pass to the subprocess.
21 |
22 | check : bool
23 | Whether to raise an exception if the subprocess returns a non-zero exit code.
24 |
25 | env : dict | None
26 | The system enviroment to run the subprocess.
27 |
28 | Returns
29 | -------
30 | subprocess.CompletedProcess
31 | The result of the subprocess.
32 |
33 | """
34 | uv = os.fsdecode(find_uv_bin())
35 | return subprocess.run( # noqa: S603
36 | [uv, *args], capture_output=True, check=check, env=env or os.environ
37 | )
38 |
--------------------------------------------------------------------------------
/src/juv/_venv.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | import tempfile
5 | import typing
6 | from pathlib import Path
7 |
8 | import jupytext
9 | import rich
10 | from rich.console import Console
11 |
12 | from juv._uv import uv
13 |
14 | from ._nbutils import code_cell, write_ipynb
15 | from ._pep723 import includes_inline_metadata
16 | from ._utils import find
17 |
18 | if typing.TYPE_CHECKING:
19 | import pathlib
20 |
21 |
22 | def sync(
23 | path: pathlib.Path,
24 | *,
25 | python: str | None,
26 | frozen: bool = False,
27 | env_path: pathlib.Path,
28 | ) -> str:
29 | env = os.environ.copy()
30 | env["VIRTUAL_ENV"] = str(env_path)
31 |
32 | if path.suffix == ".py":
33 | # just defer to uv behavior
34 | result = uv(
35 | [
36 | "sync",
37 | *(["--python", python] if python else []),
38 | "--active",
39 | "--script",
40 | str(path),
41 | ],
42 | check=True,
43 | env=env,
44 | )
45 | return result.stderr.decode("utf8")
46 |
47 | notebook = jupytext.read(path, fmt="ipynb")
48 | lockfile_contents = notebook.get("metadata", {}).get("uv.lock")
49 |
50 | # need a reference so we can modify the cell["source"]
51 | cell = find(
52 | lambda cell: (
53 | cell["cell_type"] == "code"
54 | and includes_inline_metadata("".join(cell["source"]))
55 | ),
56 | notebook["cells"],
57 | )
58 |
59 | if cell is None:
60 | notebook["cells"].insert(0, code_cell("", hidden=True))
61 | cell = notebook["cells"][0]
62 |
63 | with tempfile.NamedTemporaryFile(
64 | mode="w+",
65 | delete=True,
66 | suffix=".py",
67 | dir=path.parent,
68 | encoding="utf-8",
69 | ) as f:
70 | lockfile = Path(f"{f.name}.lock")
71 |
72 | f.write(cell["source"].strip())
73 | f.flush()
74 |
75 | if lockfile_contents:
76 | lockfile.write_text(lockfile_contents)
77 |
78 | result = uv(
79 | [
80 | "sync",
81 | *(["--python", python] if python else []),
82 | "--active",
83 | "--script",
84 | f.name,
85 | ],
86 | env=env,
87 | check=True,
88 | )
89 |
90 | if not frozen and lockfile.exists():
91 | notebook.metadata["uv.lock"] = lockfile.read_text(encoding="utf-8")
92 | write_ipynb(notebook, path)
93 | lockfile.unlink(missing_ok=True)
94 |
95 | return result.stderr.decode("utf8")
96 |
97 |
98 | def venv(
99 | *,
100 | source: pathlib.Path,
101 | python: str | None,
102 | path: pathlib.Path,
103 | no_kernel: bool,
104 | ) -> None:
105 | console = Console()
106 | rel_path = os.path.relpath(path.resolve(), Path.cwd())
107 | if path.exists():
108 | rich.print(f"Using notebook environment at: `[cyan]{rel_path}[/cyan]`")
109 | else:
110 | rich.print(f"Creating notebook environment at: `[cyan]{rel_path}[/cyan]`")
111 |
112 | uv_output = sync(source, python=python, env_path=path)
113 | env = os.environ.copy()
114 | env["VIRTUAL_ENV"] = str(path)
115 | if not no_kernel:
116 | uv(["pip", "install", "ipykernel"], env=env, check=True)
117 |
118 | for line in uv_output.split("\n"):
119 | if line.startswith((" +", " -", " ~")):
120 | prefix, suffix = line.split("==")
121 | symbol, name = prefix.strip().split(" ")
122 | color = {"+": "green", "-": "red", "~": "yellow"}[symbol]
123 | if name in IGNORE_PACKAGES:
124 | continue
125 | console.print(
126 | f" [{color}]{symbol}[/{color}] [bold]{name}[/bold][dim]=={suffix}[/dim]", # noqa: E501
127 | highlight=False,
128 | )
129 |
130 |
131 | # These packages are dependencies of `ipykernel` so they add a lot of noise to output.
132 | # We filter them out when reporting differences in the environment.
133 | IGNORE_PACKAGES = {
134 | "appnope",
135 | "asttokens",
136 | "comm",
137 | "debugpy",
138 | "decorator",
139 | "executing",
140 | "ipykernel",
141 | "ipython",
142 | "ipython-pygments-lexers",
143 | "jedi",
144 | "jupyter-client",
145 | "jupyter-core",
146 | "matplotlib-inline",
147 | "nest-asyncio",
148 | "packaging",
149 | "parso",
150 | "pexpect",
151 | "platformdirs",
152 | "prompt-toolkit",
153 | "psutil",
154 | "ptyprocess",
155 | "pure-eval",
156 | "pygments",
157 | "python-dateutil",
158 | "pyzmq",
159 | "six",
160 | "stack-data",
161 | "tornado",
162 | "traitlets",
163 | "wcwidth",
164 | }
165 |
--------------------------------------------------------------------------------
/src/juv/_version.py:
--------------------------------------------------------------------------------
1 | import importlib.metadata
2 |
3 | __version__ = importlib.metadata.version("juv")
4 |
--------------------------------------------------------------------------------
/src/juv/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/manzt/juv/30c6da32ccb1fbf3e4760ed1e4726b386feac61a/src/juv/py.typed
--------------------------------------------------------------------------------
/src/juv/static/setup.py:
--------------------------------------------------------------------------------
1 | import contextlib # noqa: D100, INP001
2 | import json
3 | import os
4 | import sys
5 | from pathlib import Path
6 |
7 |
8 | def find_jupyter_paths() -> "tuple[list[Path], list[Path]]":
9 | """Locate Jupyter directories within the current virtual environment.
10 |
11 | Ref: https://docs.jupyter.org/en/stable/use/jupyter-directories.html
12 | """
13 | jupyter_paths = [Path(sys.prefix) / "share" / "jupyter"]
14 | config_paths: "list[Path]" = [] # noqa: UP037
15 | for path in map(Path, sys.path):
16 | if path.name != "site-packages":
17 | continue
18 |
19 | if os.name == "nt": # noqa: SIM108
20 | # Windows: /Lib/site-packages → go up 2 levels
21 | venv = path.parent.parent
22 | else:
23 | # Unix: /lib/pythonX.Y/site-packages → go up 3 levels
24 | venv = path.parent.parent.parent
25 |
26 | jupyter_path = (venv / "share" / "jupyter").resolve()
27 | if jupyter_path.exists() and jupyter_path not in jupyter_paths:
28 | jupyter_paths.append(jupyter_path)
29 |
30 | config_path = (venv / "etc" / "jupyter").resolve()
31 | if config_path.exists() and config_path not in config_paths:
32 | config_paths.append(config_path)
33 |
34 | return jupyter_paths, config_paths
35 |
36 |
37 | def write_notebook_lockfile_contents_and_delete( # noqa: D103
38 | notebook: str,
39 | lockfile: "str | None",
40 | ) -> None:
41 | if not lockfile:
42 | return
43 |
44 | notebook_path = Path(notebook)
45 | lockfile_path = Path(lockfile)
46 |
47 | with notebook_path.open(encoding="utf-8") as f:
48 | nb = json.load(f)
49 |
50 | # Replace contents and rewrite notebook file before opening
51 | nb.setdefault("metadata", {})["uv.lock"] = lockfile_path.read_text("utf-8")
52 |
53 | with notebook_path.open(mode="w", encoding="utf-8") as f:
54 | json.dump(nb, f, ensure_ascii=False, indent=1)
55 | f.write("\\n")
56 |
57 | # delete the lock file
58 | lockfile_path.unlink(missing_ok=True)
59 |
60 |
61 | def setup(notebook: str, jupyter: str, run_mode: str) -> None: # noqa: D103
62 | write_notebook_lockfile_contents_and_delete(
63 | notebook,
64 | os.environ.get("JUV_LOCKFILE_PATH"),
65 | )
66 |
67 | # relay notebook info to managed session
68 | if run_mode == "managed":
69 | import importlib.metadata
70 |
71 | version = importlib.metadata.version(jupyter)
72 | print(f"JUV_MANGED={jupyter},{version}", file=sys.stderr) # noqa: T201
73 |
74 | # wire up juptyer dirs for this enviroment
75 | jupyter_paths, jupyter_config_paths = find_jupyter_paths()
76 | os.environ["JUPYTER_PATH"] = os.pathsep.join(map(str, jupyter_paths))
77 | os.environ["JUPYTER_CONFIG_PATH"] = os.pathsep.join(map(str, jupyter_config_paths))
78 |
79 | # delete this temporary script
80 | with contextlib.suppress(PermissionError):
81 | # FIXME: On Windows, a running script cannot be unlinked
82 | # because it's locked by the process. Therefore, we can't
83 | # cleanup the file until after the Jupyter server exists
84 | # like on unix.
85 | Path(str(__file__)).unlink(missing_ok=True)
86 |
--------------------------------------------------------------------------------
/tests/test_e2e.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import pathlib
4 | import subprocess
5 | import sys
6 | import time
7 | import typing
8 |
9 | import pytest
10 |
11 | pytest.importorskip("playwright")
12 |
13 |
14 | if typing.TYPE_CHECKING:
15 | from playwright.sync_api import Page
16 |
17 | SELF_DIR = pathlib.Path(__file__).parent
18 | ROOT = SELF_DIR / ".."
19 |
20 |
21 | @pytest.fixture(autouse=True)
22 | def notebook() -> typing.Generator[pathlib.Path]:
23 | path = (ROOT / "smoke.ipynb").resolve()
24 | yield path
25 | path.unlink(missing_ok=True)
26 |
27 |
28 | def juv(args: list[str], *, wait_and_check: bool = True) -> subprocess.Popen[bytes]:
29 | process = subprocess.Popen( # noqa: S603
30 | ["uv", "run", "juv", *args], # noqa: S607
31 | cwd=ROOT,
32 | stdout=sys.stdout,
33 | stderr=sys.stderr,
34 | )
35 | if wait_and_check:
36 | exit_code = process.wait(2)
37 | if exit_code != 0:
38 | msg = f"juv command failed: {args}, exit code: {exit_code}"
39 | raise RuntimeError(msg)
40 | return process
41 |
42 |
43 | def test_juv_run(page: Page, notebook: pathlib.Path) -> None:
44 | juv(["init", str(notebook)])
45 | juv(["add", str(notebook), "attrs"])
46 | process = juv(
47 | [
48 | "run",
49 | str(notebook),
50 | "--",
51 | "--host=127.0.0.1",
52 | "--port=8888",
53 | "--ServerApp.token=''",
54 | "--ServerApp.password=''",
55 | "--no-browser",
56 | ],
57 | wait_and_check=False,
58 | )
59 | # FIXME: nicer way to wait for the server to start
60 | time.sleep(1)
61 | url = "http://127.0.0.1:8888/lab"
62 | page.goto(url)
63 | # Menu
64 | page.get_by_text("File", exact=True).click()
65 | page.get_by_role("menu").get_by_text("Shut Down", exact=True).click()
66 | # Modal
67 | page.get_by_role("button", name="Shut Down").click()
68 | process.wait()
69 |
--------------------------------------------------------------------------------
/tests/test_juv.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import contextlib
4 | import os
5 | import pathlib
6 | import re
7 | import sys
8 |
9 | import jupytext
10 | import pytest
11 | from click.testing import CliRunner, Result
12 | from inline_snapshot import snapshot
13 | from jupytext.pandoc import tempfile
14 | from nbformat.v4.nbbase import new_code_cell, new_notebook
15 |
16 | from juv import cli
17 | from juv._nbutils import write_ipynb
18 | from juv._pep723 import parse_inline_script_metadata
19 | from juv._run import to_notebook
20 | from juv._uv import uv
21 |
22 | SELF_DIR = pathlib.Path(__file__).parent
23 |
24 |
25 | # Custom TemporaryDirectory for Python < 3.10
26 | # TODO: Use `ignore_cleanup_errors=True` in Python 3.10+
27 | class TemporaryDirectoryIgnoreErrors(tempfile.TemporaryDirectory):
28 | def cleanup(self) -> None:
29 | with contextlib.suppress(Exception):
30 | super().cleanup()
31 |
32 |
33 | def invoke(args: list[str], uv_python: str = "3.13") -> Result:
34 | return CliRunner().invoke(
35 | cli,
36 | args,
37 | env={
38 | **os.environ,
39 | "UV_PYTHON": uv_python,
40 | "JUV_RUN_MODE": "dry",
41 | "JUV_JUPYTER": "lab",
42 | "JUV_TZ": "America/New_York",
43 | "UV_EXCLUDE_NEWER": "2023-02-01T00:00:00-02:00",
44 | },
45 | )
46 |
47 |
48 | @pytest.fixture
49 | def sample_script() -> str:
50 | return """
51 | # /// script
52 | # dependencies = ["numpy", "pandas"]
53 | # requires-python = ">=3.8"
54 | # ///
55 |
56 | import numpy as np
57 | import pandas as pd
58 |
59 | print('Hello, world!')
60 | """
61 |
62 |
63 | def test_parse_pep723_meta(sample_script: str) -> None:
64 | meta = parse_inline_script_metadata(sample_script)
65 | assert meta == snapshot("""\
66 | dependencies = ["numpy", "pandas"]
67 | requires-python = ">=3.8"
68 | """)
69 |
70 |
71 | def test_parse_pep723_meta_no_meta() -> None:
72 | script_without_meta = "print('Hello, world!')"
73 | assert parse_inline_script_metadata(script_without_meta) is None
74 |
75 |
76 | def filter_ids(output: str) -> str:
77 | return re.sub(r'"id": "[a-zA-Z0-9-]+"', '"id": ""', output)
78 |
79 |
80 | def test_to_notebook_script(tmp_path: pathlib.Path) -> None:
81 | script = tmp_path / "script.py"
82 | script.write_text("""# /// script
83 | # dependencies = ["numpy"]
84 | # requires-python = ">=3.8"
85 | # ///
86 |
87 |
88 | import numpy as np
89 |
90 | # %%
91 | print('Hello, numpy!')
92 | arr = np.array([1, 2, 3])""")
93 |
94 | meta, nb = to_notebook(script)
95 | output = jupytext.writes(nb, fmt="ipynb")
96 | output = filter_ids(output)
97 |
98 | assert (meta, output) == snapshot(
99 | (
100 | """\
101 | # /// script
102 | # dependencies = ["numpy"]
103 | # requires-python = ">=3.8"
104 | # ///\
105 | """,
106 | """\
107 | {
108 | "cells": [
109 | {
110 | "cell_type": "code",
111 | "execution_count": null,
112 | "id": "",
113 | "metadata": {
114 | "jupyter": {
115 | "source_hidden": true
116 | }
117 | },
118 | "outputs": [],
119 | "source": [
120 | "# /// script\\n",
121 | "# dependencies = [\\"numpy\\"]\\n",
122 | "# requires-python = \\">=3.8\\"\\n",
123 | "# ///"
124 | ]
125 | },
126 | {
127 | "cell_type": "code",
128 | "execution_count": null,
129 | "id": "",
130 | "metadata": {},
131 | "outputs": [],
132 | "source": [
133 | "import numpy as np"
134 | ]
135 | },
136 | {
137 | "cell_type": "code",
138 | "execution_count": null,
139 | "id": "",
140 | "metadata": {},
141 | "outputs": [],
142 | "source": [
143 | "print('Hello, numpy!')\\n",
144 | "arr = np.array([1, 2, 3])"
145 | ]
146 | }
147 | ],
148 | "metadata": {
149 | "jupytext": {
150 | "cell_metadata_filter": "-all",
151 | "main_language": "python",
152 | "notebook_metadata_filter": "-all"
153 | }
154 | },
155 | "nbformat": 4,
156 | "nbformat_minor": 5
157 | }\
158 | """,
159 | ),
160 | )
161 |
162 |
163 | def test_run_no_notebook(
164 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
165 | ) -> None:
166 | monkeypatch.chdir(tmp_path)
167 |
168 | result = invoke(["run", "test.ipynb"])
169 | assert result.exit_code == 2 # noqa: PLR2004
170 | assert result.stdout == snapshot("""\
171 | Usage: cli run [OPTIONS] FILE [JUPYTER_ARGS]...
172 | Try 'cli run --help' for help.
173 |
174 | Error: Invalid value for 'FILE': Path 'test.ipynb' does not exist.
175 | """)
176 |
177 |
178 | def test_run_basic(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
179 | monkeypatch.chdir(tmp_path)
180 | invoke(["init", "test.ipynb"])
181 | result = invoke(["run", "test.ipynb"])
182 | assert result.exit_code == 0
183 | assert result.stdout == snapshot("uv run --no-project --with=jupyterlab --script\n")
184 |
185 |
186 | def test_run_python_override(
187 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
188 | ) -> None:
189 | monkeypatch.chdir(tmp_path)
190 | invoke(["init", "test.ipynb"])
191 |
192 | result = invoke(["run", "--python=3.12", "test.ipynb"])
193 | assert result.exit_code == 0
194 | assert result.stdout == snapshot(
195 | "uv run --no-project --python=3.12 --with=jupyterlab --script\n"
196 | )
197 |
198 |
199 | def test_run_with_script_meta(
200 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
201 | ) -> None:
202 | monkeypatch.chdir(tmp_path)
203 | invoke(["init", "test.ipynb", "--with", "numpy"])
204 | result = invoke(["run", "test.ipynb"])
205 | assert result.exit_code == 0
206 | assert result.stdout == snapshot("uv run --no-project --with=jupyterlab --script\n")
207 |
208 |
209 | def test_run_with_script_meta_and_with_args(
210 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
211 | ) -> None:
212 | monkeypatch.chdir(tmp_path)
213 | invoke(["init", "test.ipynb", "--with", "numpy"])
214 | result = invoke(["run", "--with", "polars", "--with=anywidget,foo", "test.ipynb"])
215 | assert result.exit_code == 0
216 | assert result.stdout == snapshot(
217 | "uv run --no-project --with=jupyterlab --with=polars,anywidget,foo --script\n"
218 | )
219 |
220 |
221 | def test_run_nbclassic(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
222 | monkeypatch.chdir(tmp_path)
223 | invoke(["init", "--with", "numpy", "test.ipynb"])
224 | result = invoke(["run", "--with=polars", "--jupyter=nbclassic", "test.ipynb"])
225 | assert result.exit_code == 0
226 | assert result.stdout == snapshot(
227 | "uv run --no-project --with=nbclassic --with=polars --script\n"
228 | )
229 |
230 |
231 | def test_run_notebook_and_version(
232 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
233 | ) -> None:
234 | monkeypatch.chdir(tmp_path)
235 | invoke(["init", "test.ipynb", "--python=3.8"])
236 | result = invoke(["run", "--jupyter=notebook@6.4.0", "test.ipynb"])
237 | assert result.exit_code == 0
238 | assert result.stdout == snapshot(
239 | "uv run --no-project --with=notebook==6.4.0,setuptools --script\n"
240 | )
241 |
242 |
243 | def test_run_with_extra_jupyter_flags(
244 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
245 | ) -> None:
246 | monkeypatch.chdir(tmp_path)
247 | invoke(["init", "test.ipynb"])
248 | result = invoke(
249 | [
250 | "run",
251 | "test.ipynb",
252 | "--",
253 | "--no-browser",
254 | "--port=8888",
255 | "--ip=0.0.0.0",
256 | ]
257 | )
258 | assert result.exit_code == 0
259 | assert result.stdout == snapshot("uv run --no-project --with=jupyterlab --script\n")
260 |
261 |
262 | def test_run_uses_version_specifier(
263 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
264 | ) -> None:
265 | monkeypatch.chdir(tmp_path)
266 | script = """
267 | # /// script
268 | # dependencies = ["numpy", "pandas"]
269 | # requires-python = ">=3.8,<3.10"
270 | # ///
271 |
272 | import numpy as np
273 | import pandas as pd
274 |
275 | print('Hello, world!')
276 | """
277 | script_path = tmp_path / "script.py"
278 | script_path.write_text(script)
279 |
280 | foo = to_notebook(script_path)
281 | write_ipynb(foo[1], tmp_path / "script.ipynb")
282 |
283 | result = invoke(["run", "script.ipynb"])
284 | assert result.exit_code == 0
285 | assert result.stdout == snapshot("uv run --no-project --with=jupyterlab --script\n")
286 |
287 |
288 | def filter_tempfile_ipynb(output: str) -> str:
289 | """Replace the temporary directory in the output with for snapshotting."""
290 | pattern = r"`([^`\n]+\n?[^`\n]+/)([^/\n]+\.ipynb)`"
291 | replacement = r"`/\2`"
292 | return re.sub(pattern, replacement, output)
293 |
294 |
295 | def test_add_index(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
296 | monkeypatch.chdir(tmp_path)
297 | nb = tmp_path / "foo.ipynb"
298 | write_ipynb(new_notebook(), nb)
299 | result = invoke(
300 | [
301 | "add",
302 | str(nb),
303 | "polars",
304 | "--index",
305 | "https://pip.repos.neuron.amazonaws.com",
306 | ]
307 | )
308 | assert result.exit_code == 0
309 | assert filter_tempfile_ipynb(result.stdout) == snapshot("Updated `foo.ipynb`\n")
310 | assert filter_ids(nb.read_text(encoding="utf-8")) == snapshot("""\
311 | {
312 | "cells": [
313 | {
314 | "cell_type": "code",
315 | "execution_count": null,
316 | "id": "",
317 | "metadata": {
318 | "jupyter": {
319 | "source_hidden": true
320 | }
321 | },
322 | "outputs": [],
323 | "source": [
324 | "# /// script\\n",
325 | "# requires-python = \\">=3.13\\"\\n",
326 | "# dependencies = [\\n",
327 | "# \\"polars\\",\\n",
328 | "# ]\\n",
329 | "#\\n",
330 | "# [[tool.uv.index]]\\n",
331 | "# url = \\"https://pip.repos.neuron.amazonaws.com/\\"\\n",
332 | "# ///"
333 | ]
334 | }
335 | ],
336 | "metadata": {},
337 | "nbformat": 4,
338 | "nbformat_minor": 5
339 | }\
340 | """)
341 |
342 |
343 | def test_add_default_index(
344 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
345 | ) -> None:
346 | monkeypatch.chdir(tmp_path)
347 | nb = tmp_path / "foo.ipynb"
348 | write_ipynb(new_notebook(), nb)
349 | result = invoke(
350 | [
351 | "add",
352 | str(nb),
353 | "polars",
354 | "--default-index",
355 | "https://pip.repos.neuron.amazonaws.com",
356 | ]
357 | )
358 | assert result.exit_code == 0
359 | assert filter_tempfile_ipynb(result.stdout) == snapshot("Updated `foo.ipynb`\n")
360 | assert filter_ids(nb.read_text(encoding="utf-8")) == snapshot("""\
361 | {
362 | "cells": [
363 | {
364 | "cell_type": "code",
365 | "execution_count": null,
366 | "id": "",
367 | "metadata": {
368 | "jupyter": {
369 | "source_hidden": true
370 | }
371 | },
372 | "outputs": [],
373 | "source": [
374 | "# /// script\\n",
375 | "# requires-python = \\">=3.13\\"\\n",
376 | "# dependencies = [\\n",
377 | "# \\"polars\\",\\n",
378 | "# ]\\n",
379 | "#\\n",
380 | "# [[tool.uv.index]]\\n",
381 | "# url = \\"https://pip.repos.neuron.amazonaws.com/\\"\\n",
382 | "# default = true\\n",
383 | "# ///"
384 | ]
385 | }
386 | ],
387 | "metadata": {},
388 | "nbformat": 4,
389 | "nbformat_minor": 5
390 | }\
391 | """)
392 |
393 |
394 | def test_add_creates_inline_meta(
395 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
396 | ) -> None:
397 | monkeypatch.chdir(tmp_path)
398 | nb = tmp_path / "foo.ipynb"
399 | write_ipynb(new_notebook(), nb)
400 | result = invoke(["add", str(nb), "polars==1", "anywidget"], uv_python="3.11")
401 | assert result.exit_code == 0
402 | assert filter_tempfile_ipynb(result.stdout) == snapshot("Updated `foo.ipynb`\n")
403 | assert filter_ids(nb.read_text(encoding="utf-8")) == snapshot("""\
404 | {
405 | "cells": [
406 | {
407 | "cell_type": "code",
408 | "execution_count": null,
409 | "id": "",
410 | "metadata": {
411 | "jupyter": {
412 | "source_hidden": true
413 | }
414 | },
415 | "outputs": [],
416 | "source": [
417 | "# /// script\\n",
418 | "# requires-python = \\">=3.11\\"\\n",
419 | "# dependencies = [\\n",
420 | "# \\"anywidget\\",\\n",
421 | "# \\"polars==1\\",\\n",
422 | "# ]\\n",
423 | "# ///"
424 | ]
425 | }
426 | ],
427 | "metadata": {},
428 | "nbformat": 4,
429 | "nbformat_minor": 5
430 | }\
431 | """)
432 |
433 |
434 | def test_add_prepends_script_meta(
435 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
436 | ) -> None:
437 | monkeypatch.chdir(tmp_path)
438 | path = tmp_path / "empty.ipynb"
439 | write_ipynb(
440 | new_notebook(cells=[new_code_cell("print('Hello, world!')")]),
441 | path,
442 | )
443 | result = invoke(["add", str(path), "polars==1", "anywidget"], uv_python="3.10")
444 | assert result.exit_code == 0
445 | assert filter_tempfile_ipynb(result.stdout) == snapshot("Updated `empty.ipynb`\n")
446 | assert filter_ids(path.read_text(encoding="utf-8")) == snapshot("""\
447 | {
448 | "cells": [
449 | {
450 | "cell_type": "code",
451 | "execution_count": null,
452 | "id": "",
453 | "metadata": {
454 | "jupyter": {
455 | "source_hidden": true
456 | }
457 | },
458 | "outputs": [],
459 | "source": [
460 | "# /// script\\n",
461 | "# requires-python = \\">=3.10\\"\\n",
462 | "# dependencies = [\\n",
463 | "# \\"anywidget\\",\\n",
464 | "# \\"polars==1\\",\\n",
465 | "# ]\\n",
466 | "# ///"
467 | ]
468 | },
469 | {
470 | "cell_type": "code",
471 | "execution_count": null,
472 | "id": "",
473 | "metadata": {},
474 | "outputs": [],
475 | "source": [
476 | "print('Hello, world!')"
477 | ]
478 | }
479 | ],
480 | "metadata": {},
481 | "nbformat": 4,
482 | "nbformat_minor": 5
483 | }\
484 | """)
485 |
486 |
487 | def test_add_updates_existing_meta(
488 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
489 | ) -> None:
490 | monkeypatch.chdir(tmp_path)
491 | path = tmp_path / "empty.ipynb"
492 | nb = new_notebook(
493 | cells=[
494 | new_code_cell("""# /// script
495 | # dependencies = ["numpy"]
496 | # requires-python = ">=3.8"
497 | # ///
498 | import numpy as np
499 | print('Hello, numpy!')"""),
500 | ],
501 | )
502 | write_ipynb(nb, path)
503 | result = invoke(["add", str(path), "polars==1", "anywidget"], uv_python="3.13")
504 | assert result.exit_code == 0
505 | assert filter_tempfile_ipynb(result.stdout) == snapshot("Updated `empty.ipynb`\n")
506 | assert filter_ids(path.read_text(encoding="utf-8")) == snapshot("""\
507 | {
508 | "cells": [
509 | {
510 | "cell_type": "code",
511 | "execution_count": null,
512 | "id": "",
513 | "metadata": {},
514 | "outputs": [],
515 | "source": [
516 | "# /// script\\n",
517 | "# dependencies = [\\n",
518 | "# \\"anywidget\\",\\n",
519 | "# \\"numpy\\",\\n",
520 | "# \\"polars==1\\",\\n",
521 | "# ]\\n",
522 | "# requires-python = \\">=3.8\\"\\n",
523 | "# ///\\n",
524 | "import numpy as np\\n",
525 | "print('Hello, numpy!')"
526 | ]
527 | }
528 | ],
529 | "metadata": {},
530 | "nbformat": 4,
531 | "nbformat_minor": 5
532 | }\
533 | """)
534 |
535 |
536 | def test_init_creates_notebook_with_inline_meta(
537 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
538 | ) -> None:
539 | monkeypatch.chdir(tmp_path)
540 | path = tmp_path / "empty.ipynb"
541 | result = invoke(["init", str(path)], uv_python="3.13")
542 | assert result.exit_code == 0
543 | assert filter_tempfile_ipynb(result.stdout) == snapshot(
544 | "Initialized notebook at `empty.ipynb`\n"
545 | )
546 | assert filter_ids(path.read_text(encoding="utf-8")) == snapshot("""\
547 | {
548 | "cells": [
549 | {
550 | "cell_type": "code",
551 | "execution_count": null,
552 | "id": "",
553 | "metadata": {
554 | "jupyter": {
555 | "source_hidden": true
556 | }
557 | },
558 | "outputs": [],
559 | "source": [
560 | "# /// script\\n",
561 | "# requires-python = \\">=3.13\\"\\n",
562 | "# dependencies = []\\n",
563 | "# ///"
564 | ]
565 | },
566 | {
567 | "cell_type": "code",
568 | "execution_count": null,
569 | "id": "",
570 | "metadata": {},
571 | "outputs": [],
572 | "source": []
573 | }
574 | ],
575 | "metadata": {
576 | "kernelspec": {
577 | "display_name": "Python 3",
578 | "language": "python",
579 | "name": "python3"
580 | }
581 | },
582 | "nbformat": 4,
583 | "nbformat_minor": 5
584 | }\
585 | """)
586 |
587 |
588 | def test_init_creates_notebook_with_specific_python_version(
589 | tmp_path: pathlib.Path,
590 | monkeypatch: pytest.MonkeyPatch,
591 | ) -> None:
592 | monkeypatch.chdir(tmp_path)
593 | path = tmp_path / "empty.ipynb"
594 | result = invoke(["init", str(path), "--python=3.8"])
595 | assert result.exit_code == 0
596 | assert filter_tempfile_ipynb(result.stdout) == snapshot(
597 | "Initialized notebook at `empty.ipynb`\n"
598 | )
599 | assert filter_ids(path.read_text(encoding="utf-8")) == snapshot("""\
600 | {
601 | "cells": [
602 | {
603 | "cell_type": "code",
604 | "execution_count": null,
605 | "id": "",
606 | "metadata": {
607 | "jupyter": {
608 | "source_hidden": true
609 | }
610 | },
611 | "outputs": [],
612 | "source": [
613 | "# /// script\\n",
614 | "# requires-python = \\">=3.8\\"\\n",
615 | "# dependencies = []\\n",
616 | "# ///"
617 | ]
618 | },
619 | {
620 | "cell_type": "code",
621 | "execution_count": null,
622 | "id": "",
623 | "metadata": {},
624 | "outputs": [],
625 | "source": []
626 | }
627 | ],
628 | "metadata": {
629 | "kernelspec": {
630 | "display_name": "Python 3",
631 | "language": "python",
632 | "name": "python3"
633 | }
634 | },
635 | "nbformat": 4,
636 | "nbformat_minor": 5
637 | }\
638 | """)
639 |
640 |
641 | def test_init_with_deps(
642 | tmp_path: pathlib.Path,
643 | monkeypatch: pytest.MonkeyPatch,
644 | ) -> None:
645 | monkeypatch.chdir(tmp_path)
646 | result = invoke(
647 | [
648 | "init",
649 | "--with",
650 | "rich,requests",
651 | "--with=polars==1",
652 | "--with=anywidget[dev]",
653 | "--with=numpy,pandas>=2",
654 | ],
655 | )
656 | assert result.exit_code == 0
657 | assert result.stdout == snapshot("Initialized notebook at `Untitled.ipynb`\n")
658 |
659 | path = tmp_path / "Untitled.ipynb"
660 | assert filter_ids(path.read_text(encoding="utf-8")) == snapshot("""\
661 | {
662 | "cells": [
663 | {
664 | "cell_type": "code",
665 | "execution_count": null,
666 | "id": "",
667 | "metadata": {
668 | "jupyter": {
669 | "source_hidden": true
670 | }
671 | },
672 | "outputs": [],
673 | "source": [
674 | "# /// script\\n",
675 | "# requires-python = \\">=3.13\\"\\n",
676 | "# dependencies = [\\n",
677 | "# \\"anywidget[dev]\\",\\n",
678 | "# \\"numpy\\",\\n",
679 | "# \\"pandas>=2\\",\\n",
680 | "# \\"polars==1\\",\\n",
681 | "# \\"requests\\",\\n",
682 | "# \\"rich\\",\\n",
683 | "# ]\\n",
684 | "# ///"
685 | ]
686 | },
687 | {
688 | "cell_type": "code",
689 | "execution_count": null,
690 | "id": "",
691 | "metadata": {},
692 | "outputs": [],
693 | "source": []
694 | }
695 | ],
696 | "metadata": {
697 | "kernelspec": {
698 | "display_name": "Python 3",
699 | "language": "python",
700 | "name": "python3"
701 | }
702 | },
703 | "nbformat": 4,
704 | "nbformat_minor": 5
705 | }\
706 | """)
707 |
708 |
709 | def extract_meta_cell(notebook_path: pathlib.Path) -> str:
710 | nb = jupytext.read(notebook_path)
711 | return "".join(nb.cells[0].source)
712 |
713 |
714 | def test_add_with_extras(
715 | tmp_path: pathlib.Path,
716 | monkeypatch: pytest.MonkeyPatch,
717 | ) -> None:
718 | monkeypatch.chdir(tmp_path)
719 |
720 | invoke(["init", "test.ipynb"])
721 | result = invoke(
722 | [
723 | "add",
724 | "test.ipynb",
725 | "--extra",
726 | "dev",
727 | "--extra",
728 | "foo",
729 | "anywidget",
730 | ]
731 | )
732 |
733 | assert result.exit_code == 0
734 | assert result.stdout == snapshot("Updated `test.ipynb`\n")
735 | assert extract_meta_cell(tmp_path / "test.ipynb") == snapshot("""\
736 | # /// script
737 | # requires-python = ">=3.13"
738 | # dependencies = [
739 | # "anywidget[dev,foo]",
740 | # ]
741 | # ///\
742 | """)
743 |
744 |
745 | def test_add_local_package(
746 | tmp_path: pathlib.Path,
747 | monkeypatch: pytest.MonkeyPatch,
748 | ) -> None:
749 | monkeypatch.chdir(tmp_path)
750 |
751 | uv(["init", "--lib", "foo"], check=True)
752 | invoke(["init", "test.ipynb"])
753 | result = invoke(["add", "test.ipynb", "./foo"])
754 |
755 | assert result.exit_code == 0
756 | assert result.stdout == snapshot("Updated `test.ipynb`\n")
757 | assert extract_meta_cell(tmp_path / "test.ipynb") == snapshot("""\
758 | # /// script
759 | # requires-python = ">=3.13"
760 | # dependencies = [
761 | # "foo",
762 | # ]
763 | #
764 | # [tool.uv.sources]
765 | # foo = { path = "foo" }
766 | # ///\
767 | """)
768 |
769 |
770 | def test_add_local_package_as_editable(
771 | tmp_path: pathlib.Path,
772 | monkeypatch: pytest.MonkeyPatch,
773 | ) -> None:
774 | monkeypatch.chdir(tmp_path)
775 |
776 | uv(["init", "--lib", "foo"], check=True)
777 | invoke(["init", "test.ipynb"])
778 | result = invoke(["add", "test.ipynb", "--editable", "./foo"])
779 |
780 | assert result.exit_code == 0
781 | assert result.stdout == snapshot("Updated `test.ipynb`\n")
782 | assert extract_meta_cell(tmp_path / "test.ipynb") == snapshot("""\
783 | # /// script
784 | # requires-python = ">=3.13"
785 | # dependencies = [
786 | # "foo",
787 | # ]
788 | #
789 | # [tool.uv.sources]
790 | # foo = { path = "foo", editable = true }
791 | # ///\
792 | """)
793 |
794 |
795 | @pytest.mark.skip(reason="Currently too flaky to run in CI")
796 | def test_add_git_default(
797 | tmp_path: pathlib.Path,
798 | monkeypatch: pytest.MonkeyPatch,
799 | ) -> None:
800 | monkeypatch.chdir(tmp_path)
801 |
802 | invoke(["init", "test.ipynb"])
803 | result = invoke(["add", "test.ipynb", "git+https://github.com/encode/httpx"])
804 |
805 | assert result.exit_code == 0
806 | assert result.stdout == snapshot("Updated `test.ipynb`\n")
807 | assert extract_meta_cell(tmp_path / "test.ipynb") == snapshot("""\
808 | # /// script
809 | # requires-python = ">=3.13"
810 | # dependencies = [
811 | # "httpx",
812 | # ]
813 | #
814 | # [tool.uv.sources]
815 | # httpx = { git = "https://github.com/encode/httpx" }
816 | # ///\
817 | """)
818 |
819 |
820 | @pytest.mark.skip(reason="Currently too flaky to run in CI")
821 | def test_add_git_tag(
822 | tmp_path: pathlib.Path,
823 | monkeypatch: pytest.MonkeyPatch,
824 | ) -> None:
825 | monkeypatch.chdir(tmp_path)
826 |
827 | invoke(["init", "test.ipynb"])
828 | result = invoke(
829 | [
830 | "add",
831 | "test.ipynb",
832 | "git+https://github.com/encode/httpx",
833 | "--tag",
834 | "0.19.0",
835 | ]
836 | )
837 |
838 | assert result.exit_code == 0
839 | assert result.stdout == snapshot("Updated `test.ipynb`\n")
840 | assert extract_meta_cell(tmp_path / "test.ipynb") == snapshot("""\
841 | # /// script
842 | # requires-python = ">=3.13"
843 | # dependencies = [
844 | # "httpx",
845 | # ]
846 | #
847 | # [tool.uv.sources]
848 | # httpx = { git = "https://github.com/encode/httpx", tag = "0.19.0" }
849 | # ///\
850 | """)
851 |
852 |
853 | @pytest.mark.skip(reason="Currently too flaky to run in CI")
854 | def test_add_git_branch(
855 | tmp_path: pathlib.Path,
856 | monkeypatch: pytest.MonkeyPatch,
857 | ) -> None:
858 | monkeypatch.chdir(tmp_path)
859 |
860 | invoke(["init", "test.ipynb"])
861 | result = invoke(
862 | [
863 | "add",
864 | "test.ipynb",
865 | "git+https://github.com/encode/httpx",
866 | "--branch",
867 | "master",
868 | ]
869 | )
870 |
871 | assert result.exit_code == 0
872 | assert result.stdout == snapshot("Updated `test.ipynb`\n")
873 | assert extract_meta_cell(tmp_path / "test.ipynb") == snapshot("""\
874 | # /// script
875 | # requires-python = ">=3.13"
876 | # dependencies = [
877 | # "httpx",
878 | # ]
879 | #
880 | # [tool.uv.sources]
881 | # httpx = { git = "https://github.com/encode/httpx", branch = "master" }
882 | # ///\
883 | """)
884 |
885 |
886 | @pytest.mark.skip(reason="Currently too flaky to run in CI")
887 | def test_add_git_rev(
888 | tmp_path: pathlib.Path,
889 | monkeypatch: pytest.MonkeyPatch,
890 | ) -> None:
891 | monkeypatch.chdir(tmp_path)
892 |
893 | invoke(["init", "test.ipynb"])
894 | result = invoke(
895 | [
896 | "add",
897 | "test.ipynb",
898 | "git+https://github.com/encode/httpx",
899 | "--rev",
900 | "326b9431c761e1ef1e00b9f760d1f654c8db48c6",
901 | ]
902 | )
903 |
904 | assert result.exit_code == 0
905 | assert result.stdout == snapshot("Updated `test.ipynb`\n")
906 | assert extract_meta_cell(tmp_path / "test.ipynb") == snapshot("""\
907 | # /// script
908 | # requires-python = ">=3.13"
909 | # dependencies = [
910 | # "httpx",
911 | # ]
912 | #
913 | # [tool.uv.sources]
914 | # httpx = { git = "https://github.com/encode/httpx", rev = "326b9431c761e1ef1e00b9f760d1f654c8db48c6" }
915 | # ///\
916 | """)
917 |
918 |
919 | @pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher")
920 | def test_stamp(
921 | monkeypatch: pytest.MonkeyPatch,
922 | ) -> None:
923 | # we need to run these tests in this folder because it uses the git history
924 |
925 | with TemporaryDirectoryIgnoreErrors(dir=SELF_DIR) as tmpdir:
926 | tmp_path = pathlib.Path(tmpdir)
927 | monkeypatch.chdir(tmp_path)
928 |
929 | invoke(["init", "test.ipynb"])
930 | result = invoke(
931 | ["stamp", "test.ipynb", "--timestamp", "2020-01-03 00:00:00-02:00"]
932 | )
933 |
934 | assert result.exit_code == 0
935 | assert result.stdout == snapshot(
936 | "Stamped `test.ipynb` with 2020-01-03T00:00:00-02:00\n"
937 | )
938 | assert extract_meta_cell(tmp_path / "test.ipynb") == snapshot("""\
939 | # /// script
940 | # requires-python = ">=3.13"
941 | # dependencies = []
942 | #
943 | # [tool.uv]
944 | # exclude-newer = "2020-01-03T00:00:00-02:00"
945 | # ///\
946 | """)
947 |
948 |
949 | @pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher")
950 | def test_stamp_script(
951 | monkeypatch: pytest.MonkeyPatch,
952 | ) -> None:
953 | # we need to run these tests in this folder because it uses the git history
954 |
955 | with TemporaryDirectoryIgnoreErrors(dir=SELF_DIR) as tmpdir:
956 | tmp_path = pathlib.Path(tmpdir)
957 | monkeypatch.chdir(tmp_path)
958 |
959 | with (tmp_path / "foo.py").open("w", encoding="utf-8") as f:
960 | f.write("""# /// script
961 | # requires-python = ">=3.13"
962 | # dependencies = []
963 | # ///
964 |
965 |
966 | def main() -> None:
967 | print("Hello from foo.py!")
968 |
969 |
970 | if __name__ == "__main__":
971 | main()
972 | """)
973 | result = invoke(["stamp", "foo.py", "--date", "2006-01-02"])
974 |
975 | assert result.exit_code == 0
976 | assert result.stdout == snapshot(
977 | "Stamped `foo.py` with 2006-01-03T00:00:00-05:00\n"
978 | )
979 | assert (tmp_path / "foo.py").read_text(encoding="utf-8") == snapshot("""\
980 | # /// script
981 | # requires-python = ">=3.13"
982 | # dependencies = []
983 | #
984 | # [tool.uv]
985 | # exclude-newer = "2006-01-03T00:00:00-05:00"
986 | # ///
987 |
988 |
989 | def main() -> None:
990 | print("Hello from foo.py!")
991 |
992 |
993 | if __name__ == "__main__":
994 | main()
995 | """)
996 |
997 |
998 | @pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires Python 3.9 or higher")
999 | def test_stamp_clear(
1000 | monkeypatch: pytest.MonkeyPatch,
1001 | ) -> None:
1002 | # we need to run these tests in this folder because it uses the git history
1003 |
1004 | with TemporaryDirectoryIgnoreErrors(dir=SELF_DIR) as tmpdir:
1005 | tmp_path = pathlib.Path(tmpdir)
1006 | monkeypatch.chdir(tmp_path)
1007 |
1008 | with (tmp_path / "foo.py").open("w", encoding="utf-8") as f:
1009 | f.write("""# /// script
1010 | # requires-python = ">=3.13"
1011 | # dependencies = []
1012 | #
1013 | # [tool.uv]
1014 | # exclude-newer = "blah"
1015 | # ///
1016 | """)
1017 |
1018 | result = invoke(["stamp", "foo.py", "--clear"])
1019 |
1020 | assert result.exit_code == 0
1021 | assert result.stdout == snapshot("Removed blah from `foo.py`\n")
1022 | assert (tmp_path / "foo.py").read_text(encoding="utf-8") == snapshot("""\
1023 | # /// script
1024 | # requires-python = ">=3.13"
1025 | # dependencies = []
1026 | # ///
1027 | """)
1028 |
1029 |
1030 | def test_add_notebook_pinned(
1031 | tmp_path: pathlib.Path,
1032 | monkeypatch: pytest.MonkeyPatch,
1033 | ) -> None:
1034 | monkeypatch.chdir(tmp_path)
1035 |
1036 | invoke(["init", "test.ipynb"])
1037 | result = invoke(["add", "test.ipynb", "anywidget", "--pin"])
1038 | assert result.exit_code == 0
1039 | assert result.stdout == snapshot("Updated `test.ipynb`\n")
1040 | assert extract_meta_cell(tmp_path / "test.ipynb") == snapshot("""\
1041 | # /// script
1042 | # requires-python = ">=3.13"
1043 | # dependencies = [
1044 | # "anywidget==0.1.0",
1045 | # ]
1046 | # ///\
1047 | """)
1048 |
1049 |
1050 | def test_add_script_pinned(
1051 | tmp_path: pathlib.Path,
1052 | monkeypatch: pytest.MonkeyPatch,
1053 | ) -> None:
1054 | monkeypatch.chdir(tmp_path)
1055 |
1056 | with (tmp_path / "foo.py").open("w", encoding="utf-8") as f:
1057 | f.write("""# /// script
1058 | # requires-python = ">=3.13"
1059 | # dependencies = []
1060 | # ///
1061 |
1062 | print("Hello from foo.py!")
1063 | """)
1064 |
1065 | result = invoke(["add", "foo.py", "anywidget", "--pin"])
1066 | assert result.exit_code == 0
1067 | assert result.stdout == snapshot("Updated `foo.py`\n")
1068 | assert (tmp_path / "foo.py").read_text(encoding="utf-8") == snapshot("""\
1069 | # /// script
1070 | # requires-python = ">=3.13"
1071 | # dependencies = [
1072 | # "anywidget==0.1.0",
1073 | # ]
1074 | # ///
1075 |
1076 | print("Hello from foo.py!")
1077 | """)
1078 |
1079 |
1080 | def test_remove(
1081 | tmp_path: pathlib.Path,
1082 | monkeypatch: pytest.MonkeyPatch,
1083 | ) -> None:
1084 | monkeypatch.chdir(tmp_path)
1085 |
1086 | invoke(["init", "test.ipynb"])
1087 | result = invoke(
1088 | ["add", "test.ipynb", "anywidget", "numpy==1.21.0", "polars==1.0.0"]
1089 | )
1090 | assert result.exit_code == 0
1091 | assert result.stdout == snapshot("Updated `test.ipynb`\n")
1092 | assert extract_meta_cell(tmp_path / "test.ipynb") == snapshot("""\
1093 | # /// script
1094 | # requires-python = ">=3.13"
1095 | # dependencies = [
1096 | # "anywidget",
1097 | # "numpy==1.21.0",
1098 | # "polars==1.0.0",
1099 | # ]
1100 | # ///\
1101 | """)
1102 |
1103 | result = invoke(["remove", "test.ipynb", "anywidget", "polars"])
1104 | assert result.exit_code == 0
1105 | assert result.stdout == snapshot("Updated `test.ipynb`\n")
1106 | assert extract_meta_cell(tmp_path / "test.ipynb") == snapshot("""\
1107 | # /// script
1108 | # requires-python = ">=3.13"
1109 | # dependencies = [
1110 | # "numpy==1.21.0",
1111 | # ]
1112 | # ///\
1113 | """)
1114 |
1115 |
1116 | def test_lock(
1117 | tmp_path: pathlib.Path,
1118 | monkeypatch: pytest.MonkeyPatch,
1119 | ) -> None:
1120 | monkeypatch.chdir(tmp_path)
1121 |
1122 | invoke(["init", "test.ipynb"])
1123 | invoke(["add", "test.ipynb", "polars"])
1124 | result = invoke(["lock", "test.ipynb"])
1125 | assert result.exit_code == 0
1126 | assert result.stdout == snapshot("Locked `test.ipynb`\n")
1127 |
1128 | assert extract_meta_cell(tmp_path / "test.ipynb") == snapshot("""\
1129 | # /// script
1130 | # requires-python = ">=3.13"
1131 | # dependencies = [
1132 | # "polars",
1133 | # ]
1134 | # ///\
1135 | """)
1136 |
1137 | nb = jupytext.read(tmp_path / "test.ipynb")
1138 | assert nb.metadata["uv.lock"] == snapshot("""\
1139 | version = 1
1140 | revision = 1
1141 | requires-python = ">=3.13"
1142 |
1143 | [options]
1144 | exclude-newer = "2023-02-01T02:00:00Z"
1145 |
1146 | [manifest]
1147 | requirements = [{ name = "polars" }]
1148 |
1149 | [[package]]
1150 | name = "polars"
1151 | version = "0.16.1"
1152 | source = { registry = "https://pypi.org/simple" }
1153 | sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/e34f5677393a986b5a6b0b8284da31154bdf0ed55a1feffc73cc8c0dfa4e/polars-0.16.1.tar.gz", hash = "sha256:ebba7a51581084adb85dde10579b1dd8b648f7c5ca38a6839eee64d2e4827612", size = 1352066 }
1154 | wheels = [
1155 | { url = "https://files.pythonhosted.org/packages/4d/aa/ecf2df7468dab00f8ad7b5fdcd834ca4bffee8e6095e011153c9d82d5df0/polars-0.16.1-cp37-abi3-macosx_10_7_x86_64.whl", hash = "sha256:180172c8db33f950b3f2ff7793d2cf3de9d3ad9b13c5f0181cda0ac3e7db5977", size = 14844819 },
1156 | { url = "https://files.pythonhosted.org/packages/f2/c5/f19a2b3f1d3251615ee136fb03f251eb00e4566688afa3b84f0d1cb4f4d3/polars-0.16.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:6c391546a158233172589ce810fcafd71a60d776add8421364bdd5ff05af2cd9", size = 12930182 },
1157 | { url = "https://files.pythonhosted.org/packages/32/bc/5f674384f48dfad969a634918487dc0b207ee08702d57433d24d0da6a3fb/polars-0.16.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2096a1384a5fecf003bb3915264212c63d1c43e8790126ee8fcdd682f1782ac", size = 13382356 },
1158 | { url = "https://files.pythonhosted.org/packages/7e/82/ee89b63d8cd638d12b79515fb0c63d602ca8fc5eb8d1c4b6b9f690a1a02d/polars-0.16.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:934bca853a0086a30800c40ac615578894531b378afc1ba4c1a7e15855218c64", size = 15291186 },
1159 | { url = "https://files.pythonhosted.org/packages/d8/4d/3b371736693c952b616dac469d91fb9a42217758bf0f79ac4170c032069d/polars-0.16.1-cp37-abi3-win_amd64.whl", hash = "sha256:a670586eee6fad98a2daafbe3f6dfc845b35a22e44bc4daaca93d4f0f4d05229", size = 16264469 },
1160 | ]
1161 | """)
1162 |
1163 |
1164 | def test_add_updates_lock(
1165 | tmp_path: pathlib.Path,
1166 | monkeypatch: pytest.MonkeyPatch,
1167 | ) -> None:
1168 | monkeypatch.chdir(tmp_path)
1169 |
1170 | invoke(["init", "test.ipynb"])
1171 | result = invoke(["lock", "test.ipynb"])
1172 | assert result.exit_code == 0
1173 | assert result.stdout == snapshot("Locked `test.ipynb`\n")
1174 | assert extract_meta_cell(tmp_path / "test.ipynb") == snapshot("""\
1175 | # /// script
1176 | # requires-python = ">=3.13"
1177 | # dependencies = []
1178 | # ///\
1179 | """)
1180 | assert jupytext.read(tmp_path / "test.ipynb").metadata["uv.lock"] == snapshot("""\
1181 | version = 1
1182 | revision = 1
1183 | requires-python = ">=3.13"
1184 |
1185 | [options]
1186 | exclude-newer = "2023-02-01T02:00:00Z"
1187 | """)
1188 |
1189 | result = invoke(["add", "test.ipynb", "polars"])
1190 | assert result.exit_code == 0
1191 | assert jupytext.read(tmp_path / "test.ipynb").metadata["uv.lock"] == snapshot("""\
1192 | version = 1
1193 | revision = 1
1194 | requires-python = ">=3.13"
1195 |
1196 | [options]
1197 | exclude-newer = "2023-02-01T02:00:00Z"
1198 |
1199 | [manifest]
1200 | requirements = [{ name = "polars", specifier = ">=0.16.1" }]
1201 |
1202 | [[package]]
1203 | name = "polars"
1204 | version = "0.16.1"
1205 | source = { registry = "https://pypi.org/simple" }
1206 | sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/e34f5677393a986b5a6b0b8284da31154bdf0ed55a1feffc73cc8c0dfa4e/polars-0.16.1.tar.gz", hash = "sha256:ebba7a51581084adb85dde10579b1dd8b648f7c5ca38a6839eee64d2e4827612", size = 1352066 }
1207 | wheels = [
1208 | { url = "https://files.pythonhosted.org/packages/4d/aa/ecf2df7468dab00f8ad7b5fdcd834ca4bffee8e6095e011153c9d82d5df0/polars-0.16.1-cp37-abi3-macosx_10_7_x86_64.whl", hash = "sha256:180172c8db33f950b3f2ff7793d2cf3de9d3ad9b13c5f0181cda0ac3e7db5977", size = 14844819 },
1209 | { url = "https://files.pythonhosted.org/packages/f2/c5/f19a2b3f1d3251615ee136fb03f251eb00e4566688afa3b84f0d1cb4f4d3/polars-0.16.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:6c391546a158233172589ce810fcafd71a60d776add8421364bdd5ff05af2cd9", size = 12930182 },
1210 | { url = "https://files.pythonhosted.org/packages/32/bc/5f674384f48dfad969a634918487dc0b207ee08702d57433d24d0da6a3fb/polars-0.16.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2096a1384a5fecf003bb3915264212c63d1c43e8790126ee8fcdd682f1782ac", size = 13382356 },
1211 | { url = "https://files.pythonhosted.org/packages/7e/82/ee89b63d8cd638d12b79515fb0c63d602ca8fc5eb8d1c4b6b9f690a1a02d/polars-0.16.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:934bca853a0086a30800c40ac615578894531b378afc1ba4c1a7e15855218c64", size = 15291186 },
1212 | { url = "https://files.pythonhosted.org/packages/d8/4d/3b371736693c952b616dac469d91fb9a42217758bf0f79ac4170c032069d/polars-0.16.1-cp37-abi3-win_amd64.whl", hash = "sha256:a670586eee6fad98a2daafbe3f6dfc845b35a22e44bc4daaca93d4f0f4d05229", size = 16264469 },
1213 | ]
1214 | """)
1215 |
1216 |
1217 | def test_remove_updates_lock(
1218 | tmp_path: pathlib.Path,
1219 | monkeypatch: pytest.MonkeyPatch,
1220 | ) -> None:
1221 | monkeypatch.chdir(tmp_path)
1222 |
1223 | invoke(["init", "test.ipynb"])
1224 | invoke(["add", "test.ipynb", "polars"])
1225 | result = invoke(["lock", "test.ipynb"])
1226 | assert result.exit_code == 0
1227 | assert result.stdout == snapshot("Locked `test.ipynb`\n")
1228 | assert jupytext.read(tmp_path / "test.ipynb").metadata["uv.lock"] == snapshot("""\
1229 | version = 1
1230 | revision = 1
1231 | requires-python = ">=3.13"
1232 |
1233 | [options]
1234 | exclude-newer = "2023-02-01T02:00:00Z"
1235 |
1236 | [manifest]
1237 | requirements = [{ name = "polars" }]
1238 |
1239 | [[package]]
1240 | name = "polars"
1241 | version = "0.16.1"
1242 | source = { registry = "https://pypi.org/simple" }
1243 | sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/e34f5677393a986b5a6b0b8284da31154bdf0ed55a1feffc73cc8c0dfa4e/polars-0.16.1.tar.gz", hash = "sha256:ebba7a51581084adb85dde10579b1dd8b648f7c5ca38a6839eee64d2e4827612", size = 1352066 }
1244 | wheels = [
1245 | { url = "https://files.pythonhosted.org/packages/4d/aa/ecf2df7468dab00f8ad7b5fdcd834ca4bffee8e6095e011153c9d82d5df0/polars-0.16.1-cp37-abi3-macosx_10_7_x86_64.whl", hash = "sha256:180172c8db33f950b3f2ff7793d2cf3de9d3ad9b13c5f0181cda0ac3e7db5977", size = 14844819 },
1246 | { url = "https://files.pythonhosted.org/packages/f2/c5/f19a2b3f1d3251615ee136fb03f251eb00e4566688afa3b84f0d1cb4f4d3/polars-0.16.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:6c391546a158233172589ce810fcafd71a60d776add8421364bdd5ff05af2cd9", size = 12930182 },
1247 | { url = "https://files.pythonhosted.org/packages/32/bc/5f674384f48dfad969a634918487dc0b207ee08702d57433d24d0da6a3fb/polars-0.16.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2096a1384a5fecf003bb3915264212c63d1c43e8790126ee8fcdd682f1782ac", size = 13382356 },
1248 | { url = "https://files.pythonhosted.org/packages/7e/82/ee89b63d8cd638d12b79515fb0c63d602ca8fc5eb8d1c4b6b9f690a1a02d/polars-0.16.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:934bca853a0086a30800c40ac615578894531b378afc1ba4c1a7e15855218c64", size = 15291186 },
1249 | { url = "https://files.pythonhosted.org/packages/d8/4d/3b371736693c952b616dac469d91fb9a42217758bf0f79ac4170c032069d/polars-0.16.1-cp37-abi3-win_amd64.whl", hash = "sha256:a670586eee6fad98a2daafbe3f6dfc845b35a22e44bc4daaca93d4f0f4d05229", size = 16264469 },
1250 | ]
1251 | """)
1252 |
1253 | invoke(["remove", "test.ipynb", "polars"])
1254 | assert result.exit_code == 0
1255 | assert jupytext.read(tmp_path / "test.ipynb").metadata["uv.lock"] == snapshot("""\
1256 | version = 1
1257 | revision = 1
1258 | requires-python = ">=3.13"
1259 |
1260 | [options]
1261 | exclude-newer = "2023-02-01T02:00:00Z"
1262 | """)
1263 |
1264 |
1265 | def test_tree(
1266 | tmp_path: pathlib.Path,
1267 | monkeypatch: pytest.MonkeyPatch,
1268 | ) -> None:
1269 | monkeypatch.chdir(tmp_path)
1270 |
1271 | invoke(["init", "test.ipynb"])
1272 | invoke(["add", "test.ipynb", "rich"])
1273 | result = invoke(["tree", "test.ipynb"])
1274 | assert result.exit_code == 0
1275 | assert result.stdout == snapshot("""\
1276 | rich v13.3.1
1277 | ├── markdown-it-py v2.1.0
1278 | │ └── mdurl v0.1.2
1279 | └── pygments v2.14.0
1280 | """)
1281 |
1282 |
1283 | def test_clear_lock(
1284 | tmp_path: pathlib.Path,
1285 | monkeypatch: pytest.MonkeyPatch,
1286 | ) -> None:
1287 | monkeypatch.chdir(tmp_path)
1288 |
1289 | invoke(["init", "test.ipynb"])
1290 | invoke(["add", "test.ipynb", "attrs"])
1291 | invoke(["lock", "test.ipynb"])
1292 | assert jupytext.read(tmp_path / "test.ipynb").metadata.get("uv.lock") == snapshot("""\
1293 | version = 1
1294 | revision = 1
1295 | requires-python = ">=3.13"
1296 |
1297 | [options]
1298 | exclude-newer = "2023-02-01T02:00:00Z"
1299 |
1300 | [manifest]
1301 | requirements = [{ name = "attrs" }]
1302 |
1303 | [[package]]
1304 | name = "attrs"
1305 | version = "22.2.0"
1306 | source = { registry = "https://pypi.org/simple" }
1307 | sdist = { url = "https://files.pythonhosted.org/packages/21/31/3f468da74c7de4fcf9b25591e682856389b3400b4b62f201e65f15ea3e07/attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99", size = 215900 }
1308 | wheels = [
1309 | { url = "https://files.pythonhosted.org/packages/fb/6e/6f83bf616d2becdf333a1640f1d463fef3150e2e926b7010cb0f81c95e88/attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836", size = 60018 },
1310 | ]
1311 | """)
1312 |
1313 | result = invoke(["lock", "test.ipynb", "--clear"])
1314 | assert result.exit_code == 0
1315 | assert result.stdout == snapshot("Cleared lockfile `test.ipynb`\n")
1316 | assert jupytext.read(tmp_path / "test.ipynb").metadata.get("uv.lock") is None
1317 |
1318 |
1319 | def sanitize_uv_export_command(output: str) -> str:
1320 | """Replace the temporary file path after 'uv export --script' with """
1321 | pattern = r"(uv export --script )([^\s]+[\\/][^\s]+\.py)"
1322 | replacement = r"\1"
1323 | return re.sub(pattern, replacement, output)
1324 |
1325 |
1326 | def test_export(
1327 | tmp_path: pathlib.Path,
1328 | monkeypatch: pytest.MonkeyPatch,
1329 | ) -> None:
1330 | monkeypatch.chdir(tmp_path)
1331 |
1332 | invoke(["init", "test.ipynb"])
1333 | invoke(["add", "test.ipynb", "attrs"])
1334 | result = invoke(["export", "test.ipynb"])
1335 | assert result.exit_code == 0
1336 | assert sanitize_uv_export_command(result.stdout) == snapshot("""\
1337 | # This file was autogenerated by uv via the following command:
1338 | # uv export --script
1339 | attrs==22.2.0 \\
1340 | --hash=sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836 \\
1341 | --hash=sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99
1342 | """)
1343 |
1344 |
1345 | @pytest.mark.parametrize("command", ["export", "tree"])
1346 | def test_commands_update_lock(
1347 | command: str,
1348 | tmp_path: pathlib.Path,
1349 | monkeypatch: pytest.MonkeyPatch,
1350 | ) -> None:
1351 | monkeypatch.chdir(tmp_path)
1352 |
1353 | invoke(["init", "test.ipynb"])
1354 | invoke(["lock", "test.ipynb"])
1355 | invoke(["add", "test.ipynb", "attrs"])
1356 | invoke([command, "test.ipynb"])
1357 | notebook = jupytext.read(tmp_path / "test.ipynb")
1358 | assert notebook.metadata["uv.lock"] == snapshot("""\
1359 | version = 1
1360 | revision = 1
1361 | requires-python = ">=3.13"
1362 |
1363 | [options]
1364 | exclude-newer = "2023-02-01T02:00:00Z"
1365 |
1366 | [manifest]
1367 | requirements = [{ name = "attrs", specifier = ">=22.2.0" }]
1368 |
1369 | [[package]]
1370 | name = "attrs"
1371 | version = "22.2.0"
1372 | source = { registry = "https://pypi.org/simple" }
1373 | sdist = { url = "https://files.pythonhosted.org/packages/21/31/3f468da74c7de4fcf9b25591e682856389b3400b4b62f201e65f15ea3e07/attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99", size = 215900 }
1374 | wheels = [
1375 | { url = "https://files.pythonhosted.org/packages/fb/6e/6f83bf616d2becdf333a1640f1d463fef3150e2e926b7010cb0f81c95e88/attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836", size = 60018 },
1376 | ]
1377 | """)
1378 |
1379 | notebook.cells[0] = new_code_cell("""# /// script
1380 | # dependencies = []
1381 | # requires-python = ">=3.8"
1382 | # ///
1383 | """)
1384 | write_ipynb(notebook, tmp_path / "test.ipynb")
1385 | invoke([command, "test.ipynb"])
1386 | assert jupytext.read(tmp_path / "test.ipynb").metadata["uv.lock"] == snapshot("""\
1387 | version = 1
1388 | revision = 1
1389 | requires-python = ">=3.8"
1390 |
1391 | [options]
1392 | exclude-newer = "2023-02-01T02:00:00Z"
1393 | """)
1394 |
--------------------------------------------------------------------------------