Repository: s-knibbs/dataclasses-jsonschema (python)

Issue:
using Dict type in Python3.9 results AttributeError
The following code doesn't work in Python3.9:
```
from dataclasses import dataclass
from typing import Dict

from dataclasses_jsonschema import JsonSchemaMixin


@dataclass
class User(JsonSchemaMixin):
    stuffs: Dict


User.from_dict({'stuffs': {'a': 'b'}})
```
```
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "<venvs>/lib/python3.9/site-packages/dataclasses_jsonschema/__init__.py", line 508, in from_dict
    cls._validate(data, validate_enums)
  File "<venvs>/lib/python3.9/site-packages/dataclasses_jsonschema/__init__.py", line 489, in _validate
    validate_func(data, cls.json_schema(validate_enums=validate_enums))
  File "<venvs>/lib/python3.9/site-packages/dataclasses_jsonschema/__init__.py", line 782, in json_schema
    properties[f.mapped_name], is_required = cls._get_field_schema(f.field, schema_options)
  File "<venvs>/lib/python3.9/site-packages/dataclasses_jsonschema/__init__.py", line 671, in _get_field_schema
    if field_type.__args__[1] is not Any:
  File "/usr/lib/python3.9/typing.py", line 694, in __getattr__
    raise AttributeError(attr)
AttributeError: __args__
```
Something must have changed around dataclasses because a Field's `.type` property no longer has `.__args__` for Dict, resulting the AttributeError above. This piece of code works well in Python3.8.
Python: 3.9.4
dataclasses-jsonschema: 2.14.1
It looks related: https://bugs.python.org/issue40397 (the `field_type` is no longer _GenericAlias (3.8) but _SpecialGenericAlias which no longer has the `.__args__`)

Pull request description:
Fix handling of untyped container types in Python 3.9
Fix #162 

Patch:
diff --git a/dataclasses_jsonschema/__init__.py b/dataclasses_jsonschema/__init__.py
index 25a1a37..0b5c71e 100644
--- a/dataclasses_jsonschema/__init__.py
+++ b/dataclasses_jsonschema/__init__.py
@@ -63,6 +63,14 @@ class UnknownEnumValueError(ValueError):
     pass
 
 
+@functools.lru_cache(maxsize=None)
+def get_field_args(field_type: Any) -> tuple:
+    field_args = (Any, Any)
+    if hasattr(field_type, '__args__') and field_type.__args__ is not None:
+        field_args = field_type.__args__
+    return field_args
+
+
 def is_enum(field_type: Any):
     return issubclass_safe(field_type, Enum)
 
@@ -317,12 +325,15 @@ class JsonSchemaMixin:
                 return encoded
             elif field_type_name in MAPPING_TYPES:
                 def encoder(ft, val, o):
+                    field_args = get_field_args(ft)
                     return {
-                        cls._encode_field(ft.__args__[0], k, o): cls._encode_field(ft.__args__[1], v, o)
+                        cls._encode_field(field_args[0], k, o): cls._encode_field(field_args[1], v, o)
                         for k, v in val.items()
                     }
             elif field_type_name in SEQUENCE_TYPES or (field_type_name in TUPLE_TYPES and ... in field_type.__args__):
-                def encoder(ft, val, o): return [cls._encode_field(ft.__args__[0], v, o) for v in val]
+                def encoder(ft, val, o):
+                    field_args = get_field_args(ft)
+                    return [cls._encode_field(field_args[0], v, o) for v in val]
             elif field_type_name in TUPLE_TYPES:
                 def encoder(ft, val, o):
                     return [cls._encode_field(ft.__args__[idx], v, o) for idx, v in enumerate(val)]
@@ -440,15 +451,17 @@ class JsonSchemaMixin:
                     return decoded
             elif field_type_name in MAPPING_TYPES:
                 def decoder(f, ft, val):
+                    field_args = get_field_args(ft)
                     return {
-                        cls._decode_field(f, ft.__args__[0], k): cls._decode_field(f, ft.__args__[1], v)
+                        cls._decode_field(f, field_args[0], k): cls._decode_field(f, field_args[1], v)
                         for k, v in val.items()
                     }
             elif field_type_name in SEQUENCE_TYPES or (field_type_name in TUPLE_TYPES and ... in field_type.__args__):
                 seq_type = tuple if field_type_name in TUPLE_TYPES else SEQUENCE_TYPES[field_type_name]
 
                 def decoder(f, ft, val):
-                    return seq_type(cls._decode_field(f, ft.__args__[0], v) for v in val)
+                    field_args = get_field_args(ft)
+                    return seq_type(cls._decode_field(f, field_args[0], v) for v in val)
             elif field_type_name in TUPLE_TYPES:
                 def decoder(f, ft, val):
                     return tuple(cls._decode_field(f, ft.__args__[idx], v) for idx, v in enumerate(val))
@@ -620,6 +633,7 @@ class JsonSchemaMixin:
             field_meta = FieldMeta(schema_type=schema_options.schema_type)
 
         field_type_name = cls._get_field_type_name(field_type)
+        field_args = get_field_args(field_type)  # type: ignore
         if cls._is_json_schema_subclass(field_type):
             field_schema = schema_reference(schema_options.schema_type, field_type_name)
         else:
@@ -637,7 +651,7 @@ class JsonSchemaMixin:
                 field_schema, required = cls._get_field_schema(unwrap_final(field_type), schema_options)
             elif is_literal(field_type):
                 field_schema = {
-                    'enum': list(field_type.__args__ if sys.version_info[:2] >= (3, 7) else field_type.__values__)
+                    'enum': list(field_args if sys.version_info[:2] >= (3, 7) else field_type.__values__)
                 }
             elif is_enum(field_type):
                 member_types = set()
@@ -663,27 +677,24 @@ class JsonSchemaMixin:
             elif field_type_name == 'Union':
                 if schema_options.schema_type == SchemaType.SWAGGER_V2:
                     raise TypeError('Type unions unsupported in Swagger 2.0')
-                field_schema = {
-                    'oneOf': [cls._get_field_schema(variant, schema_options)[0] for variant in field_type.__args__]
-                }
+                field_schema = {'oneOf': [cls._get_field_schema(variant, schema_options)[0] for variant in field_args]}
+                field_schema['oneOf'].sort(key=lambda item: item.get('type', ''))
             elif field_type_name in MAPPING_TYPES:
                 field_schema = {'type': 'object'}
-                if field_type.__args__[1] is not Any:
-                    field_schema['additionalProperties'] = cls._get_field_schema(
-                        field_type.__args__[1], schema_options
-                    )[0]
-            elif field_type_name in SEQUENCE_TYPES or (field_type_name in TUPLE_TYPES and ... in field_type.__args__):
+                if field_args[1] is not Any:
+                    field_schema['additionalProperties'] = cls._get_field_schema(field_args[1], schema_options)[0]
+            elif field_type_name in SEQUENCE_TYPES or (field_type_name in TUPLE_TYPES and ... in field_args):
                 field_schema = {'type': 'array'}
-                if field_type.__args__[0] is not Any:
-                    field_schema['items'] = cls._get_field_schema(field_type.__args__[0], schema_options)[0]
+                if field_args[0] is not Any:
+                    field_schema['items'] = cls._get_field_schema(field_args[0], schema_options)[0]
                 if field_type_name in ("Set", "set"):
                     field_schema['uniqueItems'] = True
             elif field_type_name in TUPLE_TYPES:
-                tuple_len = len(field_type.__args__)
+                tuple_len = len(field_args)
                 # TODO: How do we handle Optional type within lists / tuples
                 field_schema = {
                     'type': 'array', 'minItems': tuple_len, 'maxItems': tuple_len,
-                    'items': [cls._get_field_schema(type_arg, schema_options)[0] for type_arg in field_type.__args__]
+                    'items': [cls._get_field_schema(type_arg, schema_options)[0] for type_arg in field_args]
                 }
             elif field_type in JSON_ENCODABLE_TYPES:
                 field_schema.update(JSON_ENCODABLE_TYPES[field_type])
@@ -702,12 +713,14 @@ class JsonSchemaMixin:
     def _get_field_definitions(cls, field_type: Any, definitions: JsonDict,
                                schema_options: SchemaOptions):
         field_type_name = cls._get_field_type_name(field_type)
+        field_args = get_field_args(field_type)
+
         if is_optional(field_type):
             cls._get_field_definitions(unwrap_optional(field_type), definitions, schema_options)
         elif field_type_name in SEQUENCE_TYPES:
-            cls._get_field_definitions(field_type.__args__[0], definitions, schema_options)
+            cls._get_field_definitions(field_args[0], definitions, schema_options)
         elif field_type_name in MAPPING_TYPES:
-            cls._get_field_definitions(field_type.__args__[1], definitions, schema_options)
+            cls._get_field_definitions(field_args[1], definitions, schema_options)
         elif field_type_name == 'Union':
             for variant in field_type.__args__:
                 cls._get_field_definitions(variant, definitions, schema_options)
diff --git a/dataclasses_jsonschema/type_defs.py b/dataclasses_jsonschema/type_defs.py
index 156e62b..2bf81dc 100644
--- a/dataclasses_jsonschema/type_defs.py
+++ b/dataclasses_jsonschema/type_defs.py
@@ -1,11 +1,11 @@
 from enum import Enum
-from typing import Union, Dict, Any, List, TypeVar, Literal
+from typing import Union, Dict, Any, List, TypeVar
 
 try:
     # Supported in future python versions
-    from typing import TypedDict  # type: ignore
+    from typing import TypedDict, Literal  # type: ignore
 except ImportError:
-    from typing_extensions import TypedDict
+    from typing_extensions import TypedDict, Literal  # type: ignore
 
 
 JsonEncodable = Union[int, float, str, bool]
diff --git a/setup.py b/setup.py
index 25ad19b..d74e42b 100644
--- a/setup.py
+++ b/setup.py
@@ -27,7 +27,16 @@ setup(
         'apispec': ["apispec"]
     },
     setup_requires=['pytest-runner', 'setuptools_scm'],
-    tests_require=['pytest', 'flake8', 'mypy', 'apispec', 'apispec_webframeworks', 'flask', 'pytest-ordering'],
+    tests_require=[
+        'pytest',
+        'flake8',
+        'mypy',
+        'apispec',
+        'apispec_webframeworks',
+        'flask',
+        'pytest-ordering',
+        'types-python-dateutil',
+    ],
     license='MIT',
     use_scm_version=True,
     classifiers=[
diff --git a/tox.ini b/tox.ini
index 947b8d9..e67048e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -22,6 +22,7 @@ deps =
     apispec[yaml]
     apispec_webframeworks
     flask
+    types-python-dateutil
     fastvalidation: fastjsonschema
 
 [testenv:py36]
@@ -30,9 +31,15 @@ commands =
     pytest tests/test_core.py tests/test_apispec_plugin.py
     flake8 dataclasses_jsonschema
     mypy dataclasses_jsonschema
+deps =
+    types-dataclasses
+    {[testenv]deps}
 
 [testenv:py36-fastvalidation]
 commands =
     pytest tests/test_core.py tests/test_apispec_plugin.py
     flake8 dataclasses_jsonschema
     mypy dataclasses_jsonschema
+deps =
+    types-dataclasses
+    {[testenv]deps}


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index 3e0d16e..83f1767 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -5,7 +5,7 @@ from enum import Enum
 
 from dataclasses import dataclass, field
 from ipaddress import IPv4Address, IPv6Address
-from typing import List, NewType, Optional, Union, Set, Any, cast
+from typing import List, NewType, Optional, Union, Set, Any, cast, Dict
 from typing_extensions import Final, Literal
 from uuid import UUID
 
@@ -133,8 +133,8 @@ BAR_SCHEMA = {
     'properties': {
         'a': {
             'oneOf': [
+                {'$ref': '#/definitions/Point'},
                 {'type': 'string', 'enum': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']},
-                {'$ref': '#/definitions/Point'}
             ]
         }
     },
@@ -1002,3 +1002,15 @@ def test_custom_format():
 
     post = Post.from_dict({"content": "Lorem ipsum dolor ...", "slug": "some-post"})
     assert post.slug == Slug("some-post")
+
+
+def test_untyped_dict():
+    """Regression test for https://github.com/s-knibbs/dataclasses-jsonschema/issues/162"""
+
+    @dataclass
+    class Example(JsonSchemaMixin):
+        data: Dict
+
+    example = Example.from_dict({'data': {'a': 'foo'}})
+    assert example.data == {'a': 'foo'}
+    assert example.to_dict() == {'data': {'a': 'foo'}}
diff --git a/tests/test_peps.py b/tests/test_peps.py
index b5e7e52..83d092d 100644
--- a/tests/test_peps.py
+++ b/tests/test_peps.py
@@ -16,10 +16,10 @@ def test_pep_604_types():
 
     schema = Post.json_schema()
     assert schema['properties']['tags'] == {
-        'oneOf': [{'type': 'string'}, {'type': 'array', 'items': {'type': 'string'}}]
+        'oneOf': [{'type': 'array', 'items': {'type': 'string'}}, {'type': 'string'}]
     }
     assert schema['properties']['metadata'] == {
-        'type': 'array', 'items': {'oneOf': [{'type': 'string'}, {'type': 'integer'}]}
+        'type': 'array', 'items': {'oneOf': [{'type': 'integer'}, {'type': 'string'}]}
     }
     assert schema['required'] == ['body', 'metadata']