Repository: altair-viz/altair
Pull request #740 (2018-04-10 14:49:37)

ENH: add theme support
This addresses #718, adding support for switching between different chart themes.

For example, you can do

```python
import altiar as alt
alt.theme.enable('opaque')
```

and charts will now have a white background. Restore the default theme using

```python
alt.theme.enable('default')
```

Register custom themes using package entrypoints, or using, e.g.
```python
alt.theme.register('black-background', {'config': {'background': 'black'}})
```
and then enable it using
```python
alt.theme.enable('black-background')
```

@afonit – I'd love your input on what other themes we should make available by default. Currently I have "default" (set width and height) "opaque" (set width & height, and background color to white) and "none" (no config settings).


Patch:
diff --git a/altair/vegalite/v1/api.py b/altair/vegalite/v1/api.py
index f618c1ee..821534fb 100644
--- a/altair/vegalite/v1/api.py
+++ b/altair/vegalite/v1/api.py
@@ -14,6 +14,7 @@ from .schema import core, channels, Undefined, SCHEMA_URL, SCHEMA_VERSION
 from .data import data_transformers, pipe
 from ... import utils
 from .display import renderers
+from .theme import theme
 
 
 VEGALITE_VERSION = SCHEMA_VERSION.lstrip('v')
@@ -46,7 +47,6 @@ class Formula(core.Formula):
 # *************************************************************************
 
 class TopLevelMixin(object):
-    _default_spec_values = {"width": 400, "height": 300}
     _class_is_valid_at_instantiation = False
 
     def _prepare_data(self):
@@ -93,9 +93,8 @@ class TopLevelMixin(object):
             if '$schema' not in dct:
                 dct['$schema'] = SCHEMA_URL
 
-            # add default values if present
-            if copy._default_spec_values:
-                dct = utils.update_nested(copy._default_spec_values, dct, copy=True)
+            # apply theme from theme registry
+            dct = utils.update_nested(theme.get(), dct, copy=True)
         return dct
 
     def savechart(self, fp, format=None, **kwargs):
@@ -244,9 +243,9 @@ class TopLevelMixin(object):
 
 class Chart(TopLevelMixin, core.ExtendedUnitSpec):
     def __init__(self, data=Undefined, encoding=Undefined, mark=Undefined,
-                 width=400, height=300, **kwargs):
+                 **kwargs):
         super(Chart, self).__init__(data=data, encoding=encoding, mark=mark,
-                                    width=width, height=height, **kwargs)
+                                    **kwargs)
 
     @utils.use_signature(core.MarkConfig)
     def mark_area(self, **kwargs):
diff --git a/altair/vegalite/v1/theme.py b/altair/vegalite/v1/theme.py
new file mode 100644
index 00000000..d78f373d
--- /dev/null
+++ b/altair/vegalite/v1/theme.py
@@ -0,0 +1,17 @@
+"""Tools for enabling and registering chart themes"""
+
+from ...utils import PluginRegistry
+
+# 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
+    
+theme = ThemeRegistry(entry_point_group=ENTRY_POINT_GROUP)
+
+theme.register('default', {"width": 400, "height": 300})
+theme.register('none', {})
+theme.enable('default')
diff --git a/altair/vegalite/v2/api.py b/altair/vegalite/v2/api.py
index ed5bd505..2d2bdf36 100644
--- a/altair/vegalite/v2/api.py
+++ b/altair/vegalite/v2/api.py
@@ -8,6 +8,7 @@ from .schema import core, channels, mixins, Undefined, SCHEMA_URL, SCHEMA_VERSIO
 
 from .data import data_transformers, pipe
 from .display import renderers
+from .theme import theme
 from ... import utils, expr
 
 VEGALITE_VERSION = SCHEMA_VERSION.lstrip('v')
@@ -306,7 +307,6 @@ def condition(predicate, if_true, if_false, **kwargs):
 
 class TopLevelMixin(mixins.ConfigMethodMixin):
     """Mixin for top-level chart objects such as Chart, LayeredChart, etc."""
-    _default_spec_values = {"config": {"view": {"width": 400, "height": 300}}}
     _class_is_valid_at_instantiation = False
 
     def to_dict(self, *args, **kwargs):
@@ -345,10 +345,10 @@ class TopLevelMixin(mixins.ConfigMethodMixin):
             # since this is top-level we add $schema if it's missing
             if '$schema' not in dct:
                 dct['$schema'] = SCHEMA_URL
+                
+            # apply theme from theme registry
+            dct = utils.update_nested(theme.get(), dct, copy=True)
 
-            # add default values if present
-            if copy._default_spec_values:
-                dct = utils.update_nested(copy._default_spec_values, dct, copy=True)
         return dct
 
     def savechart(self, fp, format=None, **kwargs):
diff --git a/altair/vegalite/v2/theme.py b/altair/vegalite/v2/theme.py
new file mode 100644
index 00000000..0ebc6bfd
--- /dev/null
+++ b/altair/vegalite/v2/theme.py
@@ -0,0 +1,19 @@
+"""Tools for enabling and registering chart themes"""
+
+from ...utils import PluginRegistry
+
+# 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
+
+theme = ThemeRegistry(entry_point_group=ENTRY_POINT_GROUP)
+
+theme.register('default', {"config": {"view": {"width": 400, "height": 300}}})
+theme.register('opaque', {"config": {"background": "white",
+                                     "view": {"width": 400, "height": 300}}})
+theme.register('none', {})
+theme.enable('default')


Test patch:
diff --git a/altair/vegalite/tests/test_common.py b/altair/vegalite/tests/test_common.py
index 1742892d..6b9949b9 100644
--- a/altair/vegalite/tests/test_common.py
+++ b/altair/vegalite/tests/test_common.py
@@ -58,3 +58,24 @@ def test_basic_chart_from_dict(alt, basic_spec):
 
     # remainder of spec should match the basic spec
     assert dct == basic_spec
+
+
+@pytest.mark.parametrize('alt', [v1, v2])
+def test_theme_enable(alt):
+    active_theme = alt.theme.active
+
+    try:
+        alt.theme.enable('none')
+
+        chart = alt.Chart.from_dict(basic_spec)
+        dct = chart.to_dict()
+
+        # schema should be in the top level
+        assert dct.pop('$schema').startswith('http')
+
+        # remainder of spec should match the basic spec
+        # without any theme settings
+        assert dct == basic_spec
+    finally:
+        # reset the theme to its initial value
+        alt.theme.enable(active_theme)
diff --git a/altair/vegalite/v2/tests/test_api.py b/altair/vegalite/v2/tests/test_api.py
index 79fe8f25..8ee87085 100644
--- a/altair/vegalite/v2/tests/test_api.py
+++ b/altair/vegalite/v2/tests/test_api.py
@@ -299,3 +299,23 @@ def test_LookupData():
     assert dct['data'] == {'values': [{'x': 1, 'y': 4},
                                       {'x': 2, 'y': 5},
                                       {'x': 3, 'y': 6}]}
+
+
+def test_themes():
+    chart = alt.Chart('foo.txt').mark_point()
+    active = alt.theme.active
+
+    try:
+        alt.theme.enable('default')
+        assert chart.to_dict()['config'] == {"view": {"width": 400, "height": 300}}
+
+        alt.theme.enable('opaque')
+        assert chart.to_dict()['config'] == {"background": "white",
+                                             "view": {"width": 400, "height": 300}}
+
+        alt.theme.enable('none')
+        assert 'config' not in chart.to_dict()
+
+    finally:
+        # re-enable the original active theme
+        alt.theme.enable(active)