Add support for encoding dataclasses.

This commit is contained in:
Dave Shawley 2021-10-27 14:02:17 -04:00
parent be5eb94cbf
commit 1fb8dff9e2
No known key found for this signature in database
GPG key ID: F41A8A99298F8EED
5 changed files with 74 additions and 1 deletions

View file

@ -99,3 +99,6 @@ Contract Types
.. autoclass:: SupportsSettings
:members:
.. autoclass:: SupportsDataclassFields
:members:

View file

@ -5,6 +5,7 @@ Version History
--------------------------------
- Add a transcoder for `application/x-www-formurlencoded`_
- Add support for encoding :class:`decimal.Decimal`
- Add support for encoding :func:`dataclasses.dataclass` decorated classes
- Add type annotations (see :ref:`type-info`)
- Return a "406 Not Acceptable" if the :http:header:`Accept` header values cannot be matched
and there is no default content type configured

View file

@ -115,6 +115,8 @@ class JSONTranscoder(handlers.TextContentHandler):
+----------------------------+---------------------------------------+
| :class:`decimal.Decimal` | Same as ``float(value)`` |
+----------------------------+---------------------------------------+
| Dataclasses | Same as :func:`dataclasses.asdict` |
+----------------------------+---------------------------------------+
"""
if isinstance(obj, uuid.UUID):
@ -125,6 +127,8 @@ class JSONTranscoder(handlers.TextContentHandler):
return base64.b64encode(obj).decode('ASCII')
if isinstance(obj, decimal.Decimal):
return float(obj)
if dataclasses.is_dataclass(obj):
return dataclasses.asdict(obj)
raise TypeError('{!r} is not JSON serializable'.format(obj))
@ -204,6 +208,9 @@ class MsgPackTranscoder(handlers.BinaryContentHandler):
+-----------------------------------+-------------------------------+
| :class:`decimal.Decimal` | `float family`_ |
+-----------------------------------+-------------------------------+
| Dataclasses | `map family`_ after calling |
| | :func:`dataclasses.asdict` |
+-----------------------------------+-------------------------------+
.. _nil byte: https://github.com/msgpack/msgpack/blob/
0b8f5ac67cdd130f4d4d4fe6afb839b989fdb86a/spec.md#formats-nil
@ -253,6 +260,9 @@ class MsgPackTranscoder(handlers.BinaryContentHandler):
if isinstance(datum, (collections.abc.Sequence, collections.abc.Set)):
return [self.normalize_datum(item) for item in datum]
if dataclasses.is_dataclass(datum):
datum = dataclasses.asdict(datum)
if isinstance(datum, collections.abc.Mapping):
out = {}
for k, v in datum.items():
@ -319,6 +329,9 @@ class FormUrlEncodedTranscoder:
| :class:`datetime.datetime` | result of calling |
| | :meth:`~datetime.datetime.isoformat` |
+----------------------------+---------------------------------------+
| Dataclasses | same as calling |
| | :func:`dataclasses.asdict` on value |
+----------------------------+---------------------------------------+
https://url.spec.whatwg.org/#application/x-www-form-urlencoded
@ -465,6 +478,9 @@ class FormUrlEncodedTranscoder:
self, value: type_info.Serializable
) -> typing.Iterable[typing.Tuple[typing.Any, typing.Any]]:
tuples: typing.Iterable[typing.Tuple[typing.Any, typing.Any]]
if dataclasses.is_dataclass(value):
value = dataclasses.asdict(value)
if isinstance(value, collections.abc.Mapping):
tuples = value.items()
else:

View file

@ -12,6 +12,17 @@ except ImportError:
from typing_extensions import Protocol, runtime_checkable # type: ignore
@runtime_checkable
class SupportsDataclassFields(Protocol):
"""An object that looks like a dataclass.
The implementation uses the same test that :func:`dataclasses.is_dataclass`
uses in Python 3.9.
"""
__dataclass_fields__: typing.ClassVar[typing.Mapping[str, typing.Any]]
@runtime_checkable
class SupportsIsoFormat(Protocol):
"""An object that has an isoformat method."""
@ -29,7 +40,7 @@ class SupportsSettings(Protocol):
Serializable = typing.Union[SupportsIsoFormat, None, bool, bytearray, bytes,
float, int, memoryview, str, typing.Mapping,
typing.Sequence, typing.Set, uuid.UUID,
decimal.Decimal]
decimal.Decimal, SupportsDataclassFields]
"""Types that can be serialized by this library.
This is the set of types that

View file

@ -1,4 +1,5 @@
import base64
import dataclasses
import datetime
import decimal
import json
@ -354,6 +355,17 @@ class JSONTranscoderTests(unittest.TestCase):
loaded = json.loads(dumped)
self.assertEqual(loaded['n'], float(pi))
def test_that_dataclasses_are_handled_as_dicts(self):
@dataclasses.dataclass
class Point:
x: int
y: int
datum = Point(3, 4)
dumped = self.transcoder.dumps({'point': datum})
expected = json.dumps({'point': dataclasses.asdict(datum)})
self.assertDictEqual(json.loads(expected), json.loads(dumped))
class ContentSettingsTests(unittest.TestCase):
def test_that_handler_listed_in_available_content_types(self):
@ -566,6 +578,26 @@ class MsgPackTranscoderTests(unittest.TestCase):
self.assertEqual(0xcb, dumped[0])
self.assertEqual(struct.pack('>d', float(pi)), dumped[1:])
def test_that_dataclasses_are_handled_as_dicts(self):
@dataclasses.dataclass
class Point:
x: int
y: int
datum = typing.cast(type_info.SupportsDataclassFields, Point(3, 4))
dumped = self.transcoder.packb(datum)
expected = struct.pack(
'BBBBBBB',
0x82, # mapping of two fields
0xA1, # string of a single byte
ord('x'),
3, # positive integer less than 128
0xA1, # string of a single byte
ord('y'),
4, # positive integer less than 128
)
self.assertEqual(expected, dumped)
class FormUrlEncodingTranscoderTests(unittest.TestCase):
transcoder: type_info.Transcoder
@ -715,3 +747,13 @@ class FormUrlEncodingTranscoderTests(unittest.TestCase):
pi = decimal.Decimal('3.142857142857142857142857143')
_, result = self.transcoder.to_bytes({'pi': pi})
self.assertEqual('pi={}'.format(str(pi)).encode(), result)
def test_that_dataclasses_are_handled_as_dicts(self):
@dataclasses.dataclass
class Point:
x: int
y: int
datum = typing.cast(type_info.SupportsDataclassFields, Point(3, 4))
_, result = self.transcoder.to_bytes(datum)
self.assertEqual(b'x=3&y=4', result)