Repository: altair-viz/altair
Pull request #216 (2016-09-29 19:58:35)

[WIP] Changing to MIME based rendering
Initial prototype that works with JupyterLab.

Closes https://github.com/altair-viz/altair/issues/294 and https://github.com/altair-viz/altair/issues/172.

jakevdp: Is this close to being ready to merge? I'm excited to see Altair working with JupyterLab!
sanga: Any news on this? I've mostly switched to jupyterlab at this point but I'm sorely missing Altair.
jakevdp: You must be the earliest of early-adopters :smile:
gnestor: I just checked this out and tested with jupyterlab_vega and it's for working me! 

lab:

![lab](http://g.recordit.co/ZUKnccGude.gif)

notebook:

![lab](http://g.recordit.co/ipwpeO73ew.gif)
ellisonbg: Before we merge this PR, other things to add:

* [ ] Bring over utils.py from ipyvega
* [ ] Depend on jupyterlab_vega instead of ipyvega
gnestor: ☑️ Bring over utils.py from ipyvega: https://github.com/altair-viz/jupyterlab_vega/pull/8
gnestor: Ok, last thing to do is move `utils.py` from jupyterlab_vega: https://github.com/altair-viz/jupyterlab_vega/blob/master/jupyterlab_vega/utils.py

From my understanding, the need for `utils.py` is to sanitize DataFrames before displaying. It does this by:

* Copying the DaraFrame
* Raise ValueError if it has a hierarchical index
* Convert categoricals to strings
* Convert np.int dtypes to Python int objects
* Convert floats to objects and replace NaNs by None
* Convert DateTime dtypes into appropriate string representations

Another option is:

```py
data = df.to_json(orient='records')
```

This should sanitize the DataFrame as well and it doesn't require that we maintain this sanitization code in the future. 

Thoughts?
ellisonbg: We should also move over the main Vega and VegaLite classes. In our
discussion with Kyle, we felt that users would never use those classes in
practice without Altair. Also, Kyle wants to be abel touse them without
requiring jupyterlab_vega.

We have found that df.to_json alone doesn't do sufficient sanitization for
what Vega/VegaLite is expecting. I have recently found other cases that our
current sanitization code doesn't handle . If anything I think we will have
to expand what it does...

On Fri, Mar 10, 2017 at 5:29 PM, Grant Nestor <notifications@github.com>
wrote:

> Ok, last thing to do is move utils.py from jupyterlab_vega:
> https://github.com/altair-viz/jupyterlab_vega/blob/master/
> jupyterlab_vega/utils.py
>
> From my understanding, the need for utils.py is to sanitize DataFrames
> before displaying. It does this by:
>
>    - Copying the DaraFrame
>    - Raise ValueError if it has a hierarchical index
>    - Convert categoricals to strings
>    - Convert np.int dtypes to Python int objects
>    - Convert floats to objects and replace NaNs by None
>    - Convert DateTime dtypes into appropriate string representations
>
> Another option is:
>
> data = df.to_json(orient='records')
>
> This should sanitize the DataFrame as well and it doesn't require that we
> maintain this sanitization code in the future.
>
> Thoughts?
>
> —
> You are receiving this because you authored the thread.
> Reply to this email directly, view it on GitHub
> <https://github.com/altair-viz/altair/pull/216#issuecomment-285821780>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AABr0AnRkhwGmWzrelEGduxONmbwY_npks5rkerMgaJpZM4KKaTc>
> .
>



-- 
Brian E. Granger
Associate Professor of Physics and Data Science
Cal Poly State University, San Luis Obispo
@ellisonbg on Twitter and GitHub
bgranger@calpoly.edu and ellisonbg@gmail.com

gnestor: Ok, regarding the classes, there isn't much in there that's relevant to Altair. Here is the Vega (Base) class:

```py
class Vega():
    """A display class for displaying Vega visualizations in the Jupyter Notebook and IPython kernel.
    
    Vega expects a spec (a JSON-able dict) and data (dict) argument
    not already-serialized JSON strings.
    Scalar types (None, number, string) are not allowed, only dict containers.
    """
    
    # wrap data in a property, which warns about passing already-serialized JSON
    _spec = None
    _data = None
    _read_flags = 'r'
    
    def __init__(self, spec=None, data=None, url=None, filename=None, metadata=None):
        """Create a Vega display object given raw data.
        Parameters
        ----------
        spec : dict
            Vega spec. Not an already-serialized JSON string.
        data : dict
            A dict of Vega datasets where the key is the dataset name and the 
            value is the data values. Not an already-serialized JSON string.
            Scalar types (None, number, string) are not allowed, only dict
            or list containers.
        url : unicode
            A URL to download the data from.
        filename : unicode
            Path to a local file to load the data from.
        metadata: dict
            Specify extra metadata to attach to the json display object.
        """
        
        if spec is not None and isinstance(spec, str):
            if spec.startswith('http') and url is None:
                url = spec
                filename = None
                spec = None
            elif _safe_exists(spec) and filename is None:
                url = None
                filename = spec
                spec = None
        
        self.spec = spec
        self.data = data
        self.metadata = metadata
        self.url = url
        self.filename = filename
        
        self.reload()
        self._check_data()

    def reload(self):
        """Reload the raw spec from file or URL."""
        if self.filename is not None:
            with open(self.filename, self._read_flags) as f:
                self.spec = json.loads(f.read())
        elif self.url is not None:
            try:
                # Deferred import
                from urllib.request import urlopen
                response = urlopen(self.url)
                self.spec = response.read()
                # extract encoding from header, if there is one:
                encoding = None
                for sub in response.headers['content-type'].split(';'):
                    sub = sub.strip()
                    if sub.startswith('charset'):
                        encoding = sub.split('=')[-1].strip()
                        break
                # decode spec, if an encoding was specified
                if encoding:
                    self.spec = self.spec.decode(encoding, 'replace')
            except:
                self.spec = None
    
    def _check_data(self):
        if self.spec is not None and not isinstance(self.spec, dict):
            raise TypeError("%s expects a JSONable dict, not %r" % (self.__class__.__name__, self.spec))
        if self.data is not None and not isinstance(self.data, dict):
            raise TypeError("%s expects a dict, not %r" % (self.__class__.__name__, self.data))

    @property
    def spec(self):
        return self._spec
        
    @property
    def data(self):
        return self._data
        
    @spec.setter
    def spec(self, spec):
        if isinstance(spec, str):
            # warnings.warn("%s expects a JSONable dict, not %r" % (self.__class__.__name__, spec))
            spec = json.loads(spec)
        self._spec = spec

    @data.setter
    def data(self, data):
        if isinstance(data, str):
            # warnings.warn("%s expects a dict, not %r" % (self.__class__.__name__, data))
            data = json.loads(data)
        self._data = data
        
    def _ipython_display_(self):
        bundle = {
            'application/vnd.vega.v2+json': prepare_vega_spec(self.spec, self.data),
            'text/plain': '<jupyterlab_vega.Vega object>'
        }
        display(bundle, raw=True) 
```

Altair already handles URLs and data validation, pandas can load data from files, so it seems to me that the only relevant part is ` _ipython_display_`.

If we take a look at `utils.py` where `prepare_vegalite_spec` is imported from:

```py
def prepare_vegalite_spec(spec, data=None):
    """Prepare a Vega-Lite spec for sending to the frontend.
    This allows data to be passed in either as part of the spec
    or separately. If separately, the data is assumed to be a
    pandas DataFrame or object that can be converted to to a DataFrame.
    Note that if data is not None, this modifies spec in-place
    """

    if isinstance(data, pd.DataFrame):
        # We have to do the isinstance test first because we can't
        # compare a DataFrame to None.
        data = sanitize_dataframe(data)
        spec['data'] = {'values': data.to_dict(orient='records')}
    elif data is None:
        # Data is either passed in spec or error
        if 'data' not in spec:
            raise ValueError('No data provided')
    else:
        # As a last resort try to pass the data to a DataFrame and use it
        data = pd.DataFrame(data)
        data = sanitize_dataframe(data)
        spec['data'] = {'values': data.to_dict(orient='records')}
    return spec
```

Given that Altair only stores data in a DataFrame and `.to_dict()` returns data in the expected format (`{'values': data}`), the only relevant line is `data = sanitize_dataframe(data)`.

Now, `.to_dict()` uses `ToDict` from `utils/visitors.py` and it looks like:

```py
class ToDict(Visitor):
    """Crawl object structure to output dictionary"""
    def generic_visit(self, obj, *args, **kwargs):
        return obj

    def visit_list(self, obj, *args, **kwargs):
            return [self.visit(o) for o in obj]

    def visit_BaseObject(self, obj, *args, **kwargs):
        D = {}
        for k in obj.traits():
            if k in obj and k not in obj.skip:
                v = getattr(obj, k)
                if v is not None:
                    D[k] = self.visit(v)
        return D

    def _visit_with_data(self, obj, data=True):
        D = self.visit_BaseObject(obj)
        if data:
            if isinstance(obj.data, schema.Data):
                D['data'] = self.visit(obj.data)
            elif isinstance(obj.data, pd.DataFrame):
                values = sanitize_dataframe(obj.data).to_dict(orient='records')
                D['data'] = self.visit(schema.Data(values=values))
        else:
            D.pop('data', None)
        return D

    visit_Chart = _visit_with_data
    visit_LayeredChart = _visit_with_data
    visit_FacetedChart = _visit_with_data
```

Notice `values = sanitize_dataframe(obj.data).to_dict(orient='records')`! `sanitize_dataframe` is already in `utils/core.py`. 

In conclusion, I don't think we need to migrate anything from jupyterlab_vega because `sanitize_dataframe` is already being used in the display method:

```py
def _ipython_display_(self):
        from IPython.display import display
        spec = self.to_dict()
        data = {
            'application/vnd.vegalite.v1+json': spec,
            'text/plain': '<altair.VegaLite object>'
        }
        display(data, raw=True)
```
gnestor: TL;DR:

Altair's `_ipython_display_` calls `spec = self.to_dict()` which calls `ToDict` which calls `sanitize_dataframe(obj.data).to_dict(orient='records')`, so Altair's display method is already sanitizing DataFrames, and the rest jupyterlab_vega's display classes is a reimplementation of `IPython.display.DisplayObject` which involves a) loading data from a file path or URL, b) validating data, and c) getting/setting data, all of which Altair already does (except for loading from a file path).
gnestor: @jakevdp Any idea why this task is failing? https://travis-ci.org/altair-viz/altair/jobs/223802066
jakevdp: Looks like it's trying to install IPython 6.0 on Python 2.7. I think the fix is to update the travis.yml so that it runs ``pip install pip --upgrade`` before installing the other dependencies.
jakevdp: Hopefully will be fixed by #319
jakevdp: OK – if you pull in those commits from master, things should work now.
gnestor: It worked! Thanks @jakevdp! This is ready for review. Feel free to check out and test with JupyterLab. 
jakevdp: Looks good! The only issues I see reading through the changes are that ``requirements.txt`` and ``INSTALL_REQUIRES`` in ``setup.py`` need to be changed to reflect the updated dependency.

I'll try to give it a test run in JupyterLab today.
gnestor: Good catch! Updated.
jakevdp: Should have thought of that... travis uses the contents of ``requirements.txt`` to install the requirements, so they have to be up-to-date on PyPI and conda-forge before this will work.

Brings me to https://github.com/altair-viz/jupyterlab_vega/issues/32, which we need to think about before moving forward here.
ellisonbg: I have picked this work up again in preparation for the JupyterLab beta, which is planned for this Friday. We have decided that JupyterLab will *ship* with vega/vegalite rendering built in. Because of that the `jupyterlab_vega` package won't be needed.

Based on that, I have done the following:

* Added a `enable|disable_mime_rendering` that can enable/disable the logic for jupyterlab/nteract.
* That logic requires the latest release of ipython.
* In the classic notebook, things should continue to work with ipvega, but that package is not required for jupyterlab/nteract.
* I have moved all of the various utilities around to prepare for the day when `ipyvega` is not needed.
* This adds public `Vega` and `VegaLite` classes to Altair that work with lower-level dict objects that represent the raw specs.

I am finishings this up, but things are already working well:

![screen shot 2017-06-20 at 8 41 59 pm](https://user-images.githubusercontent.com/27600/27366301-0d701db8-55fa-11e7-8b9a-700af4eee83f.png)

ellisonbg: @rgbkrk this PR should allow Altair to work again in nteract. Just need to call `enable_mime_rendering`.

@gnestor
ellisonbg: OK I have now verified that this works on classic notebook by default and with lab with `enable_mime_rendering`.
ellisonbg: Todo:

- [ ] Add tests.
- [ ] Update documentation.
rgbkrk: What extension should I be building in for classic notebook users? @rdblue, @aishfenton, and I want to make the vega mimetype a built in for https://github.com/vegas-viz/Vegas too.
ellisonbg: Yep, I would like to have MIME based rendering for the classic notebook as
well. However, there are a few barriers:

1) The APIs for adding MIME based renderers in the classic notebook are
super hacky. You basically have to iterate through all existing cells of
the notebook and add the renderer. It can't be done at page load time as
the classic notebook doesn't have a runtime dependency injection system. I
think we can clean it up a bit, but I
2) We have been dramatically simplifying the API in JupyterLab for adding a
new renderer, with the goal of eliminating all of the previous boilerplate.
We are finishing that work up on lab today. One of the side benefits is
that it should be pretty simple to get the new JupyterLab APIs to work in
the classic notebook. I haven't wanted to invest more time on the classic
notebook side of these things until the lab side settled down.
3) In the long run, for things that we want to ship with the classic
notebook, we can completely side-step the complexities of (1) and just
build it right into the classic notebook, but with Vega-Lite being released
soon, I am hesitant to hard-code the classic notebook to a particular
version of VegaLite, right before their own transition. This interleaves
with our own work on Altair 2.0.
4) Time limitations trying to get JupyterLab out the door with Altair/Vega
support.

I definitely understand the desire to have these things in the classic
notebook as well. Once Lab is out the door on Friday, we can loop back with
Grant and figure out the best way to proceed with the classic notebook
versions. At that point, it shouldn't take much work.

But regardless, the code in this PR should work under any scenario.




On Wed, Jun 21, 2017 at 2:27 PM, Kyle Kelley <notifications@github.com>
wrote:

> What extension should I be building in for classic notebook users? @rdblue
> <https://github.com/rdblue>, @aishfenton <https://github.com/aishfenton>,
> and I want to make the vega mimetype a built in for
> https://github.com/vegas-viz/Vegas too.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/altair-viz/altair/pull/216#issuecomment-310210251>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AABr0PVB9eVhgnSnetikqxYawPBWehVDks5sGYq2gaJpZM4KKaTc>
> .
>



-- 
Brian E. Granger
Associate Professor of Physics and Data Science
Cal Poly State University, San Luis Obispo
@ellisonbg on Twitter and GitHub
bgranger@calpoly.edu and ellisonbg@gmail.com

ellisonbg: Tests and documentation pushed. Ready for final review and merge.
ellisonbg: @jakevdp ok to merge this?
jakevdp: I've not had a chance to try it yet, but the code looks good on a read-through. I'm happy to have this merged.

Patch:
diff --git a/altair/__init__.py b/altair/__init__.py
index c83ae4ae..5acb51ce 100644
--- a/altair/__init__.py
+++ b/altair/__init__.py
@@ -40,7 +40,8 @@ from .api import (
     EqualFilter,
     RangeFilter,
     OneOfFilter,
-    MaxRowsExceeded
+    MaxRowsExceeded,
+    enable_mime_rendering,
 )
 
 from .datasets import (
@@ -48,6 +49,11 @@ from .datasets import (
     load_dataset
 )
 
+from .utils import (
+    Vega,
+    VegaLite
+)
+
 from . import expr
 
 from .tutorial import tutorial
diff --git a/altair/api.py b/altair/api.py
index 05ce2dcf..98c3c075 100644
--- a/altair/api.py
+++ b/altair/api.py
@@ -6,13 +6,14 @@ DSL mapping Vega types to IPython traitlets.
 import os
 import functools
 import operator
+import uuid
 
 import traitlets as T
 import pandas as pd
 
 from .utils import visitors
 from .utils._py3k_compat import string_types
-from .utils import node
+from .utils import node, create_vegalite_mime_bundle
 
 from . import expr
 from . import schema
@@ -58,6 +59,24 @@ class MaxRowsExceeded(Exception):
 
 DEFAULT_MAX_ROWS = 5000
 
+#*************************************************************************
+# Rendering configuration
+#*************************************************************************
+
+# This is added to TopLevelMixin as a method if MIME rendering is enabled
+def _repr_mimebundle_(self, include, exclude, **kwargs):
+    """Return a MIME-bundle for rich display in the Jupyter Notebook."""
+    spec = self.to_dict()
+    bundle = create_vegalite_mime_bundle(spec)
+    return bundle
+
+
+def enable_mime_rendering():
+    """Enable MIME bundle based rendering used in JupyterLab/nteract."""
+    # This is what makes Python fun!
+    delattr(TopLevelMixin, '_ipython_display_')
+    TopLevelMixin._repr_mimebundle_ = _repr_mimebundle_
+
 #*************************************************************************
 # Channel Aliases
 #*************************************************************************
@@ -280,33 +299,44 @@ class TopLevelMixin(object):
     def configure_facet_axis(self, *args, **kwargs):
         """Configure the facet's axes by keyword args."""
         return self._update_subtraits(('config', 'facet', 'axis'),
-                                     *args, **kwargs)
+                                      *args, **kwargs)
 
     @use_signature(CellConfig)
     def configure_facet_cell(self, *args, **kwargs):
         """Configure the facet's cells by keyword args."""
         return self._update_subtraits(('config', 'facet', 'cell'),
-                                     *args, **kwargs)
+                                      *args, **kwargs)
 
     @use_signature(FacetGridConfig)
     def configure_facet_grid(self, *args, **kwargs):
         """Configure the facet's grid by keyword args."""
         return self._update_subtraits(('config', 'facet', 'grid'),
-                                     *args, **kwargs)
+                                      *args, **kwargs)
 
     @use_signature(FacetScaleConfig)
     def configure_facet_scale(self, *args, **kwargs):
         """Configure the facet's scales by keyword args."""
         return self._update_subtraits(('config', 'facet', 'scale'),
-                                     *args, **kwargs)
+                                      *args, **kwargs)
+
 
     # Display related methods
+
     def _ipython_display_(self):
+        """Use the vega package to display in the classic Jupyter Notebook."""
         from IPython.display import display
         from vega import VegaLite
         display(VegaLite(self.to_dict()))
 
     def display(self):
+        """Display the Chart using the Jupyter Notebook's rich output.
+
+        To use this is the classic Jupyter Notebook, the ``ipyvega`` package
+        must be installed.
+
+        To use this in JupyterLab/nteract, run the ``enable_mime_rendering``
+        function first.
+        """
         from IPython.display import display
         display(self)
 
diff --git a/altair/utils/__init__.py b/altair/utils/__init__.py
index bb67a43f..e08e9fc4 100644
--- a/altair/utils/__init__.py
+++ b/altair/utils/__init__.py
@@ -1,1 +1,6 @@
 from .core import *
+
+from .display import (
+    Vega, VegaLite, VEGA_MIME_TYPE, VEGALITE_MIME_TYPE,
+    create_vega_mime_bundle, create_vegalite_mime_bundle
+)
diff --git a/altair/utils/core.py b/altair/utils/core.py
index b0ce3fa4..94bcf6bf 100644
--- a/altair/utils/core.py
+++ b/altair/utils/core.py
@@ -178,3 +178,48 @@ def sanitize_dataframe(df):
             col = df[col_name].apply(to_list_if_array, convert_dtype=False)
             df[col_name] = col.where(col.notnull(), None)
     return df
+
+
+def prepare_vegalite_spec(spec, data=None):
+    """Prepare a Vega-Lite spec for sending to the frontend.
+
+    This allows data to be passed in either as part of the spec
+    or separately. If separately, the data is assumed to be a
+    pandas DataFrame or object that can be converted to to a DataFrame.
+    Note that if data is not None, this modifies spec in-place
+    """
+
+    if isinstance(data, pd.DataFrame):
+        # We have to do the isinstance test first because we can't
+        # compare a DataFrame to None.
+        data = sanitize_dataframe(data)
+        spec['data'] = {'values': data.to_dict(orient='records')}
+    elif data is None:
+        # Data is either passed in spec or error
+        if 'data' not in spec:
+            raise ValueError('No data provided')
+    else:
+        # As a last resort try to pass the data to a DataFrame and use it
+        data = pd.DataFrame(data)
+        data = sanitize_dataframe(data)
+        spec['data'] = {'values': data.to_dict(orient='records')}
+    return spec
+
+
+def prepare_vega_spec(spec, data=None):
+    """Prepare a Vega spec for sending to the frontend.
+    
+    This allows data to be passed in either as part of the spec
+    or separately. If separately, the data is assumed to be a
+    pandas DataFrame or object that can be converted to to a DataFrame.
+    Note that if data is not None, this modifies spec in-place
+    """
+
+    if isinstance(data, dict):
+        spec['data'] = []
+        # We have to do the isinstance test first because we can't
+        # compare a DataFrame to None.
+        for key, value in data.items():
+            data = sanitize_dataframe(value)
+            spec['data'].append({'name': key, 'values': data.to_dict(orient='records')})
+    return spec
\ No newline at end of file
diff --git a/altair/utils/display.py b/altair/utils/display.py
new file mode 100644
index 00000000..13672919
--- /dev/null
+++ b/altair/utils/display.py
@@ -0,0 +1,203 @@
+"""
+Utilities for displaying Vega/Vega-Lite in modern Jupyter frontends.
+
+Modern Jupyter frontends, such as JupyterLab and nteract, render custom
+display using MIME types rather than generic `application/javascript`.
+
+This module contains utilities for displaying Vega/Vega-Lite using custom
+MIME types in these frontends.
+"""
+
+import json
+import os
+import uuid
+
+import pandas as pd
+
+from .core import prepare_vega_spec, prepare_vegalite_spec
+
+
+# The MIME type for Vega 2.x releases.
+VEGA_MIME_TYPE = 'application/vnd.vega.v2+json'
+
+# The MIME type for Vega-Lite 1.x releases.
+VEGALITE_MIME_TYPE = 'application/vnd.vegalite.v1+json'
+
+
+def _safe_exists(path):
+    """Check path, but don't let exceptions raise"""
+    try:
+        return os.path.exists(path)
+    except Exception:
+        return False
+
+
+def create_vega_mime_bundle(spec, data=None):
+    """Create a MIME bundle for Vega given a spec.
+    """
+    assert isinstance(spec, dict)
+    bundle = {}
+    bundle['text/plain'] = '<altair.Vega object>'
+    bundle[VEGA_MIME_TYPE] = prepare_vega_spec(spec, data=data)
+    return bundle
+
+
+def create_vegalite_mime_bundle(spec, data=None):
+    """Create a MIME bundle for Vega-Lite given a spec.
+    """
+    assert isinstance(spec, dict)
+    bundle = {}
+    bundle['text/plain'] = '<altair.VegaLite object>'
+    bundle[VEGALITE_MIME_TYPE] = prepare_vegalite_spec(spec, data=data)
+    return bundle
+
+
+class Vega(object):
+    """A display class for displaying Vega visualizations in the Jupyter frontends.
+
+    Vega expects a spec (a JSON-able dict) and data (dict) argument not
+    already-serialized JSON strings. Scalar types (None, number, string) are not
+    allowed, only dict containers.
+    """
+
+    # wrap data in a property, which warns about passing already-serialized JSON
+    _spec = None
+    _data = None
+    _read_flags = 'r'
+
+    def __init__(self, spec=None, data=None, url=None, filename=None):
+        """Create a Vega display object given raw data.
+        Parameters
+        ----------
+        spec : dict
+            Vega spec. Not an already-serialized JSON string.
+        data : dict
+            A dict of Vega datasets where the key is the dataset name and the
+            value is the data values. Not an already-serialized JSON string.
+            Scalar types (None, number, string) are not allowed, only dict
+            or list containers.
+        url : unicode
+            A URL to download the data from.
+        filename : unicode
+            Path to a local file to load the data from.
+        """
+
+        if spec is not None and isinstance(spec, str):
+            if spec.startswith('http') and url is None:
+                url = spec
+                filename = None
+                spec = None
+            elif _safe_exists(spec) and filename is None:
+                url = None
+                filename = spec
+                spec = None
+
+        self.spec = spec
+        self.data = data
+        self.url = url
+        self.filename = filename
+
+        self.reload()
+        self._check_data()
+
+    def reload(self):
+        """Reload the raw spec from file or URL."""
+        if self.filename is not None:
+            with open(self.filename, self._read_flags) as f:
+                self.spec = json.loads(f.read())
+        elif self.url is not None:
+            try:
+                # Deferred import
+                try:
+                    from urllib.request import urlopen
+                except ImportError:
+                    from urllib2 import urlopen
+                response = urlopen(self.url)
+                self.spec = response.read()
+                # extract encoding from header, if there is one:
+                encoding = None
+                for sub in response.headers['content-type'].split(';'):
+                    sub = sub.strip()
+                    if sub.startswith('charset'):
+                        encoding = sub.split('=')[-1].strip()
+                        break
+                # decode spec, if an encoding was specified
+                if encoding:
+                    self.spec = self.spec.decode(encoding, 'replace')
+            except Exception:
+                self.spec = None
+
+    def _check_data(self):
+        if self.spec is not None and not isinstance(self.spec, dict):
+            raise TypeError("%s expects a JSONable dict, not %r" % \
+                (self.__class__.__name__, self.spec))
+        if self.data is not None and not isinstance(self.data, dict):
+            raise TypeError("%s expects a dict, not %r" % (self.__class__.__name__, self.data))
+
+    @property
+    def spec(self):
+        """Return the spec."""
+        return self._spec
+
+    @property
+    def data(self):
+        """Return the data."""
+        return self._data
+
+    @spec.setter
+    def spec(self, spec):
+        if isinstance(spec, str):
+            # warnings.warn("%s expects a JSONable dict, not %r" % (self.__class__.__name__, spec))
+            spec = json.loads(spec)
+        self._spec = spec
+
+    @data.setter
+    def data(self, data):
+        if isinstance(data, str):
+            # warnings.warn("%s expects a dict, not %r" % (self.__class__.__name__, data))
+            data = json.loads(data)
+        self._data = data
+
+    def _repr_mimebundle_(self, include, exclude, **kwargs):
+        """Return a MIME bundle for display in Jupyter frontends."""
+        return create_vega_mime_bundle(self.spec, self.data)
+
+
+
+
+class VegaLite(Vega):
+    """A display class for displaying Vega-Lite visualizations in the Jupyter frontends.
+
+    Vega-Lite expects a spec (a JSON-able dict) and data (dict) argument not
+    already-serialized JSON strings. Scalar types (None, number, string) are not
+    allowed, only dict containers.
+    """
+
+    def __init__(self, spec=None, data=None, url=None, filename=None):
+        """Create a VegaLite display object given raw data.
+
+        Parameters
+        ----------
+        spec : dict
+            VegaLite spec. Not an already-serialized JSON string.
+        data : dict or list
+            VegaLite data. Not an already-serialized JSON string.
+            Scalar types (None, number, string) are not allowed, only dict
+            or list containers.
+        url : unicode
+            A URL to download the data from.
+        filename : unicode
+            Path to a local file to load the data from.
+        """
+
+        super(VegaLite, self).__init__(spec=spec, data=data, url=url, filename=filename)
+
+    def _check_data(self):
+        if self.spec is not None and not isinstance(self.spec, dict):
+            raise TypeError("%s expects a JSONable dict, not %r" % (self.__class__.__name__, self.spec))
+        if self.data is not None and not isinstance(self.data, (list, pd.DataFrame)):
+            raise TypeError("%s expects a JSONable list or pandas DataFrame, not %r" % (self.__class__.__name__, self.data))
+                    
+    def _repr_mimebundle_(self, include, exclude, **kwargs):
+        """Return a MIME bundle for display in Jupyter frontends."""
+        return create_vegalite_mime_bundle(self.spec, self.data)
\ No newline at end of file
diff --git a/doc/faq.rst b/doc/faq.rst
index e2d86bf4..b3877cf7 100644
--- a/doc/faq.rst
+++ b/doc/faq.rst
@@ -10,6 +10,23 @@ Altair Frequently Asked Questions
 
 .. _altair-faq-no-display:
 
+Does Altair work in more modern frontends such as JupyterLab or nteract
+-----------------------------------------------------------------------
+
+Yes! In fact, JupyterLab and nteract ship with builtin rendering for Vega
+and VegaLite. As of the release of Altair version 1.3, this functionality is
+still being finalized, but Altair should work without any additional
+installations in these fronteds. However, you will need to call the
+``enable_mime_rendering`` function to enable this support in Altair:
+
+.. code-block: Python
+
+    from altair import enable_mime_rendering
+    enable_mime_rendering()
+
+Eventually, the classic Jupyter Notebook will be migrated to this new,
+cleaner rendering approach and this additional call will not be needed.
+
 Why isn't my plot displaying in the Jupyter Notebook?
 -----------------------------------------------------
 The most common cause of a non-displaying plot in the Jupyter Notebook is
diff --git a/doc/index.rst b/doc/index.rst
index 2fd59ab3..b2148670 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -28,11 +28,14 @@ Here is an example of using the Altair API to quickly visualize a dataset:
 
 .. altair-plot::
 
-    from altair import Chart, load_dataset
+    from altair import Chart, load_dataset, enable_mime_rendering
 
     # load built-in dataset as a pandas DataFrame
     cars = load_dataset('cars')
 
+    # Uncomment for rendering in JupyterLab & nteract
+    # enable_mime_rendering()
+
     Chart(cars).mark_circle().encode(
         x='Horsepower',
         y='Miles_per_Gallon',
diff --git a/doc/installation.rst b/doc/installation.rst
index 59fea9f3..c40c3f5f 100644
--- a/doc/installation.rst
+++ b/doc/installation.rst
@@ -35,6 +35,19 @@ within the Jupyter Notebook:
 - `Jupyter Notebook`_
 - ipyvega_
 
+Both JupyterLab_ and nteract_ have builtin rendering for Vega-Lite, so ipyvega_
+doesn't need to be installed. However, to get Altiar to emit the right data
+to render in these more modern frontends, you must call the ``enable_mime_rendering``
+function before trying to render a visualization:
+
+.. code-block: Python
+
+    from altair import enable_mime_rendering
+    enable_mime_rendering()
+
+Eventually this will not be needed, but is being provided to keep Altair working
+without change in the classic Jupyter notebook.
+
 Development Install
 -------------------
 The `Altair source repository`_ is available on GitHub.
@@ -62,3 +75,5 @@ version directly from GitHub using:
 .. _ipyvega: http://github.com/vega/ipyvega
 .. _conda: http://conda.pydata.org
 .. _Altair source repository: http://github.com/altair-viz/altair
+.. _JupyterLab: https://github.com/jupyterlab/jupyterlab
+.. _nteract: https://github.com/nteract/nteract


Test patch:
diff --git a/altair/utils/tests/test_display.py b/altair/utils/tests/test_display.py
new file mode 100644
index 00000000..f1691551
--- /dev/null
+++ b/altair/utils/tests/test_display.py
@@ -0,0 +1,60 @@
+import pytest
+import os
+import warnings
+import json
+
+import numpy as np
+import pandas as pd
+
+from .. import (
+    Vega, VegaLite, VEGA_MIME_TYPE, VEGALITE_MIME_TYPE,
+    create_vega_mime_bundle, create_vegalite_mime_bundle
+)
+
+from ... import Chart
+
+_data = pd.DataFrame({
+    'x': np.random.rand(10),
+    'y': np.random.rand(10),
+    'c': list('abcdefghij')
+})
+
+_spec = {
+    "encoding": {
+        "c": {
+            "field": "Origin",
+            "type": "nominal"
+        },
+        "x": {
+            "field": "x",
+            "type": "quantitative"
+        },
+        "y": {
+            "field": "y",
+            "type": "quantitative"
+        }
+    },
+    "mark": "point"
+}
+
+def test_create_vegalite_mimebundle():
+    bundle = create_vegalite_mime_bundle(_spec, data=_data)
+    assert 'text/plain' in bundle
+    assert VEGALITE_MIME_TYPE in bundle
+    spec = bundle[VEGALITE_MIME_TYPE]
+    assert 'data' in spec
+    assert 'encoding' in spec
+    assert 'mark' in spec
+    assert spec['encoding'] == _spec['encoding']
+
+def test_VegaLite():
+    vl = VegaLite(_spec, _data)
+    assert vl.spec == _spec
+    from altair import examples
+    filename = os.path.abspath(
+        os.path.join(os.path.dirname(examples.__file__), 'json', 'area.vl.json')
+    )
+    vl = VegaLite(filename=filename)
+    assert vl.spec['mark']=='area'
+    assert 'encoding' in vl.spec
+    assert vl.data is None