On Thu, Jul 1, 2021 at 9:43 AM John Snow <js...@redhat.com> wrote: > The Message class is here primarily to serve as a solid type to use for > mypy static typing for unambiguous annotation and documentation. > > We can also stuff JSON serialization and deserialization into this class > itself so it can be re-used even outside this infrastructure. > > Signed-off-by: John Snow <js...@redhat.com> > --- > python/qemu/aqmp/__init__.py | 4 +- > python/qemu/aqmp/message.py | 207 +++++++++++++++++++++++++++++++++++ > 2 files changed, 210 insertions(+), 1 deletion(-) > create mode 100644 python/qemu/aqmp/message.py > > diff --git a/python/qemu/aqmp/__init__.py b/python/qemu/aqmp/__init__.py > index 5c44fabeea..c1ec68a023 100644 > --- a/python/qemu/aqmp/__init__.py > +++ b/python/qemu/aqmp/__init__.py > @@ -22,12 +22,14 @@ > # the COPYING file in the top-level directory. > > from .error import AQMPError, MultiException > +from .message import Message > from .protocol import ConnectError, Runstate > > > # The order of these fields impact the Sphinx documentation order. > __all__ = ( > - # Classes > + # Classes, most to least important > + 'Message', > 'Runstate', > > # Exceptions, most generic to most explicit > diff --git a/python/qemu/aqmp/message.py b/python/qemu/aqmp/message.py > new file mode 100644 > index 0000000000..3a4b283032 > --- /dev/null > +++ b/python/qemu/aqmp/message.py > @@ -0,0 +1,207 @@ > +""" > +QMP Message Format > + > +This module provides the `Message` class, which represents a single QMP > +message sent to or from the server. > +""" > + > +import json > +from json import JSONDecodeError > +from typing import ( > + Dict, > + Iterator, > + Mapping, > + MutableMapping, > + Optional, > + Union, > +) > + > +from .error import ProtocolError > + > + > +class Message(MutableMapping[str, object]): > + """ > + Represents a single QMP protocol message. > + > + QMP uses JSON objects as its basic communicative unit; so this > + Python object is a :py:obj:`~collections.abc.MutableMapping`. It may > + be instantiated from either another mapping (like a `dict`), or from > + raw `bytes` that still need to be deserialized. > + > + Once instantiated, it may be treated like any other MutableMapping:: > + > + >>> msg = Message(b'{"hello": "world"}') > + >>> assert msg['hello'] == 'world' > + >>> msg['id'] = 'foobar' > + >>> print(msg) > + { > + "hello": "world", > + "id": "foobar" > + } > + > + It can be converted to `bytes`:: > + > + >>> msg = Message({"hello": "world"}) > + >>> print(bytes(msg)) > + b'{"hello":"world","id":"foobar"}' > + > + Or back into a garden-variety `dict`:: > + > + >>> dict(msg) > + {'hello': 'world'} > + > + > + :param value: Initial value, if any. > + :param eager: > + When `True`, attempt to serialize or deserialize the initial value > + immediately, so that conversion exceptions are raised during > + the call to ``__init__()``. > + """ > + # pylint: disable=too-many-ancestors > + > + def __init__(self, > + value: Union[bytes, Mapping[str, object]] = b'', *, > + eager: bool = True): > + self._data: Optional[bytes] = None > + self._obj: Optional[Dict[str, object]] = None > + > + if isinstance(value, bytes): > + self._data = value > + if eager: > + self._obj = self._deserialize(self._data) > + else: > + self._obj = dict(value) > + if eager: > + self._data = self._serialize(self._obj) > + > + # Methods necessary to implement the MutableMapping interface, see: > + # > https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping > + > + # We get pop, popitem, clear, update, setdefault, __contains__, > + # keys, items, values, get, __eq__ and __ne__ for free. > + > + def __getitem__(self, key: str) -> object: > + return self._object[key] > + > + def __setitem__(self, key: str, value: object) -> None: > + self._object[key] = value > + self._data = None > + > + def __delitem__(self, key: str) -> None: > + del self._object[key] > + self._data = None > + > + def __iter__(self) -> Iterator[str]: > + return iter(self._object) > + > + def __len__(self) -> int: > + return len(self._object) > + > + # Dunder methods not related to MutableMapping: > + > + def __repr__(self) -> str: > + return f"Message({self._object!r})" > + > + def __str__(self) -> str: > + """Pretty-printed representation of this QMP message.""" > + return json.dumps(self._object, indent=2) > + > + def __bytes__(self) -> bytes: > + """bytes representing this QMP message.""" > + if self._data is None: > + self._data = self._serialize(self._obj or {}) > + return self._data > + > + # > Is this something intentional?
> + > + @property > + def _object(self) -> Dict[str, object]: > + """ > + A `dict` representing this QMP message. > + > + Generated on-demand, if required. This property is private > + because it returns an object that could be used to invalidate > + the internal state of the `Message` object. > + """ > + if self._obj is None: > + self._obj = self._deserialize(self._data or b'') > + return self._obj > + > + @classmethod > + def _serialize(cls, value: object) -> bytes: > + """ > + Serialize a JSON object as `bytes`. > + > + :raise ValueError: When the object cannot be serialized. > + :raise TypeError: When the object cannot be serialized. > + > + :return: `bytes` ready to be sent over the wire. > + """ > + return json.dumps(value, separators=(',', ':')).encode('utf-8') > + > + @classmethod > + def _deserialize(cls, data: bytes) -> Dict[str, object]: > + """ > + Deserialize JSON `bytes` into a native Python `dict`. > + > + :raise DeserializationError: > + If JSON deserialization fails for any reason. > + :raise UnexpectedTypeError: > + If the data does not represent a JSON object. > + > + :return: A `dict` representing this QMP message. > + """ > + try: > + obj = json.loads(data) > + except JSONDecodeError as err: > + emsg = "Failed to deserialize QMP message." > + raise DeserializationError(emsg, data) from err > + if not isinstance(obj, dict): > + raise UnexpectedTypeError( > + "QMP message is not a JSON object.", > + obj > + ) > + return obj > + > + > +class DeserializationError(ProtocolError): > + """ > + A QMP message was not understood as JSON. > + > + When this Exception is raised, ``__cause__`` will be set to the > + `json.JSONDecodeError` Exception, which can be interrogated for > + further details. > + > + :param error_message: Human-readable string describing the error. > + :param raw: The raw `bytes` that prompted the failure. > + """ > + def __init__(self, error_message: str, raw: bytes): > + super().__init__(error_message) > + #: The raw `bytes` that were not understood as JSON. > + self.raw: bytes = raw > + > + def __str__(self) -> str: > + return "\n".join([ > + super().__str__(), > + f" raw bytes were: {str(self.raw)}", > + ]) > + > + > +class UnexpectedTypeError(ProtocolError): > + """ > + A QMP message was JSON, but not a JSON object. > + > + :param error_message: Human-readable string describing the error. > + :param value: The deserialized JSON value that wasn't an object. > + """ > + def __init__(self, error_message: str, value: object): > + super().__init__(error_message) > + #: The JSON value that was expected to be an object. > + self.value: object = value > + > + def __str__(self) -> str: > + strval = json.dumps(self.value, indent=2) > + return "\n".join([ > + super().__str__(), > + f" json value was: {strval}", > + ]) > -- > 2.31.1 > >