Skip to content

Commit 0eb14c0

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 866bf18 commit 0eb14c0

File tree

3 files changed

+131
-21
lines changed

3 files changed

+131
-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: 111 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,59 @@
11
import logging
22
import os
3+
from dataclasses import dataclass
34
from http.server import HTTPServer
45
from pathlib import Path
5-
from typing import Any, Iterator, List, Optional
6+
from typing import Any, Iterator, List, Optional, Tuple
7+
from unittest.mock import patch
68
from urllib.parse import urlparse
79
from urllib.request import getproxies
810

911
import pytest
1012
from pip._vendor import requests
1113

1214
from pip import __version__
13-
from pip._internal.exceptions import DiagnosticPipError
15+
from pip._internal.exceptions import (
16+
ConnectionFailedError,
17+
ConnectionTimeoutError,
18+
DiagnosticPipError,
19+
ProxyConnectionError,
20+
SSLVerificationError,
21+
)
1422
from pip._internal.models.link import Link
1523
from pip._internal.network.session import (
1624
CI_ENVIRONMENT_VARIABLES,
1725
PipSession,
1826
user_agent,
1927
)
2028
from pip._internal.utils.logging import VERBOSE
29+
from tests.lib.output import render_to_text
2130
from tests.lib.server import InstantCloseHTTPHandler, server_running
2231

2332

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

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-
]
357+
def test_connection_closed_by_peer(
358+
self, caplog: pytest.LogCaptureFixture, instant_close_server: Address
359+
) -> None:
360+
with PipSession(retries=1) as session:
361+
with pytest.raises(DiagnosticPipError):
362+
session.get(instant_close_server.url)
363+
assert caplog.messages == [
364+
"the connection was closed unexpectedly, retrying 1 last time"
365+
]
332366

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

0 commit comments

Comments
 (0)