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/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.
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(
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