Repository: altair-viz/altair
Pull request #776 (2018-04-25 14:26:12)

[MRG] Generalized Plugin Interface
The goal of this work is to generalize the plugin interface to allow specification of additional options and/or metadata. See below for examples.

TODO list:

- [x] Require all plugins to return a callable function or object (mainly affects theme registry)
- [x] Make ``plugins.enable()`` able to be used as a context manager
- [x] Change the theme plugins to a function returning a spec
- [x] Add ability to specify extra options when calling ``plugins.enable()``
- [x] Specialize the data_transformers plugins
- [x] Allow ``max_rows`` to be set for data transformers
- [x] Specialize the renderers plugins
- [x] Allow metadata to be set for renderers (e.g. vega-embed args and vega-tooltip args)
- [x] Test setting of metadata
- [x] Modify html output to make use of that metadata

**External TODOS:**

- update ipyvega & jupyterlab to use ``embed_options`` from mimebundle metadata

---

This PR addresses (or partially addresses) #758, #742, #674, #673, and #240.

Most of this is internal reorganization, but there are a few primary user-side API enhancements that this PR introduces out-of-the-box.

## Temporary plugin enabling via context manager
With this PR, ``plugins.enable`` can optionally be used as a context manager to temporarily enable a specified plugin. For example:
```python
with alt.data_transformers.enable('json'):
    do_something()  # executed with the json data transformer
do_something_else()  # executed with the previous data transformer
```

## Data transformer settings
After this PR, you can directly set the ``max_rows`` parameter of the default data transformer:
```python
alt.data_transformers.enable('default', max_rows=10000)
```
Alternatively, you can use
```python
alt.data_transformers.enable('default', max_rows=None)
```
and no check of the number of rows will be performed.

## Renderer metadata
This provides a mechanism to pass arbitrary metadata to the renderer. For the Colab renderer, which is built-in to the package, vegaEmbed [options](https://github.com/vega/vega-embed#options) can be passed using the ``embed_options`` argument; e.g.
```python
alt.renderers.enable('colab', embed_options={'actions': False})
```
this will hide the action links (i.e. save as png, open in vega editor, etc.).

As I've designed it here, these additional arguments will be passed in the metadata dictionary of other mimebundle representations, so that renderers like jupyterlab-vega and ipyvega can make use of them as well. For example:

```python
>>> import altair as alt
>>> alt.renderers.enable('default', embed_options={'actions': False})
>>> alt.Chart('data.csv').mark_point()._repr_mimebundle_(None, None)
({'text/plain': '<VegaLite 2 object>\n\nIf you see this message, it means the renderer has not been properly enabled\nfor the frontend that you are using. For more information, see\nhttps://altair-viz.github.io/user_guide/troubleshooting.html\n',
  'application/vnd.vegalite.v2+json': {'config': {'view': {'width': 400,
     'height': 300}},
   'data': {'url': 'data.csv'},
   'mark': 'point',
   '$schema': 'https://vega.github.io/schema/vega-lite/v2.3.0.json'}},
 {'embed_options': {'actions': False}})
```
Notice the second element in the tuple contains the embed options.

I would propose we adopt this ``embed_options`` metadata key as a standard to be accepted in the metadata and handled appropriately by jupyterlab-vega, ipyvega, and any other renderers.

jakevdp: ping @ellisonbg – I'd love to get this reviewed and merged so we can push forward with the 2.0 release!

Patch:
diff --git a/altair/utils/data.py b/altair/utils/data.py
index b138d213..dbebc13f 100644
--- a/altair/utils/data.py
+++ b/altair/utils/data.py
@@ -4,8 +4,19 @@ import uuid
 
 import pandas as pd
 from toolz.curried import curry, pipe  # noqa
+from typing import Callable
 
 from .core import sanitize_dataframe
+from .plugin_registry import PluginRegistry
+
+
+# ==============================================================================
+# Data transformer registry
+# ==============================================================================
+DataTransformerType = Callable
+
+class DataTransformerRegistry(PluginRegistry[DataTransformerType]):
+    pass
 
 
 # ==============================================================================
@@ -34,7 +45,10 @@ class MaxRowsError(Exception):
 
 @curry
 def limit_rows(data, max_rows=5000):
-    """Raise MaxRowsError if the data model has more than max_rows."""
+    """Raise MaxRowsError if the data model has more than max_rows.
+
+    If max_rows is None, then do not perform any check.
+    """
     check_data_type(data)
     if isinstance(data, pd.DataFrame):
         values = data
@@ -43,8 +57,11 @@ def limit_rows(data, max_rows=5000):
             values = data['values']
         else:
             return data
-    if len(values) > max_rows:
-        raise MaxRowsError('The number of rows in your dataset is greater than the max of {}'.format(max_rows))
+    if max_rows is not None and len(values) > max_rows:
+        raise MaxRowsError('The number of rows in your dataset is greater '
+                           'than the maximum allowed ({0}). '
+                           'For information on how to plot larger datasets '
+                           'in Altair, see the documentation'.format(max_rows))
     return data
 
 
diff --git a/altair/utils/display.py b/altair/utils/display.py
index 56e5afe7..49d8f97b 100644
--- a/altair/utils/display.py
+++ b/altair/utils/display.py
@@ -1,18 +1,35 @@
 import json
 import pkgutil
+import textwrap
 from typing import Callable, Dict
 
 from jsonschema import validate
 
+from .plugin_registry import PluginRegistry
+
 
 # ==============================================================================
-# VegaLite v1/v2 renderer logic
+# Renderer registry
 # ==============================================================================
+MimeBundleType = Dict[str, object]
+RendererType = Callable[..., MimeBundleType]
 
 
-SpecType = dict
-MimeBundleType = Dict[str, object]
-RendererType = Callable[[SpecType], MimeBundleType]
+class RendererRegistry(PluginRegistry[RendererType]):
+    entrypoint_err_messages = {
+        'notebook': textwrap.dedent(
+            """
+            To use the 'notebook' renderer, you must install the vega3 package
+            and the associated Jupyter extension.
+            See https://altair-viz.github.io/getting_started/installation.html
+            for more information.
+            """)
+    }
+
+# ==============================================================================
+# VegaLite v1/v2 renderer logic
+# ==============================================================================
+
 
 
 class Displayable(object):
@@ -53,7 +70,7 @@ class Displayable(object):
             return {}
 
 
-def default_renderer(spec, mime_type, str_repr):
+def default_renderer(spec, mime_type, str_repr, **metadata):
     """A default renderer for VegaLite 1/2 that works for modern frontends.
 
     This renderer works with modern frontends (JupyterLab, nteract) that know
@@ -61,20 +78,18 @@ def default_renderer(spec, mime_type, str_repr):
     """
     assert isinstance(spec, dict)
     bundle = {}
-    metadata = {}
     bundle['text/plain'] = str_repr
     bundle[mime_type] = spec
     return bundle, metadata
 
 
-def json_renderer(spec, str_repr):
+def json_renderer(spec, str_repr, **metadata):
     """A renderer that returns a MIME type of application/json.
 
     In JupyterLab/nteract this is rendered as a nice JSON tree.
     """
     assert isinstance(spec, dict)
     bundle = {}
-    metadata = {}
     bundle['text/plain'] = str_repr
     bundle['application/json'] = spec
     return bundle, metadata
diff --git a/altair/utils/html.py b/altair/utils/html.py
index 3b11d8e7..ea562a26 100644
--- a/altair/utils/html.py
+++ b/altair/utils/html.py
@@ -36,7 +36,7 @@ BOTTOM = """
   <div id="{output_div}"></div>
   <script type="text/javascript">
     var spec = {spec};
-    var opt = {embed_opt};
+    var embed_opt = {embed_opt};
 
     function showError(el, error){{
         el.innerHTML = ('<div class="error">'
@@ -47,7 +47,7 @@ BOTTOM = """
         throw error;
     }}
     const el = document.getElementById('{output_div}');
-    vegaEmbed("#{output_div}", spec, opt)
+    vegaEmbed("#{output_div}", spec, embed_opt)
       .catch(error => showError(el, error));
   </script>
 </body>
@@ -66,41 +66,51 @@ def spec_to_html_mimebundle(spec, mode,
                             vegaembed_version,
                             vegalite_version=None,
                             base_url="https://cdn.jsdelivr.net/npm/",
-                            opt=None, json_kwds=None):
+                            output_div='vis',
+                            embed_options=None, json_kwds=None):
     """Conver a vega/vega-lite specification to a PNG/SVG image
 
     Parameters
     ----------
     spec : dict
-        a dictionary representing a vega-lite plot spec
+        a dictionary representing a vega-lite plot spec.
     mode : string {'vega' | 'vega-lite'}
-        The rendering mode.
+        The rendering mode. This value is overridden by embed_options['mode'],
+        if it is present.
     vega_version : string
-        For html output, the version of vega.js to use
+        For html output, the version of vega.js to use.
     vegalite_version : string
-        For html output, the version of vegalite.js to use
+        For html output, the version of vegalite.js to use.
     vegaembed_version : string
-        For html output, the version of vegaembed.js to use
+        For html output, the version of vegaembed.js to use.
     base_url : string (optional)
-        The base url from which to load the javascript libraries
-    opt : dict (optional)
-        Dictionary of options to pass to the renderer
+        The base url from which to load the javascript libraries.
+    output_div : string (optional)
+        The id of the div element where the plot will be shown.
+    embed_options : dict (optional)
+        Dictionary of options to pass to the vega-embed script.
     json_kwds : dict (optional)
+        Dictionary of keywords to pass to json.dumps().
 
     Returns
     -------
     output : dict
         a mime-bundle representing the image
     """
-    opt = opt or {}
+    embed_options = embed_options or {}
     json_kwds = json_kwds or {}
+
+    mode = embed_options.setdefault('mode', mode)
+    if mode not in ['vega', 'vega-lite']:
+        raise ValueError("mode must be either 'vega' or 'vega-lite'")
+
     template = HTML_TEMPLATE[mode]
-    opt['mode'] = mode
+
     spec_html = template.format(spec=json.dumps(spec, **json_kwds),
-                                embed_opt=json.dumps(opt),
+                                embed_opt=json.dumps(embed_options),
                                 vega_version=vega_version,
                                 vegalite_version=vegalite_version,
                                 vegaembed_version=vegaembed_version,
                                 base_url=base_url,
-                                output_div='vis')
+                                output_div=output_div)
     return {'text/html': spec_html}
diff --git a/altair/utils/plugin_registry.py b/altair/utils/plugin_registry.py
index 2ad65093..3c57e1af 100644
--- a/altair/utils/plugin_registry.py
+++ b/altair/utils/plugin_registry.py
@@ -1,11 +1,39 @@
 from typing import Generic, TypeVar, cast
 
 import entrypoints
+from toolz import curry
 
 
 PluginType = TypeVar('PluginType')
 
 
+class PluginEnabler(object):
+    """Context manager for enabling plugins
+
+    This object lets you use enable() as a context manager to
+    temporarily enable a given plugin::
+
+        with plugins.enable('name'):
+            do_something()  # 'name' plugin temporarily enabled
+        # plugins back to original state
+    """
+    def __init__(self, registry, name, **options):
+        self.registry = registry
+        self.name = name
+        self.options = options
+        self.original_state = registry._get_state()
+        self.registry._enable(name, **options)
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, type, value, traceback):
+        self.registry._set_state(self.original_state    )
+
+    def __repr__(self):
+        return "{0}.enable({1!r})".format(self.registry.__class__.__name__, self.name)
+
+
 class PluginRegistry(Generic[PluginType]):
     """A registry for plugins.
 
@@ -79,9 +107,22 @@ class PluginRegistry(Generic[PluginType]):
         exts.extend(more_exts)
         return sorted(set(exts))
 
-    def enable(self, name):
-        # type: (str) -> None
-        """Enable a plugin by name."""
+    def _get_state(self):
+        """Return a dictionary representing the current state of the registry"""
+        return {'_active': self._active,
+                '_active_name': self._active_name,
+                '_plugins': self._plugins.copy(),
+                '_options': self._options.copy()}
+
+    def _set_state(self, state):
+        """Reset the state of the registry"""
+        assert set(state.keys()) == {'_active', '_active_name',
+                                     '_plugins', '_options'}
+        for key, val in state.items():
+            setattr(self, key, val)
+
+    def _enable(self, name, **options):
+        # type: (str, **Any) -> None
         if name not in self._plugins:
             try:
                 ep = entrypoints.get_single(self.entry_point_group, name)
@@ -95,6 +136,28 @@ class PluginRegistry(Generic[PluginType]):
             self.register(name, value)
         self._active_name = name
         self._active = self._plugins[name]
+        self._options = options
+
+    def enable(self, name, **options):
+        # type: (str, **Any) -> PluginEnabler
+        """Enable a plugin by name.
+
+        This can be either called directly, or used as a context manager.
+
+        Parameters
+        ----------
+        name : string
+            The name of the plugin to enable
+        **options :
+            Any additional parameters will be passed to the plugin as keyword
+            arguments
+
+        Returns
+        -------
+        PluginEnabler:
+            An object that allows enable() to be used as a context manager
+        """
+        return PluginEnabler(self, name, **options)
 
     @property
     def active(self):
@@ -102,10 +165,19 @@ class PluginRegistry(Generic[PluginType]):
         """Return the name of the currently active plugin"""
         return self._active_name
 
+    @property
+    def options(self):
+        # type: () -> str
+        """Return the current options dictionary"""
+        return self._options
+
     def get(self):
         # type: () -> PluginType
         """Return the currently active plugin."""
-        return self._active
+        if self._options:
+            return curry(self._active, **self._options)
+        else:
+            return self._active
 
     def __repr__(self):
         # type: () -> str
diff --git a/altair/utils/save.py b/altair/utils/save.py
index cbf041f5..ed4c5b7b 100644
--- a/altair/utils/save.py
+++ b/altair/utils/save.py
@@ -9,7 +9,7 @@ from .html import spec_to_html_mimebundle
 
 def save(chart, fp, vega_version, vegaembed_version,
          format=None, mode=None, vegalite_version=None,
-         opt=None, json_kwds=None, webdriver='chrome'):
+         embed_options=None, json_kwds=None, webdriver='chrome'):
     """Save a chart to file in a variety of formats
 
     Supported formats are [json, html, png, svg]
@@ -33,7 +33,7 @@ def save(chart, fp, vega_version, vegaembed_version,
         For html output, the version of vegalite.js to use
     vegaembed_version : string
         For html output, the version of vegaembed.js to use
-    opt : dict
+    embed_options : dict
         The vegaEmbed options dictionary. Default is {}
         (See https://github.com/vega/vega-embed for details)
     json_kwds : dict
@@ -45,8 +45,8 @@ def save(chart, fp, vega_version, vegaembed_version,
     if json_kwds is None:
         json_kwds = {}
 
-    if opt is None:
-        opt = {}
+    if embed_options is None:
+        embed_options = {}
 
     if format is None:
         if isinstance(fp, six.string_types):
@@ -58,8 +58,8 @@ def save(chart, fp, vega_version, vegaembed_version,
     spec = chart.to_dict()
 
     if mode is None:
-        if 'mode' in opt:
-            mode = opt['mode']
+        if 'mode' in embed_options:
+            mode = embed_options['mode']
         elif '$schema' in spec:
             mode = spec['$schema'].split('/')[-2]
         else:
@@ -76,10 +76,11 @@ def save(chart, fp, vega_version, vegaembed_version,
         json_spec = json.dumps(spec, **json_kwds)
         write_file_or_filename(fp, six.u(json_spec), mode='w')
     elif format == 'html':
-        mimebundle = spec_to_html_mimebundle(spec=spec, mode=mode, opt=opt,
+        mimebundle = spec_to_html_mimebundle(spec=spec, mode=mode,
                                              vega_version=vega_version,
                                              vegalite_version=vegalite_version,
                                              vegaembed_version=vegaembed_version,
+                                             embed_options=embed_options,
                                              json_kwds=json_kwds)
         write_file_or_filename(fp, mimebundle['text/html'], mode='w')
     elif format in ['png', 'svg']:
diff --git a/altair/utils/theme.py b/altair/utils/theme.py
new file mode 100644
index 00000000..bde01650
--- /dev/null
+++ b/altair/utils/theme.py
@@ -0,0 +1,9 @@
+"""Utilities for registering and working with themes"""
+
+from .plugin_registry import PluginRegistry
+from typing import Callable
+
+ThemeType = Callable[..., dict]
+
+class ThemeRegistry(PluginRegistry[ThemeType]):
+    pass
diff --git a/altair/vega/display.py b/altair/vega/display.py
index 010d93a1..80f5d654 100644
--- a/altair/vega/display.py
+++ b/altair/vega/display.py
@@ -1,12 +1,11 @@
 from ..utils.display import Displayable, default_renderer, json_renderer
-from ..utils.display import SpecType, MimeBundleType, RendererType
+from ..utils.display import MimeBundleType, RendererType
 
 
 __all__ = (
     "Displayable",
     "default_renderer",
     "json_renderer",
-    "SpecType",
     "MimeBundleType",
     "RendererType"
 )
diff --git a/altair/vegalite/data.py b/altair/vegalite/data.py
index e2626ddc..d54dfd93 100644
--- a/altair/vegalite/data.py
+++ b/altair/vegalite/data.py
@@ -2,16 +2,18 @@
 from toolz.curried import curry, pipe
 from ..utils.core import sanitize_dataframe
 from ..utils.data import (
-    MaxRowsError, limit_rows, sample, to_csv, to_json, to_values, check_data_type
+    MaxRowsError, limit_rows, sample, to_csv, to_json, to_values,
+    check_data_type, DataTransformerRegistry
 )
 
 
 @curry
-def default_data_transformer(data):
-    return pipe(data, limit_rows, to_values)
+def default_data_transformer(data, max_rows=5000):
+    return pipe(data, limit_rows(max_rows=max_rows), to_values)
 
 
 __all__ = (
+    'DataTransformerRegistry',
     'MaxRowsError',
     'curry',
     'sanitize_dataframe',
diff --git a/altair/vegalite/display.py b/altair/vegalite/display.py
index 010d93a1..3c62ca7e 100644
--- a/altair/vegalite/display.py
+++ b/altair/vegalite/display.py
@@ -1,12 +1,10 @@
 from ..utils.display import Displayable, default_renderer, json_renderer
-from ..utils.display import SpecType, MimeBundleType, RendererType
+from ..utils.display import RendererRegistry
 
 
 __all__ = (
     "Displayable",
     "default_renderer",
     "json_renderer",
-    "SpecType",
-    "MimeBundleType",
-    "RendererType"
+    "RendererRegistry"
 )
diff --git a/altair/vegalite/v1/__init__.py b/altair/vegalite/v1/__init__.py
index 33e37d6f..bb9363c0 100644
--- a/altair/vegalite/v1/__init__.py
+++ b/altair/vegalite/v1/__init__.py
@@ -12,6 +12,7 @@ from .. import expr
 from .display import VegaLite, renderers
 
 from .data import (
+    MaxRowsError,
     pipe, curry, limit_rows,
     sample, to_json, to_csv, to_values,
     default_data_transformer,
diff --git a/altair/vegalite/v1/api.py b/altair/vegalite/v1/api.py
index cd05ddb0..3818c983 100644
--- a/altair/vegalite/v1/api.py
+++ b/altair/vegalite/v1/api.py
@@ -89,7 +89,8 @@ class TopLevelMixin(object):
                 dct['$schema'] = SCHEMA_URL
 
             # apply theme from theme registry
-            dct = utils.update_nested(themes.get(), dct, copy=True)
+            the_theme = themes.get()
+            dct = utils.update_nested(the_theme(), dct, copy=True)
         return dct
 
     def savechart(self, fp, format=None, **kwargs):
diff --git a/altair/vegalite/v1/data.py b/altair/vegalite/v1/data.py
index de65a3ea..578ed2e1 100644
--- a/altair/vegalite/v1/data.py
+++ b/altair/vegalite/v1/data.py
@@ -1,6 +1,5 @@
-from ...utils import PluginRegistry
 from ..data import (MaxRowsError, curry, default_data_transformer, limit_rows,
-                    pipe, sample, to_csv, to_json, to_values)
+                    pipe, sample, to_csv, to_json, to_values, DataTransformerRegistry)
 
 
 # ==============================================================================
@@ -11,7 +10,7 @@ from ..data import (MaxRowsError, curry, default_data_transformer, limit_rows,
 ENTRY_POINT_GROUP = 'altair.vegalite.v1.data_transformer'  # type: str
 
 
-data_transformers = PluginRegistry(entry_point_group=ENTRY_POINT_GROUP)  # type: PluginRegistry
+data_transformers = DataTransformerRegistry(entry_point_group=ENTRY_POINT_GROUP)  # type: DataTransformerRegistry
 data_transformers.register('default', default_data_transformer)
 data_transformers.register('json', to_json)
 data_transformers.register('csv', to_csv)
diff --git a/altair/vegalite/v1/display.py b/altair/vegalite/v1/display.py
index 394d6b4d..6a10f72d 100644
--- a/altair/vegalite/v1/display.py
+++ b/altair/vegalite/v1/display.py
@@ -1,11 +1,11 @@
 import os
-import textwrap
 
-from ...utils import PluginRegistry, headless, html
+from ...utils import headless, html
+
 from ..display import Displayable
 from ..display import default_renderer as default_renderer_base
 from ..display import json_renderer as json_renderer_base
-from ..display import RendererType
+from ..display import RendererRegistry
 
 from .schema import SCHEMA_VERSION
 VEGALITE_VERSION = SCHEMA_VERSION.lstrip('v')
@@ -35,50 +35,43 @@ for the frontend that you are using. For more information, see
 https://altair-viz.github.io/user_guide/troubleshooting.html
 """
 
-renderers = PluginRegistry[RendererType](entry_point_group=ENTRY_POINT_GROUP)
-renderers.entrypoint_err_messages = {
-    'notebook': textwrap.dedent(
-        """
-        To use the 'notebook' renderer, you must install the vega package
-        and the associated Jupyter extension.
-        See https://altair-viz.github.io/getting_started/installation.html
-        for more information.
-        """)
-}
-
+renderers = RendererRegistry(entry_point_group=ENTRY_POINT_GROUP)
 
 here = os.path.dirname(os.path.realpath(__file__))
 
-
-def default_renderer(spec):
-    return default_renderer_base(spec, VEGALITE_MIME_TYPE, DEFAULT_DISPLAY)
+def default_renderer(spec, **metadata):
+    return default_renderer_base(spec, VEGALITE_MIME_TYPE, DEFAULT_DISPLAY,
+                                 **metadata)
 
 
-def json_renderer(spec):
-    return json_renderer_base(spec, DEFAULT_DISPLAY)
+def json_renderer(spec, **metadata):
+    return json_renderer_base(spec, DEFAULT_DISPLAY, **metadata)
 
 
-def png_renderer(spec):
+def png_renderer(spec, **metadata):
     return headless.spec_to_mimebundle(spec, format='png',
                                        mode='vega-lite',
                                        vega_version=VEGA_VERSION,
                                        vegaembed_version=VEGAEMBED_VERSION,
-                                       vegalite_version=VEGALITE_VERSION)
+                                       vegalite_version=VEGALITE_VERSION,
+                                       **metadata)
 
 
-def svg_renderer(spec):
+def svg_renderer(spec, **metadata):
     return headless.spec_to_mimebundle(spec, format='svg',
                                        mode='vega-lite',
                                        vega_version=VEGA_VERSION,
                                        vegaembed_version=VEGAEMBED_VERSION,
-                                       vegalite_version=VEGALITE_VERSION)
+                                       vegalite_version=VEGALITE_VERSION,
+                                       **metadata)
 
 
-def colab_renderer(spec):
+def colab_renderer(spec, **metadata):
     return html.spec_to_html_mimebundle(spec, mode='vega-lite',
                                         vega_version=VEGA_VERSION,
                                         vegaembed_version=VEGAEMBED_VERSION,
-                                        vegalite_version=VEGALITE_VERSION)
+                                        vegalite_version=VEGALITE_VERSION,
+                                        **metadata)
 
 
 renderers.register('default', default_renderer)
diff --git a/altair/vegalite/v1/theme.py b/altair/vegalite/v1/theme.py
index dfb126a9..80883160 100644
--- a/altair/vegalite/v1/theme.py
+++ b/altair/vegalite/v1/theme.py
@@ -1,17 +1,14 @@
 """Tools for enabling and registering chart themes"""
 
-from ...utils import PluginRegistry
+from ...utils.theme import ThemeRegistry
 
 # The entry point group that can be used by other packages to declare other
 # renderers that will be auto-detected. Explicit registration is also
 # allowed by the PluginRegistery API.
 ENTRY_POINT_GROUP = 'altair.vegalite.v1.theme'  # type: str
 
-class ThemeRegistry(PluginRegistry[dict]):
-    pass
-    
 themes = ThemeRegistry(entry_point_group=ENTRY_POINT_GROUP)
 
-themes.register('default', {"width": 400, "height": 300})
-themes.register('none', {})
+themes.register('default', lambda: {"width": 400, "height": 300})
+themes.register('none', lambda: {})
 themes.enable('default')
diff --git a/altair/vegalite/v2/__init__.py b/altair/vegalite/v2/__init__.py
index a7d35705..75836e71 100644
--- a/altair/vegalite/v2/__init__.py
+++ b/altair/vegalite/v2/__init__.py
@@ -15,6 +15,7 @@ from ...expr import datum
 from .display import VegaLite, renderers
 
 from .data import (
+    MaxRowsError,
     pipe, curry, limit_rows,
     sample, to_json, to_csv, to_values,
     default_data_transformer,
diff --git a/altair/vegalite/v2/api.py b/altair/vegalite/v2/api.py
index e4ca7843..d11ea088 100644
--- a/altair/vegalite/v2/api.py
+++ b/altair/vegalite/v2/api.py
@@ -345,7 +345,8 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
                 dct['$schema'] = SCHEMA_URL
 
             # apply theme from theme registry
-            dct = utils.update_nested(themes.get(), dct, copy=True)
+            the_theme = themes.get()
+            dct = utils.update_nested(the_theme(), dct, copy=True)
 
         return dct
 
diff --git a/altair/vegalite/v2/data.py b/altair/vegalite/v2/data.py
index 921d8e9e..afb64a16 100644
--- a/altair/vegalite/v2/data.py
+++ b/altair/vegalite/v2/data.py
@@ -1,6 +1,5 @@
-from ...utils import PluginRegistry
 from ..data import (MaxRowsError, curry, default_data_transformer, limit_rows,
-                    pipe, sample, to_csv, to_json, to_values)
+                    pipe, sample, to_csv, to_json, to_values, DataTransformerRegistry)
 
 
 # ==============================================================================
@@ -11,7 +10,7 @@ from ..data import (MaxRowsError, curry, default_data_transformer, limit_rows,
 ENTRY_POINT_GROUP = 'altair.vegalite.v2.data_transformer'  # type: str
 
 
-data_transformers = PluginRegistry(entry_point_group=ENTRY_POINT_GROUP)  # type: PluginRegistry
+data_transformers = DataTransformerRegistry(entry_point_group=ENTRY_POINT_GROUP)  # type: DataTransformerRegistry
 data_transformers.register('default', default_data_transformer)
 data_transformers.register('json', to_json)
 data_transformers.register('csv', to_csv)
@@ -29,4 +28,4 @@ __all__ = (
     'to_json',
     'to_values',
     'data_transformers'
-)
\ No newline at end of file
+)
diff --git a/altair/vegalite/v2/display.py b/altair/vegalite/v2/display.py
index 60013b91..860c272f 100644
--- a/altair/vegalite/v2/display.py
+++ b/altair/vegalite/v2/display.py
@@ -1,11 +1,10 @@
 import os
-import textwrap
 
-from ...utils import PluginRegistry, headless, html
+from ...utils import headless, html
 from ..display import Displayable
 from ..display import default_renderer as default_renderer_base
 from ..display import json_renderer as json_renderer_base
-from ..display import RendererType
+from ..display import RendererRegistry
 
 from .schema import SCHEMA_VERSION
 VEGALITE_VERSION = SCHEMA_VERSION.lstrip('v')
@@ -35,50 +34,43 @@ for the frontend that you are using. For more information, see
 https://altair-viz.github.io/user_guide/troubleshooting.html
 """
 
-renderers = PluginRegistry[RendererType](entry_point_group=ENTRY_POINT_GROUP)
-renderers.entrypoint_err_messages = {
-    'notebook': textwrap.dedent(
-        """
-        To use the 'notebook' renderer, you must install the vega3 package
-        and the associated Jupyter extension.
-        See https://altair-viz.github.io/getting_started/installation.html
-        for more information.
-        """)
-}
-
+renderers = RendererRegistry(entry_point_group=ENTRY_POINT_GROUP)
 
 here = os.path.dirname(os.path.realpath(__file__))
 
 
-
-def default_renderer(spec):
-    return default_renderer_base(spec, VEGALITE_MIME_TYPE, DEFAULT_DISPLAY)
+def default_renderer(spec, **metadata):
+    return default_renderer_base(spec, VEGALITE_MIME_TYPE, DEFAULT_DISPLAY,
+                                 **metadata)
 
 
-def json_renderer(spec):
-    return json_renderer_base(spec, DEFAULT_DISPLAY)
+def json_renderer(spec, **metadata):
+    return json_renderer_base(spec, DEFAULT_DISPLAY, **metadata)
 
 
-def png_renderer(spec):
+def png_renderer(spec, **metadata):
     return headless.spec_to_mimebundle(spec, format='png',
                                        mode='vega-lite',
                                        vega_version=VEGA_VERSION,
                                        vegaembed_version=VEGAEMBED_VERSION,
-                                       vegalite_version=VEGALITE_VERSION)
+                                       vegalite_version=VEGALITE_VERSION,
+                                       **metadata)
 
 
-def svg_renderer(spec):
+def svg_renderer(spec, **metadata):
     return headless.spec_to_mimebundle(spec, format='svg',
                                        mode='vega-lite',
                                        vega_version=VEGA_VERSION,
                                        vegaembed_version=VEGAEMBED_VERSION,
-                                       vegalite_version=VEGALITE_VERSION)
+                                       vegalite_version=VEGALITE_VERSION,
+                                       **metadata)
 
-def colab_renderer(spec):
+def colab_renderer(spec, **metadata):
     return html.spec_to_html_mimebundle(spec, mode='vega-lite',
                                         vega_version=VEGA_VERSION,
                                         vegaembed_version=VEGAEMBED_VERSION,
-                                        vegalite_version=VEGALITE_VERSION)
+                                        vegalite_version=VEGALITE_VERSION,
+                                        **metadata)
 
 renderers.register('default', default_renderer)
 renderers.register('jupyterlab', default_renderer)
diff --git a/altair/vegalite/v2/theme.py b/altair/vegalite/v2/theme.py
index 173a7a83..7f0658a2 100644
--- a/altair/vegalite/v2/theme.py
+++ b/altair/vegalite/v2/theme.py
@@ -1,19 +1,15 @@
 """Tools for enabling and registering chart themes"""
 
-from ...utils import PluginRegistry
+from ...utils.theme import ThemeRegistry
 
 # The entry point group that can be used by other packages to declare other
 # renderers that will be auto-detected. Explicit registration is also
 # allowed by the PluginRegistery API.
 ENTRY_POINT_GROUP = 'altair.vegalite.v2.theme'  # type: str
-
-class ThemeRegistry(PluginRegistry[dict]):
-    pass
-
 themes = ThemeRegistry(entry_point_group=ENTRY_POINT_GROUP)
 
-themes.register('default', {"config": {"view": {"width": 400, "height": 300}}})
-themes.register('opaque', {"config": {"background": "white",
-                                      "view": {"width": 400, "height": 300}}})
-themes.register('none', {})
+themes.register('default', lambda: {"config": {"view": {"width": 400, "height": 300}}})
+themes.register('opaque', lambda: {"config": {"background": "white",
+                                              "view": {"width": 400, "height": 300}}})
+themes.register('none', lambda: {})
 themes.enable('default')


Test patch:
diff --git a/altair/utils/tests/test_plugin_registry.py b/altair/utils/tests/test_plugin_registry.py
index 40387b57..3b3d2945 100644
--- a/altair/utils/tests/test_plugin_registry.py
+++ b/altair/utils/tests/test_plugin_registry.py
@@ -1,28 +1,74 @@
 from ..plugin_registry import PluginRegistry
+from typing import Callable
 
 
-class RegistryTest(PluginRegistry):
+class TypedCallableRegistry(PluginRegistry[Callable[[int], int]]):
+    pass
+
+
+class GeneralCallableRegistry(PluginRegistry):
     pass
 
 
 def test_plugin_registry():
-    plugins = RegistryTest()
+    plugins = TypedCallableRegistry()
 
     assert plugins.names() == []
     assert plugins.active == ''
     assert plugins.get() is None
-    assert repr(plugins) == "RegistryTest(active='', registered=[])"
+    assert repr(plugins) == "TypedCallableRegistry(active='', registered=[])"
 
     plugins.register('new_plugin', lambda x: x ** 2)
     assert plugins.names() == ['new_plugin']
     assert plugins.active == ''
     assert plugins.get() is None
-    assert repr(plugins) == ("RegistryTest(active='', "
+    assert repr(plugins) == ("TypedCallableRegistry(active='', "
                              "registered=['new_plugin'])")
 
     plugins.enable('new_plugin')
     assert plugins.names() == ['new_plugin']
     assert plugins.active == 'new_plugin'
     assert plugins.get()(3) == 9
-    assert repr(plugins) == ("RegistryTest(active='new_plugin', "
+    assert repr(plugins) == ("TypedCallableRegistry(active='new_plugin', "
                              "registered=['new_plugin'])")
+
+
+def test_plugin_registry_extra_options():
+    plugins = GeneralCallableRegistry()
+
+    plugins.register('metadata_plugin', lambda x, p=2: x ** p)
+    plugins.enable('metadata_plugin')
+    assert plugins.get()(3) == 9
+
+    plugins.enable('metadata_plugin', p=3)
+    assert plugins.get()(3) == 27
+
+
+def test_plugin_registry_context():
+    plugins = GeneralCallableRegistry()
+
+    plugins.register('default', lambda x, p=2: x ** p)
+
+    # At first there is no plugin enabled
+    assert plugins.active == ''
+    assert plugins.options == {}
+
+    # Make sure the context is set and reset correctly
+    with plugins.enable('default', p=6):
+        assert plugins.active == 'default'
+        assert plugins.options == {'p': 6}
+
+    assert plugins.active == ''
+    assert plugins.options == {}
+
+    # Make sure the context is reset even if there is an error
+    try:
+        with plugins.enable('default', p=6):
+            assert plugins.active == 'default'
+            assert plugins.options == {'p': 6}
+            raise ValueError()
+    except ValueError:
+        pass
+
+    assert plugins.active == ''
+    assert plugins.options == {}
diff --git a/altair/vegalite/tests/test_common.py b/altair/vegalite/tests/test_common.py
index 36093121..34679f8a 100644
--- a/altair/vegalite/tests/test_common.py
+++ b/altair/vegalite/tests/test_common.py
@@ -2,6 +2,8 @@
 
 import pytest
 
+import pandas as pd
+
 from .. import v1, v2
 
 v1_defaults = {
@@ -28,6 +30,19 @@ basic_spec = {
     },
 }
 
+
+def make_basic_chart(alt):
+    data = pd.DataFrame({
+        'a': ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'],
+        'b': [28, 55, 43, 91, 81, 53, 19, 87, 52]
+    })
+
+    return alt.Chart(data).mark_bar().encode(
+        x='a',
+        y='b'
+    )
+
+
 spec_v1 = dict(v1_defaults, **basic_spec)
 spec_v2 = dict(v2_defaults, **basic_spec)
 
@@ -79,3 +94,16 @@ def test_theme_enable(alt):
     finally:
         # reset the theme to its initial value
         alt.themes.enable(active_theme)
+
+
+@pytest.mark.parametrize('alt', [v1, v2])
+def test_max_rows(alt):
+    basic_chart = make_basic_chart(alt)
+
+    with alt.data_transformers.enable('default'):
+        basic_chart.to_dict()  # this should not fail
+
+    with alt.data_transformers.enable('default', max_rows=5):
+        print(alt.data_transformers.options)
+        with pytest.raises(alt.MaxRowsError):
+            basic_chart.to_dict()  # this should not fail
diff --git a/altair/vegalite/v2/tests/test_renderers.py b/altair/vegalite/v2/tests/test_renderers.py
new file mode 100644
index 00000000..7cad241a
--- /dev/null
+++ b/altair/vegalite/v2/tests/test_renderers.py
@@ -0,0 +1,39 @@
+"""Tests of various renderers"""
+
+import pytest
+
+import altair as alt
+
+
+@pytest.fixture
+def chart():
+    return alt.Chart('data.csv').mark_point()
+
+
+def test_colab_renderer_embed_options(chart):
+    """Test that embed_options in renderer metadata are correctly manifest in html"""
+    with alt.renderers.enable('colab', embed_options=dict(actions=False)):
+        bundle = chart._repr_mimebundle_(None, None)
+        html = bundle['text/html']
+        assert ('embed_opt = {"actions": false, "mode": "vega-lite"}' in html or
+                'embed_opt = {"mode": "vega-lite", "actions": false}' in html)
+
+    with alt.renderers.enable('colab', embed_options=dict(actions=True)):
+        bundle = chart._repr_mimebundle_(None, None)
+        html = bundle['text/html']
+        assert ('embed_opt = {"actions": true, "mode": "vega-lite"}' in html or
+                'embed_opt = {"mode": "vega-lite", "actions": true}' in html)
+
+
+# TODO: test notebook renderer here when it supports metadata
+@pytest.mark.parametrize('renderer', ['default'])
+def test_default_renderer_embed_options(chart, renderer):
+    # check that metadata is passed appropriately
+    with alt.renderers.enable(renderer, embed_options=dict(actions=False)):
+        bundle, metadata = chart._repr_mimebundle_(None, None)
+        assert metadata == {'embed_options': {'actions': False}}
+
+    # Sanity check: no metadata specified
+    with alt.renderers.enable(renderer):
+        bundle, metadata = chart._repr_mimebundle_(None, None)
+        assert metadata == {}