Repository: vega/altair

Issue:
Can't store pandas.DataFrame with column of datetime that has timezone
`sanitize_dataframe` does not understand data type datetime64[ns, UTC]
```python
data = pd.DataFrame(pd.date_range('2000-01-01',periods=5).tz_localize('UTC').rename('tz_datetime'))
print(data.info())
alt.Chart(data).mark_rule().encode(x='tz_datetime:T')
```
Out:
```python
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 5 entries, 0 to 4
Data columns (total 1 columns):
tz_datetime    5 non-null datetime64[ns, UTC]
dtypes: datetime64[ns, UTC](1)
memory usage: 120.0 bytes
None
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
~/anaconda/anaconda/envs/rr_dev/lib/python3.5/site-packages/altair/vegalite/v2/api.py in to_dict(self, *args, **kwargs)
    312         copy = self.copy()
    313         original_data = getattr(copy, 'data', Undefined)
--> 314         copy.data = _prepare_data(original_data)
    315 
    316         # We make use of two context markers:

~/anaconda/anaconda/envs/rr_dev/lib/python3.5/site-packages/altair/vegalite/v2/api.py in _prepare_data(data)
     24         return data
     25     elif isinstance(data, pd.DataFrame):
---> 26         return pipe(data, data_transformers.get())
     27     elif isinstance(data, six.string_types):
     28         return core.UrlData(data)

~/anaconda/anaconda/envs/rr_dev/lib/python3.5/site-packages/toolz/functoolz.py in pipe(data, *funcs)
    550     """
    551     for func in funcs:
--> 552         data = func(data)
    553     return data
    554 

~/anaconda/anaconda/envs/rr_dev/lib/python3.5/site-packages/toolz/functoolz.py in __call__(self, *args, **kwargs)
    281     def __call__(self, *args, **kwargs):
    282         try:
--> 283             return self._partial(*args, **kwargs)
    284         except TypeError as exc:
    285             if self._should_curry(args, kwargs, exc):

~/anaconda/anaconda/envs/rr_dev/lib/python3.5/site-packages/altair/vegalite/data.py in default_data_transformer(data, max_rows)
     10 @curry
     11 def default_data_transformer(data, max_rows=5000):
---> 12     return pipe(data, limit_rows(max_rows=max_rows), to_values)
     13 
     14 

~/anaconda/anaconda/envs/rr_dev/lib/python3.5/site-packages/toolz/functoolz.py in pipe(data, *funcs)
    550     """
    551     for func in funcs:
--> 552         data = func(data)
    553     return data
    554 

~/anaconda/anaconda/envs/rr_dev/lib/python3.5/site-packages/toolz/functoolz.py in __call__(self, *args, **kwargs)
    281     def __call__(self, *args, **kwargs):
    282         try:
--> 283             return self._partial(*args, **kwargs)
    284         except TypeError as exc:
    285             if self._should_curry(args, kwargs, exc):

~/anaconda/anaconda/envs/rr_dev/lib/python3.5/site-packages/altair/utils/data.py in to_values(data)
    123     check_data_type(data)
    124     if isinstance(data, pd.DataFrame):
--> 125         data = sanitize_dataframe(data)
    126         return {'values': data.to_dict(orient='records')}
    127     elif isinstance(data, dict):

~/anaconda/anaconda/envs/rr_dev/lib/python3.5/site-packages/altair/utils/core.py in sanitize_dataframe(df)
     89             # convert numpy bools to objects; np.bool is not JSON serializable
     90             df[col_name] = df[col_name].astype(object)
---> 91         elif np.issubdtype(dtype, np.integer):
     92             # convert integers to objects; np.int is not JSON serializable
     93             df[col_name] = df[col_name].astype(object)

~/anaconda/anaconda/envs/rr_dev/lib/python3.5/site-packages/numpy/core/numerictypes.py in issubdtype(arg1, arg2)
    724     """
    725     if not issubclass_(arg1, generic):
--> 726         arg1 = dtype(arg1).type
    727     if not issubclass_(arg2, generic):
    728         arg2_orig = arg2

TypeError: data type not understood

Chart({
  data:                 tz_datetime
  0 2000-01-01 00:00:00+00:00
  1 2000-01-02 00:00:00+00:00
  2 2000-01-03 00:00:00+00:00
  3 2000-01-04 00:00:00+00:00
  4 2000-01-05 00:00:00+00:00
})
```
In:
```python
print('altair',alt.__version__)
print('pandas',pd.__version__)
print('numpy',np.__version__)
```
Out:
```python
altair 2.0.1
pandas 0.22.0
numpy 1.14.2
```
Same for 
```
altair 2.0.1
pandas 0.23.0
numpy 1.14.3
```


Discussion:

Resolving patch:
diff --git a/altair/utils/core.py b/altair/utils/core.py
--- a/altair/utils/core.py
+++ b/altair/utils/core.py
@@ -114,6 +114,10 @@ def to_list_if_array(val):
         elif str(dtype) == 'bool':
             # convert numpy bools to objects; np.bool is not JSON serializable
             df[col_name] = df[col_name].astype(object)
+        elif str(dtype).startswith('datetime'):
+            # Convert datetimes to strings
+            # astype(str) will choose the appropriate resolution
+            df[col_name] = df[col_name].astype(str).replace('NaT', '')
         elif np.issubdtype(dtype, np.integer):
             # convert integers to objects; np.int is not JSON serializable
             df[col_name] = df[col_name].astype(object)
@@ -123,10 +127,6 @@ def to_list_if_array(val):
             col = df[col_name]
             bad_values = col.isnull() | np.isinf(col)
             df[col_name] = col.astype(object).where(~bad_values, None)
-        elif str(dtype).startswith('datetime'):
-            # Convert datetimes to strings
-            # astype(str) will choose the appropriate resolution
-            df[col_name] = df[col_name].astype(str).replace('NaT', '')
         elif dtype == object:
             # Convert numpy arrays saved as objects to lists
             # Arrays are not JSON serializable


Test patch:
diff --git a/altair/utils/tests/test_core.py b/altair/utils/tests/test_core.py
--- a/altair/utils/tests/test_core.py
+++ b/altair/utils/tests/test_core.py
@@ -57,14 +57,17 @@ def check(s, data, **kwargs):
 
     data = pd.DataFrame({'x': [1, 2, 3, 4, 5],
                          'y': ['A', 'B', 'C', 'D', 'E'],
-                         'z': pd.date_range('2018-01-01', periods=5, freq='D')})
+                         'z': pd.date_range('2018-01-01', periods=5, freq='D'),
+                         't': pd.date_range('2018-01-01', periods=5, freq='D').tz_localize('UTC')})
 
     check('x', data, field='x', type='quantitative')
     check('y', data, field='y', type='nominal')
     check('z', data, field='z', type='temporal')
+    check('t', data, field='t', type='temporal')
     check('count(x)', data, field='x', aggregate='count', type='quantitative')
     check('count()', data, aggregate='count', type='quantitative')
     check('month(z)', data, timeUnit='month', field='z', type='temporal')
+    check('month(t)', data, timeUnit='month', field='t', type='temporal')
 
 
 def test_parse_shorthand_all_aggregates():
diff --git a/altair/utils/tests/test_utils.py b/altair/utils/tests/test_utils.py
--- a/altair/utils/tests/test_utils.py
+++ b/altair/utils/tests/test_utils.py
@@ -40,7 +40,8 @@ def test_sanitize_dataframe():
                        'b': np.array([True, False, True, True, False]),
                        'd': pd.date_range('2012-01-01', periods=5, freq='H'),
                        'c': pd.Series(list('ababc'), dtype='category'),
-                       'o': pd.Series([np.array(i) for i in range(5)])})
+                       'o': pd.Series([np.array(i) for i in range(5)]),
+                       'p': pd.date_range('2012-01-01', periods=5, freq='H').tz_localize('UTC')})
 
     # add some nulls
     df.iloc[0, df.columns.get_loc('s')] = None
@@ -63,7 +64,8 @@ def test_sanitize_dataframe():
         if str(df[col].dtype).startswith('datetime'):
             # astype(datetime) introduces time-zone issues:
             # to_datetime() does not.
-            df2[col] = pd.to_datetime(df2[col])
+            utc = isinstance(df[col].dtype, pd.core.dtypes.dtypes.DatetimeTZDtype)
+            df2[col] = pd.to_datetime(df2[col], utc = utc)
         else:
             df2[col] = df2[col].astype(df[col].dtype)