diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst
index d6a0acf9cd8..a126dc66f1a 100644
--- a/docs/html/user_guide.rst
+++ b/docs/html/user_guide.rst
@@ -257,6 +257,61 @@ e.g. http://example.com/constraints.txt, so that your organization can store and
serve them in a centralized place.
+.. _`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 works with package indexes that provide upload-time metadata
+ (such as PyPI). When upload-time information is not available, packages are not
+ filtered and installation continues normally.
+
+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`:
diff --git a/news/13520.feature.rst b/news/13520.feature.rst
new file mode 100644
index 00000000000..54c272bba79
--- /dev/null
+++ b/news/13520.feature.rst
@@ -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 an index.
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..39d9db06522 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.uploaded_prior_to:
+ args.extend(["--uploaded-prior-to", finder.uploaded_prior_to.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..89fbc1fcaa9 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,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",
diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py
index dc1328ff019..0a8758ca355 100644
--- a/src/pip/_internal/cli/req_command.py
+++ b/src/pip/_internal/cli/req_command.py
@@ -348,4 +348,5 @@ def _build_package_finder(
link_collector=link_collector,
selection_prefs=selection_prefs,
target_python=target_python,
+ uploaded_prior_to=options.uploaded_prior_to,
)
diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py
index 900fb403d6f..c09e4a39c80 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.uploaded_prior_to())
self.cmd_opts.add_option(
"-d",
diff --git a/src/pip/_internal/commands/index.py b/src/pip/_internal/commands/index.py
index ecac99888db..b53099452e0 100644
--- a/src/pip/_internal/commands/index.py
+++ b/src/pip/_internal/commands/index.py
@@ -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())
@@ -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:
diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py
index 1ef7a0f4410..cdb3716a684 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.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())
diff --git a/src/pip/_internal/commands/lock.py b/src/pip/_internal/commands/lock.py
index e4a978d5aaa..c00d7d45e11 100644
--- a/src/pip/_internal/commands/lock.py
+++ b/src/pip/_internal/commands/lock.py
@@ -67,6 +67,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())
diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py
index 61be254912f..dfa144a871a 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.uploaded_prior_to())
self.cmd_opts.add_option(cmdoptions.no_deps())
self.cmd_opts.add_option(cmdoptions.progress_bar())
diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py
index bc523cd42d8..fe959efa9e4 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,
+ uploaded_prior_to: datetime.datetime | None = None,
) -> None:
"""
:param project_name: The user supplied package name.
@@ -149,6 +152,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
@@ -158,6 +163,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
@@ -176,6 +182,14 @@ 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._uploaded_prior_to is not None:
+ if link.upload_time >= self._uploaded_prior_to:
+ reason = (
+ f"Upload time {link.upload_time} not "
+ f"prior to {self._uploaded_prior_to}"
+ )
+ return (LinkType.upload_too_late, reason)
+
if link.egg_fragment:
egg_info = link.egg_fragment
ext = link.ext
@@ -593,6 +607,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
@@ -614,6 +629,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
@@ -637,6 +653,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.
@@ -645,6 +662,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()
@@ -661,6 +680,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
@@ -720,6 +740,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
@@ -739,6 +763,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]:
diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py
index 2e2c0f836ac..140f2cc47db 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,6 +8,7 @@
import posixpath
import re
import urllib.parse
+import urllib.request
from collections.abc import Mapping
from dataclasses import dataclass
from typing import (
@@ -15,6 +17,7 @@
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 +210,7 @@ class Link:
"requires_python",
"yanked_reason",
"metadata_file_data",
+ "upload_time",
"cache_link_parsing",
"egg_fragment",
]
@@ -218,6 +222,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 +244,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 +279,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 +308,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 +339,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_uploaded_prior_to.py b/tests/functional/test_uploaded_prior_to.py
new file mode 100644
index 00000000000..33053fa1712
--- /dev/null
+++ b/tests/functional/test_uploaded_prior_to.py
@@ -0,0 +1,77 @@
+"""Tests for pip install --uploaded-prior-to."""
+
+from __future__ import annotations
+
+import pytest
+
+from tests.lib import PipTestEnvironment, TestData
+
+
+class TestUploadedPriorTo:
+ """Test --uploaded-prior-to functionality.
+
+ Only effective with indexes that provide upload-time metadata.
+ """
+
+ def test_uploaded_prior_to_invalid_date(
+ self, script: PipTestEnvironment, data: TestData
+ ) -> None:
+ """Test that --uploaded-prior-to fails with invalid date format."""
+ result = script.pip_install_local(
+ "--uploaded-prior-to=invalid-date", "simple", expect_error=True
+ )
+
+ # Should fail with date parsing error
+ assert "invalid" in result.stderr.lower() or "error" in result.stderr.lower()
+
+ @pytest.mark.network
+ def test_uploaded_prior_to_with_real_pypi(self, script: PipTestEnvironment) -> None:
+ """Test uploaded-prior-to 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",
+ "--no-deps",
+ "--uploaded-prior-to=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",
+ "--no-deps",
+ "--uploaded-prior-to=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_uploaded_prior_to_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",
+ "--no-deps",
+ f"--uploaded-prior-to={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/lib/__init__.py b/tests/lib/__init__.py
index 78fe3604480..f3102db24e1 100644
--- a/tests/lib/__init__.py
+++ b/tests/lib/__init__.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import datetime
import json
import os
import pathlib
@@ -107,6 +108,7 @@ def make_test_finder(
allow_all_prereleases: bool = False,
session: PipSession | None = None,
target_python: TargetPython | None = None,
+ uploaded_prior_to: datetime.datetime | None = None,
) -> PackageFinder:
"""
Create a PackageFinder for testing purposes.
@@ -125,6 +127,7 @@ def make_test_finder(
link_collector=link_collector,
selection_prefs=selection_prefs,
target_python=target_python,
+ uploaded_prior_to=uploaded_prior_to,
)
diff --git a/tests/unit/test_cmdoptions.py b/tests/unit/test_cmdoptions.py
index 9f7e01e3cf4..9316a603a95 100644
--- a/tests/unit/test_cmdoptions.py
+++ b/tests/unit/test_cmdoptions.py
@@ -1,12 +1,17 @@
from __future__ import annotations
+import datetime
import os
+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_uploaded_prior_to,
+)
from pip._internal.cli.main_parser import identify_python_interpreter
@@ -51,3 +56,92 @@ 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_datetime",
+ [
+ (
+ "2023-01-01T00:00:00+00:00",
+ datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc),
+ ),
+ (
+ "2023-01-01T12:00:00-05:00",
+ datetime.datetime(
+ *(2023, 1, 1, 12, 0, 0),
+ tzinfo=datetime.timezone(datetime.timedelta(hours=-5)),
+ ),
+ ),
+ ],
+)
+def test_handle_uploaded_prior_to_with_timezone(
+ value: str, expected_datetime: datetime.datetime
+) -> None:
+ """Test that timezone-aware ISO 8601 date strings are parsed correctly."""
+ option = Option("--uploaded-prior-to", dest="uploaded_prior_to")
+ opt = "--uploaded-prior-to"
+ parser = OptionParser()
+ parser.values = Values()
+
+ _handle_uploaded_prior_to(option, opt, value, parser)
+
+ result = parser.values.uploaded_prior_to
+ assert isinstance(result, datetime.datetime)
+ assert result == expected_datetime
+
+
+@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_uploaded_prior_to_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("--uploaded-prior-to", dest="uploaded_prior_to")
+ opt = "--uploaded-prior-to"
+ parser = OptionParser()
+ parser.values = Values()
+
+ _handle_uploaded_prior_to(option, opt, value, parser)
+
+ result = parser.values.uploaded_prior_to
+ assert isinstance(result, datetime.datetime)
+
+ # Check that the date/time components match
+ assert result.timetuple()[:6] == expected_date_time
+
+ # 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_uploaded_prior_to_invalid_dates(invalid_value: str) -> None:
+ """Test that invalid date strings raise SystemExit via raise_option_error."""
+ option = Option("--uploaded-prior-to", dest="uploaded_prior_to")
+ opt = "--uploaded-prior-to"
+ parser = OptionParser()
+ parser.values = Values()
+
+ with pytest.raises(SystemExit):
+ _handle_uploaded_prior_to(option, opt, invalid_value, parser)
diff --git a/tests/unit/test_finder.py b/tests/unit/test_finder.py
index c8ab1abb78b..b74e8f825c1 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
@@ -574,3 +575,40 @@ 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 TestPackageFinderUploadedPriorTo:
+ """Test PackageFinder integration with uploaded_prior_to functionality.
+
+ Only effective with indexes that provide upload-time metadata.
+ """
+
+ def test_package_finder_create_with_uploaded_prior_to(self) -> None:
+ """Test that PackageFinder.create() accepts uploaded_prior_to parameter."""
+ uploaded_prior_to = datetime.datetime(
+ 2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+ )
+
+ finder = make_test_finder(uploaded_prior_to=uploaded_prior_to)
+
+ assert finder._uploaded_prior_to == uploaded_prior_to
+
+ def test_package_finder_make_link_evaluator_with_uploaded_prior_to(self) -> None:
+ """Test that PackageFinder creates LinkEvaluator with uploaded_prior_to."""
+ uploaded_prior_to = datetime.datetime(
+ 2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+ )
+
+ finder = make_test_finder(uploaded_prior_to=uploaded_prior_to)
+
+ link_evaluator = finder.make_link_evaluator("test-package")
+ assert link_evaluator._uploaded_prior_to == uploaded_prior_to
+
+ def test_package_finder_uploaded_prior_to_none(self) -> None:
+ """Test that PackageFinder works correctly when uploaded_prior_to is None."""
+ finder = make_test_finder(uploaded_prior_to=None)
+
+ assert finder._uploaded_prior_to is None
+
+ link_evaluator = finder.make_link_evaluator("test-package")
+ assert link_evaluator._uploaded_prior_to is None
diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py
index 9b4c881c050..9a5589c9931 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,184 @@ def test_filter_unallowed_hashes__log_message_with_no_match(
check_caplog(caplog, "DEBUG", expected_message)
+class TestLinkEvaluatorUploadedPriorTo:
+ """Test the uploaded_prior_to functionality in LinkEvaluator.
+
+ Only effective with indexes that provide upload-time metadata.
+ """
+
+ def make_test_link_evaluator(
+ self, uploaded_prior_to: 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,
+ uploaded_prior_to=uploaded_prior_to,
+ )
+
+ @pytest.mark.parametrize(
+ "upload_time, uploaded_prior_to, 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 not prior to "
+ "2023-06-01 00:00:00+00:00",
+ ),
+ ),
+ # Test case: upload time equals the cutoff (should be rejected)
+ (
+ 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.upload_too_late,
+ "Upload time 2023-06-01 00:00:00+00:00 not prior to "
+ "2023-06-01 00:00:00+00:00",
+ ),
+ ),
+ # Test case: no uploaded_prior_to 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_uploaded_prior_to(
+ self,
+ upload_time: datetime.datetime,
+ uploaded_prior_to: datetime.datetime | None,
+ expected_result: tuple[LinkType, str],
+ ) -> None:
+ """Test that links are properly filtered by upload time."""
+ evaluator = self.make_test_link_evaluator(uploaded_prior_to)
+ 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."""
+ uploaded_prior_to = datetime.datetime(
+ 2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+ )
+ evaluator = self.make_test_link_evaluator(uploaded_prior_to)
+
+ # 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
+ uploaded_prior_to = datetime.datetime(
+ 2023, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
+ )
+ evaluator = self.make_test_link_evaluator(uploaded_prior_to)
+
+ # 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(
+ "uploaded_prior_to",
+ [
+ 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_uploaded_prior_to_different_timezone_formats(
+ self, uploaded_prior_to: datetime.datetime
+ ) -> None:
+ """Test that different timezone formats for uploaded_prior_to work."""
+ evaluator = self.make_test_link_evaluator(uploaded_prior_to)
+
+ # 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
+
+ def test_uploaded_prior_to_boundary_precision(self) -> None:
+ """
+ Test that --uploaded-prior-to 2025-01-01 excludes packages
+ uploaded exactly at 2025-01-01T00:00:00.
+ """
+ # --uploaded-prior-to 2025-01-01 should be strictly less than 2025-01-01
+ cutoff_date = datetime.datetime(
+ 2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
+ )
+ evaluator = self.make_test_link_evaluator(uploaded_prior_to=cutoff_date)
+
+ # Package uploaded exactly at 2025-01-01T00:00:00 should be rejected
+ link_at_boundary = Link(
+ "https://example.com/myproject-1.0.tar.gz",
+ upload_time=cutoff_date,
+ )
+ result_at_boundary = evaluator.evaluate_link(link_at_boundary)
+ assert result_at_boundary[0] == LinkType.upload_too_late
+ assert "not prior to" in result_at_boundary[1]
+
+ # Package uploaded 1 second before should be accepted
+ before_cutoff = cutoff_date - datetime.timedelta(seconds=1)
+ link_before = Link(
+ "https://example.com/myproject-1.0.tar.gz",
+ upload_time=before_cutoff,
+ )
+ result_before = evaluator.evaluate_link(link_before)
+ assert result_before[0] == LinkType.candidate
+
+ # Package uploaded 1 second after should be rejected
+ after_cutoff = cutoff_date + datetime.timedelta(seconds=1)
+ link_after = Link(
+ "https://example.com/myproject-1.0.tar.gz",
+ upload_time=after_cutoff,
+ )
+ result_after = evaluator.evaluate_link(link_after)
+ assert result_after[0] == LinkType.upload_too_late
+
+
class TestCandidateEvaluator:
@pytest.mark.parametrize(
"allow_all_prereleases, prefer_binary",