Repository: lovasoa/marshmallow_dataclass

Issue:
Final type
The following simple example, which uses the [`Final`](https://docs.python.org/3/library/typing.html#typing.Final) type hint, doesn't work (because `marshmallow-dataclass` doesn't seem to support `Final`):

```python
from dataclasses import dataclass

from marshmallow_dataclass import class_schema
from typing_extensions import Final

@dataclass
class A:
    value: Final[int]

Schema = class_schema(A)  # ERROR
```

This is the error:

```
.../site-packages/marshmallow_dataclass/__init__.py:316: UserWarning: marshmallow_dataclass was called on the class typing_extensions.Final[int], which is not a dataclass. It is going to try and convert the class into a dataclass, which may have undesirable side effects. To avoid this message, make sure all your classes and all the classes of their fields are either explicitly supported by marshmallow_datcalass, or are already dataclasses. For more information, see https://github.com/lovasoa/marshmallow_dataclass/issues/51
  f"marshmallow_dataclass was called on the class {clazz}, which is not a dataclass. "
Traceback (most recent call last):
  File ".../site-packages/dataclasses.py", line 970, in fields
    fields = getattr(class_or_instance, _FIELDS)
AttributeError: '_Final' object has no attribute '__dataclass_fields__'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File ".../site-packages/marshmallow_dataclass/__init__.py", line 312, in _internal_class_schema
    fields: Tuple[dataclasses.Field, ...] = dataclasses.fields(clazz)
  File ".../site-packages/dataclasses.py", line 972, in fields
    raise TypeError('must be called with a dataclass type or instance')
TypeError: must be called with a dataclass type or instance

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File ".../site-packages/marshmallow_dataclass/__init__.py", line 323, in _internal_class_schema
    created_dataclass: type = dataclasses.dataclass(clazz)
  File ".../site-packages/dataclasses.py", line 958, in dataclass
    return wrap(_cls)
  File ".../site-packages/dataclasses.py", line 950, in wrap
    return _process_class(cls, init, repr, eq, order, unsafe_hash, frozen)
  File ".../site-packages/dataclasses.py", line 764, in _process_class
    unsafe_hash, frozen))
AttributeError: '_Final' object has no attribute '__dataclass_params__'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File ".../site-packages/marshmallow_dataclass/__init__.py", line 303, in class_schema
    return _internal_class_schema(clazz, base_schema)
  File ".../site-packages/marshmallow_dataclass/__init__.py", line 344, in _internal_class_schema
    for field in fields
  File ".../site-packages/marshmallow_dataclass/__init__.py", line 345, in <genexpr>
    if field.init
  File ".../site-packages/marshmallow_dataclass/__init__.py", line 526, in field_for_schema
    nested_schema or forward_reference or _internal_class_schema(typ, base_schema)
  File ".../site-packages/marshmallow_dataclass/__init__.py", line 327, in _internal_class_schema
    f"{getattr(clazz, '__name__', repr(clazz))} is not a dataclass and cannot be turned into one."
TypeError: typing_extensions.Final[int] is not a dataclass and cannot be turned into one.
```

I find the `Final` type quite useful for annotating a field to be read-only without runtime overhead.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f47ee64..53fa59e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
 # marshmallow\_dataclass change log
 
+- Add support for the Final type. See [#150](https://github.com/lovasoa/marshmallow_dataclass/pull/150)
+
 ## v8.4.1
 
  - Fix compatibility with older python versions.
diff --git a/marshmallow_dataclass/__init__.py b/marshmallow_dataclass/__init__.py
index 081d476..f1cd81d 100644
--- a/marshmallow_dataclass/__init__.py
+++ b/marshmallow_dataclass/__init__.py
@@ -579,6 +579,16 @@ def field_for_schema(
             **metadata,
         )
 
+    if typing_inspect.is_final_type(typ):
+        arguments = typing_inspect.get_args(typ)
+        if arguments:
+            subtyp = arguments[0]
+        elif default is not marshmallow.missing:
+            subtyp = type(default)
+        else:
+            subtyp = Any
+        return field_for_schema(subtyp, default, metadata, base_schema)
+
     # Generic types
     generic_field = _field_for_generic_type(typ, base_schema, **metadata)
     if generic_field:
diff --git a/setup.py b/setup.py
index ee17c86..d94d96a 100644
--- a/setup.py
+++ b/setup.py
@@ -56,7 +56,7 @@ setup(
     classifiers=CLASSIFIERS,
     license="MIT",
     python_requires=">=3.6",
-    install_requires=["marshmallow>=3.0.0,<4.0", "typing-inspect"],
+    install_requires=["marshmallow>=3.0.0,<4.0", "typing-inspect>=0.7.0"],
     extras_require=EXTRAS_REQUIRE,
     package_data={"marshmallow_dataclass": ["py.typed"]},
 )


Test patch:
diff --git a/tests/test_class_schema.py b/tests/test_class_schema.py
index 18dc762..02f3ba3 100644
--- a/tests/test_class_schema.py
+++ b/tests/test_class_schema.py
@@ -1,12 +1,12 @@
 import typing
 import unittest
-from typing import Any
+from typing import Any, TYPE_CHECKING
 from uuid import UUID
 
 try:
-    from typing import Literal  # type: ignore[attr-defined]
+    from typing import Final, Literal  # type: ignore[attr-defined]
 except ImportError:
-    from typing_extensions import Literal  # type: ignore[misc]
+    from typing_extensions import Final, Literal  # type: ignore[misc]
 
 import dataclasses
 from marshmallow import Schema, ValidationError
@@ -162,6 +162,60 @@ class TestClassSchema(unittest.TestCase):
             with self.assertRaises(ValidationError):
                 schema.load({"data": data})
 
+    def test_final(self):
+        @dataclasses.dataclass
+        class A:
+            # Mypy currently considers read-only dataclass attributes without a
+            # default value an error.
+            # See: https://github.com/python/mypy/issues/10688.
+            data: Final[str]  # type: ignore[misc]
+
+        schema = class_schema(A)()
+        self.assertEqual(A(data="a"), schema.load({"data": "a"}))
+        self.assertEqual(schema.dump(A(data="a")), {"data": "a"})
+        for data in [2, 2.34, False]:
+            with self.assertRaises(ValidationError):
+                schema.load({"data": data})
+
+    def test_final_infers_type_from_default(self):
+        # @dataclasses.dataclass
+        class A:
+            data: Final = "a"
+
+        # @dataclasses.dataclass
+        class B:
+            data: Final = A()
+
+        # NOTE: This workaround is needed to avoid a Mypy crash.
+        # See: https://github.com/python/mypy/issues/10090#issuecomment-865971891
+        if not TYPE_CHECKING:
+            A = dataclasses.dataclass(A)
+            B = dataclasses.dataclass(B)
+
+        schema_a = class_schema(A)()
+        self.assertEqual(A(data="a"), schema_a.load({}))
+        self.assertEqual(A(data="a"), schema_a.load({"data": "a"}))
+        self.assertEqual(A(data="b"), schema_a.load({"data": "b"}))
+        self.assertEqual(schema_a.dump(A()), {"data": "a"})
+        self.assertEqual(schema_a.dump(A(data="a")), {"data": "a"})
+        self.assertEqual(schema_a.dump(A(data="b")), {"data": "b"})
+        for data in [2, 2.34, False]:
+            with self.assertRaises(ValidationError):
+                schema_a.load({"data": data})
+
+        schema_b = class_schema(B)()
+        self.assertEqual(B(data=A()), schema_b.load({}))
+        self.assertEqual(B(data=A()), schema_b.load({"data": {}}))
+        self.assertEqual(B(data=A()), schema_b.load({"data": {"data": "a"}}))
+        self.assertEqual(B(data=A(data="b")), schema_b.load({"data": {"data": "b"}}))
+        self.assertEqual(schema_b.dump(B()), {"data": {"data": "a"}})
+        self.assertEqual(schema_b.dump(B(data=A())), {"data": {"data": "a"}})
+        self.assertEqual(schema_b.dump(B(data=A(data="a"))), {"data": {"data": "a"}})
+        self.assertEqual(schema_b.dump(B(data=A(data="b"))), {"data": {"data": "b"}})
+        for data in [2, 2.34, False]:
+            with self.assertRaises(ValidationError):
+                schema_b.load({"data": data})
+
     def test_validator_stacking(self):
         # See: https://github.com/lovasoa/marshmallow_dataclass/issues/91
         class SimpleValidator(Validator):
diff --git a/tests/test_field_for_schema.py b/tests/test_field_for_schema.py
index 50ef60b..c43c18f 100644
--- a/tests/test_field_for_schema.py
+++ b/tests/test_field_for_schema.py
@@ -5,9 +5,9 @@ from enum import Enum
 from typing import Dict, Optional, Union, Any, List, Tuple
 
 try:
-    from typing import Literal  # type: ignore[attr-defined]
+    from typing import Final, Literal  # type: ignore[attr-defined]
 except ImportError:
-    from typing_extensions import Literal  # type: ignore[misc]
+    from typing_extensions import Final, Literal  # type: ignore[misc]
 
 from marshmallow import fields, Schema, validate
 
@@ -110,6 +110,16 @@ class TestFieldForSchema(unittest.TestCase):
             fields.Raw(required=True, validate=validate.OneOf(("a", 1, 1.23, True))),
         )
 
+    def test_final(self):
+        self.assertFieldsEqual(
+            field_for_schema(Final[str]), fields.String(required=True)
+        )
+
+    def test_final_without_type(self):
+        self.assertFieldsEqual(
+            field_for_schema(Final), fields.Raw(required=True, allow_none=True)
+        )
+
     def test_union(self):
         self.assertFieldsEqual(
             field_for_schema(Union[int, str]),