Skip to content

Commit

Permalink
Merge branch 'pep-563'
Browse files Browse the repository at this point in the history
  • Loading branch information
Fatal1ty committed Nov 1, 2021
2 parents 8f10a72 + d453afc commit 8fd27a7
Show file tree
Hide file tree
Showing 12 changed files with 262 additions and 13 deletions.
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Table of contents
* [`aliases` config option](#aliases-config-option)
* [`serialize_by_alias` config option](#serialize_by_alias-config-option)
* [`namedtuple_as_dict` config option](#namedtuple_as_dict-config-option)
* [`allow_postponed_evaluation` config option](#allow_postponed_evaluation-config-option)
* [Code generation options](#code-generation-options)
* [Add `omit_none` keyword argument](#add-omit_none-keyword-argument)
* [Add `by_alias` keyword argument](#add-by_alias-keyword-argument)
Expand Down Expand Up @@ -847,6 +848,56 @@ If you want to serialize only certain named tuple fields as dictionaries, you
can use the corresponding [serialization](#serialize-option) and
[deserialization](#deserialize-option) engines.

#### `allow_postponed_evaluation` config option

[PEP 563](https://www.python.org/dev/peps/pep-0563/) solved the problem of forward references by postponing the evaluation
of annotations, so you can write the following code:

```python
from __future__ import annotations
from dataclasses import dataclass
from mashumaro import DataClassDictMixin

@dataclass
class A(DataClassDictMixin):
x: B

@dataclass
class B(DataClassDictMixin):
y: int

obj = A.from_dict({'x': {'y': 1}})
```

You don't need to write anything special here, forward references work out of
the box. If a field of a dataclass has a forward reference in the type
annotations, building of `from_dict` and `to_dict` methods of this dataclass
will be postponed until they are called once. However, if for some reason you
don't want the evaluation to be possibly postponed, you can disable it using
`allow_postponed_evaluation` option:

```python
from __future__ import annotations
from dataclasses import dataclass
from mashumaro import DataClassDictMixin

@dataclass
class A(DataClassDictMixin):
x: B

class Config:
allow_postponed_evaluation = False

# UnresolvedTypeReferenceError: Class A has unresolved type reference B
# in some of its fields

@dataclass
class B(DataClassDictMixin):
y: int
```

In this case you will get `UnresolvedTypeReferenceError` regardless of whether class B is declared below or not.

### Code generation options

#### Add `omit_none` keyword argument
Expand Down
1 change: 1 addition & 0 deletions mashumaro/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class BaseConfig:
aliases: Dict[str, str] = {}
serialize_by_alias: bool = False
namedtuple_as_dict: bool = False
allow_postponed_evaluation: bool = True


__all__ = [
Expand Down
16 changes: 16 additions & 0 deletions mashumaro/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,19 @@ def __str__(self):
f"in {self.holder_class_name}"
)
return s


class UnresolvedTypeReferenceError(NameError):
def __init__(self, holder_class, unresolved_type_name):
self.holder_class = holder_class
self.name = unresolved_type_name

@property
def holder_class_name(self):
return type_name(self.holder_class, short=True)

def __str__(self):
return (
f"Class {self.holder_class_name} has unresolved type reference "
f"{self.name} in some of its fields"
)
21 changes: 20 additions & 1 deletion mashumaro/meta/helpers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import dataclasses
import inspect
import re
import types
import typing
from contextlib import suppress
Expand All @@ -9,7 +10,15 @@

import typing_extensions

from .macros import PY_36, PY_37, PY_37_MIN, PY_38, PY_38_MIN, PY_39_MIN
from .macros import (
PY_36,
PY_37,
PY_37_MIN,
PY_38,
PY_38_MIN,
PY_39_MIN,
PY_310_MIN,
)

DataClassDictMixinPath = "mashumaro.serializer.base.dict.DataClassDictMixin"
NoneType = type(None)
Expand Down Expand Up @@ -290,8 +299,17 @@ def resolve_type_vars(cls, arg_types=(), is_cls_created=False):
return result


def get_name_error_name(e: NameError) -> str:
if PY_310_MIN:
return e.name # type: ignore
else:
match = re.search("'(.*)'", e.args[0])
return match.group(1) if match else ""


__all__ = [
"get_type_origin",
"get_args",
"type_name",
"is_special_typing_primitive",
"is_generic",
Expand All @@ -309,4 +327,5 @@ def resolve_type_vars(cls, arg_types=(), is_cls_created=False):
"is_dataclass_dict_mixin_subclass",
"resolve_type_vars",
"get_generic_name",
"get_name_error_name",
]
2 changes: 2 additions & 0 deletions mashumaro/meta/macros.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
PY_37_MIN = PY_37 or PY_38 or PY_39 or PY_310
PY_38_MIN = PY_38 or PY_39 or PY_310
PY_39_MIN = PY_39 or PY_310
PY_310_MIN = PY_310

PEP_585_COMPATIBLE = PY_39_MIN # Type Hinting Generics In Standard Collections
PEP_586_COMPATIBLE = PY_38_MIN # Literal Types
Expand All @@ -23,6 +24,7 @@
"PY_37_MIN",
"PY_38_MIN",
"PY_39_MIN",
"PY_310_MIN",
"PEP_585_COMPATIBLE",
"PEP_586_COMPATIBLE",
]
23 changes: 14 additions & 9 deletions mashumaro/serializer/base/dict.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Any, Dict, Mapping, Type, TypeVar

from mashumaro.exceptions import UnresolvedTypeReferenceError
from mashumaro.serializer.base.metaprogramming import CodeBuilder

T = TypeVar("T", bound="DataClassDictMixin")
Expand All @@ -10,17 +11,17 @@ class DataClassDictMixin:

def __init_subclass__(cls: Type[T], **kwargs):
builder = CodeBuilder(cls)
exc = None
config = builder.get_config()
try:
builder.add_from_dict()
except Exception as e:
exc = e
except UnresolvedTypeReferenceError:
if not config.allow_postponed_evaluation:
raise
try:
builder.add_to_dict()
except Exception as e:
exc = e
if exc:
raise exc
except UnresolvedTypeReferenceError:
if not config.allow_postponed_evaluation:
raise

def to_dict(
self: T,
Expand All @@ -33,7 +34,9 @@ def to_dict(
# by_alias: bool = False
**kwargs,
) -> dict:
...
builder = CodeBuilder(self.__class__)
builder.add_to_dict()
return self.to_dict(use_bytes, use_enum, use_datetime, **kwargs)

@classmethod
def from_dict(
Expand All @@ -43,7 +46,9 @@ def from_dict(
use_enum: bool = False,
use_datetime: bool = False,
) -> T:
...
builder = CodeBuilder(cls)
builder.add_from_dict()
return cls.from_dict(d, use_bytes, use_enum, use_datetime)

@classmethod
def __pre_deserialize__(cls: Type[T], d: Dict[Any, Any]) -> Dict[Any, Any]:
Expand Down
9 changes: 8 additions & 1 deletion mashumaro/serializer/base/metaprogramming.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
InvalidFieldValue,
MissingField,
ThirdPartyModuleNotFoundError,
UnresolvedTypeReferenceError,
UnserializableDataError,
UnserializableField,
UnsupportedDeserializationEngine,
Expand All @@ -40,6 +41,7 @@
get_args,
get_class_that_defines_field,
get_class_that_defines_method,
get_name_error_name,
get_type_origin,
is_class_var,
is_dataclass_dict_mixin,
Expand Down Expand Up @@ -143,7 +145,12 @@ def __get_field_types(
fields = {}
globalns = sys.modules[self.cls.__module__].__dict__.copy()
globalns[self.cls.__name__] = self.cls
for fname, ftype in typing.get_type_hints(self.cls, globalns).items():
try:
field_type_hints = typing.get_type_hints(self.cls, globalns)
except NameError as e:
name = get_name_error_name(e)
raise UnresolvedTypeReferenceError(self.cls, name) from None
for fname, ftype in field_type_hints.items():
if is_class_var(ftype) or is_init_var(ftype):
continue
if recursive or fname in self.annotations:
Expand Down
12 changes: 12 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from unittest.mock import patch

from mashumaro.meta.macros import PY_37_MIN

if not PY_37_MIN:
collect_ignore = ["test_pep_563.py"]


fake_add_from_dict = patch(
"mashumaro.serializer.base.metaprogramming." "CodeBuilder.add_from_dict",
lambda *args, **kwargs: ...,
)
67 changes: 67 additions & 0 deletions tests/test_data_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
Tuple,
)

from .conftest import fake_add_from_dict

try:
from typing import OrderedDict # New in version 3.7.2
except ImportError:
Expand Down Expand Up @@ -729,6 +731,14 @@ class _(DataClassDictMixin):
# noinspection PyTypeChecker
x: generic_type[x_type]

with fake_add_from_dict:
with pytest.raises(UnserializableField):

@dataclass
class _(DataClassDictMixin):
# noinspection PyTypeChecker
x: generic_type[x_type]


@pytest.mark.parametrize("x_type", unsupported_typing_primitives)
@pytest.mark.parametrize("generic_type", generic_sequence_types)
Expand All @@ -740,6 +750,14 @@ class _(DataClassDictMixin):
# noinspection PyTypeChecker
x: generic_type[x_type]

with fake_add_from_dict:
with pytest.raises(UnserializableDataError):

@dataclass
class _(DataClassDictMixin):
# noinspection PyTypeChecker
x: generic_type[x_type]


@pytest.mark.parametrize("x_type", unsupported_field_types)
def test_unsupported_field_types(x_type):
Expand All @@ -749,6 +767,13 @@ def test_unsupported_field_types(x_type):
class _(DataClassDictMixin):
x: x_type

with fake_add_from_dict:
with pytest.raises(UnserializableField):

@dataclass
class _(DataClassDictMixin):
x: x_type


@pytest.mark.parametrize("x_type", unsupported_typing_primitives)
def test_unsupported_typing_primitives(x_type):
Expand All @@ -758,6 +783,13 @@ def test_unsupported_typing_primitives(x_type):
class _(DataClassDictMixin):
x: x_type

with fake_add_from_dict:
with pytest.raises(UnserializableDataError):

@dataclass
class _(DataClassDictMixin):
x: x_type


@pytest.mark.parametrize("generic_type", generic_mapping_types)
def test_data_class_as_mapping_key(generic_type):
Expand All @@ -771,6 +803,13 @@ class Key(DataClassDictMixin):
class _(DataClassDictMixin):
x: generic_type[Key, int]

with fake_add_from_dict:
with pytest.raises(UnserializableDataError):

@dataclass
class _(DataClassDictMixin):
x: generic_type[Key, int]


def test_data_class_as_mapping_key_for_counter():
@dataclass
Expand All @@ -783,6 +822,13 @@ class Key(DataClassDictMixin):
class _(DataClassDictMixin):
x: Counter[Key]

with fake_add_from_dict:
with pytest.raises(UnserializableDataError):

@dataclass
class _(DataClassDictMixin):
x: Counter[Key]


def test_data_class_as_chain_map_key():
@dataclass
Expand All @@ -795,6 +841,13 @@ class Key(DataClassDictMixin):
class _(DataClassDictMixin):
x: ChainMap[Key, int]

with fake_add_from_dict:
with pytest.raises(UnserializableDataError):

@dataclass
class _(DataClassDictMixin):
x: ChainMap[Key, int]


@pytest.mark.parametrize("use_datetime", [True, False])
@pytest.mark.parametrize("use_enum", [True, False])
Expand Down Expand Up @@ -902,6 +955,13 @@ def test_weird_field_type():
class _(DataClassDictMixin):
x: 123

with fake_add_from_dict:
with pytest.raises(UnserializableDataError):

@dataclass
class _(DataClassDictMixin):
x: 123


@pytest.mark.parametrize(
"rounding", [None, decimal.ROUND_UP, decimal.ROUND_DOWN]
Expand Down Expand Up @@ -1186,6 +1246,13 @@ def test_dataclass_field_without_mixin():
class _(DataClassDictMixin):
p: DataClassWithoutMixin

with fake_add_from_dict:
with pytest.raises(UnserializableField):

@dataclass
class _(DataClassDictMixin):
p: DataClassWithoutMixin


def test_serializable_type_dataclass():
@dataclass
Expand Down
Loading

0 comments on commit 8fd27a7

Please sign in to comment.