Skip to content

Commit cc6af2f

Browse files
NiklasRosensteinGitHub Action
andauthored
Handle forward refs in base types and improve logic that determines the item or key/value type in the CollectionConverter and MappingConverter respectively (#52)
* fix: Fixed serde of types that have a parameterized generic base class. (First reported in NiklasRosenstein/pydoc-markdown#292) * tests: Add a unit tests to demonstrate that deserializing a nested type cannot work. * Properly infer item type for collections in the `CollectionConverter`, do not assume `Any` for un-parameterized collections. * Improve logic to find item type of collection in `CollectionConverter` * databind.core/: improvement: Work around highly nested error tracebacks in `Module.convert()` by expanding all converters returned by `Module.get_converter()`. Note that this means `Module.convert()` is no longer called if the module is a child of another module. * databind.core/: improvement: Add `DelegateToClassmethodConverter(serialized_type)` parameter. * databind.json/: improvement: The `MappingConverter` now does improved resolution of the key and value type just like the `CollectionConverter`; note that an unparameterized Mapping no longer has its key and value type fall back to `typing.Any` * databind.json/: improvement: Use `ConversionError.expected()` factory function in `PlainDatatypeConverter` * databind.json/: tests: Test `JsonConverter` * Updated PR references in 5 changelogs. skip-checks: true --------- Co-authored-by: GitHub Action <[email protected]>
1 parent ecc2f84 commit cc6af2f

File tree

10 files changed

+267
-28
lines changed

10 files changed

+267
-28
lines changed

databind.core/.changelog/_unreleased.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,17 @@ type = "fix"
44
description = "Fixed serde of types that have a parameterized generic base class. (First reported in NiklasRosenstein/pydoc-markdown#292)"
55
66
pr = "https://github.com/NiklasRosenstein/python-databind/pull/51"
7+
8+
[[entries]]
9+
id = "43fe920b-b6b9-4701-9403-bff9aba2e744"
10+
type = "improvement"
11+
description = "Work around highly nested error tracebacks in `Module.convert()` by expanding all converters returned by `Module.get_converter()`. Note that this means `Module.convert()` is no longer called if the module is a child of another module."
12+
author = "@NiklasRosenstein"
13+
pr = "https://github.com/NiklasRosenstein/python-databind/pull/52"
14+
15+
[[entries]]
16+
id = "e2e3fbdf-b19c-443d-bc79-8560dba4b1b5"
17+
type = "improvement"
18+
description = "Add `DelegateToClassmethodConverter(serialized_type)` parameter."
19+
author = "@NiklasRosenstein"
20+
pr = "https://github.com/NiklasRosenstein/python-databind/pull/52"

databind.core/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ python = "^3.6.3"
1818
Deprecated = "^1.2.12"
1919
nr-date = "^2.0.0"
2020
nr-stream = "^1.0.0"
21-
typeapi = "^1.4.2"
21+
typeapi = "^2.0.1"
2222
typing-extensions = ">=3.10.0"
2323

2424
[tool.poetry.dev-dependencies]

databind.core/src/databind/core/converter.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ def register(self, converter: Converter, first: bool = False) -> None:
7171
self.converters.append(converter)
7272

7373
def get_converters(self, ctx: "Context") -> t.Iterator[Converter]:
74-
yield from self.converters
74+
for converter in self.converters:
75+
if isinstance(converter, Module):
76+
yield from converter.get_converters(ctx)
77+
else:
78+
yield converter
7579

7680
def convert(self, ctx: "Context") -> t.Any:
7781
errors: t.List[t.Tuple[Converter, Exception]] = []
@@ -150,18 +154,29 @@ class DelegateToClassmethodConverter(Converter):
150154
scenario (e.g. such as de/serializing JSON with the #databind.json.settings.JsonConverter setting).
151155
"""
152156

153-
def __init__(self, *, serialize: "str | None" = None, deserialize: "str | None" = None) -> None:
157+
def __init__(
158+
self,
159+
serialized_type: t.Union[t.Type[t.Any], t.Tuple[t.Type[t.Any], ...], None] = None,
160+
*,
161+
serialize: "str | None" = None,
162+
deserialize: "str | None" = None,
163+
) -> None:
164+
self._serialized_type = serialized_type
154165
self._serialize = serialize
155166
self._deserialize = deserialize
156167

157168
def serialize(self, ctx: "Context") -> t.Any:
158169
if self._serialize is None or not isinstance(ctx.datatype, ClassTypeHint):
159170
raise NotImplementedError
171+
if not isinstance(ctx.value, ctx.datatype.type):
172+
raise ConversionError.expected(self, ctx, ctx.datatype.type)
160173
method: t.Callable[[t.Any], t.Any] = getattr(ctx.datatype.type, self._serialize)
161174
return method(ctx.value)
162175

163176
def deserialize(self, ctx: "Context") -> t.Any:
164177
if self._deserialize is None or not isinstance(ctx.datatype, ClassTypeHint):
165178
raise NotImplementedError
179+
if self._serialized_type is not None and not isinstance(ctx.value, self._serialized_type):
180+
raise ConversionError.expected(self, ctx, self._serialized_type)
166181
method: t.Callable[[t.Any], t.Any] = getattr(ctx.datatype.type, self._deserialize)
167182
return method(ctx.value)

databind.json/.changelog/_unreleased.toml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,38 @@ type = "tests"
44
description = "Add a unit tests to demonstrate that deserializing a nested type cannot work."
55
author = "@NiklasRosenstein"
66
pr = "https://github.com/NiklasRosenstein/python-databind/pull/51"
7+
8+
[[entries]]
9+
id = "89204ef2-6173-4d4e-b857-3af59db2de32"
10+
type = "fix"
11+
description = "Technically a breaking change, but any consumer who is relying on this behaviour probably does that implicitly and wants to change their code anyway. o_o -- The `CollectionConverter` no longer implicitly assumes `Any` as the item type if collection is not parameterized."
12+
author = "@NiklasRosenstein"
13+
pr = "https://github.com/NiklasRosenstein/python-databind/pull/52"
14+
15+
[[entries]]
16+
id = "0b05e104-7012-4a03-a995-12ed05878f0b"
17+
type = "improvement"
18+
description = "The `CollectionConverter` now properly infers the item type from the types base classes"
19+
author = "@NiklasRosenstein"
20+
pr = "https://github.com/NiklasRosenstein/python-databind/pull/52"
21+
22+
[[entries]]
23+
id = "ae147797-2857-4e6e-b7af-d2c6c1152e59"
24+
type = "improvement"
25+
description = "The `MappingConverter` now does improved resolution of the key and value type just like the `CollectionConverter`; note that an unparameterized Mapping no longer has its key and value type fall back to `typing.Any`"
26+
author = "@NiklasRosenstein"
27+
pr = "https://github.com/NiklasRosenstein/python-databind/pull/52"
28+
29+
[[entries]]
30+
id = "08e63490-84b9-418d-8f26-39c770f17d59"
31+
type = "improvement"
32+
description = "Use `ConversionError.expected()` factory function in `PlainDatatypeConverter`"
33+
author = "@NiklasRosenstein"
34+
pr = "https://github.com/NiklasRosenstein/python-databind/pull/52"
35+
36+
[[entries]]
37+
id = "78c6beda-22dd-419e-848f-5835ab554efd"
38+
type = "tests"
39+
description = "Test `JsonConverter`"
40+
author = "@NiklasRosenstein"
41+
pr = "https://github.com/NiklasRosenstein/python-databind/pull/52"

databind.json/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Repository = "https://github.com/NiklasRosenstein/python-databind"
1717
python = "^3.6.3"
1818
"databind.core" = "^4.3.2"
1919
nr-date = "^2.0.0"
20-
typeapi = "^1.4.2"
20+
typeapi = "^2.0.1"
2121
typing-extensions = ">=3.10.0"
2222

2323
[tool.poetry.dev-dependencies]

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

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,18 @@ def _length_check() -> None:
127127
)
128128

129129
else:
130-
# There could be no arguments to the collection type, in which case we
131-
# consider Any as the item type.
132-
item_types_iterator = iter(lambda: datatype[0] if datatype.args else TypeHint(t.Any), None)
133-
if isinstance(datatype, TupleTypeHint):
134-
python_type = tuple
135-
else:
136-
python_type = datatype.type
130+
candidates = set()
131+
for current in datatype.recurse_bases():
132+
if issubclass(current.type, t.Collection) and len(current.args) == 1:
133+
candidates.add(current.args[0])
134+
if len(candidates) == 0:
135+
raise ConversionError(self, ctx, f"could not find item type in {datatype}")
136+
elif len(candidates) > 1:
137+
raise ConversionError(self, ctx, f"found multiple item types in {datatype}: {candidates}")
138+
139+
item_type = TypeHint(next(iter(candidates)))
140+
item_types_iterator = iter(lambda: item_type, None)
141+
python_type = datatype.type
137142

138143
def _length_check() -> None:
139144
pass
@@ -317,13 +322,20 @@ def __init__(self, json_mapping_type: t.Type[t.Mapping[str, t.Any]] = dict) -> N
317322

318323
def convert(self, ctx: Context) -> t.Any:
319324
datatype = _unwrap_annotated(ctx.datatype)
325+
326+
# Find the key and value types of the mapping.
320327
if not isinstance(datatype, ClassTypeHint) or not issubclass(datatype.type, t.Mapping):
321328
raise NotImplementedError
329+
candidates = set()
330+
for current in datatype.recurse_bases():
331+
if issubclass(current.type, t.Mapping) and len(current.args) == 2:
332+
candidates.add(current.args)
333+
if len(candidates) == 0:
334+
raise ConversionError(self, ctx, f"could not find key/value type in {datatype}")
335+
elif len(candidates) > 1:
336+
raise ConversionError(self, ctx, f"found multiple key/value types in {datatype}: {candidates}")
322337

323-
if not datatype.args:
324-
key_type, value_type = t.Any, t.Any
325-
else:
326-
key_type, value_type = datatype.args
338+
key_type, value_type = next(iter(candidates))
327339

328340
if not isinstance(ctx.value, t.Mapping):
329341
raise ConversionError.expected(self, ctx, t.Mapping)
@@ -417,8 +429,7 @@ def convert(self, ctx: Context) -> t.Any:
417429
adapter = adapters.get((source_type, target_type))
418430

419431
if adapter is None:
420-
msg = f"unable to {ctx.direction.name.lower()} {source_type.__name__} -> {target_type.__name__}"
421-
raise ConversionError(self, ctx, msg)
432+
raise ConversionError.expected(self, ctx, target_type, source_type)
422433

423434
try:
424435
return adapter(ctx.value)

databind.json/src/databind/json/module.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ def __init__(self) -> None:
5555
self.register(StringifyConverter(duration, duration.parse, name="JsonModule:nr.date.duration"), first=True)
5656
self.register(LiteralConverter())
5757

58+
self.register(JsonConverterSupport(), first=True)
59+
60+
61+
class JsonConverterSupport(Module):
62+
"""
63+
Handles the JsonConverter setting.
64+
"""
65+
66+
def __init__(self) -> None:
67+
super().__init__(__name__ + ".JsonConverterSupport")
68+
5869
def get_converters(self, ctx: Context) -> Iterator[Converter]:
5970
converter_setting = ctx.get_setting(JsonConverter)
6071
if converter_setting is not None:

databind.json/src/databind/json/settings.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,12 @@ def __init__(self, supplier: t.Union[ConverterSupplier, Converter]) -> None:
5656
self.supplier = supplier
5757

5858
@staticmethod
59-
def using_classmethods(*, serialize: "str | None" = None, deserialize: "str | None" = None) -> "JsonConverter":
60-
return JsonConverter(DelegateToClassmethodConverter(serialize=serialize, deserialize=deserialize))
59+
def using_classmethods(
60+
serialized_type: t.Union[t.Type[t.Any], t.Tuple[t.Type[t.Any], ...], None] = None,
61+
*,
62+
serialize: "str | None" = None,
63+
deserialize: "str | None" = None
64+
) -> "JsonConverter":
65+
return JsonConverter(
66+
DelegateToClassmethodConverter(serialized_type, serialize=serialize, deserialize=deserialize)
67+
)

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

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import datetime
33
import decimal
44
import enum
5+
import sys
56
import typing as t
67
import uuid
78
from collections import namedtuple
@@ -36,6 +37,7 @@
3637
StringifyConverter,
3738
UnionConverter,
3839
)
40+
from databind.json.module import JsonConverterSupport, JsonModule
3941
from databind.json.settings import JsonConverter
4042

4143

@@ -200,7 +202,11 @@ def test_stringify_converter(direction: Direction) -> None:
200202
@pytest.mark.parametrize("direction", (Direction.SERIALIZE, Direction.DESERIALIZE))
201203
def test_mapping_converter(direction: Direction) -> None:
202204
mapper = make_mapper([AnyConverter(), MappingConverter(), PlainDatatypeConverter()])
203-
assert mapper.convert(direction, {"a": 1}, t.Mapping) == {"a": 1}
205+
206+
with pytest.raises(ConversionError) as excinfo:
207+
mapper.convert(direction, {"a": 1}, t.Mapping)
208+
assert str(excinfo.value).splitlines()[0] == "could not find key/value type in TypeHint(typing.Mapping)"
209+
204210
assert mapper.convert(direction, {"a": 1}, t.Mapping[str, int]) == {"a": 1}
205211
assert mapper.convert(direction, {"a": 1}, t.MutableMapping[str, int]) == {"a": 1}
206212
assert mapper.convert(direction, {"a": 1}, t.Dict[str, int]) == {"a": 1}
@@ -226,10 +232,57 @@ class CustomDict(t.Dict[K, V]):
226232
# assert mapper.convert(direction, {"a": 1}, FixedDict) == FixedDict({"a": 1})
227233

228234

235+
def test__MappingConverter__cannot_deserialize_dict_without_key_value_annotations() -> None:
236+
mapper = make_mapper([MappingConverter()])
237+
with pytest.raises(ConversionError) as excinfo:
238+
mapper.deserialize({"a": 1, "b": 2}, dict)
239+
assert str(excinfo.value).splitlines()[0] == "could not find key/value type in TypeHint(dict)"
240+
with pytest.raises(ConversionError) as excinfo:
241+
mapper.deserialize({"a": 1, "b": 2}, t.Dict)
242+
assert str(excinfo.value).splitlines()[0] == "could not find key/value type in TypeHint(typing.Dict)"
243+
244+
245+
def test__MappingConverter__can_deserialize_dict_with_key_value_annotations() -> None:
246+
mapper = make_mapper([PlainDatatypeConverter(), MappingConverter()])
247+
if sys.version_info[:2] >= (3, 10):
248+
assert mapper.deserialize({"a": 1, "b": 2}, dict[str, int]) == {"a": 1, "b": 2}
249+
assert mapper.deserialize({"a": 1, "b": 2}, t.Dict[str, int]) == {"a": 1, "b": 2}
250+
251+
252+
def test__MappingConverter__can_serde_custom_key_type() -> None:
253+
@JsonConverter.using_classmethods(str, serialize="__str__", deserialize="of")
254+
@dataclasses.dataclass(frozen=True)
255+
class MyKeyType:
256+
a: str
257+
b: str
258+
259+
def __str__(self) -> str:
260+
return f"{self.a}/{self.b}"
261+
262+
@staticmethod
263+
def of(v: str) -> "MyKeyType":
264+
return MyKeyType(*v.split("/"))
265+
266+
json = {"a/b": 1, "b/c": 2}
267+
python = {MyKeyType("a", "b"): 1, MyKeyType("b", "c"): 2}
268+
269+
for key, mapper in {
270+
"Subset": make_mapper([PlainDatatypeConverter(), MappingConverter(), JsonConverterSupport()]),
271+
"JsonModule": make_mapper([JsonModule()]),
272+
}.items():
273+
print(">", key)
274+
assert mapper.deserialize(json, t.Mapping[MyKeyType, int]) == python
275+
assert mapper.serialize(python, t.Mapping[MyKeyType, int]) == json
276+
277+
229278
@pytest.mark.parametrize("direction", (Direction.SERIALIZE, Direction.DESERIALIZE))
230279
def test_collection_converter(direction: Direction) -> None:
231280
mapper = make_mapper([AnyConverter(), CollectionConverter(), PlainDatatypeConverter()])
232-
assert mapper.convert(direction, [1, 2, 3], t.Collection) == [1, 2, 3]
281+
282+
with pytest.raises(ConversionError) as excinfo:
283+
mapper.convert(direction, [1, 2, 3], t.Collection)
284+
assert str(excinfo.value).splitlines()[0] == "could not find item type in TypeHint(typing.Collection)"
285+
233286
assert mapper.convert(direction, [1, 2, 3], t.Collection[int]) == [1, 2, 3]
234287
assert mapper.convert(direction, [1, 2, 3], t.MutableSequence[int]) == [1, 2, 3]
235288
assert mapper.convert(direction, [1, 2, 3], t.List[int]) == [1, 2, 3]
@@ -294,7 +347,7 @@ def test_union_converter_flat_plain_types_not_supported(direction: Direction) ->
294347
if direction == Direction.DESERIALIZE:
295348
with pytest.raises(ConversionError) as excinfo:
296349
assert mapper.convert(direction, {"type": "int", "int": 42}, th)
297-
assert "unable to deserialize dict -> int" in str(excinfo.value)
350+
assert "expected int, got dict instead" in str(excinfo.value)
298351
else:
299352
with pytest.raises(ConversionError) as excinfo:
300353
assert mapper.convert(direction, 42, th)
@@ -516,22 +569,28 @@ def test_deserialize_tuple() -> None:
516569

517570
with pytest.raises(ConversionError) as excinfo:
518571
databind.json.load([1, 42], t.Tuple[int, str])
519-
assert excinfo.value.message == "unable to deserialize int -> str"
572+
assert excinfo.value.message == "expected str, got int instead"
520573

521574
with pytest.raises(ConversionError) as excinfo:
522575
databind.json.load([1, 42, 3], t.Tuple[int, int])
523576
assert excinfo.value.message == "expected a tuple of length 2, found 3"
524577

525578

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.
579+
def test__namedtuple__cannot_serde() -> None:
580+
"""
581+
There is no type information for #collections.namedtuples.
582+
"""
583+
529584
mapper = make_mapper([CollectionConverter(), PlainDatatypeConverter(), AnyConverter()])
530585

531586
nt = namedtuple("nt", ["a", "b"])
532587

533-
assert mapper.serialize(nt(1, 2), nt) == [1, 2]
534-
assert mapper.deserialize([1, 2], nt) == nt(1, 2)
588+
with pytest.raises(ConversionError) as excinfo:
589+
print(mapper.serialize(nt(1, 2), nt))
590+
assert str(excinfo.value).splitlines()[0] == "could not find item type in TypeHint(converters_test.nt)"
591+
with pytest.raises(ConversionError) as excinfo:
592+
print(mapper.deserialize([1, 2], nt))
593+
assert str(excinfo.value).splitlines()[0] == "could not find item type in TypeHint(converters_test.nt)"
535594

536595

537596
def test__typing_NamedTuple() -> None:
@@ -614,3 +673,43 @@ class MyPage(Page["MyPage"]):
614673
assert mapper.deserialize(payload, Page[Page[Page[Page]]]) == Page( # type: ignore[type-arg]
615674
"root", [Page("child", [Page("grandchild", [])])]
616675
)
676+
677+
678+
def test__list__fails_without_type_parameter() -> None:
679+
mapper = make_mapper([AnyConverter(), CollectionConverter()])
680+
with pytest.raises(ConversionError) as excinfo:
681+
mapper.deserialize([1, 2, 3], list)
682+
assert str(excinfo.value).splitlines()[0] == "could not find item type in TypeHint(list)"
683+
with pytest.raises(ConversionError) as excinfo:
684+
mapper.deserialize([1, 2, 3], t.List)
685+
assert str(excinfo.value).splitlines()[0] == "could not find item type in TypeHint(typing.List)"
686+
687+
688+
def test__list__subclass_items_deserialized_correctly() -> None:
689+
class MyList(t.List[SpecificPage]):
690+
pass
691+
692+
mapper = make_mapper([SchemaConverter(), AnyConverter(), CollectionConverter(), PlainDatatypeConverter()])
693+
assert mapper.deserialize([{"name": "foo", "children": []}], t.List[SpecificPage]) == MyList(
694+
[SpecificPage("foo", [])]
695+
)
696+
assert mapper.deserialize([{"name": "foo", "children": []}], MyList) == MyList([SpecificPage("foo", [])])
697+
698+
699+
def test__JsonConverter__using_classmethods_on_plain_class() -> None:
700+
@JsonConverter.using_classmethods(str, serialize="__str__", deserialize="of")
701+
class MyCls:
702+
def __eq__(self, other: t.Any) -> bool:
703+
return type(other) is MyCls
704+
705+
def __str__(self) -> str:
706+
return "MyCls"
707+
708+
@classmethod
709+
def of(cls, v: str) -> "MyCls":
710+
assert v == "MyCls"
711+
return cls()
712+
713+
mapper = make_mapper([JsonConverterSupport()])
714+
assert mapper.serialize(MyCls(), MyCls) == "MyCls"
715+
assert mapper.deserialize("MyCls", MyCls) == MyCls()

0 commit comments

Comments
 (0)