Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit a32378c

Browse files
NiklasRosensteinGitHub Action
andauthored
Fix namedtuple serde (#49)
* add unit tests for namedtuple * update tests * fix collections.namedtuple case * databind.json/: fix: Fix serde of `collections.namedtuple` (to array) and `typing.NamedTuple` (to object) * remove anyconvert in test * fix check for __annotations__ * Updated PR references in 1 changelogs. skip-checks: true * use ubuntu-20.04 for py 3.6 --------- Co-authored-by: GitHub Action <[email protected]>
1 parent 2d0157e commit a32378c

File tree

4 files changed

+68
-7
lines changed

4 files changed

+68
-7
lines changed

.github/workflows/python.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ on:
66

77
jobs:
88
test:
9-
runs-on: ubuntu-latest
9+
runs-on: ubuntu-20.04
1010
strategy:
1111
matrix:
1212
python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.x"]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[[entries]]
2+
id = "8504f1e4-679f-4a09-8888-bfca54bd4201"
3+
type = "fix"
4+
description = "Fix serde of `collections.namedtuple` (to array) and `typing.NamedTuple` (to object)"
5+
author = "@NiklasRosenstein"
6+
pr = "https://github.com/NiklasRosenstein/python-databind/pull/49"

databind.json/src/databind/json/converters.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,31 @@ def convert(self, ctx: Context) -> t.Any:
8888
) and not isinstance(datatype, TupleTypeHint):
8989
raise NotImplementedError
9090

91+
# NamedTuples with type information are a lot like data classes, so we delegate to the SchemaConverter.
92+
if (
93+
isinstance(datatype, ClassTypeHint)
94+
and issubclass(datatype.type, tuple)
95+
and getattr(datatype.type, "__annotations__", None)
96+
):
97+
schema = Schema(
98+
fields={
99+
name: Field(
100+
datatype=TypeHint(type_),
101+
)
102+
for name, type_ in getattr(datatype.type, "__annotations__").items()
103+
},
104+
constructor=datatype.type,
105+
type=datatype.type,
106+
)
107+
if ctx.direction.is_serialize():
108+
return SchemaConverter().serialize_from_schema(ctx, schema)
109+
elif ctx.direction.is_deserialize():
110+
return SchemaConverter().deserialize_from_schema(ctx, schema)
111+
else:
112+
assert False, ctx.direction
113+
114+
# TODO(@niklas.rosenstein): Should we support an object-based JSON representation for collections.namedtuple?
115+
91116
if isinstance(datatype, TupleTypeHint) and not datatype.repeated:
92117
# Require that the length of the input data matches the tuple.
93118
item_types_iterator = iter(datatype)
@@ -129,6 +154,9 @@ def _length_check() -> None:
129154
values = list(values)
130155
if python_type == list:
131156
return values
157+
elif hasattr(python_type, "_fields"): # For collections.namedtuple
158+
return python_type(*values)
159+
132160
try:
133161
return python_type(values)
134162
except TypeError:
@@ -431,9 +459,7 @@ def _get_schema(self, ctx: Context) -> Schema:
431459
except ValueError as exc:
432460
raise NotImplementedError(str(exc))
433461

434-
def serialize(self, ctx: Context) -> t.MutableMapping[str, t.Any]:
435-
schema = self._get_schema(ctx)
436-
462+
def serialize_from_schema(self, ctx: Context, schema: Schema) -> t.MutableMapping[str, t.Any]:
437463
try:
438464
is_instance = isinstance(ctx.value, schema.type)
439465
except TypeError:
@@ -502,9 +528,7 @@ def _get_field_value(field_name: str, field: Field) -> t.Any:
502528

503529
return result
504530

505-
def deserialize(self, ctx: Context) -> t.Any:
506-
schema = self._get_schema(ctx)
507-
531+
def deserialize_from_schema(self, ctx: Context, schema: Schema) -> t.Any:
508532
if not isinstance(ctx.value, t.Mapping):
509533
raise ConversionError.expected(self, ctx, t.Mapping)
510534

@@ -575,6 +599,14 @@ def _extract_fields(fields: t.Dict[str, Field]) -> t.Dict[str, t.Any]:
575599

576600
return schema.constructor(**result)
577601

602+
def deserialize(self, ctx: Context) -> t.Any:
603+
schema = self._get_schema(ctx)
604+
return self.deserialize_from_schema(ctx, schema)
605+
606+
def serialize(self, ctx: Context) -> t.MutableMapping[str, t.Any]:
607+
schema = self._get_schema(ctx)
608+
return self.serialize_from_schema(ctx, schema)
609+
578610

579611
class StringifyConverter(Converter):
580612
"""A useful helper converter that matches on a given type or its subclasses and converts them to a string for

databind.json/src/databind/json/tests/converters_test.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import enum
55
import typing as t
66
import uuid
7+
from collections import namedtuple
78

89
import pytest
910
import typing_extensions as te
@@ -520,3 +521,25 @@ def test_deserialize_tuple() -> None:
520521
with pytest.raises(ConversionError) as excinfo:
521522
databind.json.load([1, 42, 3], t.Tuple[int, int])
522523
assert excinfo.value.message == "expected a tuple of length 2, found 3"
524+
525+
526+
def test__namedtuple() -> None:
527+
# NOTE: Need the AnyConverter because the namedtuple is not a dataclass and we don't have type information
528+
# for the fields.
529+
mapper = make_mapper([CollectionConverter(), PlainDatatypeConverter(), AnyConverter()])
530+
531+
nt = namedtuple("nt", ["a", "b"])
532+
533+
assert mapper.serialize(nt(1, 2), nt) == [1, 2]
534+
assert mapper.deserialize([1, 2], nt) == nt(1, 2)
535+
536+
537+
def test__typing_NamedTuple() -> None:
538+
mapper = make_mapper([CollectionConverter(), PlainDatatypeConverter()])
539+
540+
class Nt(t.NamedTuple):
541+
a: int
542+
b: str
543+
544+
assert mapper.serialize(Nt(1, "2"), Nt) == {"a": 1, "b": "2"}
545+
assert mapper.deserialize({"a": 1, "b": "2"}, Nt) == Nt(1, "2")

0 commit comments

Comments
 (0)