Repository: s-knibbs/dataclasses-jsonschema
Pull request #165 (2021-06-11 13:36:16)

Make type hint of from_json() compatible with json.loads()
The change makes type hint for `data` parameter of `from_json()` compatible with the corresponding one in `json.loads()`.

It makes me trouble during interactions with different libraries, but I hope it can be useful for others.

Of course, it can be solved as `Cls.from_dict(json.loads(x))`, but I appreciate the `from_json` interface.


Patch:
diff --git a/dataclasses_jsonschema/__init__.py b/dataclasses_jsonschema/__init__.py
index 25a1a37..b6eb68d 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)]
@@ -414,12 +425,15 @@ class JsonSchemaMixin:
         try:
             decoder = cls.__decode_cache[field_type]  # type: ignore
         except (KeyError, TypeError):
-            # Note: Only literal types composed of primitive values are currently supported
-            if type(value) in JSON_ENCODABLE_TYPES and (field_type in JSON_ENCODABLE_TYPES or is_literal(field_type)):
-                return value
             # Replace any nested dictionaries with their targets
             field_type_name = cls._get_field_type_name(field_type)
-            if cls._is_json_schema_subclass(field_type):
+            # Note: Only literal types composed of primitive values are currently supported
+            if type(value) in JSON_ENCODABLE_TYPES and (field_type in JSON_ENCODABLE_TYPES or is_literal(field_type)):
+                if is_literal(field_type):
+                    def decoder(_, __, val): return val
+                else:
+                    def decoder(_, ft, val): return ft(val)
+            elif cls._is_json_schema_subclass(field_type):
                 def decoder(_, ft, val): return ft.from_dict(val, validate=False)
             elif is_nullable(field_type):
                 def decoder(f, ft, val): return cls._decode_field(f, unwrap_nullable(ft), val)
@@ -440,15 +454,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 +636,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 +654,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 +680,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 +716,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)
@@ -854,7 +870,7 @@ class JsonSchemaMixin:
             return str(field_type) if match is None else match.group(1)
 
     @classmethod
-    def from_json(cls: Type[T], data: str, validate: bool = True, **json_kwargs) -> T:
+    def from_json(cls: Type[T], data: Union[str, bytes], validate: bool = True, **json_kwargs) -> T:
         return cls.from_dict(json.loads(data, **json_kwargs), validate)
 
     def to_json(self, omit_none: bool = True, validate: bool = False, **json_kwargs) -> str:
diff --git a/dataclasses_jsonschema/type_defs.py b/dataclasses_jsonschema/type_defs.py
index 068c917..2bf81dc 100644
--- a/dataclasses_jsonschema/type_defs.py
+++ b/dataclasses_jsonschema/type_defs.py
@@ -3,9 +3,9 @@ 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]
@@ -47,7 +47,8 @@ class _NULL_TYPE:
     """Sentinel value to represent null json values for nullable fields, to distinguish them from `None`
     for omitted fields.
     """
-    pass
+    def __bool__(self) -> Literal[False]:
+        return False
 
 
 NULL = _NULL_TYPE()
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/nullable.py b/tests/nullable.py
new file mode 100644
index 0000000..13cedfc
--- /dev/null
+++ b/tests/nullable.py
@@ -0,0 +1,20 @@
+"""
+This script is used to test if mypy understands that the Nullable type is always False-y.
+"""
+
+from __future__ import annotations
+from dataclasses import dataclass
+
+from dataclasses_jsonschema.type_defs import Nullable
+
+
+@dataclass
+class Example:
+    name: Nullable[str | None] = None
+
+
+example = Example("sienna")
+
+assert example.name  # If this assert passes we know `name` is a string (because it isn't None or Nullable)
+
+name_upper = example.name.upper()
diff --git a/tests/test_core.py b/tests/test_core.py
index 3e0d16e..41daa72 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,29 @@ 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'}}
+
+
+def test_integer_dict_keys():
+    """Tests that types for dict keys are preserved. Technically, JSON only supports strings as dictionary keys,
+    but these can be converted back to the original type using the type annotations
+    """
+
+    @dataclass
+    class Config(JsonSchemaMixin):
+        assignment: Dict[int, str]
+
+    c = Config({1: 'foo', 2: 'bar'})
+    data = c.to_json()
+    assert c == Config.from_json(data)
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']