├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md └── src ├── commands.rs ├── main.rs ├── notebook.rs ├── printer.rs ├── script.rs └── static └── setup.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | 14 | Test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: Swatinem/rust-cache@v2 19 | with: 20 | save-if: ${{ github.ref == 'refs/heads/main' }} 21 | - name: Build 22 | run: cargo build --verbose 23 | - name: Run tests 24 | run: cargo test --verbose 25 | 26 | Lint: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: Swatinem/rust-cache@v2 31 | with: 32 | save-if: ${{ github.ref == 'refs/heads/main' }} 33 | - name: "Install Rustfmt" 34 | run: rustup component add rustfmt 35 | - name: "rustfmt" 36 | run: cargo fmt --all --check 37 | 38 | Clippy: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v4 42 | - uses: Swatinem/rust-cache@v2 43 | with: 44 | save-if: ${{ github.ref == 'refs/heads/main' }} 45 | - name: "Install Clippy" 46 | run: rustup component add clippy 47 | - name: "Clippy" 48 | run: cargo clippy --all-targets --all-features --locked -- -D warnings 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .ipynb_checkpoints 3 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "android-tzdata" 16 | version = "0.1.1" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 19 | 20 | [[package]] 21 | name = "android_system_properties" 22 | version = "0.1.5" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 25 | dependencies = [ 26 | "libc", 27 | ] 28 | 29 | [[package]] 30 | name = "anstream" 31 | version = "0.6.18" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 34 | dependencies = [ 35 | "anstyle", 36 | "anstyle-parse", 37 | "anstyle-query", 38 | "anstyle-wincon", 39 | "colorchoice", 40 | "is_terminal_polyfill", 41 | "utf8parse", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle" 46 | version = "1.0.10" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 49 | 50 | [[package]] 51 | name = "anstyle-parse" 52 | version = "0.2.6" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 55 | dependencies = [ 56 | "utf8parse", 57 | ] 58 | 59 | [[package]] 60 | name = "anstyle-query" 61 | version = "1.1.2" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 64 | dependencies = [ 65 | "windows-sys 0.59.0", 66 | ] 67 | 68 | [[package]] 69 | name = "anstyle-wincon" 70 | version = "3.0.6" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" 73 | dependencies = [ 74 | "anstyle", 75 | "windows-sys 0.59.0", 76 | ] 77 | 78 | [[package]] 79 | name = "anyhow" 80 | version = "1.0.93" 81 | source = "registry+https://github.com/rust-lang/crates.io-index" 82 | checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" 83 | 84 | [[package]] 85 | name = "autocfg" 86 | version = "1.4.0" 87 | source = "registry+https://github.com/rust-lang/crates.io-index" 88 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 89 | 90 | [[package]] 91 | name = "bitflags" 92 | version = "2.6.0" 93 | source = "registry+https://github.com/rust-lang/crates.io-index" 94 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 95 | 96 | [[package]] 97 | name = "bumpalo" 98 | version = "3.16.0" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" 101 | 102 | [[package]] 103 | name = "cc" 104 | version = "1.1.36" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "baee610e9452a8f6f0a1b6194ec09ff9e2d85dea54432acdae41aa0761c95d70" 107 | dependencies = [ 108 | "shlex", 109 | ] 110 | 111 | [[package]] 112 | name = "cfg-if" 113 | version = "1.0.0" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 116 | 117 | [[package]] 118 | name = "chrono" 119 | version = "0.4.38" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" 122 | dependencies = [ 123 | "android-tzdata", 124 | "iana-time-zone", 125 | "js-sys", 126 | "num-traits", 127 | "wasm-bindgen", 128 | "windows-targets", 129 | ] 130 | 131 | [[package]] 132 | name = "clack" 133 | version = "0.1.0" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "7c91b44356ec0db6b07f4f3c344338463114a998032b5b57df20fb7f4fac0c9e" 136 | 137 | [[package]] 138 | name = "clap" 139 | version = "4.5.20" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" 142 | dependencies = [ 143 | "clap_builder", 144 | "clap_derive", 145 | ] 146 | 147 | [[package]] 148 | name = "clap_builder" 149 | version = "4.5.20" 150 | source = "registry+https://github.com/rust-lang/crates.io-index" 151 | checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" 152 | dependencies = [ 153 | "anstream", 154 | "anstyle", 155 | "clap_lex", 156 | "strsim", 157 | ] 158 | 159 | [[package]] 160 | name = "clap_derive" 161 | version = "4.5.18" 162 | source = "registry+https://github.com/rust-lang/crates.io-index" 163 | checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" 164 | dependencies = [ 165 | "heck", 166 | "proc-macro2", 167 | "quote", 168 | "syn", 169 | ] 170 | 171 | [[package]] 172 | name = "clap_lex" 173 | version = "0.7.2" 174 | source = "registry+https://github.com/rust-lang/crates.io-index" 175 | checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" 176 | 177 | [[package]] 178 | name = "colorchoice" 179 | version = "1.0.3" 180 | source = "registry+https://github.com/rust-lang/crates.io-index" 181 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 182 | 183 | [[package]] 184 | name = "core-foundation-sys" 185 | version = "0.8.7" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 188 | 189 | [[package]] 190 | name = "errno" 191 | version = "0.3.9" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" 194 | dependencies = [ 195 | "libc", 196 | "windows-sys 0.52.0", 197 | ] 198 | 199 | [[package]] 200 | name = "fastrand" 201 | version = "2.1.1" 202 | source = "registry+https://github.com/rust-lang/crates.io-index" 203 | checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" 204 | 205 | [[package]] 206 | name = "getrandom" 207 | version = "0.2.15" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 210 | dependencies = [ 211 | "cfg-if", 212 | "libc", 213 | "wasi", 214 | ] 215 | 216 | [[package]] 217 | name = "glob" 218 | version = "0.3.1" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" 221 | 222 | [[package]] 223 | name = "heck" 224 | version = "0.5.0" 225 | source = "registry+https://github.com/rust-lang/crates.io-index" 226 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 227 | 228 | [[package]] 229 | name = "iana-time-zone" 230 | version = "0.1.61" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" 233 | dependencies = [ 234 | "android_system_properties", 235 | "core-foundation-sys", 236 | "iana-time-zone-haiku", 237 | "js-sys", 238 | "wasm-bindgen", 239 | "windows-core", 240 | ] 241 | 242 | [[package]] 243 | name = "iana-time-zone-haiku" 244 | version = "0.1.2" 245 | source = "registry+https://github.com/rust-lang/crates.io-index" 246 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 247 | dependencies = [ 248 | "cc", 249 | ] 250 | 251 | [[package]] 252 | name = "is_terminal_polyfill" 253 | version = "1.70.1" 254 | source = "registry+https://github.com/rust-lang/crates.io-index" 255 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 256 | 257 | [[package]] 258 | name = "itoa" 259 | version = "1.0.11" 260 | source = "registry+https://github.com/rust-lang/crates.io-index" 261 | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" 262 | 263 | [[package]] 264 | name = "js-sys" 265 | version = "0.3.72" 266 | source = "registry+https://github.com/rust-lang/crates.io-index" 267 | checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" 268 | dependencies = [ 269 | "wasm-bindgen", 270 | ] 271 | 272 | [[package]] 273 | name = "jupyter-serde" 274 | version = "0.2.1" 275 | source = "git+https://github.com/runtimed/runtimed?branch=manzt/nbformat-serialize#29445b1b1d51a52003f8a1a38bbc04547f793d8d" 276 | dependencies = [ 277 | "anyhow", 278 | "chrono", 279 | "serde", 280 | "serde_json", 281 | "thiserror", 282 | "uuid", 283 | ] 284 | 285 | [[package]] 286 | name = "juv" 287 | version = "0.1.0" 288 | dependencies = [ 289 | "anstream", 290 | "anyhow", 291 | "clack", 292 | "clap", 293 | "glob", 294 | "nbformat", 295 | "once_cell", 296 | "owo-colors", 297 | "regex", 298 | "serde_json", 299 | "tempfile", 300 | "uuid", 301 | ] 302 | 303 | [[package]] 304 | name = "libc" 305 | version = "0.2.161" 306 | source = "registry+https://github.com/rust-lang/crates.io-index" 307 | checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" 308 | 309 | [[package]] 310 | name = "linux-raw-sys" 311 | version = "0.4.14" 312 | source = "registry+https://github.com/rust-lang/crates.io-index" 313 | checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" 314 | 315 | [[package]] 316 | name = "log" 317 | version = "0.4.22" 318 | source = "registry+https://github.com/rust-lang/crates.io-index" 319 | checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" 320 | 321 | [[package]] 322 | name = "memchr" 323 | version = "2.7.4" 324 | source = "registry+https://github.com/rust-lang/crates.io-index" 325 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 326 | 327 | [[package]] 328 | name = "nbformat" 329 | version = "0.3.2" 330 | source = "git+https://github.com/runtimed/runtimed?branch=manzt/nbformat-serialize#29445b1b1d51a52003f8a1a38bbc04547f793d8d" 331 | dependencies = [ 332 | "anyhow", 333 | "chrono", 334 | "jupyter-serde", 335 | "serde", 336 | "serde_json", 337 | "thiserror", 338 | "uuid", 339 | ] 340 | 341 | [[package]] 342 | name = "num-traits" 343 | version = "0.2.19" 344 | source = "registry+https://github.com/rust-lang/crates.io-index" 345 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 346 | dependencies = [ 347 | "autocfg", 348 | ] 349 | 350 | [[package]] 351 | name = "once_cell" 352 | version = "1.20.2" 353 | source = "registry+https://github.com/rust-lang/crates.io-index" 354 | checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 355 | 356 | [[package]] 357 | name = "owo-colors" 358 | version = "4.1.0" 359 | source = "registry+https://github.com/rust-lang/crates.io-index" 360 | checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56" 361 | 362 | [[package]] 363 | name = "proc-macro2" 364 | version = "1.0.89" 365 | source = "registry+https://github.com/rust-lang/crates.io-index" 366 | checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" 367 | dependencies = [ 368 | "unicode-ident", 369 | ] 370 | 371 | [[package]] 372 | name = "quote" 373 | version = "1.0.37" 374 | source = "registry+https://github.com/rust-lang/crates.io-index" 375 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 376 | dependencies = [ 377 | "proc-macro2", 378 | ] 379 | 380 | [[package]] 381 | name = "regex" 382 | version = "1.11.1" 383 | source = "registry+https://github.com/rust-lang/crates.io-index" 384 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 385 | dependencies = [ 386 | "aho-corasick", 387 | "memchr", 388 | "regex-automata", 389 | "regex-syntax", 390 | ] 391 | 392 | [[package]] 393 | name = "regex-automata" 394 | version = "0.4.8" 395 | source = "registry+https://github.com/rust-lang/crates.io-index" 396 | checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" 397 | dependencies = [ 398 | "aho-corasick", 399 | "memchr", 400 | "regex-syntax", 401 | ] 402 | 403 | [[package]] 404 | name = "regex-syntax" 405 | version = "0.8.5" 406 | source = "registry+https://github.com/rust-lang/crates.io-index" 407 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 408 | 409 | [[package]] 410 | name = "rustix" 411 | version = "0.38.39" 412 | source = "registry+https://github.com/rust-lang/crates.io-index" 413 | checksum = "375116bee2be9ed569afe2154ea6a99dfdffd257f533f187498c2a8f5feaf4ee" 414 | dependencies = [ 415 | "bitflags", 416 | "errno", 417 | "libc", 418 | "linux-raw-sys", 419 | "windows-sys 0.52.0", 420 | ] 421 | 422 | [[package]] 423 | name = "ryu" 424 | version = "1.0.18" 425 | source = "registry+https://github.com/rust-lang/crates.io-index" 426 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 427 | 428 | [[package]] 429 | name = "serde" 430 | version = "1.0.214" 431 | source = "registry+https://github.com/rust-lang/crates.io-index" 432 | checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" 433 | dependencies = [ 434 | "serde_derive", 435 | ] 436 | 437 | [[package]] 438 | name = "serde_derive" 439 | version = "1.0.214" 440 | source = "registry+https://github.com/rust-lang/crates.io-index" 441 | checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" 442 | dependencies = [ 443 | "proc-macro2", 444 | "quote", 445 | "syn", 446 | ] 447 | 448 | [[package]] 449 | name = "serde_json" 450 | version = "1.0.132" 451 | source = "registry+https://github.com/rust-lang/crates.io-index" 452 | checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" 453 | dependencies = [ 454 | "itoa", 455 | "memchr", 456 | "ryu", 457 | "serde", 458 | ] 459 | 460 | [[package]] 461 | name = "sha1_smol" 462 | version = "1.0.1" 463 | source = "registry+https://github.com/rust-lang/crates.io-index" 464 | checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" 465 | 466 | [[package]] 467 | name = "shlex" 468 | version = "1.3.0" 469 | source = "registry+https://github.com/rust-lang/crates.io-index" 470 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 471 | 472 | [[package]] 473 | name = "strsim" 474 | version = "0.11.1" 475 | source = "registry+https://github.com/rust-lang/crates.io-index" 476 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 477 | 478 | [[package]] 479 | name = "syn" 480 | version = "2.0.87" 481 | source = "registry+https://github.com/rust-lang/crates.io-index" 482 | checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" 483 | dependencies = [ 484 | "proc-macro2", 485 | "quote", 486 | "unicode-ident", 487 | ] 488 | 489 | [[package]] 490 | name = "tempfile" 491 | version = "3.13.0" 492 | source = "registry+https://github.com/rust-lang/crates.io-index" 493 | checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" 494 | dependencies = [ 495 | "cfg-if", 496 | "fastrand", 497 | "once_cell", 498 | "rustix", 499 | "windows-sys 0.59.0", 500 | ] 501 | 502 | [[package]] 503 | name = "thiserror" 504 | version = "1.0.68" 505 | source = "registry+https://github.com/rust-lang/crates.io-index" 506 | checksum = "02dd99dc800bbb97186339685293e1cc5d9df1f8fae2d0aecd9ff1c77efea892" 507 | dependencies = [ 508 | "thiserror-impl", 509 | ] 510 | 511 | [[package]] 512 | name = "thiserror-impl" 513 | version = "1.0.68" 514 | source = "registry+https://github.com/rust-lang/crates.io-index" 515 | checksum = "a7c61ec9a6f64d2793d8a45faba21efbe3ced62a886d44c36a009b2b519b4c7e" 516 | dependencies = [ 517 | "proc-macro2", 518 | "quote", 519 | "syn", 520 | ] 521 | 522 | [[package]] 523 | name = "unicode-ident" 524 | version = "1.0.13" 525 | source = "registry+https://github.com/rust-lang/crates.io-index" 526 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 527 | 528 | [[package]] 529 | name = "utf8parse" 530 | version = "0.2.2" 531 | source = "registry+https://github.com/rust-lang/crates.io-index" 532 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 533 | 534 | [[package]] 535 | name = "uuid" 536 | version = "1.11.0" 537 | source = "registry+https://github.com/rust-lang/crates.io-index" 538 | checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" 539 | dependencies = [ 540 | "getrandom", 541 | "serde", 542 | "sha1_smol", 543 | ] 544 | 545 | [[package]] 546 | name = "wasi" 547 | version = "0.11.0+wasi-snapshot-preview1" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 550 | 551 | [[package]] 552 | name = "wasm-bindgen" 553 | version = "0.2.95" 554 | source = "registry+https://github.com/rust-lang/crates.io-index" 555 | checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" 556 | dependencies = [ 557 | "cfg-if", 558 | "once_cell", 559 | "wasm-bindgen-macro", 560 | ] 561 | 562 | [[package]] 563 | name = "wasm-bindgen-backend" 564 | version = "0.2.95" 565 | source = "registry+https://github.com/rust-lang/crates.io-index" 566 | checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" 567 | dependencies = [ 568 | "bumpalo", 569 | "log", 570 | "once_cell", 571 | "proc-macro2", 572 | "quote", 573 | "syn", 574 | "wasm-bindgen-shared", 575 | ] 576 | 577 | [[package]] 578 | name = "wasm-bindgen-macro" 579 | version = "0.2.95" 580 | source = "registry+https://github.com/rust-lang/crates.io-index" 581 | checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" 582 | dependencies = [ 583 | "quote", 584 | "wasm-bindgen-macro-support", 585 | ] 586 | 587 | [[package]] 588 | name = "wasm-bindgen-macro-support" 589 | version = "0.2.95" 590 | source = "registry+https://github.com/rust-lang/crates.io-index" 591 | checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" 592 | dependencies = [ 593 | "proc-macro2", 594 | "quote", 595 | "syn", 596 | "wasm-bindgen-backend", 597 | "wasm-bindgen-shared", 598 | ] 599 | 600 | [[package]] 601 | name = "wasm-bindgen-shared" 602 | version = "0.2.95" 603 | source = "registry+https://github.com/rust-lang/crates.io-index" 604 | checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" 605 | 606 | [[package]] 607 | name = "windows-core" 608 | version = "0.52.0" 609 | source = "registry+https://github.com/rust-lang/crates.io-index" 610 | checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" 611 | dependencies = [ 612 | "windows-targets", 613 | ] 614 | 615 | [[package]] 616 | name = "windows-sys" 617 | version = "0.52.0" 618 | source = "registry+https://github.com/rust-lang/crates.io-index" 619 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 620 | dependencies = [ 621 | "windows-targets", 622 | ] 623 | 624 | [[package]] 625 | name = "windows-sys" 626 | version = "0.59.0" 627 | source = "registry+https://github.com/rust-lang/crates.io-index" 628 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 629 | dependencies = [ 630 | "windows-targets", 631 | ] 632 | 633 | [[package]] 634 | name = "windows-targets" 635 | version = "0.52.6" 636 | source = "registry+https://github.com/rust-lang/crates.io-index" 637 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 638 | dependencies = [ 639 | "windows_aarch64_gnullvm", 640 | "windows_aarch64_msvc", 641 | "windows_i686_gnu", 642 | "windows_i686_gnullvm", 643 | "windows_i686_msvc", 644 | "windows_x86_64_gnu", 645 | "windows_x86_64_gnullvm", 646 | "windows_x86_64_msvc", 647 | ] 648 | 649 | [[package]] 650 | name = "windows_aarch64_gnullvm" 651 | version = "0.52.6" 652 | source = "registry+https://github.com/rust-lang/crates.io-index" 653 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 654 | 655 | [[package]] 656 | name = "windows_aarch64_msvc" 657 | version = "0.52.6" 658 | source = "registry+https://github.com/rust-lang/crates.io-index" 659 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 660 | 661 | [[package]] 662 | name = "windows_i686_gnu" 663 | version = "0.52.6" 664 | source = "registry+https://github.com/rust-lang/crates.io-index" 665 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 666 | 667 | [[package]] 668 | name = "windows_i686_gnullvm" 669 | version = "0.52.6" 670 | source = "registry+https://github.com/rust-lang/crates.io-index" 671 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 672 | 673 | [[package]] 674 | name = "windows_i686_msvc" 675 | version = "0.52.6" 676 | source = "registry+https://github.com/rust-lang/crates.io-index" 677 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 678 | 679 | [[package]] 680 | name = "windows_x86_64_gnu" 681 | version = "0.52.6" 682 | source = "registry+https://github.com/rust-lang/crates.io-index" 683 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 684 | 685 | [[package]] 686 | name = "windows_x86_64_gnullvm" 687 | version = "0.52.6" 688 | source = "registry+https://github.com/rust-lang/crates.io-index" 689 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 690 | 691 | [[package]] 692 | name = "windows_x86_64_msvc" 693 | version = "0.52.6" 694 | source = "registry+https://github.com/rust-lang/crates.io-index" 695 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 696 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "juv" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | anstream = "0.6.18" 8 | anyhow = "1.0.93" 9 | clack = "0.1.0" 10 | clap = { version = "4.5.20", features = ["derive", "env"] } 11 | glob = "0.3.1" 12 | nbformat = { version = "0.3.2", git = "https://github.com/runtimed/runtimed", branch = "manzt/nbformat-serialize" } 13 | once_cell = "1.20.2" 14 | owo-colors = "4.1.0" 15 | regex = "1.11.1" 16 | serde_json = "1.0.132" 17 | tempfile = "3.13.0" 18 | uuid = "1.11.0" 19 | -------------------------------------------------------------------------------- /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 | # juv-rs 2 | 3 | a rewrite of [`juv`](https://github.com/manzt/juv) in rust 4 | 5 | > [!WARNING] 6 | > You are probably looking for the original 7 | > [`juv`](https:/github.com/manzt/juv) project. This is an experimental 8 | > rewrite. Proceed with caution. 9 | 10 | ## compatability 11 | 12 | - [x] `cat` 13 | - [x] `init` 14 | - [ ] `run` (partial) 15 | - [x] `exec` 16 | - [x] `add` 17 | - [x] `clear` 18 | - [ ] `edit` (partial) 19 | 20 | ## why? 21 | 22 | I needed a mental respite post-election results. 23 | 24 | Not planning to migrate existing code - performance gains are marginal and my 25 | fondness of writing Rust probably doesn't justify the added release complexity. 26 | -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | use crate::notebook::{Notebook, NotebookBuilder}; 2 | use crate::printer::Printer; 3 | use crate::script::Runtime; 4 | use anyhow::{bail, Result}; 5 | use once_cell::sync::Lazy; 6 | use owo_colors::OwoColorize; 7 | use regex::Regex; 8 | use std::fmt::Write as _; 9 | use std::io::{self, BufWriter, Write}; 10 | use std::path::{Path, PathBuf}; 11 | use std::process::{Command, Stdio}; 12 | use tempfile::NamedTempFile; 13 | 14 | #[allow(clippy::too_many_arguments)] 15 | pub fn run( 16 | printer: &Printer, 17 | path: &Path, 18 | with: &[String], 19 | python: Option<&str>, 20 | jupyter: Option<&str>, 21 | jupyter_args: &[String], 22 | no_project: bool, 23 | managed: bool, 24 | dry_run: bool, 25 | ) -> Result<()> { 26 | let runtime: Runtime = jupyter.unwrap_or("lab").parse()?; 27 | let notebook = Notebook::from_path(path)?; 28 | 29 | let meta = notebook.as_ref().cells.iter().find_map(|cell| { 30 | if let nbformat::v4::Cell::Code { source, .. } = cell { 31 | PEP723_REGEX 32 | .captures(&source.join("")) 33 | .and_then(|cap| cap.get(0).map(|m| m.as_str().to_string())) 34 | } else { 35 | None 36 | } 37 | }); 38 | 39 | // TODO: Support managed version 40 | let with_args = runtime.with_args(); 41 | let script = runtime.prepare_run_script(path, meta.as_deref(), managed, jupyter_args); 42 | 43 | let args = { 44 | let mut args = vec!["run", "--with", with_args.as_ref()]; 45 | if no_project { 46 | args.push("--no-project"); 47 | } 48 | if let Some(python) = python { 49 | args.push("--python"); 50 | args.push(python); 51 | } 52 | for with_item in with { 53 | args.push("--with"); 54 | args.push(with_item); 55 | } 56 | args.push("-"); // stdin 57 | args 58 | }; 59 | 60 | if dry_run { 61 | println!("uv {}", args.join(" ")); 62 | println!("{}", script); 63 | return Ok(()); 64 | } 65 | 66 | let mut child = Command::new("uv") 67 | .args(&args) 68 | .stdin(Stdio::piped()) 69 | .stdout(Stdio::inherit()) 70 | .stderr(Stdio::inherit()) 71 | .spawn()?; 72 | 73 | let stdin = child.stdin.as_mut().expect("Failed to open stdin"); 74 | stdin.write_all(script.as_bytes())?; 75 | 76 | let status = child.wait()?; 77 | if !status.success() { 78 | writeln!( 79 | printer.stderr(), 80 | "{}: uv command failed with exit code {}", 81 | "error".red().bold(), 82 | status.code().unwrap_or(-1) 83 | )?; 84 | std::process::exit(1); 85 | } 86 | 87 | Ok(()) 88 | } 89 | 90 | pub fn exec( 91 | _printer: &Printer, 92 | path: &Path, 93 | python: Option<&str>, 94 | with: &[String], 95 | quiet: bool, 96 | ) -> Result<()> { 97 | let path = std::path::absolute(path)?; 98 | let mut args = vec!["run", "-"]; 99 | if quiet { 100 | args.push("--quiet"); 101 | } 102 | if let Some(python) = python { 103 | args.push("--python"); 104 | args.push(python); 105 | } 106 | for with_item in with { 107 | args.push("--with"); 108 | args.push(with_item); 109 | } 110 | 111 | let mut child = Command::new("uv") 112 | .args(&args) 113 | .current_dir(path.parent().unwrap()) 114 | .stdin(Stdio::piped()) 115 | .stdout(Stdio::inherit()) 116 | .stderr(Stdio::inherit()) 117 | .spawn()?; 118 | 119 | { 120 | let mut stdin = child 121 | .stdin 122 | .as_ref() 123 | .map(BufWriter::new) 124 | .expect("Failed to open stdin"); 125 | let nb = Notebook::from_path(path.as_ref())?; 126 | write_script(&mut stdin, nb.as_ref())?; 127 | } 128 | 129 | let status = child.wait()?; 130 | if !status.success() { 131 | println!( 132 | "{}: uv command failed with exit code {}", 133 | "error".red().bold(), 134 | status.code().unwrap_or(-1) 135 | ); 136 | std::process::exit(1); 137 | } 138 | 139 | Ok(()) 140 | } 141 | 142 | pub fn init(printer: &Printer, path: Option<&Path>, python: Option<&str>) -> Result<()> { 143 | let path = match path { 144 | Some(p) => p.to_path_buf(), 145 | None => get_first_non_conflicting_untitled_ipybnb(&std::env::current_dir()?)?, 146 | }; 147 | let path = std::path::absolute(&path)?; 148 | let dir = path.parent().expect("path must have a parent"); 149 | 150 | if path.extension().and_then(|s| s.to_str()) != Some("ipynb") { 151 | writeln!( 152 | printer.stderr(), 153 | "{}: The notebook must have a `{}` extension", 154 | "error".red().bold(), 155 | ".ipynb".cyan() 156 | )?; 157 | std::process::exit(1); 158 | } 159 | 160 | let nb = new_notebook_with_inline_metadata(dir, python)?; 161 | std::fs::write(&path, serde_json::to_string_pretty(nb.as_ref())?)?; 162 | 163 | writeln!( 164 | printer.stdout(), 165 | "Initialized notebook at `{}`", 166 | path.strip_prefix(dir)?.display().cyan() 167 | )?; 168 | Ok(()) 169 | } 170 | 171 | #[allow(clippy::too_many_arguments)] 172 | pub fn add( 173 | printer: &Printer, 174 | path: &Path, 175 | packages: &[String], 176 | requirements: Option<&Path>, 177 | extras: &[String], 178 | tag: Option<&str>, 179 | branch: Option<&str>, 180 | rev: Option<&str>, 181 | editable: bool, 182 | ) -> Result<()> { 183 | let mut nb = Notebook::from_path(path)?; 184 | 185 | for cell in nb.as_mut().cells.iter_mut() { 186 | match cell { 187 | nbformat::v4::Cell::Code { source, .. } if PEP723_REGEX.is_match(&source.join("")) => { 188 | let temp_file = tempfile::Builder::new() 189 | .suffix(".py") 190 | .tempfile_in(path.parent().unwrap())?; 191 | 192 | std::fs::write(temp_file.path(), source.join("").trim())?; 193 | 194 | let mut command = Command::new("uv"); 195 | command.arg("add").arg("--script").arg(temp_file.path()); 196 | 197 | if editable { 198 | command.arg("--editable"); 199 | } 200 | 201 | if let Some(requirements) = requirements { 202 | command.arg("--requirements").arg(requirements); 203 | } 204 | 205 | if let Some(tag) = tag { 206 | command.arg("--tag").arg(tag); 207 | } 208 | 209 | if let Some(branch) = branch { 210 | command.arg("--branch").arg(branch); 211 | } 212 | 213 | if let Some(rev) = rev { 214 | command.arg("--rev").arg(rev); 215 | } 216 | 217 | for extra in extras { 218 | command.arg("--extra").arg(extra); 219 | } 220 | 221 | command.args(packages); 222 | 223 | let output = command.output()?; 224 | 225 | if !output.status.success() { 226 | let stderr = String::from_utf8_lossy(&output.stderr); 227 | anyhow::bail!("uv command failed: {}", stderr); 228 | } 229 | 230 | let contents = std::fs::read_to_string(temp_file.path())?; 231 | *source = contents 232 | .trim() 233 | .split_inclusive('\n') 234 | .map(|s| s.to_string()) 235 | .collect(); 236 | 237 | break; 238 | } 239 | _ => {} 240 | } 241 | } 242 | 243 | std::fs::write(path, serde_json::to_string_pretty(nb.as_ref())?)?; 244 | writeln!(printer.stderr(), "Updated `{}`", path.display().cyan())?; 245 | Ok(()) 246 | } 247 | 248 | pub fn edit(printer: &Printer, file: &Path, editor: Option<&str>) -> Result<()> { 249 | let nb = Notebook::from_path(file)?; 250 | let mut temp_file = tempfile::Builder::new().suffix(".md").tempfile()?; 251 | { 252 | let mut buffer = BufWriter::new(&mut temp_file); 253 | write_markdown(&mut buffer, nb.as_ref())?; 254 | buffer.flush()?; 255 | } 256 | 257 | let status = match editor { 258 | Some(editor) => Command::new(editor).arg(temp_file.path()).status()?, 259 | None => { 260 | writeln!( 261 | printer.stderr(), 262 | "{}: No editor specified. Please set the EDITOR environment variable or use the `{}` flag.", 263 | "error".red().bold(), 264 | "--editor".yellow().bold() 265 | )?; 266 | std::process::exit(1); 267 | } 268 | }; 269 | 270 | if !status.success() { 271 | writeln!( 272 | printer.stderr(), 273 | "{}: Editor command failed with exit code {}", 274 | "error".red().bold(), 275 | status.code().unwrap_or(-1) 276 | )?; 277 | std::process::exit(1); 278 | } 279 | 280 | let update = std::fs::read_to_string(temp_file.path())?; 281 | 282 | println!("{}", update); 283 | 284 | // TODO: Need to parse the markdown "cell" contents and update the corresponding cells 285 | 286 | Ok(()) 287 | } 288 | 289 | pub fn clear(printer: &Printer, targets: &[String], check: bool) -> Result<()> { 290 | let mut paths: Vec = Vec::new(); 291 | 292 | // Collect notebook paths from the specified targets 293 | for target in targets { 294 | let path = Path::new(target); 295 | if path.is_dir() { 296 | // Use glob to find .ipynb files in directory 297 | glob::glob(&format!("{}/*.ipynb", path.display()))?.for_each(|entry| { 298 | if let Ok(notebook_path) = entry { 299 | paths.push(notebook_path); 300 | } 301 | }); 302 | } else if path.is_file() && path.extension().map_or(false, |ext| ext == "ipynb") { 303 | paths.push(path.to_path_buf()); 304 | } else { 305 | writeln!( 306 | printer.stderr(), 307 | "{}: Skipping `{}` because it is not a notebook", 308 | "warning".yellow().bold(), 309 | path.display().cyan(), 310 | )?; 311 | } 312 | } 313 | 314 | if check { 315 | let mut any_not_cleared = false; 316 | 317 | // Check each notebook to see if it is already cleared 318 | for path in &paths { 319 | let notebook = Notebook::from_path(path)?; 320 | if !notebook.is_cleared() { 321 | writeln!(printer.stderr(), "{}", path.display().magenta())?; 322 | any_not_cleared = true; 323 | } 324 | } 325 | 326 | if any_not_cleared { 327 | writeln!( 328 | printer.stderr(), 329 | "{}: Some notebooks are not cleared. Use {} to fix.", 330 | "error".red(), 331 | "juv clear".yellow().bold(), 332 | )?; 333 | std::process::exit(1); 334 | } else { 335 | writeln!(printer.stderr(), "All notebooks are cleared")?; 336 | } 337 | } else { 338 | // Clear the outputs in each notebook 339 | for path in &paths { 340 | let mut notebook = Notebook::from_path(path)?; 341 | notebook.clear_cells()?; 342 | std::fs::write(path, serde_json::to_string_pretty(notebook.as_ref())?)?; 343 | writeln!( 344 | printer.stderr(), 345 | "Cleared output from `{}`", 346 | path.display().cyan() 347 | )?; 348 | } 349 | if paths.len() > 1 { 350 | writeln!( 351 | printer.stderr(), 352 | "Cleared output from {} notebooks", 353 | paths.len().to_string().cyan().bold() 354 | )?; 355 | } 356 | } 357 | 358 | Ok(()) 359 | } 360 | 361 | pub fn cat( 362 | _printer: &Printer, 363 | file: &std::path::Path, 364 | script: bool, 365 | pager: Option<&str>, 366 | ) -> Result<()> { 367 | let nb = Notebook::from_path(file)?; 368 | let mut writer: Box = match pager.map(str::trim) { 369 | Some("") | None => Box::new(BufWriter::new(io::stdout().lock())), 370 | Some(pager) => { 371 | let mut command = Command::new(pager); 372 | if pager == "bat" { 373 | let ext = if script { "py" } else { "md" }; 374 | // special case `bat` to add additional flags 375 | command 376 | .arg("--language") 377 | .arg(ext) 378 | .arg("--file-name") 379 | .arg(format!( 380 | "{}.{}", 381 | file.file_stem() 382 | .unwrap_or("stdin".as_ref()) 383 | .to_string_lossy(), 384 | ext 385 | )); 386 | } 387 | let child = command.stdin(Stdio::piped()).spawn()?; 388 | // Ok to unwrap because we know we set stdin to piped 389 | Box::new(BufWriter::new(child.stdin.unwrap())) 390 | } 391 | }; 392 | 393 | if script { 394 | write_script(&mut writer, nb.as_ref())?; 395 | } else { 396 | write_markdown(&mut writer, nb.as_ref())?; 397 | }; 398 | 399 | writer.flush()?; 400 | 401 | Ok(()) 402 | } 403 | 404 | fn write_script(writer: &mut impl Write, nb: &nbformat::v4::Notebook) -> Result<()> { 405 | for (i, cell) in nb.cells.iter().enumerate() { 406 | if i > 0 { 407 | // Add a newline between cells 408 | writer.write_all(b"\n\n")?; 409 | } 410 | match cell { 411 | nbformat::v4::Cell::Code { source, .. } => { 412 | writer.write_all(b"# %%\n")?; 413 | for line in source.iter() { 414 | writer.write_all(line.as_bytes())?; 415 | } 416 | } 417 | nbformat::v4::Cell::Markdown { source, .. } => { 418 | writer.write_all(b"# %% [markdown]\n")?; 419 | for line in source.iter() { 420 | writer.write_all(b"# ")?; 421 | writer.write_all(line.as_bytes())?; 422 | } 423 | } 424 | nbformat::v4::Cell::Raw { source, .. } => { 425 | writer.write_all(b"# %% [raw]\n")?; 426 | for line in source.iter() { 427 | writer.write_all(b"# ")?; 428 | writer.write_all(line.as_bytes())?; 429 | } 430 | } 431 | } 432 | } 433 | Ok(()) 434 | } 435 | 436 | fn write_markdown(writer: &mut impl Write, nb: &nbformat::v4::Notebook) -> Result<()> { 437 | for (i, cell) in nb.cells.iter().enumerate() { 438 | if i > 0 { 439 | // Add a newline between cells 440 | writer.write_all(b"\n\n")?; 441 | } 442 | match cell { 443 | nbformat::v4::Cell::Code { source, .. } => { 444 | writer.write_all(b"```python\n")?; 445 | for line in source.iter() { 446 | writer.write_all(line.as_bytes())?; 447 | } 448 | writer.write_all(b"\n```")?; 449 | } 450 | nbformat::v4::Cell::Markdown { source, .. } => { 451 | for line in source.iter() { 452 | writer.write_all(line.as_bytes())?; 453 | } 454 | } 455 | nbformat::v4::Cell::Raw { source, .. } => { 456 | writer.write_all(b"```\n")?; 457 | for line in source.iter() { 458 | writer.write_all(line.as_bytes())?; 459 | } 460 | writer.write_all(b"\n```")?; 461 | } 462 | } 463 | } 464 | Ok(()) 465 | } 466 | 467 | fn get_first_non_conflicting_untitled_ipybnb(directory: &Path) -> Result { 468 | let base_name = "Untitled"; 469 | let extension = "ipynb"; 470 | 471 | if !directory 472 | .join(format!("{}.{}", base_name, extension)) 473 | .exists() 474 | { 475 | return Ok(directory.join(format!("{}.{}", base_name, extension))); 476 | } 477 | 478 | for i in 1..100 { 479 | let file_name = format!("{}{}.{}", base_name, i, extension); 480 | let path = directory.join(&file_name); 481 | if !path.exists() { 482 | return Ok(path); 483 | } 484 | } 485 | 486 | bail!("Could not find an available UntitledX.ipynb"); 487 | } 488 | 489 | fn new_notebook_with_inline_metadata(directory: &Path, python: Option<&str>) -> Result { 490 | let temp_file = NamedTempFile::new_in(directory)?; 491 | let temp_path = temp_file.path().to_path_buf(); 492 | 493 | let mut command = Command::new("uv"); 494 | 495 | command 496 | .arg("init") 497 | .arg("--script") 498 | .arg(temp_path.to_str().unwrap()); 499 | 500 | if let Some(py) = python { 501 | command.arg("--python").arg(py); 502 | } 503 | 504 | let output = command.output()?; 505 | 506 | if !output.status.success() { 507 | let stderr = String::from_utf8_lossy(&output.stderr); 508 | anyhow::bail!("uv command failed: {}", stderr); 509 | } 510 | 511 | Ok(NotebookBuilder::new() 512 | .hidden_code_cell(&std::fs::read_to_string(temp_path)?) 513 | .code_cell("") 514 | .build()) 515 | } 516 | 517 | static PEP723_REGEX: Lazy = Lazy::new(|| { 518 | Regex::new(r"(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)+)^# ///$").unwrap() 519 | }); 520 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::builder::styling::{AnsiColor, Effects}; 3 | use clap::builder::Styles; 4 | use clap::{Parser, Subcommand, ValueEnum}; 5 | use std::io::Write as _; 6 | 7 | mod commands; 8 | mod notebook; 9 | mod printer; 10 | mod script; 11 | 12 | // Configures Clap v3-style help menu colors 13 | const STYLES: Styles = Styles::styled() 14 | .header(AnsiColor::Yellow.on_default().effects(Effects::BOLD)) 15 | .usage(AnsiColor::Yellow.on_default().effects(Effects::BOLD)) 16 | .literal(AnsiColor::White.on_default().effects(Effects::BOLD)) 17 | .placeholder(AnsiColor::White.on_default()); 18 | 19 | #[derive(Parser)] 20 | #[command(name = "juv", author, long_version = version())] 21 | #[command(about = "A fast toolkit for reproducible Jupyter notebooks")] 22 | #[command(styles=STYLES)] 23 | struct Cli { 24 | #[command(subcommand)] 25 | command: Commands, 26 | /// Increase verbosity 27 | #[arg(short, long, action, conflicts_with = "quiet", global = true)] 28 | verbose: bool, 29 | /// Suppress all output 30 | #[arg(short, long, action, conflicts_with = "verbose", global = true)] 31 | quiet: bool, 32 | } 33 | 34 | #[derive(ValueEnum, Debug, Clone)] 35 | #[clap(rename_all = "kebab_case")] 36 | enum VersionOutputFormat { 37 | Text, 38 | Json, 39 | } 40 | 41 | #[derive(Subcommand)] 42 | enum Commands { 43 | /// Preview the contents of a notebook 44 | Cat { 45 | /// The file to display 46 | file: std::path::PathBuf, 47 | /// Display the file as python script 48 | #[arg(long, action)] 49 | script: bool, 50 | /// A pager to use for displaying the contents 51 | #[arg(long, env = "JUV_PAGER")] 52 | pager: Option, 53 | }, 54 | /// Initialize a new notebook 55 | Init { 56 | /// The name of the project 57 | file: Option, 58 | /// The interpreter version specifier 59 | #[arg(short, long)] 60 | python: Option, 61 | }, 62 | /// Launch a notebook or script in a Jupyter front end 63 | Run { 64 | /// The notebook to run 65 | path: std::path::PathBuf, 66 | /// The runtime to use for running the notebook 67 | #[arg(long, env = "JUV_JUPYTER")] 68 | jupyter: Option, 69 | /// Run with the additional packages installed 70 | #[arg(long)] 71 | with: Vec, 72 | /// The Python interpreter to use for the run environment. 73 | #[arg(short, long)] 74 | python: Option, 75 | /// Run in juv managed mode 76 | #[arg(long, action)] 77 | managed: bool, 78 | /// Don't actually start the Jupyter runtime. 79 | /// 80 | /// Prints the command that would be run and the generated "run" script. 81 | #[arg(long, action)] 82 | dry_run: bool, 83 | /// Additional arguments to pass to the Jupyter runtime 84 | #[arg(trailing_var_arg = true)] 85 | jupyter_args: Vec, 86 | /// Avoid discovering the project or workspace 87 | #[arg(long)] 88 | no_project: bool, 89 | }, 90 | /// Execute a notebook as a script 91 | Exec { 92 | /// The notebook to execute 93 | path: std::path::PathBuf, 94 | /// The Python interpreter to use for the exec environment 95 | #[arg(short, long)] 96 | python: Option, 97 | /// Run with the additional packages installed 98 | #[arg(long)] 99 | with: Vec, 100 | }, 101 | /// Add dependencies to a notebook 102 | Add { 103 | /// The notebook to add dependencies to 104 | path: std::path::PathBuf, 105 | /// The packages to add 106 | packages: Vec, 107 | /// Add all packages listed in the given `requirements.txt` file 108 | #[arg(short, long)] 109 | requirements: Option, 110 | /// Extras to enable for the dependency 111 | #[arg(long)] 112 | extra: Vec, 113 | /// Add the requirements as editable 114 | #[arg(long)] 115 | tag: Option, 116 | /// Tag to use when adding a dependency from Git 117 | #[arg(long)] 118 | branch: Option, 119 | /// Branch to use when adding a dependency from Git 120 | #[arg(long)] 121 | rev: Option, 122 | /// Commit to use when adding a dependency from Git 123 | #[arg(long)] 124 | editable: bool, 125 | }, 126 | /// Clear notebook cell outputs 127 | /// 128 | /// Supports multiple files and glob patterns (e.g., *.ipynb, notebooks/*.ipynb) 129 | Clear { 130 | /// The files to clear, can be a glob pattern 131 | files: Vec, 132 | /// Check if the notebooks are cleared 133 | #[arg(long)] 134 | check: bool, 135 | }, 136 | /// Display juv's version 137 | Version { 138 | #[arg(long, default_value = "text", value_enum)] 139 | output_format: VersionOutputFormat, 140 | }, 141 | /// Quick edit a notebook as markdown 142 | Edit { 143 | /// The file to edit 144 | file: std::path::PathBuf, 145 | /// The editor to use 146 | #[arg(short, long, env = "EDITOR")] 147 | editor: Option, 148 | }, 149 | } 150 | 151 | fn main() -> Result<()> { 152 | let cli = Cli::parse(); 153 | let printer = match (cli.verbose, cli.quiet) { 154 | (true, false) => printer::Printer::Verbose, 155 | (false, true) => printer::Printer::Quiet, 156 | _ => printer::Printer::Default, 157 | }; 158 | match Cli::parse().command { 159 | Commands::Version { output_format } => { 160 | match output_format { 161 | VersionOutputFormat::Text => { 162 | std::io::stdout().write_all(format!("juv {}", version()).as_bytes())?; 163 | } 164 | VersionOutputFormat::Json => { 165 | let json = serde_json::json!({ "version": version() }); 166 | std::io::stdout().write_all(serde_json::to_string(&json)?.as_bytes())?; 167 | } 168 | }; 169 | std::io::stdout().write_all(b"\n")?; 170 | Ok(()) 171 | } 172 | Commands::Init { file, python } => { 173 | commands::init(&printer, file.as_deref(), python.as_deref()) 174 | } 175 | Commands::Cat { 176 | file, 177 | script, 178 | pager, 179 | } => commands::cat(&printer, &file, script, pager.as_deref()), 180 | Commands::Clear { files, check } => commands::clear(&printer, &files, check), 181 | Commands::Edit { file, editor } => commands::edit(&printer, &file, editor.as_deref()), 182 | Commands::Add { 183 | path, 184 | packages, 185 | requirements, 186 | extra, 187 | tag, 188 | branch, 189 | rev, 190 | editable, 191 | } => commands::add( 192 | &printer, 193 | &path, 194 | &packages, 195 | requirements.as_deref(), 196 | &extra, 197 | tag.as_deref(), 198 | branch.as_deref(), 199 | rev.as_deref(), 200 | editable, 201 | ), 202 | Commands::Run { 203 | path, 204 | jupyter, 205 | with, 206 | python, 207 | jupyter_args, 208 | managed, 209 | dry_run, 210 | no_project, 211 | } => commands::run( 212 | &printer, 213 | &path, 214 | &with, 215 | python.as_deref(), 216 | jupyter.as_deref(), 217 | &jupyter_args, 218 | no_project, 219 | managed, 220 | dry_run, 221 | ), 222 | Commands::Exec { path, python, with } => { 223 | commands::exec(&printer, &path, python.as_deref(), &with, cli.quiet) 224 | } 225 | } 226 | } 227 | 228 | fn version() -> &'static str { 229 | env!("CARGO_PKG_VERSION") 230 | } 231 | -------------------------------------------------------------------------------- /src/notebook.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use nbformat::v4::{Cell, CellId, CellMetadata, JupyterCellMetadata, Metadata}; 3 | use std::path::Path; 4 | 5 | pub struct Notebook(nbformat::v4::Notebook); 6 | 7 | impl AsRef for Notebook { 8 | fn as_ref(&self) -> &nbformat::v4::Notebook { 9 | &self.0 10 | } 11 | } 12 | 13 | impl AsMut for Notebook { 14 | fn as_mut(&mut self) -> &mut nbformat::v4::Notebook { 15 | &mut self.0 16 | } 17 | } 18 | 19 | impl Notebook { 20 | pub fn from_path(path: &Path) -> Result { 21 | let json = std::fs::read_to_string(path)?; 22 | Ok(Self(match nbformat::parse_notebook(&json)? { 23 | nbformat::Notebook::V4(nb) => nb, 24 | nbformat::Notebook::Legacy(legacy_nb) => nbformat::upgrade_legacy_notebook(legacy_nb)?, 25 | })) 26 | } 27 | 28 | // Whether the notebook outputs are cleared 29 | pub fn is_cleared(&self) -> bool { 30 | for cell in &self.as_ref().cells { 31 | if let Cell::Code { 32 | execution_count, 33 | outputs, 34 | .. 35 | } = cell 36 | { 37 | if execution_count.is_some() || !outputs.is_empty() { 38 | return false; 39 | } 40 | } 41 | } 42 | true 43 | } 44 | 45 | pub fn clear_cells(&mut self) -> Result<()> { 46 | for cell in &mut self.0.cells { 47 | if let Cell::Code { 48 | execution_count, 49 | outputs, 50 | .. 51 | } = cell 52 | { 53 | *execution_count = None; 54 | outputs.clear(); 55 | } 56 | } 57 | Ok(()) 58 | } 59 | } 60 | 61 | pub struct NotebookBuilder { 62 | nb: nbformat::v4::Notebook, 63 | } 64 | 65 | impl NotebookBuilder { 66 | pub fn new() -> Self { 67 | Self { 68 | nb: nbformat::v4::Notebook { 69 | nbformat: 4, 70 | nbformat_minor: 4, 71 | metadata: Metadata { 72 | kernelspec: None, 73 | language_info: None, 74 | authors: None, 75 | additional: Default::default(), 76 | }, 77 | cells: vec![], 78 | }, 79 | } 80 | } 81 | 82 | fn _code_cell(mut self, source: &str, hidden: Option) -> Self { 83 | let uuid = uuid::Uuid::new_v4().to_string(); 84 | // TODO: Could have our own builder for this as well 85 | let cell = Cell::Code { 86 | // ok to unwrap because we know the first part of the uuid is valid 87 | id: CellId::try_from(uuid.split('-').next().unwrap()).unwrap(), 88 | metadata: CellMetadata { 89 | id: None, 90 | collapsed: None, 91 | scrolled: None, 92 | deletable: None, 93 | editable: None, 94 | format: None, 95 | jupyter: hidden.map(|h| JupyterCellMetadata { 96 | source_hidden: Some(h), 97 | outputs_hidden: None, 98 | }), 99 | name: None, 100 | tags: None, 101 | execution: None, 102 | }, 103 | execution_count: None, 104 | source: source 105 | .trim() 106 | .split_inclusive('\n') 107 | .map(|s| s.to_string()) 108 | .collect(), 109 | outputs: vec![], 110 | }; 111 | self.nb.cells.push(cell); 112 | self 113 | } 114 | 115 | pub fn hidden_code_cell(self, source: &str) -> Self { 116 | self._code_cell(source, Some(true)) 117 | } 118 | 119 | pub fn code_cell(self, source: &str) -> Self { 120 | self._code_cell(source, None) 121 | } 122 | 123 | pub fn build(self) -> Notebook { 124 | Notebook(self.nb) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/printer.rs: -------------------------------------------------------------------------------- 1 | use anstream::{eprint, print}; 2 | 3 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 4 | pub(crate) enum Printer { 5 | /// A printer that prints to standard streams (e.g., stdout). 6 | Default, 7 | /// A printer that suppresses all output. 8 | Quiet, 9 | /// A printer that prints all output, including debug messages. 10 | Verbose, 11 | } 12 | 13 | impl Printer { 14 | /// Return the [`Stdout`] for this printer. 15 | pub(crate) fn stdout(self) -> Stdout { 16 | match self { 17 | Self::Default => Stdout::Enabled, 18 | Self::Quiet => Stdout::Disabled, 19 | Self::Verbose => Stdout::Enabled, 20 | } 21 | } 22 | 23 | /// Return the [`Stderr`] for this printer. 24 | pub(crate) fn stderr(self) -> Stderr { 25 | match self { 26 | Self::Default => Stderr::Enabled, 27 | Self::Quiet => Stderr::Disabled, 28 | Self::Verbose => Stderr::Enabled, 29 | } 30 | } 31 | } 32 | 33 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 34 | pub(crate) enum Stdout { 35 | Enabled, 36 | Disabled, 37 | } 38 | 39 | impl std::fmt::Write for Stdout { 40 | fn write_str(&mut self, s: &str) -> std::fmt::Result { 41 | match self { 42 | Self::Enabled => { 43 | #[allow(clippy::print_stdout, clippy::ignored_unit_patterns)] 44 | { 45 | print!("{s}"); 46 | } 47 | } 48 | Self::Disabled => {} 49 | } 50 | 51 | Ok(()) 52 | } 53 | } 54 | 55 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 56 | pub(crate) enum Stderr { 57 | Enabled, 58 | Disabled, 59 | } 60 | 61 | impl std::fmt::Write for Stderr { 62 | fn write_str(&mut self, s: &str) -> std::fmt::Result { 63 | match self { 64 | Self::Enabled => { 65 | #[allow(clippy::print_stderr, clippy::ignored_unit_patterns)] 66 | { 67 | eprint!("{s}"); 68 | } 69 | } 70 | Self::Disabled => {} 71 | } 72 | 73 | Ok(()) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/script.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, path::Path, str::FromStr}; 2 | 3 | #[derive(Debug, PartialEq)] 4 | enum RuntimeKind { 5 | Notebook, 6 | Lab, 7 | Nbclassic, 8 | } 9 | 10 | #[derive(Debug, PartialEq)] 11 | pub struct Runtime { 12 | kind: RuntimeKind, 13 | version: Option, 14 | } 15 | 16 | impl FromStr for Runtime { 17 | type Err = anyhow::Error; 18 | 19 | fn from_str(s: &str) -> Result { 20 | let (kind_str, version) = if s.contains('@') { 21 | s.split_once('@') 22 | .map(|(name, version)| (name, Some(version.to_string()))) 23 | .unwrap_or((s, None)) 24 | } else if s.contains("==") { 25 | s.split_once("==") 26 | .map(|(name, version)| (name, Some(version.to_string()))) 27 | .unwrap_or((s, None)) 28 | } else { 29 | (s, None) 30 | }; 31 | 32 | let kind = match kind_str { 33 | "notebook" => RuntimeKind::Notebook, 34 | "lab" => RuntimeKind::Lab, 35 | "nbclassic" => RuntimeKind::Nbclassic, 36 | _ => anyhow::bail!("Invalid runtime specifier: {}", s), 37 | }; 38 | 39 | Ok(Runtime { kind, version }) 40 | } 41 | } 42 | 43 | impl Runtime { 44 | /// Provides the executable name for the runtime 45 | fn exacutable(&self) -> &'static str { 46 | match self.kind { 47 | RuntimeKind::Notebook => "jupyter-notebook", 48 | RuntimeKind::Lab => "jupyter-lab", 49 | RuntimeKind::Nbclassic => "jupyter-nbclassic", 50 | } 51 | } 52 | 53 | /// Provides the module specifer to import the main function for the runtime 54 | fn main_import(&self) -> &'static str { 55 | if self.kind == RuntimeKind::Notebook && self.version.as_deref() == Some("6") { 56 | return "notebook.notebookapp"; 57 | }; 58 | match self.kind { 59 | RuntimeKind::Notebook => "notebook.app", 60 | RuntimeKind::Lab => "jupyterlab.labapp", 61 | RuntimeKind::Nbclassic => "nbclassic.notebookapp", 62 | } 63 | } 64 | 65 | /// Provides the package name for the runtime 66 | fn package_name(&self) -> &'static str { 67 | match self.kind { 68 | RuntimeKind::Notebook => "notebook", 69 | RuntimeKind::Lab => "jupyterlab", 70 | RuntimeKind::Nbclassic => "nbclassic", 71 | } 72 | } 73 | 74 | /// Provides the with args for the Runtime for uv --with=... 75 | pub fn with_args(&self) -> Cow<'static, str> { 76 | let specifier = if let Some(version) = &self.version { 77 | Cow::Owned(format!("{}=={}", self.package_name(), version)) 78 | } else { 79 | Cow::Borrowed(self.package_name()) 80 | }; 81 | if self.kind == RuntimeKind::Notebook && self.version.as_deref() == Some("6") { 82 | // notebook v6 requires setuptools 83 | format!("{},setuptools", specifier).into() 84 | } else { 85 | specifier 86 | } 87 | } 88 | 89 | /// Dynamically generates a script for uv to run the notebook/lab/nbclassic in an isolated environment 90 | #[allow(clippy::format_in_format_args)] 91 | pub fn prepare_run_script( 92 | &self, 93 | path: &Path, 94 | meta: Option<&str>, 95 | is_managed: bool, 96 | jupyter_args: &[String], 97 | ) -> String { 98 | let notebook = path.to_string_lossy(); 99 | let mut args: Vec<&str> = vec![self.exacutable(), notebook.as_ref()]; 100 | args.extend(jupyter_args.iter().map(String::as_str)); 101 | 102 | let print_version: Cow<'static, str> = if is_managed { 103 | format!( 104 | r#"import importlib.metadata;print("JUV_MANGED=" + "{name}" + "," + importlib.metadata.version("{name}"), file=sys.stderr)"#, 105 | name = self.package_name() 106 | ) 107 | .into() 108 | } else { 109 | // only print version if we are in the managed mode 110 | "".into() 111 | }; 112 | 113 | format!( 114 | r#"{meta} 115 | 116 | {setup_script} 117 | 118 | def run(): 119 | import sys 120 | from {main_import} import main 121 | 122 | setup() 123 | {print_version} 124 | sys.argv = {sys_argv} 125 | main() 126 | 127 | if __name__ == "__main__": 128 | run()"#, 129 | meta = meta.unwrap_or(""), 130 | setup_script = include_str!("static/setup.py"), 131 | main_import = self.main_import(), 132 | print_version = print_version, 133 | sys_argv = format!("{:?}", args) 134 | ) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/static/setup.py: -------------------------------------------------------------------------------- 1 | #################################################################################################### 2 | 3 | # This script is embedded into the dynamically generated main script in src/script.rs. 4 | # It is used to setup the Jupyter environment for the main script (mergeing jupyter-dirs 5 | # of uv's from multiple virtual environments). 6 | 7 | 8 | def setup_merged_jupyter_environment(): 9 | """Setup Jupyter data directories and config paths from multiple virtual environments.""" 10 | import os 11 | import signal 12 | import sys 13 | import tempfile 14 | from pathlib import Path 15 | 16 | # jupyterlab, notebook, and nbclassic have this as a dependency 17 | from platformdirs import user_data_dir 18 | 19 | juv_data_dir = Path(user_data_dir("juv")) 20 | juv_data_dir.mkdir(parents=True, exist_ok=True) 21 | 22 | temp_dir = tempfile.TemporaryDirectory(dir=juv_data_dir) 23 | merged_dir = Path(temp_dir.name) 24 | 25 | def handle_termination(signum, frame): 26 | temp_dir.cleanup() 27 | sys.exit(0) 28 | 29 | signal.signal(signal.SIGTERM, handle_termination) 30 | signal.signal(signal.SIGINT, handle_termination) 31 | 32 | config_paths = [] 33 | root_data_dir = Path(sys.prefix) / "share" / "jupyter" 34 | jupyter_paths = [root_data_dir] 35 | for path in map(Path, sys.path): 36 | if not path.name == "site-packages": 37 | continue 38 | venv_path = path.parent.parent.parent 39 | config_paths.append(venv_path / "etc" / "jupyter") 40 | data_dir = venv_path / "share" / "jupyter" 41 | if not data_dir.exists() or str(data_dir) == str(root_data_dir): 42 | continue 43 | 44 | jupyter_paths.append(data_dir) 45 | 46 | for path in reversed(jupyter_paths): 47 | for item in path.rglob("*"): 48 | if item.is_file(): 49 | dest = merged_dir / item.relative_to(path) 50 | dest.parent.mkdir(parents=True, exist_ok=True) 51 | try: 52 | os.link(item, dest) 53 | except FileExistsError: 54 | pass 55 | 56 | os.environ["JUPYTER_DATA_DIR"] = str(merged_dir) 57 | os.environ["JUPYTER_CONFIG_PATH"] = os.pathsep.join(map(str, config_paths)) 58 | 59 | 60 | def setup(): 61 | """Setup the Jupyter environment. Called from the main script.""" 62 | 63 | setup_merged_jupyter_environment() 64 | 65 | 66 | #################################################################################################### 67 | --------------------------------------------------------------------------------