From a648f5290f3175efdfbaa5fc7422f52835974aef Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 21 Aug 2023 13:00:20 -0400 Subject: [PATCH 1/6] improve Union deserialization when "__type" field specifier is not present. --- dataclasses_json/core.py | 23 ++++++++++++++--------- dataclasses_json/mm.py | 27 ++++++++++++++++++--------- tests/test_union.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 18 deletions(-) diff --git a/dataclasses_json/core.py b/dataclasses_json/core.py index 3dafe9f9..9aee7daa 100644 --- a/dataclasses_json/core.py +++ b/dataclasses_json/core.py @@ -312,18 +312,23 @@ def _decode_generic(type_, value, infer_missing): res = _support_extended_types(type_arg, value) else: # Union (already decoded or try to decode a dataclass) type_options = _get_type_args(type_) - res = value # assume already decoded - if type(value) is dict and dict not in type_options: - # FIXME if all types in the union are dataclasses this - # will just pick the first option - - # maybe find the best fitting class in that case instead? + if type(value) is not dict or dict in type_options: + # already decoded + res = value + else: + changed = False for type_option in type_options: if is_dataclass(type_option): - res = _decode_dataclass(type_option, value, infer_missing) - break - if res == value: + try: + res = _decode_dataclass(type_option, value, infer_missing) + changed = True + break + except (KeyError, ValueError): + continue + if not changed: + res = value warnings.warn( - f"Failed to encode {value} Union dataclasses." + f"Failed encoding {value} Union dataclasses." f"Expected Union to include a dataclass and it didn't." ) return res diff --git a/dataclasses_json/mm.py b/dataclasses_json/mm.py index 8e889222..9cfacf1d 100644 --- a/dataclasses_json/mm.py +++ b/dataclasses_json/mm.py @@ -100,16 +100,25 @@ def _deserialize(self, value, attr, data, **kwargs): if is_dataclass(type_) and type_.__name__ == dc_name: del tmp_value['__type'] return schema_._deserialize(tmp_value, attr, data, **kwargs) - for type_, schema_ in self.desc.items(): - if isinstance(tmp_value, _get_type_origin(type_)): - return schema_._deserialize(tmp_value, attr, data, **kwargs) - else: + elif isinstance(tmp_value, dict): warnings.warn( - f'The type "{type(tmp_value).__name__}" (value: "{tmp_value}") ' - f'is not in the list of possible types of typing.Union ' - f'(dataclass: {self.cls.__name__}, field: {self.field.name}). ' - f'Value cannot be deserialized properly.') - return super()._deserialize(tmp_value, attr, data, **kwargs) + f'Attempting to deserialize "dict" (value: "{tmp_value}) ' + f'that does not have a "__type" type specifier field into' + f'(dataclass: {self.cls.__name__}, field: {self.field.name}).' + f'Deserialization may fail, or deserialization to wrong type may occur.' + ) + return super()._deserialize(tmp_value, attr, data, **kwargs) + else: + for type_, schema_ in self.desc.items(): + if isinstance(tmp_value, _get_type_origin(type_)): + return schema_._deserialize(tmp_value, attr, data, **kwargs) + else: + warnings.warn( + f'The type "{type(tmp_value).__name__}" (value: "{tmp_value}") ' + f'is not in the list of possible types of typing.Union ' + f'(dataclass: {self.cls.__name__}, field: {self.field.name}). ' + f'Value cannot be deserialized properly.') + return super()._deserialize(tmp_value, attr, data, **kwargs) class _TupleVarLen(fields.List): diff --git a/tests/test_union.py b/tests/test_union.py index d02f8d1b..5b51ba11 100644 --- a/tests/test_union.py +++ b/tests/test_union.py @@ -37,11 +37,21 @@ class Aux2: f1: str +@dataclass_json +@dataclass +class Aux3: + f2: str + @dataclass_json @dataclass class C4: f1: Union[Aux1, Aux2] +@dataclass_json +@dataclass +class C12: + f1: Union[Aux2, Aux3] + @dataclass_json @dataclass @@ -198,3 +208,29 @@ def test_deserialize_with_error(cls, data): s = cls.schema() with pytest.raises(ValidationError): assert s.load(data) + +def test_deserialize_without_discriminator(): + # determine based on type + json = '{"f1": {"f1": 1}}' + s = C4.schema() + obj = s.loads(json) + assert obj.f1 is not None + assert type(obj.f1) == Aux1 + + json = '{"f1": {"f1": "str1"}}' + s = C4.schema() + obj = s.loads(json) + assert obj.f1 is not None + assert type(obj.f1) == Aux2 + + # determine based on field name + json = '{"f1": {"f1": "str1"}}' + s = C12.schema() + obj = s.loads(json) + assert obj.f1 is not None + assert type(obj.f1) == Aux2 + json = '{"f1": {"f2": "str1"}}' + s = C12.schema() + obj = s.loads(json) + assert obj.f1 is not None + assert type(obj.f1) == Aux3 \ No newline at end of file From 2436ca69060de3fc4534f760ea13c9d7b9bd8731 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 4 Sep 2023 16:26:35 -0400 Subject: [PATCH 2/6] update to match refactored style --- dataclasses_json/core.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/dataclasses_json/core.py b/dataclasses_json/core.py index 9aee7daa..f8dc933a 100644 --- a/dataclasses_json/core.py +++ b/dataclasses_json/core.py @@ -312,10 +312,8 @@ def _decode_generic(type_, value, infer_missing): res = _support_extended_types(type_arg, value) else: # Union (already decoded or try to decode a dataclass) type_options = _get_type_args(type_) - if type(value) is not dict or dict in type_options: - # already decoded - res = value - else: + res = value + if type(value) is dict and dict not in type_options: changed = False for type_option in type_options: if is_dataclass(type_option): @@ -326,7 +324,6 @@ def _decode_generic(type_, value, infer_missing): except (KeyError, ValueError): continue if not changed: - res = value warnings.warn( f"Failed encoding {value} Union dataclasses." f"Expected Union to include a dataclass and it didn't." From ff06a9bf7946f37ddda69af2ea9b3543dd3ca834 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 4 Sep 2023 16:28:49 -0400 Subject: [PATCH 3/6] revert unneeded warning change --- dataclasses_json/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dataclasses_json/core.py b/dataclasses_json/core.py index f8dc933a..29169442 100644 --- a/dataclasses_json/core.py +++ b/dataclasses_json/core.py @@ -325,7 +325,7 @@ def _decode_generic(type_, value, infer_missing): continue if not changed: warnings.warn( - f"Failed encoding {value} Union dataclasses." + f"Failed to encode {value} Union dataclasses." f"Expected Union to include a dataclass and it didn't." ) return res From 257f2a490b96f23a064b4857f2d72683748d1b5f Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 4 Sep 2023 16:29:21 -0400 Subject: [PATCH 4/6] re-add removed comment --- dataclasses_json/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dataclasses_json/core.py b/dataclasses_json/core.py index 29169442..9515246b 100644 --- a/dataclasses_json/core.py +++ b/dataclasses_json/core.py @@ -312,7 +312,7 @@ def _decode_generic(type_, value, infer_missing): res = _support_extended_types(type_arg, value) else: # Union (already decoded or try to decode a dataclass) type_options = _get_type_args(type_) - res = value + res = value # assume already decoded if type(value) is dict and dict not in type_options: changed = False for type_option in type_options: From 9c207022ef144ffbfb0d2bf96581de40c6d65aa6 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Mon, 4 Sep 2023 16:31:01 -0400 Subject: [PATCH 5/6] style --- dataclasses_json/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dataclasses_json/core.py b/dataclasses_json/core.py index 9515246b..ad4cee10 100644 --- a/dataclasses_json/core.py +++ b/dataclasses_json/core.py @@ -312,7 +312,7 @@ def _decode_generic(type_, value, infer_missing): res = _support_extended_types(type_arg, value) else: # Union (already decoded or try to decode a dataclass) type_options = _get_type_args(type_) - res = value # assume already decoded + res = value # assume already decoded if type(value) is dict and dict not in type_options: changed = False for type_option in type_options: From bd338ebd8ed36d39c2cd13ea8f7b50a05a33c4e8 Mon Sep 17 00:00:00 2001 From: Ian Bentley Date: Tue, 12 Sep 2023 15:26:12 -0400 Subject: [PATCH 6/6] add test for no matching type, and update logic --- dataclasses_json/core.py | 8 +++----- tests/test_union.py | 8 +++++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/dataclasses_json/core.py b/dataclasses_json/core.py index ad4cee10..3b755321 100644 --- a/dataclasses_json/core.py +++ b/dataclasses_json/core.py @@ -314,19 +314,17 @@ def _decode_generic(type_, value, infer_missing): type_options = _get_type_args(type_) res = value # assume already decoded if type(value) is dict and dict not in type_options: - changed = False for type_option in type_options: if is_dataclass(type_option): try: res = _decode_dataclass(type_option, value, infer_missing) - changed = True break except (KeyError, ValueError): continue - if not changed: + if res == value: warnings.warn( - f"Failed to encode {value} Union dataclasses." - f"Expected Union to include a dataclass and it didn't." + f"Failed to decode {value} Union dataclasses." + f"Expected Union to include a matching dataclass and it didn't." ) return res diff --git a/tests/test_union.py b/tests/test_union.py index 5b51ba11..57b848b9 100644 --- a/tests/test_union.py +++ b/tests/test_union.py @@ -233,4 +233,10 @@ def test_deserialize_without_discriminator(): s = C12.schema() obj = s.loads(json) assert obj.f1 is not None - assert type(obj.f1) == Aux3 \ No newline at end of file + assert type(obj.f1) == Aux3 + + # if no matching types, type should remain dict + json = '{"f1": {"f3": "str2"}}' + s = C12.schema() + obj = s.loads(json) + assert type(obj.f1) == dict \ No newline at end of file