Skip to content

Commit b6feafa

Browse files
Handle --frozen flag for Poetry backend via lockfile backup/restore (#1773)
* Initial plan * Add --lock flag for poetry backend under --frozen and add tests Agent-Logs-Url: https://github.com/usethis-python/usethis-python/sessions/e5f566ae-ea62-4d1d-96a9-1e0a343e008e Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> * Use exact command assertions in frozen tests per code review Agent-Logs-Url: https://github.com/usethis-python/usethis-python/sessions/e5f566ae-ea62-4d1d-96a9-1e0a343e008e Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> * Backup and restore poetry.lock when frozen=True instead of just --lock Agent-Logs-Url: https://github.com/usethis-python/usethis-python/sessions/d87a5a95-cba9-4190-8045-871fb850e40e Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> * Fix type error in tracking_mkdtemp mock for ty type checker Agent-Logs-Url: https://github.com/usethis-python/usethis-python/sessions/d87a5a95-cba9-4190-8045-871fb850e40e Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> * plan: refactor to context manager Agent-Logs-Url: https://github.com/usethis-python/usethis-python/sessions/a31b7036-cb6c-42d0-8076-7c495da36035 Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> * Refactor lockfile backup/restore into _frozen_poetry_lock context manager Agent-Logs-Url: https://github.com/usethis-python/usethis-python/sessions/a31b7036-cb6c-42d0-8076-7c495da36035 Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com>
1 parent 902799e commit b6feafa

3 files changed

Lines changed: 327 additions & 7 deletions

File tree

src/usethis/_backend/poetry/call.py

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
from __future__ import annotations
44

5+
import shutil
6+
import tempfile
7+
from contextlib import contextmanager
8+
from pathlib import Path
9+
from typing import TYPE_CHECKING
10+
511
from usethis._backend.poetry.errors import PoetrySubprocessFailedError
612
from usethis._config import usethis_config
713
from usethis._file.pyproject_toml.io_ import PyprojectTOMLManager
@@ -10,6 +16,9 @@
1016
from usethis._types.backend import BackendEnum
1117
from usethis.errors import ForbiddenBackendError
1218

19+
if TYPE_CHECKING:
20+
from collections.abc import Generator
21+
1322

1423
def call_poetry_subprocess(args: list[str], *, change_toml: bool) -> str:
1524
"""Run a subprocess using the Poetry command-line tool.
@@ -28,22 +37,61 @@ def call_poetry_subprocess(args: list[str], *, change_toml: bool) -> str:
2837
if change_toml:
2938
prepare_pyproject_write()
3039

40+
# Poetry doesn't support a --frozen flag like uv does. To emulate frozen
41+
# behaviour we: (1) pass --lock to skip installation, (2) back up
42+
# poetry.lock before the subprocess and restore it afterwards so the
43+
# lockfile is never modified. This ensures pyproject.toml is updated by
44+
# the subprocess while the lockfile remains untouched.
45+
frozen_applicable = usethis_config.frozen and args[:1] in (["add"], ["remove"])
46+
if frozen_applicable:
47+
args = [args[0], "--lock", *args[1:]]
48+
3149
new_args = ["poetry", "--no-interaction", *args]
3250

3351
if usethis_config.subprocess_verbose:
3452
new_args = [*new_args[:1], "-vvv", *new_args[1:]]
3553
elif args[:1] != ["--version"]:
3654
new_args = [*new_args[:1], "--quiet", *new_args[1:]]
3755

38-
try:
39-
output = call_subprocess(new_args, cwd=usethis_config.cpd())
40-
except SubprocessFailedError as err:
41-
raise PoetrySubprocessFailedError(err) from None
42-
except FileNotFoundError:
43-
msg = "Poetry is not installed or not found on PATH."
44-
raise PoetrySubprocessFailedError(msg) from None
56+
lock_path = usethis_config.cpd() / "poetry.lock"
57+
with _frozen_poetry_lock(lock_path) if frozen_applicable else _noop_context():
58+
try:
59+
output = call_subprocess(new_args, cwd=usethis_config.cpd())
60+
except SubprocessFailedError as err:
61+
raise PoetrySubprocessFailedError(err) from None
62+
except FileNotFoundError:
63+
msg = "Poetry is not installed or not found on PATH."
64+
raise PoetrySubprocessFailedError(msg) from None
4565

4666
if change_toml and PyprojectTOMLManager().is_locked():
4767
PyprojectTOMLManager().read_file()
4868

4969
return output
70+
71+
72+
@contextmanager
73+
def _frozen_poetry_lock(lock_path: Path) -> Generator[None, None, None]:
74+
"""Preserve the state of poetry.lock across the enclosed block.
75+
76+
If the lockfile exists beforehand it is backed up and restored afterwards;
77+
if it did not exist, any lockfile created during the block is removed.
78+
"""
79+
had_lockfile = lock_path.exists()
80+
tmp_dir = tempfile.mkdtemp()
81+
try:
82+
if had_lockfile:
83+
backup = Path(tmp_dir) / "poetry.lock"
84+
shutil.copy2(lock_path, backup)
85+
yield
86+
finally:
87+
if had_lockfile:
88+
shutil.copy2(Path(tmp_dir) / "poetry.lock", lock_path)
89+
elif lock_path.exists():
90+
lock_path.unlink()
91+
shutil.rmtree(tmp_dir)
92+
93+
94+
@contextmanager
95+
def _noop_context() -> Generator[None, None, None]:
96+
"""A no-op context manager used when frozen mode is not applicable."""
97+
yield

tests/usethis/_backend/poetry/test_call.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import tempfile
12
from pathlib import Path
23

34
import pytest
@@ -129,6 +130,200 @@ def mock_call_subprocess(*_: object, **__: object) -> str:
129130
):
130131
call_poetry_subprocess(["add", "pytest"], change_toml=False)
131132

133+
def test_frozen_adds_lock_for_add(self, monkeypatch: pytest.MonkeyPatch):
134+
"""When frozen=True, --lock should be added to 'add' commands."""
135+
136+
def mock_call_subprocess(args: list[str], **__: object) -> str:
137+
return " ".join(args)
138+
139+
monkeypatch.setattr(
140+
usethis._backend.poetry.call,
141+
"call_subprocess",
142+
mock_call_subprocess,
143+
)
144+
145+
with usethis_config.set(backend=BackendEnum.poetry, frozen=True):
146+
result = call_poetry_subprocess(["add", "pytest"], change_toml=False)
147+
148+
assert result == "poetry --quiet --no-interaction add --lock pytest"
149+
150+
def test_frozen_adds_lock_for_remove(self, monkeypatch: pytest.MonkeyPatch):
151+
"""When frozen=True, --lock should be added to 'remove' commands."""
152+
153+
def mock_call_subprocess(args: list[str], **__: object) -> str:
154+
return " ".join(args)
155+
156+
monkeypatch.setattr(
157+
usethis._backend.poetry.call,
158+
"call_subprocess",
159+
mock_call_subprocess,
160+
)
161+
162+
with usethis_config.set(backend=BackendEnum.poetry, frozen=True):
163+
result = call_poetry_subprocess(
164+
["remove", "--group", "test", "pytest"], change_toml=False
165+
)
166+
167+
assert (
168+
result
169+
== "poetry --quiet --no-interaction remove --lock --group test pytest"
170+
)
171+
172+
def test_frozen_no_lock_for_version(self, monkeypatch: pytest.MonkeyPatch):
173+
"""When frozen=True, --lock should not be added to non-add/remove commands."""
174+
175+
def mock_call_subprocess(args: list[str], **__: object) -> str:
176+
return " ".join(args)
177+
178+
monkeypatch.setattr(
179+
usethis._backend.poetry.call,
180+
"call_subprocess",
181+
mock_call_subprocess,
182+
)
183+
184+
with usethis_config.set(backend=BackendEnum.poetry, frozen=True):
185+
result = call_poetry_subprocess(["--version"], change_toml=False)
186+
187+
assert "--lock" not in result
188+
189+
def test_not_frozen_no_lock_for_add(self, monkeypatch: pytest.MonkeyPatch):
190+
"""When frozen=False, --lock should not be added to 'add' commands."""
191+
192+
def mock_call_subprocess(args: list[str], **__: object) -> str:
193+
return " ".join(args)
194+
195+
monkeypatch.setattr(
196+
usethis._backend.poetry.call,
197+
"call_subprocess",
198+
mock_call_subprocess,
199+
)
200+
201+
with usethis_config.set(backend=BackendEnum.poetry, frozen=False):
202+
result = call_poetry_subprocess(["add", "pytest"], change_toml=False)
203+
204+
assert "--lock" not in result
205+
206+
def test_frozen_restores_existing_lockfile(
207+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
208+
):
209+
"""When frozen=True and poetry.lock exists, it should be restored after the subprocess."""
210+
lock_path = tmp_path / "poetry.lock"
211+
original_content = "original-lock-content"
212+
lock_path.write_text(original_content)
213+
214+
created_dirs: list[str] = []
215+
original_mkdtemp = tempfile.mkdtemp
216+
217+
def tracking_mkdtemp(
218+
suffix: str | None = None,
219+
prefix: str | None = None,
220+
dir: str | None = None, # noqa: A002
221+
) -> str:
222+
result = original_mkdtemp(suffix=suffix, prefix=prefix, dir=dir)
223+
created_dirs.append(result)
224+
return result
225+
226+
monkeypatch.setattr(tempfile, "mkdtemp", tracking_mkdtemp)
227+
228+
def mock_call_subprocess(*_: object, **__: object) -> str:
229+
# Simulate poetry modifying the lockfile
230+
lock_path.write_text("modified-lock-content")
231+
return ""
232+
233+
monkeypatch.setattr(
234+
usethis._backend.poetry.call,
235+
"call_subprocess",
236+
mock_call_subprocess,
237+
)
238+
239+
with usethis_config.set(
240+
backend=BackendEnum.poetry, frozen=True, project_dir=tmp_path
241+
):
242+
call_poetry_subprocess(["add", "pytest"], change_toml=False)
243+
244+
assert lock_path.read_text() == original_content
245+
# Verify the temporary backup directory was cleaned up
246+
assert len(created_dirs) == 1
247+
assert not Path(created_dirs[0]).exists()
248+
249+
def test_frozen_removes_created_lockfile(
250+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
251+
):
252+
"""When frozen=True and poetry.lock didn't exist, any new lockfile should be removed."""
253+
lock_path = tmp_path / "poetry.lock"
254+
assert not lock_path.exists()
255+
256+
def mock_call_subprocess(*_: object, **__: object) -> str:
257+
# Simulate poetry creating a new lockfile
258+
lock_path.write_text("new-lock-content")
259+
return ""
260+
261+
monkeypatch.setattr(
262+
usethis._backend.poetry.call,
263+
"call_subprocess",
264+
mock_call_subprocess,
265+
)
266+
267+
with usethis_config.set(
268+
backend=BackendEnum.poetry, frozen=True, project_dir=tmp_path
269+
):
270+
call_poetry_subprocess(["add", "pytest"], change_toml=False)
271+
272+
assert not lock_path.exists()
273+
274+
def test_frozen_restores_lockfile_on_failure(
275+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
276+
):
277+
"""When frozen=True and the subprocess fails, poetry.lock should still be restored."""
278+
lock_path = tmp_path / "poetry.lock"
279+
original_content = "original-lock-content"
280+
lock_path.write_text(original_content)
281+
282+
def mock_call_subprocess(*_: object, **__: object) -> str:
283+
lock_path.write_text("modified-lock-content")
284+
msg = "mock failure"
285+
raise SubprocessFailedError(msg)
286+
287+
monkeypatch.setattr(
288+
usethis._backend.poetry.call,
289+
"call_subprocess",
290+
mock_call_subprocess,
291+
)
292+
293+
with (
294+
usethis_config.set(
295+
backend=BackendEnum.poetry, frozen=True, project_dir=tmp_path
296+
),
297+
pytest.raises(PoetrySubprocessFailedError),
298+
):
299+
call_poetry_subprocess(["add", "pytest"], change_toml=False)
300+
301+
assert lock_path.read_text() == original_content
302+
303+
def test_not_frozen_does_not_restore_lockfile(
304+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
305+
):
306+
"""When frozen=False, poetry.lock changes should be preserved."""
307+
lock_path = tmp_path / "poetry.lock"
308+
lock_path.write_text("original-lock-content")
309+
310+
def mock_call_subprocess(*_: object, **__: object) -> str:
311+
lock_path.write_text("modified-lock-content")
312+
return ""
313+
314+
monkeypatch.setattr(
315+
usethis._backend.poetry.call,
316+
"call_subprocess",
317+
mock_call_subprocess,
318+
)
319+
320+
with usethis_config.set(
321+
backend=BackendEnum.poetry, frozen=False, project_dir=tmp_path
322+
):
323+
call_poetry_subprocess(["add", "pytest"], change_toml=False)
324+
325+
assert lock_path.read_text() == "modified-lock-content"
326+
132327
def test_change_toml_rereads_when_locked(
133328
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
134329
):

tests/usethis/test_deps.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,45 @@ def mock_add_dep(dep: object, group: str) -> None:
917917
# No uv.toml default-groups should be created
918918
assert not (tmp_path / "uv.toml").exists()
919919

920+
def test_frozen_still_calls_add(
921+
self,
922+
tmp_path: Path,
923+
capfd: pytest.CaptureFixture[str],
924+
monkeypatch: pytest.MonkeyPatch,
925+
):
926+
"""When frozen=True, poetry deps are still declared in pyproject.toml."""
927+
# Arrange
928+
(tmp_path / "pyproject.toml").write_text("""\
929+
[dependency-groups]
930+
test = []
931+
""")
932+
933+
calls: list[tuple[str, str]] = []
934+
935+
def mock_add_dep(dep: object, group: str) -> None:
936+
calls.append((str(dep), group))
937+
938+
monkeypatch.setattr(
939+
"usethis._deps.add_dep_to_group_via_poetry",
940+
mock_add_dep,
941+
)
942+
943+
with (
944+
usethis_config.set(backend=BackendEnum.poetry, frozen=True),
945+
change_cwd(tmp_path),
946+
PyprojectTOMLManager(),
947+
):
948+
add_deps_to_group([Dependency(name="pytest")], "test")
949+
950+
out, err = capfd.readouterr()
951+
assert not err
952+
assert (
953+
"Adding dependency 'pytest' to the 'test' group in 'pyproject.toml'"
954+
in out
955+
)
956+
assert "Install the dependency 'pytest'" in out
957+
assert len(calls) == 1
958+
920959

921960
class TestRemoveDepsFromGroup:
922961
@pytest.mark.usefixtures("_vary_network_conn")
@@ -1124,6 +1163,44 @@ def mock_remove_dep(dep: object, group: str) -> None:
11241163
)
11251164
assert len(calls) == 1
11261165

1166+
def test_frozen_still_calls_remove(
1167+
self,
1168+
tmp_path: Path,
1169+
capfd: pytest.CaptureFixture[str],
1170+
monkeypatch: pytest.MonkeyPatch,
1171+
):
1172+
"""When frozen=True, poetry deps are still removed from pyproject.toml."""
1173+
# Arrange
1174+
(tmp_path / "pyproject.toml").write_text("""\
1175+
[dependency-groups]
1176+
test = ["pytest"]
1177+
""")
1178+
1179+
calls: list[tuple[str, str]] = []
1180+
1181+
def mock_remove_dep(dep: object, group: str) -> None:
1182+
calls.append((str(dep), group))
1183+
1184+
monkeypatch.setattr(
1185+
"usethis._deps.remove_dep_from_group_via_poetry",
1186+
mock_remove_dep,
1187+
)
1188+
1189+
with (
1190+
usethis_config.set(backend=BackendEnum.poetry, frozen=True),
1191+
change_cwd(tmp_path),
1192+
PyprojectTOMLManager(),
1193+
):
1194+
remove_deps_from_group([Dependency(name="pytest")], "test")
1195+
1196+
out, err = capfd.readouterr()
1197+
assert not err
1198+
assert (
1199+
"Removing dependency 'pytest' from the 'test' group in 'pyproject.toml'"
1200+
in out
1201+
)
1202+
assert len(calls) == 1
1203+
11271204

11281205
class TestIsDepInAnyGroup:
11291206
def test_no_group(self, uv_init_dir: Path):

0 commit comments

Comments
 (0)