Repository: pandas-dev/pandas

Issue:
BUG: in .groupby.SeriesGroupBy plot not accessible?
Previously, in `0.12` and earlier, I could quickly visualize `groupby` objects with a call to `.plot()`. Currently in master, the `.plot` method on a `groupby` object raises, with following error:

``` python
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-63-3760a47a721b> in <module>()
----> 1 grp.kWh.plot()

/pandas/pandas/core/groupby.pyc in __getattr__(self, attr)
    250 
    251         if hasattr(self.obj, attr) and attr != '_cache':
--> 252             return self._make_wrapper(attr)
    253 
    254         raise AttributeError("%r object has no attribute %r" %

/pandas/pandas/core/groupby.pyc in _make_wrapper(self, name)
    265                    "using the 'apply' method".format(kind, name,
    266                                                      type(self).__name__))
--> 267             raise AttributeError(msg)
    268 
    269         f = getattr(self.obj, name)

AttributeError: Cannot access callable attribute 'plot' of 'SeriesGroupBy' objects, try using the 'apply' method
```



Discussion:
Oh yeah, currently at:

``` python
print pd.__version__
0.12.0-706-g8e784e7
```

This is "sort of" by design. We're trying to discourage use of forwarded methods that don't make sense. Hopefully you're not using `master` in production :) Clearly this one shouldn't have been disabled. Very quick fix.

Hey @cpcloud, thanks for the quick reply, and makes sense to discourage methods that don't belong.

Not using in production, but testing `master` on our current build—I like living on the edge! Traced to this commit:
https://github.com/pydata/pandas/commit/b709389786bd7a7a5907159d554c4424166a744c

Also, I noticed that the helpful tab auto-complete of column names was clobbered with the commit as well?

Thanks again.

That code didn't touch any autocompletion code. Can you show me an example of what you mean?

@cpcloud  there's no `_local_dir()` method on grouby's.....should add (similar to in `core/generic.py` as well (but in this case refer to IIRC `self.obj`, e.g. the groupby object
this should prob be in SeriesGroupBy and DataFrameGroupby slightly differently

something like this: you can just forward it I think

in core/groupby.py

on `Groupby`

```
   def _local_dir(self):
        """ add the string-like attributes from the info_axis """
        return self.obj._local_dir()
```

sounds good


Resolving patch:
diff --git a/pandas/core/groupby.py b/pandas/core/groupby.py
--- a/pandas/core/groupby.py
+++ b/pandas/core/groupby.py
@@ -1,4 +1,5 @@
 import types
+from functools import wraps
 import numpy as np
 
 from pandas.compat import(
@@ -45,6 +46,10 @@
 """
 
 
+# special case to prevent duplicate plots when catching exceptions when
+# forwarding methods from NDFrames
+_plotting_methods = frozenset(['plot', 'boxplot', 'hist'])
+
 _apply_whitelist = frozenset(['last', 'first',
                               'mean', 'sum', 'min', 'max',
                               'head', 'tail',
@@ -52,7 +57,8 @@
                               'resample',
                               'describe',
                               'rank', 'quantile', 'count',
-                              'fillna', 'dtype'])
+                              'fillna', 'dtype']) | _plotting_methods
+
 
 
 class GroupByError(Exception):
@@ -180,7 +186,6 @@ class GroupBy(PandasObject):
     len(grouped) : int
         Number of groups
     """
-
     def __init__(self, obj, keys=None, axis=0, level=None,
                  grouper=None, exclusions=None, selection=None, as_index=True,
                  sort=True, group_keys=True, squeeze=False):
@@ -244,6 +249,9 @@ def _selection_list(self):
             return [self._selection]
         return self._selection
 
+    def _local_dir(self):
+        return sorted(set(self.obj._local_dir() + list(_apply_whitelist)))
+
     def __getattr__(self, attr):
         if attr in self.obj:
             return self[attr]
@@ -285,6 +293,15 @@ def curried_with_axis(x):
             def curried(x):
                 return f(x, *args, **kwargs)
 
+            # preserve the name so we can detect it when calling plot methods,
+            # to avoid duplicates
+            curried.__name__ = curried_with_axis.__name__ = name
+
+            # special case otherwise extra plots are created when catching the
+            # exception below
+            if name in _plotting_methods:
+                return self.apply(curried)
+
             try:
                 return self.apply(curried_with_axis)
             except Exception:
@@ -348,7 +365,11 @@ def apply(self, func, *args, **kwargs):
         applied : type depending on grouped object and function
         """
         func = _intercept_function(func)
-        f = lambda g: func(g, *args, **kwargs)
+
+        @wraps(func)
+        def f(g):
+            return func(g, *args, **kwargs)
+
         return self._python_apply_general(f)
 
     def _python_apply_general(self, f):
@@ -598,7 +619,7 @@ def __iter__(self):
     def nkeys(self):
         return len(self.groupings)
 
-    def get_iterator(self, data, axis=0, keep_internal=True):
+    def get_iterator(self, data, axis=0):
         """
         Groupby iterator
 
@@ -607,16 +628,14 @@ def get_iterator(self, data, axis=0, keep_internal=True):
         Generator yielding sequence of (name, subsetted object)
         for each group
         """
-        splitter = self._get_splitter(data, axis=axis,
-                                      keep_internal=keep_internal)
+        splitter = self._get_splitter(data, axis=axis)
         keys = self._get_group_keys()
         for key, (i, group) in zip(keys, splitter):
             yield key, group
 
-    def _get_splitter(self, data, axis=0, keep_internal=True):
+    def _get_splitter(self, data, axis=0):
         comp_ids, _, ngroups = self.group_info
-        return get_splitter(data, comp_ids, ngroups, axis=axis,
-                            keep_internal=keep_internal)
+        return get_splitter(data, comp_ids, ngroups, axis=axis)
 
     def _get_group_keys(self):
         if len(self.groupings) == 1:
@@ -627,19 +646,19 @@ def _get_group_keys(self):
             mapper = _KeyMapper(comp_ids, ngroups, self.labels, self.levels)
             return [mapper.get_key(i) for i in range(ngroups)]
 
-    def apply(self, f, data, axis=0, keep_internal=False):
+    def apply(self, f, data, axis=0):
         mutated = False
-        splitter = self._get_splitter(data, axis=axis,
-                                      keep_internal=keep_internal)
+        splitter = self._get_splitter(data, axis=axis)
         group_keys = self._get_group_keys()
 
         # oh boy
-        if hasattr(splitter, 'fast_apply') and axis == 0:
+        if (f.__name__ not in _plotting_methods and
+            hasattr(splitter, 'fast_apply') and axis == 0):
             try:
                 values, mutated = splitter.fast_apply(f, group_keys)
                 return group_keys, values, mutated
-            except (Exception) as detail:
-                # we detect a mutatation of some kind
+            except Exception:
+                # we detect a mutation of some kind
                 # so take slow path
                 pass
 
@@ -1043,7 +1062,7 @@ def get_iterator(self, data, axis=0):
                 inds = lrange(start, n)
                 yield self.binlabels[-1], data.take(inds, axis=axis)
 
-    def apply(self, f, data, axis=0, keep_internal=False):
+    def apply(self, f, data, axis=0):
         result_keys = []
         result_values = []
         mutated = False
@@ -1617,6 +1636,7 @@ def filter(self, func, dropna=True, *args, **kwargs):
         else:
             return filtered.reindex(self.obj.index) # Fill with NaNs.
 
+
 class NDFrameGroupBy(GroupBy):
 
     def _iterate_slices(self):
@@ -1939,14 +1959,14 @@ def _wrap_applied_output(self, keys, values, not_indexed_same=False):
                         index = key_index
                     else:
                         stacked_values = np.vstack([np.asarray(x)
-                                                for x in values]).T
+                                                    for x in values]).T
 
                         index = values[0].index
                         columns = key_index
 
-                except ValueError:
-                    #GH1738,, values is list of arrays of unequal lengths
-                    # fall through to the outer else caluse
+                except (ValueError, AttributeError):
+                    # GH1738: values is list of arrays of unequal lengths fall
+                    # through to the outer else caluse
                     return Series(values, index=key_index)
 
                 return DataFrame(stacked_values, index=index,
@@ -2268,6 +2288,7 @@ def ohlc(self):
         """
         return self._apply_to_column_groupbys(lambda x: x._cython_agg_general('ohlc'))
 
+
 from pandas.tools.plotting import boxplot_frame_groupby
 DataFrameGroupBy.boxplot = boxplot_frame_groupby
 
@@ -2364,7 +2385,7 @@ class NDArrayGroupBy(GroupBy):
 
 class DataSplitter(object):
 
-    def __init__(self, data, labels, ngroups, axis=0, keep_internal=False):
+    def __init__(self, data, labels, ngroups, axis=0):
         self.data = data
         self.labels = com._ensure_int64(labels)
         self.ngroups = ngroups
@@ -2419,10 +2440,8 @@ def _chop(self, sdata, slice_obj):
 
 
 class FrameSplitter(DataSplitter):
-
-    def __init__(self, data, labels, ngroups, axis=0, keep_internal=False):
-        DataSplitter.__init__(self, data, labels, ngroups, axis=axis,
-                              keep_internal=keep_internal)
+    def __init__(self, data, labels, ngroups, axis=0):
+        super(FrameSplitter, self).__init__(data, labels, ngroups, axis=axis)
 
     def fast_apply(self, f, names):
         # must return keys::list, values::list, mutated::bool
@@ -2445,10 +2464,8 @@ def _chop(self, sdata, slice_obj):
 
 
 class NDFrameSplitter(DataSplitter):
-
-    def __init__(self, data, labels, ngroups, axis=0, keep_internal=False):
-        DataSplitter.__init__(self, data, labels, ngroups, axis=axis,
-                              keep_internal=keep_internal)
+    def __init__(self, data, labels, ngroups, axis=0):
+        super(NDFrameSplitter, self).__init__(data, labels, ngroups, axis=axis)
 
         self.factory = data._constructor
 


Test patch:
diff --git a/pandas/tests/test_groupby.py b/pandas/tests/test_groupby.py
--- a/pandas/tests/test_groupby.py
+++ b/pandas/tests/test_groupby.py
@@ -2,6 +2,8 @@
 import nose
 import unittest
 
+from numpy.testing.decorators import slow
+
 from datetime import datetime
 from numpy import nan
 
@@ -9,8 +11,7 @@
 from pandas.core.index import Index, MultiIndex
 from pandas.core.common import rands
 from pandas.core.api import Categorical, DataFrame
-from pandas.core.groupby import (GroupByError, SpecificationError, DataError,
-                                 _apply_whitelist)
+from pandas.core.groupby import SpecificationError, DataError
 from pandas.core.series import Series
 from pandas.util.testing import (assert_panel_equal, assert_frame_equal,
                                  assert_series_equal, assert_almost_equal,
@@ -18,14 +19,12 @@
 from pandas.compat import(
     range, long, lrange, StringIO, lmap, lzip, map, zip, builtins, OrderedDict
 )
-from pandas import compat,  _np_version_under1p7
+from pandas import compat
 from pandas.core.panel import Panel
 from pandas.tools.merge import concat
 from collections import defaultdict
 import pandas.core.common as com
-import pandas.core.datetools as dt
 import numpy as np
-from numpy.testing import assert_equal
 
 import pandas.core.nanops as nanops
 
@@ -2728,6 +2727,85 @@ def test_groupby_whitelist(self):
                 with tm.assertRaisesRegexp(AttributeError, msg):
                     getattr(gb, bl)
 
+    def test_series_groupby_plotting_nominally_works(self):
+        try:
+            import matplotlib as mpl
+            mpl.use('Agg')
+        except ImportError:
+            raise nose.SkipTest("matplotlib not installed")
+        n = 10
+        weight = Series(np.random.normal(166, 20, size=n))
+        height = Series(np.random.normal(60, 10, size=n))
+        gender = tm.choice(['male', 'female'], size=n)
+
+        weight.groupby(gender).plot()
+        tm.close()
+        height.groupby(gender).hist()
+        tm.close()
+
+    @slow
+    def test_frame_groupby_plot_boxplot(self):
+        try:
+            import matplotlib.pyplot as plt
+            import matplotlib as mpl
+            mpl.use('Agg')
+        except ImportError:
+            raise nose.SkipTest("matplotlib not installed")
+        tm.close()
+
+        n = 10
+        weight = Series(np.random.normal(166, 20, size=n))
+        height = Series(np.random.normal(60, 10, size=n))
+        gender = tm.choice(['male', 'female'], size=n)
+        df = DataFrame({'height': height, 'weight': weight, 'gender': gender})
+        gb = df.groupby('gender')
+
+        res = gb.plot()
+        self.assertEqual(len(plt.get_fignums()), 2)
+        self.assertEqual(len(res), 2)
+        tm.close()
+
+        res = gb.boxplot()
+        self.assertEqual(len(plt.get_fignums()), 1)
+        self.assertEqual(len(res), 2)
+        tm.close()
+
+        with tm.assertRaisesRegexp(TypeError, '.*str.+float'):
+            gb.hist()
+
+    @slow
+    def test_frame_groupby_hist(self):
+        try:
+            import matplotlib.pyplot as plt
+            import matplotlib as mpl
+            mpl.use('Agg')
+        except ImportError:
+            raise nose.SkipTest("matplotlib not installed")
+        tm.close()
+
+        n = 10
+        weight = Series(np.random.normal(166, 20, size=n))
+        height = Series(np.random.normal(60, 10, size=n))
+        gender_int = tm.choice([0, 1], size=n)
+        df_int = DataFrame({'height': height, 'weight': weight,
+                            'gender': gender_int})
+        gb = df_int.groupby('gender')
+        axes = gb.hist()
+        self.assertEqual(len(axes), 2)
+        self.assertEqual(len(plt.get_fignums()), 2)
+        tm.close()
+
+    def test_tab_completion(self):
+        grp = self.mframe.groupby(level='second')
+        results = set([v for v in dir(grp) if not v.startswith('_')])
+        expected = set(['A','B','C',
+            'agg','aggregate','apply','boxplot','filter','first','get_group',
+            'groups','hist','indices','last','max','mean','median',
+            'min','name','ngroups','nth','ohlc','plot', 'prod',
+            'size','std','sum','transform','var', 'count', 'head', 'describe',
+            'cummax', 'dtype', 'quantile', 'rank', 'cumprod', 'tail',
+            'resample', 'cummin', 'fillna', 'cumsum'])
+        self.assertEqual(results, expected)
 
 def assert_fp_equal(a, b):
     assert (np.abs(a - b) < 1e-12).all()
@@ -2764,7 +2842,5 @@ def testit(label_list, shape):
 
 
 if __name__ == '__main__':
-    import nose
-    nose.runmodule(
-        argv=[__file__, '-vvs', '-x', '--pdb', '--pdb-failure', '-s'],
-        exit=False)
+    nose.runmodule(argv=[__file__, '-vvs', '-x', '--pdb', '--pdb-failure',
+                         '-s'], exit=False)