Repository: vega/altair

Issue:
Unable to pickle or copy charts
I'm unable to pickle a chart. Consider this minimal example:

```python
import pickle
import altair
from vega_datasets import data
iris = data.iris()
ch = altair.Chart(iris).mark_point().encode(
    x='petalLength',
    y='petalWidth',
    color='species'
)

with open('chart.pck', 'wb') as fid:
    pickle.dump(ch, fid)

with open('chart.pck', 'rb') as fid:
    pch = pickle.load(fid)
```

Running this results in a RecursionError:
```
(py36) C:\Develop\plab>python sandbox\test_altair_pickle.py                                              
Traceback (most recent call last):                                                                       
  File "sandbox\test_altair_pickle.py", line 15, in <module>                                             
    pch = pickle.load(fid)                                                                               
  File "C:\opt\Anaconda3\envs\py36\lib\site-packages\altair\utils\schemapi.py", line 213, in __getattr__ 
    if attr in self._kwds:                                                                               
  File "C:\opt\Anaconda3\envs\py36\lib\site-packages\altair\utils\schemapi.py", line 213, in __getattr__ 
    if attr in self._kwds:                                                                               
  File "C:\opt\Anaconda3\envs\py36\lib\site-packages\altair\utils\schemapi.py", line 213, in __getattr__ 
    if attr in self._kwds:                                                                               
  [Previous line repeated 330 more times]                                                                
RecursionError: maximum recursion depth exceeded while calling a Python object                           ```
```

It can be fixed using

```python
    def __getattr__(self, attr):
        # reminder: getattr is called after the normal lookups
        if attr == '_kwds': 
            raise AttributeError()
        if attr in self._kwds:
            return self._kwds[attr]
        else:
            try:
                _getattr = super(SchemaBase, self).__getattr__
            except AttributeError:
                _getattr = super(SchemaBase, self).__getattribute__
            return _getattr(attr)
```

I can submit a pull request if desired.



Discussion:
I would avoid pickle and instead use ``to_json``/``from_json``. Pickle is quite brittle.
But the `to_json` / `from_json` will change my chart as you commented in another issue #1644. I would really like to save/load a chart from file and have an equivalent object.

The change in `__getattr__` is minimal in my opinion, I just added the two lines:
```
  if attr == '_kwds': 
            raise AttributeError()
```
which should be there anyways IMO. If you don't like the pickle argument, then maybe you buy the copy argument:

```python
import altair
from vega_datasets import data
import copy

iris = data.iris()
ch = altair.Chart(iris).mark_point().encode(
    x='petalLength',
    y='petalWidth',
    color='species'
)

cch = copy.copy(ch)
```
BAM, infinite recursion.

OK, feel free to submit a fix – the ground truth for this code is in tools/schemapi.
(FYI, ``ch.copy()`` will do the right thing, and will be the preferred method of copying a chart even after this bug is fixed)

Resolving patch:
diff --git a/altair/utils/schemapi.py b/altair/utils/schemapi.py
--- a/altair/utils/schemapi.py
+++ b/altair/utils/schemapi.py
@@ -230,6 +230,8 @@ def _get(self, attr, default=Undefined):
 
     def __getattr__(self, attr):
         # reminder: getattr is called after the normal lookups
+        if attr == '_kwds': 
+            raise AttributeError()
         if attr in self._kwds:
             return self._kwds[attr]
         else:
diff --git a/tools/schemapi/schemapi.py b/tools/schemapi/schemapi.py
--- a/tools/schemapi/schemapi.py
+++ b/tools/schemapi/schemapi.py
@@ -37,7 +37,7 @@ def debug_mode(arg):
 
 
 def _subclasses(cls):
-    """Breadth-first sequence of classes which inherit from cls."""
+    """Breadth-first sequence of all classes which inherit from cls."""
     seen = set()
     current_set = {cls}
     while current_set:
@@ -226,6 +226,8 @@ def _get(self, attr, default=Undefined):
 
     def __getattr__(self, attr):
         # reminder: getattr is called after the normal lookups
+        if attr == '_kwds': 
+            raise AttributeError()
         if attr in self._kwds:
             return self._kwds[attr]
         else:
@@ -494,7 +496,9 @@ def _passthrough(*args, **kwds):
             return args[0] if args else kwds
 
         if cls is None:
-            # TODO: do something more than simply selecting the last match?
+            # If there are multiple matches, we use the last one in the dict.
+            # Our class dict is constructed breadth-first from top to bottom,
+            # so the last class that matches is the most specific.
             matches = self.class_dict[self.hash_schema(schema)]
             cls = matches[-1] if matches else _passthrough
         schema = _resolve_references(schema, rootschema)


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
@@ -2,7 +2,10 @@
 #
 # The contents of this file are automatically written by
 # tools/generate_schema_wrapper.py. Do not modify directly.
+import copy
+import io
 import jsonschema
+import pickle
 import pytest
 
 from ..schemapi import (UndefinedType, SchemaBase, Undefined, _FromDict,
@@ -211,11 +214,16 @@ def test_undefined_singleton():
     assert Undefined is UndefinedType()
 
 
-def test_copy():
-    dct = {'a': {'foo': 'bar'}, 'a2': {'foo': 42},
-       'b': ['a', 'b', 'c'], 'b2': [1, 2, 3], 'c': 42,
-       'd': ['x', 'y', 'z']}
+@pytest.fixture
+def dct():
+    return {
+        'a': {'foo': 'bar'}, 'a2': {'foo': 42},
+        'b': ['a', 'b', 'c'], 'b2': [1, 2, 3], 'c': 42,
+        'd': ['x', 'y', 'z']
+    }
+
 
+def test_copy_method(dct):
     myschema = MySchema.from_dict(dct)
 
     # Make sure copy is deep
@@ -246,6 +254,16 @@ def test_copy():
     assert mydct['c'] == dct['c']
 
 
+def test_copy_module(dct):
+    myschema = MySchema.from_dict(dct)
+
+    cp = copy.deepcopy(myschema)
+    cp['a']['foo'] = 'new value'
+    cp['b'] = ['A', 'B', 'C']
+    cp['c'] = 164
+    assert myschema.to_dict() == dct
+
+
 def test_attribute_error():
     m = MySchema()
     with pytest.raises(AttributeError) as err:
@@ -254,16 +272,23 @@ def test_attribute_error():
                               "'invalid_attribute'")
 
 
-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'], 'e': ['g', 'h']}
+def test_to_from_json(dct):
     json_str = MySchema.from_dict(dct).to_json()
     new_dct = MySchema.from_json(json_str).to_dict()
 
     assert new_dct == dct
 
 
+def test_to_from_pickle(dct):
+    myschema = MySchema.from_dict(dct)
+    output = io.BytesIO()
+    pickle.dump(myschema, output)
+    output.seek(0)
+    myschema_new = pickle.load(output)
+
+    assert myschema_new.to_dict() == dct
+
+
 def test_class_with_no_schema():
     class BadSchema(SchemaBase):
         pass
@@ -298,3 +323,4 @@ def test_schema_validation_error():
     assert 'test_schemapi.MySchema->a' in message
     assert "validating {!r}".format(the_err.validator) in message
     assert the_err.message in message
+
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
@@ -1,4 +1,7 @@
+import copy
+import io
 import jsonschema
+import pickle
 import pytest
 
 from ..schemapi import (UndefinedType, SchemaBase, Undefined, _FromDict,
@@ -207,11 +210,16 @@ def test_undefined_singleton():
     assert Undefined is UndefinedType()
 
 
-def test_copy():
-    dct = {'a': {'foo': 'bar'}, 'a2': {'foo': 42},
-       'b': ['a', 'b', 'c'], 'b2': [1, 2, 3], 'c': 42,
-       'd': ['x', 'y', 'z']}
+@pytest.fixture
+def dct():
+    return {
+        'a': {'foo': 'bar'}, 'a2': {'foo': 42},
+        'b': ['a', 'b', 'c'], 'b2': [1, 2, 3], 'c': 42,
+        'd': ['x', 'y', 'z']
+    }
+
 
+def test_copy_method(dct):
     myschema = MySchema.from_dict(dct)
 
     # Make sure copy is deep
@@ -242,6 +250,16 @@ def test_copy():
     assert mydct['c'] == dct['c']
 
 
+def test_copy_module(dct):
+    myschema = MySchema.from_dict(dct)
+
+    cp = copy.deepcopy(myschema)
+    cp['a']['foo'] = 'new value'
+    cp['b'] = ['A', 'B', 'C']
+    cp['c'] = 164
+    assert myschema.to_dict() == dct
+
+
 def test_attribute_error():
     m = MySchema()
     with pytest.raises(AttributeError) as err:
@@ -250,16 +268,23 @@ def test_attribute_error():
                               "'invalid_attribute'")
 
 
-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'], 'e': ['g', 'h']}
+def test_to_from_json(dct):
     json_str = MySchema.from_dict(dct).to_json()
     new_dct = MySchema.from_json(json_str).to_dict()
 
     assert new_dct == dct
 
 
+def test_to_from_pickle(dct):
+    myschema = MySchema.from_dict(dct)
+    output = io.BytesIO()
+    pickle.dump(myschema, output)
+    output.seek(0)
+    myschema_new = pickle.load(output)
+
+    assert myschema_new.to_dict() == dct
+
+
 def test_class_with_no_schema():
     class BadSchema(SchemaBase):
         pass
@@ -294,3 +319,4 @@ def test_schema_validation_error():
     assert 'test_schemapi.MySchema->a' in message
     assert "validating {!r}".format(the_err.validator) in message
     assert the_err.message in message
+