Skip to content

Commit 15f06e3

Browse files
committed
Do not allow indexes which don't provide upload-time if --uploaded-prior-to is used
1 parent 7c7670e commit 15f06e3

File tree

5 files changed

+137
-34
lines changed

5 files changed

+137
-34
lines changed

docs/html/user_guide.rst

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -333,9 +333,15 @@ results on different machines.
333333

334334
.. note::
335335

336-
This option only works with package indexes that provide upload-time metadata
337-
(such as PyPI). When upload-time information is not available, packages are not
338-
filtered and installation continues normally.
336+
This option only applies to packages from indexes, not local files. Local
337+
package files are allowed regardless of the ``--uploaded-prior-to`` setting.
338+
e.g., ``pip install /path/to/package.whl`` or packages from
339+
``--find-links`` directories.
340+
341+
This option requires package indexes that provide upload-time metadata
342+
(such as PyPI). If the index does not provide upload-time metadata for a
343+
package file, pip will fail immediately with an error message indicating
344+
that upload-time metadata is required when using ``--uploaded-prior-to``.
339345

340346
You can combine this option with other filtering mechanisms like constraints files:
341347

@@ -350,7 +356,6 @@ You can combine this option with other filtering mechanisms like constraints fil
350356
.. code-block:: shell
351357
352358
py -m pip install -c constraints.txt --uploaded-prior-to=2025-03-16 SomePackage
353-
>>>>>>> e592e95e9 (Add `--uploaded-prior-to` to the user guide)
354359
355360
356361
.. _`Dependency Groups`:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ max-complexity = 33 # default is 10
268268
[tool.ruff.lint.pylint]
269269
max-args = 15 # default is 5
270270
max-branches = 28 # default is 12
271-
max-returns = 14 # default is 6
271+
max-returns = 15 # default is 6
272272
max-statements = 134 # default is 50
273273

274274
[tool.ruff.per-file-target-version]

src/pip/_internal/index/package_finder.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@
2525
from pip._internal.exceptions import (
2626
BestVersionAlreadyInstalled,
2727
DistributionNotFound,
28+
InstallationError,
2829
InvalidWheelFilename,
2930
UnsupportedWheel,
3031
)
31-
from pip._internal.index.collector import LinkCollector, parse_links
32+
from pip._internal.index.collector import IndexContent, LinkCollector, parse_links
3233
from pip._internal.models.candidate import InstallationCandidate
3334
from pip._internal.models.format_control import FormatControl
3435
from pip._internal.models.link import Link
@@ -113,6 +114,7 @@ class LinkType(enum.Enum):
113114
platform_mismatch = enum.auto()
114115
requires_python_mismatch = enum.auto()
115116
upload_too_late = enum.auto()
117+
upload_time_missing = enum.auto()
116118

117119

118120
class LinkEvaluator:
@@ -182,14 +184,6 @@ def evaluate_link(self, link: Link) -> tuple[LinkType, str]:
182184
reason = link.yanked_reason or "<none given>"
183185
return (LinkType.yanked, f"yanked for reason: {reason}")
184186

185-
if link.upload_time is not None and self._uploaded_prior_to is not None:
186-
if link.upload_time >= self._uploaded_prior_to:
187-
reason = (
188-
f"Upload time {link.upload_time} not "
189-
f"prior to {self._uploaded_prior_to}"
190-
)
191-
return (LinkType.upload_too_late, reason)
192-
193187
if link.egg_fragment:
194188
egg_info = link.egg_fragment
195189
ext = link.ext
@@ -232,6 +226,30 @@ def evaluate_link(self, link: Link) -> tuple[LinkType, str]:
232226

233227
version = wheel.version
234228

229+
# Check upload-time filter after verifying the link is a package file.
230+
# Skip this check for local files, as --uploaded-prior-to only applies
231+
# to packages from indexes.
232+
if self._uploaded_prior_to is not None and not link.is_file:
233+
if link.upload_time is None:
234+
if isinstance(link.comes_from, IndexContent):
235+
index_info = f"Index {link.comes_from.url}"
236+
elif link.comes_from:
237+
index_info = f"Index {link.comes_from}"
238+
else:
239+
index_info = "Index"
240+
241+
return (
242+
LinkType.upload_time_missing,
243+
f"{index_info} does not provide upload-time metadata. "
244+
"Cannot use --uploaded-prior-to with this index.",
245+
)
246+
elif link.upload_time >= self._uploaded_prior_to:
247+
return (
248+
LinkType.upload_too_late,
249+
f"Upload time {link.upload_time} not "
250+
f"prior to {self._uploaded_prior_to}",
251+
)
252+
235253
# This should be up by the self.ok_binary check, but see issue 2700.
236254
if "source" not in self._formats and ext != WHEEL_EXTENSION:
237255
reason = f"No sources permitted for {self.project_name}"
@@ -798,6 +816,10 @@ def get_install_candidate(
798816
InstallationCandidate and return it. Otherwise, return None.
799817
"""
800818
result, detail = link_evaluator.evaluate_link(link)
819+
if result == LinkType.upload_time_missing:
820+
# Fail immediately if the index doesn't provide upload-time
821+
# when --uploaded-prior-to is specified
822+
raise InstallationError(detail)
801823
if result != LinkType.candidate:
802824
self._log_skipped_link(link, result, detail)
803825
return None

tests/functional/test_uploaded_prior_to.py

Lines changed: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,68 @@
55
import pytest
66

77
from tests.lib import PipTestEnvironment, TestData
8+
from tests.lib.server import (
9+
file_response,
10+
make_mock_server,
11+
package_page,
12+
server_running,
13+
)
814

915

1016
class TestUploadedPriorTo:
11-
"""Test --uploaded-prior-to functionality.
12-
13-
Only effective with indexes that provide upload-time metadata.
14-
"""
17+
"""Test --uploaded-prior-to functionality."""
1518

1619
def test_uploaded_prior_to_invalid_date(
1720
self, script: PipTestEnvironment, data: TestData
1821
) -> None:
19-
"""Test that --uploaded-prior-to fails with invalid date format."""
22+
"""Test that invalid date format is rejected."""
2023
result = script.pip_install_local(
2124
"--uploaded-prior-to=invalid-date", "simple", expect_error=True
2225
)
23-
24-
# Should fail with date parsing error
2526
assert "invalid" in result.stderr.lower() or "error" in result.stderr.lower()
2627

28+
def test_uploaded_prior_to_file_index_no_upload_time(
29+
self, script: PipTestEnvironment, data: TestData
30+
) -> None:
31+
"""Test that file:// indexes are exempt from upload-time filtering."""
32+
result = script.pip(
33+
"install",
34+
"--index-url",
35+
data.index_url("simple"),
36+
"--uploaded-prior-to=3030-01-01T00:00:00",
37+
"simple",
38+
expect_error=False,
39+
)
40+
assert "Successfully installed simple" in result.stdout
41+
42+
def test_uploaded_prior_to_http_index_no_upload_time(
43+
self, script: PipTestEnvironment, data: TestData
44+
) -> None:
45+
"""Test that HTTP index without upload-time causes immediate error."""
46+
server = make_mock_server()
47+
simple_package = data.packages / "simple-1.0.tar.gz"
48+
server.mock.side_effect = [
49+
package_page({"simple-1.0.tar.gz": "/files/simple-1.0.tar.gz"}),
50+
file_response(simple_package),
51+
]
52+
53+
with server_running(server):
54+
result = script.pip(
55+
"install",
56+
"--index-url",
57+
f"http://{server.host}:{server.port}",
58+
"--uploaded-prior-to=3030-01-01T00:00:00",
59+
"simple",
60+
expect_error=True,
61+
)
62+
63+
assert "does not provide upload-time metadata" in result.stderr
64+
assert "--uploaded-prior-to" in result.stderr or "Cannot use" in result.stderr
65+
2766
@pytest.mark.network
2867
def test_uploaded_prior_to_with_real_pypi(self, script: PipTestEnvironment) -> None:
29-
"""Test uploaded-prior-to functionality against real PyPI with upload times."""
30-
# Use a small package with known old versions for testing
31-
# requests 2.0.0 was released in 2013
32-
33-
# Test 1: With an old cutoff date, should find no matching versions
68+
"""Test filtering against real PyPI with upload-time metadata."""
69+
# Test with old cutoff date - should find no matching versions
3470
result = script.pip(
3571
"install",
3672
"--dry-run",
@@ -39,10 +75,9 @@ def test_uploaded_prior_to_with_real_pypi(self, script: PipTestEnvironment) -> N
3975
"requests==2.0.0",
4076
expect_error=True,
4177
)
42-
# Should fail because requests 2.0.0 was uploaded after 2010
43-
assert "No matching distribution found" in result.stderr
78+
assert "Could not find a version that satisfies" in result.stderr
4479

45-
# Test 2: With a date that should find the package
80+
# Test with future cutoff date - should find the package
4681
result = script.pip(
4782
"install",
4883
"--dry-run",
@@ -55,8 +90,7 @@ def test_uploaded_prior_to_with_real_pypi(self, script: PipTestEnvironment) -> N
5590

5691
@pytest.mark.network
5792
def test_uploaded_prior_to_date_formats(self, script: PipTestEnvironment) -> None:
58-
"""Test different date formats work with real PyPI."""
59-
# Test various date formats with a well known small package
93+
"""Test various date format strings are accepted."""
6094
formats = [
6195
"2030-01-01",
6296
"2030-01-01T00:00:00",
@@ -73,5 +107,34 @@ def test_uploaded_prior_to_date_formats(self, script: PipTestEnvironment) -> Non
73107
"requests==2.0.0",
74108
expect_error=False,
75109
)
76-
# All dates should allow the package
77110
assert "Would install requests-2.0.0" in result.stdout
111+
112+
def test_uploaded_prior_to_allows_local_files(
113+
self, script: PipTestEnvironment, data: TestData
114+
) -> None:
115+
"""Test that local file installs bypass upload-time filtering."""
116+
simple_wheel = data.packages / "simplewheel-1.0-py2.py3-none-any.whl"
117+
118+
result = script.pip(
119+
"install",
120+
"--no-index",
121+
"--uploaded-prior-to=2000-01-01T00:00:00",
122+
str(simple_wheel),
123+
expect_error=False,
124+
)
125+
assert "Successfully installed simplewheel-1.0" in result.stdout
126+
127+
def test_uploaded_prior_to_allows_find_links(
128+
self, script: PipTestEnvironment, data: TestData
129+
) -> None:
130+
"""Test that --find-links bypasses upload-time filtering."""
131+
result = script.pip(
132+
"install",
133+
"--no-index",
134+
"--find-links",
135+
data.find_links,
136+
"--uploaded-prior-to=2000-01-01T00:00:00",
137+
"simple==1.0",
138+
expect_error=False,
139+
)
140+
assert "Successfully installed simple-1.0" in result.stdout

tests/unit/test_index.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -440,13 +440,26 @@ def test_evaluate_link_uploaded_prior_to(
440440
assert actual == expected_result
441441

442442
def test_evaluate_link_no_upload_time(self) -> None:
443-
"""Test that links with no upload time are not filtered."""
443+
"""Test that links with no upload time cause an error when filter is set."""
444444
uploaded_prior_to = datetime.datetime(
445445
2023, 6, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
446446
)
447447
evaluator = self.make_test_link_evaluator(uploaded_prior_to)
448448

449-
# Link with no upload_time should not be filtered
449+
# Link with no upload_time should be rejected when uploaded_prior_to is set
450+
link = Link("https://example.com/myproject-1.0.tar.gz")
451+
actual = evaluator.evaluate_link(link)
452+
453+
# Should be rejected because index doesn't provide upload-time
454+
assert actual[0] == LinkType.upload_time_missing
455+
assert "Index does not provide upload-time metadata" in actual[1]
456+
457+
def test_evaluate_link_no_upload_time_no_filter(self) -> None:
458+
"""Test that links with no upload time are accepted when no filter is set."""
459+
# No uploaded_prior_to filter set
460+
evaluator = self.make_test_link_evaluator(uploaded_prior_to=None)
461+
462+
# Link with no upload_time should be accepted when no filter is set
450463
link = Link("https://example.com/myproject-1.0.tar.gz")
451464
actual = evaluator.evaluate_link(link)
452465

0 commit comments

Comments
 (0)