Repository: python-desert/desert

Issue:
Support for Mapping, MutableMapping, Sequence, etc?
Marshmallow supports serializing arbitrary sequences and mappings, so long as they provide the correct interface. Unfortunately, desert doesn't seem to allow generic types out of the box:

The following crashes:

```py
import dataclasses

@dataclasses.dataclass
class D:
    foo: Sequence[str]

schema = desert.schema(D)
```

Traceback:
```
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/dargueta/.pyenv/versions/3.8.6/lib/python3.8/site-packages/desert/__init__.py", line 24, in schema
    return desert._make.class_schema(cls, meta=meta)(many=many)
  File "/Users/dargueta/.pyenv/versions/3.8.6/lib/python3.8/site-packages/desert/_make.py", line 130, in class_schema
    attributes[field.name] = field_for_schema(
  File "/Users/dargueta/.pyenv/versions/3.8.6/lib/python3.8/site-packages/desert/_make.py", line 291, in field_for_schema
    nested = forward_reference or class_schema(typ)
  File "/Users/dargueta/.pyenv/versions/3.8.6/lib/python3.8/site-packages/desert/_make.py", line 105, in class_schema
    raise desert.exceptions.UnknownType(
desert.exceptions.UnknownType: Desert failed to infer the field type for typing.Sequence[str].
Explicitly pass a Marshmallow field type.
```

Can we add support for these? It seems like the changes would need to be made [here](https://github.com/python-desert/desert/blob/main/src/desert/_make.py#L241-L257) and not too much work.

### Environment

Python 3.8.6

* desert: 2020.11.18
* marshmallow: 3.13.0
* marshmallow-enum: 1.5.1
* marshmallow-union: 0.1.15.post1

Discussion:

Patch:
diff --git a/changelog.d/140.change.rst b/changelog.d/140.change.rst
new file mode 100644
index 0000000..672818e
--- /dev/null
+++ b/changelog.d/140.change.rst
@@ -0,0 +1,30 @@
+It is now possible to use `type-variant generics`_ in your dataclasses, such as ``Sequence``
+or ``MutableSequence`` instead of ``List``, ``Mapping`` instead of ``Dict``, etc.
+
+This allows you to hide implementation details from users of your dataclasses. If a field
+in your dataclass works just as fine with a tuple as a list, you no longer need to force
+your users to pass in a ``list`` just to satisfy type checkers.
+
+For example, by using ``Mapping`` or ``MutableMapping``, users can pass ``OrderedDict`` to
+a ``Dict`` attribute without MyPy complaining.
+
+.. code-block:: python
+
+    @dataclass
+    class OldWay:
+        str_list: List[str]
+        num_map: Dict[str, float]
+
+
+    # MyPy will reject this even though Marshmallow works just fine. If you use
+    # type-variant generics, MyPy will accept this code.
+    instance = OldClass([], collections.ChainMap(MY_DEFAULTS))
+
+
+    @dataclass
+    class NewWay:
+        str_list: List[str]                     # Type-invariants still work
+        num_map: MutableMapping[str, float]     # Now generics do too
+
+
+.. _type-variant generics: https://mypy.readthedocs.io/en/stable/generics.html
diff --git a/src/desert/_make.py b/src/desert/_make.py
index 6fc36af..0c9896a 100644
--- a/src/desert/_make.py
+++ b/src/desert/_make.py
@@ -54,6 +54,7 @@ Full example::
       Schema: t.ClassVar[Type[Schema]] = Schema # For the type checker
 """
 
+import collections.abc
 import dataclasses
 import datetime
 import decimal
@@ -244,7 +245,14 @@ def field_for_schema(
     if origin:
         arguments = typing_inspect.get_args(typ, True)
 
-        if origin in (list, t.List):
+        if origin in (
+            list,
+            t.List,
+            t.Sequence,
+            t.MutableSequence,
+            collections.abc.Sequence,
+            collections.abc.MutableSequence,
+        ):
             field = marshmallow.fields.List(field_for_schema(arguments[0]))
 
         if origin in (tuple, t.Tuple) and Ellipsis not in arguments:
@@ -256,7 +264,14 @@ def field_for_schema(
             field = VariadicTuple(
                 field_for_schema(only(arg for arg in arguments if arg != Ellipsis))
             )
-        elif origin in (dict, t.Dict):
+        elif origin in (
+            dict,
+            t.Dict,
+            t.Mapping,
+            t.MutableMapping,
+            collections.abc.Mapping,
+            collections.abc.MutableMapping,
+        ):
             field = marshmallow.fields.Dict(
                 keys=field_for_schema(arguments[0]),
                 values=field_for_schema(arguments[1]),


Test patch:
diff --git a/tests/test_make.py b/tests/test_make.py
index 28c56f6..2eb6dfd 100644
--- a/tests/test_make.py
+++ b/tests/test_make.py
@@ -163,29 +163,27 @@ def test_set_default(module: DataclassModule) -> None:
     assert data == A(1)  # type: ignore[call-arg]
 
 
-def test_list(module: DataclassModule) -> None:
+@pytest.mark.parametrize("annotation_class", (t.List, t.Sequence, t.MutableSequence))
+def test_list(module: DataclassModule, annotation_class: type) -> None:
     """Build a generic list *without* setting a factory on the dataclass."""
-
-    @module.dataclass
-    class A:
-        y: t.List[int]
+    cls = type("A", (object,), {"__annotations__": {"y": annotation_class[int]}})  # type: ignore[index]
+    A = module.dataclass(cls)
 
     schema = desert.schema_class(A)()
     data = schema.load({"y": [1]})
-    assert data == A([1])  # type: ignore[call-arg]
+    assert data == A([1])
 
 
-def test_dict(module: DataclassModule) -> None:
+@pytest.mark.parametrize("annotation_class", (t.Dict, t.Mapping, t.MutableMapping))
+def test_dict(module: DataclassModule, annotation_class: type) -> None:
     """Build a dict without setting a factory on the dataclass."""
-
-    @module.dataclass
-    class A:
-        y: t.Dict[int, int]
+    cls = type("A", (object,), {"__annotations__": {"y": annotation_class[int, int]}})  # type: ignore[index]
+    A = module.dataclass(cls)
 
     schema = desert.schema_class(A)()
     data = schema.load({"y": {1: 2, 3: 4}})
 
-    assert data == A({1: 2, 3: 4})  # type: ignore[call-arg]
+    assert data == A({1: 2, 3: 4})
 
 
 def test_nested(module: DataclassModule) -> None:
@@ -527,6 +525,13 @@ def test_raise_unknown_type(module: DataclassModule) -> None:
         desert.schema_class(A)
 
 
+T = t.TypeVar("T")
+
+
+class UnknownGeneric(t.Generic[T]):
+    pass
+
+
 @pytest.mark.skipif(
     sys.version_info[:2] <= (3, 6), reason="3.6 has isinstance(t.Sequence[int], type)."
 )
@@ -535,7 +540,7 @@ def test_raise_unknown_generic(module: DataclassModule) -> None:
 
     @module.dataclass
     class A:
-        x: t.Sequence[int]
+        x: UnknownGeneric[int]
 
     with pytest.raises(desert.exceptions.UnknownType):
         desert.schema_class(A)