diff --git a/src/pip/_internal/exceptions.py b/src/pip/_internal/exceptions.py index 97b9612a187..b947da61d20 100644 --- a/src/pip/_internal/exceptions.py +++ b/src/pip/_internal/exceptions.py @@ -656,3 +656,37 @@ def __str__(self) -> str: assert self.error is not None message_part = f".\n{self.error}\n" return f"Configuration file {self.reason}{message_part}" + + +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_incomplete: bool, resume_attempts: int + ) -> None: + if resume_incomplete: + message = ( + "Download failed after {} attempts because not enough bytes are" + " received. The incomplete file has been cleaned up." + ).format(resume_attempts) + hint = "Use --incomplete-download-retries to configure resume retry limit." + else: + message = ( + "Download failed because not enough bytes are received." + " The incomplete file has been cleaned up." + ) + hint = ( + "Use --incomplete-downloads=resume to make pip retry failed download." + ) + + super().__init__( + message=message, + context="File: {}\n" + "Resume failed download: {}\n" + "Resume retry limit: {}".format(link, resume_incomplete, resume_attempts), + hint_stmt=hint, + note_stmt="This is an issue with network connectivity, not pip.", + ) diff --git a/src/pip/_internal/network/download.py b/src/pip/_internal/network/download.py index a974bd97d35..e017bc9e7ff 100644 --- a/src/pip/_internal/network/download.py +++ b/src/pip/_internal/network/download.py @@ -10,7 +10,7 @@ from pip._vendor.requests.models import CONTENT_CHUNK_SIZE, Response from pip._internal.cli.progress_bars import get_download_progress_renderer -from pip._internal.exceptions import NetworkConnectionError +from pip._internal.exceptions import IncompleteDownloadError, NetworkConnectionError from pip._internal.models.index import PyPI from pip._internal.models.link import Link from pip._internal.network.cache import is_from_cache @@ -228,21 +228,10 @@ def __call__(self, link: Link, location: str) -> Tuple[str, str]: content_file.write(chunk) if total_length is not None and bytes_received < total_length: - if self._resume_incomplete: - logger.critical( - "Failed to download %s after %d resumption attempts.", - link, - self._resume_attempts, - ) - else: - logger.critical( - "Failed to download %s." - " Set --incomplete-downloads=resume to automatically" - "resume incomplete download.", - link, - ) os.remove(filepath) - raise RuntimeError("Incomplete download") + raise IncompleteDownloadError( + str(link), self._resume_incomplete, self._resume_attempts + ) content_type = resp.headers.get("Content-Type", "") return filepath, content_type diff --git a/tests/unit/test_network_download.py b/tests/unit/test_network_download.py index 12845c277cd..1d7f6405f36 100644 --- a/tests/unit/test_network_download.py +++ b/tests/unit/test_network_download.py @@ -6,6 +6,7 @@ import pytest +from pip._internal.exceptions import IncompleteDownloadError from pip._internal.models.link import Link from pip._internal.network.download import ( Downloader, @@ -349,7 +350,7 @@ def test_downloader( if expected_bytes is None: remove = MagicMock(return_value=None) with patch("os.remove", remove): - with pytest.raises(RuntimeError): + with pytest.raises(IncompleteDownloadError): downloader(link, str(tmpdir)) # Make sure the incomplete file is removed remove.assert_called_once()