Skip to content

Commit

Permalink
Merge pull request #212 from Fatal1ty/pep-695
Browse files Browse the repository at this point in the history
Add support for PEP 695 aliases
  • Loading branch information
Fatal1ty authored Apr 18, 2024
2 parents b11b5c2 + 69a4692 commit 98d96de
Show file tree
Hide file tree
Showing 8 changed files with 299 additions and 2 deletions.
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1815,8 +1815,9 @@ assert a1_dict == a2_dict == a3_dict == a4_dict == {"x": my_class_instance}

There are situations where you might want some values of the same type to be
treated as their own type. You can create new logical types with
[`NewType`](https://docs.python.org/3/library/typing.html#newtype) or
[`NewType`](https://docs.python.org/3/library/typing.html#newtype),
[`Annotated`](https://docs.python.org/3/library/typing.html#typing.Annotated)
or [`TypeAliasType`](https://docs.python.org/3/library/typing.html#typing.TypeAliasType)
and register serialization strategies for them:

```python
Expand All @@ -1827,9 +1828,12 @@ from mashumaro import DataClassDictMixin
SessionID = NewType("SessionID", str)
AccountID = Annotated[str, "AccountID"]

type DeviceID = str

@dataclass
class Context(DataClassDictMixin):
account_sessions: Mapping[AccountID, SessionID]
account_devices: list[DeviceID]

class Config:
serialization_strategy = {
Expand All @@ -1840,6 +1844,10 @@ class Context(DataClassDictMixin):
SessionID: {
"deserialize": lambda x: ...,
"serialize": lambda x: ...,
},
DeviceID: {
"deserialize": lambda x: ...,
"serialize": lambda x: ...,
}
}
```
Expand Down
9 changes: 9 additions & 0 deletions mashumaro/core/meta/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
PY_39_MIN,
PY_310_MIN,
PY_311_MIN,
PY_312_MIN,
)
from mashumaro.dialect import Dialect

Expand Down Expand Up @@ -85,6 +86,7 @@
"is_hashable_type",
"evaluate_forward_ref",
"get_forward_ref_referencing_globals",
"is_type_alias_type",
]


Expand Down Expand Up @@ -793,3 +795,10 @@ def get_forward_ref_referencing_globals(
)
else:
return getattr(forward_module, "__dict__", fallback)


def is_type_alias_type(typ: Type) -> bool:
if PY_312_MIN:
return isinstance(typ, typing.TypeAliasType) # type: ignore
else:
return False
3 changes: 3 additions & 0 deletions mashumaro/core/meta/types/pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
is_required,
is_self,
is_special_typing_primitive,
is_type_alias_type,
is_type_var,
is_type_var_any,
is_type_var_tuple,
Expand Down Expand Up @@ -516,6 +517,8 @@ def pack_special_typing_primitive(spec: ValueSpec) -> Optional[Expression]:
)
if evaluated is not None:
return PackerRegistry.get(spec.copy(type=evaluated))
elif is_type_alias_type(spec.type):
return PackerRegistry.get(spec.copy(type=spec.type.__value__))
raise UnserializableDataError(
f"{spec.type} as a field type is not supported by mashumaro"
)
Expand Down
3 changes: 3 additions & 0 deletions mashumaro/core/meta/types/unpack.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
is_required,
is_self,
is_special_typing_primitive,
is_type_alias_type,
is_type_var,
is_type_var_any,
is_type_var_tuple,
Expand Down Expand Up @@ -803,6 +804,8 @@ def unpack_special_typing_primitive(spec: ValueSpec) -> Optional[Expression]:
)
if evaluated is not None:
return UnpackerRegistry.get(spec.copy(type=evaluated))
elif is_type_alias_type(spec.type):
return UnpackerRegistry.get(spec.copy(type=spec.type.__value__))
raise UnserializableDataError(
f"{spec.type} as a field type is not supported by mashumaro"
)
Expand Down
1 change: 1 addition & 0 deletions mashumaro/mixins/dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class DataClassDictMixin:
__mashumaro_builder_params = {"packer": {}, "unpacker": {}} # type: ignore

def __init_subclass__(cls: Type[T], **kwargs: Any):
super().__init_subclass__(**kwargs)
for ancestor in cls.__mro__[-1:0:-1]:
builder_params_ = f"_{ancestor.__name__}__mashumaro_builder_params"
builder_params = getattr(ancestor, builder_params_, None)
Expand Down
8 changes: 7 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
from unittest.mock import patch

from mashumaro.core.const import PY_313_MIN
from mashumaro.core.const import PY_312_MIN, PY_313_MIN

if not PY_312_MIN:
collect_ignore = [
"test_generics_pep_695.py",
"test_pep_695.py",
]

if PY_313_MIN:
collect_ignore = [
Expand Down
240 changes: 240 additions & 0 deletions tests/test_generics_pep_695.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any, Mapping

from mashumaro import DataClassDictMixin
from mashumaro.mixins.json import DataClassJSONMixin
from tests.entities import MyGenericDataClass, SerializableTypeGenericList


@dataclass
class Foo[T](DataClassJSONMixin):
x: T
y: "Foo[Any] | None"


@dataclass
class Bar(Foo):
pass


def test_one_generic():
@dataclass
class A[T]:
x: T

@dataclass
class B(A[datetime], DataClassDictMixin):
pass

obj = B(datetime(2021, 8, 15))
assert B.from_dict({"x": "2021-08-15T00:00:00"}) == obj
assert obj.to_dict() == {"x": "2021-08-15T00:00:00"}


def test_one_generic_list():
@dataclass
class A[T](list[T]):
x: list[T]

@dataclass
class B(A[datetime], DataClassDictMixin):
pass

obj = B(x=[datetime(2021, 8, 15)])
assert B.from_dict({"x": ["2021-08-15T00:00:00"]}) == obj
assert obj.to_dict() == {"x": ["2021-08-15T00:00:00"]}


def test_two_generics():
@dataclass
class A1[T]:
x: list[T]

@dataclass
class A2[T, S]:
y: Mapping[T, S]

@dataclass
class B(A1[datetime], A2[datetime, date], DataClassDictMixin):
pass

obj = B(
x=[datetime(2021, 8, 15), datetime(2021, 8, 16)],
y={datetime(2021, 8, 17): date(2021, 8, 18)},
)
dump = {
"x": ["2021-08-15T00:00:00", "2021-08-16T00:00:00"],
"y": {"2021-08-17T00:00:00": "2021-08-18"},
}
assert B.from_dict(dump) == obj
assert obj.to_dict() == dump


def test_partially_concrete_generic():
@dataclass
class A[T, S]:
x: Mapping[T, S]

@dataclass
class B[S](A[datetime, S], DataClassDictMixin):
pass

obj = B(x={datetime(2022, 11, 21): "3.14"})
assert B.from_dict({"x": {"2022-11-21T00:00:00": "3.14"}}) == obj
assert obj.to_dict() == {"x": {"2022-11-21T00:00:00": "3.14"}}


def test_partially_concrete_generic_with_bound():
@dataclass
class A[T, P: (Mapping[int, int], list[float])]:
x: Mapping[T, P]

@dataclass
class B[P: (Mapping[int, int], list[float])](
A[date, P], DataClassDictMixin
):
pass

obj1 = B(x={date(2022, 11, 21): {1: 2, 3: 4}})
assert B.from_dict({"x": {"2022-11-21": {"1": "2", "3": "4"}}}) == obj1
assert obj1.to_dict() == {"x": {"2022-11-21": {1: 2, 3: 4}}}
obj2 = B(x={date(2022, 11, 21): [1.1, 3.3]})
assert (
B.from_dict({"x": {"2022-11-21": {"1.1": "2.2", "3.3": "4.4"}}})
== obj2
)
assert obj2.to_dict() == {"x": {"2022-11-21": [1.1, 3.3]}}
obj3 = B(x={date(2022, 11, 21): [1.1, 2.2, 3.3, 4.4]})
assert (
B.from_dict({"x": {"2022-11-21": ["1.1", "2.2", "3.3", "4.4"]}})
== obj3
)
assert obj3.to_dict() == {"x": {"2022-11-21": [1.1, 2.2, 3.3, 4.4]}}


def test_concrete_generic_with_different_type_var():
@dataclass
class A[T]:
x: T

@dataclass
class B[P: (Mapping[int, int], list[float])](A[P], DataClassDictMixin):
pass

obj = B.from_dict({"x": {"1": "2", "3": "4"}})
assert obj == B(x={1: 2, 3: 4})
obj = B.from_dict({"x": {"1.1": "2.2", "3.3": "4.4"}})
assert obj == B(x=[1.1, 3.3])
obj = B.from_dict({"x": ["1.1", "2.2", "3.3", "4.4"]})
assert obj == B(x=[1.1, 2.2, 3.3, 4.4])


def test_loose_generic_info_with_any_type():
@dataclass
class A[T]:
x: T

@dataclass
class B(A, DataClassDictMixin):
pass

obj = B.from_dict({"x": {"1.1": "2.2", "3.3": "4.4"}})
assert obj == B(x={"1.1": "2.2", "3.3": "4.4"})
obj = B.from_dict({"x": ["1.1", "2.2", "3.3", "4.4"]})
assert obj == B(x=["1.1", "2.2", "3.3", "4.4"])


def test_loose_generic_info_with_bound():
@dataclass
class A[P: (Mapping[int, int], list[float])]:
x: P

@dataclass
class B(A, DataClassDictMixin):
pass

obj = B.from_dict({"x": {"1": "2", "3": "4"}})
assert obj == B(x={1: 2, 3: 4})
obj = B.from_dict({"x": {"1.1": "2.2", "3.3": "4.4"}})
assert obj == B(x=[1.1, 3.3])
obj = B.from_dict({"x": ["1.1", "2.2", "3.3", "4.4"]})
assert obj == B(x=[1.1, 2.2, 3.3, 4.4])


def test_loose_generic_info_in_first_generic():
@dataclass
class A[P: (Mapping[int, int], list[float])]:
x: P

@dataclass
class B(A):
pass

@dataclass
class C[P: (Mapping[int, int], list[float])](B):
y: P

@dataclass
class D(C[list[float]], DataClassDictMixin):
pass

obj = D.from_dict({"x": {"1": "2"}, "y": {"3.3": "4.4"}})
assert obj == D(x={1: 2}, y=[3.3])
obj = D.from_dict({"x": {"1.1": "2.2"}, "y": {"3.3": "4.4"}})
assert obj == D(x=[1.1], y=[3.3])


def test_not_dataclass_generic():
class MyGeneric[P: (Mapping[int, int], list[float]), T]:
pass

@dataclass
class GenericDataClass[P: (Mapping[int, int], list[float])]:
x: P

@dataclass
class DataClass[P: (Mapping[int, int], list[float]), T](
MyGeneric[P, T], GenericDataClass[P]
):
pass

@dataclass
class ConcreteDataClass(DataClass[list[float], float], DataClassDictMixin):
pass

obj = ConcreteDataClass.from_dict({"x": {"1": "2", "3": "4"}})
assert obj == ConcreteDataClass(x=[1.0, 3.0])


def test_generic_dataclass_as_field_type():
@dataclass
class DataClass(DataClassDictMixin):
date: MyGenericDataClass[date]
str: MyGenericDataClass[str]

obj = DataClass(
date=MyGenericDataClass(date(2021, 9, 14)),
str=MyGenericDataClass("2021-09-14"),
)
dictionary = {"date": {"x": "2021-09-14"}, "str": {"x": "2021-09-14"}}
assert DataClass.from_dict(dictionary) == obj
assert obj.to_dict() == dictionary


def test_serializable_type_generic_class():
@dataclass
class DataClass(DataClassDictMixin):
x: SerializableTypeGenericList[str]

obj = DataClass(SerializableTypeGenericList(["a", "b", "c"]))
assert DataClass.from_dict({"x": ["a", "b", "c"]}) == obj
assert obj.to_dict() == {"x": ["a", "b", "c"]}


def test_self_referenced_generic_no_max_recursion_error():
obj = Bar(42, Foo(33, None))
assert obj.to_dict() == {"x": 42, "y": {"x": 33, "y": None}}
assert Bar.from_dict({"x": 42, "y": {"x": 33, "y": None}}) == obj
assert obj.to_json() == '{"x": 42, "y": {"x": 33, "y": null}}'
assert Bar.from_json('{"x": 42, "y": {"x": 33, "y": null}}') == obj
27 changes: 27 additions & 0 deletions tests/test_pep_695.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from dataclasses import dataclass
from datetime import date

from mashumaro import DataClassDictMixin
from mashumaro.codecs import BasicDecoder, BasicEncoder


def test_type_alias_type_with_dataclass_dict_mixin():
type MyDate = date

@dataclass
class MyClass(DataClassDictMixin):
x: MyDate

obj = MyClass(date(2024, 4, 15))
assert MyClass.from_dict({"x": "2024-04-15"}) == obj
assert obj.to_dict() == {"x": "2024-04-15"}


def test_type_alias_type_with_codecs():
type MyDate = date
decoder = BasicDecoder(MyDate)
encoder = BasicEncoder(MyDate)

obj = date(2024, 4, 15)
assert decoder.decode("2024-04-15") == obj
assert encoder.encode(obj) == "2024-04-15"

0 comments on commit 98d96de

Please sign in to comment.