From 0661f3d36eb2b11c7e1f62697509effca83606bf Mon Sep 17 00:00:00 2001 From: Benedikt Moneke Date: Thu, 29 Jun 2023 09:03:43 +0200 Subject: [PATCH] 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