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. diff --git a/pyproject.toml b/pyproject.toml index 9b8bd54a866..9d753997ccf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -269,7 +269,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/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/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 3519dadf13d..44f3e9917ba 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -29,6 +29,7 @@ from pip._internal.models.format_control import FormatControl from pip._internal.models.index import PyPI from pip._internal.models.target_python import TargetPython +from pip._internal.utils.datetime import parse_iso_datetime from pip._internal.utils.hashes import STRONG_HASHES from pip._internal.utils.misc import strtobool @@ -796,6 +797,52 @@ def _handle_dependency_group( help="Ignore the Requires-Python information.", ) + +def _handle_exclude_newer_than( + 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. + + Parses an ISO 8601 datetime string. If no timezone is specified in the string, + local timezone is used. + """ + if value is None: + return None + + 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( + Option, + "--exclude-newer-than", + dest="exclude_newer_than", + metavar="datetime", + action="callback", + 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." + ), +) + 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..cd735e348c2 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -7,6 +7,7 @@ from __future__ import annotations +import datetime import logging from functools import partial from optparse import Values @@ -328,6 +329,7 @@ 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. @@ -348,4 +350,5 @@ def _build_package_finder( link_collector=link_collector, selection_prefs=selection_prefs, target_python=target_python, + exclude_newer_than=exclude_newer_than, ) diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index 900fb403d6f..4416fde33bd 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.exclude_newer_than()) 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, + 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 ecac99888db..2d4571bc9f1 100644 --- a/src/pip/_internal/commands/index.py +++ b/src/pip/_internal/commands/index.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime import json import logging from collections.abc import Iterable @@ -40,6 +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.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()) @@ -86,6 +88,7 @@ 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. @@ -103,6 +106,7 @@ def _build_package_finder( link_collector=link_collector, selection_prefs=selection_prefs, target_python=target_python, + exclude_newer_than=exclude_newer_than, ) def get_available_package_versions(self, options: Values, args: list[Any]) -> None: @@ -118,6 +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, + 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 1ef7a0f4410..c109816aa59 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.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()) @@ -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, + 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 ad27e45ce93..8d1cf595bc4 100644 --- a/src/pip/_internal/commands/list.py +++ b/src/pip/_internal/commands/list.py @@ -143,7 +143,9 @@ 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 61be254912f..bd82fcc0886 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.exclude_newer_than()) 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, + exclude_newer_than=options.exclude_newer_than, + ) 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..184da807c5e 100644 --- a/src/pip/_internal/index/package_finder.py +++ b/src/pip/_internal/index/package_finder.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime import enum import functools import itertools @@ -111,6 +112,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: @@ -132,6 +134,7 @@ def __init__( target_python: TargetPython, allow_yanked: bool, ignore_requires_python: bool | None = None, + exclude_newer_than: datetime.datetime | None = None, ) -> None: """ :param project_name: The user supplied package name. @@ -149,6 +152,7 @@ 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. """ if ignore_requires_python is None: ignore_requires_python = False @@ -158,6 +162,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.project_name = project_name @@ -176,6 +181,13 @@ 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: + reason = ( + f"Upload time {link.upload_time} after {self._exclude_newer_than}" + ) + return (LinkType.upload_too_late, reason) + if link.egg_fragment: egg_info = link.egg_fragment ext = link.ext @@ -593,6 +605,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, ) -> None: """ This constructor is primarily meant to be used by the create() class @@ -614,6 +627,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.format_control = format_control @@ -637,6 +651,7 @@ def create( link_collector: LinkCollector, selection_prefs: SelectionPreferences, target_python: TargetPython | None = None, + exclude_newer_than: datetime.datetime | None = None, ) -> PackageFinder: """Create a PackageFinder. @@ -645,6 +660,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 exclude_newer_than: If set, only find links prior to the given date. """ if target_python is None: target_python = TargetPython() @@ -661,6 +677,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, ) @property @@ -720,6 +737,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 @@ -739,6 +760,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, ) 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..07c06c0b102 100644 --- a/src/pip/_internal/models/link.py +++ b/src/pip/_internal/models/link.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime import functools import itertools import logging @@ -7,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 @@ -207,6 +206,7 @@ class Link: "requires_python", "yanked_reason", "metadata_file_data", + "upload_time", "cache_link_parsing", "egg_fragment", ] @@ -218,6 +218,7 @@ def __init__( requires_python: str | None = None, yanked_reason: str | None = None, metadata_file_data: MetadataFile | None = None, + upload_time: datetime.datetime | None = None, cache_link_parsing: bool = True, hashes: Mapping[str, str] | None = None, ) -> None: @@ -239,6 +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: datetime.datetime | None + if upload_time_data := file_data.get("upload-time"): + upload_time = parse_iso_datetime(upload_time_data) + else: + upload_time = None + # The metadata info value may be a boolean, or a dict of hashes. if isinstance(metadata_info, dict): # The file exists, and hashes have been supplied @@ -325,6 +335,7 @@ def from_json( yanked_reason=yanked_reason, hashes=hashes, metadata_file_data=metadata_file_data, + upload_time=upload_time, ) @classmethod diff --git a/src/pip/_internal/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")) 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..b7a6cee8fcc 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 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(SystemExit): + _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",