From 0661f3d36eb2b11c7e1f62697509effca83606bf Mon Sep 17 00:00:00 2001 From: Benedikt Moneke Date: Thu, 29 Jun 2023 09:03:43 +0200 Subject: [PATCH 1/7] Core elements added. --- pyleco/core/__init__.py | 31 ++++ pyleco/core/message.py | 233 +++++++++++++++++++++++++++++++ pyleco/core/protocols.py | 143 +++++++++++++++++++ pyleco/core/rpc_generator.py | 54 +++++++ pyleco/core/serialization.py | 92 ++++++++++++ pyleco/errors.py | 47 +++++++ pyleco/test.py | 118 ++++++++++++++++ pyproject.toml | 4 +- tests/core/test_message.py | 192 +++++++++++++++++++++++++ tests/core/test_serialization.py | 55 ++++++++ 10 files changed, 968 insertions(+), 1 deletion(-) create mode 100644 pyleco/core/__init__.py create mode 100644 pyleco/core/message.py create mode 100644 pyleco/core/protocols.py create mode 100644 pyleco/core/rpc_generator.py create mode 100644 pyleco/core/serialization.py create mode 100644 pyleco/errors.py create mode 100644 pyleco/test.py create mode 100644 tests/core/test_message.py create mode 100644 tests/core/test_serialization.py diff --git a/pyleco/core/__init__.py b/pyleco/core/__init__.py new file mode 100644 index 000000000..27c8aa50a --- /dev/null +++ b/pyleco/core/__init__.py @@ -0,0 +1,31 @@ +# +# This file is part of the PyLECO package. +# +# Copyright (c) 2023-2023 PyLECO Developers +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +""" +Core - Essential modules for pyleco. +""" + +# Current protocol version +VERSION: int = 0 +VERSION_B: bytes = VERSION.to_bytes(1, "big") diff --git a/pyleco/core/message.py b/pyleco/core/message.py new file mode 100644 index 000000000..affba0fef --- /dev/null +++ b/pyleco/core/message.py @@ -0,0 +1,233 @@ +# +# This file is part of the PyLECO package. +# +# Copyright (c) 2023-2023 PyLECO Developers +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +from json import JSONDecodeError +from typing import Any, List, Optional, Self + + +from . import VERSION_B +from .serialization import (create_header_frame, serialize_data, interpret_header, split_name, + deserialize_data, + ) + + +# Control transfer protocol +class Message: + """A message in LECO protocol. + + It consists of the following bytes frames, for which each an attribute exists: + - `version` + - `receiver` + - `sender` + - `header` + - 0 or more `payload` frames + + If you do not specify a sender, the sending program shall add it itself. + The :attr:`data` attribute is the content of the first :attr:`payload` frame. + All attributes, except the official frames, are for convenience. + """ + + version: bytes = VERSION_B + + def __init__(self, receiver: bytes | str, sender: bytes | str = b"", + data: Optional[bytes | str | Any] = None, + header: Optional[bytes] = None, + conversation_id: bytes = b"", + **kwargs) -> None: + self._setup_caches() + self.receiver = receiver if isinstance(receiver, bytes) else receiver.encode() + self.sender = sender if isinstance(sender, bytes) else sender.encode() + self.header = (create_header_frame(conversation_id=conversation_id, **kwargs) + if header is None else header) + if isinstance(data, (bytes)): + self.payload = [data] + if isinstance(data, str): + self.payload = [data.encode()] + else: + self._data = data + + def _setup_caches(self) -> None: + self._payload: List[bytes] = [] + self._header_elements = None + self._sender_elements = None + self._receiver_elements = None + self._data = None + + @classmethod + def from_frames(cls, version: bytes, receiver: bytes, sender: bytes, header: bytes, + *payload: bytes) -> Self: + """Create a message from a frames list, for example after reading from a socket. + + .. code:: + + frames = socket.recv_multipart() + message = Message.from_frames(*frames) + """ + inst = cls(receiver, sender, header=header) + inst.version = version + inst.payload = list(payload) + return inst + + def to_frames(self) -> List[bytes]: + """Get a list representation of the message, ready for sending it.""" + if not self.sender: + raise ValueError("Empty sender frame not allowed to send.") + return self._to_frames_without_sender_check() + + def _to_frames_without_sender_check(self) -> List[bytes]: + return [self.version, self.receiver, self.sender, self.header] + self.payload + + @property + def receiver(self) -> bytes: + return self._receiver + + @receiver.setter + def receiver(self, value: bytes): + self._receiver = value + self._receiver_elements = None # reset cache + + @property + def sender(self) -> bytes: + return self._sender + + @sender.setter + def sender(self, value: bytes): + self._sender = value + self._sender_elements = None # reset cache + + @property + def header(self) -> bytes: + return self._header + + @header.setter + def header(self, value: bytes): + self._header = value + self._header_elements = None # reset cache + + @property + def payload(self) -> List[bytes]: + if self._payload == [] and self._data is not None: + self._payload = [serialize_data(self._data)] + return self._payload + + @payload.setter + def payload(self, value: List[bytes]) -> None: + self._payload = value + self._data = None # reset data + + @property + def conversation_id(self) -> bytes: + if self._header_elements is None: + self._header_elements = interpret_header(self.header) + return self._header_elements[0] + + @property + def message_id(self) -> bytes: + if self._header_elements is None: + self._header_elements = interpret_header(self.header) + return self._header_elements[1] + + @property + def receiver_node(self) -> bytes: + if self._receiver_elements is None: + self._receiver_elements = split_name(self.receiver) + return self._receiver_elements[0] + + @property + def receiver_name(self) -> bytes: + if self._receiver_elements is None: + self._receiver_elements = split_name(self.receiver) + return self._receiver_elements[1] + + @property + def sender_node(self) -> bytes: + if self._sender_elements is None: + self._sender_elements = split_name(self.sender) + return self._sender_elements[0] + + @property + def sender_name(self) -> bytes: + if self._sender_elements is None: + self._sender_elements = split_name(self.sender) + return self._sender_elements[1] + + @property + def data(self) -> object: + if self._data is None and self.payload: + self._data = deserialize_data(self.payload[0]) + return self._data + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Message): + return NotImplemented + partial_comparison = ( + self.version == other.version + and self.receiver == other.receiver + and self.sender == other.sender + and self.header == other.header + ) + try: + my_data = self.data + other_data = other.data + except JSONDecodeError: + return partial_comparison and self.payload == other.payload + else: + return (partial_comparison and my_data == other_data + and self.payload[1:] == other.payload[1:]) + + def __repr__(self) -> str: + return f"Message.from_frames({self._to_frames_without_sender_check()})" + + # String access properties + @property + def receiver_str(self) -> str: + return self.receiver.decode() + + @receiver_str.setter + def receiver_str(self, value: str): + self.receiver = value.encode() + + @property + def sender_str(self) -> str: + return self.sender.decode() + + @sender_str.setter + def sender_str(self, value: str): + self.sender = value.encode() + + @property + def receiver_node_str(self) -> str: + return self.receiver_node.decode() + + @property + def receiver_name_str(self) -> str: + return self.receiver_name.decode() + + @property + def sender_node_str(self) -> str: + return self.sender_node.decode() + + @property + def sender_name_str(self) -> str: + return self.sender_name.decode() diff --git a/pyleco/core/protocols.py b/pyleco/core/protocols.py new file mode 100644 index 000000000..c3580f1e3 --- /dev/null +++ b/pyleco/core/protocols.py @@ -0,0 +1,143 @@ +# +# This file is part of the PyLECO package. +# +# Copyright (c) 2023-2023 PyLECO Developers +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +from typing import Any, Dict, List, Optional, Protocol, Tuple + +from .message import Message +from ..core.rpc_generator import RPCGenerator + + +""" +These classes show the remotely available methods via rpc. +""" + + +class Component(Protocol): + """Any Component of the pyleco protocol.""" + + def pong(self) -> None: + """Respond to any request.""" + return # always succeeds. + + +class ExtendedComponent(Component, Protocol): + """A Component which supports more features.""" + + def set_log_level(self, level: int) -> None: ... + + def shutdown(self) -> None: ... + + +class Coordinator(Component, Protocol): + """A command protocol Coordinator""" + + def sign_in(self) -> None: ... + + def sign_out(self) -> None: ... + + def coordinator_sign_in(self) -> None: ... + + def coordinator_sign_out(self) -> None: ... + + def set_nodes(self, nodes: Dict[str, str]) -> None: ... + + def compose_global_directory(self) -> dict: ... + + def compose_local_directory(self) -> dict: ... + + +class Actor(Component, Protocol): + """An Actor Component.""" + + def get_properties(self, properties: List[str] | Tuple[str, ...]) -> Dict[str, Any]: ... + + def set_properties(self, properties: Dict[str, Any]) -> None: ... + + def call_method(self, method: str, _args: Optional[list | tuple] = None, **kwargs) -> Any: ... + +class PollingActor(Actor, Protocol): + """An Actor which allows regular polling.""" + + polling_interval: float + + def start_polling(self, polling_interval: Optional[float]) -> None: ... + + def set_polling_interval(self, polling_interval: float) -> None: ... + + def get_polling_interval(self) -> float: ... + + def stop_polling(self) -> None: ... + + +class LockingActor(Actor, Protocol): + """An Actor which allows to lock the device or channels of the device.""" + + def lock(self, resource: Optional[str] = None) -> bool: ... + + def unlock(self, resource: Optional[str] = None) -> None: ... + + def force_unlock(self, resource: Optional[str] = None) -> None: ... + + +""" +These classes show the API of tools, which talk with the LECO protocol. + +Any Component could use these tools in order to send and read messsages. +For example a Director might use these tools to direct an Actor. +""" + + +class Communicator(Component, Protocol): + """A helper class for a Component, to communicate via the LECO protocol.""" + + name: str + node: Optional[str] = None + rpc_generator: RPCGenerator + + def sign_in(self) -> None: ... + + def sign_out(self) -> None: ... + + def send(self, + receiver: str | bytes, + conversation_id: bytes = b"", + data: Optional[Any] = None, + **kwargs) -> None: + """Send a message based on kwargs.""" + self.send_message(message=Message( + receiver=receiver, conversation_id=conversation_id, data=data, **kwargs + )) + + def send_message(self, message: Message) -> None: ... + + def ask(self, receiver: bytes | str, conversation_id: bytes = b"", + data: Optional[Any] = None, + **kwargs) -> Message: + """Send a message based on kwargs and retrieve the response.""" + return self.ask_message(message=Message( + receiver=receiver, conversation_id=conversation_id, data=data, **kwargs)) + + def ask_message(self, message: Message) -> Message: ... + + def close(self) -> None: ... diff --git a/pyleco/core/rpc_generator.py b/pyleco/core/rpc_generator.py new file mode 100644 index 000000000..7a614e7bf --- /dev/null +++ b/pyleco/core/rpc_generator.py @@ -0,0 +1,54 @@ +# +# This file is part of the PyLECO package. +# +# Copyright (c) 2023-2023 PyLECO Developers +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +from typing import Any, Optional + +from jsonrpc2pyclient._irpcclient import IRPCClient + + +class RPCGenerator(IRPCClient): + """Builds and interprets json rpc messages.""" + + # TODO it stores an always growing list of "id"s, if you do not call "get_result". + + def build_request_str(self, method: str, params: Optional[list | dict] = None, **kwargs) -> str: + if kwargs and isinstance(params, list): + raise ValueError( + "You may not specify positional arguments and keyword arguments at the same time.") + if isinstance(params, dict): + params.update(kwargs) + if params: + return self._build_request(method=method, params=params).json() + elif kwargs: + return self._build_request(method=method, params=kwargs).json() + else: + return self._build_request(method=method, params=None).json() + + def get_result_from_response(self, data: bytes | str) -> Any: + """Get the result of that object or raise an error.""" + return super()._get_result_from_response(data=data) + + def clear_id_list(self) -> None: + """Reset the list of created ids.""" + self._ids = {} diff --git a/pyleco/core/serialization.py b/pyleco/core/serialization.py new file mode 100644 index 000000000..13a574370 --- /dev/null +++ b/pyleco/core/serialization.py @@ -0,0 +1,92 @@ +# +# This file is part of the PyLECO package. +# +# Copyright (c) 2023-2023 PyLECO Developers +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +import json +import random +from time import time +import struct +from typing import Tuple + +from pydantic import BaseModel + + +def create_header_frame(conversation_id: bytes = b"", message_id: bytes = b"") -> bytes: + """Create the header frame. + + :param bytes conversation_id: ID of the conversation. + :param bytes message_id: Message ID of this message, must not contain ";". + :return: header frame. + """ + return b";".join((conversation_id, message_id)) + + +def split_name(name: bytes, node: bytes = b"") -> Tuple[bytes, bytes]: + """Split a sender/receiver name with given default node.""" + s = name.split(b".") + n = s.pop(-1) + return (s.pop() if s else node), n + + +def split_name_str(name: str, node: str = "") -> Tuple[str, str]: + """Split a sender/receiver name with given default node.""" + s = name.split(".") + n = s.pop(-1) + return (s.pop() if s else node), n + + +def interpret_header(header: bytes) -> Tuple[bytes, bytes]: + """Interpret the header frame. + + :return: conversation_id, message_id + """ + try: + conversation_id, message_id = header.rsplit(b";", maxsplit=1) + except (IndexError, ValueError): + conversation_id = b"" + message_id = b"" + return conversation_id, message_id + + +def serialize_data(data: object) -> bytes: + """Turn `data` into a bytes object. + + Due to json serialization, data must not contain a bytes object! + """ + if isinstance(data, BaseModel): + return data.json().encode() + else: + return json.dumps(data).encode() + + +def deserialize_data(content: bytes) -> object: + """Turn received message content into python objects.""" + return json.loads(content.decode()) + + +def generate_conversation_id() -> bytes: + """Generate a conversation_id.""" + # struct.pack uses 8 bytes and takes 0.1 seconds for 1 Million repetitions. + # str().encode() uses 14 bytes and takes 0.5 seconds for 1 Million repetitions. + # !d is a double (8 bytes) in network byte order (big-endian) + return struct.pack("!d", time()) + random.randbytes(2) diff --git a/pyleco/errors.py b/pyleco/errors.py new file mode 100644 index 000000000..168de9b7f --- /dev/null +++ b/pyleco/errors.py @@ -0,0 +1,47 @@ +# +# This file is part of the PyLECO package. +# +# Copyright (c) 2023-2023 PyLECO Developers +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +from typing import Any + +from jsonrpcobjects.objects import ErrorObject, ErrorObjectData, ErrorResponseObject + +# TODO define valid error codes + +# Routing errors (Coordinator) +NOT_SIGNED_IN = ErrorObject(code=1234, message="You did not sign in!") +DUPLICATE_NAME = ErrorObject(code=456, message="The name is already taken.") +NODE_UNKNOWN = ErrorObject(code=4324, message="Node is not known.") +RECEIVER_UNKNOWN = ErrorObject(code=123213, message="Receiver is not in addresses list.") + + +def generate_error_with_data(error: ErrorObject, data: Any) -> ErrorObjectData: + return ErrorObjectData(code=error.code, message=error.message, data=data) + + +class CommunicationError(ConnectionError): + """Something went wrong, send a `error_msg` to the recipient.""" + + def __init__(self, text: str, error_payload: ErrorResponseObject, *args: Any) -> None: + super().__init__(text, *args) + self.error_payload = error_payload diff --git a/pyleco/test.py b/pyleco/test.py new file mode 100644 index 000000000..1a1f778e6 --- /dev/null +++ b/pyleco/test.py @@ -0,0 +1,118 @@ +# +# This file is part of the PyLECO package. +# +# Copyright (c) 2023-2023 PyLECO Developers +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +from typing import List + +from .core.message import Message +from .core.protocols import Communicator +from .core.rpc_generator import RPCGenerator + + +class FakeContext: + """A fake context instance, similar to the result of `zmq.Context.instance().""" + + def socket(self, socket_type): + return FakeSocket(socket_type) + + def term(self): + self.closed = True + + def destroy(self, linger=0): + self.closed = True + + +class FakeSocket: + """A fake socket mirroring zmq.socket API, useful for unit tests. + + :attr list _s: contains a list of messages sent via this socket. + :attr list _r: List of messages which can be read. + """ + + def __init__(self, socket_type, *args): + self.socket_type = socket_type + self.addr = None + self._s = [] + self._r = [] + self.closed = False + + def bind(self, addr): + self.addr = addr + + def bind_to_random_port(self, addr, *args, **kwargs): + self.addr = addr + return 5 + + def unbind(self, addr=None): + self.addr = None + + def connect(self, addr): + self.addr = addr + + def disconnect(self, addr=None): + self.addr = None + + def poll(self, timeout=0, flags="PollEvent.POLLIN"): + return 1 if len(self._r) else 0 + + def recv_multipart(self): + return self._r.pop(0) + + def send_multipart(self, parts): + print(parts) + for i, part in enumerate(parts): + if not isinstance(part, bytes): + # Similar to real error message. + raise TypeError(f"Frame {i} ({part}) does not support the buffer interface.") + self._s.append(list(parts)) + + def close(self, linger=None): + self.addr = None + self.closed = True + + +class FakeCommunicator(Communicator): + def __init__(self, name: str): + super().__init__() + self.name = name + self.rpc_generator = RPCGenerator() + self._r: List[Message] = [] + self._s: List[Message] = [] + + def sign_in(self) -> None: + return super().sign_in() + + def sign_out(self) -> None: + return super().sign_out() + + def close(self) -> None: + return super().close() + + def send_message(self, message: Message) -> None: + if not message.sender: + message.sender = self.name.encode() + self._s.append(message) + + def ask_message(self, message: Message) -> Message: + self.send_message(message) + return self._r.pop(0) diff --git a/pyproject.toml b/pyproject.toml index 3fa99f5e3..41dca3092 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyleco" -version = "0.0.1dev" +version = "0.0.1.dev" authors = [ { name="PyMeasure Developers" }, ] @@ -17,6 +17,8 @@ classifiers = [ requires-python = ">=3.10" dependencies = [ "pyzmq", + "openrpc", + "jsonrpc2-pyclient", ] [project.optional-dependencies] diff --git a/tests/core/test_message.py b/tests/core/test_message.py new file mode 100644 index 000000000..1011a7541 --- /dev/null +++ b/tests/core/test_message.py @@ -0,0 +1,192 @@ +# +# This file is part of the PyLECO package. +# +# Copyright (c) 2023-2023 PyLECO Developers +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +import pytest + +from pyleco.core import VERSION_B + +from pyleco.core.message import Message + + +class Test_Message_from_frames: + @pytest.fixture + def message(self) -> Message: + return Message.from_frames(b"\xffo", b"rec", b"send", b"x;y", + b'[["GET", [1, 2]], ["GET", 3]]') + + def test_payload(self, message: Message): + assert message.payload == [b'[["GET", [1, 2]], ["GET", 3]]'] + + def test_version(self, message: Message): + assert message.version == b"\xffo" + + def test_receiver(self, message: Message): + assert message.receiver == b"rec" + + def test_sender(self, message: Message): + assert message.sender == b"send" + + def test_header(self, message: Message): + assert message.header == b"x;y" + + def test_multiple_payload_frames(self): + message = Message.from_frames( + b"\xffo", b"broker", b"", b";", b'[["GET", [1, 2]], ["GET", 3]]', b"additional stuff" + ) + assert message.payload == [b'[["GET", [1, 2]], ["GET", 3]]', b"additional stuff"] + + def test_no_payload(self): + message = Message.from_frames(VERSION_B, b"broker", b"", b";") + assert message.payload == [] + + def test_get_frames_list(self, message: Message): + assert message.to_frames() == [b"\xffo", b"rec", b"send", b"x;y", + b'[["GET", [1, 2]], ["GET", 3]]'] + + +class Test_Message_create_message: + @pytest.fixture + def message(self) -> Message: + return Message( + receiver=b"rec", + sender=b"send", + data=[["GET", [1, 2]], ["GET", 3]], + conversation_id=b"x", + message_id=b"y", + ) + + def test_payload(self, message: Message): + assert message.payload == [b'[["GET", [1, 2]], ["GET", 3]]'] + + def test_version(self, message: Message): + assert message.version == VERSION_B + + def test_receiver(self, message: Message): + assert message.receiver == b"rec" + + def test_sender(self, message: Message): + assert message.sender == b"send" + + def test_header(self, message: Message): + assert message.header == b"x;y" + + def test_get_frames_list(self, message: Message): + assert message.to_frames() == [ + VERSION_B, + b"rec", + b"send", + b"x;y", + b'[["GET", [1, 2]], ["GET", 3]]', + ] + + def test_get_frames_list_without_payload(self, message: Message): + message.payload = [] + assert message.to_frames() == [VERSION_B, b"rec", b"send", b"x;y"] + + def test_message_without_data_does_not_have_payload_frame(self): + message = Message(b"rec", "send") + assert message.payload == [] + assert message.to_frames() == [VERSION_B, b"rec", b"send", b";"] + + def test_message_binary_data(self): + message = Message(b"rec", data=b"binary data") + assert message.payload[0] == b"binary data" + + def test_message_data_str_to_binary_data(self): + message = Message(b"rec", data="some string") + assert message.payload[0] == b"some string" + +class Test_Message_with_strings: + @pytest.fixture + def str_message(self) -> Message: + message = Message(receiver="N2.receiver", sender="N1.sender") + return message + + def test_receiver_is_bytes(self, str_message: Message): + assert str_message.receiver == b"N2.receiver" + + def test_sender_is_bytes(self, str_message: Message): + assert str_message.sender == b"N1.sender" + + def test_set_receiver_as_string(self, str_message: Message): + str_message.receiver_str = "New.Receiver" + assert str_message.receiver == b"New.Receiver" + + def test_set_sender_as_string(self, str_message: Message): + str_message.sender_str = "New.Sender" + assert str_message.sender == b"New.Sender" + + def test_str_return_values(self, str_message: Message): + assert str_message.receiver_str == "N2.receiver" + assert str_message.sender_str == "N1.sender" + assert str_message.receiver_node_str == "N2" + assert str_message.receiver_name_str == "receiver" + assert str_message.sender_node_str == "N1" + assert str_message.sender_name_str == "sender" + + +class Test_Message_data_payload_conversion: + def test_data_to_payload(self): + message = Message(b"r", b"s", data=([{5: "1asfd"}], 8)) + assert message.payload == [b'[[{"5": "1asfd"}], 8]'] + assert message.data == ([{5: "1asfd"}], 8) + + def test_payload_to_data(self): + message = Message.from_frames(b"v", b"r", b"s", b"h", b'[["G", ["nodes"]]]', b'p2') + assert message.payload == [b'[["G", ["nodes"]]]', b'p2'] + assert message.data == [["G", ["nodes"]]] + + +def test_message_comparison(): + m1 = Message.from_frames(VERSION_B, b"rec", b"send", b"x;y", b'[["GET", [1, 2]]') + m2 = Message.from_frames(VERSION_B, b"rec", b"send", b"x;y", b'[["GET", [1, 2]]') + assert m1 == m2 + + +@pytest.mark.parametrize("property", ("receiver", "sender", "header")) +def test_set_property_resets_cache(property): + m = Message(b"r") + setattr(m, f"_{property}_elements", [b"some", b"value"]) + setattr(m, property, b"new value") + assert getattr(m, f"_{property}_elements") is None + + +def test_get_frames_list_raises_error_on_empty_sender(): + m = Message(b"r") + with pytest.raises(ValueError): + m.to_frames() + + +class TestComparison: + def test_dictionary_order_is_irrelevant(self): + assert Message(b"r", data={"a": 1, "b": 2}) == Message(b"r", data={"b": 2, "a": 1}) + + def test_distinguish_empty_payload_frame(self): + m1 = Message("r") + m1.payload = [b""] + m2 = Message("r") + assert m2.payload == [] # verify that it does not have a payload + assert m1 != m2 + + diff --git a/tests/core/test_serialization.py b/tests/core/test_serialization.py new file mode 100644 index 000000000..d098aed22 --- /dev/null +++ b/tests/core/test_serialization.py @@ -0,0 +1,55 @@ +# +# This file is part of the PyLECO package. +# +# Copyright (c) 2023-2023 PyLECO Developers +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +import pytest +from jsonrpcobjects.objects import RequestObject + +from pyleco.core import serialization + + +@pytest.mark.parametrize("kwargs, header", ( + ({}, b";"), +)) +def test_create_header_frame(kwargs, header): + assert serialization.create_header_frame(**kwargs) == header + + +@pytest.mark.parametrize("full_name, node, name", ( + (b"local only", b"node", b"local only"), + (b"abc.def", b"abc", b"def"), +)) +def test_split_name(full_name, node, name): + assert serialization.split_name(full_name, b"node") == (node, name) + + +class Test_serialize: + def test_json_object(self): + obj = RequestObject(id=3, method="whatever") + expected = b'{"id": 3, "method": "whatever", "jsonrpc": "2.0"}' + assert serialization.serialize_data(obj) == expected + + def test_dict(self): + raw = {"some": "item", "key": "value", 5: [7, 3.1]} + expected = b'{"some": "item", "key": "value", "5": [7, 3.1]}' + assert serialization.serialize_data(raw) == expected From dff2fb534da10f8b87e16067bfa464b3011cac62 Mon Sep 17 00:00:00 2001 From: Benedikt Moneke Date: Sat, 1 Jul 2023 09:33:05 +0200 Subject: [PATCH 2/7] Change to UUIDv7 as conversation_id. --- pyleco/core/message.py | 21 ++++++++++---- pyleco/core/protocols.py | 7 ++--- pyleco/core/serialization.py | 45 +++++++++++++++++------------- pyproject.toml | 1 + tests/core/test_message.py | 47 ++++++++++++++++++++------------ tests/core/test_serialization.py | 44 +++++++++++++++++++++++++++--- 6 files changed, 114 insertions(+), 51 deletions(-) diff --git a/pyleco/core/message.py b/pyleco/core/message.py index affba0fef..42ae377d2 100644 --- a/pyleco/core/message.py +++ b/pyleco/core/message.py @@ -23,7 +23,7 @@ # from json import JSONDecodeError -from typing import Any, List, Optional, Self +from typing import Any, List, Optional, Self, Tuple from . import VERSION_B @@ -53,12 +53,15 @@ class Message: def __init__(self, receiver: bytes | str, sender: bytes | str = b"", data: Optional[bytes | str | Any] = None, header: Optional[bytes] = None, - conversation_id: bytes = b"", + conversation_id: Optional[bytes] = None, + message_id: Optional[bytes] = None, + message_type: Optional[bytes] = None, **kwargs) -> None: self._setup_caches() self.receiver = receiver if isinstance(receiver, bytes) else receiver.encode() self.sender = sender if isinstance(sender, bytes) else sender.encode() - self.header = (create_header_frame(conversation_id=conversation_id, **kwargs) + self.header = (create_header_frame(conversation_id=conversation_id, message_id=message_id, + message_type=message_type) if header is None else header) if isinstance(data, (bytes)): self.payload = [data] @@ -69,9 +72,9 @@ def __init__(self, receiver: bytes | str, sender: bytes | str = b"", def _setup_caches(self) -> None: self._payload: List[bytes] = [] - self._header_elements = None - self._sender_elements = None - self._receiver_elements = None + self._header_elements: Optional[Tuple[bytes, bytes, bytes]] = None + self._sender_elements: Optional[Tuple[bytes, bytes]] = None + self._receiver_elements: Optional[Tuple[bytes, bytes]] = None self._data = None @classmethod @@ -148,6 +151,12 @@ def message_id(self) -> bytes: self._header_elements = interpret_header(self.header) return self._header_elements[1] + @property + def message_type(self) -> bytes: + if self._header_elements is None: + self._header_elements = interpret_header(self.header) + return self._header_elements[2] + @property def receiver_node(self) -> bytes: if self._receiver_elements is None: diff --git a/pyleco/core/protocols.py b/pyleco/core/protocols.py index c3580f1e3..cc6e13c91 100644 --- a/pyleco/core/protocols.py +++ b/pyleco/core/protocols.py @@ -76,11 +76,10 @@ def set_properties(self, properties: Dict[str, Any]) -> None: ... def call_method(self, method: str, _args: Optional[list | tuple] = None, **kwargs) -> Any: ... + class PollingActor(Actor, Protocol): """An Actor which allows regular polling.""" - polling_interval: float - def start_polling(self, polling_interval: Optional[float]) -> None: ... def set_polling_interval(self, polling_interval: float) -> None: ... @@ -121,7 +120,7 @@ def sign_out(self) -> None: ... def send(self, receiver: str | bytes, - conversation_id: bytes = b"", + conversation_id: Optional[bytes] = None, data: Optional[Any] = None, **kwargs) -> None: """Send a message based on kwargs.""" @@ -131,7 +130,7 @@ def send(self, def send_message(self, message: Message) -> None: ... - def ask(self, receiver: bytes | str, conversation_id: bytes = b"", + def ask(self, receiver: bytes | str, conversation_id: Optional[bytes] = None, data: Optional[Any] = None, **kwargs) -> Message: """Send a message based on kwargs and retrieve the response.""" diff --git a/pyleco/core/serialization.py b/pyleco/core/serialization.py index 13a574370..c29cb3df7 100644 --- a/pyleco/core/serialization.py +++ b/pyleco/core/serialization.py @@ -23,22 +23,34 @@ # import json -import random -from time import time -import struct -from typing import Tuple +from typing import Optional, Tuple +from uuid_extensions import uuid7 # as long as uuid does not yet support UUIDv7 from pydantic import BaseModel -def create_header_frame(conversation_id: bytes = b"", message_id: bytes = b"") -> bytes: +def create_header_frame(conversation_id: Optional[bytes] = None, + message_id: Optional[bytes] = None, + message_type: Optional[bytes] = None) -> bytes: """Create the header frame. :param bytes conversation_id: ID of the conversation. - :param bytes message_id: Message ID of this message, must not contain ";". + :param bytes message_id: Message ID of this message, must not contain. :return: header frame. """ - return b";".join((conversation_id, message_id)) + if conversation_id is None: + conversation_id = generate_conversation_id() + elif len(conversation_id) != 16: + raise ValueError("Length of 'conversation_id' is not 16 bytes.") + if message_id is None: + message_id = b"\x00" * 3 + elif len(message_id) != 3: + raise ValueError("Length of 'message_id' is not 3 bytes.") + if message_type is None: + message_type = b"\x00" + elif len(message_type) != 1: + raise ValueError("Length of 'message_type' is not 1 bytes.") + return b"".join((conversation_id, message_id, message_type)) def split_name(name: bytes, node: bytes = b"") -> Tuple[bytes, bytes]: @@ -55,17 +67,15 @@ def split_name_str(name: str, node: str = "") -> Tuple[str, str]: return (s.pop() if s else node), n -def interpret_header(header: bytes) -> Tuple[bytes, bytes]: +def interpret_header(header: bytes) -> Tuple[bytes, bytes, bytes]: """Interpret the header frame. - :return: conversation_id, message_id + :return: conversation_id, message_id, message_type """ - try: - conversation_id, message_id = header.rsplit(b";", maxsplit=1) - except (IndexError, ValueError): - conversation_id = b"" - message_id = b"" - return conversation_id, message_id + conversation_id = header[:16] + message_id = header[16:19] + message_type = header[19:20] + return conversation_id, message_id, message_type def serialize_data(data: object) -> bytes: @@ -86,7 +96,4 @@ def deserialize_data(content: bytes) -> object: def generate_conversation_id() -> bytes: """Generate a conversation_id.""" - # struct.pack uses 8 bytes and takes 0.1 seconds for 1 Million repetitions. - # str().encode() uses 14 bytes and takes 0.5 seconds for 1 Million repetitions. - # !d is a double (8 bytes) in network byte order (big-endian) - return struct.pack("!d", time()) + random.randbytes(2) + return uuid7(as_type="bytes") # type: ignore diff --git a/pyproject.toml b/pyproject.toml index 41dca3092..405e9fc1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "pyzmq", "openrpc", "jsonrpc2-pyclient", + "uuid7", ] [project.optional-dependencies] diff --git a/tests/core/test_message.py b/tests/core/test_message.py index 1011a7541..6b1d6c309 100644 --- a/tests/core/test_message.py +++ b/tests/core/test_message.py @@ -29,10 +29,13 @@ from pyleco.core.message import Message +cid = b"conversation_id;" + + class Test_Message_from_frames: @pytest.fixture def message(self) -> Message: - return Message.from_frames(b"\xffo", b"rec", b"send", b"x;y", + return Message.from_frames(b"\xffo", b"rec", b"send", b"conversation_id;mid0", b'[["GET", [1, 2]], ["GET", 3]]') def test_payload(self, message: Message): @@ -48,7 +51,13 @@ def test_sender(self, message: Message): assert message.sender == b"send" def test_header(self, message: Message): - assert message.header == b"x;y" + assert message.header == b"conversation_id;mid0" + + def test_conversation_id(self, message: Message): + assert message.conversation_id == b"conversation_id;" + + def test_message_id(self, message: Message): + assert message.message_id == b"mid" def test_multiple_payload_frames(self): message = Message.from_frames( @@ -61,8 +70,8 @@ def test_no_payload(self): assert message.payload == [] def test_get_frames_list(self, message: Message): - assert message.to_frames() == [b"\xffo", b"rec", b"send", b"x;y", - b'[["GET", [1, 2]], ["GET", 3]]'] + assert message.to_frames() == [b"\xffo", b"rec", b"send", b"conversation_id;mid0", + b'[["GET", [1, 2]], ["GET", 3]]'] class Test_Message_create_message: @@ -72,8 +81,8 @@ def message(self) -> Message: receiver=b"rec", sender=b"send", data=[["GET", [1, 2]], ["GET", 3]], - conversation_id=b"x", - message_id=b"y", + conversation_id=b"conversation_id;", + message_id=b"mid", ) def test_payload(self, message: Message): @@ -89,25 +98,26 @@ def test_sender(self, message: Message): assert message.sender == b"send" def test_header(self, message: Message): - assert message.header == b"x;y" + assert message.header == b"conversation_id;mid\x00" - def test_get_frames_list(self, message: Message): + def test_to_frames(self, message: Message): assert message.to_frames() == [ VERSION_B, b"rec", b"send", - b"x;y", + b"conversation_id;mid\x00", b'[["GET", [1, 2]], ["GET", 3]]', ] - def test_get_frames_list_without_payload(self, message: Message): + def test_to_frames_without_payload(self, message: Message): message.payload = [] - assert message.to_frames() == [VERSION_B, b"rec", b"send", b"x;y"] + assert message.to_frames() == [VERSION_B, b"rec", b"send", b"conversation_id;mid\x00"] def test_message_without_data_does_not_have_payload_frame(self): - message = Message(b"rec", "send") + message = Message(b"rec", "send", conversation_id=b"conversation_id;") assert message.payload == [] - assert message.to_frames() == [VERSION_B, b"rec", b"send", b";"] + assert message.to_frames() == [VERSION_B, b"rec", b"send", + b"conversation_id;\x00\x00\x00\x00"] def test_message_binary_data(self): message = Message(b"rec", data=b"binary data") @@ -117,6 +127,7 @@ def test_message_data_str_to_binary_data(self): message = Message(b"rec", data="some string") assert message.payload[0] == b"some string" + class Test_Message_with_strings: @pytest.fixture def str_message(self) -> Message: @@ -180,13 +191,13 @@ def test_get_frames_list_raises_error_on_empty_sender(): class TestComparison: def test_dictionary_order_is_irrelevant(self): - assert Message(b"r", data={"a": 1, "b": 2}) == Message(b"r", data={"b": 2, "a": 1}) + m1 = Message(b"r", conversation_id=cid, data={"a": 1, "b": 2}) + m2 = Message(b"r", conversation_id=cid, data={"b": 2, "a": 1}) + assert m1 == m2 def test_distinguish_empty_payload_frame(self): - m1 = Message("r") + m1 = Message("r", conversation_id=b"conversation_id;") m1.payload = [b""] - m2 = Message("r") + m2 = Message("r", conversation_id=b"conversation_id;") assert m2.payload == [] # verify that it does not have a payload assert m1 != m2 - - diff --git a/tests/core/test_serialization.py b/tests/core/test_serialization.py index d098aed22..74e8d8fb8 100644 --- a/tests/core/test_serialization.py +++ b/tests/core/test_serialization.py @@ -28,11 +28,29 @@ from pyleco.core import serialization -@pytest.mark.parametrize("kwargs, header", ( - ({}, b";"), +class Test_create_header_frame: + @pytest.mark.parametrize("kwargs, header", ( + ({"conversation_id": b"\x00" * 16, }, bytes([0] * 20)), + )) + def test_create_header_frame(self, kwargs, header): + assert serialization.create_header_frame(**kwargs) == header + + @pytest.mark.parametrize("cid_l", (0, 1, 2, 4, 6, 7, 10, 15, 17, 20)) + def test_wrong_cid_length_raises_errors(self, cid_l): + with pytest.raises(ValueError, match="'conversation_id'"): + serialization.create_header_frame(conversation_id=bytes([0] * cid_l)) + + @pytest.mark.parametrize("mid_l", (0, 1, 2, 4, 6, 7)) + def test_wrong_mid_length_raises_errors(self, mid_l): + with pytest.raises(ValueError, match="'message_id'"): + serialization.create_header_frame(message_id=bytes([0] * mid_l)) + + +@pytest.mark.parametrize("header, conversation_id, message_id, message_type", ( + (bytes(range(20)), bytes(range(16)), b"\x10\x11\x12", b"\x13"), )) -def test_create_header_frame(kwargs, header): - assert serialization.create_header_frame(**kwargs) == header +def test_interpret_header(header, conversation_id, message_id, message_type): + assert serialization.interpret_header(header) == (conversation_id, message_id, message_type) @pytest.mark.parametrize("full_name, node, name", ( @@ -53,3 +71,21 @@ def test_dict(self): raw = {"some": "item", "key": "value", 5: [7, 3.1]} expected = b'{"some": "item", "key": "value", "5": [7, 3.1]}' assert serialization.serialize_data(raw) == expected + + +class Test_generate_conversation_id_is_UUIDv7: + @pytest.fixture + def conversation_id(self): + return serialization.generate_conversation_id() + + def test_type_is_bytes(self, conversation_id): + assert isinstance(conversation_id, bytes) + + def test_length(self, conversation_id): + assert len(conversation_id) == 16 + + def test_UUID_version(self, conversation_id): + assert conversation_id[6] >> 4 == 7 + + def test_variant(self, conversation_id): + assert conversation_id[8] >> 6 == 0b10 From ff980c10f08c43b5a457baf07c6b7feddf418134 Mon Sep 17 00:00:00 2001 From: Benedikt Moneke Date: Sat, 1 Jul 2023 19:16:32 +0200 Subject: [PATCH 3/7] Improvements from review. Type hinting improved. Comments added. Changed to LECO lingo. named tuples for header and names. --- pyleco/core/message.py | 40 ++++++------ pyleco/core/protocols.py | 18 +++--- pyleco/core/rpc_generator.py | 10 +-- pyleco/core/serialization.py | 45 ++++++++++--- pyleco/errors.py | 2 +- tests/core/test_message.py | 122 +++++++++++++++-------------------- 6 files changed, 121 insertions(+), 116 deletions(-) diff --git a/pyleco/core/message.py b/pyleco/core/message.py index 42ae377d2..72c2abbdb 100644 --- a/pyleco/core/message.py +++ b/pyleco/core/message.py @@ -23,12 +23,12 @@ # from json import JSONDecodeError -from typing import Any, List, Optional, Self, Tuple +from typing import Any, Optional, Self from . import VERSION_B from .serialization import (create_header_frame, serialize_data, interpret_header, split_name, - deserialize_data, + deserialize_data, FullName, Header ) @@ -63,18 +63,18 @@ def __init__(self, receiver: bytes | str, sender: bytes | str = b"", self.header = (create_header_frame(conversation_id=conversation_id, message_id=message_id, message_type=message_type) if header is None else header) - if isinstance(data, (bytes)): + if isinstance(data, bytes): self.payload = [data] - if isinstance(data, str): + elif isinstance(data, str): self.payload = [data.encode()] else: self._data = data def _setup_caches(self) -> None: - self._payload: List[bytes] = [] - self._header_elements: Optional[Tuple[bytes, bytes, bytes]] = None - self._sender_elements: Optional[Tuple[bytes, bytes]] = None - self._receiver_elements: Optional[Tuple[bytes, bytes]] = None + self._payload: list[bytes] = [] + self._header_elements: None | Header = None + self._sender_elements: None | FullName = None + self._receiver_elements: None | FullName = None self._data = None @classmethod @@ -92,13 +92,13 @@ def from_frames(cls, version: bytes, receiver: bytes, sender: bytes, header: byt inst.payload = list(payload) return inst - def to_frames(self) -> List[bytes]: + def to_frames(self) -> list[bytes]: """Get a list representation of the message, ready for sending it.""" if not self.sender: raise ValueError("Empty sender frame not allowed to send.") return self._to_frames_without_sender_check() - def _to_frames_without_sender_check(self) -> List[bytes]: + def _to_frames_without_sender_check(self) -> list[bytes]: return [self.version, self.receiver, self.sender, self.header] + self.payload @property @@ -129,13 +129,13 @@ def header(self, value: bytes): self._header_elements = None # reset cache @property - def payload(self) -> List[bytes]: + def payload(self) -> list[bytes]: if self._payload == [] and self._data is not None: self._payload = [serialize_data(self._data)] return self._payload @payload.setter - def payload(self, value: List[bytes]) -> None: + def payload(self, value: list[bytes]) -> None: self._payload = value self._data = None # reset data @@ -143,43 +143,43 @@ def payload(self, value: List[bytes]) -> None: def conversation_id(self) -> bytes: if self._header_elements is None: self._header_elements = interpret_header(self.header) - return self._header_elements[0] + return self._header_elements.conversation_id @property def message_id(self) -> bytes: if self._header_elements is None: self._header_elements = interpret_header(self.header) - return self._header_elements[1] + return self._header_elements.message_id @property def message_type(self) -> bytes: if self._header_elements is None: self._header_elements = interpret_header(self.header) - return self._header_elements[2] + return self._header_elements.message_type @property def receiver_node(self) -> bytes: if self._receiver_elements is None: self._receiver_elements = split_name(self.receiver) - return self._receiver_elements[0] + return self._receiver_elements.namespace @property def receiver_name(self) -> bytes: if self._receiver_elements is None: self._receiver_elements = split_name(self.receiver) - return self._receiver_elements[1] + return self._receiver_elements.name @property def sender_node(self) -> bytes: if self._sender_elements is None: self._sender_elements = split_name(self.sender) - return self._sender_elements[0] + return self._sender_elements.namespace @property def sender_name(self) -> bytes: if self._sender_elements is None: self._sender_elements = split_name(self.sender) - return self._sender_elements[1] + return self._sender_elements.name @property def data(self) -> object: @@ -197,9 +197,11 @@ def __eq__(self, other: Any) -> bool: and self.header == other.header ) try: + # Try to compare the data (python objects) instead of their bytes representation. my_data = self.data other_data = other.data except JSONDecodeError: + # Maybe the payload is binary, compare the raw payload return partial_comparison and self.payload == other.payload else: return (partial_comparison and my_data == other_data diff --git a/pyleco/core/protocols.py b/pyleco/core/protocols.py index cc6e13c91..aaaa0dd10 100644 --- a/pyleco/core/protocols.py +++ b/pyleco/core/protocols.py @@ -22,10 +22,10 @@ # THE SOFTWARE. # -from typing import Any, Dict, List, Optional, Protocol, Tuple +from typing import Any, Optional, Protocol, Union from .message import Message -from ..core.rpc_generator import RPCGenerator +from .rpc_generator import RPCGenerator """ @@ -34,7 +34,7 @@ class Component(Protocol): - """Any Component of the pyleco protocol.""" + """Any Component of the LECO protocol.""" def pong(self) -> None: """Respond to any request.""" @@ -46,7 +46,7 @@ class ExtendedComponent(Component, Protocol): def set_log_level(self, level: int) -> None: ... - def shutdown(self) -> None: ... + def shut_down(self) -> None: ... class Coordinator(Component, Protocol): @@ -60,7 +60,7 @@ def coordinator_sign_in(self) -> None: ... def coordinator_sign_out(self) -> None: ... - def set_nodes(self, nodes: Dict[str, str]) -> None: ... + def set_nodes(self, nodes: dict[str, str]) -> None: ... def compose_global_directory(self) -> dict: ... @@ -70,11 +70,11 @@ def compose_local_directory(self) -> dict: ... class Actor(Component, Protocol): """An Actor Component.""" - def get_properties(self, properties: List[str] | Tuple[str, ...]) -> Dict[str, Any]: ... + def get_parameters(self, parameters: Union[list[str], tuple[str, ...]]) -> dict[str, Any]: ... - def set_properties(self, properties: Dict[str, Any]) -> None: ... + def set_parameters(self, parameters: dict[str, Any]) -> None: ... - def call_method(self, method: str, _args: Optional[list | tuple] = None, **kwargs) -> Any: ... + def call_action(self, action: str, _args: Optional[list | tuple] = None, **kwargs) -> Any: ... class PollingActor(Actor, Protocol): @@ -100,7 +100,7 @@ def force_unlock(self, resource: Optional[str] = None) -> None: ... """ -These classes show the API of tools, which talk with the LECO protocol. +These classes show the API of tools that talk with the LECO protocol. Any Component could use these tools in order to send and read messsages. For example a Director might use these tools to direct an Actor. diff --git a/pyleco/core/rpc_generator.py b/pyleco/core/rpc_generator.py index 7a614e7bf..77d288db0 100644 --- a/pyleco/core/rpc_generator.py +++ b/pyleco/core/rpc_generator.py @@ -35,15 +35,11 @@ class RPCGenerator(IRPCClient): def build_request_str(self, method: str, params: Optional[list | dict] = None, **kwargs) -> str: if kwargs and isinstance(params, list): raise ValueError( - "You may not specify positional arguments and keyword arguments at the same time.") + "You may not specify list of positional arguments in `params` " + "and give additional keyword arguments at the same time.") if isinstance(params, dict): params.update(kwargs) - if params: - return self._build_request(method=method, params=params).json() - elif kwargs: - return self._build_request(method=method, params=kwargs).json() - else: - return self._build_request(method=method, params=None).json() + return self._build_request(method=method, params=kwargs or params).json() def get_result_from_response(self, data: bytes | str) -> Any: """Get the result of that object or raise an error.""" diff --git a/pyleco/core/serialization.py b/pyleco/core/serialization.py index c29cb3df7..11d86c765 100644 --- a/pyleco/core/serialization.py +++ b/pyleco/core/serialization.py @@ -23,10 +23,35 @@ # import json -from typing import Optional, Tuple +from typing import Optional, NamedTuple from uuid_extensions import uuid7 # as long as uuid does not yet support UUIDv7 -from pydantic import BaseModel +from jsonrpcobjects.objects import (RequestObject, RequestObjectParams, + ResultResponseObject, + ErrorResponseObject, + NotificationObject, NotificationObjectParams, + ) + + +json_objects = ( + RequestObject, + RequestObjectParams, + ResultResponseObject, + ErrorResponseObject, + NotificationObject, + NotificationObjectParams, +) + + +class FullName(NamedTuple): + namespace: bytes + name: bytes + + +class Header(NamedTuple): + conversation_id: bytes + message_id: bytes + message_type: bytes def create_header_frame(conversation_id: Optional[bytes] = None, @@ -35,7 +60,7 @@ def create_header_frame(conversation_id: Optional[bytes] = None, """Create the header frame. :param bytes conversation_id: ID of the conversation. - :param bytes message_id: Message ID of this message, must not contain. + :param bytes message_id: Message ID of this message. :return: header frame. """ if conversation_id is None: @@ -53,21 +78,21 @@ def create_header_frame(conversation_id: Optional[bytes] = None, return b"".join((conversation_id, message_id, message_type)) -def split_name(name: bytes, node: bytes = b"") -> Tuple[bytes, bytes]: +def split_name(name: bytes, node: bytes = b"") -> FullName: """Split a sender/receiver name with given default node.""" s = name.split(b".") n = s.pop(-1) - return (s.pop() if s else node), n + return FullName((s.pop() if s else node), n) -def split_name_str(name: str, node: str = "") -> Tuple[str, str]: +def split_name_str(name: str, node: str = "") -> tuple[str, str]: """Split a sender/receiver name with given default node.""" s = name.split(".") n = s.pop(-1) return (s.pop() if s else node), n -def interpret_header(header: bytes) -> Tuple[bytes, bytes, bytes]: +def interpret_header(header: bytes) -> Header: """Interpret the header frame. :return: conversation_id, message_id, message_type @@ -75,7 +100,7 @@ def interpret_header(header: bytes) -> Tuple[bytes, bytes, bytes]: conversation_id = header[:16] message_id = header[16:19] message_type = header[19:20] - return conversation_id, message_id, message_type + return Header(conversation_id, message_id, message_type) def serialize_data(data: object) -> bytes: @@ -83,8 +108,8 @@ def serialize_data(data: object) -> bytes: Due to json serialization, data must not contain a bytes object! """ - if isinstance(data, BaseModel): - return data.json().encode() + if isinstance(data, json_objects): + return data.json().encode() # type: ignore else: return json.dumps(data).encode() diff --git a/pyleco/errors.py b/pyleco/errors.py index 168de9b7f..993176c53 100644 --- a/pyleco/errors.py +++ b/pyleco/errors.py @@ -40,7 +40,7 @@ def generate_error_with_data(error: ErrorObject, data: Any) -> ErrorObjectData: class CommunicationError(ConnectionError): - """Something went wrong, send a `error_msg` to the recipient.""" + """Something went wrong, send an `error_msg` to the recipient.""" def __init__(self, text: str, error_payload: ErrorResponseObject, *args: Any) -> None: super().__init__(text, *args) diff --git a/tests/core/test_message.py b/tests/core/test_message.py index 6b1d6c309..e24037777 100644 --- a/tests/core/test_message.py +++ b/tests/core/test_message.py @@ -32,59 +32,19 @@ cid = b"conversation_id;" -class Test_Message_from_frames: - @pytest.fixture - def message(self) -> Message: - return Message.from_frames(b"\xffo", b"rec", b"send", b"conversation_id;mid0", - b'[["GET", [1, 2]], ["GET", 3]]') - - def test_payload(self, message: Message): - assert message.payload == [b'[["GET", [1, 2]], ["GET", 3]]'] - - def test_version(self, message: Message): - assert message.version == b"\xffo" - - def test_receiver(self, message: Message): - assert message.receiver == b"rec" - - def test_sender(self, message: Message): - assert message.sender == b"send" - - def test_header(self, message: Message): - assert message.header == b"conversation_id;mid0" - - def test_conversation_id(self, message: Message): - assert message.conversation_id == b"conversation_id;" - - def test_message_id(self, message: Message): - assert message.message_id == b"mid" - - def test_multiple_payload_frames(self): - message = Message.from_frames( - b"\xffo", b"broker", b"", b";", b'[["GET", [1, 2]], ["GET", 3]]', b"additional stuff" - ) - assert message.payload == [b'[["GET", [1, 2]], ["GET", 3]]', b"additional stuff"] - - def test_no_payload(self): - message = Message.from_frames(VERSION_B, b"broker", b"", b";") - assert message.payload == [] - - def test_get_frames_list(self, message: Message): - assert message.to_frames() == [b"\xffo", b"rec", b"send", b"conversation_id;mid0", - b'[["GET", [1, 2]], ["GET", 3]]'] +@pytest.fixture +def message() -> Message: + return Message( + receiver=b"N1.receiver", + sender=b"N2.sender", + data=[["GET", [1, 2]], ["GET", 3]], + conversation_id=cid, + message_id=b"mid", + message_type=b"T" + ) class Test_Message_create_message: - @pytest.fixture - def message(self) -> Message: - return Message( - receiver=b"rec", - sender=b"send", - data=[["GET", [1, 2]], ["GET", 3]], - conversation_id=b"conversation_id;", - message_id=b"mid", - ) - def test_payload(self, message: Message): assert message.payload == [b'[["GET", [1, 2]], ["GET", 3]]'] @@ -92,35 +52,31 @@ def test_version(self, message: Message): assert message.version == VERSION_B def test_receiver(self, message: Message): - assert message.receiver == b"rec" + assert message.receiver == b"N1.receiver" def test_sender(self, message: Message): - assert message.sender == b"send" + assert message.sender == b"N2.sender" def test_header(self, message: Message): - assert message.header == b"conversation_id;mid\x00" + assert message.header == b"conversation_id;midT" def test_to_frames(self, message: Message): assert message.to_frames() == [ VERSION_B, - b"rec", - b"send", - b"conversation_id;mid\x00", + b"N1.receiver", + b"N2.sender", + b"conversation_id;midT", b'[["GET", [1, 2]], ["GET", 3]]', ] - def test_to_frames_without_payload(self, message: Message): - message.payload = [] - assert message.to_frames() == [VERSION_B, b"rec", b"send", b"conversation_id;mid\x00"] - def test_message_without_data_does_not_have_payload_frame(self): - message = Message(b"rec", "send", conversation_id=b"conversation_id;") + message = Message(b"N1.receiver", b"N2.sender", conversation_id=b"conversation_id;") assert message.payload == [] - assert message.to_frames() == [VERSION_B, b"rec", b"send", + assert message.to_frames() == [VERSION_B, b"N1.receiver", b"N2.sender", b"conversation_id;\x00\x00\x00\x00"] def test_message_binary_data(self): - message = Message(b"rec", data=b"binary data") + message = Message(b"N1.receiver", data=b"binary data") assert message.payload[0] == b"binary data" def test_message_data_str_to_binary_data(self): @@ -128,7 +84,34 @@ def test_message_data_str_to_binary_data(self): assert message.payload[0] == b"some string" -class Test_Message_with_strings: +class Test_Message_from_frames: + def test_message_from_frames(self, message: Message): + message.version = b"diff" # also if the version is different + assert Message.from_frames(*message.to_frames()) == message + + def test_different_version(self, message: Message): + message.version = b"diff" # also if the version is different + new_message = Message.from_frames(*message.to_frames()) + assert new_message.version == b"diff" + + def test_multiple_payload_frames(self): + message = Message.from_frames( + b"\xffo", b"broker", b"", b";", b'[["GET", [1, 2]], ["GET", 3]]', b"additional stuff" + ) + assert message.payload == [b'[["GET", [1, 2]], ["GET", 3]]', b"additional stuff"] + + def test_no_payload(self): + message = Message.from_frames(VERSION_B, b"broker", b"", b"") + assert message.payload == [] + + +def test_to_frames_without_payload(message: Message): + message.payload = [] + assert message.to_frames() == [VERSION_B, b"N1.receiver", b"N2.sender", + b"conversation_id;midT"] + + +class Test_Message_with_string_parameters: @pytest.fixture def str_message(self) -> Message: message = Message(receiver="N2.receiver", sender="N1.sender") @@ -169,12 +152,6 @@ def test_payload_to_data(self): assert message.data == [["G", ["nodes"]]] -def test_message_comparison(): - m1 = Message.from_frames(VERSION_B, b"rec", b"send", b"x;y", b'[["GET", [1, 2]]') - m2 = Message.from_frames(VERSION_B, b"rec", b"send", b"x;y", b'[["GET", [1, 2]]') - assert m1 == m2 - - @pytest.mark.parametrize("property", ("receiver", "sender", "header")) def test_set_property_resets_cache(property): m = Message(b"r") @@ -190,6 +167,11 @@ def test_get_frames_list_raises_error_on_empty_sender(): class TestComparison: + def test_message_comparison(self): + m1 = Message.from_frames(VERSION_B, b"rec", b"send", b"x;y", b'[["GET", [1, 2]]') + m2 = Message.from_frames(VERSION_B, b"rec", b"send", b"x;y", b'[["GET", [1, 2]]') + assert m1 == m2 + def test_dictionary_order_is_irrelevant(self): m1 = Message(b"r", conversation_id=cid, data={"a": 1, "b": 2}) m2 = Message(b"r", conversation_id=cid, data={"b": 2, "a": 1}) From bc87c065bdc1d45b8cfd166d5c29239bb033efd2 Mon Sep 17 00:00:00 2001 From: Benedikt Moneke <67148916+bmoneke@users.noreply.github.com> Date: Mon, 10 Jul 2023 11:26:13 +0200 Subject: [PATCH 4/7] Error message improved. --- pyleco/core/serialization.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyleco/core/serialization.py b/pyleco/core/serialization.py index 11d86c765..746872192 100644 --- a/pyleco/core/serialization.py +++ b/pyleco/core/serialization.py @@ -65,8 +65,8 @@ def create_header_frame(conversation_id: Optional[bytes] = None, """ if conversation_id is None: conversation_id = generate_conversation_id() - elif len(conversation_id) != 16: - raise ValueError("Length of 'conversation_id' is not 16 bytes.") + elif (length := len(conversation_id)) != 16: + raise ValueError(f"Length of 'conversation_id' is {length}, not 16 bytes.") if message_id is None: message_id = b"\x00" * 3 elif len(message_id) != 3: From 2beb7e1f6765258748a4d7dd773c9b7333b3cc40 Mon Sep 17 00:00:00 2001 From: Benedikt Moneke <67148916+bmoneke@users.noreply.github.com> Date: Tue, 11 Jul 2023 12:33:14 +0200 Subject: [PATCH 5/7] Improvements from review process. Protocols separated into leco ones and internal (to pyleco) ones. Protocols carry `Protocol` in their name. Message includes more checks and does not include cached values anymore. RPCGenerator simplified. --- pyleco/core/internal_protocols.py | 76 +++++++++++ .../core/{protocols.py => leco_protocols.py} | 101 +++++++-------- pyleco/core/message.py | 121 +++++------------- pyleco/core/rpc_generator.py | 14 +- pyleco/core/serialization.py | 17 ++- tests/core/test_message.py | 52 +++++--- tests/test_import.py | 4 - 7 files changed, 206 insertions(+), 179 deletions(-) create mode 100644 pyleco/core/internal_protocols.py rename pyleco/core/{protocols.py => leco_protocols.py} (58%) delete mode 100644 tests/test_import.py diff --git a/pyleco/core/internal_protocols.py b/pyleco/core/internal_protocols.py new file mode 100644 index 000000000..bd7e04fb9 --- /dev/null +++ b/pyleco/core/internal_protocols.py @@ -0,0 +1,76 @@ +# +# This file is part of the PyLECO package. +# +# Copyright (c) 2023-2023 PyLECO Developers +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +""" +These classes show pyleco's internal API of tools that talk with the LECO protocol. + +They are not defined by LECO itself as it does not touch the message transfer. + +Any Component could use these tools in order to send and read messsages. +For example a Director might use these tools to direct an Actor. +""" + +from typing import Any, Optional, Protocol + +from .leco_protocols import ComponentProtocol +from .message import Message +from .rpc_generator import RPCGenerator + + +class CommunicatorProtocol(ComponentProtocol, Protocol): + """A helper class for a Component, to communicate via the LECO protocol. + + For example a Director might use such a class to send/read messages to/from an Actor. + """ + + name: str + namespace: Optional[str] = None + rpc_generator: RPCGenerator + + def sign_in(self) -> None: ... + + def sign_out(self) -> None: ... + + def send(self, + receiver: str | bytes, + conversation_id: Optional[bytes] = None, + data: Optional[Any] = None, + **kwargs) -> None: + """Send a message based on kwargs.""" + self.send_message(message=Message( + receiver=receiver, conversation_id=conversation_id, data=data, **kwargs + )) + + def send_message(self, message: Message) -> None: ... + + def ask(self, receiver: bytes | str, conversation_id: Optional[bytes] = None, + data: Optional[Any] = None, + **kwargs) -> Message: + """Send a message based on kwargs and retrieve the response.""" + return self.ask_message(message=Message( + receiver=receiver, conversation_id=conversation_id, data=data, **kwargs)) + + def ask_message(self, message: Message) -> Message: ... + + def close(self) -> None: ... diff --git a/pyleco/core/protocols.py b/pyleco/core/leco_protocols.py similarity index 58% rename from pyleco/core/protocols.py rename to pyleco/core/leco_protocols.py index aaaa0dd10..6ece2a269 100644 --- a/pyleco/core/protocols.py +++ b/pyleco/core/leco_protocols.py @@ -22,18 +22,54 @@ # THE SOFTWARE. # -from typing import Any, Optional, Protocol, Union +""" +These classes show which methods have to be available via RPC in order to comply with LECO message +definitions. -from .message import Message -from .rpc_generator import RPCGenerator +A combination of type checking and unit tests can test the compliance with LECO definitions. +For example, if you want to verify, that the class `Actor` fulfills the requirements for a +Component, for an Actor which supports Polling and setting the log level, you may use the following +tests. -""" -These classes show the remotely available methods via rpc. +For a static test, that all the methods are present with the correct types, the following works: + +.. code:: + + class ExtendedActorProtocol(ExtendedComponentProtocol, PollingActorProtocol, Protocol): + "Combine all required Protocols for the class under test." + pass + + def static_test_methods_are_present(): + def testing(component: ExtendedActorProtocol): + pass + testing(Actor(name="test", cls=FantasyInstrument)) + +For unit test, that all the necessary methods are reachable via RPC, the following works: + +.. code:: + + protocol_methods = [m for m in dir(ExtendedActorProtocol) if not m.startswith("_")] + + @pytest.fixture + def component_methods(actor: Actor): + response = actor.rpc.process_request( + '{"id": 1, "method": "rpc.discover", "jsonrpc": "2.0"}') + result = actor.rpc_generator.get_result_from_response(response) # type: ignore + return result.get('methods') + + @pytest.mark.parametrize("method", protocol_methods) + def test_method_is_available(component_methods, method): + for m in component_methods: + if m.get('name') == method: + return + raise AssertionError(f"Method {method} is not available.") """ +from typing import Any, Optional, Protocol, Union -class Component(Protocol): + +class ComponentProtocol(Protocol): """Any Component of the LECO protocol.""" def pong(self) -> None: @@ -41,7 +77,7 @@ def pong(self) -> None: return # always succeeds. -class ExtendedComponent(Component, Protocol): +class ExtendedComponentProtocol(ComponentProtocol, Protocol): """A Component which supports more features.""" def set_log_level(self, level: int) -> None: ... @@ -49,7 +85,7 @@ def set_log_level(self, level: int) -> None: ... def shut_down(self) -> None: ... -class Coordinator(Component, Protocol): +class CoordinatorProtocol(ComponentProtocol, Protocol): """A command protocol Coordinator""" def sign_in(self) -> None: ... @@ -67,7 +103,7 @@ def compose_global_directory(self) -> dict: ... def compose_local_directory(self) -> dict: ... -class Actor(Component, Protocol): +class ActorProtocol(ComponentProtocol, Protocol): """An Actor Component.""" def get_parameters(self, parameters: Union[list[str], tuple[str, ...]]) -> dict[str, Any]: ... @@ -77,7 +113,7 @@ def set_parameters(self, parameters: dict[str, Any]) -> None: ... def call_action(self, action: str, _args: Optional[list | tuple] = None, **kwargs) -> Any: ... -class PollingActor(Actor, Protocol): +class PollingActorProtocol(ActorProtocol, Protocol): """An Actor which allows regular polling.""" def start_polling(self, polling_interval: Optional[float]) -> None: ... @@ -89,7 +125,7 @@ def get_polling_interval(self) -> float: ... def stop_polling(self) -> None: ... -class LockingActor(Actor, Protocol): +class LockingActorProtocol(ActorProtocol, Protocol): """An Actor which allows to lock the device or channels of the device.""" def lock(self, resource: Optional[str] = None) -> bool: ... @@ -97,46 +133,3 @@ def lock(self, resource: Optional[str] = None) -> bool: ... def unlock(self, resource: Optional[str] = None) -> None: ... def force_unlock(self, resource: Optional[str] = None) -> None: ... - - -""" -These classes show the API of tools that talk with the LECO protocol. - -Any Component could use these tools in order to send and read messsages. -For example a Director might use these tools to direct an Actor. -""" - - -class Communicator(Component, Protocol): - """A helper class for a Component, to communicate via the LECO protocol.""" - - name: str - node: Optional[str] = None - rpc_generator: RPCGenerator - - def sign_in(self) -> None: ... - - def sign_out(self) -> None: ... - - def send(self, - receiver: str | bytes, - conversation_id: Optional[bytes] = None, - data: Optional[Any] = None, - **kwargs) -> None: - """Send a message based on kwargs.""" - self.send_message(message=Message( - receiver=receiver, conversation_id=conversation_id, data=data, **kwargs - )) - - def send_message(self, message: Message) -> None: ... - - def ask(self, receiver: bytes | str, conversation_id: Optional[bytes] = None, - data: Optional[Any] = None, - **kwargs) -> Message: - """Send a message based on kwargs and retrieve the response.""" - return self.ask_message(message=Message( - receiver=receiver, conversation_id=conversation_id, data=data, **kwargs)) - - def ask_message(self, message: Message) -> Message: ... - - def close(self) -> None: ... diff --git a/pyleco/core/message.py b/pyleco/core/message.py index 72c2abbdb..825ccd683 100644 --- a/pyleco/core/message.py +++ b/pyleco/core/message.py @@ -28,7 +28,7 @@ from . import VERSION_B from .serialization import (create_header_frame, serialize_data, interpret_header, split_name, - deserialize_data, FullName, Header + deserialize_data, FullName, Header, FullNameStr, split_name_str, ) @@ -44,11 +44,16 @@ class Message: - 0 or more `payload` frames If you do not specify a sender, the sending program shall add it itself. - The :attr:`data` attribute is the content of the first :attr:`payload` frame. + The :attr:`data` attribute is the content of the first :attr:`payload` frame. It can be set with + the corresponding argument. All attributes, except the official frames, are for convenience. """ version: bytes = VERSION_B + receiver: bytes + sender: bytes + header: bytes + payload: list[bytes] def __init__(self, receiver: bytes | str, sender: bytes | str = b"", data: Optional[bytes | str | Any] = None, @@ -56,10 +61,12 @@ def __init__(self, receiver: bytes | str, sender: bytes | str = b"", conversation_id: Optional[bytes] = None, message_id: Optional[bytes] = None, message_type: Optional[bytes] = None, - **kwargs) -> None: - self._setup_caches() + ) -> None: self.receiver = receiver if isinstance(receiver, bytes) else receiver.encode() self.sender = sender if isinstance(sender, bytes) else sender.encode() + if header and (conversation_id or message_id or message_type): + raise ValueError( + "You may not specify the header and some header element at the same time!") self.header = (create_header_frame(conversation_id=conversation_id, message_id=message_id, message_type=message_type) if header is None else header) @@ -67,15 +74,10 @@ def __init__(self, receiver: bytes | str, sender: bytes | str = b"", self.payload = [data] elif isinstance(data, str): self.payload = [data.encode()] + elif data is None: + self.payload = [] else: - self._data = data - - def _setup_caches(self) -> None: - self._payload: list[bytes] = [] - self._header_elements: None | Header = None - self._sender_elements: None | FullName = None - self._receiver_elements: None | FullName = None - self._data = None + self.payload = [serialize_data(data)] @classmethod def from_frames(cls, version: bytes, receiver: bytes, sender: bytes, header: bytes, @@ -101,91 +103,34 @@ def to_frames(self) -> list[bytes]: def _to_frames_without_sender_check(self) -> list[bytes]: return [self.version, self.receiver, self.sender, self.header] + self.payload + # Convenience methods to access elements @property - def receiver(self) -> bytes: - return self._receiver - - @receiver.setter - def receiver(self, value: bytes): - self._receiver = value - self._receiver_elements = None # reset cache - - @property - def sender(self) -> bytes: - return self._sender - - @sender.setter - def sender(self, value: bytes): - self._sender = value - self._sender_elements = None # reset cache + def receiver_elements(self) -> FullName: + return split_name(self.receiver) @property - def header(self) -> bytes: - return self._header - - @header.setter - def header(self, value: bytes): - self._header = value - self._header_elements = None # reset cache + def sender_elements(self) -> FullName: + return split_name(self.sender) @property - def payload(self) -> list[bytes]: - if self._payload == [] and self._data is not None: - self._payload = [serialize_data(self._data)] - return self._payload - - @payload.setter - def payload(self, value: list[bytes]) -> None: - self._payload = value - self._data = None # reset data + def header_elements(self) -> Header: + return interpret_header(self.header) @property def conversation_id(self) -> bytes: - if self._header_elements is None: - self._header_elements = interpret_header(self.header) - return self._header_elements.conversation_id + return self.header_elements.conversation_id @property def message_id(self) -> bytes: - if self._header_elements is None: - self._header_elements = interpret_header(self.header) - return self._header_elements.message_id + return self.header_elements.message_id @property def message_type(self) -> bytes: - if self._header_elements is None: - self._header_elements = interpret_header(self.header) - return self._header_elements.message_type - - @property - def receiver_node(self) -> bytes: - if self._receiver_elements is None: - self._receiver_elements = split_name(self.receiver) - return self._receiver_elements.namespace - - @property - def receiver_name(self) -> bytes: - if self._receiver_elements is None: - self._receiver_elements = split_name(self.receiver) - return self._receiver_elements.name - - @property - def sender_node(self) -> bytes: - if self._sender_elements is None: - self._sender_elements = split_name(self.sender) - return self._sender_elements.namespace - - @property - def sender_name(self) -> bytes: - if self._sender_elements is None: - self._sender_elements = split_name(self.sender) - return self._sender_elements.name + return self.header_elements.message_type @property def data(self) -> object: - if self._data is None and self.payload: - self._data = deserialize_data(self.payload[0]) - return self._data + return deserialize_data(self.payload[0]) if self.payload else None def __eq__(self, other: Any) -> bool: if not isinstance(other, Message): @@ -228,17 +173,9 @@ def sender_str(self, value: str): self.sender = value.encode() @property - def receiver_node_str(self) -> str: - return self.receiver_node.decode() - - @property - def receiver_name_str(self) -> str: - return self.receiver_name.decode() - - @property - def sender_node_str(self) -> str: - return self.sender_node.decode() + def receiver_elements_str(self) -> FullNameStr: + return split_name_str(self.receiver_str) @property - def sender_name_str(self) -> str: - return self.sender_name.decode() + def sender_elements_str(self) -> FullNameStr: + return split_name_str(self.sender_str) diff --git a/pyleco/core/rpc_generator.py b/pyleco/core/rpc_generator.py index 77d288db0..d18320ef0 100644 --- a/pyleco/core/rpc_generator.py +++ b/pyleco/core/rpc_generator.py @@ -22,7 +22,7 @@ # THE SOFTWARE. # -from typing import Any, Optional +from typing import Any from jsonrpc2pyclient._irpcclient import IRPCClient @@ -32,18 +32,16 @@ class RPCGenerator(IRPCClient): # TODO it stores an always growing list of "id"s, if you do not call "get_result". - def build_request_str(self, method: str, params: Optional[list | dict] = None, **kwargs) -> str: - if kwargs and isinstance(params, list): + def build_request_str(self, method: str, *args, **kwargs) -> str: + if args and kwargs: raise ValueError( - "You may not specify list of positional arguments in `params` " + "You may not specify list of positional arguments " "and give additional keyword arguments at the same time.") - if isinstance(params, dict): - params.update(kwargs) - return self._build_request(method=method, params=kwargs or params).json() + return self._build_request(method=method, params=kwargs or list(args) or None).json() def get_result_from_response(self, data: bytes | str) -> Any: """Get the result of that object or raise an error.""" - return super()._get_result_from_response(data=data) + return self._get_result_from_response(data=data) def clear_id_list(self) -> None: """Reset the list of created ids.""" diff --git a/pyleco/core/serialization.py b/pyleco/core/serialization.py index 746872192..f02bd9dc4 100644 --- a/pyleco/core/serialization.py +++ b/pyleco/core/serialization.py @@ -48,6 +48,11 @@ class FullName(NamedTuple): name: bytes +class FullNameStr(NamedTuple): + namespace: str + name: str + + class Header(NamedTuple): conversation_id: bytes message_id: bytes @@ -78,18 +83,18 @@ def create_header_frame(conversation_id: Optional[bytes] = None, return b"".join((conversation_id, message_id, message_type)) -def split_name(name: bytes, node: bytes = b"") -> FullName: - """Split a sender/receiver name with given default node.""" +def split_name(name: bytes, namespace: bytes = b"") -> FullName: + """Split a sender/receiver name with given default namespace.""" s = name.split(b".") n = s.pop(-1) - return FullName((s.pop() if s else node), n) + return FullName((s.pop() if s else namespace), n) -def split_name_str(name: str, node: str = "") -> tuple[str, str]: - """Split a sender/receiver name with given default node.""" +def split_name_str(name: str, namespace: str = "") -> FullNameStr: + """Split a sender/receiver name with given default namespace.""" s = name.split(".") n = s.pop(-1) - return (s.pop() if s else node), n + return FullNameStr((s.pop() if s else namespace), n) def interpret_header(header: bytes) -> Header: diff --git a/tests/core/test_message.py b/tests/core/test_message.py index e24037777..d9cad2d44 100644 --- a/tests/core/test_message.py +++ b/tests/core/test_message.py @@ -111,6 +111,31 @@ def test_to_frames_without_payload(message: Message): b"conversation_id;midT"] +class Test_Message_frame_splitting: + """Test whether the splitting of header/sender/receiver works as expected.""" + + def test_receiver_namespace(self, message: Message): + assert message.receiver_elements.namespace == b"N1" + + def test_receiver_name(self, message: Message): + assert message.receiver_elements.name == b"receiver" + + def test_sender_namespace(self, message: Message): + assert message.sender_elements.namespace == b"N2" + + def test_sender_name(self, message: Message): + assert message.sender_elements.name == b"sender" + + def test_header_conversation_id(self, message: Message): + assert message.header_elements.conversation_id == cid + + def test_header_message_id(self, message: Message): + assert message.header_elements.message_id == b"mid" + + def test_header_message_type(self, message: Message): + assert message.header_elements.message_type == b"T" + + class Test_Message_with_string_parameters: @pytest.fixture def str_message(self) -> Message: @@ -134,30 +159,26 @@ def test_set_sender_as_string(self, str_message: Message): def test_str_return_values(self, str_message: Message): assert str_message.receiver_str == "N2.receiver" assert str_message.sender_str == "N1.sender" - assert str_message.receiver_node_str == "N2" - assert str_message.receiver_name_str == "receiver" - assert str_message.sender_node_str == "N1" - assert str_message.sender_name_str == "sender" + assert str_message.receiver_elements_str == ("N2", "receiver") + assert str_message.sender_elements_str == ("N1", "sender") class Test_Message_data_payload_conversion: def test_data_to_payload(self): message = Message(b"r", b"s", data=([{5: "1asfd"}], 8)) assert message.payload == [b'[[{"5": "1asfd"}], 8]'] - assert message.data == ([{5: "1asfd"}], 8) + assert message.data == [[{'5': "1asfd"}], 8] # converted to and from json, so modified! def test_payload_to_data(self): - message = Message.from_frames(b"v", b"r", b"s", b"h", b'[["G", ["nodes"]]]', b'p2') + frames = [b"v", b"r", b"s", b"h", b'[["G", ["nodes"]]]', b'p2'] + message = Message.from_frames(*frames) assert message.payload == [b'[["G", ["nodes"]]]', b'p2'] assert message.data == [["G", ["nodes"]]] - -@pytest.mark.parametrize("property", ("receiver", "sender", "header")) -def test_set_property_resets_cache(property): - m = Message(b"r") - setattr(m, f"_{property}_elements", [b"some", b"value"]) - setattr(m, property, b"new value") - assert getattr(m, f"_{property}_elements") is None + def test_no_payload_is_no_data(self): + message = Message(b"r") + message.payload = [] # make sure, that there is no payload + assert message.data is None def test_get_frames_list_raises_error_on_empty_sender(): @@ -168,8 +189,9 @@ def test_get_frames_list_raises_error_on_empty_sender(): class TestComparison: def test_message_comparison(self): - m1 = Message.from_frames(VERSION_B, b"rec", b"send", b"x;y", b'[["GET", [1, 2]]') - m2 = Message.from_frames(VERSION_B, b"rec", b"send", b"x;y", b'[["GET", [1, 2]]') + frames = [VERSION_B, b"rec", b"send", b"x;y", b'[["GET", [1, 2]]'] + m1 = Message.from_frames(*frames) + m2 = Message.from_frames(*frames) assert m1 == m2 def test_dictionary_order_is_irrelevant(self): diff --git a/tests/test_import.py b/tests/test_import.py deleted file mode 100644 index 31c3d9945..000000000 --- a/tests/test_import.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Barebones test, to be deleted later.""" - -def test_import(): - import pyleco From 6753195213d4d30cedbbd94d10a5c48e844ec995 Mon Sep 17 00:00:00 2001 From: Benedikt Moneke <67148916+bmoneke@users.noreply.github.com> Date: Wed, 12 Jul 2023 11:31:52 +0200 Subject: [PATCH 6/7] Message cleaned up. --- pyleco/core/message.py | 35 +---------------------------------- tests/core/test_message.py | 14 -------------- 2 files changed, 1 insertion(+), 48 deletions(-) diff --git a/pyleco/core/message.py b/pyleco/core/message.py index 825ccd683..016c68d93 100644 --- a/pyleco/core/message.py +++ b/pyleco/core/message.py @@ -28,7 +28,7 @@ from . import VERSION_B from .serialization import (create_header_frame, serialize_data, interpret_header, split_name, - deserialize_data, FullName, Header, FullNameStr, split_name_str, + deserialize_data, FullName, Header ) @@ -120,14 +120,6 @@ def header_elements(self) -> Header: def conversation_id(self) -> bytes: return self.header_elements.conversation_id - @property - def message_id(self) -> bytes: - return self.header_elements.message_id - - @property - def message_type(self) -> bytes: - return self.header_elements.message_type - @property def data(self) -> object: return deserialize_data(self.payload[0]) if self.payload else None @@ -154,28 +146,3 @@ def __eq__(self, other: Any) -> bool: def __repr__(self) -> str: return f"Message.from_frames({self._to_frames_without_sender_check()})" - - # String access properties - @property - def receiver_str(self) -> str: - return self.receiver.decode() - - @receiver_str.setter - def receiver_str(self, value: str): - self.receiver = value.encode() - - @property - def sender_str(self) -> str: - return self.sender.decode() - - @sender_str.setter - def sender_str(self, value: str): - self.sender = value.encode() - - @property - def receiver_elements_str(self) -> FullNameStr: - return split_name_str(self.receiver_str) - - @property - def sender_elements_str(self) -> FullNameStr: - return split_name_str(self.sender_str) diff --git a/tests/core/test_message.py b/tests/core/test_message.py index d9cad2d44..6b276b7c4 100644 --- a/tests/core/test_message.py +++ b/tests/core/test_message.py @@ -148,20 +148,6 @@ def test_receiver_is_bytes(self, str_message: Message): def test_sender_is_bytes(self, str_message: Message): assert str_message.sender == b"N1.sender" - def test_set_receiver_as_string(self, str_message: Message): - str_message.receiver_str = "New.Receiver" - assert str_message.receiver == b"New.Receiver" - - def test_set_sender_as_string(self, str_message: Message): - str_message.sender_str = "New.Sender" - assert str_message.sender == b"New.Sender" - - def test_str_return_values(self, str_message: Message): - assert str_message.receiver_str == "N2.receiver" - assert str_message.sender_str == "N1.sender" - assert str_message.receiver_elements_str == ("N2", "receiver") - assert str_message.sender_elements_str == ("N1", "sender") - class Test_Message_data_payload_conversion: def test_data_to_payload(self): From c81d675f1ba17752fe1a278446ae1e2d041bfab2 Mon Sep 17 00:00:00 2001 From: Benedikt Moneke <67148916+bmoneke@users.noreply.github.com> Date: Wed, 12 Jul 2023 11:32:14 +0200 Subject: [PATCH 7/7] Test fixed and error codes defined. --- pyleco/errors.py | 16 +++++++++------- pyleco/test.py | 12 +++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pyleco/errors.py b/pyleco/errors.py index 993176c53..196d962f8 100644 --- a/pyleco/errors.py +++ b/pyleco/errors.py @@ -26,13 +26,15 @@ from jsonrpcobjects.objects import ErrorObject, ErrorObjectData, ErrorResponseObject -# TODO define valid error codes - -# Routing errors (Coordinator) -NOT_SIGNED_IN = ErrorObject(code=1234, message="You did not sign in!") -DUPLICATE_NAME = ErrorObject(code=456, message="The name is already taken.") -NODE_UNKNOWN = ErrorObject(code=4324, message="Node is not known.") -RECEIVER_UNKNOWN = ErrorObject(code=123213, message="Receiver is not in addresses list.") +# JSON specification: +# -32000 to -32099 Server error reserved for implementation-defined server-errors + +# TODO define valid error codes: Proposal: +# Routing errors (Coordinator) between -32000 and -32009 +NOT_SIGNED_IN = ErrorObject(code=-32000, message="You did not sign in!") +DUPLICATE_NAME = ErrorObject(code=-32001, message="The name is already taken.") +NODE_UNKNOWN = ErrorObject(code=-32002, message="Node is not known.") +RECEIVER_UNKNOWN = ErrorObject(code=-32003, message="Receiver is not in addresses list.") def generate_error_with_data(error: ErrorObject, data: Any) -> ErrorObjectData: diff --git a/pyleco/test.py b/pyleco/test.py index 1a1f778e6..35f45eb22 100644 --- a/pyleco/test.py +++ b/pyleco/test.py @@ -22,10 +22,8 @@ # THE SOFTWARE. # -from typing import List - from .core.message import Message -from .core.protocols import Communicator +from .core.internal_protocols import CommunicatorProtocol from .core.rpc_generator import RPCGenerator @@ -79,7 +77,7 @@ def recv_multipart(self): return self._r.pop(0) def send_multipart(self, parts): - print(parts) + # print(parts) for i, part in enumerate(parts): if not isinstance(part, bytes): # Similar to real error message. @@ -91,13 +89,13 @@ def close(self, linger=None): self.closed = True -class FakeCommunicator(Communicator): +class FakeCommunicator(CommunicatorProtocol): def __init__(self, name: str): super().__init__() self.name = name self.rpc_generator = RPCGenerator() - self._r: List[Message] = [] - self._s: List[Message] = [] + self._r: list[Message] = [] + self._s: list[Message] = [] def sign_in(self) -> None: return super().sign_in()