Skip to content

Commit

Permalink
Add export command extract alternative lockfile format (#70)
Browse files Browse the repository at this point in the history
  • Loading branch information
manzt authored Jan 15, 2025
1 parent 77a5659 commit 0dedc41
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 26 deletions.
12 changes: 12 additions & 0 deletions src/juv/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,18 @@ def tree(
tree(path=Path(file))


@cli.command()
@click.argument("file", type=click.Path(exists=True), required=True)
def export(
*,
file: str,
) -> None:
"""Export the notebook's lockfile to an alternate format."""
from ._export import export

export(path=Path(file))


def main() -> None:
"""Run the CLI."""
upgrade_legacy_jupyter_command(sys.argv)
Expand Down
54 changes: 54 additions & 0 deletions src/juv/_export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import sys
import tempfile
from pathlib import Path

import jupytext

from ._nbutils import code_cell, write_ipynb
from ._pep723 import includes_inline_metadata
from ._utils import find
from ._uv import uv


def export(
path: Path,
) -> None:
notebook = jupytext.read(path, fmt="ipynb")
lockfile_contents = notebook.get("metadata", {}).get("uv.lock")

# need a reference so we can modify the cell["source"]
cell = find(
lambda cell: (
cell["cell_type"] == "code"
and includes_inline_metadata("".join(cell["source"]))
),
notebook["cells"],
)

if cell is None:
notebook["cells"].insert(0, code_cell("", hidden=True))
cell = notebook["cells"][0]

with tempfile.NamedTemporaryFile(
mode="w+",
delete=True,
suffix=".py",
dir=path.parent,
encoding="utf-8",
) as f:
lockfile = Path(f"{f.name}.lock")

f.write(cell["source"].strip())
f.flush()

if lockfile_contents:
lockfile.write_text(lockfile_contents)

result = uv(["export", "--script", f.name], check=True)

sys.stdout.write(result.stdout.decode("utf-8"))

if lockfile.exists():
notebook.metadata["uv.lock"] = lockfile.read_text(encoding="utf-8")
write_ipynb(notebook, path)
lockfile.unlink(missing_ok=True)
80 changes: 54 additions & 26 deletions tests/test_juv.py
Original file line number Diff line number Diff line change
Expand Up @@ -1176,26 +1176,24 @@ def test_tree(
""")


def test_tree_updates_lock(
def test_clear_lock(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)

invoke(["init", "test.ipynb"])
invoke(["lock", "test.ipynb"])
invoke(["add", "test.ipynb", "attrs"])
invoke(["tree", "test.ipynb"])
notebook = jupytext.read(tmp_path / "test.ipynb")
assert notebook.metadata["uv.lock"] == snapshot("""\
invoke(["lock", "test.ipynb"])
assert jupytext.read(tmp_path / "test.ipynb").metadata.get("uv.lock") == snapshot("""\
version = 1
requires-python = ">=3.13"
[options]
exclude-newer = "2023-02-01T02:00:00Z"
[manifest]
requirements = [{ name = "attrs", specifier = ">=22.2.0" }]
requirements = [{ name = "attrs" }]
[[package]]
name = "attrs"
Expand All @@ -1207,40 +1205,60 @@ def test_tree_updates_lock(
]
""")

notebook.cells[0] = new_code_cell("""# /// script
# dependencies = []
# requires-python = ">=3.8"
# ///
""")
write_ipynb(notebook, tmp_path / "test.ipynb")
invoke(["tree", "test.ipynb"])
assert jupytext.read(tmp_path / "test.ipynb").metadata["uv.lock"] == snapshot("""\
version = 1
requires-python = ">=3.8"
result = invoke(["lock", "test.ipynb", "--clear"])
assert result.exit_code == 0
assert result.stdout == snapshot("Cleared lockfile `test.ipynb`\n")
assert jupytext.read(tmp_path / "test.ipynb").metadata.get("uv.lock") is None

[options]
exclude-newer = "2023-02-01T02:00:00Z"
""")

def sanitize_uv_export_command(output: str) -> str:
"""Replace the temporary file path after 'uv export --script' with <TEMPFILE>"""
pattern = r"(uv export --script )([^\s]+[\\/][^\s]+\.py)"
replacement = r"\1<TEMPFILE>"
return re.sub(pattern, replacement, output)


def test_clear_lock(
def test_export(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)

invoke(["init", "test.ipynb"])
invoke(["add", "test.ipynb", "attrs"])
result = invoke(["export", "test.ipynb"])
assert result.exit_code == 0
assert sanitize_uv_export_command(result.stdout) == snapshot("""\
# This file was autogenerated by uv via the following command:
# uv export --script <TEMPFILE>
attrs==22.2.0 \\
--hash=sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836 \\
--hash=sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99
""")


@pytest.mark.parametrize("command", ["export", "tree"])
def test_commands_update_lock(
command: str,
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)

invoke(["init", "test.ipynb"])
invoke(["lock", "test.ipynb"])
assert jupytext.read(tmp_path / "test.ipynb").metadata.get("uv.lock") == snapshot("""\
invoke(["add", "test.ipynb", "attrs"])
invoke([command, "test.ipynb"])
notebook = jupytext.read(tmp_path / "test.ipynb")
assert notebook.metadata["uv.lock"] == snapshot("""\
version = 1
requires-python = ">=3.13"
[options]
exclude-newer = "2023-02-01T02:00:00Z"
[manifest]
requirements = [{ name = "attrs" }]
requirements = [{ name = "attrs", specifier = ">=22.2.0" }]
[[package]]
name = "attrs"
Expand All @@ -1252,7 +1270,17 @@ def test_clear_lock(
]
""")

result = invoke(["lock", "test.ipynb", "--clear"])
assert result.exit_code == 0
assert result.stdout == snapshot("Cleared lockfile `test.ipynb`\n")
assert jupytext.read(tmp_path / "test.ipynb").metadata.get("uv.lock") is None
notebook.cells[0] = new_code_cell("""# /// script
# dependencies = []
# requires-python = ">=3.8"
# ///
""")
write_ipynb(notebook, tmp_path / "test.ipynb")
invoke([command, "test.ipynb"])
assert jupytext.read(tmp_path / "test.ipynb").metadata["uv.lock"] == snapshot("""\
version = 1
requires-python = ">=3.8"
[options]
exclude-newer = "2023-02-01T02:00:00Z"
""")

0 comments on commit 0dedc41

Please sign in to comment.