diff --git a/poetry/config/config.py b/poetry/config/config.py index b1b3b3e61bb..19dbbc6a224 100644 --- a/poetry/config/config.py +++ b/poetry/config/config.py @@ -33,7 +33,13 @@ class Config: "create": True, "in-project": None, "path": os.path.join("{cache-dir}", "virtualenvs"), - "options": {"always-copy": False, "system-site-packages": False}, + "options": { + "always-copy": False, + "system-site-packages": False, + "no-pip": False, + "no-setuptools": False, + "no-wheel": False, + }, }, "experimental": {"new-installer": True}, "installer": {"parallel": True}, @@ -139,6 +145,9 @@ def _get_normalizer(self, name: str) -> Callable: "virtualenvs.in-project", "virtualenvs.options.always-copy", "virtualenvs.options.system-site-packages", + "virtualenvs.options.no-pip", + "virtualenvs.options.no-wheel", + "virtualenvs.options.no-setuptools", "installer.parallel", }: return boolean_normalizer diff --git a/poetry/console/commands/config.py b/poetry/console/commands/config.py index 300245955d0..46e214a5d74 100644 --- a/poetry/console/commands/config.py +++ b/poetry/console/commands/config.py @@ -72,6 +72,21 @@ def unique_config_values(self) -> Dict[str, Tuple[Any, Any, Any]]: boolean_normalizer, False, ), + "virtualenvs.options.no-pip": ( + boolean_validator, + boolean_normalizer, + False, + ), + "virtualenvs.options.no-setuptools": ( + boolean_validator, + boolean_normalizer, + False, + ), + "virtualenvs.options.no-wheel": ( + boolean_validator, + boolean_normalizer, + False, + ), "virtualenvs.path": ( str, lambda val: str(Path(val)), diff --git a/poetry/console/commands/debug/resolve.py b/poetry/console/commands/debug/resolve.py index 06b0fa9d21f..63f542c2516 100644 --- a/poetry/console/commands/debug/resolve.py +++ b/poetry/console/commands/debug/resolve.py @@ -84,7 +84,14 @@ def handle(self) -> Optional[int]: pool = self.poetry.pool - solver = Solver(package, pool, Repository(), Repository(), self._io) + solver = Solver( + package, + pool, + Repository(), + Repository(), + self._io, + config=self.poetry.config, + ) ops = solver.solve() @@ -121,7 +128,9 @@ def handle(self) -> Optional[int]: pool.add_repository(locked_repository) - solver = Solver(package, pool, Repository(), Repository(), NullIO()) + solver = Solver( + package, pool, Repository(), Repository(), NullIO(), poetry=self.poetry + ) with solver.use_environment(env): ops = solver.solve() diff --git a/poetry/console/commands/show.py b/poetry/console/commands/show.py index 799fa4d78db..5bcee0088e9 100644 --- a/poetry/console/commands/show.py +++ b/poetry/console/commands/show.py @@ -90,6 +90,7 @@ def handle(self) -> Optional[int]: installed=Repository(), locked=locked_repo, io=NullIO(), + config=self.poetry.config, ) solver.provider.load_deferred(False) with solver.use_environment(self.env): diff --git a/poetry/installation/installer.py b/poetry/installation/installer.py index 0718eef52d0..31e17f6363a 100644 --- a/poetry/installation/installer.py +++ b/poetry/installation/installer.py @@ -74,6 +74,7 @@ def __init__( installed = self._get_installed() self._installed_repository = installed + self._poetry_config = config @property def executor(self) -> Executor: @@ -209,6 +210,7 @@ def _do_refresh(self) -> int: locked_repository, locked_repository, self._io, + config=self._poetry_config, ) ops = solver.solve(use_latest=[]) @@ -247,6 +249,7 @@ def _do_install(self, local_repo: Repository) -> int: locked_repository, self._io, remove_untracked=self._remove_untracked, + config=self._poetry_config, ) ops = solver.solve(use_latest=self._whitelist) @@ -316,6 +319,7 @@ def _do_install(self, local_repo: Repository) -> int: locked_repository, NullIO(), remove_untracked=self._remove_untracked, + config=self._poetry_config, ) # Everything is resolved at this point, so we no longer need # to load deferred dependencies (i.e. VCS, URL and path dependencies) diff --git a/poetry/puzzle/solver.py b/poetry/puzzle/solver.py index b5a413f15e0..07c6cda2eb7 100644 --- a/poetry/puzzle/solver.py +++ b/poetry/puzzle/solver.py @@ -31,6 +31,7 @@ if TYPE_CHECKING: + from poetry.config.config import Config from poetry.core.packages.dependency import Dependency from poetry.core.packages.directory_dependency import DirectoryDependency from poetry.core.packages.file_dependency import FileDependency @@ -49,6 +50,7 @@ def __init__( io: IO, remove_untracked: bool = False, provider: Optional[Provider] = None, + config: Optional["Config"] = None, ): self._package = package self._pool = pool @@ -63,10 +65,30 @@ def __init__( self._overrides = [] self._remove_untracked = remove_untracked + self._poetry_config = config + self._preserved_package_names = None + @property def provider(self) -> Provider: return self._provider + @property + def preserved_package_names(self): + if self._preserved_package_names is None: + self._preserved_package_names = { + self._package.name, + *Provider.UNSAFE_PACKAGES, + } + if self._poetry_config is not None: + deps = {package.name for package in self._locked.packages} + + for name in {"pip", "wheel", "setuptools"}: + if not self._poetry_config.get(f"virtualenvs.options.no-{name}"): + if name not in deps: + self._preserved_package_names.add(name) + + return self._preserved_package_names + @contextmanager def use_environment(self, env: Env) -> None: with self.provider.use_environment(env): @@ -190,11 +212,9 @@ def solve(self, use_latest: List[str] = None) -> List["OperationTypes"]: 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. + if installed.name in self.preserved_package_names: continue + if installed.name not in locked_names: operations.append(Uninstall(installed)) diff --git a/poetry/utils/env.py b/poetry/utils/env.py index ba9519cbc2e..5303682bb40 100644 --- a/poetry/utils/env.py +++ b/poetry/utils/env.py @@ -933,6 +933,12 @@ def build_venv( ) -> virtualenv.run.session.Session: flags = flags or {} + if flags: + # if flags are present and explicit parameters are false, use flag values + with_pip = with_pip or not flags.pop("no-pip", True) + with_setuptools = with_setuptools or not flags.pop("no-setuptools", True) + with_wheel = with_wheel or not flags.pop("no-wheel", True) + if isinstance(executable, Path): executable = executable.resolve().as_posix() @@ -1039,6 +1045,8 @@ def __init__(self, path: Path, base: Optional[Path] = None) -> None: self._platlib = None self._script_dirs = None + self._embedded_pip_path = None + @property def path(self) -> Path: return self._path @@ -1074,6 +1082,12 @@ def get_embedded_wheel(self, distribution): distribution, "{}.{}".format(self.version_info[0], self.version_info[1]) ).path + @property + def pip_embedded(self) -> str: + if self._embedded_pip_path is None: + self._embedded_pip_path = str(self.get_embedded_wheel("pip") / "pip") + return self._embedded_pip_path + @property def pip(self) -> str: """ @@ -1082,7 +1096,7 @@ def pip(self) -> str: # we do not use as_posix() here due to issues with windows pathlib2 implementation path = self._bin("pip") if not Path(path).exists(): - return str(self.get_embedded_wheel("pip") / "pip") + return str(self.pip_embedded) return path @property @@ -1187,7 +1201,7 @@ def get_python_implementation(self) -> str: def get_marker_env(self) -> Dict[str, Any]: raise NotImplementedError() - def get_pip_command(self) -> List[str]: + def get_pip_command(self, embedded: bool = False) -> List[str]: raise NotImplementedError() def get_supported_tags(self) -> List[Tag]: @@ -1210,14 +1224,17 @@ def is_sane(self) -> bool: def run(self, bin: str, *args: str, **kwargs: Any) -> Union[str, int]: if bin == "pip": - return self.run_pip(*args, **kwargs) + # when pip is required we need to ensure that we fallback to + # embedded pip when pip is not available in the environment + bin = self.get_pip_command() + else: + bin = self._bin(bin) - bin = self._bin(bin) cmd = [bin] + list(args) return self._run(cmd, **kwargs) def run_pip(self, *args: str, **kwargs: Any) -> Union[int, str]: - pip = self.get_pip_command() + pip = self.get_pip_command(embedded=True) cmd = pip + list(args) return self._run(cmd, **kwargs) @@ -1337,10 +1354,10 @@ def get_version_info(self) -> Tuple[int]: def get_python_implementation(self) -> str: return platform.python_implementation() - def get_pip_command(self) -> List[str]: + def get_pip_command(self, embedded: bool = False) -> List[str]: # If we're not in a venv, assume the interpreter we're running on # has a pip and use that - return [sys.executable, self.pip] + return [sys.executable, self.pip_embedded if embedded else self.pip] def get_paths(self) -> Dict[str, str]: # We can't use sysconfig.get_paths() because @@ -1444,10 +1461,10 @@ def get_version_info(self) -> Tuple[int]: def get_python_implementation(self) -> str: return self.marker_env["platform_python_implementation"] - def get_pip_command(self) -> List[str]: + def get_pip_command(self, embedded: bool = False) -> List[str]: # We're in a virtualenv that is known to be sane, # so assume that we have a functional pip - return [self._bin("python"), self.pip] + return [self._bin("python"), self.pip_embedded if embedded else self.pip] def get_supported_tags(self) -> List[Tag]: file_path = Path(packaging.tags.__file__) @@ -1559,8 +1576,8 @@ def __init__( self._execute = execute self.executed = [] - def get_pip_command(self) -> List[str]: - return [self._bin("python"), self.pip] + def get_pip_command(self, embedded: bool = False) -> List[str]: + return [self._bin("python"), self.pip_embedded if embedded else self.pip] def _run(self, cmd: List[str], **kwargs: Any) -> int: self.executed.append(cmd) diff --git a/tests/console/commands/env/test_use.py b/tests/console/commands/env/test_use.py index 93e96c02523..6b58df8f090 100644 --- a/tests/console/commands/env/test_use.py +++ b/tests/console/commands/env/test_use.py @@ -54,7 +54,13 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file( mock_build_env.assert_called_with( venv_py37, executable="python3.7", - flags={"always-copy": False, "system-site-packages": False}, + flags={ + "always-copy": False, + "system-site-packages": False, + "no-pip": False, + "no-wheel": False, + "no-setuptools": False, + }, ) envs_file = TOMLFile(venv_cache / "envs.toml") diff --git a/tests/console/commands/test_config.py b/tests/console/commands/test_config.py index 9cb692b40aa..ac2e3fbf8ed 100644 --- a/tests/console/commands/test_config.py +++ b/tests/console/commands/test_config.py @@ -32,6 +32,9 @@ def test_list_displays_default_value_if_not_set(tester, config, config_cache_dir virtualenvs.create = true virtualenvs.in-project = null virtualenvs.options.always-copy = false +virtualenvs.options.no-pip = false +virtualenvs.options.no-setuptools = false +virtualenvs.options.no-wheel = false virtualenvs.options.system-site-packages = false virtualenvs.path = {path} # {virtualenvs} """.format( @@ -54,6 +57,9 @@ def test_list_displays_set_get_setting(tester, config, config_cache_dir): virtualenvs.create = false virtualenvs.in-project = null virtualenvs.options.always-copy = false +virtualenvs.options.no-pip = false +virtualenvs.options.no-setuptools = false +virtualenvs.options.no-wheel = false virtualenvs.options.system-site-packages = false virtualenvs.path = {path} # {virtualenvs} """.format( @@ -98,6 +104,9 @@ def test_list_displays_set_get_local_setting(tester, config, config_cache_dir): virtualenvs.create = false virtualenvs.in-project = null virtualenvs.options.always-copy = false +virtualenvs.options.no-pip = false +virtualenvs.options.no-setuptools = false +virtualenvs.options.no-wheel = false virtualenvs.options.system-site-packages = false virtualenvs.path = {path} # {virtualenvs} """.format( diff --git a/tests/inspection/test_info.py b/tests/inspection/test_info.py index 215109d551f..3a15e605a6f 100644 --- a/tests/inspection/test_info.py +++ b/tests/inspection/test_info.py @@ -217,7 +217,7 @@ def test_info_setup_missing_mandatory_should_trigger_pep517( except PackageInfoError: assert spy.call_count == 3 else: - assert spy.call_count == 1 + assert spy.call_count == 2 def test_info_prefer_poetry_config_over_egg_info(): diff --git a/tests/installation/test_installer.py b/tests/installation/test_installer.py index e9bc0e4e1c9..34a861d0cd2 100644 --- a/tests/installation/test_installer.py +++ b/tests/installation/test_installer.py @@ -367,7 +367,16 @@ def test_run_install_no_dev_and_dev_only(installer, locker, repo, package, insta assert 1 == installer.executor.removals_count -def test_run_install_remove_untracked(installer, locker, repo, package, installed): +@pytest.mark.parametrize("bare_venv", [True, False]) +def test_run_install_remove_untracked( + bare_venv, installer, locker, repo, package, installed +): + installer._poetry_config._config["virtualenvs"]["options"]["no-pip"] = bare_venv + installer._poetry_config._config["virtualenvs"]["options"]["no-wheel"] = bare_venv + installer._poetry_config._config["virtualenvs"]["options"][ + "no-setuptools" + ] = bare_venv + locker.locked(True) locker.mock_lock_data( { @@ -416,10 +425,14 @@ def test_run_install_remove_untracked(installer, locker, repo, package, installe assert 0 == installer.executor.installations_count assert 0 == installer.executor.updates_count - assert 4 == installer.executor.removals_count - assert {"b", "c", "pip", "setuptools"} == set( - r.name for r in installer.executor.removals - ) + + expected_removals = {"b", "c"} + if bare_venv: + expected_removals.add("pip") + expected_removals.add("setuptools") + + assert len(expected_removals) == installer.executor.removals_count + assert expected_removals == set(r.name for r in installer.executor.removals) def test_run_whitelist_add(installer, locker, repo, package): diff --git a/tests/installation/test_installer_old.py b/tests/installation/test_installer_old.py index a8e7a9c1877..9b102bde9b3 100644 --- a/tests/installation/test_installer_old.py +++ b/tests/installation/test_installer_old.py @@ -292,7 +292,16 @@ 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): +@pytest.mark.parametrize("bare_venv", [True, False]) +def test_run_install_remove_untracked( + bare_venv, installer, locker, repo, package, installed +): + installer._poetry_config._config["virtualenvs"]["options"]["no-pip"] = bare_venv + installer._poetry_config._config["virtualenvs"]["options"]["no-wheel"] = bare_venv + installer._poetry_config._config["virtualenvs"]["options"][ + "no-setuptools" + ] = bare_venv + locker.locked(True) locker.mock_lock_data( { @@ -345,8 +354,13 @@ def test_run_install_remove_untracked(installer, locker, repo, package, installe updates = installer.installer.updates assert len(updates) == 0 + expected_removals = {"b", "c"} + if bare_venv: + expected_removals.add("pip") + expected_removals.add("setuptools") + removals = installer.installer.removals - assert set(r.name for r in removals) == {"b", "c", "pip", "setuptools"} + assert set(r.name for r in removals) == expected_removals def test_run_whitelist_add(installer, locker, repo, package): diff --git a/tests/test_factory.py b/tests/test_factory.py index 13a0437174c..94e8b617c14 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -234,6 +234,9 @@ def test_create_poetry_with_local_config(fixture_dir): assert not poetry.config.get("virtualenvs.create") assert not poetry.config.get("virtualenvs.options.always-copy") assert not poetry.config.get("virtualenvs.options.system-site-packages") + assert not poetry.config.get("virtualenvs.options.no-pip") + assert not poetry.config.get("virtualenvs.options.no-wheel") + assert not poetry.config.get("virtualenvs.options.no-setuptools") def test_create_poetry_with_plugins(mocker): diff --git a/tests/utils/test_env.py b/tests/utils/test_env.py index 0cc9c6c50bd..fc117e38639 100644 --- a/tests/utils/test_env.py +++ b/tests/utils/test_env.py @@ -159,7 +159,13 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file( m.assert_called_with( Path(tmp_dir) / "{}-py3.7".format(venv_name), executable="python3.7", - flags={"always-copy": False, "system-site-packages": False}, + flags={ + "always-copy": False, + "system-site-packages": False, + "no-pip": False, + "no-wheel": False, + "no-setuptools": False, + }, ) envs_file = TOMLFile(Path(tmp_dir) / "envs.toml") @@ -279,7 +285,13 @@ def test_activate_activates_different_virtualenv_with_envs_file( m.assert_called_with( Path(tmp_dir) / "{}-py3.6".format(venv_name), executable="python3.6", - flags={"always-copy": False, "system-site-packages": False}, + flags={ + "always-copy": False, + "system-site-packages": False, + "no-pip": False, + "no-wheel": False, + "no-setuptools": False, + }, ) assert envs_file.exists() @@ -333,7 +345,13 @@ def test_activate_activates_recreates_for_different_patch( build_venv_m.assert_called_with( Path(tmp_dir) / "{}-py3.7".format(venv_name), executable="python3.7", - flags={"always-copy": False, "system-site-packages": False}, + flags={ + "always-copy": False, + "system-site-packages": False, + "no-pip": False, + "no-wheel": False, + "no-setuptools": False, + }, ) remove_venv_m.assert_called_with(Path(tmp_dir) / "{}-py3.7".format(venv_name)) @@ -661,7 +679,13 @@ def test_create_venv_tries_to_find_a_compatible_python_executable_using_generic_ m.assert_called_with( config_virtualenvs_path / "{}-py3.7".format(venv_name), executable="python3", - flags={"always-copy": False, "system-site-packages": False}, + flags={ + "always-copy": False, + "system-site-packages": False, + "no-pip": False, + "no-wheel": False, + "no-setuptools": False, + }, ) @@ -685,7 +709,13 @@ def test_create_venv_tries_to_find_a_compatible_python_executable_using_specific m.assert_called_with( config_virtualenvs_path / "{}-py3.9".format(venv_name), executable="python3.9", - flags={"always-copy": False, "system-site-packages": False}, + flags={ + "always-copy": False, + "system-site-packages": False, + "no-pip": False, + "no-wheel": False, + "no-setuptools": False, + }, ) @@ -769,7 +799,13 @@ def test_create_venv_uses_patch_version_to_detect_compatibility( config_virtualenvs_path / "{}-py{}.{}".format(venv_name, version.major, version.minor), executable=None, - flags={"always-copy": False, "system-site-packages": False}, + flags={ + "always-copy": False, + "system-site-packages": False, + "no-pip": False, + "no-wheel": False, + "no-setuptools": False, + }, ) @@ -804,7 +840,13 @@ def test_create_venv_uses_patch_version_to_detect_compatibility_with_executable( config_virtualenvs_path / "{}-py{}.{}".format(venv_name, version.major, version.minor - 1), executable="python{}.{}".format(version.major, version.minor - 1), - flags={"always-copy": False, "system-site-packages": False}, + flags={ + "always-copy": False, + "system-site-packages": False, + "no-pip": False, + "no-wheel": False, + "no-setuptools": False, + }, ) @@ -838,7 +880,13 @@ def test_activate_with_in_project_setting_does_not_fail_if_no_venvs_dir( m.assert_called_with( poetry.file.parent / ".venv", executable="python3.7", - flags={"always-copy": False, "system-site-packages": False}, + flags={ + "always-copy": False, + "system-site-packages": False, + "no-pip": False, + "no-wheel": False, + "no-setuptools": False, + }, ) envs_file = TOMLFile(Path(tmp_dir) / "virtualenvs" / "envs.toml")