Repository: s-knibbs/dataclasses-jsonschema
Pull request #62 (2019-06-29 13:08:06)

Support type annotations with the 'Final' qualifier (e.g `Final[str]`)
Adds basic support for the 'Final' qualifier added in [PEP-591](https://www.python.org/dev/peps/pep-0591/)


Patch:
diff --git a/dataclasses_jsonschema/__init__.py b/dataclasses_jsonschema/__init__.py
index 9d8c365..2ce5d65 100644
--- a/dataclasses_jsonschema/__init__.py
+++ b/dataclasses_jsonschema/__init__.py
@@ -1,3 +1,4 @@
+import sys
 import functools
 from _decimal import Decimal
 from ipaddress import IPv4Address, IPv6Address
@@ -9,6 +10,8 @@ from uuid import UUID
 from enum import Enum
 import warnings
 
+from typing_extensions import Final
+
 try:
     # Supported in future python versions
     from typing import TypedDict  # type: ignore
@@ -50,7 +53,23 @@ def issubclass_safe(klass: Any, base: Type):
 
 
 def is_optional(field: Any) -> bool:
-    return str(field).startswith('typing.Union') and issubclass(field.__args__[1], type(None))
+    try:
+        return field.__origin__ == Union and issubclass(field.__args__[1], type(None))
+    except AttributeError:
+        return False
+
+
+def is_final(field: Any) -> bool:
+    try:
+        return field.__origin__ == Final
+    except AttributeError:
+        if sys.version_info[:2] == (3, 6):
+            return type(field).__qualname__ == "_Final"
+        return False
+
+
+def final_wrapped_type(final_type: Any) -> Any:
+    return final_type.__args__[0] if sys.version_info[:2] >= (3, 7) else final_type.__type__
 
 
 class SchemaType(Enum):
@@ -180,6 +199,8 @@ class JsonSchemaMixin:
                 def encoder(ft, v, __): return cls._field_encoders[ft].to_wire(v)
             elif is_optional(field_type):
                 def encoder(ft, val, o): return cls._encode_field(ft.__args__[0], val, o)
+            elif is_final(field_type):
+                def encoder(ft, val, o): return cls._encode_field(final_wrapped_type(ft), val, o)
             elif is_enum(field_type):
                 def encoder(_, v, __): return v.value
             elif field_type_name == 'Union':
@@ -269,6 +290,8 @@ class JsonSchemaMixin:
                 def decoder(_, ft, val): return ft.from_dict(val, validate=False)
             elif is_optional(field_type):
                 def decoder(f, ft, val): return cls._decode_field(f, ft.__args__[0], val)
+            elif is_final(field_type):
+                def decoder(f, ft, val): return cls._decode_field(f, final_wrapped_type(ft), val)
             elif field_type_name == 'Union':
                 # Attempt to decode the value using each decoder in turn
                 decoded = None
@@ -390,6 +413,8 @@ class JsonSchemaMixin:
             if is_optional(field_type):
                 field_schema = cls._get_field_schema(field_type.__args__[0], schema_type)[0]
                 required = False
+            elif is_final(field_type):
+                field_schema, required = cls._get_field_schema(final_wrapped_type(field_type), schema_type)
             elif is_enum(field_type):
                 member_types = set()
                 values = []
diff --git a/setup.py b/setup.py
index b42765c..e967e4b 100644
--- a/setup.py
+++ b/setup.py
@@ -4,6 +4,7 @@ requires = [
     'python-dateutil',
     'jsonschema',
     'mypy_extensions',
+    'typing_extensions',
     'dataclasses;python_version<"3.7"'
 ]
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 40e9a33..7937990 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -107,3 +107,21 @@ class ProductList(JsonSchemaMixin):
 class Zoo(JsonSchemaMixin):
     """A zoo"""
     animal_types: Optional[Dict[str, str]] = field(default_factory=dict)
+
+
+def test_final_field():
+
+    @dataclass
+    class TestWithFinal(JsonSchemaMixin):
+        name: Final[str]
+
+    expected_schema = {
+        'type': 'object',
+        'description': 'TestWithFinal(name: str)',
+        'properties': {
+            'name': {'type': 'string'}
+        },
+        'required': ['name']
+    }
+
+    assert TestWithFinal.json_schema() == compose_schema(expected_schema)
diff --git a/tests/test_all.py b/tests/test_all.py
index e6ec26e..69982fc 100644
--- a/tests/test_all.py
+++ b/tests/test_all.py
@@ -2,6 +2,7 @@ from _decimal import Decimal
 from dataclasses import dataclass, field
 from ipaddress import IPv4Address, IPv6Address
 from typing import List, NewType
+from typing_extensions import Final
 from uuid import UUID
 
 from .conftest import Foo, Point, Recursive, OpaqueData, ShoppingCart, Product, ProductList, SubSchemas, Bar, Weekday, \
@@ -414,3 +415,24 @@ def test_field_metadata():
     expected_schema['properties']['name']['example'] = 'foo'
     del expected_schema['properties']['name']['examples']
     assert Test.json_schema(schema_type=SchemaType.SWAGGER_V2, embeddable=True) == {'Test': expected_schema}
+
+
+def test_final_field():
+
+    @dataclass
+    class TestWithFinal(JsonSchemaMixin):
+        """Dataclass with final field"""
+        name: Final[str]
+
+    expected_schema = {
+        'type': 'object',
+        'description': 'Dataclass with final field',
+        'properties': {
+            'name': {'type': 'string'}
+        },
+        'required': ['name']
+    }
+
+    assert TestWithFinal.json_schema() == compose_schema(expected_schema)
+    assert TestWithFinal.from_dict({'name': 'foo'}) == TestWithFinal(name='foo')
+    assert TestWithFinal(name='foo').to_dict() == {'name': 'foo'}