Skip to content

Commit e1e72e7

Browse files
uranusjrnotatallshaw
authored andcommitted
Implement --upload-before
1 parent bef3535 commit e1e72e7

File tree

10 files changed

+89
-23
lines changed

10 files changed

+89
-23
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ max-complexity = 33 # default is 10
259259
[tool.ruff.lint.pylint]
260260
max-args = 15 # default is 5
261261
max-branches = 28 # default is 12
262-
max-returns = 13 # default is 6
262+
max-returns = 14 # default is 6
263263
max-statements = 134 # default is 50
264264

265265
######################################################################################

src/pip/_internal/cli/cmdoptions.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
# mypy: strict-optional=False
1212
from __future__ import annotations
1313

14+
import datetime
1415
import importlib.util
1516
import logging
1617
import os
@@ -796,6 +797,34 @@ def _handle_dependency_group(
796797
help="Ignore the Requires-Python information.",
797798
)
798799

800+
801+
def _handle_upload_before(
802+
option: Option, opt: str, value: str, parser: OptionParser
803+
) -> None:
804+
"""
805+
Process a value provided for the --upload-before option.
806+
807+
This is an optparse.Option callback for the --upload-before option.
808+
"""
809+
if value is None:
810+
return None
811+
upload_before = datetime.datetime.fromisoformat(value)
812+
# Assume local timezone if no offset is given in the ISO string.
813+
if upload_before.tzinfo is None:
814+
upload_before = upload_before.astimezone()
815+
parser.values.upload_before = upload_before
816+
817+
818+
upload_before: Callable[..., Option] = partial(
819+
Option,
820+
"--upload-before",
821+
dest="upload_before",
822+
metavar="datetime",
823+
action="callback",
824+
callback=_handle_upload_before,
825+
help="Skip uploads after given time. This should be an ISO 8601 string.",
826+
)
827+
799828
no_build_isolation: Callable[..., Option] = partial(
800829
Option,
801830
"--no-build-isolation",

src/pip/_internal/cli/req_command.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
PackageFinder machinery and all its vendored dependencies, etc.
66
"""
77

8-
from __future__ import annotations
9-
108
import logging
119
from functools import partial
1210
from optparse import Values
@@ -328,6 +326,7 @@ def _build_package_finder(
328326
session: PipSession,
329327
target_python: TargetPython | None = None,
330328
ignore_requires_python: bool | None = None,
329+
upload_before: datetime.datetime | None = None,
331330
) -> PackageFinder:
332331
"""
333332
Create a package finder appropriate to this requirement command.
@@ -348,4 +347,5 @@ def _build_package_finder(
348347
link_collector=link_collector,
349348
selection_prefs=selection_prefs,
350349
target_python=target_python,
350+
upload_before=upload_before,
351351
)

src/pip/_internal/commands/download.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def add_options(self) -> None:
5151
self.cmd_opts.add_option(cmdoptions.no_use_pep517())
5252
self.cmd_opts.add_option(cmdoptions.check_build_deps())
5353
self.cmd_opts.add_option(cmdoptions.ignore_requires_python())
54+
self.cmd_opts.add_option(cmdoptions.upload_before())
5455

5556
self.cmd_opts.add_option(
5657
"-d",
@@ -93,6 +94,7 @@ def run(self, options: Values, args: list[str]) -> int:
9394
session=session,
9495
target_python=target_python,
9596
ignore_requires_python=options.ignore_requires_python,
97+
upload_before=options.upload_before,
9698
)
9799

98100
build_tracker = self.enter_context(get_build_tracker())

src/pip/_internal/commands/index.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
from __future__ import annotations
2-
3-
import json
1+
import datetime
42
import logging
53
from collections.abc import Iterable
64
from optparse import Values
@@ -40,6 +38,7 @@ def add_options(self) -> None:
4038
cmdoptions.add_target_python_options(self.cmd_opts)
4139

4240
self.cmd_opts.add_option(cmdoptions.ignore_requires_python())
41+
self.cmd_opts.add_option(cmdoptions.upload_before())
4342
self.cmd_opts.add_option(cmdoptions.pre())
4443
self.cmd_opts.add_option(cmdoptions.json())
4544
self.cmd_opts.add_option(cmdoptions.no_binary())
@@ -86,6 +85,7 @@ def _build_package_finder(
8685
session: PipSession,
8786
target_python: TargetPython | None = None,
8887
ignore_requires_python: bool | None = None,
88+
upload_before: datetime.datetime | None = None,
8989
) -> PackageFinder:
9090
"""
9191
Create a package finder appropriate to the index command.
@@ -103,6 +103,7 @@ def _build_package_finder(
103103
link_collector=link_collector,
104104
selection_prefs=selection_prefs,
105105
target_python=target_python,
106+
upload_before=upload_before,
106107
)
107108

108109
def get_available_package_versions(self, options: Values, args: list[Any]) -> None:
@@ -118,6 +119,7 @@ def get_available_package_versions(self, options: Values, args: list[Any]) -> No
118119
session=session,
119120
target_python=target_python,
120121
ignore_requires_python=options.ignore_requires_python,
122+
upload_before=options.upload_before,
121123
)
122124

123125
versions: Iterable[Version] = (

src/pip/_internal/commands/install.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ def add_options(self) -> None:
207207
),
208208
)
209209

210+
self.cmd_opts.add_option(cmdoptions.upload_before())
210211
self.cmd_opts.add_option(cmdoptions.ignore_requires_python())
211212
self.cmd_opts.add_option(cmdoptions.no_build_isolation())
212213
self.cmd_opts.add_option(cmdoptions.use_pep517())
@@ -344,6 +345,7 @@ def run(self, options: Values, args: list[str]) -> int:
344345
session=session,
345346
target_python=target_python,
346347
ignore_requires_python=options.ignore_requires_python,
348+
upload_before=options.upload_before,
347349
)
348350
build_tracker = self.enter_context(get_build_tracker())
349351

src/pip/_internal/commands/list.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,10 @@ def handle_pip_version_check(self, options: Values) -> None:
143143
super().handle_pip_version_check(options)
144144

145145
def _build_package_finder(
146-
self, options: Values, session: PipSession
147-
) -> PackageFinder:
146+
self,
147+
options: Values,
148+
session: "PipSession",
149+
) -> "PackageFinder":
148150
"""
149151
Create a package finder appropriate to this list command.
150152
"""

src/pip/_internal/commands/wheel.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def add_options(self) -> None:
6464
self.cmd_opts.add_option(cmdoptions.requirements())
6565
self.cmd_opts.add_option(cmdoptions.src())
6666
self.cmd_opts.add_option(cmdoptions.ignore_requires_python())
67+
self.cmd_opts.add_option(cmdoptions.upload_before())
6768
self.cmd_opts.add_option(cmdoptions.no_deps())
6869
self.cmd_opts.add_option(cmdoptions.progress_bar())
6970

@@ -103,7 +104,11 @@ def add_options(self) -> None:
103104
def run(self, options: Values, args: list[str]) -> int:
104105
session = self.get_default_session(options)
105106

106-
finder = self._build_package_finder(options, session)
107+
finder = self._build_package_finder(
108+
options=options,
109+
session=session,
110+
upload_before=options.upload_before,
111+
)
107112

108113
options.wheel_dir = normalize_path(options.wheel_dir)
109114
ensure_dir(options.wheel_dir)

src/pip/_internal/index/package_finder.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
"""Routines related to PyPI, indexes"""
22

3-
from __future__ import annotations
4-
53
import enum
64
import functools
75
import itertools
@@ -111,6 +109,7 @@ class LinkType(enum.Enum):
111109
format_invalid = enum.auto()
112110
platform_mismatch = enum.auto()
113111
requires_python_mismatch = enum.auto()
112+
upload_too_late = enum.auto()
114113

115114

116115
class LinkEvaluator:
@@ -131,7 +130,8 @@ def __init__(
131130
formats: frozenset[str],
132131
target_python: TargetPython,
133132
allow_yanked: bool,
134-
ignore_requires_python: bool | None = None,
133+
ignore_requires_python: Optional[bool] = None,
134+
upload_before: Optional[datetime.datetime] = None,
135135
) -> None:
136136
"""
137137
:param project_name: The user supplied package name.
@@ -149,6 +149,7 @@ def __init__(
149149
:param ignore_requires_python: Whether to ignore incompatible
150150
PEP 503 "data-requires-python" values in HTML links. Defaults
151151
to False.
152+
:param upload_before: If set, only allow links prior to the given date.
152153
"""
153154
if ignore_requires_python is None:
154155
ignore_requires_python = False
@@ -158,6 +159,7 @@ def __init__(
158159
self._ignore_requires_python = ignore_requires_python
159160
self._formats = formats
160161
self._target_python = target_python
162+
self._upload_before = upload_before
161163

162164
self.project_name = project_name
163165

@@ -176,6 +178,11 @@ def evaluate_link(self, link: Link) -> tuple[LinkType, str]:
176178
reason = link.yanked_reason or "<none given>"
177179
return (LinkType.yanked, f"yanked for reason: {reason}")
178180

181+
if link.upload_time is not None and self._upload_before is not None:
182+
if link.upload_time > self._upload_before:
183+
reason = f"Upload time {link.upload_time} after {self._upload_before}"
184+
return (LinkType.upload_too_late, reason)
185+
179186
if link.egg_fragment:
180187
egg_info = link.egg_fragment
181188
ext = link.ext
@@ -590,9 +597,10 @@ def __init__(
590597
link_collector: LinkCollector,
591598
target_python: TargetPython,
592599
allow_yanked: bool,
593-
format_control: FormatControl | None = None,
594-
candidate_prefs: CandidatePreferences | None = None,
595-
ignore_requires_python: bool | None = None,
600+
format_control: Optional[FormatControl] = None,
601+
candidate_prefs: Optional[CandidatePreferences] = None,
602+
ignore_requires_python: Optional[bool] = None,
603+
upload_before: Optional[datetime.datetime] = None,
596604
) -> None:
597605
"""
598606
This constructor is primarily meant to be used by the create() class
@@ -614,6 +622,7 @@ def __init__(
614622
self._ignore_requires_python = ignore_requires_python
615623
self._link_collector = link_collector
616624
self._target_python = target_python
625+
self._upload_before = upload_before
617626

618627
self.format_control = format_control
619628

@@ -636,15 +645,17 @@ def create(
636645
cls,
637646
link_collector: LinkCollector,
638647
selection_prefs: SelectionPreferences,
639-
target_python: TargetPython | None = None,
640-
) -> PackageFinder:
648+
target_python: Optional[TargetPython] = None,
649+
upload_before: Optional[datetime.datetime] = None,
650+
) -> "PackageFinder":
641651
"""Create a PackageFinder.
642652
643653
:param selection_prefs: The candidate selection preferences, as a
644654
SelectionPreferences object.
645655
:param target_python: The target Python interpreter to use when
646656
checking compatibility. If None (the default), a TargetPython
647657
object will be constructed from the running Python.
658+
:param upload_before: If set, only find links prior to the given date.
648659
"""
649660
if target_python is None:
650661
target_python = TargetPython()
@@ -661,6 +672,7 @@ def create(
661672
allow_yanked=selection_prefs.allow_yanked,
662673
format_control=selection_prefs.format_control,
663674
ignore_requires_python=selection_prefs.ignore_requires_python,
675+
upload_before=upload_before,
664676
)
665677

666678
@property
@@ -739,6 +751,7 @@ def make_link_evaluator(self, project_name: str) -> LinkEvaluator:
739751
target_python=self._target_python,
740752
allow_yanked=self._allow_yanked,
741753
ignore_requires_python=self._ignore_requires_python,
754+
upload_before=self._upload_before,
742755
)
743756

744757
def _sort_links(self, links: Iterable[Link]) -> list[Link]:

src/pip/_internal/models/link.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
from __future__ import annotations
2-
1+
import datetime
32
import functools
43
import itertools
54
import logging
@@ -207,17 +206,19 @@ class Link:
207206
"requires_python",
208207
"yanked_reason",
209208
"metadata_file_data",
209+
"upload_time",
210210
"cache_link_parsing",
211211
"egg_fragment",
212212
]
213213

214214
def __init__(
215215
self,
216216
url: str,
217-
comes_from: str | IndexContent | None = None,
218-
requires_python: str | None = None,
219-
yanked_reason: str | None = None,
220-
metadata_file_data: MetadataFile | None = None,
217+
comes_from: Optional[Union[str, "IndexContent"]] = None,
218+
requires_python: Optional[str] = None,
219+
yanked_reason: Optional[str] = None,
220+
metadata_file_data: Optional[MetadataFile] = None,
221+
upload_time: Optional[datetime.datetime] = None,
221222
cache_link_parsing: bool = True,
222223
hashes: Mapping[str, str] | None = None,
223224
) -> None:
@@ -239,6 +240,8 @@ def __init__(
239240
no such metadata is provided. This argument, if not None, indicates
240241
that a separate metadata file exists, and also optionally supplies
241242
hashes for that file.
243+
:param upload_time: upload time of the file, or None if the information
244+
is not available from the server.
242245
:param cache_link_parsing: A flag that is used elsewhere to determine
243246
whether resources retrieved from this link should be cached. PyPI
244247
URLs should generally have this set to False, for example.
@@ -272,6 +275,7 @@ def __init__(
272275
self.requires_python = requires_python if requires_python else None
273276
self.yanked_reason = yanked_reason
274277
self.metadata_file_data = metadata_file_data
278+
self.upload_time = upload_time
275279

276280
self.cache_link_parsing = cache_link_parsing
277281
self.egg_fragment = self._egg_fragment()
@@ -300,6 +304,12 @@ def from_json(
300304
if metadata_info is None:
301305
metadata_info = file_data.get("dist-info-metadata")
302306

307+
upload_time: Optional[datetime.datetime]
308+
if upload_time_data := file_data.get("upload-time"):
309+
upload_time = datetime.datetime.fromisoformat(upload_time_data)
310+
else:
311+
upload_time = None
312+
303313
# The metadata info value may be a boolean, or a dict of hashes.
304314
if isinstance(metadata_info, dict):
305315
# The file exists, and hashes have been supplied
@@ -325,6 +335,7 @@ def from_json(
325335
yanked_reason=yanked_reason,
326336
hashes=hashes,
327337
metadata_file_data=metadata_file_data,
338+
upload_time=upload_time,
328339
)
329340

330341
@classmethod

0 commit comments

Comments
 (0)