Repository: lidatong/dataclasses-json

Issue:
`typing` module types without parameters broken when using Python 3.9
When using types in the built-in `typing` module, if a type supports additional parameters and those parameters are not used, errors result when using Python 3.9

Code:
```python
from dataclasses import dataclass
from typing import List

from dataclasses_json import DataClassJsonMixin


@dataclass
class Parent(DataClassJsonMixin):
    children: List


if __name__ == '__main__':
    p = Parent.from_dict({
        "children": ['hi']
    })
    print(p.to_json())
```

Output when running dataclasses-json 0.5.6 and Python 3.9:
```
  File "/Users/daniel.letscher/code/playground/dataclass.py", line 13, in <module>
    p = Parent.from_dict({
  File "/Users/daniel.letscher/code/playground/venv39/lib/python3.9/site-packages/dataclasses_json/api.py", line 82, in from_dict
    return _decode_dataclass(cls, kvs, infer_missing)
  File "/Users/daniel.letscher/code/playground/venv39/lib/python3.9/site-packages/dataclasses_json/core.py", line 201, in _decode_dataclass
    init_kwargs[field.name] = _decode_generic(field_type,
  File "/Users/daniel.letscher/code/playground/venv39/lib/python3.9/site-packages/dataclasses_json/core.py", line 258, in _decode_generic
    xs = _decode_items(type_.__args__[0], value, infer_missing)
  File "/Users/daniel.letscher/.pyenv/versions/3.9.7/lib/python3.9/typing.py", line 706, in __getattr__
    raise AttributeError(attr)
AttributeError: __args__
```

Output when running dataclasses-json 0.5.6 and Python 3.8:
```
{"children": ["hi"]}
```


Discussion:
UberMeatShield: This seems like a pretty solid thing to get into the project so it is working in 3.10 as well :)
lidatong: Yes I'm sorry I've been MIA... I'll take a look soon!

Patch:
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index c7a459b..2cbe068 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -15,7 +15,7 @@ jobs:
       max-parallel: 5
       matrix:
         # https://github.com/actions/python-versions/blob/main/versions-manifest.json
-        python-version: [3.6, 3.7, 3.8, 3.9, 3.10.0-rc.2]
+        python-version: [3.6, 3.7, 3.8, 3.9, 3.10]
 
     steps:
     - uses: actions/checkout@v1
diff --git a/dataclasses_json/core.py b/dataclasses_json/core.py
index cc2f8b1..529e153 100644
--- a/dataclasses_json/core.py
+++ b/dataclasses_json/core.py
@@ -11,7 +11,8 @@ from dataclasses import (MISSING,
 from datetime import datetime, timezone
 from decimal import Decimal
 from enum import Enum
-from typing import Any, Collection, Mapping, Union, get_type_hints, Tuple
+from typing import (Any, Collection, Mapping, Union, get_type_hints,
+                    Tuple, TypeVar)
 from uuid import UUID
 
 from typing_inspect import is_union_type  # type: ignore
@@ -21,6 +22,9 @@ from dataclasses_json.utils import (_get_type_cons, _get_type_origin,
                                     _handle_undefined_parameters_safe,
                                     _is_collection, _is_mapping, _is_new_type,
                                     _is_optional, _isinstance_safe,
+                                    _get_type_arg_param,
+                                    _get_type_args,
+                                    _NO_ARGS,
                                     _issubclass_safe)
 
 Json = Union[dict, list, str, int, float, bool, None]
@@ -232,6 +236,8 @@ def _support_extended_types(field_type, field_value):
 
 
 def _is_supported_generic(type_):
+    if type_ is _NO_ARGS:
+        return False
     not_str = not _issubclass_safe(type_, str)
     is_enum = _issubclass_safe(type_, Enum)
     return (not_str and _is_collection(type_)) or _is_optional(
@@ -248,14 +254,15 @@ def _decode_generic(type_, value, infer_missing):
     # FIXME this is a hack to fix a deeper underlying issue. A refactor is due.
     elif _is_collection(type_):
         if _is_mapping(type_):
-            k_type, v_type = getattr(type_, "__args__", (Any, Any))
+            k_type, v_type = _get_type_args(type_, (Any, Any))
             # a mapping type has `.keys()` and `.values()`
             # (see collections.abc)
             ks = _decode_dict_keys(k_type, value.keys(), infer_missing)
             vs = _decode_items(v_type, value.values(), infer_missing)
             xs = zip(ks, vs)
         else:
-            xs = _decode_items(type_.__args__[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
@@ -264,11 +271,12 @@ def _decode_generic(type_, value, infer_missing):
         except (TypeError, AttributeError):
             res = type_(xs)
     else:  # Optional or Union
-        if not hasattr(type_, "__args__"):
+        _args = _get_type_args(type_)
+        if _args is _NO_ARGS:
             # Any, just accept
             res = value
-        elif _is_optional(type_) and len(type_.__args__) == 2:  # Optional
-            type_arg = type_.__args__[0]
+        elif _is_optional(type_) and len(_args) == 2:  # Optional
+            type_arg = _get_type_arg_param(type_, 0)
             if is_dataclass(type_arg) or is_dataclass(value):
                 res = _decode_dataclass(type_arg, value, infer_missing)
             elif _is_supported_generic(type_arg):
@@ -288,7 +296,11 @@ def _decode_dict_keys(key_type, xs, infer_missing):
     decode_function = key_type
     # handle NoneType keys... it's weird to type a Dict as NoneType keys
     # but it's valid...
-    if key_type is None or key_type == Any:
+    # Issue #341 and PR #346:
+    #   This is a special case for Python 3.7 and Python 3.8.
+    #   By some reason, "unbound" dicts are counted
+    #   as having key type parameter to be TypeVar('KT')
+    if key_type is None or key_type == Any or isinstance(key_type, TypeVar):
         decode_function = key_type = (lambda x: x)
     # handle a nested python dict that has tuples for keys. E.g. for
     # Dict[Tuple[int], int], key_type will be typing.Tuple[int], but
diff --git a/dataclasses_json/utils.py b/dataclasses_json/utils.py
index 7221631..3005f5c 100644
--- a/dataclasses_json/utils.py
+++ b/dataclasses_json/utils.py
@@ -1,7 +1,8 @@
 import inspect
 import sys
 from datetime import datetime, timezone
-from typing import Collection, Mapping, Optional, TypeVar, Any
+from typing import (Collection, Mapping, Optional, TypeVar, Any, Type, Tuple,
+                    Union)
 
 
 def _get_type_cons(type_):
@@ -26,21 +27,28 @@ def _get_type_cons(type_):
     return cons
 
 
+_NO_TYPE_ORIGIN = object()
+
+
 def _get_type_origin(type_):
     """Some spaghetti logic to accommodate differences between 3.6 and 3.7 in
     the typing api"""
     try:
         origin = type_.__origin__
     except AttributeError:
-        if sys.version_info.minor == 6:
-            try:
-                origin = type_.__extra__
-            except AttributeError:
-                origin = type_
-            else:
-                origin = type_ if origin is None else origin
-        else:
+        # Issue #341 and PR #346:
+        # For some cases, the type_.__origin__ exists but is set to None
+        origin = _NO_TYPE_ORIGIN
+
+    if sys.version_info.minor == 6:
+        try:
+            origin = type_.__extra__
+        except AttributeError:
             origin = type_
+        else:
+            origin = type_ if origin in (None, _NO_TYPE_ORIGIN) else origin
+    elif origin is _NO_TYPE_ORIGIN:
+        origin = type_
     return origin
 
 
@@ -58,6 +66,42 @@ def _hasargs(type_, *args):
         return res
 
 
+class _NoArgs(object):
+    def __bool__(self):
+        return False
+
+    def __len__(self):
+        return 0
+
+    def __iter__(self):
+        return self
+
+    def __next__(self):
+        raise StopIteration
+
+
+_NO_ARGS = _NoArgs()
+
+
+def _get_type_args(tp: Type, default: Tuple[Type, ...] = _NO_ARGS) -> \
+        Union[Tuple[Type, ...], _NoArgs]:
+    if hasattr(tp, '__args__'):
+        if tp.__args__ is not None:
+            return tp.__args__
+    return default
+
+
+def _get_type_arg_param(tp: Type, index: int) -> Union[Type, _NoArgs]:
+    _args = _get_type_args(tp)
+    if _args is not _NO_ARGS:
+        try:
+            return _args[index]
+        except (TypeError, IndexError, NotImplementedError):
+            pass
+
+    return _NO_ARGS
+
+
 def _isinstance_safe(o, t):
     try:
         result = isinstance(o, t)


Test patch:
diff --git a/tests/entities.py b/tests/entities.py
index e9e5494..956bc84 100644
--- a/tests/entities.py
+++ b/tests/entities.py
@@ -1,4 +1,6 @@
+from collections import deque
 from dataclasses import dataclass, field
+from datetime import datetime
 from decimal import Decimal
 from typing import (Collection,
                     Deque,
@@ -16,11 +18,8 @@ from uuid import UUID
 
 from marshmallow import fields
 
-import dataclasses_json
-from datetime import datetime
-
-from dataclasses_json.cfg import config
 from dataclasses_json import (DataClassJsonMixin, LetterCase, dataclass_json)
+from dataclasses_json.cfg import config
 
 A = TypeVar('A')
 UUIDWrapper = NewType('UUIDWrapper', UUID)
@@ -47,6 +46,16 @@ class DataClassWithList(DataClassJsonMixin):
     xs: List[int]
 
 
+@dataclass(frozen=True)
+class DataClassWithListBuiltin(DataClassJsonMixin):
+    xs: list
+
+
+@dataclass(frozen=True)
+class DataClassWithListUnbound(DataClassJsonMixin):
+    xs: List
+
+
 @dataclass(frozen=True)
 class DataClassWithListDefaultFactory(DataClassJsonMixin):
     xs: List[int] = field(default_factory=list)
@@ -62,6 +71,16 @@ class DataClassWithDict(DataClassJsonMixin):
     kvs: Dict[str, str]
 
 
+@dataclass(frozen=True)
+class DataClassWithDictBuiltin(DataClassJsonMixin):
+    kvs: Dict
+
+
+@dataclass(frozen=True)
+class DataClassWithDictUnbound(DataClassJsonMixin):
+    kvs: Dict
+
+
 @dataclass(frozen=True)
 class DataClassWithDictInt(DataClassJsonMixin):
     kvs: Dict[int, str]
@@ -77,26 +96,71 @@ class DataClassWithSet(DataClassJsonMixin):
     xs: Set[int]
 
 
+@dataclass(frozen=True)
+class DataClassWithSetBuiltin(DataClassJsonMixin):
+    xs: set
+
+
+@dataclass(frozen=True)
+class DataClassWithSetUnbound(DataClassJsonMixin):
+    xs: Set
+
+
 @dataclass(frozen=True)
 class DataClassWithTuple(DataClassJsonMixin):
     xs: Tuple[int]
 
 
+@dataclass(frozen=True)
+class DataClassWithTupleBuiltin(DataClassJsonMixin):
+    xs: tuple
+
+
+@dataclass(frozen=True)
+class DataClassWithTupleUnbound(DataClassJsonMixin):
+    xs: Tuple
+
+
 @dataclass(frozen=True)
 class DataClassWithFrozenSet(DataClassJsonMixin):
     xs: FrozenSet[int]
 
 
+@dataclass(frozen=True)
+class DataClassWithFrozenSetBuiltin(DataClassJsonMixin):
+    xs: frozenset
+
+
+@dataclass(frozen=True)
+class DataClassWithFrozenSetUnbound(DataClassJsonMixin):
+    xs: FrozenSet
+
+
 @dataclass(frozen=True)
 class DataClassWithDeque(DataClassJsonMixin):
     xs: Deque[int]
 
 
+@dataclass(frozen=True)
+class DataClassWithDequeCollections(DataClassJsonMixin):
+    xs: deque
+
+
+@dataclass(frozen=True)
+class DataClassWithDequeUnbound(DataClassJsonMixin):
+    xs: Deque[int]
+
+
 @dataclass(frozen=True)
 class DataClassWithOptional(DataClassJsonMixin):
     x: Optional[int]
 
 
+@dataclass(frozen=True)
+class DataClassWithOptionalUnbound(DataClassJsonMixin):
+    x: Optional
+
+
 @dataclass
 class DataClassWithOptionalStr(DataClassJsonMixin):
     x: Optional[str] = None
diff --git a/tests/test_collections.py b/tests/test_collections.py
index 767affc..e8c5433 100644
--- a/tests/test_collections.py
+++ b/tests/test_collections.py
@@ -4,10 +4,21 @@ from tests.entities import (DataClassIntImmutableDefault,
                             DataClassMutableDefaultDict,
                             DataClassMutableDefaultList, DataClassWithDeque,
                             DataClassWithDict, DataClassWithDictInt,
+                            DataClassWithDictUnbound,
                             DataClassWithFrozenSet, DataClassWithList,
-                            DataClassWithListStr, DataClassWithMyCollection,
+                            DataClassWithListUnbound, DataClassWithListStr, DataClassWithMyCollection,
                             DataClassWithOptional, DataClassWithOptionalStr,
-                            DataClassWithSet, DataClassWithTuple,
+                            DataClassWithSet, DataClassWithSetUnbound,
+                            DataClassWithOptionalUnbound,
+                            DataClassWithSetBuiltin,
+                            DataClassWithDictBuiltin,
+                            DataClassWithListBuiltin,
+                            DataClassWithTupleBuiltin,
+                            DataClassWithFrozenSetBuiltin,
+                            DataClassWithDequeUnbound,
+                            DataClassWithFrozenSetUnbound,
+                            DataClassWithDequeCollections,
+                            DataClassWithTuple, DataClassWithTupleUnbound,
                             DataClassWithUnionIntNone, MyCollection)
 
 
@@ -15,31 +26,70 @@ class TestEncoder:
     def test_list(self):
         assert DataClassWithList([1]).to_json() == '{"xs": [1]}'
 
+    def test_list_unbound(self):
+        assert DataClassWithListUnbound([1]).to_json() == '{"xs": [1]}'
+
+    def test_list_builtin(self):
+        assert DataClassWithListBuiltin([1]).to_json() == '{"xs": [1]}'
+
     def test_list_str(self):
         assert DataClassWithListStr(['1']).to_json() == '{"xs": ["1"]}'
 
     def test_dict(self):
         assert DataClassWithDict({'1': 'a'}).to_json() == '{"kvs": {"1": "a"}}'
 
+    def test_dict_unbound(self):
+        assert DataClassWithDictUnbound({'1': 'a'}).to_json() == '{"kvs": {"1": "a"}}'
+
+    def test_dict_builtin(self):
+        assert DataClassWithDictBuiltin({'1': 'a'}).to_json() == '{"kvs": {"1": "a"}}'
+
     def test_dict_int(self):
         assert DataClassWithDictInt({1: 'a'}).to_json() == '{"kvs": {"1": "a"}}'
 
     def test_set(self):
         assert DataClassWithSet({1}).to_json() == '{"xs": [1]}'
 
+    def test_set_unbound(self):
+        assert DataClassWithSetUnbound({1}).to_json() == '{"xs": [1]}'
+
+    def test_set_builtin(self):
+        assert DataClassWithSetBuiltin({1}).to_json() == '{"xs": [1]}'
+
     def test_tuple(self):
         assert DataClassWithTuple((1,)).to_json() == '{"xs": [1]}'
 
+    def test_tuple_unbound(self):
+        assert DataClassWithTupleUnbound((1,)).to_json() == '{"xs": [1]}'
+
+    def test_tuple_builtin(self):
+        assert DataClassWithTupleBuiltin((1,)).to_json() == '{"xs": [1]}'
+
     def test_frozenset(self):
         assert DataClassWithFrozenSet(frozenset([1])).to_json() == '{"xs": [1]}'
 
+    def test_frozenset_unbound(self):
+        assert DataClassWithFrozenSetUnbound(frozenset([1])).to_json() == '{"xs": [1]}'
+
+    def test_frozenset_builtin(self):
+        assert DataClassWithFrozenSetBuiltin(frozenset([1])).to_json() == '{"xs": [1]}'
+
     def test_deque(self):
         assert DataClassWithDeque(deque([1])).to_json() == '{"xs": [1]}'
 
+    def test_deque_unbound(self):
+        assert DataClassWithDequeUnbound(deque([1])).to_json() == '{"xs": [1]}'
+
+    def test_deque_builtin(self):
+        assert DataClassWithDequeCollections(deque([1])).to_json() == '{"xs": [1]}'
+
     def test_optional(self):
         assert DataClassWithOptional(1).to_json() == '{"x": 1}'
         assert DataClassWithOptional(None).to_json() == '{"x": null}'
 
+    def test_optional_unbound(self):
+        assert DataClassWithOptionalUnbound(1).to_json() == '{"x": 1}'
+
     def test_optional_str(self):
         assert DataClassWithOptionalStr('1').to_json() == '{"x": "1"}'
         assert DataClassWithOptionalStr(None).to_json() == '{"x": null}'
@@ -68,6 +118,14 @@ class TestDecoder:
         assert (DataClassWithList.from_json('{"xs": [1]}') ==
                 DataClassWithList([1]))
 
+    def test_list_unbound(self):
+        assert (DataClassWithListUnbound.from_json('{"xs": [1]}') ==
+                DataClassWithListUnbound([1]))
+
+    def test_list_builtin(self):
+        assert (DataClassWithListBuiltin.from_json('{"xs": [1]}') ==
+                DataClassWithListBuiltin([1]))
+
     def test_list_str(self):
         assert (DataClassWithListStr.from_json('{"xs": ["1"]}') ==
                 DataClassWithListStr(["1"]))
@@ -76,6 +134,14 @@ class TestDecoder:
         assert (DataClassWithDict.from_json('{"kvs": {"1": "a"}}') ==
                 DataClassWithDict({'1': 'a'}))
 
+    def test_dict_unbound(self):
+        assert (DataClassWithDictUnbound.from_json('{"kvs": {"1": "a"}}') ==
+                DataClassWithDictUnbound({'1': 'a'}))
+
+    def test_dict_builtin(self):
+        assert (DataClassWithDictBuiltin.from_json('{"kvs": {"1": "a"}}') ==
+                DataClassWithDictBuiltin({'1': 'a'}))
+
     def test_dict_int(self):
         assert (DataClassWithDictInt.from_json('{"kvs": {"1": "a"}}') ==
                 DataClassWithDictInt({1: 'a'}))
@@ -84,24 +150,62 @@ class TestDecoder:
         assert (DataClassWithSet.from_json('{"xs": [1]}') ==
                 DataClassWithSet({1}))
 
+    def test_set_unbound(self):
+        assert (DataClassWithSetUnbound.from_json('{"xs": [1]}') ==
+                DataClassWithSetUnbound({1}))
+
+    def test_set_builtin(self):
+        assert (DataClassWithSetBuiltin.from_json('{"xs": [1]}') ==
+                DataClassWithSetBuiltin({1}))
+
     def test_tuple(self):
         assert (DataClassWithTuple.from_json('{"xs": [1]}') ==
                 DataClassWithTuple((1,)))
 
+    def test_tuple_unbound(self):
+        assert (DataClassWithTupleUnbound.from_json('{"xs": [1]}') ==
+                DataClassWithTupleUnbound((1,)))
+
+    def test_tuple_builtin(self):
+        assert (DataClassWithTupleBuiltin.from_json('{"xs": [1]}') ==
+                DataClassWithTupleBuiltin((1,)))
+
     def test_frozenset(self):
         assert (DataClassWithFrozenSet.from_json('{"xs": [1]}') ==
                 DataClassWithFrozenSet(frozenset([1])))
 
+    def test_frozenset_unbound(self):
+        assert (DataClassWithFrozenSetUnbound.from_json('{"xs": [1]}') ==
+                DataClassWithFrozenSetUnbound(frozenset([1])))
+
+    def test_frozenset_builtin(self):
+        assert (DataClassWithFrozenSetBuiltin.from_json('{"xs": [1]}') ==
+                DataClassWithFrozenSetBuiltin(frozenset([1])))
+
     def test_deque(self):
         assert (DataClassWithDeque.from_json('{"xs": [1]}') ==
                 DataClassWithDeque(deque([1])))
 
+    def test_deque_unbound(self):
+        assert (DataClassWithDequeUnbound.from_json('{"xs": [1]}') ==
+                DataClassWithDequeUnbound(deque([1])))
+
+    def test_deque_collections(self):
+        assert (DataClassWithDequeCollections.from_json('{"xs": [1]}') ==
+                DataClassWithDequeCollections(deque([1])))
+
     def test_optional(self):
         assert (DataClassWithOptional.from_json('{"x": 1}') ==
                 DataClassWithOptional(1))
         assert (DataClassWithOptional.from_json('{"x": null}') ==
                 DataClassWithOptional(None))
 
+    def test_optional_unbound(self):
+        assert (DataClassWithOptionalUnbound.from_json('{"x": 1}') ==
+                DataClassWithOptionalUnbound(1))
+        assert (DataClassWithOptionalUnbound.from_json('{"x": null}') ==
+                DataClassWithOptionalUnbound(None))
+
     def test_optional_str(self):
         assert (DataClassWithOptionalStr.from_json('{"x": "1"}') ==
                 DataClassWithOptionalStr("1"))
diff --git a/tests/test_unsupported_generics.py b/tests/test_unsupported_generics.py
index b7a4fec..9b7b9a6 100644
--- a/tests/test_unsupported_generics.py
+++ b/tests/test_unsupported_generics.py
@@ -1,5 +1,6 @@
 from dataclasses import dataclass
 from typing import TypeVar, Generic
+
 from dataclasses_json import DataClassJsonMixin
 
 
@@ -9,11 +10,11 @@ class ContainerCls(Generic[T]):
     required: bool
 
 @dataclass
-class DataClassWithUnsupscriptedGeneric(DataClassJsonMixin):
+class DataClassWithUnsubscriptedGeneric(DataClassJsonMixin):
     a: ContainerCls
 
 @dataclass
-class DataClassWithSupscriptedGeneric(DataClassJsonMixin):
+class DataClassWithSubscriptedGeneric(DataClassJsonMixin):
     a: ContainerCls[int]
 
 @dataclass
@@ -22,22 +23,22 @@ class DataClassWithParameterizedGeneric(DataClassJsonMixin, Generic[T]):
 
 
 class TestUnsupportedGenerics:
-    def test_unsupscripted(self):
+    def test_unsubscripted(self):
         j = '{ "a": { "value": "test", "required": true } }'
-        dc = DataClassWithUnsupscriptedGeneric.from_json(j)
+        dc = DataClassWithUnsubscriptedGeneric.from_json(j)
         assert dc.a == dict(value='test', required=True)
     
-    def test_supscripted(self):
+    def test_subscripted(self):
         j = '{ "a": { "value": 5, "required": false } }'
-        dc = DataClassWithSupscriptedGeneric.from_json(j)
+        dc = DataClassWithSubscriptedGeneric.from_json(j)
         assert dc.a == dict(value=5, required=False)
     
-    def test_parameterized_unsupscripted(self):
+    def test_parameterized_unsubscripted(self):
         j = '{ "a": { "value": "test", "required": true } }'
         dc = DataClassWithParameterizedGeneric.from_json(j)
         assert dc.a == dict(value='test', required=True)
     
-    def test_parameterized_supscripted(self):
+    def test_parameterized_subscripted(self):
         j = '{ "a": { "value": "test", "required": true } }'
         dc = DataClassWithParameterizedGeneric[str].from_json(j)
         assert dc.a == dict(value='test', required=True)