From 87784f48cff40daa2f94296c6d753f15c8a019b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 22 Dec 2024 21:16:15 +0200 Subject: [PATCH] Wrap backend-specific exceptions in SerializationError/DeserializationError (#57) --- docs/api.rst | 6 +++ docs/conf.py | 1 + docs/configuration.rst | 40 +++------------- docs/extending.rst | 46 +++++++++---------- docs/versionhistory.rst | 8 +++- pyproject.toml | 26 +++++------ src/asphalt/serialization/__init__.py | 14 +++--- src/asphalt/serialization/_api.py | 14 ++++-- src/asphalt/serialization/_exceptions.py | 6 +++ src/asphalt/serialization/serializers/cbor.py | 11 ++++- src/asphalt/serialization/serializers/json.py | 13 ++++-- .../serialization/serializers/msgpack.py | 11 ++++- .../serialization/serializers/pickle.py | 11 ++++- src/asphalt/serialization/serializers/yaml.py | 15 ++++-- tests/test_serializers.py | 18 +++++++- 15 files changed, 143 insertions(+), 97 deletions(-) create mode 100644 src/asphalt/serialization/_exceptions.py diff --git a/docs/api.rst b/docs/api.rst index dda1953..5a67e92 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -15,6 +15,12 @@ Serializer API .. autoclass:: CustomizableSerializer .. autoclass:: CustomTypeCodec +Exceptions +---------- + +.. autoexception:: SerializationError +.. autoexception:: DeserializationError + Marshalling callbacks --------------------- diff --git a/docs/conf.py b/docs/conf.py index e28d9bf..5e77831 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -7,6 +7,7 @@ "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints", + "sphinx_rtd_theme", ] templates_path = ["_templates"] diff --git a/docs/configuration.rst b/docs/configuration.rst index e596ca9..6a797f2 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -15,45 +15,19 @@ any necessary configuration values for it. The following backends are provided o Other backends may be provided by other components. -Once you've selected a backend, see its specific documentation to find out what configuration -values you need to provide, if any. Configuration values are expressed as constructor arguments -for the backend class:: +Once you've selected a backend, see its specific documentation to find out what +configuration values you need to provide, if any. Configuration values are expressed as +constructor arguments for the backend class:: components: serialization: backend: json -This configuration publishes a :class:`~.api.Serializer` resource named -``default`` using the JSON backend. The same can be done directly in Python code as -follows: +This configuration publishes a :class:`~Serializer` resource named ``default`` using the +JSON backend. The same can be done directly in Python code as follows: .. code-block:: python - class ApplicationComponent(ContainerComponent): - async def start(ctx: Context) -> None: + class ApplicationComponent(Component): + def __init__(self) -> None: self.add_component('serialization', backend='json') - await super().start() - - -Multiple serializers --------------------- - -If you need to configure multiple serializers, you will need to use multiple instances -of the serialization component:: - - components: - serialization: - backend: cbor - serialization/msgpack: - resource_name: msgpack - backend: msgpack - -The above configuration creates two serializer resources, available under 6 different -combinations: - -* :class:`~.api.Serializer` / ``default`` -* :class:`~.api.CustomizableSerializer` / ``default`` -* :class:`~.serializers.cbor.CBORSerializer` / ``default`` -* :class:`~.api.Serializer` / ``msgpack`` -* :class:`~.api.CustomizableSerializer` / ``msgpack`` -* :class:`~.serializers.msgpack.MsgpackSerializer` / ``msgpack`` diff --git a/docs/extending.rst b/docs/extending.rst index 590685f..3e46717 100644 --- a/docs/extending.rst +++ b/docs/extending.rst @@ -1,34 +1,30 @@ Writing new serializer backends =============================== -If you wish to implement an alternate method of serialization, you can do so by subclassing -the :class:`~asphalt.serialization.api.Serializer` class. -There are three methods implementors must override: +.. py:currentmodule:: asphalt.serialization -* :meth:`~asphalt.serialization.api.Serializer.serialize` -* :meth:`~asphalt.serialization.api.Serializer.deserialize` -* :meth:`~asphalt.serialization.api.Serializer.mimetype` +If you wish to implement an alternate method of serialization, you can do so by +subclassing the :class:`~Serializer` class. There are three methods implementors must +override: -The ``mimetype`` method is a ``@property`` that simply returns the MIME type appropriate for the -serialization scheme. This property is used by certain other components. If you cannot find an -applicable MIME type, you can use ``application/octet-stream``. +* :meth:`~Serializer.serialize` +* :meth:`~Serializer.deserialize` +* :meth:`~Serializer.mimetype` + +The ``mimetype`` method is a ``@property`` that simply returns the MIME type appropriate +for the serialization scheme. This property is used by certain other components. If you +cannot find an applicable MIME type, you can use ``application/octet-stream``. .. note:: Serializers must always serialize to bytes; never serialize to strings! If you want your serializer to be available as a backend for -:class:`~asphalt.serialization.component.SerializationComponent`, you need to add the corresponding -entry point for it. Suppose your serializer class is named ``AwesomeSerializer``, lives in the -package ``foo.bar.awesome`` and you want to give it the alias ``awesome``, add this line to your -project's ``setup.py`` under the ``entry_points`` argument in the -``asphalt.serialization.serializers`` namespace: - -.. code-block:: python - - setup( - # (...other arguments...) - entry_points={ - 'asphalt.serialization.serializers': [ - 'awesome = foo.bar.awesome:AwesomeSerializer' - ] - } - ) +:class:`~asphalt.serialization.SerializationComponent`, you need to add the +corresponding entry point for it. Suppose your serializer class is named +``AwesomeSerializer``, lives in the package ``foo.bar.awesome`` and you want to give it +the alias ``awesome``, add this line to your project's ``pyproject.toml`` under the +``entry_points`` argument in the ``asphalt.serialization.serializers`` namespace: + +.. code-block:: toml + + [project.entry-points."asphalt.serialization.serializers"] + awesome = "foo.bar.awesome:AwesomeSerializer" diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index 9622c79..680ec1f 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -1,11 +1,15 @@ Version history =============== -This library adheres to `Semantic Versioning 2.0 `_. +This library adheres to `Semantic Versioning 2.0 `_. **UNRELEASED** -- Dropped support for Python 3.7 +- Dropped support for Python 3.7 and 3.8 +- **BACKWARD INCOMPATIBLE** Bumped minimum Asphalt version to 5.0 +- **BACKWARD INCOMPATIBLE** The ``Serializer.serialize()`` and + ``Serializer.deserialize()`` methods now raise ``SerializationError`` and + ``DeserializationError`` regardless of back-end when something goes wrong **6.0.0** (2022-06-04) diff --git a/pyproject.toml b/pyproject.toml index 2f1b641..6834e63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ addopts = "-rsx --tb=short" testpaths = ["tests"] [tool.mypy] -python_version = "3.8" +python_version = "3.9" strict = true explicit_package_bases = true @@ -96,18 +96,18 @@ branch = true show_missing = true [tool.tox] -legacy_tox_ini = """ -[tox] -envlist = py38, py39, py310, py311, py312, py313, pypy3 +env_list = ["py39", "py310", "py311", "py312", "py313", "pypy3"] skip_missing_interpreters = true -minversion = 4.4.3 -[testenv] -extras = test -commands = python -m pytest {posargs} -package = editable +[tool.tox.env_run_base] +commands = [["python", "-m", "pytest", { replace = "posargs", extend = true }]] +package = "editable" +extras = ["test"] -[testenv:docs] -extras = doc -commands = sphinx-build -n docs build/sphinx -""" +[tool.tox.env.pyright] +deps = ["pyright"] +commands = [["pyright", "--verifytypes", "asphalt.exceptions"]] + +[tool.tox.env.docs] +commands = [["sphinx-build", "-W", "-n", "docs", "build/sphinx", { replace = "posargs", extend = true }]] +extras = ["doc"] diff --git a/src/asphalt/serialization/__init__.py b/src/asphalt/serialization/__init__.py index df5d386..2269983 100644 --- a/src/asphalt/serialization/__init__.py +++ b/src/asphalt/serialization/__init__.py @@ -1,16 +1,16 @@ -from typing import Any - from ._api import CustomizableSerializer as CustomizableSerializer from ._api import CustomTypeCodec as CustomTypeCodec from ._api import Serializer as Serializer from ._component import SerializationComponent as SerializationComponent +from ._exceptions import DeserializationError as DeserializationError +from ._exceptions import SerializationError as SerializationError from ._marshalling import default_marshaller as default_marshaller from ._marshalling import default_unmarshaller as default_unmarshaller from ._object_codec import DefaultCustomTypeCodec as DefaultCustomTypeCodec # Re-export imports, so they look like they live directly in this package -key: str -value: Any -for key, value in list(locals().items()): - if getattr(value, "__module__", "").startswith(f"{__name__}."): - value.__module__ = __name__ +for __value in list(locals().values()): + if getattr(__value, "__module__", "").startswith(f"{__name__}."): + __value.__module__ = __name__ + +del __value diff --git a/src/asphalt/serialization/_api.py b/src/asphalt/serialization/_api.py index 5fb82cf..2b6b638 100644 --- a/src/asphalt/serialization/_api.py +++ b/src/asphalt/serialization/_api.py @@ -38,15 +38,23 @@ class Serializer(metaclass=ABCMeta): serialization support for custom types. """ - __slots__ = () + __slots__ = ("__weakref__",) @abstractmethod def serialize(self, obj: Any) -> bytes: - """Serialize a Python object into bytes.""" + """ + Serialize a Python object into bytes. + + :raises SerializationError: if serialization fails + """ @abstractmethod def deserialize(self, payload: bytes) -> Any: - """Deserialize bytes into a Python object.""" + """ + Deserialize bytes into a Python object. + + :raises DeserializationError: if deserialization fails + """ @property @abstractmethod diff --git a/src/asphalt/serialization/_exceptions.py b/src/asphalt/serialization/_exceptions.py new file mode 100644 index 0000000..0c84bb2 --- /dev/null +++ b/src/asphalt/serialization/_exceptions.py @@ -0,0 +1,6 @@ +class SerializationError(Exception): + """Raised when serialization fails.""" + + +class DeserializationError(Exception): + """Raised when deserialization fails.""" diff --git a/src/asphalt/serialization/serializers/cbor.py b/src/asphalt/serialization/serializers/cbor.py index 43e101e..169b3e3 100644 --- a/src/asphalt/serialization/serializers/cbor.py +++ b/src/asphalt/serialization/serializers/cbor.py @@ -5,6 +5,7 @@ import cbor2 from asphalt.core import qualified_name, resolve_reference +from .. import DeserializationError, SerializationError from .._api import CustomizableSerializer from .._object_codec import DefaultCustomTypeCodec @@ -129,10 +130,16 @@ def __init__( self.decoder_options: dict[str, Any] = decoder_options or {} def serialize(self, obj: Any) -> bytes: - return cbor2.dumps(obj, **self.encoder_options) + try: + return cbor2.dumps(obj, **self.encoder_options) + except cbor2.CBOREncodeError as exc: + raise SerializationError(str(exc)) from exc def deserialize(self, payload: bytes) -> Any: - return cbor2.loads(payload, **self.decoder_options) + try: + return cbor2.loads(payload, **self.decoder_options) + except cbor2.CBORDecodeError as exc: + raise DeserializationError(str(exc)) from exc @property def mimetype(self) -> str: diff --git a/src/asphalt/serialization/serializers/json.py b/src/asphalt/serialization/serializers/json.py index 8493918..98f3c4a 100644 --- a/src/asphalt/serialization/serializers/json.py +++ b/src/asphalt/serialization/serializers/json.py @@ -6,6 +6,7 @@ from asphalt.core import resolve_reference +from .. import DeserializationError, SerializationError from .._api import CustomizableSerializer from .._object_codec import DefaultCustomTypeCodec @@ -81,11 +82,17 @@ def __init__( self._decoder = JSONDecoder(**self.decoder_options) def serialize(self, obj: Any) -> bytes: - return self._encoder.encode(obj).encode(self.encoding) + try: + return self._encoder.encode(obj).encode(self.encoding) + except Exception as exc: + raise SerializationError(str(exc)) from exc def deserialize(self, payload: bytes) -> Any: - text_payload = payload.decode(self.encoding) - return self._decoder.decode(text_payload) + try: + text_payload = payload.decode(self.encoding) + return self._decoder.decode(text_payload) + except Exception as exc: + raise DeserializationError(str(exc)) from exc @property def mimetype(self) -> str: diff --git a/src/asphalt/serialization/serializers/msgpack.py b/src/asphalt/serialization/serializers/msgpack.py index f73c2af..e8e6538 100644 --- a/src/asphalt/serialization/serializers/msgpack.py +++ b/src/asphalt/serialization/serializers/msgpack.py @@ -5,6 +5,7 @@ from asphalt.core import resolve_reference from msgpack import ExtType, packb, unpackb +from .. import DeserializationError, SerializationError from .._api import CustomizableSerializer from .._object_codec import DefaultCustomTypeCodec @@ -103,10 +104,16 @@ def __init__( self.unpacker_options.setdefault("raw", False) def serialize(self, obj: Any) -> bytes: - return packb(obj, **self.packer_options) # type: ignore[no-any-return] + try: + return packb(obj, **self.packer_options) # type: ignore[no-any-return] + except Exception as exc: + raise SerializationError(str(exc)) from exc def deserialize(self, payload: bytes) -> Any: - return unpackb(payload, **self.unpacker_options) + try: + return unpackb(payload, **self.unpacker_options) + except Exception as exc: + raise DeserializationError(str(exc)) from exc @property def mimetype(self) -> str: diff --git a/src/asphalt/serialization/serializers/pickle.py b/src/asphalt/serialization/serializers/pickle.py index e7c9e16..2f97662 100644 --- a/src/asphalt/serialization/serializers/pickle.py +++ b/src/asphalt/serialization/serializers/pickle.py @@ -4,6 +4,7 @@ from typing import Any from .._api import Serializer +from .._exceptions import DeserializationError, SerializationError class PickleSerializer(Serializer): @@ -26,10 +27,16 @@ def __init__(self, protocol: int = pickle.HIGHEST_PROTOCOL): self.protocol: int = protocol def serialize(self, obj: Any) -> bytes: - return pickle.dumps(obj, protocol=self.protocol) + try: + return pickle.dumps(obj, protocol=self.protocol) + except Exception as exc: + raise SerializationError(str(exc)) from exc def deserialize(self, payload: bytes) -> Any: - return pickle.loads(payload) + try: + return pickle.loads(payload) + except Exception as exc: + raise DeserializationError(str(exc)) from exc @property def mimetype(self) -> str: diff --git a/src/asphalt/serialization/serializers/yaml.py b/src/asphalt/serialization/serializers/yaml.py index 972e069..3c0483b 100644 --- a/src/asphalt/serialization/serializers/yaml.py +++ b/src/asphalt/serialization/serializers/yaml.py @@ -5,6 +5,7 @@ from ruamel.yaml import YAML +from .. import DeserializationError, SerializationError from .._api import Serializer @@ -35,12 +36,18 @@ def __init__(self, safe: bool = True): self._yaml = YAML(typ="safe" if safe else "unsafe") def serialize(self, obj: Any) -> bytes: - buffer = StringIO() - self._yaml.dump(obj, buffer) - return buffer.getvalue().encode("utf-8") + try: + buffer = StringIO() + self._yaml.dump(obj, buffer) + return buffer.getvalue().encode("utf-8") + except Exception as exc: + raise SerializationError(str(exc)) from exc def deserialize(self, payload: bytes) -> Any: - return self._yaml.load(payload) + try: + return self._yaml.load(payload) + except Exception as exc: + raise DeserializationError(str(exc)) from exc @property def mimetype(self) -> str: diff --git a/tests/test_serializers.py b/tests/test_serializers.py index f4c1c9a..4000b3d 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -2,8 +2,10 @@ import re import sys +from binascii import unhexlify from datetime import datetime, timezone from functools import partial +from socket import socket from types import SimpleNamespace from typing import Any, cast @@ -12,7 +14,11 @@ from cbor2 import CBORTag from msgpack import ExtType -from asphalt.serialization import CustomizableSerializer +from asphalt.serialization import ( + CustomizableSerializer, + DeserializationError, + SerializationError, +) from asphalt.serialization.serializers.cbor import CBORSerializer, CBORTypeCodec from asphalt.serialization.serializers.json import JSONSerializer from asphalt.serialization.serializers.msgpack import ( @@ -115,6 +121,16 @@ def test_basic_types_roundtrip(serializer: CustomizableSerializer, input: Any) - assert deserialized == input +def test_serialization_error(serializer: CustomizableSerializer) -> None: + with pytest.raises(SerializationError), socket() as sock: + serializer.serialize(sock) + + +def test_deserialization_error(serializer: CustomizableSerializer) -> None: + with pytest.raises(DeserializationError): + serializer.deserialize(unhexlify("c11b9b9b9b0000000000")) + + @pytest.mark.parametrize("serializer_type", ["cbor", "pickle", "yaml"]) def test_circular_reference(serializer: CustomizableSerializer) -> None: a: dict[str, Any] = {"foo": 1}