Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce resumable downloads with --resume-retries #12991

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions news/12991.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Add support to enable resuming incomplete downloads.

Control the number of retry attempts using the ``--resume-retries`` flag.
10 changes: 10 additions & 0 deletions src/pip/_internal/cli/cmdoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,15 @@ def check_list_path_option(options: Values) -> None:
help=("Enable deprecated functionality, that will be removed in the future."),
)

resume_retries: Callable[..., Option] = partial(
Option,
"--resume-retries",
dest="resume_retries",
type="int",
default=0,
help="Maximum number of resumption retries for incomplete downloads"
"(default %default times).",
)

##########
# groups #
Expand Down Expand Up @@ -1061,6 +1070,7 @@ def check_list_path_option(options: Values) -> None:
no_python_version_warning,
use_new_feature,
use_deprecated_feature,
resume_retries,
],
}

Expand Down
21 changes: 17 additions & 4 deletions src/pip/_internal/cli/progress_bars.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def _rich_progress_bar(
*,
bar_type: str,
size: Optional[int],
initial_progress: Optional[int] = None,
) -> Generator[bytes, None, None]:
assert bar_type == "on", "This should only be used in the default mode."

Expand All @@ -51,6 +52,8 @@ def _rich_progress_bar(

progress = Progress(*columns, refresh_per_second=5)
task_id = progress.add_task(" " * (get_indentation() + 2), total=total)
if initial_progress is not None:
progress.update(task_id, advance=initial_progress)
with progress:
for chunk in iterable:
yield chunk
Expand All @@ -61,12 +64,13 @@ def _raw_progress_bar(
iterable: Iterable[bytes],
*,
size: Optional[int],
initial_progress: Optional[int] = None,
) -> Generator[bytes, None, None]:
def write_progress(current: int, total: int) -> None:
sys.stdout.write("Progress %d of %d\n" % (current, total))
sys.stdout.flush()

current = 0
current = initial_progress or 0
total = size or 0
rate_limiter = RateLimiter(0.25)

Expand All @@ -80,15 +84,24 @@ def write_progress(current: int, total: int) -> None:


def get_download_progress_renderer(
*, bar_type: str, size: Optional[int] = None
*, bar_type: str, size: Optional[int] = None, initial_progress: Optional[int] = None
) -> DownloadProgressRenderer:
"""Get an object that can be used to render the download progress.

Returns a callable, that takes an iterable to "wrap".
"""
if bar_type == "on":
return functools.partial(_rich_progress_bar, bar_type=bar_type, size=size)
return functools.partial(
_rich_progress_bar,
bar_type=bar_type,
size=size,
initial_progress=initial_progress,
)
elif bar_type == "raw":
return functools.partial(_raw_progress_bar, size=size)
return functools.partial(
_raw_progress_bar,
size=size,
initial_progress=initial_progress,
)
else:
return iter # no-op, when passed an iterator
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 @@ -142,6 +142,7 @@ def make_requirement_preparer(
lazy_wheel=lazy_wheel,
verbosity=verbosity,
legacy_resolver=legacy_resolver,
resume_retries=options.resume_retries,
)

@classmethod
Expand Down
21 changes: 21 additions & 0 deletions src/pip/_internal/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,3 +807,24 @@ def __init__(
),
hint_stmt="To proceed this package must be uninstalled.",
)


class IncompleteDownloadError(DiagnosticPipError):
"""Raised when the downloader receives fewer bytes than advertised
in the Content-Length header."""

reference = "incomplete-download-error"

def __init__(self, link: str, resume_retries: int) -> None:
message = (
f"Download failed after {resume_retries} attempts because not enough"
" bytes were received. The incomplete file has been cleaned up."
)
hint = "Use --resume-retries to configure resume retry limit."

super().__init__(
message=message,
context=f"File: {link}\nResume retry limit: {resume_retries}",
hint_stmt=hint,
note_stmt="This is an issue with network connectivity, not pip.",
)
Loading
Loading