Repository: oxan/djangorestframework-dataclasses

Issue:
Support for types like frozenset?
I have a dataclass that contains a frozenset field. When I use save() on my serializer the created object contains a list though:

```
@dataclass
class Foo:
    bar: frozenset[str] | None = None


class FooSerializer(DataclassSerializer):
    class Meta:
        dataclass = Foo

class FooView(APIView):
    def post(self, request, format=None):
        serializer = FooSerializer(data=request.data)
        if serializer.is_valid():
            foo = serializer.save()
            # type of foo.bar is list
```

I already had a look at serializer_field_mapping and tried to add frozenset to it, but found no way to make it work.

What can I do to make the serializer automatically turn the list into a frozenset?

Comments:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index d4d38e9..5c23dee 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,3 +1,11 @@
+1.3.0, TBD
+----------
+Features & fixes:
+
+* Create values for fields of non-``list`` or ``dict`` composite types (such as ``frozenset`` or ``OrderedDict``) as
+  that type, instead of ``list`` or ``dict``.
+* Allow overriding the field for specific composite types through the ``serializer_field_mapping`` dictionary.
+
 1.2.0, 18 November 2022
 -----------------------
 Features & fixes:
diff --git a/README.rst b/README.rst
index cef229a..87cdea7 100644
--- a/README.rst
+++ b/README.rst
@@ -337,9 +337,29 @@ their name or value. The member name is used as display name.
 * ``enum_class``: The enumeration class.
 * ``by_name``: Whether members are represented by their value (``False``) or name (``True``).
 
+IterableField
+~~~~~~~~~~~~~
+A subclass of `ListField`_ that can return values that aren't of type ``list``, such as ``set``.
+
+**Signature**: ``IterableField(container=list)``
+
+* ``container``: The type of the returned iterable. Must have a constructor that accepts a single parameter of type
+  ``list``, containing the values for the iterable.
+
+MappingField
+~~~~~~~~~~~~
+A subclass of `DictField`_ that can return values that aren't of type ``dict``, such as ``collections.OrderedDict``.
+
+**Signature**: ``IterableField(container=dict)``
+
+* ``container``: The type of the returned mapping. Must have a constructor that accepts a single parameter of type
+  ``dict``, containing the values for the mapping.
+
 .. _`enumerations`: https://docs.python.org/3/library/enum.html
 .. _`ChoiceField`: https://www.django-rest-framework.org/api-guide/fields/#choicefield
 .. _`DecimalField`: https://www.django-rest-framework.org/api-guide/fields/#decimalfield
+.. _`ListField`: https://www.django-rest-framework.org/api-guide/fields/#listfield
+.. _`DictField`: https://www.django-rest-framework.org/api-guide/fields/#dictfield
 
 Advanced usage
 --------------
diff --git a/pyproject.toml b/pyproject.toml
index 571ac0c..6bfcdae 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,10 +42,10 @@ dependencies = [
 ]
 
 [project.urls]
-repository = "https://github.com/oxan/djangorestframework-dataclasses"
-documentation = "https://github.com/oxan/djangorestframework-dataclasses/blob/master/README.rst"
-changelog = "https://github.com/oxan/djangorestframework-dataclasses/blob/master/CHANGELOG.rst"
-sponsor = "https://github.com/sponsors/oxan"
+Repository = "https://github.com/oxan/djangorestframework-dataclasses"
+Documentation = "https://github.com/oxan/djangorestframework-dataclasses/blob/master/README.rst"
+Changelog = "https://github.com/oxan/djangorestframework-dataclasses/blob/master/CHANGELOG.rst"
+Sponsor = "https://github.com/sponsors/oxan"
 
 [tool.coverage.run]
 branch = true
diff --git a/rest_framework_dataclasses/field_utils.py b/rest_framework_dataclasses/field_utils.py
index cbd0ce3..1fa0e31 100644
--- a/rest_framework_dataclasses/field_utils.py
+++ b/rest_framework_dataclasses/field_utils.py
@@ -3,7 +3,7 @@ from __future__ import annotations
 import dataclasses
 import inspect
 
-from typing import Any, Dict, Generic, Mapping, Type, TypeVar
+from typing import Any, Dict, Generic, Mapping, Optional, Type, TypeVar
 
 from rest_framework.utils.model_meta import RelationInfo
 
@@ -28,6 +28,7 @@ class TypeInfo:
     is_final: bool
     is_nullable: bool
     base_type: type
+    container_type: Optional[type]
 
 
 def get_dataclass_definition(dataclass_type: Type[T]) -> DataclassDefinition[T]:
@@ -62,6 +63,12 @@ def get_type_info(tp: type) -> TypeInfo:
     is_mapping = typing_utils.is_mapping_type(tp)
     is_many = typing_utils.is_iterable_type(tp)
 
+    cp = None
+    if is_mapping or is_many:
+        container_type = typing_utils.get_container_type(tp)
+        if not inspect.isabstract(container_type):
+            cp = container_type
+
     if is_mapping:
         tp = typing_utils.get_mapping_value_type(tp)
     elif is_many:  # This must be elif (instead of if), as otherwise we'd reduce mappings twice as they're also iterable
@@ -70,7 +77,7 @@ def get_type_info(tp: type) -> TypeInfo:
     if typing_utils.is_type_variable(tp):
         tp = typing_utils.get_variable_type_substitute(tp)
 
-    return TypeInfo(is_many, is_mapping, is_final, is_nullable, tp)
+    return TypeInfo(is_many, is_mapping, is_final, is_nullable, tp, cp)
 
 
 def get_relation_info(type_info: TypeInfo) -> RelationInfo:
diff --git a/rest_framework_dataclasses/fields.py b/rest_framework_dataclasses/fields.py
index 42e4714..f7506aa 100644
--- a/rest_framework_dataclasses/fields.py
+++ b/rest_framework_dataclasses/fields.py
@@ -1,4 +1,4 @@
-from rest_framework.fields import DecimalField, ChoiceField
+from rest_framework.fields import DecimalField, ChoiceField, DictField, ListField
 
 
 class DefaultDecimalField(DecimalField):
@@ -42,3 +42,21 @@ class EnumField(ChoiceField):
             return value.name
         else:
             return value.value
+
+
+class IterableField(ListField):
+    def __init__(self, **kwargs):
+        self.container = kwargs.pop('container', list)
+        super(IterableField, self).__init__(**kwargs)
+
+    def to_internal_value(self, value):
+        return self.container(super(IterableField, self).to_internal_value(value))
+
+
+class MappingField(DictField):
+    def __init__(self, **kwargs):
+        self.container = kwargs.pop('container', dict)
+        super(MappingField, self).__init__(**kwargs)
+
+    def to_internal_value(self, value):
+        return self.container(super(MappingField, self).to_internal_value(value))
diff --git a/rest_framework_dataclasses/serializers.py b/rest_framework_dataclasses/serializers.py
index 07657db..2832907 100644
--- a/rest_framework_dataclasses/serializers.py
+++ b/rest_framework_dataclasses/serializers.py
@@ -79,8 +79,8 @@ class DataclassSerializer(rest_framework.serializers.Serializer, Generic[T]):
         datetime.time:      rest_framework.fields.TimeField,
         datetime.timedelta: rest_framework.fields.DurationField,
         uuid.UUID:          rest_framework.fields.UUIDField,
-        dict:               rest_framework.fields.DictField,
-        list:               rest_framework.fields.ListField
+        list:               fields.IterableField,
+        dict:               fields.MappingField,
     }
     serializer_related_field: Type[SerializerField] = PrimaryKeyRelatedField
 
@@ -417,15 +417,25 @@ class DataclassSerializer(rest_framework.serializers.Serializer, Generic[T]):
         """
         Create a composite (mapping or list) field.
         """
-        # Lookup the types from the field mapping, so that it can easily be changed without overriding the method.
+        # Default to using the field for a dict or list from the field mapping, so that it can easily be changed without
+        # overriding this method.
         if type_info.is_mapping:
             field_class = self.serializer_field_mapping[dict]
         else:
             field_class = self.serializer_field_mapping[list]
+        field_kwargs: KWArgs = {}
+
+        # If the type of the container is known, see if there's a field override; and pass it to the field.
+        if type_info.container_type:
+            field_kwargs['container'] = type_info.container_type
+            try:
+                field_class = field_utils.lookup_type_in_mapping(self.serializer_field_mapping, type_info.container_type)
+            except KeyError:
+                pass
 
-        # If the base type is not specified or is any type, we don't have to bother creating the child field.
+        # If the base type is not specified or is any type, no need to bother creating the child field.
         if type_info.base_type is Any:
-            return field_class, {}
+            return field_class, field_kwargs
 
         # Recurse to build the child field (i.e. the field of every instance). We pass the extra kwargs that are
         # specified for the child field through, so these can be used to recursively specify kwargs for child fields.
@@ -439,7 +449,7 @@ class DataclassSerializer(rest_framework.serializers.Serializer, Generic[T]):
 
         # Create child field and initialize parent field kwargs
         child_field = child_field_class(**child_field_kwargs)
-        field_kwargs = {'child': child_field}
+        field_kwargs['child'] = child_field
         return field_class, field_kwargs
 
     def build_standard_field(self, field_name: str, type_info: TypeInfo) -> SerializerFieldDefinition:
diff --git a/rest_framework_dataclasses/typing_utils.py b/rest_framework_dataclasses/typing_utils.py
index 4b8c529..a43faa0 100644
--- a/rest_framework_dataclasses/typing_utils.py
+++ b/rest_framework_dataclasses/typing_utils.py
@@ -100,6 +100,20 @@ def is_iterable_type(tp: type) -> bool:
     )
 
 
+def get_container_type(tp: type) -> typing.Optional[type]:
+    """
+    Return the unsubscripted container type of the given type.
+
+    Some examples of container types are:
+
+        Iterable[str] -> Iterable
+        List[str] -> list
+        Dict[str, int] -> dict
+
+    """
+    return get_origin(tp)
+
+
 def get_iterable_element_type(tp: type) -> type:
     """
     Get the type of elements in an iterable.


Test patch:
diff --git a/tests/test_field_generation.py b/tests/test_field_generation.py
index 53357a4..5e455c5 100644
--- a/tests/test_field_generation.py
+++ b/tests/test_field_generation.py
@@ -58,26 +58,31 @@ class FieldsTest(unittest.TestCase):
     def test_composite(self):
         var_type = typing.TypeVar('var_type')
 
-        self.check_field(typing.Iterable[str], fields.ListField, {'child': fields.CharField})
-        self.check_field(typing.Sequence[str], fields.ListField, {'child': fields.CharField})
-        self.check_field(typing.Tuple[str], fields.ListField, {'child': fields.CharField})
-        self.check_field(typing.List[str], fields.ListField, {'child': fields.CharField})
-        self.check_field(typing.List[typing.Any], fields.ListField, {})
-        self.check_field(typing.List[var_type], fields.ListField, {})
-        self.check_field(typing.List, fields.ListField, {})
-        self.check_field(list, fields.ListField, {})
-
-        self.check_field(typing.Mapping[str, int], fields.DictField, {'child': fields.IntegerField})
-        self.check_field(typing.Dict[str, int], fields.DictField, {'child': fields.IntegerField})
-        self.check_field(typing.Dict[str, typing.Any], fields.DictField, {})
-        self.check_field(typing.Dict[str, var_type], fields.DictField, {})
-        self.check_field(typing.Dict, fields.DictField, {})
-        self.check_field(dict, fields.DictField, {})
-
-        self.check_field(typing.Optional[typing.List[str]], fields.ListField,
-                         {'allow_null': True, 'child': fields.CharField})
-        self.check_field(typing.Optional[typing.Dict[str, int]], fields.DictField,
-                         {'allow_null': True, 'child': fields.IntegerField})
+        self.check_field(typing.Iterable[str], custom_fields.IterableField, {'child': fields.CharField})
+        self.check_field(typing.Sequence[str], custom_fields.IterableField, {'child': fields.CharField})
+        self.check_field(typing.Tuple[str], custom_fields.IterableField,
+                         {'child': fields.CharField, 'container': tuple})
+        self.check_field(typing.Set[str], custom_fields.IterableField, {'child': fields.CharField, 'container': set})
+        self.check_field(typing.List[str], custom_fields.IterableField, {'child': fields.CharField, 'container': list})
+        self.check_field(typing.List[typing.Any], custom_fields.IterableField, {'container': list})
+        self.check_field(typing.List[var_type], custom_fields.IterableField, {'container': list})
+        self.check_field(typing.List, custom_fields.IterableField, {'container': list})
+        self.check_field(list, custom_fields.IterableField, {})
+
+        self.check_field(typing.Mapping[str, int], custom_fields.MappingField, {'child': fields.IntegerField})
+        self.check_field(typing.OrderedDict[str, int], custom_fields.MappingField,
+                         {'child': fields.IntegerField, 'container': collections.OrderedDict})
+        self.check_field(typing.Dict[str, int], custom_fields.MappingField,
+                         {'child': fields.IntegerField, 'container': dict})
+        self.check_field(typing.Dict[str, typing.Any], custom_fields.MappingField, {'container': dict})
+        self.check_field(typing.Dict[str, var_type], custom_fields.MappingField, {'container': dict})
+        self.check_field(typing.Dict, custom_fields.MappingField, {'container': dict})
+        self.check_field(dict, custom_fields.MappingField, {})
+
+        self.check_field(typing.Optional[typing.List[str]], custom_fields.IterableField,
+                         {'allow_null': True, 'child': fields.CharField, 'container': list})
+        self.check_field(typing.Optional[typing.Dict[str, int]], custom_fields.MappingField,
+                         {'allow_null': True, 'child': fields.IntegerField, 'container': dict})
 
         # check that kwargs generated for the child field are actually applied
         _, list_kwargs = self.build_typed_field(typing.List[typing.Optional[str]])
@@ -97,15 +102,18 @@ class FieldsTest(unittest.TestCase):
 
     @unittest.skipIf(sys.version_info < (3, 9, 0), 'Python 3.9 required')
     def test_composite_pep585(self):
-        self.check_field(abc.Iterable[str], fields.ListField, {'child': fields.CharField})
-        self.check_field(abc.Sequence[str], fields.ListField, {'child': fields.CharField})
-        self.check_field(tuple[str], fields.ListField, {'child': fields.CharField})
-        self.check_field(list[str], fields.ListField, {'child': fields.CharField})
-        self.check_field(list[typing.Any], fields.ListField, {})
-
-        self.check_field(abc.Mapping[str, int], fields.DictField, {'child': fields.IntegerField})
-        self.check_field(dict[str, int], fields.DictField, {'child': fields.IntegerField})
-        self.check_field(dict[str, typing.Any], fields.DictField, {})
+        self.check_field(abc.Iterable[str], custom_fields.IterableField, {'child': fields.CharField})
+        self.check_field(abc.Sequence[str], custom_fields.IterableField, {'child': fields.CharField})
+        self.check_field(tuple[str], custom_fields.IterableField, {'child': fields.CharField, 'container': tuple})
+        self.check_field(set[str], custom_fields.IterableField, {'child': fields.CharField, 'container': set})
+        self.check_field(list[str], custom_fields.IterableField, {'child': fields.CharField, 'container': list})
+        self.check_field(list[typing.Any], custom_fields.IterableField, {'container': list})
+
+        self.check_field(abc.Mapping[str, int], custom_fields.MappingField, {'child': fields.IntegerField})
+        self.check_field(collections.OrderedDict[str, int], custom_fields.MappingField,
+                         {'child': fields.IntegerField, 'container': collections.OrderedDict})
+        self.check_field(dict[str, int], custom_fields.MappingField, {'child': fields.IntegerField, 'container': dict})
+        self.check_field(dict[str, typing.Any], custom_fields.MappingField, {'container': dict})
 
         # check that kwargs generated for the child field are actually applied
         _, list_kwargs = self.build_typed_field(list[typing.Optional[str]])
@@ -119,8 +127,10 @@ class FieldsTest(unittest.TestCase):
 
     @unittest.skipIf(sys.version_info < (3, 10, 0), 'Python 3.10 required')
     def test_composite_pep604(self):
-        self.check_field(typing.List[str] | None, fields.ListField)
-        self.check_field(typing.Dict[str, int] | None, fields.DictField)
+        self.check_field(typing.List[str] | None, custom_fields.IterableField,
+                         {'child': fields.CharField, 'allow_null': True, 'container': list})
+        self.check_field(typing.Dict[str, int] | None, custom_fields.MappingField,
+                         {'child': fields.IntegerField, 'allow_null': True, 'container': dict})
 
     def test_nested(self):
         refclass = dataclasses.make_dataclass('ReferencedDataclass', [])
@@ -153,6 +163,11 @@ class FieldsTest(unittest.TestCase):
         self.check_field(Person, relations.PrimaryKeyRelatedField,
                          {'queryset': Person._default_manager})
 
+        # test changing serializer_related_field, and a hyperlinked field which triggers a different code path
+        DataclassSerializer.serializer_related_field = relations.HyperlinkedRelatedField
+        self.check_field(Person, relations.HyperlinkedRelatedField,
+                         {'queryset': Person._default_manager, 'view_name': 'person-detail'})
+
     def test_literal(self):
         self.check_field(Literal['a', 'b'], fields.ChoiceField,
                          {'choices': ['a', 'b'], 'allow_blank': False})
diff --git a/tests/test_fields.py b/tests/test_fields.py
index 58d08a5..56c532c 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -1,10 +1,11 @@
+import collections
 import enum
 
 from unittest import TestCase
 
 from rest_framework.exceptions import ValidationError
 
-from rest_framework_dataclasses.fields import DefaultDecimalField, EnumField
+from rest_framework_dataclasses.fields import DefaultDecimalField, EnumField, IterableField, MappingField
 
 
 class FieldTest(TestCase):
@@ -44,3 +45,18 @@ class FieldTest(TestCase):
         # check explicit specification of options
         field = EnumField(Color, choices=[('FF0000', 'RED'), ('00FF00', 'GREEN')])
         self.assertEqual(len(field.choices), 2)
+
+    def test_iterable_field(self):
+        default_field = IterableField()
+        self.assertEqual(default_field.to_internal_value(['foo', 'bar']), ['foo', 'bar'])
+
+        set_field = IterableField(container=set)
+        self.assertEqual(set_field.to_internal_value(['foo', 'bar', 'baz']), {'foo', 'bar', 'baz'})
+
+    def test_mapping_field(self):
+        default_field = MappingField()
+        self.assertEqual(default_field.to_internal_value({'foo': 'bar'}), {'foo': 'bar'})
+
+        ordered_field = MappingField(container=collections.OrderedDict)
+        ordered_values = {'foo': 'bar', 'abc': 'def'}
+        self.assertEqual(ordered_field.to_internal_value(ordered_values), collections.OrderedDict(ordered_values))