Skip to content

Implement --exclude-newer-than #13520

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions news/13520.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add ``--exclude-newer-than`` option to exclude packages uploaded after a given date.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

######################################################################################
Expand Down
2 changes: 2 additions & 0 deletions src/pip/_internal/build_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 47 additions & 0 deletions src/pip/_internal/cli/cmdoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/pip/_internal/cli/req_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import datetime
import logging
from functools import partial
from optparse import Values
Expand Down Expand Up @@ -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.
Expand All @@ -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,
)
2 changes: 2 additions & 0 deletions src/pip/_internal/commands/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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())
Expand Down
5 changes: 5 additions & 0 deletions src/pip/_internal/commands/index.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import datetime
import json
import logging
from collections.abc import Iterable
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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] = (
Expand Down
2 changes: 2 additions & 0 deletions src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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())

Expand Down
4 changes: 3 additions & 1 deletion src/pip/_internal/commands/list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion src/pip/_internal/commands/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions src/pip/_internal/index/package_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import datetime
import enum
import functools
import itertools
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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

Expand All @@ -176,6 +181,13 @@ def evaluate_link(self, link: Link) -> tuple[LinkType, str]:
reason = link.yanked_reason or "<none given>"
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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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.

Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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]:
Expand Down
21 changes: 16 additions & 5 deletions src/pip/_internal/models/link.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
from __future__ import annotations

import datetime
import functools
import itertools
import logging
import os
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
Expand Down Expand Up @@ -207,6 +206,7 @@ class Link:
"requires_python",
"yanked_reason",
"metadata_file_data",
"upload_time",
"cache_link_parsing",
"egg_fragment",
]
Expand All @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -325,6 +335,7 @@ def from_json(
yanked_reason=yanked_reason,
hashes=hashes,
metadata_file_data=metadata_file_data,
upload_time=upload_time,
)

@classmethod
Expand Down
Loading
Loading