Repository: lidatong/dataclasses-json

Issue:
[BUG] tuple decode assumes homogenous sequence
### Description

If I express a field type as `tuple[int, str]`, I would expect the two dimensions of the tuple to be decoded as each separate type. This is especially true if the type of the first dimension is an Enum, because assuming that only the first type applies to all values means decoding the second value as an Enum even though it isn't the appropriate value.

### Code snippet that reproduces the issue

```py
from dataclasses import dataclass, field
from enum import Enum

from dataclasses_json import config, dataclass_json


class ExampleEnum(Enum):
    FIRST = "first"


@dataclass_json
@dataclass
class Example:
    example_inner: tuple[ExampleEnum, str]


# This should produce no error. On the contrary, this should be perfectly valid.
Example.from_dict(dict(example_inner=("first", "second")))
```

### Describe the results you expected

No error. Instead, got:
```py
Traceback (most recent call last):
  File "/tmp/t.py", line 18, in <module>
    Example.from_dict(dict(example_inner=("first", "second")))
  File "/usr/local/lib/python3.9/site-packages/dataclasses_json/api.py", line 70, in from_dict
    return _decode_dataclass(cls, kvs, infer_missing)
  File "/usr/local/lib/python3.9/site-packages/dataclasses_json/core.py", line 210, in _decode_dataclass
    init_kwargs[field.name] = _decode_generic(field_type,
  File "/usr/local/lib/python3.9/site-packages/dataclasses_json/core.py", line 279, in _decode_generic
    res = t(xs)
  File "/usr/local/lib/python3.9/site-packages/dataclasses_json/core.py", line 339, in <genexpr>
    items = (_decode_generic(type_arg, x, infer_missing) for x in xs)
  File "/usr/local/lib/python3.9/site-packages/dataclasses_json/core.py", line 258, in _decode_generic
    res = type_(value)
  File "/usr/local/Cellar/python@3.9/3.9.16/Frameworks/Python.framework/Versions/3.9/lib/python3.9/enum.py", line 384, in __call__
    return cls.__new__(cls, value)
  File "/usr/local/Cellar/python@3.9/3.9.16/Frameworks/Python.framework/Versions/3.9/lib/python3.9/enum.py", line 702, in __new__
    raise ve_exc
ValueError: 'second' is not a valid ExampleEnum
```

### Python version you are using

3.9.16

### Environment description

irrelevant, cause is obvious: lack of supported functionality, but in this case we should see an error due to no support for tuples rather than an error due to trying to decode the enum.

Comments:

Patch:
diff --git a/dataclasses_json/core.py b/dataclasses_json/core.py
index 3118036..acb5a47 100644
--- a/dataclasses_json/core.py
+++ b/dataclasses_json/core.py
@@ -23,7 +23,7 @@ from dataclasses_json.utils import (_get_type_cons, _get_type_origin,
                                     _get_type_arg_param,
                                     _get_type_args,
                                     _NO_ARGS,
-                                    _issubclass_safe)
+                                    _issubclass_safe, _is_tuple)
 
 Json = Union[dict, list, str, int, float, bool, None]
 
@@ -278,9 +278,10 @@ def _decode_generic(type_, value, infer_missing):
             ks = _decode_dict_keys(k_type, value.keys(), infer_missing)
             vs = _decode_items(v_type, value.values(), infer_missing)
             xs = zip(ks, vs)
+        elif _is_tuple(type_):
+            xs = _decode_items(_get_type_args(type_) or _NO_ARGS, value, infer_missing)
         else:
-            xs = _decode_items(_get_type_arg_param(type_, 0),
-                               value, infer_missing)
+            xs = _decode_items(_get_type_arg_param(type_, 0), value, infer_missing)
 
         # get the constructor if using corresponding generic type in `typing`
         # otherwise fallback on constructing using type_ itself
@@ -335,7 +336,7 @@ def _decode_dict_keys(key_type, xs, infer_missing):
     return map(decode_function, _decode_items(key_type, xs, infer_missing))
 
 
-def _decode_items(type_arg, xs, infer_missing):
+def _decode_items(type_args, xs, infer_missing):
     """
     This is a tricky situation where we need to check both the annotated
     type info (which is usually a type from `typing`) and check the
@@ -345,14 +346,16 @@ def _decode_items(type_arg, xs, infer_missing):
     type_arg is a typevar we need to extract the reified type information
     hence the check of `is_dataclass(vs)`
     """
-    if is_dataclass(type_arg) or is_dataclass(xs):
-        items = (_decode_dataclass(type_arg, x, infer_missing)
-                 for x in xs)
-    elif _is_supported_generic(type_arg):
-        items = (_decode_generic(type_arg, x, infer_missing) for x in xs)
-    else:
-        items = xs
-    return items
+    def _decode_item(type_arg, x):
+        if is_dataclass(type_arg) or is_dataclass(xs):
+            return _decode_dataclass(type_arg, x, infer_missing)
+        if _is_supported_generic(type_arg):
+            return _decode_generic(type_arg, x, infer_missing)
+        return x
+
+    if _isinstance_safe(type_args, Collection) and not _issubclass_safe(type_args, Enum):
+        return list(_decode_item(type_arg, x) for type_arg, x in zip(type_args, xs))
+    return list(_decode_item(type_args, x) for x in xs)
 
 
 def _asdict(obj, encode_json=False):
diff --git a/dataclasses_json/utils.py b/dataclasses_json/utils.py
index 355e690..0927cd0 100644
--- a/dataclasses_json/utils.py
+++ b/dataclasses_json/utils.py
@@ -150,6 +150,10 @@ def _is_collection(type_):
     return _issubclass_safe(_get_type_origin(type_), Collection)
 
 
+def _is_tuple(type_):
+    return _issubclass_safe(_get_type_origin(type_), Tuple)
+
+
 def _is_nonstr_collection(type_):
     return (_issubclass_safe(_get_type_origin(type_), Collection)
             and not _issubclass_safe(type_, str))


Test patch:
diff --git a/tests/test_enum.py b/tests/test_enum.py
index f4848ed..4313c27 100644
--- a/tests/test_enum.py
+++ b/tests/test_enum.py
@@ -1,6 +1,6 @@
 import json
 from enum import Enum
-from typing import Dict, List, Optional
+from typing import Dict, List
 import pytest
 
 from dataclasses import dataclass, field
diff --git a/tests/test_tuples.py b/tests/test_tuples.py
new file mode 100644
index 0000000..44e3574
--- /dev/null
+++ b/tests/test_tuples.py
@@ -0,0 +1,57 @@
+import json
+from enum import Enum
+from typing import Tuple
+
+from dataclasses import dataclass
+
+from dataclasses_json import dataclass_json
+
+
+class MyEnum(Enum):
+    STR1 = "str1"
+    STR2 = "str2"
+    STR3 = "str3"
+    INT1 = 1
+    FLOAT1 = 1.23
+
+
+@dataclass_json
+@dataclass
+class DataWithHeterogeneousTuple:
+    my_tuple: Tuple[MyEnum, str, float]
+
+
+hetero_tuple_json = '{"my_tuple": ["str1", "str2", 1.23]}'
+hetero_tuple_data = DataWithHeterogeneousTuple(my_tuple=(MyEnum.STR1, "str2", 1.23))
+
+
+@dataclass_json
+@dataclass
+class DataWithNestedTuple:
+    my_tuple: Tuple[Tuple[int, str], Tuple[MyEnum, int], int]
+
+
+nested_tuple_json = '{"my_tuple": [[1, "str1"], ["str2", 1], 1]}'
+nested_tuple_data = DataWithNestedTuple(my_tuple=((1, "str1"), (MyEnum.STR2, 1), 1))
+
+
+class TestEncoder:
+    def test_enum_with_tuple(self):
+        assert hetero_tuple_data.to_json() == hetero_tuple_json, f'Actual: {hetero_tuple_data.to_json()}, Expected: {hetero_tuple_json}'
+        assert hetero_tuple_data.to_dict(encode_json=True) == json.loads(hetero_tuple_json), f'Actual: {hetero_tuple_data.to_dict()}, Expected: {json.loads(hetero_tuple_json)}'
+
+    def test_nested_tuple(self):
+        assert nested_tuple_data.to_json() == nested_tuple_json, f'Actual: {nested_tuple_data.to_json()}, Expected: {nested_tuple_json}'
+        assert nested_tuple_data.to_dict(encode_json=True) == json.loads(nested_tuple_json), f'Actual: {nested_tuple_data.to_dict()}, Expected: {json.loads(nested_tuple_json)}'
+
+
+class TestDecoder:
+    def test_enum_with_tuple(self):
+        tuple_data_from_json = DataWithHeterogeneousTuple.from_json(hetero_tuple_json)
+        assert hetero_tuple_data == tuple_data_from_json
+        assert tuple_data_from_json.to_json() == hetero_tuple_json
+
+    def test_nested_tuple(self):
+        tuple_data_from_json = DataWithNestedTuple.from_json(nested_tuple_json)
+        assert nested_tuple_data == tuple_data_from_json
+        assert tuple_data_from_json.to_json() == nested_tuple_json