From eb94519a5d6c557cafdd8d261bb49519618a0d7b Mon Sep 17 00:00:00 2001 From: Anton Agestam Date: Tue, 7 Jun 2022 15:22:57 +0300 Subject: [PATCH 01/19] Support PEP 604 unions, types.UnionType --- CHANGELOG.md | 3 +++ dacite/types.py | 10 +++++++++- tests/common.py | 1 + tests/test_types.py | 17 ++++++++++++++++- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 763130d..901e35d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add explicit `__all__` configuration +- Support [PEP 604] unions through `types.UnionType` + +[PEP 604]: https://peps.python.org/pep-0604/ ### Fixed diff --git a/dacite/types.py b/dacite/types.py index 5e030b9..452e57b 100644 --- a/dacite/types.py +++ b/dacite/types.py @@ -63,7 +63,15 @@ def is_generic(type_: Type) -> bool: def is_union(type_: Type) -> bool: - return is_generic(type_) and type_.__origin__ == Union + if is_generic(type_) and type_.__origin__ == Union: + return True + + try: + from types import UnionType # type: ignore + + return isinstance(type_, UnionType) + except ImportError: + return False def is_literal(type_: Type) -> bool: diff --git a/tests/common.py b/tests/common.py index 71a557a..99bee06 100644 --- a/tests/common.py +++ b/tests/common.py @@ -3,3 +3,4 @@ import pytest literal_support = init_var_type_support = pytest.mark.skipif(sys.version_info < (3, 8), reason="requires Python 3.8") +pep_604_support = pytest.mark.skipif(sys.version_info < (3, 10), reason="requires Python 3.10") diff --git a/tests/test_types.py b/tests/test_types.py index 6ba1591..9044423 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -21,7 +21,7 @@ is_type_generic, is_set, ) -from tests.common import literal_support, init_var_type_support +from tests.common import literal_support, init_var_type_support, pep_604_support def test_is_union_with_union(): @@ -32,6 +32,11 @@ def test_is_union_with_non_union(): assert not is_union(int) +@pep_604_support +def test_is_union_with_pep_604_union(): + assert is_union(int | float) + + @literal_support def test_is_literal_with_literal(): from typing import Literal @@ -63,6 +68,16 @@ def test_is_optional_with_optional_of_union(): assert is_optional(Optional[Union[int, float]]) +@pep_604_support +def test_is_optional_with_pep_604_union(): + assert is_optional(int | float | None) + + +@pep_604_support +def test_is_optional_with_non_optional_pep_604_union(): + assert not is_optional(int | float) + + def test_extract_optional(): assert extract_optional(Optional[int]) == int From 2a6f8289cfa831877752f13a0b785f142572465a Mon Sep 17 00:00:00 2001 From: "liusong.liu" Date: Thu, 10 Nov 2022 21:10:12 +0800 Subject: [PATCH 02/19] feat: add `allow_missing_fields_as_none` option for missing fields in dict assigned with None value --- dacite/config.py | 1 + dacite/dataclasses.py | 4 ++-- tests/core/test_config.py | 9 +++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/dacite/config.py b/dacite/config.py index 1766a68..21655ab 100644 --- a/dacite/config.py +++ b/dacite/config.py @@ -10,3 +10,4 @@ class Config: check_types: bool = True strict: bool = False strict_unions_match: bool = False + allow_missing_fields_as_none: bool = False diff --git a/dacite/dataclasses.py b/dacite/dataclasses.py index 4f48736..ec5f821 100644 --- a/dacite/dataclasses.py +++ b/dacite/dataclasses.py @@ -11,12 +11,12 @@ class DefaultValueNotFoundError(Exception): pass -def get_default_value_for_field(field: Field) -> Any: +def get_default_value_for_field(field: Field, allow_missing_fields_as_none: bool = False) -> Any: if field.default != MISSING: return field.default elif field.default_factory != MISSING: # type: ignore return field.default_factory() # type: ignore - elif is_optional(field.type): + elif is_optional(field.type) or allow_missing_fields_as_none: return None raise DefaultValueNotFoundError() diff --git a/tests/core/test_config.py b/tests/core/test_config.py index f8fec33..300f732 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -215,3 +215,12 @@ class Z: result = from_dict(Z, data, Config(strict_unions_match=True)) assert result == Z(u=Y(f=1)) + + +def test_from_dict_with_allow_missing_fields_with_none(): + @dataclass + class X: + a: int + b: str + + assert X(a=1, b=None) == from_dict(X, {"a": 1}, Config(allow_missing_fields_as_none=True)) From 4563c704bfd13f72c99c915cfb0fc9bebaa46446 Mon Sep 17 00:00:00 2001 From: "Dr. Nick" Date: Thu, 27 Oct 2022 13:26:17 -0400 Subject: [PATCH 03/19] fixed from_dict in case of frozen dataclass with non-init field with default value --- dacite/core.py | 6 ++++-- tests/core/test_frozen_non_init_var.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 tests/core/test_frozen_non_init_var.py diff --git a/dacite/core.py b/dacite/core.py index 145e734..21ea47a 100644 --- a/dacite/core.py +++ b/dacite/core.py @@ -67,12 +67,14 @@ def from_dict(data_class: Type[T], data: Data, config: Optional[Config] = None) raise if config.check_types and not is_instance(value, field.type): raise WrongTypeError(field_path=field.name, field_type=field.type, value=value) + elif not field.init: + # If the non-init field isn't in the dict, let the dataclass handle default, to ensure + # we won't get errors in the case of frozen dataclasses, as issue #195 highlights. + continue else: try: value = get_default_value_for_field(field) except DefaultValueNotFoundError: - if not field.init: - continue raise MissingValueError(field.name) if field.init: init_values[field.name] = value diff --git a/tests/core/test_frozen_non_init_var.py b/tests/core/test_frozen_non_init_var.py new file mode 100644 index 0000000..d08a622 --- /dev/null +++ b/tests/core/test_frozen_non_init_var.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass, field + +from dacite import from_dict + + +def test_from_dict_with_frozen_non_init_var_with_default(): + @dataclass(frozen=True) + class X: + a: int = field(init=False, default=5) + + result = from_dict(X, {}) + + assert result.a == 5 From 9357dc16670e2b72673fd3405c0f40732a37f457 Mon Sep 17 00:00:00 2001 From: daiwt Date: Thu, 16 Sep 2021 17:26:52 +0800 Subject: [PATCH 04/19] fix #92. change Data from Dict to Mapping --- dacite/data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dacite/data.py b/dacite/data.py index 560cdf8..c8e6ce4 100644 --- a/dacite/data.py +++ b/dacite/data.py @@ -1,3 +1,3 @@ -from typing import Dict, Any +from typing import Mapping, Any -Data = Dict[str, Any] +Data = Mapping[str, Any] From 02aaf8252de7a5342fa604290e614cb7a2608d9c Mon Sep 17 00:00:00 2001 From: BurningKarl <10158964+BurningKarl@users.noreply.github.com> Date: Fri, 10 Dec 2021 19:33:36 +0100 Subject: [PATCH 05/19] Fix extract_optional for Optional of Union. --- dacite/types.py | 9 +++++---- tests/test_types.py | 4 ++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/dacite/types.py b/dacite/types.py index 452e57b..780728a 100644 --- a/dacite/types.py +++ b/dacite/types.py @@ -52,10 +52,11 @@ def is_optional(type_: Type) -> bool: def extract_optional(optional: Type[Optional[T]]) -> T: - for type_ in extract_generic(optional): - if type_ is not type(None): - return type_ - raise ValueError("can not find not-none value") + other_members = [member for member in extract_generic(optional) if member is not type(None)] + if other_members: + return Union[tuple(other_members)] + else: + raise ValueError("can not find not-none value") def is_generic(type_: Type) -> bool: diff --git a/tests/test_types.py b/tests/test_types.py index 9044423..c9d1ea6 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -87,6 +87,10 @@ def test_extract_optional_with_wrong_type(): extract_optional(List[None]) +def test_extract_optional_with_optional_of_union(): + assert extract_optional(Optional[Union[int, str]]) == Union[int, str] + + def test_is_generic_with_generic(): assert is_generic(Optional[int]) From 60d2a46aa6e76208ecc341a6049b4db4ff1898fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Vidi=C4=87?= Date: Sun, 19 Apr 2020 09:48:53 +0200 Subject: [PATCH 06/19] Fix encoding of PKG-INFO file Reproducible build fails if the file is encoded differently depending on the current locale settings when reading README.md. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 89c86c5..6f2620e 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ name="dacite", version="1.6.0", description="Simple creation of data classes from dictionaries.", - long_description=open("README.md").read(), + long_description=open("README.md", encoding="utf-8").read(), long_description_content_type="text/markdown", author="Konrad Hałas", author_email="halas.konrad@gmail.com", From 83b0eeb04a045341b58e956989e433d92be1cc0d Mon Sep 17 00:00:00 2001 From: Moonyoung Kang Date: Sat, 10 Apr 2021 04:24:53 -0700 Subject: [PATCH 07/19] .gitignore updated --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5f36185..887edb0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ *.pyc dist/ *.egg-info/ -build/ \ No newline at end of file +build/ +/venv/ +/.idea/ From e7adcc99d1e948e53965d51ee30e337de110c95c Mon Sep 17 00:00:00 2001 From: Tomasz Karbownicki Date: Wed, 9 Dec 2020 12:23:11 +0100 Subject: [PATCH 08/19] from_dict customization per data class --- dacite/core.py | 2 ++ tests/core/test_config.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/dacite/core.py b/dacite/core.py index 21ea47a..9edd0ea 100644 --- a/dacite/core.py +++ b/dacite/core.py @@ -98,6 +98,8 @@ def _build_value(type_: Type, data: Any, config: Config) -> Any: _build_value(type_=extract_generic(type_)[0], data=single_val, config=config) for single_val in data ) elif is_dataclass(type_) and is_instance(data, Data): + if hasattr(type_, "from_dict"): + return type_.from_dict(data=data, config=config) return from_dict(data_class=type_, data=data, config=config) return data diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 300f732..647a4f5 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from datetime import date from enum import Enum from typing import Optional, List, Union @@ -224,3 +225,35 @@ class X: b: str assert X(a=1, b=None) == from_dict(X, {"a": 1}, Config(allow_missing_fields_as_none=True)) + + +def test_custom_from_dict_in_nested_data_class(): + @dataclass + class X: + d: date + t: str + + def from_dict(data_class, data, config): + data["t"] = "prefix {}".format(data["t"]) + return from_dict( + data_class=data_class, + data=data, + config=Config(type_hooks={date: date.fromtimestamp}), + ) + + @dataclass + class Y: + d: date + x: X + + config = Config(type_hooks={date: date.fromordinal}) + data = {"d": 737790, "x": {"d": 1607511900.985121, "t": "abc"}} + result = from_dict(Y, data, config=config) + + assert result == Y( + d=date(2020, 12, 31), + x=X( + d=date(2020, 12, 9), + t="prefix abc", + ), + ) From dba81d07a4b06830afe77b8c51f03d93fd82bfab Mon Sep 17 00:00:00 2001 From: Szymon Swic Date: Mon, 9 May 2022 15:13:54 +0300 Subject: [PATCH 09/19] Fix Type_hooks not applied to InitVar fields #158 test for type_hook with initVar added --- dacite/core.py | 5 +++-- dacite/types.py | 10 +++++++++- tests/core/test_config.py | 29 ++++++++++++++++++++++++++++- tests/test_types.py | 12 +++++++++++- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/dacite/core.py b/dacite/core.py index 9edd0ea..645d078 100644 --- a/dacite/core.py +++ b/dacite/core.py @@ -1,7 +1,7 @@ import copy from dataclasses import is_dataclass from itertools import zip_longest -from typing import TypeVar, Type, Optional, get_type_hints, Mapping, Any +from typing import TypeVar, Type, Optional, Mapping, Any from dacite.config import Config from dacite.data import Data @@ -27,6 +27,7 @@ is_init_var, extract_init_var, is_set, + get_data_class_hints, ) T = TypeVar("T") @@ -44,7 +45,7 @@ def from_dict(data_class: Type[T], data: Data, config: Optional[Config] = None) post_init_values: Data = {} config = config or Config() try: - data_class_hints = get_type_hints(data_class, globalns=config.forward_references) + data_class_hints = get_data_class_hints(data_class, globalns=config.forward_references) except NameError as error: raise ForwardReferenceError(str(error)) data_class_fields = get_fields(data_class) diff --git a/dacite/types.py b/dacite/types.py index 780728a..452f41b 100644 --- a/dacite/types.py +++ b/dacite/types.py @@ -1,5 +1,5 @@ from dataclasses import InitVar -from typing import Type, Any, Optional, Union, Collection, TypeVar, Dict, Callable, Mapping, List, Tuple +from typing import Type, Any, Optional, Union, Collection, TypeVar, Dict, Callable, Mapping, List, Tuple, get_type_hints T = TypeVar("T", bound=Any) @@ -40,6 +40,14 @@ def transform_value( return value +def get_data_class_hints(data_class: Type[T], globalns: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + type_hints = get_type_hints(data_class, globalns=globalns) + for attr, type_hint in type_hints.items(): + if is_init_var(type_hint): + type_hints[attr] = extract_init_var(type_hint) + return type_hints + + def extract_origin_collection(collection: Type) -> Type: try: return collection.__extra__ diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 647a4f5..30a48e4 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, InitVar from datetime import date from enum import Enum from typing import Optional, List, Union @@ -100,6 +100,33 @@ class X: assert result == X(c=["test"]) +def test_from_dict_with_type_hooks_and_init_vars(): + @dataclass + class X: + name: str + value: int + + def x_factory(tuple): + class_map = {"X": X} + return from_dict(data_class=class_map[tuple[0]], data=tuple[1]) + + @dataclass + class MyDictContainer: + name: str + var: InitVar[X] + + def __post_init__(self, var: X): + self.name += var.name + + data = { + "name": "test", + "var": ("X", {"name": "_VARS_NAME", "value": 1}), + } + + d = from_dict(data_class=MyDictContainer, data=data, config=Config(type_hooks={X: x_factory})) + assert d.name == "test_VARS_NAME" + + def test_from_dict_with_type_hook_exception(): @dataclass class X: diff --git a/tests/test_types.py b/tests/test_types.py index c9d1ea6..ba6ddb9 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,4 +1,4 @@ -from dataclasses import InitVar +from dataclasses import dataclass, InitVar from typing import Optional, Union, List, Any, Dict, NewType, TypeVar, Generic, Collection, Tuple, Type import pytest @@ -18,6 +18,7 @@ is_literal, is_init_var, extract_init_var, + get_data_class_hints, is_type_generic, is_set, ) @@ -435,3 +436,12 @@ def test_is_set_int_class(): def test_is_set_union(): assert not is_set(Union[int, float]) + + +def test_get_type_hint_for_init_var(): + @dataclass + class X: + name: str + value: InitVar[int] + + assert get_data_class_hints(X) == {"name": str, "value": int} From 6f5c2a36a899076da85853249209134e15553be9 Mon Sep 17 00:00:00 2001 From: Michael Hill Date: Sun, 12 Apr 2020 01:16:00 +0300 Subject: [PATCH 10/19] Add generic hook types --- .gitignore | 1 + README.md | 50 +++++++++++++++++++++++++++++++++++++++ dacite/config.py | 4 ++-- dacite/types.py | 23 +++++++++++++++++- tests/core/test_config.py | 36 +++++++++++++++++++++++++++- 5 files changed, 110 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 887edb0..f04da3e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .pytest_cache/ +.mypy_cache/ *.pyc dist/ *.egg-info/ diff --git a/README.md b/README.md index 58c1b69..ba10e3e 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,56 @@ If a data class field type is a `Optional[T]` you can pass both - collections, e.g. when a field has type `List[T]` you can use `List[T]` to transform whole collection or `T` to transform each item. +To target all types use `Any`. Targeting collections without their sub-types +will target all collections of those types, such as `List` and `Dict`. + +```python +@dataclass +class ShoppingCart: + store: str + item_ids: List[int] + +data = { + 'store': '7-Eleven', + 'item_ids': [1, 2, 3], +} + +def print_value(value): + print(value) + return value + +def print_collection(collection): + for item in collection: + print(item) + return collection + +result = from_dict( + data_class=ShoppingCart, + data=data, + config=Config( + type_hooks={ + Any: print_value, + List: print_collection + } + ) +) +``` + +prints + +``` +7-Eleven +[1, 2, 3] +1 +2 +3 +``` + +If a data class field type is a `Optional[T]` you can pass both - +`Optional[T]` or just `T` - as a key in `type_hooks`. The same with generic +collections, e.g. when a field has type `List[T]` you can use `List[T]` to +transform whole collection or `T` to transform each item. + ### Casting It's a very common case that you want to create an instance of a field type diff --git a/dacite/config.py b/dacite/config.py index 21655ab..718df8e 100644 --- a/dacite/config.py +++ b/dacite/config.py @@ -1,10 +1,10 @@ from dataclasses import dataclass, field -from typing import Dict, Any, Callable, Optional, Type, List +from typing import Dict, Any, Callable, Optional, Type, List, Union @dataclass class Config: - type_hooks: Dict[Type, Callable[[Any], Any]] = field(default_factory=dict) + type_hooks: Dict[Union[Type, object], Callable[[Any], Any]] = field(default_factory=dict) cast: List[Type] = field(default_factory=list) forward_references: Optional[Dict[str, Any]] = None check_types: bool = True diff --git a/dacite/types.py b/dacite/types.py index 452f41b..e7eb393 100644 --- a/dacite/types.py +++ b/dacite/types.py @@ -5,11 +5,20 @@ def transform_value( - type_hooks: Dict[Type, Callable[[Any], Any]], cast: List[Type], target_type: Type, value: Any + type_hooks: Dict[Union[Type, object], Callable[[Any], Any]], cast: List[Type], target_type: Type, value: Any ) -> Any: + # Generic hook type match + if Any in type_hooks: + value = type_hooks[Any](value) + if is_generic_collection(target_type): + collection_type = extract_origin_type(target_type) + if collection_type and collection_type in type_hooks: + value = type_hooks[collection_type](value) + # Exact hook type match if target_type in type_hooks: value = type_hooks[target_type](value) else: + # Cast to types in cast list for cast_type in cast: if is_subclass(target_type, cast_type): if is_generic_collection(target_type): @@ -20,11 +29,13 @@ def transform_value( else: value = target_type(value) break + # Peel optional types if is_optional(target_type): if value is None: return None target_type = extract_optional(target_type) return transform_value(type_hooks, cast, target_type, value) + # For collections (dict/list), transform each item if is_generic_collection(target_type) and isinstance(value, extract_origin_collection(target_type)): collection_cls = value.__class__ if issubclass(collection_cls, dict): @@ -55,6 +66,16 @@ def extract_origin_collection(collection: Type) -> Type: return collection.__origin__ + +def extract_origin_type(collection: Type) -> Optional[Type]: + collection_type = extract_origin_collection(collection) + if collection_type is list: + return List + elif collection_type is dict: + return Dict + return None + + def is_optional(type_: Type) -> bool: return is_union(type_) and type(None) in extract_generic(type_) diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 30a48e4..3a279ec 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, InitVar from datetime import date from enum import Enum -from typing import Optional, List, Union +from typing import Any, Dict, Optional, List, Union import pytest @@ -44,6 +44,40 @@ class X: assert result == X(s="test") +def test_from_dict_with_generic_type_hooks(): + @dataclass + class X: + s: str + + result = from_dict(X, {"s": "TEST"}, Config(type_hooks={Any: str.lower})) + + assert result == X(s="test") + + +def test_from_dict_with_generic_list_type_hooks(): + @dataclass + class X: + l: List[int] + + result = from_dict(X, {"l": [3,1,2]}, Config(type_hooks={List: sorted})) + + assert result == X(l=[1,2,3]) + + +def test_from_dict_with_generic_dict_type_hooks(): + @dataclass + class X: + d: Dict[str, int] + + def add_b(value): + value["b"] = 2 + return value + + result = from_dict(X, {"d": {"a": 1}}, Config(type_hooks={Dict: add_b})) + + assert result == X(d={"a": 1, "b": 2}) + + def test_from_dict_with_cast(): @dataclass class X: From 05610a1931d9cea0920cd61690d03bcae3739b78 Mon Sep 17 00:00:00 2001 From: Michal Chrobok Date: Wed, 11 May 2022 02:31:27 +0300 Subject: [PATCH 11/19] fix #168 empty tuple used for Sequence type, added tests added tests for type hinting with generic collections --- dacite/core.py | 2 ++ dacite/types.py | 6 +++++- tests/common.py | 1 + tests/core/test_collection.py | 22 +++++++++++++++++++++- tests/test_types.py | 33 ++++++++++++++++++++++++++++++++- 5 files changed, 61 insertions(+), 3 deletions(-) diff --git a/dacite/core.py b/dacite/core.py index 645d078..f23e0ab 100644 --- a/dacite/core.py +++ b/dacite/core.py @@ -142,6 +142,8 @@ def _build_value_for_collection(collection: Type, data: Any, config: Config) -> item_type = extract_generic(collection, defaults=(Any, Any))[1] return data_type((key, _build_value(type_=item_type, data=value, config=config)) for key, value in data.items()) elif is_instance(data, tuple): + if not data: + return data_type() types = extract_generic(collection) if len(types) == 2 and types[1] == Ellipsis: return data_type(_build_value(type_=types[0], data=item, config=config) for item in data) diff --git a/dacite/types.py b/dacite/types.py index e7eb393..eb81715 100644 --- a/dacite/types.py +++ b/dacite/types.py @@ -104,6 +104,10 @@ def is_union(type_: Type) -> bool: return False +def is_tuple(type_: Type) -> bool: + return is_subclass(type_, Tuple) + + def is_literal(type_: Type) -> bool: try: from typing import Literal # type: ignore @@ -147,7 +151,7 @@ def is_instance(value: Any, type_: Type) -> bool: return False if not extract_generic(type_): return True - if isinstance(value, tuple): + if isinstance(value, tuple) and is_tuple(type_): tuple_types = extract_generic(type_) if len(tuple_types) == 1 and tuple_types[0] == (): return len(value) == 0 diff --git a/tests/common.py b/tests/common.py index 99bee06..460b5ee 100644 --- a/tests/common.py +++ b/tests/common.py @@ -3,4 +3,5 @@ import pytest literal_support = init_var_type_support = pytest.mark.skipif(sys.version_info < (3, 8), reason="requires Python 3.8") +type_hints_with_generic_collections_support = pytest.mark.skipif(sys.version_info < (3, 9), reason="requires Python 3.9") pep_604_support = pytest.mark.skipif(sys.version_info < (3, 10), reason="requires Python 3.10") diff --git a/tests/core/test_collection.py b/tests/core/test_collection.py index af091d4..e94228b 100644 --- a/tests/core/test_collection.py +++ b/tests/core/test_collection.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List, Set, Union, Dict, Collection, Tuple +from typing import List, Set, Union, Dict, Collection, Tuple, Sequence import pytest @@ -269,3 +269,23 @@ class X: result = from_dict(X, {"t": (1, 2, 3)}) assert result == X(t=(1, 2, 3)) + + +def test_from_dict_with_sequence_and_tuple(): + @dataclass + class X: + s: Sequence[int] + + result = from_dict(X, {'s': (1, 2, 3)}) + + assert result == X(s=(1, 2, 3)) + + +def test_from_dict_with_sequence_and_empty_tuple(): + @dataclass + class X: + s: Sequence[int] + + result = from_dict(X, {'s': ()}) + + assert result == X(s=()) diff --git a/tests/test_types.py b/tests/test_types.py index ba6ddb9..d73a9ed 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -21,8 +21,9 @@ get_data_class_hints, is_type_generic, is_set, + is_tuple, ) -from tests.common import literal_support, init_var_type_support, pep_604_support +from tests.common import literal_support, init_var_type_support, type_hints_with_generic_collections_support, pep_604_support def test_is_union_with_union(): @@ -38,6 +39,36 @@ def test_is_union_with_pep_604_union(): assert is_union(int | float) +def test_is_tuple_with_tuple(): + assert is_tuple(Tuple[int, float, str]) + + +def test_is_tuple_with_variable_length_tuple(): + assert is_tuple(Tuple[int, ...]) + + +def test_is_tuple_with_not_parametrized_tuple(): + assert is_tuple(Tuple) + + +def test_is_tuple_with_tuple_class_object(): + assert is_tuple(tuple) + + +@type_hints_with_generic_collections_support +def test_is_tuple_with_tuple_generic(): + assert is_tuple(tuple[int, float, str]) + + +@type_hints_with_generic_collections_support +def test_is_tuple_with_variable_length_tuple_generic(): + assert is_tuple(tuple[int, ...]) + + +def test_is_tuple_with_non_tuple(): + assert not is_tuple(int) + + @literal_support def test_is_literal_with_literal(): from typing import Literal From 9c04b283f359831e90eff570fc93b0ac41423e1c Mon Sep 17 00:00:00 2001 From: Anton Agestam Date: Mon, 3 Feb 2020 17:20:49 +0200 Subject: [PATCH 12/19] Add stricter mypy type check flags - Remove unused type: ignore. - Remove asterisk import and add `__all__` with all exported names, to pass with the `no_explicit_reexport` mypy flag. - Add various mypy config for developer ergonomics (prettier errors, printing error codes, warning about unused config, ignores and casts etc). Kept `warn_return_any = False` in `dacite.types` for now since there would have to be a lot of ignores/casts otherwise. --- .travis.yml | 2 +- dacite/dataclasses.py | 2 +- setup.cfg | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 setup.cfg diff --git a/.travis.yml b/.travis.yml index e26f73a..647d84e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,7 @@ install: script: - pytest --cov=dacite - black --check . -- mypy dacite +- mypy - pylint dacite after_success: coveralls diff --git a/dacite/dataclasses.py b/dacite/dataclasses.py index ec5f821..2daca56 100644 --- a/dacite/dataclasses.py +++ b/dacite/dataclasses.py @@ -22,7 +22,7 @@ def get_default_value_for_field(field: Field, allow_missing_fields_as_none: bool def create_instance(data_class: Type[T], init_values: Data, post_init_values: Data) -> T: - instance = data_class(**init_values) + instance: T = data_class(**init_values) for key, value in post_init_values.items(): setattr(instance, key, value) return instance diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..9aee99d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,22 @@ +[mypy] +python_version = 3.6 +files = dacite +show_error_codes = True +pretty = True + +no_implicit_reexport = True +no_implicit_optional = True +strict_equality = True +strict_optional = True +check_untyped_defs = True +disallow_incomplete_defs = True +ignore_missing_imports = False + +warn_unused_configs = True +warn_redundant_casts = True +warn_unused_ignores = True +warn_return_any = True +warn_unreachable = True + +[mypy-dacite.types] +warn_return_any = False From 558f578fcae1731361fe622f57f517ada831008d Mon Sep 17 00:00:00 2001 From: Sara Sinback Date: Fri, 27 Nov 2020 11:20:17 -0500 Subject: [PATCH 13/19] types: more general type hint for type_hooks Also add a test to help ensure that type_hooks continues to work with a very general Mapping implementation. --- README.md | 3 +-- dacite/config.py | 4 ++-- dacite/types.py | 2 +- tests/core/test_config.py | 49 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ba10e3e..8a2a686 100644 --- a/README.md +++ b/README.md @@ -235,8 +235,7 @@ assert result == B(a_list=[A(x='test1', y=1), A(x='test2', y=2)]) You can use `Config.type_hooks` argument if you want to transform the input data of a data class field with given type into the new value. You have to -pass a following mapping: `{Type: callable}`, where `callable` is a -`Callable[[Any], Any]`. +pass a mapping of type `Mapping[Type, Callable[[Any], Any]`. ```python @dataclass diff --git a/dacite/config.py b/dacite/config.py index 718df8e..4fa7bc1 100644 --- a/dacite/config.py +++ b/dacite/config.py @@ -1,10 +1,10 @@ from dataclasses import dataclass, field -from typing import Dict, Any, Callable, Optional, Type, List, Union +from typing import Dict, Any, Callable, Optional, Type, List, Union, Mapping @dataclass class Config: - type_hooks: Dict[Union[Type, object], Callable[[Any], Any]] = field(default_factory=dict) + type_hooks: Mapping[Union[Type, object], Callable[[Any], Any]] = field(default_factory=dict) cast: List[Type] = field(default_factory=list) forward_references: Optional[Dict[str, Any]] = None check_types: bool = True diff --git a/dacite/types.py b/dacite/types.py index eb81715..b7708bf 100644 --- a/dacite/types.py +++ b/dacite/types.py @@ -5,7 +5,7 @@ def transform_value( - type_hooks: Dict[Union[Type, object], Callable[[Any], Any]], cast: List[Type], target_type: Type, value: Any + type_hooks: Mapping[Union[Type, object], Callable[[Any], Any]], cast: List[Type], target_type: Type, value: Any ) -> Any: # Generic hook type match if Any in type_hooks: diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 3a279ec..4784b69 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -1,3 +1,4 @@ +from collections.abc import MutableMapping from dataclasses import dataclass, InitVar from datetime import date from enum import Enum @@ -14,6 +15,39 @@ ) +class TypeHooksMapping(MutableMapping): + """ + Simple mapping implementation which lets us test that Config.type_hooks may + be a Mapping, which is more general than the typical use case of being a + Dict. + """ + def __init__(self, *args, **kwargs): + self.__dict__.update(*args, **kwargs) + + def __getitem__(self, key): + if key in self.__dict__: + # If a type hook has been specified, use it. + return self.__dict__[key] + else: + # Otherwise, use a dummy type hook which always constructs a 1. + return lambda _: 1 + + def __setitem__(self, key, value): + self.__dict__[key] = value + + def __delitem__(self): + if key in self.__dict__: + del self.__dict__[key] + else: + raise IndexError + + def __len__(self): + return len(self.__dict__) + + def __iter__(self): + return iter(self.__dict__) + + def test_from_dict_with_type_hooks(): @dataclass class X: @@ -44,6 +78,21 @@ class X: assert result == X(s="test") +def test_type_hook_mapping(): + @dataclass + class X: + s: str + i: int + + result = from_dict( + X, + {"s": "TEST", "i": 0}, + Config(type_hooks=TypeHooksMapping({str: str.lower})) + ) + + assert result == X(s="test", i=1) + + def test_from_dict_with_generic_type_hooks(): @dataclass class X: From 882872bb16f468eacfa214f78ea688bb3f3f212c Mon Sep 17 00:00:00 2001 From: Idan Miara Date: Tue, 29 Nov 2022 14:09:19 +0200 Subject: [PATCH 14/19] linting and fix issues --- dacite/core.py | 6 +++--- dacite/data.py | 3 ++- dacite/dataclasses.py | 4 ++-- dacite/types.py | 7 +++---- tests/common.py | 4 +++- tests/core/test_collection.py | 4 ++-- tests/core/test_config.py | 18 +++++++++++++++--- tests/test_types.py | 7 ++++++- 8 files changed, 36 insertions(+), 17 deletions(-) diff --git a/dacite/core.py b/dacite/core.py index f23e0ab..84d3361 100644 --- a/dacite/core.py +++ b/dacite/core.py @@ -4,7 +4,7 @@ from typing import TypeVar, Type, Optional, Mapping, Any from dacite.config import Config -from dacite.data import Data +from dacite.data import Data, DictData from dacite.dataclasses import get_default_value_for_field, create_instance, DefaultValueNotFoundError, get_fields from dacite.exceptions import ( ForwardReferenceError, @@ -41,8 +41,8 @@ def from_dict(data_class: Type[T], data: Data, config: Optional[Config] = None) :param config: a configuration of the creation process :return: an instance of a data class """ - init_values: Data = {} - post_init_values: Data = {} + init_values: DictData = {} + post_init_values: DictData = {} config = config or Config() try: data_class_hints = get_data_class_hints(data_class, globalns=config.forward_references) diff --git a/dacite/data.py b/dacite/data.py index c8e6ce4..bb67ea6 100644 --- a/dacite/data.py +++ b/dacite/data.py @@ -1,3 +1,4 @@ -from typing import Mapping, Any +from typing import Dict, Mapping, Any Data = Mapping[str, Any] +DictData = Dict[str, Any] diff --git a/dacite/dataclasses.py b/dacite/dataclasses.py index 2daca56..d271aaa 100644 --- a/dacite/dataclasses.py +++ b/dacite/dataclasses.py @@ -14,8 +14,8 @@ class DefaultValueNotFoundError(Exception): def get_default_value_for_field(field: Field, allow_missing_fields_as_none: bool = False) -> Any: if field.default != MISSING: return field.default - elif field.default_factory != MISSING: # type: ignore - return field.default_factory() # type: ignore + elif field.default_factory != MISSING: + return field.default_factory() elif is_optional(field.type) or allow_missing_fields_as_none: return None raise DefaultValueNotFoundError() diff --git a/dacite/types.py b/dacite/types.py index b7708bf..87241b8 100644 --- a/dacite/types.py +++ b/dacite/types.py @@ -66,7 +66,6 @@ def extract_origin_collection(collection: Type) -> Type: return collection.__origin__ - def extract_origin_type(collection: Type) -> Optional[Type]: collection_type = extract_origin_collection(collection) if collection_type is list: @@ -83,7 +82,7 @@ def is_optional(type_: Type) -> bool: def extract_optional(optional: Type[Optional[T]]) -> T: other_members = [member for member in extract_generic(optional) if member is not type(None)] if other_members: - return Union[tuple(other_members)] + return Union[tuple(other_members)] # type: ignore else: raise ValueError("can not find not-none value") @@ -105,7 +104,7 @@ def is_union(type_: Type) -> bool: def is_tuple(type_: Type) -> bool: - return is_subclass(type_, Tuple) + return is_subclass(type_, tuple) def is_literal(type_: Type) -> bool: @@ -200,7 +199,7 @@ def extract_generic(type_: Type, defaults: Tuple = ()) -> tuple: try: if hasattr(type_, "_special") and type_._special: return defaults - return type_.__args__ or defaults # type: ignore + return type_.__args__ or defaults except AttributeError: return defaults diff --git a/tests/common.py b/tests/common.py index 460b5ee..6e82eb8 100644 --- a/tests/common.py +++ b/tests/common.py @@ -3,5 +3,7 @@ import pytest literal_support = init_var_type_support = pytest.mark.skipif(sys.version_info < (3, 8), reason="requires Python 3.8") -type_hints_with_generic_collections_support = pytest.mark.skipif(sys.version_info < (3, 9), reason="requires Python 3.9") +type_hints_with_generic_collections_support = pytest.mark.skipif( + sys.version_info < (3, 9), reason="requires Python 3.9" +) pep_604_support = pytest.mark.skipif(sys.version_info < (3, 10), reason="requires Python 3.10") diff --git a/tests/core/test_collection.py b/tests/core/test_collection.py index e94228b..37f8f19 100644 --- a/tests/core/test_collection.py +++ b/tests/core/test_collection.py @@ -276,7 +276,7 @@ def test_from_dict_with_sequence_and_tuple(): class X: s: Sequence[int] - result = from_dict(X, {'s': (1, 2, 3)}) + result = from_dict(X, {"s": (1, 2, 3)}) assert result == X(s=(1, 2, 3)) @@ -286,6 +286,6 @@ def test_from_dict_with_sequence_and_empty_tuple(): class X: s: Sequence[int] - result = from_dict(X, {'s': ()}) + result = from_dict(X, {"s": ()}) assert result == X(s=()) diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 4784b69..33ba5c8 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -21,6 +21,7 @@ class TypeHooksMapping(MutableMapping): be a Mapping, which is more general than the typical use case of being a Dict. """ + def __init__(self, *args, **kwargs): self.__dict__.update(*args, **kwargs) @@ -108,16 +109,16 @@ def test_from_dict_with_generic_list_type_hooks(): class X: l: List[int] - result = from_dict(X, {"l": [3,1,2]}, Config(type_hooks={List: sorted})) + result = from_dict(X, {"l": [3, 1, 2]}, Config(type_hooks={List: sorted})) - assert result == X(l=[1,2,3]) + assert result == X(l=[1, 2, 3]) def test_from_dict_with_generic_dict_type_hooks(): @dataclass class X: d: Dict[str, int] - + def add_b(value): value["b"] = 2 return value @@ -127,6 +128,17 @@ def add_b(value): assert result == X(d={"a": 1, "b": 2}) +def test_type_hook_mapping(): + @dataclass + class X: + s: str + i: int + + result = from_dict(X, {"s": "TEST", "i": 0}, Config(type_hooks=TypeHooksMapping({str: str.lower}))) + + assert result == X(s="test", i=1) + + def test_from_dict_with_cast(): @dataclass class X: diff --git a/tests/test_types.py b/tests/test_types.py index d73a9ed..814010b 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -23,7 +23,12 @@ is_set, is_tuple, ) -from tests.common import literal_support, init_var_type_support, type_hints_with_generic_collections_support, pep_604_support +from tests.common import ( + literal_support, + init_var_type_support, + pep_604_support, + type_hints_with_generic_collections_support, +) def test_is_union_with_union(): From 793c1e0a76b1917530465c75aab332841f7e1443 Mon Sep 17 00:00:00 2001 From: Tobias Pfeiffer Date: Tue, 10 Aug 2021 10:35:29 +0300 Subject: [PATCH 15/19] do not treat generic numpy.ndarray as a generic collection allow `is_instance` checks for arbitrary generic types add test for `npt.NDArray` handling --- dacite/types.py | 9 +++++++- setup.py | 12 +++++++++- tests/common.py | 1 + tests/core/test_ndarray.py | 47 ++++++++++++++++++++++++++++++++++++++ tests/test_types.py | 2 +- 5 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 tests/core/test_ndarray.py diff --git a/dacite/types.py b/dacite/types.py index 87241b8..92714c8 100644 --- a/dacite/types.py +++ b/dacite/types.py @@ -175,6 +175,9 @@ def is_instance(value: Any, type_: Type) -> bool: return is_instance(value, extract_init_var(type_)) elif is_type_generic(type_): return is_subclass(value, extract_generic(type_)[0]) + elif is_generic(type_): + origin = extract_origin_collection(type_) + return isinstance(value, origin) else: try: # As described in PEP 484 - section: "The numeric tower" @@ -190,11 +193,15 @@ def is_generic_collection(type_: Type) -> bool: return False origin = extract_origin_collection(type_) try: - return bool(origin and issubclass(origin, Collection)) + return bool(origin and issubclass(origin, Collection) and not skip_generic_conversion(origin)) except (TypeError, AttributeError): return False +def skip_generic_conversion(origin: Type) -> bool: + return origin.__module__ == "numpy" and origin.__qualname__ == "ndarray" + + def extract_generic(type_: Type, defaults: Tuple = ()) -> tuple: try: if hasattr(type_, "_special") and type_._special: diff --git a/setup.py b/setup.py index 6f2620e..f335970 100644 --- a/setup.py +++ b/setup.py @@ -27,5 +27,15 @@ packages=["dacite"], package_data={"dacite": ["py.typed"]}, install_requires=['dataclasses;python_version<"3.7"'], - extras_require={"dev": ["pytest>=5", "pytest-cov", "coveralls", "black", "mypy", "pylint"]}, + extras_require={ + "dev": [ + "pytest>=5", + "pytest-cov", + "coveralls", + "black", + "mypy", + "pylint", + 'numpy>=1.21.0;python_version>="3.7"', + ] + }, ) diff --git a/tests/common.py b/tests/common.py index 6e82eb8..906d6f7 100644 --- a/tests/common.py +++ b/tests/common.py @@ -2,6 +2,7 @@ import pytest +ndarray_support = pytest.mark.skipif(sys.version_info < (3, 7), reason="requires Python 3.7") literal_support = init_var_type_support = pytest.mark.skipif(sys.version_info < (3, 8), reason="requires Python 3.8") type_hints_with_generic_collections_support = pytest.mark.skipif( sys.version_info < (3, 9), reason="requires Python 3.9" diff --git a/tests/core/test_ndarray.py b/tests/core/test_ndarray.py new file mode 100644 index 0000000..df04813 --- /dev/null +++ b/tests/core/test_ndarray.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass +from typing import Sequence, TypeVar + +import numpy +import numpy.typing +import numpy.testing + +from dacite import from_dict, Config +from tests.common import ndarray_support + + +@ndarray_support +def test_from_dict_with_ndarray(): + @dataclass + class X: + a: numpy.ndarray + + result = from_dict(X, {"a": numpy.array([1, 2, 3])}) + + numpy.testing.assert_allclose(result.a, numpy.array([1, 2, 3])) + + +@ndarray_support +def test_from_dict_with_nptndarray(): + @dataclass + class X: + a: numpy.typing.NDArray[numpy.float64] + + result = from_dict(X, {"a": numpy.array([1, 2, 3])}) + + numpy.testing.assert_allclose(result.a, numpy.array([1, 2, 3])) + + +@ndarray_support +def test_from_dict_with_nptndarray_and_converter(): + @dataclass + class X: + a: numpy.typing.NDArray[numpy.float64] + + D = TypeVar("D", bound=numpy.generic) + + def coerce_to_array(s: Sequence[D]) -> numpy.typing.NDArray[D]: + return numpy.array(s) + + result = from_dict(X, {"a": [1, 2, 3]}, Config(type_hooks={numpy.typing.NDArray[numpy.float64]: coerce_to_array})) + + numpy.testing.assert_allclose(result.a, numpy.array([1, 2, 3])) diff --git a/tests/test_types.py b/tests/test_types.py index 814010b..07b119e 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -264,7 +264,7 @@ def test_is_instance_with_not_supported_generic_types(): class X(Generic[T]): pass - assert not is_instance(X[str](), X[str]) + assert is_instance(X[str](), X[str]) def test_is_instance_with_generic_mapping_and_matching_value_type(): From 91193fb54c48ed5f9c0c25f8dfdf7cfb6c08070b Mon Sep 17 00:00:00 2001 From: Idan Miara Date: Tue, 29 Nov 2022 17:32:49 +0200 Subject: [PATCH 16/19] test_ndarray.py - skip if numpy is not installed --- tests/core/test_ndarray.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/core/test_ndarray.py b/tests/core/test_ndarray.py index df04813..ed9e970 100644 --- a/tests/core/test_ndarray.py +++ b/tests/core/test_ndarray.py @@ -1,9 +1,13 @@ from dataclasses import dataclass from typing import Sequence, TypeVar - -import numpy -import numpy.typing -import numpy.testing +import pytest + +try: + import numpy + import numpy.typing + import numpy.testing +except ImportError: + pytest.skip("numpy not available", allow_module_level=True) from dacite import from_dict, Config from tests.common import ndarray_support From cc0d4a88ab4b5d3831eb18ba74e28f051350ca0e Mon Sep 17 00:00:00 2001 From: Idan Miara Date: Tue, 29 Nov 2022 17:22:59 +0200 Subject: [PATCH 17/19] fix some tests mark 2 tests with `init_var_type_support` --- dacite/core.py | 4 +++- dacite/types.py | 11 ++++++++- tests/core/test_config.py | 50 +++++++++++++++++++++++++++------------ tests/test_types.py | 1 + 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/dacite/core.py b/dacite/core.py index 84d3361..6167072 100644 --- a/dacite/core.py +++ b/dacite/core.py @@ -74,7 +74,9 @@ def from_dict(data_class: Type[T], data: Data, config: Optional[Config] = None) continue else: try: - value = get_default_value_for_field(field) + value = get_default_value_for_field( + field, allow_missing_fields_as_none=config.allow_missing_fields_as_none + ) except DefaultValueNotFoundError: raise MissingValueError(field.name) if field.init: diff --git a/dacite/types.py b/dacite/types.py index 92714c8..a08f708 100644 --- a/dacite/types.py +++ b/dacite/types.py @@ -148,7 +148,7 @@ def is_instance(value: Any, type_: Type) -> bool: origin = extract_origin_collection(type_) if not isinstance(value, origin): return False - if not extract_generic(type_): + if extract_generic_no_defaults(type_) is None: return True if isinstance(value, tuple) and is_tuple(type_): tuple_types = extract_generic(type_) @@ -211,6 +211,15 @@ def extract_generic(type_: Type, defaults: Tuple = ()) -> tuple: return defaults +def extract_generic_no_defaults(type_: Type) -> Union[tuple, None]: + try: + if hasattr(type_, "_special") and type_._special: + return None + return type_.__args__ + except AttributeError: + return None + + def is_subclass(sub_type: Type, base_type: Type) -> bool: if is_generic_collection(sub_type): sub_type = extract_origin_collection(sub_type) diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 33ba5c8..774fb75 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -2,6 +2,8 @@ from dataclasses import dataclass, InitVar from datetime import date from enum import Enum +from datetime import date +from dataclasses import dataclass, InitVar from typing import Any, Dict, Optional, List, Union import pytest @@ -13,6 +15,7 @@ UnexpectedDataError, StrictUnionMatchError, ) +from tests.common import init_var_type_support class TypeHooksMapping(MutableMapping): @@ -26,17 +29,12 @@ def __init__(self, *args, **kwargs): self.__dict__.update(*args, **kwargs) def __getitem__(self, key): - if key in self.__dict__: - # If a type hook has been specified, use it. - return self.__dict__[key] - else: - # Otherwise, use a dummy type hook which always constructs a 1. - return lambda _: 1 + return self.__dict__[key] def __setitem__(self, key, value): self.__dict__[key] = value - def __delitem__(self): + def __delitem__(self, key): if key in self.__dict__: del self.__dict__[key] else: @@ -49,6 +47,16 @@ def __iter__(self): return iter(self.__dict__) +class TypeHooksMappingWithDefault(TypeHooksMapping): + def __getitem__(self, key): + if key in self.__dict__: + # If a type hook has been specified, use it. + return self.__dict__[key] + else: + # Otherwise, use a dummy type hook which always constructs a 1. + return lambda _: 1 + + def test_from_dict_with_type_hooks(): @dataclass class X: @@ -85,11 +93,7 @@ class X: s: str i: int - result = from_dict( - X, - {"s": "TEST", "i": 0}, - Config(type_hooks=TypeHooksMapping({str: str.lower})) - ) + result = from_dict(X, {"s": "TEST", "i": 0}, Config(type_hooks=TypeHooksMapping({str: str.lower}))) assert result == X(s="test", i=1) @@ -129,12 +133,25 @@ def add_b(value): def test_type_hook_mapping(): + @dataclass + class X: + s: str + + result = from_dict(X, {"s": "TEST"}, Config(type_hooks=TypeHooksMapping({str: str.lower}))) + + assert result == X(s="test") + + +@pytest.mark.skip( + reason="not working because it runs on all of the fields, " "not just as default. see 'if Any in type_hooks:'" +) +def test_type_hook_mapping_with_default(): @dataclass class X: s: str i: int - result = from_dict(X, {"s": "TEST", "i": 0}, Config(type_hooks=TypeHooksMapping({str: str.lower}))) + result = from_dict(X, {"s": "TEST", "i": 0}, Config(type_hooks=TypeHooksMappingWithDefault({str: str.lower}))) assert result == X(s="test", i=1) @@ -195,6 +212,7 @@ class X: assert result == X(c=["test"]) +@init_var_type_support def test_from_dict_with_type_hooks_and_init_vars(): @dataclass class X: @@ -355,12 +373,14 @@ class X: d: date t: str + @classmethod def from_dict(data_class, data, config): - data["t"] = "prefix {}".format(data["t"]) + data["t"] = f"prefix {data['t']}" + config = Config(type_hooks={date: date.fromtimestamp}) return from_dict( data_class=data_class, data=data, - config=Config(type_hooks={date: date.fromtimestamp}), + config=config, ) @dataclass diff --git a/tests/test_types.py b/tests/test_types.py index 07b119e..738b7e0 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -474,6 +474,7 @@ def test_is_set_union(): assert not is_set(Union[int, float]) +@init_var_type_support def test_get_type_hint_for_init_var(): @dataclass class X: From 711b030515de2d445d686338c3363ea64af304c1 Mon Sep 17 00:00:00 2001 From: Idan Miara Date: Tue, 29 Nov 2022 22:07:05 +0200 Subject: [PATCH 18/19] CHANGELOG.md - updated --- CHANGELOG.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 901e35d..ebf39ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0b1] - 2022-11-28 + ### Added - Add explicit `__all__` configuration -- Support [PEP 604] unions through `types.UnionType` +- Support [PEP 604] unions through `types.UnionType` #184 +- numpy.typing.NDArray dataclass members #156 +- Add `allow_missing_fields_as_none` option for missing fields #199 +- Ability to customize `from_dict` per dataclass #122 +- more general type hint for `type_hooks` #120 +- Add Generic Hook Types #83 +- Add stricter mypy type check flags #76 [PEP 604]: https://peps.python.org/pep-0604/ ### Fixed - Do not suppress `KeyError` in a type hook +- `from_dict` in case of frozen dataclass with non-init default #196 +- empty tuple used for Sequence type #175 +- Type_hooks not applied to InitVar fields #172 +- extract_optional for Optional of Union. #164 +- change Data from Dict to Mapping #159 +- Fix encoding of PKG-INFO file #86 ## [1.6.0] - 2020-11-30 From bada8b16beb9052047f00eefb0058c58f6afa188 Mon Sep 17 00:00:00 2001 From: Idan Miara Date: Tue, 29 Nov 2022 17:23:41 +0200 Subject: [PATCH 19/19] Drop support for Python 3.6 and Add support for Python 3.11 --- .github/workflows/code_check.yaml | 2 +- .travis.yml | 5 +++-- README.md | 2 +- setup.cfg | 2 +- setup.py | 8 ++++---- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/code_check.yaml b/.github/workflows/code_check.yaml index 50e19f5..173d67b 100644 --- a/.github/workflows/code_check.yaml +++ b/.github/workflows/code_check.yaml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '3.7', '3.8', '3.9', '3.10' ] + python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11' ] name: Python ${{ matrix.python-version }} steps: diff --git a/.travis.yml b/.travis.yml index 647d84e..1374ab6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,11 @@ dist: xenial language: python python: -- 3.6 - 3.7 - 3.8 -- 3.9-dev +- 3.9 +- 3.10 +- 3.11 install: - pip install -e .[dev] script: diff --git a/README.md b/README.md index 8a2a686..65b1f42 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ $ pip install dacite ## Requirements -Minimum Python version supported by `dacite` is 3.6. +Minimum Python version supported by `dacite` is 3.7. ## Quick start diff --git a/setup.cfg b/setup.cfg index 9aee99d..f95cfcf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [mypy] -python_version = 3.6 +python_version = 3.7 files = dacite show_error_codes = True pretty = True diff --git a/setup.py b/setup.py index f335970..5c46cc6 100644 --- a/setup.py +++ b/setup.py @@ -16,17 +16,17 @@ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Topic :: Software Development :: Libraries :: Python Modules", ], - python_requires=">=3.6", + python_requires=">=3.7", keywords="dataclasses", packages=["dacite"], package_data={"dacite": ["py.typed"]}, - install_requires=['dataclasses;python_version<"3.7"'], extras_require={ "dev": [ "pytest>=5", @@ -35,7 +35,7 @@ "black", "mypy", "pylint", - 'numpy>=1.21.0;python_version>="3.7"', + "numpy>=1.21.0", ] }, )