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 and forward references #272

Merged
merged 27 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
90ea3f8
improve data typing
trim21 Aug 31, 2024
5650a6d
run formatter
trim21 Aug 31, 2024
d493680
support generics and forward references
avlonder Jan 11, 2025
a89bc16
update docs
avlonder Jan 11, 2025
deb629e
unit tests
avlonder Jan 12, 2025
91a5f85
fix get_origin import error
avlonder Jan 13, 2025
b982236
refactor type hints to be backwards compatible with python 3.8
avlonder Jan 13, 2025
3eb8f93
test generic forward reference
avlonder Jan 19, 2025
9303df5
compatible with python 3.8
avlonder Jan 22, 2025
57bc1e1
add missing pep_604 annotation
avlonder Jan 22, 2025
b88d37b
type checking
avlonder Jan 24, 2025
79351f3
revertTypeVar fallback
avlonder Jan 24, 2025
4d7295a
Merge pull request #269 from avlonder/master
mciszczon Jan 27, 2025
369001c
Silence mypy errors for imports from typing_extensions
mciszczon Jan 27, 2025
675bbd4
Silence mypy arg-type error for isinstance(...)
mciszczon Jan 27, 2025
8b4ddae
Do not fail on performance alert, leave comment instead
mciszczon Jan 27, 2025
a782cbe
Avoid too broad exception
mciszczon Jan 27, 2025
cb12e11
Fix import of dacite.exceptions
mciszczon Jan 27, 2025
b3d55f8
Merge pull request #260 from trim21/improve-data-typing
mciszczon Jan 27, 2025
9aa0105
Run black
mciszczon Jan 27, 2025
c566618
Ensure Data implements __contains__
mciszczon Jan 27, 2025
f47942b
Add Python 3.12 and 3.13 to setup.py
mciszczon Jan 27, 2025
86ee7cb
Handle typing.Protocol import for older Python versions
mciszczon Jan 27, 2025
6ca2b37
Ignore black for Data(Protocol) as it produces different outputs on d…
mciszczon Jan 27, 2025
e3f1e1f
Update CHANGELOG.md
mciszczon Jan 27, 2025
88e591b
Update publish workflow
mciszczon Jan 27, 2025
b20329c
Improve too generic error message for generics
mciszczon Jan 27, 2025
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
4 changes: 2 additions & 2 deletions .github/workflows/code_check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,5 @@ jobs:
benchmark-data-dir-path: performance/${{ matrix.python-version }}
comment-always: false
alert-threshold: '130%'
comment-on-alert: false
fail-on-alert: true
comment-on-alert: true
fail-on-alert: false
33 changes: 27 additions & 6 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ on:
types: [published]

jobs:
publish:
runs-on: ubuntu-22.04
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
Expand All @@ -16,8 +16,29 @@ jobs:
run: |
python -m pip install --upgrade build
python -m build
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
- name: Store the distribution packages
uses: actions/upload-artifact@v4
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
name: python-package-distributions
path: dist/

publish-to-pypi:
name: Publish to PyPI
needs:
- build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/dacite
permissions:
id-token: write # IMPORTANT: mandatory for trusted publishing
steps:
- name: Download dist
uses: actions/download-artifact@v4
with:
name: python-package-distributions
path: dist/
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ build/
.idea

venv*
dacite-env*

.benchmarks
benchmark.json
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- [Support generics](https://github.com/konradhalas/dacite/pull/272)
- Change type definition for `Data` in order to be more permissive
- Fix issues with caching internal function calls

## [1.8.1] - 2023-05-12
Expand Down
139 changes: 109 additions & 30 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 Expand Up @@ -392,33 +471,33 @@ first within an issue.

Clone `dacite` repository:

```
$ git clone git@github.com:konradhalas/dacite.git
```bash
git clone git@github.com:konradhalas/dacite.git
```

Create and activate virtualenv in the way you like:

```
$ python3 -m venv dacite-env
$ source dacite-env/bin/activate
```bash
python3 -m venv dacite-env
source dacite-env/bin/activate
```

Install all `dacite` dependencies:

```
$ pip install -e .[dev]
```bash
pip install -e .[dev]
```

And, optionally but recommended, install pre-commit hook for black:

```
$ pre-commit install
```bash
pre-commit install
```

To run tests you just have to fire:

```
$ pytest
```bash
pytest
```

### Performance testing
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
15 changes: 13 additions & 2 deletions dacite/data.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
from typing import Mapping, Any
try:
from typing import Protocol # type: ignore
except ImportError:
from typing_extensions import Protocol # type: ignore

Data = Mapping[str, Any]

# fmt: off
class Data(Protocol):
def keys(self): ...

def __getitem__(self, item): ...

def __contains__(self, item): ...
# fmt: on
Loading
Loading