Skip to content

fix: update, add and remove shall not uninstall extra dependencies #10016

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,14 @@ poetry install -E mysql -E pgsql
poetry install --all-extras
```

Any extras not specified will always be removed.
Any extras not specified will be kept but not installed:

```bash
poetry install --extras "A B" # C is removed
poetry install --extras "A B" # C is kept if already installed
```

If you want to remove unspecified extras, use the `sync` command.

By default `poetry` will install your project's package every time you run `install`:

```bash
Expand Down
14 changes: 4 additions & 10 deletions src/poetry/puzzle/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ def calculate_operations(
else:
priorities = defaultdict(int)
relevant_result_packages: set[NormalizedName] = set()
pending_extra_uninstalls: list[Package] = [] # list for deterministic order
for result_package in self._result_packages:
is_unsolicited_extra = False
if self._marker_env:
Expand All @@ -103,9 +102,9 @@ def calculate_operations(
relevant_result_packages.add(result_package.name)

if installed_package := self._installed_packages.get(result_package.name):
# Extras that were not requested are always uninstalled.
# Extras that were not requested are not relevant.
if is_unsolicited_extra:
pending_extra_uninstalls.append(installed_package)
pass

# We have to perform an update if the version or another
# attribute of the package has changed (source type, url, ref, ...).
Expand Down Expand Up @@ -141,14 +140,9 @@ def calculate_operations(
op.skip("Not required")
operations.append(op)

uninstalls: set[NormalizedName] = set()
for package in pending_extra_uninstalls:
if package.name not in (relevant_result_packages | uninstalls):
uninstalls.add(package.name)
if package.name not in system_site_packages:
operations.append(Uninstall(package))

if with_uninstalls:
uninstalls: set[NormalizedName] = set()

result_packages = {package.name for package in self._result_packages}
for current_package in self._current_packages:
if current_package.name not in (result_packages | uninstalls) and (
Expand Down
5 changes: 4 additions & 1 deletion tests/installation/test_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,7 @@ def test_run_with_different_dependency_extras(
@pytest.mark.parametrize("is_locked", [False, True])
@pytest.mark.parametrize("is_installed", [False, True])
@pytest.mark.parametrize("with_extras", [False, True])
@pytest.mark.parametrize("do_update", [False, True])
@pytest.mark.parametrize("do_sync", [False, True])
def test_run_installs_extras_with_deps_if_requested(
installer: Installer,
Expand All @@ -1410,6 +1411,7 @@ def test_run_installs_extras_with_deps_if_requested(
is_locked: bool,
is_installed: bool,
with_extras: bool,
do_update: bool,
do_sync: bool,
) -> None:
package.extras = {canonicalize_name("foo"): [get_dependency("C")]}
Expand Down Expand Up @@ -1443,6 +1445,7 @@ def test_run_installs_extras_with_deps_if_requested(

if with_extras:
installer.extras(["foo"])
installer.update(do_update)
installer.requires_synchronization(do_sync)
result = installer.run()
assert result == 0
Expand All @@ -1459,7 +1462,7 @@ def test_run_installs_extras_with_deps_if_requested(
expected_installations_count = 0 if is_installed else 2
# We only want to uninstall extras if we do a "poetry install" without extras,
# not if we do a "poetry update" or "poetry add".
expected_removals_count = 2 if is_installed else 0
expected_removals_count = 2 if is_installed and do_sync else 0

assert installer.executor.installations_count == expected_installations_count
assert installer.executor.removals_count == expected_removals_count
Expand Down
7 changes: 5 additions & 2 deletions tests/puzzle/test_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,8 +433,10 @@ def test_calculate_operations_extras(
if extras:
ops = [{"job": "install", "package": Package("a", "1"), "skipped": installed}]
elif installed:
# extras are always removed, even if with_uninstalls is False
ops = [{"job": "remove", "package": Package("a", "1")}]
if with_uninstalls and sync:
ops = [{"job": "remove", "package": Package("a", "1")}]
else:
ops = []
else:
ops = [{"job": "install", "package": Package("a", "1"), "skipped": True}]

Expand Down Expand Up @@ -494,6 +496,7 @@ def test_calculate_operations_extras_no_redundant_uninstall(extra: str) -> None:

check_operations(
transaction.calculate_operations(
synchronize=True,
extras=set() if not extra else {canonicalize_name(extra)},
),
ops,
Expand Down
Loading