From 6f28fbee0266d53a3de60407207b3d72771e9cb0 Mon Sep 17 00:00:00 2001 From: bensynapse <118375461+bensynapse@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:15:46 +0300 Subject: [PATCH] Docstrings for every public name, and clearer messages for two mistakes The docs site will get an API reference built from the docstrings. So every public function and class now has a full one, with arguments, return values, exceptions, warnings and a tested example. Success, Error and the three async dispatch functions had none. The module docstrings that named a function that doesn't exist, or showed a method returning a plain value, are fixed. A method that returns a plain value, as 4.x methods did, now logs one line. It says to return Success(value) or Error(code, message), and links to the migration guide. Before, the log showed a traceback into jsonrpcserver and the client got a bare Internal error, so there was nothing to go on. The response is the same as before. With debug=True its data has the same hint. serve() now says where it's listening when it starts. With the default host it says that it accepts connections on every network interface. The line goes to the jsonrpcserver.server logger, and to stderr when logging isn't configured. It still writes nothing when sys.stderr is None (#269). tests/test_docstrings.py runs the docstring examples and checks that every name in __all__ has a docstring. --- CHANGELOG.md | 10 ++ jsonrpcserver/async_dispatcher.py | 5 + jsonrpcserver/async_main.py | 85 ++++++++++++++++- jsonrpcserver/codes.py | 14 ++- jsonrpcserver/dispatcher.py | 41 +++++++- jsonrpcserver/exceptions.py | 26 ++++- jsonrpcserver/main.py | 151 +++++++++++++++++++++--------- jsonrpcserver/methods.py | 58 ++++++++---- jsonrpcserver/response.py | 54 ++++++++--- jsonrpcserver/result.py | 90 ++++++++++++++++-- jsonrpcserver/sentinels.py | 3 + jsonrpcserver/server.py | 53 ++++++++++- tests/test_async_dispatcher.py | 18 ++++ tests/test_dispatcher.py | 32 ++++++- tests/test_docstrings.py | 31 ++++++ tests/test_server.py | 51 +++++++++- 16 files changed, 620 insertions(+), 102 deletions(-) create mode 100644 tests/test_docstrings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa892d..50164a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,16 @@ Errors you return on purpose, with `Error`, `InvalidParams` or by raising clients can't parse anything else. The response is still sent as before. - `@method` gives a `UserWarning` for a name that starts with `rpc.`, because the spec reserves those names. +- A method that returns a plain value instead of `Success(value)` is a common + mistake when upgrading from 4.x. It's now logged with a message that says + what to change, instead of a traceback. The client still gets an Internal + error. +- `serve()` says where it's listening when it starts, including a note when it + listens on every network interface (the default). It logs the line on the + `jsonrpcserver.server` logger, and writes it to stderr if logging isn't + configured. +- Every public function and class has a full docstring, which the new API + reference on the docs site is built from. ### Behaviour changes diff --git a/jsonrpcserver/async_dispatcher.py b/jsonrpcserver/async_dispatcher.py index b5f3589..995a970 100644 --- a/jsonrpcserver/async_dispatcher.py +++ b/jsonrpcserver/async_dispatcher.py @@ -13,6 +13,7 @@ NORESPONSE, BatchTooLargeResponse, Deserialized, + InvalidResultError, create_request, deserialize_request, exception_data, @@ -20,6 +21,7 @@ extract_kwargs, extract_list, get_method, + log_invalid_result, member_id, not_notification, to_response, @@ -52,6 +54,9 @@ async def call( validate_result(result) except JsonRpcError as exc: return Left(ErrorResult(code=exc.code, message=exc.message, data=exc.data)) + except InvalidResultError as exc: + log_invalid_result(request.method, exc, logger) + return Left(InternalErrorResult(exception_data(exc, debug))) except Exception as exc: # Other error inside method - Internal error logger.exception("Method %r raised an exception", request.method) diff --git a/jsonrpcserver/async_main.py b/jsonrpcserver/async_main.py index 306ee33..9d3fedd 100644 --- a/jsonrpcserver/async_main.py +++ b/jsonrpcserver/async_main.py @@ -1,4 +1,10 @@ -"""Async version of main.py. The public async functions.""" +"""The async dispatch functions, for asyncio servers. + +They take the same arguments as the functions in main.py, and are imported from +the package as async_dispatch, async_dispatch_to_serializable and +async_dispatch_to_response. Methods can be async functions or plain ones. The +requests in a batch run concurrently. +""" from typing import Any, Callable, Dict, Iterable, List, Optional, Union, cast @@ -30,6 +36,30 @@ async def dispatch_to_response( debug: bool = False, max_batch_size: Optional[int] = None, ) -> Union[Response, Iterable[Response], None]: + """Dispatch a request and give the response as Response objects. Async. + + The async version of `dispatch_to_response`, imported from the package as + `async_dispatch_to_response`. It takes the same arguments. + + Args: + request: The JSON-RPC request string. + methods: The same as for `dispatch_to_response`. + context: The same as for `dispatch_to_response`. + deserializer: The same as for `dispatch_to_response`. + validator: The same as for `dispatch_to_response`. + post_process: The same as for `dispatch_to_response`. + debug: The same as for `dispatch_to_response`. + max_batch_size: The same as for `dispatch_to_response`. Every request in a + batch runs at the same time, so a limit matters even more here. + + Returns: + A Response for a single request, a list of Responses for a batch, or None + if there's nothing to send back. The type hint says Iterable for a + batch, but it's a list. + + Raises: + ValueError: If `max_batch_size` isn't None or a positive int. + """ check_max_batch_size(max_batch_size) response = await dispatch_to_response_pure( deserializer=deserializer, @@ -54,6 +84,27 @@ async def dispatch_to_serializable( debug: bool = False, max_batch_size: Optional[int] = None, ) -> Union[Dict[str, Any], List[Dict[str, Any]], None]: + """Dispatch a request and give the response as a dict. Async. + + The async version of `dispatch_to_serializable`, imported from the package as + `async_dispatch_to_serializable`. + + Args: + request: The JSON-RPC request string. + methods: The same as for `dispatch_to_response`. + context: The same as for `dispatch_to_response`. + deserializer: The same as for `dispatch_to_response`. + validator: The same as for `dispatch_to_response`. + debug: The same as for `dispatch_to_response`. + max_batch_size: The same as for `dispatch_to_response`. + + Returns: + The response as a dict, a list of dicts for a batch, or None if there's + nothing to send back. + + Raises: + ValueError: If `max_batch_size` isn't None or a positive int. + """ return cast( Union[Dict[str, Any], List[Dict[str, Any]], None], await dispatch_to_response( @@ -82,6 +133,37 @@ async def dispatch_to_json( [Union[Dict[str, Any], List[Dict[str, Any]], None]], str ] = default_serializer, ) -> str: + """Dispatch a request and give the response as a JSON string. Async. + + This is `async_dispatch`, the async version of `dispatch`. Methods can be async + functions or plain ones (plain ones work from 5.0.10). A plain method runs on + the event loop, so a slow one holds up every other request. + + Args: + request: The JSON-RPC request string. + methods: The same as for `dispatch_to_response`. + context: The same as for `dispatch_to_response`. + deserializer: The same as for `dispatch_to_response`. + validator: The same as for `dispatch_to_response`. + debug: The same as for `dispatch_to_response`. + max_batch_size: The same as for `dispatch_to_response`. + serializer: The same as for `dispatch`. + + Returns: + The response as a JSON string, or "" if there's nothing to send back. + + Raises: + ValueError: If `max_batch_size` isn't None or a positive int. + + Example: + >>> import asyncio + >>> from jsonrpcserver import Result, Success, async_dispatch + >>> async def ping() -> Result: + ... return Success("pong") + >>> request = '{"jsonrpc": "2.0", "method": "ping", "id": 1}' + >>> asyncio.run(async_dispatch(request, {"ping": ping})) + '{"jsonrpc": "2.0", "result": "pong", "id": 1}' + """ response = await dispatch_to_serializable( request, methods, @@ -95,3 +177,4 @@ async def dispatch_to_json( dispatch = dispatch_to_json +"""Another name for `dispatch_to_json`. The package exports it as `async_dispatch`.""" diff --git a/jsonrpcserver/codes.py b/jsonrpcserver/codes.py index 6634f63..76aad52 100644 --- a/jsonrpcserver/codes.py +++ b/jsonrpcserver/codes.py @@ -1,8 +1,20 @@ -"""JSONRPC error codes from http://www.jsonrpc.org/specification#error_object""" +"""The error codes jsonrpcserver sends. + +The first five are defined by the spec: +https://www.jsonrpc.org/specification#error_object +""" ERROR_PARSE_ERROR = -32700 +"""The request isn't valid JSON (or the deserializer raised).""" ERROR_INVALID_REQUEST = -32600 +"""The JSON isn't a valid request object, or a batch is empty or too big.""" ERROR_METHOD_NOT_FOUND = -32601 +"""No method has that name.""" ERROR_INVALID_PARAMS = -32602 +"""The params don't fit the method's signature, or the method said they're invalid.""" ERROR_INTERNAL_ERROR = -32603 +"""The method raised, returned something other than a Result, or its result +could not be serialized. +""" ERROR_SERVER_ERROR = -32000 +"""Something failed in jsonrpcserver itself, outside any method.""" diff --git a/jsonrpcserver/dispatcher.py b/jsonrpcserver/dispatcher.py index 2fe4e0e..b71b813 100644 --- a/jsonrpcserver/dispatcher.py +++ b/jsonrpcserver/dispatcher.py @@ -3,6 +3,7 @@ """ import logging +import reprlib from functools import partial from inspect import iscoroutine, signature from itertools import starmap @@ -112,23 +113,52 @@ def extract_kwargs(request: Request) -> Dict[str, Any]: return request.params if isinstance(request.params, dict) else {} +class InvalidResultError(AssertionError): + """A method returned something other than Success(...) or Error(...).""" + + def __init__(self, result: object): + super().__init__( + f"The method did not return a valid Result (returned {result!r}). " + "Return Success(value) or Error(code, message)." + ) + self.result = result + + def validate_result(result: object) -> None: """Validate the return value from a method. - Raises an AssertionError if the result returned from a method is invalid. + Raises an InvalidResultError, a subclass of AssertionError, if the result returned + from a method is invalid. Returns: None """ # Not an assert statement, because python -O would remove it. + returned = result # Not narrowed by the checks below. error: object = getattr(result, "_error", None) value: object = getattr(result, "_value", None) if not ( (isinstance(result, Left) and isinstance(error, ErrorResult)) or (isinstance(result, Right) and isinstance(value, SuccessResult)) ): - raise AssertionError( - f"The method did not return a valid Result (returned {result!r})" - ) + raise InvalidResultError(returned) + + +def log_invalid_result( + method_name: str, exc: InvalidResultError, log: logging.Logger +) -> None: + """Log a method that returned a plain value, which 4.x allowed, without a traceback. + + The traceback would only point into jsonrpcserver, so the message says what to + change instead. + """ + log.error( + "Method %r returned %s, which is not a Result, so the client got an " + "Internal error. Return Success(value) or Error(code, message). Since 5.0 a " + "plain return value is not enough: " + "https://bensynapse.github.io/jsonrpcserver/migration/", + method_name, + reprlib.repr(exc.result), + ) def exception_data(exc: BaseException, debug: bool) -> Any: @@ -166,6 +196,9 @@ def call( # response. except JsonRpcError as exc: return Left(ErrorResult(code=exc.code, message=exc.message, data=exc.data)) + except InvalidResultError as exc: + log_invalid_result(request.method, exc, logger) + return Left(InternalErrorResult(exception_data(exc, debug))) # Any other uncaught exception inside method - internal error. except Exception as exc: logger.exception("Method %r raised an exception", request.method) diff --git a/jsonrpcserver/exceptions.py b/jsonrpcserver/exceptions.py index 67b8426..06c59a8 100644 --- a/jsonrpcserver/exceptions.py +++ b/jsonrpcserver/exceptions.py @@ -1,4 +1,4 @@ -"""Exceptions""" +"""The exception a method can raise to send an error response.""" from typing import Any @@ -7,9 +7,27 @@ class JsonRpcError(Exception): - """A JsonRpcError exception can be raised from inside a method, as an alternate way - to return an error response. See - https://bensynapse.github.io/jsonrpcserver/methods/#results + """Raise it in a method, or in anything a method calls, to send an error response. + + It's the same as returning `Error(code, message, data)`, but it works from deep + inside other functions. Like `Error`, it's sent as it is, whatever `debug` is. + + Args: + code: The error code, an integer. + message: A short description of the error, as a string. + data: Extra information for the client. If it isn't given, the response has + no `data` member. + + Warns: + UserWarning: If `code` isn't an int or `message` isn't a str. New in 5.0.10. + + Example: + >>> from jsonrpcserver import Result, dispatch_to_serializable + >>> def withdraw(amount: int) -> Result: + ... raise JsonRpcError(2, "Insufficient funds", {"balance": 10}) + >>> request = '{"jsonrpc": "2.0", "method": "withdraw", "params": [5], "id": 1}' + >>> dispatch_to_serializable(request, {"withdraw": withdraw})["error"] + {'code': 2, 'message': 'Insufficient funds', 'data': {'balance': 10}} """ def __init__(self, code: int, message: str, data: Any = NODATA): diff --git a/jsonrpcserver/main.py b/jsonrpcserver/main.py index fade575..382071f 100644 --- a/jsonrpcserver/main.py +++ b/jsonrpcserver/main.py @@ -1,13 +1,13 @@ -"""The public functions. +"""The dispatch functions. -These three public functions all perform the same function of dispatching a JSON-RPC -request, but they each give a different return value. +All three take a JSON-RPC request string, call the methods and give the response. +They differ only in the form of the response: -- dispatch_to_responses: Returns Response(s) (or None for notifications). -- dispatch_to_serializable: Returns a Python dict or list of dicts (or None for - notifications). -- dispatch_to_json/dispatch: Returns a JSON-RPC response string (or an empty string for - notifications). +- dispatch_to_response gives Response objects, or None for a notification. +- dispatch_to_serializable gives a dict or a list of dicts, or None for a + notification. +- dispatch_to_json, also called dispatch, gives a JSON string, or an empty string + for a notification. """ import json @@ -127,40 +127,51 @@ def dispatch_to_response( debug: bool = False, max_batch_size: Optional[int] = None, ) -> Union[Response, List[Response], None]: - """Takes a JSON-RPC request string and dispatches it to method(s), giving Response - namedtuple(s) or None. + """Dispatch a request and give the response as Response objects. - This is a public wrapper around dispatch_to_response_pure, adding globals and - default values to be nicer for end users. + Most code wants `dispatch` (a JSON string) or `dispatch_to_serializable` (dicts) + instead. Use this one to inspect or change responses before they're serialized. + + Each Response is an oslash `Right` holding a `SuccessResponse`, or a `Left` + holding an `ErrorResponse`. oslash has no public way to read them, so check + `isinstance(response, Left)` and read `response._error` or `response._value`. + These attributes are stable for all of 5.x. Printing a Response raises + `TypeError`, because of a bug in oslash; print `to_dict(response)` instead. Args: request: The JSON-RPC request string. - methods: Dictionary of methods that can be called - mapping of function names to - functions. If not passed, uses the internal global_methods dict which is - populated with the @method decorator. - context: If given, will be passed as the first argument to methods. - deserializer: Function that deserializes the request string. - validator: Function that validates the JSON-RPC request. The function should - raise an exception if the request is invalid. Batch members are validated - individually, with nested arrays rejected before calling the validator. - To disable validation, pass lambda _: None. - post_process: Function that will be applied to Responses. - debug: If True, the error response for an uncaught exception in a method - includes the exception message in "data". The default leaves "data" out, - because exception messages can contain passwords, file paths and other - details a client shouldn't see. Only turn this on in development. The - exception is logged either way. + methods: The methods requests can call, as a dict (or any mapping) of names + to functions. The default is the dict that `@method` fills in, + `jsonrpcserver.methods.global_methods`. + context: If given, it's passed as the first argument to every method. The + client can't see or set it. + deserializer: The function that parses the request string. The default is + `json.loads`. If it raises, the client gets a -32700 Parse error whose + `data` is the exception message. + validator: The function that checks a parsed request against the JSON-RPC + spec. It should raise an exception, of any kind, if the request is + invalid. In a batch it's called once for each request (new in 5.0.10). + The default checks against a JSON schema. `lambda _: None` turns + validation off. + post_process: A function applied to each Response before it's returned. + debug: If True, the error response for an exception a method doesn't catch + includes the exception message in `data`. The default leaves `data` + out, because exception messages can contain passwords, file paths and + other details a client shouldn't see. The exception is logged either + way. New in 5.0.10. max_batch_size: The most requests a batch may hold. A bigger batch gets a - single Invalid request response and none of it is dispatched. The default, - None, means no limit. Every member costs validation time, so a server - open to the internet should set one, such as 100. + single -32600 Invalid request response, and none of it is run. The + default, None, means no limit. A server open to the internet should set + one, such as 100. New in 5.0.10. Returns: - A Response, list of Responses or None. + A Response for a single request, a list of Responses for a batch, or None + if there's nothing to send back (a notification, or a batch of only + notifications). With `post_process`, whatever it returns for each. - Examples: - >>> dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}') - '{"jsonrpc": "2.0", "result": "pong", "id": 1}' + Raises: + ValueError: If `max_batch_size` isn't None or a positive int. Requests never + raise: a bad request or a failing method gives an error response. """ check_max_batch_size(max_batch_size) response = dispatch_to_response_pure( @@ -186,10 +197,35 @@ def dispatch_to_serializable( debug: bool = False, max_batch_size: Optional[int] = None, ) -> Union[Dict[str, Any], List[Dict[str, Any]], None]: - """Takes a JSON-RPC request string and dispatches it to method(s), giving responses - as dicts (or None). + """Dispatch a request and give the response as a dict. + + Use it when your framework serializes the response itself, such as a Django + `JsonResponse`, or when you want to inspect it. + + Args: + request: The JSON-RPC request string. + methods: The same as for `dispatch_to_response`. + context: The same as for `dispatch_to_response`. + deserializer: The same as for `dispatch_to_response`. + validator: The same as for `dispatch_to_response`. + debug: The same as for `dispatch_to_response`. + max_batch_size: The same as for `dispatch_to_response`. - The arguments are the same as dispatch_to_response, apart from post_process. + Returns: + The response as a dict, a list of dicts for a batch, or None if there's + nothing to send back. + + Raises: + ValueError: If `max_batch_size` isn't None or a positive int. + + Example: + >>> from jsonrpcserver import Result, Success, dispatch_to_serializable + >>> def ping() -> Result: + ... return Success("pong") + >>> dispatch_to_serializable( + ... '{"jsonrpc": "2.0", "method": "ping", "id": 1}', methods={"ping": ping} + ... ) + {'jsonrpc': '2.0', 'result': 'pong', 'id': 1} """ return cast( Union[Dict[str, Any], List[Dict[str, Any]], None], @@ -219,17 +255,40 @@ def dispatch_to_json( [Union[Dict[str, Any], List[Dict[str, Any]], str]], str ] = default_serializer, ) -> str: - """Takes a JSON-RPC request string and dispatches it to method(s), giving a JSON-RPC - response string. + """Dispatch a request and give the response as a JSON string. - This is the main public method, it goes through the entire JSON-RPC process - it's a - function from JSON-RPC request string to JSON-RPC response string. + This is `dispatch`, the function most code uses. Send the string back to the + client. An empty string means there's nothing to send back: the request was a + notification, or a batch of only notifications. Over HTTP, send status 204 with + no body for that. Args: - serializer: A function to serialize a Python object to json. The default is - json.dumps with allow_nan=False. If it raises for a response (say the - method returned a datetime), that response becomes an Internal error. - The rest: The same as dispatch_to_response. + request: The JSON-RPC request string. + methods: The same as for `dispatch_to_response`. + context: The same as for `dispatch_to_response`. + deserializer: The same as for `dispatch_to_response`. + validator: The same as for `dispatch_to_response`. + debug: The same as for `dispatch_to_response`. + max_batch_size: The same as for `dispatch_to_response`. + serializer: The function that turns the response into a string. The + default is `json.dumps` with `allow_nan=False`, so a result holding NaN + or Infinity gives an Internal error instead of invalid JSON (new in + 5.0.10). If the serializer raises for a response, say because the + method returned a `datetime`, that response becomes an Internal error + and the rest of a batch is sent as usual. + + Returns: + The response as a JSON string, or "" if there's nothing to send back. + + Raises: + ValueError: If `max_batch_size` isn't None or a positive int. + + Example: + >>> from jsonrpcserver import Result, Success, dispatch + >>> def ping() -> Result: + ... return Success("pong") + >>> dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', {"ping": ping}) + '{"jsonrpc": "2.0", "result": "pong", "id": 1}' """ response = dispatch_to_serializable( request, @@ -245,5 +304,5 @@ def dispatch_to_json( return "" if response is None else serialize(serializer, response, debug) -# "dispatch" aliases dispatch_to_json. dispatch = dispatch_to_json +"""Another name for `dispatch_to_json`, and the one most code uses.""" diff --git a/jsonrpcserver/methods.py b/jsonrpcserver/methods.py index dffbfee..4254ab2 100644 --- a/jsonrpcserver/methods.py +++ b/jsonrpcserver/methods.py @@ -1,15 +1,16 @@ -"""A method is a Python function that can be called by a JSON-RPC request. +"""Methods: the functions a JSON-RPC request can call. -They're held in a dict, a mapping of function names to functions. +The dispatch functions look methods up in a dict of names to functions. The @method +decorator adds a function to global_methods, the dict they use by default. To use +your own dict instead, pass it as the methods argument: -The @method decorator adds a method to jsonrpcserver's internal global_methods dict. -Alternatively pass your own dictionary of methods to `dispatch` with the methods param. + dispatch(request) # the functions registered with @method + dispatch(request, methods={"ping": ping}) # only the functions in this dict - >>> dispatch(request) # Uses the internal collection of funcs added with @method - >>> dispatch(request, methods={"ping": lambda: "pong"}) # Custom collection +Either way, a method returns Success(...) or Error(...), not a plain value. -Methods can take either positional or named arguments, but not both. This is a -limitation of JSON-RPC. +A request's params are either a list (positional arguments) or an object (named +arguments), not both. That's a JSON-RPC rule. """ import warnings @@ -26,6 +27,11 @@ MethodsArgument = Mapping[str, AnyMethod] global_methods: Dict[str, AnyMethod] = {} +"""The methods registered with `@method`, as a dict of names to functions. + +The dispatch functions use it when they're called without `methods`. It's shared by +the whole process, so every module that uses `@method` adds to it. +""" F = TypeVar("F", bound=Callable[..., Any]) @@ -39,18 +45,36 @@ def method(f: None = None, name: Optional[str] = None) -> Callable[[F], F]: ... def method(f: Optional[F] = None, name: Optional[str] = None) -> Any: - """A decorator to add a function into jsonrpcserver's internal global_methods dict. - The global_methods dict will be used by default unless a methods argument is passed - to `dispatch`. + """Register a function as a JSON-RPC method. + + The function is added to `global_methods`, which the dispatch functions use when + they're called without `methods`. It's returned unchanged, so you can still call + it yourself, and type checkers see its real signature (from 5.0.10). A method + with the same name as an earlier one replaces it, without a warning. + + Use it with or without arguments: + + ```python + @method + def ping() -> Result: + return Success("pong") + + @method(name="sum") + def add(a: int, b: int) -> Result: + return Success(a + b) + ``` - Functions can be renamed by passing a name argument: + Args: + f: The function. Leave it out to pass `name`. + name: The name requests use to call the method. The default is the + function's name. - @method(name="bar") - def foo(): - ... + Returns: + The function itself, or, when called with only `name`, a decorator. - The decorated function is returned unchanged, so type checkers still see its real - signature. A method with the same name as an earlier one replaces it. + Warns: + UserWarning: If the name starts with "rpc.". The JSON-RPC spec reserves those + names. New in 5.0.10. """ def decorator(func: F) -> F: diff --git a/jsonrpcserver/response.py b/jsonrpcserver/response.py index 694b891..c93c9a3 100644 --- a/jsonrpcserver/response.py +++ b/jsonrpcserver/response.py @@ -1,6 +1,7 @@ -"""The response data types. +"""Responses: what dispatch_to_response gives, and how to turn them into dicts. -https://www.jsonrpc.org/specification#response_object +A Response is a Result plus the request's id +(https://www.jsonrpc.org/specification#response_object). """ import warnings @@ -20,17 +21,17 @@ class SuccessResponse(NamedTuple): - """It would be nice to subclass Success here, adding only id. But it's not possible - to easily subclass NamedTuples in Python 3.6. (I believe it can be done in 3.8.) - """ + """A successful response: the method's result and the request's id.""" result: Any id: Any class ErrorResponse(NamedTuple): - """It would be nice to subclass Error here, adding only id. But it's not possible to - easily subclass NamedTuples in Python 3.6. (I believe it can be done in 3.8.) + """An error response: the error's code, message and data, and the request's id. + + `data` is `NODATA` when there's no data, so it's left out of the response. `id` + is None when the request couldn't be read, as the spec requires. """ code: int @@ -41,8 +42,16 @@ class ErrorResponse(NamedTuple): # oslash's Either takes the success type first, then the error type. Response = Either[SuccessResponse, ErrorResponse] -# Kept for backward compatibility. Use Response. +"""What `dispatch_to_response` gives for each request. + +An oslash `Either`: a `Right` holding a `SuccessResponse`, or a `Left` holding an +`ErrorResponse`. To read one, check `isinstance(response, Left)`, then read +`response._error` (an `ErrorResponse`) or `response._value` (a `SuccessResponse`). +oslash has no public accessor, but these attributes are stable for all of 5.x. Or turn +it into a dict with `to_dict`. +""" ResponseType = Type[Response] +"""Deprecated: use `Response`. Kept so code written for 5.0.9 keeps working.""" def ParseErrorResponse(data: Any) -> ErrorResponse: # pylint: disable=invalid-name @@ -78,7 +87,7 @@ def ServerErrorResponse(data: Any, id: Any) -> ErrorResponse: def to_error_dict(response: ErrorResponse) -> Dict[str, Any]: - """From ErrorResponse object to dict""" + """Turn an ErrorResponse into a JSON-RPC response dict, leaving out missing data.""" return { "jsonrpc": "2.0", "error": { @@ -92,12 +101,27 @@ def to_error_dict(response: ErrorResponse) -> Dict[str, Any]: def to_success_dict(response: SuccessResponse) -> Dict[str, Any]: - """From SuccessResponse object to dict""" + """Turn a SuccessResponse into a JSON-RPC response dict.""" return {"jsonrpc": "2.0", "result": response.result, "id": response.id} def to_dict(response: Response) -> Dict[str, Any]: - """Serialize either an error or success response object to dict""" + """Turn a Response into a JSON-RPC response dict. + + Args: + response: A Response from `dispatch_to_response`. + + Returns: + The response as a dict, ready for `json.dumps`. + + Example: + >>> from jsonrpcserver import Result, Success, dispatch_to_response + >>> def ping() -> Result: + ... return Success("pong") + >>> request = '{"jsonrpc": "2.0", "method": "ping", "id": 1}' + >>> to_dict(dispatch_to_response(request, {"ping": ping})) + {'jsonrpc': '2.0', 'result': 'pong', 'id': 1} + """ if isinstance(response, Left): return to_error_dict(response._error) success = cast("Right[SuccessResponse, ErrorResponse]", response) @@ -107,7 +131,7 @@ def to_dict(response: Response) -> Dict[str, Any]: def to_serializable( response: Union[Response, List[Response], None], ) -> Union[Deserialized, None]: - """Serialize a response object (or list of them), to a dict, or list of them.""" + """Turn a Response, a list of them or None into a dict, a list of dicts or None.""" if response is None: return None if isinstance(response, List): @@ -129,18 +153,18 @@ def _deprecated(old: str, new: str) -> None: def serialize_error(response: ErrorResponse) -> Dict[str, Any]: - """Deprecated. Use to_error_dict.""" + """Deprecated: use `to_error_dict`. Warns with DeprecationWarning (5.0.10).""" _deprecated("serialize_error", "to_error_dict") return to_error_dict(response) def serialize_success(response: SuccessResponse) -> Dict[str, Any]: - """Deprecated. Use to_success_dict.""" + """Deprecated: use `to_success_dict`. Warns with DeprecationWarning (5.0.10).""" _deprecated("serialize_success", "to_success_dict") return to_success_dict(response) def to_serializable_one(response: Response) -> Dict[str, Any]: - """Deprecated. Use to_dict.""" + """Deprecated: use `to_dict`. Warns with DeprecationWarning (5.0.10).""" _deprecated("to_serializable_one", "to_dict") return to_dict(response) diff --git a/jsonrpcserver/result.py b/jsonrpcserver/result.py index 0d06dac..16c17e5 100644 --- a/jsonrpcserver/result.py +++ b/jsonrpcserver/result.py @@ -1,10 +1,8 @@ -"""Result data types - the results of calling a method. +"""What a method returns: Success, Error or InvalidParams. -Results are the JSON-RPC response objects -(https://www.jsonrpc.org/specification#response_object), minus the "jsonrpc" and "id" -parts - the library takes care of these parts for you. - -The public functions are Success, Error and InvalidParams. +A Result is the "result" or "error" part of a JSON-RPC response object +(https://www.jsonrpc.org/specification#response_object). The library adds the +"jsonrpc" and "id" parts. """ from typing import Any, NamedTuple @@ -19,6 +17,8 @@ class SuccessResult(NamedTuple): + """The value inside the Result that `Success` returns.""" + result: Any = None def __repr__(self) -> str: @@ -26,6 +26,11 @@ def __repr__(self) -> str: class ErrorResult(NamedTuple): + """The value inside the Result that `Error` and `InvalidParams` return. + + `data` is `NODATA` when there's no data, so it's left out of the response. + """ + code: int message: str data: Any = NODATA # The spec says this value may be omitted @@ -40,6 +45,12 @@ def __repr__(self) -> str: # Union of the two valid result types. oslash's Either takes the success type # first, then the error type. Result = Either[SuccessResult, ErrorResult] +"""The return type of a method: what `Success`, `Error` and `InvalidParams` give. + +It's an oslash `Either`: a `Right` holding a `SuccessResult`, or a `Left` holding an +`ErrorResult`. Use it as the return annotation of your methods. You don't need to +look inside it. +""" # Helpers @@ -61,16 +72,79 @@ def InvalidParamsResult(data: Any = NODATA) -> ErrorResult: def Success(result: Any = None) -> Result: + """A successful result. Return it from a method. + + Args: + result: The value for the response's `result` member. It can be anything + the serializer handles. The default, None, is sent as `null`. + + Returns: + A Result holding the value. + + Example: + >>> from jsonrpcserver import Result, Success, dispatch + >>> def ping() -> Result: + ... return Success("pong") + >>> dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', {"ping": ping}) + '{"jsonrpc": "2.0", "result": "pong", "id": 1}' + """ return Right(SuccessResult(result)) def Error(code: int, message: str, data: Any = NODATA) -> Result: + """An error result. Return it from a method to send an error response. + + It's sent as it is, whatever `debug` is, so don't put secrets in it. + + Args: + code: The error code, an integer. The spec reserves -32768 to -32000 for + its own errors, so pick other numbers for yours. + message: A short description of the error, as a string. + data: Extra information for the client, such as details of what went + wrong. If it isn't given, the response has no `data` member. + + Returns: + A Result holding the error. + + Warns: + UserWarning: If `code` isn't an int or `message` isn't a str. The error is + still sent as given. New in 5.0.10. + + Example: + >>> from jsonrpcserver import Error, Result, dispatch_to_serializable + >>> def fail() -> Result: + ... return Error(1, "It failed", {"reason": "example"}) + >>> request = '{"jsonrpc": "2.0", "method": "fail", "id": 1}' + >>> dispatch_to_serializable(request, {"fail": fail})["error"] + {'code': 1, 'message': 'It failed', 'data': {'reason': 'example'}} + """ warn_if_invalid_error(code, message, stacklevel=2) return Left(ErrorResult(code, message, data)) def InvalidParams(data: Any = NODATA) -> Result: - """InvalidParams is a shortcut to save you from having to pass the Invalid Params - JSON-RPC code to Error. + """An Invalid params error: `Error(-32602, "Invalid params", data)`. + + Return it when the arguments have the right shape but a bad value. + jsonrpcserver already sends this error when the arguments don't fit the + method's signature. + + Args: + data: What was wrong, for the client. If it isn't given, the response has no + `data` member. + + Returns: + A Result holding the error. + + Example: + >>> from jsonrpcserver import InvalidParams, Result, Success + >>> from jsonrpcserver import dispatch_to_serializable + >>> def rate(stars: int) -> Result: + ... if stars not in range(1, 6): + ... return InvalidParams("Stars must be 1 to 5") + ... return Success() + >>> request = '{"jsonrpc": "2.0", "method": "rate", "params": [6], "id": 1}' + >>> dispatch_to_serializable(request, {"rate": rate})["error"] + {'code': -32602, 'message': 'Invalid params', 'data': 'Stars must be 1 to 5'} """ return Left(InvalidParamsResult(data)) diff --git a/jsonrpcserver/sentinels.py b/jsonrpcserver/sentinels.py index 0ed0cca..d4a814c 100644 --- a/jsonrpcserver/sentinels.py +++ b/jsonrpcserver/sentinels.py @@ -21,5 +21,8 @@ def __repr__(self) -> str: NOCONTEXT = Sentinel("NoContext") +"""The default for `context`: don't pass a context to methods.""" NODATA = Sentinel("NoData") +"""The default for `data` in `Error` and `JsonRpcError`: leave `data` out.""" NOID = Sentinel("NoId") +"""The id of a notification, which has none.""" diff --git a/jsonrpcserver/server.py b/jsonrpcserver/server.py index ab11a66..715967c 100644 --- a/jsonrpcserver/server.py +++ b/jsonrpcserver/server.py @@ -1,5 +1,4 @@ -"""A simple development server for serving JSON-RPC requests using Python's builtin -http.server module. +"""A development server for trying out methods, built on Python's http.server. It's meant for trying things out. For production, put dispatch behind a real web server or framework. @@ -7,6 +6,7 @@ import json import logging +import sys from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any @@ -60,11 +60,56 @@ def do_POST(self) -> None: self.end_headers() +def listening_message(name: str, port: int) -> str: + """The line serve() shows when it starts.""" + if name in ("", "0.0.0.0"): + return ( + f" * Listening on port {port} on every network interface. This is a " + 'development server. Use serve("localhost", ...) to accept only local ' + "connections." + ) + return f" * Listening on http://{name}:{port}/. This is a development server." + + def serve(name: str = "", port: int = 5000) -> None: - """A simple function to serve HTTP requests. For development only.""" - logger.info(" * Listening on port %s", port) + """Serve the methods registered with `@method` over HTTP. For development only. + + It answers POST requests on any path with `dispatch`, sends 204 No Content for a + notification, and runs each request in its own thread. It runs until the process + is stopped, for example with Ctrl+C. + + When it starts, it logs where it's listening on the `jsonrpcserver.server` + logger. If logging isn't configured, it writes that line to stderr instead + (new in 5.0.10). Each request is logged at INFO level. + + It has no TLS, no authentication and no request size limit, and it doesn't pass + `max_batch_size`. Put `dispatch` behind a real web server or framework in + production. + + Args: + name: The host name or address to listen on. The default, "", listens on + every network interface, so other machines can connect. Pass "localhost" + to accept only local connections. + port: The port to listen on. + + Example: + ```python + from jsonrpcserver import Result, Success, method, serve + + @method + def ping() -> Result: + return Success("pong") + + serve("localhost", 8000) + ``` + """ httpd = ThreadingHTTPServer((name, port), RequestHandler) try: + message = listening_message(name, port) + logger.info("%s", message) + if not logger.hasHandlers() and sys.stderr is not None: + # logging isn't configured, so the line above went nowhere. + print(message, file=sys.stderr, flush=True) httpd.serve_forever() finally: httpd.server_close() diff --git a/tests/test_async_dispatcher.py b/tests/test_async_dispatcher.py index a7378f0..6c35bda 100644 --- a/tests/test_async_dispatcher.py +++ b/tests/test_async_dispatcher.py @@ -94,6 +94,24 @@ async def method() -> Result: assert str(record.exc_info[1]) == "secret detail" +@pytest.mark.asyncio +async def test_plain_return_value_is_logged_with_a_hint( + caplog: pytest.LogCaptureFixture, +) -> None: + async def method() -> str: + return "pong" + + assert await call(Request("ping", [], 1), NOCONTEXT, method) == Left( + ErrorResult(ERROR_INTERNAL_ERROR, "Internal error") + ) + (record,) = caplog.records + assert record.name == "jsonrpcserver.async_dispatcher" + assert record.exc_info is None + assert record.getMessage().startswith( + "Method 'ping' returned 'pong', which is not a Result" + ) + + @pytest.mark.asyncio async def test_dispatch_request() -> None: request = Request("ping", [], 1) diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index 46edae0..d645669 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -529,12 +529,42 @@ def not_a_result() -> None: ErrorResponse( ERROR_INTERNAL_ERROR, "Internal error", - "The method did not return a valid Result (returned None)", + "The method did not return a valid Result (returned None). " + "Return Success(value) or Error(code, message).", 1, ) ) +def test_plain_return_value_is_logged_with_a_hint( + caplog: pytest.LogCaptureFixture, +) -> None: + """A 4.x-style method that returns a plain value gets a log line saying what to do, + not a traceback into jsonrpcserver.""" + + def ping() -> str: + return "pong" + + response = dispatch_to_response_pure( + deserializer=default_deserializer, + validator=default_validator, + post_process=identity, + context=NOCONTEXT, + methods={"ping": ping}, + request='{"jsonrpc": "2.0", "method": "ping", "id": 1}', + ) + assert response == Left( + ErrorResponse(ERROR_INTERNAL_ERROR, "Internal error", NODATA, 1) + ) + (record,) = caplog.records + assert record.levelname == "ERROR" + assert record.exc_info is None + assert record.getMessage().startswith( + "Method 'ping' returned 'pong', which is not a Result, so the client got an " + "Internal error. Return Success(value) or Error(code, message)." + ) + + def test_dispatch_to_response_pure_raising_exception() -> None: """Allow raising an exception to return an error.""" diff --git a/tests/test_docstrings.py b/tests/test_docstrings.py new file mode 100644 index 0000000..da95cf3 --- /dev/null +++ b/tests/test_docstrings.py @@ -0,0 +1,31 @@ +"""The docstrings are the API reference, so their examples must run and every +public name must have one.""" + +import doctest +import inspect +from types import ModuleType + +import pytest + +import jsonrpcserver +from jsonrpcserver import async_main, exceptions, main, response, result + +MODULES = [main, async_main, result, exceptions, response] + + +@pytest.mark.parametrize("module", MODULES, ids=lambda m: m.__name__) +def test_docstring_examples(module: ModuleType) -> None: + outcome = doctest.testmod(module, optionflags=doctest.ELLIPSIS) + assert outcome.attempted > 0 + assert outcome.failed == 0 + + +@pytest.mark.parametrize("name", jsonrpcserver.__all__) +def test_every_public_name_has_a_docstring(name: str) -> None: + obj = getattr(jsonrpcserver, name) + if name == "Result": + # A typing alias. Its docstring is an attribute docstring in result.py, + # which the API reference reads from the source. + assert '"""The return type of a method' in inspect.getsource(result) + else: + assert (obj.__doc__ or "").strip(), name diff --git a/tests/test_server.py b/tests/test_server.py index b31ebc5..ae3f530 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -12,7 +12,8 @@ import pytest from jsonrpcserver import Result, Success, method -from jsonrpcserver.server import RequestHandler, serve +from jsonrpcserver import server as server_module +from jsonrpcserver.server import RequestHandler, listening_message, serve @patch("jsonrpcserver.server.ThreadingHTTPServer") @@ -37,6 +38,54 @@ def test_serve_closes_on_interrupt(server: Mock) -> None: server.return_value.server_close.assert_called_once_with() +def test_listening_message_every_interface() -> None: + assert listening_message("", 5000) == ( + " * Listening on port 5000 on every network interface. This is a development " + 'server. Use serve("localhost", ...) to accept only local connections.' + ) + assert listening_message("0.0.0.0", 8000).startswith( + " * Listening on port 8000 on every network interface." + ) + + +def test_listening_message_one_host() -> None: + assert listening_message("localhost", 8000) == ( + " * Listening on http://localhost:8000/. This is a development server." + ) + + +@patch("jsonrpcserver.server.ThreadingHTTPServer") +def test_serve_logs_where_it_listens( + server: Mock, caplog: pytest.LogCaptureFixture, capsys: pytest.CaptureFixture[str] +) -> None: + with caplog.at_level(logging.INFO, logger="jsonrpcserver.server"): + serve("localhost", 8000) + assert [r.getMessage() for r in caplog.records] == [ + listening_message("localhost", 8000) + ] + # Logging is configured (pytest's handler), so nothing extra goes to stderr. + assert capsys.readouterr().err == "" + + +@patch("jsonrpcserver.server.ThreadingHTTPServer") +def test_serve_prints_where_it_listens_without_logging( + server: Mock, capsys: pytest.CaptureFixture[str] +) -> None: + with patch.object(server_module.logger, "hasHandlers", return_value=False): + serve("localhost", 8000) + assert capsys.readouterr().err == listening_message("localhost", 8000) + "\n" + + +@patch("jsonrpcserver.server.ThreadingHTTPServer") +def test_serve_without_stderr(server: Mock) -> None: + """sys.stderr is None in a PyInstaller app built with --noconsole (#269).""" + with patch.object( + server_module.logger, "hasHandlers", return_value=False + ), patch.object(sys, "stderr", None): + serve("localhost", 8000) + server.return_value.serve_forever.assert_called_once_with() + + @method(name="server_test_ping") def ping() -> Result: return Success("pong")