Skip to content

Commit 9c34c00

Browse files
committed
Add unit tests for new connection errors
Also refactor two testing utilities: - I moved the rendered() helper which converts a rich renderable (or str equivalent) into pure text from test_exceptions.py to the test library. I also enabled soft wrap to make it a bit nicer for testing single sentences renderables (although it may make sense to make it configurable later on). - I extracted the "instant close" HTTP server into its own fixture so it could be easily reused for a connection error unit test as well.
1 parent fb2c51a commit 9c34c00

File tree

3 files changed

+119
-21
lines changed

3 files changed

+119
-21
lines changed

tests/lib/output.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from io import StringIO
2+
3+
from pip._vendor.rich.console import Console, RenderableType
4+
5+
6+
def render_to_text(
7+
renderable: RenderableType,
8+
*,
9+
color: bool = False,
10+
) -> str:
11+
with StringIO() as stream:
12+
console = Console(
13+
force_terminal=False,
14+
file=stream,
15+
color_system="truecolor" if color else None,
16+
soft_wrap=True,
17+
)
18+
console.print(renderable)
19+
return stream.getvalue()

tests/unit/test_exceptions.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from pip._vendor import rich
1313

1414
from pip._internal.exceptions import DiagnosticPipError, ExternallyManagedEnvironment
15+
from tests.lib.output import render_to_text as rendered
1516

1617

1718
class TestDiagnosticPipErrorCreation:
@@ -274,17 +275,6 @@ def test_no_hint_no_note_no_context(self) -> None:
274275
)
275276

276277

277-
def rendered(error: DiagnosticPipError, *, color: bool = False) -> str:
278-
with io.StringIO() as stream:
279-
console = rich.console.Console(
280-
force_terminal=False,
281-
file=stream,
282-
color_system="truecolor" if color else None,
283-
)
284-
console.print(error)
285-
return stream.getvalue()
286-
287-
288278
class TestDiagnosticPipErrorPresentation_Unicode:
289279
def test_complete(self) -> None:
290280
err = DiagnosticPipError(

tests/unit/test_network_session.py

Lines changed: 99 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,40 @@
22
import os
33
from http.server import HTTPServer
44
from pathlib import Path
5-
from typing import Any, Iterator, List, Optional
5+
from typing import Any, Iterator, List, Optional, Tuple
6+
from unittest.mock import patch
67
from urllib.parse import urlparse
78
from urllib.request import getproxies
89

910
import pytest
1011
from pip._vendor import requests
1112

1213
from pip import __version__
13-
from pip._internal.exceptions import DiagnosticPipError
14+
from pip._internal.exceptions import (
15+
ConnectionFailedError,
16+
ConnectionTimeoutError,
17+
DiagnosticPipError,
18+
ProxyConnectionError,
19+
SSLVerificationError,
20+
)
1421
from pip._internal.models.link import Link
1522
from pip._internal.network.session import (
1623
CI_ENVIRONMENT_VARIABLES,
1724
PipSession,
1825
user_agent,
1926
)
2027
from pip._internal.utils.logging import VERBOSE
28+
from tests.lib.output import render_to_text
2129
from tests.lib.server import InstantCloseHTTPHandler, server_running
2230

2331

32+
def render_diagnostic_error(error: DiagnosticPipError) -> Tuple[str, Optional[str]]:
33+
message = render_to_text(error.message).rstrip()
34+
if error.context is None:
35+
return (message, None)
36+
return (message, render_to_text(error.context).rstrip())
37+
38+
2439
def get_user_agent() -> str:
2540
# These tests are testing the computation of the user agent, so we want to
2641
# avoid reusing cached values.
@@ -287,6 +302,13 @@ def test_proxy(self, proxy: Optional[str]) -> None:
287302
)
288303

289304

305+
@pytest.fixture(scope="module")
306+
def instant_close_http_server() -> Iterator[str]:
307+
with HTTPServer(("localhost", 0), InstantCloseHTTPHandler) as server:
308+
with server_running(server):
309+
yield f"http://{server.server_name}:{server.server_port}/"
310+
311+
290312
@pytest.mark.network
291313
class TestRetryWarningRewriting:
292314
@pytest.fixture(autouse=True)
@@ -321,14 +343,15 @@ def test_timeout(self, caplog: pytest.LogCaptureFixture) -> None:
321343
"server didn't respond within 0.2 seconds, retrying 1 last time"
322344
]
323345

324-
def test_connection_aborted(self, caplog: pytest.LogCaptureFixture) -> None:
325-
with HTTPServer(("localhost", 0), InstantCloseHTTPHandler) as server:
326-
with server_running(server), PipSession(retries=1) as session:
327-
with pytest.raises(DiagnosticPipError):
328-
session.get(f"http://{server.server_name}:{server.server_port}/")
329-
assert caplog.messages == [
330-
"the connection was closed unexpectedly, retrying 1 last time"
331-
]
346+
def test_connection_closed_by_peer(
347+
self, caplog: pytest.LogCaptureFixture, instant_close_http_server: str
348+
) -> None:
349+
with PipSession(retries=1) as session:
350+
with pytest.raises(DiagnosticPipError):
351+
session.get(instant_close_http_server)
352+
assert caplog.messages == [
353+
"the connection was closed unexpectedly, retrying 1 last time"
354+
]
332355

333356
def test_proxy(self, caplog: pytest.LogCaptureFixture) -> None:
334357
with PipSession(retries=1) as session:
@@ -345,3 +368,69 @@ def test_verbose(self, caplog: pytest.LogCaptureFixture) -> None:
345368
warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING]
346369
assert len(warnings) == 1
347370
assert not warnings[0].endswith("retrying 1 last time")
371+
372+
373+
@pytest.mark.network
374+
class TestConnectionErrors:
375+
@pytest.fixture
376+
def session(self) -> Iterator[PipSession]:
377+
with PipSession() as session:
378+
yield session
379+
380+
def test_non_existent_domain(self, session: PipSession) -> None:
381+
url = "https://404.example.com/"
382+
with pytest.raises(ConnectionFailedError) as e:
383+
session.get(url)
384+
message, _ = render_diagnostic_error(e.value)
385+
assert message == f"Failed to connect to 404.example.com while fetching {url}"
386+
387+
def test_connection_closed_by_peer(
388+
self, session: PipSession, instant_close_http_server: str
389+
) -> None:
390+
with pytest.raises(ConnectionFailedError) as e:
391+
session.get(instant_close_http_server)
392+
message, context = render_diagnostic_error(e.value)
393+
assert message == (
394+
f"Failed to connect to localhost while fetching {instant_close_http_server}"
395+
)
396+
assert context == (
397+
"Details: the connection was closed without a reply from the server."
398+
)
399+
400+
def test_timeout(self, session: PipSession) -> None:
401+
url = "https://httpstat.us/200?sleep=400"
402+
with pytest.raises(ConnectionTimeoutError) as e:
403+
session.get(url, timeout=0.2)
404+
message, context = render_diagnostic_error(e.value)
405+
assert message == f"Unable to fetch {url}"
406+
assert context is not None
407+
assert context.startswith("httpstat.us didn't respond within 0.2 seconds")
408+
409+
def test_expired_ssl(self, session: PipSession) -> None:
410+
url = "https://expired.badssl.com/"
411+
with pytest.raises(SSLVerificationError) as e:
412+
session.get(url)
413+
message, _ = render_diagnostic_error(e.value)
414+
assert message == (
415+
"Failed to establish a secure connection to expired.badssl.com "
416+
f"while fetching {url}"
417+
)
418+
419+
@patch("pip._internal.network.utils.has_tls", lambda: False)
420+
def test_missing_python_ssl_support(self, session: PipSession) -> None:
421+
# So, this demonstrates a potentially invalid assumption: a SSL error
422+
# when SSL support is missing is assumed to be caused by that. Not ideal
423+
# but unlikely to cause issues in practice.
424+
with pytest.raises(SSLVerificationError) as e:
425+
session.get("https://expired.badssl.com/")
426+
_, context = render_diagnostic_error(e.value)
427+
assert context == "The built-in ssl module is not available."
428+
429+
def test_broken_proxy(self, session: PipSession) -> None:
430+
url = "https://pypi.org/"
431+
proxy = "https://404.example.com"
432+
session.proxies = {"https": proxy}
433+
with pytest.raises(ProxyConnectionError) as e:
434+
session.get(url)
435+
message, _ = render_diagnostic_error(e.value)
436+
assert message == f"Failed to connect to proxy {proxy}:443 while fetching {url}"

0 commit comments

Comments
 (0)