Skip to content

Commit 36ccb34

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 52db1c9 commit 36ccb34

File tree

3 files changed

+138
-21
lines changed

3 files changed

+138
-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: 118 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,60 @@
11
import logging
22
import os
3+
import sys
4+
from dataclasses import dataclass
35
from http.server import HTTPServer
46
from pathlib import Path
5-
from typing import Any, Iterator, List, Optional
7+
from typing import Any, Iterator, List, Optional, Tuple
8+
from unittest.mock import patch
69
from urllib.parse import urlparse
710
from urllib.request import getproxies
811

912
import pytest
1013
from pip._vendor import requests
1114

1215
from pip import __version__
13-
from pip._internal.exceptions import DiagnosticPipError
16+
from pip._internal.exceptions import (
17+
ConnectionFailedError,
18+
ConnectionTimeoutError,
19+
DiagnosticPipError,
20+
ProxyConnectionError,
21+
SSLVerificationError,
22+
)
1423
from pip._internal.models.link import Link
1524
from pip._internal.network.session import (
1625
CI_ENVIRONMENT_VARIABLES,
1726
PipSession,
1827
user_agent,
1928
)
2029
from pip._internal.utils.logging import VERBOSE
30+
from tests.lib.output import render_to_text
2131
from tests.lib.server import InstantCloseHTTPHandler, server_running
2232

2333

34+
def render_diagnostic_error(error: DiagnosticPipError) -> Tuple[str, Optional[str]]:
35+
message = render_to_text(error.message).rstrip()
36+
if error.context is None:
37+
return (message, None)
38+
return (message, render_to_text(error.context).rstrip())
39+
40+
41+
@dataclass(frozen=True)
42+
class Address:
43+
host: str
44+
port: int
45+
46+
@property
47+
def url(self) -> str:
48+
return f"http://{self.host}:{self.port}/"
49+
50+
51+
@pytest.fixture(scope="module")
52+
def instant_close_server() -> Iterator[Address]:
53+
with HTTPServer(("127.0.0.1", 0), InstantCloseHTTPHandler) as server:
54+
with server_running(server):
55+
yield Address(server.server_name, server.server_port)
56+
57+
2458
def get_user_agent() -> str:
2559
# These tests are testing the computation of the user agent, so we want to
2660
# avoid reusing cached values.
@@ -321,14 +355,18 @@ def test_timeout(self, caplog: pytest.LogCaptureFixture) -> None:
321355
"server didn't respond within 0.2 seconds, retrying 1 last time"
322356
]
323357

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-
]
358+
@pytest.mark.skipif(
359+
sys.platform != "linux", reason="Only Linux raises the needed urllib3 error"
360+
)
361+
def test_connection_closed_by_peer(
362+
self, caplog: pytest.LogCaptureFixture, instant_close_server: Address
363+
) -> None:
364+
with PipSession(retries=1) as session:
365+
with pytest.raises(DiagnosticPipError):
366+
session.get(instant_close_server.url)
367+
assert caplog.messages == [
368+
"the connection was closed unexpectedly, retrying 1 last time"
369+
]
332370

333371
def test_proxy(self, caplog: pytest.LogCaptureFixture) -> None:
334372
with PipSession(retries=1) as session:
@@ -345,3 +383,73 @@ def test_verbose(self, caplog: pytest.LogCaptureFixture) -> None:
345383
warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING]
346384
assert len(warnings) == 1
347385
assert not warnings[0].endswith("retrying 1 last time")
386+
387+
388+
@pytest.mark.network
389+
class TestConnectionErrors:
390+
@pytest.fixture
391+
def session(self) -> Iterator[PipSession]:
392+
with PipSession() as session:
393+
yield session
394+
395+
def test_non_existent_domain(self, session: PipSession) -> None:
396+
url = "https://404.example.com/"
397+
with pytest.raises(ConnectionFailedError) as e:
398+
session.get(url)
399+
message, _ = render_diagnostic_error(e.value)
400+
assert message == f"Failed to connect to 404.example.com while fetching {url}"
401+
402+
@pytest.mark.skipif(
403+
sys.platform != "linux", reason="Only Linux raises the needed urllib3 error"
404+
)
405+
def test_connection_closed_by_peer(
406+
self, session: PipSession, instant_close_server: Address
407+
) -> None:
408+
with pytest.raises(ConnectionFailedError) as e:
409+
session.get(instant_close_server.url)
410+
message, context = render_diagnostic_error(e.value)
411+
assert message == (
412+
f"Failed to connect to {instant_close_server.host} "
413+
f"while fetching {instant_close_server.url}"
414+
)
415+
assert context == (
416+
"Details: the connection was closed without a reply from the server."
417+
)
418+
419+
def test_timeout(self, session: PipSession) -> None:
420+
url = "https://httpstat.us/200?sleep=400"
421+
with pytest.raises(ConnectionTimeoutError) as e:
422+
session.get(url, timeout=0.2)
423+
message, context = render_diagnostic_error(e.value)
424+
assert message == f"Unable to fetch {url}"
425+
assert context is not None
426+
assert context.startswith("httpstat.us didn't respond within 0.2 seconds")
427+
428+
def test_expired_ssl(self, session: PipSession) -> None:
429+
url = "https://expired.badssl.com/"
430+
with pytest.raises(SSLVerificationError) as e:
431+
session.get(url)
432+
message, _ = render_diagnostic_error(e.value)
433+
assert message == (
434+
"Failed to establish a secure connection to expired.badssl.com "
435+
f"while fetching {url}"
436+
)
437+
438+
@patch("pip._internal.network.utils.has_tls", lambda: False)
439+
def test_missing_python_ssl_support(self, session: PipSession) -> None:
440+
# So, this demonstrates a potentially invalid assumption: a SSL error
441+
# when SSL support is missing is assumed to be caused by that. Not ideal
442+
# but unlikely to cause issues in practice.
443+
with pytest.raises(SSLVerificationError) as e:
444+
session.get("https://expired.badssl.com/")
445+
_, context = render_diagnostic_error(e.value)
446+
assert context == "The built-in ssl module is not available."
447+
448+
def test_broken_proxy(self, session: PipSession) -> None:
449+
url = "https://pypi.org/"
450+
proxy = "https://404.example.com"
451+
session.proxies = {"https": proxy}
452+
with pytest.raises(ProxyConnectionError) as e:
453+
session.get(url)
454+
message, _ = render_diagnostic_error(e.value)
455+
assert message == f"Failed to connect to proxy {proxy}:443 while fetching {url}"

0 commit comments

Comments
 (0)