Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e81f48e
Implement --upload-before
uranusjr May 20, 2024
685c472
Fix merge errors and rename to "exclude-newer-than"
notatallshaw Aug 6, 2025
df750e3
Make common parse_iso_time
notatallshaw Aug 6, 2025
49deb06
Add documentation on how to specify explicit timezone
notatallshaw Aug 6, 2025
f35d085
Add exclude-newer tests
notatallshaw Aug 6, 2025
5e7e259
Pass exclude-newer-than to isolated build install
notatallshaw Aug 6, 2025
67102a6
NEWS ENTRY
notatallshaw Aug 6, 2025
719f2bd
Fix linting
notatallshaw Aug 6, 2025
a490cf6
Add helpful error message on incorrect datetime format
notatallshaw Aug 9, 2025
2c8ecc7
Update tests/functional/test_exclude_newer.py
notatallshaw Aug 15, 2025
264a0e4
Add `--no-deps` to request installs to not download unneeded packages
notatallshaw Aug 19, 2025
6e982de
Remove excessive functional tests
notatallshaw Aug 19, 2025
a892fbe
Clean up test_finder tests
notatallshaw Aug 19, 2025
ce9ab0a
Update `test_handle_exclude_newer_than_naive_dates` comparison
notatallshaw Aug 19, 2025
798a66f
Improve parameter formatting of `test_handle_exclude_newer_than_with_…
notatallshaw Aug 19, 2025
57e4210
Get exclude_newer_than from option
notatallshaw Aug 19, 2025
dba723c
Add exclude-newer-than to the lock command
notatallshaw Aug 19, 2025
f3346fc
Remove change in list, links, and wheel
notatallshaw Aug 19, 2025
8ea6f77
Update docs and news items to make clear index needs to provide `uplo…
notatallshaw Aug 19, 2025
2a9f0ea
Change name to uploaded prior to
notatallshaw Aug 21, 2025
db022eb
Add `--uploaded-prior-to` to the user guide
notatallshaw Aug 21, 2025
7c7670e
Fix type hint error in `make_test_link_evaluator`
notatallshaw Oct 4, 2025
15f06e3
Do not allow indexes which don't provide `upload-time` if `--uploaded…
notatallshaw Oct 18, 2025
041c8b6
Rename and reword news entry
notatallshaw Oct 18, 2025
716ee0d
Use year 2100 in tests instead of 3030 to avoid Windows datetime limits
notatallshaw Oct 18, 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
60 changes: 60 additions & 0 deletions docs/html/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,66 @@ Example build constraints file (``build-constraints.txt``):
cython==0.29.24


.. _`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 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:

.. 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`:


Expand Down
2 changes: 2 additions & 0 deletions news/13625.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add ``--uploaded-prior-to`` option to only consider packages uploaded prior to
a given datetime when the ``upload-time`` field is available from a remote index.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 13 # default is 6
max-returns = 15 # default is 6
max-statements = 134 # default is 50

[tool.ruff.per-file-target-version]
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 @@ -230,6 +230,8 @@ def install(
# in the isolated build environment
extra_environ = {"extra_environ": {"_PIP_IN_BUILD_IGNORE_CONSTRAINTS": "1"}}

if finder.uploaded_prior_to:
args.extend(["--uploaded-prior-to", finder.uploaded_prior_to.isoformat()])
args.append("--")
args.extend(requirements)

Expand Down
49 changes: 49 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 @@ -834,6 +835,54 @@ def _handle_dependency_group(
help="Ignore the Requires-Python information.",
)


def _handle_uploaded_prior_to(
option: Option, opt: str, value: str, parser: OptionParser
) -> None:
"""
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.

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

try:
uploaded_prior_to = parse_iso_datetime(value)
# Use local timezone if no offset is given in the ISO string.
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 --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)


uploaded_prior_to: Callable[..., Option] = partial(
Option,
"--uploaded-prior-to",
dest="uploaded_prior_to",
metavar="datetime",
action="callback",
callback=_handle_uploaded_prior_to,
type="str",
help=(
"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."
),
)

no_build_isolation: Callable[..., Option] = partial(
Option,
"--no-build-isolation",
Expand Down
1 change: 1 addition & 0 deletions src/pip/_internal/cli/req_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,4 +371,5 @@ def _build_package_finder(
link_collector=link_collector,
selection_prefs=selection_prefs,
target_python=target_python,
uploaded_prior_to=options.uploaded_prior_to,
)
1 change: 1 addition & 0 deletions src/pip/_internal/commands/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.uploaded_prior_to())

self.cmd_opts.add_option(
"-d",
Expand Down
2 changes: 2 additions & 0 deletions src/pip/_internal/commands/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +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.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())
Expand Down Expand Up @@ -103,6 +104,7 @@ def _build_package_finder(
link_collector=link_collector,
selection_prefs=selection_prefs,
target_python=target_python,
uploaded_prior_to=options.uploaded_prior_to,
)

def get_available_package_versions(self, options: Values, args: list[Any]) -> None:
Expand Down
1 change: 1 addition & 0 deletions src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ def add_options(self) -> None:
),
)

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())
Expand Down
1 change: 1 addition & 0 deletions src/pip/_internal/commands/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,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.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())
Expand Down
1 change: 1 addition & 0 deletions src/pip/_internal/commands/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.uploaded_prior_to())
self.cmd_opts.add_option(cmdoptions.no_deps())
self.cmd_opts.add_option(cmdoptions.progress_bar())

Expand Down
49 changes: 48 additions & 1 deletion 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 All @@ -24,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
Expand Down Expand Up @@ -111,6 +113,8 @@ class LinkType(enum.Enum):
format_invalid = enum.auto()
platform_mismatch = enum.auto()
requires_python_mismatch = enum.auto()
upload_too_late = enum.auto()
upload_time_missing = enum.auto()


class LinkEvaluator:
Expand All @@ -132,6 +136,7 @@ def __init__(
target_python: TargetPython,
allow_yanked: bool,
ignore_requires_python: bool | None = None,
uploaded_prior_to: datetime.datetime | None = None,
) -> None:
"""
:param project_name: The user supplied package name.
Expand All @@ -149,6 +154,8 @@ def __init__(
:param ignore_requires_python: Whether to ignore incompatible
PEP 503 "data-requires-python" values in HTML links. Defaults
to False.
: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
Expand All @@ -158,6 +165,7 @@ def __init__(
self._ignore_requires_python = ignore_requires_python
self._formats = formats
self._target_python = target_python
self._uploaded_prior_to = uploaded_prior_to

self.project_name = project_name

Expand Down Expand Up @@ -218,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}"
Expand Down Expand Up @@ -593,6 +625,7 @@ def __init__(
format_control: FormatControl | None = None,
candidate_prefs: CandidatePreferences | None = None,
ignore_requires_python: bool | None = None,
uploaded_prior_to: datetime.datetime | None = None,
) -> None:
"""
This constructor is primarily meant to be used by the create() class
Expand All @@ -614,6 +647,7 @@ def __init__(
self._ignore_requires_python = ignore_requires_python
self._link_collector = link_collector
self._target_python = target_python
self._uploaded_prior_to = uploaded_prior_to

self.format_control = format_control

Expand All @@ -637,6 +671,7 @@ def create(
link_collector: LinkCollector,
selection_prefs: SelectionPreferences,
target_python: TargetPython | None = None,
uploaded_prior_to: datetime.datetime | None = None,
) -> PackageFinder:
"""Create a PackageFinder.

Expand All @@ -645,6 +680,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 uploaded_prior_to: If set, only find links uploaded prior
to the given datetime.
"""
if target_python is None:
target_python = TargetPython()
Expand All @@ -661,6 +698,7 @@ def create(
allow_yanked=selection_prefs.allow_yanked,
format_control=selection_prefs.format_control,
ignore_requires_python=selection_prefs.ignore_requires_python,
uploaded_prior_to=uploaded_prior_to,
)

@property
Expand Down Expand Up @@ -720,6 +758,10 @@ def prefer_binary(self) -> bool:
def set_prefer_binary(self) -> None:
self._candidate_prefs.prefer_binary = True

@property
def uploaded_prior_to(self) -> datetime.datetime | None:
return self._uploaded_prior_to

def requires_python_skipped_reasons(self) -> list[str]:
reasons = {
detail
Expand All @@ -739,6 +781,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,
uploaded_prior_to=self._uploaded_prior_to,
)

def _sort_links(self, links: Iterable[Link]) -> list[Link]:
Expand Down Expand Up @@ -773,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
Expand Down
Loading