konradhalas / dacite

Simple creation of data classes from dictionaries.
MIT License
1.72k stars 106 forks source link

Fields of type Optional[Union[A, B]] not working with cast #163

Closed BurningKarl closed 1 year ago

BurningKarl commented 2 years ago

Example to reproduce faulty behavior:

from dataclasses import dataclass
from typing import Optional, Union
import dacite

class A(int):
    pass

class B(str):
    pass

@dataclass
class C:
    a_or_b: Optional[Union[A, B]]

obj_from_dict = dacite.from_dict(
    data_class=C,
    data={"a_or_b": "Hello world!"},
    config=dacite.Config(cast=[A, B]),
)
assert obj_from_dict == C(a_or_b=B("Hello world!"))

Issue

I would expect this to work flawlessly, since I included both A and B in cast to turn int to A and str to B so that the types match. Unfortunately this is not the case, instead I get

Traceback (most recent call last):
  File "problem.py", line 15, in <module>
    obj_from_dict = dacite.from_dict(
  File "[...]/lib/python3.8/site-packages/dacite/core.py", line 60, in from_dict
    transformed_value = transform_value(
  File "[...]/lib/python3.8/site-packages/dacite/types.py", line 24, in transform_value
    return transform_value(type_hooks, cast, target_type, value)
  File "[...]/lib/python3.8/site-packages/dacite/types.py", line 18, in transform_value
    value = target_type(value)
ValueError: invalid literal for int() with base 10: 'Hello world!'

If we inspect this more closely, we first enter from_dict and then transform_value where the target type is Union[A, B, None]. Since the target type is correctly identified as optional by is_optional and since the value is not None (but "Hello world!"), transform_value is again called. This time the target type is extract_optional(Union[A, B, None]). It should be Union[A, B], but in the current implementation it is A. This causes the program to incorrectly cast "Hello World!" to A and the program crashes.

Solution

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")

should be changed to something like

def extract_optional(optional: Type[Optional[T]]) -> T:
    other_members = [member for member in extract_generic(optional) if member is not type(None)]
    if not other_members:
        raise ValueError("can not find not-none value")
    else:
        return Union[tuple(other_members)]

This way extract_optional(Union[A, B, None]) == Union[A, B] and no incorrect casting happens. The Union is then properly handled by _build_value_for_union. Note that, if other_members contains only a single type, say A, then Union[A] == A.

Basically, this is the same problem reported in #26 for is_optional, but this time for extract_optional. It is also probably related to #161. This issues does not appear without including the types inside the union in type_hooks or cast, because then the incorrect call to transform_values does not actually do anything.

KKawamura1 commented 2 years ago

I just rushed into this issue. Any updates on it?