""" Client class. Base class for the clients. """ import logging from abc import ABCMeta, abstractmethod from json import dumps as serialize from json import loads as deserialize from typing import Any, Callable, Dict, Iterator, List, Optional, Union from apply_defaults import apply_config, apply_self # type: ignore from .config import config from .exceptions import ReceivedErrorResponseError from .log import log_ from .parse import parse from .requests import Notification, Request from .response import ErrorResponse, Response request_log = logging.getLogger(__name__ + ".request") response_log = logging.getLogger(__name__ + ".response") class Client(metaclass=ABCMeta): """ Protocol-agnostic base class for clients. Subclasses must override `send_message` to transport the message. """ DEFAULT_REQUEST_LOG_FORMAT = "--> %(message)s" DEFAULT_RESPONSE_LOG_FORMAT = "<-- %(message)s" @apply_config(config, converters={"id_generator": "getcallable"}) def __init__( self, trim_log_values: bool = False, validate_against_schema: bool = True, id_generator: Optional[Iterator] = None, basic_logging: bool = False, ) -> None: """ Args: trim_log_values: Abbreviate the log entries of requests and responses. validate_against_schema: Validate response against the JSON-RPC schema. id_generator: Iterable of values to use as the "id" part of the request. basic_logging: Will create log handlers to output request & response messages. """ self.trim_log_values = trim_log_values self.validate_against_schema = validate_against_schema self.id_generator = id_generator if basic_logging: self.basic_logging() def basic_logging(self) -> None: """ Call this on the client object to create log handlers to output request and response messages. """ # Request handler if len(request_log.handlers) == 0: request_handler = logging.StreamHandler() request_handler.setFormatter( logging.Formatter(fmt=self.DEFAULT_REQUEST_LOG_FORMAT) ) request_log.addHandler(request_handler) request_log.setLevel(logging.INFO) # Response handler if len(response_log.handlers) == 0: response_handler = logging.StreamHandler() response_handler.setFormatter( logging.Formatter(fmt=self.DEFAULT_RESPONSE_LOG_FORMAT) ) response_log.addHandler(response_handler) response_log.setLevel(logging.INFO) @apply_self def log_request( self, request: str, trim_log_values: bool = False, **kwargs: Any ) -> None: """ Log a request. Args: request: The JSON-RPC request string. trim_log_values: Log an abbreviated version of the request. """ return log_(request, request_log, "info", trim=trim_log_values, **kwargs) @apply_self def log_response( self, response: Response, trim_log_values: bool = False, **kwargs: Any ) -> None: """ Log a response. Note this is different to log_request, in that it takes a Response object, not a string. Args: response: The Response object to log. Note this is different to log_request which takes a string. trim_log_values: Log an abbreviated version of the response. """ return log_(response.text, response_log, "info", trim=trim_log_values, **kwargs) @abstractmethod def send_message( self, request: str, response_expected: bool, **kwargs: Any ) -> Response: """ Transport the message to the server and return the response. Args: request: The JSON-RPC request string. response_expected: Whether the request expects a response. Returns: A Response object. """ def validate_response(self, response: Response) -> None: """ Can be overridden for custom validation of the response. Raise an exception to fail validation. """ pass @apply_self def send( self, request: Union[str, Dict, List], trim_log_values: bool = False, validate_against_schema: bool = True, **kwargs: Any ) -> Response: """ Send a request, passing the whole JSON-RPC request object. After sending, logs, validates and parses. >>> client.send('{"jsonrpc": "2.0", "method": "ping", "id": 1}') <Response[1]> Args: request: The JSON-RPC request. Can be either a JSON-encoded string or a Request/Notification object. trim_log_values: Abbreviate the log entries of requests and responses. validate_against_schema: Validate response against the JSON-RPC schema. kwargs: Clients can use this to configure an single request. For example, HTTPClient passes this through to `requests.Session.send()`. in the case of a Notification. """ # We need both the serialized and deserialized version of the request if isinstance(request, str): request_text = request request_deserialized = deserialize(request) else: request_text = serialize(request) request_deserialized = request batch = isinstance(request_deserialized, list) response_expected = batch or "id" in request_deserialized self.log_request(request_text, trim_log_values=trim_log_values) response = self.send_message( request_text, response_expected=response_expected, **kwargs ) self.log_response(response, trim_log_values=trim_log_values) self.validate_response(response) response.data = parse( response.text, batch=batch, validate_against_schema=validate_against_schema ) # If received a single error response, raise if isinstance(response.data, ErrorResponse): raise ReceivedErrorResponseError(response.data) return response @apply_self def notify( self, method_name: str, *args: Any, trim_log_values: Optional[bool] = None, validate_against_schema: Optional[bool] = None, **kwargs: Any ) -> Response: """ Send a JSON-RPC request, without expecting a response. Args: method_name: The remote procedure's method name. args: Positional arguments passed to the remote procedure. kwargs: Keyword arguments passed to the remote procedure. trim_log_values: Abbreviate the log entries of requests and responses. validate_against_schema: Validate response against the JSON-RPC schema. """ return self.send( Notification(method_name, *args, **kwargs), trim_log_values=trim_log_values, validate_against_schema=validate_against_schema, ) @apply_self def request( self, method_name: str, *args: Any, trim_log_values: bool = False, validate_against_schema: bool = True, id_generator: Optional[Iterator] = None, **kwargs: Any ) -> Response: """ Send a request by passing the method and arguments. >>> client.request("cat", name="Yoko") <Response[1] Args: method_name: The remote procedure's method name. args: Positional arguments passed to the remote procedure. kwargs: Keyword arguments passed to the remote procedure. trim_log_values: Abbreviate the log entries of requests and responses. validate_against_schema: Validate response against the JSON-RPC schema. id_generator: Iterable of values to use as the "id" part of the request. """ return self.send( Request(method_name, id_generator=id_generator, *args, **kwargs), trim_log_values=trim_log_values, validate_against_schema=validate_against_schema, ) def __getattr__(self, name: str) -> Callable: """ This gives us an alternate way to make a request. >>> client.cube(3) --> {"jsonrpc": "2.0", "method": "cube", "params": [3], "id": 1} That's the same as saying `client.request("cube", 3)`. """ def attr_handler(*args: Any, **kwargs: Any) -> Response: return self.request(name, *args, **kwargs) return attr_handler