diff --git a/news/11336.feature.rst b/news/11336.feature.rst new file mode 100644 index 00000000000..3aa42b31820 --- /dev/null +++ b/news/11336.feature.rst @@ -0,0 +1 @@ +Add a ``pip install --version-selection=min`` option to select minimum versions. diff --git a/news/8085.feature.rst b/news/8085.feature.rst new file mode 100644 index 00000000000..3aa42b31820 --- /dev/null +++ b/news/8085.feature.rst @@ -0,0 +1 @@ +Add a ``pip install --version-selection=min`` option to select minimum versions. diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index 1044809f040..7f81e78909f 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -333,6 +333,7 @@ def make_resolver( ignore_requires_python: bool = False, force_reinstall: bool = False, upgrade_strategy: str = "to-satisfy-only", + version_selection: str = "max", use_pep517: Optional[bool] = None, py_version_info: Optional[Tuple[int, ...]] = None, ) -> BaseResolver: @@ -363,6 +364,7 @@ def make_resolver( ignore_requires_python=ignore_requires_python, force_reinstall=force_reinstall, upgrade_strategy=upgrade_strategy, + version_selection=version_selection, py_version_info=py_version_info, ) import pip._internal.resolution.legacy.resolver diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index 29907645c81..b9112785fa4 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -179,6 +179,21 @@ def add_options(self) -> None: ), ) + self.cmd_opts.add_option( + "--version-selection", + dest="version_selection", + default="max", + choices=["max", "min"], + help=( + "Determines how dependency versions are selected when given a range " + "[default: %default]. " + "'max' - select the maximum compatible versions available " + "within the given ranges. " + "'min' - select the minimum compatible versions available " + "within the given ranges." + ), + ) + self.cmd_opts.add_option( "--force-reinstall", dest="force_reinstall", @@ -361,6 +376,7 @@ def run(self, options: Values, args: List[str]) -> int: ignore_requires_python=options.ignore_requires_python, force_reinstall=options.force_reinstall, upgrade_strategy=upgrade_strategy, + version_selection=options.version_selection, use_pep517=options.use_pep517, ) diff --git a/src/pip/_internal/resolution/resolvelib/factory.py b/src/pip/_internal/resolution/resolvelib/factory.py index a4c24b52a1b..4137667891b 100644 --- a/src/pip/_internal/resolution/resolvelib/factory.py +++ b/src/pip/_internal/resolution/resolvelib/factory.py @@ -33,6 +33,7 @@ ) from pip._internal.index.package_finder import PackageFinder from pip._internal.metadata import BaseDistribution, get_default_environment +from pip._internal.models.candidate import InstallationCandidate from pip._internal.models.link import Link from pip._internal.models.wheel import Wheel from pip._internal.operations.prepare import RequirementPreparer @@ -231,6 +232,7 @@ def _iter_found_candidates( specifier: SpecifierSet, hashes: Hashes, prefers_installed: bool, + version_selection: str, incompatible_ids: Set[int], ) -> Iterable[Candidate]: if not ireqs: @@ -299,10 +301,47 @@ def is_pinned(specifier: SpecifierSet) -> bool: return True return False + def has_lower_bound(specifier: SpecifierSet) -> bool: + for sp in specifier: + if sp.operator in (">", ">=", "~=", "=="): + return True + return False + + def should_apply_version_selection(name: str) -> bool: + return name not in ("pip", "setuptools", "wheel") + + def apply_version_selection_to_icans( + version_selection: str, icans: List[InstallationCandidate] + ) -> List[InstallationCandidate]: + if version_selection == "max": + # PackageFinder returns earlier versions first, so we reverse. + return list(reversed(icans)) + + if version_selection == "min": + return icans + + return icans + + def check_version_selection( + version_selection: str, specifier: SpecifierSet + ) -> None: + if version_selection == "min" and not has_lower_bound(specifier): + raise InstallationError( + f"No lower bound for {name} " + "(lower bounds are required on all dependencies " + 'when using "min" version selection)' + ) + + if should_apply_version_selection(name): + check_version_selection(version_selection, specifier) + icans = apply_version_selection_to_icans(version_selection, icans) + else: + # The default behavior is to always pick the max version + icans = list(reversed(icans)) + pinned = is_pinned(specifier) - # PackageFinder returns earlier versions first, so we reverse. - for ican in reversed(icans): + for ican in icans: if not (all_yanked and pinned) and ican.link.is_yanked: continue func = functools.partial( @@ -374,6 +413,7 @@ def find_candidates( incompatibilities: Mapping[str, Iterator[Candidate]], constraint: Constraint, prefers_installed: bool, + version_selection: str, ) -> Iterable[Candidate]: # Collect basic lookup information from the requirements. explicit_candidates: Set[Candidate] = set() @@ -426,6 +466,7 @@ def find_candidates( constraint.specifier, constraint.hashes, prefers_installed, + version_selection, incompat_ids, ) diff --git a/src/pip/_internal/resolution/resolvelib/provider.py b/src/pip/_internal/resolution/resolvelib/provider.py index 6300dfc57f0..8ae8c5a863e 100644 --- a/src/pip/_internal/resolution/resolvelib/provider.py +++ b/src/pip/_internal/resolution/resolvelib/provider.py @@ -82,6 +82,7 @@ class PipProvider(_ProviderBase): are canonicalized project names. :params ignore_dependencies: Whether the user specified ``--no-deps``. :params upgrade_strategy: The user-specified upgrade strategy. + :params version_selection: The user-specified version selection strategy. :params user_requested: A set of canonicalized package names that the user supplied for pip to install/upgrade. """ @@ -92,12 +93,14 @@ def __init__( constraints: Dict[str, Constraint], ignore_dependencies: bool, upgrade_strategy: str, + version_selection: str, user_requested: Dict[str, int], ) -> None: self._factory = factory self._constraints = constraints self._ignore_dependencies = ignore_dependencies self._upgrade_strategy = upgrade_strategy + self._version_selection = version_selection self._user_requested = user_requested self._known_depths: Dict[str, float] = collections.defaultdict(lambda: math.inf) @@ -226,6 +229,7 @@ def _eligible_for_upgrade(identifier: str) -> bool: requirements=requirements, constraint=constraint, prefers_installed=(not _eligible_for_upgrade(identifier)), + version_selection=self._version_selection, incompatibilities=incompatibilities, ) diff --git a/src/pip/_internal/resolution/resolvelib/resolver.py b/src/pip/_internal/resolution/resolvelib/resolver.py index a605d6c254f..c7d39a3f26d 100644 --- a/src/pip/_internal/resolution/resolvelib/resolver.py +++ b/src/pip/_internal/resolution/resolvelib/resolver.py @@ -34,6 +34,7 @@ class Resolver(BaseResolver): _allowed_strategies = {"eager", "only-if-needed", "to-satisfy-only"} + _allowed_version_selection = {"max", "min"} def __init__( self, @@ -47,10 +48,12 @@ def __init__( ignore_requires_python: bool, force_reinstall: bool, upgrade_strategy: str, + version_selection: str, py_version_info: Optional[Tuple[int, ...]] = None, ): super().__init__() assert upgrade_strategy in self._allowed_strategies + assert version_selection in self._allowed_version_selection self.factory = Factory( finder=finder, @@ -65,6 +68,7 @@ def __init__( ) self.ignore_dependencies = ignore_dependencies self.upgrade_strategy = upgrade_strategy + self.version_selection = version_selection self._result: Optional[Result] = None def resolve( @@ -76,6 +80,7 @@ def resolve( constraints=collected.constraints, ignore_dependencies=self.ignore_dependencies, upgrade_strategy=self.upgrade_strategy, + version_selection=self.version_selection, user_requested=collected.user_requested, ) if "PIP_RESOLVER_DEBUG" in os.environ: diff --git a/tests/functional/test_install_reqs.py b/tests/functional/test_install_reqs.py index c5fc11dd7a5..47768c59d3c 100644 --- a/tests/functional/test_install_reqs.py +++ b/tests/functional/test_install_reqs.py @@ -278,6 +278,49 @@ def test_install_collected_dependencies_first(script: PipTestEnvironment) -> Non assert text.endswith("toporequires2") +def test_install_version_selection_default(script: PipTestEnvironment) -> None: + result = script.pip_install_local("simple") + assert "Successfully installed simple-3.0" in str(result) + + +def test_install_version_selection_max(script: PipTestEnvironment) -> None: + result = script.pip_install_local("simple", "--version-selection=max") + assert "Successfully installed simple-3.0" in str(result) + + +def test_install_version_selection_min_lower_bound_required( + script: PipTestEnvironment, +) -> None: + result = script.pip_install_local( + "simple", + "--version-selection=min", + allow_error=True, + ) + assert result.returncode == 1 + assert ( + "ERROR: No lower bound for simple " + "(lower bounds are required on all dependencies " + 'when using "min" version selection)' in str(result) + ) + + +def test_install_version_selection_min(script: PipTestEnvironment) -> None: + result = script.pip_install_local("simple>=0.0", "--version-selection=min") + assert "Successfully installed simple-1.0" in str(result) + + +def test_install_version_selection_default_transitive( + script: PipTestEnvironment, +) -> None: + result = script.pip_install_local("require_simple") + assert "Successfully installed require_simple-1.0 simple-3.0" in str(result) + + +def test_install_version_selection_min_transitive(script: PipTestEnvironment) -> None: + result = script.pip_install_local("require_simple>=0.0", "--version-selection=min") + assert "Successfully installed require_simple-1.0 simple-2.0" in str(result) + + @pytest.mark.network def test_install_local_editable_with_subdirectory(script: PipTestEnvironment) -> None: version_pkg_path = _create_test_package_with_subdirectory(script, "version_subdir") diff --git a/tests/unit/resolution_resolvelib/conftest.py b/tests/unit/resolution_resolvelib/conftest.py index 9ef9f8c5c18..c1d1dc8a08c 100644 --- a/tests/unit/resolution_resolvelib/conftest.py +++ b/tests/unit/resolution_resolvelib/conftest.py @@ -74,5 +74,6 @@ def provider(factory: Factory) -> Iterator[PipProvider]: constraints={}, ignore_dependencies=False, upgrade_strategy="to-satisfy-only", + version_selection="max", user_requested={}, ) diff --git a/tests/unit/resolution_resolvelib/test_provider.py b/tests/unit/resolution_resolvelib/test_provider.py index ab1dc74caa3..20ef3b9aecf 100644 --- a/tests/unit/resolution_resolvelib/test_provider.py +++ b/tests/unit/resolution_resolvelib/test_provider.py @@ -35,6 +35,7 @@ def test_provider_known_depths(factory: Factory) -> None: constraints={}, ignore_dependencies=False, upgrade_strategy="to-satisfy-only", + version_selection="max", user_requested={root_requirement_name: 0}, ) diff --git a/tests/unit/resolution_resolvelib/test_requirement.py b/tests/unit/resolution_resolvelib/test_requirement.py index 6864e70ea0a..9d928abcba0 100644 --- a/tests/unit/resolution_resolvelib/test_requirement.py +++ b/tests/unit/resolution_resolvelib/test_requirement.py @@ -3,6 +3,7 @@ from typing import Iterator, List, Tuple import pytest +from pip._vendor.packaging.specifiers import SpecifierSet from pip._vendor.resolvelib import BaseReporter, Resolver from pip._internal.resolution.resolvelib.base import Candidate, Constraint, Requirement @@ -79,6 +80,7 @@ def test_new_resolver_correct_number_of_matches( {}, Constraint.empty(), prefers_installed=False, + version_selection="max", ) assert sum(1 for _ in matches) == match_count @@ -96,11 +98,46 @@ def test_new_resolver_candidates_match_requirement( {}, Constraint.empty(), prefers_installed=False, + version_selection="max", ) + previous_candidate = None for c in candidates: assert isinstance(c, Candidate) assert req.is_satisfied_by(c) + if previous_candidate is not None: + assert c.version < previous_candidate.version + + previous_candidate = c + + +def test_new_resolver_candidates_version_selection_min( + test_cases: List[Tuple[str, str, int]], factory: Factory +) -> None: + """Candidates returned from find_candidates should satisfy the requirement""" + for spec, _, _ in test_cases: + req = factory.make_requirement_from_spec(spec, comes_from=None) + assert req is not None + constraint = Constraint.empty() + constraint.specifier = SpecifierSet(">=0.0") + candidates = factory.find_candidates( + req.name, + {req.name: [req]}, + {}, + constraint, + prefers_installed=False, + version_selection="min", + ) + previous_candidate = None + for c in candidates: + assert isinstance(c, Candidate) + assert req.is_satisfied_by(c) + + if previous_candidate is not None: + assert c.version > previous_candidate.version + + previous_candidate = c + def test_new_resolver_full_resolve(factory: Factory, provider: PipProvider) -> None: """A very basic full resolve""" diff --git a/tests/unit/resolution_resolvelib/test_resolver.py b/tests/unit/resolution_resolvelib/test_resolver.py index 87c2b5f3533..f2dac99f0fc 100644 --- a/tests/unit/resolution_resolvelib/test_resolver.py +++ b/tests/unit/resolution_resolvelib/test_resolver.py @@ -29,6 +29,7 @@ def resolver(preparer: RequirementPreparer, finder: PackageFinder) -> Resolver: ignore_requires_python=False, force_reinstall=False, upgrade_strategy="to-satisfy-only", + version_selection="max", ) return resolver