diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst
index d6a0acf9cd8..2dce8aa063c 100644
--- a/docs/html/user_guide.rst
+++ b/docs/html/user_guide.rst
@@ -257,6 +257,108 @@ e.g. http://example.com/constraints.txt, so that your organization can store and
serve them in a centralized place.
+.. _`Build Constraints`:
+
+Build Constraints
+-----------------
+
+.. versionadded:: 25.3
+
+Build constraints are a type of constraints file that applies only to isolated
+build environments used for building packages from source. Unlike regular
+constraints, which affect the packages installed in your environment, build
+constraints only influence the versions of packages available during the
+build process.
+
+This is useful when you need to constrain build dependencies
+(such as ``setuptools``, ``cython``, etc.) without affecting the
+final installed environment.
+
+Use build constraints like so:
+
+.. tab:: Unix/macOS
+
+ .. code-block:: shell
+
+ python -m pip install --build-constraint build-constraints.txt SomePackage
+
+.. tab:: Windows
+
+ .. code-block:: shell
+
+ py -m pip install --build-constraint build-constraints.txt SomePackage
+
+Example build constraints file (``build-constraints.txt``):
+
+.. code-block:: text
+
+ # Constrain setuptools version during build
+ setuptools>=45,<80
+ # Pin Cython for packages that use it to build
+ cython==0.29.24
+
+
+.. _`Filtering by Upload Time`:
+
+
+Filtering by Upload Time
+=========================
+
+The ``--uploaded-prior-to`` option allows you to filter packages by their upload time
+to an index, only considering packages that were uploaded before a specified datetime.
+This can be useful for creating reproducible builds by ensuring you only install
+packages that were available at a known point in time.
+
+.. tab:: Unix/macOS
+
+ .. code-block:: shell
+
+ python -m pip install --uploaded-prior-to=2025-03-16T00:00:00Z SomePackage
+
+.. tab:: Windows
+
+ .. code-block:: shell
+
+ py -m pip install --uploaded-prior-to=2025-03-16T00:00:00Z SomePackage
+
+The option accepts ISO 8601 datetime strings in several formats:
+
+* ``2025-03-16`` - Date in local timezone
+* ``2025-03-16 12:30:00`` - Datetime in local timezone
+* ``2025-03-16T12:30:00Z`` - Datetime in UTC
+* ``2025-03-16T12:30:00+05:00`` - Datetime in UTC offset
+
+For consistency across machines, use either UTC format (with 'Z' suffix) or UTC offset
+format (with timezone offset like '+05:00'). Local timezone formats may produce different
+results on different machines.
+
+.. note::
+
+ This option only applies to packages from indexes, not local files. Local
+ package files are allowed regardless of the ``--uploaded-prior-to`` setting.
+ e.g., ``pip install /path/to/package.whl`` or packages from
+ ``--find-links`` directories.
+
+ This option requires package indexes that provide upload-time metadata
+ (such as PyPI). If the index does not provide upload-time metadata for a
+ package file, pip will fail immediately with an error message indicating
+ that upload-time metadata is required when using ``--uploaded-prior-to``.
+
+You can combine this option with other filtering mechanisms like constraints files:
+
+.. tab:: Unix/macOS
+
+ .. code-block:: shell
+
+ python -m pip install -c constraints.txt --uploaded-prior-to=2025-03-16 SomePackage
+
+.. tab:: Windows
+
+ .. code-block:: shell
+
+ py -m pip install -c constraints.txt --uploaded-prior-to=2025-03-16 SomePackage
+
+
.. _`Dependency Groups`:
diff --git a/news/12603.feature.rst b/news/12603.feature.rst
new file mode 100644
index 00000000000..cab327a85d2
--- /dev/null
+++ b/news/12603.feature.rst
@@ -0,0 +1 @@
+When PEP-658 metadata is available, full distribution files are no longer downloaded when using ``pip lock`` or ``pip install --dry-run``.
diff --git a/news/13520.feature.rst b/news/13520.feature.rst
new file mode 100644
index 00000000000..54c272bba79
--- /dev/null
+++ b/news/13520.feature.rst
@@ -0,0 +1,2 @@
+Add ``--uploaded-prior-to`` option to only consider packages uploaded prior to
+a given datetime when the ``upload-time`` field is available from an index.
diff --git a/news/13534.feature.rst b/news/13534.feature.rst
new file mode 100644
index 00000000000..541fd852d14
--- /dev/null
+++ b/news/13534.feature.rst
@@ -0,0 +1,3 @@
+Add support for build constraints via the ``--build-constraint`` option. This
+allows constraining the versions of packages used during the build process
+(e.g., setuptools) without affecting the final installation.
diff --git a/news/13534.removal.rst b/news/13534.removal.rst
new file mode 100644
index 00000000000..c8212bb15b2
--- /dev/null
+++ b/news/13534.removal.rst
@@ -0,0 +1,8 @@
+Deprecate the ``PIP_CONSTRAINT`` environment variable for specifying build
+constraints.
+
+Build constraints should now be specified using the ``--build-constraint``
+option or the ``PIP_BUILD_CONSTRAINT`` environment variable. When using build
+constraints, ``PIP_CONSTRAINT`` no longer affects isolated build environments.
+To opt in to this behavior without specifying any build constraints, use
+``--use-feature=build-constraint``.
diff --git a/pyproject.toml b/pyproject.toml
index 813300c26de..b7089703802 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -268,7 +268,7 @@ max-complexity = 33 # default is 10
[tool.ruff.lint.pylint]
max-args = 15 # default is 5
max-branches = 28 # default is 12
-max-returns = 13 # default is 6
+max-returns = 15 # default is 6
max-statements = 134 # default is 50
[tool.ruff.per-file-target-version]
diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py
index 98844e70287..9bbbea5f4f7 100644
--- a/src/pip/_internal/build_env.py
+++ b/src/pip/_internal/build_env.py
@@ -11,7 +11,7 @@
from collections import OrderedDict
from collections.abc import Iterable
from types import TracebackType
-from typing import TYPE_CHECKING, Protocol
+from typing import TYPE_CHECKING, Protocol, TypedDict
from pip._vendor.packaging.version import Version
@@ -19,6 +19,7 @@
from pip._internal.cli.spinners import open_spinner
from pip._internal.locations import get_platlib, get_purelib, get_scheme
from pip._internal.metadata import get_default_environment, get_environment
+from pip._internal.utils.deprecation import deprecated
from pip._internal.utils.logging import VERBOSE
from pip._internal.utils.packaging import get_requirement
from pip._internal.utils.subprocess import call_subprocess
@@ -28,6 +29,10 @@
from pip._internal.index.package_finder import PackageFinder
from pip._internal.req.req_install import InstallRequirement
+ class ExtraEnviron(TypedDict, total=False):
+ extra_environ: dict[str, str]
+
+
logger = logging.getLogger(__name__)
@@ -101,8 +106,44 @@ class SubprocessBuildEnvironmentInstaller:
Install build dependencies by calling pip in a subprocess.
"""
- def __init__(self, finder: PackageFinder) -> None:
+ def __init__(
+ self,
+ finder: PackageFinder,
+ build_constraints: list[str] | None = None,
+ build_constraint_feature_enabled: bool = False,
+ ) -> None:
self.finder = finder
+ self._build_constraints = build_constraints or []
+ self._build_constraint_feature_enabled = build_constraint_feature_enabled
+
+ def _deprecation_constraint_check(self) -> None:
+ """
+ Check for deprecation warning: PIP_CONSTRAINT affecting build environments.
+
+ This warns when build-constraint feature is NOT enabled and PIP_CONSTRAINT
+ is not empty.
+ """
+ if self._build_constraint_feature_enabled or self._build_constraints:
+ return
+
+ pip_constraint = os.environ.get("PIP_CONSTRAINT")
+ if not pip_constraint or not pip_constraint.strip():
+ return
+
+ deprecated(
+ reason=(
+ "Setting PIP_CONSTRAINT will not affect "
+ "build constraints in the future,"
+ ),
+ replacement=(
+ "to specify build constraints using --build-constraint or "
+ "PIP_BUILD_CONSTRAINT. To disable this warning without "
+ "any build constraints set --use-feature=build-constraint or "
+ 'PIP_USE_FEATURE="build-constraint"'
+ ),
+ gone_in="26.2",
+ issue=None,
+ )
def install(
self,
@@ -112,6 +153,8 @@ def install(
kind: str,
for_req: InstallRequirement | None,
) -> None:
+ self._deprecation_constraint_check()
+
finder = self.finder
args: list[str] = [
sys.executable,
@@ -167,6 +210,28 @@ def install(
args.append("--pre")
if finder.prefer_binary:
args.append("--prefer-binary")
+ if finder.uploaded_prior_to:
+ args.extend(["--uploaded-prior-to", finder.uploaded_prior_to.isoformat()])
+
+ # Handle build constraints
+ if self._build_constraint_feature_enabled:
+ args.extend(["--use-feature", "build-constraint"])
+
+ if self._build_constraints:
+ # Build constraints must be passed as both constraints
+ # and build constraints, so that nested builds receive
+ # build constraints
+ for constraint_file in self._build_constraints:
+ args.extend(["--constraint", constraint_file])
+ args.extend(["--build-constraint", constraint_file])
+
+ extra_environ: ExtraEnviron = {}
+ if self._build_constraint_feature_enabled and not self._build_constraints:
+ # If there are no build constraints but the build constraints
+ # feature is enabled then we must ignore regular constraints
+ # in the isolated build environment
+ extra_environ = {"extra_environ": {"_PIP_IN_BUILD_IGNORE_CONSTRAINTS": "1"}}
+
args.append("--")
args.extend(requirements)
@@ -178,6 +243,7 @@ def install(
args,
command_desc=f"installing {kind}{identify_requirement}",
spinner=spinner,
+ **extra_environ,
)
diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py
index 3519dadf13d..73e25e36032 100644
--- a/src/pip/_internal/cli/cmdoptions.py
+++ b/src/pip/_internal/cli/cmdoptions.py
@@ -29,6 +29,7 @@
from pip._internal.models.format_control import FormatControl
from pip._internal.models.index import PyPI
from pip._internal.models.target_python import TargetPython
+from pip._internal.utils.datetime import parse_iso_datetime
from pip._internal.utils.hashes import STRONG_HASHES
from pip._internal.utils.misc import strtobool
@@ -101,6 +102,29 @@ def check_dist_restriction(options: Values, check_target: bool = False) -> None:
)
+def check_build_constraints(options: Values) -> None:
+ """Function for validating build constraints options.
+
+ :param options: The OptionParser options.
+ """
+ if hasattr(options, "build_constraints") and options.build_constraints:
+ if not options.build_isolation:
+ raise CommandError(
+ "--build-constraint cannot be used with --no-build-isolation."
+ )
+
+ # Import here to avoid circular imports
+ from pip._internal.network.session import PipSession
+ from pip._internal.req.req_file import get_file_content
+
+ # Eagerly check build constraints file contents
+ # is valid so that we don't fail in when trying
+ # to check constraints in isolated build process
+ with PipSession() as session:
+ for constraint_file in options.build_constraints:
+ get_file_content(constraint_file, session)
+
+
def _path_option_check(option: Option, opt: str, value: str) -> str:
return os.path.expanduser(value)
@@ -430,6 +454,21 @@ def constraints() -> Option:
)
+def build_constraints() -> Option:
+ return Option(
+ "--build-constraint",
+ dest="build_constraints",
+ action="append",
+ type="str",
+ default=[],
+ metavar="file",
+ help=(
+ "Constrain build dependencies using the given constraints file. "
+ "This option can be used multiple times."
+ ),
+ )
+
+
def requirements() -> Option:
return Option(
"-r",
@@ -796,6 +835,54 @@ def _handle_dependency_group(
help="Ignore the Requires-Python information.",
)
+
+def _handle_uploaded_prior_to(
+ option: Option, opt: str, value: str, parser: OptionParser
+) -> None:
+ """
+ This is an optparse.Option callback for the --uploaded-prior-to option.
+
+ Parses an ISO 8601 datetime string. If no timezone is specified in the string,
+ local timezone is used.
+
+ Note: This option only works with indexes that provide upload-time metadata
+ as specified in the simple repository API:
+ https://packaging.python.org/en/latest/specifications/simple-repository-api/
+ """
+ if value is None:
+ return None
+
+ try:
+ uploaded_prior_to = parse_iso_datetime(value)
+ # Use local timezone if no offset is given in the ISO string.
+ if uploaded_prior_to.tzinfo is None:
+ uploaded_prior_to = uploaded_prior_to.astimezone()
+ parser.values.uploaded_prior_to = uploaded_prior_to
+ except ValueError as exc:
+ msg = (
+ f"invalid --uploaded-prior-to value: {value!r}: {exc}. "
+ f"Expected an ISO 8601 datetime string, "
+ f"e.g '2023-01-01' or '2023-01-01T00:00:00Z'"
+ )
+ raise_option_error(parser, option=option, msg=msg)
+
+
+uploaded_prior_to: Callable[..., Option] = partial(
+ Option,
+ "--uploaded-prior-to",
+ dest="uploaded_prior_to",
+ metavar="datetime",
+ action="callback",
+ callback=_handle_uploaded_prior_to,
+ type="str",
+ help=(
+ "Only consider packages uploaded prior to the given date time. "
+ "Accepts ISO 8601 strings (e.g., '2023-01-01T00:00:00Z'). "
+ "Uses local timezone if none specified. Only effective when "
+ "installing from indexes that provide upload-time metadata."
+ ),
+)
+
no_build_isolation: Callable[..., Option] = partial(
Option,
"--no-build-isolation",
@@ -1072,6 +1159,7 @@ def check_list_path_option(options: Values) -> None:
default=[],
choices=[
"fast-deps",
+ "build-constraint",
]
+ ALWAYS_ENABLED_FEATURES,
help="Enable new functionality, that may be backward incompatible.",
diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py
index dc1328ff019..44aee20e819 100644
--- a/src/pip/_internal/cli/req_command.py
+++ b/src/pip/_internal/cli/req_command.py
@@ -8,6 +8,7 @@
from __future__ import annotations
import logging
+import os
from functools import partial
from optparse import Values
from typing import Any
@@ -44,6 +45,16 @@
logger = logging.getLogger(__name__)
+def should_ignore_regular_constraints(options: Values) -> bool:
+ """
+ Check if regular constraints should be ignored because
+ we are in a isolated build process and build constraints
+ feature is enabled but no build constraints were passed.
+ """
+
+ return os.environ.get("_PIP_IN_BUILD_IGNORE_CONSTRAINTS") == "1"
+
+
KEEPABLE_TEMPDIR_TYPES = [
tempdir_kinds.BUILD_ENV,
tempdir_kinds.EPHEM_WHEEL_CACHE,
@@ -132,12 +143,22 @@ def make_requirement_preparer(
"fast-deps has no effect when used with the legacy resolver."
)
+ # Handle build constraints
+ build_constraints = getattr(options, "build_constraints", [])
+ build_constraint_feature_enabled = (
+ "build-constraint" in options.features_enabled
+ )
+
return RequirementPreparer(
build_dir=temp_build_dir_path,
src_dir=options.src_dir,
download_dir=download_dir,
build_isolation=options.build_isolation,
- build_isolation_installer=SubprocessBuildEnvironmentInstaller(finder),
+ build_isolation_installer=SubprocessBuildEnvironmentInstaller(
+ finder,
+ build_constraints=build_constraints,
+ build_constraint_feature_enabled=build_constraint_feature_enabled,
+ ),
check_build_deps=options.check_build_deps,
build_tracker=build_tracker,
session=session,
@@ -221,20 +242,22 @@ def get_requirements(
Parse command-line arguments into the corresponding requirements.
"""
requirements: list[InstallRequirement] = []
- for filename in options.constraints:
- for parsed_req in parse_requirements(
- filename,
- constraint=True,
- finder=finder,
- options=options,
- session=session,
- ):
- req_to_add = install_req_from_parsed_requirement(
- parsed_req,
- isolated=options.isolated_mode,
- user_supplied=False,
- )
- requirements.append(req_to_add)
+
+ if not should_ignore_regular_constraints(options):
+ for filename in options.constraints:
+ for parsed_req in parse_requirements(
+ filename,
+ constraint=True,
+ finder=finder,
+ options=options,
+ session=session,
+ ):
+ req_to_add = install_req_from_parsed_requirement(
+ parsed_req,
+ isolated=options.isolated_mode,
+ user_supplied=False,
+ )
+ requirements.append(req_to_add)
for req in args:
req_to_add = install_req_from_line(
@@ -348,4 +371,5 @@ def _build_package_finder(
link_collector=link_collector,
selection_prefs=selection_prefs,
target_python=target_python,
+ uploaded_prior_to=options.uploaded_prior_to,
)
diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py
index 900fb403d6f..6a4752ad710 100644
--- a/src/pip/_internal/commands/download.py
+++ b/src/pip/_internal/commands/download.py
@@ -36,6 +36,7 @@ class DownloadCommand(RequirementCommand):
def add_options(self) -> None:
self.cmd_opts.add_option(cmdoptions.constraints())
+ self.cmd_opts.add_option(cmdoptions.build_constraints())
self.cmd_opts.add_option(cmdoptions.requirements())
self.cmd_opts.add_option(cmdoptions.no_deps())
self.cmd_opts.add_option(cmdoptions.global_options())
@@ -51,6 +52,7 @@ def add_options(self) -> None:
self.cmd_opts.add_option(cmdoptions.no_use_pep517())
self.cmd_opts.add_option(cmdoptions.check_build_deps())
self.cmd_opts.add_option(cmdoptions.ignore_requires_python())
+ self.cmd_opts.add_option(cmdoptions.uploaded_prior_to())
self.cmd_opts.add_option(
"-d",
@@ -81,6 +83,7 @@ def run(self, options: Values, args: list[str]) -> int:
options.editables = []
cmdoptions.check_dist_restriction(options)
+ cmdoptions.check_build_constraints(options)
options.download_dir = normalize_path(options.download_dir)
ensure_dir(options.download_dir)
@@ -130,6 +133,8 @@ def run(self, options: Values, args: list[str]) -> int:
requirement_set = resolver.resolve(reqs, check_supported_wheels=True)
+ preparer.prepare_linked_requirements_more(requirement_set.requirements.values())
+
downloaded: list[str] = []
for req in requirement_set.requirements.values():
if req.satisfied_by is None:
@@ -137,8 +142,6 @@ def run(self, options: Values, args: list[str]) -> int:
preparer.save_linked_requirement(req)
downloaded.append(req.name)
- preparer.prepare_linked_requirements_more(requirement_set.requirements.values())
-
if downloaded:
write_output("Successfully downloaded %s", " ".join(downloaded))
diff --git a/src/pip/_internal/commands/index.py b/src/pip/_internal/commands/index.py
index ecac99888db..b53099452e0 100644
--- a/src/pip/_internal/commands/index.py
+++ b/src/pip/_internal/commands/index.py
@@ -40,6 +40,7 @@ def add_options(self) -> None:
cmdoptions.add_target_python_options(self.cmd_opts)
self.cmd_opts.add_option(cmdoptions.ignore_requires_python())
+ self.cmd_opts.add_option(cmdoptions.uploaded_prior_to())
self.cmd_opts.add_option(cmdoptions.pre())
self.cmd_opts.add_option(cmdoptions.json())
self.cmd_opts.add_option(cmdoptions.no_binary())
@@ -103,6 +104,7 @@ def _build_package_finder(
link_collector=link_collector,
selection_prefs=selection_prefs,
target_python=target_python,
+ uploaded_prior_to=options.uploaded_prior_to,
)
def get_available_package_versions(self, options: Values, args: list[Any]) -> None:
diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py
index 1ef7a0f4410..97103f8651c 100644
--- a/src/pip/_internal/commands/install.py
+++ b/src/pip/_internal/commands/install.py
@@ -87,6 +87,7 @@ class InstallCommand(RequirementCommand):
def add_options(self) -> None:
self.cmd_opts.add_option(cmdoptions.requirements())
self.cmd_opts.add_option(cmdoptions.constraints())
+ self.cmd_opts.add_option(cmdoptions.build_constraints())
self.cmd_opts.add_option(cmdoptions.no_deps())
self.cmd_opts.add_option(cmdoptions.pre())
@@ -207,6 +208,7 @@ def add_options(self) -> None:
),
)
+ self.cmd_opts.add_option(cmdoptions.uploaded_prior_to())
self.cmd_opts.add_option(cmdoptions.ignore_requires_python())
self.cmd_opts.add_option(cmdoptions.no_build_isolation())
self.cmd_opts.add_option(cmdoptions.use_pep517())
@@ -303,6 +305,7 @@ def run(self, options: Values, args: list[str]) -> int:
if options.upgrade:
upgrade_strategy = options.upgrade_strategy
+ cmdoptions.check_build_constraints(options)
cmdoptions.check_dist_restriction(options, check_target=True)
logger.verbose("Using %s", get_pip_version())
@@ -414,6 +417,13 @@ def run(self, options: Values, args: list[str]) -> int:
)
return SUCCESS
+ # If there is any more preparation to do for the actual installation, do
+ # so now. This includes actually downloading the files in the case that
+ # we have been using PEP-658 metadata so far.
+ preparer.prepare_linked_requirements_more(
+ requirement_set.requirements.values()
+ )
+
try:
pip_req = requirement_set.get_requirement("pip")
except KeyError:
diff --git a/src/pip/_internal/commands/lock.py b/src/pip/_internal/commands/lock.py
index e4a978d5aaa..ee0c464c809 100644
--- a/src/pip/_internal/commands/lock.py
+++ b/src/pip/_internal/commands/lock.py
@@ -59,6 +59,7 @@ def add_options(self) -> None:
)
self.cmd_opts.add_option(cmdoptions.requirements())
self.cmd_opts.add_option(cmdoptions.constraints())
+ self.cmd_opts.add_option(cmdoptions.build_constraints())
self.cmd_opts.add_option(cmdoptions.no_deps())
self.cmd_opts.add_option(cmdoptions.pre())
@@ -67,6 +68,7 @@ def add_options(self) -> None:
self.cmd_opts.add_option(cmdoptions.src())
self.cmd_opts.add_option(cmdoptions.ignore_requires_python())
+ self.cmd_opts.add_option(cmdoptions.uploaded_prior_to())
self.cmd_opts.add_option(cmdoptions.no_build_isolation())
self.cmd_opts.add_option(cmdoptions.use_pep517())
self.cmd_opts.add_option(cmdoptions.no_use_pep517())
@@ -98,6 +100,8 @@ def run(self, options: Values, args: list[str]) -> int:
"without prior warning."
)
+ cmdoptions.check_build_constraints(options)
+
session = self.get_default_session(options)
finder = self._build_package_finder(
diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py
index 61be254912f..4cef8973061 100644
--- a/src/pip/_internal/commands/wheel.py
+++ b/src/pip/_internal/commands/wheel.py
@@ -60,10 +60,12 @@ def add_options(self) -> None:
self.cmd_opts.add_option(cmdoptions.no_use_pep517())
self.cmd_opts.add_option(cmdoptions.check_build_deps())
self.cmd_opts.add_option(cmdoptions.constraints())
+ self.cmd_opts.add_option(cmdoptions.build_constraints())
self.cmd_opts.add_option(cmdoptions.editable())
self.cmd_opts.add_option(cmdoptions.requirements())
self.cmd_opts.add_option(cmdoptions.src())
self.cmd_opts.add_option(cmdoptions.ignore_requires_python())
+ self.cmd_opts.add_option(cmdoptions.uploaded_prior_to())
self.cmd_opts.add_option(cmdoptions.no_deps())
self.cmd_opts.add_option(cmdoptions.progress_bar())
@@ -101,6 +103,8 @@ def add_options(self) -> None:
@with_cleanup
def run(self, options: Values, args: list[str]) -> int:
+ cmdoptions.check_build_constraints(options)
+
session = self.get_default_session(options)
finder = self._build_package_finder(options, session)
@@ -145,6 +149,8 @@ def run(self, options: Values, args: list[str]) -> int:
requirement_set = resolver.resolve(reqs, check_supported_wheels=True)
+ preparer.prepare_linked_requirements_more(requirement_set.requirements.values())
+
reqs_to_build: list[InstallRequirement] = []
for req in requirement_set.requirements.values():
if req.is_wheel:
@@ -152,8 +158,6 @@ def run(self, options: Values, args: list[str]) -> int:
else:
reqs_to_build.append(req)
- preparer.prepare_linked_requirements_more(requirement_set.requirements.values())
-
# build wheels
build_successes, build_failures = build(
reqs_to_build,
diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py
index ae6f8962f6f..33d80822ba2 100644
--- a/src/pip/_internal/index/package_finder.py
+++ b/src/pip/_internal/index/package_finder.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import datetime
import enum
import functools
import itertools
@@ -24,10 +25,11 @@
from pip._internal.exceptions import (
BestVersionAlreadyInstalled,
DistributionNotFound,
+ InstallationError,
InvalidWheelFilename,
UnsupportedWheel,
)
-from pip._internal.index.collector import LinkCollector, parse_links
+from pip._internal.index.collector import IndexContent, LinkCollector, parse_links
from pip._internal.models.candidate import InstallationCandidate
from pip._internal.models.format_control import FormatControl
from pip._internal.models.link import Link
@@ -111,6 +113,8 @@ class LinkType(enum.Enum):
format_invalid = enum.auto()
platform_mismatch = enum.auto()
requires_python_mismatch = enum.auto()
+ upload_too_late = enum.auto()
+ upload_time_missing = enum.auto()
class LinkEvaluator:
@@ -132,6 +136,7 @@ def __init__(
target_python: TargetPython,
allow_yanked: bool,
ignore_requires_python: bool | None = None,
+ uploaded_prior_to: datetime.datetime | None = None,
) -> None:
"""
:param project_name: The user supplied package name.
@@ -149,6 +154,8 @@ def __init__(
:param ignore_requires_python: Whether to ignore incompatible
PEP 503 "data-requires-python" values in HTML links. Defaults
to False.
+ :param uploaded_prior_to: If set, only allow links uploaded prior to
+ the given datetime.
"""
if ignore_requires_python is None:
ignore_requires_python = False
@@ -158,6 +165,7 @@ def __init__(
self._ignore_requires_python = ignore_requires_python
self._formats = formats
self._target_python = target_python
+ self._uploaded_prior_to = uploaded_prior_to
self.project_name = project_name
@@ -218,6 +226,30 @@ def evaluate_link(self, link: Link) -> tuple[LinkType, str]:
version = wheel.version
+ # Check upload-time filter after verifying the link is a package file.
+ # Skip this check for local files, as --uploaded-prior-to only applies
+ # to packages from indexes.
+ if self._uploaded_prior_to is not None and not link.is_file:
+ if link.upload_time is None:
+ if isinstance(link.comes_from, IndexContent):
+ index_info = f"Index {link.comes_from.url}"
+ elif link.comes_from:
+ index_info = f"Index {link.comes_from}"
+ else:
+ index_info = "Index"
+
+ return (
+ LinkType.upload_time_missing,
+ f"{index_info} does not provide upload-time metadata. "
+ "Cannot use --uploaded-prior-to with this index.",
+ )
+ elif link.upload_time >= self._uploaded_prior_to:
+ return (
+ LinkType.upload_too_late,
+ f"Upload time {link.upload_time} not "
+ f"prior to {self._uploaded_prior_to}",
+ )
+
# This should be up by the self.ok_binary check, but see issue 2700.
if "source" not in self._formats and ext != WHEEL_EXTENSION:
reason = f"No sources permitted for {self.project_name}"
@@ -593,6 +625,7 @@ def __init__(
format_control: FormatControl | None = None,
candidate_prefs: CandidatePreferences | None = None,
ignore_requires_python: bool | None = None,
+ uploaded_prior_to: datetime.datetime | None = None,
) -> None:
"""
This constructor is primarily meant to be used by the create() class
@@ -614,6 +647,7 @@ def __init__(
self._ignore_requires_python = ignore_requires_python
self._link_collector = link_collector
self._target_python = target_python
+ self._uploaded_prior_to = uploaded_prior_to
self.format_control = format_control
@@ -637,6 +671,7 @@ def create(
link_collector: LinkCollector,
selection_prefs: SelectionPreferences,
target_python: TargetPython | None = None,
+ uploaded_prior_to: datetime.datetime | None = None,
) -> PackageFinder:
"""Create a PackageFinder.
@@ -645,6 +680,8 @@ def create(
:param target_python: The target Python interpreter to use when
checking compatibility. If None (the default), a TargetPython
object will be constructed from the running Python.
+ :param uploaded_prior_to: If set, only find links uploaded prior
+ to the given datetime.
"""
if target_python is None:
target_python = TargetPython()
@@ -661,6 +698,7 @@ def create(
allow_yanked=selection_prefs.allow_yanked,
format_control=selection_prefs.format_control,
ignore_requires_python=selection_prefs.ignore_requires_python,
+ uploaded_prior_to=uploaded_prior_to,
)
@property
@@ -720,6 +758,10 @@ def prefer_binary(self) -> bool:
def set_prefer_binary(self) -> None:
self._candidate_prefs.prefer_binary = True
+ @property
+ def uploaded_prior_to(self) -> datetime.datetime | None:
+ return self._uploaded_prior_to
+
def requires_python_skipped_reasons(self) -> list[str]:
reasons = {
detail
@@ -739,6 +781,7 @@ def make_link_evaluator(self, project_name: str) -> LinkEvaluator:
target_python=self._target_python,
allow_yanked=self._allow_yanked,
ignore_requires_python=self._ignore_requires_python,
+ uploaded_prior_to=self._uploaded_prior_to,
)
def _sort_links(self, links: Iterable[Link]) -> list[Link]:
@@ -773,6 +816,10 @@ def get_install_candidate(
InstallationCandidate and return it. Otherwise, return None.
"""
result, detail = link_evaluator.evaluate_link(link)
+ if result == LinkType.upload_time_missing:
+ # Fail immediately if the index doesn't provide upload-time
+ # when --uploaded-prior-to is specified
+ raise InstallationError(detail)
if result != LinkType.candidate:
self._log_skipped_link(link, result, detail)
return None
diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py
index 2e2c0f836ac..140f2cc47db 100644
--- a/src/pip/_internal/models/link.py
+++ b/src/pip/_internal/models/link.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import datetime
import functools
import itertools
import logging
@@ -7,6 +8,7 @@
import posixpath
import re
import urllib.parse
+import urllib.request
from collections.abc import Mapping
from dataclasses import dataclass
from typing import (
@@ -15,6 +17,7 @@
NamedTuple,
)
+from pip._internal.utils.datetime import parse_iso_datetime
from pip._internal.utils.deprecation import deprecated
from pip._internal.utils.filetypes import WHEEL_EXTENSION
from pip._internal.utils.hashes import Hashes
@@ -207,6 +210,7 @@ class Link:
"requires_python",
"yanked_reason",
"metadata_file_data",
+ "upload_time",
"cache_link_parsing",
"egg_fragment",
]
@@ -218,6 +222,7 @@ def __init__(
requires_python: str | None = None,
yanked_reason: str | None = None,
metadata_file_data: MetadataFile | None = None,
+ upload_time: datetime.datetime | None = None,
cache_link_parsing: bool = True,
hashes: Mapping[str, str] | None = None,
) -> None:
@@ -239,6 +244,8 @@ def __init__(
no such metadata is provided. This argument, if not None, indicates
that a separate metadata file exists, and also optionally supplies
hashes for that file.
+ :param upload_time: upload time of the file, or None if the information
+ is not available from the server.
:param cache_link_parsing: A flag that is used elsewhere to determine
whether resources retrieved from this link should be cached. PyPI
URLs should generally have this set to False, for example.
@@ -272,6 +279,7 @@ def __init__(
self.requires_python = requires_python if requires_python else None
self.yanked_reason = yanked_reason
self.metadata_file_data = metadata_file_data
+ self.upload_time = upload_time
self.cache_link_parsing = cache_link_parsing
self.egg_fragment = self._egg_fragment()
@@ -300,6 +308,12 @@ def from_json(
if metadata_info is None:
metadata_info = file_data.get("dist-info-metadata")
+ upload_time: datetime.datetime | None
+ if upload_time_data := file_data.get("upload-time"):
+ upload_time = parse_iso_datetime(upload_time_data)
+ else:
+ upload_time = None
+
# The metadata info value may be a boolean, or a dict of hashes.
if isinstance(metadata_info, dict):
# The file exists, and hashes have been supplied
@@ -325,6 +339,7 @@ def from_json(
yanked_reason=yanked_reason,
hashes=hashes,
metadata_file_data=metadata_file_data,
+ upload_time=upload_time,
)
@classmethod
diff --git a/src/pip/_internal/operations/prepare.py b/src/pip/_internal/operations/prepare.py
index e53cdda6868..a72e0e4793b 100644
--- a/src/pip/_internal/operations/prepare.py
+++ b/src/pip/_internal/operations/prepare.py
@@ -531,6 +531,12 @@ def prepare_linked_requirement(
metadata_dist = self._fetch_metadata_only(req)
if metadata_dist is not None:
req.needs_more_preparation = True
+ req.set_dist(metadata_dist)
+ # Ensure download_info is available even in dry-run mode
+ if req.download_info is None:
+ req.download_info = direct_url_from_link(
+ req.link, req.source_dir
+ )
return metadata_dist
# None of the optimizations worked, fully prepare the requirement
diff --git a/src/pip/_internal/req/req_install.py b/src/pip/_internal/req/req_install.py
index c9f6bff17e8..cdc71f738f0 100644
--- a/src/pip/_internal/req/req_install.py
+++ b/src/pip/_internal/req/req_install.py
@@ -168,6 +168,10 @@ def __init__(
# details).
self.metadata_directory: str | None = None
+ # The cached metadata distribution that this requirement represents.
+ # See get_dist / set_dist.
+ self._distribution: BaseDistribution | None = None
+
# The static build requirements (from pyproject.toml)
self.pyproject_requires: list[str] | None = None
@@ -604,8 +608,13 @@ def metadata(self) -> Any:
return self._metadata
+ def set_dist(self, distribution: BaseDistribution) -> None:
+ self._distribution = distribution
+
def get_dist(self) -> BaseDistribution:
- if self.metadata_directory:
+ if self._distribution is not None:
+ return self._distribution
+ elif self.metadata_directory:
return get_directory_distribution(self.metadata_directory)
elif self.local_file_path and self.is_wheel:
assert self.req is not None
diff --git a/src/pip/_internal/resolution/resolvelib/resolver.py b/src/pip/_internal/resolution/resolvelib/resolver.py
index 1ba70c2b39e..c4fd4e28f93 100644
--- a/src/pip/_internal/resolution/resolvelib/resolver.py
+++ b/src/pip/_internal/resolution/resolvelib/resolver.py
@@ -180,11 +180,6 @@ def resolve(
req_set.add_named_requirement(ireq)
- reqs = req_set.all_requirements
- self.factory.preparer.prepare_linked_requirements_more(reqs)
- for req in reqs:
- req.prepared = True
- req.needs_more_preparation = False
return req_set
def get_installation_order(
diff --git a/src/pip/_internal/self_outdated_check.py b/src/pip/_internal/self_outdated_check.py
index 5999ddb3737..79904d8905c 100644
--- a/src/pip/_internal/self_outdated_check.py
+++ b/src/pip/_internal/self_outdated_check.py
@@ -23,6 +23,7 @@
from pip._internal.models.selection_prefs import SelectionPreferences
from pip._internal.network.session import PipSession
from pip._internal.utils.compat import WINDOWS
+from pip._internal.utils.datetime import parse_iso_datetime
from pip._internal.utils.entrypoints import (
get_best_invocation_for_this_pip,
get_best_invocation_for_this_python,
@@ -50,15 +51,6 @@ def _get_statefile_name(key: str) -> str:
return name
-def _convert_date(isodate: str) -> datetime.datetime:
- """Convert an ISO format string to a date.
-
- Handles the format 2020-01-22T14:24:01Z (trailing Z)
- which is not supported by older versions of fromisoformat.
- """
- return datetime.datetime.fromisoformat(isodate.replace("Z", "+00:00"))
-
-
class SelfCheckState:
def __init__(self, cache_dir: str) -> None:
self._state: dict[str, Any] = {}
@@ -93,7 +85,7 @@ def get(self, current_time: datetime.datetime) -> str | None:
return None
# Determine if we need to refresh the state
- last_check = _convert_date(self._state["last_check"])
+ last_check = parse_iso_datetime(self._state["last_check"])
time_since_last_check = current_time - last_check
if time_since_last_check > _WEEK:
return None
diff --git a/src/pip/_internal/utils/datetime.py b/src/pip/_internal/utils/datetime.py
index 776e49898f7..dfab713d9f0 100644
--- a/src/pip/_internal/utils/datetime.py
+++ b/src/pip/_internal/utils/datetime.py
@@ -1,6 +1,7 @@
"""For when pip wants to check the date or time."""
import datetime
+import sys
def today_is_later_than(year: int, month: int, day: int) -> bool:
@@ -8,3 +9,16 @@ def today_is_later_than(year: int, month: int, day: int) -> bool:
given = datetime.date(year, month, day)
return today > given
+
+
+def parse_iso_datetime(isodate: str) -> datetime.datetime:
+ """Convert an ISO format string to a datetime.
+
+ Handles the format 2020-01-22T14:24:01Z (trailing Z)
+ which is not supported by older versions of fromisoformat.
+ """
+ # Python 3.11+ supports Z suffix natively in fromisoformat
+ if sys.version_info >= (3, 11):
+ return datetime.datetime.fromisoformat(isodate)
+ else:
+ return datetime.datetime.fromisoformat(isodate.replace("Z", "+00:00"))
diff --git a/tests/functional/test_build_constraints.py b/tests/functional/test_build_constraints.py
new file mode 100644
index 00000000000..1196eb64cf8
--- /dev/null
+++ b/tests/functional/test_build_constraints.py
@@ -0,0 +1,206 @@
+"""Tests for the build constraints feature."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from unittest import mock
+
+import pytest
+
+from pip._internal.utils.urls import path_to_url
+
+from tests.lib import PipTestEnvironment, TestPipResult, create_test_package_with_setup
+
+
+def _create_simple_test_package(script: PipTestEnvironment, name: str) -> Path:
+ """Create a simple test package with minimal setup."""
+ return create_test_package_with_setup(
+ script,
+ name=name,
+ version="1.0",
+ py_modules=[name],
+ )
+
+
+def _create_constraints_file(
+ script: PipTestEnvironment, filename: str, content: str
+) -> Path:
+ """Create a constraints file with the given content."""
+ constraints_file = script.scratch_path / filename
+ constraints_file.write_text(content)
+ return constraints_file
+
+
+def _run_pip_install_with_build_constraints(
+ script: PipTestEnvironment,
+ project_dir: Path,
+ build_constraints_file: Path,
+ extra_args: list[str] | None = None,
+ expect_error: bool = False,
+) -> TestPipResult:
+ """Run pip install with build constraints and common arguments."""
+ args = [
+ "install",
+ "--no-cache-dir",
+ "--build-constraint",
+ str(build_constraints_file),
+ "--use-feature",
+ "build-constraint",
+ "--use-pep517",
+ ]
+
+ if extra_args:
+ args.extend(extra_args)
+
+ args.append(str(project_dir))
+
+ return script.pip(*args, expect_error=expect_error)
+
+
+def _run_pip_install_with_build_constraints_no_feature_flag(
+ script: PipTestEnvironment,
+ project_dir: Path,
+ constraints_file: Path,
+) -> TestPipResult:
+ """Run pip install with build constraints but without the feature flag."""
+ return script.pip(
+ "install",
+ "--build-constraint",
+ str(constraints_file),
+ "--use-pep517",
+ str(project_dir),
+ )
+
+
+@pytest.mark.network
+def test_build_constraints_basic_functionality_simple(
+ script: PipTestEnvironment, tmpdir: Path
+) -> None:
+ """Test that build constraints options are accepted and processed."""
+ project_dir = _create_simple_test_package(
+ script=script, name="test_build_constraints"
+ )
+ constraints_file = _create_constraints_file(
+ script=script, filename="constraints.txt", content="setuptools>=40.0.0\n"
+ )
+ result = _run_pip_install_with_build_constraints(
+ script=script, project_dir=project_dir, build_constraints_file=constraints_file
+ )
+ result.assert_installed(
+ "test-build-constraints", editable=False, without_files=["."]
+ )
+
+
+@pytest.mark.network
+def test_build_constraints_vs_regular_constraints_simple(
+ script: PipTestEnvironment, tmpdir: Path
+) -> None:
+ """Test that build constraints and regular constraints work independently."""
+ project_dir = create_test_package_with_setup(
+ script,
+ name="test_isolation",
+ version="1.0",
+ py_modules=["test_isolation"],
+ install_requires=["six"],
+ )
+ build_constraints_file = _create_constraints_file(
+ script=script, filename="build_constraints.txt", content="setuptools>=40.0.0\n"
+ )
+ regular_constraints_file = _create_constraints_file(
+ script=script, filename="constraints.txt", content="six>=1.10.0\n"
+ )
+ result = script.pip(
+ "install",
+ "--no-cache-dir",
+ "--build-constraint",
+ build_constraints_file,
+ "--constraint",
+ regular_constraints_file,
+ "--use-feature",
+ "build-constraint",
+ "--use-pep517",
+ str(project_dir),
+ expect_error=False,
+ )
+ assert "Successfully installed" in result.stdout
+ assert "test_isolation" in result.stdout
+
+
+@pytest.mark.network
+def test_build_constraints_environment_isolation_simple(
+ script: PipTestEnvironment, tmpdir: Path
+) -> None:
+ """Test that build constraints work correctly in isolated build environments."""
+ project_dir = _create_simple_test_package(script=script, name="test_env_isolation")
+ constraints_file = _create_constraints_file(
+ script=script, filename="build_constraints.txt", content="setuptools>=40.0.0\n"
+ )
+ result = _run_pip_install_with_build_constraints(
+ script=script,
+ project_dir=project_dir,
+ build_constraints_file=constraints_file,
+ extra_args=["--isolated"],
+ )
+ result.assert_installed("test-env-isolation", editable=False, without_files=["."])
+
+
+@pytest.mark.network
+def test_build_constraints_file_not_found(
+ script: PipTestEnvironment, tmpdir: Path
+) -> None:
+ """Test behavior when build constraints file doesn't exist."""
+ project_dir = _create_simple_test_package(
+ script=script, name="test_missing_constraints"
+ )
+ missing_constraints = script.scratch_path / "missing_constraints.txt"
+ result = _run_pip_install_with_build_constraints(
+ script=script,
+ project_dir=project_dir,
+ build_constraints_file=missing_constraints,
+ expect_error=True,
+ )
+ assert "Could not open requirements file" in result.stderr
+ assert "No such file or directory" in result.stderr
+
+
+@pytest.mark.network
+def test_build_constraints_without_feature_flag(
+ script: PipTestEnvironment, tmpdir: Path
+) -> None:
+ """Test that --build-constraint automatically enables the feature."""
+ project_dir = _create_simple_test_package(script=script, name="test_no_feature")
+ constraints_file = _create_constraints_file(
+ script=script, filename="constraints.txt", content="setuptools>=40.0.0\n"
+ )
+ result = _run_pip_install_with_build_constraints_no_feature_flag(
+ script=script, project_dir=project_dir, constraints_file=constraints_file
+ )
+ # Should succeed now that --build-constraint auto-enables the feature
+ assert result.returncode == 0
+ result.assert_installed("test-no-feature", editable=False, without_files=["."])
+
+
+@pytest.mark.network
+def test_constraints_dont_pass_through(
+ script: PipTestEnvironment, tmpdir: Path
+) -> None:
+ """When build constraints enabled, check PIP_CONSTRAINT won't affect builds."""
+ project_dir = create_test_package_with_setup(
+ script,
+ name="test_isolation",
+ version="1.0",
+ py_modules=["test_isolation"],
+ )
+ constraints = _create_constraints_file(
+ script=script, filename="constraints.txt", content="setuptools==2000\n"
+ )
+ with mock.patch.dict(os.environ, {"PIP_CONSTRAINT": path_to_url(str(constraints))}):
+ result = script.pip(
+ "install",
+ "--no-cache-dir",
+ str(project_dir),
+ "--use-pep517",
+ "--use-feature=build-constraint",
+ )
+ result.assert_installed("test_isolation", editable=False, without_files=["."])
diff --git a/tests/functional/test_uploaded_prior_to.py b/tests/functional/test_uploaded_prior_to.py
new file mode 100644
index 00000000000..2320976f2d2
--- /dev/null
+++ b/tests/functional/test_uploaded_prior_to.py
@@ -0,0 +1,140 @@
+"""Tests for pip install --uploaded-prior-to."""
+
+from __future__ import annotations
+
+import pytest
+
+from tests.lib import PipTestEnvironment, TestData
+from tests.lib.server import (
+ file_response,
+ make_mock_server,
+ package_page,
+ server_running,
+)
+
+
+class TestUploadedPriorTo:
+ """Test --uploaded-prior-to functionality."""
+
+ def test_uploaded_prior_to_invalid_date(
+ self, script: PipTestEnvironment, data: TestData
+ ) -> None:
+ """Test that invalid date format is rejected."""
+ result = script.pip_install_local(
+ "--uploaded-prior-to=invalid-date", "simple", expect_error=True
+ )
+ assert "invalid" in result.stderr.lower() or "error" in result.stderr.lower()
+
+ def test_uploaded_prior_to_file_index_no_upload_time(
+ self, script: PipTestEnvironment, data: TestData
+ ) -> None:
+ """Test that file:// indexes are exempt from upload-time filtering."""
+ result = script.pip(
+ "install",
+ "--index-url",
+ data.index_url("simple"),
+ "--uploaded-prior-to=3030-01-01T00:00:00",
+ "simple",
+ expect_error=False,
+ )
+ assert "Successfully installed simple" in result.stdout
+
+ def test_uploaded_prior_to_http_index_no_upload_time(
+ self, script: PipTestEnvironment, data: TestData
+ ) -> None:
+ """Test that HTTP index without upload-time causes immediate error."""
+ server = make_mock_server()
+ simple_package = data.packages / "simple-1.0.tar.gz"
+ server.mock.side_effect = [
+ package_page({"simple-1.0.tar.gz": "/files/simple-1.0.tar.gz"}),
+ file_response(simple_package),
+ ]
+
+ with server_running(server):
+ result = script.pip(
+ "install",
+ "--index-url",
+ f"http://{server.host}:{server.port}",
+ "--uploaded-prior-to=3030-01-01T00:00:00",
+ "simple",
+ expect_error=True,
+ )
+
+ assert "does not provide upload-time metadata" in result.stderr
+ assert "--uploaded-prior-to" in result.stderr or "Cannot use" in result.stderr
+
+ @pytest.mark.network
+ def test_uploaded_prior_to_with_real_pypi(self, script: PipTestEnvironment) -> None:
+ """Test filtering against real PyPI with upload-time metadata."""
+ # Test with old cutoff date - should find no matching versions
+ result = script.pip(
+ "install",
+ "--dry-run",
+ "--no-deps",
+ "--uploaded-prior-to=2010-01-01T00:00:00",
+ "requests==2.0.0",
+ expect_error=True,
+ )
+ assert "Could not find a version that satisfies" in result.stderr
+
+ # Test with future cutoff date - should find the package
+ result = script.pip(
+ "install",
+ "--dry-run",
+ "--no-deps",
+ "--uploaded-prior-to=2030-01-01T00:00:00",
+ "requests==2.0.0",
+ expect_error=False,
+ )
+ assert "Would install requests-2.0.0" in result.stdout
+
+ @pytest.mark.network
+ def test_uploaded_prior_to_date_formats(self, script: PipTestEnvironment) -> None:
+ """Test various date format strings are accepted."""
+ formats = [
+ "2030-01-01",
+ "2030-01-01T00:00:00",
+ "2030-01-01T00:00:00+00:00",
+ "2030-01-01T00:00:00-05:00",
+ ]
+
+ for date_format in formats:
+ result = script.pip(
+ "install",
+ "--dry-run",
+ "--no-deps",
+ f"--uploaded-prior-to={date_format}",
+ "requests==2.0.0",
+ expect_error=False,
+ )
+ assert "Would install requests-2.0.0" in result.stdout
+
+ def test_uploaded_prior_to_allows_local_files(
+ self, script: PipTestEnvironment, data: TestData
+ ) -> None:
+ """Test that local file installs bypass upload-time filtering."""
+ simple_wheel = data.packages / "simplewheel-1.0-py2.py3-none-any.whl"
+
+ result = script.pip(
+ "install",
+ "--no-index",
+ "--uploaded-prior-to=2000-01-01T00:00:00",
+ str(simple_wheel),
+ expect_error=False,
+ )
+ assert "Successfully installed simplewheel-1.0" in result.stdout
+
+ def test_uploaded_prior_to_allows_find_links(
+ self, script: PipTestEnvironment, data: TestData
+ ) -> None:
+ """Test that --find-links bypasses upload-time filtering."""
+ result = script.pip(
+ "install",
+ "--no-index",
+ "--find-links",
+ data.find_links,
+ "--uploaded-prior-to=2000-01-01T00:00:00",
+ "simple==1.0",
+ expect_error=False,
+ )
+ assert "Successfully installed simple-1.0" in result.stdout
diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py
index 78fe3604480..f3102db24e1 100644
--- a/tests/lib/__init__.py
+++ b/tests/lib/__init__.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import datetime
import json
import os
import pathlib
@@ -107,6 +108,7 @@ def make_test_finder(
allow_all_prereleases: bool = False,
session: PipSession | None = None,
target_python: TargetPython | None = None,
+ uploaded_prior_to: datetime.datetime | None = None,
) -> PackageFinder:
"""
Create a PackageFinder for testing purposes.
@@ -125,6 +127,7 @@ def make_test_finder(
link_collector=link_collector,
selection_prefs=selection_prefs,
target_python=target_python,
+ uploaded_prior_to=uploaded_prior_to,
)
diff --git a/tests/unit/test_build_constraints.py b/tests/unit/test_build_constraints.py
new file mode 100644
index 00000000000..b9505b7bd94
--- /dev/null
+++ b/tests/unit/test_build_constraints.py
@@ -0,0 +1,114 @@
+"""Tests for build constraints functionality."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from unittest import mock
+
+import pytest
+
+from pip._internal.build_env import SubprocessBuildEnvironmentInstaller, _Prefix
+from pip._internal.utils.deprecation import PipDeprecationWarning
+
+from tests.lib import make_test_finder
+
+
+class TestSubprocessBuildEnvironmentInstaller:
+ """Test SubprocessBuildEnvironmentInstaller build constraints functionality."""
+
+ @mock.patch.dict(os.environ, {}, clear=True)
+ def test_deprecation_check_no_pip_constraint(self) -> None:
+ """Test no deprecation warning when PIP_CONSTRAINT is not set."""
+ finder = make_test_finder()
+ installer = SubprocessBuildEnvironmentInstaller(
+ finder,
+ build_constraint_feature_enabled=False,
+ )
+
+ # Should not raise any warning
+ installer._deprecation_constraint_check()
+
+ @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": ""})
+ def test_deprecation_check_empty_pip_constraint(self) -> None:
+ """Test no deprecation warning for empty PIP_CONSTRAINT."""
+ finder = make_test_finder()
+ installer = SubprocessBuildEnvironmentInstaller(
+ finder,
+ build_constraint_feature_enabled=False,
+ )
+
+ # Should not raise any warning since PIP_CONSTRAINT is empty
+ installer._deprecation_constraint_check()
+
+ @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": " "})
+ def test_deprecation_check_whitespace_pip_constraint(self) -> None:
+ """Test no deprecation warning for whitespace-only PIP_CONSTRAINT."""
+ finder = make_test_finder()
+ installer = SubprocessBuildEnvironmentInstaller(
+ finder,
+ build_constraint_feature_enabled=False,
+ )
+
+ # Should not raise any warning since PIP_CONSTRAINT is only whitespace
+ installer._deprecation_constraint_check()
+
+ @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraints.txt"})
+ def test_deprecation_check_feature_enabled(self) -> None:
+ """Test no deprecation warning when build-constraint feature is enabled."""
+ finder = make_test_finder()
+ installer = SubprocessBuildEnvironmentInstaller(
+ finder,
+ build_constraint_feature_enabled=True,
+ )
+
+ # Should not raise any warning
+ installer._deprecation_constraint_check()
+
+ @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraints.txt"})
+ def test_deprecation_check_warning_shown(self) -> None:
+ """Test deprecation warning emitted when PIP_CONSTRAINT is set
+ and build-constraint is not enabled."""
+ finder = make_test_finder()
+ installer = SubprocessBuildEnvironmentInstaller(
+ finder,
+ build_constraint_feature_enabled=False,
+ )
+
+ with pytest.warns(PipDeprecationWarning) as warning_info:
+ installer._deprecation_constraint_check()
+
+ assert len(warning_info) == 1
+ message = str(warning_info[0].message)
+ assert (
+ "Setting PIP_CONSTRAINT will not affect build constraints in the future"
+ in message
+ )
+ assert (
+ "to specify build constraints using "
+ "--build-constraint or PIP_BUILD_CONSTRAINT" in message
+ )
+
+ @mock.patch("pip._internal.build_env.call_subprocess")
+ @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraints.txt"})
+ def test_install_calls_deprecation_check(
+ self, mock_call_subprocess: mock.Mock, tmp_path: Path
+ ) -> None:
+ """Test install method calls deprecation check and proceeds with warning."""
+ finder = make_test_finder()
+ installer = SubprocessBuildEnvironmentInstaller(
+ finder,
+ build_constraint_feature_enabled=False,
+ )
+ prefix = _Prefix(str(tmp_path))
+
+ with pytest.warns(PipDeprecationWarning):
+ installer.install(
+ requirements=["setuptools"],
+ prefix=prefix,
+ kind="build dependencies",
+ for_req=None,
+ )
+
+ # Verify that call_subprocess was called (install proceeded after warning)
+ mock_call_subprocess.assert_called_once()
diff --git a/tests/unit/test_cmdoptions.py b/tests/unit/test_cmdoptions.py
index 9f7e01e3cf4..9316a603a95 100644
--- a/tests/unit/test_cmdoptions.py
+++ b/tests/unit/test_cmdoptions.py
@@ -1,12 +1,17 @@
from __future__ import annotations
+import datetime
import os
+from optparse import Option, OptionParser, Values
from pathlib import Path
from venv import EnvBuilder
import pytest
-from pip._internal.cli.cmdoptions import _convert_python_version
+from pip._internal.cli.cmdoptions import (
+ _convert_python_version,
+ _handle_uploaded_prior_to,
+)
from pip._internal.cli.main_parser import identify_python_interpreter
@@ -51,3 +56,92 @@ def test_identify_python_interpreter_venv(tmpdir: Path) -> None:
# Passing a non-existent file returns None
assert identify_python_interpreter(str(tmpdir / "nonexistent")) is None
+
+
+@pytest.mark.parametrize(
+ "value, expected_datetime",
+ [
+ (
+ "2023-01-01T00:00:00+00:00",
+ datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc),
+ ),
+ (
+ "2023-01-01T12:00:00-05:00",
+ datetime.datetime(
+ *(2023, 1, 1, 12, 0, 0),
+ tzinfo=datetime.timezone(datetime.timedelta(hours=-5)),
+ ),
+ ),
+ ],
+)
+def test_handle_uploaded_prior_to_with_timezone(
+ value: str, expected_datetime: datetime.datetime
+) -> None:
+ """Test that timezone-aware ISO 8601 date strings are parsed correctly."""
+ option = Option("--uploaded-prior-to", dest="uploaded_prior_to")
+ opt = "--uploaded-prior-to"
+ parser = OptionParser()
+ parser.values = Values()
+
+ _handle_uploaded_prior_to(option, opt, value, parser)
+
+ result = parser.values.uploaded_prior_to
+ assert isinstance(result, datetime.datetime)
+ assert result == expected_datetime
+
+
+@pytest.mark.parametrize(
+ "value, expected_date_time",
+ [
+ # Test basic ISO 8601 formats (timezone-naive, will get local timezone)
+ ("2023-01-01T00:00:00", (2023, 1, 1, 0, 0, 0)),
+ ("2023-12-31T23:59:59", (2023, 12, 31, 23, 59, 59)),
+ # Test date only (will be extended to midnight)
+ ("2023-01-01", (2023, 1, 1, 0, 0, 0)),
+ ],
+)
+def test_handle_uploaded_prior_to_naive_dates(
+ value: str, expected_date_time: tuple[int, int, int, int, int, int]
+) -> None:
+ """Test that timezone-naive ISO 8601 date strings get local timezone applied."""
+ option = Option("--uploaded-prior-to", dest="uploaded_prior_to")
+ opt = "--uploaded-prior-to"
+ parser = OptionParser()
+ parser.values = Values()
+
+ _handle_uploaded_prior_to(option, opt, value, parser)
+
+ result = parser.values.uploaded_prior_to
+ assert isinstance(result, datetime.datetime)
+
+ # Check that the date/time components match
+ assert result.timetuple()[:6] == expected_date_time
+
+ # Check that local timezone was applied (result should not be timezone-naive)
+ assert result.tzinfo is not None
+
+ # Verify it's equivalent to creating the same datetime and applying local timezone
+ naive_dt = datetime.datetime(*expected_date_time)
+ expected_with_local_tz = naive_dt.astimezone()
+ assert result == expected_with_local_tz
+
+
+@pytest.mark.parametrize(
+ "invalid_value",
+ [
+ "not-a-date",
+ "2023-13-01", # Invalid month
+ "2023-01-32", # Invalid day
+ "2023-01-01T25:00:00", # Invalid hour
+ "", # Empty string
+ ],
+)
+def test_handle_uploaded_prior_to_invalid_dates(invalid_value: str) -> None:
+ """Test that invalid date strings raise SystemExit via raise_option_error."""
+ option = Option("--uploaded-prior-to", dest="uploaded_prior_to")
+ opt = "--uploaded-prior-to"
+ parser = OptionParser()
+ parser.values = Values()
+
+ with pytest.raises(SystemExit):
+ _handle_uploaded_prior_to(option, opt, invalid_value, parser)
diff --git a/tests/unit/test_finder.py b/tests/unit/test_finder.py
index b93a576f0af..74f366b9af4 100644
--- a/tests/unit/test_finder.py
+++ b/tests/unit/test_finder.py
@@ -1,3 +1,4 @@
+import datetime
import logging
from collections.abc import Iterable
from unittest.mock import Mock, patch
@@ -575,3 +576,40 @@ def test_find_all_candidates_find_links_and_index(data: TestData) -> None:
versions = finder.find_all_candidates("simple")
# first the find-links versions then the page versions
assert [str(v.version) for v in versions] == ["3.0", "2.0", "1.0", "1.0"]
+
+
+class TestPackageFinderUploadedPriorTo:
+ """Test PackageFinder integration with uploaded_prior_to functionality.
+
+ Only effective with indexes that provide upload-time metadata.
+ """
+
+ def test_package_finder_create_with_uploaded_prior_to(self) -> None:
+ """Test that PackageFinder.create() accepts uploaded_prior_to parameter."""
+ uploaded_prior_to = datetime.datetime(
+ 2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+ )
+
+ finder = make_test_finder(uploaded_prior_to=uploaded_prior_to)
+
+ assert finder._uploaded_prior_to == uploaded_prior_to
+
+ def test_package_finder_make_link_evaluator_with_uploaded_prior_to(self) -> None:
+ """Test that PackageFinder creates LinkEvaluator with uploaded_prior_to."""
+ uploaded_prior_to = datetime.datetime(
+ 2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+ )
+
+ finder = make_test_finder(uploaded_prior_to=uploaded_prior_to)
+
+ link_evaluator = finder.make_link_evaluator("test-package")
+ assert link_evaluator._uploaded_prior_to == uploaded_prior_to
+
+ def test_package_finder_uploaded_prior_to_none(self) -> None:
+ """Test that PackageFinder works correctly when uploaded_prior_to is None."""
+ finder = make_test_finder(uploaded_prior_to=None)
+
+ assert finder._uploaded_prior_to is None
+
+ link_evaluator = finder.make_link_evaluator("test-package")
+ assert link_evaluator._uploaded_prior_to is None
diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py
index e571b441f9d..da03b45259b 100644
--- a/tests/unit/test_index.py
+++ b/tests/unit/test_index.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import datetime
import logging
import pytest
@@ -365,6 +366,197 @@ def test_filter_unallowed_hashes__log_message_with_no_match(
check_caplog(caplog, "DEBUG", expected_message)
+class TestLinkEvaluatorUploadedPriorTo:
+ """Test the uploaded_prior_to functionality in LinkEvaluator.
+
+ Only effective with indexes that provide upload-time metadata.
+ """
+
+ def make_test_link_evaluator(
+ self, uploaded_prior_to: datetime.datetime | None = None
+ ) -> LinkEvaluator:
+ """Create a LinkEvaluator for testing."""
+ target_python = TargetPython()
+ return LinkEvaluator(
+ project_name="myproject",
+ canonical_name=canonicalize_name("myproject"),
+ formats=frozenset(["source", "binary"]),
+ target_python=target_python,
+ allow_yanked=True,
+ uploaded_prior_to=uploaded_prior_to,
+ )
+
+ @pytest.mark.parametrize(
+ "upload_time, uploaded_prior_to, expected_result",
+ [
+ # Test case: upload time is before the cutoff (should be accepted)
+ (
+ datetime.datetime(2023, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc),
+ datetime.datetime(2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc),
+ (LinkType.candidate, "1.0"),
+ ),
+ # Test case: upload time is after the cutoff (should be rejected)
+ (
+ datetime.datetime(2023, 8, 1, 12, 0, 0, tzinfo=datetime.timezone.utc),
+ datetime.datetime(2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc),
+ (
+ LinkType.upload_too_late,
+ "Upload time 2023-08-01 12:00:00+00:00 not prior to "
+ "2023-06-01 00:00:00+00:00",
+ ),
+ ),
+ # Test case: upload time equals the cutoff (should be rejected)
+ (
+ datetime.datetime(2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc),
+ datetime.datetime(2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc),
+ (
+ LinkType.upload_too_late,
+ "Upload time 2023-06-01 00:00:00+00:00 not prior to "
+ "2023-06-01 00:00:00+00:00",
+ ),
+ ),
+ # Test case: no uploaded_prior_to set (should be accepted)
+ (
+ datetime.datetime(2023, 8, 1, 12, 0, 0, tzinfo=datetime.timezone.utc),
+ None,
+ (LinkType.candidate, "1.0"),
+ ),
+ ],
+ )
+ def test_evaluate_link_uploaded_prior_to(
+ self,
+ upload_time: datetime.datetime,
+ uploaded_prior_to: datetime.datetime | None,
+ expected_result: tuple[LinkType, str],
+ ) -> None:
+ """Test that links are properly filtered by upload time."""
+ evaluator = self.make_test_link_evaluator(uploaded_prior_to)
+ link = Link(
+ "https://example.com/myproject-1.0.tar.gz",
+ upload_time=upload_time,
+ )
+
+ actual = evaluator.evaluate_link(link)
+ assert actual == expected_result
+
+ def test_evaluate_link_no_upload_time(self) -> None:
+ """Test that links with no upload time cause an error when filter is set."""
+ uploaded_prior_to = datetime.datetime(
+ 2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+ )
+ evaluator = self.make_test_link_evaluator(uploaded_prior_to)
+
+ # Link with no upload_time should be rejected when uploaded_prior_to is set
+ link = Link("https://example.com/myproject-1.0.tar.gz")
+ actual = evaluator.evaluate_link(link)
+
+ # Should be rejected because index doesn't provide upload-time
+ assert actual[0] == LinkType.upload_time_missing
+ assert "Index does not provide upload-time metadata" in actual[1]
+
+ def test_evaluate_link_no_upload_time_no_filter(self) -> None:
+ """Test that links with no upload time are accepted when no filter is set."""
+ # No uploaded_prior_to filter set
+ evaluator = self.make_test_link_evaluator(uploaded_prior_to=None)
+
+ # Link with no upload_time should be accepted when no filter is set
+ link = Link("https://example.com/myproject-1.0.tar.gz")
+ actual = evaluator.evaluate_link(link)
+
+ # Should be accepted as candidate (assuming no other issues)
+ assert actual[0] == LinkType.candidate
+ assert actual[1] == "1.0"
+
+ def test_evaluate_link_timezone_handling(self) -> None:
+ """Test that timezone-aware datetimes are handled correctly."""
+ # Set cutoff time in UTC
+ uploaded_prior_to = datetime.datetime(
+ 2023, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
+ )
+ evaluator = self.make_test_link_evaluator(uploaded_prior_to)
+
+ # Test upload time in different timezone (earlier in UTC)
+ upload_time_est = datetime.datetime(
+ *(2023, 6, 1, 10, 0, 0),
+ tzinfo=datetime.timezone(datetime.timedelta(hours=-5)), # EST
+ )
+ link = Link(
+ "https://example.com/myproject-1.0.tar.gz",
+ upload_time=upload_time_est,
+ )
+
+ actual = evaluator.evaluate_link(link)
+ # 10:00 EST = 15:00 UTC, which is after 12:00 UTC cutoff
+ assert actual[0] == LinkType.upload_too_late
+
+ @pytest.mark.parametrize(
+ "uploaded_prior_to",
+ [
+ datetime.datetime(2023, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc),
+ datetime.datetime(
+ *(2023, 6, 1, 12, 0, 0),
+ tzinfo=datetime.timezone(datetime.timedelta(hours=2)),
+ ),
+ ],
+ )
+ def test_uploaded_prior_to_different_timezone_formats(
+ self, uploaded_prior_to: datetime.datetime
+ ) -> None:
+ """Test that different timezone formats for uploaded_prior_to work."""
+ evaluator = self.make_test_link_evaluator(uploaded_prior_to)
+
+ # Create a link with upload time clearly after the cutoff
+ upload_time = datetime.datetime(
+ 2023, 12, 31, 23, 59, 59, tzinfo=datetime.timezone.utc
+ )
+ link = Link(
+ "https://example.com/myproject-1.0.tar.gz",
+ upload_time=upload_time,
+ )
+
+ actual = evaluator.evaluate_link(link)
+ # Should be rejected regardless of timezone format
+ assert actual[0] == LinkType.upload_too_late
+
+ def test_uploaded_prior_to_boundary_precision(self) -> None:
+ """
+ Test that --uploaded-prior-to 2025-01-01 excludes packages
+ uploaded exactly at 2025-01-01T00:00:00.
+ """
+ # --uploaded-prior-to 2025-01-01 should be strictly less than 2025-01-01
+ cutoff_date = datetime.datetime(
+ 2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+ )
+ evaluator = self.make_test_link_evaluator(uploaded_prior_to=cutoff_date)
+
+ # Package uploaded exactly at 2025-01-01T00:00:00 should be rejected
+ link_at_boundary = Link(
+ "https://example.com/myproject-1.0.tar.gz",
+ upload_time=cutoff_date,
+ )
+ result_at_boundary = evaluator.evaluate_link(link_at_boundary)
+ assert result_at_boundary[0] == LinkType.upload_too_late
+ assert "not prior to" in result_at_boundary[1]
+
+ # Package uploaded 1 second before should be accepted
+ before_cutoff = cutoff_date - datetime.timedelta(seconds=1)
+ link_before = Link(
+ "https://example.com/myproject-1.0.tar.gz",
+ upload_time=before_cutoff,
+ )
+ result_before = evaluator.evaluate_link(link_before)
+ assert result_before[0] == LinkType.candidate
+
+ # Package uploaded 1 second after should be rejected
+ after_cutoff = cutoff_date + datetime.timedelta(seconds=1)
+ link_after = Link(
+ "https://example.com/myproject-1.0.tar.gz",
+ upload_time=after_cutoff,
+ )
+ result_after = evaluator.evaluate_link(link_after)
+ assert result_after[0] == LinkType.upload_too_late
+
+
class TestCandidateEvaluator:
@pytest.mark.parametrize(
"allow_all_prereleases, prefer_binary",