From e1e72e7b953dbf04aa5f1e926ec16166f1a4a767 Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Mon, 20 May 2024 15:50:39 -0400 Subject: [PATCH 01/49] Implement --upload-before --- pyproject.toml | 2 +- src/pip/_internal/cli/cmdoptions.py | 29 +++++++++++++++++++++++ src/pip/_internal/cli/req_command.py | 4 ++-- src/pip/_internal/commands/download.py | 2 ++ src/pip/_internal/commands/index.py | 8 ++++--- src/pip/_internal/commands/install.py | 2 ++ src/pip/_internal/commands/list.py | 6 +++-- src/pip/_internal/commands/wheel.py | 7 +++++- src/pip/_internal/index/package_finder.py | 29 ++++++++++++++++------- src/pip/_internal/models/link.py | 23 +++++++++++++----- 10 files changed, 89 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2da4e4aa2b5..c9b08aa97f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -259,7 +259,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 = 14 # default is 6 max-statements = 134 # default is 50 ###################################################################################### diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 3519dadf13d..4f60f1c5fea 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -11,6 +11,7 @@ # mypy: strict-optional=False from __future__ import annotations +import datetime import importlib.util import logging import os @@ -796,6 +797,34 @@ def _handle_dependency_group( help="Ignore the Requires-Python information.", ) + +def _handle_upload_before( + option: Option, opt: str, value: str, parser: OptionParser +) -> None: + """ + Process a value provided for the --upload-before option. + + This is an optparse.Option callback for the --upload-before option. + """ + if value is None: + return None + upload_before = datetime.datetime.fromisoformat(value) + # Assume local timezone if no offset is given in the ISO string. + if upload_before.tzinfo is None: + upload_before = upload_before.astimezone() + parser.values.upload_before = upload_before + + +upload_before: Callable[..., Option] = partial( + Option, + "--upload-before", + dest="upload_before", + metavar="datetime", + action="callback", + callback=_handle_upload_before, + help="Skip uploads after given time. This should be an ISO 8601 string.", +) + no_build_isolation: Callable[..., Option] = partial( Option, "--no-build-isolation", diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index dc1328ff019..cf83d7d8892 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -5,8 +5,6 @@ PackageFinder machinery and all its vendored dependencies, etc. """ -from __future__ import annotations - import logging from functools import partial from optparse import Values @@ -328,6 +326,7 @@ def _build_package_finder( session: PipSession, target_python: TargetPython | None = None, ignore_requires_python: bool | None = None, + upload_before: datetime.datetime | None = None, ) -> PackageFinder: """ Create a package finder appropriate to this requirement command. @@ -348,4 +347,5 @@ def _build_package_finder( link_collector=link_collector, selection_prefs=selection_prefs, target_python=target_python, + upload_before=upload_before, ) diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index 900fb403d6f..1f3c21eab15 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -51,6 +51,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.upload_before()) self.cmd_opts.add_option( "-d", @@ -93,6 +94,7 @@ def run(self, options: Values, args: list[str]) -> int: session=session, target_python=target_python, ignore_requires_python=options.ignore_requires_python, + upload_before=options.upload_before, ) build_tracker = self.enter_context(get_build_tracker()) diff --git a/src/pip/_internal/commands/index.py b/src/pip/_internal/commands/index.py index ecac99888db..2e94eab4184 100644 --- a/src/pip/_internal/commands/index.py +++ b/src/pip/_internal/commands/index.py @@ -1,6 +1,4 @@ -from __future__ import annotations - -import json +import datetime import logging from collections.abc import Iterable from optparse import Values @@ -40,6 +38,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.upload_before()) self.cmd_opts.add_option(cmdoptions.pre()) self.cmd_opts.add_option(cmdoptions.json()) self.cmd_opts.add_option(cmdoptions.no_binary()) @@ -86,6 +85,7 @@ def _build_package_finder( session: PipSession, target_python: TargetPython | None = None, ignore_requires_python: bool | None = None, + upload_before: datetime.datetime | None = None, ) -> PackageFinder: """ Create a package finder appropriate to the index command. @@ -103,6 +103,7 @@ def _build_package_finder( link_collector=link_collector, selection_prefs=selection_prefs, target_python=target_python, + upload_before=upload_before, ) def get_available_package_versions(self, options: Values, args: list[Any]) -> None: @@ -118,6 +119,7 @@ def get_available_package_versions(self, options: Values, args: list[Any]) -> No session=session, target_python=target_python, ignore_requires_python=options.ignore_requires_python, + upload_before=options.upload_before, ) versions: Iterable[Version] = ( diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index 1ef7a0f4410..bb0d6d2c673 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -207,6 +207,7 @@ def add_options(self) -> None: ), ) + self.cmd_opts.add_option(cmdoptions.upload_before()) 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()) @@ -344,6 +345,7 @@ def run(self, options: Values, args: list[str]) -> int: session=session, target_python=target_python, ignore_requires_python=options.ignore_requires_python, + upload_before=options.upload_before, ) build_tracker = self.enter_context(get_build_tracker()) diff --git a/src/pip/_internal/commands/list.py b/src/pip/_internal/commands/list.py index ad27e45ce93..8f1da2b0c84 100644 --- a/src/pip/_internal/commands/list.py +++ b/src/pip/_internal/commands/list.py @@ -143,8 +143,10 @@ def handle_pip_version_check(self, options: Values) -> None: super().handle_pip_version_check(options) def _build_package_finder( - self, options: Values, session: PipSession - ) -> PackageFinder: + self, + options: Values, + session: "PipSession", + ) -> "PackageFinder": """ Create a package finder appropriate to this list command. """ diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py index 61be254912f..ee15cab6928 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -64,6 +64,7 @@ def add_options(self) -> None: 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.upload_before()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.progress_bar()) @@ -103,7 +104,11 @@ def add_options(self) -> None: def run(self, options: Values, args: list[str]) -> int: session = self.get_default_session(options) - finder = self._build_package_finder(options, session) + finder = self._build_package_finder( + options=options, + session=session, + upload_before=options.upload_before, + ) options.wheel_dir = normalize_path(options.wheel_dir) ensure_dir(options.wheel_dir) diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py index bc523cd42d8..264608a57c9 100644 --- a/src/pip/_internal/index/package_finder.py +++ b/src/pip/_internal/index/package_finder.py @@ -1,7 +1,5 @@ """Routines related to PyPI, indexes""" -from __future__ import annotations - import enum import functools import itertools @@ -111,6 +109,7 @@ class LinkType(enum.Enum): format_invalid = enum.auto() platform_mismatch = enum.auto() requires_python_mismatch = enum.auto() + upload_too_late = enum.auto() class LinkEvaluator: @@ -131,7 +130,8 @@ def __init__( formats: frozenset[str], target_python: TargetPython, allow_yanked: bool, - ignore_requires_python: bool | None = None, + ignore_requires_python: Optional[bool] = None, + upload_before: Optional[datetime.datetime] = None, ) -> None: """ :param project_name: The user supplied package name. @@ -149,6 +149,7 @@ def __init__( :param ignore_requires_python: Whether to ignore incompatible PEP 503 "data-requires-python" values in HTML links. Defaults to False. + :param upload_before: If set, only allow links prior to the given date. """ if ignore_requires_python is None: ignore_requires_python = False @@ -158,6 +159,7 @@ def __init__( self._ignore_requires_python = ignore_requires_python self._formats = formats self._target_python = target_python + self._upload_before = upload_before self.project_name = project_name @@ -176,6 +178,11 @@ def evaluate_link(self, link: Link) -> tuple[LinkType, str]: reason = link.yanked_reason or "" return (LinkType.yanked, f"yanked for reason: {reason}") + if link.upload_time is not None and self._upload_before is not None: + if link.upload_time > self._upload_before: + reason = f"Upload time {link.upload_time} after {self._upload_before}" + return (LinkType.upload_too_late, reason) + if link.egg_fragment: egg_info = link.egg_fragment ext = link.ext @@ -590,9 +597,10 @@ def __init__( link_collector: LinkCollector, target_python: TargetPython, allow_yanked: bool, - format_control: FormatControl | None = None, - candidate_prefs: CandidatePreferences | None = None, - ignore_requires_python: bool | None = None, + format_control: Optional[FormatControl] = None, + candidate_prefs: Optional[CandidatePreferences] = None, + ignore_requires_python: Optional[bool] = None, + upload_before: Optional[datetime.datetime] = None, ) -> None: """ This constructor is primarily meant to be used by the create() class @@ -614,6 +622,7 @@ def __init__( self._ignore_requires_python = ignore_requires_python self._link_collector = link_collector self._target_python = target_python + self._upload_before = upload_before self.format_control = format_control @@ -636,8 +645,9 @@ def create( cls, link_collector: LinkCollector, selection_prefs: SelectionPreferences, - target_python: TargetPython | None = None, - ) -> PackageFinder: + target_python: Optional[TargetPython] = None, + upload_before: Optional[datetime.datetime] = None, + ) -> "PackageFinder": """Create a PackageFinder. :param selection_prefs: The candidate selection preferences, as a @@ -645,6 +655,7 @@ 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 upload_before: If set, only find links prior to the given date. """ if target_python is None: target_python = TargetPython() @@ -661,6 +672,7 @@ def create( allow_yanked=selection_prefs.allow_yanked, format_control=selection_prefs.format_control, ignore_requires_python=selection_prefs.ignore_requires_python, + upload_before=upload_before, ) @property @@ -739,6 +751,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, + upload_before=self._upload_before, ) def _sort_links(self, links: Iterable[Link]) -> list[Link]: diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py index 2e2c0f836ac..6cf732dff1f 100644 --- a/src/pip/_internal/models/link.py +++ b/src/pip/_internal/models/link.py @@ -1,5 +1,4 @@ -from __future__ import annotations - +import datetime import functools import itertools import logging @@ -207,6 +206,7 @@ class Link: "requires_python", "yanked_reason", "metadata_file_data", + "upload_time", "cache_link_parsing", "egg_fragment", ] @@ -214,10 +214,11 @@ class Link: def __init__( self, url: str, - comes_from: str | IndexContent | None = None, - requires_python: str | None = None, - yanked_reason: str | None = None, - metadata_file_data: MetadataFile | None = None, + comes_from: Optional[Union[str, "IndexContent"]] = None, + requires_python: Optional[str] = None, + yanked_reason: Optional[str] = None, + metadata_file_data: Optional[MetadataFile] = None, + upload_time: Optional[datetime.datetime] = None, cache_link_parsing: bool = True, hashes: Mapping[str, str] | None = None, ) -> None: @@ -239,6 +240,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 +275,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 +304,12 @@ def from_json( if metadata_info is None: metadata_info = file_data.get("dist-info-metadata") + upload_time: Optional[datetime.datetime] + if upload_time_data := file_data.get("upload-time"): + upload_time = datetime.datetime.fromisoformat(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 +335,7 @@ def from_json( yanked_reason=yanked_reason, hashes=hashes, metadata_file_data=metadata_file_data, + upload_time=upload_time, ) @classmethod From c8b24817f10903d126a97e1a33edda8489982d5b Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 5 Aug 2025 22:08:00 -0400 Subject: [PATCH 02/49] Fix merge errors and rename to "exclude-newer-than" --- src/pip/_internal/cli/cmdoptions.py | 25 +++++++++++----------- src/pip/_internal/cli/req_command.py | 7 ++++-- src/pip/_internal/commands/download.py | 4 ++-- src/pip/_internal/commands/index.py | 11 ++++++---- src/pip/_internal/commands/install.py | 4 ++-- src/pip/_internal/commands/list.py | 4 ++-- src/pip/_internal/commands/wheel.py | 4 ++-- src/pip/_internal/index/package_finder.py | 26 ++++++++++++----------- src/pip/_internal/models/link.py | 2 ++ 9 files changed, 49 insertions(+), 38 deletions(-) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 4f60f1c5fea..4b7df3cbbd3 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -798,31 +798,32 @@ def _handle_dependency_group( ) -def _handle_upload_before( +def _handle_exclude_newer_than( option: Option, opt: str, value: str, parser: OptionParser ) -> None: """ - Process a value provided for the --upload-before option. + Process a value provided for the --exclude-newer-than option. - This is an optparse.Option callback for the --upload-before option. + This is an optparse.Option callback for the --exclude-newer-than option. """ if value is None: return None - upload_before = datetime.datetime.fromisoformat(value) + exclude_newer_than = datetime.datetime.fromisoformat(value) # Assume local timezone if no offset is given in the ISO string. - if upload_before.tzinfo is None: - upload_before = upload_before.astimezone() - parser.values.upload_before = upload_before + if exclude_newer_than.tzinfo is None: + exclude_newer_than = exclude_newer_than.astimezone() + parser.values.exclude_newer_than = exclude_newer_than -upload_before: Callable[..., Option] = partial( +exclude_newer_than: Callable[..., Option] = partial( Option, - "--upload-before", - dest="upload_before", + "--exclude-newer-than", + dest="exclude_newer_than", metavar="datetime", action="callback", - callback=_handle_upload_before, - help="Skip uploads after given time. This should be an ISO 8601 string.", + callback=_handle_exclude_newer_than, + type="str", + help="Exclude packages newer than given time. This should be an ISO 8601 string.", ) no_build_isolation: Callable[..., Option] = partial( diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index cf83d7d8892..cd735e348c2 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -5,6 +5,9 @@ PackageFinder machinery and all its vendored dependencies, etc. """ +from __future__ import annotations + +import datetime import logging from functools import partial from optparse import Values @@ -326,7 +329,7 @@ def _build_package_finder( session: PipSession, target_python: TargetPython | None = None, ignore_requires_python: bool | None = None, - upload_before: datetime.datetime | None = None, + exclude_newer_than: datetime.datetime | None = None, ) -> PackageFinder: """ Create a package finder appropriate to this requirement command. @@ -347,5 +350,5 @@ def _build_package_finder( link_collector=link_collector, selection_prefs=selection_prefs, target_python=target_python, - upload_before=upload_before, + exclude_newer_than=exclude_newer_than, ) diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index 1f3c21eab15..4416fde33bd 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -51,7 +51,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.upload_before()) + self.cmd_opts.add_option(cmdoptions.exclude_newer_than()) self.cmd_opts.add_option( "-d", @@ -94,7 +94,7 @@ def run(self, options: Values, args: list[str]) -> int: session=session, target_python=target_python, ignore_requires_python=options.ignore_requires_python, - upload_before=options.upload_before, + exclude_newer_than=options.exclude_newer_than, ) build_tracker = self.enter_context(get_build_tracker()) diff --git a/src/pip/_internal/commands/index.py b/src/pip/_internal/commands/index.py index 2e94eab4184..2d4571bc9f1 100644 --- a/src/pip/_internal/commands/index.py +++ b/src/pip/_internal/commands/index.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import datetime +import json import logging from collections.abc import Iterable from optparse import Values @@ -38,7 +41,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.upload_before()) + self.cmd_opts.add_option(cmdoptions.exclude_newer_than()) self.cmd_opts.add_option(cmdoptions.pre()) self.cmd_opts.add_option(cmdoptions.json()) self.cmd_opts.add_option(cmdoptions.no_binary()) @@ -85,7 +88,7 @@ def _build_package_finder( session: PipSession, target_python: TargetPython | None = None, ignore_requires_python: bool | None = None, - upload_before: datetime.datetime | None = None, + exclude_newer_than: datetime.datetime | None = None, ) -> PackageFinder: """ Create a package finder appropriate to the index command. @@ -103,7 +106,7 @@ def _build_package_finder( link_collector=link_collector, selection_prefs=selection_prefs, target_python=target_python, - upload_before=upload_before, + exclude_newer_than=exclude_newer_than, ) def get_available_package_versions(self, options: Values, args: list[Any]) -> None: @@ -119,7 +122,7 @@ def get_available_package_versions(self, options: Values, args: list[Any]) -> No session=session, target_python=target_python, ignore_requires_python=options.ignore_requires_python, - upload_before=options.upload_before, + exclude_newer_than=options.exclude_newer_than, ) versions: Iterable[Version] = ( diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index bb0d6d2c673..c109816aa59 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -207,7 +207,7 @@ def add_options(self) -> None: ), ) - self.cmd_opts.add_option(cmdoptions.upload_before()) + self.cmd_opts.add_option(cmdoptions.exclude_newer_than()) 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()) @@ -345,7 +345,7 @@ def run(self, options: Values, args: list[str]) -> int: session=session, target_python=target_python, ignore_requires_python=options.ignore_requires_python, - upload_before=options.upload_before, + exclude_newer_than=options.exclude_newer_than, ) build_tracker = self.enter_context(get_build_tracker()) diff --git a/src/pip/_internal/commands/list.py b/src/pip/_internal/commands/list.py index 8f1da2b0c84..8d1cf595bc4 100644 --- a/src/pip/_internal/commands/list.py +++ b/src/pip/_internal/commands/list.py @@ -145,8 +145,8 @@ def handle_pip_version_check(self, options: Values) -> None: def _build_package_finder( self, options: Values, - session: "PipSession", - ) -> "PackageFinder": + session: PipSession, + ) -> PackageFinder: """ Create a package finder appropriate to this list command. """ diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py index ee15cab6928..bd82fcc0886 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -64,7 +64,7 @@ def add_options(self) -> None: 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.upload_before()) + self.cmd_opts.add_option(cmdoptions.exclude_newer_than()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.progress_bar()) @@ -107,7 +107,7 @@ def run(self, options: Values, args: list[str]) -> int: finder = self._build_package_finder( options=options, session=session, - upload_before=options.upload_before, + exclude_newer_than=options.exclude_newer_than, ) options.wheel_dir = normalize_path(options.wheel_dir) diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py index 264608a57c9..1acc2cc85c1 100644 --- a/src/pip/_internal/index/package_finder.py +++ b/src/pip/_internal/index/package_finder.py @@ -1,5 +1,7 @@ """Routines related to PyPI, indexes""" +from __future__ import annotations +import datetime import enum import functools import itertools @@ -131,7 +133,7 @@ def __init__( target_python: TargetPython, allow_yanked: bool, ignore_requires_python: Optional[bool] = None, - upload_before: Optional[datetime.datetime] = None, + exclude_newer_than: Optional[datetime.datetime] = None, ) -> None: """ :param project_name: The user supplied package name. @@ -149,7 +151,7 @@ def __init__( :param ignore_requires_python: Whether to ignore incompatible PEP 503 "data-requires-python" values in HTML links. Defaults to False. - :param upload_before: If set, only allow links prior to the given date. + :param exclude_newer_than: If set, only allow links prior to the given date. """ if ignore_requires_python is None: ignore_requires_python = False @@ -159,7 +161,7 @@ def __init__( self._ignore_requires_python = ignore_requires_python self._formats = formats self._target_python = target_python - self._upload_before = upload_before + self._exclude_newer_than = exclude_newer_than self.project_name = project_name @@ -178,9 +180,9 @@ def evaluate_link(self, link: Link) -> tuple[LinkType, str]: reason = link.yanked_reason or "" return (LinkType.yanked, f"yanked for reason: {reason}") - if link.upload_time is not None and self._upload_before is not None: - if link.upload_time > self._upload_before: - reason = f"Upload time {link.upload_time} after {self._upload_before}" + if link.upload_time is not None and self._exclude_newer_than is not None: + if link.upload_time > self._exclude_newer_than: + reason = f"Upload time {link.upload_time} after {self._exclude_newer_than}" return (LinkType.upload_too_late, reason) if link.egg_fragment: @@ -600,7 +602,7 @@ def __init__( format_control: Optional[FormatControl] = None, candidate_prefs: Optional[CandidatePreferences] = None, ignore_requires_python: Optional[bool] = None, - upload_before: Optional[datetime.datetime] = None, + exclude_newer_than: Optional[datetime.datetime] = None, ) -> None: """ This constructor is primarily meant to be used by the create() class @@ -622,7 +624,7 @@ def __init__( self._ignore_requires_python = ignore_requires_python self._link_collector = link_collector self._target_python = target_python - self._upload_before = upload_before + self._exclude_newer_than = exclude_newer_than self.format_control = format_control @@ -646,7 +648,7 @@ def create( link_collector: LinkCollector, selection_prefs: SelectionPreferences, target_python: Optional[TargetPython] = None, - upload_before: Optional[datetime.datetime] = None, + exclude_newer_than: Optional[datetime.datetime] = None, ) -> "PackageFinder": """Create a PackageFinder. @@ -655,7 +657,7 @@ 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 upload_before: If set, only find links prior to the given date. + :param exclude_newer_than: If set, only find links prior to the given date. """ if target_python is None: target_python = TargetPython() @@ -672,7 +674,7 @@ def create( allow_yanked=selection_prefs.allow_yanked, format_control=selection_prefs.format_control, ignore_requires_python=selection_prefs.ignore_requires_python, - upload_before=upload_before, + exclude_newer_than=exclude_newer_than, ) @property @@ -751,7 +753,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, - upload_before=self._upload_before, + exclude_newer_than=self._exclude_newer_than, ) def _sort_links(self, links: Iterable[Link]) -> list[Link]: diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py index 6cf732dff1f..f6172e51008 100644 --- a/src/pip/_internal/models/link.py +++ b/src/pip/_internal/models/link.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime import functools import itertools From a53d08afe63bf40c2ea1c7cdc9bd7015a058506c Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 5 Aug 2025 22:09:04 -0400 Subject: [PATCH 03/49] Make common parse_iso_time --- src/pip/_internal/models/link.py | 22 ++++++++++------------ src/pip/_internal/self_outdated_check.py | 12 ++---------- src/pip/_internal/utils/datetime.py | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py index f6172e51008..07c06c0b102 100644 --- a/src/pip/_internal/models/link.py +++ b/src/pip/_internal/models/link.py @@ -8,14 +8,12 @@ import posixpath import re import urllib.parse +import urllib.request from collections.abc import Mapping from dataclasses import dataclass -from typing import ( - TYPE_CHECKING, - Any, - NamedTuple, -) +from typing import TYPE_CHECKING, Any, 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 @@ -216,11 +214,11 @@ class Link: def __init__( self, url: str, - comes_from: Optional[Union[str, "IndexContent"]] = None, - requires_python: Optional[str] = None, - yanked_reason: Optional[str] = None, - metadata_file_data: Optional[MetadataFile] = None, - upload_time: Optional[datetime.datetime] = None, + comes_from: str | IndexContent | None = None, + 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: @@ -306,9 +304,9 @@ def from_json( if metadata_info is None: metadata_info = file_data.get("dist-info-metadata") - upload_time: Optional[datetime.datetime] + upload_time: datetime.datetime | None if upload_time_data := file_data.get("upload-time"): - upload_time = datetime.datetime.fromisoformat(upload_time_data) + upload_time = parse_iso_datetime(upload_time_data) else: upload_time = None diff --git a/src/pip/_internal/self_outdated_check.py b/src/pip/_internal/self_outdated_check.py index ca507f113a4..760afd955d6 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, @@ -45,15 +46,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] = {} @@ -88,7 +80,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")) From 6db6c94c689511861db67d9d0b4a708432790115 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 5 Aug 2025 22:16:29 -0400 Subject: [PATCH 04/49] Add documentation on how to specify explicit timezone --- src/pip/_internal/cli/cmdoptions.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 4b7df3cbbd3..ae2f50ec7cb 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -805,6 +805,9 @@ def _handle_exclude_newer_than( Process a value provided for the --exclude-newer-than option. This is an optparse.Option callback for the --exclude-newer-than option. + + Parses an ISO 8601 datetime string. If no timezone is specified in the string, + local timezone is used. """ if value is None: return None @@ -823,7 +826,12 @@ def _handle_exclude_newer_than( action="callback", callback=_handle_exclude_newer_than, type="str", - help="Exclude packages newer than given time. This should be an ISO 8601 string.", + help=( + "Exclude packages newer than given time. This should be an ISO 8601 string. " + "If no timezone is specified, local time is used. " + "For consistency across environments, specify the timezone explicitly " + "e.g., '2023-01-01T00:00:00Z' for UTC or '2023-01-01T00:00:00-05:00' for UTC-5." + ), ) no_build_isolation: Callable[..., Option] = partial( From c0ec2ec1f14a9ebef35c6941f4efc706679e8ff9 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 5 Aug 2025 22:22:00 -0400 Subject: [PATCH 05/49] Add exclude-newer tests --- tests/functional/test_exclude_newer.py | 93 +++++++++++++++++ tests/unit/test_cmdoptions.py | 115 ++++++++++++++++++++- tests/unit/test_finder.py | 75 ++++++++++++++ tests/unit/test_index.py | 134 +++++++++++++++++++++++++ 4 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 tests/functional/test_exclude_newer.py diff --git a/tests/functional/test_exclude_newer.py b/tests/functional/test_exclude_newer.py new file mode 100644 index 00000000000..03dbe9707f0 --- /dev/null +++ b/tests/functional/test_exclude_newer.py @@ -0,0 +1,93 @@ +"""Tests for pip install --exclude-newer-than.""" + +from __future__ import annotations + +import pytest + +from tests.lib import PipTestEnvironment, TestData + + +class TestExcludeNewer: + """Test --exclude-newer-than functionality.""" + + def test_exclude_newer_than_invalid_date( + self, script: PipTestEnvironment, data: TestData + ) -> None: + """Test that --exclude-newer-than fails with invalid date format.""" + result = script.pip( + "install", + "--no-index", + "-f", + data.packages, + "--exclude-newer-than=invalid-date", + "simple", + expect_error=True, + ) + + # Should fail with date parsing error + assert "invalid" in result.stderr.lower() or "error" in result.stderr.lower() + + def test_exclude_newer_than_help_text(self, script: PipTestEnvironment) -> None: + """Test that --exclude-newer-than appears in help text.""" + result = script.pip("install", "--help") + assert "--exclude-newer-than" in result.stdout + assert "datetime" in result.stdout + + @pytest.mark.parametrize("command", ["install", "download", "wheel"]) + def test_exclude_newer_than_available_in_commands( + self, script: PipTestEnvironment, command: str + ) -> None: + """Test that --exclude-newer-than is available in relevant commands.""" + result = script.pip(command, "--help") + assert "--exclude-newer-than" in result.stdout + + @pytest.mark.network + def test_exclude_newer_than_with_real_pypi( + self, script: PipTestEnvironment + ) -> None: + """Test exclude-newer functionality against real PyPI with upload times.""" + # Use a small package with known old versions for testing + # requests 2.0.0 was released in 2013 + + # Test 1: With an old cutoff date, should find no matching versions + result = script.pip( + "install", + "--dry-run", + "--exclude-newer-than=2010-01-01T00:00:00", + "requests==2.0.0", + expect_error=True, + ) + # Should fail because requests 2.0.0 was uploaded after 2010 + assert "No matching distribution found" in result.stderr + + # Test 2: With a date that should find the package + result = script.pip( + "install", + "--dry-run", + "--exclude-newer-than=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_exclude_newer_than_date_formats(self, script: PipTestEnvironment) -> None: + """Test different date formats work with real PyPI.""" + # Test various date formats with a well known small package + 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", + f"--exclude-newer-than={date_format}", + "requests==2.0.0", + expect_error=False, + ) + # All dates should allow the package + assert "Would install requests-2.0.0" in result.stdout diff --git a/tests/unit/test_cmdoptions.py b/tests/unit/test_cmdoptions.py index 9f7e01e3cf4..8d46979caf4 100644 --- a/tests/unit/test_cmdoptions.py +++ b/tests/unit/test_cmdoptions.py @@ -1,12 +1,18 @@ from __future__ import annotations +import datetime import os +from collections.abc import Callable +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_exclude_newer_than, +) from pip._internal.cli.main_parser import identify_python_interpreter @@ -51,3 +57,110 @@ 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_check", + [ + # Test with timezone info (should be preserved exactly) + ( + "2023-01-01T00:00:00+00:00", + lambda dt: dt + == datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc), + ), + ( + "2023-01-01T12:00:00-05:00", + lambda dt: ( + dt + == datetime.datetime( + *(2023, 1, 1, 12, 0, 0), + tzinfo=datetime.timezone(datetime.timedelta(hours=-5)), + ) + ), + ), + ], +) +def test_handle_exclude_newer_than_with_timezone( + value: str, expected_check: Callable[[datetime.datetime], bool] +) -> None: + """Test that timezone-aware ISO 8601 date strings are parsed correctly.""" + option = Option("--exclude-newer-than", dest="exclude_newer_than") + opt = "--exclude-newer-than" + parser = OptionParser() + parser.values = Values() + + _handle_exclude_newer_than(option, opt, value, parser) + + result = parser.values.exclude_newer_than + assert isinstance(result, datetime.datetime) + assert expected_check(result) + + +@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_exclude_newer_than_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("--exclude-newer-than", dest="exclude_newer_than") + opt = "--exclude-newer-than" + parser = OptionParser() + parser.values = Values() + + _handle_exclude_newer_than(option, opt, value, parser) + + result = parser.values.exclude_newer_than + assert isinstance(result, datetime.datetime) + + # Check that the date/time components match + ( + expected_year, + expected_month, + expected_day, + expected_hour, + expected_minute, + expected_second, + ) = expected_date_time + assert result.year == expected_year + assert result.month == expected_month + assert result.day == expected_day + assert result.hour == expected_hour + assert result.minute == expected_minute + assert result.second == expected_second + + # 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_exclude_newer_than_invalid_dates(invalid_value: str) -> None: + """Test that invalid date strings raise ValueError.""" + option = Option("--exclude-newer-than", dest="exclude_newer_than") + opt = "--exclude-newer-than" + parser = OptionParser() + parser.values = Values() + + with pytest.raises(ValueError): + _handle_exclude_newer_than(option, opt, invalid_value, parser) diff --git a/tests/unit/test_finder.py b/tests/unit/test_finder.py index c8ab1abb78b..401cc90eeef 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 @@ -10,14 +11,19 @@ import pip._internal.utils.compatibility_tags from pip._internal.exceptions import BestVersionAlreadyInstalled, DistributionNotFound +from pip._internal.index.collector import LinkCollector from pip._internal.index.package_finder import ( CandidateEvaluator, InstallationCandidate, Link, LinkEvaluator, LinkType, + PackageFinder, ) +from pip._internal.models.search_scope import SearchScope +from pip._internal.models.selection_prefs import SelectionPreferences from pip._internal.models.target_python import TargetPython +from pip._internal.network.session import PipSession from pip._internal.req.constructors import install_req_from_line from tests.lib import TestData, make_test_finder @@ -574,3 +580,72 @@ 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 TestPackageFinderExcludeNewerThan: + """Test PackageFinder integration with exclude_newer_than functionality.""" + + def test_package_finder_create_with_exclude_newer_than(self) -> None: + """Test that PackageFinder.create() accepts exclude_newer_than parameter.""" + session = PipSession() + search_scope = SearchScope([], [], no_index=False) + link_collector = LinkCollector(session, search_scope) + selection_prefs = SelectionPreferences( + allow_yanked=False, + allow_all_prereleases=False, + ) + exclude_newer_than = datetime.datetime( + 2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc + ) + + finder = PackageFinder.create( + link_collector=link_collector, + selection_prefs=selection_prefs, + exclude_newer_than=exclude_newer_than, + ) + + assert finder._exclude_newer_than == exclude_newer_than + + def test_package_finder_make_link_evaluator_with_exclude_newer_than(self) -> None: + """Test that PackageFinder creates LinkEvaluator with exclude_newer_than.""" + + session = PipSession() + search_scope = SearchScope([], [], no_index=False) + link_collector = LinkCollector(session, search_scope) + selection_prefs = SelectionPreferences( + allow_yanked=False, + allow_all_prereleases=False, + ) + exclude_newer_than = datetime.datetime( + 2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc + ) + + finder = PackageFinder.create( + link_collector=link_collector, + selection_prefs=selection_prefs, + exclude_newer_than=exclude_newer_than, + ) + + link_evaluator = finder.make_link_evaluator("test-package") + assert link_evaluator._exclude_newer_than == exclude_newer_than + + def test_package_finder_exclude_newer_than_none(self) -> None: + """Test that PackageFinder works correctly when exclude_newer_than is None.""" + session = PipSession() + search_scope = SearchScope([], [], no_index=False) + link_collector = LinkCollector(session, search_scope) + selection_prefs = SelectionPreferences( + allow_yanked=False, + allow_all_prereleases=False, + ) + + finder = PackageFinder.create( + link_collector=link_collector, + selection_prefs=selection_prefs, + exclude_newer_than=None, + ) + + assert finder._exclude_newer_than is None + + link_evaluator = finder.make_link_evaluator("test-package") + assert link_evaluator._exclude_newer_than is None diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py index 9b4c881c050..90c00ba0207 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 @@ -364,6 +365,139 @@ def test_filter_unallowed_hashes__log_message_with_no_match( check_caplog(caplog, "DEBUG", expected_message) +class TestLinkEvaluatorExcludeNewerThan: + """Test the exclude_newer_than functionality in LinkEvaluator.""" + + def make_test_link_evaluator( + self, exclude_newer_than: datetime.datetime | None = None + ) -> LinkEvaluator: + """Create a LinkEvaluator for testing.""" + target_python = TargetPython() + return LinkEvaluator( + project_name="myproject", + canonical_name="myproject", + formats=frozenset(["source", "binary"]), + target_python=target_python, + allow_yanked=True, + exclude_newer_than=exclude_newer_than, + ) + + @pytest.mark.parametrize( + "upload_time, exclude_newer_than, 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 after " + "2023-06-01 00:00:00+00:00", + ), + ), + # Test case: upload time equals the cutoff (should be accepted) + ( + 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.candidate, "1.0"), + ), + # Test case: no exclude_newer_than 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_exclude_newer_than( + self, + upload_time: datetime.datetime, + exclude_newer_than: datetime.datetime | None, + expected_result: tuple[LinkType, str], + ) -> None: + """Test that links are properly filtered by upload time.""" + evaluator = self.make_test_link_evaluator(exclude_newer_than) + 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 are not filtered.""" + exclude_newer_than = datetime.datetime( + 2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc + ) + evaluator = self.make_test_link_evaluator(exclude_newer_than) + + # Link with no upload_time should not be filtered + 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 + exclude_newer_than = datetime.datetime( + 2023, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc + ) + evaluator = self.make_test_link_evaluator(exclude_newer_than) + + # 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( + "exclude_newer_than", + [ + 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_exclude_newer_than_different_timezone_formats( + self, exclude_newer_than: datetime.datetime + ) -> None: + """Test that different timezone formats for exclude_newer_than work.""" + evaluator = self.make_test_link_evaluator(exclude_newer_than) + + # 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 + + class TestCandidateEvaluator: @pytest.mark.parametrize( "allow_all_prereleases, prefer_binary", From 2bf1d3ab3cfa4d26a2edcb25a37dea37c93585fd Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 5 Aug 2025 22:29:45 -0400 Subject: [PATCH 06/49] Pass exclude-newer-than to isolated build install --- src/pip/_internal/build_env.py | 2 ++ src/pip/_internal/index/package_finder.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 3a246a1e349..2100f272891 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -167,6 +167,8 @@ def install( args.append("--pre") if finder.prefer_binary: args.append("--prefer-binary") + if finder.exclude_newer_than: + args.extend(["--exclude-newer-than", finder.exclude_newer_than.isoformat()]) args.append("--") args.extend(requirements) with open_spinner(f"Installing {kind}") as spinner: diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py index 1acc2cc85c1..ac2f019d682 100644 --- a/src/pip/_internal/index/package_finder.py +++ b/src/pip/_internal/index/package_finder.py @@ -734,6 +734,10 @@ def prefer_binary(self) -> bool: def set_prefer_binary(self) -> None: self._candidate_prefs.prefer_binary = True + @property + def exclude_newer_than(self) -> datetime.datetime | None: + return self._exclude_newer_than + def requires_python_skipped_reasons(self) -> list[str]: reasons = { detail From d5adbda6f0251bbeea8f2d9b79bb34740a9acb96 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 5 Aug 2025 22:30:05 -0400 Subject: [PATCH 07/49] NEWS ENTRY --- news/13520.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/13520.feature.rst diff --git a/news/13520.feature.rst b/news/13520.feature.rst new file mode 100644 index 00000000000..6752d128f9a --- /dev/null +++ b/news/13520.feature.rst @@ -0,0 +1 @@ +Add ``--exclude-newer-than`` option to exclude packages uploaded after a given date. From c374b2cd3642e126d8e73b07199465566eda6149 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 5 Aug 2025 22:34:10 -0400 Subject: [PATCH 08/49] Fix linting --- src/pip/_internal/index/package_finder.py | 23 +++++++++++++---------- tests/unit/test_cmdoptions.py | 2 +- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py index ac2f019d682..184da807c5e 100644 --- a/src/pip/_internal/index/package_finder.py +++ b/src/pip/_internal/index/package_finder.py @@ -1,4 +1,5 @@ """Routines related to PyPI, indexes""" + from __future__ import annotations import datetime @@ -132,8 +133,8 @@ def __init__( formats: frozenset[str], target_python: TargetPython, allow_yanked: bool, - ignore_requires_python: Optional[bool] = None, - exclude_newer_than: Optional[datetime.datetime] = None, + ignore_requires_python: bool | None = None, + exclude_newer_than: datetime.datetime | None = None, ) -> None: """ :param project_name: The user supplied package name. @@ -182,7 +183,9 @@ def evaluate_link(self, link: Link) -> tuple[LinkType, str]: if link.upload_time is not None and self._exclude_newer_than is not None: if link.upload_time > self._exclude_newer_than: - reason = f"Upload time {link.upload_time} after {self._exclude_newer_than}" + reason = ( + f"Upload time {link.upload_time} after {self._exclude_newer_than}" + ) return (LinkType.upload_too_late, reason) if link.egg_fragment: @@ -599,10 +602,10 @@ def __init__( link_collector: LinkCollector, target_python: TargetPython, allow_yanked: bool, - format_control: Optional[FormatControl] = None, - candidate_prefs: Optional[CandidatePreferences] = None, - ignore_requires_python: Optional[bool] = None, - exclude_newer_than: Optional[datetime.datetime] = None, + format_control: FormatControl | None = None, + candidate_prefs: CandidatePreferences | None = None, + ignore_requires_python: bool | None = None, + exclude_newer_than: datetime.datetime | None = None, ) -> None: """ This constructor is primarily meant to be used by the create() class @@ -647,9 +650,9 @@ def create( cls, link_collector: LinkCollector, selection_prefs: SelectionPreferences, - target_python: Optional[TargetPython] = None, - exclude_newer_than: Optional[datetime.datetime] = None, - ) -> "PackageFinder": + target_python: TargetPython | None = None, + exclude_newer_than: datetime.datetime | None = None, + ) -> PackageFinder: """Create a PackageFinder. :param selection_prefs: The candidate selection preferences, as a diff --git a/tests/unit/test_cmdoptions.py b/tests/unit/test_cmdoptions.py index 8d46979caf4..721ae1869eb 100644 --- a/tests/unit/test_cmdoptions.py +++ b/tests/unit/test_cmdoptions.py @@ -138,7 +138,7 @@ def test_handle_exclude_newer_than_naive_dates( # 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() From 72f363d56a0eac4cf5adaf5f98cd0e8a976adba1 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 8 Aug 2025 21:50:59 -0400 Subject: [PATCH 09/49] Add helpful error message on incorrect datetime format --- src/pip/_internal/cli/cmdoptions.py | 21 +++++++++++++++------ tests/unit/test_cmdoptions.py | 4 ++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index ae2f50ec7cb..44f3e9917ba 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -11,7 +11,6 @@ # mypy: strict-optional=False from __future__ import annotations -import datetime import importlib.util import logging import os @@ -30,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 @@ -811,11 +811,20 @@ def _handle_exclude_newer_than( """ if value is None: return None - exclude_newer_than = datetime.datetime.fromisoformat(value) - # Assume local timezone if no offset is given in the ISO string. - if exclude_newer_than.tzinfo is None: - exclude_newer_than = exclude_newer_than.astimezone() - parser.values.exclude_newer_than = exclude_newer_than + + try: + exclude_newer_than = parse_iso_datetime(value) + # Use local timezone if no offset is given in the ISO string. + if exclude_newer_than.tzinfo is None: + exclude_newer_than = exclude_newer_than.astimezone() + parser.values.exclude_newer_than = exclude_newer_than + except ValueError as exc: + msg = ( + f"invalid --exclude-newer-than 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) exclude_newer_than: Callable[..., Option] = partial( diff --git a/tests/unit/test_cmdoptions.py b/tests/unit/test_cmdoptions.py index 721ae1869eb..b7a6cee8fcc 100644 --- a/tests/unit/test_cmdoptions.py +++ b/tests/unit/test_cmdoptions.py @@ -156,11 +156,11 @@ def test_handle_exclude_newer_than_naive_dates( ], ) def test_handle_exclude_newer_than_invalid_dates(invalid_value: str) -> None: - """Test that invalid date strings raise ValueError.""" + """Test that invalid date strings raise SystemExit via raise_option_error.""" option = Option("--exclude-newer-than", dest="exclude_newer_than") opt = "--exclude-newer-than" parser = OptionParser() parser.values = Values() - with pytest.raises(ValueError): + with pytest.raises(SystemExit): _handle_exclude_newer_than(option, opt, invalid_value, parser) From 007caf6ae5319fc4f1823ac19ef5d0fc7ecb2f34 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Thu, 14 Aug 2025 21:23:00 -0400 Subject: [PATCH 10/49] Update tests/functional/test_exclude_newer.py Co-authored-by: Richard Si --- tests/functional/test_exclude_newer.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/functional/test_exclude_newer.py b/tests/functional/test_exclude_newer.py index 03dbe9707f0..8970f30a8fe 100644 --- a/tests/functional/test_exclude_newer.py +++ b/tests/functional/test_exclude_newer.py @@ -14,14 +14,8 @@ def test_exclude_newer_than_invalid_date( self, script: PipTestEnvironment, data: TestData ) -> None: """Test that --exclude-newer-than fails with invalid date format.""" - result = script.pip( - "install", - "--no-index", - "-f", - data.packages, - "--exclude-newer-than=invalid-date", - "simple", - expect_error=True, + result = script.pip_install_local( + "--exclude-newer-than=invalid-date", "simple", expect_error=True ) # Should fail with date parsing error From 0f1bc46774434fead5f24a88c35037758cf02e85 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Mon, 18 Aug 2025 20:59:03 -0400 Subject: [PATCH 11/49] Add `--no-deps` to request installs to not download unneeded packages --- tests/functional/test_exclude_newer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/functional/test_exclude_newer.py b/tests/functional/test_exclude_newer.py index 8970f30a8fe..37ce085a850 100644 --- a/tests/functional/test_exclude_newer.py +++ b/tests/functional/test_exclude_newer.py @@ -47,6 +47,7 @@ def test_exclude_newer_than_with_real_pypi( result = script.pip( "install", "--dry-run", + "--no-deps", "--exclude-newer-than=2010-01-01T00:00:00", "requests==2.0.0", expect_error=True, @@ -58,6 +59,7 @@ def test_exclude_newer_than_with_real_pypi( result = script.pip( "install", "--dry-run", + "--no-deps", "--exclude-newer-than=2030-01-01T00:00:00", "requests==2.0.0", expect_error=False, @@ -79,6 +81,7 @@ def test_exclude_newer_than_date_formats(self, script: PipTestEnvironment) -> No result = script.pip( "install", "--dry-run", + "--no-deps", f"--exclude-newer-than={date_format}", "requests==2.0.0", expect_error=False, From ad90024c16cecf5339fca48d2033db04c94d8cbd Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Mon, 18 Aug 2025 20:59:55 -0400 Subject: [PATCH 12/49] Remove excessive functional tests --- tests/functional/test_exclude_newer.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/functional/test_exclude_newer.py b/tests/functional/test_exclude_newer.py index 37ce085a850..d993f774f22 100644 --- a/tests/functional/test_exclude_newer.py +++ b/tests/functional/test_exclude_newer.py @@ -21,20 +21,6 @@ def test_exclude_newer_than_invalid_date( # Should fail with date parsing error assert "invalid" in result.stderr.lower() or "error" in result.stderr.lower() - def test_exclude_newer_than_help_text(self, script: PipTestEnvironment) -> None: - """Test that --exclude-newer-than appears in help text.""" - result = script.pip("install", "--help") - assert "--exclude-newer-than" in result.stdout - assert "datetime" in result.stdout - - @pytest.mark.parametrize("command", ["install", "download", "wheel"]) - def test_exclude_newer_than_available_in_commands( - self, script: PipTestEnvironment, command: str - ) -> None: - """Test that --exclude-newer-than is available in relevant commands.""" - result = script.pip(command, "--help") - assert "--exclude-newer-than" in result.stdout - @pytest.mark.network def test_exclude_newer_than_with_real_pypi( self, script: PipTestEnvironment From 6ff91a4a46eb0fd95500497347c4378f65d156a1 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Mon, 18 Aug 2025 21:12:29 -0400 Subject: [PATCH 13/49] Clean up test_finder tests --- tests/lib/__init__.py | 3 +++ tests/unit/test_finder.py | 46 +++------------------------------------ 2 files changed, 6 insertions(+), 43 deletions(-) diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py index 78fe3604480..02cb08ed43b 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, + exclude_newer_than: 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, + exclude_newer_than=exclude_newer_than, ) diff --git a/tests/unit/test_finder.py b/tests/unit/test_finder.py index 401cc90eeef..b9fa6b0d962 100644 --- a/tests/unit/test_finder.py +++ b/tests/unit/test_finder.py @@ -11,19 +11,14 @@ import pip._internal.utils.compatibility_tags from pip._internal.exceptions import BestVersionAlreadyInstalled, DistributionNotFound -from pip._internal.index.collector import LinkCollector from pip._internal.index.package_finder import ( CandidateEvaluator, InstallationCandidate, Link, LinkEvaluator, LinkType, - PackageFinder, ) -from pip._internal.models.search_scope import SearchScope -from pip._internal.models.selection_prefs import SelectionPreferences from pip._internal.models.target_python import TargetPython -from pip._internal.network.session import PipSession from pip._internal.req.constructors import install_req_from_line from tests.lib import TestData, make_test_finder @@ -587,63 +582,28 @@ class TestPackageFinderExcludeNewerThan: def test_package_finder_create_with_exclude_newer_than(self) -> None: """Test that PackageFinder.create() accepts exclude_newer_than parameter.""" - session = PipSession() - search_scope = SearchScope([], [], no_index=False) - link_collector = LinkCollector(session, search_scope) - selection_prefs = SelectionPreferences( - allow_yanked=False, - allow_all_prereleases=False, - ) exclude_newer_than = datetime.datetime( 2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc ) - finder = PackageFinder.create( - link_collector=link_collector, - selection_prefs=selection_prefs, - exclude_newer_than=exclude_newer_than, - ) + finder = make_test_finder(exclude_newer_than=exclude_newer_than) assert finder._exclude_newer_than == exclude_newer_than def test_package_finder_make_link_evaluator_with_exclude_newer_than(self) -> None: """Test that PackageFinder creates LinkEvaluator with exclude_newer_than.""" - - session = PipSession() - search_scope = SearchScope([], [], no_index=False) - link_collector = LinkCollector(session, search_scope) - selection_prefs = SelectionPreferences( - allow_yanked=False, - allow_all_prereleases=False, - ) exclude_newer_than = datetime.datetime( 2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc ) - finder = PackageFinder.create( - link_collector=link_collector, - selection_prefs=selection_prefs, - exclude_newer_than=exclude_newer_than, - ) + finder = make_test_finder(exclude_newer_than=exclude_newer_than) link_evaluator = finder.make_link_evaluator("test-package") assert link_evaluator._exclude_newer_than == exclude_newer_than def test_package_finder_exclude_newer_than_none(self) -> None: """Test that PackageFinder works correctly when exclude_newer_than is None.""" - session = PipSession() - search_scope = SearchScope([], [], no_index=False) - link_collector = LinkCollector(session, search_scope) - selection_prefs = SelectionPreferences( - allow_yanked=False, - allow_all_prereleases=False, - ) - - finder = PackageFinder.create( - link_collector=link_collector, - selection_prefs=selection_prefs, - exclude_newer_than=None, - ) + finder = make_test_finder(exclude_newer_than=None) assert finder._exclude_newer_than is None From fbe923d439b162d22308cfce6b81fd1a6bc59dab Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Mon, 18 Aug 2025 21:42:45 -0400 Subject: [PATCH 14/49] Update `test_handle_exclude_newer_than_naive_dates` comparison --- tests/unit/test_cmdoptions.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/tests/unit/test_cmdoptions.py b/tests/unit/test_cmdoptions.py index b7a6cee8fcc..c33eea49bb3 100644 --- a/tests/unit/test_cmdoptions.py +++ b/tests/unit/test_cmdoptions.py @@ -121,20 +121,7 @@ def test_handle_exclude_newer_than_naive_dates( assert isinstance(result, datetime.datetime) # Check that the date/time components match - ( - expected_year, - expected_month, - expected_day, - expected_hour, - expected_minute, - expected_second, - ) = expected_date_time - assert result.year == expected_year - assert result.month == expected_month - assert result.day == expected_day - assert result.hour == expected_hour - assert result.minute == expected_minute - assert result.second == expected_second + 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 From 703cdc4f37287c9af8f3a69a8da7c4ae9744ced6 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Mon, 18 Aug 2025 21:51:41 -0400 Subject: [PATCH 15/49] Improve parameter formatting of `test_handle_exclude_newer_than_with_timezone` --- tests/unit/test_cmdoptions.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/tests/unit/test_cmdoptions.py b/tests/unit/test_cmdoptions.py index c33eea49bb3..f5ae2eb2f87 100644 --- a/tests/unit/test_cmdoptions.py +++ b/tests/unit/test_cmdoptions.py @@ -2,7 +2,6 @@ import datetime import os -from collections.abc import Callable from optparse import Option, OptionParser, Values from pathlib import Path from venv import EnvBuilder @@ -60,28 +59,23 @@ def test_identify_python_interpreter_venv(tmpdir: Path) -> None: @pytest.mark.parametrize( - "value, expected_check", + "value, expected_datetime", [ - # Test with timezone info (should be preserved exactly) ( "2023-01-01T00:00:00+00:00", - lambda dt: dt - == datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc), ), ( "2023-01-01T12:00:00-05:00", - lambda dt: ( - dt - == datetime.datetime( - *(2023, 1, 1, 12, 0, 0), - tzinfo=datetime.timezone(datetime.timedelta(hours=-5)), - ) + datetime.datetime( + *(2023, 1, 1, 12, 0, 0), + tzinfo=datetime.timezone(datetime.timedelta(hours=-5)), ), ), ], ) def test_handle_exclude_newer_than_with_timezone( - value: str, expected_check: Callable[[datetime.datetime], bool] + value: str, expected_datetime: datetime.datetime ) -> None: """Test that timezone-aware ISO 8601 date strings are parsed correctly.""" option = Option("--exclude-newer-than", dest="exclude_newer_than") @@ -93,7 +87,7 @@ def test_handle_exclude_newer_than_with_timezone( result = parser.values.exclude_newer_than assert isinstance(result, datetime.datetime) - assert expected_check(result) + assert result == expected_datetime @pytest.mark.parametrize( From 6cf2bec23357c10a35d1e615873a43d25f9bb6f6 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 00:43:27 -0400 Subject: [PATCH 16/49] Get exclude_newer_than from option --- src/pip/_internal/cli/req_command.py | 4 +--- src/pip/_internal/commands/download.py | 1 - src/pip/_internal/commands/index.py | 5 +---- src/pip/_internal/commands/install.py | 1 - src/pip/_internal/commands/wheel.py | 1 - 5 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index cd735e348c2..7ae0eb396dd 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -7,7 +7,6 @@ from __future__ import annotations -import datetime import logging from functools import partial from optparse import Values @@ -329,7 +328,6 @@ def _build_package_finder( session: PipSession, target_python: TargetPython | None = None, ignore_requires_python: bool | None = None, - exclude_newer_than: datetime.datetime | None = None, ) -> PackageFinder: """ Create a package finder appropriate to this requirement command. @@ -350,5 +348,5 @@ def _build_package_finder( link_collector=link_collector, selection_prefs=selection_prefs, target_python=target_python, - exclude_newer_than=exclude_newer_than, + exclude_newer_than=options.exclude_newer_than, ) diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index 4416fde33bd..50a7149470d 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -94,7 +94,6 @@ def run(self, options: Values, args: list[str]) -> int: session=session, target_python=target_python, ignore_requires_python=options.ignore_requires_python, - exclude_newer_than=options.exclude_newer_than, ) build_tracker = self.enter_context(get_build_tracker()) diff --git a/src/pip/_internal/commands/index.py b/src/pip/_internal/commands/index.py index 2d4571bc9f1..408005dcfde 100644 --- a/src/pip/_internal/commands/index.py +++ b/src/pip/_internal/commands/index.py @@ -1,6 +1,5 @@ from __future__ import annotations -import datetime import json import logging from collections.abc import Iterable @@ -88,7 +87,6 @@ def _build_package_finder( session: PipSession, target_python: TargetPython | None = None, ignore_requires_python: bool | None = None, - exclude_newer_than: datetime.datetime | None = None, ) -> PackageFinder: """ Create a package finder appropriate to the index command. @@ -106,7 +104,7 @@ def _build_package_finder( link_collector=link_collector, selection_prefs=selection_prefs, target_python=target_python, - exclude_newer_than=exclude_newer_than, + exclude_newer_than=options.exclude_newer_than, ) def get_available_package_versions(self, options: Values, args: list[Any]) -> None: @@ -122,7 +120,6 @@ def get_available_package_versions(self, options: Values, args: list[Any]) -> No session=session, target_python=target_python, ignore_requires_python=options.ignore_requires_python, - exclude_newer_than=options.exclude_newer_than, ) versions: Iterable[Version] = ( diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index c109816aa59..da5a882931c 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -345,7 +345,6 @@ def run(self, options: Values, args: list[str]) -> int: session=session, target_python=target_python, ignore_requires_python=options.ignore_requires_python, - exclude_newer_than=options.exclude_newer_than, ) build_tracker = self.enter_context(get_build_tracker()) diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py index bd82fcc0886..3ddd8a96a28 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -107,7 +107,6 @@ def run(self, options: Values, args: list[str]) -> int: finder = self._build_package_finder( options=options, session=session, - exclude_newer_than=options.exclude_newer_than, ) options.wheel_dir = normalize_path(options.wheel_dir) From 4713c6d03f9bce2b1990666d7bde9e8f8ae13b46 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 00:43:53 -0400 Subject: [PATCH 17/49] Add exclude-newer-than to the lock command --- src/pip/_internal/commands/lock.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pip/_internal/commands/lock.py b/src/pip/_internal/commands/lock.py index e4a978d5aaa..a54b380b9d6 100644 --- a/src/pip/_internal/commands/lock.py +++ b/src/pip/_internal/commands/lock.py @@ -67,6 +67,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.exclude_newer_than()) 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()) From 841ae12e88015c8ca7bbd791e9ed6d5ad9140a17 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 00:49:29 -0400 Subject: [PATCH 18/49] Remove change in list, links, and wheel --- src/pip/_internal/commands/list.py | 4 +--- src/pip/_internal/commands/wheel.py | 5 +---- src/pip/_internal/models/link.py | 6 +++++- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/pip/_internal/commands/list.py b/src/pip/_internal/commands/list.py index 8d1cf595bc4..ad27e45ce93 100644 --- a/src/pip/_internal/commands/list.py +++ b/src/pip/_internal/commands/list.py @@ -143,9 +143,7 @@ def handle_pip_version_check(self, options: Values) -> None: super().handle_pip_version_check(options) def _build_package_finder( - self, - options: Values, - session: PipSession, + self, options: Values, session: PipSession ) -> PackageFinder: """ Create a package finder appropriate to this list command. diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py index 3ddd8a96a28..9ee6c9b45d3 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -104,10 +104,7 @@ def add_options(self) -> None: def run(self, options: Values, args: list[str]) -> int: session = self.get_default_session(options) - finder = self._build_package_finder( - options=options, - session=session, - ) + finder = self._build_package_finder(options, session) options.wheel_dir = normalize_path(options.wheel_dir) ensure_dir(options.wheel_dir) diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py index 07c06c0b102..140f2cc47db 100644 --- a/src/pip/_internal/models/link.py +++ b/src/pip/_internal/models/link.py @@ -11,7 +11,11 @@ import urllib.request from collections.abc import Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import ( + TYPE_CHECKING, + Any, + NamedTuple, +) from pip._internal.utils.datetime import parse_iso_datetime from pip._internal.utils.deprecation import deprecated From 61ec9b09aed6a204c1e166d5bfd0f47fd51c96af Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 10:01:10 -0400 Subject: [PATCH 19/49] Update docs and news items to make clear index needs to provide `upload-time` field --- news/13520.feature.rst | 3 ++- src/pip/_internal/cli/cmdoptions.py | 11 +++++++---- tests/functional/test_exclude_newer.py | 10 ++++++++-- tests/unit/test_finder.py | 5 ++++- tests/unit/test_index.py | 5 ++++- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/news/13520.feature.rst b/news/13520.feature.rst index 6752d128f9a..0f6e2be599b 100644 --- a/news/13520.feature.rst +++ b/news/13520.feature.rst @@ -1 +1,2 @@ -Add ``--exclude-newer-than`` option to exclude packages uploaded after a given date. +Add ``--exclude-newer-than`` option to exclude packages uploaded after a given date, +only effective with indexes that provide upload-time metadata. diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 44f3e9917ba..cb7a365a385 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -808,6 +808,10 @@ def _handle_exclude_newer_than( 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 @@ -836,10 +840,9 @@ def _handle_exclude_newer_than( callback=_handle_exclude_newer_than, type="str", help=( - "Exclude packages newer than given time. This should be an ISO 8601 string. " - "If no timezone is specified, local time is used. " - "For consistency across environments, specify the timezone explicitly " - "e.g., '2023-01-01T00:00:00Z' for UTC or '2023-01-01T00:00:00-05:00' for UTC-5." + "Exclude packages newer than given 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." ), ) diff --git a/tests/functional/test_exclude_newer.py b/tests/functional/test_exclude_newer.py index d993f774f22..c1d0ffecedc 100644 --- a/tests/functional/test_exclude_newer.py +++ b/tests/functional/test_exclude_newer.py @@ -1,4 +1,7 @@ -"""Tests for pip install --exclude-newer-than.""" +"""Tests for pip install --exclude-newer-than. + +Only effective with indexes that provide upload-time metadata. +""" from __future__ import annotations @@ -8,7 +11,10 @@ class TestExcludeNewer: - """Test --exclude-newer-than functionality.""" + """Test --exclude-newer-than functionality. + + Only effective with indexes that provide upload-time metadata. + """ def test_exclude_newer_than_invalid_date( self, script: PipTestEnvironment, data: TestData diff --git a/tests/unit/test_finder.py b/tests/unit/test_finder.py index b9fa6b0d962..bcad8de6af2 100644 --- a/tests/unit/test_finder.py +++ b/tests/unit/test_finder.py @@ -578,7 +578,10 @@ def test_find_all_candidates_find_links_and_index(data: TestData) -> None: class TestPackageFinderExcludeNewerThan: - """Test PackageFinder integration with exclude_newer_than functionality.""" + """Test PackageFinder integration with exclude_newer_than functionality. + + Only effective with indexes that provide upload-time metadata. + """ def test_package_finder_create_with_exclude_newer_than(self) -> None: """Test that PackageFinder.create() accepts exclude_newer_than parameter.""" diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py index 90c00ba0207..7cc2d2b3fdb 100644 --- a/tests/unit/test_index.py +++ b/tests/unit/test_index.py @@ -366,7 +366,10 @@ def test_filter_unallowed_hashes__log_message_with_no_match( class TestLinkEvaluatorExcludeNewerThan: - """Test the exclude_newer_than functionality in LinkEvaluator.""" + """Test the exclude_newer_than functionality in LinkEvaluator. + + Only effective with indexes that provide upload-time metadata. + """ def make_test_link_evaluator( self, exclude_newer_than: datetime.datetime | None = None From e1f274a51322af0ce69693fdeb0b477ab9cde5c7 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Thu, 21 Aug 2025 09:48:08 -0400 Subject: [PATCH 20/49] Change name to uploaded prior to --- news/13520.feature.rst | 4 +- src/pip/_internal/build_env.py | 4 +- src/pip/_internal/cli/cmdoptions.py | 31 ++++--- src/pip/_internal/cli/req_command.py | 2 +- src/pip/_internal/commands/download.py | 2 +- src/pip/_internal/commands/index.py | 4 +- src/pip/_internal/commands/install.py | 2 +- src/pip/_internal/commands/lock.py | 2 +- src/pip/_internal/commands/wheel.py | 2 +- src/pip/_internal/index/package_finder.py | 31 +++---- ...ude_newer.py => test_uploaded_prior_to.py} | 29 +++---- tests/lib/__init__.py | 4 +- tests/unit/test_cmdoptions.py | 30 +++---- tests/unit/test_finder.py | 34 ++++---- tests/unit/test_index.py | 84 ++++++++++++++----- 15 files changed, 152 insertions(+), 113 deletions(-) rename tests/functional/{test_exclude_newer.py => test_uploaded_prior_to.py} (70%) diff --git a/news/13520.feature.rst b/news/13520.feature.rst index 0f6e2be599b..54c272bba79 100644 --- a/news/13520.feature.rst +++ b/news/13520.feature.rst @@ -1,2 +1,2 @@ -Add ``--exclude-newer-than`` option to exclude packages uploaded after a given date, -only effective with indexes that provide upload-time metadata. +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/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 2100f272891..39d9db06522 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -167,8 +167,8 @@ def install( args.append("--pre") if finder.prefer_binary: args.append("--prefer-binary") - if finder.exclude_newer_than: - args.extend(["--exclude-newer-than", finder.exclude_newer_than.isoformat()]) + if finder.uploaded_prior_to: + args.extend(["--uploaded-prior-to", finder.uploaded_prior_to.isoformat()]) args.append("--") args.extend(requirements) with open_spinner(f"Installing {kind}") as spinner: diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index cb7a365a385..89fbc1fcaa9 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -798,13 +798,11 @@ def _handle_dependency_group( ) -def _handle_exclude_newer_than( +def _handle_uploaded_prior_to( option: Option, opt: str, value: str, parser: OptionParser ) -> None: """ - Process a value provided for the --exclude-newer-than option. - - This is an optparse.Option callback for the --exclude-newer-than option. + 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. @@ -817,32 +815,33 @@ def _handle_exclude_newer_than( return None try: - exclude_newer_than = parse_iso_datetime(value) + uploaded_prior_to = parse_iso_datetime(value) # Use local timezone if no offset is given in the ISO string. - if exclude_newer_than.tzinfo is None: - exclude_newer_than = exclude_newer_than.astimezone() - parser.values.exclude_newer_than = exclude_newer_than + 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 --exclude-newer-than value: {value!r}: {exc}. " + 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) -exclude_newer_than: Callable[..., Option] = partial( +uploaded_prior_to: Callable[..., Option] = partial( Option, - "--exclude-newer-than", - dest="exclude_newer_than", + "--uploaded-prior-to", + dest="uploaded_prior_to", metavar="datetime", action="callback", - callback=_handle_exclude_newer_than, + callback=_handle_uploaded_prior_to, type="str", help=( - "Exclude packages newer than given 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." + "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." ), ) diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index 7ae0eb396dd..0a8758ca355 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -348,5 +348,5 @@ def _build_package_finder( link_collector=link_collector, selection_prefs=selection_prefs, target_python=target_python, - exclude_newer_than=options.exclude_newer_than, + uploaded_prior_to=options.uploaded_prior_to, ) diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index 50a7149470d..c09e4a39c80 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -51,7 +51,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.exclude_newer_than()) + self.cmd_opts.add_option(cmdoptions.uploaded_prior_to()) self.cmd_opts.add_option( "-d", diff --git a/src/pip/_internal/commands/index.py b/src/pip/_internal/commands/index.py index 408005dcfde..b53099452e0 100644 --- a/src/pip/_internal/commands/index.py +++ b/src/pip/_internal/commands/index.py @@ -40,7 +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.exclude_newer_than()) + 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()) @@ -104,7 +104,7 @@ def _build_package_finder( link_collector=link_collector, selection_prefs=selection_prefs, target_python=target_python, - exclude_newer_than=options.exclude_newer_than, + 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 da5a882931c..cdb3716a684 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -207,7 +207,7 @@ def add_options(self) -> None: ), ) - self.cmd_opts.add_option(cmdoptions.exclude_newer_than()) + 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()) diff --git a/src/pip/_internal/commands/lock.py b/src/pip/_internal/commands/lock.py index a54b380b9d6..c00d7d45e11 100644 --- a/src/pip/_internal/commands/lock.py +++ b/src/pip/_internal/commands/lock.py @@ -67,7 +67,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.exclude_newer_than()) + 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()) diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py index 9ee6c9b45d3..dfa144a871a 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -64,7 +64,7 @@ def add_options(self) -> None: 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.exclude_newer_than()) + 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()) diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py index 184da807c5e..fe959efa9e4 100644 --- a/src/pip/_internal/index/package_finder.py +++ b/src/pip/_internal/index/package_finder.py @@ -134,7 +134,7 @@ def __init__( target_python: TargetPython, allow_yanked: bool, ignore_requires_python: bool | None = None, - exclude_newer_than: datetime.datetime | None = None, + uploaded_prior_to: datetime.datetime | None = None, ) -> None: """ :param project_name: The user supplied package name. @@ -152,7 +152,8 @@ def __init__( :param ignore_requires_python: Whether to ignore incompatible PEP 503 "data-requires-python" values in HTML links. Defaults to False. - :param exclude_newer_than: If set, only allow links prior to the given date. + :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 @@ -162,7 +163,7 @@ def __init__( self._ignore_requires_python = ignore_requires_python self._formats = formats self._target_python = target_python - self._exclude_newer_than = exclude_newer_than + self._uploaded_prior_to = uploaded_prior_to self.project_name = project_name @@ -181,10 +182,11 @@ def evaluate_link(self, link: Link) -> tuple[LinkType, str]: reason = link.yanked_reason or "" return (LinkType.yanked, f"yanked for reason: {reason}") - if link.upload_time is not None and self._exclude_newer_than is not None: - if link.upload_time > self._exclude_newer_than: + if link.upload_time is not None and self._uploaded_prior_to is not None: + if link.upload_time >= self._uploaded_prior_to: reason = ( - f"Upload time {link.upload_time} after {self._exclude_newer_than}" + f"Upload time {link.upload_time} not " + f"prior to {self._uploaded_prior_to}" ) return (LinkType.upload_too_late, reason) @@ -605,7 +607,7 @@ def __init__( format_control: FormatControl | None = None, candidate_prefs: CandidatePreferences | None = None, ignore_requires_python: bool | None = None, - exclude_newer_than: datetime.datetime | None = None, + uploaded_prior_to: datetime.datetime | None = None, ) -> None: """ This constructor is primarily meant to be used by the create() class @@ -627,7 +629,7 @@ def __init__( self._ignore_requires_python = ignore_requires_python self._link_collector = link_collector self._target_python = target_python - self._exclude_newer_than = exclude_newer_than + self._uploaded_prior_to = uploaded_prior_to self.format_control = format_control @@ -651,7 +653,7 @@ def create( link_collector: LinkCollector, selection_prefs: SelectionPreferences, target_python: TargetPython | None = None, - exclude_newer_than: datetime.datetime | None = None, + uploaded_prior_to: datetime.datetime | None = None, ) -> PackageFinder: """Create a PackageFinder. @@ -660,7 +662,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 exclude_newer_than: If set, only find links prior to the given date. + :param uploaded_prior_to: If set, only find links uploaded prior + to the given datetime. """ if target_python is None: target_python = TargetPython() @@ -677,7 +680,7 @@ def create( allow_yanked=selection_prefs.allow_yanked, format_control=selection_prefs.format_control, ignore_requires_python=selection_prefs.ignore_requires_python, - exclude_newer_than=exclude_newer_than, + uploaded_prior_to=uploaded_prior_to, ) @property @@ -738,8 +741,8 @@ def set_prefer_binary(self) -> None: self._candidate_prefs.prefer_binary = True @property - def exclude_newer_than(self) -> datetime.datetime | None: - return self._exclude_newer_than + def uploaded_prior_to(self) -> datetime.datetime | None: + return self._uploaded_prior_to def requires_python_skipped_reasons(self) -> list[str]: reasons = { @@ -760,7 +763,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, - exclude_newer_than=self._exclude_newer_than, + uploaded_prior_to=self._uploaded_prior_to, ) def _sort_links(self, links: Iterable[Link]) -> list[Link]: diff --git a/tests/functional/test_exclude_newer.py b/tests/functional/test_uploaded_prior_to.py similarity index 70% rename from tests/functional/test_exclude_newer.py rename to tests/functional/test_uploaded_prior_to.py index c1d0ffecedc..33053fa1712 100644 --- a/tests/functional/test_exclude_newer.py +++ b/tests/functional/test_uploaded_prior_to.py @@ -1,7 +1,4 @@ -"""Tests for pip install --exclude-newer-than. - -Only effective with indexes that provide upload-time metadata. -""" +"""Tests for pip install --uploaded-prior-to.""" from __future__ import annotations @@ -10,28 +7,26 @@ from tests.lib import PipTestEnvironment, TestData -class TestExcludeNewer: - """Test --exclude-newer-than functionality. +class TestUploadedPriorTo: + """Test --uploaded-prior-to functionality. Only effective with indexes that provide upload-time metadata. """ - def test_exclude_newer_than_invalid_date( + def test_uploaded_prior_to_invalid_date( self, script: PipTestEnvironment, data: TestData ) -> None: - """Test that --exclude-newer-than fails with invalid date format.""" + """Test that --uploaded-prior-to fails with invalid date format.""" result = script.pip_install_local( - "--exclude-newer-than=invalid-date", "simple", expect_error=True + "--uploaded-prior-to=invalid-date", "simple", expect_error=True ) # Should fail with date parsing error assert "invalid" in result.stderr.lower() or "error" in result.stderr.lower() @pytest.mark.network - def test_exclude_newer_than_with_real_pypi( - self, script: PipTestEnvironment - ) -> None: - """Test exclude-newer functionality against real PyPI with upload times.""" + def test_uploaded_prior_to_with_real_pypi(self, script: PipTestEnvironment) -> None: + """Test uploaded-prior-to functionality against real PyPI with upload times.""" # Use a small package with known old versions for testing # requests 2.0.0 was released in 2013 @@ -40,7 +35,7 @@ def test_exclude_newer_than_with_real_pypi( "install", "--dry-run", "--no-deps", - "--exclude-newer-than=2010-01-01T00:00:00", + "--uploaded-prior-to=2010-01-01T00:00:00", "requests==2.0.0", expect_error=True, ) @@ -52,14 +47,14 @@ def test_exclude_newer_than_with_real_pypi( "install", "--dry-run", "--no-deps", - "--exclude-newer-than=2030-01-01T00:00:00", + "--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_exclude_newer_than_date_formats(self, script: PipTestEnvironment) -> None: + def test_uploaded_prior_to_date_formats(self, script: PipTestEnvironment) -> None: """Test different date formats work with real PyPI.""" # Test various date formats with a well known small package formats = [ @@ -74,7 +69,7 @@ def test_exclude_newer_than_date_formats(self, script: PipTestEnvironment) -> No "install", "--dry-run", "--no-deps", - f"--exclude-newer-than={date_format}", + f"--uploaded-prior-to={date_format}", "requests==2.0.0", expect_error=False, ) diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py index 02cb08ed43b..f3102db24e1 100644 --- a/tests/lib/__init__.py +++ b/tests/lib/__init__.py @@ -108,7 +108,7 @@ def make_test_finder( allow_all_prereleases: bool = False, session: PipSession | None = None, target_python: TargetPython | None = None, - exclude_newer_than: datetime.datetime | None = None, + uploaded_prior_to: datetime.datetime | None = None, ) -> PackageFinder: """ Create a PackageFinder for testing purposes. @@ -127,7 +127,7 @@ def make_test_finder( link_collector=link_collector, selection_prefs=selection_prefs, target_python=target_python, - exclude_newer_than=exclude_newer_than, + uploaded_prior_to=uploaded_prior_to, ) diff --git a/tests/unit/test_cmdoptions.py b/tests/unit/test_cmdoptions.py index f5ae2eb2f87..9316a603a95 100644 --- a/tests/unit/test_cmdoptions.py +++ b/tests/unit/test_cmdoptions.py @@ -10,7 +10,7 @@ from pip._internal.cli.cmdoptions import ( _convert_python_version, - _handle_exclude_newer_than, + _handle_uploaded_prior_to, ) from pip._internal.cli.main_parser import identify_python_interpreter @@ -74,18 +74,18 @@ def test_identify_python_interpreter_venv(tmpdir: Path) -> None: ), ], ) -def test_handle_exclude_newer_than_with_timezone( +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("--exclude-newer-than", dest="exclude_newer_than") - opt = "--exclude-newer-than" + option = Option("--uploaded-prior-to", dest="uploaded_prior_to") + opt = "--uploaded-prior-to" parser = OptionParser() parser.values = Values() - _handle_exclude_newer_than(option, opt, value, parser) + _handle_uploaded_prior_to(option, opt, value, parser) - result = parser.values.exclude_newer_than + result = parser.values.uploaded_prior_to assert isinstance(result, datetime.datetime) assert result == expected_datetime @@ -100,18 +100,18 @@ def test_handle_exclude_newer_than_with_timezone( ("2023-01-01", (2023, 1, 1, 0, 0, 0)), ], ) -def test_handle_exclude_newer_than_naive_dates( +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("--exclude-newer-than", dest="exclude_newer_than") - opt = "--exclude-newer-than" + option = Option("--uploaded-prior-to", dest="uploaded_prior_to") + opt = "--uploaded-prior-to" parser = OptionParser() parser.values = Values() - _handle_exclude_newer_than(option, opt, value, parser) + _handle_uploaded_prior_to(option, opt, value, parser) - result = parser.values.exclude_newer_than + result = parser.values.uploaded_prior_to assert isinstance(result, datetime.datetime) # Check that the date/time components match @@ -136,12 +136,12 @@ def test_handle_exclude_newer_than_naive_dates( "", # Empty string ], ) -def test_handle_exclude_newer_than_invalid_dates(invalid_value: str) -> None: +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("--exclude-newer-than", dest="exclude_newer_than") - opt = "--exclude-newer-than" + option = Option("--uploaded-prior-to", dest="uploaded_prior_to") + opt = "--uploaded-prior-to" parser = OptionParser() parser.values = Values() with pytest.raises(SystemExit): - _handle_exclude_newer_than(option, opt, invalid_value, parser) + _handle_uploaded_prior_to(option, opt, invalid_value, parser) diff --git a/tests/unit/test_finder.py b/tests/unit/test_finder.py index bcad8de6af2..b74e8f825c1 100644 --- a/tests/unit/test_finder.py +++ b/tests/unit/test_finder.py @@ -577,38 +577,38 @@ def test_find_all_candidates_find_links_and_index(data: TestData) -> None: assert [str(v.version) for v in versions] == ["3.0", "2.0", "1.0", "1.0"] -class TestPackageFinderExcludeNewerThan: - """Test PackageFinder integration with exclude_newer_than functionality. +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_exclude_newer_than(self) -> None: - """Test that PackageFinder.create() accepts exclude_newer_than parameter.""" - exclude_newer_than = datetime.datetime( + 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(exclude_newer_than=exclude_newer_than) + finder = make_test_finder(uploaded_prior_to=uploaded_prior_to) - assert finder._exclude_newer_than == exclude_newer_than + assert finder._uploaded_prior_to == uploaded_prior_to - def test_package_finder_make_link_evaluator_with_exclude_newer_than(self) -> None: - """Test that PackageFinder creates LinkEvaluator with exclude_newer_than.""" - exclude_newer_than = datetime.datetime( + 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(exclude_newer_than=exclude_newer_than) + finder = make_test_finder(uploaded_prior_to=uploaded_prior_to) link_evaluator = finder.make_link_evaluator("test-package") - assert link_evaluator._exclude_newer_than == exclude_newer_than + assert link_evaluator._uploaded_prior_to == uploaded_prior_to - def test_package_finder_exclude_newer_than_none(self) -> None: - """Test that PackageFinder works correctly when exclude_newer_than is None.""" - finder = make_test_finder(exclude_newer_than=None) + 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._exclude_newer_than is None + assert finder._uploaded_prior_to is None link_evaluator = finder.make_link_evaluator("test-package") - assert link_evaluator._exclude_newer_than is None + assert link_evaluator._uploaded_prior_to is None diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py index 7cc2d2b3fdb..9a5589c9931 100644 --- a/tests/unit/test_index.py +++ b/tests/unit/test_index.py @@ -365,14 +365,14 @@ def test_filter_unallowed_hashes__log_message_with_no_match( check_caplog(caplog, "DEBUG", expected_message) -class TestLinkEvaluatorExcludeNewerThan: - """Test the exclude_newer_than functionality in LinkEvaluator. +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, exclude_newer_than: datetime.datetime | None = None + self, uploaded_prior_to: datetime.datetime | None = None ) -> LinkEvaluator: """Create a LinkEvaluator for testing.""" target_python = TargetPython() @@ -382,11 +382,11 @@ def make_test_link_evaluator( formats=frozenset(["source", "binary"]), target_python=target_python, allow_yanked=True, - exclude_newer_than=exclude_newer_than, + uploaded_prior_to=uploaded_prior_to, ) @pytest.mark.parametrize( - "upload_time, exclude_newer_than, expected_result", + "upload_time, uploaded_prior_to, expected_result", [ # Test case: upload time is before the cutoff (should be accepted) ( @@ -400,17 +400,21 @@ def make_test_link_evaluator( 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 after " + "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 accepted) + # 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.candidate, "1.0"), + ( + 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 exclude_newer_than set (should be accepted) + # Test case: no uploaded_prior_to set (should be accepted) ( datetime.datetime(2023, 8, 1, 12, 0, 0, tzinfo=datetime.timezone.utc), None, @@ -418,14 +422,14 @@ def make_test_link_evaluator( ), ], ) - def test_evaluate_link_exclude_newer_than( + def test_evaluate_link_uploaded_prior_to( self, upload_time: datetime.datetime, - exclude_newer_than: datetime.datetime | None, + 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(exclude_newer_than) + evaluator = self.make_test_link_evaluator(uploaded_prior_to) link = Link( "https://example.com/myproject-1.0.tar.gz", upload_time=upload_time, @@ -436,10 +440,10 @@ def test_evaluate_link_exclude_newer_than( def test_evaluate_link_no_upload_time(self) -> None: """Test that links with no upload time are not filtered.""" - exclude_newer_than = datetime.datetime( + uploaded_prior_to = datetime.datetime( 2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc ) - evaluator = self.make_test_link_evaluator(exclude_newer_than) + evaluator = self.make_test_link_evaluator(uploaded_prior_to) # Link with no upload_time should not be filtered link = Link("https://example.com/myproject-1.0.tar.gz") @@ -452,10 +456,10 @@ def test_evaluate_link_no_upload_time(self) -> None: def test_evaluate_link_timezone_handling(self) -> None: """Test that timezone-aware datetimes are handled correctly.""" # Set cutoff time in UTC - exclude_newer_than = datetime.datetime( + uploaded_prior_to = datetime.datetime( 2023, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc ) - evaluator = self.make_test_link_evaluator(exclude_newer_than) + evaluator = self.make_test_link_evaluator(uploaded_prior_to) # Test upload time in different timezone (earlier in UTC) upload_time_est = datetime.datetime( @@ -472,7 +476,7 @@ def test_evaluate_link_timezone_handling(self) -> None: assert actual[0] == LinkType.upload_too_late @pytest.mark.parametrize( - "exclude_newer_than", + "uploaded_prior_to", [ datetime.datetime(2023, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc), datetime.datetime( @@ -481,11 +485,11 @@ def test_evaluate_link_timezone_handling(self) -> None: ), ], ) - def test_exclude_newer_than_different_timezone_formats( - self, exclude_newer_than: datetime.datetime + def test_uploaded_prior_to_different_timezone_formats( + self, uploaded_prior_to: datetime.datetime ) -> None: - """Test that different timezone formats for exclude_newer_than work.""" - evaluator = self.make_test_link_evaluator(exclude_newer_than) + """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( @@ -500,6 +504,44 @@ def test_exclude_newer_than_different_timezone_formats( # 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( From e592e95e9d7dcd57d3f3ab76a4e69984ca7a1f8c Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Thu, 21 Aug 2025 09:48:14 -0400 Subject: [PATCH 21/49] Add `--uploaded-prior-to` to the user guide --- docs/html/user_guide.rst | 55 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst index d6a0acf9cd8..a126dc66f1a 100644 --- a/docs/html/user_guide.rst +++ b/docs/html/user_guide.rst @@ -257,6 +257,61 @@ e.g. http://example.com/constraints.txt, so that your organization can store and serve them in a centralized place. +.. _`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 works with package indexes that provide upload-time metadata + (such as PyPI). When upload-time information is not available, packages are not + filtered and installation continues normally. + +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`: From b5e49232adce7fbb6718fcdce713e92991438c4e Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sat, 4 Oct 2025 14:32:43 -0400 Subject: [PATCH 22/49] Fix type hint error in `make_test_link_evaluator` --- tests/unit/test_index.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py index 1b93d4a5c00..779864365c7 100644 --- a/tests/unit/test_index.py +++ b/tests/unit/test_index.py @@ -379,7 +379,7 @@ def make_test_link_evaluator( target_python = TargetPython() return LinkEvaluator( project_name="myproject", - canonical_name="myproject", + canonical_name=canonicalize_name("myproject"), formats=frozenset(["source", "binary"]), target_python=target_python, allow_yanked=True, From 64f45293cbc8d274edd79ffba869d652f387c9ea Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sat, 18 Oct 2025 12:04:58 -0400 Subject: [PATCH 23/49] Do not allow indexes which don't provide `upload-time` if `--uploaded-prior-to` is used --- docs/html/user_guide.rst | 12 ++- pyproject.toml | 2 +- src/pip/_internal/index/package_finder.py | 40 +++++++-- tests/functional/test_uploaded_prior_to.py | 99 ++++++++++++++++++---- tests/unit/test_index.py | 17 +++- 5 files changed, 137 insertions(+), 33 deletions(-) diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst index a126dc66f1a..01b6d6690f0 100644 --- a/docs/html/user_guide.rst +++ b/docs/html/user_guide.rst @@ -293,9 +293,15 @@ results on different machines. .. note:: - This option only works with package indexes that provide upload-time metadata - (such as PyPI). When upload-time information is not available, packages are not - filtered and installation continues normally. + 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: diff --git a/pyproject.toml b/pyproject.toml index 79f12384bb1..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 = 14 # 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/index/package_finder.py b/src/pip/_internal/index/package_finder.py index 6517a4b63d7..33d80822ba2 100644 --- a/src/pip/_internal/index/package_finder.py +++ b/src/pip/_internal/index/package_finder.py @@ -25,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 @@ -113,6 +114,7 @@ class LinkType(enum.Enum): platform_mismatch = enum.auto() requires_python_mismatch = enum.auto() upload_too_late = enum.auto() + upload_time_missing = enum.auto() class LinkEvaluator: @@ -182,14 +184,6 @@ def evaluate_link(self, link: Link) -> tuple[LinkType, str]: reason = link.yanked_reason or "" return (LinkType.yanked, f"yanked for reason: {reason}") - if link.upload_time is not None and self._uploaded_prior_to is not None: - if link.upload_time >= self._uploaded_prior_to: - reason = ( - f"Upload time {link.upload_time} not " - f"prior to {self._uploaded_prior_to}" - ) - return (LinkType.upload_too_late, reason) - if link.egg_fragment: egg_info = link.egg_fragment ext = link.ext @@ -232,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}" @@ -798,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/tests/functional/test_uploaded_prior_to.py b/tests/functional/test_uploaded_prior_to.py index 33053fa1712..2320976f2d2 100644 --- a/tests/functional/test_uploaded_prior_to.py +++ b/tests/functional/test_uploaded_prior_to.py @@ -5,32 +5,68 @@ 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. - - Only effective with indexes that provide upload-time metadata. - """ + """Test --uploaded-prior-to functionality.""" def test_uploaded_prior_to_invalid_date( self, script: PipTestEnvironment, data: TestData ) -> None: - """Test that --uploaded-prior-to fails with invalid date format.""" + """Test that invalid date format is rejected.""" result = script.pip_install_local( "--uploaded-prior-to=invalid-date", "simple", expect_error=True ) - - # Should fail with date parsing error 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 uploaded-prior-to functionality against real PyPI with upload times.""" - # Use a small package with known old versions for testing - # requests 2.0.0 was released in 2013 - - # Test 1: With an old cutoff date, should find no matching versions + """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", @@ -39,10 +75,9 @@ def test_uploaded_prior_to_with_real_pypi(self, script: PipTestEnvironment) -> N "requests==2.0.0", expect_error=True, ) - # Should fail because requests 2.0.0 was uploaded after 2010 - assert "No matching distribution found" in result.stderr + assert "Could not find a version that satisfies" in result.stderr - # Test 2: With a date that should find the package + # Test with future cutoff date - should find the package result = script.pip( "install", "--dry-run", @@ -55,8 +90,7 @@ def test_uploaded_prior_to_with_real_pypi(self, script: PipTestEnvironment) -> N @pytest.mark.network def test_uploaded_prior_to_date_formats(self, script: PipTestEnvironment) -> None: - """Test different date formats work with real PyPI.""" - # Test various date formats with a well known small package + """Test various date format strings are accepted.""" formats = [ "2030-01-01", "2030-01-01T00:00:00", @@ -73,5 +107,34 @@ def test_uploaded_prior_to_date_formats(self, script: PipTestEnvironment) -> Non "requests==2.0.0", expect_error=False, ) - # All dates should allow the package 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/unit/test_index.py b/tests/unit/test_index.py index 779864365c7..da03b45259b 100644 --- a/tests/unit/test_index.py +++ b/tests/unit/test_index.py @@ -440,13 +440,26 @@ def test_evaluate_link_uploaded_prior_to( assert actual == expected_result def test_evaluate_link_no_upload_time(self) -> None: - """Test that links with no upload time are not filtered.""" + """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 not be filtered + # 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) From a3b3ac162242738efd597b75b424c240565497d7 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 8 Aug 2025 22:18:53 -0400 Subject: [PATCH 24/49] Implement build constraints --- src/pip/_internal/build_env.py | 71 +++++++++++++++++++++++++- src/pip/_internal/cli/cmdoptions.py | 34 ++++++++++++ src/pip/_internal/cli/req_command.py | 57 +++++++++++++++------ src/pip/_internal/commands/download.py | 2 + src/pip/_internal/commands/install.py | 2 + src/pip/_internal/commands/lock.py | 3 ++ src/pip/_internal/commands/wheel.py | 3 ++ 7 files changed, 155 insertions(+), 17 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 8b668b4f6d8..57b6967e7be 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 @@ -31,6 +32,10 @@ logger = logging.getLogger(__name__) +class ExtraEnviron(TypedDict, total=False): + extra_environ: dict[str, str] + + def _dedup(a: str, b: str) -> tuple[str] | tuple[str, str]: return (a, b) if a != b else (a,) @@ -101,8 +106,49 @@ 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, + constraints: list[str] | None = None, + ) -> None: self.finder = finder + self._build_constraints = build_constraints or [] + self._build_constraint_feature_enabled = build_constraint_feature_enabled + self._constraints = constraints or [] + + def _deprecation_constraint_check(self) -> None: + """ + Check for deprecation warning: PIP_CONSTRAINT affecting build environments. + + This warns when build-constraint feature is NOT enabled but regular constraints + match what PIP_CONSTRAINT environment variable points to. + """ + if self._build_constraint_feature_enabled: + return + + if not self._constraints: + return + + if not os.environ.get("PIP_CONSTRAINT"): + return + + pip_constraint_files = [ + f.strip() for f in os.environ["PIP_CONSTRAINT"].split() if f.strip() + ] + if pip_constraint_files and set(pip_constraint_files) == set(self._constraints): + deprecated( + reason=( + "Setting PIP_CONSTRAINT will not affect " + "build constraints in the future," + ), + replacement=( + 'PIP_BUILD_CONSTRAINT with PIP_USE_FEATURE="build-constraint"' + ), + gone_in="26.2", + issue=None, + ) def install( self, @@ -112,6 +158,8 @@ def install( kind: str, for_req: InstallRequirement | None, ) -> None: + self._deprecation_constraint_check() + finder = self.finder args: list[str] = [ sys.executable, @@ -169,6 +217,24 @@ def install( args.append("--prefer-binary") if finder.uploaded_prior_to: args.extend(["--uploaded-prior-to", finder.uploaded_prior_to.isoformat()]) + + # Handle build constraints + extra_environ: ExtraEnviron = {} + if self._build_constraint_feature_enabled: + # Build constraints must be passed as both constraints + # and build constraints to the subprocess + for constraint_file in self._build_constraints: + args.extend(["--constraint", constraint_file]) + args.extend(["--build-constraint", constraint_file]) + args.extend(["--use-feature", "build-constraint"]) + + # If there are no build constraints but the build constraint + # process is enabled then we must ignore regular constraints + if not self._build_constraints: + extra_environ = { + "extra_environ": {"_PIP_IN_BUILD_IGNORE_CONSTRAINTS": "1"} + } + args.append("--") args.extend(requirements) @@ -180,6 +246,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 89fbc1fcaa9..9210b933f92 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -102,6 +102,23 @@ def check_dist_restriction(options: Values, check_target: bool = False) -> None: ) +def check_build_constraints(options: Values) -> None: + """Function for validating build constraint options. + + :param options: The OptionParser options. + """ + if hasattr(options, "build_constraints") and options.build_constraints: + if "build-constraint" not in options.features_enabled: + raise CommandError( + "To use --build-constraint, you must enable this feature with " + "--use-feature=build-constraint." + ) + if not options.build_isolation: + raise CommandError( + "--build-constraint cannot be used with --no-build-isolation." + ) + + def _path_option_check(option: Option, opt: str, value: str) -> str: return os.path.expanduser(value) @@ -431,6 +448,22 @@ def constraints() -> Option: ) +def build_constraint() -> 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. " + "Requires --use-feature=build-constraint." + ), + ) + + def requirements() -> Option: return Option( "-r", @@ -1121,6 +1154,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 0a8758ca355..a57b4a02e0d 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,26 @@ def make_requirement_preparer( "fast-deps has no effect when used with the legacy resolver." ) + # Handle build constraints + build_constraints = getattr(options, "build_constraints", []) + constraints = getattr(options, "constraints", []) + build_constraint_feature_enabled = ( + hasattr(options, "features_enabled") + and options.features_enabled + and "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, + constraints=constraints, + ), check_build_deps=options.check_build_deps, build_tracker=build_tracker, session=session, @@ -221,20 +246,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( diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index c09e4a39c80..0ca4a8fb2ae 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_constraint()) self.cmd_opts.add_option(cmdoptions.requirements()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.global_options()) @@ -82,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) diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index cdb3716a684..c15e02e6dc1 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_constraint()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.pre()) @@ -304,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()) diff --git a/src/pip/_internal/commands/lock.py b/src/pip/_internal/commands/lock.py index c00d7d45e11..8302165e40b 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_constraint()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.pre()) @@ -99,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 dfa144a871a..8997d3b72e7 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -60,6 +60,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.constraints()) + self.cmd_opts.add_option(cmdoptions.build_constraint()) self.cmd_opts.add_option(cmdoptions.editable()) self.cmd_opts.add_option(cmdoptions.requirements()) self.cmd_opts.add_option(cmdoptions.src()) @@ -102,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) From b8f35138ce151b373abef6e42284286fef71a6ef Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 8 Aug 2025 22:19:02 -0400 Subject: [PATCH 25/49] Add build constraints tests --- tests/functional/test_build_constraints.py | 176 +++++++++++++++++++++ tests/unit/test_build_constraints.py | 150 ++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 tests/functional/test_build_constraints.py create mode 100644 tests/unit/test_build_constraints.py diff --git a/tests/functional/test_build_constraints.py b/tests/functional/test_build_constraints.py new file mode 100644 index 00000000000..b3a9067bcd1 --- /dev/null +++ b/tests/functional/test_build_constraints.py @@ -0,0 +1,176 @@ +"""Tests for the build constraints feature.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +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", + ] + + if extra_args: + args.extend(extra_args) + + args.append(str(project_dir)) + + return script.pip(*args, expect_error=expect_error) + + +def _assert_successful_installation(result: TestPipResult, package_name: str) -> None: + """Assert that the package was successfully installed.""" + assert f"Successfully installed {package_name}" in result.stdout + + +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), + str(project_dir), + expect_error=True, + ) + + +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 + ) + _assert_successful_installation( + result=result, package_name="test_build_constraints" + ) + + +@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", + 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"], + ) + _assert_successful_installation(result=result, package_name="test_env_isolation") + + +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, + ) + _assert_successful_installation( + result=result, package_name="test_missing_constraints" + ) + + +def test_build_constraints_without_feature_flag( + script: PipTestEnvironment, tmpdir: Path +) -> None: + """Test that --build-constraint requires the feature flag.""" + project_dir = _create_simple_test_package(script=script, name="test_no_feature") + constraints_file = _create_constraints_file( + script=script, filename="constraints.txt", content="setuptools==45.0.0\n" + ) + result = _run_pip_install_with_build_constraints_no_feature_flag( + script=script, project_dir=project_dir, constraints_file=constraints_file + ) + assert result.returncode != 0 + assert "build-constraint" in result.stderr.lower() diff --git a/tests/unit/test_build_constraints.py b/tests/unit/test_build_constraints.py new file mode 100644 index 00000000000..c851a4505d7 --- /dev/null +++ b/tests/unit/test_build_constraints.py @@ -0,0 +1,150 @@ +"""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 is shown when PIP_CONSTRAINT is not set.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["constraints.txt"], + build_constraint_feature_enabled=False, + ) + + # Should not raise any warning + 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 is shown when + build-constraint feature is enabled + """ + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["constraints.txt"], + 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_constraint_mismatch(self) -> None: + """ + Test no deprecation warning is shown when + PIP_CONSTRAINT doesn't match regular constraints. + """ + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["different.txt"], + build_constraint_feature_enabled=False, + ) + + # 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 is shown when conditions are met.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["constraints.txt"], + 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 'PIP_BUILD_CONSTRAINT with PIP_USE_FEATURE="build-constraint"' in message + + @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraint1.txt constraint2.txt"}) + def test_deprecation_check_multiple_constraints(self) -> None: + """Test deprecation warning works with multiple constraints.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["constraint1.txt", "constraint2.txt"], + build_constraint_feature_enabled=False, + ) + + with pytest.warns(PipDeprecationWarning): + installer._deprecation_constraint_check() + + @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraint1.txt constraint2.txt"}) + def test_deprecation_check_multiple_constraints_different_order(self) -> None: + """Test deprecation warning works when constraints are in different order.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["constraint2.txt", "constraint1.txt"], + build_constraint_feature_enabled=False, + ) + + with pytest.warns(PipDeprecationWarning): + installer._deprecation_constraint_check() + + @mock.patch.dict( + os.environ, {"PIP_CONSTRAINT": "constraint1.txt constraint2.txt extra.txt"} + ) + def test_deprecation_check_partial_match_no_warning(self) -> None: + """Test no deprecation warning is shown when only partial match.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["constraint1.txt", "constraint2.txt"], + build_constraint_feature_enabled=False, + ) + + # Should not raise any warning since PIP_CONSTRAINT has extra file + installer._deprecation_constraint_check() + + @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.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + constraints=["constraints.txt"], + 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() From 4e3aecd303a1a34f2d94ee772e4d16e7d03ab9d2 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 8 Aug 2025 22:42:13 -0400 Subject: [PATCH 26/49] Add build constraints to user guide --- docs/html/user_guide.rst | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst index 01b6d6690f0..e1ead12b6bc 100644 --- a/docs/html/user_guide.rst +++ b/docs/html/user_guide.rst @@ -257,6 +257,51 @@ 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.2 +.. note:: + + Build constraints are currently an **experimental feature** and must be + enabled using ``--use-feature=build-constraint``. + +Build constraints are a specialized type of constraints file that apply only +to the build environment when building packages from source. Unlike regular +constraints which affect the installed packages in your environment, build +constraints only influence the versions of packages available during the +build process. + +This is particularly useful when you need to constrain build dependencies +(like ``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 --use-feature=build-constraint SomePackage + +.. tab:: Windows + + .. code-block:: shell + + py -m pip install --build-constraint build-constraints.txt --use-feature=build-constraint SomePackage + +Example build constraints file (``build-constraints.txt``): + +.. code-block:: text + + # Constrain setuptools version during build + setuptools>=45.0.0,<60.0.0 + # Pin Cython for packages that use it + cython==0.29.24 + + .. _`Filtering by Upload Time`: From b6b6a7f49fe68a61f3e28473f1beffd8316ef78a Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 8 Aug 2025 22:45:55 -0400 Subject: [PATCH 27/49] NEWS ENTRY --- news/13534.feature.rst | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 news/13534.feature.rst diff --git a/news/13534.feature.rst b/news/13534.feature.rst new file mode 100644 index 00000000000..8500f8063a2 --- /dev/null +++ b/news/13534.feature.rst @@ -0,0 +1,4 @@ +Add experimental build constraints support via ``--use-feature=build-constraint``. +This allows constraining the versions of packages used during the build process +(e.g., setuptools). Build constraints can be specified via ``PIP_BUILD_CONSTRAINT`` +environment variable or ``--build-constraint`` flag. From 30fd4e12983df7b118575c16ec929d92d59d1392 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sat, 9 Aug 2025 10:08:57 -0400 Subject: [PATCH 28/49] Imply using new behavior when build constraints are provided without needing to use the `build-constraint` feature --- docs/html/user_guide.rst | 24 ++++++++----------- news/13534.feature.rst | 12 ++++++---- src/pip/_internal/build_env.py | 28 ++++++++++++++-------- src/pip/_internal/cli/cmdoptions.py | 8 +------ tests/functional/test_build_constraints.py | 8 +++---- tests/unit/test_build_constraints.py | 18 ++++---------- 6 files changed, 45 insertions(+), 53 deletions(-) diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst index e1ead12b6bc..2dce8aa063c 100644 --- a/docs/html/user_guide.rst +++ b/docs/html/user_guide.rst @@ -262,20 +262,16 @@ serve them in a centralized place. Build Constraints ----------------- -.. versionadded:: 25.2 -.. note:: - - Build constraints are currently an **experimental feature** and must be - enabled using ``--use-feature=build-constraint``. +.. versionadded:: 25.3 -Build constraints are a specialized type of constraints file that apply only -to the build environment when building packages from source. Unlike regular -constraints which affect the installed packages in your environment, build +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 particularly useful when you need to constrain build dependencies -(like ``setuptools``, ``cython``, etc.) without affecting the +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: @@ -284,21 +280,21 @@ Use build constraints like so: .. code-block:: shell - python -m pip install --build-constraint build-constraints.txt --use-feature=build-constraint SomePackage + python -m pip install --build-constraint build-constraints.txt SomePackage .. tab:: Windows .. code-block:: shell - py -m pip install --build-constraint build-constraints.txt --use-feature=build-constraint SomePackage + 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.0.0,<60.0.0 - # Pin Cython for packages that use it + setuptools>=45,<80 + # Pin Cython for packages that use it to build cython==0.29.24 diff --git a/news/13534.feature.rst b/news/13534.feature.rst index 8500f8063a2..6d7635ace48 100644 --- a/news/13534.feature.rst +++ b/news/13534.feature.rst @@ -1,4 +1,8 @@ -Add experimental build constraints support via ``--use-feature=build-constraint``. -This allows constraining the versions of packages used during the build process -(e.g., setuptools). Build constraints can be specified via ``PIP_BUILD_CONSTRAINT`` -environment variable or ``--build-constraint`` flag. +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). + +When using ``--build-constraint``, you can no longer pass constraints to +isolated build environments via the ``PIP_CONSTRAINT`` environment variable. +To opt in to this behavior without specifying any build constraints, use +``--use-feature=build-constraint``. diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 57b6967e7be..26e8a39c419 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -128,6 +128,9 @@ def _deprecation_constraint_check(self) -> None: if self._build_constraint_feature_enabled: return + if self._build_constraints: + return + if not self._constraints: return @@ -135,16 +138,19 @@ def _deprecation_constraint_check(self) -> None: return pip_constraint_files = [ - f.strip() for f in os.environ["PIP_CONSTRAINT"].split() if f.strip() + f for f in os.environ["PIP_CONSTRAINT"].split() if f.strip() ] - if pip_constraint_files and set(pip_constraint_files) == set(self._constraints): + if pip_constraint_files and pip_constraint_files == self._constraints: deprecated( reason=( "Setting PIP_CONSTRAINT will not affect " "build constraints in the future," ), replacement=( - 'PIP_BUILD_CONSTRAINT with PIP_USE_FEATURE="build-constraint"' + "to specify build constraints use --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, @@ -221,19 +227,21 @@ def install( # Handle build constraints extra_environ: ExtraEnviron = {} 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 to the subprocess + # 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]) - args.extend(["--use-feature", "build-constraint"]) + if self._build_constraint_feature_enabled and not self._build_constraints: # If there are no build constraints but the build constraint - # process is enabled then we must ignore regular constraints - if not self._build_constraints: - extra_environ = { - "extra_environ": {"_PIP_IN_BUILD_IGNORE_CONSTRAINTS": "1"} - } + # 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) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 9210b933f92..a6f52de2c40 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -108,11 +108,6 @@ def check_build_constraints(options: Values) -> None: :param options: The OptionParser options. """ if hasattr(options, "build_constraints") and options.build_constraints: - if "build-constraint" not in options.features_enabled: - raise CommandError( - "To use --build-constraint, you must enable this feature with " - "--use-feature=build-constraint." - ) if not options.build_isolation: raise CommandError( "--build-constraint cannot be used with --no-build-isolation." @@ -458,8 +453,7 @@ def build_constraint() -> Option: metavar="file", help=( "Constrain build dependencies using the given constraints file. " - "This option can be used multiple times. " - "Requires --use-feature=build-constraint." + "This option can be used multiple times." ), ) diff --git a/tests/functional/test_build_constraints.py b/tests/functional/test_build_constraints.py index b3a9067bcd1..2c3749d3ca6 100644 --- a/tests/functional/test_build_constraints.py +++ b/tests/functional/test_build_constraints.py @@ -69,7 +69,6 @@ def _run_pip_install_with_build_constraints_no_feature_flag( "--build-constraint", str(constraints_file), str(project_dir), - expect_error=True, ) @@ -164,7 +163,7 @@ def test_build_constraints_file_not_found( def test_build_constraints_without_feature_flag( script: PipTestEnvironment, tmpdir: Path ) -> None: - """Test that --build-constraint requires the feature flag.""" + """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==45.0.0\n" @@ -172,5 +171,6 @@ def test_build_constraints_without_feature_flag( result = _run_pip_install_with_build_constraints_no_feature_flag( script=script, project_dir=project_dir, constraints_file=constraints_file ) - assert result.returncode != 0 - assert "build-constraint" in result.stderr.lower() + # Should succeed now that --build-constraint auto-enables the feature + assert result.returncode == 0 + _assert_successful_installation(result=result, package_name="test_no_feature") diff --git a/tests/unit/test_build_constraints.py b/tests/unit/test_build_constraints.py index c851a4505d7..5da4c32844e 100644 --- a/tests/unit/test_build_constraints.py +++ b/tests/unit/test_build_constraints.py @@ -81,7 +81,10 @@ def test_deprecation_check_warning_shown(self) -> None: "Setting PIP_CONSTRAINT will not affect build constraints in the future" in message ) - assert 'PIP_BUILD_CONSTRAINT with PIP_USE_FEATURE="build-constraint"' in message + assert ( + "to specify build constraints use " + "--build-constraint or PIP_BUILD_CONSTRAINT" in message + ) @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraint1.txt constraint2.txt"}) def test_deprecation_check_multiple_constraints(self) -> None: @@ -96,19 +99,6 @@ def test_deprecation_check_multiple_constraints(self) -> None: with pytest.warns(PipDeprecationWarning): installer._deprecation_constraint_check() - @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraint1.txt constraint2.txt"}) - def test_deprecation_check_multiple_constraints_different_order(self) -> None: - """Test deprecation warning works when constraints are in different order.""" - finder = make_test_finder() - installer = SubprocessBuildEnvironmentInstaller( - finder, - constraints=["constraint2.txt", "constraint1.txt"], - build_constraint_feature_enabled=False, - ) - - with pytest.warns(PipDeprecationWarning): - installer._deprecation_constraint_check() - @mock.patch.dict( os.environ, {"PIP_CONSTRAINT": "constraint1.txt constraint2.txt extra.txt"} ) From f1831e79c765fb61d55691fef9004a12e872f864 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Wed, 13 Aug 2025 20:09:31 -0400 Subject: [PATCH 29/49] Update src/pip/_internal/cli/req_command.py Co-authored-by: Richard Si --- src/pip/_internal/cli/req_command.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index a57b4a02e0d..82f1fbe98c5 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -146,11 +146,7 @@ def make_requirement_preparer( # Handle build constraints build_constraints = getattr(options, "build_constraints", []) constraints = getattr(options, "constraints", []) - build_constraint_feature_enabled = ( - hasattr(options, "features_enabled") - and options.features_enabled - and "build-constraint" in options.features_enabled - ) + build_constraint_feature_enabled = "build-constraint" in options.features_enabled return RequirementPreparer( build_dir=temp_build_dir_path, From 50e45d2f3f2390ff3ef22859ed29e7030677ae23 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Wed, 13 Aug 2025 20:11:19 -0400 Subject: [PATCH 30/49] Update src/pip/_internal/build_env.py Co-authored-by: Richard Si --- src/pip/_internal/build_env.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 26e8a39c419..bb1533f0028 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -147,8 +147,8 @@ def _deprecation_constraint_check(self) -> None: "build constraints in the future," ), replacement=( - "to specify build constraints use --build-constraint or " - "PIP_BUILD_CONSTRAINT, to disable this warning without " + "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"' ), From 9465e3e2d03f9271e3df2028828f43aff5e057ff Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 18:54:45 -0400 Subject: [PATCH 31/49] Fix linting --- src/pip/_internal/cli/req_command.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index 82f1fbe98c5..f553a1ae5cc 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -146,7 +146,9 @@ def make_requirement_preparer( # Handle build constraints build_constraints = getattr(options, "build_constraints", []) constraints = getattr(options, "constraints", []) - build_constraint_feature_enabled = "build-constraint" in options.features_enabled + build_constraint_feature_enabled = ( + "build-constraint" in options.features_enabled + ) return RequirementPreparer( build_dir=temp_build_dir_path, From 8524919bf31f3b6b8be5e00733f4c772f37f1d91 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 19:10:41 -0400 Subject: [PATCH 32/49] Fix test --- tests/unit/test_build_constraints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_build_constraints.py b/tests/unit/test_build_constraints.py index 5da4c32844e..daaf20ce092 100644 --- a/tests/unit/test_build_constraints.py +++ b/tests/unit/test_build_constraints.py @@ -82,7 +82,7 @@ def test_deprecation_check_warning_shown(self) -> None: in message ) assert ( - "to specify build constraints use " + "to specify build constraints using " "--build-constraint or PIP_BUILD_CONSTRAINT" in message ) From 5453030b18b5808f1829d0b13a73e500f2302a6d Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 19:11:11 -0400 Subject: [PATCH 33/49] Consistently use "build constraints" in variables and documentation --- src/pip/_internal/build_env.py | 2 +- src/pip/_internal/cli/cmdoptions.py | 4 ++-- src/pip/_internal/commands/download.py | 2 +- src/pip/_internal/commands/install.py | 2 +- src/pip/_internal/commands/lock.py | 2 +- src/pip/_internal/commands/wheel.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index bb1533f0028..ef454304e0a 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -238,7 +238,7 @@ def install( args.extend(["--build-constraint", constraint_file]) if self._build_constraint_feature_enabled and not self._build_constraints: - # If there are no build constraints but the build constraint + # 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"}} diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index a6f52de2c40..190735b35e7 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -103,7 +103,7 @@ def check_dist_restriction(options: Values, check_target: bool = False) -> None: def check_build_constraints(options: Values) -> None: - """Function for validating build constraint options. + """Function for validating build constraints options. :param options: The OptionParser options. """ @@ -443,7 +443,7 @@ def constraints() -> Option: ) -def build_constraint() -> Option: +def build_constraints() -> Option: return Option( "--build-constraint", dest="build_constraints", diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index 0ca4a8fb2ae..25b5ceace6e 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -36,7 +36,7 @@ class DownloadCommand(RequirementCommand): def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.constraints()) - self.cmd_opts.add_option(cmdoptions.build_constraint()) + 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()) diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index c15e02e6dc1..9e671853531 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -87,7 +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_constraint()) + self.cmd_opts.add_option(cmdoptions.build_constraints()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.pre()) diff --git a/src/pip/_internal/commands/lock.py b/src/pip/_internal/commands/lock.py index 8302165e40b..ee0c464c809 100644 --- a/src/pip/_internal/commands/lock.py +++ b/src/pip/_internal/commands/lock.py @@ -59,7 +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_constraint()) + self.cmd_opts.add_option(cmdoptions.build_constraints()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.pre()) diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py index 8997d3b72e7..3beeba943c8 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -60,7 +60,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.constraints()) - self.cmd_opts.add_option(cmdoptions.build_constraint()) + 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()) From 0de13347b0184eba7f414979a7eda15fedb145ae Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 19:42:01 -0400 Subject: [PATCH 34/49] Simplify deprecation warning --- src/pip/_internal/build_env.py | 45 +++++++---------- tests/unit/test_build_constraints.py | 74 +++++++++------------------- 2 files changed, 42 insertions(+), 77 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index ef454304e0a..d44d5dd1ed0 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -122,39 +122,30 @@ def _deprecation_constraint_check(self) -> None: """ Check for deprecation warning: PIP_CONSTRAINT affecting build environments. - This warns when build-constraint feature is NOT enabled but regular constraints - match what PIP_CONSTRAINT environment variable points to. + This warns when build-constraint feature is NOT enabled and PIP_CONSTRAINT + is not empty. """ if self._build_constraint_feature_enabled: return - if self._build_constraints: - return - - if not self._constraints: - return - - if not os.environ.get("PIP_CONSTRAINT"): + pip_constraint = os.environ.get("PIP_CONSTRAINT") + if not pip_constraint or not pip_constraint.strip(): return - pip_constraint_files = [ - f for f in os.environ["PIP_CONSTRAINT"].split() if f.strip() - ] - if pip_constraint_files and pip_constraint_files == self._constraints: - 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, - ) + 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, diff --git a/tests/unit/test_build_constraints.py b/tests/unit/test_build_constraints.py index daaf20ce092..b9505b7bd94 100644 --- a/tests/unit/test_build_constraints.py +++ b/tests/unit/test_build_constraints.py @@ -19,56 +19,59 @@ class TestSubprocessBuildEnvironmentInstaller: @mock.patch.dict(os.environ, {}, clear=True) def test_deprecation_check_no_pip_constraint(self) -> None: - """Test no deprecation warning is shown when PIP_CONSTRAINT is not set.""" + """Test no deprecation warning when PIP_CONSTRAINT is not set.""" finder = make_test_finder() installer = SubprocessBuildEnvironmentInstaller( finder, - constraints=["constraints.txt"], build_constraint_feature_enabled=False, ) # Should not raise any warning 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 is shown when - build-constraint feature is enabled - """ + @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, - constraints=["constraints.txt"], - build_constraint_feature_enabled=True, + build_constraint_feature_enabled=False, ) - # Should not raise any warning + # Should not raise any warning since PIP_CONSTRAINT is empty installer._deprecation_constraint_check() - @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraints.txt"}) - def test_deprecation_check_constraint_mismatch(self) -> None: - """ - Test no deprecation warning is shown when - PIP_CONSTRAINT doesn't match regular constraints. - """ + @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, - constraints=["different.txt"], 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 is shown when conditions are met.""" + """Test deprecation warning emitted when PIP_CONSTRAINT is set + and build-constraint is not enabled.""" finder = make_test_finder() installer = SubprocessBuildEnvironmentInstaller( finder, - constraints=["constraints.txt"], build_constraint_feature_enabled=False, ) @@ -86,44 +89,15 @@ def test_deprecation_check_warning_shown(self) -> None: "--build-constraint or PIP_BUILD_CONSTRAINT" in message ) - @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraint1.txt constraint2.txt"}) - def test_deprecation_check_multiple_constraints(self) -> None: - """Test deprecation warning works with multiple constraints.""" - finder = make_test_finder() - installer = SubprocessBuildEnvironmentInstaller( - finder, - constraints=["constraint1.txt", "constraint2.txt"], - build_constraint_feature_enabled=False, - ) - - with pytest.warns(PipDeprecationWarning): - installer._deprecation_constraint_check() - - @mock.patch.dict( - os.environ, {"PIP_CONSTRAINT": "constraint1.txt constraint2.txt extra.txt"} - ) - def test_deprecation_check_partial_match_no_warning(self) -> None: - """Test no deprecation warning is shown when only partial match.""" - finder = make_test_finder() - installer = SubprocessBuildEnvironmentInstaller( - finder, - constraints=["constraint1.txt", "constraint2.txt"], - build_constraint_feature_enabled=False, - ) - - # Should not raise any warning since PIP_CONSTRAINT has extra file - installer._deprecation_constraint_check() - @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.""" + """Test install method calls deprecation check and proceeds with warning.""" finder = make_test_finder() installer = SubprocessBuildEnvironmentInstaller( finder, - constraints=["constraints.txt"], build_constraint_feature_enabled=False, ) prefix = _Prefix(str(tmp_path)) From b9a21b5128ee4663f2bdfa08edc35a9f802ceeff Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 19:53:09 -0400 Subject: [PATCH 35/49] Only emit pip constraint deprecation warning once --- src/pip/_internal/build_env.py | 15 +++++++++- tests/unit/test_build_constraints.py | 41 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index d44d5dd1ed0..aa386b19343 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -31,6 +31,9 @@ logger = logging.getLogger(__name__) +# Global flag to track if deprecation warning has been shown +_DEPRECATION_WARNING_SHOWN = False + class ExtraEnviron(TypedDict, total=False): extra_environ: dict[str, str] @@ -123,15 +126,25 @@ 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. + is not empty, but only shows the warning once per process. """ + global _DEPRECATION_WARNING_SHOWN + if self._build_constraint_feature_enabled: return + if _DEPRECATION_WARNING_SHOWN: + return + pip_constraint = os.environ.get("PIP_CONSTRAINT") if not pip_constraint or not pip_constraint.strip(): return + # Don't warn if we're in a build environment that ignores constraints + if os.environ.get("_PIP_IN_BUILD_IGNORE_CONSTRAINTS") == "1": + return + + _DEPRECATION_WARNING_SHOWN = True deprecated( reason=( "Setting PIP_CONSTRAINT will not affect " diff --git a/tests/unit/test_build_constraints.py b/tests/unit/test_build_constraints.py index b9505b7bd94..d5d467d1aea 100644 --- a/tests/unit/test_build_constraints.py +++ b/tests/unit/test_build_constraints.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import warnings from pathlib import Path from unittest import mock @@ -17,6 +18,12 @@ class TestSubprocessBuildEnvironmentInstaller: """Test SubprocessBuildEnvironmentInstaller build constraints functionality.""" + def setup_method(self) -> None: + """Reset the global deprecation warning flag before each test.""" + import pip._internal.build_env + + pip._internal.build_env._DEPRECATION_WARNING_SHOWN = False + @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.""" @@ -112,3 +119,37 @@ def test_install_calls_deprecation_check( # Verify that call_subprocess was called (install proceeded after warning) mock_call_subprocess.assert_called_once() + + @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraints.txt"}) + def test_deprecation_check_warning_shown_only_once(self) -> None: + """Test deprecation warning is shown only once per process.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + build_constraint_feature_enabled=False, + ) + + with pytest.warns(PipDeprecationWarning): + installer._deprecation_constraint_check() + + with warnings.catch_warnings(record=True) as warning_list: + warnings.simplefilter("always") + installer._deprecation_constraint_check() + assert len(warning_list) == 0 + + @mock.patch.dict( + os.environ, + {"PIP_CONSTRAINT": "constraints.txt", "_PIP_IN_BUILD_IGNORE_CONSTRAINTS": "1"}, + ) + def test_deprecation_check_no_warning_when_ignoring_constraints(self) -> None: + """Test no deprecation warning when _PIP_IN_BUILD_IGNORE_CONSTRAINTS is set.""" + finder = make_test_finder() + installer = SubprocessBuildEnvironmentInstaller( + finder, + build_constraint_feature_enabled=False, + ) + + with warnings.catch_warnings(record=True) as warning_list: + warnings.simplefilter("always") + installer._deprecation_constraint_check() + assert len(warning_list) == 0 From 97b6de1dfb161af6e36f2425bc661dab9d9e4c5a Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 19:55:15 -0400 Subject: [PATCH 36/49] Move `ExtraEnviron` into type checking block --- src/pip/_internal/build_env.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index aa386b19343..84dca7febf9 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -29,16 +29,16 @@ 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__) # Global flag to track if deprecation warning has been shown _DEPRECATION_WARNING_SHOWN = False -class ExtraEnviron(TypedDict, total=False): - extra_environ: dict[str, str] - - def _dedup(a: str, b: str) -> tuple[str] | tuple[str, str]: return (a, b) if a != b else (a,) @@ -229,7 +229,6 @@ def install( args.extend(["--uploaded-prior-to", finder.uploaded_prior_to.isoformat()]) # Handle build constraints - extra_environ: ExtraEnviron = {} if self._build_constraint_feature_enabled: args.extend(["--use-feature", "build-constraint"]) @@ -241,6 +240,7 @@ def install( 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 From 707c449e60f5829da0c939411b21df1b03ea1491 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 20:06:37 -0400 Subject: [PATCH 37/49] Use standard `assert_installed` in functional tests for build constraints --- tests/functional/test_build_constraints.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/tests/functional/test_build_constraints.py b/tests/functional/test_build_constraints.py index 2c3749d3ca6..c56414a082e 100644 --- a/tests/functional/test_build_constraints.py +++ b/tests/functional/test_build_constraints.py @@ -53,11 +53,6 @@ def _run_pip_install_with_build_constraints( return script.pip(*args, expect_error=expect_error) -def _assert_successful_installation(result: TestPipResult, package_name: str) -> None: - """Assert that the package was successfully installed.""" - assert f"Successfully installed {package_name}" in result.stdout - - def _run_pip_install_with_build_constraints_no_feature_flag( script: PipTestEnvironment, project_dir: Path, @@ -85,8 +80,8 @@ def test_build_constraints_basic_functionality_simple( result = _run_pip_install_with_build_constraints( script=script, project_dir=project_dir, build_constraints_file=constraints_file ) - _assert_successful_installation( - result=result, package_name="test_build_constraints" + result.assert_installed( + "test-build-constraints", editable=False, without_files=["."] ) @@ -139,7 +134,7 @@ def test_build_constraints_environment_isolation_simple( build_constraints_file=constraints_file, extra_args=["--isolated"], ) - _assert_successful_installation(result=result, package_name="test_env_isolation") + result.assert_installed("test-env-isolation", editable=False, without_files=["."]) def test_build_constraints_file_not_found( @@ -155,8 +150,8 @@ def test_build_constraints_file_not_found( project_dir=project_dir, build_constraints_file=missing_constraints, ) - _assert_successful_installation( - result=result, package_name="test_missing_constraints" + result.assert_installed( + "test-missing-constraints", editable=False, without_files=["."] ) @@ -173,4 +168,4 @@ def test_build_constraints_without_feature_flag( ) # Should succeed now that --build-constraint auto-enables the feature assert result.returncode == 0 - _assert_successful_installation(result=result, package_name="test_no_feature") + result.assert_installed("test-no-feature", editable=False, without_files=["."]) From c5e19c2e17b7d8822b039d1ef3507aef483cbda0 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 20:25:22 -0400 Subject: [PATCH 38/49] Eagerly assert build constraints files --- src/pip/_internal/cli/cmdoptions.py | 14 ++++++++++++++ tests/functional/test_build_constraints.py | 6 +++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 190735b35e7..c7e49afd47f 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -113,6 +113,20 @@ def check_build_constraints(options: Values) -> None: "--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 + + session = PipSession() + try: + # Eagerly check build constraints file contents + # is valid so that we don't fail in when trying + # to check constraints in isolated build process + for constraint_file in options.build_constraints: + get_file_content(constraint_file, session) + finally: + session.close() + def _path_option_check(option: Option, opt: str, value: str) -> str: return os.path.expanduser(value) diff --git a/tests/functional/test_build_constraints.py b/tests/functional/test_build_constraints.py index c56414a082e..5ed6dc954fb 100644 --- a/tests/functional/test_build_constraints.py +++ b/tests/functional/test_build_constraints.py @@ -149,10 +149,10 @@ def test_build_constraints_file_not_found( script=script, project_dir=project_dir, build_constraints_file=missing_constraints, + expect_error=True, ) - result.assert_installed( - "test-missing-constraints", editable=False, without_files=["."] - ) + assert "Could not open requirements file" in result.stderr + assert "No such file or directory" in result.stderr def test_build_constraints_without_feature_flag( From c65504986c1163951adec3ba95cf07752c6fe586 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 20:38:55 -0400 Subject: [PATCH 39/49] Add deprecation news item. --- news/13534.feature.rst | 7 +------ news/13534.removal.rst | 8 ++++++++ 2 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 news/13534.removal.rst diff --git a/news/13534.feature.rst b/news/13534.feature.rst index 6d7635ace48..541fd852d14 100644 --- a/news/13534.feature.rst +++ b/news/13534.feature.rst @@ -1,8 +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). - -When using ``--build-constraint``, you can no longer pass constraints to -isolated build environments via the ``PIP_CONSTRAINT`` environment variable. -To opt in to this behavior without specifying any build constraints, use -``--use-feature=build-constraint``. +(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``. From 830324bc74bd49f3a89e4d17b5922fcf838ebcef Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 20:47:01 -0400 Subject: [PATCH 40/49] Remove pointless check for `_PIP_IN_BUILD_IGNORE_CONSTRAINTS` in `_deprecation_constraint_check` --- src/pip/_internal/build_env.py | 4 ---- tests/unit/test_build_constraints.py | 17 ----------------- 2 files changed, 21 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 84dca7febf9..f0da9b228a6 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -140,10 +140,6 @@ def _deprecation_constraint_check(self) -> None: if not pip_constraint or not pip_constraint.strip(): return - # Don't warn if we're in a build environment that ignores constraints - if os.environ.get("_PIP_IN_BUILD_IGNORE_CONSTRAINTS") == "1": - return - _DEPRECATION_WARNING_SHOWN = True deprecated( reason=( diff --git a/tests/unit/test_build_constraints.py b/tests/unit/test_build_constraints.py index d5d467d1aea..719fd7ea714 100644 --- a/tests/unit/test_build_constraints.py +++ b/tests/unit/test_build_constraints.py @@ -136,20 +136,3 @@ def test_deprecation_check_warning_shown_only_once(self) -> None: warnings.simplefilter("always") installer._deprecation_constraint_check() assert len(warning_list) == 0 - - @mock.patch.dict( - os.environ, - {"PIP_CONSTRAINT": "constraints.txt", "_PIP_IN_BUILD_IGNORE_CONSTRAINTS": "1"}, - ) - def test_deprecation_check_no_warning_when_ignoring_constraints(self) -> None: - """Test no deprecation warning when _PIP_IN_BUILD_IGNORE_CONSTRAINTS is set.""" - finder = make_test_finder() - installer = SubprocessBuildEnvironmentInstaller( - finder, - build_constraint_feature_enabled=False, - ) - - with warnings.catch_warnings(record=True) as warning_list: - warnings.simplefilter("always") - installer._deprecation_constraint_check() - assert len(warning_list) == 0 From 1d679b8cfabd812ddae580835f5fa26050e2a252 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Tue, 19 Aug 2025 21:21:41 -0400 Subject: [PATCH 41/49] Exit `_deprecation_constraint_check` early when build constraints present --- src/pip/_internal/build_env.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index f0da9b228a6..97590013eb5 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -130,7 +130,7 @@ def _deprecation_constraint_check(self) -> None: """ global _DEPRECATION_WARNING_SHOWN - if self._build_constraint_feature_enabled: + if self._build_constraint_feature_enabled or self._build_constraints: return if _DEPRECATION_WARNING_SHOWN: From 84c955bfe0f1fab343e62da95c880b78f3ff3719 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Wed, 20 Aug 2025 20:53:35 -0400 Subject: [PATCH 42/49] Remove superfluous `constraints` parameter --- src/pip/_internal/build_env.py | 2 -- src/pip/_internal/cli/req_command.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 97590013eb5..aa255136c8e 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -114,12 +114,10 @@ def __init__( finder: PackageFinder, build_constraints: list[str] | None = None, build_constraint_feature_enabled: bool = False, - constraints: list[str] | None = None, ) -> None: self.finder = finder self._build_constraints = build_constraints or [] self._build_constraint_feature_enabled = build_constraint_feature_enabled - self._constraints = constraints or [] def _deprecation_constraint_check(self) -> None: """ diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index f553a1ae5cc..44aee20e819 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -145,7 +145,6 @@ def make_requirement_preparer( # Handle build constraints build_constraints = getattr(options, "build_constraints", []) - constraints = getattr(options, "constraints", []) build_constraint_feature_enabled = ( "build-constraint" in options.features_enabled ) @@ -159,7 +158,6 @@ def make_requirement_preparer( finder, build_constraints=build_constraints, build_constraint_feature_enabled=build_constraint_feature_enabled, - constraints=constraints, ), check_build_deps=options.check_build_deps, build_tracker=build_tracker, From 8e595f775d9b1cbf401a054ed5dc826680eae5ac Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sat, 11 Oct 2025 09:33:10 -0400 Subject: [PATCH 43/49] Use with to close pip session. --- src/pip/_internal/cli/cmdoptions.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index c7e49afd47f..73e25e36032 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -117,15 +117,12 @@ def check_build_constraints(options: Values) -> None: from pip._internal.network.session import PipSession from pip._internal.req.req_file import get_file_content - session = PipSession() - try: - # Eagerly check build constraints file contents - # is valid so that we don't fail in when trying - # to check constraints in isolated build process + # 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) - finally: - session.close() def _path_option_check(option: Option, opt: str, value: str) -> str: From b7ca0bf6ab5009dff2b92deb89a9185d7f6d2819 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sat, 11 Oct 2025 09:33:59 -0400 Subject: [PATCH 44/49] Remove deprication supression logic --- src/pip/_internal/build_env.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index aa255136c8e..9bbbea5f4f7 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -35,9 +35,6 @@ class ExtraEnviron(TypedDict, total=False): logger = logging.getLogger(__name__) -# Global flag to track if deprecation warning has been shown -_DEPRECATION_WARNING_SHOWN = False - def _dedup(a: str, b: str) -> tuple[str] | tuple[str, str]: return (a, b) if a != b else (a,) @@ -124,21 +121,15 @@ 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, but only shows the warning once per process. + is not empty. """ - global _DEPRECATION_WARNING_SHOWN - if self._build_constraint_feature_enabled or self._build_constraints: return - if _DEPRECATION_WARNING_SHOWN: - return - pip_constraint = os.environ.get("PIP_CONSTRAINT") if not pip_constraint or not pip_constraint.strip(): return - _DEPRECATION_WARNING_SHOWN = True deprecated( reason=( "Setting PIP_CONSTRAINT will not affect " From 6183877bc9a6d74f095f0958c8494c53dfa3867b Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sat, 11 Oct 2025 09:46:03 -0400 Subject: [PATCH 45/49] Update tests --- tests/functional/test_build_constraints.py | 37 +++++++++++++++++++++- tests/unit/test_build_constraints.py | 23 -------------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/tests/functional/test_build_constraints.py b/tests/functional/test_build_constraints.py index 5ed6dc954fb..1196eb64cf8 100644 --- a/tests/functional/test_build_constraints.py +++ b/tests/functional/test_build_constraints.py @@ -2,10 +2,14 @@ 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 @@ -43,6 +47,7 @@ def _run_pip_install_with_build_constraints( str(build_constraints_file), "--use-feature", "build-constraint", + "--use-pep517", ] if extra_args: @@ -63,10 +68,12 @@ def _run_pip_install_with_build_constraints_no_feature_flag( "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: @@ -112,6 +119,7 @@ def test_build_constraints_vs_regular_constraints_simple( regular_constraints_file, "--use-feature", "build-constraint", + "--use-pep517", str(project_dir), expect_error=False, ) @@ -137,6 +145,7 @@ def test_build_constraints_environment_isolation_simple( 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: @@ -155,13 +164,14 @@ def test_build_constraints_file_not_found( 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==45.0.0\n" + 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 @@ -169,3 +179,28 @@ def test_build_constraints_without_feature_flag( # 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/unit/test_build_constraints.py b/tests/unit/test_build_constraints.py index 719fd7ea714..10d2f318b22 100644 --- a/tests/unit/test_build_constraints.py +++ b/tests/unit/test_build_constraints.py @@ -18,12 +18,6 @@ class TestSubprocessBuildEnvironmentInstaller: """Test SubprocessBuildEnvironmentInstaller build constraints functionality.""" - def setup_method(self) -> None: - """Reset the global deprecation warning flag before each test.""" - import pip._internal.build_env - - pip._internal.build_env._DEPRECATION_WARNING_SHOWN = False - @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.""" @@ -119,20 +113,3 @@ def test_install_calls_deprecation_check( # Verify that call_subprocess was called (install proceeded after warning) mock_call_subprocess.assert_called_once() - - @mock.patch.dict(os.environ, {"PIP_CONSTRAINT": "constraints.txt"}) - def test_deprecation_check_warning_shown_only_once(self) -> None: - """Test deprecation warning is shown only once per process.""" - finder = make_test_finder() - installer = SubprocessBuildEnvironmentInstaller( - finder, - build_constraint_feature_enabled=False, - ) - - with pytest.warns(PipDeprecationWarning): - installer._deprecation_constraint_check() - - with warnings.catch_warnings(record=True) as warning_list: - warnings.simplefilter("always") - installer._deprecation_constraint_check() - assert len(warning_list) == 0 From 9587d29171edb01f3ec32b59ced4bd2713f9f4ff Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sat, 11 Oct 2025 09:50:37 -0400 Subject: [PATCH 46/49] fix lint --- tests/unit/test_build_constraints.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/test_build_constraints.py b/tests/unit/test_build_constraints.py index 10d2f318b22..b9505b7bd94 100644 --- a/tests/unit/test_build_constraints.py +++ b/tests/unit/test_build_constraints.py @@ -3,7 +3,6 @@ from __future__ import annotations import os -import warnings from pathlib import Path from unittest import mock From 3f0350e738cf8d8a2f4277f9c177fa28f27c95ca Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Tue, 15 Jul 2025 05:03:52 +0200 Subject: [PATCH 47/49] Avoid `pip install --dry-run` downloading full wheels * Removed "more preparation" (downloading) from the resolver to prevent downloading before dry-run validation * Added distribution caching to `InstallRequirement` with `set_dist()` and `get_dist()` methods to preserve metadata-only distributions * Set `download_info` during metadata-only fetching to ensure it's available for commands like `pip lock` and `--report` without requiring full downloads Closes #12603. --- news/12603.feature.rst | 1 + src/pip/_internal/commands/download.py | 4 ++-- src/pip/_internal/commands/install.py | 7 +++++++ src/pip/_internal/commands/wheel.py | 4 ++-- src/pip/_internal/operations/prepare.py | 6 ++++++ src/pip/_internal/req/req_install.py | 11 ++++++++++- src/pip/_internal/resolution/resolvelib/resolver.py | 5 ----- 7 files changed, 28 insertions(+), 10 deletions(-) create mode 100644 news/12603.feature.rst diff --git a/news/12603.feature.rst b/news/12603.feature.rst new file mode 100644 index 00000000000..da56b771820 --- /dev/null +++ b/news/12603.feature.rst @@ -0,0 +1 @@ +When PEP-658 metadata is available, full distribution download no longer occurs when using dry-run mode on install. diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index 25b5ceace6e..6a4752ad710 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -133,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: @@ -140,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/install.py b/src/pip/_internal/commands/install.py index 9e671853531..97103f8651c 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -417,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/wheel.py b/src/pip/_internal/commands/wheel.py index 3beeba943c8..4cef8973061 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -149,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: @@ -156,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/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( From 8c5db4d0f80ec3f98914ced3ab44078ecdd01f57 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Wed, 24 Sep 2025 13:17:08 +0200 Subject: [PATCH 48/49] Refine the news --- news/12603.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/12603.feature.rst b/news/12603.feature.rst index da56b771820..e432bd95028 100644 --- a/news/12603.feature.rst +++ b/news/12603.feature.rst @@ -1 +1 @@ -When PEP-658 metadata is available, full distribution download no longer occurs when using dry-run mode on install. +When PEP-658 metadata is available, full distribution download no longer occurs when using ``pip lock`` or ``pip install --dry-run``. From b7e7ecfc42dda098362924805c71575ccfb4e7f0 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Mon, 13 Oct 2025 23:32:18 -0400 Subject: [PATCH 49/49] Update news/12603.feature.rst Co-authored-by: Richard Si --- news/12603.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/12603.feature.rst b/news/12603.feature.rst index e432bd95028..cab327a85d2 100644 --- a/news/12603.feature.rst +++ b/news/12603.feature.rst @@ -1 +1 @@ -When PEP-658 metadata is available, full distribution download no longer occurs when using ``pip lock`` or ``pip install --dry-run``. +When PEP-658 metadata is available, full distribution files are no longer downloaded when using ``pip lock`` or ``pip install --dry-run``.