Repository: encode/httpx
Pull request #1077 (2020-07-21 10:57:16)

Include 'request' on TooManyRedirect and RequestBodyUnavailable
Minor enhancement.
Include the `request` instance where possible on exceptions, and use helpful error messaging.


Patch:
diff --git a/docs/api.md b/docs/api.md
index 2ad1f9f..a34d366 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -120,26 +120,9 @@ what gets sent over the wire.*
 * `.full_path` - **str**
 * `.fragment` - **str**
 * `.is_ssl` - **bool**
-* `.origin` - **Origin**
 * `.is_absolute_url` - **bool**
 * `.is_relative_url` - **bool**
 * `def .copy_with([scheme], [authority], [path], [query], [fragment])` - **URL**
-* `def .resolve_with(url)` - **URL**
-
-## `Origin`
-
-*A normalized, IDNA supporting set of scheme/host/port info.*
-
-```python
->>> Origin('https://example.org') == Origin('HTTPS://EXAMPLE.ORG:443')
-True
-```
-
-* `def __init__(url)`
-* `.scheme` - **str**
-* `.is_ssl` - **bool**
-* `.host` - **str**
-* `.port` - **int**
 
 ## `Headers`
 
diff --git a/httpx/_auth.py b/httpx/_auth.py
index ab43097..d60dbdc 100644
--- a/httpx/_auth.py
+++ b/httpx/_auth.py
@@ -100,7 +100,12 @@ class DigestAuth(Auth):
 
     def auth_flow(self, request: Request) -> typing.Generator[Request, Response, None]:
         if not request.stream.can_replay():
-            raise RequestBodyUnavailable("Request body is no longer available.")
+            raise RequestBodyUnavailable(
+                "Cannot use digest auth with streaming requests that are unable "
+                "to replay the request body if a second request is required.",
+                request=request,
+            )
+
         response = yield request
 
         if response.status_code != 401 or "www-authenticate" not in response.headers:
diff --git a/httpx/_client.py b/httpx/_client.py
index 1ee9352..24812d9 100644
--- a/httpx/_client.py
+++ b/httpx/_client.py
@@ -20,13 +20,12 @@ from ._config import (
 from ._content_streams import ContentStream
 from ._exceptions import (
     HTTPCORE_EXC_MAP,
-    HTTPError,
     InvalidURL,
     RequestBodyUnavailable,
     TooManyRedirects,
     map_exceptions,
 )
-from ._models import URL, Cookies, Headers, Origin, QueryParams, Request, Response
+from ._models import URL, Cookies, Headers, QueryParams, Request, Response
 from ._status_codes import codes
 from ._transports.asgi import ASGITransport
 from ._transports.wsgi import WSGITransport
@@ -47,6 +46,7 @@ from ._utils import (
     NetRCInfo,
     get_environment_proxies,
     get_logger,
+    same_origin,
     should_not_be_proxied,
 )
 
@@ -80,7 +80,7 @@ class BaseClient:
         self.timeout = Timeout(timeout)
         self.max_redirects = max_redirects
         self.trust_env = trust_env
-        self.netrc = NetRCInfo()
+        self._netrc = NetRCInfo()
 
     def _get_proxy_map(
         self, proxies: typing.Optional[ProxiesTypes], trust_env: bool,
@@ -269,7 +269,7 @@ class BaseClient:
             return BasicAuth(username=username, password=password)
 
         if self.trust_env and "Authorization" not in request.headers:
-            credentials = self.netrc.get_credentials(request.url.authority)
+            credentials = self._netrc.get_credentials(request.url.authority)
             if credentials is not None:
                 return BasicAuth(username=credentials[0], password=credentials[1])
 
@@ -346,7 +346,7 @@ class BaseClient:
         """
         headers = Headers(request.headers)
 
-        if Origin(url) != Origin(request.url):
+        if not same_origin(url, request.url):
             # Strip Authorization headers when responses are redirected away from
             # the origin.
             headers.pop("Authorization", None)
@@ -376,7 +376,8 @@ class BaseClient:
         if not request.stream.can_replay():
             raise RequestBodyUnavailable(
                 "Got a redirect response, but the request body was streaming "
-                "and is no longer available."
+                "and is no longer available.",
+                request=request,
             )
 
         return request.stream
@@ -628,7 +629,9 @@ class Client(BaseClient):
 
         while True:
             if len(history) > self.max_redirects:
-                raise TooManyRedirects()
+                raise TooManyRedirects(
+                    "Exceeded maximum allowed redirects.", request=request
+                )
 
             response = self._send_handling_auth(
                 request, auth=auth, timeout=timeout, history=history
@@ -690,28 +693,20 @@ class Client(BaseClient):
 
         transport = self._transport_for_url(request.url)
 
-        try:
-            with map_exceptions(HTTPCORE_EXC_MAP):
-                (
-                    http_version,
-                    status_code,
-                    reason_phrase,
-                    headers,
-                    stream,
-                ) = transport.request(
-                    request.method.encode(),
-                    request.url.raw,
-                    headers=request.headers.raw,
-                    stream=request.stream,
-                    timeout=timeout.as_dict(),
-                )
-        except HTTPError as exc:
-            # Add the original request to any HTTPError unless
-            # there'a already a request attached in the case of
-            # a ProxyError.
-            if exc._request is None:
-                exc._request = request
-            raise
+        with map_exceptions(HTTPCORE_EXC_MAP, request=request):
+            (
+                http_version,
+                status_code,
+                reason_phrase,
+                headers,
+                stream,
+            ) = transport.request(
+                request.method.encode(),
+                request.url.raw,
+                headers=request.headers.raw,
+                stream=request.stream,
+                timeout=timeout.as_dict(),
+            )
         response = Response(
             status_code,
             http_version=http_version.decode("ascii"),
@@ -1167,7 +1162,9 @@ class AsyncClient(BaseClient):
 
         while True:
             if len(history) > self.max_redirects:
-                raise TooManyRedirects()
+                raise TooManyRedirects(
+                    "Exceeded maximum allowed redirects.", request=request
+                )
 
             response = await self._send_handling_auth(
                 request, auth=auth, timeout=timeout, history=history
@@ -1231,28 +1228,20 @@ class AsyncClient(BaseClient):
 
         transport = self._transport_for_url(request.url)
 
-        try:
-            with map_exceptions(HTTPCORE_EXC_MAP):
-                (
-                    http_version,
-                    status_code,
-                    reason_phrase,
-                    headers,
-                    stream,
-                ) = await transport.request(
-                    request.method.encode(),
-                    request.url.raw,
-                    headers=request.headers.raw,
-                    stream=request.stream,
-                    timeout=timeout.as_dict(),
-                )
-        except HTTPError as exc:
-            # Add the original request to any HTTPError unless
-            # there'a already a request attached in the case of
-            # a ProxyError.
-            if exc._request is None:
-                exc._request = request
-            raise
+        with map_exceptions(HTTPCORE_EXC_MAP, request=request):
+            (
+                http_version,
+                status_code,
+                reason_phrase,
+                headers,
+                stream,
+            ) = await transport.request(
+                request.method.encode(),
+                request.url.raw,
+                headers=request.headers.raw,
+                stream=request.stream,
+                timeout=timeout.as_dict(),
+            )
         response = Response(
             status_code,
             http_version=http_version.decode("ascii"),
diff --git a/httpx/_decoders.py b/httpx/_decoders.py
index 1ea47b0..d1c60fb 100644
--- a/httpx/_decoders.py
+++ b/httpx/_decoders.py
@@ -233,12 +233,18 @@ class LineDecoder:
     def decode(self, text: str) -> typing.List[str]:
         lines = []
 
-        if text.startswith("\n") and self.buffer and self.buffer[-1] == "\r":
-            # Handle the case where we have an "\r\n" split across
-            # our previous input, and our new chunk.
-            lines.append(self.buffer[:-1] + "\n")
-            self.buffer = ""
-            text = text[1:]
+        if text and self.buffer and self.buffer[-1] == "\r":
+            if text.startswith("\n"):
+                # Handle the case where we have an "\r\n" split across
+                # our previous input, and our new chunk.
+                lines.append(self.buffer[:-1] + "\n")
+                self.buffer = ""
+                text = text[1:]
+            else:
+                # Handle the case where we have "\r" at the end of our
+                # previous input.
+                lines.append(self.buffer[:-1] + "\n")
+                self.buffer = ""
 
         while text:
             num_chars = len(text)
diff --git a/httpx/_exceptions.py b/httpx/_exceptions.py
index f36fafb..a5271c3 100644
--- a/httpx/_exceptions.py
+++ b/httpx/_exceptions.py
@@ -216,7 +216,8 @@ class CookieConflict(HTTPError):
 
 @contextlib.contextmanager
 def map_exceptions(
-    mapping: typing.Mapping[typing.Type[Exception], typing.Type[Exception]]
+    mapping: typing.Mapping[typing.Type[Exception], typing.Type[Exception]],
+    **kwargs: typing.Any,
 ) -> typing.Iterator[None]:
     try:
         yield
@@ -235,7 +236,8 @@ def map_exceptions(
         if mapped_exc is None:
             raise
 
-        raise mapped_exc(exc) from None
+        message = str(exc)
+        raise mapped_exc(message, **kwargs) from None  # type: ignore
 
 
 HTTPCORE_EXC_MAP = {
diff --git a/httpx/_models.py b/httpx/_models.py
index 892a959..b992d9d 100644
--- a/httpx/_models.py
+++ b/httpx/_models.py
@@ -87,10 +87,6 @@ class URL:
             if not self.host:
                 raise InvalidURL("No host included in URL.")
 
-        # Allow setting full_path to custom attributes requests
-        # like OPTIONS, CONNECT, and forwarding proxy requests.
-        self._full_path: typing.Optional[str] = None
-
     @property
     def scheme(self) -> str:
         return self._uri_reference.scheme or ""
@@ -138,17 +134,11 @@ class URL:
 
     @property
     def full_path(self) -> str:
-        if self._full_path is not None:
-            return self._full_path
         path = self.path
         if self.query:
             path += "?" + self.query
         return path
 
-    @full_path.setter
-    def full_path(self, value: typing.Optional[str]) -> None:
-        self._full_path = value
-
     @property
     def fragment(self) -> str:
         return self._uri_reference.fragment or ""
@@ -244,37 +234,6 @@ class URL:
         return f"{class_name}({url_str!r})"
 
 
-class Origin:
-    """
-    The URL scheme and authority information, as a comparable, hashable object.
-    """
-
-    def __init__(self, url: URLTypes) -> None:
-        if not isinstance(url, URL):
-            url = URL(url)
-        self.scheme = url.scheme
-        self.is_ssl = url.is_ssl
-        self.host = url.host
-        self.port = url.port
-
-    def __eq__(self, other: typing.Any) -> bool:
-        return (
-            isinstance(other, self.__class__)
-            and self.scheme == other.scheme
-            and self.host == other.host
-            and self.port == other.port
-        )
-
-    def __hash__(self) -> int:
-        return hash((self.scheme, self.host, self.port))
-
-    def __repr__(self) -> str:
-        class_name = self.__class__.__name__
-        return (
-            f"{class_name}(scheme={self.scheme!r} host={self.host!r} port={self.port})"
-        )
-
-
 class QueryParams(typing.Mapping[str, str]):
     """
     URL query parameters, as a multi-dict.
diff --git a/httpx/_utils.py b/httpx/_utils.py
index 8c01eb1..b85ec75 100644
--- a/httpx/_utils.py
+++ b/httpx/_utils.py
@@ -260,8 +260,18 @@ def get_logger(name: str) -> Logger:
     return typing.cast(Logger, logger)
 
 
+def same_origin(url: "URL", other: "URL") -> bool:
+    """
+    Return 'True' if the given URLs share the same origin.
+    """
+    return (
+        url.scheme == other.scheme and url.host == other.host and url.port == other.port
+    )
+
+
 def should_not_be_proxied(url: "URL") -> bool:
-    """ Return True if url should not be proxied,
+    """
+    Return True if url should not be proxied,
     return False otherwise.
     """
     no_proxy = getproxies().get("no")


Test patch:
diff --git a/tests/models/test_url.py b/tests/models/test_url.py
index f9a568a..de9da7d 100644
--- a/tests/models/test_url.py
+++ b/tests/models/test_url.py
@@ -1,7 +1,6 @@
 import pytest
 
 from httpx import URL, InvalidURL
-from httpx._models import Origin
 
 
 @pytest.mark.parametrize(
@@ -177,34 +176,6 @@ def test_url_set():
     assert all(url in urls for url in url_set)
 
 
-def test_url_full_path_setter():
-    url = URL("http://example.org")
-
-    url.full_path = "http://example.net"
-    assert url.full_path == "http://example.net"
-
-
-def test_origin_from_url_string():
-    origin = Origin("https://example.com")
-    assert origin.scheme == "https"
-    assert origin.is_ssl
-    assert origin.host == "example.com"
-    assert origin.port == 443
-
-
-def test_origin_repr():
-    origin = Origin("https://example.com:8080")
-    assert str(origin) == "Origin(scheme='https' host='example.com' port=8080)"
-
-
-def test_origin_equal():
-    origin1 = Origin("https://example.com")
-    origin2 = Origin("https://example.com")
-    assert origin1 is not origin2
-    assert origin1 == origin2
-    assert len({origin1, origin2}) == 1
-
-
 def test_url_copywith_for_authority():
     copy_with_kwargs = {
         "username": "username",
diff --git a/tests/test_decoders.py b/tests/test_decoders.py
index 89c545b..6b79931 100644
--- a/tests/test_decoders.py
+++ b/tests/test_decoders.py
@@ -247,14 +247,13 @@ def test_line_decoder_cr():
     assert decoder.flush() == ["c\n"]
 
     # Issue #1033
-    # TODO: This seems like another bug; fix expectations and results.
     decoder = LineDecoder()
     assert decoder.decode("") == []
     assert decoder.decode("12345\r") == []
-    assert decoder.decode("foo ") == []
+    assert decoder.decode("foo ") == ["12345\n"]
     assert decoder.decode("bar ") == []
     assert decoder.decode("baz\r") == []
-    assert decoder.flush() == ["12345\rfoo bar baz\n"]
+    assert decoder.flush() == ["foo bar baz\n"]
 
 
 def test_line_decoder_crnl():
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 70fc1d6..fa30ee8 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -12,6 +12,7 @@ from httpx._utils import (
     guess_json_utf,
     obfuscate_sensitive_headers,
     parse_header_links,
+    same_origin,
     should_not_be_proxied,
 )
 from tests.utils import override_log_level
@@ -294,3 +295,15 @@ def test_should_not_be_proxied(url, no_proxy, expected):
     os.environ.update(no_proxy)
     parsed_url = httpx.URL(url)
     assert should_not_be_proxied(parsed_url) == expected
+
+
+def test_same_origin():
+    origin1 = httpx.URL("https://example.com")
+    origin2 = httpx.URL("HTTPS://EXAMPLE.COM:443")
+    assert same_origin(origin1, origin2)
+
+
+def test_not_same_origin():
+    origin1 = httpx.URL("https://example.com")
+    origin2 = httpx.URL("HTTP://EXAMPLE.COM")
+    assert not same_origin(origin1, origin2)