Repository: altair-viz/altair
Pull request #215 (2016-09-28 22:18:44)

MRG: add vega expression machinery
This PR adds a way to create validated expressions for vega formulas (addresses #208). Previously, everything had to be done with a string; for example:

```python
from altair import Chart, Formula

Chart(data).encode(
    x='x',
    y='foo',
).transform_data(
    calculate=[Formula(field='foo', expr='if(log(datum.x) < 2, "A", "B"')],
    filter='datum.x > 0'
)
```
The problem is that this string is not validated on the Python side, and the errors from Vega-Lite are not particularly helpful.

After this PR, you can instead do

```python
from altair import Chart, Formula, expr

# can pass URL, dataframe, Data obj, etc.
data = expr.DataFrame(data)

# creates a filter operation
data = data[data.x > 0]

# create a transform operation
data['foo'] = expr.if_(expr.log(data.x) < 2, "A", "B")

Chart(data).encode(
    x='x',
    y='foo',
)
```
which is more intuitive, less error-prone, and is  structurally validated on the Python side.

This is also fully backward-compatible; i.e. you can still pass the string if you wish.

jakevdp: @ellisonbg had the idea to expose this via a Pandas-like interface. I've committed some initial code that does this... still have not created the mechanism to ingest this into the spec, but that will come. The idea is we'd do something like this:

```python
from altair import Chart, expr, load_dataset

cars = load_dataset('cars', as_expr=True)
cars['newcol'] = expr.sin(cars.Miles_per_gallon / cars.Acceleration)

Chart(cars).mark_tick().encode(
    x='newcol'
)
```
and the JSON output would be something like:
```python
{
 'data': {'url': 'https://vega.github.io/vega-datasets/data/cars.json'},
 'encoding': {'x': {'field': 'newcol'}},
 'mark': 'tick',
 'transform': {'calculate': [{'field': 'newcol', 'expr': 'sin(datum.Miles_per_gallon / datum.Horsepower)'}]}
}
```
jakevdp: @ellisonbg I think this is ready for a review and merge, if you have a chance to take a look!
jakevdp: Good point on a repr for the dataframe wrapper. I think probably the most useful thing would be something like

```Data source with columns ['x', 'y', 'z']```

or

```Data source with unknown columns```

perhaps followed by a ``repr`` of the underlying object, if any.
jakevdp: > Is it indented to ever expand .encode to accept expr.core.Series (assuming that column is in data)?

We'd discussed that, but decided not to go that route because it would likely lead to either confusion (why doesn't this other array work?) or feature-creep (why require a dataset at all?). We might revisit that in the future.
jakevdp: Thanks for the review – I think I hit all the main points. I also did a couple other tweaks, such as making the default ``df`` read-only to prevent issues related to state.
jakevdp: Waiting on @ellisonbg to review and merge. In the meantime, I'm going to start preparing for a 1.2 release on top of this PR.

Patch:
diff --git a/altair/__init__.py b/altair/__init__.py
index 8d177093..9bba54c7 100644
--- a/altair/__init__.py
+++ b/altair/__init__.py
@@ -42,4 +42,6 @@ from .datasets import (
     load_dataset
 )
 
+from . import expr
+
 from .tutorial import tutorial
diff --git a/altair/api.py b/altair/api.py
index a7f34453..a7cf3a70 100644
--- a/altair/api.py
+++ b/altair/api.py
@@ -4,6 +4,8 @@ Main API for Vega-lite spec generation.
 DSL mapping Vega types to IPython traitlets.
 """
 import os
+import functools
+import operator
 
 import traitlets as T
 import pandas as pd
@@ -12,6 +14,7 @@ from .utils import visitors
 from .utils._py3k_compat import string_types
 from .utils import node
 
+from . import expr
 from . import schema
 
 from .schema import AggregateOp
@@ -28,7 +31,6 @@ from .schema import FacetGridConfig
 from .schema import FacetScaleConfig
 from .schema import FontStyle
 from .schema import FontWeight
-from .schema import Formula
 from .schema import HorizontalAlign
 from .schema import LegendConfig
 from .schema import Legend
@@ -40,7 +42,6 @@ from .schema import SortField
 from .schema import SortOrder
 from .schema import StackOffset
 from .schema import TimeUnit
-from .schema import Transform
 from .schema import UnitSpec
 from .schema import UnitEncoding
 from .schema import VerticalAlign
@@ -66,6 +67,42 @@ def use_signature(Obj):
     return decorate
 
 
+#*************************************************************************
+# Formula wrapper
+# - makes field a required first argument of initialization
+# - allows expr trait to be an Expression and processes it properly
+#*************************************************************************
+class Formula(schema.Formula):
+    expr = T.Union([T.Unicode(), T.Instance(expr.Expression)],
+                    allow_none=True, default_value=None,
+                    help=schema.Formula.expr.help)
+
+    def __init__(self, field, expr=None, **kwargs):
+        super(Formula, self).__init__(field=field, expr=expr, **kwargs)
+
+    def _finalize(self, **kwargs):
+        """Finalize object: convert expr expression to string if necessary"""
+        if isinstance(self.expr, expr.Expression):
+            self.expr = repr(self.expr)
+        super(Formula, self)._finalize(**kwargs)
+
+
+#*************************************************************************
+# Transform wrapper
+# - allows filter trait to be an Expression and processes it properly
+#*************************************************************************
+class Transform(schema.Transform):
+    filter = T.Union([T.Unicode(), T.Instance(expr.Expression)],
+                     allow_none=True, default_value=None,
+                     help=schema.Transform.filter.help)
+
+    def _finalize(self, **kwargs):
+        """Finalize object: convert filter expression to string"""
+        if isinstance(self.filter, expr.Expression):
+            self.filter = repr(self.filter)
+        super(Transform, self)._finalize(**kwargs)
+
+
 #*************************************************************************
 # Top-level Objects
 #*************************************************************************
@@ -272,13 +309,36 @@ class TopLevelMixin(object):
               files=files, jupyter_warning=jupyter_warning,
               open_browser=open_browser, http_server=http_server)
 
+    def _finalize_data(self):
+        """
+        This function is called by _finalize() below. It checks whether the
+        data attribute contains expressions, and if so it extracts the
+        appropriate data object and generates the appropriate transforms.
+        """
+        if isinstance(self.data, expr.DataFrame):
+            columns = self.data._cols
+            calculated_cols = self.data._calculated_cols
+            filters = self.data._filters
+            self.data = self.data._data
+            if columns is not None and isinstance(self.data, pd.DataFrame):
+                self.data = self.data[columns]
+            if calculated_cols:
+                self.transform_data(calculate=[Formula(field, expr=exp)
+                                               for field, exp
+                                               in calculated_cols.items()])
+            if filters:
+                self.transform_data(filter=functools.reduce(operator.and_,
+                                                            filters))
+
 
 class Chart(schema.ExtendedUnitSpec, TopLevelMixin):
     _data = None
 
-    # use specialized version of Encoding
+    # use specialized version of Encoding and Transform
     encoding = T.Instance(Encoding, allow_none=True, default_value=None,
                           help=schema.ExtendedUnitSpec.encoding.help)
+    transform = T.Instance(Transform, allow_none=True, default_value=None,
+                           help=schema.ExtendedUnitSpec.transform.help)
 
     @property
     def data(self):
@@ -288,7 +348,8 @@ class Chart(schema.ExtendedUnitSpec, TopLevelMixin):
     def data(self, new):
         if isinstance(new, string_types):
             self._data = Data(url=new)
-        elif (isinstance(new, pd.DataFrame) or isinstance(new, Data) or new is None):
+        elif (new is None or isinstance(new, pd.DataFrame)
+              or isinstance(new, expr.DataFrame) or isinstance(new, Data)):
             self._data = new
         else:
             raise TypeError('Expected DataFrame or altair.Data, got: {0}'.format(new))
@@ -374,6 +435,7 @@ class Chart(schema.ExtendedUnitSpec, TopLevelMixin):
         return self._update_subtraits('encoding', *args, **kwargs)
 
     def _finalize(self, **kwargs):
+        self._finalize_data()
         # data comes from wrappers, but self.data overrides this if defined
         if self.data is not None:
             kwargs['data'] = self.data
@@ -421,9 +483,11 @@ class Chart(schema.ExtendedUnitSpec, TopLevelMixin):
 class LayeredChart(schema.LayerSpec, TopLevelMixin):
     _data = None
 
-    # Use specialized version of Chart
+    # Use specialized version of Chart and Transform
     layers = T.List(T.Instance(Chart), allow_none=True, default_value=None,
                     help=schema.LayerSpec.layers.help)
+    transform = T.Instance(Transform, allow_none=True, default_value=None,
+                           help=schema.LayerSpec.transform.help)
 
     @property
     def data(self):
@@ -433,7 +497,8 @@ class LayeredChart(schema.LayerSpec, TopLevelMixin):
     def data(self, new):
         if isinstance(new, string_types):
             self._data = Data(url=new)
-        elif (isinstance(new, pd.DataFrame) or isinstance(new, Data) or new is None):
+        elif (new is None or isinstance(new, pd.DataFrame)
+              or isinstance(new, expr.DataFrame) or isinstance(new, Data)):
             self._data = new
         else:
             raise TypeError('Expected DataFrame or altair.Data, got: {0}'.format(new))
@@ -462,6 +527,7 @@ class LayeredChart(schema.LayerSpec, TopLevelMixin):
         return self
 
     def _finalize(self, **kwargs):
+        self._finalize_data()
         # data comes from wrappers, but self.data overrides this if defined
         if self.data is not None:
             kwargs['data'] = self.data
@@ -478,11 +544,14 @@ class LayeredChart(schema.LayerSpec, TopLevelMixin):
 class FacetedChart(schema.FacetSpec, TopLevelMixin):
     _data = None
 
+    # Use specialized version of Facet, spec, and Transform
     facet = T.Instance(Facet, allow_none=True, default_value=None,
                        help=schema.FacetSpec.facet.help)
     spec = T.Union([T.Instance(LayeredChart), T.Instance(Chart)],
                    allow_none=True, default_value=None,
                    help=schema.FacetSpec.spec.help)
+    transform = T.Instance(Transform, allow_none=True, default_value=None,
+                           help=schema.FacetSpec.transform.help)
 
     @property
     def data(self):
@@ -492,7 +561,8 @@ class FacetedChart(schema.FacetSpec, TopLevelMixin):
     def data(self, new):
         if isinstance(new, string_types):
             self._data = Data(url=new)
-        elif (isinstance(new, pd.DataFrame) or isinstance(new, Data) or new is None):
+        elif (new is None or isinstance(new, pd.DataFrame)
+              or isinstance(new, expr.DataFrame) or isinstance(new, Data)):
             self._data = new
         else:
             raise TypeError('Expected DataFrame or altair.Data, got: {0}'.format(new))
@@ -522,6 +592,7 @@ class FacetedChart(schema.FacetSpec, TopLevelMixin):
         return self._update_subtraits('facet', *args, **kwargs)
 
     def _finalize(self, **kwargs):
+        self._finalize_data()
         # data comes from wrappers, but self.data overrides this if defined
         if self.data is not None:
             kwargs['data'] = self.data
diff --git a/altair/expr/__init__.py b/altair/expr/__init__.py
new file mode 100644
index 00000000..ee21d35d
--- /dev/null
+++ b/altair/expr/__init__.py
@@ -0,0 +1,9 @@
+"""Tools for generating transformation expressions with a dataframe syntax"""
+
+from .core import DataFrame, Expression
+from .funcs import *
+from .consts import *
+
+# This object provides lazily-evaluated dataframe-like operations
+# useful for any data source.
+df = DataFrame('anonymous', cols=None, read_only=True)
diff --git a/altair/expr/const_listing.json b/altair/expr/const_listing.json
new file mode 100644
index 00000000..49020ba6
--- /dev/null
+++ b/altair/expr/const_listing.json
@@ -0,0 +1,11 @@
+{
+  "NaN": "not a number (same as JavaScript literal NaN)",
+  "LN10": "the natural log of 10 (alias to Math.LN10)",
+  "E": "the transcendental number e (alias to Math.E)",
+  "LOG10E": "the base 10 logarithm e (alias to Math.LOG10E)",
+  "LOG2E": "the base 2 logarithm of e (alias to Math.LOG2E)",
+  "SQRT1_2": "the square root of 0.5 (alias to Math.SQRT1_2)",
+  "LN2": "the natural log of 2 (alias to Math.LN2)",
+  "SQRT2": "the square root of 2 (alias to Math.SQRT1_2)",
+  "PI": "the transcendental number pi (alias to Math.PI)"
+}
diff --git a/altair/expr/consts.py b/altair/expr/consts.py
new file mode 100644
index 00000000..2f4f39ea
--- /dev/null
+++ b/altair/expr/consts.py
@@ -0,0 +1,19 @@
+import json
+import os
+from .core import ConstExpression
+
+
+# This maps vega expression constant names to the Python name
+NAME_MAP = {}
+
+
+def _populate_namespace():
+    all_ = []
+    with open(os.path.join(os.path.dirname(__file__), 'const_listing.json')) as f:
+        const_listing = json.load(f)
+    for name, doc in const_listing.items():
+        globals()[NAME_MAP.get(name, name)] = ConstExpression(name, doc)
+        all_.append(NAME_MAP.get(name, name))
+    return all_
+
+__all__ = _populate_namespace()
diff --git a/altair/expr/core.py b/altair/expr/core.py
new file mode 100644
index 00000000..c7946291
--- /dev/null
+++ b/altair/expr/core.py
@@ -0,0 +1,306 @@
+__all__ = ['DataFrame']
+
+
+import warnings
+from collections import OrderedDict
+from ..utils._py3k_compat import string_types
+
+import pandas as pd
+
+
+class Expression(object):
+    """Expression
+
+    This is an object to enable the build-up of Javascript expressions using
+    a Python syntax. Calling ``repr(obj)`` will return a Javascript
+    representation of the object and/or the operations it encodes.
+    """
+    # TODO: Javascript's add is does things like string concatenation...
+    # should we attempt to translate Python's semantics appropriately?
+    def __add__(self, other):
+        return BinaryExpression("+", self, other)
+
+    def __radd__(self, other):
+        return BinaryExpression("+", other, self)
+
+    def __sub__(self, other):
+        return BinaryExpression("-", self, other)
+
+    def __rsub__(self, other):
+        return BinaryExpression("-", other, self)
+
+    def __mul__(self, other):
+        return BinaryExpression("*", self, other)
+
+    def __rmul__(self, other):
+        return BinaryExpression("*", other, self)
+
+    def __floordiv__(self, other):
+        return BinaryExpression("/", self, other)
+
+    def __rfloordiv__(self, other):
+        return BinaryExpression("/", other, self)
+
+    def __truediv__(self, other):
+        warnings.warn("Javascript uses floor division; use '//' to silence this warning")
+        return self.__floordiv__(other)
+
+    def __rtruediv__(self, other):
+        warnings.warn("Javascript uses floor division; use '//' to silence this warning")
+        return self.__rfloordiv(other)
+
+    __div__ = __floordiv__
+
+    __rdiv__ = __rfloordiv__
+
+    def __mod__(self, other):
+        return BinaryExpression('%', self, other)
+
+    def __rmod__(self, other):
+        return BinaryExpression('%', other, self)
+
+    def __pow__(self, other):
+        # "**" Javascript operator is not supported in all browsers
+        return FunctionExpression('pow', self, other)
+
+    def __rpow__(self, other):
+        # "**" Javascript operator is not supported in all browsers
+        return FunctionExpression('pow', other, self)
+
+    def __neg__(self):
+        return UnaryExpression('-', self)
+
+    def __pos__(self):
+        return UnaryExpression('+', self)
+
+    # comparison operators
+
+    def __eq__(self, other):
+        return BinaryExpression("==", self, other)
+
+    def __ne__(self, other):
+        return BinaryExpression("!=", self, other)
+
+    def __gt__(self, other):
+        return BinaryExpression(">", self, other)
+
+    def __lt__(self, other):
+        return BinaryExpression("<", self, other)
+
+    def __ge__(self, other):
+        return BinaryExpression(">=", self, other)
+
+    def __le__(self, other):
+        return BinaryExpression("<=", self, other)
+
+    def __abs__(self):
+        return FunctionExpression('abs', self)
+
+    # TODO: how to handle assignment? Overwriting would be an issue...
+
+    # logical operators
+    def __and__(self, other):
+        return BinaryExpression('&&', self, other)
+
+    def __rand__(self, other):
+        return BinaryExpression('&&', other, self)
+
+    def __or__(self, other):
+        return BinaryExpression('||', self, other)
+
+    def __ror__(self, other):
+        return BinaryExpression('||', other, self)
+
+    def __invert__(self):
+        return UnaryExpression('!', self)
+
+
+class UnaryExpression(Expression):
+    def __init__(self, op, val):
+        self.op = op
+        self.val = val
+
+    def __repr__(self):
+        return "({op}{val})".format(op=self.op, val=repr(self.val))
+
+
+class BinaryExpression(Expression):
+    def __init__(self, op, lhs, rhs):
+        self.op = op
+        self.lhs = lhs
+        self.rhs = rhs
+
+    def __repr__(self):
+        return "({lhs}{op}{rhs})".format(op=self.op,
+                                         lhs=repr(self.lhs),
+                                         rhs=repr(self.rhs))
+
+
+class FunctionExpression(Expression):
+    def __init__(self, name, *args):
+        self.name = name
+        self.args = args
+
+    def __repr__(self):
+        args = ','.join(repr(arg) for arg in self.args)
+        return "{name}({args})".format(name=self.name, args=args)
+
+
+class ConstExpression(Expression):
+    def __init__(self, name, doc):
+        self.name = name
+        self.doc = doc
+        self.__doc__ = """{0}: {1}""".format(name, doc)
+
+    def __repr__(self):
+        return str(self.name)
+
+
+class Series(Expression):
+    def __init__(self, name, parent=None):
+        self.name = name
+        self.parent = parent
+
+    def __repr__(self):
+        return "datum.{name}".format(name=self.name)
+
+    @property
+    def contents(self):
+        if self.parent is None:
+            return None
+        else:
+            return self.parent._calculated_cols.get(self.name, None)
+
+
+class DataFrame(object):
+    """Lazy wrapper for altair data source
+
+    This object provides DataFrame-like operations for any data source, which
+    are not immediately computed but rather stored for later translation into
+    Vega-Lite's data transformation language, which is a subset of javascript.
+
+    If you need an "anonymous" DataFrame object, i.e. one not linked to any
+    particular dataset, ``altair.expr.df`` is available.
+
+    Parameters
+    ----------
+    data : string, pd.DataFrame, or altair.Data object
+        The data to wrap and do operations on
+    cols : list (optional)
+        The column names within the dataset. If not specified, columns will be
+        inferred from the data if possible (i.e. if data is a pandas DataFrame).
+
+    Notes
+    -----
+        When column names (``cols``) are present, they will be available via
+        tab-completion in the IPython shell, and attempting to access a column
+        which does not appear in the list will result in an error.
+
+    Examples
+    --------
+    >>> from altair import expr, Chart
+    >>> df = expr.DataFrame('url/to/my/data.json')
+    >>> df['density'] = df.population // df.area    # add calculated column
+    >>> df = df[df.density > 100]                   # filter by value
+    >>> df
+    <Data Wrapper; colums=[*, density]>
+
+    # Filter and transform operations are realized in the Chart object
+    >>> chart = Chart(df)
+    >>> print(chart.to_json(indent=2))         # doctest: +NORMALIZE_WHITESPACE
+    {
+      "data": {
+        "url": "url/to/my/data.json"
+      },
+      "transform": {
+        "calculate": [
+          {
+            "expr": "(datum.population/datum.area)",
+            "field": "density"
+          }
+        ],
+        "filter": "(datum.density>100)"
+      }
+    }
+    """
+    def __init__(self, data, cols=None, read_only=False):
+        self.read_only = read_only
+
+        if isinstance(data, self.__class__):
+            self._data = data._data
+            if cols is None:
+                self._cols = self._get_cols(self._data, data._cols)
+            else:
+                self._cols = self._get_cols(self._data, cols)
+            self._calculated_cols = data._calculated_cols.copy()
+            self._filters = data._filters[:]
+        else:
+            self._data = data
+            self._cols = self._get_cols(data, cols)
+            self._calculated_cols = OrderedDict({})
+            self._filters = []
+
+    def copy(self, **kwargs):
+        return self.__class__(self, **kwargs)
+
+    def __repr__(self):
+        if self._cols is None:
+            cols = ['*']
+        else:
+            cols = list(self._cols)
+        cols.extend(list(self._calculated_cols))
+        return ("<Data Wrapper; colums=[{0}]>".format(', '.join(cols)))
+
+    @classmethod
+    def _get_cols(cls, data, cols=None):
+        def is_altair_Data(data):
+            # importing here avoids circular imports
+            from ... import Data
+            return isinstance(data, Data)
+
+        if cols is not None:
+            return list(cols)
+        elif isinstance(data, string_types):
+            return None
+        elif isinstance(data, pd.DataFrame):
+            return list(data.columns)
+        elif is_altair_Data(data):
+            if hasattr(data, 'values'):
+                return list(data.values[0].keys())
+            else:
+                return None
+        else:
+            raise ValueError("Unrecognized data type")
+
+    def __dir__(self):
+        return (self._cols or []) + list(self._calculated_cols.keys())
+
+    def __getattr__(self, attr):
+        if self._cols is None or attr in self._cols or attr in self._calculated_cols:
+            return Series(attr, self)
+        else:
+            raise AttributeError("No attribute {0}".format(attr))
+
+    def __getitem__(self, attr):
+        if isinstance(attr, string_types):
+            # Select a column
+            if self._cols is None or attr in self._cols or attr in self._calculated_cols:
+                return Series(attr, self)
+            else:
+                raise KeyError("No column {0}".format(attr))
+        elif isinstance(attr, Expression):
+            # Apply a filter
+            result = self.copy()
+            result._filters.append(attr)
+            return result
+        elif isinstance(attr, (list, tuple)):
+            return self.copy(cols=attr)
+        else:
+            raise KeyError("attribute {0} not recognized".format(attr))
+
+    def __setitem__(self, attr, obj):
+        if self.read_only:
+            raise ValueError("Cannot set a column in a read-only data source")
+        if (self._cols is not None and attr in self._cols) or attr in self._calculated_cols:
+            raise ValueError("Cannot overwrite column '{0}'".format(attr))
+        self._calculated_cols[attr] = obj
diff --git a/altair/expr/func_listing.json b/altair/expr/func_listing.json
new file mode 100644
index 00000000..b5ee3867
--- /dev/null
+++ b/altair/expr/func_listing.json
@@ -0,0 +1,69 @@
+{
+  "utcyear": "returns the year for a given date input, in UTC time",
+  "indexof": "returns the first index of an element (for array inputs) or substring (for string inputs) e.g., `indexof(\"visualization\", \"i\") == 1",
+  "upper": "transforms a string to upper-case",
+  "asin": "trigonometric arcsine (alias to `Math.asin`)",
+  "length": "returns the length of an array or string",
+  "isFinite": "checks if a value is a finite number (same as JavaScript `isFinite`)",
+  "open": "opens a hyperlink (alias to `window.open`). This function is only valid when running in the browser. It should not be invoked within a server-side (e.g., node.js) environment.",
+  "cos": "trigonometric cosine (alias to `Math.cos`)",
+  "lastindexof": "returns the last index of an element (for array inputs) or substring (for string inputs)  e.g., `lastindexof(\"visualization\", \"i\") == 10",
+  "if": "if the first argument evaluates true then the second argument is returned, otherwise the third argument is returned (`if(a, b, c)` is equivalent to `a ? b : c`)",
+  "timeFormat": "formats a string as a local datetime; the first argument must be a valid [d3-time-format specifier](https://github.com/d3/d3-time-format/) (e.g., `timeFormat(\"%A\", datum.timestamp)`)",
+  "timezoneoffset": "returns the timezone offset from the local timezone to UTC for a given date input",
+  "utcmilliseconds": "returns the hours component for a given date input, in UTC time",
+  "test": "evaluates a regular expression against a string, returning true if the string matches the pattern, false otherwise. _Example_: `test(/\\d{3}/, \"32-21-9483\") -> true",
+  "utcmonth": "returns the (zero-based) month for a given date input, in UTC time",
+  "eventGroup": "returns the scenegraph group mark item within which the current event has occurred. If no arguments are provided, the immediate parent group is returned. If a group name is provided, the matching ancestor group item is returned.",
+  "minutes": "returns the minutes component for a given date input, in local time",
+  "tan": "trigonometric tangent (alias to `Math.tan`)",
+  "milliseconds": "returns the milliseconds component for a given date input, in local time",
+  "atan": "trigonometric arctangent (alias to `Math.atan`)",
+  "parseInt": "parses a string to an integer value (same as JavaScript `parseInt`)",
+  "year": "returns the year for a given date input, in local time",
+  "utcseconds": "returns the hours component for a given date input, in UTC time",
+  "seconds": "returns the seconds component for a given date input, in local time",
+  "pow": "exponentiates the first argument by the second argument (alias to `Math.pow`)",
+  "abs": "absolute value (alias to `Math.abs`)",
+  "utc": "returns a timestamp for a UTC date _(year, month[, day, hour, min, sec])",
+  "date": "returns the day of the month for a given date input, in local time",
+  "day": "return the day of the week for a given date input, in local time",
+  "acos": "trigonometric arccosine (alias to `Math.acos`)",
+  "time": "returns the epoch-based timestamp for a given date input",
+  "indata": "tests if a specified datasource contains a tuple with a given value for a specific field (i.e., `indata(\"table\", val, \"price\")`)",
+  "clamp": "restricts a value between a specified min and max (e.g. `clamp(value, min, max)`)",
+  "lower": "transforms a string to lower-case",
+  "log": "natural logarithm function (alias to `Math.log`)",
+  "atan2": "returns the arctangent of the quotient of its arguments (alias to `Math.atan2`)",
+  "eventX": "returns the x-coordinate for the current event. If no arguments are provided, the top-level coordinate space of the visualization is used. If a group name is provided, the coordinate-space of the matching ancestor group item is used.",
+  "month": "returns the (zero-based) month for a given date input, in local time",
+  "sin": "trigonometric sine (alias to `Math.sin`)",
+  "datetime": "returns a new `Date` instance _(year, month[, day, hour, min, sec, millisec])_  Note that, just like Javascript, `month` is 0-based. For example, `1` represents February.",
+  "scale": "applies a named scale transform to a specified value; by default, looks for the scale at the top-level of the specification, but an optional signal can also be supplied corresponding to the group which contains the scale (i.e., `scale(\"x\", val, group)`). *Note:* This function is only legal within signal stream handlers and mark [production rules](https://github.com/vega/vega/wiki/Marks#production-rules). Invoking this function elsewhere (e.g., with filter or formula transforms) will result in an error.",
+  "random": "generates a pseudo-random number in the range [0,1) (alias to `Math.random`)",
+  "sqrt": "square root function (alias to `Math.sqrt`)",
+  "round": "rounds to the nearest integer (alias to `Math.round`)",
+  "min": "returns the minimum argument value (alias to `Math.min`)",
+  "utcday": "returns the day of the week for a given date input, in UTC time",
+  "max": "return the maximum argument (alias to `Math.max`)",
+  "replace": "replace a pattern with a given string (alias to `String.replace`)",
+  "utcminutes": "returns the hours component for a given date input, in UTC time",
+  "regexp": "creates a regular expression instance from input strings (same as JavaScript `RegExp`)",
+  "utchours": "returns the hours component for a given date input, in UTC time",
+  "isNaN": "checks if a value is not-a-number (same as JavaScript `isNaN`)",
+  "eventItem": "a zero-argument function that returns the current scenegraph item that is the subject of the event.",
+  "utcdate": "returns the day of the month for a given date input, in UTC time",
+  "ceil": "rounds to the nearest integer of greater value (alias to `Math.ceil`)",
+  "utcFormat": "formats a string as a [UTC](https://en.wikipedia.org/wiki/Coordinated_Universal_Time) datetime; the first argument must be a valid [d3-time-format specifier](https://github.com/mbostock/d3/wiki/Time-Formatting) (e.g., `utcFormat(\"%\", datum.timestamp)`).",
+  "substring": "extracts a substring from a string (alias to `String.substring`)",
+  "hours": "returns the hours component for a given date input, in local time",
+  "format": "formats a string as a numeric value; the first argument must be a valid [d3-format specifier](https://github.com/d3/d3-format/) (e.g., `format(\",.2f\", datum.value)`)",
+  "exp": "raises _e_ to the provided exponent (alias to `Math.exp`)",
+  "parseFloat": "parses a string to a floating-point value (same as JavaScript `parseFloat`)",
+  "iscale": "applies an inverse scale transform to a specified value; by default, looks for the scale at the top-level of the specification, but an optional signal can also be supplied corresponding to the group which contains the scale (i.e., `iscale(\"x\", val, group)`). *Note:* This function is only legal within signal stream handlers and mark [production rules](https://github.com/vega/vega/wiki/Marks#production-rules). Invoking this function elsewhere (e.g., with filter or formula transforms) will result in an error.",
+  "now": "returns the timestamp for the current time",
+  "slice": "slices a string into a substring (alias to `String.slice`)",
+  "floor": "rounds to the nearest integer of lower value (alias to `Math.floor`)",
+  "inrange": "tests whether a value falls within a specified inclusive extent; an optional flag uses an exclusive extent instead (i.e., `inrange(value, a, b, exclusive?) `).",
+  "eventY": "returns the y-coordinate for the current event. If no arguments are provided, the top-level coordinate space of the visualization is used. If a group name is provided, the coordinate-space of the matching ancestor group item is used."
+}
diff --git a/altair/expr/funcs.py b/altair/expr/funcs.py
new file mode 100644
index 00000000..cabd647b
--- /dev/null
+++ b/altair/expr/funcs.py
@@ -0,0 +1,33 @@
+import os
+import json
+from .core import FunctionExpression
+
+
+# This maps vega expression function names to the Python name
+NAME_MAP = {'if': 'where'}
+
+
+class ExprFunc(object):
+    def __init__(self, name, doc):
+        self.name = name
+        self.doc = doc
+        self.__doc__ = """{0}(*args)\n    {1}""".format(name, doc)
+
+    def __call__(self, *args):
+        return FunctionExpression(self.name, *args)
+
+    def __repr__(self):
+        return "<function expr.{0}(*args)>".format(self.name)
+
+
+def _populate_namespace():
+    all_ = []
+    with open(os.path.join(os.path.dirname(__file__), 'func_listing.json')) as f:
+        func_listing = json.load(f)
+    for name, doc in func_listing.items():
+        globals()[NAME_MAP.get(name, name)] = ExprFunc(name, doc)
+        all_.append(NAME_MAP.get(name, name))
+    return all_
+
+
+__all__ = _populate_namespace()
diff --git a/doc/documentation/transform.rst b/doc/documentation/transform.rst
index e5a6dd20..f6c12f82 100644
--- a/doc/documentation/transform.rst
+++ b/doc/documentation/transform.rst
@@ -3,10 +3,17 @@
 Data Transformations
 --------------------
 
+.. currentmodule:: altair
+
 Altair provides a data transformation API that allows both *filtering* and
-*transformation* of values within the plot renderer. In both cases, the
-expressions are given in terms of *javascript strings* passed to the
-``transform_data`` method.
+*transformation* of values within the plot renderer. Within Vega-Lite, filter
+and transforms operations are specified in terms of *javascript strings* which
+make use of Vega's
+`Expression Documentation <https://github.com/vega/vega/wiki/Expressions>`_.
+Altair provides a Python-style interface to generate these expressions without
+having to create the strings manually; this can be done either via a direct
+functional expression interface, or via a Pandas-like dataframe interface.
+We will see examples of both of these below.
 
 For example, consider this visualization of the historical US population,
 split by age and gender:
@@ -37,41 +44,110 @@ in, say, pandas, and building a chart using the result, but it would be nice to
 do this within the Altair spec itself so that we can use the original data
 source.
 
-What we're looking for is a ``filter`` operation in the first case, and a
-``calculate`` operation in the second. Altair exposes these via the
-:meth:`Chart.transform_data` method, which passes its arguments to the
-:class:`Transform` class:
+Vega-Lite allows for this via a ``transform`` field within the plot specification,
+and Atltair provides a Pandas-style interface by which these transform fields
+can be specified.
 
-.. altair-trait-table:: Transform
-
-Let's remake the plot, using these transformation operations: we'll use
-``filter`` to limit the year to 2000, and ``calculate`` to convert the
-*1/2* labels to *Male/Female*:
+To demonstrate this, let's remake the plot using this interface to filter
+the data by year, and to create a new column which maps the *1/2* labels
+to "Male"/"Female":
 
 .. altair-plot::
 
-    from altair import Chart, Color, Scale, Formula
+    from altair import Chart, Color, Scale, expr
 
-    data = 'https://vega.github.io/vega-datasets/data/population.json'
     pink_blue = Scale(range=["pink", "lightblue"])
 
+    # this does not actually download data;
+    # just puts a dataframe-like interface around the URL reference
+    data = expr.DataFrame('https://vega.github.io/vega-datasets/data/population.json')
+
+    # Add a new column to the data
+    data['gender'] = expr.where(data.sex == 1, "Male", "Female")
+
+    # Create a filtered version of the data
+    data2000 = data[data.year == 2000]
+
+    Chart(data2000).mark_bar().encode(
+        x='age:O',
+        y='mean(people):Q',
+        color=Color('gender:N', scale=pink_blue)
+    )
+
+Creating and manipulating the data this way generates appropriate code that
+is stored in the spec and then evaluated at the time the plot is generated.
+We can see this by printing the resulting specification:
+
+>>> from altair import Chart, expr
+>>> data = expr.DataFrame('data.json')
+>>> data['gender'] = expr.where(data.sex == 1, "Male", "Female")
+>>> data2000 = data[data.year == 2000]
+>>> print(Chart(data2000).to_json(indent=2))
+{
+  "data": {
+    "url": "data.json"
+  },
+  "transform": {
+    "calculate": [
+      {
+        "expr": "if((datum.sex==1),'Male','Female')",
+        "field": "gender"
+      }
+    ],
+    "filter": "(datum.year==2000)"
+  }
+}
+
+Notice that in the resulting specification the ``data`` field contains only the
+URL, and the additional information has been encoded within a ``transform``
+field using the
+`Expression Interface <https://github.com/vega/vega/wiki/Expressions>`_ provided
+by the Vega package.
+
+If you would prefer to add these field manually rather than using the :class:`expr.DataFrame`
+interface, the :meth:`~Chart.transform_data` method and related :class:`~Transform`
+class gives you functional access to these attributes using the :mod:`vega.expr` syntax:
+
+.. altair-setup::
+
+    from altair import Chart, Color, Scale, Formula, expr
+    pink_blue = Scale(range=["pink", "lightblue"])
+
+.. altair-plot::
+
+    data = 'https://vega.github.io/vega-datasets/data/population.json'
+
     Chart(data).mark_bar().encode(
         x='age:O',
         y='mean(people):Q',
         color=Color('gender:N', scale=pink_blue)
     ).transform_data(
-        filter='datum.year == 2000',
-        calculate=[Formula(field='gender',
-                           expr='datum.sex == 1 ? "Male" : "Female"')],
+        calculate=[Formula('gender', expr.where(expr.df.sex==1,'Male','Female'))],
+        filter=(expr.df.year == 2000)
     )
 
-The ``filter`` attribute of :meth:`~Chart.transform_data` accepts a string of
-javascript code, referring to the data column name as an attribute of
-``datum``, which you can think of as the row within the dataset.
-The `calculate`` attribute accepts a list of :class:`Formula` objects, which
-each define a new column using an valid javascript expression over existing
-columns. For details on how this expression can be formed, see Vega's
-`Expression Documentation <https://github.com/vega/vega/wiki/Expressions>`_.
+Or if you really like to do things by hand, the raw javascript strings can be
+passed instead:
+
+.. altair-setup::
+
+   data = 'https://vega.github.io/vega-datasets/data/population.json'
+
+.. altair-plot::
+
+    Chart(data).mark_bar().encode(
+        x='age:O',
+        y='mean(people):Q',
+        color=Color('gender:N', scale=pink_blue)
+    ).transform_data(
+        calculate=[Formula('gender', 'if(datum.sex == 1, "M", "F")')],
+        filter=('datum.year == 2000')
+    )
+
+While in all these cases the data manipulation could be done as a preprocessing
+step, embedding the processed data directly in the URL, this sort of simple
+manipulation of an existing data source can lead to much more compact and efficient
+plot specifications.
 
-The :ref:`gallery_bar_grouped` example shows a more refined view of this dataset
-using some of these techniques.
+The :ref:`gallery_bar_grouped` example shows a more refined view of this
+same dataset using some of these techniques.
diff --git a/doc/tutorials/exploring-weather.rst b/doc/tutorials/exploring-weather.rst
index 5ad545d4..06dc9e59 100644
--- a/doc/tutorials/exploring-weather.rst
+++ b/doc/tutorials/exploring-weather.rst
@@ -120,33 +120,33 @@ you can confirm for minimum daily temperatures as well.
 
 You might also wonder how the variability of the temperatures changes
 throughout the year. For this, we have to add a computation to derive a new field.
-We'll create a new field via a :class:`~altair.Formula` object that defines
-this field using a Javascript string:
+Altair provides a convenient syntax to express such transformations in a
+familiar Pandas-like syntax:
 
 .. altair-setup::
     :show:
 
-    from altair import Formula
-    temp_range = Formula(field='temp_range',
-                         expr='datum.temp_max - datum.temp_min')
+    from altair import expr
 
-Now we can pass this formula object to the :meth:`Chart.transform_data` method,
-and refer to this new data by name as we would with any other column:
+    df = expr.DataFrame(df)
+    df['temp_range'] = df.temp_max - df.temp_min
+
+Note that this calculation doesn't actually do any data manipulation, but rather
+encodes and stores the operations within the plot specification, where they will
+be calculated by the renderer.
 
 .. altair-plot::
 
     Chart(df).mark_line().encode(
         X('date:T', timeUnit='month'),
         y='mean(temp_range):Q'
-    ).transform_data(
-        calculate=[temp_range],
     )
 
 Of course, the same calculation could be done by using Pandas manipulations to
-add a column to the dataframe; the disadvantage there is that the derived value
-would have to be explicitly stored in the plot specification rather than
-computed on-demand in the browser.
-
+explicitly add a column to the dataframe; the disadvantage there is that the
+derived values would have to be stored in the plot specification
+rather than computed on-demand in the browser. For more information on these
+transformations, see :ref:`data-transformations`.
 
 Next we will explore the ``weather`` field, which encodes a categorical
 variable describing the weather on a given day.
@@ -170,55 +170,49 @@ stack the bars atop each other:
 The default color palette’s semantics might not match our expectation.
 For example, we probably do not expect “sun” (sunny) to be purple.
 We can tune the chart by providing a color scale range that maps the values
-from the weather field to meaningful colors, using standard hex color codes.
+from the weather field to meaningful colors, using standard hex color codes:
+
+.. altair-setup::
+   :show:
+
+   from altair import Scale
+
+   scale = Scale(domain=['sun', 'fog', 'drizzle', 'rain', 'snow'],
+                 range=['#e7ba52', '#c7c7c7', '#aec7e8', '#1f77b4', '#9467bd'])
+
+This scale can be passed to the color encoding to be applied to the plot style.
 In addition, we can customize the titles for the axis and legend to make the
 meaning of the plot more clear:
 
 .. altair-plot::
 
-    from altair import Axis, Scale
+    from altair import Axis, Legend
 
     Chart(df).mark_bar().encode(
-        x=X('date:T', timeUnit='month',
-            axis=Axis(title='Month of the year')),
+        x=X('date:T', timeUnit='month', axis=Axis(title='Month of the year')),
         y='count(*):Q',
-        color=Color('weather',
-                    legend=Legend(title='Weather type'),
-                    scale=Scale(
-                        domain=['sun', 'fog', 'drizzle', 'rain', 'snow'],
-                        range=['#e7ba52', '#c7c7c7', '#aec7e8', '#1f77b4', '#9467bd'],
-                    ),
-        ),
+        color=Color('weather', legend=Legend(title='Weather type'), scale=scale),
     )
 
 Combining the above ideas lets us create any number of flexible visualizations
-of this dataset. For example, here is a plot that explores the relationship
-between weather, precipitation, maximum temperature, and temperature range,
-and is also configured to use a larger canvas:
+of this dataset. For example, here is a plot that uses the customizations we
+have developed above to explore the relationship between weather, precipitation,
+maximum temperature, and temperature range, configured to use a larger canvas:
 
 .. altair-plot::
 
-    from altair import Chart, X, Y, Color, Formula, Axis, Scale
-
-    temp_range = Formula(field='temp_range',
-                         expr='datum.temp_max - datum.temp_min')
-    scale = Scale(domain=['sun', 'fog', 'drizzle', 'rain', 'snow'],
-                  range=['#e7ba52', '#c7c7c7', '#aec7e8', '#1f77b4', '#9467bd'])
-
     Chart(df).mark_point().encode(
         X('temp_max', axis=Axis(title='Maximum Daily Temperature (C)')),
         Y('temp_range', axis=Axis(title='Daily Temperature Range (C)')),
         Color('weather', scale=scale),
         size='precipitation',
-    ).transform_data(
-        calculate=[temp_range]
     ).configure_cell(width=600, height=400)
 
 This gives us even more insight into the weather patterns in Seattle: rainy and
 foggy days tend to be cooler with a narrower range of temperatures, while warmer
 days tend to be dry and sunny, with a wider spread between low and high temperature.
 
-This is the end of this tutorial where you have seen various ways to discretize
+This is the end of this tutorial where you have seen various ways to bin
 and aggregate data, derive new fields, and customize your charts.
 You can find more visualizations in the :ref:`example-gallery`.
 If you want to further customize your charts, you can refer to Altair's
diff --git a/setup.py b/setup.py
index 29756797..027ac2a7 100644
--- a/setup.py
+++ b/setup.py
@@ -33,6 +33,8 @@ PACKAGES            = ['altair',
                        'altair.datasets',
                        'altair.examples',
                        'altair.examples.tests',
+                       'altair.expr',
+                       'altair.expr.tests',
                        'altair.utils',
                        'altair.utils.tests',
                        'altair.schema',
@@ -46,7 +48,8 @@ PACKAGE_DATA        = {'altair': ['notebooks/*.ipynb',
                                   'schema/*.json',
                                   'examples/*.json',
                                   'examples/json/*.json',
-                                  'datasets/*.json']}
+                                  'datasets/*.json',
+                                  'expr/*.json']}
 AUTHOR              = "Brian E. Granger / Jake VanderPlas"
 AUTHOR_EMAIL        = "ellisonbg@gmail.com / jakevdp@cs.washington.edu"
 URL                 = 'http://github.com/ellisonbg/altair/'


Test patch:
diff --git a/altair/expr/tests/__init__.py b/altair/expr/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/altair/expr/tests/test_expr.py b/altair/expr/tests/test_expr.py
new file mode 100644
index 00000000..bc4059ba
--- /dev/null
+++ b/altair/expr/tests/test_expr.py
@@ -0,0 +1,117 @@
+from __future__ import division
+
+import operator
+
+import pytest
+
+import pandas as pd
+
+from ... import expr
+
+
+@pytest.fixture
+def data():
+    return pd.DataFrame({'xxx': [1, 2, 3],
+                         'yyy': [4, 5, 6],
+                         'zzz': [7, 8, 9]})
+
+def test_dataframe_namespace(data):
+    df = expr.DataFrame(data)
+    assert set(dir(df)) == set(iter(data))
+
+    df = expr.DataFrame(data, cols=['x'])
+    assert set(dir(df)) == {'x'}
+
+    df = expr.DataFrame('http://url.com/path/to/data.json')
+    assert dir(df) == []
+
+    df = expr.DataFrame('http://url.com/path/to/data.json',
+                        cols=['a', 'b', 'c'])
+    assert set(dir(df)) == {'a', 'b', 'c'}
+
+
+def test_dataframe_newcols(data):
+    df = expr.DataFrame(data)
+    df['sum'] = df.xxx + df.yyy + df.zzz
+    df['prod'] = df.xxx * df.yyy * df.zzz
+    assert set(dir(df)) == set.union({'sum', 'prod'}, set(iter(data)))
+    assert repr(df.sum.contents) == '((datum.xxx+datum.yyy)+datum.zzz)'
+    assert repr(df.prod.contents) == '((datum.xxx*datum.yyy)*datum.zzz)'
+
+
+def test_unary_operations(data):
+    df = expr.DataFrame(data)
+    OP_MAP = {'-': operator.neg, '+': operator.pos}
+    for op, func in OP_MAP.items():
+        z = func(df.xxx)
+        assert repr(z) == '({0}datum.xxx)'.format(op)
+
+
+def test_binary_operations(data):
+    df = expr.DataFrame(data)
+    OP_MAP = {'+': operator.add, '-': operator.sub,
+              '*': operator.mul, '/': operator.floordiv,
+              '%': operator.mod, '==': operator.eq,
+              '<': operator.lt, '<=': operator.le,
+              '>': operator.gt, '>=': operator.ge,
+              '!=': operator.ne, '&&': operator.and_,
+              '||': operator.or_}
+    # When these are on the RHS, the opposite is evaluated instead.
+    INEQ_REVERSE = {'>':'<', '<': '>',
+                    '<=':'>=', '>=':'<=',
+                    '==':'==', '!=':'!='}
+    for op, func in OP_MAP.items():
+        z1 = func(df.xxx, 2)
+        assert repr(z1) == '(datum.xxx{0}2)'.format(op)
+
+        z2 = func(2, df.xxx)
+        if op in INEQ_REVERSE:
+            assert repr(z2) == '(datum.xxx{0}2)'.format(INEQ_REVERSE[op])
+        else:
+            assert repr(z2) == '(2{0}datum.xxx)'.format(op)
+
+        z3 = func(df.xxx, df.yyy)
+        assert repr(z3) == '(datum.xxx{0}datum.yyy)'.format(op)
+
+
+def test_abs(data):
+    df = expr.DataFrame(data)
+    z = abs(df.xxx)
+    assert repr(z) == 'abs(datum.xxx)'
+
+
+def test_expr_funcs(data):
+    """test all functions defined in expr.funcs"""
+    df = expr.DataFrame(data)
+    name_map = {val:key for key, val in expr.funcs.NAME_MAP.items()}
+    for funcname in expr.funcs.__all__:
+        func = getattr(expr, funcname)
+        z = func(df.xxx)
+        assert repr(z) == '{0}(datum.xxx)'.format(name_map.get(funcname,
+                                                               funcname))
+
+
+def test_expr_consts(data):
+    """Test all constants defined in expr.consts"""
+    df = expr.DataFrame(data)
+    name_map = {val:key for key, val in expr.consts.NAME_MAP.items()}
+    for constname in expr.consts.__all__:
+        const = getattr(expr, constname)
+        z = const * df.xxx
+        assert repr(z) == '({0}*datum.xxx)'.format(name_map.get(constname,
+                                                                constname))
+
+
+def test_getitem_list(data):
+    """Test getting a sub-dataframe"""
+    df = expr.DataFrame(data)
+    df['calculated'] = df.xxx + df.yyy + df.zzz
+
+    # df2 should have a subset of df's values
+    df2 = df[['xxx', 'yyy', 'calculated']]
+    assert set(dir(df2)) == {'xxx', 'yyy', 'calculated'}
+
+    # changing df2 shouldn't affect df1
+    df2['qqq'] = df2.xxx / df2.yyy
+    assert set(dir(df2)) == {'xxx', 'yyy', 'calculated', 'qqq'}
+    assert set(dir(df)) == {'xxx', 'yyy', 'zzz', 'calculated'}
diff --git a/altair/tests/test_api.py b/altair/tests/test_api.py
index 0d23c772..bb3c92d3 100644
--- a/altair/tests/test_api.py
+++ b/altair/tests/test_api.py
@@ -431,3 +431,78 @@ def test_chart_serve():
     chart = Chart(data).mark_line().encode(x='x', y='y')
 
     chart.serve(open_browser=False, http_server=MockServer)
+
+
+def test_formula_expression():
+     formula = Formula('blah', expr.log(expr.df.value) / expr.LN10)
+     assert formula.field == 'blah'
+     assert formula.expr == '(log(datum.value)/LN10)'
+
+
+def test_filter_expression():
+    transform = Transform(filter=(expr.df.value < expr.log(2)))
+    assert transform.filter == "(datum.value<log(2))"
+
+
+def test_df_formula():
+    # create a formula the manual way
+    chart1 = Chart('data.json').mark_line().encode(
+        x='x',
+        y='y'
+    ).transform_data(
+        calculate=[Formula('y', 'sin(((2*PI)*datum.x))')]
+    )
+
+    # create a formula with the dataframe interface
+    df = expr.DataFrame('data.json')
+    df['y'] = expr.sin(2 * expr.PI * df.x)
+    chart2 = Chart(df).mark_line().encode(
+        x='x',
+        y='y'
+    )
+
+    # make sure the outputs match
+    assert chart1.to_dict() == chart2.to_dict()
+
+
+def test_df_filter():
+    # create a filter the manual way
+    chart1 = Chart('data.json').mark_point().encode(
+        x='x',
+        y='y'
+    ).transform_data(
+        filter='(datum.x<2)'
+    )
+
+    # create a filter with the dataframe interface
+    df = expr.DataFrame('data.json')
+    df = df[df.x < 2]
+    chart2 = Chart(df).mark_point().encode(
+        x='x',
+        y='y',
+    )
+
+    # make sure outputs match
+    assert chart1.to_dict() == chart2.to_dict()
+
+
+def test_df_filter_multiple():
+    # create a filter the manual way
+    chart1 = Chart('data.json').mark_point().encode(
+        x='x',
+        y='y'
+    ).transform_data(
+        filter='((datum.x<2)&&(datum.y>4))'
+    )
+
+    # use the dataframe interface to create *two* filters
+    df = expr.DataFrame('data.json')
+    df = df[df.x < 2]
+    df = df[df.y > 4]
+    chart2 = Chart(df).mark_point().encode(
+        x='x',
+        y='y',
+    )
+
+    # make sure outputs match
+    assert chart1.to_dict() == chart2.to_dict()