Skip to content

Commit 13bb855

Browse files
committed
add support for PEP 621: poetry remove (#9135)
1 parent 36ce88d commit 13bb855

File tree

4 files changed

+313
-53
lines changed

4 files changed

+313
-53
lines changed

src/poetry/console/commands/remove.py

+44-29
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from cleo.helpers import argument
88
from cleo.helpers import option
99
from packaging.utils import canonicalize_name
10+
from poetry.core.packages.dependency import Dependency
1011
from poetry.core.packages.dependency_group import MAIN_GROUP
1112
from tomlkit.toml_document import TOMLDocument
1213

@@ -66,39 +67,45 @@ def handle(self) -> int:
6667
group = self.option("group", self.default_group)
6768

6869
content: dict[str, Any] = self.poetry.file.read()
69-
poetry_content = content["tool"]["poetry"]
70+
project_content = content.get("project", {})
71+
poetry_content = content.get("tool", {}).get("poetry", {})
7072

7173
if group is None:
72-
removed = []
74+
# remove from all groups
75+
removed = set()
7376
group_sections = [
74-
(group_name, group_section.get("dependencies", {}))
75-
for group_name, group_section in poetry_content.get("group", {}).items()
77+
(
78+
MAIN_GROUP,
79+
project_content.get("dependencies", []),
80+
poetry_content.get("dependencies", {}),
81+
)
7682
]
83+
group_sections.extend(
84+
(group_name, [], group_section.get("dependencies", {}))
85+
for group_name, group_section in poetry_content.get("group", {}).items()
86+
)
7787

78-
for group_name, section in [
79-
(MAIN_GROUP, poetry_content["dependencies"]),
80-
*group_sections,
81-
]:
82-
removed += self._remove_packages(packages, section, group_name)
83-
if group_name != MAIN_GROUP:
84-
if not section:
85-
del poetry_content["group"][group_name]
86-
else:
87-
poetry_content["group"][group_name]["dependencies"] = section
88+
for group_name, project_section, poetry_section in group_sections:
89+
removed |= self._remove_packages(
90+
packages, project_section, poetry_section, group_name
91+
)
92+
if group_name != MAIN_GROUP and not poetry_section:
93+
del poetry_content["group"][group_name]
8894
elif group == "dev" and "dev-dependencies" in poetry_content:
8995
# We need to account for the old `dev-dependencies` section
9096
removed = self._remove_packages(
91-
packages, poetry_content["dev-dependencies"], "dev"
97+
packages, [], poetry_content["dev-dependencies"], "dev"
9298
)
9399

94100
if not poetry_content["dev-dependencies"]:
95101
del poetry_content["dev-dependencies"]
96102
else:
97-
removed = []
103+
removed = set()
98104
if "group" in poetry_content:
99105
if group in poetry_content["group"]:
100106
removed = self._remove_packages(
101107
packages,
108+
[],
102109
poetry_content["group"][group].get("dependencies", {}),
103110
group,
104111
)
@@ -109,23 +116,21 @@ def handle(self) -> int:
109116
if "group" in poetry_content and not poetry_content["group"]:
110117
del poetry_content["group"]
111118

112-
removed_set = set(removed)
113-
not_found = set(packages).difference(removed_set)
119+
not_found = set(packages).difference(removed)
114120
if not_found:
115121
raise ValueError(
116122
"The following packages were not found: " + ", ".join(sorted(not_found))
117123
)
118124

119125
# Refresh the locker
120-
content["tool"]["poetry"] = poetry_content
121126
self.poetry.locker.set_pyproject_data(content)
122127
self.installer.set_locker(self.poetry.locker)
123128
self.installer.set_package(self.poetry.package)
124129
self.installer.dry_run(self.option("dry-run", False))
125130
self.installer.verbose(self.io.is_verbose())
126131
self.installer.update(True)
127132
self.installer.execute_operations(not self.option("lock"))
128-
self.installer.whitelist(removed_set)
133+
self.installer.whitelist(removed)
129134

130135
status = self.installer.run()
131136

@@ -136,17 +141,27 @@ def handle(self) -> int:
136141
return status
137142

138143
def _remove_packages(
139-
self, packages: list[str], section: dict[str, Any], group_name: str
140-
) -> list[str]:
141-
removed = []
144+
self,
145+
packages: list[str],
146+
project_section: list[str],
147+
poetry_section: dict[str, Any],
148+
group_name: str,
149+
) -> set[str]:
150+
removed = set()
142151
group = self.poetry.package.dependency_group(group_name)
143-
section_keys = list(section.keys())
144152

145153
for package in packages:
146-
for existing_package in section_keys:
147-
if canonicalize_name(existing_package) == canonicalize_name(package):
148-
del section[existing_package]
149-
removed.append(package)
150-
group.remove_dependency(package)
154+
normalized_name = canonicalize_name(package)
155+
for requirement in project_section.copy():
156+
if Dependency.create_from_pep_508(requirement).name == normalized_name:
157+
project_section.remove(requirement)
158+
removed.add(package)
159+
for existing_package in list(poetry_section):
160+
if canonicalize_name(existing_package) == normalized_name:
161+
del poetry_section[existing_package]
162+
removed.add(package)
163+
164+
for package in removed:
165+
group.remove_dependency(package)
151166

152167
return removed

tests/console/commands/test_remove.py

+116-24
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from typing import TYPE_CHECKING
44
from typing import Any
5+
from typing import Callable
56
from typing import cast
67

78
import pytest
@@ -31,25 +32,103 @@
3132
@pytest.fixture
3233
def poetry_with_up_to_date_lockfile(
3334
project_factory: ProjectFactory, fixture_dir: FixtureDirGetter
34-
) -> Poetry:
35-
source = fixture_dir("up_to_date_lock")
35+
) -> Callable[[str], Poetry]:
36+
def get_poetry(fixture_name: str) -> Poetry:
37+
source = fixture_dir(fixture_name)
3638

37-
poetry = project_factory(
38-
name="foobar",
39-
pyproject_content=(source / "pyproject.toml").read_text(encoding="utf-8"),
40-
poetry_lock_content=(source / "poetry.lock").read_text(encoding="utf-8"),
41-
)
39+
poetry = project_factory(
40+
name="foobar",
41+
pyproject_content=(source / "pyproject.toml").read_text(encoding="utf-8"),
42+
poetry_lock_content=(source / "poetry.lock").read_text(encoding="utf-8"),
43+
)
44+
45+
assert isinstance(poetry.locker, TestLocker)
46+
poetry.locker.locked(True)
47+
return poetry
4248

43-
assert isinstance(poetry.locker, TestLocker)
44-
poetry.locker.locked(True)
45-
return poetry
49+
return get_poetry
4650

4751

4852
@pytest.fixture()
4953
def tester(command_tester_factory: CommandTesterFactory) -> CommandTester:
5054
return command_tester_factory("remove")
5155

5256

57+
def test_remove_from_project_and_poetry(
58+
tester: CommandTester,
59+
app: PoetryTestApplication,
60+
repo: TestRepository,
61+
installed: Repository,
62+
) -> None:
63+
repo.add_package(Package("foo", "2.0.0"))
64+
repo.add_package(Package("bar", "1.0.0"))
65+
66+
pyproject: dict[str, Any] = app.poetry.file.read()
67+
68+
project_dependencies: dict[str, Any] = tomlkit.parse(
69+
"""\
70+
[project]
71+
dependencies = [
72+
"foo>=2.0",
73+
"bar>=1.0",
74+
]
75+
"""
76+
)
77+
78+
poetry_dependencies: dict[str, Any] = tomlkit.parse(
79+
"""\
80+
[tool.poetry.dependencies]
81+
foo = "^2.0.0"
82+
bar = "^1.0.0"
83+
84+
"""
85+
)
86+
87+
pyproject["project"]["dependencies"] = project_dependencies["project"][
88+
"dependencies"
89+
]
90+
pyproject["tool"]["poetry"]["dependencies"] = poetry_dependencies["tool"]["poetry"][
91+
"dependencies"
92+
]
93+
pyproject = cast("TOMLDocument", pyproject)
94+
app.poetry.file.write(pyproject)
95+
96+
app.poetry.package.add_dependency(Factory.create_dependency("foo", "^2.0.0"))
97+
app.poetry.package.add_dependency(Factory.create_dependency("bar", "^1.0.0"))
98+
99+
tester.execute("foo")
100+
101+
pyproject = app.poetry.file.read()
102+
pyproject = cast("dict[str, Any]", pyproject)
103+
project_dependencies = pyproject["project"]["dependencies"]
104+
assert "foo>=2.0" not in project_dependencies
105+
assert "bar>=1.0" in project_dependencies
106+
poetry_dependencies = pyproject["tool"]["poetry"]["dependencies"]
107+
assert "foo" not in poetry_dependencies
108+
assert "bar" in poetry_dependencies
109+
110+
expected_project_string = """\
111+
dependencies = [
112+
"bar>=1.0",
113+
]
114+
"""
115+
expected_poetry_string = """\
116+
117+
[tool.poetry.dependencies]
118+
bar = "^1.0.0"
119+
120+
"""
121+
pyproject = cast("TOMLDocument", pyproject)
122+
string_content = pyproject.as_string()
123+
if "\r\n" in string_content:
124+
# consistent line endings
125+
expected_project_string = expected_project_string.replace("\n", "\r\n")
126+
expected_poetry_string = expected_poetry_string.replace("\n", "\r\n")
127+
128+
assert expected_project_string in string_content
129+
assert expected_poetry_string in string_content
130+
131+
53132
def test_remove_without_specific_group_removes_from_all_groups(
54133
tester: CommandTester,
55134
app: PoetryTestApplication,
@@ -110,7 +189,7 @@ def test_remove_without_specific_group_removes_from_all_groups(
110189
assert expected in string_content
111190

112191

113-
def test_remove_without_specific_group_removes_from_specific_groups(
192+
def test_remove_with_specific_group_removes_from_specific_groups(
114193
tester: CommandTester,
115194
app: PoetryTestApplication,
116195
repo: TestRepository,
@@ -169,7 +248,7 @@ def test_remove_without_specific_group_removes_from_specific_groups(
169248
assert expected in string_content
170249

171250

172-
def test_remove_does_not_live_empty_groups(
251+
def test_remove_does_not_keep_empty_groups(
173252
tester: CommandTester,
174253
app: PoetryTestApplication,
175254
repo: TestRepository,
@@ -299,33 +378,41 @@ def test_remove_command_should_not_write_changes_upon_installer_errors(
299378
assert app.poetry.file.read().as_string() == original_content
300379

301380

381+
@pytest.mark.parametrize(
382+
"fixture_name", ["up_to_date_lock", "up_to_date_lock_non_package"]
383+
)
302384
def test_remove_with_dry_run_keep_files_intact(
303-
poetry_with_up_to_date_lockfile: Poetry,
385+
fixture_name: str,
386+
poetry_with_up_to_date_lockfile: Callable[[str], Poetry],
304387
repo: TestRepository,
305388
command_tester_factory: CommandTesterFactory,
306389
) -> None:
307-
tester = command_tester_factory("remove", poetry=poetry_with_up_to_date_lockfile)
390+
poetry = poetry_with_up_to_date_lockfile(fixture_name)
391+
tester = command_tester_factory("remove", poetry=poetry)
308392

309-
original_pyproject_content = poetry_with_up_to_date_lockfile.file.read()
310-
original_lockfile_content = poetry_with_up_to_date_lockfile._locker.lock_data
393+
original_pyproject_content = poetry.file.read()
394+
original_lockfile_content = poetry._locker.lock_data
311395

312396
repo.add_package(get_package("docker", "4.3.1"))
313397

314398
tester.execute("docker --dry-run")
315399

316-
assert poetry_with_up_to_date_lockfile.file.read() == original_pyproject_content
317-
assert (
318-
poetry_with_up_to_date_lockfile._locker.lock_data == original_lockfile_content
319-
)
400+
assert poetry.file.read() == original_pyproject_content
401+
assert poetry._locker.lock_data == original_lockfile_content
320402

321403

404+
@pytest.mark.parametrize(
405+
"fixture_name", ["up_to_date_lock", "up_to_date_lock_non_package"]
406+
)
322407
def test_remove_performs_uninstall_op(
323-
poetry_with_up_to_date_lockfile: Poetry,
408+
fixture_name: str,
409+
poetry_with_up_to_date_lockfile: Callable[[str], Poetry],
324410
command_tester_factory: CommandTesterFactory,
325411
installed: Repository,
326412
) -> None:
327413
installed.add_package(get_package("docker", "4.3.1"))
328-
tester = command_tester_factory("remove", poetry=poetry_with_up_to_date_lockfile)
414+
poetry = poetry_with_up_to_date_lockfile(fixture_name)
415+
tester = command_tester_factory("remove", poetry=poetry)
329416

330417
tester.execute("docker")
331418

@@ -343,13 +430,18 @@ def test_remove_performs_uninstall_op(
343430
assert tester.io.fetch_output() == expected
344431

345432

433+
@pytest.mark.parametrize(
434+
"fixture_name", ["up_to_date_lock", "up_to_date_lock_non_package"]
435+
)
346436
def test_remove_with_lock_does_not_perform_uninstall_op(
347-
poetry_with_up_to_date_lockfile: Poetry,
437+
fixture_name: str,
438+
poetry_with_up_to_date_lockfile: Callable[[str], Poetry],
348439
command_tester_factory: CommandTesterFactory,
349440
installed: Repository,
350441
) -> None:
351442
installed.add_package(get_package("docker", "4.3.1"))
352-
tester = command_tester_factory("remove", poetry=poetry_with_up_to_date_lockfile)
443+
poetry = poetry_with_up_to_date_lockfile(fixture_name)
444+
tester = command_tester_factory("remove", poetry=poetry)
353445

354446
tester.execute("docker --lock")
355447

0 commit comments

Comments
 (0)