Repository: pynamodb/PynamoDB

Issue:
Why MapAttribute is required for ListAttribute?
I think `ListAttribute` could get common`Attribute` like `UnicodeAttribute`
But it couldn't.

On pynamodb/attributes.py#L560-563
```python
        if of:
            if not issubclass(of, MapAttribute):
                raise ValueError("'of' must be subclass of MapAttribute")
            self.element_type = of
```

And so on, in this document section, http://pynamodb.readthedocs.io/en/latest/attributes.html#list-attributes

```python
from pynamodb.attributes import ListAttribute, NumberAttribute, UnicodeAttribute

class Office(Model):
    class Meta:
        table_name = 'OfficeModel'
    office_id = NumberAttribute(hash_key=True)
    employees = ListAttribute(of=UnicodeAttribute)
```

I wanna know what a right intent is. Thanks

Discussion:
jpinner-lyft: ```
>>> from pynamodb.attributes import UnicodeAttribute, UnicodeSetAttribute, ListAttribute
>>> from pynamodb.models import Model
>>> class UnicodeSetListModel(Model):
...     foo = UnicodeAttribute(hash_key=True)
...     usl = ListAttribute(of=UnicodeSetAttribute)
... 
>>> uslm = UnicodeSetListModel()
>>> uslm.foo = 'foo'
>>> uslm.usl = [{'Hello'}, {'World'}, {'!'}]
>>> uslm._serialize()
{'attributes': {'usl': {'L': [{'SS': ['Hello']}, {'SS': ['World']}, {'SS': ['!']}]}}, 'HASH': 'foo'}
>>>
>>> uslm._deserialize({'usl': {'L': [{'SS': ['Hello']}, {'SS': ['World', '!']}]}})
>>> uslm.usl
[{'Hello'}, {'World', '!'}]
>>>
>>> uslm._deserialize({'usl': {'L': [{'SS': ['Hello']}, {'S': 'World!'}]}})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/jpinner/workspace/PynamoDB/pynamodb/models.py", line 1128, in _deserialize
    value = attr.deserialize(value)
  File "/Users/jpinner/workspace/PynamoDB/pynamodb/attributes.py", line 975, in deserialize
    raise ValueError("Cannot deserialize elements of type: {}".format(attr_type))
ValueError: Cannot deserialize elements of type: S
```

Patch:
diff --git a/docs/attributes.rst b/docs/attributes.rst
index ac216c2..8ae75d9 100644
--- a/docs/attributes.rst
+++ b/docs/attributes.rst
@@ -134,7 +134,7 @@ Creating an untyped list is done like so:
     GroceryList(store_name='Haight Street Market',
                 groceries=['bread', 1, 'butter', 6, 'milk', 1])
 
-PynamoDB can provide type safety if it is required. Currently PynamoDB does not allow type checks on anything other than ``MapAttribute`` and subclasses of ``MapAttribute``. We're working on adding more generic type checking in a future version.
+PynamoDB can provide type safety if it is required. Currently PynamoDB does not allow type checks on anything other than subclasses of ``Attribute``. We're working on adding more generic type checking in a future version.
 When defining your model use the ``of=`` kwarg and pass in a class. PynamoDB will check that all items in the list are of the type you require.
 
 .. code-block:: python
diff --git a/docs/release_notes.rst b/docs/release_notes.rst
index a2c53a1..fc3ad79 100644
--- a/docs/release_notes.rst
+++ b/docs/release_notes.rst
@@ -9,6 +9,7 @@ v5.0 (unreleased)
   cannot contain range keys, this had limited utility to begin with, and would sometimes cause confusing
   "'filter_condition' cannot contain key attributes" errors.
 * Replace the internal attribute type constants with their "short" DynamoDB version (#827)
+* Typed list attributes can now support any Attribute subclass (#833)
 
 v4.3.3
 ----------
diff --git a/pynamodb/attributes.py b/pynamodb/attributes.py
index cd2379b..800a590 100644
--- a/pynamodb/attributes.py
+++ b/pynamodb/attributes.py
@@ -874,7 +874,7 @@ def _get_value_for_deserialize(value):
 
 
 def _get_class_for_deserialize(value):
-    value_type = list(value.keys())[0]
+    value_type = next(iter(value.keys()))
     if value_type not in DESERIALIZE_CLASS_MAP:
         raise ValueError('Unknown value: ' + str(value))
     return DESERIALIZE_CLASS_MAP[value_type]
@@ -922,7 +922,7 @@ class ListAttribute(Generic[_T], Attribute[List[_T]]):
         null: Optional[bool] = None,
         default: Optional[Union[Any, Callable[..., Any]]] = None,
         attr_name: Optional[str] = None,
-        of: Optional[Type[_T]]=None,
+        of: Optional[Type[_T]] = None,
     ) -> None:
         super().__init__(
             hash_key=hash_key,
@@ -932,8 +932,8 @@ class ListAttribute(Generic[_T], Attribute[List[_T]]):
             attr_name=attr_name,
         )
         if of:
-            if not issubclass(of, MapAttribute):
-                raise ValueError("'of' must be subclass of MapAttribute")
+            if not issubclass(of, Attribute):
+                raise ValueError("'of' must be a subclass of Attribute")
             self.element_type = of
 
     def remove_indexes(self, *indexes):
@@ -947,10 +947,16 @@ class ListAttribute(Generic[_T], Attribute[List[_T]]):
         """
         rval = []
         for v in values:
-            attr_class = (self.element_type()
-                          if self.element_type
-                          else _get_class_for_serialize(v))
-            rval.append({attr_class.attr_type: attr_class.serialize(v)})
+            attr_class = self._get_serialize_class(v)
+            if self.element_type and v is not None and not isinstance(attr_class, self.element_type):
+                raise ValueError("List elements must be of type: {}".format(self.element_type.__name__))
+            attr_type = attr_class.attr_type
+            attr_value = attr_class.serialize(v)
+            if attr_value is None:
+                # When attribute values serialize to "None" (e.g. empty sets) we store {"NULL": True} in DynamoDB.
+                attr_type = NULL
+                attr_value = True
+            rval.append({attr_type: attr_value})
         return rval
 
     def deserialize(self, values):
@@ -959,23 +965,44 @@ class ListAttribute(Generic[_T], Attribute[List[_T]]):
         """
         deserialized_lst = []
         for v in values:
-            class_for_deserialize = self.element_type() if self.element_type else _get_class_for_deserialize(v)
-            attr_value = _get_value_for_deserialize(v)
-            deserialized_lst.append(class_for_deserialize.deserialize(attr_value))
+            attr_type, attr_value = next(iter(v.items()))
+            attr_class = self._get_deserialize_class(attr_type)
+            if attr_class.attr_type != attr_type:
+                raise ValueError("Cannot deserialize {} elements from type: {}".format(
+                    attr_class.__class__.__name__, attr_type))
+            deserialized_lst.append(attr_class.deserialize(attr_value))
         return deserialized_lst
 
     def __getitem__(self, idx: int) -> Path:
         # for typing only
         return super().__getitem__(idx)
 
+    def _get_serialize_class(self, value):
+        if value is None:
+            return NullAttribute()
+        if isinstance(value, Attribute):
+            return value
+        if self.element_type:
+            return self.element_type()
+        return SERIALIZE_CLASS_MAP[type(value)]
+
+    def _get_deserialize_class(self, attr_type):
+        if self.element_type and attr_type != NULL:
+            return self.element_type()
+        return DESERIALIZE_CLASS_MAP[attr_type]
+
 
 DESERIALIZE_CLASS_MAP: Dict[str, Attribute] = {
+    BINARY: BinaryAttribute(),
+    BINARY_SET: BinarySetAttribute(),
+    BOOLEAN: BooleanAttribute(),
     LIST: ListAttribute(),
+    MAP: MapAttribute(),
+    NULL: NullAttribute(),
     NUMBER: NumberAttribute(),
+    NUMBER_SET: NumberSetAttribute(),
     STRING: UnicodeAttribute(),
-    BOOLEAN: BooleanAttribute(),
-    MAP: MapAttribute(),
-    NULL: NullAttribute()
+    STRING_SET: UnicodeSetAttribute()
 }
 
 SERIALIZE_CLASS_MAP = {


Test patch:
diff --git a/tests/test_attributes.py b/tests/test_attributes.py
index 9922fb6..1b1061e 100644
--- a/tests/test_attributes.py
+++ b/tests/test_attributes.py
@@ -903,6 +903,37 @@ class TestValueDeserialize:
         assert actual is None
 
 
+class TestListAttribute:
+
+    def test_untyped_list(self):
+        untyped_list = [{'Hello': 'World'}, ['!'], {'foo', 'bar'}, None, "", 0, False]
+        serialized = ListAttribute().serialize(untyped_list)
+        # set attributes are serialized as lists
+        untyped_list[2] = list(untyped_list[2])
+        assert ListAttribute().deserialize(serialized) == untyped_list
+
+    def test_list_of_strings(self):
+        string_list_attribute = ListAttribute(of=UnicodeAttribute)
+        string_list = ['foo', 'bar', 'baz']
+        serialized = string_list_attribute.serialize(string_list)
+        assert string_list_attribute.deserialize(serialized) == string_list
+
+    def test_list_type_error(self):
+        string_list_attribute = ListAttribute(of=UnicodeAttribute)
+
+        with pytest.raises(ValueError):
+            string_list_attribute.serialize([MapAttribute(foo='bar')])
+
+        with pytest.raises(ValueError):
+            string_list_attribute.deserialize([{'M': {'foo': {'S': 'bar'}}}])
+
+    def test_serialize_null(self):
+        string_set_list_attribute = ListAttribute(of=UnicodeSetAttribute)
+        list_with_empty_set = [{'foo'}, {}, None]
+        serialized = string_set_list_attribute.serialize(list_with_empty_set)
+        assert string_set_list_attribute.deserialize(serialized) == [{'foo'}, None, None]
+
+
 class TestMapAndListAttribute:
 
     def test_map_of_list(self):