diff --git a/docs/docs/cli.md b/docs/docs/cli.md index f025c4f15f5..127031dcae1 100644 --- a/docs/docs/cli.md +++ b/docs/docs/cli.md @@ -116,6 +116,13 @@ the `--no-dev` option. poetry install --no-dev ``` +If you want to remove old dependencies no longer present in the lock file, use the +`--remove-untracked` option. + +```bash +poetry install --remove-untracked +``` + You can also specify the extras you want installed by passing the `--E|--extras` option (See [Extras](#extras) for more info) diff --git a/poetry/console/commands/install.py b/poetry/console/commands/install.py index d567dac4783..635608b4e0c 100644 --- a/poetry/console/commands/install.py +++ b/poetry/console/commands/install.py @@ -19,6 +19,9 @@ class InstallCommand(EnvCommand): "Output the operations but do not execute anything " "(implicitly enables --verbose).", ), + option( + "remove-untracked", None, "Removes packages not present in the lock file.", + ), option( "extras", "E", @@ -57,6 +60,7 @@ def handle(self): installer.extras(extras) installer.dev_mode(not self.option("no-dev")) installer.dry_run(self.option("dry-run")) + installer.remove_untracked(self.option("remove-untracked")) installer.verbose(self.option("verbose")) return_code = installer.run() diff --git a/poetry/installation/installer.py b/poetry/installation/installer.py index e01703303f6..442961c0653 100644 --- a/poetry/installation/installer.py +++ b/poetry/installation/installer.py @@ -38,6 +38,7 @@ def __init__( self._pool = pool self._dry_run = False + self._remove_untracked = False self._update = False self._verbose = False self._write_lock = True @@ -82,6 +83,14 @@ def dry_run(self, dry_run=True): # type: (bool) -> Installer def is_dry_run(self): # type: () -> bool return self._dry_run + def remove_untracked(self, remove_untracked=True): # type: (bool) -> Installer + self._remove_untracked = remove_untracked + + return self + + def is_remove_untracked(self): # type: () -> bool + return self._remove_untracked + def verbose(self, verbose=True): # type: (bool) -> Installer self._verbose = verbose @@ -155,6 +164,7 @@ def _do_install(self, local_repo): self._installed_repository, locked_repository, self._io, + remove_untracked=self._remove_untracked, ) ops = solver.solve(use_latest=self._whitelist) @@ -221,7 +231,12 @@ def _do_install(self, local_repo): whitelist.append(pkg.name) solver = Solver( - root, pool, self._installed_repository, locked_repository, NullIO() + root, + pool, + self._installed_repository, + locked_repository, + NullIO(), + remove_untracked=self._remove_untracked, ) with solver.use_environment(self._env): diff --git a/poetry/puzzle/solver.py b/poetry/puzzle/solver.py index 5ee6f5a485e..c68b7c80106 100644 --- a/poetry/puzzle/solver.py +++ b/poetry/puzzle/solver.py @@ -5,10 +5,15 @@ from typing import Dict from typing import List +from clikit.io import ConsoleIO + from poetry.core.packages import Package +from poetry.core.packages.project_package import ProjectPackage from poetry.mixology import resolve_version from poetry.mixology.failure import SolveFailure from poetry.packages import DependencyPackage +from poetry.repositories import Pool +from poetry.repositories import Repository from poetry.utils.env import Env from .exceptions import OverrideNeeded @@ -21,7 +26,15 @@ class Solver: - def __init__(self, package, pool, installed, locked, io): + def __init__( + self, + package, # type: ProjectPackage + pool, # type: Pool + installed, # type: Repository + locked, # type: Repository + io, # type: ConsoleIO + remove_untracked=False, # type: bool + ): self._package = package self._pool = pool self._installed = installed @@ -29,6 +42,7 @@ def __init__(self, package, pool, installed, locked, io): self._io = io self._provider = Provider(self._package, self._pool, self._io) self._overrides = [] + self._remove_untracked = remove_untracked @property def provider(self): # type: () -> Provider @@ -132,6 +146,18 @@ def solve(self, use_latest=None): # type: (...) -> List[Operation] operations.append(op) + if self._remove_untracked: + locked_names = {locked.name for locked in self._locked.packages} + + for installed in self._installed.packages: + if installed.name == self._package.name: + continue + if installed.name in Provider.UNSAFE_PACKAGES: + # Never remove pip, setuptools etc. + continue + if installed.name not in locked_names: + operations.append(Uninstall(installed)) + return sorted( operations, key=lambda o: ( diff --git a/tests/installation/test_installer.py b/tests/installation/test_installer.py index 4fdc420256b..127352a9d01 100644 --- a/tests/installation/test_installer.py +++ b/tests/installation/test_installer.py @@ -296,6 +296,59 @@ def test_run_install_no_dev(installer, locker, repo, package, installed): assert len(removals) == 1 +def test_run_install_remove_untracked(installer, locker, repo, package, installed): + locker.locked(True) + locker.mock_lock_data( + { + "package": [ + { + "name": "a", + "version": "1.0", + "category": "main", + "optional": False, + "platform": "*", + "python-versions": "*", + "checksum": [], + } + ], + "metadata": { + "python-versions": "*", + "platform": "*", + "content-hash": "123456789", + "hashes": {"a": []}, + }, + } + ) + package_a = get_package("a", "1.0") + package_b = get_package("b", "1.1") + package_c = get_package("c", "1.2") + package_pip = get_package("pip", "20.0.0") + repo.add_package(package_a) + repo.add_package(package_b) + repo.add_package(package_c) + repo.add_package(package_pip) + + installed.add_package(package_a) + installed.add_package(package_b) + installed.add_package(package_c) + installed.add_package(package_pip) # Always required and never removed. + installed.add_package(package) # Root package never removed. + + package.add_dependency("A", "~1.0") + + installer.dev_mode(True).remove_untracked(True) + installer.run() + + installs = installer.installer.installs + assert len(installs) == 0 + + updates = installer.installer.updates + assert len(updates) == 0 + + removals = installer.installer.removals + assert set(r.name for r in removals) == {"b", "c"} + + def test_run_whitelist_add(installer, locker, repo, package): locker.locked(True) locker.mock_lock_data( diff --git a/tests/puzzle/test_solver.py b/tests/puzzle/test_solver.py index e9d32ba058d..b44144a27ba 100644 --- a/tests/puzzle/test_solver.py +++ b/tests/puzzle/test_solver.py @@ -1851,3 +1851,25 @@ def test_solver_should_not_go_into_an_infinite_loop_on_duplicate_dependencies( {"job": "install", "package": package_a}, ], ) + + +def test_solver_remove_untracked_single(package, pool, installed, locked, io): + solver = Solver(package, pool, installed, locked, io, remove_untracked=True) + package_a = get_package("a", "1.0") + installed.add_package(package_a) + + ops = solver.solve() + + check_solver_result(ops, [{"job": "remove", "package": package_a}]) + + +def test_solver_remove_untracked_keeps_critical_package( + package, pool, installed, locked, io +): + solver = Solver(package, pool, installed, locked, io, remove_untracked=True) + package_pip = get_package("pip", "1.0") + installed.add_package(package_pip) + + ops = solver.solve() + + check_solver_result(ops, [])