Repository: altair-viz/altair
Pull request #422 (2017-10-20 19:18:21)

MAINT: push finalize behavior upstream to schemapi


Patch:
diff --git a/altair/v1/api.py b/altair/v1/api.py
index 16e1f934..f1e9c551 100644
--- a/altair/v1/api.py
+++ b/altair/v1/api.py
@@ -60,7 +60,7 @@ class MaxRowsExceeded(Exception):
 
 class FieldError(Exception):
     """Raised if a channel has a field related error.
-    
+
     This is raised if a channel has no field name or if the field name is
     not found as the column name of the ``DataFrame``.
     """
@@ -286,7 +286,7 @@ class TopLevelMixin(object):
         spec : dict
             The JSON specification of the chart object.
         """
-        dct = super(TopLevelMixin, self.clone()).to_dict(data=data, validate_columns=validate_columns)
+        dct = super(TopLevelMixin, self).to_dict(data=data, validate_columns=validate_columns)
         dct['$schema'] = schema.vegalite_schema_url
         return dct
 
@@ -536,7 +536,7 @@ class TopLevelMixin(object):
                 else:
                     self.transform_data(filter=filters)
 
-        # If self.data is a pd.DataFrame, check to see if data has too many rows. 
+        # If self.data is a pd.DataFrame, check to see if data has too many rows.
         if isinstance(self.data, pd.DataFrame):
             if len(self.data) > self.max_rows:
                 raise MaxRowsExceeded(
@@ -551,7 +551,7 @@ class TopLevelMixin(object):
 
     def _validate_columns(self):
         """Validate the columns in the encoding, but only if if the data is a ``DataFrame``.
-        
+
         This has to be called after the rest of the ``_finalize()`` logic, which fills in the
         shortcut field names and also processes the expressions for computed fields.
 
@@ -561,7 +561,7 @@ class TopLevelMixin(object):
         2. That the specified field name is present the column names of the ``DataFrame`` or
            computed field from transform expressions.
 
-        This logic only runs when the dataset is a ``DataFrame``. 
+        This logic only runs when the dataset is a ``DataFrame``.
         """
 
         # Only validate columns if the data is a pd.DataFrame.
diff --git a/altair/v1/schema/_interface/__init__.py b/altair/v1/schema/_interface/__init__.py
index 3b9763e0..182fc711 100644
--- a/altair/v1/schema/_interface/__init__.py
+++ b/altair/v1/schema/_interface/__init__.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 # Auto-generated by schemapi: do not modify file directly
-# - schemapi version: 0.3.0
-# - date:    2017-10-18 12:48:51
+# - schemapi version: 0.4.0.dev0+7fad5ec
+# - date:    2017-10-20 13:00:58
 
 from .schema import Root
 from .schema import AggregateOp
diff --git a/altair/v1/schema/_interface/channel_collections.py b/altair/v1/schema/_interface/channel_collections.py
index 625af0dd..ffec93f9 100644
--- a/altair/v1/schema/_interface/channel_collections.py
+++ b/altair/v1/schema/_interface/channel_collections.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 # Auto-generated file: do not modify directly
-# - altair version info: v1.2.0-167-gac441ae
-# - date: 2017-10-18 12:48:51
+# - altair version info: v1.2.1-20-g5752f2f
+# - date: 2017-10-20 13:00:58
 
 import traitlets as T
 from . import jstraitlets as jst
diff --git a/altair/v1/schema/_interface/channel_wrappers.py b/altair/v1/schema/_interface/channel_wrappers.py
index 644b0232..eff7a6ed 100644
--- a/altair/v1/schema/_interface/channel_wrappers.py
+++ b/altair/v1/schema/_interface/channel_wrappers.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 # Auto-generated file: do not modify directly
-# - altair version info: v1.2.0-167-gac441ae
-# - date: 2017-10-18 12:48:51
+# - altair version info: v1.2.1-20-g5752f2f
+# - date: 2017-10-20 13:00:58
 
 import pandas as pd
 
diff --git a/altair/v1/schema/_interface/jstraitlets.py b/altair/v1/schema/_interface/jstraitlets.py
index d58012ef..92e7b502 100644
--- a/altair/v1/schema/_interface/jstraitlets.py
+++ b/altair/v1/schema/_interface/jstraitlets.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 # Auto-generated by schemapi: do not modify file directly
-# - schemapi version: 0.3.0
-# - date:    2017-10-18 12:48:51
+# - schemapi version: 0.4.0.dev0+7fad5ec
+# - date:    2017-10-20 13:00:58
 
 """
 Extensions to traitlets for compatibility with JSON Schema
@@ -19,7 +19,6 @@ instances can be instantiated from and serialized to dictionaries of values.
 """
 import copy
 import json
-import textwrap
 
 import six
 import traitlets as T
@@ -29,6 +28,27 @@ from traitlets.utils.importstring import import_item
 __jsonschema_draft__ = 4
 
 
+def textwrap_indent(text, prefix, predicate=None):
+    """Adds 'prefix' to the beginning of selected lines in 'text'.
+
+    If 'predicate' is provided, 'prefix' will only be added to the lines
+    where 'predicate(line)' is True. If 'predicate' is not provided,
+    it will default to adding 'prefix' to all non-empty lines that do not
+    consist solely of whitespace characters.
+
+    Note: this function is built-in to Python 3.3+ as textwrap.indent().
+    We define it here for Python 2.X compatibility
+    """
+    if predicate is None:
+        def predicate(line):
+            return line.strip()
+
+    def prefixed_lines():
+        for line in text.splitlines(True):
+            yield (prefix + line if predicate(line) else line)
+    return ''.join(prefixed_lines())
+
+
 class UndefinedTraitError(T.TraitError):
     """Exception to raise when a required trait is undefined"""
     pass
@@ -116,7 +136,9 @@ class JSONHasTraits(T.HasTraits):
 
         kwds = {name: _clone(getattr(self, name))
                 for name in self.trait_names()}
-        return self.__class__(**kwds)
+        obj =  self.__class__(**kwds)
+        obj._metadata = self._metadata.copy()
+        return obj
 
     @classmethod
     def _get_additional_traits(cls):
@@ -150,9 +172,10 @@ class JSONHasTraits(T.HasTraits):
 
     def to_dict(self, **kwargs):
         """Output a (nested) dict encoding the contents of this instance"""
-        self._finalize(**kwargs)
+        obj = self.clone()
+        obj._finalize(**kwargs)
         Visitor = self._converter_registry.get('to_dict', ToDict)
-        return Visitor().visit(self, **kwargs)
+        return Visitor().visit(obj, **kwargs)
 
     @classmethod
     def from_json(cls, json_string, json_kwds=None, **kwargs):
@@ -166,10 +189,12 @@ class JSONHasTraits(T.HasTraits):
         return json.dumps(dct, **(json_kwds or {}))
 
     def to_python(self, **kwargs):
+        obj = self.clone()
+        obj._finalize(**kwargs)
         Visitor = self._converter_registry.get('to_python', ToPython)
         # Explicitly convert to str() for the sake of derived classes
-        # which return a more complicated code representation object.
-        return str(Visitor().visit(self, **kwargs))
+        # which may return a custom code representation object.
+        return str(Visitor().visit(obj, **kwargs))
 
     def __dir__(self):
         """Customize tab completed attributes."""
@@ -817,7 +842,7 @@ class ToPython(Visitor):
     def visit_list(self, obj, *args, **kwargs):
         # TODO: make more compact for simple args?
         arglist = ',\n'.join(self.visit(item) for item in obj)
-        return "[\n{0}\n]".format(textwrap.indent(arglist, 4 * ' '))
+        return "[\n{0}\n]".format(textwrap_indent(arglist, 4 * ' '))
 
     def visit_JSONHasTraits(self, obj, *args, **kwargs):
         # TODO: make more compact for simple args?
@@ -833,4 +858,4 @@ class ToPython(Visitor):
         arglist = '\n'.join('{0}={1},'.format(*item)
                             for item in sorted(kwds.items())).rstrip(',')
         return "{0}(\n{1}\n)".format(obj.__class__.__name__,
-                                     textwrap.indent(arglist, 4 * " "))
+                                     textwrap_indent(arglist, 4 * " "))
diff --git a/altair/v1/schema/_interface/named_channels.py b/altair/v1/schema/_interface/named_channels.py
index 29bf06de..19e857fe 100644
--- a/altair/v1/schema/_interface/named_channels.py
+++ b/altair/v1/schema/_interface/named_channels.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 # Auto-generated file: do not modify directly
-# - altair version info: v1.2.0-167-gac441ae
-# - date: 2017-10-18 12:48:51
+# - altair version info: v1.2.1-20-g5752f2f
+# - date: 2017-10-20 13:00:58
 
 from . import channel_wrappers
 
diff --git a/altair/v1/schema/_interface/schema.py b/altair/v1/schema/_interface/schema.py
index 3ec042ff..d2079ce0 100644
--- a/altair/v1/schema/_interface/schema.py
+++ b/altair/v1/schema/_interface/schema.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 # Auto-generated by schemapi: do not modify file directly
-# - schemapi version: 0.3.0
-# - date:    2017-10-18 12:48:51
+# - schemapi version: 0.4.0.dev0+7fad5ec
+# - date:    2017-10-20 13:00:58
 
 
 
diff --git a/altair/v1/schema/visitors.py b/altair/v1/schema/visitors.py
index 262ec33c..a2a92149 100644
--- a/altair/v1/schema/visitors.py
+++ b/altair/v1/schema/visitors.py
@@ -1,3 +1,5 @@
+import warnings
+
 import traitlets as T
 import pandas as pd
 


Test patch:
diff --git a/altair/v1/schema/_interface/tests/__init__.py b/altair/v1/schema/_interface/tests/__init__.py
index ad0cc5d1..3374d11f 100644
--- a/altair/v1/schema/_interface/tests/__init__.py
+++ b/altair/v1/schema/_interface/tests/__init__.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
 # Auto-generated by schemapi: do not modify file directly
-# - schemapi version: 0.3.0
-# - date:    2017-10-18 12:48:51
+# - schemapi version: 0.4.0.dev0+7fad5ec
+# - date:    2017-10-20 13:00:58
 
diff --git a/altair/v1/schema/_interface/tests/test_jstraitlets.py b/altair/v1/schema/_interface/tests/test_jstraitlets.py
index 1b882f3d..72cc63ab 100644
--- a/altair/v1/schema/_interface/tests/test_jstraitlets.py
+++ b/altair/v1/schema/_interface/tests/test_jstraitlets.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 # Auto-generated by schemapi: do not modify file directly
-# - schemapi version: 0.3.0
-# - date:    2017-10-18 12:48:51
+# - schemapi version: 0.4.0.dev0+7fad5ec
+# - date:    2017-10-20 13:00:58
 
 import pytest
 
@@ -356,3 +356,25 @@ def test_clone():
 
     assert obj1.to_dict() != D  # original object is modified
     assert obj2.to_dict() == D  # clone is not modified
+
+
+def test_cloned_finalization():
+    class Foo(jst.JSONHasTraits):
+        message = jst.JSONString()
+
+        def _finalize(self):
+            self.message = "finalized"
+            return super(Foo, self)._finalize()
+
+    f = Foo(message='hello')
+    assert f.to_dict() == {'message': 'finalized'}
+    assert f.message == 'hello'
+
+    remove_whitespace = lambda s: ''.join(s.split())
+
+    # derived packages may use Py27 and may override ToPython;
+    # to play well with them we allow for the following:
+    results = ["Foo(message='finalized')", "Foo(message=u'finalized')",
+               "Foo(message='finalized',)", "Foo(message=u'finalized',)"]
+    assert remove_whitespace(f.to_python()) in results
+    assert f.message == 'hello'
diff --git a/altair/v1/tests/test_api.py b/altair/v1/tests/test_api.py
index 4d0b2743..589ec31a 100644
--- a/altair/v1/tests/test_api.py
+++ b/altair/v1/tests/test_api.py
@@ -10,7 +10,6 @@ from .. import *
 from .. import schema
 from ..schema import jstraitlets as jst
 from ..examples import iter_examples
-from ...datasets import connection_ok
 from ...utils.node import consistent_with_png, consistent_with_svg
 from ...utils._py3k_compat import PY2
 
@@ -235,10 +234,12 @@ def test_to_python_with_methods():
     assert code_in == code_out.replace(' ', '').replace('\n','')
 
 
-@pytest.mark.skipif(not connection_ok(), reason="No Internet Connection")
 def test_to_python_stocks():
     """Test a more complicated spec for conversion to altair"""
-    data = load_dataset('stocks')
+    data = pd.DataFrame({'date': ['Jan 1 2000', 'Jan 1 2000',
+                                  'Jan 2 2000', 'Jan 2 2000'],
+                          'price': [500, 100, 501, 101],
+                          'symbol': ['GOOG', 'MSFT', 'GOOG', 'MSFT']})
 
     chart = Chart(data).mark_line().encode(
         x='date:T',
@@ -386,19 +387,28 @@ def sample_code():
         return SAMPLE_CODE.strip()
 
 
-@pytest.mark.skipif(not connection_ok(), reason="No Internet Connection")
 def test_finalize(sample_code):
-    cars = load_dataset('cars')
-
-    # Test that finalize is not called for ``to_python()`` method
+    cars = pd.DataFrame({'Miles_per_Gallon': [32, 33, 21, 22],
+                         'Origin': ['Europe', 'Europe', 'USA', 'USA']})
     obj = eval(sample_code)
 
+    # Test that to_dict() does not modify the object's state
+    D = obj.to_dict()
+    assert obj.encoding.x.type is jst.undefined
+
+    # Test that to_code() does not modify the object's state
+    code = obj.to_python(data='df')
+    assert obj.encoding.x.type is jst.undefined
+
+    # Test that code round-trip works as expected
     assert obj.to_python(data='cars') == sample_code
 
-    # Confirm that _finalize() changes the state
+    # Confirm that _finalize() changes the state & infers types
     assert obj.encoding.x.type is jst.undefined
+    assert obj.encoding.y.type is jst.undefined
     obj._finalize()
-    assert obj.encoding.x.type is not jst.undefined
+    assert obj.encoding.x.type == 'quantitative'
+    assert obj.encoding.y.type == 'nominal'
 
     # Confirm that finalized object contains correct type information
     D = obj.to_dict(data=False)
@@ -611,7 +621,7 @@ def test_validate_spec():
         c.to_dict(validate_columns=True)
     c.validate_columns = False
     assert isinstance(c.to_dict(validate_columns=True), dict)
-    
+
     c = make_chart()
     c.encode(x='x', y='count(*)')
     assert isinstance(c.to_dict(validate_columns=True), dict)