From b869d8261cead1229d114b47bd311b088af4d8a8 Mon Sep 17 00:00:00 2001 From: Benedikt Burger <67148916+BenediktBurger@users.noreply.github.com> Date: Wed, 20 Nov 2024 10:38:47 +0100 Subject: [PATCH 1/5] Expand RPCServer tests --- tests/json_utils/test_rpc_server.py | 97 ++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 7 deletions(-) diff --git a/tests/json_utils/test_rpc_server.py b/tests/json_utils/test_rpc_server.py index ffa33d26..bb2fa9d5 100644 --- a/tests/json_utils/test_rpc_server.py +++ b/tests/json_utils/test_rpc_server.py @@ -23,13 +23,26 @@ # import json +import logging import pytest from pyleco.json_utils.rpc_generator import RPCGenerator from pyleco.json_utils.rpc_server_definition import RPCServer -from pyleco.json_utils.json_objects import Request, ParamsRequest, ResultResponse -from pyleco.json_utils.errors import ServerError, InvalidRequest, INVALID_REQUEST, SERVER_ERROR +from pyleco.json_utils.json_objects import ( + Request, + ParamsRequest, + ResultResponse, + ErrorResponse, + DataError, +) +from pyleco.json_utils.errors import ( + ServerError, + InvalidRequest, + INVALID_REQUEST, + SERVER_ERROR, + INTERNAL_ERROR, +) try: # Load openrpc server for comparison, if available. @@ -45,13 +58,13 @@ def rpc_generator() -> RPCGenerator: return RPCGenerator() -def side_effect_method(arg=None): +def side_effect_method(arg=None) -> int: global args args = (arg,) return 5 -def fail(): +def fail() -> None: """Fail always. This method fails always. @@ -60,13 +73,13 @@ def fail(): raise NotImplementedError -def simple(): +def simple() -> int: """A method without parameters.""" return 7 -def obligatory_parameter(arg1: float): - """Needs an argument""" +def obligatory_parameter(arg1: float) -> float: + """Needs an argument.""" return arg1 * 2 @@ -81,6 +94,18 @@ def rpc_server(request) -> RPCServer: return rpc_server +@pytest.fixture +def rpc_server_local() -> RPCServer: + """Create an instance of PyLECO's RPC Server""" + rpc_server = RPCServer() + rpc_server.method(name="sem")(side_effect_method) + rpc_server.method()(side_effect_method) + rpc_server.method()(fail) + rpc_server.method()(simple) + rpc_server.method()(obligatory_parameter) + return rpc_server + + def test_success(rpc_generator: RPCGenerator, rpc_server: RPCServer): request = rpc_generator.build_request_str(method="sem", arg=3) response = rpc_server.process_request(request) @@ -181,3 +206,61 @@ def test_rpc_discover_not_listed(self, methods: list): for m in methods: if m.get("name") == "rpc.discover": raise AssertionError("rpc.discover is listed as a method!") + + +# tests regarding the local implementation of the RPC Server +def test_process_single_notification(rpc_server_local: RPCServer): + result = rpc_server_local._process_single_request( + {"jsonrpc": "2.0", "method": "simple"} + ) + assert result is None + + +class Test_process_request: + def test_log_exception(self, rpc_server_local: RPCServer, caplog: pytest.LogCaptureFixture): + rpc_server_local.process_request(b"\xff") + records = caplog.record_tuples + assert records[-1] == ( + "pyleco.json_utils.rpc_server_definition", + logging.ERROR, + "UnicodeDecodeError:", + ) + + def test_exception_response(self, rpc_server_local: RPCServer): + result = rpc_server_local.process_request(b"\xff") + assert result == ErrorResponse(id=None, error=INTERNAL_ERROR).model_dump_json() + + def test_invalid_request(self, rpc_server_local: RPCServer): + result = rpc_server_local.process_request(b"7") + assert ( + result + == ErrorResponse( + id=None, error=DataError.from_error(INVALID_REQUEST, 7) + ).model_dump_json() + ) + + def test_batch_entry_notification(self, rpc_server_local: RPCServer): + """A notification (request without id) shall not return anything.""" + requests = [ + {"jsonrpc": "2.0", "method": "simple"}, + {"jsonrpc": "2.0", "method": "simple", "id": 4}, + ] + result = json.loads(rpc_server_local.process_request(json.dumps(requests))) + assert result == [{"jsonrpc": "2.0", "result": 7, "id": 4}] + + def test_batch_of_notifications(self, rpc_server_local: RPCServer): + """A notification (request without id) shall not return anything.""" + requests = [ + {"jsonrpc": "2.0", "method": "simple"}, + {"jsonrpc": "2.0", "method": "simple"}, + ] + result = rpc_server_local.process_request(json.dumps(requests)) + assert result is None + + def test_notification(self, rpc_server_local: RPCServer): + """A notification (request without id) shall not return anything.""" + requests = [ + {"jsonrpc": "2.0", "method": "simple"}, + ] + result = rpc_server_local.process_request(json.dumps(requests)) + assert result is None From 8b43e31bcd6eb427fd7adcdc4a20c1ef6507abaf Mon Sep 17 00:00:00 2001 From: Benedikt Burger <67148916+BenediktBurger@users.noreply.github.com> Date: Wed, 20 Nov 2024 11:29:20 +0100 Subject: [PATCH 2/5] Add batch message type. --- pyleco/json_utils/json_objects.py | 31 ++++++++++++++++++++++-- tests/json_utils/test_json_objects.py | 34 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/pyleco/json_utils/json_objects.py b/pyleco/json_utils/json_objects.py index 2488db04..f47f82a5 100644 --- a/pyleco/json_utils/json_objects.py +++ b/pyleco/json_utils/json_objects.py @@ -32,8 +32,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass import json -from typing import Any, Optional, Union - +from typing import Any, Generic, Iterable, Optional, TypeVar, Union ErrorType = Union["DataError", "Error"] NotificationType = Union["Notification", "ParamsNotification"] @@ -130,3 +129,31 @@ def model_dump(self) -> dict[str, Any]: pre_dict = asdict(self) pre_dict["error"] = asdict(self.error) return pre_dict + +""" +Batch Handling. + +Not included in jsonrpc2-objects, but defined by JSONRPC 2.0 +""" +BatchType = TypeVar("BatchType", RequestType, ResponseType) + + +class BatchObject(list, Generic[BatchType]): + """A batch of requests or responses.""" + # Not defined by jsonrpc2-objects + + def __init__(self, iterable: Optional[Iterable[BatchType]] = None): + if iterable: + super().__init__(item for item in iterable) + else: + super().__init__() + + def model_dump(self) -> list[dict[str, Any]]: + return [obj.model_dump() for obj in self] + + def model_dump_json(self) -> str: + return json.dumps(self.model_dump(), separators=(",", ":")) + + +RequestBatch = BatchObject[RequestType] +ResponseBatch = BatchObject[ResponseType] diff --git a/tests/json_utils/test_json_objects.py b/tests/json_utils/test_json_objects.py index 07191029..2f41ef24 100644 --- a/tests/json_utils/test_json_objects.py +++ b/tests/json_utils/test_json_objects.py @@ -22,6 +22,8 @@ # THE SOFTWARE. # +import pytest + from pyleco.json_utils import json_objects @@ -83,3 +85,35 @@ def test_generate_data_error_from_error(): assert data_error.code == error.code assert data_error.message == error.message assert data_error.data == "data" + + +class Test_BatchObject: + element = json_objects.Request(5, "start") + + @pytest.fixture + def batch_obj(self): + return json_objects.BatchObject([self.element]) + + def test_init_with_values(self): + obj = json_objects.BatchObject([self.element]) + assert obj == [self.element] + + def test_bool_value_with_element(self): + obj = json_objects.BatchObject([self.element]) + assert bool(obj) is True + + def test_bool_value_without_element(self): + obj = json_objects.BatchObject() + assert bool(obj) is False + + def test_append(self, batch_obj: json_objects.BatchObject): + el2 = json_objects.Request(5, "start") + batch_obj.append(el2) + assert batch_obj[-1] == el2 + + def test_model_dump(self, batch_obj: json_objects.BatchObject): + assert batch_obj.model_dump() == [self.element.model_dump()] + + def test_model_dump_json(self, batch_obj: json_objects.BatchObject): + result = '[{"id":5,"method":"start","jsonrpc":"2.0"}]' + assert batch_obj.model_dump_json() == result From 1f03a8de02459b6fae5b4a8a22dbe3fb1b8012d3 Mon Sep 17 00:00:00 2001 From: Benedikt Burger <67148916+BenediktBurger@users.noreply.github.com> Date: Wed, 20 Nov 2024 11:44:59 +0100 Subject: [PATCH 3/5] Improve inner workings of RPCServer. --- pyleco/json_utils/rpc_server_definition.py | 58 ++++++++++------------ tests/json_utils/test_rpc_server.py | 40 ++++++++++++++- 2 files changed, 66 insertions(+), 32 deletions(-) diff --git a/pyleco/json_utils/rpc_server_definition.py b/pyleco/json_utils/rpc_server_definition.py index f5b015ae..8440f90a 100644 --- a/pyleco/json_utils/rpc_server_definition.py +++ b/pyleco/json_utils/rpc_server_definition.py @@ -28,7 +28,7 @@ from typing import Any, Callable, Optional, Union from .errors import INTERNAL_ERROR, SERVER_ERROR, INVALID_REQUEST -from .json_objects import ResultResponse, ErrorResponse, DataError +from .json_objects import ResultResponse, ErrorResponse, DataError, ResponseType, ResponseBatch log = logging.getLogger(__name__) @@ -50,15 +50,8 @@ def __init__( self.method(name="rpc.discover")(self.discover) def method(self, name: Optional[str] = None, **kwargs) -> Callable[[Callable], None]: - if name is None: - - def method_registrar(method: Callable) -> None: - return self._register_method(name=method.__name__, method=method) - else: - - def method_registrar(method: Callable) -> None: - return self._register_method(name=name, method=method) - + def method_registrar(method: Callable) -> None: + return self._register_method(name=name or method.__name__, method=method) return method_registrar def _register_method(self, name: str, method: Callable) -> None: @@ -67,31 +60,34 @@ def _register_method(self, name: str, method: Callable) -> None: def process_request(self, data: Union[bytes, str]) -> Optional[str]: try: json_data = json.loads(data) - if isinstance(json_data, list): - results = [] - for element in json_data: - result = self._process_single_request(element) - if result is not None: - results.append(result.model_dump()) - if results: - return json.dumps(results, separators=(",", ":")) - else: - return None - elif isinstance(json_data, dict): - result = self._process_single_request(json_data) - if result: - return result.model_dump_json() - else: - return None - else: - return ErrorResponse( - id=None, - error=DataError.from_error(INVALID_REQUEST, json_data), - ).model_dump_json() + result = self.process_request_object(json_data=json_data) + return result.model_dump_json() if result else None except Exception as exc: log.exception(f"{type(exc).__name__}:", exc_info=exc) return ErrorResponse(id=None, error=INTERNAL_ERROR).model_dump_json() + def process_request_object( + self, json_data: object + ) -> Optional[Union[ResponseType, ResponseBatch]]: + result: Optional[Union[ResponseType, ResponseBatch]] + if isinstance(json_data, list): + result = ResponseBatch() + for element in json_data: + result_element = self._process_single_request(element) + if result_element is not None: + result.append(result_element) + elif isinstance(json_data, dict): + result = self._process_single_request(json_data) + else: + result = ErrorResponse( + id=None, + error=DataError.from_error(INVALID_REQUEST, json_data), + ) + if result: + return result + else: + return None + def _process_single_request( self, request: dict[str, Any] ) -> Union[ResultResponse, ErrorResponse, None]: diff --git a/tests/json_utils/test_rpc_server.py b/tests/json_utils/test_rpc_server.py index bb2fa9d5..be4e09f1 100644 --- a/tests/json_utils/test_rpc_server.py +++ b/tests/json_utils/test_rpc_server.py @@ -35,6 +35,7 @@ ResultResponse, ErrorResponse, DataError, + ResponseBatch, ) from pyleco.json_utils.errors import ( ServerError, @@ -245,7 +246,7 @@ def test_batch_entry_notification(self, rpc_server_local: RPCServer): {"jsonrpc": "2.0", "method": "simple"}, {"jsonrpc": "2.0", "method": "simple", "id": 4}, ] - result = json.loads(rpc_server_local.process_request(json.dumps(requests))) + result = json.loads(rpc_server_local.process_request(json.dumps(requests))) # type: ignore assert result == [{"jsonrpc": "2.0", "result": 7, "id": 4}] def test_batch_of_notifications(self, rpc_server_local: RPCServer): @@ -264,3 +265,40 @@ def test_notification(self, rpc_server_local: RPCServer): ] result = rpc_server_local.process_request(json.dumps(requests)) assert result is None + + +class Test_process_request_object: + def test_invalid_request(self, rpc_server_local: RPCServer): + result = rpc_server_local.process_request_object(7) + assert ( + result + == ErrorResponse( + id=None, error=DataError.from_error(INVALID_REQUEST, 7) + ) + ) + + def test_batch_entry_notification(self, rpc_server_local: RPCServer): + """A notification (request without id) shall not return anything.""" + requests = [ + {"jsonrpc": "2.0", "method": "simple"}, + {"jsonrpc": "2.0", "method": "simple", "id": 4}, + ] + result = rpc_server_local.process_request_object(requests) + assert result == ResponseBatch([ResultResponse(4, 7)]) + + def test_batch_of_notifications(self, rpc_server_local: RPCServer): + """A notification (request without id) shall not return anything.""" + requests = [ + {"jsonrpc": "2.0", "method": "simple"}, + {"jsonrpc": "2.0", "method": "simple"}, + ] + result = rpc_server_local.process_request_object(requests) + assert result is None + + def test_notification(self, rpc_server_local: RPCServer): + """A notification (request without id) shall not return anything.""" + requests = [ + {"jsonrpc": "2.0", "method": "simple"}, + ] + result = rpc_server_local.process_request_object(requests) + assert result is None From 49f39ca4ca21ddd221cceceba43f5ef58efa6e61 Mon Sep 17 00:00:00 2001 From: Benedikt Burger <67148916+BenediktBurger@users.noreply.github.com> Date: Wed, 20 Nov 2024 12:03:10 +0100 Subject: [PATCH 4/5] Fix not defined name --- tests/json_utils/test_rpc_server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/json_utils/test_rpc_server.py b/tests/json_utils/test_rpc_server.py index be4e09f1..06e07975 100644 --- a/tests/json_utils/test_rpc_server.py +++ b/tests/json_utils/test_rpc_server.py @@ -59,6 +59,7 @@ def rpc_generator() -> RPCGenerator: return RPCGenerator() +args = None def side_effect_method(arg=None) -> int: global args args = (arg,) From 003def07ea455f87ed5b52e96e4c68c37d874998 Mon Sep 17 00:00:00 2001 From: Benedikt Burger <67148916+BenediktBurger@users.noreply.github.com> Date: Wed, 20 Nov 2024 14:02:00 +0100 Subject: [PATCH 5/5] Improve BatchObject to force type for all methods. --- pyleco/json_utils/json_objects.py | 17 ++++++++--------- tests/json_utils/test_json_objects.py | 6 +++++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/pyleco/json_utils/json_objects.py b/pyleco/json_utils/json_objects.py index f47f82a5..000dd9e2 100644 --- a/pyleco/json_utils/json_objects.py +++ b/pyleco/json_utils/json_objects.py @@ -32,7 +32,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass import json -from typing import Any, Generic, Iterable, Optional, TypeVar, Union +from typing import Any, List, Optional, TypeVar, Union ErrorType = Union["DataError", "Error"] NotificationType = Union["Notification", "ParamsNotification"] @@ -138,15 +138,14 @@ def model_dump(self) -> dict[str, Any]: BatchType = TypeVar("BatchType", RequestType, ResponseType) -class BatchObject(list, Generic[BatchType]): - """A batch of requests or responses.""" - # Not defined by jsonrpc2-objects +class BatchObject(List[BatchType]): + """A batch of JSONRPC message objects. - def __init__(self, iterable: Optional[Iterable[BatchType]] = None): - if iterable: - super().__init__(item for item in iterable) - else: - super().__init__() + It works like a list of appropriate message objects and offers the possibility to dump + this batch object to a plain python object or to JSON. + """ + # Parent class is typing.List, as Python<3.9 does not like list[BatchType] + # Not defined by jsonrpc2-objects def model_dump(self) -> list[dict[str, Any]]: return [obj.model_dump() for obj in self] diff --git a/tests/json_utils/test_json_objects.py b/tests/json_utils/test_json_objects.py index 2f41ef24..59474fe4 100644 --- a/tests/json_utils/test_json_objects.py +++ b/tests/json_utils/test_json_objects.py @@ -94,10 +94,14 @@ class Test_BatchObject: def batch_obj(self): return json_objects.BatchObject([self.element]) - def test_init_with_values(self): + def test_init_with_value(self): obj = json_objects.BatchObject([self.element]) assert obj == [self.element] + def test_init_with_values(self): + obj = json_objects.BatchObject([self.element, self.element]) + assert obj == [self.element, self.element] + def test_bool_value_with_element(self): obj = json_objects.BatchObject([self.element]) assert bool(obj) is True