Skip to content

Commit ecc2f84

Browse files
NiklasRosensteinGitHub Action
andauthored
nr/fix forward refs in base types (#51)
* 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. * Updated PR references in 1 changelogs. skip-checks: true --------- Co-authored-by: GitHub Action <[email protected]>
1 parent 5ad82a6 commit ecc2f84

File tree

4 files changed

+84
-1
lines changed

4 files changed

+84
-1
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[[entries]]
2+
id = "b44947f0-8e70-4a29-b584-a20eeff51bba"
3+
type = "fix"
4+
description = "Fixed serde of types that have a parameterized generic base class. (First reported in NiklasRosenstein/pydoc-markdown#292)"
5+
6+
pr = "https://github.com/NiklasRosenstein/python-databind/pull/51"

databind.core/src/databind/core/schema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ class A(Generic[T]):
246246

247247
# Continue with the base classes.
248248
for base in hint.bases or hint.type.__bases__:
249-
base_hint = TypeHint(base).parameterize(parameter_map)
249+
base_hint = TypeHint(base, source=hint.type).evaluate().parameterize(parameter_map)
250250
assert isinstance(base_hint, ClassTypeHint), f"nani? {base_hint}"
251251
if dataclasses.is_dataclass(base_hint.type):
252252
queue.append(base_hint)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[[entries]]
2+
id = "6ac5f7d0-aeb8-436c-bc2c-a178e2a82a74"
3+
type = "tests"
4+
description = "Add a unit tests to demonstrate that deserializing a nested type cannot work."
5+
author = "@NiklasRosenstein"
6+
pr = "https://github.com/NiklasRosenstein/python-databind/pull/51"

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,3 +543,74 @@ class Nt(t.NamedTuple):
543543

544544
assert mapper.serialize(Nt(1, "2"), Nt) == {"a": 1, "b": "2"}
545545
assert mapper.deserialize({"a": 1, "b": "2"}, Nt) == Nt(1, "2")
546+
547+
548+
T_Page = t.TypeVar("T_Page", bound="Page[t.Any]")
549+
550+
551+
@dataclasses.dataclass
552+
class Page(t.Generic[T_Page]):
553+
name: str
554+
children: t.List[T_Page]
555+
556+
557+
@dataclasses.dataclass
558+
class SpecificPage(Page["SpecificPage"]):
559+
pass
560+
561+
562+
def test__parameterized_base_type_with_forward_ref() -> None:
563+
mapper = make_mapper([SchemaConverter(), PlainDatatypeConverter(), CollectionConverter()])
564+
payload = {"name": "root", "children": [{"name": "child", "children": []}]}
565+
expected = SpecificPage("root", [SpecificPage("child", [])])
566+
assert mapper.deserialize(payload, SpecificPage) == expected
567+
mapper.serialize(expected, SpecificPage) == payload
568+
569+
570+
def test__parameterized_self_seferential_generic_cannot_be_processed() -> None:
571+
"""
572+
A self-referential generic type (or nested generic type) cannot be properly parameterized in Mypy. [1]
573+
574+
For the page type above, if you would like to use it as-is, you would need to infinitely parameterized it,
575+
as in `Page[Page[Page[Page[Page[... etc]]]]]`. As far as I am aware (@NiklasRosenstein, 2023.06.10), this can
576+
only be solved by creating a dedicated specialized type that is self-referential via its base-class:
577+
578+
```py
579+
class MyPage(Page["MyPage"]):
580+
pass
581+
```
582+
583+
When we encounter a partially parameterized type like `Page[Page]` (the inner `Page` is missing a type parameter),
584+
databind will not have a way of knowing the value for the type parameter of the inner `Page` and will therefore
585+
fail with an error like this:
586+
587+
```
588+
databind.core.converter.NoMatchingConverter: no deserializer for `TypeHint(~T_Page)` and payload of type `dict`
589+
```
590+
591+
[1]: https://github.com/python/mypy/issues/13693
592+
"""
593+
594+
mapper = make_mapper([SchemaConverter(), PlainDatatypeConverter(), CollectionConverter()])
595+
596+
payload = {
597+
"name": "root",
598+
"children": [
599+
{
600+
"name": "child",
601+
"children": [
602+
# This is the level at which the deserialization will fail.
603+
{"name": "grandchild", "children": []}
604+
],
605+
}
606+
],
607+
}
608+
609+
with pytest.raises(NoMatchingConverter) as excinfo:
610+
mapper.deserialize(payload, Page[Page]) # type: ignore[type-arg]
611+
assert str(excinfo.value).splitlines()[0] == "no deserializer for `TypeHint(~T_Page)` and payload of type `dict`"
612+
613+
# It works with an additional level of page parameterization.
614+
assert mapper.deserialize(payload, Page[Page[Page[Page]]]) == Page( # type: ignore[type-arg]
615+
"root", [Page("child", [Page("grandchild", [])])]
616+
)

0 commit comments

Comments
 (0)