From 45e6878b5ddb17dc2654f92575e6fb26ad578d48 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 14 Nov 2025 22:09:33 -0500 Subject: [PATCH 1/3] Final and pre-release control options for package selection --- docs/html/user_guide.rst | 49 +++++++++ src/pip/_internal/build_env.py | 8 +- src/pip/_internal/cli/cmdoptions.py | 81 +++++++++++++++ src/pip/_internal/cli/req_command.py | 2 +- src/pip/_internal/commands/download.py | 3 + src/pip/_internal/commands/index.py | 6 +- src/pip/_internal/commands/install.py | 3 + src/pip/_internal/commands/list.py | 6 +- src/pip/_internal/commands/lock.py | 3 + src/pip/_internal/commands/wheel.py | 3 + src/pip/_internal/index/package_finder.py | 30 +++--- src/pip/_internal/models/release_control.py | 105 ++++++++++++++++++++ src/pip/_internal/models/selection_prefs.py | 9 +- src/pip/_internal/req/req_file.py | 11 +- src/pip/_internal/self_outdated_check.py | 3 +- 15 files changed, 300 insertions(+), 22 deletions(-) create mode 100644 src/pip/_internal/models/release_control.py diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst index 8711f0f9126..b1759c1680e 100644 --- a/docs/html/user_guide.rst +++ b/docs/html/user_guide.rst @@ -296,6 +296,55 @@ Example build constraints file (``build-constraints.txt``): # Pin Cython for packages that use it to build cython==0.29.24 +Controlling Pre-release Installation +===================================== + +By default, pip installs stable versions of packages, unless their specifier includes +a pre-release version (e.g., ``SomePackage>=1.0a1``) or if there are no stable versions +available that satisfy the requirement. The ``--all-releases``and ``--only-final`` +options provide per-package control over pre-release selection. + +Use ``--all-releases`` to allow pre-releases for specific packages: + +.. tab:: Unix/macOS + + .. code-block:: shell + + python -m pip install --all-releases=DependencyPackage SomePackage + python -m pip install --all-releases=:all: SomePackage + +.. tab:: Windows + + .. code-block:: shell + + py -m pip install --all-releases=DependencyPackage SomePackage + py -m pip install --all-releases=:all: SomePackage + +Use ``--only-final`` to explicitly disable pre-releases for specific packages: + +.. tab:: Unix/macOS + + .. code-block:: shell + + python -m pip install --only-final=DependencyPackage SomePackage + python -m pip install --only-final=:all: SomePackage + +.. tab:: Windows + + .. code-block:: shell + + py -m pip install --only-final=DependencyPackage SomePackage + py -m pip install --only-final=:all: SomePackage + +Both options accept ``:all:`` to apply to all packages, ``:none:`` to clear +the setting, or comma-separated package names. Package-specific settings +override ``:all:``. These options can also be used in requirements files. + +.. note:: + + The ``--pre`` flag is equivalent to ``--all-releases :all:`` but cannot + be combined with ``--all-releases`` or ``--only-final``. + .. _`Dependency Groups`: diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index f28d862f279..a5a21e71b0c 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -188,6 +188,12 @@ def install( ) ) + if finder.release_control is not None: + # Use ordered args to preserve the user's original command-line order + # This is important because later flags can override earlier ones + for attr_name, value in finder.release_control.get_ordered_args(): + args.extend(("--" + attr_name.replace("_", "-"), value)) + index_urls = finder.index_urls if index_urls: args.extend(["-i", index_urls[0]]) @@ -206,8 +212,6 @@ def install( args.extend(["--cert", finder.custom_cert]) if finder.client_cert: args.extend(["--client-cert", finder.client_cert]) - if finder.allow_all_prereleases: - args.append("--pre") if finder.prefer_binary: args.append("--prefer-binary") diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index a4737757b23..f06ea8731c8 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -27,6 +27,7 @@ from pip._internal.locations import USER_CACHE_DIR, get_src_prefix from pip._internal.models.format_control import FormatControl from pip._internal.models.index import PyPI +from pip._internal.models.release_control import ReleaseControl from pip._internal.models.target_python import TargetPython from pip._internal.utils.hashes import STRONG_HASHES from pip._internal.utils.misc import strtobool @@ -580,6 +581,86 @@ def only_binary() -> Option: ) +def _get_release_control(values: Values, option: Option) -> Any: + """Get a release_control object.""" + return getattr(values, option.dest) + + +def _handle_all_releases( + option: Option, opt_str: str, value: str, parser: OptionParser +) -> None: + existing = _get_release_control(parser.values, option) + existing.handle_mutual_excludes( + value, + existing.all_releases, + existing.only_final, + "all_releases", + ) + + +def _handle_only_final( + option: Option, opt_str: str, value: str, parser: OptionParser +) -> None: + existing = _get_release_control(parser.values, option) + existing.handle_mutual_excludes( + value, + existing.only_final, + existing.all_releases, + "only_final", + ) + + +def all_releases() -> Option: + release_control = ReleaseControl(set(), set()) + return Option( + "--all-releases", + dest="release_control", + action="callback", + callback=_handle_all_releases, + type="str", + default=release_control, + help="Allow all release types (including pre-releases) for a package. " + "Can be supplied multiple times, and each time adds to the existing " + 'value. Accepts either ":all:" to allow pre-releases for all ' + 'packages, ":none:" to empty the set (notice the colons), or one or ' + "more package names with commas between them (no colons). Cannot be " + "used with --pre.", + ) + + +def only_final() -> Option: + release_control = ReleaseControl(set(), set()) + return Option( + "--only-final", + dest="release_control", + action="callback", + callback=_handle_only_final, + type="str", + default=release_control, + help="Only allow final releases (no pre-releases) for a package. Can be " + "supplied multiple times, and each time adds to the existing value. " + 'Accepts either ":all:" to disable pre-releases for all packages, ' + '":none:" to empty the set, or one or more package names with commas ' + "between them. Cannot be used with --pre.", + ) + + +def check_release_control_exclusive(options: Values) -> None: + """ + Raise an error if --pre is used with --all-releases or --only-final, + and transform --pre into --all-releases :all: if used alone. + """ + if not hasattr(options, "pre") or not options.pre: + return + + release_control = options.release_control + if release_control.all_releases or release_control.only_final: + raise CommandError("--pre cannot be used with --all-releases or --only-final.") + + # Transform --pre into --all-releases :all: + release_control.all_releases.add(":all:") + + platforms: Callable[..., Option] = partial( Option, "--platform", diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index f6d7f81e82e..2bef2acf238 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -359,7 +359,7 @@ def _build_package_finder( selection_prefs = SelectionPreferences( allow_yanked=True, format_control=options.format_control, - allow_all_prereleases=options.pre, + release_control=options.release_control, prefer_binary=options.prefer_binary, ignore_requires_python=ignore_requires_python, ) diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index 903917b9ba2..6a887af5667 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -43,6 +43,8 @@ def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.prefer_binary()) self.cmd_opts.add_option(cmdoptions.src()) self.cmd_opts.add_option(cmdoptions.pre()) + self.cmd_opts.add_option(cmdoptions.all_releases()) + self.cmd_opts.add_option(cmdoptions.only_final()) self.cmd_opts.add_option(cmdoptions.require_hashes()) self.cmd_opts.add_option(cmdoptions.progress_bar()) self.cmd_opts.add_option(cmdoptions.no_build_isolation()) @@ -80,6 +82,7 @@ def run(self, options: Values, args: list[str]) -> int: cmdoptions.check_dist_restriction(options) cmdoptions.check_build_constraints(options) + cmdoptions.check_release_control_exclusive(options) options.download_dir = normalize_path(options.download_dir) ensure_dir(options.download_dir) diff --git a/src/pip/_internal/commands/index.py b/src/pip/_internal/commands/index.py index ecac99888db..91055a573e0 100644 --- a/src/pip/_internal/commands/index.py +++ b/src/pip/_internal/commands/index.py @@ -41,6 +41,8 @@ def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.ignore_requires_python()) self.cmd_opts.add_option(cmdoptions.pre()) + self.cmd_opts.add_option(cmdoptions.all_releases()) + self.cmd_opts.add_option(cmdoptions.only_final()) self.cmd_opts.add_option(cmdoptions.json()) self.cmd_opts.add_option(cmdoptions.no_binary()) self.cmd_opts.add_option(cmdoptions.only_binary()) @@ -59,6 +61,8 @@ def handler_map(self) -> dict[str, Callable[[Values, list[str]], None]]: } def run(self, options: Values, args: list[str]) -> int: + cmdoptions.check_release_control_exclusive(options) + handler_map = self.handler_map() # Determine action @@ -95,7 +99,7 @@ def _build_package_finder( # Pass allow_yanked=False to ignore yanked versions. selection_prefs = SelectionPreferences( allow_yanked=False, - allow_all_prereleases=options.pre, + release_control=options.release_control, ignore_requires_python=ignore_requires_python, ) diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index b16b8e3dbcb..7f46bf0db40 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -89,6 +89,8 @@ def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.build_constraints()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.pre()) + self.cmd_opts.add_option(cmdoptions.all_releases()) + self.cmd_opts.add_option(cmdoptions.only_final()) self.cmd_opts.add_option(cmdoptions.editable()) self.cmd_opts.add_option( @@ -303,6 +305,7 @@ def run(self, options: Values, args: list[str]) -> int: cmdoptions.check_build_constraints(options) cmdoptions.check_dist_restriction(options, check_target=True) + cmdoptions.check_release_control_exclusive(options) logger.verbose("Using %s", get_pip_version()) options.use_user_site = decide_user_install( diff --git a/src/pip/_internal/commands/list.py b/src/pip/_internal/commands/list.py index ad27e45ce93..76b2025b48b 100644 --- a/src/pip/_internal/commands/list.py +++ b/src/pip/_internal/commands/list.py @@ -99,6 +99,8 @@ def add_options(self) -> None: "pip only finds stable versions." ), ) + self.cmd_opts.add_option(cmdoptions.all_releases()) + self.cmd_opts.add_option(cmdoptions.only_final()) self.cmd_opts.add_option( "--format", @@ -157,7 +159,7 @@ def _build_package_finder( # Pass allow_yanked=False to ignore yanked versions. selection_prefs = SelectionPreferences( allow_yanked=False, - allow_all_prereleases=options.pre, + release_control=options.release_control, ) return PackageFinder.create( @@ -166,6 +168,8 @@ def _build_package_finder( ) def run(self, options: Values, args: list[str]) -> int: + cmdoptions.check_release_control_exclusive(options) + if options.outdated and options.uptodate: raise CommandError("Options --outdated and --uptodate cannot be combined.") diff --git a/src/pip/_internal/commands/lock.py b/src/pip/_internal/commands/lock.py index b02fb95dacf..07355f7f31c 100644 --- a/src/pip/_internal/commands/lock.py +++ b/src/pip/_internal/commands/lock.py @@ -59,6 +59,8 @@ def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.build_constraints()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.pre()) + self.cmd_opts.add_option(cmdoptions.all_releases()) + self.cmd_opts.add_option(cmdoptions.only_final()) self.cmd_opts.add_option(cmdoptions.editable()) @@ -96,6 +98,7 @@ def run(self, options: Values, args: list[str]) -> int: ) cmdoptions.check_build_constraints(options) + cmdoptions.check_release_control_exclusive(options) session = self.get_default_session(options) diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py index 28503940d46..e4917da955c 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -85,6 +85,8 @@ def add_options(self) -> None: "pip only finds stable versions." ), ) + self.cmd_opts.add_option(cmdoptions.all_releases()) + self.cmd_opts.add_option(cmdoptions.only_final()) self.cmd_opts.add_option(cmdoptions.require_hashes()) @@ -99,6 +101,7 @@ def add_options(self) -> None: @with_cleanup def run(self, options: Values, args: list[str]) -> int: cmdoptions.check_build_constraints(options) + cmdoptions.check_release_control_exclusive(options) session = self.get_default_session(options) diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py index ae6f8962f6f..e502e06f651 100644 --- a/src/pip/_internal/index/package_finder.py +++ b/src/pip/_internal/index/package_finder.py @@ -31,6 +31,7 @@ from pip._internal.models.candidate import InstallationCandidate from pip._internal.models.format_control import FormatControl from pip._internal.models.link import Link +from pip._internal.models.release_control import ReleaseControl from pip._internal.models.search_scope import SearchScope from pip._internal.models.selection_prefs import SelectionPreferences from pip._internal.models.target_python import TargetPython @@ -350,7 +351,7 @@ class CandidatePreferences: """ prefer_binary: bool = False - allow_all_prereleases: bool = False + release_control: ReleaseControl | None = None @dataclass(frozen=True) @@ -391,7 +392,7 @@ def create( project_name: str, target_python: TargetPython | None = None, prefer_binary: bool = False, - allow_all_prereleases: bool = False, + release_control: ReleaseControl | None = None, specifier: specifiers.BaseSpecifier | None = None, hashes: Hashes | None = None, ) -> CandidateEvaluator: @@ -417,7 +418,7 @@ def create( supported_tags=supported_tags, specifier=specifier, prefer_binary=prefer_binary, - allow_all_prereleases=allow_all_prereleases, + release_control=release_control, hashes=hashes, ) @@ -427,14 +428,14 @@ def __init__( supported_tags: list[Tag], specifier: specifiers.BaseSpecifier, prefer_binary: bool = False, - allow_all_prereleases: bool = False, + release_control: ReleaseControl | None = None, hashes: Hashes | None = None, ) -> None: """ :param supported_tags: The PEP 425 tags supported by the target Python in order of preference (most preferred first). """ - self._allow_all_prereleases = allow_all_prereleases + self._release_control = release_control self._hashes = hashes self._prefer_binary = prefer_binary self._project_name = project_name @@ -455,7 +456,12 @@ def get_applicable_candidates( Return the applicable candidates from a list of candidates. """ # Using None infers from the specifier instead. - allow_prereleases = self._allow_all_prereleases or None + if self._release_control is not None: + allow_prereleases = self._release_control.allows_prereleases( + canonicalize_name(self._project_name) + ) + else: + allow_prereleases = None specifier = self._specifier # We turn the version object into a str here because otherwise @@ -651,7 +657,7 @@ def create( candidate_prefs = CandidatePreferences( prefer_binary=selection_prefs.prefer_binary, - allow_all_prereleases=selection_prefs.allow_all_prereleases, + release_control=selection_prefs.release_control, ) return cls( @@ -707,11 +713,11 @@ def client_cert(self) -> str | None: return cert @property - def allow_all_prereleases(self) -> bool: - return self._candidate_prefs.allow_all_prereleases + def release_control(self) -> ReleaseControl | None: + return self._candidate_prefs.release_control - def set_allow_all_prereleases(self) -> None: - self._candidate_prefs.allow_all_prereleases = True + def set_release_control(self, release_control: ReleaseControl) -> None: + self._candidate_prefs.release_control = release_control @property def prefer_binary(self) -> bool: @@ -890,7 +896,7 @@ def make_candidate_evaluator( project_name=project_name, target_python=self._target_python, prefer_binary=candidate_prefs.prefer_binary, - allow_all_prereleases=candidate_prefs.allow_all_prereleases, + release_control=candidate_prefs.release_control, specifier=specifier, hashes=hashes, ) diff --git a/src/pip/_internal/models/release_control.py b/src/pip/_internal/models/release_control.py new file mode 100644 index 00000000000..aa11a458cf9 --- /dev/null +++ b/src/pip/_internal/models/release_control.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from pip._vendor.packaging.utils import canonicalize_name + +from pip._internal.exceptions import CommandError + + +class ReleaseControl: + """Helper for managing which release types can be installed.""" + + __slots__ = ["all_releases", "only_final", "_order"] + + def __init__( + self, + all_releases: set[str] | None = None, + only_final: set[str] | None = None, + ) -> None: + if all_releases is None: + all_releases = set() + if only_final is None: + only_final = set() + + self.all_releases = all_releases + self.only_final = only_final + # Track the order of arguments as (attribute_name, value) tuples + # This is used to reconstruct arguments in the correct order for subprocesses + self._order: list[tuple[str, str]] = [] + + def __eq__(self, other: object) -> bool: + if not isinstance(other, self.__class__): + return NotImplemented + + # Only compare all_releases and only_final, not _order + # The _order list is for internal tracking and reconstruction + return ( + self.all_releases == other.all_releases + and self.only_final == other.only_final + ) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.all_releases}, {self.only_final})" + + def handle_mutual_excludes( + self, value: str, target: set[str], other: set[str], attr_name: str + ) -> None: + if value.startswith("-"): + raise CommandError( + "--all-releases / --only-final option requires 1 argument." + ) + new = value.split(",") + while ":all:" in new: + other.clear() + target.clear() + target.add(":all:") + # Track :all: in order + self._order.append((attr_name, ":all:")) + del new[: new.index(":all:") + 1] + # Without a none, we want to discard everything as :all: covers it + if ":none:" not in new: + return + for name in new: + if name == ":none:": + target.clear() + # Track :none: in order + self._order.append((attr_name, ":none:")) + continue + name = canonicalize_name(name) + other.discard(name) + target.add(name) + # Track package-specific setting in order + self._order.append((attr_name, name)) + + def get_ordered_args(self) -> list[tuple[str, str]]: + """ + Get ordered list of (flag_name, value) tuples for reconstructing CLI args. + + Returns: + List of tuples where each tuple is (attribute_name, value). + The attribute_name is either 'all_releases' or 'only_final'. + + Example: + [("all_releases", ":all:"), ("only_final", "simple")] + would be reconstructed as: + ["--all-releases", ":all:", "--only-final", "simple"] + """ + return self._order[:] + + def allows_prereleases(self, canonical_name: str) -> bool | None: + """ + Determine if pre-releases are allowed for a package. + + Returns: + True: Pre-releases are allowed (package in all_releases) + False: Only final releases allowed (package in only_final) + None: No specific setting, use default behavior + """ + if canonical_name in self.all_releases: + return True + elif canonical_name in self.only_final: + return False + elif ":all:" in self.all_releases: + return True + elif ":all:" in self.only_final: + return False + return None diff --git a/src/pip/_internal/models/selection_prefs.py b/src/pip/_internal/models/selection_prefs.py index 8d5b42dfa31..04ef63ab543 100644 --- a/src/pip/_internal/models/selection_prefs.py +++ b/src/pip/_internal/models/selection_prefs.py @@ -1,6 +1,7 @@ from __future__ import annotations from pip._internal.models.format_control import FormatControl +from pip._internal.models.release_control import ReleaseControl # TODO: This needs Python 3.10's improved slots support for dataclasses @@ -13,7 +14,7 @@ class SelectionPreferences: __slots__ = [ "allow_yanked", - "allow_all_prereleases", + "release_control", "format_control", "prefer_binary", "ignore_requires_python", @@ -26,7 +27,7 @@ class SelectionPreferences: def __init__( self, allow_yanked: bool, - allow_all_prereleases: bool = False, + release_control: ReleaseControl | None = None, format_control: FormatControl | None = None, prefer_binary: bool = False, ignore_requires_python: bool | None = None, @@ -35,6 +36,8 @@ def __init__( :param allow_yanked: Whether files marked as yanked (in the sense of PEP 592) are permitted to be candidates for install. + :param release_control: A ReleaseControl object or None. Used to control + whether pre-releases are allowed for specific packages. :param format_control: A FormatControl object or None. Used to control the selection of source packages / binary packages when consulting the index and links. @@ -47,7 +50,7 @@ def __init__( ignore_requires_python = False self.allow_yanked = allow_yanked - self.allow_all_prereleases = allow_all_prereleases + self.release_control = release_control self.format_control = format_control self.prefer_binary = prefer_binary self.ignore_requires_python = ignore_requires_python diff --git a/src/pip/_internal/req/req_file.py b/src/pip/_internal/req/req_file.py index a4f54b438f2..10f138c895b 100644 --- a/src/pip/_internal/req/req_file.py +++ b/src/pip/_internal/req/req_file.py @@ -25,6 +25,7 @@ from pip._internal.cli import cmdoptions from pip._internal.exceptions import InstallationError, RequirementsFileParseError +from pip._internal.models.release_control import ReleaseControl from pip._internal.models.search_scope import SearchScope if TYPE_CHECKING: @@ -59,6 +60,8 @@ cmdoptions.prefer_binary, cmdoptions.require_hashes, cmdoptions.pre, + cmdoptions.all_releases, + cmdoptions.only_final, cmdoptions.trusted_host, cmdoptions.use_new_feature, ] @@ -273,8 +276,14 @@ def handle_option_line( ) finder.search_scope = search_scope + # Transform --pre into --all-releases :all: if opts.pre: - finder.set_allow_all_prereleases() + if not opts.release_control: + opts.release_control = ReleaseControl() + opts.release_control.all_releases.add(":all:") + + if opts.release_control: + finder.set_release_control(opts.release_control) if opts.prefer_binary: finder.set_prefer_binary() diff --git a/src/pip/_internal/self_outdated_check.py b/src/pip/_internal/self_outdated_check.py index db015b9df2b..bf49f62bf9a 100644 --- a/src/pip/_internal/self_outdated_check.py +++ b/src/pip/_internal/self_outdated_check.py @@ -20,6 +20,7 @@ from pip._internal.index.collector import LinkCollector from pip._internal.index.package_finder import PackageFinder from pip._internal.metadata import get_default_environment +from pip._internal.models.release_control import ReleaseControl from pip._internal.models.selection_prefs import SelectionPreferences from pip._internal.network.session import PipSession from pip._internal.utils.compat import WINDOWS @@ -187,7 +188,7 @@ def _get_current_remote_pip_version( # yanked version. selection_prefs = SelectionPreferences( allow_yanked=False, - allow_all_prereleases=False, # Explicitly set to False + release_control=ReleaseControl(only_final={"pip"}), ) finder = PackageFinder.create( From a7d88ecb98a907bac51004fb30ebcfabbcba4760 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 14 Nov 2025 22:09:43 -0500 Subject: [PATCH 2/3] Add tests for final and prerelease control --- .../test_install_release_control.py | 238 ++++++++++++++++++ tests/lib/__init__.py | 9 +- tests/unit/test_index.py | 31 ++- tests/unit/test_options.py | 64 ++++- tests/unit/test_release_control.py | 188 ++++++++++++++ tests/unit/test_req_file.py | 34 ++- 6 files changed, 555 insertions(+), 9 deletions(-) create mode 100644 tests/functional/test_install_release_control.py create mode 100644 tests/unit/test_release_control.py diff --git a/tests/functional/test_install_release_control.py b/tests/functional/test_install_release_control.py new file mode 100644 index 00000000000..69f89987a01 --- /dev/null +++ b/tests/functional/test_install_release_control.py @@ -0,0 +1,238 @@ +"""Tests for --all-releases and --only-final release control flags.""" + +from __future__ import annotations + +from tests.lib import PipTestEnvironment, create_basic_wheel_for_package + + +def test_all_releases_allows_prereleases(script: PipTestEnvironment) -> None: + """Test that --all-releases :all: allows installing pre-release versions.""" + pkg_path = create_basic_wheel_for_package(script, "simple", "1.0a1") + + result = script.pip( + "install", + "--all-releases=:all:", + "--no-cache-dir", + "--no-index", + "-f", + pkg_path.parent, + "simple==1.0a1", + ) + result.assert_installed("simple", editable=False) + + +def test_all_releases_package_specific(script: PipTestEnvironment) -> None: + """Test --all-releases with package allows pre-releases for that package.""" + pkg_path = create_basic_wheel_for_package(script, "simple", "1.0a1") + + result = script.pip( + "install", + "--all-releases=simple", + "--no-cache-dir", + "--no-index", + "-f", + pkg_path.parent, + "simple==1.0a1", + ) + result.assert_installed("simple", editable=False) + + +def test_only_final_blocks_prereleases(script: PipTestEnvironment) -> None: + """Test that --only-final :all: blocks pre-release versions.""" + pkg_path = create_basic_wheel_for_package(script, "simple", "1.0a1") + + result = script.pip( + "install", + "--only-final=:all:", + "--no-cache-dir", + "--no-index", + "-f", + pkg_path.parent, + "simple==1.0a1", + expect_error=True, + ) + assert "Could not find a version that satisfies the requirement" in result.stderr + + +def test_only_final_package_specific(script: PipTestEnvironment) -> None: + """Test --only-final with package blocks pre-releases for that package.""" + pkg_path = create_basic_wheel_for_package(script, "simple", "1.0a1") + + result = script.pip( + "install", + "--only-final=simple", + "--no-cache-dir", + "--no-index", + "-f", + pkg_path.parent, + "simple==1.0a1", + expect_error=True, + ) + assert "Could not find a version that satisfies the requirement" in result.stderr + + +def test_pre_transforms_to_all_releases(script: PipTestEnvironment) -> None: + """Test that --pre is equivalent to --all-releases :all:.""" + pkg_path = create_basic_wheel_for_package(script, "simple", "1.0a1") + + result = script.pip( + "install", + "--pre", + "--no-cache-dir", + "--no-index", + "-f", + pkg_path.parent, + "simple==1.0a1", + ) + result.assert_installed("simple", editable=False) + + +def test_pre_with_all_releases_fails(script: PipTestEnvironment) -> None: + """Test that --pre cannot be used with --all-releases.""" + result = script.pip( + "install", + "--pre", + "--all-releases=pkg1", + "dummy", + expect_error=True, + ) + assert "--pre cannot be used with --all-releases or --only-final" in result.stderr + + +def test_pre_with_only_final_fails(script: PipTestEnvironment) -> None: + """Test that --pre cannot be used with --only-final.""" + result = script.pip( + "install", + "--pre", + "--only-final=pkg1", + "dummy", + expect_error=True, + ) + assert "--pre cannot be used with --all-releases or --only-final" in result.stderr + + +def test_all_releases_none(script: PipTestEnvironment) -> None: + """Test that --all-releases :none: empties the set.""" + # Create both a prerelease and a final version + pre_pkg = create_basic_wheel_for_package(script, "simple", "1.0a1") + create_basic_wheel_for_package(script, "simple", "1.0") + + # Without specifying exact version, it should install the final version + # because :none: cleared the :all: setting + script.pip( + "install", + "--all-releases=:all:", + "--all-releases=:none:", + "--no-cache-dir", + "--no-index", + "-f", + pre_pkg.parent, + "simple", + ) + script.assert_installed(simple="1.0") + + +def test_package_specific_overrides_all(script: PipTestEnvironment) -> None: + """Test that package-specific --only-final overrides :all: --all-releases.""" + pkg_path = create_basic_wheel_for_package(script, "simple", "1.0a1") + + # Allow pre-releases for all packages + result = script.pip( + "install", + "--all-releases=:all:", + "--only-final=simple", # But not for 'simple' + "--no-cache-dir", + "--no-index", + "-f", + pkg_path.parent, + "simple==1.0a1", + expect_error=True, + ) + assert "Could not find a version that satisfies the requirement" in result.stderr + + +def test_requirements_file_all_releases(script: PipTestEnvironment) -> None: + """Test --all-releases in requirements file.""" + pkg_path = create_basic_wheel_for_package(script, "simple", "1.0a1") + + requirements = script.scratch_path / "requirements.txt" + requirements.write_text("--all-releases :all:\nsimple==1.0a1\n") + + result = script.pip( + "install", + "--no-cache-dir", + "--no-index", + "-f", + pkg_path.parent, + "-r", + requirements, + ) + result.assert_installed("simple", editable=False) + + +def test_requirements_file_only_final(script: PipTestEnvironment) -> None: + """Test --only-final in requirements file.""" + # Create both a prerelease and a final version + pre_pkg = create_basic_wheel_for_package(script, "simple", "1.0a1") + create_basic_wheel_for_package(script, "simple", "1.0") + + requirements = script.scratch_path / "requirements.txt" + requirements.write_text("--only-final :all:\nsimple\n") # No specific version + + script.pip( + "install", + "--no-cache-dir", + "--no-index", + "-f", + pre_pkg.parent, + "-r", + requirements, + ) + # Should install final version, not prerelease + script.assert_installed(simple="1.0") + + +def test_order_only_final_then_all_releases(script: PipTestEnvironment) -> None: + """Test critical case: --only-final=:all: --all-releases=. + + This tests that argument order is preserved when passed to build backends. + When the user specifies --only-final=:all: --all-releases=simple, they + expect 'simple' to allow prereleases (later flag overrides). + """ + pkg_path = create_basic_wheel_for_package(script, "simple", "1.0a1") + + # This should allow prereleases for 'simple' because --all-releases comes after + result = script.pip( + "install", + "--only-final=:all:", + "--all-releases=simple", + "--no-cache-dir", + "--no-index", + "-f", + pkg_path.parent, + "simple==1.0a1", + ) + result.assert_installed("simple", editable=False) + + +def test_order_all_releases_then_only_final(script: PipTestEnvironment) -> None: + """Test reverse case: --all-releases=:all: --only-final=. + + This tests that when the user specifies --all-releases=:all: --only-final=simple, + 'simple' should only allow final releases (later flag overrides). + """ + pkg_path = create_basic_wheel_for_package(script, "simple", "1.0a1") + + # This should block prereleases for 'simple' because --only-final comes after + result = script.pip( + "install", + "--all-releases=:all:", + "--only-final=simple", + "--no-cache-dir", + "--no-index", + "-f", + pkg_path.parent, + "simple==1.0a1", + expect_error=True, + ) + assert "Could not find a version that satisfies the requirement" in result.stderr diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py index 1b9d1b96350..610cf137f29 100644 --- a/tests/lib/__init__.py +++ b/tests/lib/__init__.py @@ -30,6 +30,7 @@ from pip._internal.index.package_finder import PackageFinder from pip._internal.locations import get_major_minor_version from pip._internal.models.direct_url import DIRECT_URL_METADATA_NAME, DirectUrl +from pip._internal.models.release_control import ReleaseControl from pip._internal.models.search_scope import SearchScope from pip._internal.models.selection_prefs import SelectionPreferences from pip._internal.models.target_python import TargetPython @@ -113,9 +114,15 @@ def make_test_finder( index_urls=index_urls, session=session, ) + + # Convert allow_all_prereleases to release_control + release_control = ReleaseControl() + if allow_all_prereleases: + release_control.all_releases.add(":all:") + selection_prefs = SelectionPreferences( allow_yanked=True, - allow_all_prereleases=allow_all_prereleases, + release_control=release_control, ) return PackageFinder.create( diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py index e571b441f9d..42e22dac8e9 100644 --- a/tests/unit/test_index.py +++ b/tests/unit/test_index.py @@ -22,6 +22,7 @@ filter_unallowed_hashes, ) from pip._internal.models.link import Link +from pip._internal.models.release_control import ReleaseControl from pip._internal.models.search_scope import SearchScope from pip._internal.models.selection_prefs import SelectionPreferences from pip._internal.models.target_python import TargetPython @@ -379,14 +380,20 @@ def test_create(self, allow_all_prereleases: bool, prefer_binary: bool) -> None: target_python = TargetPython() target_python._valid_tags = [Tag("py36", "none", "any")] specifier = SpecifierSet() + + # Convert allow_all_prereleases to release_control + release_control = ReleaseControl() + if allow_all_prereleases: + release_control.all_releases.add(":all:") + evaluator = CandidateEvaluator.create( project_name="my-project", target_python=target_python, - allow_all_prereleases=allow_all_prereleases, + release_control=release_control, prefer_binary=prefer_binary, specifier=specifier, ) - assert evaluator._allow_all_prereleases == allow_all_prereleases + assert evaluator._release_control == release_control assert evaluator._prefer_binary == prefer_binary assert evaluator._specifier is specifier assert evaluator._supported_tags == [Tag("py36", "none", "any")] @@ -599,9 +606,15 @@ def test_create__candidate_prefs( session=PipSession(), search_scope=SearchScope([], [], False), ) + + # Convert allow_all_prereleases to release_control + release_control = ReleaseControl() + if allow_all_prereleases: + release_control.all_releases.add(":all:") + selection_prefs = SelectionPreferences( allow_yanked=True, - allow_all_prereleases=allow_all_prereleases, + release_control=release_control, prefer_binary=prefer_binary, ) finder = PackageFinder.create( @@ -609,7 +622,7 @@ def test_create__candidate_prefs( selection_prefs=selection_prefs, ) candidate_prefs = finder._candidate_prefs - assert candidate_prefs.allow_all_prereleases == allow_all_prereleases + assert candidate_prefs.release_control == release_control assert candidate_prefs.prefer_binary == prefer_binary def test_create__link_collector(self) -> None: @@ -791,9 +804,15 @@ def test_make_candidate_evaluator( ) -> None: target_python = TargetPython() target_python._valid_tags = [Tag("py36", "none", "any")] + + # Convert allow_all_prereleases to release_control + release_control = ReleaseControl() + if allow_all_prereleases: + release_control.all_releases.add(":all:") + candidate_prefs = CandidatePreferences( prefer_binary=prefer_binary, - allow_all_prereleases=allow_all_prereleases, + release_control=release_control, ) link_collector = LinkCollector( session=PipSession(), @@ -814,7 +833,7 @@ def test_make_candidate_evaluator( specifier=specifier, hashes=hashes, ) - assert evaluator._allow_all_prereleases == allow_all_prereleases + assert evaluator._release_control == release_control assert evaluator._hashes == hashes assert evaluator._prefer_binary == prefer_binary assert evaluator._project_name == "my-project" diff --git a/tests/unit/test_options.py b/tests/unit/test_options.py index 1052558ab2f..f5855813d9b 100644 --- a/tests/unit/test_options.py +++ b/tests/unit/test_options.py @@ -10,10 +10,11 @@ import pytest import pip._internal.configuration +from pip._internal.cli import cmdoptions from pip._internal.cli.main import main from pip._internal.commands import create_command from pip._internal.commands.configuration import ConfigurationCommand -from pip._internal.exceptions import PipError +from pip._internal.exceptions import CommandError, PipError from tests.lib.options_helpers import AddFakeCommandMixin @@ -572,3 +573,64 @@ def test_client_cert(self) -> None: tuple[Values, list[str]], main(["--client-cert", "~/path", "fake"]) ) assert options.client_cert == os.path.expanduser("~/path") + + +class TestReleaseControlOptions: + """Tests for --all-releases and --only-final options.""" + + def test_all_releases_and_only_final_together(self) -> None: + """Test --all-releases and --only-final together (last wins).""" + # This should not fail - the mutual exclusion is only with --pre + # The behavior follows --no-binary and --only-binary pattern + cmd = create_command("install") + options, args = cmd.parser.parse_args( + ["--all-releases=pkg1", "--only-final=pkg2", "dummy-package"] + ) + # Both should be present in their respective sets + assert "pkg1" in options.release_control.all_releases + assert "pkg2" in options.release_control.only_final + + def test_pre_transforms_to_all_releases(self) -> None: + """Test that --pre is transformed into --all-releases :all:.""" + cmd = create_command("install") + options, args = cmd.parser.parse_args(["--pre", "dummy-package"]) + + # Before transformation + assert options.pre is True + assert ":all:" not in options.release_control.all_releases + + # Apply transformation + cmdoptions.check_release_control_exclusive(options) + + # After transformation + assert ":all:" in options.release_control.all_releases + + def test_check_release_control_exclusive_with_pre_and_all_releases(self) -> None: + """Test that check raises CommandError when --pre used with --all-releases.""" + cmd = create_command("install") + options, args = cmd.parser.parse_args( + ["--pre", "--all-releases=pkg1", "dummy-package"] + ) + + with pytest.raises(CommandError, match="--pre cannot be used with"): + cmdoptions.check_release_control_exclusive(options) + + def test_check_release_control_exclusive_with_pre_and_only_final(self) -> None: + """Test that check raises CommandError when --pre used with --only-final.""" + cmd = create_command("install") + options, args = cmd.parser.parse_args( + ["--pre", "--only-final=pkg1", "dummy-package"] + ) + + with pytest.raises(CommandError, match="--pre cannot be used with"): + cmdoptions.check_release_control_exclusive(options) + + def test_check_release_control_exclusive_without_pre(self) -> None: + """Test that check passes when --pre is not used.""" + cmd = create_command("install") + options, args = cmd.parser.parse_args( + ["--all-releases=pkg1", "--only-final=pkg2", "dummy-package"] + ) + + # Should not raise + cmdoptions.check_release_control_exclusive(options) diff --git a/tests/unit/test_release_control.py b/tests/unit/test_release_control.py new file mode 100644 index 00000000000..b26a5aa8974 --- /dev/null +++ b/tests/unit/test_release_control.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from optparse import Values + +import pytest + +from pip._internal.cli import cmdoptions +from pip._internal.cli.base_command import Command +from pip._internal.cli.status_codes import SUCCESS +from pip._internal.models.release_control import ReleaseControl + + +class SimpleCommand(Command): + def __init__(self) -> None: + super().__init__("fake", "fake summary") + + def add_options(self) -> None: + self.cmd_opts.add_option(cmdoptions.all_releases()) + self.cmd_opts.add_option(cmdoptions.only_final()) + + def run(self, options: Values, args: list[str]) -> int: + self.options = options + return SUCCESS + + +def test_all_releases_overrides() -> None: + cmd = SimpleCommand() + cmd.main(["fake", "--only-final=:all:", "--all-releases=fred"]) + release_control = ReleaseControl({"fred"}, {":all:"}) + assert cmd.options.release_control == release_control + + +def test_only_final_overrides() -> None: + cmd = SimpleCommand() + cmd.main(["fake", "--all-releases=:all:", "--only-final=fred"]) + release_control = ReleaseControl({":all:"}, {"fred"}) + assert cmd.options.release_control == release_control + + +def test_none_resets() -> None: + cmd = SimpleCommand() + cmd.main(["fake", "--all-releases=:all:", "--all-releases=:none:"]) + release_control = ReleaseControl(set(), set()) + assert cmd.options.release_control == release_control + + +def test_none_preserves_other_side() -> None: + cmd = SimpleCommand() + cmd.main( + ["fake", "--all-releases=:all:", "--only-final=fred", "--all-releases=:none:"] + ) + release_control = ReleaseControl(set(), {"fred"}) + assert cmd.options.release_control == release_control + + +def test_comma_separated_values() -> None: + cmd = SimpleCommand() + cmd.main(["fake", "--all-releases=pkg1,pkg2,pkg3"]) + release_control = ReleaseControl({"pkg1", "pkg2", "pkg3"}, set()) + assert cmd.options.release_control == release_control + + +@pytest.mark.parametrize( + "all_releases,only_final,package,expected", + [ + # Package specifically in all_releases + ({"fred"}, set(), "fred", True), + # Package specifically in all_releases, even with :all: in only_final + ({"fred"}, {":all:"}, "fred", True), + # Package specifically in only_final + (set(), {"fred"}, "fred", False), + # Package specifically in only_final, even with :all: in all_releases + ({":all:"}, {"fred"}, "fred", False), + # No specific setting, :all: in all_releases + ({":all:"}, set(), "fred", True), + # No specific setting, :all: in only_final + (set(), {":all:"}, "fred", False), + # No specific setting at all + (set(), set(), "fred", None), + ], +) +def test_allows_prereleases( + all_releases: set[str], only_final: set[str], package: str, expected: bool | None +) -> None: + rc = ReleaseControl(all_releases, only_final) + assert rc.allows_prereleases(package) == expected + + +def test_order_tracking_all_releases() -> None: + """Test that order is tracked for --all-releases.""" + cmd = SimpleCommand() + cmd.main(["fake", "--all-releases=pkg1", "--all-releases=pkg2"]) + + ordered_args = cmd.options.release_control.get_ordered_args() + assert ordered_args == [ + ("all_releases", "pkg1"), + ("all_releases", "pkg2"), + ] + + +def test_order_tracking_only_final() -> None: + """Test that order is tracked for --only-final.""" + cmd = SimpleCommand() + cmd.main(["fake", "--only-final=pkg1", "--only-final=pkg2"]) + + ordered_args = cmd.options.release_control.get_ordered_args() + assert ordered_args == [ + ("only_final", "pkg1"), + ("only_final", "pkg2"), + ] + + +def test_order_tracking_mixed() -> None: + """Test that order is tracked when mixing --all-releases and --only-final.""" + cmd = SimpleCommand() + cmd.main( + [ + "fake", + "--all-releases=pkg1", + "--only-final=pkg2", + "--all-releases=pkg3", + ] + ) + + ordered_args = cmd.options.release_control.get_ordered_args() + assert ordered_args == [ + ("all_releases", "pkg1"), + ("only_final", "pkg2"), + ("all_releases", "pkg3"), + ] + + +def test_order_tracking_all_special() -> None: + """Test that order is tracked for :all: special value.""" + cmd = SimpleCommand() + cmd.main(["fake", "--all-releases=:all:", "--only-final=pkg1"]) + + ordered_args = cmd.options.release_control.get_ordered_args() + assert ordered_args == [ + ("all_releases", ":all:"), + ("only_final", "pkg1"), + ] + + +def test_order_tracking_critical_case() -> None: + """Test the critical case: --only-final=:all: --all-releases=pkg.""" + cmd = SimpleCommand() + cmd.main(["fake", "--only-final=:all:", "--all-releases=pkg1"]) + + ordered_args = cmd.options.release_control.get_ordered_args() + # The order should be preserved: only_final first, then all_releases + assert ordered_args == [ + ("only_final", ":all:"), + ("all_releases", "pkg1"), + ] + + +def test_order_tracking_none_reset() -> None: + """Test that :none: is tracked in order.""" + cmd = SimpleCommand() + cmd.main( + [ + "fake", + "--all-releases=:all:", + "--all-releases=:none:", + "--all-releases=pkg1", + ] + ) + + ordered_args = cmd.options.release_control.get_ordered_args() + assert ordered_args == [ + ("all_releases", ":all:"), + ("all_releases", ":none:"), + ("all_releases", "pkg1"), + ] + + +def test_order_tracking_comma_separated() -> None: + """Test that comma-separated values are tracked individually.""" + cmd = SimpleCommand() + cmd.main(["fake", "--all-releases=pkg1,pkg2,pkg3"]) + + ordered_args = cmd.options.release_control.get_ordered_args() + assert ordered_args == [ + ("all_releases", "pkg1"), + ("all_releases", "pkg2"), + ("all_releases", "pkg3"), + ] diff --git a/tests/unit/test_req_file.py b/tests/unit/test_req_file.py index 88fc4c58bb9..05a928c493d 100644 --- a/tests/unit/test_req_file.py +++ b/tests/unit/test_req_file.py @@ -527,7 +527,39 @@ def test_set_finder_allow_all_prereleases( self, line_processor: LineProcessor, finder: PackageFinder ) -> None: line_processor("--pre", "file", 1, finder=finder) - assert finder.allow_all_prereleases + # --pre should add :all: to release_control.all_releases + assert finder._candidate_prefs.release_control is not None + assert ":all:" in finder._candidate_prefs.release_control.all_releases + + def test_set_finder_all_releases( + self, line_processor: LineProcessor, finder: PackageFinder + ) -> None: + line_processor("--all-releases :all:", "file", 1, finder=finder) + assert finder._candidate_prefs.release_control is not None + assert ":all:" in finder._candidate_prefs.release_control.all_releases + + def test_set_finder_all_releases_specific_package( + self, line_processor: LineProcessor, finder: PackageFinder + ) -> None: + line_processor("--all-releases pkg1,pkg2", "file", 1, finder=finder) + assert finder._candidate_prefs.release_control is not None + assert "pkg1" in finder._candidate_prefs.release_control.all_releases + assert "pkg2" in finder._candidate_prefs.release_control.all_releases + + def test_set_finder_only_final( + self, line_processor: LineProcessor, finder: PackageFinder + ) -> None: + line_processor("--only-final :all:", "file", 1, finder=finder) + assert finder._candidate_prefs.release_control is not None + assert ":all:" in finder._candidate_prefs.release_control.only_final + + def test_set_finder_only_final_specific_package( + self, line_processor: LineProcessor, finder: PackageFinder + ) -> None: + line_processor("--only-final pkg1,pkg2", "file", 1, finder=finder) + assert finder._candidate_prefs.release_control is not None + assert "pkg1" in finder._candidate_prefs.release_control.only_final + assert "pkg2" in finder._candidate_prefs.release_control.only_final def test_use_feature( self, line_processor: LineProcessor, options: mock.Mock From dbd03ab57aede4acbb24d93f0bd28428e4b20a48 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 14 Nov 2025 22:18:10 -0500 Subject: [PATCH 3/3] News Entry --- news/13221.feature.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 news/13221.feature.rst diff --git a/news/13221.feature.rst b/news/13221.feature.rst new file mode 100644 index 00000000000..53375ac6ef7 --- /dev/null +++ b/news/13221.feature.rst @@ -0,0 +1,2 @@ +Add ``--all-releases`` and ``--only-final`` options to control pre-release +and final release selection during package installation.