Skip to content

Commit 4c2e8ea

Browse files
gmargaritisyichi-yangichard26
authored
Introduce resumable downloads with --resume-retries (#12991)
Add the option --resume-retries to allow pip to retry a download X number of times. When a download times out or fails midway through and the download size is known, a HTTP range request will be issued to resume the download. If the server supports a partial download (i.e. returns 206 Partial Content), the download will be resumed. If the server responds with 200 (i.e., the server does NOT support range requests or the file has changed since the last request), we fall back to restarting the download. This repeats as necessary until the download is complete or no more retries remain. The number of resumes currently defaults to zero, but it is expected to be increased sometime after the 25.1 release once we've gotten feedback. Signed-off-by: gmargaritis <[email protected]> Co-authored-by: Yichi Yang <[email protected]> Co-authored-by: Richard Si <[email protected]>
1 parent a0dead9 commit 4c2e8ea

File tree

11 files changed

+483
-58
lines changed

11 files changed

+483
-58
lines changed

news/12991.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add support to enable resuming incomplete downloads.
2+
3+
Control the number of retry attempts using the ``--resume-retries`` flag.

src/pip/_internal/cli/cmdoptions.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,8 +281,17 @@ class PipOption(Option):
281281
dest="retries",
282282
type="int",
283283
default=5,
284-
help="Maximum number of retries each connection should attempt "
285-
"(default %default times).",
284+
help="Maximum attempts to establish a new HTTP connection. (default: %default)",
285+
)
286+
287+
resume_retries: Callable[..., Option] = partial(
288+
Option,
289+
"--resume-retries",
290+
dest="resume_retries",
291+
type="int",
292+
default=0,
293+
help="Maximum attempts to resume or restart an incomplete download. "
294+
"(default: %default)",
286295
)
287296

288297
timeout: Callable[..., Option] = partial(
@@ -1077,7 +1086,6 @@ def check_list_path_option(options: Values) -> None:
10771086
help=("Enable deprecated functionality, that will be removed in the future."),
10781087
)
10791088

1080-
10811089
##########
10821090
# groups #
10831091
##########
@@ -1110,6 +1118,7 @@ def check_list_path_option(options: Values) -> None:
11101118
no_python_version_warning,
11111119
use_new_feature,
11121120
use_deprecated_feature,
1121+
resume_retries,
11131122
],
11141123
}
11151124

src/pip/_internal/cli/progress_bars.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def _rich_download_progress_bar(
2929
*,
3030
bar_type: str,
3131
size: Optional[int],
32+
initial_progress: Optional[int] = None,
3233
) -> Generator[bytes, None, None]:
3334
assert bar_type == "on", "This should only be used in the default mode."
3435

@@ -54,6 +55,8 @@ def _rich_download_progress_bar(
5455

5556
progress = Progress(*columns, refresh_per_second=5)
5657
task_id = progress.add_task(" " * (get_indentation() + 2), total=total)
58+
if initial_progress is not None:
59+
progress.update(task_id, advance=initial_progress)
5760
with progress:
5861
for chunk in iterable:
5962
yield chunk
@@ -86,12 +89,13 @@ def _raw_progress_bar(
8689
iterable: Iterable[bytes],
8790
*,
8891
size: Optional[int],
92+
initial_progress: Optional[int] = None,
8993
) -> Generator[bytes, None, None]:
9094
def write_progress(current: int, total: int) -> None:
9195
sys.stdout.write(f"Progress {current} of {total}\n")
9296
sys.stdout.flush()
9397

94-
current = 0
98+
current = initial_progress or 0
9599
total = size or 0
96100
rate_limiter = RateLimiter(0.25)
97101

@@ -105,18 +109,25 @@ def write_progress(current: int, total: int) -> None:
105109

106110

107111
def get_download_progress_renderer(
108-
*, bar_type: str, size: Optional[int] = None
112+
*, bar_type: str, size: Optional[int] = None, initial_progress: Optional[int] = None
109113
) -> ProgressRenderer[bytes]:
110114
"""Get an object that can be used to render the download progress.
111115
112116
Returns a callable, that takes an iterable to "wrap".
113117
"""
114118
if bar_type == "on":
115119
return functools.partial(
116-
_rich_download_progress_bar, bar_type=bar_type, size=size
120+
_rich_download_progress_bar,
121+
bar_type=bar_type,
122+
size=size,
123+
initial_progress=initial_progress,
117124
)
118125
elif bar_type == "raw":
119-
return functools.partial(_raw_progress_bar, size=size)
126+
return functools.partial(
127+
_raw_progress_bar,
128+
size=size,
129+
initial_progress=initial_progress,
130+
)
120131
else:
121132
return iter # no-op, when passed an iterator
122133

src/pip/_internal/cli/req_command.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ def make_requirement_preparer(
144144
lazy_wheel=lazy_wheel,
145145
verbosity=verbosity,
146146
legacy_resolver=legacy_resolver,
147+
resume_retries=options.resume_retries,
147148
)
148149

149150
@classmethod

src/pip/_internal/exceptions.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from pip._vendor.requests.models import Request, Response
2828

2929
from pip._internal.metadata import BaseDistribution
30+
from pip._internal.models.link import Link
3031
from pip._internal.req.req_install import InstallRequirement
3132

3233
logger = logging.getLogger(__name__)
@@ -809,6 +810,38 @@ def __init__(
809810
)
810811

811812

813+
class IncompleteDownloadError(DiagnosticPipError):
814+
"""Raised when the downloader receives fewer bytes than advertised
815+
in the Content-Length header."""
816+
817+
reference = "incomplete-download"
818+
819+
def __init__(
820+
self, link: "Link", received: int, expected: int, *, retries: int
821+
) -> None:
822+
# Dodge circular import.
823+
from pip._internal.utils.misc import format_size
824+
825+
download_status = f"{format_size(received)}/{format_size(expected)}"
826+
if retries:
827+
retry_status = f"after {retries} attempts "
828+
hint = "Use --resume-retries to configure resume attempt limit."
829+
else:
830+
retry_status = ""
831+
hint = "Consider using --resume-retries to enable download resumption."
832+
message = Text(
833+
f"Download failed {retry_status}because not enough bytes "
834+
f"were received ({download_status})"
835+
)
836+
837+
super().__init__(
838+
message=message,
839+
context=f"URL: {link.redacted_url}",
840+
hint_stmt=hint,
841+
note_stmt="This is an issue with network connectivity, not pip.",
842+
)
843+
844+
812845
class ResolutionTooDeepError(DiagnosticPipError):
813846
"""Raised when the dependency resolver exceeds the maximum recursion depth."""
814847

src/pip/_internal/models/link.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,9 +380,9 @@ def __str__(self) -> str:
380380
else:
381381
rp = ""
382382
if self.comes_from:
383-
return f"{redact_auth_from_url(self._url)} (from {self.comes_from}){rp}"
383+
return f"{self.redacted_url} (from {self.comes_from}){rp}"
384384
else:
385-
return redact_auth_from_url(str(self._url))
385+
return self.redacted_url
386386

387387
def __repr__(self) -> str:
388388
return f"<Link {self}>"
@@ -404,6 +404,10 @@ def __lt__(self, other: Any) -> bool:
404404
def url(self) -> str:
405405
return self._url
406406

407+
@property
408+
def redacted_url(self) -> str:
409+
return redact_auth_from_url(self.url)
410+
407411
@property
408412
def filename(self) -> str:
409413
path = self.path.rstrip("/")

0 commit comments

Comments
 (0)