from __future__ import annotations
import logging
import types
import typing
import httpx
from restpite import RestpiteResponse
from restpite import __version__
from restpite._config import RESPITE_DEFAULT_TIMEOUT_CONFIG
from restpite._config import RESTPITE_DEFAULT_LIMITS
from restpite._config import RESTPITE_DEFAULT_MAX_REDIRECTS
from ._dispatching import HandlerDispatcher
from ._models import Connection
from ._types import AUTH_TYPES
from ._types import CERTIFICATE_TYPES
from ._types import CONTENT_TYPES
from ._types import COOKIE_TYPES
from ._types import DATA_TYPES
from ._types import EVENT_HOOKS_TYPES
from ._types import EVENT_SUBSCRIBERS_TYPES
from ._types import FILES_TYPES
from ._types import HEADER_TYPES
from ._types import LIMIT_TYPES
from ._types import MOUNT_TYPES
from ._types import PROXY_TYPES
from ._types import QUERY_STRING_TYPES
from ._types import TIMEOUT_TYPES
from ._types import TRANSPORT_TYPES
from ._types import URL_TYPES
from ._types import VERIFY_TYPES
from ._verbs import GET
log = logging.getLogger(__name__)
[docs]class RestpiteClient:
"""
A synchronous (IO blocking) HTTP client which supports:
* HTTP/2
* Connection pooling
* Cookie persistence
* Redirects
* Request/Response tracking and performance
* Much more ...
* Note: Only keyword arguments are supported by the client instance.
Usage:
```python
>>> client = RestpiteClient()
>>> response = client.get("https://www.google.com", headers={"foo": "bar"}, ...)
```
Context usage:
```python
>>> with RestpiteClient() as client:
>>> client.get("https://www.google.com", params={"foo": "bar"})
```
:param auth: (Optional) By default Restpite supports `Basic` and `Digest` auth. In order to use `Basic` auth, pass
a tuple containing either bytes or strings, this will be sent in the request Authorization header b64 encoded.
In order to use digest auth you should pass a :class:`httpx.DigestAuth` instance. Subsequently subclassing
:class:`httpx.Auth` and implementing your own authorization mechanism can be useful. Lastly any callable
can be passed that takes a single :class:`httpx.Request` instance and returns the same instance. If omitted,
no request headers will be manipulated for authorization.
:param params: (Optional) Mapping of data to be sent in the request query string parameters. To provide multiple
values for the same parameter provide a sequence of primitive types as the mapping key:
```python
>>> params = {"foo": ["bar", "baz"], "one": 25}
>>> response = RestpiteClient().get("https://www.google.com", params=params)
>>> response.url
>>> URL('https://www.google.com?foo=bar&foo=baz&one=25')
If omitted, no query string parameters will be appended to the url for issued requests.
:param headers: (Optional) Mapping of data to be send in the request headers. Headers should be a mapping
of either strings or bytes. By default all restpite issued requests will add their own user agent header
in order to easily distinguish requests generated via the library.
If omitted, no user defined request headers will be sent with issued requests.
:param cookies: (Optional) Mapping of Cookie items to send when issuing requests. Cookies can be either
:class:`http.cookiejar.CookieJar` or a Mapping[str, str]. When using a client instance, cookies set by a
remote server will be persisted across subsequent requests. It is advised to pass cookies= during client
instantiation, rather than on an individual request basis.
If omitted, no user defined cookies will be sent with the issued requests.
:param verify: (Optional) string, boolean or :class:`ssl.SSLContext`. By default Restpite will use the
CA bundle provided by the `Certifi` package. In the event you would like to specify a different bundle
CA, verify should be passed the string path (or using an instance of SSLContext instead). In order to
completely disable SSL verification (not advised - tho sometimes necessary and useful in a development
or testing environment). verify=False can be passed on the client.
If omitted, Certifi CA bundle will be assumed.
:param cert: (Optional) A :class:`restpite.CertificateInfo` instance containing data for the SSL
Certificate used by the requested host. CertificateInfo instances can contain either:
* The path to the SSL certificate file (certfile=)
* The path to the SSL certificate file (certfile=) and the path to a key file (keyfile=)
* The path to both SSL certificate & key file, along with a password (password=)
if omitted, The Certifi CA bundle will be assumed.
:param http1: If specified as True, the client will communicate over the HTTP/1 protocol.
:param http2: If specified as True, data will be transfered using the new HTTP/2 binary
format rather than the traditional HTTP/1 text format. HTTP/2 requires a single TCP
connection to handle multiple concurrent requests. To better understand the changes
and performance improvements of HTTP/2: https://http2-explained.haxx.se/content/en/
If http1=True & http2=True is provided to the client, http2 will be used. By default
:class:`RestpiteClient` will communicate using the more common HTTP/1 protocol however
the option is there if you wish to explore it.
:param proxies: (Optional) :class:`Dict` mapping proxy keys to proxy URLs. SOCKS proxies
are not yet supported. Also permits a single string such as "http://localhost:8030". An
example for multiple proxies would be:
```python
>>> proxies = {"http": "http://localhost:8030", "https": "http://localhost:8031"}
>>> RestpiteClient(proxies=proxies)
```
Note: The example above is not a typo; https traffic should likely route through http
as far as the proxy is concerned, while some may support HTTPS most proxies will
only support HTTP.
For more fine grained proxy control, passing an instance of :class:`httpx.Proxy` can
allow you to control things like Tunnelling vs Forwarding. By default forwarding
will be used for HTTP requests and tunneling for https requests.
:param mounts: Transports can be mounted against given domains or schemas, in order to control which
transport dispatched requests should be routed through, a mounts mapping can be provided which follows
the same style used for proxy routing.
:param timeout: :class:`restpite.Timeout`: Unlike when using requests, by default all requests sent will assume
sensible default timeouts (10.00), these are configurable on an individual basis.
* connect -> How long we will attempt to wait for a success socket connection to be established to the server.
* read -> How long we will wait for a chunk of data to be received from the remote server to the client.
* write -> How long we will wait for a chunk of data to be sent from the client to the server.
* pool -> How long we are willing to wait for a connection from the connection pool to be acquired.
By default, Restpite will assume a `10.0` second timeout across the board, this is configurable on:
* A per request basis: `client.get(timeout=...)`
* On a client directly and applied to all subsequent requests: `RestpiteClient(timeout...)`
It is possible to completely remove timeouts across the board by setting the timeout= parameter explicitly
to `None`. This will prevent exceptions for all timeouts specified above during request dispatch workflow.
Through a number of environment variables, these can be globally configured, for each of the 4 potential
timeouts, an equivalent env var can be set:
* RESTPITE_CONNECT_TIMEOUT
* RESTPITE_READ_TIMEOUT
* RESTPITE_WRITE_TIMEOUT
* RESTPITE_POOL_TIMEOUT
:param connection: A :class:`restpite.Connection` instance to dictate connection pooling limitations. Connection
controls both the max_keepalive connections (default: 20) and the maximum_connections for the client
(default: 100). A :class:`Tuple` of length two is also permitted where index 0 will control the max_keepalive
and index 1 the maximum_connections
* connection is only available on the client.
:param max_redirects: The maximum number of response redirects that should be followed, defaults to 20.
:param event_hooks: (Optional) mutable mapping of callable objects, callbacks when particular events
occur. Note: If using an asynchronous client the callables should be coroutines.
:param transport: (Optional) transport class to use for sending eequests over the network.
:param app: (Optional) WSGI Server to dispatch requests too, rather than sending actual
network requests.
:param trust_env: A :class:`bool` to control if the the `.netrc` can be used to read for
basic auth credentials. This is `True` by default, in the event that `trust_env=True`
and there are no `Authorization` HTTP Headers then Basic Auth will be used after reading
credentials from the file.
:param user_agent: (Optional) a :class:`str` to set the user agent HTTP header on all requests
dispatched by the client. By default if omitted, restpite will create it's own user agent header
which includes the restpite version:
* `python-restpite/{restpite_version}`
:param event_subscribers: (Optional) user defined classes that implement the :class:`restpite.Notifyable`
protocol. A hooking mechanism used by restpite to notify observers at various stage(s) of the HTTP
communication process, allowing things like pre and post processing of requests and responses.
:param base_url: (Optional) A :class:`str` or :class:`restpite.URL` instance used when
instantiating a client to prefix all issued requests with that url. For example
for api testing you may have a base_url="dev.product.com", then subsequent
requests can specify only "/{api_version}/users" resulting in all issued requests
from the client talking to `dev.product.com/v1/users`.
"""
def __init__(
self,
*,
auth: typing.Optional[AUTH_TYPES] = None,
params: typing.Optional[QUERY_STRING_TYPES] = None,
headers: typing.Optional[HEADER_TYPES] = None,
cookies: typing.Optional[COOKIE_TYPES] = None,
verify: typing.Optional[VERIFY_TYPES] = True,
cert: typing.Optional[CERTIFICATE_TYPES] = None,
http1: bool = True,
http2: bool = False,
proxies: typing.Optional[PROXY_TYPES] = None,
mounts: typing.Optional[MOUNT_TYPES] = None,
timeout: typing.Optional[TIMEOUT_TYPES] = RESPITE_DEFAULT_TIMEOUT_CONFIG,
connection: typing.Optional[LIMIT_TYPES] = RESTPITE_DEFAULT_LIMITS,
max_redirects: int = RESTPITE_DEFAULT_MAX_REDIRECTS,
event_hooks: typing.Optional[EVENT_HOOKS_TYPES] = None,
base_url: URL_TYPES = "",
transport: typing.Optional[TRANSPORT_TYPES] = None,
app: typing.Optional[typing.Callable[[typing.Any], typing.Any]] = None,
trust_env: bool = True,
user_agent: typing.Optional[str] = None,
event_subscribers: typing.Optional[EVENT_SUBSCRIBERS_TYPES] = None,
) -> None:
# Handle the case of a user provided user-agent header, defaulting to the restpite version otherwise
headers = {} if headers is None else headers
headers.update({"user-agent": user_agent or f"python-restpite/{__version__}"})
# Build a `Connection` instance if a tuple is provided.
connection = Connection.from_tuple(connection) if isinstance(connection, tuple) else connection
self.client = httpx.Client(
auth=auth,
params=params,
headers=headers,
cookies=cookies,
verify=verify,
cert=cert,
http1=http1,
http2=http2,
proxies=proxies,
mounts=mounts,
timeout=timeout,
limits=connection,
max_redirects=max_redirects,
event_hooks=event_hooks,
base_url=base_url,
transport=transport,
app=app,
trust_env=trust_env,
)
self.event_subscribers = event_subscribers or []
self.event_dispatcher = HandlerDispatcher()
# Register subscribers
for subscriber in self.event_subscribers:
self.event_dispatcher.subscribe(subscriber)
@property
def headers(self) -> HEADER_TYPES:
return self.client.headers # type: ignore[no-any-return]
@headers.setter
def headers(self, headers: HEADER_TYPES) -> None:
self.headers = headers
@property
def cookies(self) -> COOKIE_TYPES:
return self.client.cookies # type: ignore[no-any-return]
@cookies.setter
def cookies(self, cookies: COOKIE_TYPES) -> None:
self.cookies = cookies
@property
def params(self) -> QUERY_STRING_TYPES:
return self.client.params # type: ignore[no-any-return]
@params.setter
def params(self, params: QUERY_STRING_TYPES) -> None:
self.params = params
@property
def user_agent(self) -> typing.Any:
return self.headers["user-agent"]
def __getattr__(self, item: str) -> typing.Any:
"""
Handle delegation to the underlying httpx client. If client code has attempted to access
a RestpiteClient attribute that doesn't exist, __getattribute__ would raise an `AttributeError`
causing this __getattr__ to be invoked. Firstly lets check if the attr even exists on the
delegated client instance, if not raise the AttributeError as normal. Otherwise return the
attribute or callable method if the attr is callable.
"""
attr = getattr(self.client, item)
if not callable(attr):
return attr
def wrapper(*args, **kwargs):
return attr(*args, **kwargs)
return wrapper
def __enter__(self) -> RestpiteClient:
return self
def __exit__(
self,
exc_type: typing.Optional[typing.Type[BaseException]] = None,
exc_value: typing.Optional[BaseException] = None,
traceback: typing.Optional[types.TracebackType] = None,
) -> None:
self.client.__exit__(exc_type, exc_value, traceback)
[docs] def request(
self,
method: str,
url: URL_TYPES,
*,
content: typing.Optional[CONTENT_TYPES] = None,
data: typing.Optional[DATA_TYPES] = None,
files: typing.Optional[FILES_TYPES] = None,
json: typing.Optional[typing.Any] = None,
params: typing.Optional[QUERY_STRING_TYPES] = None,
headers: typing.Optional[HEADER_TYPES] = None,
cookies: typing.Optional[COOKIE_TYPES] = None,
auth: AUTH_TYPES = None,
allow_redirects: bool = True,
timeout: TIMEOUT_TYPES = RESPITE_DEFAULT_TIMEOUT_CONFIG,
) -> RestpiteResponse:
"""
Responsible for managing the actual HTTP Request from request -> Response
# TODO: Understand these types (args)
# TODO: Understand the proper flow of the traffic through the underlying httpx library
# TODO: Dispatching hooks mechanism around some of this
# TODO: Hooks for raw request sending, raw response received, post RestpiteResponse, post RequestRequest
# TODO: handlers = dispatching hook / calls to client code, adapter = transport adapters of requests
# TODO: hooks need dispatched here multiple times, Hooks need invoked as well to permit control!
# TODO: Built in capturing of all traffic, thinking simple `restpite.json` (configurable on|off) ?
# TODO: !!! - Investigate auth `UNSET` != `NoneType`
"""
request_keyword = {
"method": method,
"url": url,
"content": content,
"data": data,
"files": files,
"json": json,
"params": params,
"headers": headers,
"cookies": cookies,
}
send_kw = {"auth": auth, "allow_redirects": allow_redirects, "timeout": timeout}
try:
# TODO: This is just... event_hooks= but in a round about way
httpx_request = self.client.build_request(**request_keyword)
self.event_dispatcher.dispatch("before_sending_request", request=httpx_request, **request_keyword)
response = RestpiteResponse(self.client.send(request=httpx_request, **send_kw))
self.event_dispatcher.dispatch("after_receiving_response", response)
return response
except httpx.HTTPError as exc:
# TODO: Too broad!
self.event_dispatcher.dispatch("on_exception", exc)
raise exc from None
# TODO - Implement Get properly
[docs] def get(self, url: URL_TYPES, *args, **kwargs) -> RestpiteResponse:
"""
Issue a HTTP GET request
"""
return self.request(GET, url, *args, **kwargs)