Source code for restpite._client

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)