Repository: lovasoa/marshmallow_dataclass

Issue:
Literal field and tagged union
The following simple example, which uses a [`Literal`](https://www.python.org/dev/peps/pep-0586/), doesn't work (because `marshmallow-dataclass` doesn't seem to support `Literal`):

```python
from dataclasses import dataclass

from marshmallow_dataclass import class_schema
from typing_extensions import Literal

@dataclass
class A:
    choices: Literal['x', 'y']

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.Literal['x', 'y'], 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: '_Literal' 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: '_Literal' 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.Literal['x', 'y'] is not a dataclass and cannot be turned into one.
```

I think `Literal` is quite useful, e.g. for creating tagged unions. Is there any chance to add support for `Literal` to `marshmallow-dataclass`?

Comments:

Patch:
diff --git a/marshmallow_dataclass/__init__.py b/marshmallow_dataclass/__init__.py
index 05f39b9..9f4b770 100644
--- a/marshmallow_dataclass/__init__.py
+++ b/marshmallow_dataclass/__init__.py
@@ -451,6 +451,17 @@ def field_for_schema(
         metadata.setdefault("allow_none", True)
         return marshmallow.fields.Raw(**metadata)
 
+    if typing_inspect.is_literal_type(typ):
+        arguments = typing_inspect.get_args(typ)
+        return marshmallow.fields.Raw(
+            validate=(
+                marshmallow.validate.Equal(arguments[0])
+                if len(arguments) == 1
+                else marshmallow.validate.OneOf(arguments)
+            ),
+            **metadata,
+        )
+
     # Generic types
     origin = typing_inspect.get_origin(typ)
     if origin:
diff --git a/setup.py b/setup.py
index 4f7f771..fc65498 100644
--- a/setup.py
+++ b/setup.py
@@ -23,6 +23,10 @@ EXTRAS_REQUIRE = {
         # re: pypy: typed-ast (a dependency of mypy) fails to install on pypy
         # https://github.com/python/typed_ast/issues/111
         "pytest-mypy-plugins>=1.2.0; implementation_name != 'pypy'",
+        # `Literal` was introduced in:
+        # - Python 3.8 (https://www.python.org/dev/peps/pep-0586)
+        # - typing-extensions 3.7.2 (https://github.com/python/typing/pull/591)
+        "typing-extensions~=3.7.2; python_version < '3.8'",
     ],
 }
 EXTRAS_REQUIRE["dev"] = (


Test patch:
diff --git a/tests/test_class_schema.py b/tests/test_class_schema.py
index b6c9837..5aea442 100644
--- a/tests/test_class_schema.py
+++ b/tests/test_class_schema.py
@@ -3,6 +3,11 @@ import unittest
 from typing import Any
 from uuid import UUID
 
+try:
+    from typing import Literal
+except ImportError:
+    from typing_extensions import Literal  # type: ignore
+
 import dataclasses
 from marshmallow import Schema, ValidationError
 from marshmallow.fields import Field, UUID as UUIDField, List as ListField, Integer
@@ -132,6 +137,31 @@ class TestClassSchema(unittest.TestCase):
         schema = class_schema(A)()
         self.assertRaises(ValidationError, lambda: schema.load({"data": None}))
 
+    def test_literal(self):
+        @dataclasses.dataclass
+        class A:
+            data: Literal["a"]
+
+        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 ["b", 2, 2.34, False]:
+            with self.assertRaises(ValidationError):
+                schema.load({"data": data})
+
+    def test_literal_multiple_types(self):
+        @dataclasses.dataclass
+        class A:
+            data: Literal["a", 1, 1.23, True]
+
+        schema = class_schema(A)()
+        for data in ["a", 1, 1.23, True]:
+            self.assertEqual(A(data=data), schema.load({"data": data}))
+            self.assertEqual(schema.dump(A(data=data)), {"data": data})
+        for data in ["b", 2, 2.34, False]:
+            with self.assertRaises(ValidationError):
+                schema.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 96df872..4a388cc 100644
--- a/tests/test_field_for_schema.py
+++ b/tests/test_field_for_schema.py
@@ -4,7 +4,12 @@ import unittest
 from enum import Enum
 from typing import Dict, Optional, Union, Any, List, Tuple
 
-from marshmallow import fields, Schema
+try:
+    from typing import Literal
+except ImportError:
+    from typing_extensions import Literal  # type: ignore
+
+from marshmallow import fields, Schema, validate
 
 from marshmallow_dataclass import field_for_schema, dataclass, union_field
 
@@ -88,6 +93,18 @@ class TestFieldForSchema(unittest.TestCase):
             marshmallow_enum.EnumField(enum=Color, required=True),
         )
 
+    def test_literal(self):
+        self.assertFieldsEqual(
+            field_for_schema(Literal["a"]),
+            fields.Raw(required=True, validate=validate.Equal("a")),
+        )
+
+    def test_literal_multiple_types(self):
+        self.assertFieldsEqual(
+            field_for_schema(Literal["a", 1, 1.23, True]),
+            fields.Raw(required=True, validate=validate.OneOf(("a", 1, 1.23, True))),
+        )
+
     def test_union(self):
         self.assertFieldsEqual(
             field_for_schema(Union[int, str]),