Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support generics, forward references and case conversion #269

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 98 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,14 @@ assert user == User(name='John', age=30, is_active=True)
Dacite supports following features:

- nested structures
- (basic) types checking
- (basic) type checking
- optional fields (i.e. `typing.Optional`)
- unions
- generics
- forward references
- collections
- custom type hooks
- case conversion

## Motivation

Expand Down Expand Up @@ -109,6 +111,7 @@ Configuration is a (data) class with following fields:
- `check_types`
- `strict`
- `strict_unions_match`
- `convert_key`

The examples below show all features of `from_dict` function and usage
of all `Config` parameters.
Expand Down Expand Up @@ -233,6 +236,71 @@ result = from_dict(data_class=B, data=data)
assert result == B(a_list=[A(x='test1', y=1), A(x='test2', y=2)])
```

### Generics

Dacite supports generics: (multi-)generic dataclasses, but also dataclasses that inherit from a generic dataclass, or dataclasses that have a generic dataclass field.

```python
T = TypeVar('T')
U = TypeVar('U')

@dataclass
class X:
a: str


@dataclass
class A(Generic[T, U]):
x: T
y: list[U]

data = {
'x': {
'a': 'foo',
},
'y': [1, 2, 3]
}

result = from_dict(data_class=A[X, int], data=data)

assert result == A(x=X(a='foo'), y=[1,2,3])


@dataclass
class B(A[X, int]):
z: str

data = {
'x': {
'a': 'foo',
},
'y': [1, 2, 3],
'z': 'bar'
}

result = from_dict(data_class=B, data=data)

assert result == B(x=X(a='foo'), y=[1,2,3], z='bar')


@dataclass
class C:
z: A[X, int]

data = {
'z': {
'x': {
'a': 'foo',
},
'y': [1, 2, 3],
}
}

result = from_dict(data_class=C, data=data)

assert result == C(z=A(x=X(a='foo'), y=[1,2,3]))
```

### Type hooks

You can use `Config.type_hooks` argument if you want to transform the input
Expand Down Expand Up @@ -313,30 +381,17 @@ data = from_dict(X, {"y": {"s": "text"}}, Config(forward_references={"Y": Y}))
assert data == X(Y("text"))
```

### Types checking
### Type checking

There are rare cases when `dacite` built-in type checker can not validate
your types (e.g. custom generic class) or you have such functionality
covered by other library and you don't want to validate your types twice.
In such case you can disable type checking with `Config(check_types=False)`.
By default types checking is enabled.
If you want to trade-off type checking for speed, you can disabled type checking by setting `check_types` to `False`.

```python
T = TypeVar('T')


class X(Generic[T]):
pass


@dataclass
class A:
x: X[str]


x = X[str]()
x: str

assert from_dict(A, {'x': x}, config=Config(check_types=False)) == A(x=x)
# won't throw an error even though the type is wrong
from_dict(A, {'x': 4}, config=Config(check_types=False))
```

### Strict mode
Expand All @@ -354,6 +409,30 @@ returns instance of this type. It means that it's possible that there are other
matching types further on the `Union` types list. With `strict_unions_match`
only a single match is allowed, otherwise `dacite` raises `StrictUnionMatchError`.

## Convert key

You can pass a callable to the `convert_key` configuration parameter to convert camelCase to snake_case.

```python
def to_camel_case(key: str) -> str:
first_part, *remaining_parts = key.split('_')
return first_part + ''.join(part.title() for part in remaining_parts)

@dataclass
class Person:
first_name: str
last_name: str

data = {
'firstName': 'John',
'lastName': 'Doe'
}

result = from_dict(Person, data, Config(convert_key=to_camel_case))

assert result == Person(first_name='John', last_name='Doe')
```

## Exceptions

Whenever something goes wrong, `from_dict` will raise adequate
Expand Down
1 change: 1 addition & 0 deletions dacite/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class Config:
check_types: bool = True
strict: bool = False
strict_unions_match: bool = False
convert_key: Callable[[str], str] = field(default_factory=lambda: lambda x: x)

@cached_property
def hashable_forward_references(self) -> Optional[FrozenDict]:
Expand Down
24 changes: 17 additions & 7 deletions dacite/core.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
from dataclasses import is_dataclass
from itertools import zip_longest
from typing import TypeVar, Type, Optional, get_type_hints, Mapping, Any, Collection, MutableMapping
from typing import TypeVar, Type, Optional, Mapping, Any, Collection, MutableMapping

from dacite.cache import cache
from dacite.config import Config
from dacite.data import Data
from dacite.dataclasses import (
get_default_value_for_field,
DefaultValueNotFoundError,
get_fields,
is_frozen,
)
from dacite.exceptions import (
Expand All @@ -33,6 +32,8 @@
is_subclass,
)

from dacite.generics import get_concrete_type_hints, get_fields, orig

T = TypeVar("T")


Expand All @@ -47,21 +48,26 @@ def from_dict(data_class: Type[T], data: Data, config: Optional[Config] = None)
init_values: MutableMapping[str, Any] = {}
post_init_values: MutableMapping[str, Any] = {}
config = config or Config()

try:
data_class_hints = cache(get_type_hints)(data_class, localns=config.hashable_forward_references)
data_class_hints = cache(get_concrete_type_hints)(data_class, localns=config.hashable_forward_references)
except NameError as error:
raise ForwardReferenceError(str(error))

data_class_fields = cache(get_fields)(data_class)

if config.strict:
extra_fields = set(data.keys()) - {f.name for f in data_class_fields}
if extra_fields:
raise UnexpectedDataError(keys=extra_fields)

for field in data_class_fields:
field_type = data_class_hints[field.name]
if field.name in data:
key = config.convert_key(field.name)

if key in data:
try:
field_data = data[field.name]
value = _build_value(type_=field_type, data=field_data, config=config)
value = _build_value(type_=field_type, data=data[key], config=config)
except DaciteFieldError as error:
error.update_path(field.name)
raise
Expand All @@ -74,13 +80,17 @@ def from_dict(data_class: Type[T], data: Data, config: Optional[Config] = None)
if not field.init:
continue
raise MissingValueError(field.name)

if field.init:
init_values[field.name] = value
elif not is_frozen(data_class):
post_init_values[field.name] = value

instance = data_class(**init_values)

for key, value in post_init_values.items():
setattr(instance, key, value)

return instance


Expand All @@ -95,7 +105,7 @@ def _build_value(type_: Type, data: Any, config: Config) -> Any:
data = _build_value_for_union(union=type_, data=data, config=config)
elif is_generic_collection(type_):
data = _build_value_for_collection(collection=type_, data=data, config=config)
elif cache(is_dataclass)(type_) and isinstance(data, Mapping):
elif cache(is_dataclass)(orig(type_)) and isinstance(data, Mapping):
data = from_dict(data_class=type_, data=data, config=config)
for cast_type in config.cast:
if is_subclass(type_, cast_type):
Expand Down
95 changes: 95 additions & 0 deletions dacite/generics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import sys
from dataclasses import Field, is_dataclass
from typing import Any, Dict, Generic, List, Literal, Tuple, Type, TypeVar, get_type_hints

try:
from typing import get_args, get_origin
except ImportError:
from typing_extensions import get_args, get_origin

from .dataclasses import get_fields as dataclasses_get_fields


def _add_generics(type_origin: Any, type_args: Tuple, generics: Dict[TypeVar, Type]) -> None:
"""Adds (type var, concrete type) entries derived from a type's origin and args to the provided generics dict."""
if type_origin and type_args and hasattr(type_origin, "__parameters__"):
for param, arg in zip(type_origin.__parameters__, type_args):
if param.__class__ is TypeVar:
if param in generics and generics[param] != arg:
raise Exception("Generics error.")
generics[param] = arg


def _dereference(type_name: str, data_class: Type) -> Type:
"""
Try to find the class belonging to the reference in the provided module and,
if not found, iteratively look in parent modules.
"""
if data_class.__class__.__name__ == type_name:
return data_class

module_name = data_class.__module__
parts = module_name.split(".")
for i in range(len(parts)):
try:
module = sys.modules[".".join(parts[:-i]) if i else module_name]
return getattr(module, type_name)
except AttributeError:
pass
raise AttributeError("Could not find reference.")


def _concretize(hint: Type, generics: Dict[TypeVar, Type], data_class: Type) -> Type:
"""Recursively replace type vars and forward references by concrete types."""

if hint.__class__ is str:
return _dereference(hint, data_class)

if hint.__class__ is TypeVar:
return generics.get(hint, hint)

hint_origin = get_origin(hint)
hint_args = get_args(hint)
if hint_origin and hint_args and hint_origin is not Literal:
concrete_hint_args = tuple(_concretize(a, generics, data_class) for a in hint_args)
return hint_origin[concrete_hint_args]

return hint


def orig(data_class: Type) -> Any:
if is_dataclass(data_class):
return data_class
return get_origin(data_class)


def get_concrete_type_hints(data_class: Type, *args, **kwargs) -> Dict[str, Any]:
"""
An overwrite of typing.get_type_hints supporting generics and forward references,
i.e. substituting concrete types in type vars and references.
"""
generics = {}

dc_origin = get_origin(data_class)
dc_args = get_args(data_class)
_add_generics(dc_origin, dc_args, generics)

if hasattr(data_class, "__orig_bases__"):
for base in data_class.__orig_bases__:
base_origin = get_origin(base)
base_args = get_args(base)
if base_origin is not Generic:
_add_generics(base_origin, base_args, generics)

data_class = orig(data_class)
hints = get_type_hints(data_class, *args, **kwargs)

for key, hint in hints.copy().items():
hints[key] = _concretize(hint, generics, data_class)

return hints


def get_fields(data_class: Type) -> List[Field]:
"""An overwrite of dacite's get_fields function, supporting generics."""
return dataclasses_get_fields(orig(data_class))
Loading