Skip to content

Commit 8c522fb

Browse files
committed
fix(convert): refresh pair baseline after canonical pair sync
1 parent 437918c commit 8c522fb

3 files changed

Lines changed: 182 additions & 5 deletions

File tree

jupyter_jcli/commands/convert_cmd.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,43 @@
55
import click
66
import nbformat
77

8+
from jupyter_jcli import pair_baseline
9+
from jupyter_jcli.canonicalize import canonicalize_py_text
810
from jupyter_jcli.pair_io import create_ipynb_from_parsed, emit_py_percent, update_ipynb_sources
9-
from jupyter_jcli.parser import parse_ipynb, parse_py_percent
11+
from jupyter_jcli.parser import ipynb_path_for_py, parse_ipynb, parse_py_percent
1012

1113

1214
@click.group()
1315
def convert():
1416
"""Convert between .ipynb and py:percent (.py) formats."""
1517

1618

19+
def _is_canonical_pair(py_path: Path, ipynb_path: Path) -> bool:
20+
"""Return True when *py_path* and *ipynb_path* are the managed pair."""
21+
return ipynb_path_for_py(py_path).resolve(strict=False) == ipynb_path.resolve(strict=False)
22+
23+
24+
def _refresh_pair_baseline(py_path: Path) -> None:
25+
"""Best-effort baseline refresh after a successful canonical pair sync."""
26+
try:
27+
canonical_text = canonicalize_py_text(py_path.read_text(encoding="utf-8"))
28+
except (OSError, UnicodeDecodeError):
29+
return
30+
pair_baseline.write_baseline(py_path, canonical_text)
31+
32+
1733
@convert.command("ipynb-to-py")
1834
@click.argument("in_ipynb", metavar="<in.ipynb>", type=click.Path(exists=True, dir_okay=False))
1935
@click.argument("out_py", metavar="<out.py>", type=click.Path(dir_okay=False))
2036
def ipynb_to_py(in_ipynb: str, out_py: str) -> None:
2137
"""Convert a .ipynb file to py:percent format."""
2238
parsed = parse_ipynb(in_ipynb)
2339
text = emit_py_percent(parsed)
24-
Path(out_py).write_text(text, encoding="utf-8")
40+
in_ipynb_path = Path(in_ipynb)
41+
out_py_path = Path(out_py)
42+
out_py_path.write_text(text, encoding="utf-8")
43+
if _is_canonical_pair(out_py_path, in_ipynb_path):
44+
_refresh_pair_baseline(out_py_path)
2545
click.echo(f"Wrote {out_py}")
2646

2747

@@ -36,23 +56,27 @@ def py_to_ipynb(in_py: str, out_ipynb: str | None) -> None:
3656
(outputs and metadata are preserved). Otherwise a new notebook is created.
3757
"""
3858
parsed = parse_py_percent(in_py)
59+
in_py_path = Path(in_py)
3960

4061
# Determine output path
4162
if out_ipynb is None:
42-
py_path = Path(in_py)
43-
stem = py_path.stem
63+
stem = in_py_path.stem
4464
if stem.endswith(".dummy"):
4565
stem = stem[: -len(".dummy")]
46-
out_ipynb = str(py_path.parent / f"{stem}.ipynb")
66+
out_ipynb = str(in_py_path.parent / f"{stem}.ipynb")
4767

4868
out_path = Path(out_ipynb)
4969

5070
if out_path.exists():
5171
# Update existing notebook sources only
5272
update_ipynb_sources(out_path, parsed.cells)
73+
if _is_canonical_pair(in_py_path, out_path):
74+
_refresh_pair_baseline(in_py_path)
5375
click.echo(f"Updated {out_ipynb}")
5476
else:
5577
# Create a new notebook
5678
nb = create_ipynb_from_parsed(parsed)
5779
nbformat.write(nb, str(out_path))
80+
if _is_canonical_pair(in_py_path, out_path):
81+
_refresh_pair_baseline(in_py_path)
5882
click.echo(f"Wrote {out_ipynb}")

tests/test_convert_cmd.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""Tests for `j-cli convert`."""
22

3+
import subprocess
34
from pathlib import Path
45

56
import nbformat
67
import pytest
78
from click.testing import CliRunner
89

10+
from jupyter_jcli import pair_baseline
11+
from jupyter_jcli.canonicalize import canonicalize_py_text
912
from jupyter_jcli.cli import main
1013
from jupyter_jcli.parser import parse_py_percent, parse_py_percent_text
1114

@@ -19,6 +22,46 @@ def _invoke(*args: str):
1922
return runner.invoke(main, list(args), catch_exceptions=False)
2023

2124

25+
@pytest.fixture
26+
def git_repo(tmp_path: Path) -> Path:
27+
subprocess.run(["git", "init"], cwd=str(tmp_path), check=True, capture_output=True)
28+
subprocess.run(
29+
["git", "config", "user.email", "test@test.com"],
30+
cwd=str(tmp_path),
31+
check=True,
32+
capture_output=True,
33+
)
34+
subprocess.run(
35+
["git", "config", "user.name", "Test User"],
36+
cwd=str(tmp_path),
37+
check=True,
38+
capture_output=True,
39+
)
40+
return tmp_path
41+
42+
43+
def _git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
44+
return subprocess.run(
45+
["git", *args],
46+
cwd=str(repo),
47+
check=True,
48+
capture_output=True,
49+
text=True,
50+
)
51+
52+
53+
def _has_ref(repo: Path, rel_py_path: str) -> bool:
54+
ref_name = pair_baseline._ref_name(Path(rel_py_path).as_posix())
55+
result = subprocess.run(
56+
["git", "for-each-ref", ref_name, "--format=%(refname)"],
57+
cwd=str(repo),
58+
check=True,
59+
capture_output=True,
60+
text=True,
61+
)
62+
return result.stdout.strip() == ref_name
63+
64+
2265
def _make_ipynb(cells: list[tuple[str, str, list]], kernel: str = "python3") -> nbformat.NotebookNode:
2366
nb = nbformat.v4.new_notebook()
2467
nb.metadata["kernelspec"] = {"name": kernel, "display_name": kernel, "language": "python"}
@@ -87,6 +130,51 @@ def test_roundtrip_content(self, tmp_path):
87130
assert parsed.cells[0].source == "a = 1"
88131
assert parsed.cells[1].source == "b = 2"
89132

133+
def test_canonical_ipynb_to_py_refreshes_baseline(self, git_repo):
134+
nb = _make_ipynb([("code", "x = 1", [])])
135+
ipynb = git_repo / "nb.ipynb"
136+
nbformat.write(nb, str(ipynb))
137+
py = git_repo / "nb.py"
138+
139+
result = _invoke("convert", "ipynb-to-py", str(ipynb), str(py))
140+
141+
assert result.exit_code == 0
142+
assert _has_ref(git_repo, "nb.py")
143+
assert pair_baseline.read_baseline(py) == canonicalize_py_text(py.read_text(encoding="utf-8"))
144+
145+
def test_dummy_ipynb_to_py_refreshes_baseline(self, git_repo):
146+
nb = _make_ipynb([("code", "x = 1", [])])
147+
ipynb = git_repo / "nb.ipynb"
148+
nbformat.write(nb, str(ipynb))
149+
py = git_repo / "nb.dummy.py"
150+
151+
result = _invoke("convert", "ipynb-to-py", str(ipynb), str(py))
152+
153+
assert result.exit_code == 0
154+
assert _has_ref(git_repo, "nb.dummy.py")
155+
156+
def test_noncanonical_ipynb_to_py_does_not_refresh_baseline(self, git_repo):
157+
nb = _make_ipynb([("code", "x = 1", [])])
158+
ipynb = git_repo / "nb.ipynb"
159+
nbformat.write(nb, str(ipynb))
160+
py = git_repo / "custom.py"
161+
162+
result = _invoke("convert", "ipynb-to-py", str(ipynb), str(py))
163+
164+
assert result.exit_code == 0
165+
assert not _has_ref(git_repo, "custom.py")
166+
167+
def test_ipynb_to_py_non_git_still_succeeds(self, tmp_path):
168+
nb = _make_ipynb([("code", "x = 1", [])])
169+
ipynb = tmp_path / "nb.ipynb"
170+
nbformat.write(nb, str(ipynb))
171+
py = tmp_path / "nb.py"
172+
173+
result = _invoke("convert", "ipynb-to-py", str(ipynb), str(py))
174+
175+
assert result.exit_code == 0
176+
assert py.exists()
177+
90178

91179
# ---------------------------------------------------------------------------
92180
# py-to-ipynb — new file creation
@@ -129,6 +217,37 @@ def test_dummy_py_default_output(self, tmp_path):
129217
assert result.exit_code == 0
130218
assert (tmp_path / "foo.ipynb").exists()
131219

220+
def test_canonical_py_to_ipynb_refreshes_baseline(self, git_repo):
221+
py = git_repo / "script.py"
222+
py.write_text("# %%\nx = 1\n", encoding="utf-8")
223+
224+
result = _invoke("convert", "py-to-ipynb", str(py))
225+
226+
assert result.exit_code == 0
227+
assert (git_repo / "script.ipynb").exists()
228+
assert _has_ref(git_repo, "script.py")
229+
assert pair_baseline.read_baseline(py) == canonicalize_py_text(py.read_text(encoding="utf-8"))
230+
231+
def test_noncanonical_py_to_ipynb_does_not_refresh_baseline(self, git_repo):
232+
py = git_repo / "script.py"
233+
py.write_text("# %%\nx = 1\n", encoding="utf-8")
234+
out = git_repo / "custom.ipynb"
235+
236+
result = _invoke("convert", "py-to-ipynb", str(py), str(out))
237+
238+
assert result.exit_code == 0
239+
assert out.exists()
240+
assert not _has_ref(git_repo, "script.py")
241+
242+
def test_py_to_ipynb_non_git_still_succeeds(self, tmp_path):
243+
py = tmp_path / "script.py"
244+
py.write_text("# %%\nx = 1\n", encoding="utf-8")
245+
246+
result = _invoke("convert", "py-to-ipynb", str(py))
247+
248+
assert result.exit_code == 0
249+
assert (tmp_path / "script.ipynb").exists()
250+
132251

133252
# ---------------------------------------------------------------------------
134253
# py-to-ipynb — in-place update (preserve outputs)

tests/test_hooks_pair_drift.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,40 @@ def test_pre_merge_updates_ref_so_following_post_does_not_conflict(
678678
assert [cell.source for cell in nb_after.cells if cell.source.strip()] == ["x = 40"]
679679

680680

681+
class TestConvertSeededBaseline:
682+
def test_convert_then_first_edit_uses_sticky_baseline(self, git_repo: Path):
683+
py, ipynb = _make_pair(git_repo, ["x = 1"], ["x = 1"])
684+
_git(git_repo, "add", "nb.py")
685+
_git(git_repo, "commit", "-m", "init", env=_git_env(100))
686+
687+
py.write_text(py.read_text(encoding="utf-8").replace("x = 1", "x = 10"), encoding="utf-8")
688+
689+
runner = CliRunner()
690+
convert = runner.invoke(
691+
main,
692+
["convert", "py-to-ipynb", str(py), str(ipynb)],
693+
catch_exceptions=False,
694+
)
695+
assert convert.exit_code == 0
696+
697+
ref_ts = _git(
698+
git_repo,
699+
"log",
700+
"-1",
701+
"--format=%ct",
702+
pair_baseline._ref_name("nb.py"),
703+
).stdout.strip()
704+
assert ref_ts
705+
706+
py.write_text(py.read_text(encoding="utf-8").replace("x = 10", "x = 20"), encoding="utf-8")
707+
code, out = _invoke_post({"tool_name": "Edit", "tool_input": {"file_path": str(py)}})
708+
709+
assert code == 0
710+
assert "Auto-synced" in _additional_context(out)
711+
nb_after = nbformat.read(str(ipynb), as_version=4)
712+
assert [cell.source for cell in nb_after.cells if cell.source.strip()] == ["x = 20"]
713+
714+
681715
class TestGcPairSyncRefsCLI:
682716
def test_dry_run_reports_without_deleting(self, git_repo: Path, monkeypatch: pytest.MonkeyPatch):
683717
py, _ipynb = _make_pair(git_repo, ["x = 1"], ["x = 1"])

0 commit comments

Comments
 (0)