Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e1e72e7
Implement --upload-before
uranusjr May 20, 2024
c8b2481
Fix merge errors and rename to "exclude-newer-than"
notatallshaw Aug 6, 2025
a53d08a
Make common parse_iso_time
notatallshaw Aug 6, 2025
6db6c94
Add documentation on how to specify explicit timezone
notatallshaw Aug 6, 2025
c0ec2ec
Add exclude-newer tests
notatallshaw Aug 6, 2025
2bf1d3a
Pass exclude-newer-than to isolated build install
notatallshaw Aug 6, 2025
d5adbda
NEWS ENTRY
notatallshaw Aug 6, 2025
c374b2c
Fix linting
notatallshaw Aug 6, 2025
bc162b2
Merge branch 'main' into exclude-newer-than
notatallshaw Aug 8, 2025
72f363d
Add helpful error message on incorrect datetime format
notatallshaw Aug 9, 2025
181a7ca
Merge branch 'main' into exclude-newer-than
notatallshaw Aug 9, 2025
007caf6
Update tests/functional/test_exclude_newer.py
notatallshaw Aug 15, 2025
b53c5e8
Merge branch 'main' into exclude-newer-than
notatallshaw Aug 15, 2025
0f1bc46
Add `--no-deps` to request installs to not download unneeded packages
notatallshaw Aug 19, 2025
ad90024
Remove excessive functional tests
notatallshaw Aug 19, 2025
6ff91a4
Clean up test_finder tests
notatallshaw Aug 19, 2025
fbe923d
Update `test_handle_exclude_newer_than_naive_dates` comparison
notatallshaw Aug 19, 2025
703cdc4
Improve parameter formatting of `test_handle_exclude_newer_than_with_…
notatallshaw Aug 19, 2025
6cf2bec
Get exclude_newer_than from option
notatallshaw Aug 19, 2025
4713c6d
Add exclude-newer-than to the lock command
notatallshaw Aug 19, 2025
841ae12
Remove change in list, links, and wheel
notatallshaw Aug 19, 2025
61ec9b0
Update docs and news items to make clear index needs to provide `uplo…
notatallshaw Aug 19, 2025
e1f274a
Change name to uploaded prior to
notatallshaw Aug 21, 2025
e592e95
Add `--uploaded-prior-to` to the user guide
notatallshaw Aug 21, 2025
3cc912c
Merge branch 'main' into exclude-newer-than
notatallshaw Aug 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Comment on lines +311 to +315
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know that the JSON version of the Simple API is used in the vast majority of installs, but we should probably support this feature with the HTML Simple API if possible.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feature doesn't exist in the HTML version of the API, it only exists as addition JSON fields in the spec: https://peps.python.org/pep-0700/#specification

For the HTML version of the API, there is no change from version 1.0. For the JSON version of the API, the following changes are made:

IMO this was a short sighted choice by the spec authors, as it has prevented certain simple index libraries from supporting this feature. But we can't change the past.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, thanks for the information. /me wonders if it'd be useful to propose a v1.1 of the HTML spec to bring it to feature parity with the JSON API, but I don't have the time for that, haha.


# 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