Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
manzt committed Oct 19, 2024
0 parents commit 3f249c0
Show file tree
Hide file tree
Showing 7 changed files with 344 additions and 0 deletions.
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
Lint:
runs-on: macos-14
if: github.repository == 'manzt/juv'

steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
version: "0.4.x"
- run: |
uv run ruff format --check
uv run ruff check
Test:
runs-on: macos-14
if: github.repository == 'manzt/juv'

steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
version: "0.4.x"
- run: uv run pytest
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.ipynb_checkpoints
__pycache__
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# juv

A little wrapper around `uv` to launch ephemeral Jupyter notebooks.

```sh
uvx juv
# juv [uvx flags] <command>[@version] <path>
#
# Commands:
# lab Launch JupyterLab
# notebook Launch Jupyter Notebook (classic)
#
# Arguments:
# path Path to the Python script or notebook file
#
# Examples:
# uvx juv lab script.py
# uvx juv [email protected] script.ipynb
# uvx juv notebook existing_notebook.ipynb
# uvx juv --with pandas,matplotlib lab new_notebook.ipynb
```

`juv` has two main commands:

- `juv lab` launches a Jupyter Lab session
- `juv notebook` launches a classic notebook session

Both commands accept a single argument: the path to the notebook or script to
launch. A script will be converted to a notebook before launching.

```sh
uvx juv lab script.py # creates script.ipynb
```

Any flags that are passed prior to the command (e.g., `uvx juv --with=polars
lab`) will be forwarded to `uvx` as-is. This allows you to specify additional
dependencies, a different interpreter, etc.

## what

[PEP 723 (inline script metadata)](https://peps.python.org/pep-0723) allows
specifying dependencies as comments within Python scripts, enabling
self-contained, reproducible execution. This feature could significantly
improve reproducibility in the data science ecosystem, since many analyses are
shared as standalone code (not packages). However, _a lot_ of data science code
lives in notebooks (`.ipynb` files), not Python scripts (`.py` files).

`juv` bridges this gap by:

- Extending PEP 723-style metadata support from `uv` to Jupyter notebooks
- Launching Jupyter sessions with the specified dependencies

It's a simple Python script that parses the notebook and starts a Jupyter
session with the specified dependencies (piggybacking on `uv`'s existing
functionality).

## alternatives

`juv` is opinionated and might not suit your preferences. That's ok! `uv` is
super extensible, and I recommend reading [the
documentation](https://docs.astral.sh/uv) to learn about its primitives.

For example, you can achieve a similar workflow using the `--with-requirements`
flag:

```sh
uvx --with-requirements=requirements.txt --from=jupyter-core --with=jupyterlab jupyter lab notebook.ipynb
```

While slightly more verbose, and breaking self-containment, this approach works well.
15 changes: 15 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[project]
name = "juv"
version = "0.0.0"
description = "A little wrapper around `uv` to launch ephemeral Jupyter notebooks."
readme = "README.md"
authors = [{ name = "Trevor Manz", email = "[email protected]" }]
requires-python = ">=3.8"
dependencies = []

[project.scripts]
juv = "juv:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
222 changes: 222 additions & 0 deletions src/juv/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
"""A wrapper around `uv` to launch ephemeral Jupyter notebooks."""

from __future__ import annotations

import pathlib
import re
import tomllib
import json
import dataclasses
import sys
import shutil
import os
import typing

import rich


@dataclasses.dataclass
class Pep723Meta:
dependencies: list[str]
python_version: str | None


REGEX = r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$"


def parse_pep723_meta(script: str) -> Pep723Meta | None:
name = "script"
matches = list(
filter(lambda m: m.group("type") == name, re.finditer(REGEX, script))
)
if len(matches) > 1:
raise ValueError(f"Multiple {name} blocks found")
elif len(matches) == 1:
content = "".join(
line[2:] if line.startswith("# ") else line[1:]
for line in matches[0].group("content").splitlines(keepends=True)
)
meta = tomllib.loads(content)
return Pep723Meta(
dependencies=meta.get("dependencies", []),
python_version=meta.get("requires-python"),
)
else:
return None


def nbcell(source: str) -> dict:
return {
"cell_type": "code",
"execution_count": None,
"metadata": {},
"outputs": [],
"source": source,
}


def script_to_nb(script: str) -> str:
"""Embeds the a given script as the first cell in a Jupyter notebook."""
cells: list[dict] = []

meta_block = re.search(REGEX, script)

if meta_block:
meta_block = meta_block.group(0)
cells.append(nbcell(meta_block))
script = script.replace(meta_block, "")

cells.append(nbcell(script.strip()))

return json.dumps(
obj={
"cells": cells,
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3",
}
},
"nbformat": 4,
"nbformat_minor": 5,
},
indent=2,
)


def to_notebook(fp: pathlib.Path) -> tuple[Pep723Meta | None, str]:
match fp.suffix:
case ".py":
content = fp.read_text()
meta = parse_pep723_meta(content)
return meta, script_to_nb(content)
case ".ipynb":
content = fp.read_text()
for cell in json.loads(content).get("cells", []):
if cell.get("cell_type") == "code":
meta = parse_pep723_meta(cell["source"])
return meta, content

return None, content
case _:
raise ValueError(f"Unsupported file extension: {fp.suffix}")


def assert_uv_available():
if shutil.which("uv") is None:
print("Error: 'uv' command not found.", file=sys.stderr)
print("Please install 'uv' to run `juv`.", file=sys.stderr)
print(
"For more information, visit: https://github.com/astral-sh/uv",
file=sys.stderr,
)
sys.exit(1)


def run_notebook(
nb_path: pathlib.Path,
pep723_meta: Pep723Meta | None,
command: typing.Literal["lab", "notebook"],
pre_args: list[str],
command_version: str | None,
) -> None:
assert_uv_available()

cmd = ["uvx", "--from", "jupyter-core", "--with", "setuptools"]

if pep723_meta:
if pep723_meta.python_version and not any(
x.startswith("--python") for x in pre_args
):
cmd.extend(["--python", pep723_meta.python_version])

for dep in pep723_meta.dependencies:
cmd.extend(["--with", dep])

if command == "lab":
cmd.extend([
"--with",
f"jupyterlab=={command_version}" if command_version else "jupyterlab",
])
elif command == "notebook":
cmd.extend([
"--with",
f"notebook=={command_version}" if command_version else "notebook",
])

cmd.extend(pre_args)

cmd.extend(["jupyter", command, str(nb_path)])

try:
os.execvp(cmd[0], cmd)
except OSError as e:
print(f"Error executing {cmd[0]}: {e}", file=sys.stderr)
sys.exit(1)


def split_args() -> tuple[list[str], list[str], str | None]:
for i, arg in enumerate(sys.argv):
if arg in ["lab", "notebook"]:
return sys.argv[1:i], sys.argv[i:], None

if arg.startswith("lab@") or arg.startswith("notebook@"):
# replace the command with the actual command but get the version
command, version = sys.argv[i].split("@", 1)
return sys.argv[1:i], [command] + sys.argv[i + 1 :], version

return [], sys.argv, None


def parse_juv_command(command: str | None) -> typing.Literal["lab", "notebook"] | None:
if command == "lab":
return "lab"
if command == "notebook":
return "notebook"
return None


def main() -> None:
pre_args, post_args, command_version = split_args()

help = r"""A wrapper around `[cyan]uv[/cyan]` to launch ephemeral Jupyter notebooks.
[b]Usage[/b]: juv \[uvx flags] <COMMAND>\[@version] \[PATH]
[b]Examples[/b]:
uvx juv lab script.py
uvx juv notebook existing_notebook.ipynb
uvx juv --python=3.8 [email protected] script.ipynb
[b]Notes[/b]:
- Any flags before the 'lab' or 'notebook' command are passed directly to uv."""

if "-h" in sys.argv or "--help" in sys.argv:
rich.print(help)
sys.exit(0)

command = parse_juv_command(post_args[0] if post_args else None)
file = post_args[1] if len(post_args) > 1 else None

if command is None or not file:
rich.print(help)
sys.exit(1)

file = pathlib.Path(file)

if not file.exists():
print(f"Error: {file} does not exist.", file=sys.stderr)
sys.exit(1)

meta, content = to_notebook(file)

if file.suffix == ".py":
file = file.with_suffix(".ipynb")
file.write_text(content)

run_notebook(file, meta, command, pre_args, command_version)


if __name__ == "__main__":
main()
Empty file added src/juv/py.typed
Empty file.

0 comments on commit 3f249c0

Please sign in to comment.