Finessing interface

This commit is contained in:
Tom Christie 2019-04-30 11:26:11 +01:00
parent d652479b07
commit d7619a92a8
9 changed files with 215 additions and 52 deletions

View File

@ -15,7 +15,7 @@ from .config import (
TimeoutConfig,
)
from .dispatch.connection_pool import ConnectionPool
from .models import URL, Request, Response
from .models import URL, BodyTypes, HeaderTypes, Request, Response, URLTypes
class Client:
@ -37,14 +37,14 @@ class Client:
async def request(
self,
method: str,
url: typing.Union[str, URL],
url: URLTypes,
*,
body: typing.Union[bytes, typing.AsyncIterator[bytes]] = b"",
headers: typing.List[typing.Tuple[bytes, bytes]] = [],
body: BodyTypes = b"",
headers: HeaderTypes = None,
stream: bool = False,
allow_redirects: bool = True,
ssl: typing.Optional[SSLConfig] = None,
timeout: typing.Optional[TimeoutConfig] = None,
ssl: SSLConfig = None,
timeout: TimeoutConfig = None,
) -> Response:
request = Request(method, url, headers=headers, body=body)
self.prepare_request(request)
@ -59,26 +59,74 @@ class Client:
async def get(
self,
url: typing.Union[str, URL],
url: URLTypes,
*,
headers: typing.List[typing.Tuple[bytes, bytes]] = [],
headers: HeaderTypes = None,
stream: bool = False,
ssl: typing.Optional[SSLConfig] = None,
timeout: typing.Optional[TimeoutConfig] = None,
allow_redirects: bool = True,
ssl: SSLConfig = None,
timeout: TimeoutConfig = None,
) -> Response:
return await self.request(
"GET", url, headers=headers, stream=stream, ssl=ssl, timeout=timeout
"GET",
url,
headers=headers,
stream=stream,
allow_redirects=allow_redirects,
ssl=ssl,
timeout=timeout,
)
async def options(
self,
url: URLTypes,
*,
headers: HeaderTypes = None,
stream: bool = False,
allow_redirects: bool = True,
ssl: SSLConfig = None,
timeout: TimeoutConfig = None,
) -> Response:
return await self.request(
"OPTIONS",
url,
headers=headers,
stream=stream,
allow_redirects=allow_redirects,
ssl=ssl,
timeout=timeout,
)
async def head(
self,
url: URLTypes,
*,
headers: HeaderTypes = None,
stream: bool = False,
allow_redirects: bool = False, #  Note: Differs to usual default.
ssl: SSLConfig = None,
timeout: TimeoutConfig = None,
) -> Response:
return await self.request(
"HEAD",
url,
headers=headers,
stream=stream,
allow_redirects=allow_redirects,
ssl=ssl,
timeout=timeout,
)
async def post(
self,
url: typing.Union[str, URL],
url: URLTypes,
*,
body: typing.Union[bytes, typing.AsyncIterator[bytes]] = b"",
headers: typing.List[typing.Tuple[bytes, bytes]] = [],
body: BodyTypes = b"",
headers: HeaderTypes = None,
stream: bool = False,
ssl: typing.Optional[SSLConfig] = None,
timeout: typing.Optional[TimeoutConfig] = None,
allow_redirects: bool = True,
ssl: SSLConfig = None,
timeout: TimeoutConfig = None,
) -> Response:
return await self.request(
"POST",
@ -86,6 +134,73 @@ class Client:
body=body,
headers=headers,
stream=stream,
allow_redirects=allow_redirects,
ssl=ssl,
timeout=timeout,
)
async def put(
self,
url: URLTypes,
*,
body: BodyTypes = b"",
headers: HeaderTypes = None,
stream: bool = False,
allow_redirects: bool = True,
ssl: SSLConfig = None,
timeout: TimeoutConfig = None,
) -> Response:
return await self.request(
"PUT",
url,
body=body,
headers=headers,
stream=stream,
allow_redirects=allow_redirects,
ssl=ssl,
timeout=timeout,
)
async def patch(
self,
url: URLTypes,
*,
body: BodyTypes = b"",
headers: HeaderTypes = None,
stream: bool = False,
allow_redirects: bool = True,
ssl: SSLConfig = None,
timeout: TimeoutConfig = None,
) -> Response:
return await self.request(
"PATCH",
url,
body=body,
headers=headers,
stream=stream,
allow_redirects=allow_redirects,
ssl=ssl,
timeout=timeout,
)
async def delete(
self,
url: URLTypes,
*,
body: BodyTypes = b"",
headers: HeaderTypes = None,
stream: bool = False,
allow_redirects: bool = True,
ssl: SSLConfig = None,
timeout: TimeoutConfig = None,
) -> Response:
return await self.request(
"DELETE",
url,
body=body,
headers=headers,
stream=stream,
allow_redirects=allow_redirects,
ssl=ssl,
timeout=timeout,
)
@ -99,14 +214,19 @@ class Client:
*,
stream: bool = False,
allow_redirects: bool = True,
ssl: typing.Optional[SSLConfig] = None,
timeout: typing.Optional[TimeoutConfig] = None,
ssl: SSLConfig = None,
timeout: TimeoutConfig = None,
) -> Response:
options = {"stream": stream} # type: typing.Dict[str, typing.Any]
options = {
"stream": stream,
"allow_redirects": allow_redirects,
} # type: typing.Dict[str, typing.Any]
if ssl is not None:
options["ssl"] = ssl
if timeout is not None:
options["timeout"] = timeout
return await self.adapter.send(request, **options)
async def close(self) -> None:

View File

@ -71,6 +71,8 @@ class SSLConfig:
context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH)
context.verify_mode = ssl.CERT_REQUIRED
context.options |= ssl.OP_NO_SSLv2
context.options |= ssl.OP_NO_SSLv3
context.options |= ssl.OP_NO_COMPRESSION

View File

@ -75,14 +75,14 @@ class HTTP11Connection(Adapter):
event = await self._receive_event(timeout)
assert isinstance(event, h11.Response)
reason = event.reason.decode("latin1")
reason_phrase = event.reason.decode("latin1")
status_code = event.status_code
headers = event.headers
body = self._body_iter(timeout)
response = Response(
status_code=status_code,
reason=reason,
reason_phrase=reason_phrase,
protocol="HTTP/1.1",
headers=headers,
body=body,

View File

@ -1,4 +1,3 @@
import http
import typing
from urllib.parse import urlsplit
@ -11,12 +10,27 @@ from .decoders import (
MultiDecoder,
)
from .exceptions import ResponseClosed, StreamConsumed
from .utils import normalize_header_key, normalize_header_value
from .status_codes import codes
from .utils import get_reason_phrase, normalize_header_key, normalize_header_value
URLTypes = typing.Union["URL", str]
HeaderTypes = typing.Union[
"Headers",
typing.Dict[typing.AnyStr, typing.AnyStr],
typing.List[typing.Tuple[typing.AnyStr, typing.AnyStr]],
]
BodyTypes = typing.Union[bytes, typing.AsyncIterator[bytes]]
class URL:
def __init__(self, url: str = "") -> None:
self.components = urlsplit(url)
def __init__(self, url: URLTypes) -> None:
if isinstance(url, str):
self.components = urlsplit(url)
else:
self.components = url.components
if not self.components.scheme:
raise ValueError("No scheme included in URL.")
if self.components.scheme not in ("http", "https"):
@ -106,13 +120,6 @@ class Origin:
return hash((self.is_ssl, self.hostname, self.port))
HeaderTypes = typing.Union[
"Headers",
typing.Dict[typing.AnyStr, typing.AnyStr],
typing.List[typing.Tuple[typing.AnyStr, typing.AnyStr]],
]
class Headers(typing.MutableMapping[str, str]):
"""
A case-insensitive multidict.
@ -239,7 +246,7 @@ class Request:
url: typing.Union[str, URL],
*,
headers: HeaderTypes = None,
body: typing.Union[bytes, typing.AsyncIterator[bytes]] = b"",
body: BodyTypes = b"",
):
self.method = method.upper()
self.url = URL(url) if isinstance(url, str) else url
@ -298,22 +305,19 @@ class Response:
self,
status_code: int,
*,
reason: typing.Optional[str] = None,
protocol: typing.Optional[str] = None,
headers: typing.List[typing.Tuple[bytes, bytes]] = [],
body: typing.Union[bytes, typing.AsyncIterator[bytes]] = b"",
reason_phrase: str = None,
protocol: str = None,
headers: HeaderTypes = None,
body: BodyTypes = b"",
on_close: typing.Callable = None,
request: Request = None,
history: typing.List["Response"] = None,
):
self.status_code = status_code
if not reason:
try:
self.reason = http.HTTPStatus(status_code).phrase
except ValueError as exc:
self.reason = ""
if reason_phrase is None:
self.reason_phrase = get_reason_phrase(status_code)
else:
self.reason = reason
self.reason_phrase = reason_phrase
self.protocol = protocol
self.headers = Headers(headers)
self.on_close = on_close
@ -397,5 +401,13 @@ class Response:
@property
def is_redirect(self) -> bool:
return (
self.status_code in (301, 302, 303, 307, 308) and "location" in self.headers
self.status_code
in (
codes.moved_permanently,
codes.found,
codes.see_other,
codes.temporary_redirect,
codes.permanent_redirect,
)
and "location" in self.headers
)

View File

@ -18,8 +18,8 @@ class SyncResponse:
return self._response.status_code
@property
def reason(self) -> str:
return self._response.reason
def reason_phrase(self) -> str:
return self._response.reason_phrase
@property
def headers(self) -> Headers:

View File

@ -1,3 +1,4 @@
import http
import typing
from urllib.parse import quote
@ -69,3 +70,13 @@ def normalize_header_value(value: typing.AnyStr) -> bytes:
if isinstance(value, bytes):
return value
return value.encode("latin-1")
def get_reason_phrase(status_code: int) -> str:
"""
Return an HTTP reason phrase, eg. "OK" for 200, or "Not Found" for 404.
"""
try:
return http.HTTPStatus(status_code).phrase
except ValueError as exc:
return ""

View File

@ -5,18 +5,18 @@ import httpcore
@pytest.mark.asyncio
async def test_get(server):
url = "http://127.0.0.1:8000/"
async with httpcore.Client() as client:
response = await client.request("GET", "http://127.0.0.1:8000/")
response = await client.get(url)
assert response.status_code == 200
assert response.body == b"Hello, world!"
@pytest.mark.asyncio
async def test_post(server):
url = "http://127.0.0.1:8000/"
async with httpcore.Client() as client:
response = await client.request(
"POST", "http://127.0.0.1:8000/", body=b"Hello, world!"
)
response = await client.post(url, body=b"Hello, world!")
assert response.status_code == 200

View File

@ -1,6 +1,24 @@
import ssl
import pytest
import httpcore
@pytest.mark.asyncio
async def test_load_ssl_config():
ssl_config = httpcore.SSLConfig()
context = await ssl_config.load_ssl_context()
assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED
@pytest.mark.asyncio
async def test_load_ssl_config_no_verify(verify=False):
ssl_config = httpcore.SSLConfig(verify=False)
context = await ssl_config.load_ssl_context()
assert context.verify_mode == ssl.VerifyMode.CERT_NONE
def test_ssl_repr():
ssl = httpcore.SSLConfig(verify=False)
assert repr(ssl) == "SSLConfig(cert=None, verify=False)"

View File

@ -11,7 +11,7 @@ async def streaming_body():
def test_response():
response = httpcore.Response(200, body=b"Hello, world!")
assert response.status_code == 200
assert response.reason == "OK"
assert response.reason_phrase == "OK"
assert response.body == b"Hello, world!"
assert response.is_closed
@ -71,4 +71,4 @@ async def test_cannot_read_after_response_closed():
def test_unknown_status_code():
response = httpcore.Response(600)
assert response.status_code == 600
assert response.reason == ""
assert response.reason_phrase == ""