├── .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 | juv logo 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 | 3 | 4 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------