From 630d5e8dc76c6b553ec8477d0d50b174e752baec Mon Sep 17 00:00:00 2001 From: Florimond Manca Date: Mon, 1 Mar 2021 12:54:12 +0100 Subject: [PATCH 1/3] Add support for response context manager interface --- httpx/_client.py | 52 +++++++++++++++++++++++++++++++++++++++++------- httpx/_compat.py | 9 +++++++++ httpx/_utils.py | 12 +++++++++++ setup.py | 2 ++ 4 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 httpx/_compat.py diff --git a/httpx/_client.py b/httpx/_client.py index 3465a10b75..7ed3efd530 100644 --- a/httpx/_client.py +++ b/httpx/_client.py @@ -1,3 +1,4 @@ +import contextlib import datetime import enum import typing @@ -8,6 +9,7 @@ from .__version__ import __version__ from ._auth import Auth, BasicAuth, FunctionAuth +from ._compat import AsyncExitStack from ._config import ( DEFAULT_LIMITS, DEFAULT_MAX_REDIRECTS, @@ -50,6 +52,8 @@ NetRCInfo, Timer, URLPattern, + cast_async_context_manager, + cast_context_manager, get_environment_proxies, get_logger, same_origin, @@ -852,19 +856,35 @@ def _send_single_request(self, request: Request, timeout: Timeout) -> Response: timer = Timer() timer.sync_start() + exit_stack = contextlib.ExitStack() + with map_exceptions(HTTPCORE_EXC_MAP, request=request): - (status_code, headers, stream, ext) = transport.request( + # NOTE: Once https://github.com/encode/httpcore/pull/206 is merged, + # HTTPCore will always return a context manager. + raw_response = transport.request( request.method.encode(), request.url.raw, headers=request.headers.raw, stream=request.stream, # type: ignore ext={"timeout": timeout.as_dict()}, ) + try: + (status_code, headers, stream, ext) = raw_response + except ValueError: + response_ctx = cast_context_manager(raw_response) + (status_code, headers, stream, ext) = exit_stack.enter_context( + response_ctx + ) + else: + if hasattr( + stream, "close" + ): # Our own content streams don't have close methods. + exit_stack.callback(stream.close) def on_close(response: Response) -> None: response.elapsed = datetime.timedelta(seconds=timer.sync_elapsed()) - if hasattr(stream, "close"): - stream.close() + with map_exceptions(HTTPCORE_EXC_MAP, request=request): + exit_stack.close() response = Response( status_code, @@ -1488,20 +1508,38 @@ async def _send_single_request( timer = Timer() await timer.async_start() + exit_stack = AsyncExitStack() + with map_exceptions(HTTPCORE_EXC_MAP, request=request): - (status_code, headers, stream, ext) = await transport.arequest( + # NOTE: Once https://github.com/encode/httpcore/pull/206 is merged, + # HTTPCore will always return a context manager. + raw_response = transport.arequest( request.method.encode(), request.url.raw, headers=request.headers.raw, stream=request.stream, # type: ignore ext={"timeout": timeout.as_dict()}, ) + try: + (status_code, headers, stream, ext) = await raw_response + except ValueError: + response_ctx = cast_async_context_manager(raw_response) + ( + status_code, + headers, + stream, + ext, + ) = await exit_stack.enter_async_context(response_ctx) + else: + if hasattr( + stream, "aclose" + ): # Our own content streams don't have close methods. + exit_stack.push_async_callback(stream.aclose) async def on_close(response: Response) -> None: response.elapsed = datetime.timedelta(seconds=await timer.async_elapsed()) - if hasattr(stream, "aclose"): - with map_exceptions(HTTPCORE_EXC_MAP, request=request): - await stream.aclose() + with map_exceptions(HTTPCORE_EXC_MAP, request=request): + await exit_stack.aclose() response = Response( status_code, diff --git a/httpx/_compat.py b/httpx/_compat.py new file mode 100644 index 0000000000..fbaeb11cc1 --- /dev/null +++ b/httpx/_compat.py @@ -0,0 +1,9 @@ +try: + from contextlib import AsyncExitStack # type: ignore # Py3.6 +except ImportError: # pragma: no cover + # Python 3.6 + from async_exit_stack import AsyncExitstack # type: ignore # noqa: F401 + +__all__ = [ + "AsyncExitStack", +] diff --git a/httpx/_utils.py b/httpx/_utils.py index 072db3f1e8..72fffcc234 100644 --- a/httpx/_utils.py +++ b/httpx/_utils.py @@ -19,6 +19,8 @@ if typing.TYPE_CHECKING: # pragma: no cover from ._models import URL +T = typing.TypeVar("T") + _HTML5_FORM_ENCODING_REPLACEMENTS = {'"': "%22", "\\": "\\\\"} _HTML5_FORM_ENCODING_REPLACEMENTS.update( @@ -539,3 +541,13 @@ def __eq__(self, other: typing.Any) -> bool: def warn_deprecated(message: str) -> None: # pragma: nocover warnings.warn(message, DeprecationWarning, stacklevel=2) + + +def cast_context_manager(value: T) -> typing.ContextManager[T]: + return value # type: ignore + + +def cast_async_context_manager( + value: typing.Awaitable[T], +) -> typing.AsyncContextManager[T]: + return value # type: ignore diff --git a/setup.py b/setup.py index 0c59851011..5959eb9b9f 100644 --- a/setup.py +++ b/setup.py @@ -60,6 +60,8 @@ def get_packages(package): "sniffio", "rfc3986[idna2008]>=1.3,<2", "httpcore==0.12.*", + # Backports. + "async_exit_stack; python_version<'3.7'", ], extras_require={ "http2": "h2==3.*", From 82998ded855e20be35ea833952e8f209d4fed1e5 Mon Sep 17 00:00:00 2001 From: florimondmanca Date: Sat, 13 Mar 2021 18:16:52 +0100 Subject: [PATCH 2/3] Typo --- httpx/_compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/httpx/_compat.py b/httpx/_compat.py index fbaeb11cc1..10fe5b9d0d 100644 --- a/httpx/_compat.py +++ b/httpx/_compat.py @@ -2,7 +2,7 @@ from contextlib import AsyncExitStack # type: ignore # Py3.6 except ImportError: # pragma: no cover # Python 3.6 - from async_exit_stack import AsyncExitstack # type: ignore # noqa: F401 + from async_exit_stack import AsyncExitStack # type: ignore # noqa: F401 __all__ = [ "AsyncExitStack", From f225dc16f1d6ac3c4010bba73858a038637503ec Mon Sep 17 00:00:00 2001 From: florimondmanca Date: Sat, 13 Mar 2021 18:21:50 +0100 Subject: [PATCH 3/3] Skip coverage --- httpx/_client.py | 4 ++-- httpx/_utils.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/httpx/_client.py b/httpx/_client.py index 7ed3efd530..7de8029dc3 100644 --- a/httpx/_client.py +++ b/httpx/_client.py @@ -870,7 +870,7 @@ def _send_single_request(self, request: Request, timeout: Timeout) -> Response: ) try: (status_code, headers, stream, ext) = raw_response - except ValueError: + except ValueError: # pragma: no cover response_ctx = cast_context_manager(raw_response) (status_code, headers, stream, ext) = exit_stack.enter_context( response_ctx @@ -1522,7 +1522,7 @@ async def _send_single_request( ) try: (status_code, headers, stream, ext) = await raw_response - except ValueError: + except ValueError: # pragma: no cover response_ctx = cast_async_context_manager(raw_response) ( status_code, diff --git a/httpx/_utils.py b/httpx/_utils.py index 72fffcc234..d734bcef9f 100644 --- a/httpx/_utils.py +++ b/httpx/_utils.py @@ -543,11 +543,11 @@ def warn_deprecated(message: str) -> None: # pragma: nocover warnings.warn(message, DeprecationWarning, stacklevel=2) -def cast_context_manager(value: T) -> typing.ContextManager[T]: +def cast_context_manager(value: T) -> typing.ContextManager[T]: # pragma: no cover return value # type: ignore def cast_async_context_manager( value: typing.Awaitable[T], -) -> typing.AsyncContextManager[T]: +) -> typing.AsyncContextManager[T]: # pragma: no cover return value # type: ignore