How-to — Communication Protocols


The Basics

To define your communication protocol, you must instantiate one of the following protocol objects:

They all have one thing in common: they wrap a serializer and a converter.

You can either directly create an instance:

 1from __future__ import annotations
 2
 3from typing import Any, TypeAlias
 4
 5from easynetwork.protocol import DatagramProtocol
 6from easynetwork.serializers import JSONSerializer
 7
 8SentPacket: TypeAlias = Any
 9ReceivedPacket: TypeAlias = Any
10
11json_protocol: DatagramProtocol[SentPacket, ReceivedPacket] = DatagramProtocol(JSONSerializer())

or create a subclass:

 1from __future__ import annotations
 2
 3from typing import Any, TypeAlias
 4
 5from easynetwork.protocol import DatagramProtocol
 6from easynetwork.serializers import JSONSerializer
 7
 8SentPacket: TypeAlias = Any
 9ReceivedPacket: TypeAlias = Any
10
11
12class JSONDatagramProtocol(DatagramProtocol[SentPacket, ReceivedPacket]):
13    def __init__(self) -> None:
14        serializer = JSONSerializer()
15        super().__init__(serializer)
16
17
18json_protocol = JSONDatagramProtocol()

Tip

The latter is recommended. The main advantage of this model is to declaratively define the communication protocol (the name of the class being that of the protocol, the types of objects sent and received, etc.).

Another advantage is that the serializer (and converter, if any) can be configured in a single place in the project.

Usage

The protocol objects are requested by endpoint and server implementations to handle the data sent and received:

1def main() -> None:
2    protocol = JSONDatagramProtocol()
3
4    with UDPNetworkClient(("remote_address", 12345), protocol) as endpoint:
5        endpoint.send_packet({"data": 42})
6
7        ...

Warning

A protocol object is intended to be shared by multiple endpoints. Do not store sensitive data in these objects. You might see some magic.

Parsing Error

The protocol objects raise a BaseProtocolParseError subclass when the received data is invalid:

The raised exception is DatagramProtocolParseError.

1try:
2    received_packet = endpoint.recv_packet()
3except DatagramProtocolParseError:
4    print("The received data is invalid.")
5else:
6    print(f"Received {received_packet!r} from {endpoint.get_remote_address()}.")

Tip

The underlying DeserializeError instance is available with the error attribute.

The Converters

TL;DR: Why should you always have a converter in your protocol object?

Unless the serializer is already making the tea and coffee for you, in 99% of cases the data received can be anything, as long as it’s in the right format. On the other hand, the application has to comply with the format for sending data to the remote endpoint.

However, you just want to be able to manipulate your business objects without having to worry about such problems.

This is what a converter can do for you. It creates a DTO suitable for the underlying serializer and validates the received DTO to recreate the business object.

Writing A Converter

To write a converter, you must create a subclass of AbstractPacketConverter and override its convert_to_dto_packet() and create_from_dto_packet() methods.

For example:

 1from __future__ import annotations
 2
 3import dataclasses
 4import uuid
 5from typing import Any, TypeGuard
 6
 7from easynetwork.converter import AbstractPacketConverter
 8from easynetwork.exceptions import PacketConversionError
 9
10
11@dataclasses.dataclass
12class Person:
13    id: uuid.UUID
14    name: str
15    age: int
16    friends: list[uuid.UUID] = dataclasses.field(default_factory=list)
17
18
19class PersonToJSONConverter(AbstractPacketConverter[Person, dict[str, Any]]):
20    def convert_to_dto_packet(self, person: Person, /) -> dict[str, Any]:
21        return {
22            "id": str(person.id),
23            "name": person.name,
24            "age": person.age,
25            "friends": [str(friend_uuid) for friend_uuid in person.friends],
26        }
27
28    def create_from_dto_packet(self, packet: dict[str, Any], /) -> Person:
29        match packet:
30            case {
31                "id": str(person_id),
32                "name": str(name),
33                "age": int(age),
34                "friends": list(friends),
35            } if self._is_valid_list_of_friends(friends):
36                try:
37                    person = Person(
38                        id=uuid.UUID(person_id),
39                        name=name,
40                        age=age,
41                        friends=[uuid.UUID(friend_id) for friend_id in friends],
42                    )
43                except ValueError:  # Invalid UUIDs
44                    raise PacketConversionError("Invalid UUID fields") from None
45
46            case _:
47                raise PacketConversionError("Invalid packet format")
48
49        return person
50
51    @staticmethod
52    def _is_valid_list_of_friends(friends_list: list[Any]) -> TypeGuard[list[str]]:
53        return all(isinstance(friend_id, str) for friend_id in friends_list)

Warning

The create_from_dto_packet() function must raise a PacketConversionError to indicate that a parsing error was “expected” so that the received data is considered invalid.

Otherwise, any other error is considered a crash.

This converter can now be used in our protocol object:

1class PersonProtocol(StreamProtocol[Person, Person]):
2    def __init__(self) -> None:
3        serializer = JSONSerializer()
4        converter = PersonToJSONConverter()
5
6        super().__init__(serializer, converter=converter)

Note

Now this protocol is annotated to send and receive a Person object.

In the application, you can now safely handle an object with real meaning:

 1def main() -> None:
 2    protocol = PersonProtocol()
 3
 4    with TCPNetworkClient(("remote_address", 12345), protocol) as endpoint:
 5        john_doe = Person(
 6            id=uuid.uuid4(),
 7            name="John Doe",
 8            age=36,
 9            friends=[uuid.uuid4() for _ in range(5)],
10        )
11
12        # Person object directly sent
13        endpoint.send_packet(john_doe)
14
15        try:
16            # The received object should be a Person instance.
17            received_person = endpoint.recv_packet()
18        except StreamProtocolParseError as exc:
19            match exc.error:
20                case DeserializeError():
21                    print("It is not even a JSON object.")
22                case PacketConversionError():
23                    print("It is not a valid Person in JSON object.")
24        else:
25            assert isinstance(received_person, Person)
26            print(f"Received person: {received_person!r}")

Writing A Composite Converter

Most of the time, the packets sent and received are different (the request/response system). To deal with this, a protocol object accepts a composite converter.

To write a composite converter, there are two ways described below.

Note

Do what you think is best, there is no recommended method.

Option 1: Using StapledPacketConverter

 1from __future__ import annotations
 2
 3from typing import Any
 4
 5from easynetwork.converter import AbstractPacketConverter, StapledPacketConverter
 6from easynetwork.protocol import StreamProtocol
 7from easynetwork.serializers import JSONSerializer
 8
 9
10class Request:
11    ...
12
13
14class Response:
15    ...
16
17
18class RequestConverter(AbstractPacketConverter[Request, dict[str, Any]]):
19    def convert_to_dto_packet(self, request: Request, /) -> dict[str, Any]:
20        ...
21
22    def create_from_dto_packet(self, request_dict: dict[str, Any], /) -> Request:
23        ...
24
25
26class ResponseConverter(AbstractPacketConverter[Response, dict[str, Any]]):
27    def convert_to_dto_packet(self, response: Response, /) -> dict[str, Any]:
28        ...
29
30    def create_from_dto_packet(self, response_dict: dict[str, Any], /) -> Response:
31        ...
32
33
34serializer = JSONSerializer()
35request_converter = RequestConverter()
36response_converter = ResponseConverter()
37
38client_protocol: StreamProtocol[Request, Response] = StreamProtocol(
39    serializer=serializer,
40    converter=StapledPacketConverter(
41        sent_packet_converter=request_converter,
42        received_packet_converter=response_converter,
43    ),
44)
45server_protocol: StreamProtocol[Response, Request] = StreamProtocol(
46    serializer=serializer,
47    converter=StapledPacketConverter(
48        sent_packet_converter=response_converter,
49        received_packet_converter=request_converter,
50    ),
51)

Option 2: By Subclassing AbstractPacketConverterComposite

 1from __future__ import annotations
 2
 3from typing import Any
 4
 5from easynetwork.converter import AbstractPacketConverterComposite
 6from easynetwork.protocol import StreamProtocol
 7from easynetwork.serializers import JSONSerializer
 8
 9
10class Request:
11    ...
12
13
14class Response:
15    ...
16
17
18class ClientConverter(AbstractPacketConverterComposite[Request, Response, dict[str, Any], dict[str, Any]]):
19    def convert_to_dto_packet(self, request: Request, /) -> dict[str, Any]:
20        ...
21
22    def create_from_dto_packet(self, response_dict: dict[str, Any], /) -> Response:
23        ...
24
25
26class ServerConverter(AbstractPacketConverterComposite[Response, Request, dict[str, Any], dict[str, Any]]):
27    def convert_to_dto_packet(self, response: Response, /) -> dict[str, Any]:
28        ...
29
30    def create_from_dto_packet(self, request_dict: dict[str, Any], /) -> Request:
31        ...
32
33
34serializer = JSONSerializer()
35
36client_protocol: StreamProtocol[Request, Response] = StreamProtocol(
37    serializer=serializer,
38    converter=ClientConverter(),
39)
40server_protocol: StreamProtocol[Response, Request] = StreamProtocol(
41    serializer=serializer,
42    converter=ServerConverter(),
43)