diff --git a/pyproject.toml b/pyproject.toml index 7af2982838a..76438e56bdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,7 +205,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 a47f8a3f46a..3b9409ecd8b 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -10,6 +10,7 @@ # The following comment should be removed at some point in the future. # mypy: strict-optional=False +import datetime import importlib.util import logging import os @@ -741,6 +742,34 @@ def _handle_no_cache_dir( 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 92900f94ff4..21c8b41bb80 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -5,6 +5,7 @@ PackageFinder machinery and all its vendored dependencies, etc. """ +import datetime import logging from functools import partial from optparse import Values @@ -306,6 +307,7 @@ def _build_package_finder( session: PipSession, target_python: Optional[TargetPython] = None, ignore_requires_python: Optional[bool] = None, + upload_before: Optional[datetime.datetime] = None, ) -> PackageFinder: """ Create a package finder appropriate to this requirement command. @@ -326,4 +328,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 917bbb91d83..c528dda33c8 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -52,6 +52,7 @@ def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.no_use_pep517()) self.cmd_opts.add_option(cmdoptions.check_build_deps()) self.cmd_opts.add_option(cmdoptions.ignore_requires_python()) + self.cmd_opts.add_option(cmdoptions.upload_before()) self.cmd_opts.add_option( "-d", @@ -94,6 +95,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 2e2661bba71..22bc362b4b3 100644 --- a/src/pip/_internal/commands/index.py +++ b/src/pip/_internal/commands/index.py @@ -1,3 +1,4 @@ +import datetime import logging from optparse import Values from typing import Any, Iterable, List, Optional @@ -33,6 +34,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.no_binary()) self.cmd_opts.add_option(cmdoptions.only_binary()) @@ -81,6 +83,7 @@ def _build_package_finder( session: PipSession, target_python: Optional[TargetPython] = None, ignore_requires_python: Optional[bool] = None, + upload_before: Optional[datetime.datetime] = None, ) -> PackageFinder: """ Create a package finder appropriate to the index command. @@ -98,6 +101,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: @@ -113,6 +117,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 d5b06c8c785..cd7fb7fbfd8 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -192,6 +192,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()) @@ -329,6 +330,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 82fc46a118f..a14c740a735 100644 --- a/src/pip/_internal/commands/list.py +++ b/src/pip/_internal/commands/list.py @@ -139,7 +139,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 278719f4e0c..0b429aeeaa0 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -65,6 +65,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()) @@ -104,7 +105,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 fb270f22f83..e0c434f6d5b 100644 --- a/src/pip/_internal/index/package_finder.py +++ b/src/pip/_internal/index/package_finder.py @@ -1,5 +1,6 @@ """Routines related to PyPI, indexes""" +import datetime import enum import functools import itertools @@ -104,6 +105,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: @@ -125,6 +127,7 @@ def __init__( target_python: TargetPython, allow_yanked: bool, ignore_requires_python: Optional[bool] = None, + upload_before: Optional[datetime.datetime] = None, ) -> None: """ :param project_name: The user supplied package name. @@ -142,6 +145,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 @@ -151,6 +155,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 @@ -169,6 +174,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 @@ -593,6 +603,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, ) -> None: """ This constructor is primarily meant to be used by the create() class @@ -614,6 +625,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 @@ -630,6 +642,7 @@ def create( link_collector: LinkCollector, selection_prefs: SelectionPreferences, target_python: Optional[TargetPython] = None, + upload_before: Optional[datetime.datetime] = None, ) -> "PackageFinder": """Create a PackageFinder. @@ -638,6 +651,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() @@ -654,6 +668,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 @@ -714,6 +729,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 2f41f2f6a09..837271a599b 100644 --- a/src/pip/_internal/models/link.py +++ b/src/pip/_internal/models/link.py @@ -1,3 +1,4 @@ +import datetime import functools import itertools import logging @@ -190,6 +191,7 @@ class Link: "requires_python", "yanked_reason", "metadata_file_data", + "upload_time", "cache_link_parsing", "egg_fragment", ] @@ -201,6 +203,7 @@ def __init__( 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: Optional[Mapping[str, str]] = None, ) -> None: @@ -222,6 +225,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. @@ -253,6 +258,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() @@ -281,6 +287,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 @@ -306,6 +318,7 @@ def from_json( yanked_reason=yanked_reason, hashes=hashes, metadata_file_data=metadata_file_data, + upload_time=upload_time, ) @classmethod