Repository: vega/altair

Issue:
BUG: deserialization of objects containing lists can lead to errors
I've only been able to make this happen with the fold transform:
```python
chart = alt.Chart('data.txt').mark_point().transform_fold(
    ['y1', 'y2'],
    as_=['key', 'value']
)

dct = chart.to_dict()
alt.Chart.from_dict(dct)
```
```pytb
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-3-c02fc13e6329> in <module>()
      6 dct = chart.to_dict()
      7 
----> 8 alt.Chart.from_dict(dct)

/usr/local/lib/python3.6/dist-packages/altair/vegalite/v3/api.py in from_dict(cls, dct, validate)
   1520         # First try from_dict for the Chart type
   1521         try:
-> 1522             return super(Chart, cls).from_dict(dct, validate=validate)
   1523         except jsonschema.ValidationError:
   1524             pass

/usr/local/lib/python3.6/dist-packages/altair/utils/schemapi.py in from_dict(cls, dct, validate, _wrapper_classes)
    339         converter = _FromDict(_wrapper_classes)
    340         return converter.from_dict(constructor=cls, root=cls,
--> 341                                    schema=cls._schema, dct=dct)
    342 
    343     @classmethod

/usr/local/lib/python3.6/dist-packages/altair/utils/schemapi.py in from_dict(self, constructor, root, schema, dct)
    492                 if key in props:
    493                     prop_constructor, prop_schema = _get_constructor(props[key])
--> 494                     val = self.from_dict(prop_constructor, root, prop_schema, val)
    495                 kwds[key] = val
    496             return constructor(**kwds)

/usr/local/lib/python3.6/dist-packages/altair/utils/schemapi.py in from_dict(self, constructor, root, schema, dct)
    504                 item_constructor = self._passthrough
    505             dct = [self.from_dict(item_constructor, root, item_schema, val)
--> 506                    for val in dct]
    507             return constructor(dct)
    508         else:

/usr/local/lib/python3.6/dist-packages/altair/utils/schemapi.py in <listcomp>(.0)
    504                 item_constructor = self._passthrough
    505             dct = [self.from_dict(item_constructor, root, item_schema, val)
--> 506                    for val in dct]
    507             return constructor(dct)
    508         else:

/usr/local/lib/python3.6/dist-packages/altair/utils/schemapi.py in from_dict(self, constructor, root, schema, dct)
    483                     continue
    484                 else:
--> 485                     return self.from_dict(this_constructor, root, this_schema, dct)
    486 
    487         if isinstance(dct, dict):

/usr/local/lib/python3.6/dist-packages/altair/utils/schemapi.py in from_dict(self, constructor, root, schema, dct)
    492                 if key in props:
    493                     prop_constructor, prop_schema = _get_constructor(props[key])
--> 494                     val = self.from_dict(prop_constructor, root, prop_schema, val)
    495                 kwds[key] = val
    496             return constructor(**kwds)

/usr/local/lib/python3.6/dist-packages/altair/utils/schemapi.py in from_dict(self, constructor, root, schema, dct)
    499             if 'items' in schema:
    500                 item_schema = schema['items']
--> 501                 item_constructor, item_schema = _get_constructor(item_schema)
    502             else:
    503                 item_schema = {}

/usr/local/lib/python3.6/dist-packages/altair/utils/schemapi.py in _get_constructor(schema)
    468         def _get_constructor(schema):
    469             # TODO: do something more than simply selecting the last match?
--> 470             hash_ = self.hash_schema(schema)
    471             matches = self.class_dict[hash_]
    472             constructor = matches[-1] if matches else self._passthrough

/usr/local/lib/python3.6/dist-packages/altair/utils/schemapi.py in hash_schema(cls, schema, use_json)
    432         """
    433         if cls._hash_exclude_keys:
--> 434             schema = {key: val for key, val in schema.items()
    435                       if key not in cls._hash_exclude_keys}
    436         if use_json:

AttributeError: 'list' object has no attribute 'items'
```


Discussion:
Simpler reproduction:
```python
alt.FoldTransform.from_dict({'fold': ['a', 'b'], 'as': ['key', 'value']})
```

Resolving patch:
diff --git a/altair/utils/schemapi.py b/altair/utils/schemapi.py
--- a/altair/utils/schemapi.py
+++ b/altair/utils/schemapi.py
@@ -453,7 +453,7 @@ def hash_schema(cls, schema, use_json=True):
         on recursive conversions of unhashable to hashable types; the former seems
         to be slightly faster in several benchmarks.
         """
-        if cls._hash_exclude_keys:
+        if cls._hash_exclude_keys and isinstance(schema, dict):
             schema = {key: val for key, val in schema.items()
                       if key not in cls._hash_exclude_keys}
         if use_json:
diff --git a/tools/schemapi/schemapi.py b/tools/schemapi/schemapi.py
--- a/tools/schemapi/schemapi.py
+++ b/tools/schemapi/schemapi.py
@@ -449,7 +449,7 @@ def hash_schema(cls, schema, use_json=True):
         on recursive conversions of unhashable to hashable types; the former seems
         to be slightly faster in several benchmarks.
         """
-        if cls._hash_exclude_keys:
+        if cls._hash_exclude_keys and isinstance(schema, dict):
             schema = {key: val for key, val in schema.items()
                       if key not in cls._hash_exclude_keys}
         if use_json:


Test patch:
diff --git a/altair/utils/tests/test_schemapi.py b/altair/utils/tests/test_schemapi.py
--- a/altair/utils/tests/test_schemapi.py
+++ b/altair/utils/tests/test_schemapi.py
@@ -30,7 +30,8 @@ class MySchema(_TestSchema):
             'b2': {'type': 'array', 'items': {'type': 'number'}},
             'c': {'type': ['string', 'number']},
             'd': {'anyOf': [{'$ref': '#/definitions/StringMapping'},
-                            {'$ref': '#/definitions/StringArray'}]}
+                            {'$ref': '#/definitions/StringArray'}]},
+            'e': {'items': [{'type': 'string'}, {'type': 'string'}]}
         }
     }
 
@@ -114,7 +115,7 @@ class InvalidProperties(_TestSchema):
 def test_construct_multifaceted_schema():
     dct = {'a': {'foo': 'bar'}, 'a2': {'foo': 42},
        'b': ['a', 'b', 'c'], 'b2': [1, 2, 3], 'c': 42,
-       'd': ['x', 'y', 'z']}
+       'd': ['x', 'y', 'z'], 'e': ['a', 'b']}
 
     myschema = MySchema.from_dict(dct)
     assert myschema.to_dict() == dct
@@ -256,7 +257,7 @@ def test_attribute_error():
 def test_to_from_json():
     dct = {'a': {'foo': 'bar'}, 'a2': {'foo': 42},
            'b': ['a', 'b', 'c'], 'b2': [1, 2, 3], 'c': 42,
-           'd': ['x', 'y', 'z']}
+           'd': ['x', 'y', 'z'], 'e': ['g', 'h']}
     json_str = MySchema.from_dict(dct).to_json()
     new_dct = MySchema.from_json(json_str).to_dict()
 
diff --git a/tools/schemapi/tests/test_schemapi.py b/tools/schemapi/tests/test_schemapi.py
--- a/tools/schemapi/tests/test_schemapi.py
+++ b/tools/schemapi/tests/test_schemapi.py
@@ -26,7 +26,8 @@ class MySchema(_TestSchema):
             'b2': {'type': 'array', 'items': {'type': 'number'}},
             'c': {'type': ['string', 'number']},
             'd': {'anyOf': [{'$ref': '#/definitions/StringMapping'},
-                            {'$ref': '#/definitions/StringArray'}]}
+                            {'$ref': '#/definitions/StringArray'}]},
+            'e': {'items': [{'type': 'string'}, {'type': 'string'}]}
         }
     }
 
@@ -110,7 +111,7 @@ class InvalidProperties(_TestSchema):
 def test_construct_multifaceted_schema():
     dct = {'a': {'foo': 'bar'}, 'a2': {'foo': 42},
        'b': ['a', 'b', 'c'], 'b2': [1, 2, 3], 'c': 42,
-       'd': ['x', 'y', 'z']}
+       'd': ['x', 'y', 'z'], 'e': ['a', 'b']}
 
     myschema = MySchema.from_dict(dct)
     assert myschema.to_dict() == dct
@@ -252,7 +253,7 @@ def test_attribute_error():
 def test_to_from_json():
     dct = {'a': {'foo': 'bar'}, 'a2': {'foo': 42},
            'b': ['a', 'b', 'c'], 'b2': [1, 2, 3], 'c': 42,
-           'd': ['x', 'y', 'z']}
+           'd': ['x', 'y', 'z'], 'e': ['g', 'h']}
     json_str = MySchema.from_dict(dct).to_json()
     new_dct = MySchema.from_json(json_str).to_dict()