Skip to content

Commit 9fa6a84

Browse files
authored
Merge pull request #272 from konradhalas/feature/generics
2 parents 1cdc3ef + b20329c commit 9fa6a84

15 files changed

+480
-106
lines changed

.github/workflows/code_check.yaml

+2-2
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,5 @@ jobs:
4646
benchmark-data-dir-path: performance/${{ matrix.python-version }}
4747
comment-always: false
4848
alert-threshold: '130%'
49-
comment-on-alert: false
50-
fail-on-alert: true
49+
comment-on-alert: true
50+
fail-on-alert: false

.github/workflows/publish.yaml

+27-6
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ on:
44
types: [published]
55

66
jobs:
7-
publish:
8-
runs-on: ubuntu-22.04
7+
build:
8+
runs-on: ubuntu-latest
99
steps:
1010
- uses: actions/checkout@v4
1111
- uses: actions/setup-python@v5
@@ -16,8 +16,29 @@ jobs:
1616
run: |
1717
python -m pip install --upgrade build
1818
python -m build
19-
- name: Publish package to PyPI
20-
uses: pypa/gh-action-pypi-publish@release/v1
19+
- name: Store the distribution packages
20+
uses: actions/upload-artifact@v4
2121
with:
22-
user: __token__
23-
password: ${{ secrets.PYPI_API_TOKEN }}
22+
name: python-package-distributions
23+
path: dist/
24+
25+
publish-to-pypi:
26+
name: Publish to PyPI
27+
needs:
28+
- build
29+
runs-on: ubuntu-latest
30+
environment:
31+
name: pypi
32+
url: https://pypi.org/p/dacite
33+
permissions:
34+
id-token: write # IMPORTANT: mandatory for trusted publishing
35+
steps:
36+
- name: Download dist
37+
uses: actions/download-artifact@v4
38+
with:
39+
name: python-package-distributions
40+
path: dist/
41+
- name: Publish package to PyPI
42+
uses: pypa/gh-action-pypi-publish@release/v1
43+
with:
44+
password: ${{ secrets.PYPI_API_TOKEN }}

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ build/
66
.idea
77

88
venv*
9+
dacite-env*
910

1011
.benchmarks
1112
benchmark.json

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

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

1214
## [1.8.1] - 2023-05-12

README.md

+109-30
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,14 @@ assert user == User(name='John', age=30, is_active=True)
5454
Dacite supports following features:
5555

5656
- nested structures
57-
- (basic) types checking
57+
- (basic) type checking
5858
- optional fields (i.e. `typing.Optional`)
5959
- unions
60+
- generics
6061
- forward references
6162
- collections
6263
- custom type hooks
64+
- case conversion
6365

6466
## Motivation
6567

@@ -109,6 +111,7 @@ Configuration is a (data) class with following fields:
109111
- `check_types`
110112
- `strict`
111113
- `strict_unions_match`
114+
- `convert_key`
112115

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

239+
### Generics
240+
241+
Dacite supports generics: (multi-)generic dataclasses, but also dataclasses that inherit from a generic dataclass, or dataclasses that have a generic dataclass field.
242+
243+
```python
244+
T = TypeVar('T')
245+
U = TypeVar('U')
246+
247+
@dataclass
248+
class X:
249+
a: str
250+
251+
252+
@dataclass
253+
class A(Generic[T, U]):
254+
x: T
255+
y: list[U]
256+
257+
data = {
258+
'x': {
259+
'a': 'foo',
260+
},
261+
'y': [1, 2, 3]
262+
}
263+
264+
result = from_dict(data_class=A[X, int], data=data)
265+
266+
assert result == A(x=X(a='foo'), y=[1,2,3])
267+
268+
269+
@dataclass
270+
class B(A[X, int]):
271+
z: str
272+
273+
data = {
274+
'x': {
275+
'a': 'foo',
276+
},
277+
'y': [1, 2, 3],
278+
'z': 'bar'
279+
}
280+
281+
result = from_dict(data_class=B, data=data)
282+
283+
assert result == B(x=X(a='foo'), y=[1,2,3], z='bar')
284+
285+
286+
@dataclass
287+
class C:
288+
z: A[X, int]
289+
290+
data = {
291+
'z': {
292+
'x': {
293+
'a': 'foo',
294+
},
295+
'y': [1, 2, 3],
296+
}
297+
}
298+
299+
result = from_dict(data_class=C, data=data)
300+
301+
assert result == C(z=A(x=X(a='foo'), y=[1,2,3]))
302+
```
303+
236304
### Type hooks
237305

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

316-
### Types checking
384+
### Type checking
317385

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

324388
```python
325-
T = TypeVar('T')
326-
327-
328-
class X(Generic[T]):
329-
pass
330-
331-
332389
@dataclass
333390
class A:
334-
x: X[str]
335-
336-
337-
x = X[str]()
391+
x: str
338392

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

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

412+
## Convert key
413+
414+
You can pass a callable to the `convert_key` configuration parameter to convert camelCase to snake_case.
415+
416+
```python
417+
def to_camel_case(key: str) -> str:
418+
first_part, *remaining_parts = key.split('_')
419+
return first_part + ''.join(part.title() for part in remaining_parts)
420+
421+
@dataclass
422+
class Person:
423+
first_name: str
424+
last_name: str
425+
426+
data = {
427+
'firstName': 'John',
428+
'lastName': 'Doe'
429+
}
430+
431+
result = from_dict(Person, data, Config(convert_key=to_camel_case))
432+
433+
assert result == Person(first_name='John', last_name='Doe')
434+
```
435+
357436
## Exceptions
358437

359438
Whenever something goes wrong, `from_dict` will raise adequate
@@ -392,33 +471,33 @@ first within an issue.
392471

393472
Clone `dacite` repository:
394473

395-
```
396-
$ git clone git@github.com:konradhalas/dacite.git
474+
```bash
475+
git clone git@github.com:konradhalas/dacite.git
397476
```
398477

399478
Create and activate virtualenv in the way you like:
400479

401-
```
402-
$ python3 -m venv dacite-env
403-
$ source dacite-env/bin/activate
480+
```bash
481+
python3 -m venv dacite-env
482+
source dacite-env/bin/activate
404483
```
405484

406485
Install all `dacite` dependencies:
407486

408-
```
409-
$ pip install -e .[dev]
487+
```bash
488+
pip install -e .[dev]
410489
```
411490

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

414-
```
415-
$ pre-commit install
493+
```bash
494+
pre-commit install
416495
```
417496

418497
To run tests you just have to fire:
419498

420-
```
421-
$ pytest
499+
```bash
500+
pytest
422501
```
423502

424503
### Performance testing

dacite/config.py

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class Config:
1919
check_types: bool = True
2020
strict: bool = False
2121
strict_unions_match: bool = False
22+
convert_key: Callable[[str], str] = field(default_factory=lambda: lambda x: x)
2223

2324
@cached_property
2425
def hashable_forward_references(self) -> Optional[FrozenDict]:

dacite/core.py

+17-7
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
from dataclasses import is_dataclass
22
from itertools import zip_longest
3-
from typing import TypeVar, Type, Optional, get_type_hints, Mapping, Any, Collection, MutableMapping
3+
from typing import TypeVar, Type, Optional, Mapping, Any, Collection, MutableMapping
44

55
from dacite.cache import cache
66
from dacite.config import Config
77
from dacite.data import Data
88
from dacite.dataclasses import (
99
get_default_value_for_field,
1010
DefaultValueNotFoundError,
11-
get_fields,
1211
is_frozen,
1312
)
1413
from dacite.exceptions import (
@@ -33,6 +32,8 @@
3332
is_subclass,
3433
)
3534

35+
from dacite.generics import get_concrete_type_hints, get_fields, orig
36+
3637
T = TypeVar("T")
3738

3839

@@ -47,21 +48,26 @@ def from_dict(data_class: Type[T], data: Data, config: Optional[Config] = None)
4748
init_values: MutableMapping[str, Any] = {}
4849
post_init_values: MutableMapping[str, Any] = {}
4950
config = config or Config()
51+
5052
try:
51-
data_class_hints = cache(get_type_hints)(data_class, localns=config.hashable_forward_references)
53+
data_class_hints = cache(get_concrete_type_hints)(data_class, localns=config.hashable_forward_references)
5254
except NameError as error:
5355
raise ForwardReferenceError(str(error))
56+
5457
data_class_fields = cache(get_fields)(data_class)
58+
5559
if config.strict:
5660
extra_fields = set(data.keys()) - {f.name for f in data_class_fields}
5761
if extra_fields:
5862
raise UnexpectedDataError(keys=extra_fields)
63+
5964
for field in data_class_fields:
6065
field_type = data_class_hints[field.name]
61-
if field.name in data:
66+
key = config.convert_key(field.name)
67+
68+
if key in data:
6269
try:
63-
field_data = data[field.name]
64-
value = _build_value(type_=field_type, data=field_data, config=config)
70+
value = _build_value(type_=field_type, data=data[key], config=config)
6571
except DaciteFieldError as error:
6672
error.update_path(field.name)
6773
raise
@@ -74,13 +80,17 @@ def from_dict(data_class: Type[T], data: Data, config: Optional[Config] = None)
7480
if not field.init:
7581
continue
7682
raise MissingValueError(field.name)
83+
7784
if field.init:
7885
init_values[field.name] = value
7986
elif not is_frozen(data_class):
8087
post_init_values[field.name] = value
88+
8189
instance = data_class(**init_values)
90+
8291
for key, value in post_init_values.items():
8392
setattr(instance, key, value)
93+
8494
return instance
8595

8696

@@ -95,7 +105,7 @@ def _build_value(type_: Type, data: Any, config: Config) -> Any:
95105
data = _build_value_for_union(union=type_, data=data, config=config)
96106
elif is_generic_collection(type_):
97107
data = _build_value_for_collection(collection=type_, data=data, config=config)
98-
elif cache(is_dataclass)(type_) and isinstance(data, Mapping):
108+
elif cache(is_dataclass)(orig(type_)) and isinstance(data, Mapping):
99109
data = from_dict(data_class=type_, data=data, config=config)
100110
for cast_type in config.cast:
101111
if is_subclass(type_, cast_type):

dacite/data.py

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1-
from typing import Mapping, Any
1+
try:
2+
from typing import Protocol # type: ignore
3+
except ImportError:
4+
from typing_extensions import Protocol # type: ignore
25

3-
Data = Mapping[str, Any]
6+
7+
# fmt: off
8+
class Data(Protocol):
9+
def keys(self): ...
10+
11+
def __getitem__(self, item): ...
12+
13+
def __contains__(self, item): ...
14+
# fmt: on

0 commit comments

Comments
 (0)