From 13bb8557868606548e974695561a68e7e55bde6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Fri, 15 Mar 2024 17:53:52 +0100 Subject: [PATCH] add support for PEP 621: poetry remove (#9135) --- src/poetry/console/commands/remove.py | 73 +++++---- tests/console/commands/test_remove.py | 140 ++++++++++++++--- .../up_to_date_lock_non_package/poetry.lock | 143 ++++++++++++++++++ .../pyproject.toml | 10 ++ 4 files changed, 313 insertions(+), 53 deletions(-) create mode 100644 tests/fixtures/up_to_date_lock_non_package/poetry.lock create mode 100644 tests/fixtures/up_to_date_lock_non_package/pyproject.toml diff --git a/src/poetry/console/commands/remove.py b/src/poetry/console/commands/remove.py index 718a7d59903..dcd7ffff08c 100644 --- a/src/poetry/console/commands/remove.py +++ b/src/poetry/console/commands/remove.py @@ -7,6 +7,7 @@ from cleo.helpers import argument from cleo.helpers import option from packaging.utils import canonicalize_name +from poetry.core.packages.dependency import Dependency from poetry.core.packages.dependency_group import MAIN_GROUP from tomlkit.toml_document import TOMLDocument @@ -66,39 +67,45 @@ def handle(self) -> int: group = self.option("group", self.default_group) content: dict[str, Any] = self.poetry.file.read() - poetry_content = content["tool"]["poetry"] + project_content = content.get("project", {}) + poetry_content = content.get("tool", {}).get("poetry", {}) if group is None: - removed = [] + # remove from all groups + removed = set() group_sections = [ - (group_name, group_section.get("dependencies", {})) - for group_name, group_section in poetry_content.get("group", {}).items() + ( + MAIN_GROUP, + project_content.get("dependencies", []), + poetry_content.get("dependencies", {}), + ) ] + group_sections.extend( + (group_name, [], group_section.get("dependencies", {})) + for group_name, group_section in poetry_content.get("group", {}).items() + ) - for group_name, section in [ - (MAIN_GROUP, poetry_content["dependencies"]), - *group_sections, - ]: - removed += self._remove_packages(packages, section, group_name) - if group_name != MAIN_GROUP: - if not section: - del poetry_content["group"][group_name] - else: - poetry_content["group"][group_name]["dependencies"] = section + for group_name, project_section, poetry_section in group_sections: + removed |= self._remove_packages( + packages, project_section, poetry_section, group_name + ) + if group_name != MAIN_GROUP and not poetry_section: + del poetry_content["group"][group_name] elif group == "dev" and "dev-dependencies" in poetry_content: # We need to account for the old `dev-dependencies` section removed = self._remove_packages( - packages, poetry_content["dev-dependencies"], "dev" + packages, [], poetry_content["dev-dependencies"], "dev" ) if not poetry_content["dev-dependencies"]: del poetry_content["dev-dependencies"] else: - removed = [] + removed = set() if "group" in poetry_content: if group in poetry_content["group"]: removed = self._remove_packages( packages, + [], poetry_content["group"][group].get("dependencies", {}), group, ) @@ -109,15 +116,13 @@ def handle(self) -> int: if "group" in poetry_content and not poetry_content["group"]: del poetry_content["group"] - removed_set = set(removed) - not_found = set(packages).difference(removed_set) + not_found = set(packages).difference(removed) if not_found: raise ValueError( "The following packages were not found: " + ", ".join(sorted(not_found)) ) # Refresh the locker - content["tool"]["poetry"] = poetry_content self.poetry.locker.set_pyproject_data(content) self.installer.set_locker(self.poetry.locker) self.installer.set_package(self.poetry.package) @@ -125,7 +130,7 @@ def handle(self) -> int: self.installer.verbose(self.io.is_verbose()) self.installer.update(True) self.installer.execute_operations(not self.option("lock")) - self.installer.whitelist(removed_set) + self.installer.whitelist(removed) status = self.installer.run() @@ -136,17 +141,27 @@ def handle(self) -> int: return status def _remove_packages( - self, packages: list[str], section: dict[str, Any], group_name: str - ) -> list[str]: - removed = [] + self, + packages: list[str], + project_section: list[str], + poetry_section: dict[str, Any], + group_name: str, + ) -> set[str]: + removed = set() group = self.poetry.package.dependency_group(group_name) - section_keys = list(section.keys()) for package in packages: - for existing_package in section_keys: - if canonicalize_name(existing_package) == canonicalize_name(package): - del section[existing_package] - removed.append(package) - group.remove_dependency(package) + normalized_name = canonicalize_name(package) + for requirement in project_section.copy(): + if Dependency.create_from_pep_508(requirement).name == normalized_name: + project_section.remove(requirement) + removed.add(package) + for existing_package in list(poetry_section): + if canonicalize_name(existing_package) == normalized_name: + del poetry_section[existing_package] + removed.add(package) + + for package in removed: + group.remove_dependency(package) return removed diff --git a/tests/console/commands/test_remove.py b/tests/console/commands/test_remove.py index 6991045f82b..fb67774a50e 100644 --- a/tests/console/commands/test_remove.py +++ b/tests/console/commands/test_remove.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING from typing import Any +from typing import Callable from typing import cast import pytest @@ -31,18 +32,21 @@ @pytest.fixture def poetry_with_up_to_date_lockfile( project_factory: ProjectFactory, fixture_dir: FixtureDirGetter -) -> Poetry: - source = fixture_dir("up_to_date_lock") +) -> Callable[[str], Poetry]: + def get_poetry(fixture_name: str) -> Poetry: + source = fixture_dir(fixture_name) - poetry = project_factory( - name="foobar", - pyproject_content=(source / "pyproject.toml").read_text(encoding="utf-8"), - poetry_lock_content=(source / "poetry.lock").read_text(encoding="utf-8"), - ) + poetry = project_factory( + name="foobar", + pyproject_content=(source / "pyproject.toml").read_text(encoding="utf-8"), + poetry_lock_content=(source / "poetry.lock").read_text(encoding="utf-8"), + ) + + assert isinstance(poetry.locker, TestLocker) + poetry.locker.locked(True) + return poetry - assert isinstance(poetry.locker, TestLocker) - poetry.locker.locked(True) - return poetry + return get_poetry @pytest.fixture() @@ -50,6 +54,81 @@ def tester(command_tester_factory: CommandTesterFactory) -> CommandTester: return command_tester_factory("remove") +def test_remove_from_project_and_poetry( + tester: CommandTester, + app: PoetryTestApplication, + repo: TestRepository, + installed: Repository, +) -> None: + repo.add_package(Package("foo", "2.0.0")) + repo.add_package(Package("bar", "1.0.0")) + + pyproject: dict[str, Any] = app.poetry.file.read() + + project_dependencies: dict[str, Any] = tomlkit.parse( + """\ +[project] +dependencies = [ + "foo>=2.0", + "bar>=1.0", +] +""" + ) + + poetry_dependencies: dict[str, Any] = tomlkit.parse( + """\ +[tool.poetry.dependencies] +foo = "^2.0.0" +bar = "^1.0.0" + +""" + ) + + pyproject["project"]["dependencies"] = project_dependencies["project"][ + "dependencies" + ] + pyproject["tool"]["poetry"]["dependencies"] = poetry_dependencies["tool"]["poetry"][ + "dependencies" + ] + pyproject = cast("TOMLDocument", pyproject) + app.poetry.file.write(pyproject) + + app.poetry.package.add_dependency(Factory.create_dependency("foo", "^2.0.0")) + app.poetry.package.add_dependency(Factory.create_dependency("bar", "^1.0.0")) + + tester.execute("foo") + + pyproject = app.poetry.file.read() + pyproject = cast("dict[str, Any]", pyproject) + project_dependencies = pyproject["project"]["dependencies"] + assert "foo>=2.0" not in project_dependencies + assert "bar>=1.0" in project_dependencies + poetry_dependencies = pyproject["tool"]["poetry"]["dependencies"] + assert "foo" not in poetry_dependencies + assert "bar" in poetry_dependencies + + expected_project_string = """\ +dependencies = [ + "bar>=1.0", +] +""" + expected_poetry_string = """\ + +[tool.poetry.dependencies] +bar = "^1.0.0" + +""" + pyproject = cast("TOMLDocument", pyproject) + string_content = pyproject.as_string() + if "\r\n" in string_content: + # consistent line endings + expected_project_string = expected_project_string.replace("\n", "\r\n") + expected_poetry_string = expected_poetry_string.replace("\n", "\r\n") + + assert expected_project_string in string_content + assert expected_poetry_string in string_content + + def test_remove_without_specific_group_removes_from_all_groups( tester: CommandTester, app: PoetryTestApplication, @@ -110,7 +189,7 @@ def test_remove_without_specific_group_removes_from_all_groups( assert expected in string_content -def test_remove_without_specific_group_removes_from_specific_groups( +def test_remove_with_specific_group_removes_from_specific_groups( tester: CommandTester, app: PoetryTestApplication, repo: TestRepository, @@ -169,7 +248,7 @@ def test_remove_without_specific_group_removes_from_specific_groups( assert expected in string_content -def test_remove_does_not_live_empty_groups( +def test_remove_does_not_keep_empty_groups( tester: CommandTester, app: PoetryTestApplication, repo: TestRepository, @@ -299,33 +378,41 @@ def test_remove_command_should_not_write_changes_upon_installer_errors( assert app.poetry.file.read().as_string() == original_content +@pytest.mark.parametrize( + "fixture_name", ["up_to_date_lock", "up_to_date_lock_non_package"] +) def test_remove_with_dry_run_keep_files_intact( - poetry_with_up_to_date_lockfile: Poetry, + fixture_name: str, + poetry_with_up_to_date_lockfile: Callable[[str], Poetry], repo: TestRepository, command_tester_factory: CommandTesterFactory, ) -> None: - tester = command_tester_factory("remove", poetry=poetry_with_up_to_date_lockfile) + poetry = poetry_with_up_to_date_lockfile(fixture_name) + tester = command_tester_factory("remove", poetry=poetry) - original_pyproject_content = poetry_with_up_to_date_lockfile.file.read() - original_lockfile_content = poetry_with_up_to_date_lockfile._locker.lock_data + original_pyproject_content = poetry.file.read() + original_lockfile_content = poetry._locker.lock_data repo.add_package(get_package("docker", "4.3.1")) tester.execute("docker --dry-run") - assert poetry_with_up_to_date_lockfile.file.read() == original_pyproject_content - assert ( - poetry_with_up_to_date_lockfile._locker.lock_data == original_lockfile_content - ) + assert poetry.file.read() == original_pyproject_content + assert poetry._locker.lock_data == original_lockfile_content +@pytest.mark.parametrize( + "fixture_name", ["up_to_date_lock", "up_to_date_lock_non_package"] +) def test_remove_performs_uninstall_op( - poetry_with_up_to_date_lockfile: Poetry, + fixture_name: str, + poetry_with_up_to_date_lockfile: Callable[[str], Poetry], command_tester_factory: CommandTesterFactory, installed: Repository, ) -> None: installed.add_package(get_package("docker", "4.3.1")) - tester = command_tester_factory("remove", poetry=poetry_with_up_to_date_lockfile) + poetry = poetry_with_up_to_date_lockfile(fixture_name) + tester = command_tester_factory("remove", poetry=poetry) tester.execute("docker") @@ -343,13 +430,18 @@ def test_remove_performs_uninstall_op( assert tester.io.fetch_output() == expected +@pytest.mark.parametrize( + "fixture_name", ["up_to_date_lock", "up_to_date_lock_non_package"] +) def test_remove_with_lock_does_not_perform_uninstall_op( - poetry_with_up_to_date_lockfile: Poetry, + fixture_name: str, + poetry_with_up_to_date_lockfile: Callable[[str], Poetry], command_tester_factory: CommandTesterFactory, installed: Repository, ) -> None: installed.add_package(get_package("docker", "4.3.1")) - tester = command_tester_factory("remove", poetry=poetry_with_up_to_date_lockfile) + poetry = poetry_with_up_to_date_lockfile(fixture_name) + tester = command_tester_factory("remove", poetry=poetry) tester.execute("docker --lock") diff --git a/tests/fixtures/up_to_date_lock_non_package/poetry.lock b/tests/fixtures/up_to_date_lock_non_package/poetry.lock new file mode 100644 index 00000000000..c3b04ddf2a3 --- /dev/null +++ b/tests/fixtures/up_to_date_lock_non_package/poetry.lock @@ -0,0 +1,143 @@ +# This file is automatically @generated by Poetry 1.5.0.dev0 and should not be changed by hand. + +[[package]] +name = "certifi" +version = "2020.12.5" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = "*" +files = [ + {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, + {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, +] + +[[package]] +name = "chardet" +version = "4.0.0" +description = "Universal encoding detector for Python 2 and 3" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, + {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, +] + +[[package]] +name = "docker" +version = "4.3.1" +description = "A Python library for the Docker Engine API." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "docker-4.3.1-py2.py3-none-any.whl", hash = "sha256:13966471e8bc23b36bfb3a6fb4ab75043a5ef1dac86516274777576bed3b9828"}, + {file = "docker-4.3.1.tar.gz", hash = "sha256:bad94b8dd001a8a4af19ce4becc17f41b09f228173ffe6a4e0355389eef142f2"}, +] + +[package.dependencies] +pywin32 = {version = "227", markers = "sys_platform == \"win32\""} +requests = ">=2.14.2,<2.18.0 || >2.18.0" +six = ">=1.4.0" +websocket-client = ">=0.32.0" + +[package.extras] +ssh = ["paramiko (>=2.4.2)"] +tls = ["cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=17.5.0)"] + +[[package]] +name = "idna" +version = "2.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, + {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, +] + +[[package]] +name = "pywin32" +version = "227" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +files = [ + {file = "pywin32-227-cp27-cp27m-win32.whl", hash = "sha256:371fcc39416d736401f0274dd64c2302728c9e034808e37381b5e1b22be4a6b0"}, + {file = "pywin32-227-cp27-cp27m-win_amd64.whl", hash = "sha256:4cdad3e84191194ea6d0dd1b1b9bdda574ff563177d2adf2b4efec2a244fa116"}, + {file = "pywin32-227-cp35-cp35m-win32.whl", hash = "sha256:f4c5be1a293bae0076d93c88f37ee8da68136744588bc5e2be2f299a34ceb7aa"}, + {file = "pywin32-227-cp35-cp35m-win_amd64.whl", hash = "sha256:a929a4af626e530383a579431b70e512e736e9588106715215bf685a3ea508d4"}, + {file = "pywin32-227-cp36-cp36m-win32.whl", hash = "sha256:300a2db938e98c3e7e2093e4491439e62287d0d493fe07cce110db070b54c0be"}, + {file = "pywin32-227-cp36-cp36m-win_amd64.whl", hash = "sha256:9b31e009564fb95db160f154e2aa195ed66bcc4c058ed72850d047141b36f3a2"}, + {file = "pywin32-227-cp37-cp37m-win32.whl", hash = "sha256:47a3c7551376a865dd8d095a98deba954a98f326c6fe3c72d8726ca6e6b15507"}, + {file = "pywin32-227-cp37-cp37m-win_amd64.whl", hash = "sha256:31f88a89139cb2adc40f8f0e65ee56a8c585f629974f9e07622ba80199057511"}, + {file = "pywin32-227-cp38-cp38-win32.whl", hash = "sha256:7f18199fbf29ca99dff10e1f09451582ae9e372a892ff03a28528a24d55875bc"}, + {file = "pywin32-227-cp38-cp38-win_amd64.whl", hash = "sha256:7c1ae32c489dc012930787f06244426f8356e129184a02c25aef163917ce158e"}, + {file = "pywin32-227-cp39-cp39-win32.whl", hash = "sha256:c054c52ba46e7eb6b7d7dfae4dbd987a1bb48ee86debe3f245a2884ece46e295"}, + {file = "pywin32-227-cp39-cp39-win_amd64.whl", hash = "sha256:f27cec5e7f588c3d1051651830ecc00294f90728d19c3bf6916e6dba93ea357c"}, +] + +[[package]] +name = "requests" +version = "2.25.1" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, + {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +chardet = ">=3.0.2,<5" +idna = ">=2.5,<3" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +security = ["cryptography (>=1.3.4)", "pyOpenSSL (>=0.14)"] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] + +[[package]] +name = "six" +version = "1.15.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, + {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, +] + +[[package]] +name = "urllib3" +version = "1.26.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +files = [ + {file = "urllib3-1.26.3-py2.py3-none-any.whl", hash = "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80"}, + {file = "urllib3-1.26.3.tar.gz", hash = "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"}, +] + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "websocket-client" +version = "0.58.0" +description = "WebSocket client for Python with low level API options" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "websocket_client-0.58.0-py2.py3-none-any.whl", hash = "sha256:44b5df8f08c74c3d82d28100fdc81f4536809ce98a17f0757557813275fbb663"}, + {file = "websocket_client-0.58.0.tar.gz", hash = "sha256:63509b41d158ae5b7f67eb4ad20fecbb4eee99434e73e140354dc3ff8e09716f"}, +] + +[package.dependencies] +six = "*" + +[metadata] +lock-version = "2.0" +python-versions = "^3.8" +content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8" diff --git a/tests/fixtures/up_to_date_lock_non_package/pyproject.toml b/tests/fixtures/up_to_date_lock_non_package/pyproject.toml new file mode 100644 index 00000000000..6760fde5449 --- /dev/null +++ b/tests/fixtures/up_to_date_lock_non_package/pyproject.toml @@ -0,0 +1,10 @@ +[tool.poetry] +package-mode = false + +[tool.poetry.dependencies] +python = "^3.8" +docker = ">=4.3.1" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api"