From e2abff6f409f8924b57256aa6811036a4d3891e9 Mon Sep 17 00:00:00 2001 From: Javad Date: Wed, 21 Mar 2018 20:53:27 -0400 Subject: [PATCH 01/81] removed colorbars from _handle_shared_axes when called by scatterplot and hexbin --- pandas/plotting/_core.py | 6 ++++++ pandas/plotting/_tools.py | 6 ++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index fa2766bb63d55..76b9b01a605e2 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -869,12 +869,16 @@ def _make_plot(self): label = None scatter = ax.scatter(data[x].values, data[y].values, c=c_values, label=label, cmap=cmap, **self.kwds) + if cb: + ax._pandas_colorbar_axes = True img = ax.collections[0] kws = dict(ax=ax) if self.mpl_ge_1_3_1(): kws['label'] = c if c_is_column else '' self.fig.colorbar(img, **kws) + + if label is not None: self._add_legend_handle(scatter, label) @@ -902,6 +906,7 @@ def __init__(self, data, x, y, C=None, **kwargs): def _make_plot(self): x, y, data, C = self.x, self.y, self.data, self.C ax = self.axes[0] + # pandas uses colormap, matplotlib uses cmap. cmap = self.colormap or 'BuGn' cmap = self.plt.cm.get_cmap(cmap) @@ -915,6 +920,7 @@ def _make_plot(self): ax.hexbin(data[x].values, data[y].values, C=c_values, cmap=cmap, **self.kwds) if cb: + ax._pandas_colorbar_axes = True img = ax.collections[0] self.fig.colorbar(img, ax=ax) diff --git a/pandas/plotting/_tools.py b/pandas/plotting/_tools.py index 816586fbb82f5..737082ff7b118 100644 --- a/pandas/plotting/_tools.py +++ b/pandas/plotting/_tools.py @@ -298,7 +298,6 @@ def _remove_labels_from_axis(axis): def _handle_shared_axes(axarr, nplots, naxes, nrows, ncols, sharex, sharey): if nplots > 1: - if nrows > 1: try: # first find out the ax layout, @@ -306,12 +305,11 @@ def _handle_shared_axes(axarr, nplots, naxes, nrows, ncols, sharex, sharey): layout = np.zeros((nrows + 1, ncols + 1), dtype=np.bool) for ax in axarr: layout[ax.rowNum, ax.colNum] = ax.get_visible() - for ax in axarr: # only the last row of subplots should get x labels -> all # other off layout handles the case that the subplot is - # the last in the column, because below is no subplot/gap. - if not layout[ax.rowNum + 1, ax.colNum]: + # the last in the column, because below is no subplot/gap. + if not layout[ax.rowNum + 1, ax.colNum] or getattr(ax, '_pandas_colorbar_axes', False): continue if sharex or len(ax.get_shared_x_axes() .get_siblings(ax)) > 1: From 0ea226fcd226d740227dc27c3c039cfa035ace12 Mon Sep 17 00:00:00 2001 From: Javad Date: Wed, 21 Mar 2018 20:57:05 -0400 Subject: [PATCH 02/81] removed colorbars from _handle_shared_axes when called by scatterplot and hexbin --- pandas/plotting/_core.py | 3 --- pandas/plotting/_tools.py | 4 +++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 76b9b01a605e2..99db7fab09011 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -869,7 +869,6 @@ def _make_plot(self): label = None scatter = ax.scatter(data[x].values, data[y].values, c=c_values, label=label, cmap=cmap, **self.kwds) - if cb: ax._pandas_colorbar_axes = True img = ax.collections[0] @@ -877,8 +876,6 @@ def _make_plot(self): if self.mpl_ge_1_3_1(): kws['label'] = c if c_is_column else '' self.fig.colorbar(img, **kws) - - if label is not None: self._add_legend_handle(scatter, label) diff --git a/pandas/plotting/_tools.py b/pandas/plotting/_tools.py index 737082ff7b118..96e31b72ac7f1 100644 --- a/pandas/plotting/_tools.py +++ b/pandas/plotting/_tools.py @@ -298,6 +298,7 @@ def _remove_labels_from_axis(axis): def _handle_shared_axes(axarr, nplots, naxes, nrows, ncols, sharex, sharey): if nplots > 1: + if nrows > 1: try: # first find out the ax layout, @@ -305,10 +306,11 @@ def _handle_shared_axes(axarr, nplots, naxes, nrows, ncols, sharex, sharey): layout = np.zeros((nrows + 1, ncols + 1), dtype=np.bool) for ax in axarr: layout[ax.rowNum, ax.colNum] = ax.get_visible() + for ax in axarr: # only the last row of subplots should get x labels -> all # other off layout handles the case that the subplot is - # the last in the column, because below is no subplot/gap. + # the last in the column, because below is no subplot/gap. if not layout[ax.rowNum + 1, ax.colNum] or getattr(ax, '_pandas_colorbar_axes', False): continue if sharex or len(ax.get_shared_x_axes() From 80f949fe0d7750719d2eab4114cba92cf0f8085d Mon Sep 17 00:00:00 2001 From: Javad Date: Wed, 21 Mar 2018 22:32:23 -0400 Subject: [PATCH 03/81] added a debug global variable --- pandas/plotting/_core.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 99db7fab09011..93ddae79702e3 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -870,7 +870,8 @@ def _make_plot(self): scatter = ax.scatter(data[x].values, data[y].values, c=c_values, label=label, cmap=cmap, **self.kwds) if cb: - ax._pandas_colorbar_axes = True + if PATCH_MODE: + ax._pandas_colorbar_axes = True img = ax.collections[0] kws = dict(ax=ax) if self.mpl_ge_1_3_1(): @@ -917,7 +918,8 @@ def _make_plot(self): ax.hexbin(data[x].values, data[y].values, C=c_values, cmap=cmap, **self.kwds) if cb: - ax._pandas_colorbar_axes = True + if PATCH_MODE: + ax._pandas_colorbar_axes = True img = ax.collections[0] self.fig.colorbar(img, ax=ax) @@ -2793,6 +2795,7 @@ def __call__(self, x=None, y=None, kind='line', ax=None, rot=None, fontsize=None, colormap=None, table=False, yerr=None, xerr=None, secondary_y=False, sort_columns=False, **kwds): + return plot_frame(self._data, kind=kind, x=x, y=y, ax=ax, subplots=subplots, sharex=sharex, sharey=sharey, layout=layout, figsize=figsize, use_index=use_index, @@ -3210,7 +3213,7 @@ def pie(self, y=None, **kwds): """ return self(kind='pie', y=y, **kwds) - def scatter(self, x, y, s=None, c=None, **kwds): + def scatter(self, x, y, s=None, c=None,PATCH_MODE_FLAG = False, **kwds): """ Create a scatter plot with varying marker point size and color. @@ -3289,9 +3292,13 @@ def scatter(self, x, y, s=None, c=None, **kwds): ... c='species', ... colormap='viridis') """ + + global PATCH_MODE + PATCH_MODE = PATCH_MODE_FLAG + return self(kind='scatter', x=x, y=y, c=c, s=s, **kwds) - def hexbin(self, x, y, C=None, reduce_C_function=None, gridsize=None, + def hexbin(self, x, y, C=None, reduce_C_function=None, gridsize=None, PATCH_MODE_FLAG = False, **kwds): """ Generate a hexagonal binning plot. @@ -3374,6 +3381,9 @@ def hexbin(self, x, y, C=None, reduce_C_function=None, gridsize=None, ... gridsize=10, ... cmap="viridis") """ + global PATCH_MODE + PATCH_MODE = PATCH_MODE_FLAG + if reduce_C_function is not None: kwds['reduce_C_function'] = reduce_C_function if gridsize is not None: From 8f52eab88b1c2d676460a88dd2a93b74576ba50a Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Thu, 22 Mar 2018 10:01:45 -0400 Subject: [PATCH 04/81] removed whitespaces --- pandas/plotting/_tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/plotting/_tools.py b/pandas/plotting/_tools.py index 96e31b72ac7f1..c6e74b5845a80 100644 --- a/pandas/plotting/_tools.py +++ b/pandas/plotting/_tools.py @@ -298,7 +298,7 @@ def _remove_labels_from_axis(axis): def _handle_shared_axes(axarr, nplots, naxes, nrows, ncols, sharex, sharey): if nplots > 1: - + if nrows > 1: try: # first find out the ax layout, @@ -306,7 +306,7 @@ def _handle_shared_axes(axarr, nplots, naxes, nrows, ncols, sharex, sharey): layout = np.zeros((nrows + 1, ncols + 1), dtype=np.bool) for ax in axarr: layout[ax.rowNum, ax.colNum] = ax.get_visible() - + for ax in axarr: # only the last row of subplots should get x labels -> all # other off layout handles the case that the subplot is From 215fb8987513148a959ce2b919933134b28bd196 Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Wed, 28 Mar 2018 14:48:34 -0400 Subject: [PATCH 05/81] Update _core.py removing PATH_MODE debugging flag --- pandas/plotting/_core.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 93ddae79702e3..e4b966d393ef3 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -870,8 +870,7 @@ def _make_plot(self): scatter = ax.scatter(data[x].values, data[y].values, c=c_values, label=label, cmap=cmap, **self.kwds) if cb: - if PATCH_MODE: - ax._pandas_colorbar_axes = True + ax._pandas_colorbar_axes = True img = ax.collections[0] kws = dict(ax=ax) if self.mpl_ge_1_3_1(): @@ -918,8 +917,7 @@ def _make_plot(self): ax.hexbin(data[x].values, data[y].values, C=c_values, cmap=cmap, **self.kwds) if cb: - if PATCH_MODE: - ax._pandas_colorbar_axes = True + ax._pandas_colorbar_axes = True img = ax.collections[0] self.fig.colorbar(img, ax=ax) @@ -3213,7 +3211,7 @@ def pie(self, y=None, **kwds): """ return self(kind='pie', y=y, **kwds) - def scatter(self, x, y, s=None, c=None,PATCH_MODE_FLAG = False, **kwds): + def scatter(self, x, y, s=None, c=None, **kwds): """ Create a scatter plot with varying marker point size and color. @@ -3292,13 +3290,10 @@ def scatter(self, x, y, s=None, c=None,PATCH_MODE_FLAG = False, **kwds): ... c='species', ... colormap='viridis') """ - - global PATCH_MODE - PATCH_MODE = PATCH_MODE_FLAG return self(kind='scatter', x=x, y=y, c=c, s=s, **kwds) - def hexbin(self, x, y, C=None, reduce_C_function=None, gridsize=None, PATCH_MODE_FLAG = False, + def hexbin(self, x, y, C=None, reduce_C_function=None, gridsize=None, **kwds): """ Generate a hexagonal binning plot. @@ -3381,8 +3376,6 @@ def hexbin(self, x, y, C=None, reduce_C_function=None, gridsize=None, PATCH_MODE ... gridsize=10, ... cmap="viridis") """ - global PATCH_MODE - PATCH_MODE = PATCH_MODE_FLAG if reduce_C_function is not None: kwds['reduce_C_function'] = reduce_C_function From 7c6efe25466bb3fd9499a39a8994bb0858b3f56b Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Wed, 28 Mar 2018 15:01:50 -0400 Subject: [PATCH 06/81] Update _core.py remove whitespaces --- pandas/plotting/_core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index e4b966d393ef3..9452cfc999318 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -903,7 +903,7 @@ def __init__(self, data, x, y, C=None, **kwargs): def _make_plot(self): x, y, data, C = self.x, self.y, self.data, self.C ax = self.axes[0] - + # pandas uses colormap, matplotlib uses cmap. cmap = self.colormap or 'BuGn' cmap = self.plt.cm.get_cmap(cmap) @@ -2793,7 +2793,7 @@ def __call__(self, x=None, y=None, kind='line', ax=None, rot=None, fontsize=None, colormap=None, table=False, yerr=None, xerr=None, secondary_y=False, sort_columns=False, **kwds): - + return plot_frame(self._data, kind=kind, x=x, y=y, ax=ax, subplots=subplots, sharex=sharex, sharey=sharey, layout=layout, figsize=figsize, use_index=use_index, From 8c71f60c14504100501c2206dac2a99f7c6a0c86 Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Wed, 28 Mar 2018 22:52:17 -0400 Subject: [PATCH 07/81] Update _tools.py --- pandas/plotting/_tools.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pandas/plotting/_tools.py b/pandas/plotting/_tools.py index c6e74b5845a80..fe75762636294 100644 --- a/pandas/plotting/_tools.py +++ b/pandas/plotting/_tools.py @@ -311,7 +311,8 @@ def _handle_shared_axes(axarr, nplots, naxes, nrows, ncols, sharex, sharey): # only the last row of subplots should get x labels -> all # other off layout handles the case that the subplot is # the last in the column, because below is no subplot/gap. - if not layout[ax.rowNum + 1, ax.colNum] or getattr(ax, '_pandas_colorbar_axes', False): + if not layout[ax.rowNum + 1, ax.colNum] or \ + getattr(ax, '_pandas_colorbar_axes', False): continue if sharex or len(ax.get_shared_x_axes() .get_siblings(ax)) > 1: From 63b9ee66065bed7287f0ea1c9ffca4860c81846e Mon Sep 17 00:00:00 2001 From: Javad Date: Wed, 28 Mar 2018 23:27:41 -0400 Subject: [PATCH 08/81] included tests for scatterplot and hexbin plot to ensure colorbar does not exclude x-axis label and tick-labels --- pandas/tests/plotting/test_frame.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/pandas/tests/plotting/test_frame.py b/pandas/tests/plotting/test_frame.py index b29afcb404ac6..9fdab839e0748 100644 --- a/pandas/tests/plotting/test_frame.py +++ b/pandas/tests/plotting/test_frame.py @@ -1030,6 +1030,35 @@ def test_plot_scatter(self): axes = df.plot(x='x', y='y', kind='scatter', subplots=True) self._check_axes_shape(axes, axes_num=1, layout=(1, 1)) + @pytest.mark.slow + def test_if_scatterplot_colorbar_affects_xaxis_visibility(self): + random_array = np.random.random((1000,3)) + df = pd.DataFrame(random_array,columns=['A label','B label','C label']) + + ax1 = df.plot.scatter(x='A label', y='B label') + ax2 = df.plot.scatter(x='A label', y='B label', c='C label') + + assert all([vis[0].get_visible()==vis[1].get_visible() for vis in + zip(ax1.xaxis.get_minorticklabels(),ax2.xaxis.get_minorticklabels())]),\ + 'minor x-axis tick labels visibility changes when colorbar included' + assert all([vis[0].get_visible()==vis[1].get_visible() for vis in + zip(ax1.xaxis.get_majorticklabels(),ax2.xaxis.get_majorticklabels())]),\ + 'major x-axis tick labels visibility changes when colorbar included' + assert ax1.xaxis.get_label().get_visible()==ax2.xaxis.get_label().get_visible(),\ + 'x-axis label visibility changes when colorbar included' + + @pytest.mark.slow + def test_if_hexbin_xaxis_label_is_visible(self): + random_array = np.random.random((1000,3)) + df = pd.DataFrame(random_array,columns=['A label','B label','C label']) + + ax = df.plot.hexbin('A label','B label', gridsize=12); + assert all([vis.get_visible() for vis in ax.xaxis.get_minorticklabels()]),\ + 'minor x-axis tick labels are not visible' + assert all([vis.get_visible() for vis in ax.xaxis.get_majorticklabels()]),\ + 'major x-axis tick labels are not visible' + assert ax.xaxis.get_label().get_visible(),'x-axis label is not visible' + @pytest.mark.slow def test_plot_scatter_with_categorical_data(self): # GH 16199 From f40167253bb9290fc4249d3cf0db3ff4d42aefe3 Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Wed, 28 Mar 2018 23:32:38 -0400 Subject: [PATCH 09/81] Update test_frame.py remove whitespace --- pandas/tests/plotting/test_frame.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/tests/plotting/test_frame.py b/pandas/tests/plotting/test_frame.py index 9fdab839e0748..04603e599c059 100644 --- a/pandas/tests/plotting/test_frame.py +++ b/pandas/tests/plotting/test_frame.py @@ -1046,7 +1046,7 @@ def test_if_scatterplot_colorbar_affects_xaxis_visibility(self): 'major x-axis tick labels visibility changes when colorbar included' assert ax1.xaxis.get_label().get_visible()==ax2.xaxis.get_label().get_visible(),\ 'x-axis label visibility changes when colorbar included' - + @pytest.mark.slow def test_if_hexbin_xaxis_label_is_visible(self): random_array = np.random.random((1000,3)) From b821474995cc96ce4ad6c79a6b8974e2a03d8306 Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Thu, 29 Mar 2018 08:07:40 -0400 Subject: [PATCH 10/81] Update test_frame.py changing for PEP8 --- pandas/tests/plotting/test_frame.py | 38 +++++++++++++++++------------ 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/pandas/tests/plotting/test_frame.py b/pandas/tests/plotting/test_frame.py index 04603e599c059..16c383a5569ec 100644 --- a/pandas/tests/plotting/test_frame.py +++ b/pandas/tests/plotting/test_frame.py @@ -1030,7 +1030,7 @@ def test_plot_scatter(self): axes = df.plot(x='x', y='y', kind='scatter', subplots=True) self._check_axes_shape(axes, axes_num=1, layout=(1, 1)) - @pytest.mark.slow + @pytest.mark.slow def test_if_scatterplot_colorbar_affects_xaxis_visibility(self): random_array = np.random.random((1000,3)) df = pd.DataFrame(random_array,columns=['A label','B label','C label']) @@ -1038,26 +1038,34 @@ def test_if_scatterplot_colorbar_affects_xaxis_visibility(self): ax1 = df.plot.scatter(x='A label', y='B label') ax2 = df.plot.scatter(x='A label', y='B label', c='C label') - assert all([vis[0].get_visible()==vis[1].get_visible() for vis in - zip(ax1.xaxis.get_minorticklabels(),ax2.xaxis.get_minorticklabels())]),\ - 'minor x-axis tick labels visibility changes when colorbar included' - assert all([vis[0].get_visible()==vis[1].get_visible() for vis in - zip(ax1.xaxis.get_majorticklabels(),ax2.xaxis.get_majorticklabels())]),\ - 'major x-axis tick labels visibility changes when colorbar included' - assert ax1.xaxis.get_label().get_visible()==ax2.xaxis.get_label().get_visible(),\ - 'x-axis label visibility changes when colorbar included' + assert all([vis[0].get_visible() == vis[1].get_visible() for vis in + zip(ax1.xaxis.get_minorticklabels(), + ax2.xaxis.get_minorticklabels())]), \ + 'minor x-axis tick labels visibility' + 'changes when colorbar included' + assert all([vis[0].get_visible() == vis[1].get_visible() for vis in + zip(ax1.xaxis.get_majorticklabels(), + ax2.xaxis.get_majorticklabels())]), \ + 'major x-axis tick labels visibility' + 'changes when colorbar included' + assert ax1.xaxis.get_label().get_visible() == \ + ax2.xaxis.get_label().get_visible(), \ + 'x-axis label visibility changes when colorbar included' @pytest.mark.slow def test_if_hexbin_xaxis_label_is_visible(self): random_array = np.random.random((1000,3)) df = pd.DataFrame(random_array,columns=['A label','B label','C label']) - ax = df.plot.hexbin('A label','B label', gridsize=12); - assert all([vis.get_visible() for vis in ax.xaxis.get_minorticklabels()]),\ - 'minor x-axis tick labels are not visible' - assert all([vis.get_visible() for vis in ax.xaxis.get_majorticklabels()]),\ - 'major x-axis tick labels are not visible' - assert ax.xaxis.get_label().get_visible(),'x-axis label is not visible' + ax = df.plot.hexbin('A label','B label', gridsize=12) + assert all([vis.get_visible() for vis in + ax.xaxis.get_minorticklabels()]), \ + 'minor x-axis tick labels are not visible' + assert all([vis.get_visible() for vis in + ax.xaxis.get_majorticklabels()]), \ + 'major x-axis tick labels are not visible' + assert ax.xaxis.get_label().get_visible(), \ + 'x-axis label is not visible' @pytest.mark.slow def test_plot_scatter_with_categorical_data(self): From 52c1fa814b8226c1840af166f40dbd1fa3c9a8d8 Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Thu, 29 Mar 2018 08:07:46 -0400 Subject: [PATCH 11/81] Update _tools.py changing for PEP8 --- pandas/plotting/_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/plotting/_tools.py b/pandas/plotting/_tools.py index fe75762636294..4e0b86a4782ec 100644 --- a/pandas/plotting/_tools.py +++ b/pandas/plotting/_tools.py @@ -312,7 +312,7 @@ def _handle_shared_axes(axarr, nplots, naxes, nrows, ncols, sharex, sharey): # other off layout handles the case that the subplot is # the last in the column, because below is no subplot/gap. if not layout[ax.rowNum + 1, ax.colNum] or \ - getattr(ax, '_pandas_colorbar_axes', False): + getattr(ax, '_pandas_colorbar_axes', False): continue if sharex or len(ax.get_shared_x_axes() .get_siblings(ax)) > 1: From edf008debaf0e8de6ce8170474f03086ef45aebd Mon Sep 17 00:00:00 2001 From: Javad Date: Thu, 29 Mar 2018 09:10:40 -0400 Subject: [PATCH 12/81] fixing assert message --- pandas/tests/plotting/test_frame.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/tests/plotting/test_frame.py b/pandas/tests/plotting/test_frame.py index 16c383a5569ec..c549e7fe7e393 100644 --- a/pandas/tests/plotting/test_frame.py +++ b/pandas/tests/plotting/test_frame.py @@ -1041,12 +1041,12 @@ def test_if_scatterplot_colorbar_affects_xaxis_visibility(self): assert all([vis[0].get_visible() == vis[1].get_visible() for vis in zip(ax1.xaxis.get_minorticklabels(), ax2.xaxis.get_minorticklabels())]), \ - 'minor x-axis tick labels visibility' + 'minor x-axis tick labels visibility ' \ 'changes when colorbar included' assert all([vis[0].get_visible() == vis[1].get_visible() for vis in zip(ax1.xaxis.get_majorticklabels(), ax2.xaxis.get_majorticklabels())]), \ - 'major x-axis tick labels visibility' + 'major x-axis tick labels visibility ' \ 'changes when colorbar included' assert ax1.xaxis.get_label().get_visible() == \ ax2.xaxis.get_label().get_visible(), \ From db6cf67641da371988c3900830a3cf14eff83bf4 Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Thu, 29 Mar 2018 10:16:54 -0400 Subject: [PATCH 13/81] Update test_frame.py fixing lint E231 --- pandas/tests/plotting/test_frame.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pandas/tests/plotting/test_frame.py b/pandas/tests/plotting/test_frame.py index c549e7fe7e393..fa834fdcf8ca3 100644 --- a/pandas/tests/plotting/test_frame.py +++ b/pandas/tests/plotting/test_frame.py @@ -1032,8 +1032,8 @@ def test_plot_scatter(self): @pytest.mark.slow def test_if_scatterplot_colorbar_affects_xaxis_visibility(self): - random_array = np.random.random((1000,3)) - df = pd.DataFrame(random_array,columns=['A label','B label','C label']) + random_array = np.random.random((1000, 3)) + df = pd.DataFrame(random_array,columns=['A label', 'B label', 'C label']) ax1 = df.plot.scatter(x='A label', y='B label') ax2 = df.plot.scatter(x='A label', y='B label', c='C label') @@ -1043,7 +1043,7 @@ def test_if_scatterplot_colorbar_affects_xaxis_visibility(self): ax2.xaxis.get_minorticklabels())]), \ 'minor x-axis tick labels visibility ' \ 'changes when colorbar included' - assert all([vis[0].get_visible() == vis[1].get_visible() for vis in + assert all([vis[0].get_visible() == vis[1].get_visible() for vis in zip(ax1.xaxis.get_majorticklabels(), ax2.xaxis.get_majorticklabels())]), \ 'major x-axis tick labels visibility ' \ @@ -1054,10 +1054,10 @@ def test_if_scatterplot_colorbar_affects_xaxis_visibility(self): @pytest.mark.slow def test_if_hexbin_xaxis_label_is_visible(self): - random_array = np.random.random((1000,3)) - df = pd.DataFrame(random_array,columns=['A label','B label','C label']) + random_array = np.random.random((1000, 3)) + df = pd.DataFrame(random_array,columns=['A label', 'B label', 'C label']) - ax = df.plot.hexbin('A label','B label', gridsize=12) + ax = df.plot.hexbin('A label', 'B label', gridsize=12) assert all([vis.get_visible() for vis in ax.xaxis.get_minorticklabels()]), \ 'minor x-axis tick labels are not visible' From 186a09cadf960d806c501182b9e92a3c47fb4269 Mon Sep 17 00:00:00 2001 From: Javad Date: Thu, 29 Mar 2018 14:08:02 -0400 Subject: [PATCH 14/81] fixing style issues --- pandas/plotting/_core.py | 10 +++++++ pandas/plotting/_tools.py | 4 +-- pandas/tests/plotting/test_frame.py | 42 ++++++++++++++--------------- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 9452cfc999318..caac315f5b247 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -870,7 +870,12 @@ def _make_plot(self): scatter = ax.scatter(data[x].values, data[y].values, c=c_values, label=label, cmap=cmap, **self.kwds) if cb: + # The following attribute determines which axes belong to + # colorbars. When sharex = True, this allows `_handle_shared_axes` + # to skip them. Otherwise colobars will cause x-axis label and + # tick labels to disappear. ax._pandas_colorbar_axes = True + img = ax.collections[0] kws = dict(ax=ax) if self.mpl_ge_1_3_1(): @@ -917,7 +922,12 @@ def _make_plot(self): ax.hexbin(data[x].values, data[y].values, C=c_values, cmap=cmap, **self.kwds) if cb: + # The following attribute determines which axes belong to + # colorbars. When sharex = True, this allows `_handle_shared_axes` + # to skip them. Otherwise colobars will cause x-axis label and + # tick labels to disappear. ax._pandas_colorbar_axes = True + img = ax.collections[0] self.fig.colorbar(img, ax=ax) diff --git a/pandas/plotting/_tools.py b/pandas/plotting/_tools.py index 4e0b86a4782ec..15e3c048a21a6 100644 --- a/pandas/plotting/_tools.py +++ b/pandas/plotting/_tools.py @@ -311,8 +311,8 @@ def _handle_shared_axes(axarr, nplots, naxes, nrows, ncols, sharex, sharey): # only the last row of subplots should get x labels -> all # other off layout handles the case that the subplot is # the last in the column, because below is no subplot/gap. - if not layout[ax.rowNum + 1, ax.colNum] or \ - getattr(ax, '_pandas_colorbar_axes', False): + if (not layout[ax.rowNum + 1, ax.colNum] or + getattr(ax, '_pandas_colorbar_axes', False)): continue if sharex or len(ax.get_shared_x_axes() .get_siblings(ax)) > 1: diff --git a/pandas/tests/plotting/test_frame.py b/pandas/tests/plotting/test_frame.py index fa834fdcf8ca3..4c1917fc7cefb 100644 --- a/pandas/tests/plotting/test_frame.py +++ b/pandas/tests/plotting/test_frame.py @@ -1033,39 +1033,39 @@ def test_plot_scatter(self): @pytest.mark.slow def test_if_scatterplot_colorbar_affects_xaxis_visibility(self): random_array = np.random.random((1000, 3)) - df = pd.DataFrame(random_array,columns=['A label', 'B label', 'C label']) + df = pd.DataFrame(random_array, + columns=['A label', 'B label', 'C label']) ax1 = df.plot.scatter(x='A label', y='B label') ax2 = df.plot.scatter(x='A label', y='B label', c='C label') - assert all([vis[0].get_visible() == vis[1].get_visible() for vis in - zip(ax1.xaxis.get_minorticklabels(), - ax2.xaxis.get_minorticklabels())]), \ - 'minor x-axis tick labels visibility ' \ - 'changes when colorbar included' - assert all([vis[0].get_visible() == vis[1].get_visible() for vis in - zip(ax1.xaxis.get_majorticklabels(), - ax2.xaxis.get_majorticklabels())]), \ - 'major x-axis tick labels visibility ' \ - 'changes when colorbar included' - assert ax1.xaxis.get_label().get_visible() == \ - ax2.xaxis.get_label().get_visible(), \ - 'x-axis label visibility changes when colorbar included' + vis1 = [vis.get_visible() for vis in + ax1.xaxis.get_minorticklabels()] + vis2 = [vis.get_visible() for vis in + ax2.xaxis.get_minorticklabels()] + assert vis1 == vis2 + + vis1 = [vis.get_visible() for vis in + ax1.xaxis.get_majorticklabels()] + vis2 = [vis.get_visible() for vis in + ax2.xaxis.get_majorticklabels()] + assert vis1 == vis2 + + assert (ax1.xaxis.get_label().get_visible() == + ax2.xaxis.get_label().get_visible()) @pytest.mark.slow def test_if_hexbin_xaxis_label_is_visible(self): random_array = np.random.random((1000, 3)) - df = pd.DataFrame(random_array,columns=['A label', 'B label', 'C label']) + df = pd.DataFrame(random_array, + columns=['A label', 'B label', 'C label']) ax = df.plot.hexbin('A label', 'B label', gridsize=12) assert all([vis.get_visible() for vis in - ax.xaxis.get_minorticklabels()]), \ - 'minor x-axis tick labels are not visible' + ax.xaxis.get_minorticklabels()]) assert all([vis.get_visible() for vis in - ax.xaxis.get_majorticklabels()]), \ - 'major x-axis tick labels are not visible' - assert ax.xaxis.get_label().get_visible(), \ - 'x-axis label is not visible' + ax.xaxis.get_majorticklabels()]) + assert ax.xaxis.get_label().get_visible() @pytest.mark.slow def test_plot_scatter_with_categorical_data(self): From e6980f3e483a47293601329cc986e70d5791c33a Mon Sep 17 00:00:00 2001 From: l736x Date: Thu, 22 Mar 2018 09:39:00 +0100 Subject: [PATCH 15/81] DOC: Improve the docstrings of CategoricalIndex.map (#20286) --- pandas/core/arrays/categorical.py | 67 +++++++++++++++++++++++++++---- pandas/core/indexes/base.py | 7 ++-- pandas/core/indexes/category.py | 65 ++++++++++++++++++++++++++---- pandas/core/series.py | 17 ++++---- 4 files changed, 131 insertions(+), 25 deletions(-) diff --git a/pandas/core/arrays/categorical.py b/pandas/core/arrays/categorical.py index e7d414f9de544..afbf4baf0d002 100644 --- a/pandas/core/arrays/categorical.py +++ b/pandas/core/arrays/categorical.py @@ -1080,20 +1080,73 @@ def remove_unused_categories(self, inplace=False): return cat def map(self, mapper): - """Apply mapper function to its categories (not codes). + """ + Map categories using input correspondence (dict, Series, or function). + + Maps the categories to new categories. If the mapping correspondence is + one-to-one the result is a :class:`~pandas.Categorical` which has the + same order property as the original, otherwise a :class:`~pandas.Index` + is returned. + + If a `dict` or :class:`~pandas.Series` is used any unmapped category is + mapped to `NaN`. Note that if this happens an :class:`~pandas.Index` + will be returned. Parameters ---------- - mapper : callable - Function to be applied. When all categories are mapped - to different categories, the result will be Categorical which has - the same order property as the original. Otherwise, the result will - be np.ndarray. + mapper : function, dict, or Series + Mapping correspondence. Returns ------- - applied : Categorical or Index. + pandas.Categorical or pandas.Index + Mapped categorical. + + See Also + -------- + CategoricalIndex.map : Apply a mapping correspondence on a + :class:`~pandas.CategoricalIndex`. + Index.map : Apply a mapping correspondence on an + :class:`~pandas.Index`. + Series.map : Apply a mapping correspondence on a + :class:`~pandas.Series`. + Series.apply : Apply more complex functions on a + :class:`~pandas.Series`. + + Examples + -------- + >>> cat = pd.Categorical(['a', 'b', 'c']) + >>> cat + [a, b, c] + Categories (3, object): [a, b, c] + >>> cat.map(lambda x: x.upper()) + [A, B, C] + Categories (3, object): [A, B, C] + >>> cat.map({'a': 'first', 'b': 'second', 'c': 'third'}) + [first, second, third] + Categories (3, object): [first, second, third] + + If the mapping is one-to-one the ordering of the categories is + preserved: + + >>> cat = pd.Categorical(['a', 'b', 'c'], ordered=True) + >>> cat + [a, b, c] + Categories (3, object): [a < b < c] + >>> cat.map({'a': 3, 'b': 2, 'c': 1}) + [3, 2, 1] + Categories (3, int64): [3 < 2 < 1] + + If the mapping is not one-to-one an :class:`~pandas.Index` is returned: + + >>> cat.map({'a': 'first', 'b': 'second', 'c': 'first'}) + Index(['first', 'second', 'first'], dtype='object') + + If a `dict` is used, all unmapped categories are mapped to `NaN` and + the result is an :class:`~pandas.Index`: + >>> cat.map({'a': 'first', 'b': 'second'}) + Index(['first', 'second', nan], dtype='object') """ new_categories = self.categories.map(mapper) try: diff --git a/pandas/core/indexes/base.py b/pandas/core/indexes/base.py index 446b1b02706e3..95bfc8bfcb5c5 100644 --- a/pandas/core/indexes/base.py +++ b/pandas/core/indexes/base.py @@ -3352,14 +3352,16 @@ def groupby(self, values): return result def map(self, mapper, na_action=None): - """Map values of Series using input correspondence + """ + Map values using input correspondence (a dict, Series, or function). Parameters ---------- mapper : function, dict, or Series + Mapping correspondence. na_action : {None, 'ignore'} If 'ignore', propagate NA values, without passing them to the - mapping function + mapping correspondence. Returns ------- @@ -3367,7 +3369,6 @@ def map(self, mapper, na_action=None): The output of the mapping function applied to the index. If the function returns a tuple with more than one element a MultiIndex will be returned. - """ from .multi import MultiIndex diff --git a/pandas/core/indexes/category.py b/pandas/core/indexes/category.py index 7b902b92d44a4..71caa098c7a28 100644 --- a/pandas/core/indexes/category.py +++ b/pandas/core/indexes/category.py @@ -660,20 +660,71 @@ def is_dtype_equal(self, other): take_nd = take def map(self, mapper): - """Apply mapper function to its categories (not codes). + """ + Map values using input correspondence (a dict, Series, or function). + + Maps the values (their categories, not the codes) of the index to new + categories. If the mapping correspondence is one-to-one the result is a + :class:`~pandas.CategoricalIndex` which has the same order property as + the original, otherwise an :class:`~pandas.Index` is returned. + + If a `dict` or :class:`~pandas.Series` is used any unmapped category is + mapped to `NaN`. Note that if this happens an :class:`~pandas.Index` + will be returned. Parameters ---------- - mapper : callable - Function to be applied. When all categories are mapped - to different categories, the result will be a CategoricalIndex - which has the same order property as the original. Otherwise, - the result will be a Index. + mapper : function, dict, or Series + Mapping correspondence. Returns ------- - applied : CategoricalIndex or Index + pandas.CategoricalIndex or pandas.Index + Mapped index. + + See Also + -------- + Index.map : Apply a mapping correspondence on an + :class:`~pandas.Index`. + Series.map : Apply a mapping correspondence on a + :class:`~pandas.Series`. + Series.apply : Apply more complex functions on a + :class:`~pandas.Series`. + Examples + -------- + >>> idx = pd.CategoricalIndex(['a', 'b', 'c']) + >>> idx + CategoricalIndex(['a', 'b', 'c'], categories=['a', 'b', 'c'], + ordered=False, dtype='category') + >>> idx.map(lambda x: x.upper()) + CategoricalIndex(['A', 'B', 'C'], categories=['A', 'B', 'C'], + ordered=False, dtype='category') + >>> idx.map({'a': 'first', 'b': 'second', 'c': 'third'}) + CategoricalIndex(['first', 'second', 'third'], categories=['first', + 'second', 'third'], ordered=False, dtype='category') + + If the mapping is one-to-one the ordering of the categories is + preserved: + + >>> idx = pd.CategoricalIndex(['a', 'b', 'c'], ordered=True) + >>> idx + CategoricalIndex(['a', 'b', 'c'], categories=['a', 'b', 'c'], + ordered=True, dtype='category') + >>> idx.map({'a': 3, 'b': 2, 'c': 1}) + CategoricalIndex([3, 2, 1], categories=[3, 2, 1], ordered=True, + dtype='category') + + If the mapping is not one-to-one an :class:`~pandas.Index` is returned: + + >>> idx.map({'a': 'first', 'b': 'second', 'c': 'first'}) + Index(['first', 'second', 'first'], dtype='object') + + If a `dict` is used, all unmapped categories are mapped to `NaN` and + the result is an :class:`~pandas.Index`: + + >>> idx.map({'a': 'first', 'b': 'second'}) + Index(['first', 'second', nan], dtype='object') """ return self._shallow_copy_with_infer(self.values.map(mapper)) diff --git a/pandas/core/series.py b/pandas/core/series.py index e4801242073a2..f0ba369e1731a 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -2831,25 +2831,26 @@ def unstack(self, level=-1, fill_value=None): def map(self, arg, na_action=None): """ - Map values of Series using input correspondence (which can be - a dict, Series, or function) + Map values of Series using input correspondence (a dict, Series, or + function). Parameters ---------- arg : function, dict, or Series + Mapping correspondence. na_action : {None, 'ignore'} If 'ignore', propagate NA values, without passing them to the - mapping function + mapping correspondence. Returns ------- y : Series - same index as caller + Same index as caller. Examples -------- - Map inputs to outputs (both of type `Series`) + Map inputs to outputs (both of type `Series`): >>> x = pd.Series([1,2,3], index=['one', 'two', 'three']) >>> x @@ -2900,9 +2901,9 @@ def map(self, arg, na_action=None): See Also -------- - Series.apply: For applying more complex functions on a Series - DataFrame.apply: Apply a function row-/column-wise - DataFrame.applymap: Apply a function elementwise on a whole DataFrame + Series.apply : For applying more complex functions on a Series. + DataFrame.apply : Apply a function row-/column-wise. + DataFrame.applymap : Apply a function elementwise on a whole DataFrame. Notes ----- From cdc0240caa3df48b2cbbe190fcde93ff92e2aef2 Mon Sep 17 00:00:00 2001 From: Igor Shelvinskyi Date: Thu, 22 Mar 2018 09:23:27 +0000 Subject: [PATCH 16/81] DOC: update the DataFrame.to_hdf() docstirng (#20186) --- pandas/core/generic.py | 106 ++++++++++++++++++++++++++++++----------- 1 file changed, 77 insertions(+), 29 deletions(-) diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 2adc289f98d94..a97699d0f5c77 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -1889,40 +1889,50 @@ def to_json(self, path_or_buf=None, orient=None, date_format=None, index=index) def to_hdf(self, path_or_buf, key, **kwargs): - """Write the contained data to an HDF5 file using HDFStore. + """ + Write the contained data to an HDF5 file using HDFStore. + + Hierarchical Data Format (HDF) is self-describing, allowing an + application to interpret the structure and contents of a file with + no outside information. One HDF file can hold a mix of related objects + which can be accessed as a group or as individual objects. + + In order to add another DataFrame or Series to an existing HDF file + please use append mode and a different a key. + + For more information see the :ref:`user guide `. Parameters ---------- - path_or_buf : the path (string) or HDFStore object - key : string - identifier for the group in the store - mode : optional, {'a', 'w', 'r+'}, default 'a' - - ``'w'`` - Write; a new file is created (an existing file with the same - name would be deleted). - ``'a'`` - Append; an existing file is opened for reading and writing, - and if the file does not exist it is created. - ``'r+'`` - It is similar to ``'a'``, but the file must already exist. - format : 'fixed(f)|table(t)', default is 'fixed' - fixed(f) : Fixed format - Fast writing/reading. Not-appendable, nor searchable - table(t) : Table format - Write as a PyTables Table structure which may perform - worse but allow more flexible operations like searching - / selecting subsets of the data - append : boolean, default False - For Table formats, append the input data to the existing - data_columns : list of columns, or True, default None + path_or_buf : str or pandas.HDFStore + File path or HDFStore object. + key : str + Identifier for the group in the store. + mode : {'a', 'w', 'r+'}, default 'a' + Mode to open file: + + - 'w': write, a new file is created (an existing file with + the same name would be deleted). + - 'a': append, an existing file is opened for reading and + writing, and if the file does not exist it is created. + - 'r+': similar to 'a', but the file must already exist. + format : {'fixed', 'table'}, default 'fixed' + Possible values: + + - 'fixed': Fixed format. Fast writing/reading. Not-appendable, + nor searchable. + - 'table': Table format. Write as a PyTables Table structure + which may perform worse but allow more flexible operations + like searching / selecting subsets of the data. + append : bool, default False + For Table formats, append the input data to the existing. + data_columns : list of columns or True, optional List of columns to create as indexed data columns for on-disk queries, or True to use all columns. By default only the axes of the object are indexed. See `here `__. - Applicable only to format='table'. - complevel : int, 0-9, default None + complevel : {0-9}, optional Specifies a compression level for data. A value of 0 disables compression. complib : {'zlib', 'lzo', 'bzip2', 'blosc'}, default 'zlib' @@ -1934,11 +1944,49 @@ def to_hdf(self, path_or_buf, key, **kwargs): Specifying a compression library which is not available issues a ValueError. fletcher32 : bool, default False - If applying compression use the fletcher32 checksum - dropna : boolean, default False. + If applying compression use the fletcher32 checksum. + dropna : bool, default False If true, ALL nan rows will not be written to store. - """ + See Also + -------- + DataFrame.read_hdf : Read from HDF file. + DataFrame.to_parquet : Write a DataFrame to the binary parquet format. + DataFrame.to_sql : Write to a sql table. + DataFrame.to_feather : Write out feather-format for DataFrames. + DataFrame.to_csv : Write out to a csv file. + + Examples + -------- + >>> df = pd.DataFrame({'A': [1, 2, 3], 'B': [4, 5, 6]}, + ... index=['a', 'b', 'c']) + >>> df.to_hdf('data.h5', key='df', mode='w') + + We can add another object to the same file: + + >>> s = pd.Series([1, 2, 3, 4]) + >>> s.to_hdf('data.h5', key='s') + + Reading from HDF file: + + >>> pd.read_hdf('data.h5', 'df') + A B + a 1 4 + b 2 5 + c 3 6 + >>> pd.read_hdf('data.h5', 's') + 0 1 + 1 2 + 2 3 + 3 4 + dtype: int64 + + Deleting file with data: + + >>> import os + >>> os.remove('data.h5') + + """ from pandas.io import pytables return pytables.to_hdf(path_or_buf, key, self, **kwargs) From 4848deab4ee6d27e228c7069a3a59095225b6e25 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Thu, 22 Mar 2018 10:46:55 +0100 Subject: [PATCH 17/81] DOC: enable docstring on DataFrame.columns/index (#20385) --- doc/source/api.rst | 18 +++++++++++------- pandas/_libs/properties.pyx | 9 ++++++--- pandas/core/frame.py | 5 ++++- pandas/core/generic.py | 4 ++-- pandas/core/panel.py | 3 ++- pandas/core/series.py | 3 ++- pandas/tests/frame/test_api.py | 6 ++++-- pandas/tests/series/test_api.py | 4 +++- 8 files changed, 34 insertions(+), 18 deletions(-) diff --git a/doc/source/api.rst b/doc/source/api.rst index dba7f6526f22a..dfb6d03ec9159 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -263,7 +263,11 @@ Constructor Attributes ~~~~~~~~~~ **Axes** - * **index**: axis labels + +.. autosummary:: + :toctree: generated/ + + Series.index .. autosummary:: :toctree: generated/ @@ -845,13 +849,15 @@ Attributes and underlying data ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Axes** - * **index**: row labels - * **columns**: column labels +.. autosummary:: + :toctree: generated/ + + DataFrame.index + DataFrame.columns .. autosummary:: :toctree: generated/ - DataFrame.as_matrix DataFrame.dtypes DataFrame.ftypes DataFrame.get_dtype_counts @@ -2546,8 +2552,7 @@ objects. :hidden: generated/pandas.DataFrame.blocks - generated/pandas.DataFrame.columns - generated/pandas.DataFrame.index + generated/pandas.DataFrame.as_matrix generated/pandas.DataFrame.ix generated/pandas.Index.asi8 generated/pandas.Index.data @@ -2566,6 +2571,5 @@ objects. generated/pandas.Series.asobject generated/pandas.Series.blocks generated/pandas.Series.from_array - generated/pandas.Series.index generated/pandas.Series.ix generated/pandas.Timestamp.offset diff --git a/pandas/_libs/properties.pyx b/pandas/_libs/properties.pyx index 67f58851a9a70..e3f16f224db1c 100644 --- a/pandas/_libs/properties.pyx +++ b/pandas/_libs/properties.pyx @@ -42,11 +42,14 @@ cache_readonly = CachedProperty cdef class AxisProperty(object): - cdef: + + cdef readonly: Py_ssize_t axis + object __doc__ - def __init__(self, axis=0): + def __init__(self, axis=0, doc=""): self.axis = axis + self.__doc__ = doc def __get__(self, obj, type): cdef: @@ -54,7 +57,7 @@ cdef class AxisProperty(object): if obj is None: # Only instances have _data, not classes - return None + return self else: axes = obj._data.axes return axes[self.axis] diff --git a/pandas/core/frame.py b/pandas/core/frame.py index efb002474f876..081a8b39a3849 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -6989,7 +6989,10 @@ def isin(self, values): DataFrame._setup_axes(['index', 'columns'], info_axis=1, stat_axis=0, - axes_are_reversed=True, aliases={'rows': 0}) + axes_are_reversed=True, aliases={'rows': 0}, + docs={ + 'index': 'The index (row labels) of the DataFrame.', + 'columns': 'The column labels of the DataFrame.'}) DataFrame._add_numeric_operations() DataFrame._add_series_or_dataframe_operations() diff --git a/pandas/core/generic.py b/pandas/core/generic.py index a97699d0f5c77..74b760fa4e3c4 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -236,7 +236,7 @@ def _constructor_expanddim(self): @classmethod def _setup_axes(cls, axes, info_axis=None, stat_axis=None, aliases=None, slicers=None, axes_are_reversed=False, build_axes=True, - ns=None): + ns=None, docs=None): """Provide axes setup for the major PandasObjects. Parameters @@ -278,7 +278,7 @@ def _setup_axes(cls, axes, info_axis=None, stat_axis=None, aliases=None, if build_axes: def set_axis(a, i): - setattr(cls, a, properties.AxisProperty(i)) + setattr(cls, a, properties.AxisProperty(i, docs.get(a, a))) cls._internal_names_set.add(a) if axes_are_reversed: diff --git a/pandas/core/panel.py b/pandas/core/panel.py index 052d555df76f1..5bb4b72a0562d 100644 --- a/pandas/core/panel.py +++ b/pandas/core/panel.py @@ -1523,7 +1523,8 @@ def _extract_axis(self, data, axis=0, intersect=False): stat_axis=1, aliases={'major': 'major_axis', 'minor': 'minor_axis'}, slicers={'major_axis': 'index', - 'minor_axis': 'columns'}) + 'minor_axis': 'columns'}, + docs={}) ops.add_special_arithmetic_methods(Panel) ops.add_flex_arithmetic_methods(Panel) diff --git a/pandas/core/series.py b/pandas/core/series.py index f0ba369e1731a..3e3600898ba7f 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -3871,7 +3871,8 @@ def to_period(self, freq=None, copy=True): hist = gfx.hist_series -Series._setup_axes(['index'], info_axis=0, stat_axis=0, aliases={'rows': 0}) +Series._setup_axes(['index'], info_axis=0, stat_axis=0, aliases={'rows': 0}, + docs={'index': 'The index (axis labels) of the Series.'}) Series._add_numeric_operations() Series._add_series_only_operations() Series._add_series_or_dataframe_operations() diff --git a/pandas/tests/frame/test_api.py b/pandas/tests/frame/test_api.py index 8ba5469480e64..b2cbd0b07d7f5 100644 --- a/pandas/tests/frame/test_api.py +++ b/pandas/tests/frame/test_api.py @@ -6,6 +6,7 @@ # pylint: disable-msg=W0612,E1101 from copy import deepcopy +import pydoc import sys from distutils.version import LooseVersion @@ -362,8 +363,9 @@ def test_axis_aliases(self): def test_class_axis(self): # https://github.com/pandas-dev/pandas/issues/18147 - DataFrame.index # no exception! - DataFrame.columns # no exception! + # no exception and no empty docstring + assert pydoc.getdoc(DataFrame.index) + assert pydoc.getdoc(DataFrame.columns) def test_more_values(self): values = self.mixed_frame.values diff --git a/pandas/tests/series/test_api.py b/pandas/tests/series/test_api.py index cf8698bc5ed5e..f7f1ea019a3f0 100644 --- a/pandas/tests/series/test_api.py +++ b/pandas/tests/series/test_api.py @@ -1,6 +1,7 @@ # coding=utf-8 # pylint: disable-msg=E1101,W0612 from collections import OrderedDict +import pydoc import pytest @@ -384,7 +385,8 @@ def test_axis_alias(self): def test_class_axis(self): # https://github.com/pandas-dev/pandas/issues/18147 - Series.index # no exception! + # no exception and no empty docstring + assert pydoc.getdoc(Series.index) def test_numpy_unique(self): # it works! From 5aed4b5db196d530b70ef7bd0d424d6d7244984f Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Thu, 22 Mar 2018 11:34:26 +0100 Subject: [PATCH 18/81] DOC: general docstring formatting fixes (#20449) --- pandas/core/base.py | 1 + pandas/core/frame.py | 2 -- pandas/core/generic.py | 20 +++++++++++--------- pandas/core/indexes/base.py | 11 ++++++----- pandas/core/indexes/datetimelike.py | 16 ++++++---------- pandas/core/reshape/tile.py | 2 +- 6 files changed, 25 insertions(+), 27 deletions(-) diff --git a/pandas/core/base.py b/pandas/core/base.py index f686975366419..b3eb9a0ae7530 100644 --- a/pandas/core/base.py +++ b/pandas/core/base.py @@ -855,6 +855,7 @@ def min(self): 'a' For a MultiIndex, the minimum is determined lexicographically. + >>> idx = pd.MultiIndex.from_product([('a', 'b'), (2, 1)]) >>> idx.min() ('a', 1) diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 081a8b39a3849..cf41737a04ba6 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -5465,8 +5465,6 @@ def _gotitem(self, key, ndim, subset=None): return self[key] _agg_doc = dedent(""" - Notes - ----- The aggregation operations are always performed over an axis, either the index (default) or the column axis. This behavior is different from `numpy` aggregation functions (`mean`, `median`, `prod`, `sum`, `std`, diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 74b760fa4e3c4..bd1a2371315a0 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -1910,20 +1910,20 @@ def to_hdf(self, path_or_buf, key, **kwargs): Identifier for the group in the store. mode : {'a', 'w', 'r+'}, default 'a' Mode to open file: - + - 'w': write, a new file is created (an existing file with - the same name would be deleted). + the same name would be deleted). - 'a': append, an existing file is opened for reading and - writing, and if the file does not exist it is created. + writing, and if the file does not exist it is created. - 'r+': similar to 'a', but the file must already exist. format : {'fixed', 'table'}, default 'fixed' Possible values: - + - 'fixed': Fixed format. Fast writing/reading. Not-appendable, - nor searchable. + nor searchable. - 'table': Table format. Write as a PyTables Table structure - which may perform worse but allow more flexible operations - like searching / selecting subsets of the data. + which may perform worse but allow more flexible operations + like searching / selecting subsets of the data. append : bool, default False For Table formats, append the input data to the existing. data_columns : list of columns or True, optional @@ -5795,10 +5795,11 @@ def replace(self, to_replace=None, value=None, inplace=False, limit=None, * None: (default) no fill restriction * 'inside' Only fill NaNs surrounded by valid values (interpolate). * 'outside' Only fill NaNs outside valid values (extrapolate). - .. versionadded:: 0.21.0 If limit is specified, consecutive NaNs will be filled in this direction. + + .. versionadded:: 0.21.0 inplace : bool, default False Update the NDFrame in place if possible. downcast : optional, 'infer' or None, defaults to None @@ -7717,6 +7718,7 @@ def truncate(self, before=None, after=None, axis=None, copy=True): The index values in ``truncate`` can be datetimes or string dates. + >>> dates = pd.date_range('2016-01-01', '2016-02-01', freq='s') >>> df = pd.DataFrame(index=dates, data={'A': 1}) >>> df.tail() @@ -7960,7 +7962,7 @@ def abs(self): 0 1 days dtype: timedelta64[ns] - Select rows with data closest to certian value using argsort (from + Select rows with data closest to certain value using argsort (from `StackOverflow `__). >>> df = pd.DataFrame({ diff --git a/pandas/core/indexes/base.py b/pandas/core/indexes/base.py index 95bfc8bfcb5c5..40f543e211f0c 100644 --- a/pandas/core/indexes/base.py +++ b/pandas/core/indexes/base.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta import warnings import operator +from textwrap import dedent import numpy as np from pandas._libs import (lib, index as libindex, tslib as libts, @@ -2183,7 +2184,7 @@ def isna(self): mapped to ``True`` values. Everything else get mapped to ``False`` values. Characters such as empty strings `''` or :attr:`numpy.inf` are not considered NA values - (unless you set :attr:`pandas.options.mode.use_inf_as_na` `= True`). + (unless you set ``pandas.options.mode.use_inf_as_na = True``). .. versionadded:: 0.20.0 @@ -4700,7 +4701,7 @@ def _add_logical_methods(cls): %(outname)s : bool or array_like (if axis is specified) A single element array_like may be converted to bool.""" - _index_shared_docs['index_all'] = """ + _index_shared_docs['index_all'] = dedent(""" See Also -------- @@ -4738,9 +4739,9 @@ def _add_logical_methods(cls): >>> pd.Index([0, 0, 0]).any() False - """ + """) - _index_shared_docs['index_any'] = """ + _index_shared_docs['index_any'] = dedent(""" See Also -------- @@ -4761,7 +4762,7 @@ def _add_logical_methods(cls): >>> index = pd.Index([0, 0, 0]) >>> index.any() False - """ + """) def _make_logical_function(name, desc, f): @Substitution(outname=name, desc=desc) diff --git a/pandas/core/indexes/datetimelike.py b/pandas/core/indexes/datetimelike.py index e9011a3eb912c..b906ea0f4784c 100644 --- a/pandas/core/indexes/datetimelike.py +++ b/pandas/core/indexes/datetimelike.py @@ -86,16 +86,12 @@ def strftime(self, date_format): Examples -------- - >>> import datetime - >>> data = pd.date_range(datetime.datetime(2018,3,10,19,27,52), - ... periods=4, freq='B') - >>> df = pd.DataFrame(data, columns=['date']) - >>> df.date[1] - Timestamp('2018-03-13 19:27:52') - >>> df.date[1].strftime('%d-%m-%Y') - '13-03-2018' - >>> df.date[1].strftime('%B %d, %Y, %r') - 'March 13, 2018, 07:27:52 PM' + >>> rng = pd.date_range(pd.Timestamp("2018-03-10 09:00"), + ... periods=3, freq='s') + >>> rng.strftime('%B %d, %Y, %r') + Index(['March 10, 2018, 09:00:00 AM', 'March 10, 2018, 09:00:01 AM', + 'March 10, 2018, 09:00:02 AM'], + dtype='object') """.format("https://docs.python.org/3/library/datetime.html" "#strftime-and-strptime-behavior") diff --git a/pandas/core/reshape/tile.py b/pandas/core/reshape/tile.py index be28f7091712f..118198ea0320d 100644 --- a/pandas/core/reshape/tile.py +++ b/pandas/core/reshape/tile.py @@ -51,7 +51,7 @@ def cut(x, bins, right=True, labels=None, retbins=False, precision=3, right : bool, default True Indicates whether `bins` includes the rightmost edge or not. If ``right == True`` (the default), then the `bins` ``[1, 2, 3, 4]`` - indicate (1,2], (2,3], (3,4]. This argument is ignored when + indicate (1,2], (2,3], (3,4]. This argument is ignored when `bins` is an IntervalIndex. labels : array or bool, optional Specifies the labels for the returned bins. Must be the same length as From e2053c974100bf87088f709e9b2d5fc00d3daf0e Mon Sep 17 00:00:00 2001 From: Ming Li <14131823+minggli@users.noreply.github.com> Date: Thu, 22 Mar 2018 10:40:38 +0000 Subject: [PATCH 19/81] parameterize tests in scalar/timedelta (#20428) --- .../scalar/timedelta/test_construction.py | 30 ++++------ pandas/tests/scalar/timedelta/test_formats.py | 58 ++++++------------- 2 files changed, 29 insertions(+), 59 deletions(-) diff --git a/pandas/tests/scalar/timedelta/test_construction.py b/pandas/tests/scalar/timedelta/test_construction.py index 5ccad9e6b4e3c..d648140aa7347 100644 --- a/pandas/tests/scalar/timedelta/test_construction.py +++ b/pandas/tests/scalar/timedelta/test_construction.py @@ -195,28 +195,18 @@ def test_iso_constructor_raises(fmt): Timedelta(fmt) -def test_td_constructor_on_nanoseconds(): +@pytest.mark.parametrize('constructed_td, conversion', [ + (Timedelta(nanoseconds=100), '100ns'), + (Timedelta(days=1, hours=1, minutes=1, weeks=1, seconds=1, milliseconds=1, + microseconds=1, nanoseconds=1), 694861001001001), + (Timedelta(microseconds=1) + Timedelta(nanoseconds=1), '1us1ns'), + (Timedelta(microseconds=1) - Timedelta(nanoseconds=1), '999ns'), + (Timedelta(microseconds=1) + 5 * Timedelta(nanoseconds=-2), '990ns')]) +def test_td_constructor_on_nanoseconds(constructed_td, conversion): # GH#9273 - result = Timedelta(nanoseconds=100) - expected = Timedelta('100ns') - assert result == expected - - result = Timedelta(days=1, hours=1, minutes=1, weeks=1, seconds=1, - milliseconds=1, microseconds=1, nanoseconds=1) - expected = Timedelta(694861001001001) - assert result == expected - - result = Timedelta(microseconds=1) + Timedelta(nanoseconds=1) - expected = Timedelta('1us1ns') - assert result == expected - - result = Timedelta(microseconds=1) - Timedelta(nanoseconds=1) - expected = Timedelta('999ns') - assert result == expected + assert constructed_td == Timedelta(conversion) - result = Timedelta(microseconds=1) + 5 * Timedelta(nanoseconds=-2) - expected = Timedelta('990ns') - assert result == expected +def test_td_constructor_value_error(): with pytest.raises(TypeError): Timedelta(nanoseconds='abc') diff --git a/pandas/tests/scalar/timedelta/test_formats.py b/pandas/tests/scalar/timedelta/test_formats.py index 8a877c7d1c0fa..0d0b24f192f96 100644 --- a/pandas/tests/scalar/timedelta/test_formats.py +++ b/pandas/tests/scalar/timedelta/test_formats.py @@ -1,48 +1,28 @@ # -*- coding: utf-8 -*- -from pandas import Timedelta - - -def test_repr(): - assert (repr(Timedelta(10, unit='d')) == - "Timedelta('10 days 00:00:00')") - assert (repr(Timedelta(10, unit='s')) == - "Timedelta('0 days 00:00:10')") - assert (repr(Timedelta(10, unit='ms')) == - "Timedelta('0 days 00:00:00.010000')") - assert (repr(Timedelta(-10, unit='ms')) == - "Timedelta('-1 days +23:59:59.990000')") +import pytest +from pandas import Timedelta -def test_isoformat(): - td = Timedelta(days=6, minutes=50, seconds=3, - milliseconds=10, microseconds=10, nanoseconds=12) - expected = 'P6DT0H50M3.010010012S' - result = td.isoformat() - assert result == expected - td = Timedelta(days=4, hours=12, minutes=30, seconds=5) - result = td.isoformat() - expected = 'P4DT12H30M5S' - assert result == expected +@pytest.mark.parametrize('td, expected_repr', [ + (Timedelta(10, unit='d'), "Timedelta('10 days 00:00:00')"), + (Timedelta(10, unit='s'), "Timedelta('0 days 00:00:10')"), + (Timedelta(10, unit='ms'), "Timedelta('0 days 00:00:00.010000')"), + (Timedelta(-10, unit='ms'), "Timedelta('-1 days +23:59:59.990000')")]) +def test_repr(td, expected_repr): + assert repr(td) == expected_repr - td = Timedelta(nanoseconds=123) - result = td.isoformat() - expected = 'P0DT0H0M0.000000123S' - assert result == expected +@pytest.mark.parametrize('td, expected_iso', [ + (Timedelta(days=6, minutes=50, seconds=3, milliseconds=10, microseconds=10, + nanoseconds=12), 'P6DT0H50M3.010010012S'), + (Timedelta(days=4, hours=12, minutes=30, seconds=5), 'P4DT12H30M5S'), + (Timedelta(nanoseconds=123), 'P0DT0H0M0.000000123S'), # trim nano - td = Timedelta(microseconds=10) - result = td.isoformat() - expected = 'P0DT0H0M0.00001S' - assert result == expected - + (Timedelta(microseconds=10), 'P0DT0H0M0.00001S'), # trim micro - td = Timedelta(milliseconds=1) - result = td.isoformat() - expected = 'P0DT0H0M0.001S' - assert result == expected - + (Timedelta(milliseconds=1), 'P0DT0H0M0.001S'), # don't strip every 0 - result = Timedelta(minutes=1).isoformat() - expected = 'P0DT0H1M0S' - assert result == expected + (Timedelta(minutes=1), 'P0DT0H1M0S')]) +def test_isoformat(td, expected_iso): + assert td.isoformat() == expected_iso From 268fcf60c73458c25c08418e4b4a91da5e07b69e Mon Sep 17 00:00:00 2001 From: Mason Gallo Date: Thu, 22 Mar 2018 10:31:50 -0400 Subject: [PATCH 20/81] API & BUG: allow list-like y argument to df.plot & fix integer arg to x,y (#20000) * Add support for list-like y argument * update whatsnew * add doc change for y * Add test cases and fix position args * don't copy save cols ahead of time and update whatsnew * address fdbck --- doc/source/whatsnew/v0.23.0.txt | 2 ++ pandas/plotting/_core.py | 35 +++++++++++++++++------- pandas/tests/plotting/test_frame.py | 41 +++++++++++++++++++++++------ 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index 151ab8456c1d7..1d60febe29b4a 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -987,6 +987,7 @@ Plotting ^^^^^^^^ - :func:`DataFrame.plot` now raises a ``ValueError`` when the ``x`` or ``y`` argument is improperly formed (:issue:`18671`) +- Bug in :func:`DataFrame.plot` when ``x`` and ``y`` arguments given as positions caused incorrect referenced columns for line, bar and area plots (:issue:`20056`) - Bug in formatting tick labels with ``datetime.time()`` and fractional seconds (:issue:`18478`). - :meth:`Series.plot.kde` has exposed the args ``ind`` and ``bw_method`` in the docstring (:issue:`18461`). The argument ``ind`` may now also be an integer (number of sample points). @@ -1042,3 +1043,4 @@ Other - Improved error message when attempting to use a Python keyword as an identifier in a ``numexpr`` backed query (:issue:`18221`) - Bug in accessing a :func:`pandas.get_option`, which raised ``KeyError`` rather than ``OptionError`` when looking up a non-existant option key in some cases (:issue:`19789`) +- :func:`DataFrame.plot` now supports multiple columns to the ``y`` argument (:issue:`19699`) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index caac315f5b247..32720b8f1ac56 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -1764,22 +1764,22 @@ def _plot(data, x=None, y=None, subplots=False, plot_obj = klass(data, subplots=subplots, ax=ax, kind=kind, **kwds) else: if isinstance(data, ABCDataFrame): + data_cols = data.columns if x is not None: if is_integer(x) and not data.columns.holds_integer(): - x = data.columns[x] + x = data_cols[x] elif not isinstance(data[x], ABCSeries): raise ValueError("x must be a label or position") data = data.set_index(x) if y is not None: - if is_integer(y) and not data.columns.holds_integer(): - y = data.columns[y] - elif not isinstance(data[y], ABCSeries): - raise ValueError("y must be a label or position") - label = kwds['label'] if 'label' in kwds else y - series = data[y].copy() # Don't modify - series.name = label + # check if we have y as int or list of ints + int_ylist = is_list_like(y) and all(is_integer(c) for c in y) + int_y_arg = is_integer(y) or int_ylist + if int_y_arg and not data.columns.holds_integer(): + y = data_cols[y] + label_kw = kwds['label'] if 'label' in kwds else False for kw in ['xerr', 'yerr']: if (kw in kwds) and \ (isinstance(kwds[kw], string_types) or @@ -1788,7 +1788,22 @@ def _plot(data, x=None, y=None, subplots=False, kwds[kw] = data[kwds[kw]] except (IndexError, KeyError, TypeError): pass - data = series + + # don't overwrite + data = data[y].copy() + + if isinstance(data, ABCSeries): + label_name = label_kw or y + data.name = label_name + else: + match = is_list_like(label_kw) and len(label_kw) == len(y) + if label_kw and not match: + raise ValueError( + "label should be list-like and same length as y" + ) + label_name = label_kw or data.columns + data.columns = label_name + plot_obj = klass(data, subplots=subplots, ax=ax, kind=kind, **kwds) plot_obj.generate() @@ -1801,7 +1816,7 @@ def _plot(data, x=None, y=None, subplots=False, series_kind = "" df_coord = """x : label or position, default None - y : label or position, default None + y : label, position or list of label, positions, default None Allows plotting of one column versus another""" series_coord = "" diff --git a/pandas/tests/plotting/test_frame.py b/pandas/tests/plotting/test_frame.py index 4c1917fc7cefb..3977db048cec5 100644 --- a/pandas/tests/plotting/test_frame.py +++ b/pandas/tests/plotting/test_frame.py @@ -2207,26 +2207,51 @@ def test_invalid_kind(self): with pytest.raises(ValueError): df.plot(kind='aasdf') - @pytest.mark.parametrize("x,y", [ - (['B', 'C'], 'A'), - ('A', ['B', 'C']) + @pytest.mark.parametrize("x,y,lbl", [ + (['B', 'C'], 'A', 'a'), + (['A'], ['B', 'C'], ['b', 'c']), + ('A', ['B', 'C'], 'badlabel') ]) - def test_invalid_xy_args(self, x, y): - # GH 18671 + def test_invalid_xy_args(self, x, y, lbl): + # GH 18671, 19699 allows y to be list-like but not x df = DataFrame({"A": [1, 2], 'B': [3, 4], 'C': [5, 6]}) with pytest.raises(ValueError): - df.plot(x=x, y=y) + df.plot(x=x, y=y, label=lbl) @pytest.mark.parametrize("x,y", [ ('A', 'B'), - ('B', 'A') + (['A'], 'B') ]) def test_invalid_xy_args_dup_cols(self, x, y): - # GH 18671 + # GH 18671, 19699 allows y to be list-like but not x df = DataFrame([[1, 3, 5], [2, 4, 6]], columns=list('AAB')) with pytest.raises(ValueError): df.plot(x=x, y=y) + @pytest.mark.parametrize("x,y,lbl,colors", [ + ('A', ['B'], ['b'], ['red']), + ('A', ['B', 'C'], ['b', 'c'], ['red', 'blue']), + (0, [1, 2], ['bokeh', 'cython'], ['green', 'yellow']) + ]) + def test_y_listlike(self, x, y, lbl, colors): + # GH 19699: tests list-like y and verifies lbls & colors + df = DataFrame({"A": [1, 2], 'B': [3, 4], 'C': [5, 6]}) + _check_plot_works(df.plot, x='A', y=y, label=lbl) + + ax = df.plot(x=x, y=y, label=lbl, color=colors) + assert len(ax.lines) == len(y) + self._check_colors(ax.get_lines(), linecolors=colors) + + @pytest.mark.parametrize("x,y,colnames", [ + (0, 1, ['A', 'B']), + (1, 0, [0, 1]) + ]) + def test_xy_args_integer(self, x, y, colnames): + # GH 20056: tests integer args for xy and checks col names + df = DataFrame({"A": [1, 2], 'B': [3, 4]}) + df.columns = colnames + _check_plot_works(df.plot, x=x, y=y) + @pytest.mark.slow def test_hexbin_basic(self): df = self.hexbin_df From 3a76199421330445d982bf054bc182e04836397e Mon Sep 17 00:00:00 2001 From: William Ayd Date: Thu, 22 Mar 2018 15:48:47 -0700 Subject: [PATCH 21/81] Removed dead groupby code (#20457) --- pandas/core/groupby.py | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/pandas/core/groupby.py b/pandas/core/groupby.py index 4352a001aa989..7b68ad67675ff 100644 --- a/pandas/core/groupby.py +++ b/pandas/core/groupby.py @@ -944,23 +944,6 @@ def _cumcount_array(self, ascending=True): rev[sorter] = np.arange(count, dtype=np.intp) return out[rev].astype(np.int64, copy=False) - def _index_with_as_index(self, b): - """ - Take boolean mask of index to be returned from apply, if as_index=True - - """ - # TODO perf, it feels like this should already be somewhere... - from itertools import chain - original = self._selected_obj.index - gp = self.grouper - levels = chain((gp.levels[i][gp.labels[i][b]] - for i in range(len(gp.groupings))), - (original._get_level_values(i)[b] - for i in range(original.nlevels))) - new = MultiIndex.from_arrays(list(levels)) - new.names = gp.names + original.names - return new - def _try_cast(self, result, obj, numeric_only=False): """ try to cast the result to our obj original type, @@ -2295,18 +2278,6 @@ def size(self): index=self.result_index, dtype='int64') - @cache_readonly - def _max_groupsize(self): - """ - Compute size of largest group - """ - # For many items in each group this is much faster than - # self.size().max(), in worst case marginally slower - if self.indices: - return max(len(v) for v in self.indices.values()) - else: - return 0 - @cache_readonly def groups(self): """ dict {group name -> group labels} """ @@ -2941,9 +2912,6 @@ def __init__(self, index, grouper=None, obj=None, name=None, level=None, if isinstance(grouper, MultiIndex): self.grouper = grouper.values - # pre-computed - self._should_compress = True - # we have a single grouper which may be a myriad of things, # some of which are dependent on the passing in level @@ -4964,10 +4932,6 @@ def _wrap_aggregated_output(self, output, names=None): raise com.AbstractMethodError(self) -class NDArrayGroupBy(GroupBy): - pass - - # ---------------------------------------------------------------------- # Splitting / application @@ -5020,10 +4984,6 @@ def apply(self, f): raise com.AbstractMethodError(self) -class ArraySplitter(DataSplitter): - pass - - class SeriesSplitter(DataSplitter): def _chop(self, sdata, slice_obj): From 189dd8e4a05ebb7b109eea4b97ae56f082fce856 Mon Sep 17 00:00:00 2001 From: Ming Li <14131823+minggli@users.noreply.github.com> Date: Thu, 22 Mar 2018 23:12:06 +0000 Subject: [PATCH 22/81] EHN: allow zip compression in `to_pickle`, `to_json`, `to_csv` (#20394) --- doc/source/whatsnew/v0.23.0.txt | 1 + pandas/conftest.py | 10 ------ pandas/core/frame.py | 6 ++-- pandas/core/generic.py | 7 ++-- pandas/core/series.py | 6 ++-- pandas/io/common.py | 45 +++++++++++++++++------- pandas/io/formats/csvs.py | 14 ++++++-- pandas/io/pickle.py | 6 ++-- pandas/tests/frame/test_to_csv.py | 23 +++--------- pandas/tests/io/json/test_compression.py | 36 +++++++------------ pandas/tests/io/test_pickle.py | 6 ++-- pandas/tests/series/test_io.py | 10 +++--- pandas/util/testing.py | 2 +- 13 files changed, 86 insertions(+), 86 deletions(-) diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index 1d60febe29b4a..9159c03edee2e 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -344,6 +344,7 @@ Other Enhancements - :meth:`DataFrame.to_sql` now performs a multivalue insert if the underlying connection supports itk rather than inserting row by row. ``SQLAlchemy`` dialects supporting multivalue inserts include: ``mysql``, ``postgresql``, ``sqlite`` and any dialect with ``supports_multivalues_insert``. (:issue:`14315`, :issue:`8953`) - :func:`read_html` now accepts a ``displayed_only`` keyword argument to controls whether or not hidden elements are parsed (``True`` by default) (:issue:`20027`) +- zip compression is supported via ``compression=zip`` in :func:`DataFrame.to_pickle`, :func:`Series.to_pickle`, :func:`DataFrame.to_csv`, :func:`Series.to_csv`, :func:`DataFrame.to_json`, :func:`Series.to_json`. (:issue:`17778`) .. _whatsnew_0230.api_breaking: diff --git a/pandas/conftest.py b/pandas/conftest.py index 7a4ef56d7d749..81a039e484cf1 100644 --- a/pandas/conftest.py +++ b/pandas/conftest.py @@ -75,16 +75,6 @@ def compression(request): return request.param -@pytest.fixture(params=[None, 'gzip', 'bz2', - pytest.param('xz', marks=td.skip_if_no_lzma)]) -def compression_no_zip(request): - """ - Fixture for trying common compression types in compression tests - except zip - """ - return request.param - - @pytest.fixture(scope='module') def datetime_tz_utc(): from datetime import timezone diff --git a/pandas/core/frame.py b/pandas/core/frame.py index cf41737a04ba6..57aba8078f3c5 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -1654,9 +1654,9 @@ def to_csv(self, path_or_buf=None, sep=",", na_rep='', float_format=None, A string representing the encoding to use in the output file, defaults to 'ascii' on Python 2 and 'utf-8' on Python 3. compression : string, optional - a string representing the compression to use in the output file, - allowed values are 'gzip', 'bz2', 'xz', - only used when the first argument is a filename + A string representing the compression to use in the output file. + Allowed values are 'gzip', 'bz2', 'zip', 'xz'. This input is only + used when the first argument is a filename. line_terminator : string, default ``'\n'`` The newline character or character sequence to use in the output file diff --git a/pandas/core/generic.py b/pandas/core/generic.py index bd1a2371315a0..a5b67c1105374 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -1814,9 +1814,9 @@ def to_json(self, path_or_buf=None, orient=None, date_format=None, .. versionadded:: 0.19.0 - compression : {None, 'gzip', 'bz2', 'xz'} + compression : {None, 'gzip', 'bz2', 'zip', 'xz'} A string representing the compression to use in the output file, - only used when the first argument is a filename + only used when the first argument is a filename. .. versionadded:: 0.21.0 @@ -2133,7 +2133,8 @@ def to_pickle(self, path, compression='infer', ---------- path : str File path where the pickled object will be stored. - compression : {'infer', 'gzip', 'bz2', 'xz', None}, default 'infer' + compression : {'infer', 'gzip', 'bz2', 'zip', 'xz', None}, \ + default 'infer' A string representing the compression to use in the output file. By default, infers from the file extension in specified path. diff --git a/pandas/core/series.py b/pandas/core/series.py index 3e3600898ba7f..da598259d272d 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -3633,9 +3633,9 @@ def to_csv(self, path=None, index=True, sep=",", na_rep='', a string representing the encoding to use if the contents are non-ascii, for python versions prior to 3 compression : string, optional - a string representing the compression to use in the output file, - allowed values are 'gzip', 'bz2', 'xz', only used when the first - argument is a filename + A string representing the compression to use in the output file. + Allowed values are 'gzip', 'bz2', 'zip', 'xz'. This input is only + used when the first argument is a filename. date_format: string, default None Format string for datetime objects. decimal: string, default '.' diff --git a/pandas/io/common.py b/pandas/io/common.py index e312181f08512..4769edd157b94 100644 --- a/pandas/io/common.py +++ b/pandas/io/common.py @@ -5,6 +5,7 @@ import codecs import mmap from contextlib import contextmanager, closing +from zipfile import ZipFile from pandas.compat import StringIO, BytesIO, string_types, text_type from pandas import compat @@ -363,18 +364,20 @@ def _get_handle(path_or_buf, mode, encoding=None, compression=None, # ZIP Compression elif compression == 'zip': - import zipfile - zip_file = zipfile.ZipFile(path_or_buf) - zip_names = zip_file.namelist() - if len(zip_names) == 1: - f = zip_file.open(zip_names.pop()) - elif len(zip_names) == 0: - raise ValueError('Zero files found in ZIP file {}' - .format(path_or_buf)) - else: - raise ValueError('Multiple files found in ZIP file.' - ' Only one file per ZIP: {}' - .format(zip_names)) + zf = BytesZipFile(path_or_buf, mode) + if zf.mode == 'w': + f = zf + elif zf.mode == 'r': + zip_names = zf.namelist() + if len(zip_names) == 1: + f = zf.open(zip_names.pop()) + elif len(zip_names) == 0: + raise ValueError('Zero files found in ZIP file {}' + .format(path_or_buf)) + else: + raise ValueError('Multiple files found in ZIP file.' + ' Only one file per ZIP: {}' + .format(zip_names)) # XZ Compression elif compression == 'xz': @@ -425,6 +428,24 @@ def _get_handle(path_or_buf, mode, encoding=None, compression=None, return f, handles +class BytesZipFile(ZipFile, BytesIO): + """ + Wrapper for standard library class ZipFile and allow the returned file-like + handle to accept byte strings via `write` method. + + BytesIO provides attributes of file-like object and ZipFile.writestr writes + bytes strings into a member of the archive. + """ + # GH 17778 + def __init__(self, file, mode='r', **kwargs): + if mode in ['wb', 'rb']: + mode = mode.replace('b', '') + super(BytesZipFile, self).__init__(file, mode, **kwargs) + + def write(self, data): + super(BytesZipFile, self).writestr(self.filename, data) + + class MMapWrapper(BaseIterator): """ Wrapper for the Python's mmap class so that it can be properly read in diff --git a/pandas/io/formats/csvs.py b/pandas/io/formats/csvs.py index 4e2021bcba72b..29b8d29af0808 100644 --- a/pandas/io/formats/csvs.py +++ b/pandas/io/formats/csvs.py @@ -133,8 +133,8 @@ def save(self): else: f, handles = _get_handle(self.path_or_buf, self.mode, encoding=encoding, - compression=self.compression) - close = True + compression=None) + close = True if self.compression is None else False try: writer_kwargs = dict(lineterminator=self.line_terminator, @@ -151,6 +151,16 @@ def save(self): self._save() finally: + # GH 17778 handles compression for byte strings. + if not close and self.compression: + f.close() + with open(self.path_or_buf, 'r') as f: + data = f.read() + f, handles = _get_handle(self.path_or_buf, self.mode, + encoding=encoding, + compression=self.compression) + f.write(data) + close = True if close: f.close() diff --git a/pandas/io/pickle.py b/pandas/io/pickle.py index 8c72c315c142c..d27735fbca318 100644 --- a/pandas/io/pickle.py +++ b/pandas/io/pickle.py @@ -18,7 +18,7 @@ def to_pickle(obj, path, compression='infer', protocol=pkl.HIGHEST_PROTOCOL): Any python object. path : str File path where the pickled object will be stored. - compression : {'infer', 'gzip', 'bz2', 'xz', None}, default 'infer' + compression : {'infer', 'gzip', 'bz2', 'zip', 'xz', None}, default 'infer' A string representing the compression to use in the output file. By default, infers from the file extension in specified path. @@ -74,7 +74,7 @@ def to_pickle(obj, path, compression='infer', protocol=pkl.HIGHEST_PROTOCOL): if protocol < 0: protocol = pkl.HIGHEST_PROTOCOL try: - pkl.dump(obj, f, protocol=protocol) + f.write(pkl.dumps(obj, protocol=protocol)) finally: for _f in fh: _f.close() @@ -93,7 +93,7 @@ def read_pickle(path, compression='infer'): ---------- path : str File path where the pickled object will be loaded. - compression : {'infer', 'gzip', 'bz2', 'xz', 'zip', None}, default 'infer' + compression : {'infer', 'gzip', 'bz2', 'zip', 'xz', None}, default 'infer' For on-the-fly decompression of on-disk data. If 'infer', then use gzip, bz2, xz or zip if path ends in '.gz', '.bz2', '.xz', or '.zip' respectively, and no decompression otherwise. diff --git a/pandas/tests/frame/test_to_csv.py b/pandas/tests/frame/test_to_csv.py index dda5cdea52cac..e4829ebf48561 100644 --- a/pandas/tests/frame/test_to_csv.py +++ b/pandas/tests/frame/test_to_csv.py @@ -919,7 +919,7 @@ def test_to_csv_path_is_none(self): recons = pd.read_csv(StringIO(csv_str), index_col=0) assert_frame_equal(self.frame, recons) - def test_to_csv_compression(self, compression_no_zip): + def test_to_csv_compression(self, compression): df = DataFrame([[0.123456, 0.234567, 0.567567], [12.32112, 123123.2, 321321.2]], @@ -927,35 +927,22 @@ def test_to_csv_compression(self, compression_no_zip): with ensure_clean() as filename: - df.to_csv(filename, compression=compression_no_zip) + df.to_csv(filename, compression=compression) # test the round trip - to_csv -> read_csv - rs = read_csv(filename, compression=compression_no_zip, + rs = read_csv(filename, compression=compression, index_col=0) assert_frame_equal(df, rs) # explicitly make sure file is compressed - with tm.decompress_file(filename, compression_no_zip) as fh: + with tm.decompress_file(filename, compression) as fh: text = fh.read().decode('utf8') for col in df.columns: assert col in text - with tm.decompress_file(filename, compression_no_zip) as fh: + with tm.decompress_file(filename, compression) as fh: assert_frame_equal(df, read_csv(fh, index_col=0)) - def test_to_csv_compression_value_error(self): - # GH7615 - # use the compression kw in to_csv - df = DataFrame([[0.123456, 0.234567, 0.567567], - [12.32112, 123123.2, 321321.2]], - index=['A', 'B'], columns=['X', 'Y', 'Z']) - - with ensure_clean() as filename: - # zip compression is not supported and should raise ValueError - import zipfile - pytest.raises(zipfile.BadZipfile, df.to_csv, - filename, compression="zip") - def test_to_csv_date_format(self): with ensure_clean('__tmp_to_csv_date_format__') as path: dt_index = self.tsframe.index diff --git a/pandas/tests/io/json/test_compression.py b/pandas/tests/io/json/test_compression.py index 08335293f9292..c9074ca49e5be 100644 --- a/pandas/tests/io/json/test_compression.py +++ b/pandas/tests/io/json/test_compression.py @@ -5,32 +5,22 @@ from pandas.util.testing import assert_frame_equal, assert_raises_regex -def test_compression_roundtrip(compression_no_zip): +def test_compression_roundtrip(compression): df = pd.DataFrame([[0.123456, 0.234567, 0.567567], [12.32112, 123123.2, 321321.2]], index=['A', 'B'], columns=['X', 'Y', 'Z']) with tm.ensure_clean() as path: - df.to_json(path, compression=compression_no_zip) + df.to_json(path, compression=compression) assert_frame_equal(df, pd.read_json(path, - compression=compression_no_zip)) + compression=compression)) # explicitly ensure file was compressed. - with tm.decompress_file(path, compression_no_zip) as fh: + with tm.decompress_file(path, compression) as fh: result = fh.read().decode('utf8') assert_frame_equal(df, pd.read_json(result)) -def test_compress_zip_value_error(): - df = pd.DataFrame([[0.123456, 0.234567, 0.567567], - [12.32112, 123123.2, 321321.2]], - index=['A', 'B'], columns=['X', 'Y', 'Z']) - - with tm.ensure_clean() as path: - import zipfile - pytest.raises(zipfile.BadZipfile, df.to_json, path, compression="zip") - - def test_read_zipped_json(): uncompressed_path = tm.get_data_path("tsframe_v012.json") uncompressed_df = pd.read_json(uncompressed_path) @@ -41,7 +31,7 @@ def test_read_zipped_json(): assert_frame_equal(uncompressed_df, compressed_df) -def test_with_s3_url(compression_no_zip): +def test_with_s3_url(compression): boto3 = pytest.importorskip('boto3') pytest.importorskip('s3fs') moto = pytest.importorskip('moto') @@ -52,35 +42,35 @@ def test_with_s3_url(compression_no_zip): bucket = conn.create_bucket(Bucket="pandas-test") with tm.ensure_clean() as path: - df.to_json(path, compression=compression_no_zip) + df.to_json(path, compression=compression) with open(path, 'rb') as f: bucket.put_object(Key='test-1', Body=f) roundtripped_df = pd.read_json('s3://pandas-test/test-1', - compression=compression_no_zip) + compression=compression) assert_frame_equal(df, roundtripped_df) -def test_lines_with_compression(compression_no_zip): +def test_lines_with_compression(compression): with tm.ensure_clean() as path: df = pd.read_json('{"a": [1, 2, 3], "b": [4, 5, 6]}') df.to_json(path, orient='records', lines=True, - compression=compression_no_zip) + compression=compression) roundtripped_df = pd.read_json(path, lines=True, - compression=compression_no_zip) + compression=compression) assert_frame_equal(df, roundtripped_df) -def test_chunksize_with_compression(compression_no_zip): +def test_chunksize_with_compression(compression): with tm.ensure_clean() as path: df = pd.read_json('{"a": ["foo", "bar", "baz"], "b": [4, 5, 6]}') df.to_json(path, orient='records', lines=True, - compression=compression_no_zip) + compression=compression) res = pd.read_json(path, lines=True, chunksize=1, - compression=compression_no_zip) + compression=compression) roundtripped_df = pd.concat(res) assert_frame_equal(df, roundtripped_df) diff --git a/pandas/tests/io/test_pickle.py b/pandas/tests/io/test_pickle.py index 2ba3e174404c7..6bc3af2ba3fd2 100644 --- a/pandas/tests/io/test_pickle.py +++ b/pandas/tests/io/test_pickle.py @@ -352,7 +352,7 @@ def compress_file(self, src_path, dest_path, compression): f.write(fh.read()) f.close() - def test_write_explicit(self, compression_no_zip, get_random_path): + def test_write_explicit(self, compression, get_random_path): base = get_random_path path1 = base + ".compressed" path2 = base + ".raw" @@ -361,10 +361,10 @@ def test_write_explicit(self, compression_no_zip, get_random_path): df = tm.makeDataFrame() # write to compressed file - df.to_pickle(p1, compression=compression_no_zip) + df.to_pickle(p1, compression=compression) # decompress - with tm.decompress_file(p1, compression=compression_no_zip) as f: + with tm.decompress_file(p1, compression=compression) as f: with open(p2, "wb") as fh: fh.write(f.read()) diff --git a/pandas/tests/series/test_io.py b/pandas/tests/series/test_io.py index 62d1372525cc8..0b0d4334c86a3 100644 --- a/pandas/tests/series/test_io.py +++ b/pandas/tests/series/test_io.py @@ -138,26 +138,26 @@ def test_to_csv_path_is_none(self): csv_str = s.to_csv(path=None) assert isinstance(csv_str, str) - def test_to_csv_compression(self, compression_no_zip): + def test_to_csv_compression(self, compression): s = Series([0.123456, 0.234567, 0.567567], index=['A', 'B', 'C'], name='X') with ensure_clean() as filename: - s.to_csv(filename, compression=compression_no_zip, header=True) + s.to_csv(filename, compression=compression, header=True) # test the round trip - to_csv -> read_csv - rs = pd.read_csv(filename, compression=compression_no_zip, + rs = pd.read_csv(filename, compression=compression, index_col=0, squeeze=True) assert_series_equal(s, rs) # explicitly ensure file was compressed - with tm.decompress_file(filename, compression_no_zip) as fh: + with tm.decompress_file(filename, compression) as fh: text = fh.read().decode('utf8') assert s.name in text - with tm.decompress_file(filename, compression_no_zip) as fh: + with tm.decompress_file(filename, compression) as fh: assert_series_equal(s, pd.read_csv(fh, index_col=0, squeeze=True)) diff --git a/pandas/util/testing.py b/pandas/util/testing.py index a1e9dcff38ec7..f72c3b061208c 100644 --- a/pandas/util/testing.py +++ b/pandas/util/testing.py @@ -173,7 +173,7 @@ def decompress_file(path, compression): path : str The path where the file is read from - compression : {'gzip', 'bz2', 'xz', None} + compression : {'gzip', 'bz2', 'zip', 'xz', None} Name of the decompression to use Returns From c302b0473ad8cb49a7afbf90ccf01cc0d2f7e84b Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Thu, 22 Mar 2018 18:20:05 -0500 Subject: [PATCH 23/81] ENH: Sorting of ExtensionArrays (#19957) --- pandas/core/arrays/base.py | 52 ++++++++++++++++++ pandas/core/arrays/categorical.py | 53 +++++++++++++------ pandas/tests/extension/base/methods.py | 40 ++++++++++++++ .../extension/category/test_categorical.py | 12 +++++ pandas/tests/extension/conftest.py | 20 +++++++ .../tests/extension/decimal/test_decimal.py | 24 ++++++++- pandas/tests/extension/json/array.py | 11 ++++ pandas/tests/extension/json/test_json.py | 41 +++++++++++++- 8 files changed, 235 insertions(+), 18 deletions(-) diff --git a/pandas/core/arrays/base.py b/pandas/core/arrays/base.py index 55a72585acbe5..d53caa265b9b3 100644 --- a/pandas/core/arrays/base.py +++ b/pandas/core/arrays/base.py @@ -2,6 +2,7 @@ import numpy as np from pandas.errors import AbstractMethodError +from pandas.compat.numpy import function as nv _not_implemented_message = "{} does not implement {}." @@ -236,6 +237,57 @@ def isna(self): """ raise AbstractMethodError(self) + def _values_for_argsort(self): + # type: () -> ndarray + """Return values for sorting. + + Returns + ------- + ndarray + The transformed values should maintain the ordering between values + within the array. + + See Also + -------- + ExtensionArray.argsort + """ + # Note: this is used in `ExtensionArray.argsort`. + return np.array(self) + + def argsort(self, ascending=True, kind='quicksort', *args, **kwargs): + """ + Return the indices that would sort this array. + + Parameters + ---------- + ascending : bool, default True + Whether the indices should result in an ascending + or descending sort. + kind : {'quicksort', 'mergesort', 'heapsort'}, optional + Sorting algorithm. + *args, **kwargs: + passed through to :func:`numpy.argsort`. + + Returns + ------- + index_array : ndarray + Array of indices that sort ``self``. + + See Also + -------- + numpy.argsort : Sorting implementation used internally. + """ + # Implementor note: You have two places to override the behavior of + # argsort. + # 1. _values_for_argsort : construct the values passed to np.argsort + # 2. argsort : total control over sorting. + ascending = nv.validate_argsort_with_ascending(ascending, args, kwargs) + values = self._values_for_argsort() + result = np.argsort(values, kind=kind, **kwargs) + if not ascending: + result = result[::-1] + return result + def fillna(self, value=None, method=None, limit=None): """ Fill NA/NaN values using the specified method. diff --git a/pandas/core/arrays/categorical.py b/pandas/core/arrays/categorical.py index afbf4baf0d002..6eadef37da344 100644 --- a/pandas/core/arrays/categorical.py +++ b/pandas/core/arrays/categorical.py @@ -1431,17 +1431,24 @@ def check_for_ordered(self, op): "you can use .as_ordered() to change the " "Categorical to an ordered one\n".format(op=op)) - def argsort(self, ascending=True, kind='quicksort', *args, **kwargs): - """ - Returns the indices that would sort the Categorical instance if - 'sort_values' was called. This function is implemented to provide - compatibility with numpy ndarray objects. + def _values_for_argsort(self): + return self._codes.copy() - While an ordering is applied to the category values, arg-sorting - in this context refers more to organizing and grouping together - based on matching category values. Thus, this function can be - called on an unordered Categorical instance unlike the functions - 'Categorical.min' and 'Categorical.max'. + def argsort(self, *args, **kwargs): + # TODO(PY2): use correct signature + # We have to do *args, **kwargs to avoid a a py2-only signature + # issue since np.argsort differs from argsort. + """Return the indicies that would sort the Categorical. + + Parameters + ---------- + ascending : bool, default True + Whether the indices should result in an ascending + or descending sort. + kind : {'quicksort', 'mergesort', 'heapsort'}, optional + Sorting algorithm. + *args, **kwargs: + passed through to :func:`numpy.argsort`. Returns ------- @@ -1450,12 +1457,28 @@ def argsort(self, ascending=True, kind='quicksort', *args, **kwargs): See also -------- numpy.ndarray.argsort + + Notes + ----- + While an ordering is applied to the category values, arg-sorting + in this context refers more to organizing and grouping together + based on matching category values. Thus, this function can be + called on an unordered Categorical instance unlike the functions + 'Categorical.min' and 'Categorical.max'. + + Examples + -------- + >>> pd.Categorical(['b', 'b', 'a', 'c']).argsort() + array([2, 0, 1, 3]) + + >>> cat = pd.Categorical(['b', 'b', 'a', 'c'], + ... categories=['c', 'b', 'a'], + ... ordered=True) + >>> cat.argsort() + array([3, 0, 1, 2]) """ - ascending = nv.validate_argsort_with_ascending(ascending, args, kwargs) - result = np.argsort(self._codes.copy(), kind=kind, **kwargs) - if not ascending: - result = result[::-1] - return result + # Keep the implementation here just for the docstring. + return super(Categorical, self).argsort(*args, **kwargs) def sort_values(self, inplace=False, ascending=True, na_position='last'): """ Sorts the Categorical by category value returning a new diff --git a/pandas/tests/extension/base/methods.py b/pandas/tests/extension/base/methods.py index 7ce80e25d8cf6..4d467d62d0a56 100644 --- a/pandas/tests/extension/base/methods.py +++ b/pandas/tests/extension/base/methods.py @@ -32,6 +32,46 @@ def test_apply_simple_series(self, data): result = pd.Series(data).apply(id) assert isinstance(result, pd.Series) + def test_argsort(self, data_for_sorting): + result = pd.Series(data_for_sorting).argsort() + expected = pd.Series(np.array([2, 0, 1], dtype=np.int64)) + self.assert_series_equal(result, expected) + + def test_argsort_missing(self, data_missing_for_sorting): + result = pd.Series(data_missing_for_sorting).argsort() + expected = pd.Series(np.array([1, -1, 0], dtype=np.int64)) + self.assert_series_equal(result, expected) + + @pytest.mark.parametrize('ascending', [True, False]) + def test_sort_values(self, data_for_sorting, ascending): + ser = pd.Series(data_for_sorting) + result = ser.sort_values(ascending=ascending) + expected = ser.iloc[[2, 0, 1]] + if not ascending: + expected = expected[::-1] + + self.assert_series_equal(result, expected) + + @pytest.mark.parametrize('ascending', [True, False]) + def test_sort_values_missing(self, data_missing_for_sorting, ascending): + ser = pd.Series(data_missing_for_sorting) + result = ser.sort_values(ascending=ascending) + if ascending: + expected = ser.iloc[[2, 0, 1]] + else: + expected = ser.iloc[[0, 2, 1]] + self.assert_series_equal(result, expected) + + @pytest.mark.parametrize('ascending', [True, False]) + def test_sort_values_frame(self, data_for_sorting, ascending): + df = pd.DataFrame({"A": [1, 2, 1], + "B": data_for_sorting}) + result = df.sort_values(['A', 'B']) + expected = pd.DataFrame({"A": [1, 1, 2], + 'B': data_for_sorting.take([2, 0, 1])}, + index=[2, 0, 1]) + self.assert_frame_equal(result, expected) + @pytest.mark.parametrize('box', [pd.Series, lambda x: x]) @pytest.mark.parametrize('method', [lambda x: x.unique(), pd.unique]) def test_unique(self, data, box, method): diff --git a/pandas/tests/extension/category/test_categorical.py b/pandas/tests/extension/category/test_categorical.py index b6dd181c1d8f3..b602d9ee78e2a 100644 --- a/pandas/tests/extension/category/test_categorical.py +++ b/pandas/tests/extension/category/test_categorical.py @@ -29,6 +29,18 @@ def data_missing(): return Categorical([np.nan, 'A']) +@pytest.fixture +def data_for_sorting(): + return Categorical(['A', 'B', 'C'], categories=['C', 'A', 'B'], + ordered=True) + + +@pytest.fixture +def data_missing_for_sorting(): + return Categorical(['A', None, 'B'], categories=['B', 'A'], + ordered=True) + + @pytest.fixture def na_value(): return np.nan diff --git a/pandas/tests/extension/conftest.py b/pandas/tests/extension/conftest.py index 21ed8894e8ebb..04dfb408fc378 100644 --- a/pandas/tests/extension/conftest.py +++ b/pandas/tests/extension/conftest.py @@ -30,6 +30,26 @@ def all_data(request, data, data_missing): return data_missing +@pytest.fixture +def data_for_sorting(): + """Length-3 array with a known sort order. + + This should be three items [B, C, A] with + A < B < C + """ + raise NotImplementedError + + +@pytest.fixture +def data_missing_for_sorting(): + """Length-3 array with a known sort order. + + This should be three items [B, NA, A] with + A < B and NA missing. + """ + raise NotImplementedError + + @pytest.fixture def na_cmp(): """Binary operator for comparing NA values. diff --git a/pandas/tests/extension/decimal/test_decimal.py b/pandas/tests/extension/decimal/test_decimal.py index 4c6ef9b4d38c8..7d959ea4fcd84 100644 --- a/pandas/tests/extension/decimal/test_decimal.py +++ b/pandas/tests/extension/decimal/test_decimal.py @@ -25,6 +25,20 @@ def data_missing(): return DecimalArray([decimal.Decimal('NaN'), decimal.Decimal(1)]) +@pytest.fixture +def data_for_sorting(): + return DecimalArray([decimal.Decimal('1'), + decimal.Decimal('2'), + decimal.Decimal('0')]) + + +@pytest.fixture +def data_missing_for_sorting(): + return DecimalArray([decimal.Decimal('1'), + decimal.Decimal('NaN'), + decimal.Decimal('0')]) + + @pytest.fixture def na_cmp(): return lambda x, y: x.is_nan() and y.is_nan() @@ -48,11 +62,17 @@ def assert_series_equal(self, left, right, *args, **kwargs): *args, **kwargs) def assert_frame_equal(self, left, right, *args, **kwargs): - self.assert_series_equal(left.dtypes, right.dtypes) - for col in left.columns: + # TODO(EA): select_dtypes + decimals = (left.dtypes == 'decimal').index + + for col in decimals: self.assert_series_equal(left[col], right[col], *args, **kwargs) + left = left.drop(columns=decimals) + right = right.drop(columns=decimals) + tm.assert_frame_equal(left, right, *args, **kwargs) + class TestDtype(BaseDecimal, base.BaseDtypeTests): pass diff --git a/pandas/tests/extension/json/array.py b/pandas/tests/extension/json/array.py index 322944129146a..ee0951812b8f0 100644 --- a/pandas/tests/extension/json/array.py +++ b/pandas/tests/extension/json/array.py @@ -44,7 +44,11 @@ def __getitem__(self, item): return self._constructor_from_sequence([ x for x, m in zip(self, item) if m ]) + elif isinstance(item, collections.Iterable): + # fancy indexing + return type(self)([self.data[i] for i in item]) else: + # slice return type(self)(self.data[item]) def __setitem__(self, key, value): @@ -104,6 +108,13 @@ def _concat_same_type(cls, to_concat): data = list(itertools.chain.from_iterable([x.data for x in to_concat])) return cls(data) + def _values_for_argsort(self): + # Disable NumPy's shape inference by including an empty tuple... + # If all the elemnts of self are the same size P, NumPy will + # cast them to an (N, P) array, instead of an (N,) array of tuples. + frozen = [()] + list(tuple(x.items()) for x in self) + return np.array(frozen, dtype=object)[1:] + def make_data(): # TODO: Use a regular dict. See _NDFrameIndexer._setitem_with_indexer diff --git a/pandas/tests/extension/json/test_json.py b/pandas/tests/extension/json/test_json.py index 16d5e4415a79f..aec561ece8573 100644 --- a/pandas/tests/extension/json/test_json.py +++ b/pandas/tests/extension/json/test_json.py @@ -29,6 +29,16 @@ def data_missing(): return JSONArray([{}, {'a': 10}]) +@pytest.fixture +def data_for_sorting(): + return JSONArray([{'b': 1}, {'c': 4}, {'a': 2, 'c': 3}]) + + +@pytest.fixture +def data_missing_for_sorting(): + return JSONArray([{'b': 1}, {}, {'a': 4}]) + + @pytest.fixture def na_value(): return {} @@ -70,10 +80,39 @@ def test_fillna_frame(self): class TestMethods(base.BaseMethodsTests): - @pytest.mark.skip(reason="Unhashable") + unhashable = pytest.mark.skip(reason="Unhashable") + unstable = pytest.mark.skipif(sys.version_info <= (3, 5), + reason="Dictionary order unstable") + + @unhashable def test_value_counts(self, all_data, dropna): pass + @unhashable + def test_sort_values_frame(self): + # TODO (EA.factorize): see if _values_for_factorize allows this. + pass + + @unstable + def test_argsort(self, data_for_sorting): + super(TestMethods, self).test_argsort(data_for_sorting) + + @unstable + def test_argsort_missing(self, data_missing_for_sorting): + super(TestMethods, self).test_argsort_missing( + data_missing_for_sorting) + + @unstable + @pytest.mark.parametrize('ascending', [True, False]) + def test_sort_values(self, data_for_sorting, ascending): + super(TestMethods, self).test_sort_values( + data_for_sorting, ascending) + + @pytest.mark.parametrize('ascending', [True, False]) + def test_sort_values_missing(self, data_missing_for_sorting, ascending): + super(TestMethods, self).test_sort_values_missing( + data_missing_for_sorting, ascending) + class TestCasting(base.BaseCastingTests): pass From faefc89d278bc775f9078eb3c8e4e0b1d19b358e Mon Sep 17 00:00:00 2001 From: Igor Conrado Alves de Lima Date: Fri, 23 Mar 2018 05:36:08 -0300 Subject: [PATCH 24/81] DOC: Improve the docstring of DataFrame.transpose() (#20254) * DOC: Improve the docstring of DataFrame.transpose() Co-authored-by: Carlos Eduardo Moreira dos Santos --- pandas/core/frame.py | 95 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 57aba8078f3c5..8ff2b6c85eeed 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -2289,7 +2289,100 @@ def memory_usage(self, index=True, deep=False): return result def transpose(self, *args, **kwargs): - """Transpose index and columns""" + """ + Transpose index and columns. + + Reflect the DataFrame over its main diagonal by writing rows as columns + and vice-versa. The property :attr:`.T` is an accessor to the method + :meth:`transpose`. + + Parameters + ---------- + copy : bool, default False + If True, the underlying data is copied. Otherwise (default), no + copy is made if possible. + *args, **kwargs + Additional keywords have no effect but might be accepted for + compatibility with numpy. + + Returns + ------- + DataFrame + The transposed DataFrame. + + See Also + -------- + numpy.transpose : Permute the dimensions of a given array. + + Notes + ----- + Transposing a DataFrame with mixed dtypes will result in a homogeneous + DataFrame with the `object` dtype. In such a case, a copy of the data + is always made. + + Examples + -------- + **Square DataFrame with homogeneous dtype** + + >>> d1 = {'col1': [1, 2], 'col2': [3, 4]} + >>> df1 = pd.DataFrame(data=d1) + >>> df1 + col1 col2 + 0 1 3 + 1 2 4 + + >>> df1_transposed = df1.T # or df1.transpose() + >>> df1_transposed + 0 1 + col1 1 2 + col2 3 4 + + When the dtype is homogeneous in the original DataFrame, we get a + transposed DataFrame with the same dtype: + + >>> df1.dtypes + col1 int64 + col2 int64 + dtype: object + >>> df1_transposed.dtypes + 0 int64 + 1 int64 + dtype: object + + **Non-square DataFrame with mixed dtypes** + + >>> d2 = {'name': ['Alice', 'Bob'], + ... 'score': [9.5, 8], + ... 'employed': [False, True], + ... 'kids': [0, 0]} + >>> df2 = pd.DataFrame(data=d2) + >>> df2 + name score employed kids + 0 Alice 9.5 False 0 + 1 Bob 8.0 True 0 + + >>> df2_transposed = df2.T # or df2.transpose() + >>> df2_transposed + 0 1 + name Alice Bob + score 9.5 8 + employed False True + kids 0 0 + + When the DataFrame has mixed dtypes, we get a transposed DataFrame with + the `object` dtype: + + >>> df2.dtypes + name object + score float64 + employed bool + kids int64 + dtype: object + >>> df2_transposed.dtypes + 0 object + 1 object + dtype: object + """ nv.validate_transpose(args, dict()) return super(DataFrame, self).transpose(1, 0, **kwargs) From 4a77c9622b94bed348231cdad16665a415a81b91 Mon Sep 17 00:00:00 2001 From: jen w Date: Fri, 23 Mar 2018 03:52:04 -0500 Subject: [PATCH 25/81] DOC: Update pandas.Series.copy docstring (#20261) --- pandas/core/generic.py | 104 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 9 deletions(-) diff --git a/pandas/core/generic.py b/pandas/core/generic.py index a5b67c1105374..fc6eda0290c28 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -5005,22 +5005,108 @@ def astype(self, dtype, copy=True, errors='raise', **kwargs): def copy(self, deep=True): """ - Make a copy of this objects data. + Make a copy of this object's indices and data. + + When ``deep=True`` (default), a new object will be created with a + copy of the calling object's data and indices. Modifications to + the data or indices of the copy will not be reflected in the + original object (see notes below). + + When ``deep=False``, a new object will be created without copying + the calling object's data or index (only references to the data + and index are copied). Any changes to the data of the original + will be reflected in the shallow copy (and vice versa). Parameters ---------- - deep : boolean or string, default True + deep : bool, default True Make a deep copy, including a copy of the data and the indices. - With ``deep=False`` neither the indices or the data are copied. - - Note that when ``deep=True`` data is copied, actual python objects - will not be copied recursively, only the reference to the object. - This is in contrast to ``copy.deepcopy`` in the Standard Library, - which recursively copies object data. + With ``deep=False`` neither the indices nor the data are copied. Returns ------- - copy : type of caller + copy : Series, DataFrame or Panel + Object type matches caller. + + Notes + ----- + When ``deep=True``, data is copied but actual Python objects + will not be copied recursively, only the reference to the object. + This is in contrast to `copy.deepcopy` in the Standard Library, + which recursively copies object data (see examples below). + + While ``Index`` objects are copied when ``deep=True``, the underlying + numpy array is not copied for performance reasons. Since ``Index`` is + immutable, the underlying data can be safely shared and a copy + is not needed. + + Examples + -------- + >>> s = pd.Series([1, 2], index=["a", "b"]) + >>> s + a 1 + b 2 + dtype: int64 + + >>> s_copy = s.copy() + >>> s_copy + a 1 + b 2 + dtype: int64 + + **Shallow copy versus default (deep) copy:** + + >>> s = pd.Series([1, 2], index=["a", "b"]) + >>> deep = s.copy() + >>> shallow = s.copy(deep=False) + + Shallow copy shares data and index with original. + + >>> s is shallow + False + >>> s.values is shallow.values and s.index is shallow.index + True + + Deep copy has own copy of data and index. + + >>> s is deep + False + >>> s.values is deep.values or s.index is deep.index + False + + Updates to the data shared by shallow copy and original is reflected + in both; deep copy remains unchanged. + + >>> s[0] = 3 + >>> shallow[1] = 4 + >>> s + a 3 + b 4 + dtype: int64 + >>> shallow + a 3 + b 4 + dtype: int64 + >>> deep + a 1 + b 2 + dtype: int64 + + Note that when copying an object containing Python objects, a deep copy + will copy the data, but will not do so recursively. Updating a nested + data object will be reflected in the deep copy. + + >>> s = pd.Series([[1, 2], [3, 4]]) + >>> deep = s.copy() + >>> s[0][0] = 10 + >>> s + 0 [10, 2] + 1 [3, 4] + dtype: object + >>> deep + 0 [10, 2] + 1 [3, 4] + dtype: object """ data = self._data.copy(deep=deep) return self._constructor(data).__finalize__(self) From e03b4b8151a0e9c378634f68610dae4193d60165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Garc=C3=ADa=20M=C3=A1rquez?= <839653+Lkxz@users.noreply.github.com> Date: Fri, 23 Mar 2018 10:04:32 +0100 Subject: [PATCH 26/81] DOC: update the pandas.DataFrame.plot.box docstring (#20373) --- pandas/plotting/_core.py | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 32720b8f1ac56..6e4ab0137a376 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -3060,19 +3060,51 @@ def barh(self, x=None, y=None, **kwds): def box(self, by=None, **kwds): r""" - Boxplot + Make a box plot of the DataFrame columns. + + A box plot is a method for graphically depicting groups of numerical + data through their quartiles. + The box extends from the Q1 to Q3 quartile values of the data, + with a line at the median (Q2). The whiskers extend from the edges + of box to show the range of the data. The position of the whiskers + is set by default to 1.5*IQR (IQR = Q3 - Q1) from the edges of the + box. Outlier points are those past the end of the whiskers. + + For further details see Wikipedia's + entry for `boxplot `__. + + A consideration when using this chart is that the box and the whiskers + can overlap, which is very common when plotting small sets of data. Parameters ---------- by : string or sequence Column in the DataFrame to group by. - `**kwds` : optional - Additional keyword arguments are documented in + **kwds : optional + Additional keywords are documented in :meth:`pandas.DataFrame.plot`. Returns ------- axes : :class:`matplotlib.axes.Axes` or numpy.ndarray of them + + See Also + -------- + pandas.DataFrame.boxplot: Another method to draw a box plot. + pandas.Series.plot.box: Draw a box plot from a Series object. + matplotlib.pyplot.boxplot: Draw a box plot in matplotlib. + + Examples + -------- + Draw a box plot from a DataFrame with four columns of randomly + generated data. + + .. plot:: + :context: close-figs + + >>> data = np.random.randn(25, 4) + >>> df = pd.DataFrame(data, columns=list('ABCD')) + >>> ax = df.plot.box() """ return self(kind='box', by=by, **kwds) From 5ffb078ff0949946281c75e4504a6ff8e0bd3fce Mon Sep 17 00:00:00 2001 From: Imanflow Date: Fri, 23 Mar 2018 10:24:13 +0100 Subject: [PATCH 27/81] DOC: make deprecation warning more visible with red box (#20357) --- doc/source/themes/nature_with_gtoc/static/nature.css_t | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/doc/source/themes/nature_with_gtoc/static/nature.css_t b/doc/source/themes/nature_with_gtoc/static/nature.css_t index b61068ee28bef..4571d97ec50ba 100644 --- a/doc/source/themes/nature_with_gtoc/static/nature.css_t +++ b/doc/source/themes/nature_with_gtoc/static/nature.css_t @@ -198,10 +198,18 @@ div.body p, div.body dd, div.body li { line-height: 1.5em; } -div.admonition p.admonition-title + p { +div.admonition p.admonition-title + p, div.deprecated p { display: inline; } +div.deprecated { + margin-bottom: 10px; + margin-top: 10px; + padding: 7px; + background-color: #ffe4e4; + border: 1px solid #f66; +} + div.highlight{ background-color: white; } From 689b3febb78c0815d7444ea6a3c2bf4276f74015 Mon Sep 17 00:00:00 2001 From: Jeff Reback Date: Fri, 23 Mar 2018 10:48:19 -0400 Subject: [PATCH 28/81] TST: clean deprecation warnings & some parametrizing (#20467) --- pandas/core/frame.py | 2 +- pandas/tests/test_base.py | 154 +++++++++++++++++++------------------- 2 files changed, 78 insertions(+), 78 deletions(-) diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 8ff2b6c85eeed..9b09c87689762 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -2153,7 +2153,7 @@ def _verbose_repr(): lines.append(_put_str(col, space) + tmpl % (count, dtype)) def _non_verbose_repr(): - lines.append(self.columns.summary(name='Columns')) + lines.append(self.columns._summary(name='Columns')) def _sizeof_fmt(num, size_qualifier): # returns size in human readable format diff --git a/pandas/tests/test_base.py b/pandas/tests/test_base.py index 9f7b06ed2d61c..c4c02c0bf6f17 100644 --- a/pandas/tests/test_base.py +++ b/pandas/tests/test_base.py @@ -647,83 +647,83 @@ def test_value_counts_bins(self): assert s.nunique() == 0 - def test_value_counts_datetime64(self): - klasses = [Index, Series] - for klass in klasses: - # GH 3002, datetime64[ns] - # don't test names though - txt = "\n".join(['xxyyzz20100101PIE', 'xxyyzz20100101GUM', - 'xxyyzz20100101EGG', 'xxyyww20090101EGG', - 'foofoo20080909PIE', 'foofoo20080909GUM']) - f = StringIO(txt) - df = pd.read_fwf(f, widths=[6, 8, 3], - names=["person_id", "dt", "food"], - parse_dates=["dt"]) - - s = klass(df['dt'].copy()) - s.name = None - - idx = pd.to_datetime(['2010-01-01 00:00:00Z', - '2008-09-09 00:00:00Z', - '2009-01-01 00:00:00X']) - expected_s = Series([3, 2, 1], index=idx) - tm.assert_series_equal(s.value_counts(), expected_s) - - expected = np_array_datetime64_compat(['2010-01-01 00:00:00Z', - '2009-01-01 00:00:00Z', - '2008-09-09 00:00:00Z'], - dtype='datetime64[ns]') - if isinstance(s, Index): - tm.assert_index_equal(s.unique(), DatetimeIndex(expected)) - else: - tm.assert_numpy_array_equal(s.unique(), expected) - - assert s.nunique() == 3 - - # with NaT - s = df['dt'].copy() - s = klass([v for v in s.values] + [pd.NaT]) - - result = s.value_counts() - assert result.index.dtype == 'datetime64[ns]' - tm.assert_series_equal(result, expected_s) - - result = s.value_counts(dropna=False) - expected_s[pd.NaT] = 1 - tm.assert_series_equal(result, expected_s) - - unique = s.unique() - assert unique.dtype == 'datetime64[ns]' - - # numpy_array_equal cannot compare pd.NaT - if isinstance(s, Index): - exp_idx = DatetimeIndex(expected.tolist() + [pd.NaT]) - tm.assert_index_equal(unique, exp_idx) - else: - tm.assert_numpy_array_equal(unique[:3], expected) - assert pd.isna(unique[3]) - - assert s.nunique() == 3 - assert s.nunique(dropna=False) == 4 - - # timedelta64[ns] - td = df.dt - df.dt + timedelta(1) - td = klass(td, name='dt') - - result = td.value_counts() - expected_s = Series([6], index=[Timedelta('1day')], name='dt') - tm.assert_series_equal(result, expected_s) - - expected = TimedeltaIndex(['1 days'], name='dt') - if isinstance(td, Index): - tm.assert_index_equal(td.unique(), expected) - else: - tm.assert_numpy_array_equal(td.unique(), expected.values) - - td2 = timedelta(1) + (df.dt - df.dt) - td2 = klass(td2, name='dt') - result2 = td2.value_counts() - tm.assert_series_equal(result2, expected_s) + @pytest.mark.parametrize('klass', [Index, Series]) + def test_value_counts_datetime64(self, klass): + + # GH 3002, datetime64[ns] + # don't test names though + txt = "\n".join(['xxyyzz20100101PIE', 'xxyyzz20100101GUM', + 'xxyyzz20100101EGG', 'xxyyww20090101EGG', + 'foofoo20080909PIE', 'foofoo20080909GUM']) + f = StringIO(txt) + df = pd.read_fwf(f, widths=[6, 8, 3], + names=["person_id", "dt", "food"], + parse_dates=["dt"]) + + s = klass(df['dt'].copy()) + s.name = None + + idx = pd.to_datetime(['2010-01-01 00:00:00Z', + '2008-09-09 00:00:00Z', + '2009-01-01 00:00:00Z']) + expected_s = Series([3, 2, 1], index=idx) + tm.assert_series_equal(s.value_counts(), expected_s) + + expected = np_array_datetime64_compat(['2010-01-01 00:00:00Z', + '2009-01-01 00:00:00Z', + '2008-09-09 00:00:00Z'], + dtype='datetime64[ns]') + if isinstance(s, Index): + tm.assert_index_equal(s.unique(), DatetimeIndex(expected)) + else: + tm.assert_numpy_array_equal(s.unique(), expected) + + assert s.nunique() == 3 + + # with NaT + s = df['dt'].copy() + s = klass([v for v in s.values] + [pd.NaT]) + + result = s.value_counts() + assert result.index.dtype == 'datetime64[ns]' + tm.assert_series_equal(result, expected_s) + + result = s.value_counts(dropna=False) + expected_s[pd.NaT] = 1 + tm.assert_series_equal(result, expected_s) + + unique = s.unique() + assert unique.dtype == 'datetime64[ns]' + + # numpy_array_equal cannot compare pd.NaT + if isinstance(s, Index): + exp_idx = DatetimeIndex(expected.tolist() + [pd.NaT]) + tm.assert_index_equal(unique, exp_idx) + else: + tm.assert_numpy_array_equal(unique[:3], expected) + assert pd.isna(unique[3]) + + assert s.nunique() == 3 + assert s.nunique(dropna=False) == 4 + + # timedelta64[ns] + td = df.dt - df.dt + timedelta(1) + td = klass(td, name='dt') + + result = td.value_counts() + expected_s = Series([6], index=[Timedelta('1day')], name='dt') + tm.assert_series_equal(result, expected_s) + + expected = TimedeltaIndex(['1 days'], name='dt') + if isinstance(td, Index): + tm.assert_index_equal(td.unique(), expected) + else: + tm.assert_numpy_array_equal(td.unique(), expected.values) + + td2 = timedelta(1) + (df.dt - df.dt) + td2 = klass(td2, name='dt') + result2 = td2.value_counts() + tm.assert_series_equal(result2, expected_s) def test_factorize(self): for orig in self.objs: From 7f97c13cfcb0bee0bc9f53e1b070be37a820db2f Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Fri, 23 Mar 2018 14:24:38 -0500 Subject: [PATCH 29/81] REF: Mock all S3 Tests (#20409) * REF: Mock all S3 Tests Closes https://github.com/pandas-dev/pandas/issues/19825 --- asv_bench/benchmarks/io/csv.py | 32 ----------------------- pandas/tests/io/parser/test_network.py | 35 ++++++++++++++++++-------- 2 files changed, 24 insertions(+), 43 deletions(-) diff --git a/asv_bench/benchmarks/io/csv.py b/asv_bench/benchmarks/io/csv.py index 3b7fdc6e2d78c..0f5d07f9fac55 100644 --- a/asv_bench/benchmarks/io/csv.py +++ b/asv_bench/benchmarks/io/csv.py @@ -118,38 +118,6 @@ def time_read_uint64_na_values(self): na_values=self.na_values) -class S3(object): - # Make sure that we can read part of a file from S3 without - # needing to download the entire thing. Use the timeit.default_timer - # to measure wall time instead of CPU time -- we want to see - # how long it takes to download the data. - timer = timeit.default_timer - params = ([None, "gzip", "bz2"], ["python", "c"]) - param_names = ["compression", "engine"] - - def setup(self, compression, engine): - if compression == "bz2" and engine == "c" and PY2: - # The Python 2 C parser can't read bz2 from open files. - raise NotImplementedError - try: - import s3fs # noqa - except ImportError: - # Skip these benchmarks if `boto` is not installed. - raise NotImplementedError - - ext = "" - if compression == "gzip": - ext = ".gz" - elif compression == "bz2": - ext = ".bz2" - self.big_fname = "s3://pandas-test/large_random.csv" + ext - - def time_read_csv_10_rows(self, compression, engine): - # Read a small number of rows from a huge (100,000 x 50) table. - read_csv(self.big_fname, nrows=10, compression=compression, - engine=engine) - - class ReadCSVThousands(BaseIO): goal_time = 0.2 diff --git a/pandas/tests/io/parser/test_network.py b/pandas/tests/io/parser/test_network.py index f16338fda6245..fdf45f307e953 100644 --- a/pandas/tests/io/parser/test_network.py +++ b/pandas/tests/io/parser/test_network.py @@ -4,13 +4,16 @@ Tests parsers ability to read and parse non-local files and hence require a network connection to be read. """ +import logging + import pytest +import numpy as np import pandas.util.testing as tm import pandas.util._test_decorators as td from pandas import DataFrame from pandas.io.parsers import read_csv, read_table -from pandas.compat import BytesIO +from pandas.compat import BytesIO, StringIO @pytest.mark.network @@ -45,9 +48,9 @@ def check_compressed_urls(salaries_table, compression, extension, mode, tm.assert_frame_equal(url_table, salaries_table) +@pytest.mark.usefixtures("s3_resource") class TestS3(object): - @tm.network def test_parse_public_s3_bucket(self): pytest.importorskip('s3fs') # more of an integration test due to the not-public contents portion @@ -66,7 +69,6 @@ def test_parse_public_s3_bucket(self): assert not df.empty tm.assert_frame_equal(read_csv(tm.get_data_path('tips.csv')), df) - @tm.network def test_parse_public_s3n_bucket(self): # Read from AWS s3 as "s3n" URL @@ -76,7 +78,6 @@ def test_parse_public_s3n_bucket(self): tm.assert_frame_equal(read_csv( tm.get_data_path('tips.csv')).iloc[:10], df) - @tm.network def test_parse_public_s3a_bucket(self): # Read from AWS s3 as "s3a" URL df = read_csv('s3a://pandas-test/tips.csv', nrows=10) @@ -85,7 +86,6 @@ def test_parse_public_s3a_bucket(self): tm.assert_frame_equal(read_csv( tm.get_data_path('tips.csv')).iloc[:10], df) - @tm.network def test_parse_public_s3_bucket_nrows(self): for ext, comp in [('', None), ('.gz', 'gzip'), ('.bz2', 'bz2')]: df = read_csv('s3://pandas-test/tips.csv' + @@ -95,7 +95,6 @@ def test_parse_public_s3_bucket_nrows(self): tm.assert_frame_equal(read_csv( tm.get_data_path('tips.csv')).iloc[:10], df) - @tm.network def test_parse_public_s3_bucket_chunked(self): # Read with a chunksize chunksize = 5 @@ -114,7 +113,6 @@ def test_parse_public_s3_bucket_chunked(self): chunksize * i_chunk: chunksize * (i_chunk + 1)] tm.assert_frame_equal(true_df, df) - @tm.network def test_parse_public_s3_bucket_chunked_python(self): # Read with a chunksize using the Python parser chunksize = 5 @@ -133,7 +131,6 @@ def test_parse_public_s3_bucket_chunked_python(self): chunksize * i_chunk: chunksize * (i_chunk + 1)] tm.assert_frame_equal(true_df, df) - @tm.network def test_parse_public_s3_bucket_python(self): for ext, comp in [('', None), ('.gz', 'gzip'), ('.bz2', 'bz2')]: df = read_csv('s3://pandas-test/tips.csv' + ext, engine='python', @@ -143,7 +140,6 @@ def test_parse_public_s3_bucket_python(self): tm.assert_frame_equal(read_csv( tm.get_data_path('tips.csv')), df) - @tm.network def test_infer_s3_compression(self): for ext in ['', '.gz', '.bz2']: df = read_csv('s3://pandas-test/tips.csv' + ext, @@ -153,7 +149,6 @@ def test_infer_s3_compression(self): tm.assert_frame_equal(read_csv( tm.get_data_path('tips.csv')), df) - @tm.network def test_parse_public_s3_bucket_nrows_python(self): for ext, comp in [('', None), ('.gz', 'gzip'), ('.bz2', 'bz2')]: df = read_csv('s3://pandas-test/tips.csv' + ext, engine='python', @@ -163,7 +158,6 @@ def test_parse_public_s3_bucket_nrows_python(self): tm.assert_frame_equal(read_csv( tm.get_data_path('tips.csv')).iloc[:10], df) - @tm.network def test_s3_fails(self): with pytest.raises(IOError): read_csv('s3://nyqpug/asdf.csv') @@ -188,3 +182,22 @@ def test_read_csv_handles_boto_s3_object(self, expected = read_csv(tips_file) tm.assert_frame_equal(result, expected) + + def test_read_csv_chunked_download(self, s3_resource, caplog): + # 8 MB, S3FS usees 5MB chunks + df = DataFrame(np.random.randn(100000, 4), columns=list('abcd')) + buf = BytesIO() + str_buf = StringIO() + + df.to_csv(str_buf) + + buf = BytesIO(str_buf.getvalue().encode('utf-8')) + + s3_resource.Bucket("pandas-test").put_object( + Key="large-file.csv", + Body=buf) + + with caplog.at_level(logging.DEBUG, logger='s3fs.core'): + read_csv("s3://pandas-test/large-file.csv", nrows=5) + # log of fetch_range (start, stop) + assert ((0, 5505024) in set(x.args[-2:] for x in caplog.records)) From 9dd5111bbda8c7c11ec50ce0ca682a2b69d77da3 Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Fri, 23 Mar 2018 20:14:10 -0500 Subject: [PATCH 30/81] TST: Fixed version comparison (#20469) * TST: Fixed version comparison This failed to skip for 3.5.x because the micro component made it False. * Use pandas.compat * More pandas compat --- pandas/tests/extension/json/test_json.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pandas/tests/extension/json/test_json.py b/pandas/tests/extension/json/test_json.py index aec561ece8573..8083a1ce69092 100644 --- a/pandas/tests/extension/json/test_json.py +++ b/pandas/tests/extension/json/test_json.py @@ -1,15 +1,14 @@ import operator -import sys import pytest +from pandas.compat import PY2, PY36 from pandas.tests.extension import base from .array import JSONArray, JSONDtype, make_data -pytestmark = pytest.mark.skipif(sys.version_info[0] == 2, - reason="Py2 doesn't have a UserDict") +pytestmark = pytest.mark.skipif(PY2, reason="Py2 doesn't have a UserDict") @pytest.fixture @@ -81,7 +80,7 @@ def test_fillna_frame(self): class TestMethods(base.BaseMethodsTests): unhashable = pytest.mark.skip(reason="Unhashable") - unstable = pytest.mark.skipif(sys.version_info <= (3, 5), + unstable = pytest.mark.skipif(not PY36, # 3.6 or higher reason="Dictionary order unstable") @unhashable From 10db32e16278761ab3f8f548ba08490e4cbaeb70 Mon Sep 17 00:00:00 2001 From: Jeff Reback Date: Sun, 25 Mar 2018 10:10:14 -0400 Subject: [PATCH 31/81] TST: 32-bit compat for categorical factorization tests (#20482) --- pandas/tests/categorical/test_algos.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pandas/tests/categorical/test_algos.py b/pandas/tests/categorical/test_algos.py index 61764ec0ff632..f727184e862d8 100644 --- a/pandas/tests/categorical/test_algos.py +++ b/pandas/tests/categorical/test_algos.py @@ -15,7 +15,7 @@ def test_factorize(categories, ordered): categories=categories, ordered=ordered) labels, uniques = pd.factorize(cat) - expected_labels = np.array([0, 0, 1, 2, -1], dtype='int64') + expected_labels = np.array([0, 0, 1, 2, -1], dtype=np.intp) expected_uniques = pd.Categorical(['b', 'a', 'c'], categories=categories, ordered=ordered) @@ -27,7 +27,7 @@ def test_factorize(categories, ordered): def test_factorized_sort(): cat = pd.Categorical(['b', 'b', None, 'a']) labels, uniques = pd.factorize(cat, sort=True) - expected_labels = np.array([1, 1, -1, 0], dtype='int64') + expected_labels = np.array([1, 1, -1, 0], dtype=np.intp) expected_uniques = pd.Categorical(['a', 'b']) tm.assert_numpy_array_equal(labels, expected_labels) @@ -40,7 +40,7 @@ def test_factorized_sort_ordered(): ordered=True) labels, uniques = pd.factorize(cat, sort=True) - expected_labels = np.array([0, 0, -1, 1], dtype='int64') + expected_labels = np.array([0, 0, -1, 1], dtype=np.intp) expected_uniques = pd.Categorical(['b', 'a'], categories=['c', 'b', 'a'], ordered=True) From c1cbd96d4cf6eea429638b442cf13d56c10deba1 Mon Sep 17 00:00:00 2001 From: Paul Reidy Date: Sun, 25 Mar 2018 15:10:27 +0100 Subject: [PATCH 32/81] API: Preserve int columns in to_dict('index') (#20444) --- doc/source/whatsnew/v0.23.0.txt | 1 + pandas/core/frame.py | 3 ++- pandas/tests/frame/test_convert_to.py | 27 +++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index 9159c03edee2e..a02845b6ca1cf 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -716,6 +716,7 @@ Other API Changes - :func:`Series.str.replace` now takes an optional `regex` keyword which, when set to ``False``, uses literal string replacement rather than regex replacement (:issue:`16808`) - :func:`DatetimeIndex.strftime` and :func:`PeriodIndex.strftime` now return an ``Index`` instead of a numpy array to be consistent with similar accessors (:issue:`20127`) - Constructing a Series from a list of length 1 no longer broadcasts this list when a longer index is specified (:issue:`19714`, :issue:`20391`). +- :func:`DataFrame.to_dict` with ``orient='index'`` no longer casts int columns to float for a DataFrame with only int and float columns (:issue:`18580`) .. _whatsnew_0230.deprecations: diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 9b09c87689762..1fac497a76c8f 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -1102,7 +1102,8 @@ def to_dict(self, orient='dict', into=dict): for k, v in zip(self.columns, np.atleast_1d(row))) for row in self.values] elif orient.lower().startswith('i'): - return into_c((k, v.to_dict(into)) for k, v in self.iterrows()) + return into_c((t[0], dict(zip(self.columns, t[1:]))) + for t in self.itertuples()) else: raise ValueError("orient '%s' not understood" % orient) diff --git a/pandas/tests/frame/test_convert_to.py b/pandas/tests/frame/test_convert_to.py index 024de8bc13f72..82dadacd5b1ac 100644 --- a/pandas/tests/frame/test_convert_to.py +++ b/pandas/tests/frame/test_convert_to.py @@ -5,6 +5,7 @@ import pytest import pytz import collections +from collections import OrderedDict, defaultdict import numpy as np from pandas import compat @@ -288,3 +289,29 @@ def test_frame_to_dict_tz(self): ] tm.assert_dict_equal(result[0], expected[0]) tm.assert_dict_equal(result[1], expected[1]) + + @pytest.mark.parametrize('into, expected', [ + (dict, {0: {'int_col': 1, 'float_col': 1.0}, + 1: {'int_col': 2, 'float_col': 2.0}, + 2: {'int_col': 3, 'float_col': 3.0}}), + (OrderedDict, OrderedDict([(0, {'int_col': 1, 'float_col': 1.0}), + (1, {'int_col': 2, 'float_col': 2.0}), + (2, {'int_col': 3, 'float_col': 3.0})])), + (defaultdict(list), defaultdict(list, + {0: {'int_col': 1, 'float_col': 1.0}, + 1: {'int_col': 2, 'float_col': 2.0}, + 2: {'int_col': 3, 'float_col': 3.0}})) + ]) + def test_to_dict_index_dtypes(self, into, expected): + # GH 18580 + # When using to_dict(orient='index') on a dataframe with int + # and float columns only the int columns were cast to float + + df = DataFrame({'int_col': [1, 2, 3], + 'float_col': [1.0, 2.0, 3.0]}) + + result = df.to_dict(orient='index', into=into) + cols = ['int_col', 'float_col'] + result = DataFrame.from_dict(result, orient='index')[cols] + expected = DataFrame.from_dict(expected, orient='index')[cols] + tm.assert_frame_equal(result, expected) From cfad93ff920c270a5924f1bbd0d547280c10c223 Mon Sep 17 00:00:00 2001 From: tv3141 Date: Sun, 25 Mar 2018 15:32:13 +0100 Subject: [PATCH 33/81] DOC: Fix broken dependency links (#20471) * Change bottleneck link to github repository page. An issue exists for the broken documentation link on pypy https://github.com/kwgoodman/bottleneck/issues/173 * Fix link to fastparquet documentation. --- doc/source/install.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/source/install.rst b/doc/source/install.rst index 7d741c6c2c75a..c96d4fbeb4ad2 100644 --- a/doc/source/install.rst +++ b/doc/source/install.rst @@ -212,7 +212,7 @@ Recommended Dependencies ``numexpr`` uses multiple cores as well as smart chunking and caching to achieve large speedups. If installed, must be Version 2.4.6 or higher. -* `bottleneck `__: for accelerating certain types of ``nan`` +* `bottleneck `__: for accelerating certain types of ``nan`` evaluations. ``bottleneck`` uses specialized cython routines to achieve large speedups. If installed, must be Version 1.0.0 or higher. @@ -233,7 +233,7 @@ Optional Dependencies * `xarray `__: pandas like handling for > 2 dims, needed for converting Panels to xarray objects. Version 0.7.0 or higher is recommended. * `PyTables `__: necessary for HDF5-based storage. Version 3.0.0 or higher required, Version 3.2.1 or higher highly recommended. * `Feather Format `__: necessary for feather-based storage, version 0.3.1 or higher. -* `Apache Parquet `__, either `pyarrow `__ (>= 0.4.1) or `fastparquet `__ (>= 0.0.6) for parquet-based storage. The `snappy `__ and `brotli `__ are available for compression support. +* `Apache Parquet `__, either `pyarrow `__ (>= 0.4.1) or `fastparquet `__ (>= 0.0.6) for parquet-based storage. The `snappy `__ and `brotli `__ are available for compression support. * `SQLAlchemy `__: for SQL database support. Version 0.8.1 or higher recommended. Besides SQLAlchemy, you also need a database specific driver. You can find an overview of supported drivers for each SQL dialect in the `SQLAlchemy docs `__. Some common drivers are: * `psycopg2 `__: for PostgreSQL From f3eaa554978bf6337d14263cd2fde5d9cfa4c92d Mon Sep 17 00:00:00 2001 From: Tarbo Fukazawa Date: Sun, 25 Mar 2018 23:49:16 +0100 Subject: [PATCH 34/81] DOC: update the pandas.Series.str.startswith docstring (#20458) * DOC: update the pandas.Series.str.startswith docstring * DOC: update the pandas.Series.str.startswith docstring * DOC: update the pandas.Series.str.startswith docstring 2 * DOC: update the pandas.Series.str.startswith docstring 3 * DOC: update the pandas.Series.str.startswith docstring 4 --- pandas/core/strings.py | 49 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/pandas/core/strings.py b/pandas/core/strings.py index b98fa106336fc..d6a67435aeb09 100644 --- a/pandas/core/strings.py +++ b/pandas/core/strings.py @@ -328,19 +328,54 @@ def str_contains(arr, pat, case=True, flags=0, na=np.nan, regex=True): def str_startswith(arr, pat, na=np.nan): """ - Return boolean Series/``array`` indicating whether each string in the - Series/Index starts with passed pattern. Equivalent to - :meth:`str.startswith`. + Test if the start of each string element matches a pattern. + + Equivalent to :meth:`str.startswith`. Parameters ---------- - pat : string - Character sequence - na : bool, default NaN + pat : str + Character sequence. Regular expressions are not accepted. + na : object, default NaN + Object shown if element tested is not a string. Returns ------- - startswith : Series/array of boolean values + Series or Index of bool + A Series of booleans indicating whether the given pattern matches + the start of each string element. + + See Also + -------- + str.startswith : Python standard library string method. + Series.str.endswith : Same as startswith, but tests the end of string. + Series.str.contains : Tests if string element contains a pattern. + + Examples + -------- + >>> s = pd.Series(['bat', 'Bear', 'cat', np.nan]) + >>> s + 0 bat + 1 Bear + 2 cat + 3 NaN + dtype: object + + >>> s.str.startswith('b') + 0 True + 1 False + 2 False + 3 NaN + dtype: object + + Specifying `na` to be `False` instead of `NaN`. + + >>> s.str.startswith('b', na=False) + 0 True + 1 False + 2 False + 3 False + dtype: bool """ f = lambda x: x.startswith(pat) return _na_map(f, arr, na, dtype=bool) From daa3b337c4f791f9e0c55dd4c5208f9c95efe903 Mon Sep 17 00:00:00 2001 From: Jing Qiang Goh Date: Mon, 26 Mar 2018 07:55:21 +0800 Subject: [PATCH 35/81] BUG: dropna() on single column timezone-aware values (#13407) (#20422) --- doc/source/whatsnew/v0.23.0.txt | 3 ++- pandas/core/frame.py | 4 +++- pandas/tests/frame/test_missing.py | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index a02845b6ca1cf..39bfc8c633dbb 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -904,11 +904,12 @@ Timezones - :func:`Timestamp.replace` will now handle Daylight Savings transitions gracefully (:issue:`18319`) - Bug in tz-aware :class:`DatetimeIndex` where addition/subtraction with a :class:`TimedeltaIndex` or array with ``dtype='timedelta64[ns]'`` was incorrect (:issue:`17558`) - Bug in :func:`DatetimeIndex.insert` where inserting ``NaT`` into a timezone-aware index incorrectly raised (:issue:`16357`) -- Bug in the :class:`DataFrame` constructor, where tz-aware Datetimeindex and a given column name will result in an empty ``DataFrame`` (:issue:`19157`) +- Bug in :class:`DataFrame` constructor, where tz-aware Datetimeindex and a given column name will result in an empty ``DataFrame`` (:issue:`19157`) - Bug in :func:`Timestamp.tz_localize` where localizing a timestamp near the minimum or maximum valid values could overflow and return a timestamp with an incorrect nanosecond value (:issue:`12677`) - Bug when iterating over :class:`DatetimeIndex` that was localized with fixed timezone offset that rounded nanosecond precision to microseconds (:issue:`19603`) - Bug in :func:`DataFrame.diff` that raised an ``IndexError`` with tz-aware values (:issue:`18578`) - Bug in :func:`melt` that converted tz-aware dtypes to tz-naive (:issue:`15785`) +- Bug in :func:`Dataframe.count` that raised an ``ValueError`` if .dropna() method is invoked for single column timezone-aware values. (:issue:`13407`) Offsets ^^^^^^^ diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 1fac497a76c8f..93fb3d5d545d8 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -6579,7 +6579,9 @@ def count(self, axis=0, level=None, numeric_only=False): # column frames with an extension array result = notna(frame).sum(axis=axis) else: - counts = notna(frame.values).sum(axis=axis) + # GH13407 + series_counts = notna(frame).sum(axis=axis) + counts = series_counts.values result = Series(counts, index=frame._get_agg_axis(axis)) return result.astype('int64') diff --git a/pandas/tests/frame/test_missing.py b/pandas/tests/frame/test_missing.py index 2e4e8b9582cf6..668eae21c664f 100644 --- a/pandas/tests/frame/test_missing.py +++ b/pandas/tests/frame/test_missing.py @@ -8,6 +8,9 @@ from numpy import nan, random import numpy as np +import datetime +import dateutil + from pandas.compat import lrange from pandas import (DataFrame, Series, Timestamp, date_range, Categorical) @@ -183,6 +186,26 @@ def test_dropna_multiple_axes(self): inp.dropna(how='all', axis=(0, 1), inplace=True) assert_frame_equal(inp, expected) + def test_dropna_tz_aware_datetime(self): + # GH13407 + df = DataFrame() + dt1 = datetime.datetime(2015, 1, 1, + tzinfo=dateutil.tz.tzutc()) + dt2 = datetime.datetime(2015, 2, 2, + tzinfo=dateutil.tz.tzutc()) + df['Time'] = [dt1] + result = df.dropna(axis=0) + expected = DataFrame({'Time': [dt1]}) + assert_frame_equal(result, expected) + + # Ex2 + df = DataFrame({'Time': [dt1, None, np.nan, dt2]}) + result = df.dropna(axis=0) + expected = DataFrame([dt1, dt2], + columns=['Time'], + index=[0, 3]) + assert_frame_equal(result, expected) + def test_fillna(self): tf = self.tsframe tf.loc[tf.index[:5], 'A'] = nan From 40a91c57f8ebb186bd731bab8aa737b6f74053c9 Mon Sep 17 00:00:00 2001 From: Jeff Reback Date: Sun, 25 Mar 2018 21:17:38 -0400 Subject: [PATCH 36/81] TST: test_nanops some parametrize & catch warnings (RuntimeWarning: All-Nan slice in tests) (#20484) * TST: test_nanops some parametrize & catch warnings (RuntimeWarning: All-NaN slice in tests) * COMPAT: work around deprecation warning on non-equal dtype comparisons closes #20011 * WARN: bincount minlength deprecation warning --- pandas/core/groupby.py | 2 +- pandas/core/indexes/base.py | 9 +++- pandas/tests/frame/test_analytics.py | 6 ++- pandas/tests/test_nanops.py | 75 ++++++++++++---------------- pandas/tests/test_panel.py | 6 ++- 5 files changed, 48 insertions(+), 50 deletions(-) diff --git a/pandas/core/groupby.py b/pandas/core/groupby.py index 7b68ad67675ff..601acac20c96d 100644 --- a/pandas/core/groupby.py +++ b/pandas/core/groupby.py @@ -3860,7 +3860,7 @@ def count(self): mask = (ids != -1) & ~isna(val) ids = _ensure_platform_int(ids) - out = np.bincount(ids[mask], minlength=ngroups or None) + out = np.bincount(ids[mask], minlength=ngroups or 0) return Series(out, index=self.grouper.result_index, diff --git a/pandas/core/indexes/base.py b/pandas/core/indexes/base.py index 40f543e211f0c..12bb09e8f8a8a 100644 --- a/pandas/core/indexes/base.py +++ b/pandas/core/indexes/base.py @@ -99,9 +99,14 @@ def cmp_method(self, other): # don't pass MultiIndex with np.errstate(all='ignore'): result = ops._comp_method_OBJECT_ARRAY(op, self.values, other) + else: - with np.errstate(all='ignore'): - result = op(self.values, np.asarray(other)) + + # numpy will show a DeprecationWarning on invalid elementwise + # comparisons, this will raise in the future + with warnings.catch_warnings(record=True): + with np.errstate(all='ignore'): + result = op(self.values, np.asarray(other)) # technically we could support bool dtyped Index # for now just return the indexing array directly diff --git a/pandas/tests/frame/test_analytics.py b/pandas/tests/frame/test_analytics.py index 59a30fc69905f..8efa140237614 100644 --- a/pandas/tests/frame/test_analytics.py +++ b/pandas/tests/frame/test_analytics.py @@ -529,7 +529,8 @@ def wrapper(x): self._check_stat_op('median', wrapper, check_dates=True) def test_min(self): - self._check_stat_op('min', np.min, check_dates=True) + with warnings.catch_warnings(record=True): + self._check_stat_op('min', np.min, check_dates=True) self._check_stat_op('min', np.min, frame=self.intframe) def test_cummin(self): @@ -579,7 +580,8 @@ def test_cummax(self): assert np.shape(cummax_xs) == np.shape(self.tsframe) def test_max(self): - self._check_stat_op('max', np.max, check_dates=True) + with warnings.catch_warnings(record=True): + self._check_stat_op('max', np.max, check_dates=True) self._check_stat_op('max', np.max, frame=self.intframe) def test_mad(self): diff --git a/pandas/tests/test_nanops.py b/pandas/tests/test_nanops.py index dffb303af6ae1..a70ee80aee180 100644 --- a/pandas/tests/test_nanops.py +++ b/pandas/tests/test_nanops.py @@ -301,24 +301,6 @@ def check_funs(self, testfunc, targfunc, allow_complex=True, allow_complex=allow_complex) self.check_fun(testfunc, targfunc, 'arr_obj', **kwargs) - def check_funs_ddof(self, - testfunc, - targfunc, - allow_complex=True, - allow_all_nan=True, - allow_str=True, - allow_date=False, - allow_tdelta=False, - allow_obj=True, ): - for ddof in range(3): - try: - self.check_funs(testfunc, targfunc, allow_complex, - allow_all_nan, allow_str, allow_date, - allow_tdelta, allow_obj, ddof=ddof) - except BaseException as exc: - exc.args += ('ddof %s' % ddof, ) - raise - def _badobj_wrap(self, value, func, allow_complex=True, **kwargs): if value.dtype.kind == 'O': if allow_complex: @@ -381,37 +363,46 @@ def test_nanmedian(self): allow_str=False, allow_date=False, allow_tdelta=True, allow_obj='convert') - def test_nanvar(self): - self.check_funs_ddof(nanops.nanvar, np.var, allow_complex=False, - allow_str=False, allow_date=False, - allow_tdelta=True, allow_obj='convert') + @pytest.mark.parametrize('ddof', range(3)) + def test_nanvar(self, ddof): + self.check_funs(nanops.nanvar, np.var, allow_complex=False, + allow_str=False, allow_date=False, + allow_tdelta=True, allow_obj='convert', ddof=ddof) - def test_nanstd(self): - self.check_funs_ddof(nanops.nanstd, np.std, allow_complex=False, - allow_str=False, allow_date=False, - allow_tdelta=True, allow_obj='convert') + @pytest.mark.parametrize('ddof', range(3)) + def test_nanstd(self, ddof): + self.check_funs(nanops.nanstd, np.std, allow_complex=False, + allow_str=False, allow_date=False, + allow_tdelta=True, allow_obj='convert', ddof=ddof) @td.skip_if_no('scipy', min_version='0.17.0') - def test_nansem(self): + @pytest.mark.parametrize('ddof', range(3)) + def test_nansem(self, ddof): from scipy.stats import sem with np.errstate(invalid='ignore'): - self.check_funs_ddof(nanops.nansem, sem, allow_complex=False, - allow_str=False, allow_date=False, - allow_tdelta=False, allow_obj='convert') + self.check_funs(nanops.nansem, sem, allow_complex=False, + allow_str=False, allow_date=False, + allow_tdelta=False, allow_obj='convert', ddof=ddof) def _minmax_wrap(self, value, axis=None, func=None): + + # numpy warns if all nan res = func(value, axis) if res.dtype.kind == 'm': res = np.atleast_1d(res) return res def test_nanmin(self): - func = partial(self._minmax_wrap, func=np.min) - self.check_funs(nanops.nanmin, func, allow_str=False, allow_obj=False) + with warnings.catch_warnings(record=True): + func = partial(self._minmax_wrap, func=np.min) + self.check_funs(nanops.nanmin, func, + allow_str=False, allow_obj=False) def test_nanmax(self): - func = partial(self._minmax_wrap, func=np.max) - self.check_funs(nanops.nanmax, func, allow_str=False, allow_obj=False) + with warnings.catch_warnings(record=True): + func = partial(self._minmax_wrap, func=np.max) + self.check_funs(nanops.nanmax, func, + allow_str=False, allow_obj=False) def _argminmax_wrap(self, value, axis=None, func=None): res = func(value, axis) @@ -425,17 +416,15 @@ def _argminmax_wrap(self, value, axis=None, func=None): return res def test_nanargmax(self): - func = partial(self._argminmax_wrap, func=np.argmax) - self.check_funs(nanops.nanargmax, func, allow_str=False, - allow_obj=False, allow_date=True, allow_tdelta=True) + with warnings.catch_warnings(record=True): + func = partial(self._argminmax_wrap, func=np.argmax) + self.check_funs(nanops.nanargmax, func, + allow_str=False, allow_obj=False, + allow_date=True, allow_tdelta=True) def test_nanargmin(self): - func = partial(self._argminmax_wrap, func=np.argmin) - if tm.sys.version_info[0:2] == (2, 6): - self.check_funs(nanops.nanargmin, func, allow_date=True, - allow_tdelta=True, allow_str=False, - allow_obj=False) - else: + with warnings.catch_warnings(record=True): + func = partial(self._argminmax_wrap, func=np.argmin) self.check_funs(nanops.nanargmin, func, allow_str=False, allow_obj=False) diff --git a/pandas/tests/test_panel.py b/pandas/tests/test_panel.py index 301a7fc437fcf..7973b27601237 100644 --- a/pandas/tests/test_panel.py +++ b/pandas/tests/test_panel.py @@ -100,10 +100,12 @@ def wrapper(x): self._check_stat_op('median', wrapper) def test_min(self): - self._check_stat_op('min', np.min) + with catch_warnings(record=True): + self._check_stat_op('min', np.min) def test_max(self): - self._check_stat_op('max', np.max) + with catch_warnings(record=True): + self._check_stat_op('max', np.max) @td.skip_if_no_scipy def test_skew(self): From eecb1299d22133bb0cdb09f4dd6dd06ad2e1a4dc Mon Sep 17 00:00:00 2001 From: Ibrahim Sharaf ElDen Date: Mon, 26 Mar 2018 09:24:01 +0200 Subject: [PATCH 37/81] ENH: DataFrame.pivot accepts a list of values (#18636) --- doc/source/whatsnew/v0.23.0.txt | 1 + pandas/core/frame.py | 31 +++++++---- pandas/core/reshape/reshape.py | 15 ++++-- pandas/tests/reshape/test_pivot.py | 83 ++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 15 deletions(-) diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index 39bfc8c633dbb..107ce7855a00d 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -326,6 +326,7 @@ Other Enhancements - ``Resampler`` objects now have a functioning :attr:`~pandas.core.resample.Resampler.pipe` method. Previously, calls to ``pipe`` were diverted to the ``mean`` method (:issue:`17905`). - :func:`~pandas.api.types.is_scalar` now returns ``True`` for ``DateOffset`` objects (:issue:`18943`). +- :func:`DataFrame.pivot` now accepts a list for the ``values=`` kwarg (:issue:`17160`). - Added :func:`pandas.api.extensions.register_dataframe_accessor`, :func:`pandas.api.extensions.register_series_accessor`, and :func:`pandas.api.extensions.register_index_accessor`, accessor for libraries downstream of pandas diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 93fb3d5d545d8..3aac560d43249 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -5050,11 +5050,14 @@ def pivot(self, index=None, columns=None, values=None): existing index. columns : string or object Column to use to make new frame's columns. - values : string or object, optional - Column to use for populating new frame's values. If not + values : string, object or a list of the previous, optional + Column(s) to use for populating new frame's values. If not specified, all remaining columns will be used and the result will have hierarchically indexed columns. + .. versionchanged :: 0.23.0 + Also accept list of column names. + Returns ------- DataFrame @@ -5083,15 +5086,16 @@ def pivot(self, index=None, columns=None, values=None): >>> df = pd.DataFrame({'foo': ['one', 'one', 'one', 'two', 'two', ... 'two'], ... 'bar': ['A', 'B', 'C', 'A', 'B', 'C'], - ... 'baz': [1, 2, 3, 4, 5, 6]}) + ... 'baz': [1, 2, 3, 4, 5, 6], + ... 'zoo': ['x', 'y', 'z', 'q', 'w', 't']}) >>> df - foo bar baz - 0 one A 1 - 1 one B 2 - 2 one C 3 - 3 two A 4 - 4 two B 5 - 5 two C 6 + foo bar baz zoo + 0 one A 1 x + 1 one B 2 y + 2 one C 3 z + 3 two A 4 q + 4 two B 5 w + 5 two C 6 t >>> df.pivot(index='foo', columns='bar', values='baz') bar A B C @@ -5105,6 +5109,13 @@ def pivot(self, index=None, columns=None, values=None): one 1 2 3 two 4 5 6 + >>> df.pivot(index='foo', columns='bar', values=['baz', 'zoo']) + baz zoo + bar A B C A B C + foo + one 1 2 3 x y z + two 4 5 6 q w t + A ValueError is raised if there are any duplicates. >>> df = pd.DataFrame({"foo": ['one', 'one', 'two', 'two'], diff --git a/pandas/core/reshape/reshape.py b/pandas/core/reshape/reshape.py index 3ef152d091b24..389f1af48434a 100644 --- a/pandas/core/reshape/reshape.py +++ b/pandas/core/reshape/reshape.py @@ -392,16 +392,21 @@ def pivot(self, index=None, columns=None, values=None): cols = [columns] if index is None else [index, columns] append = index is None indexed = self.set_index(cols, append=append) - return indexed.unstack(columns) else: if index is None: index = self.index else: index = self[index] - indexed = self._constructor_sliced( - self[values].values, - index=MultiIndex.from_arrays([index, self[columns]])) - return indexed.unstack(columns) + index = MultiIndex.from_arrays([index, self[columns]]) + + if is_list_like(values) and not isinstance(values, tuple): + # Exclude tuple because it is seen as a single column name + indexed = self._constructor(self[values].values, index=index, + columns=values) + else: + indexed = self._constructor_sliced(self[values].values, + index=index) + return indexed.unstack(columns) def pivot_simple(index, columns, values): diff --git a/pandas/tests/reshape/test_pivot.py b/pandas/tests/reshape/test_pivot.py index 786c57a4a82df..92bedbabdf2f1 100644 --- a/pandas/tests/reshape/test_pivot.py +++ b/pandas/tests/reshape/test_pivot.py @@ -371,6 +371,89 @@ def test_pivot_periods(self): pv = df.pivot(index='p1', columns='p2', values='data1') tm.assert_frame_equal(pv, expected) + @pytest.mark.parametrize('values', [ + ['baz', 'zoo'], np.array(['baz', 'zoo']), + pd.Series(['baz', 'zoo']), pd.Index(['baz', 'zoo']) + ]) + def test_pivot_with_list_like_values(self, values): + # issue #17160 + df = pd.DataFrame({'foo': ['one', 'one', 'one', 'two', 'two', 'two'], + 'bar': ['A', 'B', 'C', 'A', 'B', 'C'], + 'baz': [1, 2, 3, 4, 5, 6], + 'zoo': ['x', 'y', 'z', 'q', 'w', 't']}) + + result = df.pivot(index='foo', columns='bar', values=values) + + data = [[1, 2, 3, 'x', 'y', 'z'], + [4, 5, 6, 'q', 'w', 't']] + index = Index(data=['one', 'two'], name='foo') + columns = MultiIndex(levels=[['baz', 'zoo'], ['A', 'B', 'C']], + labels=[[0, 0, 0, 1, 1, 1], [0, 1, 2, 0, 1, 2]], + names=[None, 'bar']) + expected = DataFrame(data=data, index=index, + columns=columns, dtype='object') + tm.assert_frame_equal(result, expected) + + @pytest.mark.parametrize('values', [ + ['bar', 'baz'], np.array(['bar', 'baz']), + pd.Series(['bar', 'baz']), pd.Index(['bar', 'baz']) + ]) + def test_pivot_with_list_like_values_nans(self, values): + # issue #17160 + df = pd.DataFrame({'foo': ['one', 'one', 'one', 'two', 'two', 'two'], + 'bar': ['A', 'B', 'C', 'A', 'B', 'C'], + 'baz': [1, 2, 3, 4, 5, 6], + 'zoo': ['x', 'y', 'z', 'q', 'w', 't']}) + + result = df.pivot(index='zoo', columns='foo', values=values) + + data = [[np.nan, 'A', np.nan, 4], + [np.nan, 'C', np.nan, 6], + [np.nan, 'B', np.nan, 5], + ['A', np.nan, 1, np.nan], + ['B', np.nan, 2, np.nan], + ['C', np.nan, 3, np.nan]] + index = Index(data=['q', 't', 'w', 'x', 'y', 'z'], name='zoo') + columns = MultiIndex(levels=[['bar', 'baz'], ['one', 'two']], + labels=[[0, 0, 1, 1], [0, 1, 0, 1]], + names=[None, 'foo']) + expected = DataFrame(data=data, index=index, + columns=columns, dtype='object') + tm.assert_frame_equal(result, expected) + + @pytest.mark.xfail(reason='MultiIndexed unstack with tuple names fails' + 'with KeyError #19966') + def test_pivot_with_multiindex(self): + # issue #17160 + index = Index(data=[0, 1, 2, 3, 4, 5]) + data = [['one', 'A', 1, 'x'], + ['one', 'B', 2, 'y'], + ['one', 'C', 3, 'z'], + ['two', 'A', 4, 'q'], + ['two', 'B', 5, 'w'], + ['two', 'C', 6, 't']] + columns = MultiIndex(levels=[['bar', 'baz'], ['first', 'second']], + labels=[[0, 0, 1, 1], [0, 1, 0, 1]]) + df = DataFrame(data=data, index=index, columns=columns, dtype='object') + result = df.pivot(index=('bar', 'first'), columns=('bar', 'second'), + values=('baz', 'first')) + + data = {'A': Series([1, 4], index=['one', 'two']), + 'B': Series([2, 5], index=['one', 'two']), + 'C': Series([3, 6], index=['one', 'two'])} + expected = DataFrame(data) + tm.assert_frame_equal(result, expected) + + def test_pivot_with_tuple_of_values(self): + # issue #17160 + df = pd.DataFrame({'foo': ['one', 'one', 'one', 'two', 'two', 'two'], + 'bar': ['A', 'B', 'C', 'A', 'B', 'C'], + 'baz': [1, 2, 3, 4, 5, 6], + 'zoo': ['x', 'y', 'z', 'q', 'w', 't']}) + with pytest.raises(KeyError): + # tuple is seen as a single column name + df.pivot(index='zoo', columns='foo', values=('bar', 'baz')) + def test_margins(self): def _check_output(result, values_col, index=['A', 'B'], columns=['C'], From 31f7dc21c76e11bdcb58c0cecec600e5a70c24c5 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Mon, 26 Mar 2018 14:48:49 +0200 Subject: [PATCH 38/81] BUG: raise error when setting cached properties (#20487) --- pandas/_libs/properties.pyx | 3 +++ pandas/tests/indexes/test_base.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/pandas/_libs/properties.pyx b/pandas/_libs/properties.pyx index e3f16f224db1c..0f2900619fdb6 100644 --- a/pandas/_libs/properties.pyx +++ b/pandas/_libs/properties.pyx @@ -37,6 +37,9 @@ cdef class CachedProperty(object): PyDict_SetItem(cache, self.name, val) return val + def __set__(self, obj, value): + raise AttributeError("Can't set attribute") + cache_readonly = CachedProperty diff --git a/pandas/tests/indexes/test_base.py b/pandas/tests/indexes/test_base.py index 22ef2fe7aa19e..ff9c86fbfe384 100644 --- a/pandas/tests/indexes/test_base.py +++ b/pandas/tests/indexes/test_base.py @@ -2056,6 +2056,11 @@ def test_iadd_preserves_name(self): ser.index -= 1 assert ser.index.name == "foo" + def test_cached_properties_not_settable(self): + idx = pd.Index([1, 2, 3]) + with tm.assert_raises_regex(AttributeError, "Can't set attribute"): + idx.is_unique = False + class TestMixedIntIndex(Base): # Mostly the tests from common.py for which the results differ From 56ca9a35c5d619da0dda757a70d23118aee9e941 Mon Sep 17 00:00:00 2001 From: Samuel Sinayoko Date: Mon, 26 Mar 2018 14:06:16 +0100 Subject: [PATCH 39/81] DOC: update the DataFrame.stack docstring (#20430) --- pandas/core/frame.py | 176 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 153 insertions(+), 23 deletions(-) diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 3aac560d43249..d2617305d220a 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -5250,36 +5250,166 @@ def pivot_table(self, values=None, index=None, columns=None, def stack(self, level=-1, dropna=True): """ - Pivot a level of the (possibly hierarchical) column labels, returning a - DataFrame (or Series in the case of an object with a single level of - column labels) having a hierarchical index with a new inner-most level - of row labels. - The level involved will automatically get sorted. + Stack the prescribed level(s) from columns to index. + + Return a reshaped DataFrame or Series having a multi-level + index with one or more new inner-most levels compared to the current + DataFrame. The new inner-most levels are created by pivoting the + columns of the current dataframe: + + - if the columns have a single level, the output is a Series; + - if the columns have multiple levels, the new index + level(s) is (are) taken from the prescribed level(s) and + the output is a DataFrame. + + The new index levels are sorted. Parameters ---------- - level : int, string, or list of these, default last level - Level(s) to stack, can pass level name - dropna : boolean, default True - Whether to drop rows in the resulting Frame/Series with no valid - values + level : int, str, list, default -1 + Level(s) to stack from the column axis onto the index + axis, defined as one index or label, or a list of indices + or labels. + dropna : bool, default True + Whether to drop rows in the resulting Frame/Series with + missing values. Stacking a column level onto the index + axis can create combinations of index and column values + that are missing from the original dataframe. See Examples + section. + + Returns + ------- + DataFrame or Series + Stacked dataframe or series. + + See Also + -------- + DataFrame.unstack : Unstack prescribed level(s) from index axis + onto column axis. + DataFrame.pivot : Reshape dataframe from long format to wide + format. + DataFrame.pivot_table : Create a spreadsheet-style pivot table + as a DataFrame. + + Notes + ----- + The function is named by analogy with a collection of books + being re-organised from being side by side on a horizontal + position (the columns of the dataframe) to being stacked + vertically on top of of each other (in the index of the + dataframe). Examples - ---------- - >>> s - a b - one 1. 2. - two 3. 4. + -------- + **Single level columns** + + >>> df_single_level_cols = pd.DataFrame([[0, 1], [2, 3]], + ... index=['cat', 'dog'], + ... columns=['weight', 'height']) + + Stacking a dataframe with a single level column axis returns a Series: + + >>> df_single_level_cols + weight height + cat 0 1 + dog 2 3 + >>> df_single_level_cols.stack() + cat weight 0 + height 1 + dog weight 2 + height 3 + dtype: int64 - >>> s.stack() - one a 1 - b 2 - two a 3 - b 4 + **Multi level columns: simple case** + + >>> multicol1 = pd.MultiIndex.from_tuples([('weight', 'kg'), + ... ('weight', 'pounds')]) + >>> df_multi_level_cols1 = pd.DataFrame([[1, 2], [2, 4]], + ... index=['cat', 'dog'], + ... columns=multicol1) + + Stacking a dataframe with a multi-level column axis: + + >>> df_multi_level_cols1 + weight + kg pounds + cat 1 2 + dog 2 4 + >>> df_multi_level_cols1.stack() + weight + cat kg 1 + pounds 2 + dog kg 2 + pounds 4 + + **Missing values** + + >>> multicol2 = pd.MultiIndex.from_tuples([('weight', 'kg'), + ... ('height', 'm')]) + >>> df_multi_level_cols2 = pd.DataFrame([[1.0, 2.0], [3.0, 4.0]], + ... index=['cat', 'dog'], + ... columns=multicol2) + + It is common to have missing values when stacking a dataframe + with multi-level columns, as the stacked dataframe typically + has more values than the original dataframe. Missing values + are filled with NaNs: + + >>> df_multi_level_cols2 + weight height + kg m + cat 1.0 2.0 + dog 3.0 4.0 + >>> df_multi_level_cols2.stack() + height weight + cat kg NaN 1.0 + m 2.0 NaN + dog kg NaN 3.0 + m 4.0 NaN + + **Prescribing the level(s) to be stacked** + + The first parameter controls which level or levels are stacked: + + >>> df_multi_level_cols2.stack(0) + kg m + cat height NaN 2.0 + weight 1.0 NaN + dog height NaN 4.0 + weight 3.0 NaN + >>> df_multi_level_cols2.stack([0, 1]) + cat height m 2.0 + weight kg 1.0 + dog height m 4.0 + weight kg 3.0 + dtype: float64 - Returns - ------- - stacked : DataFrame or Series + **Dropping missing values** + + >>> df_multi_level_cols3 = pd.DataFrame([[None, 1.0], [2.0, 3.0]], + ... index=['cat', 'dog'], + ... columns=multicol2) + + Note that rows where all values are missing are dropped by + default but this behaviour can be controlled via the dropna + keyword parameter: + + >>> df_multi_level_cols3 + weight height + kg m + cat NaN 1.0 + dog 2.0 3.0 + >>> df_multi_level_cols3.stack(dropna=False) + height weight + cat kg NaN NaN + m 1.0 NaN + dog kg NaN 2.0 + m 3.0 NaN + >>> df_multi_level_cols3.stack(dropna=True) + height weight + cat m 1.0 NaN + dog kg NaN 2.0 + m 3.0 NaN """ from pandas.core.reshape.reshape import stack, stack_multiple From 492130b54c68e78f29ef389fc48edbc98694400b Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Mon, 26 Mar 2018 09:29:04 -0500 Subject: [PATCH 40/81] CI: Fixed deprecationWarning (#20489) Closes #20479 --- pandas/tests/extension/decimal/test_decimal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/tests/extension/decimal/test_decimal.py b/pandas/tests/extension/decimal/test_decimal.py index 7d959ea4fcd84..b6303ededd0dc 100644 --- a/pandas/tests/extension/decimal/test_decimal.py +++ b/pandas/tests/extension/decimal/test_decimal.py @@ -134,7 +134,7 @@ def test_series_constructor_with_same_dtype_ok(): def test_series_constructor_coerce_extension_array_to_dtype_raises(): arr = DecimalArray([decimal.Decimal('10.0')]) - xpr = "Cannot specify a dtype 'int64' .* \('decimal'\)." + xpr = r"Cannot specify a dtype 'int64' .* \('decimal'\)." with tm.assert_raises_regex(ValueError, xpr): pd.Series(arr, dtype='int64') From f02b82b27519fb3a8888f8bff1839cb53be3ba41 Mon Sep 17 00:00:00 2001 From: Cheuk Ting Ho Date: Mon, 26 Mar 2018 21:12:20 +0100 Subject: [PATCH 41/81] DOC: update the isna, isnull, notna and notnull docstring (#20459) --- pandas/core/dtypes/missing.py | 147 +++++++++++++++++++++++++++++----- 1 file changed, 128 insertions(+), 19 deletions(-) diff --git a/pandas/core/dtypes/missing.py b/pandas/core/dtypes/missing.py index 01c88c269e7e0..2c8d229f9b0cb 100644 --- a/pandas/core/dtypes/missing.py +++ b/pandas/core/dtypes/missing.py @@ -29,23 +29,78 @@ def isna(obj): - """Detect missing values (NaN in numeric arrays, None/NaN in object arrays) + """ + Detect missing values for an array-like object. + + This function takes a scalar or array-like object and indictates + whether values are missing (``NaN`` in numeric arrays, ``None`` or ``NaN`` + in object arrays, ``NaT`` in datetimelike). Parameters ---------- - arr : ndarray or object value - Object to check for null-ness + obj : scalar or array-like + Object to check for null or missing values. Returns ------- - isna : array-like of bool or bool - Array or bool indicating whether an object is null or if an array is - given which of the element is null. + bool or array-like of bool + For scalar input, returns a scalar boolean. + For array input, returns an array of boolean indicating whether each + corresponding element is missing. - See also + See Also + -------- + notna : boolean inverse of pandas.isna. + Series.isna : Detetct missing values in a Series. + DataFrame.isna : Detect missing values in a DataFrame. + Index.isna : Detect missing values in an Index. + + Examples -------- - pandas.notna: boolean inverse of pandas.isna - pandas.isnull: alias of isna + Scalar arguments (including strings) result in a scalar boolean. + + >>> pd.isna('dog') + False + + >>> pd.isna(np.nan) + True + + ndarrays result in an ndarray of booleans. + + >>> array = np.array([[1, np.nan, 3], [4, 5, np.nan]]) + >>> array + array([[ 1., nan, 3.], + [ 4., 5., nan]]) + >>> pd.isna(array) + array([[False, True, False], + [False, False, True]]) + + For indexes, an ndarray of booleans is returned. + + >>> index = pd.DatetimeIndex(["2017-07-05", "2017-07-06", None, + ... "2017-07-08"]) + >>> index + DatetimeIndex(['2017-07-05', '2017-07-06', 'NaT', '2017-07-08'], + dtype='datetime64[ns]', freq=None) + >>> pd.isna(index) + array([False, False, True, False]) + + For Series and DataFrame, the same type is returned, containing booleans. + + >>> df = pd.DataFrame([['ant', 'bee', 'cat'], ['dog', None, 'fly']]) + >>> df + 0 1 2 + 0 ant bee cat + 1 dog None fly + >>> pd.isna(df) + 0 1 2 + 0 False False False + 1 False True False + + >>> pd.isna(df[1]) + 0 False + 1 True + Name: 1, dtype: bool """ return _isna(obj) @@ -197,24 +252,78 @@ def _isna_ndarraylike_old(obj): def notna(obj): - """Replacement for numpy.isfinite / -numpy.isnan which is suitable for use - on object arrays. + """ + Detect non-missing values for an array-like object. + + This function takes a scalar or array-like object and indictates + whether values are valid (not missing, which is ``NaN`` in numeric + arrays, ``None`` or ``NaN`` in object arrays, ``NaT`` in datetimelike). Parameters ---------- - arr : ndarray or object value - Object to check for *not*-null-ness + obj : array-like or object value + Object to check for *not* null or *non*-missing values. Returns ------- - notisna : array-like of bool or bool - Array or bool indicating whether an object is *not* null or if an array - is given which of the element is *not* null. + bool or array-like of bool + For scalar input, returns a scalar boolean. + For array input, returns an array of boolean indicating whether each + corresponding element is valid. - See also + See Also + -------- + isna : boolean inverse of pandas.notna. + Series.notna : Detetct valid values in a Series. + DataFrame.notna : Detect valid values in a DataFrame. + Index.notna : Detect valid values in an Index. + + Examples -------- - pandas.isna : boolean inverse of pandas.notna - pandas.notnull : alias of notna + Scalar arguments (including strings) result in a scalar boolean. + + >>> pd.notna('dog') + True + + >>> pd.notna(np.nan) + False + + ndarrays result in an ndarray of booleans. + + >>> array = np.array([[1, np.nan, 3], [4, 5, np.nan]]) + >>> array + array([[ 1., nan, 3.], + [ 4., 5., nan]]) + >>> pd.notna(array) + array([[ True, False, True], + [ True, True, False]]) + + For indexes, an ndarray of booleans is returned. + + >>> index = pd.DatetimeIndex(["2017-07-05", "2017-07-06", None, + ... "2017-07-08"]) + >>> index + DatetimeIndex(['2017-07-05', '2017-07-06', 'NaT', '2017-07-08'], + dtype='datetime64[ns]', freq=None) + >>> pd.notna(index) + array([ True, True, False, True]) + + For Series and DataFrame, the same type is returned, containing booleans. + + >>> df = pd.DataFrame([['ant', 'bee', 'cat'], ['dog', None, 'fly']]) + >>> df + 0 1 2 + 0 ant bee cat + 1 dog None fly + >>> pd.notna(df) + 0 1 2 + 0 True True True + 1 True False True + + >>> pd.notna(df[1]) + 0 True + 1 False + Name: 1, dtype: bool """ res = isna(obj) if is_scalar(res): From 9778c851589786f3e29b3a958ab307357a9ef8c0 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Tue, 27 Mar 2018 12:22:32 +0200 Subject: [PATCH 42/81] CLN: remove deprecated infer_dst keyword (#20490) --- asv_bench/benchmarks/timeseries.py | 3 +-- doc/source/timeseries.rst | 7 +++---- doc/source/whatsnew/v0.23.0.txt | 4 ++++ pandas/core/generic.py | 6 ------ pandas/core/indexes/datetimes.py | 11 ----------- pandas/core/indexes/period.py | 4 +--- pandas/tests/indexes/datetimes/test_timezones.py | 8 +------- 7 files changed, 10 insertions(+), 33 deletions(-) diff --git a/asv_bench/benchmarks/timeseries.py b/asv_bench/benchmarks/timeseries.py index e1a6bc7a68e9d..eada401d2930b 100644 --- a/asv_bench/benchmarks/timeseries.py +++ b/asv_bench/benchmarks/timeseries.py @@ -75,8 +75,7 @@ def setup(self): freq='S')) def time_infer_dst(self): - with warnings.catch_warnings(record=True): - self.index.tz_localize('US/Eastern', infer_dst=True) + self.index.tz_localize('US/Eastern', ambiguous='infer') class ResetIndex(object): diff --git a/doc/source/timeseries.rst b/doc/source/timeseries.rst index 466c48b780861..86cff4a358975 100644 --- a/doc/source/timeseries.rst +++ b/doc/source/timeseries.rst @@ -2191,10 +2191,9 @@ Ambiguous Times when Localizing In some cases, localize cannot determine the DST and non-DST hours when there are duplicates. This often happens when reading files or database records that simply -duplicate the hours. Passing ``ambiguous='infer'`` (``infer_dst`` argument in prior -releases) into ``tz_localize`` will attempt to determine the right offset. Below -the top example will fail as it contains ambiguous times and the bottom will -infer the right offset. +duplicate the hours. Passing ``ambiguous='infer'`` into ``tz_localize`` will +attempt to determine the right offset. Below the top example will fail as it +contains ambiguous times and the bottom will infer the right offset. .. ipython:: python diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index 107ce7855a00d..bf1b37e1ae35d 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -778,6 +778,10 @@ Removal of prior version deprecations/changes - The top-level functions ``pd.rolling_*``, ``pd.expanding_*`` and ``pd.ewm*`` have been removed (Deprecated since v0.18). Instead, use the DataFrame/Series methods :attr:`~DataFrame.rolling`, :attr:`~DataFrame.expanding` and :attr:`~DataFrame.ewm` (:issue:`18723`) - Imports from ``pandas.core.common`` for functions such as ``is_datetime64_dtype`` are now removed. These are located in ``pandas.api.types``. (:issue:`13634`, :issue:`19769`) +- The ``infer_dst`` keyword in :meth:`Series.tz_localize`, :meth:`DatetimeIndex.tz_localize` + and :class:`DatetimeIndex` have been removed. ``infer_dst=True`` is equivalent to + ``ambiguous='infer'``, and ``infer_dst=False`` to ``ambiguous='raise'`` (:issue:`7963`). + .. _whatsnew_0230.performance: diff --git a/pandas/core/generic.py b/pandas/core/generic.py index fc6eda0290c28..6810aff56806f 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -7937,9 +7937,6 @@ def _tz_convert(ax, tz): result.set_axis(ax, axis=axis, inplace=True) return result.__finalize__(self) - @deprecate_kwarg(old_arg_name='infer_dst', new_arg_name='ambiguous', - mapping={True: 'infer', - False: 'raise'}) def tz_localize(self, tz, axis=0, level=None, copy=True, ambiguous='raise'): """ @@ -7963,9 +7960,6 @@ def tz_localize(self, tz, axis=0, level=None, copy=True, - 'NaT' will return NaT where there are ambiguous times - 'raise' will raise an AmbiguousTimeError if there are ambiguous times - infer_dst : boolean, default False - .. deprecated:: 0.15.0 - Attempt to infer fall dst-transition hours based on order Returns ------- diff --git a/pandas/core/indexes/datetimes.py b/pandas/core/indexes/datetimes.py index e8bc9a2519333..75f4ec4f0d341 100644 --- a/pandas/core/indexes/datetimes.py +++ b/pandas/core/indexes/datetimes.py @@ -208,9 +208,6 @@ class DatetimeIndex(DatelikeOps, TimelikeOps, DatetimeIndexOpsMixin, times) - 'NaT' will return NaT where there are ambiguous times - 'raise' will raise an AmbiguousTimeError if there are ambiguous times - infer_dst : boolean, default False - .. deprecated:: 0.15.0 - Attempt to infer fall dst-transition hours based on order name : object Name to be stored in the index dayfirst : bool, default False @@ -329,8 +326,6 @@ def _add_comparison_methods(cls): _is_numeric_dtype = False _infer_as_myclass = True - @deprecate_kwarg(old_arg_name='infer_dst', new_arg_name='ambiguous', - mapping={True: 'infer', False: 'raise'}) def __new__(cls, data=None, freq=None, start=None, end=None, periods=None, tz=None, normalize=False, closed=None, ambiguous='raise', @@ -2270,8 +2265,6 @@ def tz_convert(self, tz): # No conversion since timestamps are all UTC to begin with return self._shallow_copy(tz=tz) - @deprecate_kwarg(old_arg_name='infer_dst', new_arg_name='ambiguous', - mapping={True: 'infer', False: 'raise'}) def tz_localize(self, tz, ambiguous='raise', errors='raise'): """ Localize tz-naive DatetimeIndex to tz-aware DatetimeIndex. @@ -2306,10 +2299,6 @@ def tz_localize(self, tz, ambiguous='raise', errors='raise'): .. versionadded:: 0.19.0 - infer_dst : boolean, default False - .. deprecated:: 0.15.0 - Attempt to infer fall dst-transition hours based on order - Returns ------- DatetimeIndex diff --git a/pandas/core/indexes/period.py b/pandas/core/indexes/period.py index 705dc36d92522..4a224d4e6ee7f 100644 --- a/pandas/core/indexes/period.py +++ b/pandas/core/indexes/period.py @@ -1095,7 +1095,7 @@ def tz_convert(self, tz): """ raise NotImplementedError("Not yet implemented for PeriodIndex") - def tz_localize(self, tz, infer_dst=False): + def tz_localize(self, tz, ambiguous='raise'): """ Localize tz-naive DatetimeIndex to given time zone (using pytz/dateutil), or remove timezone from tz-aware DatetimeIndex @@ -1106,8 +1106,6 @@ def tz_localize(self, tz, infer_dst=False): Time zone for time. Corresponding timestamps would be converted to time zone of the TimeSeries. None will remove timezone holding local time. - infer_dst : boolean, default False - Attempt to infer fall dst-transition hours based on order Returns ------- diff --git a/pandas/tests/indexes/datetimes/test_timezones.py b/pandas/tests/indexes/datetimes/test_timezones.py index 2913812db0dd4..a8191816238b1 100644 --- a/pandas/tests/indexes/datetimes/test_timezones.py +++ b/pandas/tests/indexes/datetimes/test_timezones.py @@ -341,9 +341,6 @@ def test_dti_tz_localize_ambiguous_infer(self, tz): di = DatetimeIndex(times) localized = di.tz_localize(tz, ambiguous='infer') tm.assert_index_equal(dr, localized) - with tm.assert_produces_warning(FutureWarning): - localized_old = di.tz_localize(tz, infer_dst=True) - tm.assert_index_equal(dr, localized_old) tm.assert_index_equal(dr, DatetimeIndex(times, tz=tz, ambiguous='infer')) @@ -353,9 +350,6 @@ def test_dti_tz_localize_ambiguous_infer(self, tz): localized = dr.tz_localize(tz) localized_infer = dr.tz_localize(tz, ambiguous='infer') tm.assert_index_equal(localized, localized_infer) - with tm.assert_produces_warning(FutureWarning): - localized_infer_old = dr.tz_localize(tz, infer_dst=True) - tm.assert_index_equal(localized, localized_infer_old) @pytest.mark.parametrize('tz', [pytz.timezone('US/Eastern'), gettz('US/Eastern')]) @@ -525,7 +519,7 @@ def test_dti_tz_localize_ambiguous_flags(self, tz): localized = DatetimeIndex(times, tz=tz, ambiguous=is_dst) tm.assert_index_equal(dr, localized) - # Test duplicate times where infer_dst fails + # Test duplicate times where inferring the dst fails times += times di = DatetimeIndex(times) From a346778dc70586cfaff292d12e29f9f593ee71c7 Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Tue, 27 Mar 2018 05:28:10 -0500 Subject: [PATCH 43/81] Parametrized NA sentinel for factorize (#20473) --- pandas/_libs/hashtable.pyx | 8 ++-- pandas/_libs/hashtable_class_helper.pxi.in | 48 +++++++++++++++------- pandas/core/algorithms.py | 29 +++++++++---- pandas/core/arrays/categorical.py | 6 +-- pandas/core/dtypes/missing.py | 3 +- pandas/tests/dtypes/test_missing.py | 41 ++++++++++-------- pandas/tests/test_algos.py | 30 ++++++++++++++ 7 files changed, 115 insertions(+), 50 deletions(-) diff --git a/pandas/_libs/hashtable.pyx b/pandas/_libs/hashtable.pyx index 07b4b80603e03..15d93374da3a9 100644 --- a/pandas/_libs/hashtable.pyx +++ b/pandas/_libs/hashtable.pyx @@ -70,7 +70,7 @@ cdef class Factorizer: return self.count def factorize(self, ndarray[object] values, sort=False, na_sentinel=-1, - check_null=True): + na_value=None): """ Factorize values with nans replaced by na_sentinel >>> factorize(np.array([1,2,np.nan], dtype='O'), na_sentinel=20) @@ -81,7 +81,7 @@ cdef class Factorizer: uniques.extend(self.uniques.to_array()) self.uniques = uniques labels = self.table.get_labels(values, self.uniques, - self.count, na_sentinel, check_null) + self.count, na_sentinel, na_value) mask = (labels == na_sentinel) # sort on if sort: @@ -114,7 +114,7 @@ cdef class Int64Factorizer: return self.count def factorize(self, int64_t[:] values, sort=False, - na_sentinel=-1, check_null=True): + na_sentinel=-1, na_value=None): """ Factorize values with nans replaced by na_sentinel >>> factorize(np.array([1,2,np.nan], dtype='O'), na_sentinel=20) @@ -126,7 +126,7 @@ cdef class Int64Factorizer: self.uniques = uniques labels = self.table.get_labels(values, self.uniques, self.count, na_sentinel, - check_null) + na_value=na_value) # sort on if sort: diff --git a/pandas/_libs/hashtable_class_helper.pxi.in b/pandas/_libs/hashtable_class_helper.pxi.in index bca4e388f3279..eca66f78499db 100644 --- a/pandas/_libs/hashtable_class_helper.pxi.in +++ b/pandas/_libs/hashtable_class_helper.pxi.in @@ -250,13 +250,13 @@ cdef class HashTable: {{py: -# name, dtype, null_condition, float_group -dtypes = [('Float64', 'float64', 'val != val', True), - ('UInt64', 'uint64', 'False', False), - ('Int64', 'int64', 'val == iNaT', False)] +# name, dtype, float_group, default_na_value +dtypes = [('Float64', 'float64', True, 'nan'), + ('UInt64', 'uint64', False, 0), + ('Int64', 'int64', False, 'iNaT')] def get_dispatch(dtypes): - for (name, dtype, null_condition, float_group) in dtypes: + for (name, dtype, float_group, default_na_value) in dtypes: unique_template = """\ cdef: Py_ssize_t i, n = len(values) @@ -298,13 +298,13 @@ def get_dispatch(dtypes): return uniques.to_array() """ - unique_template = unique_template.format(name=name, dtype=dtype, null_condition=null_condition, float_group=float_group) + unique_template = unique_template.format(name=name, dtype=dtype, float_group=float_group) - yield (name, dtype, null_condition, float_group, unique_template) + yield (name, dtype, float_group, default_na_value, unique_template) }} -{{for name, dtype, null_condition, float_group, unique_template in get_dispatch(dtypes)}} +{{for name, dtype, float_group, default_na_value, unique_template in get_dispatch(dtypes)}} cdef class {{name}}HashTable(HashTable): @@ -408,24 +408,36 @@ cdef class {{name}}HashTable(HashTable): @cython.boundscheck(False) def get_labels(self, {{dtype}}_t[:] values, {{name}}Vector uniques, Py_ssize_t count_prior, Py_ssize_t na_sentinel, - bint check_null=True): + object na_value=None): cdef: Py_ssize_t i, n = len(values) int64_t[:] labels Py_ssize_t idx, count = count_prior int ret = 0 - {{dtype}}_t val + {{dtype}}_t val, na_value2 khiter_t k {{name}}VectorData *ud + bint use_na_value labels = np.empty(n, dtype=np.int64) ud = uniques.data + use_na_value = na_value is not None + + if use_na_value: + # We need this na_value2 because we want to allow users + # to *optionally* specify an NA sentinel *of the correct* type. + # We use None, to make it optional, which requires `object` type + # for the parameter. To please the compiler, we use na_value2, + # which is only used if it's *specified*. + na_value2 = <{{dtype}}_t>na_value + else: + na_value2 = {{default_na_value}} with nogil: for i in range(n): val = values[i] - if check_null and {{null_condition}}: + if val != val or (use_na_value and val == na_value2): labels[i] = na_sentinel continue @@ -695,7 +707,7 @@ cdef class StringHashTable(HashTable): @cython.boundscheck(False) def get_labels(self, ndarray[object] values, ObjectVector uniques, Py_ssize_t count_prior, int64_t na_sentinel, - bint check_null=1): + object na_value=None): cdef: Py_ssize_t i, n = len(values) int64_t[:] labels @@ -706,10 +718,12 @@ cdef class StringHashTable(HashTable): char *v char **vecs khiter_t k + bint use_na_value # these by-definition *must* be strings labels = np.zeros(n, dtype=np.int64) uindexer = np.empty(n, dtype=np.int64) + use_na_value = na_value is not None # pre-filter out missing # and assign pointers @@ -717,7 +731,8 @@ cdef class StringHashTable(HashTable): for i in range(n): val = values[i] - if PyUnicode_Check(val) or PyString_Check(val): + if ((PyUnicode_Check(val) or PyString_Check(val)) and + not (use_na_value and val == na_value)): v = util.get_c_string(val) vecs[i] = v else: @@ -868,7 +883,7 @@ cdef class PyObjectHashTable(HashTable): def get_labels(self, ndarray[object] values, ObjectVector uniques, Py_ssize_t count_prior, int64_t na_sentinel, - bint check_null=True): + object na_value=None): cdef: Py_ssize_t i, n = len(values) int64_t[:] labels @@ -876,14 +891,17 @@ cdef class PyObjectHashTable(HashTable): int ret = 0 object val khiter_t k + bint use_na_value labels = np.empty(n, dtype=np.int64) + use_na_value = na_value is not None for i in range(n): val = values[i] hash(val) - if check_null and val != val or val is None: + if ((val != val or val is None) or + (use_na_value and val == na_value)): labels[i] = na_sentinel continue diff --git a/pandas/core/algorithms.py b/pandas/core/algorithms.py index de2e638265f1e..45f86f044a4b2 100644 --- a/pandas/core/algorithms.py +++ b/pandas/core/algorithms.py @@ -29,7 +29,7 @@ _ensure_float64, _ensure_uint64, _ensure_int64) from pandas.compat.numpy import _np_version_under1p10 -from pandas.core.dtypes.missing import isna +from pandas.core.dtypes.missing import isna, na_value_for_dtype from pandas.core import common as com from pandas._libs import algos, lib, hashtable as htable @@ -435,7 +435,8 @@ def isin(comps, values): return f(comps, values) -def _factorize_array(values, check_nulls, na_sentinel=-1, size_hint=None): +def _factorize_array(values, na_sentinel=-1, size_hint=None, + na_value=None): """Factorize an array-like to labels and uniques. This doesn't do any coercion of types or unboxing before factorization. @@ -443,11 +444,14 @@ def _factorize_array(values, check_nulls, na_sentinel=-1, size_hint=None): Parameters ---------- values : ndarray - check_nulls : bool - Whether to check for nulls in the hashtable's 'get_labels' method. na_sentinel : int, default -1 size_hint : int, optional Passsed through to the hashtable's 'get_labels' method + na_value : object, optional + A value in `values` to consider missing. Note: only use this + parameter when you know that you don't have any values pandas would + consider missing in the array (NaN for float data, iNaT for + datetimes, etc.). Returns ------- @@ -457,7 +461,8 @@ def _factorize_array(values, check_nulls, na_sentinel=-1, size_hint=None): table = hash_klass(size_hint or len(values)) uniques = vec_klass() - labels = table.get_labels(values, uniques, 0, na_sentinel, check_nulls) + labels = table.get_labels(values, uniques, 0, na_sentinel, + na_value=na_value) labels = _ensure_platform_int(labels) uniques = uniques.to_array() @@ -508,10 +513,18 @@ def factorize(values, sort=False, order=None, na_sentinel=-1, size_hint=None): dtype = original.dtype else: values, dtype, _ = _ensure_data(values) - check_nulls = not is_integer_dtype(original) - labels, uniques = _factorize_array(values, check_nulls, + + if (is_datetime64_any_dtype(original) or + is_timedelta64_dtype(original) or + is_period_dtype(original)): + na_value = na_value_for_dtype(original.dtype) + else: + na_value = None + + labels, uniques = _factorize_array(values, na_sentinel=na_sentinel, - size_hint=size_hint) + size_hint=size_hint, + na_value=na_value) if sort and len(uniques) > 0: from pandas.core.sorting import safe_sort diff --git a/pandas/core/arrays/categorical.py b/pandas/core/arrays/categorical.py index 6eadef37da344..ac57660300be4 100644 --- a/pandas/core/arrays/categorical.py +++ b/pandas/core/arrays/categorical.py @@ -7,7 +7,6 @@ from pandas import compat from pandas.compat import u, lzip from pandas._libs import lib, algos as libalgos -from pandas._libs.tslib import iNaT from pandas.core.dtypes.generic import ( ABCSeries, ABCIndexClass, ABCCategoricalIndex) @@ -2163,11 +2162,10 @@ def factorize(self, na_sentinel=-1): from pandas.core.algorithms import _factorize_array codes = self.codes.astype('int64') - codes[codes == -1] = iNaT # We set missing codes, normally -1, to iNaT so that the # Int64HashTable treats them as missing values. - labels, uniques = _factorize_array(codes, check_nulls=True, - na_sentinel=na_sentinel) + labels, uniques = _factorize_array(codes, na_sentinel=na_sentinel, + na_value=-1) uniques = self._constructor(self.categories.take(uniques), categories=self.categories, ordered=self.ordered) diff --git a/pandas/core/dtypes/missing.py b/pandas/core/dtypes/missing.py index 2c8d229f9b0cb..2c98cedd7d715 100644 --- a/pandas/core/dtypes/missing.py +++ b/pandas/core/dtypes/missing.py @@ -11,6 +11,7 @@ is_datetimelike_v_numeric, is_float_dtype, is_datetime64_dtype, is_datetime64tz_dtype, is_timedelta64_dtype, is_interval_dtype, + is_period_dtype, is_complex_dtype, is_string_like_dtype, is_bool_dtype, is_integer_dtype, is_dtype_equal, @@ -502,7 +503,7 @@ def na_value_for_dtype(dtype, compat=True): dtype = pandas_dtype(dtype) if (is_datetime64_dtype(dtype) or is_datetime64tz_dtype(dtype) or - is_timedelta64_dtype(dtype)): + is_timedelta64_dtype(dtype) or is_period_dtype(dtype)): return NaT elif is_float_dtype(dtype): return np.nan diff --git a/pandas/tests/dtypes/test_missing.py b/pandas/tests/dtypes/test_missing.py index 4f208bc352c70..365d8d762d673 100644 --- a/pandas/tests/dtypes/test_missing.py +++ b/pandas/tests/dtypes/test_missing.py @@ -15,7 +15,8 @@ from pandas import (NaT, Float64Index, Series, DatetimeIndex, TimedeltaIndex, date_range) from pandas.core.dtypes.common import is_scalar -from pandas.core.dtypes.dtypes import DatetimeTZDtype +from pandas.core.dtypes.dtypes import ( + DatetimeTZDtype, PeriodDtype, IntervalDtype) from pandas.core.dtypes.missing import ( array_equivalent, isna, notna, isnull, notnull, na_value_for_dtype) @@ -311,23 +312,27 @@ def test_array_equivalent_str(): np.array(['A', 'X'], dtype=dtype)) -def test_na_value_for_dtype(): - for dtype in [np.dtype('M8[ns]'), np.dtype('m8[ns]'), - DatetimeTZDtype('datetime64[ns, US/Eastern]')]: - assert na_value_for_dtype(dtype) is NaT - - for dtype in ['u1', 'u2', 'u4', 'u8', - 'i1', 'i2', 'i4', 'i8']: - assert na_value_for_dtype(np.dtype(dtype)) == 0 - - for dtype in ['bool']: - assert na_value_for_dtype(np.dtype(dtype)) is False - - for dtype in ['f2', 'f4', 'f8']: - assert np.isnan(na_value_for_dtype(np.dtype(dtype))) - - for dtype in ['O']: - assert np.isnan(na_value_for_dtype(np.dtype(dtype))) +@pytest.mark.parametrize('dtype, na_value', [ + # Datetime-like + (np.dtype("M8[ns]"), NaT), + (np.dtype("m8[ns]"), NaT), + (DatetimeTZDtype('datetime64[ns, US/Eastern]'), NaT), + (PeriodDtype("M"), NaT), + # Integer + ('u1', 0), ('u2', 0), ('u4', 0), ('u8', 0), + ('i1', 0), ('i2', 0), ('i4', 0), ('i8', 0), + # Bool + ('bool', False), + # Float + ('f2', np.nan), ('f4', np.nan), ('f8', np.nan), + # Object + ('O', np.nan), + # Interval + (IntervalDtype(), np.nan), +]) +def test_na_value_for_dtype(dtype, na_value): + result = na_value_for_dtype(dtype) + assert result is na_value class TestNAObj(object): diff --git a/pandas/tests/test_algos.py b/pandas/tests/test_algos.py index 884b1eb7342c6..ada4f880e92a4 100644 --- a/pandas/tests/test_algos.py +++ b/pandas/tests/test_algos.py @@ -257,6 +257,36 @@ def test_deprecate_order(self): with tm.assert_produces_warning(False): algos.factorize(data) + @pytest.mark.parametrize('data', [ + np.array([0, 1, 0], dtype='u8'), + np.array([-2**63, 1, -2**63], dtype='i8'), + np.array(['__nan__', 'foo', '__nan__'], dtype='object'), + ]) + def test_parametrized_factorize_na_value_default(self, data): + # arrays that include the NA default for that type, but isn't used. + l, u = algos.factorize(data) + expected_uniques = data[[0, 1]] + expected_labels = np.array([0, 1, 0], dtype='i8') + tm.assert_numpy_array_equal(l, expected_labels) + tm.assert_numpy_array_equal(u, expected_uniques) + + @pytest.mark.parametrize('data, na_value', [ + (np.array([0, 1, 0, 2], dtype='u8'), 0), + (np.array([1, 0, 1, 2], dtype='u8'), 1), + (np.array([-2**63, 1, -2**63, 0], dtype='i8'), -2**63), + (np.array([1, -2**63, 1, 0], dtype='i8'), 1), + (np.array(['a', '', 'a', 'b'], dtype=object), 'a'), + (np.array([(), ('a', 1), (), ('a', 2)], dtype=object), ()), + (np.array([('a', 1), (), ('a', 1), ('a', 2)], dtype=object), + ('a', 1)), + ]) + def test_parametrized_factorize_na_value(self, data, na_value): + l, u = algos._factorize_array(data, na_value=na_value) + expected_uniques = data[[1, 3]] + expected_labels = np.array([-1, 0, -1, 1], dtype='i8') + tm.assert_numpy_array_equal(l, expected_labels) + tm.assert_numpy_array_equal(u, expected_uniques) + class TestUnique(object): From b4365e842dd9bda528311d17377b3d023112ee2f Mon Sep 17 00:00:00 2001 From: Grant Smith Date: Tue, 27 Mar 2018 07:29:07 -0400 Subject: [PATCH 44/81] DEPR: deprecate get_ftype_counts (GH18243) (#20404) --- doc/source/whatsnew/v0.23.0.txt | 1 + pandas/core/generic.py | 6 ++++++ pandas/tests/series/test_dtypes.py | 6 ++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index bf1b37e1ae35d..d7c92ed822ffc 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -744,6 +744,7 @@ Deprecations - ``pandas.tseries.plotting.tsplot`` is deprecated. Use :func:`Series.plot` instead (:issue:`18627`) - ``Index.summary()`` is deprecated and will be removed in a future version (:issue:`18217`) +- ``NDFrame.get_ftype_counts()`` is deprecated and will be removed in a future version (:issue:`18243`) .. _whatsnew_0230.prior_deprecations: diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 6810aff56806f..80885fd9ef139 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -4726,6 +4726,8 @@ def get_ftype_counts(self): """ Return counts of unique ftypes in this object. + .. deprecated:: 0.23.0 + This is useful for SparseDataFrame or for DataFrames containing sparse arrays. @@ -4756,6 +4758,10 @@ def get_ftype_counts(self): object:dense 1 dtype: int64 """ + warnings.warn("get_ftype_counts is deprecated and will " + "be removed in a future version", + FutureWarning, stacklevel=2) + from pandas import Series return Series(self._data.get_ftype_counts()) diff --git a/pandas/tests/series/test_dtypes.py b/pandas/tests/series/test_dtypes.py index 56ff092dd0a27..dd1b623f0f7ff 100644 --- a/pandas/tests/series/test_dtypes.py +++ b/pandas/tests/series/test_dtypes.py @@ -64,8 +64,10 @@ def test_dtype(self): assert self.ts.ftypes == 'float64:dense' tm.assert_series_equal(self.ts.get_dtype_counts(), Series(1, ['float64'])) - tm.assert_series_equal(self.ts.get_ftype_counts(), - Series(1, ['float64:dense'])) + # GH18243 - Assert .get_ftype_counts is deprecated + with tm.assert_produces_warning(FutureWarning): + tm.assert_series_equal(self.ts.get_ftype_counts(), + Series(1, ['float64:dense'])) @pytest.mark.parametrize("value", [np.nan, np.inf]) @pytest.mark.parametrize("dtype", [np.int32, np.int64]) From 15b71380cbfa6117096b3541ecae9a664e1b5f46 Mon Sep 17 00:00:00 2001 From: froessler <11539266+fdroessler@users.noreply.github.com> Date: Tue, 27 Mar 2018 13:10:37 +0100 Subject: [PATCH 45/81] DOC: update the Series.str.join docstring (#20463) --- pandas/core/strings.py | 52 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/pandas/core/strings.py b/pandas/core/strings.py index d6a67435aeb09..355c50aa06c1f 100644 --- a/pandas/core/strings.py +++ b/pandas/core/strings.py @@ -976,17 +976,59 @@ def str_get_dummies(arr, sep='|'): def str_join(arr, sep): """ - Join lists contained as elements in the Series/Index with - passed delimiter. Equivalent to :meth:`str.join`. + Join lists contained as elements in the Series/Index with passed delimiter. + + If the elements of a Series are lists themselves, join the content of these + lists using the delimiter passed to the function. + This function is an equivalent to :meth:`str.join`. Parameters ---------- - sep : string - Delimiter + sep : str + Delimiter to use between list entries. Returns ------- - joined : Series/Index of objects + Series/Index: object + + Notes + ----- + If any of the lists does not contain string objects the result of the join + will be `NaN`. + + See Also + -------- + str.join : Standard library version of this method. + Series.str.split : Split strings around given separator/delimiter. + + Examples + -------- + + Example with a list that contains non-string elements. + + >>> s = pd.Series([['lion', 'elephant', 'zebra'], + ... [1.1, 2.2, 3.3], + ... ['cat', np.nan, 'dog'], + ... ['cow', 4.5, 'goat'] + ... ['duck', ['swan', 'fish'], 'guppy']]) + >>> s + 0 [lion, elephant, zebra] + 1 [1.1, 2.2, 3.3] + 2 [cat, nan, dog] + 3 [cow, 4.5, goat] + 4 [duck, [swan, fish], guppy] + dtype: object + + Join all lists using an '-', the lists containing object(s) of types other + than str will become a NaN. + + >>> s.str.join('-') + 0 lion-elephant-zebra + 1 NaN + 2 NaN + 3 NaN + 4 NaN + dtype: object """ return _na_map(sep.join, arr) From b0143e540a5f47619025f9574be77a2a234c1398 Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Tue, 27 Mar 2018 10:40:49 -0500 Subject: [PATCH 46/81] ENH/API: ExtensionArray.factorize (#20361) --- pandas/core/algorithms.py | 140 +++++++++++++++--- pandas/core/arrays/base.py | 85 +++++++++++ pandas/core/arrays/categorical.py | 59 +------- pandas/core/base.py | 27 ++-- pandas/tests/extension/base/methods.py | 21 +++ .../extension/category/test_categorical.py | 5 + pandas/tests/extension/conftest.py | 11 ++ pandas/tests/extension/decimal/array.py | 4 + .../tests/extension/decimal/test_decimal.py | 9 ++ pandas/tests/extension/json/array.py | 8 + pandas/tests/extension/json/test_json.py | 11 ++ 11 files changed, 290 insertions(+), 90 deletions(-) diff --git a/pandas/core/algorithms.py b/pandas/core/algorithms.py index 45f86f044a4b2..065a5782aced1 100644 --- a/pandas/core/algorithms.py +++ b/pandas/core/algorithms.py @@ -4,6 +4,8 @@ """ from __future__ import division from warnings import warn, catch_warnings +from textwrap import dedent + import numpy as np from pandas.core.dtypes.cast import ( @@ -34,7 +36,10 @@ from pandas.core import common as com from pandas._libs import algos, lib, hashtable as htable from pandas._libs.tslib import iNaT -from pandas.util._decorators import deprecate_kwarg +from pandas.util._decorators import (Appender, Substitution, + deprecate_kwarg) + +_shared_docs = {} # --------------- # @@ -146,10 +151,9 @@ def _reconstruct_data(values, dtype, original): Returns ------- Index for extension types, otherwise ndarray casted to dtype - """ from pandas import Index - if is_categorical_dtype(dtype): + if is_extension_array_dtype(dtype): pass elif is_datetime64tz_dtype(dtype) or is_period_dtype(dtype): values = Index(original)._shallow_copy(values, name=None) @@ -469,32 +473,124 @@ def _factorize_array(values, na_sentinel=-1, size_hint=None, return labels, uniques -@deprecate_kwarg(old_arg_name='order', new_arg_name=None) -def factorize(values, sort=False, order=None, na_sentinel=-1, size_hint=None): - """ - Encode input values as an enumerated type or categorical variable +_shared_docs['factorize'] = """ + Encode the object as an enumerated type or categorical variable. + + This method is useful for obtaining a numeric representation of an + array when all that matters is identifying distinct values. `factorize` + is available as both a top-level function :func:`pandas.factorize`, + and as a method :meth:`Series.factorize` and :meth:`Index.factorize`. Parameters ---------- - values : Sequence - ndarrays must be 1-D. Sequences that aren't pandas objects are - coereced to ndarrays before factorization. - sort : boolean, default False - Sort by values + %(values)s%(sort)s%(order)s na_sentinel : int, default -1 - Value to mark "not found" - size_hint : hint to the hashtable sizer + Value to mark "not found". + %(size_hint)s\ Returns ------- - labels : the indexer to the original array - uniques : ndarray (1-d) or Index - the unique values. Index is returned when passed values is Index or - Series + labels : ndarray + An integer ndarray that's an indexer into `uniques`. + ``uniques.take(labels)`` will have the same values as `values`. + uniques : ndarray, Index, or Categorical + The unique valid values. When `values` is Categorical, `uniques` + is a Categorical. When `values` is some other pandas object, an + `Index` is returned. Otherwise, a 1-D ndarray is returned. + + .. note :: + + Even if there's a missing value in `values`, `uniques` will + *not* contain an entry for it. + + See Also + -------- + pandas.cut : Discretize continuous-valued array. + pandas.unique : Find the unique valuse in an array. + + Examples + -------- + These examples all show factorize as a top-level method like + ``pd.factorize(values)``. The results are identical for methods like + :meth:`Series.factorize`. + + >>> labels, uniques = pd.factorize(['b', 'b', 'a', 'c', 'b']) + >>> labels + array([0, 0, 1, 2, 0]) + >>> uniques + array(['b', 'a', 'c'], dtype=object) + + With ``sort=True``, the `uniques` will be sorted, and `labels` will be + shuffled so that the relationship is the maintained. + + >>> labels, uniques = pd.factorize(['b', 'b', 'a', 'c', 'b'], sort=True) + >>> labels + array([1, 1, 0, 2, 1]) + >>> uniques + array(['a', 'b', 'c'], dtype=object) + + Missing values are indicated in `labels` with `na_sentinel` + (``-1`` by default). Note that missing values are never + included in `uniques`. + + >>> labels, uniques = pd.factorize(['b', None, 'a', 'c', 'b']) + >>> labels + array([ 0, -1, 1, 2, 0]) + >>> uniques + array(['b', 'a', 'c'], dtype=object) - note: an array of Periods will ignore sort as it returns an always sorted - PeriodIndex. + Thus far, we've only factorized lists (which are internally coerced to + NumPy arrays). When factorizing pandas objects, the type of `uniques` + will differ. For Categoricals, a `Categorical` is returned. + + >>> cat = pd.Categorical(['a', 'a', 'c'], categories=['a', 'b', 'c']) + >>> labels, uniques = pd.factorize(cat) + >>> labels + array([0, 0, 1]) + >>> uniques + [a, c] + Categories (3, object): [a, b, c] + + Notice that ``'b'`` is in ``uniques.categories``, desipite not being + present in ``cat.values``. + + For all other pandas objects, an Index of the appropriate type is + returned. + + >>> cat = pd.Series(['a', 'a', 'c']) + >>> labels, uniques = pd.factorize(cat) + >>> labels + array([0, 0, 1]) + >>> uniques + Index(['a', 'c'], dtype='object') """ + + +@Substitution( + values=dedent("""\ + values : sequence + A 1-D seqeunce. Sequences that aren't pandas objects are + coereced to ndarrays before factorization. + """), + order=dedent("""\ + order + .. deprecated:: 0.23.0 + + This parameter has no effect and is deprecated. + """), + sort=dedent("""\ + sort : bool, default False + Sort `uniques` and shuffle `labels` to maintain the + relationship. + """), + size_hint=dedent("""\ + size_hint : int, optional + Hint to the hashtable sizer. + """), +) +@Appender(_shared_docs['factorize']) +@deprecate_kwarg(old_arg_name='order', new_arg_name=None) +def factorize(values, sort=False, order=None, na_sentinel=-1, size_hint=None): # Implementation notes: This method is responsible for 3 things # 1.) coercing data to array-like (ndarray, Index, extension array) # 2.) factorizing labels and uniques @@ -507,9 +603,9 @@ def factorize(values, sort=False, order=None, na_sentinel=-1, size_hint=None): values = _ensure_arraylike(values) original = values - if is_categorical_dtype(values): + if is_extension_array_dtype(values): values = getattr(values, '_values', values) - labels, uniques = values.factorize() + labels, uniques = values.factorize(na_sentinel=na_sentinel) dtype = original.dtype else: values, dtype, _ = _ensure_data(values) diff --git a/pandas/core/arrays/base.py b/pandas/core/arrays/base.py index d53caa265b9b3..c281bd80cb274 100644 --- a/pandas/core/arrays/base.py +++ b/pandas/core/arrays/base.py @@ -77,6 +77,24 @@ def _constructor_from_sequence(cls, scalars): """ raise AbstractMethodError(cls) + @classmethod + def _from_factorized(cls, values, original): + """Reconstruct an ExtensionArray after factorization. + + Parameters + ---------- + values : ndarray + An integer ndarray with the factorized values. + original : ExtensionArray + The original ExtensionArray that factorize was called on. + + See Also + -------- + pandas.factorize + ExtensionArray.factorize + """ + raise AbstractMethodError(cls) + # ------------------------------------------------------------------------ # Must be a Sequence # ------------------------------------------------------------------------ @@ -353,6 +371,73 @@ def unique(self): uniques = unique(self.astype(object)) return self._constructor_from_sequence(uniques) + def _values_for_factorize(self): + # type: () -> Tuple[ndarray, Any] + """Return an array and missing value suitable for factorization. + + Returns + ------- + values : ndarray + An array suitable for factoraization. This should maintain order + and be a supported dtype (Float64, Int64, UInt64, String, Object). + By default, the extension array is cast to object dtype. + na_value : object + The value in `values` to consider missing. This will be treated + as NA in the factorization routines, so it will be coded as + `na_sentinal` and not included in `uniques`. By default, + ``np.nan`` is used. + """ + return self.astype(object), np.nan + + def factorize(self, na_sentinel=-1): + # type: (int) -> Tuple[ndarray, ExtensionArray] + """Encode the extension array as an enumerated type. + + Parameters + ---------- + na_sentinel : int, default -1 + Value to use in the `labels` array to indicate missing values. + + Returns + ------- + labels : ndarray + An interger NumPy array that's an indexer into the original + ExtensionArray. + uniques : ExtensionArray + An ExtensionArray containing the unique values of `self`. + + .. note:: + + uniques will *not* contain an entry for the NA value of + the ExtensionArray if there are any missing values present + in `self`. + + See Also + -------- + pandas.factorize : Top-level factorize method that dispatches here. + + Notes + ----- + :meth:`pandas.factorize` offers a `sort` keyword as well. + """ + # Impelmentor note: There are two ways to override the behavior of + # pandas.factorize + # 1. _values_for_factorize and _from_factorize. + # Specify the values passed to pandas' internal factorization + # routines, and how to convert from those values back to the + # original ExtensionArray. + # 2. ExtensionArray.factorize. + # Complete control over factorization. + from pandas.core.algorithms import _factorize_array + + arr, na_value = self._values_for_factorize() + + labels, uniques = _factorize_array(arr, na_sentinel=na_sentinel, + na_value=na_value) + + uniques = self._from_factorized(uniques, self) + return labels, uniques + # ------------------------------------------------------------------------ # Indexing methods # ------------------------------------------------------------------------ diff --git a/pandas/core/arrays/categorical.py b/pandas/core/arrays/categorical.py index ac57660300be4..b5a4785fd98a6 100644 --- a/pandas/core/arrays/categorical.py +++ b/pandas/core/arrays/categorical.py @@ -2118,58 +2118,15 @@ def unique(self): take_codes = sorted(take_codes) return cat.set_categories(cat.categories.take(take_codes)) - def factorize(self, na_sentinel=-1): - """Encode the Categorical as an enumerated type. - - Parameters - ---------- - sort : boolean, default False - Sort by values - na_sentinel: int, default -1 - Value to mark "not found" - - Returns - ------- - labels : ndarray - An integer NumPy array that's an indexer into the original - Categorical - uniques : Categorical - A Categorical whose values are the unique values and - whose dtype matches the original CategoricalDtype. Note that if - there any unobserved categories in ``self`` will not be present - in ``uniques.values``. They will be present in - ``uniques.categories`` - - Examples - -------- - >>> cat = pd.Categorical(['a', 'a', 'c'], categories=['a', 'b', 'c']) - >>> labels, uniques = cat.factorize() - >>> labels - (array([0, 0, 1]), - >>> uniques - [a, c] - Categories (3, object): [a, b, c]) - - Missing values are handled - - >>> labels, uniques = pd.factorize(pd.Categorical(['a', 'b', None])) - >>> labels - array([ 0, 1, -1]) - >>> uniques - [a, b] - Categories (2, object): [a, b] - """ - from pandas.core.algorithms import _factorize_array - + def _values_for_factorize(self): codes = self.codes.astype('int64') - # We set missing codes, normally -1, to iNaT so that the - # Int64HashTable treats them as missing values. - labels, uniques = _factorize_array(codes, na_sentinel=na_sentinel, - na_value=-1) - uniques = self._constructor(self.categories.take(uniques), - categories=self.categories, - ordered=self.ordered) - return labels, uniques + return codes, -1 + + @classmethod + def _from_factorized(cls, uniques, original): + return original._constructor(original.categories.take(uniques), + categories=original.categories, + ordered=original.ordered) def equals(self, other): """ diff --git a/pandas/core/base.py b/pandas/core/base.py index b3eb9a0ae7530..99e2af9fb3aeb 100644 --- a/pandas/core/base.py +++ b/pandas/core/base.py @@ -2,6 +2,7 @@ Base and utility classes for pandas objects. """ import warnings +import textwrap from pandas import compat from pandas.compat import builtins import numpy as np @@ -1151,24 +1152,16 @@ def memory_usage(self, deep=False): v += lib.memory_usage_of_objects(self.values) return v + @Substitution( + values='', order='', size_hint='', + sort=textwrap.dedent("""\ + sort : boolean, default False + Sort `uniques` and shuffle `labels` to maintain the + relationship. + """)) + @Appender(algorithms._shared_docs['factorize']) def factorize(self, sort=False, na_sentinel=-1): - """ - Encode the object as an enumerated type or categorical variable - - Parameters - ---------- - sort : boolean, default False - Sort by values - na_sentinel: int, default -1 - Value to mark "not found" - - Returns - ------- - labels : the indexer to the original array - uniques : the unique Index - """ - from pandas.core.algorithms import factorize - return factorize(self, sort=sort, na_sentinel=na_sentinel) + return algorithms.factorize(self, sort=sort, na_sentinel=na_sentinel) _shared_docs['searchsorted'] = ( """Find indices where elements should be inserted to maintain order. diff --git a/pandas/tests/extension/base/methods.py b/pandas/tests/extension/base/methods.py index 4d467d62d0a56..f9f079cb21858 100644 --- a/pandas/tests/extension/base/methods.py +++ b/pandas/tests/extension/base/methods.py @@ -2,6 +2,7 @@ import numpy as np import pandas as pd +import pandas.util.testing as tm from .base import BaseExtensionTests @@ -82,3 +83,23 @@ def test_unique(self, data, box, method): assert len(result) == 1 assert isinstance(result, type(data)) assert result[0] == duplicated[0] + + @pytest.mark.parametrize('na_sentinel', [-1, -2]) + def test_factorize(self, data_for_grouping, na_sentinel): + labels, uniques = pd.factorize(data_for_grouping, + na_sentinel=na_sentinel) + expected_labels = np.array([0, 0, na_sentinel, + na_sentinel, 1, 1, 0, 2], + dtype='int64') + expected_uniques = data_for_grouping.take([0, 4, 7]) + + tm.assert_numpy_array_equal(labels, expected_labels) + self.assert_extension_array_equal(uniques, expected_uniques) + + @pytest.mark.parametrize('na_sentinel', [-1, -2]) + def test_factorize_equivalence(self, data_for_grouping, na_sentinel): + l1, u1 = pd.factorize(data_for_grouping, na_sentinel=na_sentinel) + l2, u2 = data_for_grouping.factorize(na_sentinel=na_sentinel) + + tm.assert_numpy_array_equal(l1, l2) + self.assert_extension_array_equal(u1, u2) diff --git a/pandas/tests/extension/category/test_categorical.py b/pandas/tests/extension/category/test_categorical.py index b602d9ee78e2a..7528299578326 100644 --- a/pandas/tests/extension/category/test_categorical.py +++ b/pandas/tests/extension/category/test_categorical.py @@ -46,6 +46,11 @@ def na_value(): return np.nan +@pytest.fixture +def data_for_grouping(): + return Categorical(['a', 'a', None, None, 'b', 'b', 'a', 'c']) + + class TestDtype(base.BaseDtypeTests): pass diff --git a/pandas/tests/extension/conftest.py b/pandas/tests/extension/conftest.py index 04dfb408fc378..4cb4ea21d9be3 100644 --- a/pandas/tests/extension/conftest.py +++ b/pandas/tests/extension/conftest.py @@ -66,3 +66,14 @@ def na_cmp(): def na_value(): """The scalar missing value for this type. Default 'None'""" return None + + +@pytest.fixture +def data_for_grouping(): + """Data for factorization, grouping, and unique tests. + + Expected to be like [B, B, NA, NA, A, A, B, C] + + Where A < B < C and NA is missing + """ + raise NotImplementedError diff --git a/pandas/tests/extension/decimal/array.py b/pandas/tests/extension/decimal/array.py index f1852542088ff..b66a14c77a059 100644 --- a/pandas/tests/extension/decimal/array.py +++ b/pandas/tests/extension/decimal/array.py @@ -36,6 +36,10 @@ def __init__(self, values): def _constructor_from_sequence(cls, scalars): return cls(scalars) + @classmethod + def _from_factorized(cls, values, original): + return cls(values) + def __getitem__(self, item): if isinstance(item, numbers.Integral): return self.values[item] diff --git a/pandas/tests/extension/decimal/test_decimal.py b/pandas/tests/extension/decimal/test_decimal.py index b6303ededd0dc..22c1a67a0d60d 100644 --- a/pandas/tests/extension/decimal/test_decimal.py +++ b/pandas/tests/extension/decimal/test_decimal.py @@ -49,6 +49,15 @@ def na_value(): return decimal.Decimal("NaN") +@pytest.fixture +def data_for_grouping(): + b = decimal.Decimal('1.0') + a = decimal.Decimal('0.0') + c = decimal.Decimal('2.0') + na = decimal.Decimal('NaN') + return DecimalArray([b, b, na, na, a, a, b, c]) + + class BaseDecimal(object): def assert_series_equal(self, left, right, *args, **kwargs): diff --git a/pandas/tests/extension/json/array.py b/pandas/tests/extension/json/array.py index ee0951812b8f0..51a68a3701046 100644 --- a/pandas/tests/extension/json/array.py +++ b/pandas/tests/extension/json/array.py @@ -37,6 +37,10 @@ def __init__(self, values): def _constructor_from_sequence(cls, scalars): return cls(scalars) + @classmethod + def _from_factorized(cls, values, original): + return cls([collections.UserDict(x) for x in values if x != ()]) + def __getitem__(self, item): if isinstance(item, numbers.Integral): return self.data[item] @@ -108,6 +112,10 @@ def _concat_same_type(cls, to_concat): data = list(itertools.chain.from_iterable([x.data for x in to_concat])) return cls(data) + def _values_for_factorize(self): + frozen = tuple(tuple(x.items()) for x in self) + return np.array(frozen, dtype=object), () + def _values_for_argsort(self): # Disable NumPy's shape inference by including an empty tuple... # If all the elemnts of self are the same size P, NumPy will diff --git a/pandas/tests/extension/json/test_json.py b/pandas/tests/extension/json/test_json.py index 8083a1ce69092..63d97d5e7a2c5 100644 --- a/pandas/tests/extension/json/test_json.py +++ b/pandas/tests/extension/json/test_json.py @@ -48,6 +48,17 @@ def na_cmp(): return operator.eq +@pytest.fixture +def data_for_grouping(): + return JSONArray([ + {'b': 1}, {'b': 1}, + {}, {}, + {'a': 0, 'c': 2}, {'a': 0, 'c': 2}, + {'b': 1}, + {'c': 2}, + ]) + + class TestDtype(base.BaseDtypeTests): pass From e6c29454a93273419117ec03c0b7108f4c7bfdf1 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Fri, 16 Mar 2018 15:01:14 +0100 Subject: [PATCH 47/81] DOC: update vendored numpydoc version Numpydoc upstream at 21a194e9b42ebe95a70475e35cb69bcb4801ff36 --- doc/sphinxext/numpydoc/__init__.py | 7 +- doc/sphinxext/numpydoc/comment_eater.py | 169 ---- doc/sphinxext/numpydoc/compiler_unparse.py | 865 ------------------ doc/sphinxext/numpydoc/docscrape.py | 245 +++-- doc/sphinxext/numpydoc/docscrape_sphinx.py | 261 ++++-- doc/sphinxext/numpydoc/linkcode.py | 83 -- doc/sphinxext/numpydoc/numpydoc.py | 187 +++- doc/sphinxext/numpydoc/phantom_import.py | 167 ---- doc/sphinxext/numpydoc/plot_directive.py | 642 ------------- .../numpydoc/templates/numpydoc_docstring.rst | 16 + .../numpydoc/tests/test_docscrape.py | 547 +++++++++-- doc/sphinxext/numpydoc/tests/test_linkcode.py | 5 - .../numpydoc/tests/test_phantom_import.py | 5 - .../numpydoc/tests/test_plot_directive.py | 5 - .../numpydoc/tests/test_traitsdoc.py | 5 - doc/sphinxext/numpydoc/traitsdoc.py | 142 --- 16 files changed, 1033 insertions(+), 2318 deletions(-) mode change 100755 => 100644 doc/sphinxext/numpydoc/__init__.py delete mode 100755 doc/sphinxext/numpydoc/comment_eater.py delete mode 100755 doc/sphinxext/numpydoc/compiler_unparse.py mode change 100755 => 100644 doc/sphinxext/numpydoc/docscrape.py mode change 100755 => 100644 doc/sphinxext/numpydoc/docscrape_sphinx.py delete mode 100644 doc/sphinxext/numpydoc/linkcode.py mode change 100755 => 100644 doc/sphinxext/numpydoc/numpydoc.py delete mode 100755 doc/sphinxext/numpydoc/phantom_import.py delete mode 100755 doc/sphinxext/numpydoc/plot_directive.py create mode 100644 doc/sphinxext/numpydoc/templates/numpydoc_docstring.rst mode change 100755 => 100644 doc/sphinxext/numpydoc/tests/test_docscrape.py delete mode 100644 doc/sphinxext/numpydoc/tests/test_linkcode.py delete mode 100644 doc/sphinxext/numpydoc/tests/test_phantom_import.py delete mode 100644 doc/sphinxext/numpydoc/tests/test_plot_directive.py delete mode 100644 doc/sphinxext/numpydoc/tests/test_traitsdoc.py delete mode 100755 doc/sphinxext/numpydoc/traitsdoc.py diff --git a/doc/sphinxext/numpydoc/__init__.py b/doc/sphinxext/numpydoc/__init__.py old mode 100755 new mode 100644 index 0fce2cf747e23..30dba8fcf9132 --- a/doc/sphinxext/numpydoc/__init__.py +++ b/doc/sphinxext/numpydoc/__init__.py @@ -1,3 +1,8 @@ from __future__ import division, absolute_import, print_function -from .numpydoc import setup +__version__ = '0.8.0.dev0' + + +def setup(app, *args, **kwargs): + from .numpydoc import setup + return setup(app, *args, **kwargs) diff --git a/doc/sphinxext/numpydoc/comment_eater.py b/doc/sphinxext/numpydoc/comment_eater.py deleted file mode 100755 index 8cddd3305f0bc..0000000000000 --- a/doc/sphinxext/numpydoc/comment_eater.py +++ /dev/null @@ -1,169 +0,0 @@ -from __future__ import division, absolute_import, print_function - -import sys -if sys.version_info[0] >= 3: - from io import StringIO -else: - from io import StringIO - -import compiler -import inspect -import textwrap -import tokenize - -from .compiler_unparse import unparse - - -class Comment(object): - """ A comment block. - """ - is_comment = True - def __init__(self, start_lineno, end_lineno, text): - # int : The first line number in the block. 1-indexed. - self.start_lineno = start_lineno - # int : The last line number. Inclusive! - self.end_lineno = end_lineno - # str : The text block including '#' character but not any leading spaces. - self.text = text - - def add(self, string, start, end, line): - """ Add a new comment line. - """ - self.start_lineno = min(self.start_lineno, start[0]) - self.end_lineno = max(self.end_lineno, end[0]) - self.text += string - - def __repr__(self): - return '%s(%r, %r, %r)' % (self.__class__.__name__, self.start_lineno, - self.end_lineno, self.text) - - -class NonComment(object): - """ A non-comment block of code. - """ - is_comment = False - def __init__(self, start_lineno, end_lineno): - self.start_lineno = start_lineno - self.end_lineno = end_lineno - - def add(self, string, start, end, line): - """ Add lines to the block. - """ - if string.strip(): - # Only add if not entirely whitespace. - self.start_lineno = min(self.start_lineno, start[0]) - self.end_lineno = max(self.end_lineno, end[0]) - - def __repr__(self): - return '%s(%r, %r)' % (self.__class__.__name__, self.start_lineno, - self.end_lineno) - - -class CommentBlocker(object): - """ Pull out contiguous comment blocks. - """ - def __init__(self): - # Start with a dummy. - self.current_block = NonComment(0, 0) - - # All of the blocks seen so far. - self.blocks = [] - - # The index mapping lines of code to their associated comment blocks. - self.index = {} - - def process_file(self, file): - """ Process a file object. - """ - if sys.version_info[0] >= 3: - nxt = file.__next__ - else: - nxt = file.next - for token in tokenize.generate_tokens(nxt): - self.process_token(*token) - self.make_index() - - def process_token(self, kind, string, start, end, line): - """ Process a single token. - """ - if self.current_block.is_comment: - if kind == tokenize.COMMENT: - self.current_block.add(string, start, end, line) - else: - self.new_noncomment(start[0], end[0]) - else: - if kind == tokenize.COMMENT: - self.new_comment(string, start, end, line) - else: - self.current_block.add(string, start, end, line) - - def new_noncomment(self, start_lineno, end_lineno): - """ We are transitioning from a noncomment to a comment. - """ - block = NonComment(start_lineno, end_lineno) - self.blocks.append(block) - self.current_block = block - - def new_comment(self, string, start, end, line): - """ Possibly add a new comment. - - Only adds a new comment if this comment is the only thing on the line. - Otherwise, it extends the noncomment block. - """ - prefix = line[:start[1]] - if prefix.strip(): - # Oops! Trailing comment, not a comment block. - self.current_block.add(string, start, end, line) - else: - # A comment block. - block = Comment(start[0], end[0], string) - self.blocks.append(block) - self.current_block = block - - def make_index(self): - """ Make the index mapping lines of actual code to their associated - prefix comments. - """ - for prev, block in zip(self.blocks[:-1], self.blocks[1:]): - if not block.is_comment: - self.index[block.start_lineno] = prev - - def search_for_comment(self, lineno, default=None): - """ Find the comment block just before the given line number. - - Returns None (or the specified default) if there is no such block. - """ - if not self.index: - self.make_index() - block = self.index.get(lineno, None) - text = getattr(block, 'text', default) - return text - - -def strip_comment_marker(text): - """ Strip # markers at the front of a block of comment text. - """ - lines = [] - for line in text.splitlines(): - lines.append(line.lstrip('#')) - text = textwrap.dedent('\n'.join(lines)) - return text - - -def get_class_traits(klass): - """ Yield all of the documentation for trait definitions on a class object. - """ - # FIXME: gracefully handle errors here or in the caller? - source = inspect.getsource(klass) - cb = CommentBlocker() - cb.process_file(StringIO(source)) - mod_ast = compiler.parse(source) - class_ast = mod_ast.node.nodes[0] - for node in class_ast.code.nodes: - # FIXME: handle other kinds of assignments? - if isinstance(node, compiler.ast.Assign): - name = node.nodes[0].name - rhs = unparse(node.expr).strip() - doc = strip_comment_marker(cb.search_for_comment(node.lineno, default='')) - yield name, rhs, doc - diff --git a/doc/sphinxext/numpydoc/compiler_unparse.py b/doc/sphinxext/numpydoc/compiler_unparse.py deleted file mode 100755 index eabb5934c9cc8..0000000000000 --- a/doc/sphinxext/numpydoc/compiler_unparse.py +++ /dev/null @@ -1,865 +0,0 @@ -""" Turn compiler.ast structures back into executable python code. - - The unparse method takes a compiler.ast tree and transforms it back into - valid python code. It is incomplete and currently only works for - import statements, function calls, function definitions, assignments, and - basic expressions. - - Inspired by python-2.5-svn/Demo/parser/unparse.py - - fixme: We may want to move to using _ast trees because the compiler for - them is about 6 times faster than compiler.compile. -""" -from __future__ import division, absolute_import, print_function - -import sys -from compiler.ast import Const, Name, Tuple, Div, Mul, Sub, Add - -if sys.version_info[0] >= 3: - from io import StringIO -else: - from StringIO import StringIO - -def unparse(ast, single_line_functions=False): - s = StringIO() - UnparseCompilerAst(ast, s, single_line_functions) - return s.getvalue().lstrip() - -op_precedence = { 'compiler.ast.Power':3, 'compiler.ast.Mul':2, 'compiler.ast.Div':2, - 'compiler.ast.Add':1, 'compiler.ast.Sub':1 } - -class UnparseCompilerAst: - """ Methods in this class recursively traverse an AST and - output source code for the abstract syntax; original formatting - is disregarged. - """ - - ######################################################################### - # object interface. - ######################################################################### - - def __init__(self, tree, file = sys.stdout, single_line_functions=False): - """ Unparser(tree, file=sys.stdout) -> None. - - Print the source for tree to file. - """ - self.f = file - self._single_func = single_line_functions - self._do_indent = True - self._indent = 0 - self._dispatch(tree) - self._write("\n") - self.f.flush() - - ######################################################################### - # Unparser private interface. - ######################################################################### - - ### format, output, and dispatch methods ################################ - - def _fill(self, text = ""): - "Indent a piece of text, according to the current indentation level" - if self._do_indent: - self._write("\n"+" "*self._indent + text) - else: - self._write(text) - - def _write(self, text): - "Append a piece of text to the current line." - self.f.write(text) - - def _enter(self): - "Print ':', and increase the indentation." - self._write(": ") - self._indent += 1 - - def _leave(self): - "Decrease the indentation level." - self._indent -= 1 - - def _dispatch(self, tree): - "_dispatcher function, _dispatching tree type T to method _T." - if isinstance(tree, list): - for t in tree: - self._dispatch(t) - return - meth = getattr(self, "_"+tree.__class__.__name__) - if tree.__class__.__name__ == 'NoneType' and not self._do_indent: - return - meth(tree) - - - ######################################################################### - # compiler.ast unparsing methods. - # - # There should be one method per concrete grammar type. They are - # organized in alphabetical order. - ######################################################################### - - def _Add(self, t): - self.__binary_op(t, '+') - - def _And(self, t): - self._write(" (") - for i, node in enumerate(t.nodes): - self._dispatch(node) - if i != len(t.nodes)-1: - self._write(") and (") - self._write(")") - - def _AssAttr(self, t): - """ Handle assigning an attribute of an object - """ - self._dispatch(t.expr) - self._write('.'+t.attrname) - - def _Assign(self, t): - """ Expression Assignment such as "a = 1". - - This only handles assignment in expressions. Keyword assignment - is handled separately. - """ - self._fill() - for target in t.nodes: - self._dispatch(target) - self._write(" = ") - self._dispatch(t.expr) - if not self._do_indent: - self._write('; ') - - def _AssName(self, t): - """ Name on left hand side of expression. - - Treat just like a name on the right side of an expression. - """ - self._Name(t) - - def _AssTuple(self, t): - """ Tuple on left hand side of an expression. - """ - - # _write each elements, separated by a comma. - for element in t.nodes[:-1]: - self._dispatch(element) - self._write(", ") - - # Handle the last one without writing comma - last_element = t.nodes[-1] - self._dispatch(last_element) - - def _AugAssign(self, t): - """ +=,-=,*=,/=,**=, etc. operations - """ - - self._fill() - self._dispatch(t.node) - self._write(' '+t.op+' ') - self._dispatch(t.expr) - if not self._do_indent: - self._write(';') - - def _Bitand(self, t): - """ Bit and operation. - """ - - for i, node in enumerate(t.nodes): - self._write("(") - self._dispatch(node) - self._write(")") - if i != len(t.nodes)-1: - self._write(" & ") - - def _Bitor(self, t): - """ Bit or operation - """ - - for i, node in enumerate(t.nodes): - self._write("(") - self._dispatch(node) - self._write(")") - if i != len(t.nodes)-1: - self._write(" | ") - - def _CallFunc(self, t): - """ Function call. - """ - self._dispatch(t.node) - self._write("(") - comma = False - for e in t.args: - if comma: self._write(", ") - else: comma = True - self._dispatch(e) - if t.star_args: - if comma: self._write(", ") - else: comma = True - self._write("*") - self._dispatch(t.star_args) - if t.dstar_args: - if comma: self._write(", ") - else: comma = True - self._write("**") - self._dispatch(t.dstar_args) - self._write(")") - - def _Compare(self, t): - self._dispatch(t.expr) - for op, expr in t.ops: - self._write(" " + op + " ") - self._dispatch(expr) - - def _Const(self, t): - """ A constant value such as an integer value, 3, or a string, "hello". - """ - self._dispatch(t.value) - - def _Decorators(self, t): - """ Handle function decorators (eg. @has_units) - """ - for node in t.nodes: - self._dispatch(node) - - def _Dict(self, t): - self._write("{") - for i, (k, v) in enumerate(t.items): - self._dispatch(k) - self._write(": ") - self._dispatch(v) - if i < len(t.items)-1: - self._write(", ") - self._write("}") - - def _Discard(self, t): - """ Node for when return value is ignored such as in "foo(a)". - """ - self._fill() - self._dispatch(t.expr) - - def _Div(self, t): - self.__binary_op(t, '/') - - def _Ellipsis(self, t): - self._write("...") - - def _From(self, t): - """ Handle "from xyz import foo, bar as baz". - """ - # fixme: Are From and ImportFrom handled differently? - self._fill("from ") - self._write(t.modname) - self._write(" import ") - for i, (name,asname) in enumerate(t.names): - if i != 0: - self._write(", ") - self._write(name) - if asname is not None: - self._write(" as "+asname) - - def _Function(self, t): - """ Handle function definitions - """ - if t.decorators is not None: - self._fill("@") - self._dispatch(t.decorators) - self._fill("def "+t.name + "(") - defaults = [None] * (len(t.argnames) - len(t.defaults)) + list(t.defaults) - for i, arg in enumerate(zip(t.argnames, defaults)): - self._write(arg[0]) - if arg[1] is not None: - self._write('=') - self._dispatch(arg[1]) - if i < len(t.argnames)-1: - self._write(', ') - self._write(")") - if self._single_func: - self._do_indent = False - self._enter() - self._dispatch(t.code) - self._leave() - self._do_indent = True - - def _Getattr(self, t): - """ Handle getting an attribute of an object - """ - if isinstance(t.expr, (Div, Mul, Sub, Add)): - self._write('(') - self._dispatch(t.expr) - self._write(')') - else: - self._dispatch(t.expr) - - self._write('.'+t.attrname) - - def _If(self, t): - self._fill() - - for i, (compare,code) in enumerate(t.tests): - if i == 0: - self._write("if ") - else: - self._write("elif ") - self._dispatch(compare) - self._enter() - self._fill() - self._dispatch(code) - self._leave() - self._write("\n") - - if t.else_ is not None: - self._write("else") - self._enter() - self._fill() - self._dispatch(t.else_) - self._leave() - self._write("\n") - - def _IfExp(self, t): - self._dispatch(t.then) - self._write(" if ") - self._dispatch(t.test) - - if t.else_ is not None: - self._write(" else (") - self._dispatch(t.else_) - self._write(")") - - def _Import(self, t): - """ Handle "import xyz.foo". - """ - self._fill("import ") - - for i, (name,asname) in enumerate(t.names): - if i != 0: - self._write(", ") - self._write(name) - if asname is not None: - self._write(" as "+asname) - - def _Keyword(self, t): - """ Keyword value assignment within function calls and definitions. - """ - self._write(t.name) - self._write("=") - self._dispatch(t.expr) - - def _List(self, t): - self._write("[") - for i,node in enumerate(t.nodes): - self._dispatch(node) - if i < len(t.nodes)-1: - self._write(", ") - self._write("]") - - def _Module(self, t): - if t.doc is not None: - self._dispatch(t.doc) - self._dispatch(t.node) - - def _Mul(self, t): - self.__binary_op(t, '*') - - def _Name(self, t): - self._write(t.name) - - def _NoneType(self, t): - self._write("None") - - def _Not(self, t): - self._write('not (') - self._dispatch(t.expr) - self._write(')') - - def _Or(self, t): - self._write(" (") - for i, node in enumerate(t.nodes): - self._dispatch(node) - if i != len(t.nodes)-1: - self._write(") or (") - self._write(")") - - def _Pass(self, t): - self._write("pass\n") - - def _Printnl(self, t): - self._fill("print ") - if t.dest: - self._write(">> ") - self._dispatch(t.dest) - self._write(", ") - comma = False - for node in t.nodes: - if comma: self._write(', ') - else: comma = True - self._dispatch(node) - - def _Power(self, t): - self.__binary_op(t, '**') - - def _Return(self, t): - self._fill("return ") - if t.value: - if isinstance(t.value, Tuple): - text = ', '.join(name.name for name in t.value.asList()) - self._write(text) - else: - self._dispatch(t.value) - if not self._do_indent: - self._write('; ') - - def _Slice(self, t): - self._dispatch(t.expr) - self._write("[") - if t.lower: - self._dispatch(t.lower) - self._write(":") - if t.upper: - self._dispatch(t.upper) - #if t.step: - # self._write(":") - # self._dispatch(t.step) - self._write("]") - - def _Sliceobj(self, t): - for i, node in enumerate(t.nodes): - if i != 0: - self._write(":") - if not (isinstance(node, Const) and node.value is None): - self._dispatch(node) - - def _Stmt(self, tree): - for node in tree.nodes: - self._dispatch(node) - - def _Sub(self, t): - self.__binary_op(t, '-') - - def _Subscript(self, t): - self._dispatch(t.expr) - self._write("[") - for i, value in enumerate(t.subs): - if i != 0: - self._write(",") - self._dispatch(value) - self._write("]") - - def _TryExcept(self, t): - self._fill("try") - self._enter() - self._dispatch(t.body) - self._leave() - - for handler in t.handlers: - self._fill('except ') - self._dispatch(handler[0]) - if handler[1] is not None: - self._write(', ') - self._dispatch(handler[1]) - self._enter() - self._dispatch(handler[2]) - self._leave() - - if t.else_: - self._fill("else") - self._enter() - self._dispatch(t.else_) - self._leave() - - def _Tuple(self, t): - - if not t.nodes: - # Empty tuple. - self._write("()") - else: - self._write("(") - - # _write each elements, separated by a comma. - for element in t.nodes[:-1]: - self._dispatch(element) - self._write(", ") - - # Handle the last one without writing comma - last_element = t.nodes[-1] - self._dispatch(last_element) - - self._write(")") - - def _UnaryAdd(self, t): - self._write("+") - self._dispatch(t.expr) - - def _UnarySub(self, t): - self._write("-") - self._dispatch(t.expr) - - def _With(self, t): - self._fill('with ') - self._dispatch(t.expr) - if t.vars: - self._write(' as ') - self._dispatch(t.vars.name) - self._enter() - self._dispatch(t.body) - self._leave() - self._write('\n') - - def _int(self, t): - self._write(repr(t)) - - def __binary_op(self, t, symbol): - # Check if parenthesis are needed on left side and then dispatch - has_paren = False - left_class = str(t.left.__class__) - if (left_class in op_precedence.keys() and - op_precedence[left_class] < op_precedence[str(t.__class__)]): - has_paren = True - if has_paren: - self._write('(') - self._dispatch(t.left) - if has_paren: - self._write(')') - # Write the appropriate symbol for operator - self._write(symbol) - # Check if parenthesis are needed on the right side and then dispatch - has_paren = False - right_class = str(t.right.__class__) - if (right_class in op_precedence.keys() and - op_precedence[right_class] < op_precedence[str(t.__class__)]): - has_paren = True - if has_paren: - self._write('(') - self._dispatch(t.right) - if has_paren: - self._write(')') - - def _float(self, t): - # if t is 0.1, str(t)->'0.1' while repr(t)->'0.1000000000001' - # We prefer str here. - self._write(str(t)) - - def _str(self, t): - self._write(repr(t)) - - def _tuple(self, t): - self._write(str(t)) - - ######################################################################### - # These are the methods from the _ast modules unparse. - # - # As our needs to handle more advanced code increase, we may want to - # modify some of the methods below so that they work for compiler.ast. - ######################################################################### - -# # stmt -# def _Expr(self, tree): -# self._fill() -# self._dispatch(tree.value) -# -# def _Import(self, t): -# self._fill("import ") -# first = True -# for a in t.names: -# if first: -# first = False -# else: -# self._write(", ") -# self._write(a.name) -# if a.asname: -# self._write(" as "+a.asname) -# -## def _ImportFrom(self, t): -## self._fill("from ") -## self._write(t.module) -## self._write(" import ") -## for i, a in enumerate(t.names): -## if i == 0: -## self._write(", ") -## self._write(a.name) -## if a.asname: -## self._write(" as "+a.asname) -## # XXX(jpe) what is level for? -## -# -# def _Break(self, t): -# self._fill("break") -# -# def _Continue(self, t): -# self._fill("continue") -# -# def _Delete(self, t): -# self._fill("del ") -# self._dispatch(t.targets) -# -# def _Assert(self, t): -# self._fill("assert ") -# self._dispatch(t.test) -# if t.msg: -# self._write(", ") -# self._dispatch(t.msg) -# -# def _Exec(self, t): -# self._fill("exec ") -# self._dispatch(t.body) -# if t.globals: -# self._write(" in ") -# self._dispatch(t.globals) -# if t.locals: -# self._write(", ") -# self._dispatch(t.locals) -# -# def _Print(self, t): -# self._fill("print ") -# do_comma = False -# if t.dest: -# self._write(">>") -# self._dispatch(t.dest) -# do_comma = True -# for e in t.values: -# if do_comma:self._write(", ") -# else:do_comma=True -# self._dispatch(e) -# if not t.nl: -# self._write(",") -# -# def _Global(self, t): -# self._fill("global") -# for i, n in enumerate(t.names): -# if i != 0: -# self._write(",") -# self._write(" " + n) -# -# def _Yield(self, t): -# self._fill("yield") -# if t.value: -# self._write(" (") -# self._dispatch(t.value) -# self._write(")") -# -# def _Raise(self, t): -# self._fill('raise ') -# if t.type: -# self._dispatch(t.type) -# if t.inst: -# self._write(", ") -# self._dispatch(t.inst) -# if t.tback: -# self._write(", ") -# self._dispatch(t.tback) -# -# -# def _TryFinally(self, t): -# self._fill("try") -# self._enter() -# self._dispatch(t.body) -# self._leave() -# -# self._fill("finally") -# self._enter() -# self._dispatch(t.finalbody) -# self._leave() -# -# def _excepthandler(self, t): -# self._fill("except ") -# if t.type: -# self._dispatch(t.type) -# if t.name: -# self._write(", ") -# self._dispatch(t.name) -# self._enter() -# self._dispatch(t.body) -# self._leave() -# -# def _ClassDef(self, t): -# self._write("\n") -# self._fill("class "+t.name) -# if t.bases: -# self._write("(") -# for a in t.bases: -# self._dispatch(a) -# self._write(", ") -# self._write(")") -# self._enter() -# self._dispatch(t.body) -# self._leave() -# -# def _FunctionDef(self, t): -# self._write("\n") -# for deco in t.decorators: -# self._fill("@") -# self._dispatch(deco) -# self._fill("def "+t.name + "(") -# self._dispatch(t.args) -# self._write(")") -# self._enter() -# self._dispatch(t.body) -# self._leave() -# -# def _For(self, t): -# self._fill("for ") -# self._dispatch(t.target) -# self._write(" in ") -# self._dispatch(t.iter) -# self._enter() -# self._dispatch(t.body) -# self._leave() -# if t.orelse: -# self._fill("else") -# self._enter() -# self._dispatch(t.orelse) -# self._leave -# -# def _While(self, t): -# self._fill("while ") -# self._dispatch(t.test) -# self._enter() -# self._dispatch(t.body) -# self._leave() -# if t.orelse: -# self._fill("else") -# self._enter() -# self._dispatch(t.orelse) -# self._leave -# -# # expr -# def _Str(self, tree): -# self._write(repr(tree.s)) -## -# def _Repr(self, t): -# self._write("`") -# self._dispatch(t.value) -# self._write("`") -# -# def _Num(self, t): -# self._write(repr(t.n)) -# -# def _ListComp(self, t): -# self._write("[") -# self._dispatch(t.elt) -# for gen in t.generators: -# self._dispatch(gen) -# self._write("]") -# -# def _GeneratorExp(self, t): -# self._write("(") -# self._dispatch(t.elt) -# for gen in t.generators: -# self._dispatch(gen) -# self._write(")") -# -# def _comprehension(self, t): -# self._write(" for ") -# self._dispatch(t.target) -# self._write(" in ") -# self._dispatch(t.iter) -# for if_clause in t.ifs: -# self._write(" if ") -# self._dispatch(if_clause) -# -# def _IfExp(self, t): -# self._dispatch(t.body) -# self._write(" if ") -# self._dispatch(t.test) -# if t.orelse: -# self._write(" else ") -# self._dispatch(t.orelse) -# -# unop = {"Invert":"~", "Not": "not", "UAdd":"+", "USub":"-"} -# def _UnaryOp(self, t): -# self._write(self.unop[t.op.__class__.__name__]) -# self._write("(") -# self._dispatch(t.operand) -# self._write(")") -# -# binop = { "Add":"+", "Sub":"-", "Mult":"*", "Div":"/", "Mod":"%", -# "LShift":">>", "RShift":"<<", "BitOr":"|", "BitXor":"^", "BitAnd":"&", -# "FloorDiv":"//", "Pow": "**"} -# def _BinOp(self, t): -# self._write("(") -# self._dispatch(t.left) -# self._write(")" + self.binop[t.op.__class__.__name__] + "(") -# self._dispatch(t.right) -# self._write(")") -# -# boolops = {_ast.And: 'and', _ast.Or: 'or'} -# def _BoolOp(self, t): -# self._write("(") -# self._dispatch(t.values[0]) -# for v in t.values[1:]: -# self._write(" %s " % self.boolops[t.op.__class__]) -# self._dispatch(v) -# self._write(")") -# -# def _Attribute(self,t): -# self._dispatch(t.value) -# self._write(".") -# self._write(t.attr) -# -## def _Call(self, t): -## self._dispatch(t.func) -## self._write("(") -## comma = False -## for e in t.args: -## if comma: self._write(", ") -## else: comma = True -## self._dispatch(e) -## for e in t.keywords: -## if comma: self._write(", ") -## else: comma = True -## self._dispatch(e) -## if t.starargs: -## if comma: self._write(", ") -## else: comma = True -## self._write("*") -## self._dispatch(t.starargs) -## if t.kwargs: -## if comma: self._write(", ") -## else: comma = True -## self._write("**") -## self._dispatch(t.kwargs) -## self._write(")") -# -# # slice -# def _Index(self, t): -# self._dispatch(t.value) -# -# def _ExtSlice(self, t): -# for i, d in enumerate(t.dims): -# if i != 0: -# self._write(': ') -# self._dispatch(d) -# -# # others -# def _arguments(self, t): -# first = True -# nonDef = len(t.args)-len(t.defaults) -# for a in t.args[0:nonDef]: -# if first:first = False -# else: self._write(", ") -# self._dispatch(a) -# for a,d in zip(t.args[nonDef:], t.defaults): -# if first:first = False -# else: self._write(", ") -# self._dispatch(a), -# self._write("=") -# self._dispatch(d) -# if t.vararg: -# if first:first = False -# else: self._write(", ") -# self._write("*"+t.vararg) -# if t.kwarg: -# if first:first = False -# else: self._write(", ") -# self._write("**"+t.kwarg) -# -## def _keyword(self, t): -## self._write(t.arg) -## self._write("=") -## self._dispatch(t.value) -# -# def _Lambda(self, t): -# self._write("lambda ") -# self._dispatch(t.args) -# self._write(": ") -# self._dispatch(t.body) - - - diff --git a/doc/sphinxext/numpydoc/docscrape.py b/doc/sphinxext/numpydoc/docscrape.py old mode 100755 new mode 100644 index 38cb62581ae76..6ca62922dba4c --- a/doc/sphinxext/numpydoc/docscrape.py +++ b/doc/sphinxext/numpydoc/docscrape.py @@ -9,6 +9,17 @@ import pydoc from warnings import warn import collections +import copy +import sys + + +def strip_blank_lines(l): + "Remove leading and trailing blank lines from a list of lines" + while l and not l[0].strip(): + del l[0] + while l and not l[-1].strip(): + del l[-1] + return l class Reader(object): @@ -23,10 +34,10 @@ def __init__(self, data): String with lines separated by '\n'. """ - if isinstance(data,list): + if isinstance(data, list): self._str = data else: - self._str = data.split('\n') # store string as list of lines + self._str = data.split('\n') # store string as list of lines self.reset() @@ -34,7 +45,7 @@ def __getitem__(self, n): return self._str[n] def reset(self): - self._l = 0 # current line nr + self._l = 0 # current line nr def read(self): if not self.eof(): @@ -66,8 +77,10 @@ def read_to_condition(self, condition_func): def read_to_next_empty_line(self): self.seek_next_non_empty_line() + def is_empty(line): return not line.strip() + return self.read_to_condition(is_empty) def read_to_next_unindented_line(self): @@ -75,7 +88,7 @@ def is_unindented(line): return (line.strip() and (len(line.lstrip()) == len(line))) return self.read_to_condition(is_unindented) - def peek(self,n=0): + def peek(self, n=0): if self._l + n < len(self._str): return self[self._l + n] else: @@ -85,41 +98,69 @@ def is_empty(self): return not ''.join(self._str).strip() -class NumpyDocString(object): +class ParseError(Exception): + def __str__(self): + message = self.args[0] + if hasattr(self, 'docstring'): + message = "%s in %r" % (message, self.docstring) + return message + + +class NumpyDocString(collections.Mapping): + """Parses a numpydoc string to an abstract representation + + Instances define a mapping from section title to structured data. + + """ + + sections = { + 'Signature': '', + 'Summary': [''], + 'Extended Summary': [], + 'Parameters': [], + 'Returns': [], + 'Yields': [], + 'Raises': [], + 'Warns': [], + 'Other Parameters': [], + 'Attributes': [], + 'Methods': [], + 'See Also': [], + 'Notes': [], + 'Warnings': [], + 'References': '', + 'Examples': '', + 'index': {} + } + def __init__(self, docstring, config={}): + orig_docstring = docstring docstring = textwrap.dedent(docstring).split('\n') self._doc = Reader(docstring) - self._parsed_data = { - 'Signature': '', - 'Summary': [''], - 'Extended Summary': [], - 'Parameters': [], - 'Returns': [], - 'Raises': [], - 'Warns': [], - 'Other Parameters': [], - 'Attributes': [], - 'Methods': [], - 'See Also': [], - 'Notes': [], - 'Warnings': [], - 'References': '', - 'Examples': '', - 'index': {} - } - - self._parse() - - def __getitem__(self,key): + self._parsed_data = copy.deepcopy(self.sections) + + try: + self._parse() + except ParseError as e: + e.docstring = orig_docstring + raise + + def __getitem__(self, key): return self._parsed_data[key] - def __setitem__(self,key,val): + def __setitem__(self, key, val): if key not in self._parsed_data: - warn("Unknown section %s" % key) + self._error_location("Unknown section %s" % key, error=False) else: self._parsed_data[key] = val + def __iter__(self): + return iter(self._parsed_data) + + def __len__(self): + return len(self._parsed_data) + def _is_at_section(self): self._doc.seek_next_non_empty_line() @@ -131,17 +172,19 @@ def _is_at_section(self): if l1.startswith('.. index::'): return True - l2 = self._doc.peek(1).strip() # ---------- or ========== + l2 = self._doc.peek(1).strip() # ---------- or ========== return l2.startswith('-'*len(l1)) or l2.startswith('='*len(l1)) - def _strip(self,doc): + def _strip(self, doc): i = 0 j = 0 - for i,line in enumerate(doc): - if line.strip(): break + for i, line in enumerate(doc): + if line.strip(): + break - for j,line in enumerate(doc[::-1]): - if line.strip(): break + for j, line in enumerate(doc[::-1]): + if line.strip(): + break return doc[i:len(doc)-j] @@ -149,7 +192,7 @@ def _read_to_next_section(self): section = self._doc.read_to_next_empty_line() while not self._is_at_section() and not self._doc.eof(): - if not self._doc.peek(-1).strip(): # previous line was empty + if not self._doc.peek(-1).strip(): # previous line was empty section += [''] section += self._doc.read_to_next_empty_line() @@ -161,14 +204,14 @@ def _read_sections(self): data = self._read_to_next_section() name = data[0].strip() - if name.startswith('..'): # index section + if name.startswith('..'): # index section yield name, data[1:] elif len(data) < 2: yield StopIteration else: yield name, self._strip(data[2:]) - def _parse_param_list(self,content): + def _parse_param_list(self, content): r = Reader(content) params = [] while not r.eof(): @@ -180,14 +223,16 @@ def _parse_param_list(self,content): desc = r.read_to_next_unindented_line() desc = dedent_lines(desc) + desc = strip_blank_lines(desc) - params.append((arg_name,arg_type,desc)) + params.append((arg_name, arg_type, desc)) return params - - _name_rgx = re.compile(r"^\s*(:(?P\w+):`(?P[a-zA-Z0-9_.-]+)`|" + _name_rgx = re.compile(r"^\s*(:(?P\w+):" + r"`(?P(?:~\w+\.)?[a-zA-Z0-9_.-]+)`|" r" (?P[a-zA-Z0-9_.-]+))\s*", re.X) + def _parse_see_also(self, content): """ func_name : Descriptive text @@ -207,7 +252,7 @@ def parse_item_name(text): return g[3], None else: return g[2], g[1] - raise ValueError("%s is not a item name" % text) + raise ParseError("%s is not a item name" % text) def push_item(name, rest): if not name: @@ -220,7 +265,8 @@ def push_item(name, rest): rest = [] for line in content: - if not line.strip(): continue + if not line.strip(): + continue m = self._name_rgx.match(line) if m and line[m.end():].strip().startswith(':'): @@ -270,7 +316,7 @@ def _parse_summary(self): # If several signatures present, take the last one while True: summary = self._doc.read_to_next_empty_line() - summary_str = " ".join(s.strip() for s in summary).strip() + summary_str = " ".join([s.strip() for s in summary]).strip() if re.compile('^([\w., ]+=)?\s*[\w\.]+\(.*\)$').match(summary_str): self['Signature'] = summary_str if not self._is_at_section(): @@ -287,11 +333,27 @@ def _parse(self): self._doc.reset() self._parse_summary() - for (section,content) in self._read_sections(): + sections = list(self._read_sections()) + section_names = set([section for section, content in sections]) + + has_returns = 'Returns' in section_names + has_yields = 'Yields' in section_names + # We could do more tests, but we are not. Arbitrarily. + if has_returns and has_yields: + msg = 'Docstring contains both a Returns and Yields section.' + raise ValueError(msg) + + for (section, content) in sections: if not section.startswith('..'): - section = ' '.join(s.capitalize() for s in section.split(' ')) - if section in ('Parameters', 'Returns', 'Raises', 'Warns', - 'Other Parameters', 'Attributes', 'Methods'): + section = (s.capitalize() for s in section.split(' ')) + section = ' '.join(section) + if self.get(section): + self._error_location("The section %s appears twice" + % section) + + if section in ('Parameters', 'Returns', 'Yields', 'Raises', + 'Warns', 'Other Parameters', 'Attributes', + 'Methods'): self[section] = self._parse_param_list(content) elif section.startswith('.. index::'): self['index'] = self._parse_index(section, content) @@ -300,6 +362,20 @@ def _parse(self): else: self[section] = content + def _error_location(self, msg, error=True): + if hasattr(self, '_obj'): + # we know where the docs came from: + try: + filename = inspect.getsourcefile(self._obj) + except TypeError: + filename = None + msg = msg + (" in the docstring of %s in %s." + % (self._obj, filename)) + if error: + raise ValueError(msg) + else: + warn(msg) + # string conversion routines def _str_header(self, name, symbol='-'): @@ -313,7 +389,7 @@ def _str_indent(self, doc, indent=4): def _str_signature(self): if self['Signature']: - return [self['Signature'].replace('*','\*')] + [''] + return [self['Signature'].replace('*', '\*')] + [''] else: return [''] @@ -333,12 +409,13 @@ def _str_param_list(self, name): out = [] if self[name]: out += self._str_header(name) - for param,param_type,desc in self[name]: + for param, param_type, desc in self[name]: if param_type: out += ['%s : %s' % (param, param_type)] else: out += [param] - out += self._str_indent(desc) + if desc and ''.join(desc).strip(): + out += self._str_indent(desc) out += [''] return out @@ -351,7 +428,8 @@ def _str_section(self, name): return out def _str_see_also(self, func_role): - if not self['See Also']: return [] + if not self['See Also']: + return [] out = [] out += self._str_header("See Also") last_had_desc = True @@ -378,7 +456,7 @@ def _str_see_also(self, func_role): def _str_index(self): idx = self['index'] out = [] - out += ['.. index:: %s' % idx.get('default','')] + out += ['.. index:: %s' % idx.get('default', '')] for section, references in idx.items(): if section == 'default': continue @@ -390,12 +468,12 @@ def __str__(self, func_role=''): out += self._str_signature() out += self._str_summary() out += self._str_extended_summary() - for param_list in ('Parameters', 'Returns', 'Other Parameters', - 'Raises', 'Warns'): + for param_list in ('Parameters', 'Returns', 'Yields', + 'Other Parameters', 'Raises', 'Warns'): out += self._str_param_list(param_list) out += self._str_section('Warnings') out += self._str_see_also(func_role) - for s in ('Notes','References','Examples'): + for s in ('Notes', 'References', 'Examples'): out += self._str_section(s) for param_list in ('Attributes', 'Methods'): out += self._str_param_list(param_list) @@ -403,17 +481,19 @@ def __str__(self, func_role=''): return '\n'.join(out) -def indent(str,indent=4): +def indent(str, indent=4): indent_str = ' '*indent if str is None: return indent_str lines = str.split('\n') return '\n'.join(indent_str + l for l in lines) + def dedent_lines(lines): """Deindent a list of lines maximally""" return textwrap.dedent("\n".join(lines)).split("\n") + def header(text, style='-'): return text + '\n' + style*len(text) + '\n' @@ -421,7 +501,7 @@ def header(text, style='-'): class FunctionDoc(NumpyDocString): def __init__(self, func, role='func', doc=None, config={}): self._f = func - self._role = role # e.g. "func" or "meth" + self._role = role # e.g. "func" or "meth" if doc is None: if func is None: @@ -432,12 +512,17 @@ def __init__(self, func, role='func', doc=None, config={}): if not self['Signature'] and func is not None: func, func_name = self.get_func() try: - # try to read signature - argspec = inspect.getargspec(func) - argspec = inspect.formatargspec(*argspec) - argspec = argspec.replace('*','\*') - signature = '%s%s' % (func_name, argspec) - except TypeError as e: + try: + signature = str(inspect.signature(func)) + except (AttributeError, ValueError): + # try to read signature, backward compat for older Python + if sys.version_info[0] >= 3: + argspec = inspect.getfullargspec(func) + else: + argspec = inspect.getargspec(func) + signature = inspect.formatargspec(*argspec) + signature = '%s%s' % (func_name, signature.replace('*', '\*')) + except TypeError: signature = '%s()' % func_name self['Signature'] = signature @@ -461,7 +546,7 @@ def __str__(self): if self._role: if self._role not in roles: print("Warning: invalid role %s" % self._role) - out += '.. %s:: %s\n \n\n' % (roles.get(self._role,''), + out += '.. %s:: %s\n \n\n' % (roles.get(self._role, ''), func_name) out += super(FunctionDoc, self).__str__(func_role=self._role) @@ -478,6 +563,9 @@ def __init__(self, cls, doc=None, modulename='', func_doc=FunctionDoc, raise ValueError("Expected a class or None, but got %r" % cls) self._cls = cls + self.show_inherited_members = config.get( + 'show_inherited_class_members', True) + if modulename and not modulename.endswith('.'): modulename += '.' self._mod = modulename @@ -501,27 +589,36 @@ def splitlines_x(s): if not self[field]: doc_list = [] for name in sorted(items): - try: + try: doc_item = pydoc.getdoc(getattr(self._cls, name)) doc_list.append((name, '', splitlines_x(doc_item))) - except AttributeError: - pass # method doesn't exist + except AttributeError: + pass # method doesn't exist self[field] = doc_list @property def methods(self): if self._cls is None: return [] - return [name for name,func in inspect.getmembers(self._cls) + return [name for name, func in inspect.getmembers(self._cls) if ((not name.startswith('_') or name in self.extra_public_methods) - and isinstance(func, collections.Callable))] + and isinstance(func, collections.Callable) + and self._is_show_member(name))] @property def properties(self): if self._cls is None: return [] - return [name for name,func in inspect.getmembers(self._cls) - if not name.startswith('_') and - (func is None or isinstance(func, property) or - inspect.isgetsetdescriptor(func))] + return [name for name, func in inspect.getmembers(self._cls) + if (not name.startswith('_') and + (func is None or isinstance(func, property) or + inspect.isgetsetdescriptor(func)) + and self._is_show_member(name))] + + def _is_show_member(self, name): + if self.show_inherited_members: + return True # show all class members + if name not in self._cls.__dict__: + return False # class member is inherited, we do not show it + return True diff --git a/doc/sphinxext/numpydoc/docscrape_sphinx.py b/doc/sphinxext/numpydoc/docscrape_sphinx.py old mode 100755 new mode 100644 index 127ed49c106ad..087ddafb3e3ee --- a/doc/sphinxext/numpydoc/docscrape_sphinx.py +++ b/doc/sphinxext/numpydoc/docscrape_sphinx.py @@ -1,8 +1,18 @@ from __future__ import division, absolute_import, print_function -import sys, re, inspect, textwrap, pydoc -import sphinx +import sys +import re +import inspect +import textwrap +import pydoc import collections +import os + +from jinja2 import FileSystemLoader +from jinja2.sandbox import SandboxedEnvironment +import sphinx +from sphinx.jinja2glue import BuiltinTemplateLoader + from .docscrape import NumpyDocString, FunctionDoc, ClassDoc if sys.version_info[0] >= 3: @@ -11,15 +21,24 @@ sixu = lambda s: unicode(s, 'unicode_escape') +IMPORT_MATPLOTLIB_RE = r'\b(import +matplotlib|from +matplotlib +import)\b' + + class SphinxDocString(NumpyDocString): def __init__(self, docstring, config={}): - # Subclasses seemingly do not call this. NumpyDocString.__init__(self, docstring, config=config) + self.load_config(config) def load_config(self, config): self.use_plots = config.get('use_plots', False) + self.use_blockquotes = config.get('use_blockquotes', False) self.class_members_toctree = config.get('class_members_toctree', True) - self.class_members_list = config.get('class_members_list', True) + self.template = config.get('template', None) + if self.template is None: + template_dirs = [os.path.join(os.path.dirname(__file__), 'templates')] + template_loader = FileSystemLoader(template_dirs) + template_env = SandboxedEnvironment(loader=template_loader) + self.template = template_env.get_template('numpydoc_docstring.rst') # string conversion routines def _str_header(self, name, symbol='`'): @@ -47,38 +66,149 @@ def _str_summary(self): def _str_extended_summary(self): return self['Extended Summary'] + [''] - def _str_returns(self): + def _str_returns(self, name='Returns'): + if self.use_blockquotes: + typed_fmt = '**%s** : %s' + untyped_fmt = '**%s**' + else: + typed_fmt = '%s : %s' + untyped_fmt = '%s' + out = [] - if self['Returns']: - out += self._str_field_list('Returns') + if self[name]: + out += self._str_field_list(name) out += [''] - for param, param_type, desc in self['Returns']: + for param, param_type, desc in self[name]: if param_type: - out += self._str_indent(['**%s** : %s' % (param.strip(), - param_type)]) + out += self._str_indent([typed_fmt % (param.strip(), + param_type)]) else: - out += self._str_indent([param.strip()]) - if desc: + out += self._str_indent([untyped_fmt % param.strip()]) + if desc and self.use_blockquotes: out += [''] - out += self._str_indent(desc, 8) + elif not desc: + desc = ['..'] + out += self._str_indent(desc, 8) out += [''] return out - def _str_param_list(self, name): + def _process_param(self, param, desc, fake_autosummary): + """Determine how to display a parameter + + Emulates autosummary behavior if fake_autosummary + + Parameters + ---------- + param : str + The name of the parameter + desc : list of str + The parameter description as given in the docstring. This is + ignored when autosummary logic applies. + fake_autosummary : bool + If True, autosummary-style behaviour will apply for params + that are attributes of the class and have a docstring. + + Returns + ------- + display_param : str + The marked up parameter name for display. This may include a link + to the corresponding attribute's own documentation. + desc : list of str + A list of description lines. This may be identical to the input + ``desc``, if ``autosum is None`` or ``param`` is not a class + attribute, or it will be a summary of the class attribute's + docstring. + + Notes + ----- + This does not have the autosummary functionality to display a method's + signature, and hence is not used to format methods. It may be + complicated to incorporate autosummary's signature mangling, as it + relies on Sphinx's plugin mechanism. + """ + param = param.strip() + display_param = ('**%s**' if self.use_blockquotes else '%s') % param + + if not fake_autosummary: + return display_param, desc + + param_obj = getattr(self._obj, param, None) + if not (callable(param_obj) + or isinstance(param_obj, property) + or inspect.isgetsetdescriptor(param_obj)): + param_obj = None + obj_doc = pydoc.getdoc(param_obj) + + if not (param_obj and obj_doc): + return display_param, desc + + prefix = getattr(self, '_name', '') + if prefix: + autosum_prefix = '~%s.' % prefix + link_prefix = '%s.' % prefix + else: + autosum_prefix = '' + link_prefix = '' + + # Referenced object has a docstring + display_param = ':obj:`%s <%s%s>`' % (param, + link_prefix, + param) + if obj_doc: + # Overwrite desc. Take summary logic of autosummary + desc = re.split('\n\s*\n', obj_doc.strip(), 1)[0] + # XXX: Should this have DOTALL? + # It does not in autosummary + m = re.search(r"^([A-Z].*?\.)(?:\s|$)", + ' '.join(desc.split())) + if m: + desc = m.group(1).strip() + else: + desc = desc.partition('\n')[0] + desc = desc.split('\n') + return display_param, desc + + def _str_param_list(self, name, fake_autosummary=False): + """Generate RST for a listing of parameters or similar + + Parameter names are displayed as bold text, and descriptions + are in blockquotes. Descriptions may therefore contain block + markup as well. + + Parameters + ---------- + name : str + Section name (e.g. Parameters) + fake_autosummary : bool + When True, the parameter names may correspond to attributes of the + object beign documented, usually ``property`` instances on a class. + In this case, names will be linked to fuller descriptions. + + Returns + ------- + rst : list of str + """ out = [] if self[name]: out += self._str_field_list(name) out += [''] for param, param_type, desc in self[name]: + display_param, desc = self._process_param(param, desc, + fake_autosummary) + if param_type: - out += self._str_indent(['**%s** : %s' % (param.strip(), - param_type)]) + out += self._str_indent(['%s : %s' % (display_param, + param_type)]) else: - out += self._str_indent(['**%s**' % param.strip()]) - if desc: + out += self._str_indent([display_param]) + if desc and self.use_blockquotes: out += [''] - out += self._str_indent(desc, 8) + elif not desc: + # empty definition + desc = ['..'] + out += self._str_indent(desc, 8) out += [''] + return out @property @@ -96,7 +226,7 @@ def _str_member_list(self, name): """ out = [] - if self[name] and self.class_members_list: + if self[name]: out += ['.. rubric:: %s' % name, ''] prefix = getattr(self, '_name', '') @@ -115,13 +245,11 @@ def _str_member_list(self, name): or inspect.isgetsetdescriptor(param_obj)): param_obj = None - # pandas HACK - do not exclude attributes which are None - # if param_obj and (pydoc.getdoc(param_obj) or not desc): - # # Referenced object has a docstring - # autosum += [" %s%s" % (prefix, param)] - # else: - # others.append((param, param_type, desc)) - autosum += [" %s%s" % (prefix, param)] + if param_obj and pydoc.getdoc(param_obj): + # Referenced object has a docstring + autosum += [" %s%s" % (prefix, param)] + else: + others.append((param, param_type, desc)) if autosum: out += ['.. autosummary::'] @@ -130,15 +258,15 @@ def _str_member_list(self, name): out += [''] + autosum if others: - maxlen_0 = max(3, max(len(x[0]) for x in others)) - hdr = sixu("=")*maxlen_0 + sixu(" ") + sixu("=")*10 + maxlen_0 = max(3, max([len(x[0]) + 4 for x in others])) + hdr = sixu("=") * maxlen_0 + sixu(" ") + sixu("=") * 10 fmt = sixu('%%%ds %%s ') % (maxlen_0,) - out += ['', hdr] + out += ['', '', hdr] for param, param_type, desc in others: desc = sixu(" ").join(x.strip() for x in desc).strip() if param_type: desc = "(%s) %s" % (param_type, desc) - out += [fmt % (param.strip(), desc)] + out += [fmt % ("**" + param.strip() + "**", desc)] out += [hdr] out += [''] return out @@ -147,7 +275,6 @@ def _str_section(self, name): out = [] if self[name]: out += self._str_header(name) - out += [''] content = textwrap.dedent("\n".join(self[name])).split("\n") out += content out += [''] @@ -166,6 +293,7 @@ def _str_warnings(self): if self['Warnings']: out = ['.. warning::', ''] out += self._str_indent(self['Warnings']) + out += [''] return out def _str_index(self): @@ -174,7 +302,7 @@ def _str_index(self): if len(idx) == 0: return out - out += ['.. index:: %s' % idx.get('default','')] + out += ['.. index:: %s' % idx.get('default', '')] for section, references in idx.items(): if section == 'default': continue @@ -182,6 +310,7 @@ def _str_index(self): out += [' single: %s' % (', '.join(references))] else: out += [' %s: %s' % (section, ','.join(references))] + out += [''] return out def _str_references(self): @@ -195,21 +324,21 @@ def _str_references(self): # Latex collects all references to a separate bibliography, # so we need to insert links to it if sphinx.__version__ >= "0.6": - out += ['.. only:: latex',''] + out += ['.. only:: latex', ''] else: - out += ['.. latexonly::',''] + out += ['.. latexonly::', ''] items = [] for line in self['References']: m = re.match(r'.. \[([a-z0-9._-]+)\]', line, re.I) if m: items.append(m.group(1)) - out += [' ' + ", ".join("[%s]_" % item for item in items), ''] + out += [' ' + ", ".join(["[%s]_" % item for item in items]), ''] return out def _str_examples(self): examples_str = "\n".join(self['Examples']) - if (self.use_plots and 'import matplotlib' in examples_str + if (self.use_plots and re.search(IMPORT_MATPLOTLIB_RE, examples_str) and 'plot::' not in examples_str): out = [] out += self._str_header('Examples') @@ -221,42 +350,52 @@ def _str_examples(self): return self._str_section('Examples') def __str__(self, indent=0, func_role="obj"): - out = [] - out += self._str_signature() - out += self._str_index() + [''] - out += self._str_summary() - out += self._str_extended_summary() - out += self._str_param_list('Parameters') - out += self._str_returns() - for param_list in ('Other Parameters', 'Raises', 'Warns'): - out += self._str_param_list(param_list) - out += self._str_warnings() - out += self._str_see_also(func_role) - out += self._str_section('Notes') - out += self._str_references() - out += self._str_examples() - for param_list in ('Attributes', 'Methods'): - out += self._str_member_list(param_list) - out = self._str_indent(out,indent) - return '\n'.join(out) + ns = { + 'signature': self._str_signature(), + 'index': self._str_index(), + 'summary': self._str_summary(), + 'extended_summary': self._str_extended_summary(), + 'parameters': self._str_param_list('Parameters'), + 'returns': self._str_returns('Returns'), + 'yields': self._str_returns('Yields'), + 'other_parameters': self._str_param_list('Other Parameters'), + 'raises': self._str_param_list('Raises'), + 'warns': self._str_param_list('Warns'), + 'warnings': self._str_warnings(), + 'see_also': self._str_see_also(func_role), + 'notes': self._str_section('Notes'), + 'references': self._str_references(), + 'examples': self._str_examples(), + 'attributes': self._str_param_list('Attributes', + fake_autosummary=True), + 'methods': self._str_member_list('Methods'), + } + ns = dict((k, '\n'.join(v)) for k, v in ns.items()) + + rendered = self.template.render(**ns) + return '\n'.join(self._str_indent(rendered.split('\n'), indent)) + class SphinxFunctionDoc(SphinxDocString, FunctionDoc): def __init__(self, obj, doc=None, config={}): self.load_config(config) FunctionDoc.__init__(self, obj, doc=doc, config=config) + class SphinxClassDoc(SphinxDocString, ClassDoc): def __init__(self, obj, doc=None, func_doc=None, config={}): self.load_config(config) ClassDoc.__init__(self, obj, doc=doc, func_doc=None, config=config) + class SphinxObjDoc(SphinxDocString): def __init__(self, obj, doc=None, config={}): self._f = obj self.load_config(config) SphinxDocString.__init__(self, doc, config=config) -def get_doc_object(obj, what=None, doc=None, config={}): + +def get_doc_object(obj, what=None, doc=None, config={}, builder=None): if what is None: if inspect.isclass(obj): what = 'class' @@ -266,6 +405,16 @@ def get_doc_object(obj, what=None, doc=None, config={}): what = 'function' else: what = 'object' + + template_dirs = [os.path.join(os.path.dirname(__file__), 'templates')] + if builder is not None: + template_loader = BuiltinTemplateLoader() + template_loader.init(builder, dirs=template_dirs) + else: + template_loader = FileSystemLoader(template_dirs) + template_env = SandboxedEnvironment(loader=template_loader) + config['template'] = template_env.get_template('numpydoc_docstring.rst') + if what == 'class': return SphinxClassDoc(obj, func_doc=SphinxFunctionDoc, doc=doc, config=config) diff --git a/doc/sphinxext/numpydoc/linkcode.py b/doc/sphinxext/numpydoc/linkcode.py deleted file mode 100644 index 1ad3ab82cb49c..0000000000000 --- a/doc/sphinxext/numpydoc/linkcode.py +++ /dev/null @@ -1,83 +0,0 @@ -# -*- coding: utf-8 -*- -""" - linkcode - ~~~~~~~~ - - Add external links to module code in Python object descriptions. - - :copyright: Copyright 2007-2011 by the Sphinx team, see AUTHORS. - :license: BSD, see LICENSE for details. - -""" -from __future__ import division, absolute_import, print_function - -import warnings -import collections - -warnings.warn("This extension has been accepted to Sphinx upstream. " - "Use the version from there (Sphinx >= 1.2) " - "https://bitbucket.org/birkenfeld/sphinx/pull-request/47/sphinxextlinkcode", - FutureWarning, stacklevel=1) - - -from docutils import nodes - -from sphinx import addnodes -from sphinx.locale import _ -from sphinx.errors import SphinxError - -class LinkcodeError(SphinxError): - category = "linkcode error" - -def doctree_read(app, doctree): - env = app.builder.env - - resolve_target = getattr(env.config, 'linkcode_resolve', None) - if not isinstance(env.config.linkcode_resolve, collections.Callable): - raise LinkcodeError( - "Function `linkcode_resolve` is not given in conf.py") - - domain_keys = dict( - py=['module', 'fullname'], - c=['names'], - cpp=['names'], - js=['object', 'fullname'], - ) - - for objnode in doctree.traverse(addnodes.desc): - domain = objnode.get('domain') - uris = set() - for signode in objnode: - if not isinstance(signode, addnodes.desc_signature): - continue - - # Convert signode to a specified format - info = {} - for key in domain_keys.get(domain, []): - value = signode.get(key) - if not value: - value = '' - info[key] = value - if not info: - continue - - # Call user code to resolve the link - uri = resolve_target(domain, info) - if not uri: - # no source - continue - - if uri in uris or not uri: - # only one link per name, please - continue - uris.add(uri) - - onlynode = addnodes.only(expr='html') - onlynode += nodes.reference('', '', internal=False, refuri=uri) - onlynode[0] += nodes.inline('', _('[source]'), - classes=['viewcode-link']) - signode += onlynode - -def setup(app): - app.connect('doctree-read', doctree_read) - app.add_config_value('linkcode_resolve', None, '') diff --git a/doc/sphinxext/numpydoc/numpydoc.py b/doc/sphinxext/numpydoc/numpydoc.py old mode 100755 new mode 100644 index 4861aa90edce1..0a6cc79ce150f --- a/doc/sphinxext/numpydoc/numpydoc.py +++ b/doc/sphinxext/numpydoc/numpydoc.py @@ -10,14 +10,17 @@ - Convert Parameters etc. sections to field lists. - Convert See Also section to a See also entry. - Renumber references. -- Extract the signature from the docstring, if it can't be determined otherwise. +- Extract the signature from the docstring, if it can't be determined + otherwise. .. [1] https://github.com/numpy/numpy/blob/master/doc/HOWTO_DOCUMENT.rst.txt """ from __future__ import division, absolute_import, print_function -import os, sys, re, pydoc +import sys +import re +import pydoc import sphinx import inspect import collections @@ -26,6 +29,7 @@ raise RuntimeError("Sphinx 1.0.1 or newer is required") from .docscrape_sphinx import get_doc_object, SphinxDocString +from . import __version__ if sys.version_info[0] >= 3: sixu = lambda s: s @@ -33,29 +37,64 @@ sixu = lambda s: unicode(s, 'unicode_escape') -def mangle_docstrings(app, what, name, obj, options, lines, +def rename_references(app, what, name, obj, options, lines, reference_offset=[0]): + # replace reference numbers so that there are no duplicates + references = set() + for line in lines: + line = line.strip() + m = re.match(sixu('^.. \\[(%s)\\]') % app.config.numpydoc_citation_re, + line, re.I) + if m: + references.add(m.group(1)) - cfg = dict(use_plots=app.config.numpydoc_use_plots, - show_class_members=app.config.numpydoc_show_class_members, - class_members_toctree=app.config.numpydoc_class_members_toctree, - ) + if references: + for r in references: + if r.isdigit(): + new_r = sixu("R%d") % (reference_offset[0] + int(r)) + else: + new_r = sixu("%s%d") % (r, reference_offset[0]) + for i, line in enumerate(lines): + lines[i] = lines[i].replace(sixu('[%s]_') % r, + sixu('[%s]_') % new_r) + lines[i] = lines[i].replace(sixu('.. [%s]') % r, + sixu('.. [%s]') % new_r) + + reference_offset[0] += len(references) + + +DEDUPLICATION_TAG = ' !! processed by numpydoc !!' + + +def mangle_docstrings(app, what, name, obj, options, lines): + if DEDUPLICATION_TAG in lines: + return + + cfg = {'use_plots': app.config.numpydoc_use_plots, + 'use_blockquotes': app.config.numpydoc_use_blockquotes, + 'show_class_members': app.config.numpydoc_show_class_members, + 'show_inherited_class_members': + app.config.numpydoc_show_inherited_class_members, + 'class_members_toctree': app.config.numpydoc_class_members_toctree} + + u_NL = sixu('\n') if what == 'module': # Strip top title - title_re = re.compile(sixu('^\\s*[#*=]{4,}\\n[a-z0-9 -]+\\n[#*=]{4,}\\s*'), - re.I|re.S) - lines[:] = title_re.sub(sixu(''), sixu("\n").join(lines)).split(sixu("\n")) + pattern = '^\\s*[#*=]{4,}\\n[a-z0-9 -]+\\n[#*=]{4,}\\s*' + title_re = re.compile(sixu(pattern), re.I | re.S) + lines[:] = title_re.sub(sixu(''), u_NL.join(lines)).split(u_NL) else: - doc = get_doc_object(obj, what, sixu("\n").join(lines), config=cfg) + doc = get_doc_object(obj, what, u_NL.join(lines), config=cfg, + builder=app.builder) if sys.version_info[0] >= 3: doc = str(doc) else: doc = unicode(doc) - lines[:] = doc.split(sixu("\n")) + lines[:] = doc.split(u_NL) - if app.config.numpydoc_edit_link and hasattr(obj, '__name__') and \ - obj.__name__: + if (app.config.numpydoc_edit_link and hasattr(obj, '__name__') and + obj.__name__): if hasattr(obj, '__module__'): v = dict(full_name=sixu("%s.%s") % (obj.__module__, obj.__name__)) else: @@ -64,48 +103,36 @@ def mangle_docstrings(app, what, name, obj, options, lines, lines += [sixu(' %s') % x for x in (app.config.numpydoc_edit_link % v).split("\n")] - # replace reference numbers so that there are no duplicates - references = [] - for line in lines: - line = line.strip() - m = re.match(sixu('^.. \\[([a-z0-9_.-])\\]'), line, re.I) - if m: - references.append(m.group(1)) + # call function to replace reference numbers so that there are no + # duplicates + rename_references(app, what, name, obj, options, lines) - # start renaming from the longest string, to avoid overwriting parts - references.sort(key=lambda x: -len(x)) - if references: - for i, line in enumerate(lines): - for r in references: - if re.match(sixu('^\\d+$'), r): - new_r = sixu("R%d") % (reference_offset[0] + int(r)) - else: - new_r = sixu("%s%d") % (r, reference_offset[0]) - lines[i] = lines[i].replace(sixu('[%s]_') % r, - sixu('[%s]_') % new_r) - lines[i] = lines[i].replace(sixu('.. [%s]') % r, - sixu('.. [%s]') % new_r) + lines += ['..', DEDUPLICATION_TAG] - reference_offset[0] += len(references) def mangle_signature(app, what, name, obj, options, sig, retann): # Do not try to inspect classes that don't define `__init__` if (inspect.isclass(obj) and (not hasattr(obj, '__init__') or - 'initializes x; see ' in pydoc.getdoc(obj.__init__))): + 'initializes x; see ' in pydoc.getdoc(obj.__init__))): return '', '' - if not (isinstance(obj, collections.Callable) or hasattr(obj, '__argspec_is_invalid_')): return - if not hasattr(obj, '__doc__'): return + if not (isinstance(obj, collections.Callable) or + hasattr(obj, '__argspec_is_invalid_')): + return + if not hasattr(obj, '__doc__'): + return doc = SphinxDocString(pydoc.getdoc(obj)) - if doc['Signature']: - sig = re.sub(sixu("^[^(]*"), sixu(""), doc['Signature']) + sig = doc['Signature'] or getattr(obj, '__text_signature__', None) + if sig: + sig = re.sub(sixu("^[^(]*"), sixu(""), sig) return sig, sixu('') + def setup(app, get_doc_object_=get_doc_object): if not hasattr(app, 'add_config_value'): - return # probably called by nose, better bail out + return # probably called by nose, better bail out global get_doc_object get_doc_object = get_doc_object_ @@ -114,21 +141,31 @@ def setup(app, get_doc_object_=get_doc_object): app.connect('autodoc-process-signature', mangle_signature) app.add_config_value('numpydoc_edit_link', None, False) app.add_config_value('numpydoc_use_plots', None, False) + app.add_config_value('numpydoc_use_blockquotes', None, False) app.add_config_value('numpydoc_show_class_members', True, True) + app.add_config_value('numpydoc_show_inherited_class_members', True, True) app.add_config_value('numpydoc_class_members_toctree', True, True) + app.add_config_value('numpydoc_citation_re', '[a-z0-9_.-]+', True) # Extra mangling domains app.add_domain(NumpyPythonDomain) app.add_domain(NumpyCDomain) -#------------------------------------------------------------------------------ + app.setup_extension('sphinx.ext.autosummary') + + metadata = {'version': __version__, + 'parallel_read_safe': True} + return metadata + +# ------------------------------------------------------------------------------ # Docstring-mangling domains -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ from docutils.statemachine import ViewList from sphinx.domains.c import CDomain from sphinx.domains.python import PythonDomain + class ManglingDomainBase(object): directive_mangling_map = {} @@ -141,6 +178,7 @@ def wrap_mangling_directives(self): self.directives[name] = wrap_mangling_directive( self.directives[name], objtype) + class NumpyPythonDomain(ManglingDomainBase, PythonDomain): name = 'np' directive_mangling_map = { @@ -154,6 +192,7 @@ class NumpyPythonDomain(ManglingDomainBase, PythonDomain): } indices = [] + class NumpyCDomain(ManglingDomainBase, CDomain): name = 'np-c' directive_mangling_map = { @@ -164,6 +203,63 @@ class NumpyCDomain(ManglingDomainBase, CDomain): 'var': 'object', } + +def match_items(lines, content_old): + """Create items for mangled lines. + + This function tries to match the lines in ``lines`` with the items (source + file references and line numbers) in ``content_old``. The + ``mangle_docstrings`` function changes the actual docstrings, but doesn't + keep track of where each line came from. The manging does many operations + on the original lines, which are hard to track afterwards. + + Many of the line changes come from deleting or inserting blank lines. This + function tries to match lines by ignoring blank lines. All other changes + (such as inserting figures or changes in the references) are completely + ignored, so the generated line numbers will be off if ``mangle_docstrings`` + does anything non-trivial. + + This is a best-effort function and the real fix would be to make + ``mangle_docstrings`` actually keep track of the ``items`` together with + the ``lines``. + + Examples + -------- + >>> lines = ['', 'A', '', 'B', ' ', '', 'C', 'D'] + >>> lines_old = ['a', '', '', 'b', '', 'c'] + >>> items_old = [('file1.py', 0), ('file1.py', 1), ('file1.py', 2), + ... ('file2.py', 0), ('file2.py', 1), ('file2.py', 2)] + >>> content_old = ViewList(lines_old, items=items_old) + >>> match_items(lines, content_old) # doctest: +NORMALIZE_WHITESPACE + [('file1.py', 0), ('file1.py', 0), ('file2.py', 0), ('file2.py', 0), + ('file2.py', 2), ('file2.py', 2), ('file2.py', 2), ('file2.py', 2)] + >>> # first 2 ``lines`` are matched to 'a', second 2 to 'b', rest to 'c' + >>> # actual content is completely ignored. + + Notes + ----- + The algorithm tries to match any line in ``lines`` with one in + ``lines_old``. It skips over all empty lines in ``lines_old`` and assigns + this line number to all lines in ``lines``, unless a non-empty line is + found in ``lines`` in which case it goes to the next line in ``lines_old``. + + """ + items_new = [] + lines_old = content_old.data + items_old = content_old.items + j = 0 + for i, line in enumerate(lines): + # go to next non-empty line in old: + # line.strip() checks whether the string is all whitespace + while j < len(lines_old) - 1 and not lines_old[j].strip(): + j += 1 + items_new.append(items_old[j]) + if line.strip() and j < len(lines_old) - 1: + j += 1 + assert(len(items_new) == len(lines)) + return items_new + + def wrap_mangling_directive(base_directive, objtype): class directive(base_directive): def run(self): @@ -179,7 +275,10 @@ def run(self): lines = list(self.content) mangle_docstrings(env.app, objtype, name, None, None, lines) - self.content = ViewList(lines, self.content.parent) + if self.content: + items = match_items(lines, self.content) + self.content = ViewList(lines, items=items, + parent=self.content.parent) return base_directive.run(self) diff --git a/doc/sphinxext/numpydoc/phantom_import.py b/doc/sphinxext/numpydoc/phantom_import.py deleted file mode 100755 index f33dd838e8bb3..0000000000000 --- a/doc/sphinxext/numpydoc/phantom_import.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -============== -phantom_import -============== - -Sphinx extension to make directives from ``sphinx.ext.autodoc`` and similar -extensions to use docstrings loaded from an XML file. - -This extension loads an XML file in the Pydocweb format [1] and -creates a dummy module that contains the specified docstrings. This -can be used to get the current docstrings from a Pydocweb instance -without needing to rebuild the documented module. - -.. [1] https://github.com/pv/pydocweb - -""" -from __future__ import division, absolute_import, print_function - -import imp, sys, compiler, types, os, inspect, re - -def setup(app): - app.connect('builder-inited', initialize) - app.add_config_value('phantom_import_file', None, True) - -def initialize(app): - fn = app.config.phantom_import_file - if (fn and os.path.isfile(fn)): - print("[numpydoc] Phantom importing modules from", fn, "...") - import_phantom_module(fn) - -#------------------------------------------------------------------------------ -# Creating 'phantom' modules from an XML description -#------------------------------------------------------------------------------ -def import_phantom_module(xml_file): - """ - Insert a fake Python module to sys.modules, based on a XML file. - - The XML file is expected to conform to Pydocweb DTD. The fake - module will contain dummy objects, which guarantee the following: - - - Docstrings are correct. - - Class inheritance relationships are correct (if present in XML). - - Function argspec is *NOT* correct (even if present in XML). - Instead, the function signature is prepended to the function docstring. - - Class attributes are *NOT* correct; instead, they are dummy objects. - - Parameters - ---------- - xml_file : str - Name of an XML file to read - - """ - import lxml.etree as etree - - object_cache = {} - - tree = etree.parse(xml_file) - root = tree.getroot() - - # Sort items so that - # - Base classes come before classes inherited from them - # - Modules come before their contents - all_nodes = {n.attrib['id']: n for n in root} - - def _get_bases(node, recurse=False): - bases = [x.attrib['ref'] for x in node.findall('base')] - if recurse: - j = 0 - while True: - try: - b = bases[j] - except IndexError: break - if b in all_nodes: - bases.extend(_get_bases(all_nodes[b])) - j += 1 - return bases - - type_index = ['module', 'class', 'callable', 'object'] - - def base_cmp(a, b): - x = cmp(type_index.index(a.tag), type_index.index(b.tag)) - if x != 0: return x - - if a.tag == 'class' and b.tag == 'class': - a_bases = _get_bases(a, recurse=True) - b_bases = _get_bases(b, recurse=True) - x = cmp(len(a_bases), len(b_bases)) - if x != 0: return x - if a.attrib['id'] in b_bases: return -1 - if b.attrib['id'] in a_bases: return 1 - - return cmp(a.attrib['id'].count('.'), b.attrib['id'].count('.')) - - nodes = root.getchildren() - nodes.sort(base_cmp) - - # Create phantom items - for node in nodes: - name = node.attrib['id'] - doc = (node.text or '').decode('string-escape') + "\n" - if doc == "\n": doc = "" - - # create parent, if missing - parent = name - while True: - parent = '.'.join(parent.split('.')[:-1]) - if not parent: break - if parent in object_cache: break - obj = imp.new_module(parent) - object_cache[parent] = obj - sys.modules[parent] = obj - - # create object - if node.tag == 'module': - obj = imp.new_module(name) - obj.__doc__ = doc - sys.modules[name] = obj - elif node.tag == 'class': - bases = [object_cache[b] for b in _get_bases(node) - if b in object_cache] - bases.append(object) - init = lambda self: None - init.__doc__ = doc - obj = type(name, tuple(bases), {'__doc__': doc, '__init__': init}) - obj.__name__ = name.split('.')[-1] - elif node.tag == 'callable': - funcname = node.attrib['id'].split('.')[-1] - argspec = node.attrib.get('argspec') - if argspec: - argspec = re.sub('^[^(]*', '', argspec) - doc = "%s%s\n\n%s" % (funcname, argspec, doc) - obj = lambda: 0 - obj.__argspec_is_invalid_ = True - if sys.version_info[0] >= 3: - obj.__name__ = funcname - else: - obj.func_name = funcname - obj.__name__ = name - obj.__doc__ = doc - if inspect.isclass(object_cache[parent]): - obj.__objclass__ = object_cache[parent] - else: - class Dummy(object): pass - obj = Dummy() - obj.__name__ = name - obj.__doc__ = doc - if inspect.isclass(object_cache[parent]): - obj.__get__ = lambda: None - object_cache[name] = obj - - if parent: - if inspect.ismodule(object_cache[parent]): - obj.__module__ = parent - setattr(object_cache[parent], name.split('.')[-1], obj) - - # Populate items - for node in root: - obj = object_cache.get(node.attrib['id']) - if obj is None: continue - for ref in node.findall('ref'): - if node.tag == 'class': - if ref.attrib['ref'].startswith(node.attrib['id'] + '.'): - setattr(obj, ref.attrib['name'], - object_cache.get(ref.attrib['ref'])) - else: - setattr(obj, ref.attrib['name'], - object_cache.get(ref.attrib['ref'])) diff --git a/doc/sphinxext/numpydoc/plot_directive.py b/doc/sphinxext/numpydoc/plot_directive.py deleted file mode 100755 index 2014f857076c1..0000000000000 --- a/doc/sphinxext/numpydoc/plot_directive.py +++ /dev/null @@ -1,642 +0,0 @@ -""" -A special directive for generating a matplotlib plot. - -.. warning:: - - This is a hacked version of plot_directive.py from Matplotlib. - It's very much subject to change! - - -Usage ------ - -Can be used like this:: - - .. plot:: examples/example.py - - .. plot:: - - import matplotlib.pyplot as plt - plt.plot([1,2,3], [4,5,6]) - - .. plot:: - - A plotting example: - - >>> import matplotlib.pyplot as plt - >>> plt.plot([1,2,3], [4,5,6]) - -The content is interpreted as doctest formatted if it has a line starting -with ``>>>``. - -The ``plot`` directive supports the options - - format : {'python', 'doctest'} - Specify the format of the input - - include-source : bool - Whether to display the source code. Default can be changed in conf.py - -and the ``image`` directive options ``alt``, ``height``, ``width``, -``scale``, ``align``, ``class``. - -Configuration options ---------------------- - -The plot directive has the following configuration options: - - plot_include_source - Default value for the include-source option - - plot_pre_code - Code that should be executed before each plot. - - plot_basedir - Base directory, to which plot:: file names are relative to. - (If None or empty, file names are relative to the directoly where - the file containing the directive is.) - - plot_formats - File formats to generate. List of tuples or strings:: - - [(suffix, dpi), suffix, ...] - - that determine the file format and the DPI. For entries whose - DPI was omitted, sensible defaults are chosen. - - plot_html_show_formats - Whether to show links to the files in HTML. - -TODO ----- - -* Refactor Latex output; now it's plain images, but it would be nice - to make them appear side-by-side, or in floats. - -""" -from __future__ import division, absolute_import, print_function - -import sys, os, glob, shutil, imp, warnings, re, textwrap, traceback -import sphinx - -if sys.version_info[0] >= 3: - from io import StringIO -else: - from io import StringIO - -import warnings -warnings.warn("A plot_directive module is also available under " - "matplotlib.sphinxext; expect this numpydoc.plot_directive " - "module to be deprecated after relevant features have been " - "integrated there.", - FutureWarning, stacklevel=2) - - -#------------------------------------------------------------------------------ -# Registration hook -#------------------------------------------------------------------------------ - -def setup(app): - setup.app = app - setup.config = app.config - setup.confdir = app.confdir - - app.add_config_value('plot_pre_code', '', True) - app.add_config_value('plot_include_source', False, True) - app.add_config_value('plot_formats', ['png', 'hires.png', 'pdf'], True) - app.add_config_value('plot_basedir', None, True) - app.add_config_value('plot_html_show_formats', True, True) - - app.add_directive('plot', plot_directive, True, (0, 1, False), - **plot_directive_options) - -#------------------------------------------------------------------------------ -# plot:: directive -#------------------------------------------------------------------------------ -from docutils.parsers.rst import directives -from docutils import nodes - -def plot_directive(name, arguments, options, content, lineno, - content_offset, block_text, state, state_machine): - return run(arguments, content, options, state_machine, state, lineno) -plot_directive.__doc__ = __doc__ - -def _option_boolean(arg): - if not arg or not arg.strip(): - # no argument given, assume used as a flag - return True - elif arg.strip().lower() in ('no', '0', 'false'): - return False - elif arg.strip().lower() in ('yes', '1', 'true'): - return True - else: - raise ValueError('"%s" unknown boolean' % arg) - -def _option_format(arg): - return directives.choice(arg, ('python', 'lisp')) - -def _option_align(arg): - return directives.choice(arg, ("top", "middle", "bottom", "left", "center", - "right")) - -plot_directive_options = {'alt': directives.unchanged, - 'height': directives.length_or_unitless, - 'width': directives.length_or_percentage_or_unitless, - 'scale': directives.nonnegative_int, - 'align': _option_align, - 'class': directives.class_option, - 'include-source': _option_boolean, - 'format': _option_format, - } - -#------------------------------------------------------------------------------ -# Generating output -#------------------------------------------------------------------------------ - -from docutils import nodes, utils - -try: - # Sphinx depends on either Jinja or Jinja2 - import jinja2 - def format_template(template, **kw): - return jinja2.Template(template).render(**kw) -except ImportError: - import jinja - def format_template(template, **kw): - return jinja.from_string(template, **kw) - -TEMPLATE = """ -{{ source_code }} - -{{ only_html }} - - {% if source_link or (html_show_formats and not multi_image) %} - ( - {%- if source_link -%} - `Source code <{{ source_link }}>`__ - {%- endif -%} - {%- if html_show_formats and not multi_image -%} - {%- for img in images -%} - {%- for fmt in img.formats -%} - {%- if source_link or not loop.first -%}, {% endif -%} - `{{ fmt }} <{{ dest_dir }}/{{ img.basename }}.{{ fmt }}>`__ - {%- endfor -%} - {%- endfor -%} - {%- endif -%} - ) - {% endif %} - - {% for img in images %} - .. figure:: {{ build_dir }}/{{ img.basename }}.png - {%- for option in options %} - {{ option }} - {% endfor %} - - {% if html_show_formats and multi_image -%} - ( - {%- for fmt in img.formats -%} - {%- if not loop.first -%}, {% endif -%} - `{{ fmt }} <{{ dest_dir }}/{{ img.basename }}.{{ fmt }}>`__ - {%- endfor -%} - ) - {%- endif -%} - {% endfor %} - -{{ only_latex }} - - {% for img in images %} - .. image:: {{ build_dir }}/{{ img.basename }}.pdf - {% endfor %} - -""" - -class ImageFile(object): - def __init__(self, basename, dirname): - self.basename = basename - self.dirname = dirname - self.formats = [] - - def filename(self, format): - return os.path.join(self.dirname, "%s.%s" % (self.basename, format)) - - def filenames(self): - return [self.filename(fmt) for fmt in self.formats] - -def run(arguments, content, options, state_machine, state, lineno): - if arguments and content: - raise RuntimeError("plot:: directive can't have both args and content") - - document = state_machine.document - config = document.settings.env.config - - options.setdefault('include-source', config.plot_include_source) - - # determine input - rst_file = document.attributes['source'] - rst_dir = os.path.dirname(rst_file) - - if arguments: - if not config.plot_basedir: - source_file_name = os.path.join(rst_dir, - directives.uri(arguments[0])) - else: - source_file_name = os.path.join(setup.confdir, config.plot_basedir, - directives.uri(arguments[0])) - code = open(source_file_name, 'r').read() - output_base = os.path.basename(source_file_name) - else: - source_file_name = rst_file - code = textwrap.dedent("\n".join(map(str, content))) - counter = document.attributes.get('_plot_counter', 0) + 1 - document.attributes['_plot_counter'] = counter - base, ext = os.path.splitext(os.path.basename(source_file_name)) - output_base = '%s-%d.py' % (base, counter) - - base, source_ext = os.path.splitext(output_base) - if source_ext in ('.py', '.rst', '.txt'): - output_base = base - else: - source_ext = '' - - # ensure that LaTeX includegraphics doesn't choke in foo.bar.pdf filenames - output_base = output_base.replace('.', '-') - - # is it in doctest format? - is_doctest = contains_doctest(code) - if 'format' in options: - if options['format'] == 'python': - is_doctest = False - else: - is_doctest = True - - # determine output directory name fragment - source_rel_name = relpath(source_file_name, setup.confdir) - source_rel_dir = os.path.dirname(source_rel_name) - while source_rel_dir.startswith(os.path.sep): - source_rel_dir = source_rel_dir[1:] - - # build_dir: where to place output files (temporarily) - build_dir = os.path.join(os.path.dirname(setup.app.doctreedir), - 'plot_directive', - source_rel_dir) - if not os.path.exists(build_dir): - os.makedirs(build_dir) - - # output_dir: final location in the builder's directory - dest_dir = os.path.abspath(os.path.join(setup.app.builder.outdir, - source_rel_dir)) - - # how to link to files from the RST file - dest_dir_link = os.path.join(relpath(setup.confdir, rst_dir), - source_rel_dir).replace(os.path.sep, '/') - build_dir_link = relpath(build_dir, rst_dir).replace(os.path.sep, '/') - source_link = dest_dir_link + '/' + output_base + source_ext - - # make figures - try: - results = makefig(code, source_file_name, build_dir, output_base, - config) - errors = [] - except PlotError as err: - reporter = state.memo.reporter - sm = reporter.system_message( - 2, "Exception occurred in plotting %s: %s" % (output_base, err), - line=lineno) - results = [(code, [])] - errors = [sm] - - # generate output restructuredtext - total_lines = [] - for j, (code_piece, images) in enumerate(results): - if options['include-source']: - if is_doctest: - lines = [''] - lines += [row.rstrip() for row in code_piece.split('\n')] - else: - lines = ['.. code-block:: python', ''] - lines += [' %s' % row.rstrip() - for row in code_piece.split('\n')] - source_code = "\n".join(lines) - else: - source_code = "" - - opts = [':%s: %s' % (key, val) for key, val in list(options.items()) - if key in ('alt', 'height', 'width', 'scale', 'align', 'class')] - - only_html = ".. only:: html" - only_latex = ".. only:: latex" - - if j == 0: - src_link = source_link - else: - src_link = None - - result = format_template( - TEMPLATE, - dest_dir=dest_dir_link, - build_dir=build_dir_link, - source_link=src_link, - multi_image=len(images) > 1, - only_html=only_html, - only_latex=only_latex, - options=opts, - images=images, - source_code=source_code, - html_show_formats=config.plot_html_show_formats) - - total_lines.extend(result.split("\n")) - total_lines.extend("\n") - - if total_lines: - state_machine.insert_input(total_lines, source=source_file_name) - - # copy image files to builder's output directory - if not os.path.exists(dest_dir): - os.makedirs(dest_dir) - - for code_piece, images in results: - for img in images: - for fn in img.filenames(): - shutil.copyfile(fn, os.path.join(dest_dir, - os.path.basename(fn))) - - # copy script (if necessary) - if source_file_name == rst_file: - target_name = os.path.join(dest_dir, output_base + source_ext) - f = open(target_name, 'w') - f.write(unescape_doctest(code)) - f.close() - - return errors - - -#------------------------------------------------------------------------------ -# Run code and capture figures -#------------------------------------------------------------------------------ - -import matplotlib -matplotlib.use('Agg') -import matplotlib.pyplot as plt -import matplotlib.image as image -from matplotlib import _pylab_helpers - -import exceptions - -def contains_doctest(text): - try: - # check if it's valid Python as-is - compile(text, '', 'exec') - return False - except SyntaxError: - pass - r = re.compile(r'^\s*>>>', re.M) - m = r.search(text) - return bool(m) - -def unescape_doctest(text): - """ - Extract code from a piece of text, which contains either Python code - or doctests. - - """ - if not contains_doctest(text): - return text - - code = "" - for line in text.split("\n"): - m = re.match(r'^\s*(>>>|\.\.\.) (.*)$', line) - if m: - code += m.group(2) + "\n" - elif line.strip(): - code += "# " + line.strip() + "\n" - else: - code += "\n" - return code - -def split_code_at_show(text): - """ - Split code at plt.show() - - """ - - parts = [] - is_doctest = contains_doctest(text) - - part = [] - for line in text.split("\n"): - if (not is_doctest and line.strip() == 'plt.show()') or \ - (is_doctest and line.strip() == '>>> plt.show()'): - part.append(line) - parts.append("\n".join(part)) - part = [] - else: - part.append(line) - if "\n".join(part).strip(): - parts.append("\n".join(part)) - return parts - -class PlotError(RuntimeError): - pass - -def run_code(code, code_path, ns=None): - # Change the working directory to the directory of the example, so - # it can get at its data files, if any. - pwd = os.getcwd() - old_sys_path = list(sys.path) - if code_path is not None: - dirname = os.path.abspath(os.path.dirname(code_path)) - os.chdir(dirname) - sys.path.insert(0, dirname) - - # Redirect stdout - stdout = sys.stdout - sys.stdout = StringIO() - - # Reset sys.argv - old_sys_argv = sys.argv - sys.argv = [code_path] - - try: - try: - code = unescape_doctest(code) - if ns is None: - ns = {} - if not ns: - exec(setup.config.plot_pre_code, ns) - exec(code, ns) - except (Exception, SystemExit) as err: - raise PlotError(traceback.format_exc()) - finally: - os.chdir(pwd) - sys.argv = old_sys_argv - sys.path[:] = old_sys_path - sys.stdout = stdout - return ns - - -#------------------------------------------------------------------------------ -# Generating figures -#------------------------------------------------------------------------------ - -def out_of_date(original, derived): - """ - Returns True if derivative is out-of-date wrt original, - both of which are full file paths. - """ - return (not os.path.exists(derived) - or os.stat(derived).st_mtime < os.stat(original).st_mtime) - - -def makefig(code, code_path, output_dir, output_base, config): - """ - Run a pyplot script *code* and save the images under *output_dir* - with file names derived from *output_base* - - """ - - # -- Parse format list - default_dpi = {'png': 80, 'hires.png': 200, 'pdf': 50} - formats = [] - for fmt in config.plot_formats: - if isinstance(fmt, str): - formats.append((fmt, default_dpi.get(fmt, 80))) - elif type(fmt) in (tuple, list) and len(fmt)==2: - formats.append((str(fmt[0]), int(fmt[1]))) - else: - raise PlotError('invalid image format "%r" in plot_formats' % fmt) - - # -- Try to determine if all images already exist - - code_pieces = split_code_at_show(code) - - # Look for single-figure output files first - all_exists = True - img = ImageFile(output_base, output_dir) - for format, dpi in formats: - if out_of_date(code_path, img.filename(format)): - all_exists = False - break - img.formats.append(format) - - if all_exists: - return [(code, [img])] - - # Then look for multi-figure output files - results = [] - all_exists = True - for i, code_piece in enumerate(code_pieces): - images = [] - for j in range(1000): - img = ImageFile('%s_%02d_%02d' % (output_base, i, j), output_dir) - for format, dpi in formats: - if out_of_date(code_path, img.filename(format)): - all_exists = False - break - img.formats.append(format) - - # assume that if we have one, we have them all - if not all_exists: - all_exists = (j > 0) - break - images.append(img) - if not all_exists: - break - results.append((code_piece, images)) - - if all_exists: - return results - - # -- We didn't find the files, so build them - - results = [] - ns = {} - - for i, code_piece in enumerate(code_pieces): - # Clear between runs - plt.close('all') - - # Run code - run_code(code_piece, code_path, ns) - - # Collect images - images = [] - fig_managers = _pylab_helpers.Gcf.get_all_fig_managers() - for j, figman in enumerate(fig_managers): - if len(fig_managers) == 1 and len(code_pieces) == 1: - img = ImageFile(output_base, output_dir) - else: - img = ImageFile("%s_%02d_%02d" % (output_base, i, j), - output_dir) - images.append(img) - for format, dpi in formats: - try: - figman.canvas.figure.savefig(img.filename(format), dpi=dpi) - except exceptions.BaseException as err: - raise PlotError(traceback.format_exc()) - img.formats.append(format) - - # Results - results.append((code_piece, images)) - - return results - - -#------------------------------------------------------------------------------ -# Relative pathnames -#------------------------------------------------------------------------------ - -try: - from os.path import relpath -except ImportError: - # Copied from Python 2.7 - if 'posix' in sys.builtin_module_names: - def relpath(path, start=os.path.curdir): - """Return a relative version of a path""" - from os.path import sep, curdir, join, abspath, commonprefix, \ - pardir - - if not path: - raise ValueError("no path specified") - - start_list = abspath(start).split(sep) - path_list = abspath(path).split(sep) - - # Work out how much of the filepath is shared by start and path. - i = len(commonprefix([start_list, path_list])) - - rel_list = [pardir] * (len(start_list)-i) + path_list[i:] - if not rel_list: - return curdir - return join(*rel_list) - elif 'nt' in sys.builtin_module_names: - def relpath(path, start=os.path.curdir): - """Return a relative version of a path""" - from os.path import sep, curdir, join, abspath, commonprefix, \ - pardir, splitunc - - if not path: - raise ValueError("no path specified") - start_list = abspath(start).split(sep) - path_list = abspath(path).split(sep) - if start_list[0].lower() != path_list[0].lower(): - unc_path, rest = splitunc(path) - unc_start, rest = splitunc(start) - if bool(unc_path) ^ bool(unc_start): - raise ValueError("Cannot mix UNC and non-UNC paths (%s and %s)" - % (path, start)) - else: - raise ValueError("path is on drive %s, start on drive %s" - % (path_list[0], start_list[0])) - # Work out how much of the filepath is shared by start and path. - for i in range(min(len(start_list), len(path_list))): - if start_list[i].lower() != path_list[i].lower(): - break - else: - i += 1 - - rel_list = [pardir] * (len(start_list)-i) + path_list[i:] - if not rel_list: - return curdir - return join(*rel_list) - else: - raise RuntimeError("Unsupported platform (no relpath available!)") diff --git a/doc/sphinxext/numpydoc/templates/numpydoc_docstring.rst b/doc/sphinxext/numpydoc/templates/numpydoc_docstring.rst new file mode 100644 index 0000000000000..1900db53cee47 --- /dev/null +++ b/doc/sphinxext/numpydoc/templates/numpydoc_docstring.rst @@ -0,0 +1,16 @@ +{{index}} +{{summary}} +{{extended_summary}} +{{parameters}} +{{returns}} +{{yields}} +{{other_parameters}} +{{raises}} +{{warns}} +{{warnings}} +{{see_also}} +{{notes}} +{{references}} +{{examples}} +{{attributes}} +{{methods}} diff --git a/doc/sphinxext/numpydoc/tests/test_docscrape.py b/doc/sphinxext/numpydoc/tests/test_docscrape.py old mode 100755 new mode 100644 index b412124d774bb..2fb4eb5ab277e --- a/doc/sphinxext/numpydoc/tests/test_docscrape.py +++ b/doc/sphinxext/numpydoc/tests/test_docscrape.py @@ -1,11 +1,25 @@ # -*- encoding:utf-8 -*- from __future__ import division, absolute_import, print_function -import sys, textwrap - -from numpydoc.docscrape import NumpyDocString, FunctionDoc, ClassDoc -from numpydoc.docscrape_sphinx import SphinxDocString, SphinxClassDoc -from nose.tools import * +import re +import sys +import textwrap +import warnings + +import jinja2 + +from numpydoc.docscrape import ( + NumpyDocString, + FunctionDoc, + ClassDoc, + ParseError +) +from numpydoc.docscrape_sphinx import (SphinxDocString, SphinxClassDoc, + SphinxFunctionDoc) +from nose.tools import (assert_equal, assert_raises, assert_list_equal, + assert_true) + +assert_list_equal.__self__.maxDiff = None if sys.version_info[0] >= 3: sixu = lambda s: s @@ -50,6 +64,7 @@ list of str This is not a real return value. It exists to test anonymous return values. + no_description Other Parameters ---------------- @@ -122,18 +137,35 @@ ''' doc = NumpyDocString(doc_txt) +doc_yields_txt = """ +Test generator + +Yields +------ +a : int + The number of apples. +b : int + The number of bananas. +int + The number of unknowns. +""" +doc_yields = NumpyDocString(doc_yields_txt) + def test_signature(): assert doc['Signature'].startswith('numpy.multivariate_normal(') assert doc['Signature'].endswith('spam=None)') + def test_summary(): assert doc['Summary'][0].startswith('Draw values') assert doc['Summary'][-1].endswith('covariance.') + def test_extended_summary(): assert doc['Extended Summary'][0].startswith('The multivariate normal') + def test_parameters(): assert_equal(len(doc['Parameters']), 3) assert_equal([n for n,_,_ in doc['Parameters']], ['mean','cov','shape']) @@ -141,7 +173,8 @@ def test_parameters(): arg, arg_type, desc = doc['Parameters'][1] assert_equal(arg_type, '(N, N) ndarray') assert desc[0].startswith('Covariance matrix') - assert doc['Parameters'][0][-1][-2] == ' (1+2+3)/3' + assert doc['Parameters'][0][-1][-1] == ' (1+2+3)/3' + def test_other_parameters(): assert_equal(len(doc['Other Parameters']), 1) @@ -150,8 +183,9 @@ def test_other_parameters(): assert_equal(arg_type, 'parrot') assert desc[0].startswith('A parrot off its mortal coil') + def test_returns(): - assert_equal(len(doc['Returns']), 2) + assert_equal(len(doc['Returns']), 3) arg, arg_type, desc = doc['Returns'][0] assert_equal(arg, 'out') assert_equal(arg_type, 'ndarray') @@ -164,36 +198,152 @@ def test_returns(): assert desc[0].startswith('This is not a real') assert desc[-1].endswith('anonymous return values.') + arg, arg_type, desc = doc['Returns'][2] + assert_equal(arg, 'no_description') + assert_equal(arg_type, '') + assert not ''.join(desc).strip() + + +def test_yields(): + section = doc_yields['Yields'] + assert_equal(len(section), 3) + truth = [('a', 'int', 'apples.'), + ('b', 'int', 'bananas.'), + ('int', '', 'unknowns.')] + for (arg, arg_type, desc), (arg_, arg_type_, end) in zip(section, truth): + assert_equal(arg, arg_) + assert_equal(arg_type, arg_type_) + assert desc[0].startswith('The number of') + assert desc[0].endswith(end) + + +def test_returnyield(): + doc_text = """ +Test having returns and yields. + +Returns +------- +int + The number of apples. + +Yields +------ +a : int + The number of apples. +b : int + The number of bananas. + +""" + assert_raises(ValueError, NumpyDocString, doc_text) + + +def test_section_twice(): + doc_text = """ +Test having a section Notes twice + +Notes +----- +See the next note for more information + +Notes +----- +That should break... +""" + assert_raises(ValueError, NumpyDocString, doc_text) + + # if we have a numpydoc object, we know where the error came from + class Dummy(object): + """ + Dummy class. + + Notes + ----- + First note. + + Notes + ----- + Second note. + + """ + def spam(self, a, b): + """Spam\n\nSpam spam.""" + pass + + def ham(self, c, d): + """Cheese\n\nNo cheese.""" + pass + + def dummy_func(arg): + """ + Dummy function. + + Notes + ----- + First note. + + Notes + ----- + Second note. + """ + + try: + SphinxClassDoc(Dummy) + except ValueError as e: + # python 3 version or python 2 version + assert_true("test_section_twice..Dummy" in str(e) + or 'test_docscrape.Dummy' in str(e)) + + try: + SphinxFunctionDoc(dummy_func) + except ValueError as e: + # python 3 version or python 2 version + assert_true("test_section_twice..dummy_func" in str(e) + or 'function dummy_func' in str(e)) + + def test_notes(): assert doc['Notes'][0].startswith('Instead') assert doc['Notes'][-1].endswith('definite.') assert_equal(len(doc['Notes']), 17) + def test_references(): assert doc['References'][0].startswith('..') assert doc['References'][-1].endswith('2001.') + def test_examples(): assert doc['Examples'][0].startswith('>>>') assert doc['Examples'][-1].endswith('True]') + def test_index(): assert_equal(doc['index']['default'], 'random') assert_equal(len(doc['index']), 2) assert_equal(len(doc['index']['refguide']), 2) -def non_blank_line_by_line_compare(a,b): + +def _strip_blank_lines(s): + "Remove leading, trailing and multiple blank lines" + s = re.sub(r'^\s*\n', '', s) + s = re.sub(r'\n\s*$', '', s) + s = re.sub(r'\n\s*\n', r'\n\n', s) + return s + + +def line_by_line_compare(a, b): a = textwrap.dedent(a) b = textwrap.dedent(b) - a = [l.rstrip() for l in a.split('\n') if l.strip()] - b = [l.rstrip() for l in b.split('\n') if l.strip()] - for n,line in enumerate(a): - if not line == b[n]: - raise AssertionError("Lines %s of a and b differ: " - "\n>>> %s\n<<< %s\n" % - (n,line,b[n])) + a = [l.rstrip() for l in _strip_blank_lines(a).split('\n')] + b = [l.rstrip() for l in _strip_blank_lines(b).split('\n')] + assert_list_equal(a, b) + + def test_str(): - non_blank_line_by_line_compare(str(doc), + # doc_txt has the order of Notes and See Also sections flipped. + # This should be handled automatically, and so, one thing this test does + # is to make sure that See Also precedes Notes in the output. + line_by_line_compare(str(doc), """numpy.multivariate_normal(mean, cov, shape=None, spam=None) Draw values from a multivariate normal distribution with specified @@ -210,7 +360,6 @@ def test_str(): .. math:: (1+2+3)/3 - cov : (N, N) ndarray Covariance matrix of the distribution. shape : tuple of ints @@ -230,6 +379,7 @@ def test_str(): list of str This is not a real return value. It exists to test anonymous return values. +no_description Other Parameters ---------------- @@ -252,6 +402,7 @@ def test_str(): See Also -------- + `some`_, `other`_, `funcs`_ `otherfunc`_ @@ -302,9 +453,25 @@ def test_str(): :refguide: random;distributions, random;gauss""") +def test_yield_str(): + line_by_line_compare(str(doc_yields), +"""Test generator + +Yields +------ +a : int + The number of apples. +b : int + The number of bananas. +int + The number of unknowns. + +.. index:: """) + + def test_sphinx_str(): sphinx_doc = SphinxDocString(doc_txt) - non_blank_line_by_line_compare(str(sphinx_doc), + line_by_line_compare(str(sphinx_doc), """ .. index:: random single: random;distributions, random;gauss @@ -317,28 +484,24 @@ def test_sphinx_str(): :Parameters: - **mean** : (N,) ndarray - + mean : (N,) ndarray Mean of the N-dimensional distribution. .. math:: (1+2+3)/3 - **cov** : (N, N) ndarray - + cov : (N, N) ndarray Covariance matrix of the distribution. - **shape** : tuple of ints - + shape : tuple of ints Given a shape of, for example, (m,n,k), m*n*k samples are generated, and packed in an m-by-n-by-k arrangement. Because each sample is N-dimensional, the output shape is (m,n,k,N). :Returns: - **out** : ndarray - + out : ndarray The drawn samples, arranged according to `shape`. If the shape given is (m,n,...), then the shape of `out` is (m,n,...,N). @@ -347,26 +510,25 @@ def test_sphinx_str(): value drawn from the distribution. list of str - This is not a real return value. It exists to test anonymous return values. -:Other Parameters: + no_description + .. - **spam** : parrot +:Other Parameters: + spam : parrot A parrot off its mortal coil. :Raises: - **RuntimeError** - + RuntimeError Some error :Warns: - **RuntimeWarning** - + RuntimeWarning Some warning .. warning:: @@ -427,6 +589,24 @@ def test_sphinx_str(): """) +def test_sphinx_yields_str(): + sphinx_doc = SphinxDocString(doc_yields_txt) + line_by_line_compare(str(sphinx_doc), +"""Test generator + +:Yields: + + a : int + The number of apples. + + b : int + The number of bananas. + + int + The number of unknowns. +""") + + doc2 = NumpyDocString(""" Returns array of indices of the maximum values of along the given axis. @@ -438,27 +618,39 @@ def test_sphinx_str(): If None, the index is into the flattened array, otherwise along the specified axis""") + def test_parameters_without_extended_description(): assert_equal(len(doc2['Parameters']), 2) + doc3 = NumpyDocString(""" my_signature(*params, **kwds) Return this and that. """) + def test_escape_stars(): signature = str(doc3).split('\n')[0] assert_equal(signature, 'my_signature(\*params, \*\*kwds)') + def my_func(a, b, **kwargs): + pass + + fdoc = FunctionDoc(func=my_func) + assert_equal(fdoc['Signature'], 'my_func(a, b, \*\*kwargs)') + + doc4 = NumpyDocString( """a.conj() Return an array with all complex-valued elements conjugated.""") + def test_empty_extended_summary(): assert_equal(doc4['Extended Summary'], []) + doc5 = NumpyDocString( """ a.something() @@ -474,18 +666,21 @@ def test_empty_extended_summary(): If needed """) + def test_raises(): assert_equal(len(doc5['Raises']), 1) name,_,desc = doc5['Raises'][0] assert_equal(name,'LinAlgException') assert_equal(desc,['If array is singular.']) + def test_warns(): assert_equal(len(doc5['Warns']), 1) name,_,desc = doc5['Warns'][0] assert_equal(name,'SomeWarning') assert_equal(desc,['If needed']) + def test_see_also(): doc6 = NumpyDocString( """ @@ -500,21 +695,23 @@ def test_see_also(): func_f, func_g, :meth:`func_h`, func_j, func_k :obj:`baz.obj_q` + :obj:`~baz.obj_r` :class:`class_j`: fubar foobar """) - assert len(doc6['See Also']) == 12 + assert len(doc6['See Also']) == 13 for func, desc, role in doc6['See Also']: if func in ('func_a', 'func_b', 'func_c', 'func_f', - 'func_g', 'func_h', 'func_j', 'func_k', 'baz.obj_q'): + 'func_g', 'func_h', 'func_j', 'func_k', 'baz.obj_q', + '~baz.obj_r'): assert(not desc) else: assert(desc) if func == 'func_h': assert role == 'meth' - elif func == 'baz.obj_q': + elif func == 'baz.obj_q' or func == '~baz.obj_r': assert role == 'obj' elif func == 'class_j': assert role == 'class' @@ -528,6 +725,23 @@ def test_see_also(): elif func == 'class_j': assert desc == ['fubar', 'foobar'] + +def test_see_also_parse_error(): + text = ( + """ + z(x,theta) + + See Also + -------- + :func:`~foo` + """) + with assert_raises(ParseError) as err: + NumpyDocString(text) + assert_equal( + str(r":func:`~foo` is not a item name in '\n z(x,theta)\n\n See Also\n --------\n :func:`~foo`\n '"), + str(err.exception) + ) + def test_see_also_print(): class Dummy(object): """ @@ -546,12 +760,45 @@ class Dummy(object): assert(' some relationship' in s) assert(':func:`func_d`' in s) + +def test_unknown_section(): + doc_text = """ +Test having an unknown section + +Mope +---- +This should be ignored and warned about +""" + + class BadSection(object): + """Class with bad section. + + Nope + ---- + This class has a nope section. + """ + pass + + with warnings.catch_warnings(record=True) as w: + NumpyDocString(doc_text) + assert len(w) == 1 + assert "Unknown section Mope" == str(w[0].message) + + with warnings.catch_warnings(record=True) as w: + SphinxClassDoc(BadSection) + assert len(w) == 1 + assert_true('test_docscrape.test_unknown_section..BadSection' + in str(w[0].message) + or 'test_docscrape.BadSection' in str(w[0].message)) + + doc7 = NumpyDocString(""" Doc starts on second line. """) + def test_empty_first_line(): assert doc7['Summary'][0].startswith('Doc starts') @@ -582,6 +829,7 @@ def test_unicode(): assert isinstance(doc['Summary'][0], str) assert doc['Summary'][0] == 'öäöäöäöäöåååå' + def test_plot_examples(): cfg = dict(use_plots=True) @@ -594,6 +842,15 @@ def test_plot_examples(): """, config=cfg) assert 'plot::' in str(doc), str(doc) + doc = SphinxDocString(""" + Examples + -------- + >>> from matplotlib import pyplot as plt + >>> plt.plot([1,2,3],[4,5,6]) + >>> plt.show() + """, config=cfg) + assert 'plot::' in str(doc), str(doc) + doc = SphinxDocString(""" Examples -------- @@ -605,6 +862,47 @@ def test_plot_examples(): """, config=cfg) assert str(doc).count('plot::') == 1, str(doc) + +def test_use_blockquotes(): + cfg = dict(use_blockquotes=True) + doc = SphinxDocString(""" + Parameters + ---------- + abc : def + ghi + jkl + mno + + Returns + ------- + ABC : DEF + GHI + JKL + MNO + """, config=cfg) + line_by_line_compare(str(doc), ''' + :Parameters: + + **abc** : def + + ghi + + **jkl** + + mno + + :Returns: + + **ABC** : DEF + + GHI + + **JKL** + + MNO + ''') + + def test_class_members(): class Dummy(object): @@ -646,6 +944,47 @@ class Ignorable(object): else: assert 'Spammity index' in str(doc), str(doc) + class SubDummy(Dummy): + """ + Subclass of Dummy class. + + """ + def ham(self, c, d): + """Cheese\n\nNo cheese.\nOverloaded Dummy.ham""" + pass + + def bar(self, a, b): + """Bar\n\nNo bar""" + pass + + for cls in (ClassDoc, SphinxClassDoc): + doc = cls(SubDummy, config=dict(show_class_members=True, + show_inherited_class_members=False)) + assert 'Methods' in str(doc), (cls, str(doc)) + assert 'spam' not in str(doc), (cls, str(doc)) + assert 'ham' in str(doc), (cls, str(doc)) + assert 'bar' in str(doc), (cls, str(doc)) + assert 'spammity' not in str(doc), (cls, str(doc)) + + if cls is SphinxClassDoc: + assert '.. autosummary::' in str(doc), str(doc) + else: + assert 'Spammity index' not in str(doc), str(doc) + + doc = cls(SubDummy, config=dict(show_class_members=True, + show_inherited_class_members=True)) + assert 'Methods' in str(doc), (cls, str(doc)) + assert 'spam' in str(doc), (cls, str(doc)) + assert 'ham' in str(doc), (cls, str(doc)) + assert 'bar' in str(doc), (cls, str(doc)) + assert 'spammity' in str(doc), (cls, str(doc)) + + if cls is SphinxClassDoc: + assert '.. autosummary::' in str(doc), str(doc) + else: + assert 'Spammity index' in str(doc), str(doc) + + def test_duplicate_signature(): # Duplicate function signatures occur e.g. in ufuncs, when the # automatic mechanism adds one, and a more detailed comes from the @@ -669,6 +1008,7 @@ def test_duplicate_signature(): f : callable ``f(t, y, *f_args)`` Aaa. jac : callable ``jac(t, y, *jac_args)`` + Bbb. Attributes @@ -678,6 +1018,17 @@ def test_duplicate_signature(): y : ndarray Current variable values. + * hello + * world + an_attribute : float + The docstring is printed instead + no_docstring : str + But a description + no_docstring2 : str + multiline_sentence + midword_period + no_period + Methods ------- a @@ -689,9 +1040,10 @@ def test_duplicate_signature(): For usage examples, see `ode`. """ + def test_class_members_doc(): doc = ClassDoc(None, class_doc_txt) - non_blank_line_by_line_compare(str(doc), + line_by_line_compare(str(doc), """ Foo @@ -713,55 +1065,140 @@ def test_class_members_doc(): y : ndarray Current variable values. + * hello + * world + an_attribute : float + The docstring is printed instead + no_docstring : str + But a description + no_docstring2 : str + multiline_sentence + midword_period + no_period + Methods ------- a - b - c .. index:: """) + def test_class_members_doc_sphinx(): - doc = SphinxClassDoc(None, class_doc_txt) - non_blank_line_by_line_compare(str(doc), + class Foo: + @property + def an_attribute(self): + """Test attribute""" + return None + + @property + def no_docstring(self): + return None + + @property + def no_docstring2(self): + return None + + @property + def multiline_sentence(self): + """This is a + sentence. It spans multiple lines.""" + return None + + @property + def midword_period(self): + """The sentence for numpy.org.""" + return None + + @property + def no_period(self): + """This does not have a period + so we truncate its summary to the first linebreak + + Apparently. + """ + return None + + doc = SphinxClassDoc(Foo, class_doc_txt) + line_by_line_compare(str(doc), """ Foo :Parameters: - **f** : callable ``f(t, y, *f_args)`` - + f : callable ``f(t, y, *f_args)`` Aaa. - **jac** : callable ``jac(t, y, *jac_args)`` - + jac : callable ``jac(t, y, *jac_args)`` Bbb. .. rubric:: Examples For usage examples, see `ode`. - .. rubric:: Attributes + :Attributes: + + t : float + Current time. + + y : ndarray + Current variable values. + + * hello + * world - === ========== - t (float) Current time. - y (ndarray) Current variable values. - === ========== + :obj:`an_attribute ` : float + Test attribute + + no_docstring : str + But a description + + no_docstring2 : str + .. + + :obj:`multiline_sentence ` + This is a sentence. + + :obj:`midword_period ` + The sentence for numpy.org. + + :obj:`no_period ` + This does not have a period .. rubric:: Methods - === ========== - a - b - c - === ========== + ===== ========== + **a** + **b** + **c** + ===== ========== """) + +def test_templated_sections(): + doc = SphinxClassDoc(None, class_doc_txt, + config={'template': jinja2.Template('{{examples}}\n{{parameters}}')}) + line_by_line_compare(str(doc), + """ + .. rubric:: Examples + + For usage examples, see `ode`. + + :Parameters: + + f : callable ``f(t, y, *f_args)`` + Aaa. + + jac : callable ``jac(t, y, *jac_args)`` + Bbb. + + """) + + if __name__ == "__main__": import nose nose.run() diff --git a/doc/sphinxext/numpydoc/tests/test_linkcode.py b/doc/sphinxext/numpydoc/tests/test_linkcode.py deleted file mode 100644 index 340166a485fcd..0000000000000 --- a/doc/sphinxext/numpydoc/tests/test_linkcode.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import division, absolute_import, print_function - -import numpydoc.linkcode - -# No tests at the moment... diff --git a/doc/sphinxext/numpydoc/tests/test_phantom_import.py b/doc/sphinxext/numpydoc/tests/test_phantom_import.py deleted file mode 100644 index 173b5662b8df7..0000000000000 --- a/doc/sphinxext/numpydoc/tests/test_phantom_import.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import division, absolute_import, print_function - -import numpydoc.phantom_import - -# No tests at the moment... diff --git a/doc/sphinxext/numpydoc/tests/test_plot_directive.py b/doc/sphinxext/numpydoc/tests/test_plot_directive.py deleted file mode 100644 index 0e511fcbc1428..0000000000000 --- a/doc/sphinxext/numpydoc/tests/test_plot_directive.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import division, absolute_import, print_function - -import numpydoc.plot_directive - -# No tests at the moment... diff --git a/doc/sphinxext/numpydoc/tests/test_traitsdoc.py b/doc/sphinxext/numpydoc/tests/test_traitsdoc.py deleted file mode 100644 index d36e5ddbd751f..0000000000000 --- a/doc/sphinxext/numpydoc/tests/test_traitsdoc.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import division, absolute_import, print_function - -import numpydoc.traitsdoc - -# No tests at the moment... diff --git a/doc/sphinxext/numpydoc/traitsdoc.py b/doc/sphinxext/numpydoc/traitsdoc.py deleted file mode 100755 index 596c54eb389a3..0000000000000 --- a/doc/sphinxext/numpydoc/traitsdoc.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -========= -traitsdoc -========= - -Sphinx extension that handles docstrings in the Numpy standard format, [1] -and support Traits [2]. - -This extension can be used as a replacement for ``numpydoc`` when support -for Traits is required. - -.. [1] http://projects.scipy.org/numpy/wiki/CodingStyleGuidelines#docstring-standard -.. [2] http://code.enthought.com/projects/traits/ - -""" -from __future__ import division, absolute_import, print_function - -import inspect -import os -import pydoc -import collections - -from . import docscrape -from . import docscrape_sphinx -from .docscrape_sphinx import SphinxClassDoc, SphinxFunctionDoc, SphinxDocString - -from . import numpydoc - -from . import comment_eater - -class SphinxTraitsDoc(SphinxClassDoc): - def __init__(self, cls, modulename='', func_doc=SphinxFunctionDoc): - if not inspect.isclass(cls): - raise ValueError("Initialise using a class. Got %r" % cls) - self._cls = cls - - if modulename and not modulename.endswith('.'): - modulename += '.' - self._mod = modulename - self._name = cls.__name__ - self._func_doc = func_doc - - docstring = pydoc.getdoc(cls) - docstring = docstring.split('\n') - - # De-indent paragraph - try: - indent = min(len(s) - len(s.lstrip()) for s in docstring - if s.strip()) - except ValueError: - indent = 0 - - for n,line in enumerate(docstring): - docstring[n] = docstring[n][indent:] - - self._doc = docscrape.Reader(docstring) - self._parsed_data = { - 'Signature': '', - 'Summary': '', - 'Description': [], - 'Extended Summary': [], - 'Parameters': [], - 'Returns': [], - 'Raises': [], - 'Warns': [], - 'Other Parameters': [], - 'Traits': [], - 'Methods': [], - 'See Also': [], - 'Notes': [], - 'References': '', - 'Example': '', - 'Examples': '', - 'index': {} - } - - self._parse() - - def _str_summary(self): - return self['Summary'] + [''] - - def _str_extended_summary(self): - return self['Description'] + self['Extended Summary'] + [''] - - def __str__(self, indent=0, func_role="func"): - out = [] - out += self._str_signature() - out += self._str_index() + [''] - out += self._str_summary() - out += self._str_extended_summary() - for param_list in ('Parameters', 'Traits', 'Methods', - 'Returns','Raises'): - out += self._str_param_list(param_list) - out += self._str_see_also("obj") - out += self._str_section('Notes') - out += self._str_references() - out += self._str_section('Example') - out += self._str_section('Examples') - out = self._str_indent(out,indent) - return '\n'.join(out) - -def looks_like_issubclass(obj, classname): - """ Return True if the object has a class or superclass with the given class - name. - - Ignores old-style classes. - """ - t = obj - if t.__name__ == classname: - return True - for klass in t.__mro__: - if klass.__name__ == classname: - return True - return False - -def get_doc_object(obj, what=None, config=None): - if what is None: - if inspect.isclass(obj): - what = 'class' - elif inspect.ismodule(obj): - what = 'module' - elif isinstance(obj, collections.Callable): - what = 'function' - else: - what = 'object' - if what == 'class': - doc = SphinxTraitsDoc(obj, '', func_doc=SphinxFunctionDoc, config=config) - if looks_like_issubclass(obj, 'HasTraits'): - for name, trait, comment in comment_eater.get_class_traits(obj): - # Exclude private traits. - if not name.startswith('_'): - doc['Traits'].append((name, trait, comment.splitlines())) - return doc - elif what in ('function', 'method'): - return SphinxFunctionDoc(obj, '', config=config) - else: - return SphinxDocString(pydoc.getdoc(obj), config=config) - -def setup(app): - # init numpydoc - numpydoc.setup(app, get_doc_object) - From d2e23f82829bf4dc3162cacffc45f9dccd573f74 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Fri, 16 Mar 2018 17:29:50 +0100 Subject: [PATCH 48/81] DOC: change to numpydoc: add option to use member listing for attributes --- doc/sphinxext/numpydoc/docscrape_sphinx.py | 7 +++++-- doc/sphinxext/numpydoc/numpydoc.py | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/doc/sphinxext/numpydoc/docscrape_sphinx.py b/doc/sphinxext/numpydoc/docscrape_sphinx.py index 087ddafb3e3ee..1da99ed482898 100644 --- a/doc/sphinxext/numpydoc/docscrape_sphinx.py +++ b/doc/sphinxext/numpydoc/docscrape_sphinx.py @@ -33,6 +33,7 @@ def load_config(self, config): self.use_plots = config.get('use_plots', False) self.use_blockquotes = config.get('use_blockquotes', False) self.class_members_toctree = config.get('class_members_toctree', True) + self.attributes_as_param_list = config.get('attributes_as_param_list', True) self.template = config.get('template', None) if self.template is None: template_dirs = [os.path.join(os.path.dirname(__file__), 'templates')] @@ -366,8 +367,10 @@ def __str__(self, indent=0, func_role="obj"): 'notes': self._str_section('Notes'), 'references': self._str_references(), 'examples': self._str_examples(), - 'attributes': self._str_param_list('Attributes', - fake_autosummary=True), + 'attributes': + self._str_param_list('Attributes', fake_autosummary=True) + if self.attributes_as_param_list + else self._str_member_list('Attributes'), 'methods': self._str_member_list('Methods'), } ns = dict((k, '\n'.join(v)) for k, v in ns.items()) diff --git a/doc/sphinxext/numpydoc/numpydoc.py b/doc/sphinxext/numpydoc/numpydoc.py index 0a6cc79ce150f..dc20b3f828eb2 100644 --- a/doc/sphinxext/numpydoc/numpydoc.py +++ b/doc/sphinxext/numpydoc/numpydoc.py @@ -76,7 +76,9 @@ def mangle_docstrings(app, what, name, obj, options, lines): 'show_class_members': app.config.numpydoc_show_class_members, 'show_inherited_class_members': app.config.numpydoc_show_inherited_class_members, - 'class_members_toctree': app.config.numpydoc_class_members_toctree} + 'class_members_toctree': app.config.numpydoc_class_members_toctree, + 'attributes_as_param_list': + app.config.numpydoc_attributes_as_param_list} u_NL = sixu('\n') if what == 'module': @@ -146,6 +148,7 @@ def setup(app, get_doc_object_=get_doc_object): app.add_config_value('numpydoc_show_inherited_class_members', True, True) app.add_config_value('numpydoc_class_members_toctree', True, True) app.add_config_value('numpydoc_citation_re', '[a-z0-9_.-]+', True) + app.add_config_value('numpydoc_attributes_as_param_list', True, True) # Extra mangling domains app.add_domain(NumpyPythonDomain) From 91e5e6b9ed08456b87cb98afb85ec6c5f478cde3 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Tue, 27 Mar 2018 16:51:08 +0200 Subject: [PATCH 49/81] DOC: change to numpydoc: use isdatadescriptor instead of isgetsetdescriptor to check attributes This is needed to ensure .columns / .index / cached properties are recognized as an attribute --- doc/sphinxext/numpydoc/docscrape.py | 2 +- doc/sphinxext/numpydoc/docscrape_sphinx.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/sphinxext/numpydoc/docscrape.py b/doc/sphinxext/numpydoc/docscrape.py index 6ca62922dba4c..598b4438ffabc 100644 --- a/doc/sphinxext/numpydoc/docscrape.py +++ b/doc/sphinxext/numpydoc/docscrape.py @@ -613,7 +613,7 @@ def properties(self): return [name for name, func in inspect.getmembers(self._cls) if (not name.startswith('_') and (func is None or isinstance(func, property) or - inspect.isgetsetdescriptor(func)) + inspect.isdatadescriptor(func)) and self._is_show_member(name))] def _is_show_member(self, name): diff --git a/doc/sphinxext/numpydoc/docscrape_sphinx.py b/doc/sphinxext/numpydoc/docscrape_sphinx.py index 1da99ed482898..19c355eba1898 100644 --- a/doc/sphinxext/numpydoc/docscrape_sphinx.py +++ b/doc/sphinxext/numpydoc/docscrape_sphinx.py @@ -243,7 +243,7 @@ def _str_member_list(self, name): param_obj = getattr(self._obj, param, None) if not (callable(param_obj) or isinstance(param_obj, property) - or inspect.isgetsetdescriptor(param_obj)): + or inspect.isdatadescriptor(param_obj)): param_obj = None if param_obj and pydoc.getdoc(param_obj): From 4c6688db3e8a06b212c6d57a12f264ef9ca1a1fe Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Fri, 16 Mar 2018 17:04:19 +0100 Subject: [PATCH 50/81] DOC: use numpydoc_use_blockquotes=False for compatibility --- doc/source/api.rst | 4 ++++ doc/source/conf.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/doc/source/api.rst b/doc/source/api.rst index dfb6d03ec9159..a5d24302e69e2 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -2557,6 +2557,8 @@ objects. generated/pandas.Index.asi8 generated/pandas.Index.data generated/pandas.Index.flags + generated/pandas.Index.holds_integer + generated/pandas.Index.is_type_compatible generated/pandas.Index.nlevels generated/pandas.Index.sort generated/pandas.Panel.agg @@ -2572,4 +2574,6 @@ objects. generated/pandas.Series.blocks generated/pandas.Series.from_array generated/pandas.Series.ix + generated/pandas.Series.imag + generated/pandas.Series.real generated/pandas.Timestamp.offset diff --git a/doc/source/conf.py b/doc/source/conf.py index 46249af8a5a56..43c7c23c5e20d 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -86,6 +86,12 @@ if any(re.match("\s*api\s*", l) for l in index_rst_lines): autosummary_generate = True +# numpydoc +# for now use old parameter listing (styling + **kwargs problem) +numpydoc_use_blockquotes = True +# use member listing for attributes +numpydoc_attributes_as_param_list = False + # matplotlib plot directive plot_include_source = True plot_formats = [("png", 90)] From 3e915556f67c11bc182ea3807634aa91951cc96d Mon Sep 17 00:00:00 2001 From: Ming Li Date: Fri, 23 Mar 2018 21:58:52 +0000 Subject: [PATCH 51/81] DOC: docstring to series.unique (#20474) --- pandas/core/base.py | 24 ----------------------- pandas/core/series.py | 45 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/pandas/core/base.py b/pandas/core/base.py index 99e2af9fb3aeb..8907e9144b60e 100644 --- a/pandas/core/base.py +++ b/pandas/core/base.py @@ -1021,30 +1021,6 @@ def value_counts(self, normalize=False, sort=True, ascending=False, normalize=normalize, bins=bins, dropna=dropna) return result - _shared_docs['unique'] = ( - """ - Return unique values in the object. Uniques are returned in order - of appearance, this does NOT sort. Hash table-based unique. - - Parameters - ---------- - values : 1d array-like - - Returns - ------- - unique values. - - If the input is an Index, the return is an Index - - If the input is a Categorical dtype, the return is a Categorical - - If the input is a Series/ndarray, the return will be an ndarray - - See Also - -------- - unique - Index.unique - Series.unique - """) - - @Appender(_shared_docs['unique'] % _indexops_doc_kwargs) def unique(self): values = self._values diff --git a/pandas/core/series.py b/pandas/core/series.py index da598259d272d..48e6453e36491 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -1429,8 +1429,51 @@ def mode(self): # TODO: Add option for bins like value_counts() return algorithms.mode(self) - @Appender(base._shared_docs['unique'] % _shared_doc_kwargs) def unique(self): + """ + Return unique values of Series object. + + Uniques are returned in order of appearance. Hash table-based unique, + therefore does NOT sort. + + Returns + ------- + ndarray or Categorical + The unique values returned as a NumPy array. In case of categorical + data type, returned as a Categorical. + + See Also + -------- + pandas.unique : top-level unique method for any 1-d array-like object. + Index.unique : return Index with unique values from an Index object. + + Examples + -------- + >>> pd.Series([2, 1, 3, 3], name='A').unique() + array([2, 1, 3]) + + >>> pd.Series([pd.Timestamp('2016-01-01') for _ in range(3)]).unique() + array(['2016-01-01T00:00:00.000000000'], dtype='datetime64[ns]') + + >>> pd.Series([pd.Timestamp('2016-01-01', tz='US/Eastern') + ... for _ in range(3)]).unique() + array([Timestamp('2016-01-01 00:00:00-0500', tz='US/Eastern')], + dtype=object) + + An unordered Categorical will return categories in the order of + appearance. + + >>> pd.Series(pd.Categorical(list('baabc'))).unique() + [b, a, c] + Categories (3, object): [b, a, c] + + An ordered Categorical preserves the category ordering. + + >>> pd.Series(pd.Categorical(list('baabc'), categories=list('abc'), + ... ordered=True)).unique() + [b, a, c] + Categories (3, object): [a < b < c] + """ result = super(Series, self).unique() if is_datetime64tz_dtype(self.dtype): From 71dff0df60ce820ee03fc3959bbd5f526cd3cf24 Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Wed, 28 Mar 2018 02:49:47 -0500 Subject: [PATCH 52/81] DOC: add guide on shared docstrings (#20016) --- doc/source/contributing.rst | 1 - doc/source/contributing_docstring.rst | 79 +++++++++++++++++++++++++++ pandas/core/frame.py | 3 +- pandas/core/generic.py | 10 ++-- pandas/core/panel.py | 5 +- pandas/core/series.py | 3 +- 6 files changed, 90 insertions(+), 11 deletions(-) diff --git a/doc/source/contributing.rst b/doc/source/contributing.rst index ff0aa8af611db..967d1fe3369f0 100644 --- a/doc/source/contributing.rst +++ b/doc/source/contributing.rst @@ -1088,5 +1088,4 @@ The branch will still exist on GitHub, so to delete it there do:: git push origin --delete shiny-new-feature - .. _Gitter: https://gitter.im/pydata/pandas diff --git a/doc/source/contributing_docstring.rst b/doc/source/contributing_docstring.rst index c210bb7050fb8..f80bfd9253764 100644 --- a/doc/source/contributing_docstring.rst +++ b/doc/source/contributing_docstring.rst @@ -82,6 +82,9 @@ about reStructuredText can be found in: - `Quick reStructuredText reference `_ - `Full reStructuredText specification `_ +Pandas has some helpers for sharing docstrings between related classes, see +:ref:`docstring.sharing`. + The rest of this document will summarize all the above guides, and will provide additional convention specific to the pandas project. @@ -916,3 +919,79 @@ plot will be generated automatically when building the documentation. >>> s.plot() """ pass + +.. _docstring.sharing: + +Sharing Docstrings +------------------ + +Pandas has a system for sharing docstrings, with slight variations, between +classes. This helps us keep docstrings consistent, while keeping things clear +for the user reading. It comes at the cost of some complexity when writing. + +Each shared docstring will have a base template with variables, like +``%(klass)s``. The variables filled in later on using the ``Substitution`` +decorator. Finally, docstrings can be appended to with the ``Appender`` +decorator. + +In this example, we'll create a parent docstring normally (this is like +``pandas.core.generic.NDFrame``. Then we'll have two children (like +``pandas.core.series.Series`` and ``pandas.core.frame.DataFrame``). We'll +substitute the children's class names in this docstring. + +.. code-block:: python + + class Parent: + def my_function(self): + """Apply my function to %(klass)s.""" + ... + + class ChildA(Parent): + @Substitution(klass="ChildA") + @Appender(Parent.my_function.__doc__) + def my_function(self): + ... + + class ChildB(Parent): + @Substitution(klass="ChildB") + @Appender(Parent.my_function.__doc__) + def my_function(self): + ... + +The resulting docstrings are + +.. code-block:: python + + >>> print(Parent.my_function.__doc__) + Apply my function to %(klass)s. + >>> print(ChildA.my_function.__doc__) + Apply my function to ChildA. + >>> print(ChildB.my_function.__doc__) + Apply my function to ChildB. + +Notice two things: + +1. We "append" the parent docstring to the children docstrings, which are + initially empty. +2. Python decorators are applied inside out. So the order is Append then + Substitution, even though Substitution comes first in the file. + +Our files will often contain a module-level ``_shared_doc_kwargs`` with some +common substitution values (things like ``klass``, ``axes``, etc). + +You can substitute and append in one shot with something like + +.. code-block:: python + + @Appender(template % _shared_doc_kwargs) + def my_function(self): + ... + +where ``template`` may come from a module-level ``_shared_docs`` dictionary +mapping function names to docstrings. Wherever possible, we prefer using +``Appender`` and ``Substitution``, since the docstring-writing processes is +slightly closer to normal. + +See ``pandas.core.generic.NDFrame.fillna`` for an example template, and +``pandas.core.series.Series.fillna`` and ``pandas.core.generic.frame.fillna`` +for the filled versions. diff --git a/pandas/core/frame.py b/pandas/core/frame.py index d2617305d220a..ace975385ce32 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -3696,7 +3696,8 @@ def rename(self, *args, **kwargs): kwargs.pop('mapper', None) return super(DataFrame, self).rename(**kwargs) - @Appender(_shared_docs['fillna'] % _shared_doc_kwargs) + @Substitution(**_shared_doc_kwargs) + @Appender(NDFrame.fillna.__doc__) def fillna(self, value=None, method=None, axis=None, inplace=False, limit=None, downcast=None, **kwargs): return super(DataFrame, diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 80885fd9ef139..f1fa43818ce64 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -5252,7 +5252,9 @@ def infer_objects(self): # ---------------------------------------------------------------------- # Filling NA's - _shared_docs['fillna'] = (""" + def fillna(self, value=None, method=None, axis=None, inplace=False, + limit=None, downcast=None): + """ Fill NA/NaN values using the specified method Parameters @@ -5343,11 +5345,7 @@ def infer_objects(self): 1 3.0 4.0 NaN 1 2 NaN 1.0 NaN 5 3 NaN 3.0 NaN 4 - """) - - @Appender(_shared_docs['fillna'] % _shared_doc_kwargs) - def fillna(self, value=None, method=None, axis=None, inplace=False, - limit=None, downcast=None): + """ inplace = validate_bool_kwarg(inplace, 'inplace') value, method = validate_fillna_kwargs(value, method) diff --git a/pandas/core/panel.py b/pandas/core/panel.py index 5bb4b72a0562d..7c087ac7deafc 100644 --- a/pandas/core/panel.py +++ b/pandas/core/panel.py @@ -31,7 +31,7 @@ create_block_manager_from_blocks) from pandas.core.series import Series from pandas.core.reshape.util import cartesian_product -from pandas.util._decorators import Appender +from pandas.util._decorators import Appender, Substitution from pandas.util._validators import validate_axis_style_args _shared_doc_kwargs = dict( @@ -1254,7 +1254,8 @@ def transpose(self, *args, **kwargs): return super(Panel, self).transpose(*axes, **kwargs) - @Appender(_shared_docs['fillna'] % _shared_doc_kwargs) + @Substitution(**_shared_doc_kwargs) + @Appender(NDFrame.fillna.__doc__) def fillna(self, value=None, method=None, axis=None, inplace=False, limit=None, downcast=None, **kwargs): return super(Panel, self).fillna(value=value, method=method, axis=axis, diff --git a/pandas/core/series.py b/pandas/core/series.py index 48e6453e36491..62f0ea3ce8b2a 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -3384,7 +3384,8 @@ def drop(self, labels=None, axis=0, index=None, columns=None, columns=columns, level=level, inplace=inplace, errors=errors) - @Appender(generic._shared_docs['fillna'] % _shared_doc_kwargs) + @Substitution(**_shared_doc_kwargs) + @Appender(generic.NDFrame.fillna.__doc__) def fillna(self, value=None, method=None, axis=None, inplace=False, limit=None, downcast=None, **kwargs): return super(Series, self).fillna(value=value, method=method, From 72524e87c86ef2e733b8d6b5ad2445ffff517114 Mon Sep 17 00:00:00 2001 From: Clemens Brunner Date: Wed, 28 Mar 2018 09:51:22 +0200 Subject: [PATCH 53/81] Set pd.options.display.max_columns=0 by default (#17023) Change `max_columns` to `0` (automatically adapt the number of displayed columns to the actual terminal width) --- doc/source/_static/print_df_new.png | Bin 0 -> 77202 bytes doc/source/_static/print_df_old.png | Bin 0 -> 89239 bytes doc/source/options.rst | 26 +++++----- doc/source/whatsnew/v0.23.0.txt | 29 +++++++++++ pandas/core/config_init.py | 7 ++- pandas/io/formats/format.py | 3 +- pandas/io/formats/terminal.py | 19 ++++++- pandas/tests/frame/test_dtypes.py | 15 +++--- pandas/tests/frame/test_repr_info.py | 4 +- pandas/tests/io/formats/test_format.py | 67 ++++++++++++++----------- 10 files changed, 117 insertions(+), 53 deletions(-) create mode 100644 doc/source/_static/print_df_new.png create mode 100644 doc/source/_static/print_df_old.png diff --git a/doc/source/_static/print_df_new.png b/doc/source/_static/print_df_new.png new file mode 100644 index 0000000000000000000000000000000000000000..767d7d3f0ef06a3e3e9c3840d7bc0425685aceb3 GIT binary patch literal 77202 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 literal 0 HcmV?d00001 diff --git a/doc/source/_static/print_df_old.png b/doc/source/_static/print_df_old.png new file mode 100644 index 0000000000000000000000000000000000000000..5f458722f1269a91cfe38c1e5d16d26697e23b22 GIT binary patch literal 89239 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

|4C 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 tag for the DataFrame HTML repr. -display.html.use_mathjax True When True, Jupyter notebook will process - table contents using MathJax, rendering - mathematical expressions enclosed by the +display.html.use_mathjax True When True, Jupyter notebook will process + table contents using MathJax, rendering + mathematical expressions enclosed by the dollar symbol. io.excel.xls.writer xlwt The default Excel writer engine for 'xls' files. @@ -422,7 +422,7 @@ io.hdf.dropna_table True drop ALL nan rows when appe io.parquet.engine None The engine to use as a default for parquet reading and writing. If None then try 'pyarrow' and 'fastparquet' -mode.chained_assignment warn Controls ``SettingWithCopyWarning``: +mode.chained_assignment warn Controls ``SettingWithCopyWarning``: 'raise', 'warn', or None. Raise an exception, warn, or no action if trying to use :ref:`chained assignment `. diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index d7c92ed822ffc..b57b49c79bb93 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -658,6 +658,35 @@ Notice in the example above that the converted ``Categorical`` has retained ``or Note that the unintenional conversion of ``ordered`` discussed above did not arise in previous versions due to separate bugs that prevented ``astype`` from doing any type of category to category conversion (:issue:`10696`, :issue:`18593`). These bugs have been fixed in this release, and motivated changing the default value of ``ordered``. +.. _whatsnew_0230.api_breaking.pretty_printing: + +Better pretty-printing of DataFrames in a terminal +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Previously, the default value for the maximum number of columns was +``pd.options.display.max_columns=20``. This meant that relatively wide data +frames would not fit within the terminal width, and pandas would introduce line +breaks to display these 20 columns. This resulted in an output that was +relatively difficult to read: + +.. image:: _static/print_df_old.png + +If Python runs in a terminal, the maximum number of columns is now determined +automatically so that the printed data frame fits within the current terminal +width (``pd.options.display.max_columns=0``) (:issue:`17023`). If Python runs +as a Jupyter kernel (such as the Jupyter QtConsole or a Jupyter notebook, as +well as in many IDEs), this value cannot be inferred automatically and is thus +set to `20` as in previous versions. In a terminal, this results in a much +nicer output: + +.. image:: _static/print_df_new.png + +Note that if you don't like the new default, you can always set this option +yourself. To revert to the old setting, you can run this line: + +.. code-block:: python + + pd.options.display.max_columns = 20 + .. _whatsnew_0230.api.datetimelike: Datetimelike API Changes diff --git a/pandas/core/config_init.py b/pandas/core/config_init.py index 0edbf892172a9..b836a35b8cf29 100644 --- a/pandas/core/config_init.py +++ b/pandas/core/config_init.py @@ -13,6 +13,7 @@ from pandas.core.config import (is_int, is_bool, is_text, is_instance_factory, is_one_of_factory, is_callable) from pandas.io.formats.console import detect_console_encoding +from pandas.io.formats.terminal import is_terminal # compute @@ -314,7 +315,11 @@ def table_schema_cb(key): cf.register_option('max_categories', 8, pc_max_categories_doc, validator=is_int) cf.register_option('max_colwidth', 50, max_colwidth_doc, validator=is_int) - cf.register_option('max_columns', 20, pc_max_cols_doc, + if is_terminal(): + max_cols = 0 # automatically determine optimal number of columns + else: + max_cols = 20 # cannot determine optimal number of columns + cf.register_option('max_columns', max_cols, pc_max_cols_doc, validator=is_instance_factory([type(None), int])) cf.register_option('large_repr', 'truncate', pc_large_repr_doc, validator=is_one_of_factory(['truncate', 'info'])) diff --git a/pandas/io/formats/format.py b/pandas/io/formats/format.py index 1731dbb3ac68d..12201f62946ac 100644 --- a/pandas/io/formats/format.py +++ b/pandas/io/formats/format.py @@ -625,7 +625,8 @@ def to_string(self): max_len += size_tr_col # Need to make space for largest row # plus truncate dot col dif = max_len - self.w - adj_dif = dif + # '+ 1' to avoid too wide repr (GH PR #17023) + adj_dif = dif + 1 col_lens = Series([Series(ele).apply(len).max() for ele in strcols]) n_cols = len(col_lens) diff --git a/pandas/io/formats/terminal.py b/pandas/io/formats/terminal.py index 4bcb28fa59b86..07ab445182680 100644 --- a/pandas/io/formats/terminal.py +++ b/pandas/io/formats/terminal.py @@ -17,7 +17,7 @@ import sys import shutil -__all__ = ['get_terminal_size'] +__all__ = ['get_terminal_size', 'is_terminal'] def get_terminal_size(): @@ -48,6 +48,23 @@ def get_terminal_size(): return tuple_xy +def is_terminal(): + """ + Detect if Python is running in a terminal. + + Returns True if Python is running in a terminal or False if not. + """ + try: + ip = get_ipython() + except NameError: # assume standard Python interpreter in a terminal + return True + else: + if hasattr(ip, 'kernel'): # IPython as a Jupyter kernel + return False + else: # IPython in a terminal + return True + + def _get_terminal_size_windows(): res = None try: diff --git a/pandas/tests/frame/test_dtypes.py b/pandas/tests/frame/test_dtypes.py index 90daa9aa882c8..152159965036d 100644 --- a/pandas/tests/frame/test_dtypes.py +++ b/pandas/tests/frame/test_dtypes.py @@ -875,10 +875,11 @@ def test_astype_str(self): columns=self.tzframe.columns) tm.assert_frame_equal(result, expected) - result = str(self.tzframe) - assert ('0 2013-01-01 2013-01-01 00:00:00-05:00 ' - '2013-01-01 00:00:00+01:00') in result - assert ('1 2013-01-02 ' - 'NaT NaT') in result - assert ('2 2013-01-03 2013-01-03 00:00:00-05:00 ' - '2013-01-03 00:00:00+01:00') in result + with option_context('display.max_columns', 20): + result = str(self.tzframe) + assert ('0 2013-01-01 2013-01-01 00:00:00-05:00 ' + '2013-01-01 00:00:00+01:00') in result + assert ('1 2013-01-02 ' + 'NaT NaT') in result + assert ('2 2013-01-03 2013-01-03 00:00:00-05:00 ' + '2013-01-03 00:00:00+01:00') in result diff --git a/pandas/tests/frame/test_repr_info.py b/pandas/tests/frame/test_repr_info.py index 3e5aae10618e9..8fc6fef11798a 100644 --- a/pandas/tests/frame/test_repr_info.py +++ b/pandas/tests/frame/test_repr_info.py @@ -172,8 +172,8 @@ def test_repr_column_name_unicode_truncation_bug(self): 'the CSV file externally. I want to Call' ' the File through the code..')}) - result = repr(df) - assert 'StringCol' in result + with option_context('display.max_columns', 20): + assert 'StringCol' in repr(df) def test_latex_repr(self): result = r"""\begin{tabular}{llll} diff --git a/pandas/tests/io/formats/test_format.py b/pandas/tests/io/formats/test_format.py index 6c3b75cdfa6df..ab9f61cffc16b 100644 --- a/pandas/tests/io/formats/test_format.py +++ b/pandas/tests/io/formats/test_format.py @@ -961,7 +961,8 @@ def test_pprint_thing(self): def test_wide_repr(self): with option_context('mode.sim_interactive', True, - 'display.show_dimensions', True): + 'display.show_dimensions', True, + 'display.max_columns', 20): max_cols = get_option('display.max_columns') df = DataFrame(tm.rands_array(25, size=(10, max_cols - 1))) set_option('display.expand_frame_repr', False) @@ -979,7 +980,8 @@ def test_wide_repr(self): reset_option('display.expand_frame_repr') def test_wide_repr_wide_columns(self): - with option_context('mode.sim_interactive', True): + with option_context('mode.sim_interactive', True, + 'display.max_columns', 20): df = DataFrame(np.random.randn(5, 3), columns=['a' * 90, 'b' * 90, 'c' * 90]) rep_str = repr(df) @@ -987,7 +989,8 @@ def test_wide_repr_wide_columns(self): assert len(rep_str.splitlines()) == 20 def test_wide_repr_named(self): - with option_context('mode.sim_interactive', True): + with option_context('mode.sim_interactive', True, + 'display.max_columns', 20): max_cols = get_option('display.max_columns') df = DataFrame(tm.rands_array(25, size=(10, max_cols - 1))) df.index.name = 'DataFrame Index' @@ -1008,7 +1011,8 @@ def test_wide_repr_named(self): reset_option('display.expand_frame_repr') def test_wide_repr_multiindex(self): - with option_context('mode.sim_interactive', True): + with option_context('mode.sim_interactive', True, + 'display.max_columns', 20): midx = MultiIndex.from_arrays(tm.rands_array(5, size=(2, 10))) max_cols = get_option('display.max_columns') df = DataFrame(tm.rands_array(25, size=(10, max_cols - 1)), @@ -1030,7 +1034,8 @@ def test_wide_repr_multiindex(self): reset_option('display.expand_frame_repr') def test_wide_repr_multiindex_cols(self): - with option_context('mode.sim_interactive', True): + with option_context('mode.sim_interactive', True, + 'display.max_columns', 20): max_cols = get_option('display.max_columns') midx = MultiIndex.from_arrays(tm.rands_array(5, size=(2, 10))) mcols = MultiIndex.from_arrays( @@ -1044,15 +1049,16 @@ def test_wide_repr_multiindex_cols(self): wide_repr = repr(df) assert rep_str != wide_repr - with option_context('display.width', 150): + with option_context('display.width', 150, 'display.max_columns', 20): wider_repr = repr(df) assert len(wider_repr) < len(wide_repr) reset_option('display.expand_frame_repr') def test_wide_repr_unicode(self): - with option_context('mode.sim_interactive', True): - max_cols = get_option('display.max_columns') + with option_context('mode.sim_interactive', True, + 'display.max_columns', 20): + max_cols = 20 df = DataFrame(tm.rands_array(25, size=(10, max_cols - 1))) set_option('display.expand_frame_repr', False) rep_str = repr(df) @@ -1442,17 +1448,17 @@ def test_repr_html_mathjax(self): assert 'tex2jax_ignore' in df._repr_html_() def test_repr_html_wide(self): - max_cols = get_option('display.max_columns') + max_cols = 20 df = DataFrame(tm.rands_array(25, size=(10, max_cols - 1))) - reg_repr = df._repr_html_() - assert "..." not in reg_repr + with option_context('display.max_rows', 60, 'display.max_columns', 20): + assert "..." not in df._repr_html_() wide_df = DataFrame(tm.rands_array(25, size=(10, max_cols + 1))) - wide_repr = wide_df._repr_html_() - assert "..." in wide_repr + with option_context('display.max_rows', 60, 'display.max_columns', 20): + assert "..." in wide_df._repr_html_() def test_repr_html_wide_multiindex_cols(self): - max_cols = get_option('display.max_columns') + max_cols = 20 mcols = MultiIndex.from_product([np.arange(max_cols // 2), ['foo', 'bar']], @@ -1467,8 +1473,8 @@ def test_repr_html_wide_multiindex_cols(self): names=['first', 'second']) df = DataFrame(tm.rands_array(25, size=(10, len(mcols))), columns=mcols) - wide_repr = df._repr_html_() - assert '...' in wide_repr + with option_context('display.max_rows', 60, 'display.max_columns', 20): + assert '...' in df._repr_html_() def test_repr_html_long(self): with option_context('display.max_rows', 60): @@ -1512,14 +1518,15 @@ def test_repr_html_float(self): assert u('2 columns') in long_repr def test_repr_html_long_multiindex(self): - max_rows = get_option('display.max_rows') + max_rows = 60 max_L1 = max_rows // 2 tuples = list(itertools.product(np.arange(max_L1), ['foo', 'bar'])) idx = MultiIndex.from_tuples(tuples, names=['first', 'second']) df = DataFrame(np.random.randn(max_L1 * 2, 2), index=idx, columns=['A', 'B']) - reg_repr = df._repr_html_() + with option_context('display.max_rows', 60, 'display.max_columns', 20): + reg_repr = df._repr_html_() assert '...' not in reg_repr tuples = list(itertools.product(np.arange(max_L1 + 1), ['foo', 'bar'])) @@ -1530,20 +1537,22 @@ def test_repr_html_long_multiindex(self): assert '...' in long_repr def test_repr_html_long_and_wide(self): - max_cols = get_option('display.max_columns') - max_rows = get_option('display.max_rows') + max_cols = 20 + max_rows = 60 h, w = max_rows - 1, max_cols - 1 df = DataFrame({k: np.arange(1, 1 + h) for k in np.arange(w)}) - assert '...' not in df._repr_html_() + with option_context('display.max_rows', 60, 'display.max_columns', 20): + assert '...' not in df._repr_html_() h, w = max_rows + 1, max_cols + 1 df = DataFrame({k: np.arange(1, 1 + h) for k in np.arange(w)}) - assert '...' in df._repr_html_() + with option_context('display.max_rows', 60, 'display.max_columns', 20): + assert '...' in df._repr_html_() def test_info_repr(self): - max_rows = get_option('display.max_rows') - max_cols = get_option('display.max_columns') + max_rows = 60 + max_cols = 20 # Long h, w = max_rows + 1, max_cols - 1 df = DataFrame({k: np.arange(1, 1 + h) for k in np.arange(w)}) @@ -1555,7 +1564,8 @@ def test_info_repr(self): h, w = max_rows - 1, max_cols + 1 df = DataFrame({k: np.arange(1, 1 + h) for k in np.arange(w)}) assert has_horizontally_truncated_repr(df) - with option_context('display.large_repr', 'info'): + with option_context('display.large_repr', 'info', + 'display.max_columns', max_cols): assert has_info_repr(df) def test_info_repr_max_cols(self): @@ -1575,8 +1585,8 @@ def test_info_repr_max_cols(self): # fmt.set_option('display.max_info_columns', 4) # exceeded def test_info_repr_html(self): - max_rows = get_option('display.max_rows') - max_cols = get_option('display.max_columns') + max_rows = 60 + max_cols = 20 # Long h, w = max_rows + 1, max_cols - 1 df = DataFrame({k: np.arange(1, 1 + h) for k in np.arange(w)}) @@ -1588,7 +1598,8 @@ def test_info_repr_html(self): h, w = max_rows - 1, max_cols + 1 df = DataFrame({k: np.arange(1, 1 + h) for k in np.arange(w)}) assert ' Date: Wed, 28 Mar 2018 06:05:57 -0400 Subject: [PATCH 54/81] DOC: whatsnew edits --- doc/source/whatsnew/v0.23.0.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index b57b49c79bb93..a19b71c27d998 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -454,7 +454,7 @@ Convert to an xarray DataArray .. _whatsnew_0230.api_breaking.core_common: pandas.core.common removals -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^^^^^^ The following error & warning messages are removed from ``pandas.core.common`` (:issue:`13634`, :issue:`19769`): @@ -923,8 +923,8 @@ Timedelta - Bug in :func:`Timedelta.__floordiv__`, :func:`Timedelta.__rfloordiv__` where operating with a ``Tick`` object would raise a ``TypeError`` instead of returning a numeric value (:issue:`19738`) - Bug in :func:`Period.asfreq` where periods near ``datetime(1, 1, 1)`` could be converted incorrectly (:issue:`19643`, :issue:`19834`) - Bug in :func:`Timedelta.total_seconds()` causing precision errors i.e. ``Timedelta('30S').total_seconds()==30.000000000000004`` (:issue:`19458`) -- Bug in :func: `Timedelta.__rmod__` where operating with a ``numpy.timedelta64`` returned a ``timedelta64`` object instead of a ``Timedelta`` (:issue:`19820`) -- Multiplication of :class:`TimedeltaIndex` by ``TimedeltaIndex`` will now raise ``TypeError`` instead of raising ``ValueError`` in cases of length mis-match (:issue`19333`) +- Bug in :func:`Timedelta.__rmod__` where operating with a ``numpy.timedelta64`` returned a ``timedelta64`` object instead of a ``Timedelta`` (:issue:`19820`) +- Multiplication of :class:`TimedeltaIndex` by ``TimedeltaIndex`` will now raise ``TypeError`` instead of raising ``ValueError`` in cases of length mis-match (:issue:`19333`) - Bug in indexing a :class:`TimedeltaIndex` with a ``np.timedelta64`` object which was raising a ``TypeError`` (:issue:`20393`) @@ -965,7 +965,7 @@ Numeric - Bug in :class:`Index` constructor with ``dtype='uint64'`` where int-like floats were not coerced to :class:`UInt64Index` (:issue:`18400`) - Bug in :class:`DataFrame` flex arithmetic (e.g. ``df.add(other, fill_value=foo)``) with a ``fill_value`` other than ``None`` failed to raise ``NotImplementedError`` in corner cases where either the frame or ``other`` has length zero (:issue:`19522`) - Multiplication and division of numeric-dtyped :class:`Index` objects with timedelta-like scalars returns ``TimedeltaIndex`` instead of raising ``TypeError`` (:issue:`19333`) -- Bug where ``NaN`` was returned instead of 0 by :func:`Series.pct_change` and :func:`DataFrame.pct_change` when ``fill_method`` is not ``None`` (provided) (:issue:`19873`) +- Bug where ``NaN`` was returned instead of 0 by :func:`Series.pct_change` and :func:`DataFrame.pct_change` when ``fill_method`` is not ``None`` (:issue:`19873`) Indexing From 3549ecac32cdf1954f3aa1c0cb905ec09d19e5e5 Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Wed, 28 Mar 2018 05:35:44 -0500 Subject: [PATCH 55/81] ENH: Support ExtensionArray in Groupby (#20502) --- pandas/core/groupby.py | 4 +- pandas/tests/extension/base/__init__.py | 1 + pandas/tests/extension/base/groupby.py | 69 +++++++++++++++++++ .../tests/extension/decimal/test_decimal.py | 4 ++ pandas/tests/extension/json/array.py | 4 +- pandas/tests/extension/json/test_json.py | 41 +++++++++-- 6 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 pandas/tests/extension/base/groupby.py diff --git a/pandas/core/groupby.py b/pandas/core/groupby.py index 601acac20c96d..7c89cab6b1428 100644 --- a/pandas/core/groupby.py +++ b/pandas/core/groupby.py @@ -44,7 +44,7 @@ DataError, SpecificationError) from pandas.core.index import (Index, MultiIndex, CategoricalIndex, _ensure_index) -from pandas.core.arrays import Categorical +from pandas.core.arrays import ExtensionArray, Categorical from pandas.core.frame import DataFrame from pandas.core.generic import NDFrame, _shared_docs from pandas.core.internals import BlockManager, make_block @@ -2968,7 +2968,7 @@ def __init__(self, index, grouper=None, obj=None, name=None, level=None, # no level passed elif not isinstance(self.grouper, - (Series, Index, Categorical, np.ndarray)): + (Series, Index, ExtensionArray, np.ndarray)): if getattr(self.grouper, 'ndim', 1) != 1: t = self.name or str(type(self.grouper)) raise ValueError("Grouper for '%s' not 1-dimensional" % t) diff --git a/pandas/tests/extension/base/__init__.py b/pandas/tests/extension/base/__init__.py index 27c106efd0524..f8078d2798b32 100644 --- a/pandas/tests/extension/base/__init__.py +++ b/pandas/tests/extension/base/__init__.py @@ -44,6 +44,7 @@ class TestMyDtype(BaseDtypeTests): from .constructors import BaseConstructorsTests # noqa from .dtype import BaseDtypeTests # noqa from .getitem import BaseGetitemTests # noqa +from .groupby import BaseGroupbyTests # noqa from .interface import BaseInterfaceTests # noqa from .methods import BaseMethodsTests # noqa from .missing import BaseMissingTests # noqa diff --git a/pandas/tests/extension/base/groupby.py b/pandas/tests/extension/base/groupby.py new file mode 100644 index 0000000000000..a29ef2a509a63 --- /dev/null +++ b/pandas/tests/extension/base/groupby.py @@ -0,0 +1,69 @@ +import pytest + +import pandas.util.testing as tm +import pandas as pd +from .base import BaseExtensionTests + + +class BaseGroupbyTests(BaseExtensionTests): + """Groupby-specific tests.""" + + def test_grouping_grouper(self, data_for_grouping): + df = pd.DataFrame({ + "A": ["B", "B", None, None, "A", "A", "B", "C"], + "B": data_for_grouping + }) + gr1 = df.groupby("A").grouper.groupings[0] + gr2 = df.groupby("B").grouper.groupings[0] + + tm.assert_numpy_array_equal(gr1.grouper, df.A.values) + tm.assert_extension_array_equal(gr2.grouper, data_for_grouping) + + @pytest.mark.parametrize('as_index', [True, False]) + def test_groupby_extension_agg(self, as_index, data_for_grouping): + df = pd.DataFrame({"A": [1, 1, 2, 2, 3, 3, 1, 4], + "B": data_for_grouping}) + result = df.groupby("B", as_index=as_index).A.mean() + _, index = pd.factorize(data_for_grouping, sort=True) + # TODO(ExtensionIndex): remove astype + index = pd.Index(index.astype(object), name="B") + expected = pd.Series([3, 1, 4], index=index, name="A") + if as_index: + self.assert_series_equal(result, expected) + else: + expected = expected.reset_index() + self.assert_frame_equal(result, expected) + + def test_groupby_extension_no_sort(self, data_for_grouping): + df = pd.DataFrame({"A": [1, 1, 2, 2, 3, 3, 1, 4], + "B": data_for_grouping}) + result = df.groupby("B", sort=False).A.mean() + _, index = pd.factorize(data_for_grouping, sort=False) + # TODO(ExtensionIndex): remove astype + index = pd.Index(index.astype(object), name="B") + expected = pd.Series([1, 3, 4], index=index, name="A") + self.assert_series_equal(result, expected) + + def test_groupby_extension_transform(self, data_for_grouping): + valid = data_for_grouping[~data_for_grouping.isna()] + df = pd.DataFrame({"A": [1, 1, 3, 3, 1, 4], + "B": valid}) + + result = df.groupby("B").A.transform(len) + expected = pd.Series([3, 3, 2, 2, 3, 1], name="A") + + self.assert_series_equal(result, expected) + + @pytest.mark.parametrize('op', [ + lambda x: 1, + lambda x: [1] * len(x), + lambda x: pd.Series([1] * len(x)), + lambda x: x, + ], ids=['scalar', 'list', 'series', 'object']) + def test_groupby_extension_apply(self, data_for_grouping, op): + df = pd.DataFrame({"A": [1, 1, 2, 2, 3, 3, 1, 4], + "B": data_for_grouping}) + df.groupby("B").apply(op) + df.groupby("B").A.apply(op) + df.groupby("A").apply(op) + df.groupby("A").B.apply(op) diff --git a/pandas/tests/extension/decimal/test_decimal.py b/pandas/tests/extension/decimal/test_decimal.py index 22c1a67a0d60d..d509170565e1a 100644 --- a/pandas/tests/extension/decimal/test_decimal.py +++ b/pandas/tests/extension/decimal/test_decimal.py @@ -127,6 +127,10 @@ class TestCasting(BaseDecimal, base.BaseCastingTests): pass +class TestGroupby(BaseDecimal, base.BaseGroupbyTests): + pass + + def test_series_constructor_coerce_data_to_extension_dtype_raises(): xpr = ("Cannot cast data to extension dtype 'decimal'. Pass the " "extension array directly.") diff --git a/pandas/tests/extension/json/array.py b/pandas/tests/extension/json/array.py index 51a68a3701046..d9ae49d87804a 100644 --- a/pandas/tests/extension/json/array.py +++ b/pandas/tests/extension/json/array.py @@ -113,8 +113,8 @@ def _concat_same_type(cls, to_concat): return cls(data) def _values_for_factorize(self): - frozen = tuple(tuple(x.items()) for x in self) - return np.array(frozen, dtype=object), () + frozen = self._values_for_argsort() + return frozen, () def _values_for_argsort(self): # Disable NumPy's shape inference by including an empty tuple... diff --git a/pandas/tests/extension/json/test_json.py b/pandas/tests/extension/json/test_json.py index 63d97d5e7a2c5..5e9639c487c37 100644 --- a/pandas/tests/extension/json/test_json.py +++ b/pandas/tests/extension/json/test_json.py @@ -89,11 +89,12 @@ def test_fillna_frame(self): """We treat dictionaries as a mapping in fillna, not a scalar.""" -class TestMethods(base.BaseMethodsTests): - unhashable = pytest.mark.skip(reason="Unhashable") - unstable = pytest.mark.skipif(not PY36, # 3.6 or higher - reason="Dictionary order unstable") +unhashable = pytest.mark.skip(reason="Unhashable") +unstable = pytest.mark.skipif(not PY36, # 3.6 or higher + reason="Dictionary order unstable") + +class TestMethods(base.BaseMethodsTests): @unhashable def test_value_counts(self, all_data, dropna): pass @@ -118,6 +119,7 @@ def test_sort_values(self, data_for_sorting, ascending): super(TestMethods, self).test_sort_values( data_for_sorting, ascending) + @unstable @pytest.mark.parametrize('ascending', [True, False]) def test_sort_values_missing(self, data_missing_for_sorting, ascending): super(TestMethods, self).test_sort_values_missing( @@ -126,3 +128,34 @@ def test_sort_values_missing(self, data_missing_for_sorting, ascending): class TestCasting(base.BaseCastingTests): pass + + +class TestGroupby(base.BaseGroupbyTests): + + @unhashable + def test_groupby_extension_transform(self): + """ + This currently fails in Series.name.setter, since the + name must be hashable, but the value is a dictionary. + I think this is what we want, i.e. `.name` should be the original + values, and not the values for factorization. + """ + + @unhashable + def test_groupby_extension_apply(self): + """ + This fails in Index._do_unique_check with + + > hash(val) + E TypeError: unhashable type: 'UserDict' with + + I suspect that once we support Index[ExtensionArray], + we'll be able to dispatch unique. + """ + + @unstable + @pytest.mark.parametrize('as_index', [True, False]) + def test_groupby_extension_agg(self, as_index, data_for_grouping): + super(TestGroupby, self).test_groupby_extension_agg( + as_index, data_for_grouping + ) From 223ae0a9b7123149e4586ccd0473ce94ccb694eb Mon Sep 17 00:00:00 2001 From: Tarbo Fukazawa Date: Wed, 28 Mar 2018 17:15:12 +0100 Subject: [PATCH 56/81] DOC: update the pandas.Series.str.endswith docstring (#20491) --- pandas/core/strings.py | 49 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/pandas/core/strings.py b/pandas/core/strings.py index 355c50aa06c1f..068c31aeda865 100644 --- a/pandas/core/strings.py +++ b/pandas/core/strings.py @@ -383,19 +383,54 @@ def str_startswith(arr, pat, na=np.nan): def str_endswith(arr, pat, na=np.nan): """ - Return boolean Series indicating whether each string in the - Series/Index ends with passed pattern. Equivalent to - :meth:`str.endswith`. + Test if the end of each string element matches a pattern. + + Equivalent to :meth:`str.endswith`. Parameters ---------- - pat : string - Character sequence - na : bool, default NaN + pat : str + Character sequence. Regular expressions are not accepted. + na : object, default NaN + Object shown if element tested is not a string. Returns ------- - endswith : Series/array of boolean values + Series or Index of bool + A Series of booleans indicating whether the given pattern matches + the end of each string element. + + See Also + -------- + str.endswith : Python standard library string method. + Series.str.startswith : Same as endswith, but tests the start of string. + Series.str.contains : Tests if string element contains a pattern. + + Examples + -------- + >>> s = pd.Series(['bat', 'bear', 'caT', np.nan]) + >>> s + 0 bat + 1 bear + 2 caT + 3 NaN + dtype: object + + >>> s.str.endswith('t') + 0 True + 1 False + 2 False + 3 NaN + dtype: object + + Specifying `na` to be `False` instead of `NaN`. + + >>> s.str.endswith('t', na=False) + 0 True + 1 False + 2 False + 3 False + dtype: bool """ f = lambda x: x.endswith(pat) return _na_map(f, arr, na, dtype=bool) From f067b47d8abc824cdd3786ac0b22c6cb3578e067 Mon Sep 17 00:00:00 2001 From: Hamish Pitkeathly Date: Wed, 28 Mar 2018 17:22:12 +0100 Subject: [PATCH 57/81] DOC: Improving the docstring of Series.str.upper and related (#20462) --- pandas/core/strings.py | 59 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/pandas/core/strings.py b/pandas/core/strings.py index 068c31aeda865..23c891ec4fcd0 100644 --- a/pandas/core/strings.py +++ b/pandas/core/strings.py @@ -2257,11 +2257,68 @@ def rindex(self, sub, start=0, end=None): _shared_docs['casemethods'] = (""" Convert strings in the Series/Index to %(type)s. + Equivalent to :meth:`str.%(method)s`. Returns ------- - converted : Series/Index of objects + Series/Index of objects + + See Also + -------- + Series.str.lower : Converts all characters to lowercase. + Series.str.upper : Converts all characters to uppercase. + Series.str.title : Converts first character of each word to uppercase and + remaining to lowercase. + Series.str.capitalize : Converts first character to uppercase and + remaining to lowercase. + Series.str.swapcase : Converts uppercase to lowercase and lowercase to + uppercase. + + Examples + -------- + >>> s = pd.Series(['lower', 'CAPITALS', 'this is a sentence', 'SwApCaSe']) + >>> s + 0 lower + 1 CAPITALS + 2 this is a sentence + 3 SwApCaSe + dtype: object + + >>> s.str.lower() + 0 lower + 1 capitals + 2 this is a sentence + 3 swapcase + dtype: object + + >>> s.str.upper() + 0 LOWER + 1 CAPITALS + 2 THIS IS A SENTENCE + 3 SWAPCASE + dtype: object + + >>> s.str.title() + 0 Lower + 1 Capitals + 2 This Is A Sentence + 3 Swapcase + dtype: object + + >>> s.str.capitalize() + 0 Lower + 1 Capitals + 2 This is a sentence + 3 Swapcase + dtype: object + + >>> s.str.swapcase() + 0 LOWER + 1 capitals + 2 THIS IS A SENTENCE + 3 sWaPcAsE + dtype: object """) _shared_docs['lower'] = dict(type='lowercase', method='lower') _shared_docs['upper'] = dict(type='uppercase', method='upper') From aa1dbd24db22e2b8b6ecf1468994618de0134b07 Mon Sep 17 00:00:00 2001 From: Mark Woodbridge <1101318+mrw34@users.noreply.github.com> Date: Wed, 28 Mar 2018 17:37:15 +0100 Subject: [PATCH 58/81] DOC: update the Series.between docstring (#20443) --- pandas/core/series.py | 59 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/pandas/core/series.py b/pandas/core/series.py index 62f0ea3ce8b2a..e8b99466698cc 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -3556,19 +3556,68 @@ def isin(self, values): def between(self, left, right, inclusive=True): """ - Return boolean Series equivalent to left <= series <= right. NA values - will be treated as False + Return boolean Series equivalent to left <= series <= right. + + This function returns a boolean vector containing `True` wherever the + corresponding Series element is between the boundary values `left` and + `right`. NA values are treated as `False`. Parameters ---------- left : scalar - Left boundary + Left boundary. right : scalar - Right boundary + Right boundary. + inclusive : bool, default True + Include boundaries. Returns ------- - is_between : Series + Series + Each element will be a boolean. + + Notes + ----- + This function is equivalent to ``(left <= ser) & (ser <= right)`` + + See Also + -------- + pandas.Series.gt : Greater than of series and other + pandas.Series.lt : Less than of series and other + + Examples + -------- + >>> s = pd.Series([2, 0, 4, 8, np.nan]) + + Boundary values are included by default: + + >>> s.between(1, 4) + 0 True + 1 False + 2 True + 3 False + 4 False + dtype: bool + + With `inclusive` set to ``False`` boundary values are excluded: + + >>> s.between(1, 4, inclusive=False) + 0 True + 1 False + 2 False + 3 False + 4 False + dtype: bool + + `left` and `right` can be any scalar value: + + >>> s = pd.Series(['Alice', 'Bob', 'Carol', 'Eve']) + >>> s.between('Anna', 'Daniel') + 0 False + 1 True + 2 True + 3 False + dtype: bool """ if inclusive: lmask = self >= left From 86f17cb47595c6a58ae989cd4a364d7d1afcf436 Mon Sep 17 00:00:00 2001 From: Maximiliano Greco Date: Thu, 29 Mar 2018 04:31:13 +0200 Subject: [PATCH 59/81] CLN: Removed not necessary bn switch decorator on nansum (#20481) --- pandas/core/nanops.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pandas/core/nanops.py b/pandas/core/nanops.py index d4851f579dda4..90333c23817c5 100644 --- a/pandas/core/nanops.py +++ b/pandas/core/nanops.py @@ -326,7 +326,6 @@ def nanall(values, axis=None, skipna=True): @disallow('M8') -@bottleneck_switch() def nansum(values, axis=None, skipna=True, min_count=0): values, mask, dtype, dtype_max = _get_values(values, skipna, 0) dtype_sum = dtype_max From 3d47aec42c7e2db161d3cca55af2a734f9825cbd Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Wed, 28 Mar 2018 21:31:37 -0500 Subject: [PATCH 60/81] DOC: Fix various warnings (#20509) --- doc/source/comparison_with_r.rst | 2 +- doc/source/contributing.rst | 13 +++++++++---- doc/source/cookbook.rst | 2 ++ doc/source/io.rst | 2 ++ doc/source/release.rst | 2 +- doc/source/whatsnew/v0.10.0.txt | 2 +- doc/source/whatsnew/v0.16.1.txt | 2 +- doc/source/whatsnew/v0.23.0.txt | 2 +- doc/source/whatsnew/v0.6.1.txt | 4 ++-- doc/source/whatsnew/v0.7.3.txt | 6 +++--- pandas/core/generic.py | 7 +++---- pandas/core/series.py | 28 +++++++++++++++------------- pandas/plotting/_core.py | 2 +- pandas/util/_decorators.py | 19 ++++++++++++------- 14 files changed, 54 insertions(+), 39 deletions(-) diff --git a/doc/source/comparison_with_r.rst b/doc/source/comparison_with_r.rst index eb97aeeb7e696..a7586f623a160 100644 --- a/doc/source/comparison_with_r.rst +++ b/doc/source/comparison_with_r.rst @@ -397,7 +397,7 @@ In Python, this list would be a list of tuples, so pd.DataFrame(a) For more details and examples see :ref:`the Into to Data Structures -documentation `. +documentation `. |meltdf|_ ~~~~~~~~~~~~~~~~ diff --git a/doc/source/contributing.rst b/doc/source/contributing.rst index 967d1fe3369f0..6d5ac31c39a62 100644 --- a/doc/source/contributing.rst +++ b/doc/source/contributing.rst @@ -298,6 +298,11 @@ Some other important things to know about the docs: Standard**. Follow the :ref:`pandas docstring guide ` for detailed instructions on how to write a correct docstring. + .. toctree:: + :maxdepth: 2 + + contributing_docstring.rst + - The tutorials make heavy use of the `ipython directive `_ sphinx extension. This directive lets you put code in the documentation which will be run @@ -900,7 +905,7 @@ Documenting your code Changes should be reflected in the release notes located in ``doc/source/whatsnew/vx.y.z.txt``. This file contains an ongoing change log for each release. Add an entry to this file to document your fix, enhancement or (unavoidable) breaking change. Make sure to include the -GitHub issue number when adding your entry (using `` :issue:`1234` `` where `1234` is the +GitHub issue number when adding your entry (using ``:issue:`1234``` where ``1234`` is the issue/pull request number). If your code is an enhancement, it is most likely necessary to add usage @@ -1020,7 +1025,7 @@ release. To submit a pull request: #. Click ``Send Pull Request``. This request then goes to the repository maintainers, and they will review -the code. +the code. .. _contributing.update-pr: @@ -1028,7 +1033,7 @@ Updating your pull request -------------------------- Based on the review you get on your pull request, you will probably need to make -some changes to the code. In that case, you can make them in your branch, +some changes to the code. In that case, you can make them in your branch, add a new commit to that branch, push it to GitHub, and the pull request will be automatically updated. Pushing them to GitHub again is done by:: @@ -1039,7 +1044,7 @@ This will automatically update your pull request with the latest code and restar Another reason you might need to update your pull request is to solve conflicts with changes that have been merged into the master branch since you opened your -pull request. +pull request. To do this, you need to "merge upstream master" in your branch:: diff --git a/doc/source/cookbook.rst b/doc/source/cookbook.rst index b6690eff89836..4e61228d5c0ad 100644 --- a/doc/source/cookbook.rst +++ b/doc/source/cookbook.rst @@ -411,6 +411,8 @@ Levels `Flatten Hierarchical columns `__ +.. _cookbook.missing_data: + Missing Data ------------ diff --git a/doc/source/io.rst b/doc/source/io.rst index d6bd81861adee..68b431925d983 100644 --- a/doc/source/io.rst +++ b/doc/source/io.rst @@ -3862,6 +3862,8 @@ Then create the index when finished appending. See `here `__ for how to create a completely-sorted-index (CSI) on an existing store. +.. _io.hdf5-query-data-columns: + Query via Data Columns ++++++++++++++++++++++ diff --git a/doc/source/release.rst b/doc/source/release.rst index 8e063116cbf07..da3362b47b29b 100644 --- a/doc/source/release.rst +++ b/doc/source/release.rst @@ -71,7 +71,7 @@ Highlights include: - Temporarily restore matplotlib datetime plotting functionality. This should resolve issues for users who relied implicitly on pandas to plot datetimes - with matplotlib. See :ref:`here `. + with matplotlib. See :ref:`here `. - Improvements to the Parquet IO functions introduced in 0.21.0. See :ref:`here `. diff --git a/doc/source/whatsnew/v0.10.0.txt b/doc/source/whatsnew/v0.10.0.txt index 222a2da23865c..3fc05158b7fe7 100644 --- a/doc/source/whatsnew/v0.10.0.txt +++ b/doc/source/whatsnew/v0.10.0.txt @@ -409,7 +409,7 @@ N Dimensional Panels (Experimental) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Adding experimental support for Panel4D and factory functions to create n-dimensional named panels. -:ref:`Docs ` for NDim. Here is a taste of what to expect. +Here is a taste of what to expect. .. code-block:: ipython diff --git a/doc/source/whatsnew/v0.16.1.txt b/doc/source/whatsnew/v0.16.1.txt index 9e1dc391d7ace..5c716f6ad45c1 100644 --- a/doc/source/whatsnew/v0.16.1.txt +++ b/doc/source/whatsnew/v0.16.1.txt @@ -26,7 +26,7 @@ Highlights include: .. warning:: - In pandas 0.17.0, the sub-package ``pandas.io.data`` will be removed in favor of a separately installable package. See :ref:`here for details ` (:issue:`8961`) + In pandas 0.17.0, the sub-package ``pandas.io.data`` will be removed in favor of a separately installable package (:issue:`8961`). Enhancements ~~~~~~~~~~~~ diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index a19b71c27d998..9a8659dfd8b06 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -331,7 +331,7 @@ Other Enhancements :func:`pandas.api.extensions.register_series_accessor`, and :func:`pandas.api.extensions.register_index_accessor`, accessor for libraries downstream of pandas to register custom accessors like ``.cat`` on pandas objects. See - :ref:`Registering Custom Accessors ` for more (:issue:`14781`). + :ref:`Registering Custom Accessors ` for more (:issue:`14781`). - ``IntervalIndex.astype`` now supports conversions between subtypes when passed an ``IntervalDtype`` (:issue:`19197`) - :class:`IntervalIndex` and its associated constructor methods (``from_arrays``, ``from_breaks``, ``from_tuples``) have gained a ``dtype`` parameter (:issue:`19262`) diff --git a/doc/source/whatsnew/v0.6.1.txt b/doc/source/whatsnew/v0.6.1.txt index a2dab738546f9..acd5b0774f2bb 100644 --- a/doc/source/whatsnew/v0.6.1.txt +++ b/doc/source/whatsnew/v0.6.1.txt @@ -16,12 +16,12 @@ New features - Add PyQt table widget to sandbox (:issue:`435`) - DataFrame.align can :ref:`accept Series arguments ` and an :ref:`axis option ` (:issue:`461`) -- Implement new :ref:`SparseArray ` and :ref:`SparseList ` +- Implement new :ref:`SparseArray ` and `SparseList` data structures. SparseSeries now derives from SparseArray (:issue:`463`) - :ref:`Better console printing options ` (:issue:`453`) - Implement fast :ref:`data ranking ` for Series and DataFrame, fast versions of scipy.stats.rankdata (:issue:`428`) -- Implement :ref:`DataFrame.from_items ` alternate +- Implement `DataFrame.from_items` alternate constructor (:issue:`444`) - DataFrame.convert_objects method for :ref:`inferring better dtypes ` for object columns (:issue:`302`) diff --git a/doc/source/whatsnew/v0.7.3.txt b/doc/source/whatsnew/v0.7.3.txt index 6b5199c55cbf5..77cc72d8707cf 100644 --- a/doc/source/whatsnew/v0.7.3.txt +++ b/doc/source/whatsnew/v0.7.3.txt @@ -22,7 +22,7 @@ New features from pandas.tools.plotting import scatter_matrix scatter_matrix(df, alpha=0.2) -.. image:: _static/scatter_matrix_kde.png +.. image:: savefig/scatter_matrix_kde.png :width: 5in - Add ``stacked`` argument to Series and DataFrame's ``plot`` method for @@ -32,14 +32,14 @@ New features df.plot(kind='bar', stacked=True) -.. image:: _static/bar_plot_stacked_ex.png +.. image:: savefig/bar_plot_stacked_ex.png :width: 4in .. code-block:: python df.plot(kind='barh', stacked=True) -.. image:: _static/barh_plot_stacked_ex.png +.. image:: savefig/barh_plot_stacked_ex.png :width: 4in - Add log x and y :ref:`scaling options ` to diff --git a/pandas/core/generic.py b/pandas/core/generic.py index f1fa43818ce64..d5cd22732f0a9 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -1900,7 +1900,7 @@ def to_hdf(self, path_or_buf, key, **kwargs): In order to add another DataFrame or Series to an existing HDF file please use append mode and a different a key. - For more information see the :ref:`user guide `. + For more information see the :ref:`user guide `. Parameters ---------- @@ -1929,8 +1929,7 @@ def to_hdf(self, path_or_buf, key, **kwargs): data_columns : list of columns or True, optional List of columns to create as indexed data columns for on-disk queries, or True to use all columns. By default only the axes - of the object are indexed. See `here - `__. + of the object are indexed. See :ref:`io.hdf5-query-data-columns`. Applicable only to format='table'. complevel : {0-9}, optional Specifies a compression level for data. @@ -2141,7 +2140,7 @@ def to_pickle(self, path, compression='infer', .. versionadded:: 0.20.0 protocol : int Int which indicates which protocol should be used by the pickler, - default HIGHEST_PROTOCOL (see [1], paragraph 12.1.2). The possible + default HIGHEST_PROTOCOL (see [1]_ paragraph 12.1.2). The possible values for this parameter depend on the version of Python. For Python 2.x, possible values are 0, 1, 2. For Python>=3.0, 3 is a valid value. For Python >= 3.4, 4 is a valid value. A negative diff --git a/pandas/core/series.py b/pandas/core/series.py index e8b99466698cc..89075e5e6acbb 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -467,8 +467,8 @@ def asobject(self): """Return object Series which contains boxed values. .. deprecated :: 0.23.0 - Use ``astype(object) instead. + Use ``astype(object) instead. *this is an internal non-public method* """ @@ -1772,18 +1772,20 @@ def idxmax(self, axis=0, skipna=True, *args, **kwargs): return self.index[i] # ndarray compat - argmin = deprecate('argmin', idxmin, '0.21.0', - msg="'argmin' is deprecated, use 'idxmin' instead. " - "The behavior of 'argmin' will be corrected to " - "return the positional minimum in the future. " - "Use 'series.values.argmin' to get the position of " - "the minimum now.") - argmax = deprecate('argmax', idxmax, '0.21.0', - msg="'argmax' is deprecated, use 'idxmax' instead. " - "The behavior of 'argmax' will be corrected to " - "return the positional maximum in the future. " - "Use 'series.values.argmax' to get the position of " - "the maximum now.") + argmin = deprecate( + 'argmin', idxmin, '0.21.0', + msg=dedent("""\ + 'argmin' is deprecated, use 'idxmin' instead. The behavior of 'argmin' + will be corrected to return the positional minimum in the future. + Use 'series.values.argmin' to get the position of the minimum now.""") + ) + argmax = deprecate( + 'argmax', idxmax, '0.21.0', + msg=dedent("""\ + 'argmax' is deprecated, use 'idxmax' instead. The behavior of 'argmax' + will be corrected to return the positional maximum in the future. + Use 'series.values.argmax' to get the position of the maximum now.""") + ) def round(self, decimals=0, *args, **kwargs): """ diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 6e4ab0137a376..814d63e74e9cb 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -3114,7 +3114,7 @@ def hist(self, by=None, bins=10, **kwds): A histogram is a representation of the distribution of data. This function groups the values of all given Series in the DataFrame - into bins, and draws all bins in only one :ref:`matplotlib.axes.Axes`. + into bins and draws all bins in one :class:`matplotlib.axes.Axes`. This is useful when the DataFrame's Series are in a similar scale. Parameters diff --git a/pandas/util/_decorators.py b/pandas/util/_decorators.py index 1753bc8b8fc33..624fbbbd4f05e 100644 --- a/pandas/util/_decorators.py +++ b/pandas/util/_decorators.py @@ -45,13 +45,18 @@ def wrapper(*args, **kwargs): return alternative(*args, **kwargs) # adding deprecated directive to the docstring - msg = msg or 'Use `{alt_name}` instead.' - docstring = '.. deprecated:: {}\n'.format(version) - docstring += dedent(' ' + ('\n'.join(wrap(msg, 70)))) - - if getattr(wrapper, '__doc__') is not None: - docstring += dedent(wrapper.__doc__) - + msg = msg or 'Use `{alt_name}` instead.'.format(alt_name=alt_name) + tpl = dedent(""" + .. deprecated:: {version} + + {msg} + + {rest} + """) + rest = getattr(wrapper, '__doc__', '') + docstring = tpl.format(version=version, + msg='\n '.join(wrap(msg, 70)), + rest=dedent(rest)) wrapper.__doc__ = docstring return wrapper From 0c3c72393beb98b00ba8abccc7f39b93c6aa8a4c Mon Sep 17 00:00:00 2001 From: Jeff Reback Date: Thu, 29 Mar 2018 06:10:59 -0400 Subject: [PATCH 61/81] DOC: whatsnew edits --- doc/source/whatsnew/v0.23.0.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index 9a8659dfd8b06..c6dadb7589869 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -914,8 +914,8 @@ Timedelta ^^^^^^^^^ - Bug in :func:`Timedelta.__mul__` where multiplying by ``NaT`` returned ``NaT`` instead of raising a ``TypeError`` (:issue:`19819`) -- Bug in :class:`Series` with ``dtype='timedelta64[ns]`` where addition or subtraction of ``TimedeltaIndex`` had results cast to ``dtype='int64'`` (:issue:`17250`) -- Bug in :class:`Series` with ``dtype='timedelta64[ns]`` where addition or subtraction of ``TimedeltaIndex`` could return a ``Series`` with an incorrect name (:issue:`19043`) +- Bug in :class:`Series` with ``dtype='timedelta64[ns]'`` where addition or subtraction of ``TimedeltaIndex`` had results cast to ``dtype='int64'`` (:issue:`17250`) +- Bug in :class:`Series` with ``dtype='timedelta64[ns]'`` where addition or subtraction of ``TimedeltaIndex`` could return a ``Series`` with an incorrect name (:issue:`19043`) - Bug in :func:`Timedelta.__floordiv__` and :func:`Timedelta.__rfloordiv__` dividing by many incompatible numpy objects was incorrectly allowed (:issue:`18846`) - Bug where dividing a scalar timedelta-like object with :class:`TimedeltaIndex` performed the reciprocal operation (:issue:`19125`) - Bug in :class:`TimedeltaIndex` where division by a ``Series`` would return a ``TimedeltaIndex`` instead of a ``Series`` (:issue:`19042`) @@ -944,7 +944,7 @@ Timezones - Bug when iterating over :class:`DatetimeIndex` that was localized with fixed timezone offset that rounded nanosecond precision to microseconds (:issue:`19603`) - Bug in :func:`DataFrame.diff` that raised an ``IndexError`` with tz-aware values (:issue:`18578`) - Bug in :func:`melt` that converted tz-aware dtypes to tz-naive (:issue:`15785`) -- Bug in :func:`Dataframe.count` that raised an ``ValueError`` if .dropna() method is invoked for single column timezone-aware values. (:issue:`13407`) +- Bug in :func:`Dataframe.count` that raised an ``ValueError``, if :func:`Dataframe.dropna` was called for a single column with timezone-aware values. (:issue:`13407`) Offsets ^^^^^^^ @@ -1028,6 +1028,7 @@ Plotting - Bug in :func:`DataFrame.plot` when ``x`` and ``y`` arguments given as positions caused incorrect referenced columns for line, bar and area plots (:issue:`20056`) - Bug in formatting tick labels with ``datetime.time()`` and fractional seconds (:issue:`18478`). - :meth:`Series.plot.kde` has exposed the args ``ind`` and ``bw_method`` in the docstring (:issue:`18461`). The argument ``ind`` may now also be an integer (number of sample points). +- :func:`DataFrame.plot` now supports multiple columns to the ``y`` argument (:issue:`19699`) Groupby/Resample/Rolling @@ -1081,4 +1082,3 @@ Other - Improved error message when attempting to use a Python keyword as an identifier in a ``numexpr`` backed query (:issue:`18221`) - Bug in accessing a :func:`pandas.get_option`, which raised ``KeyError`` rather than ``OptionError`` when looking up a non-existant option key in some cases (:issue:`19789`) -- :func:`DataFrame.plot` now supports multiple columns to the ``y`` argument (:issue:`19699`) From 145d75c72f841032cf4cf8a13d715c2289d725b9 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 29 Mar 2018 10:39:08 -0400 Subject: [PATCH 62/81] DOC: Add comparison with Stata page to documentation (#19945) * Add Comparison with Stata to documentation * Add comparison_with_stata.rst to index.rst.template * Fix section underlining * Small fixes --- doc/source/comparison_with_stata.rst | 680 +++++++++++++++++++++++++++ doc/source/index.rst.template | 1 + 2 files changed, 681 insertions(+) create mode 100644 doc/source/comparison_with_stata.rst diff --git a/doc/source/comparison_with_stata.rst b/doc/source/comparison_with_stata.rst new file mode 100644 index 0000000000000..6c518983d5904 --- /dev/null +++ b/doc/source/comparison_with_stata.rst @@ -0,0 +1,680 @@ +.. currentmodule:: pandas +.. _compare_with_stata: + +Comparison with Stata +********************* +For potential users coming from `Stata `__ +this page is meant to demonstrate how different Stata operations would be +performed in pandas. + +If you're new to pandas, you might want to first read through :ref:`10 Minutes to pandas<10min>` +to familiarize yourself with the library. + +As is customary, we import pandas and NumPy as follows. This means that we can refer to the +libraries as ``pd`` and ``np``, respectively, for the rest of the document. + +.. ipython:: python + + import pandas as pd + import numpy as np + + +.. note:: + + Throughout this tutorial, the pandas ``DataFrame`` will be displayed by calling + ``df.head()``, which displays the first N (default 5) rows of the ``DataFrame``. + This is often used in interactive work (e.g. `Jupyter notebook + `_ or terminal) -- the equivalent in Stata would be: + + .. code-block:: stata + + list in 1/5 + +Data Structures +--------------- + +General Terminology Translation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. csv-table:: + :header: "pandas", "Stata" + :widths: 20, 20 + + ``DataFrame``, data set + column, variable + row, observation + groupby, bysort + ``NaN``, ``.`` + + +``DataFrame`` / ``Series`` +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A ``DataFrame`` in pandas is analogous to a Stata data set -- a two-dimensional +data source with labeled columns that can be of different types. As will be +shown in this document, almost any operation that can be applied to a data set +in Stata can also be accomplished in pandas. + +A ``Series`` is the data structure that represents one column of a +``DataFrame``. Stata doesn't have a separate data structure for a single column, +but in general, working with a ``Series`` is analogous to referencing a column +of a data set in Stata. + +``Index`` +~~~~~~~~~ + +Every ``DataFrame`` and ``Series`` has an ``Index`` -- labels on the +*rows* of the data. Stata does not have an exactly analogous concept. In Stata, a data set's +rows are essentially unlabeled, other than an implicit integer index that can be +accessed with ``_n``. + +In pandas, if no index is specified, an integer index is also used by default +(first row = 0, second row = 1, and so on). While using a labeled ``Index`` or +``MultiIndex`` can enable sophisticated analyses and is ultimately an important +part of pandas to understand, for this comparison we will essentially ignore the +``Index`` and just treat the ``DataFrame`` as a collection of columns. Please +see the :ref:`indexing documentation` for much more on how to use an +``Index`` effectively. + + +Data Input / Output +------------------- + +Constructing a DataFrame from Values +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A Stata data set can be built from specified values by +placing the data after an ``input`` statement and +specifying the column names. + +.. code-block:: stata + + input x y + 1 2 + 3 4 + 5 6 + end + +A pandas ``DataFrame`` can be constructed in many different ways, +but for a small number of values, it is often convenient to specify it as +a Python dictionary, where the keys are the column names +and the values are the data. + +.. ipython:: python + + df = pd.DataFrame({ + 'x': [1, 3, 5], + 'y': [2, 4, 6]}) + df + + +Reading External Data +~~~~~~~~~~~~~~~~~~~~~ + +Like Stata, pandas provides utilities for reading in data from +many formats. The ``tips`` data set, found within the pandas +tests (`csv `_) +will be used in many of the following examples. + +Stata provides ``import delimited`` to read csv data into a data set in memory. +If the ``tips.csv`` file is in the current working directory, we can import it as follows. + +.. code-block:: stata + + import delimited tips.csv + +The pandas method is :func:`read_csv`, which works similarly. Additionally, it will automatically download +the data set if presented with a url. + +.. ipython:: python + + url = 'https://raw.github.com/pandas-dev/pandas/master/pandas/tests/data/tips.csv' + tips = pd.read_csv(url) + tips.head() + +Like ``import delimited``, :func:`read_csv` can take a number of parameters to specify +how the data should be parsed. For example, if the data were instead tab delimited, +did not have column names, and existed in the current working directory, +the pandas command would be: + +.. code-block:: python + + tips = pd.read_csv('tips.csv', sep='\t', header=None) + + # alternatively, read_table is an alias to read_csv with tab delimiter + tips = pd.read_table('tips.csv', header=None) + +Pandas can also read Stata data sets in ``.dta`` format with the :func:`read_stata` function. + +.. code-block:: python + + df = pd.read_stata('data.dta') + +In addition to text/csv and Stata files, pandas supports a variety of other data formats +such as Excel, SAS, HDF5, Parquet, and SQL databases. These are all read via a ``pd.read_*`` +function. See the :ref:`IO documentation` for more details. + + +Exporting Data +~~~~~~~~~~~~~~ + +The inverse of ``import delimited`` in Stata is ``export delimited`` + +.. code-block:: stata + + export delimited tips2.csv + +Similarly in pandas, the opposite of ``read_csv`` is :meth:`DataFrame.to_csv`. + +.. code-block:: python + + tips.to_csv('tips2.csv') + +Pandas can also export to Stata file format with the :meth:`DataFrame.to_stata` method. + +.. code-block:: python + + tips.to_stata('tips2.dta') + + +Data Operations +--------------- + +Operations on Columns +~~~~~~~~~~~~~~~~~~~~~ + +In Stata, arbitrary math expressions can be used with the ``generate`` and +``replace`` commands on new or existing columns. The ``drop`` command drops +the column from the data set. + +.. code-block:: stata + + replace total_bill = total_bill - 2 + generate new_bill = total_bill / 2 + drop new_bill + +pandas provides similar vectorized operations by +specifying the individual ``Series`` in the ``DataFrame``. +New columns can be assigned in the same way. The :meth:`DataFrame.drop` method +drops a column from the ``DataFrame``. + +.. ipython:: python + + tips['total_bill'] = tips['total_bill'] - 2 + tips['new_bill'] = tips['total_bill'] / 2 + tips.head() + + tips = tips.drop('new_bill', axis=1) + +Filtering +~~~~~~~~~ + +Filtering in Stata is done with an ``if`` clause on one or more columns. + +.. code-block:: stata + + list if total_bill > 10 + +DataFrames can be filtered in multiple ways; the most intuitive of which is using +:ref:`boolean indexing `. + +.. ipython:: python + + tips[tips['total_bill'] > 10].head() + +If/Then Logic +~~~~~~~~~~~~~ + +In Stata, an ``if`` clause can also be used to create new columns. + +.. code-block:: stata + + generate bucket = "low" if total_bill < 10 + replace bucket = "high" if total_bill >= 10 + +The same operation in pandas can be accomplished using +the ``where`` method from ``numpy``. + +.. ipython:: python + + tips['bucket'] = np.where(tips['total_bill'] < 10, 'low', 'high') + tips.head() + +.. ipython:: python + :suppress: + + tips = tips.drop('bucket', axis=1) + +Date Functionality +~~~~~~~~~~~~~~~~~~ + +Stata provides a variety of functions to do operations on +date/datetime columns. + +.. code-block:: stata + + generate date1 = mdy(1, 15, 2013) + generate date2 = date("Feb152015", "MDY") + + generate date1_year = year(date1) + generate date2_month = month(date2) + + * shift date to beginning of next month + generate date1_next = mdy(month(date1) + 1, 1, year(date1)) if month(date1) != 12 + replace date1_next = mdy(1, 1, year(date1) + 1) if month(date1) == 12 + generate months_between = mofd(date2) - mofd(date1) + + list date1 date2 date1_year date2_month date1_next months_between + +The equivalent pandas operations are shown below. In addition to these +functions, pandas supports other Time Series features +not available in Stata (such as time zone handling and custom offsets) -- +see the :ref:`timeseries documentation` for more details. + +.. ipython:: python + + tips['date1'] = pd.Timestamp('2013-01-15') + tips['date2'] = pd.Timestamp('2015-02-15') + tips['date1_year'] = tips['date1'].dt.year + tips['date2_month'] = tips['date2'].dt.month + tips['date1_next'] = tips['date1'] + pd.offsets.MonthBegin() + tips['months_between'] = (tips['date2'].dt.to_period('M') - + tips['date1'].dt.to_period('M')) + + tips[['date1','date2','date1_year','date2_month', + 'date1_next','months_between']].head() + +.. ipython:: python + :suppress: + + tips = tips.drop(['date1','date2','date1_year', + 'date2_month','date1_next','months_between'], axis=1) + +Selection of Columns +~~~~~~~~~~~~~~~~~~~~ + +Stata provides keywords to select, drop, and rename columns. + +.. code-block:: stata + + keep sex total_bill tip + + drop sex + + rename total_bill total_bill_2 + +The same operations are expressed in pandas below. Note that in contrast to Stata, these +operations do not happen in place. To make these changes persist, assign the operation back +to a variable. + +.. ipython:: python + + # keep + tips[['sex', 'total_bill', 'tip']].head() + + # drop + tips.drop('sex', axis=1).head() + + # rename + tips.rename(columns={'total_bill': 'total_bill_2'}).head() + + +Sorting by Values +~~~~~~~~~~~~~~~~~ + +Sorting in Stata is accomplished via ``sort`` + +.. code-block:: stata + + sort sex total_bill + +pandas objects have a :meth:`DataFrame.sort_values` method, which +takes a list of columns to sort by. + +.. ipython:: python + + tips = tips.sort_values(['sex', 'total_bill']) + tips.head() + + +String Processing +----------------- + +Finding Length of String +~~~~~~~~~~~~~~~~~~~~~~~~ + +Stata determines the length of a character string with the :func:`strlen` and +:func:`ustrlen` functions for ASCII and Unicode strings, respectively. + +.. code-block:: stata + + generate strlen_time = strlen(time) + generate ustrlen_time = ustrlen(time) + +Python determines the length of a character string with the ``len`` function. +In Python 3, all strings are Unicode strings. ``len`` includes trailing blanks. +Use ``len`` and ``rstrip`` to exclude trailing blanks. + +.. ipython:: python + + tips['time'].str.len().head() + tips['time'].str.rstrip().str.len().head() + + +Finding Position of Substring +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Stata determines the position of a character in a string with the :func:`strpos` function. +This takes the string defined by the first argument and searches for the +first position of the substring you supply as the second argument. + +.. code-block:: stata + + generate str_position = strpos(sex, "ale") + +Python determines the position of a character in a string with the +:func:`find` function. ``find`` searches for the first position of the +substring. If the substring is found, the function returns its +position. Keep in mind that Python indexes are zero-based and +the function will return -1 if it fails to find the substring. + +.. ipython:: python + + tips['sex'].str.find("ale").head() + + +Extracting Substring by Position +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Stata extracts a substring from a string based on its position with the :func:`substr` function. + +.. code-block:: stata + + generate short_sex = substr(sex, 1, 1) + +With pandas you can use ``[]`` notation to extract a substring +from a string by position locations. Keep in mind that Python +indexes are zero-based. + +.. ipython:: python + + tips['sex'].str[0:1].head() + + +Extracting nth Word +~~~~~~~~~~~~~~~~~~~ + +The Stata :func:`word` function returns the nth word from a string. +The first argument is the string you want to parse and the +second argument specifies which word you want to extract. + +.. code-block:: stata + + clear + input str20 string + "John Smith" + "Jane Cook" + end + + generate first_name = word(name, 1) + generate last_name = word(name, -1) + +Python extracts a substring from a string based on its text +by using regular expressions. There are much more powerful +approaches, but this just shows a simple approach. + +.. ipython:: python + + firstlast = pd.DataFrame({'string': ['John Smith', 'Jane Cook']}) + firstlast['First_Name'] = firstlast['string'].str.split(" ", expand=True)[0] + firstlast['Last_Name'] = firstlast['string'].str.rsplit(" ", expand=True)[0] + firstlast + + +Changing Case +~~~~~~~~~~~~~ + +The Stata :func:`strupper`, :func:`strlower`, :func:`strproper`, +:func:`ustrupper`, :func:`ustrlower`, and :func:`ustrtitle` functions +change the case of ASCII and Unicode strings, respectively. + +.. code-block:: stata + + clear + input str20 string + "John Smith" + "Jane Cook" + end + + generate upper = strupper(string) + generate lower = strlower(string) + generate title = strproper(string) + list + +The equivalent Python functions are ``upper``, ``lower``, and ``title``. + +.. ipython:: python + + firstlast = pd.DataFrame({'string': ['John Smith', 'Jane Cook']}) + firstlast['upper'] = firstlast['string'].str.upper() + firstlast['lower'] = firstlast['string'].str.lower() + firstlast['title'] = firstlast['string'].str.title() + firstlast + +Merging +------- + +The following tables will be used in the merge examples + +.. ipython:: python + + df1 = pd.DataFrame({'key': ['A', 'B', 'C', 'D'], + 'value': np.random.randn(4)}) + df1 + df2 = pd.DataFrame({'key': ['B', 'D', 'D', 'E'], + 'value': np.random.randn(4)}) + df2 + +In Stata, to perform a merge, one data set must be in memory +and the other must be referenced as a file name on disk. In +contrast, Python must have both ``DataFrames`` already in memory. + +By default, Stata performs an outer join, where all observations +from both data sets are left in memory after the merge. One can +keep only observations from the initial data set, the merged data set, +or the intersection of the two by using the values created in the +``_merge`` variable. + +.. code-block:: stata + + * First create df2 and save to disk + clear + input str1 key + B + D + D + E + end + generate value = rnormal() + save df2.dta + + * Now create df1 in memory + clear + input str1 key + A + B + C + D + end + generate value = rnormal() + + preserve + + * Left join + merge 1:n key using df2.dta + keep if _merge == 1 + + * Right join + restore, preserve + merge 1:n key using df2.dta + keep if _merge == 2 + + * Inner join + restore, preserve + merge 1:n key using df2.dta + keep if _merge == 3 + + * Outer join + restore + merge 1:n key using df2.dta + +pandas DataFrames have a :meth:`DataFrame.merge` method, which provides +similar functionality. Note that different join +types are accomplished via the ``how`` keyword. + +.. ipython:: python + + inner_join = df1.merge(df2, on=['key'], how='inner') + inner_join + + left_join = df1.merge(df2, on=['key'], how='left') + left_join + + right_join = df1.merge(df2, on=['key'], how='right') + right_join + + outer_join = df1.merge(df2, on=['key'], how='outer') + outer_join + + +Missing Data +------------ + +Like Stata, pandas has a representation for missing data -- the +special float value ``NaN`` (not a number). Many of the semantics +are the same; for example missing data propagates through numeric +operations, and is ignored by default for aggregations. + +.. ipython:: python + + outer_join + outer_join['value_x'] + outer_join['value_y'] + outer_join['value_x'].sum() + +One difference is that missing data cannot be compared to its sentinel value. +For example, in Stata you could do this to filter missing values. + +.. code-block:: stata + + * Keep missing values + list if value_x == . + * Keep non-missing values + list if value_x != . + +This doesn't work in pandas. Instead, the :func:`pd.isna` or :func:`pd.notna` functions +should be used for comparisons. + +.. ipython:: python + + outer_join[pd.isna(outer_join['value_x'])] + outer_join[pd.notna(outer_join['value_x'])] + +Pandas also provides a variety of methods to work with missing data -- some of +which would be challenging to express in Stata. For example, there are methods to +drop all rows with any missing values, replacing missing values with a specified +value, like the mean, or forward filling from previous rows. See the +:ref:`missing data documentation` for more. + +.. ipython:: python + + # Drop rows with any missing value + outer_join.dropna() + + # Fill forwards + outer_join.fillna(method='ffill') + + # Impute missing values with the mean + outer_join['value_x'].fillna(outer_join['value_x'].mean()) + + +GroupBy +------- + +Aggregation +~~~~~~~~~~~ + +Stata's ``collapse`` can be used to group by one or +more key variables and compute aggregations on +numeric columns. + +.. code-block:: stata + + collapse (sum) total_bill tip, by(sex smoker) + +pandas provides a flexible ``groupby`` mechanism that +allows similar aggregations. See the :ref:`groupby documentation` +for more details and examples. + +.. ipython:: python + + tips_summed = tips.groupby(['sex', 'smoker'])['total_bill', 'tip'].sum() + tips_summed.head() + + +Transformation +~~~~~~~~~~~~~~ + +In Stata, if the group aggregations need to be used with the +original data set, one would usually use ``bysort`` with :func:`egen`. +For example, to subtract the mean for each observation by smoker group. + +.. code-block:: stata + + bysort sex smoker: egen group_bill = mean(total_bill) + generate adj_total_bill = total_bill - group_bill + + +pandas ``groubpy`` provides a ``transform`` mechanism that allows +these type of operations to be succinctly expressed in one +operation. + +.. ipython:: python + + gb = tips.groupby('smoker')['total_bill'] + tips['adj_total_bill'] = tips['total_bill'] - gb.transform('mean') + tips.head() + + +By Group Processing +~~~~~~~~~~~~~~~~~~~ + +In addition to aggregation, pandas ``groupby`` can be used to +replicate most other ``bysort`` processing from Stata. For example, +the following example lists the first observation in the current +sort order by sex/smoker group. + +.. code-block:: stata + + bysort sex smoker: list if _n == 1 + +In pandas this would be written as: + +.. ipython:: python + + tips.groupby(['sex','smoker']).first() + + +Other Considerations +-------------------- + +Disk vs Memory +~~~~~~~~~~~~~~ + +Pandas and Stata both operate exclusively in memory. This means that the size of +data able to be loaded in pandas is limited by your machine's memory. +If out of core processing is needed, one possibility is the +`dask.dataframe `_ +library, which provides a subset of pandas functionality for an +on-disk ``DataFrame``. + + diff --git a/doc/source/index.rst.template b/doc/source/index.rst.template index 1ef88a524732f..82ed5e36b6c82 100644 --- a/doc/source/index.rst.template +++ b/doc/source/index.rst.template @@ -150,6 +150,7 @@ See the package overview for more detail about what's in the library. comparison_with_r comparison_with_sql comparison_with_sas + comparison_with_stata {% endif -%} {% if include_api -%} api From 64a2a551a9600a4b622d2a7b4f8ceb705bff7426 Mon Sep 17 00:00:00 2001 From: Pulkit Maloo Date: Thu, 29 Mar 2018 12:23:36 -0400 Subject: [PATCH 63/81] DOC: Update missing_data.rst (#20424) --- doc/source/missing_data.rst | 60 ++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/doc/source/missing_data.rst b/doc/source/missing_data.rst index ee0e2c7462f66..3950e4c80749b 100644 --- a/doc/source/missing_data.rst +++ b/doc/source/missing_data.rst @@ -75,7 +75,7 @@ arise and we wish to also consider that "missing" or "not available" or "NA". To make detecting missing values easier (and across different array dtypes), pandas provides the :func:`isna` and :func:`notna` functions, which are also methods on -``Series`` and ``DataFrame`` objects: +Series and DataFrame objects: .. ipython:: python @@ -170,9 +170,8 @@ The descriptive statistics and computational methods discussed in the account for missing data. For example: * When summing data, NA (missing) values will be treated as zero. -* If the data are all NA, the result will be NA. -* Methods like **cumsum** and **cumprod** ignore NA values, but preserve them - in the resulting arrays. +* If the data are all NA, the result will be 0. +* Cumulative methods like :meth:`~DataFrame.cumsum` and :meth:`~DataFrame.cumprod` ignore NA values by default, but preserve them in the resulting arrays. To override this behaviour and include NA values, use ``skipna=False``. .. ipython:: python @@ -180,6 +179,7 @@ account for missing data. For example: df['one'].sum() df.mean(1) df.cumsum() + df.cumsum(skipna=False) .. _missing_data.numeric_sum: @@ -189,33 +189,24 @@ Sum/Prod of Empties/Nans .. warning:: - This behavior is now standard as of v0.21.0; previously sum/prod would give different - results if the ``bottleneck`` package was installed. - See the :ref:`v0.21.0 whatsnew `. + This behavior is now standard as of v0.22.0 and is consistent with the default in ``numpy``; previously sum/prod of all-NA or empty Series/DataFrames would return NaN. + See :ref:`v0.22.0 whatsnew ` for more. -With ``sum`` or ``prod`` on an empty or all-``NaN`` ``Series``, or columns of a ``DataFrame``, the result will be all-``NaN``. - -.. ipython:: python - - s = pd.Series([np.nan]) - - s.sum() - -Summing over an empty ``Series`` will return ``NaN``: +The sum of an empty or all-NA Series or column of a DataFrame is 0. .. ipython:: python + pd.Series([np.nan]).sum() + pd.Series([]).sum() -.. warning:: +The product of an empty or all-NA Series or column of a DataFrame is 1. - These behaviors differ from the default in ``numpy`` where an empty sum returns zero. - - .. ipython:: python - - np.nansum(np.array([np.nan])) - np.nansum(np.array([])) +.. ipython:: python + pd.Series([np.nan]).prod() + + pd.Series([]).prod() NA values in GroupBy @@ -242,7 +233,7 @@ with missing data. Filling missing values: fillna ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The **fillna** function can "fill in" NA values with non-NA data in a couple +:meth:`~DataFrame.fillna` can "fill in" NA values with non-NA data in a couple of ways, which we illustrate: **Replace NA with a scalar value** @@ -292,8 +283,8 @@ To remind you, these are the available filling methods: With time series data, using pad/ffill is extremely common so that the "last known value" is available at every time point. -The ``ffill()`` function is equivalent to ``fillna(method='ffill')`` -and ``bfill()`` is equivalent to ``fillna(method='bfill')`` +:meth:`~DataFrame.ffill` is equivalent to ``fillna(method='ffill')`` +and :meth:`~DataFrame.bfill` is equivalent to ``fillna(method='bfill')`` .. _missing_data.PandasObject: @@ -329,7 +320,7 @@ Dropping axis labels with missing data: dropna ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You may wish to simply exclude labels from a data set which refer to missing -data. To do this, use the :meth:`~DataFrame.dropna` method: +data. To do this, use :meth:`~DataFrame.dropna`: .. ipython:: python :suppress: @@ -344,7 +335,7 @@ data. To do this, use the :meth:`~DataFrame.dropna` method: df.dropna(axis=1) df['one'].dropna() -An equivalent :meth:`~Series.dropna` method is available for Series. +An equivalent :meth:`~Series.dropna` is available for Series. DataFrame.dropna has considerably more options than Series.dropna, which can be examined :ref:`in the API `. @@ -357,7 +348,7 @@ Interpolation The ``limit_area`` keyword argument was added. -Both Series and DataFrame objects have an :meth:`~DataFrame.interpolate` method +Both Series and DataFrame objects have :meth:`~DataFrame.interpolate` that, by default, performs linear interpolation at missing datapoints. .. ipython:: python @@ -486,7 +477,7 @@ at the new values. Interpolation Limits ^^^^^^^^^^^^^^^^^^^^ -Like other pandas fill methods, ``interpolate`` accepts a ``limit`` keyword +Like other pandas fill methods, :meth:`~DataFrame.interpolate` accepts a ``limit`` keyword argument. Use this argument to limit the number of consecutive ``NaN`` values filled since the last valid observation: @@ -533,8 +524,9 @@ the ``limit_area`` parameter restricts filling to either inside or outside value Replacing Generic Values ~~~~~~~~~~~~~~~~~~~~~~~~ -Often times we want to replace arbitrary values with other values. The -``replace`` method in Series/DataFrame provides an efficient yet +Often times we want to replace arbitrary values with other values. + +:meth:`~Series.replace` in Series and :meth:`~DataFrame.replace` in DataFrame provides an efficient yet flexible way to perform such replacements. For a Series, you can replace a single value or a list of values by another @@ -674,7 +666,7 @@ want to use a regular expression. Numeric Replacement ~~~~~~~~~~~~~~~~~~~ -The :meth:`~DataFrame.replace` method is similar to :meth:`~DataFrame.fillna`. +:meth:`~DataFrame.replace` is similar to :meth:`~DataFrame.fillna`. .. ipython:: python @@ -763,7 +755,7 @@ contains NAs, an exception will be generated: reindexed = s.reindex(list(range(8))).fillna(0) reindexed[crit] -However, these can be filled in using **fillna** and it will work fine: +However, these can be filled in using :meth:`~DataFrame.fillna` and it will work fine: .. ipython:: python From 41cdfdcef117cd0cbbc60b02ee0833c94c3a8178 Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Thu, 29 Mar 2018 14:46:25 -0400 Subject: [PATCH 64/81] Update v0.23.0.txt plotting bugfixes --- doc/source/whatsnew/v0.23.0.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index c6dadb7589869..73986ff25d725 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -1029,7 +1029,7 @@ Plotting - Bug in formatting tick labels with ``datetime.time()`` and fractional seconds (:issue:`18478`). - :meth:`Series.plot.kde` has exposed the args ``ind`` and ``bw_method`` in the docstring (:issue:`18461`). The argument ``ind`` may now also be an integer (number of sample points). - :func:`DataFrame.plot` now supports multiple columns to the ``y`` argument (:issue:`19699`) - +- Bug in :func:'DataFrame.plot.scatter' and :func:'DataFrame.plot.hexbin' caused x-axis label and ticklabels to disappear when colorbar was on (:issue:`10611` and :issue:`10678`) Groupby/Resample/Rolling ^^^^^^^^^^^^^^^^^^^^^^^^ From 48291ef217395f38b01339fefb3081f642ea38f0 Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Thu, 29 Mar 2018 15:33:28 -0400 Subject: [PATCH 65/81] Update test_frame.py extra comments pointing to issues --- pandas/tests/plotting/test_frame.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pandas/tests/plotting/test_frame.py b/pandas/tests/plotting/test_frame.py index 3977db048cec5..c170203c46345 100644 --- a/pandas/tests/plotting/test_frame.py +++ b/pandas/tests/plotting/test_frame.py @@ -1032,6 +1032,8 @@ def test_plot_scatter(self): @pytest.mark.slow def test_if_scatterplot_colorbar_affects_xaxis_visibility(self): + # addressing issue #10611, to ensure colobar does not + # interfere with x-axis label and ticklabels random_array = np.random.random((1000, 3)) df = pd.DataFrame(random_array, columns=['A label', 'B label', 'C label']) @@ -1056,6 +1058,8 @@ def test_if_scatterplot_colorbar_affects_xaxis_visibility(self): @pytest.mark.slow def test_if_hexbin_xaxis_label_is_visible(self): + # addressing issue #10678, to ensure colobar does not + # interfere with x-axis label and ticklabels random_array = np.random.random((1000, 3)) df = pd.DataFrame(random_array, columns=['A label', 'B label', 'C label']) From 09c76361a799991d6913321f4704ef706ef28dc0 Mon Sep 17 00:00:00 2001 From: Javad Date: Thu, 29 Mar 2018 23:08:33 -0400 Subject: [PATCH 66/81] remove whitespace --- pandas/tests/plotting/test_frame.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/tests/plotting/test_frame.py b/pandas/tests/plotting/test_frame.py index c170203c46345..174d8e4fddc6f 100644 --- a/pandas/tests/plotting/test_frame.py +++ b/pandas/tests/plotting/test_frame.py @@ -1032,7 +1032,7 @@ def test_plot_scatter(self): @pytest.mark.slow def test_if_scatterplot_colorbar_affects_xaxis_visibility(self): - # addressing issue #10611, to ensure colobar does not + # addressing issue #10611, to ensure colobar does not # interfere with x-axis label and ticklabels random_array = np.random.random((1000, 3)) df = pd.DataFrame(random_array, @@ -1058,7 +1058,7 @@ def test_if_scatterplot_colorbar_affects_xaxis_visibility(self): @pytest.mark.slow def test_if_hexbin_xaxis_label_is_visible(self): - # addressing issue #10678, to ensure colobar does not + # addressing issue #10678, to ensure colobar does not # interfere with x-axis label and ticklabels random_array = np.random.random((1000, 3)) df = pd.DataFrame(random_array, From 864420b9cee6b36926110fdcb42ade66ca7f582e Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Tue, 10 Apr 2018 06:47:46 -0400 Subject: [PATCH 67/81] Inline backend (#2) * minor change in comment * added condition on inline backend * added condition on inline backend * issue with floats determining the position of axes corners in ipython --- pandas/plotting/_core.py | 16 ++++------------ pandas/plotting/_tools.py | 3 +-- pandas/tests/plotting/test_frame.py | 6 ++++-- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 814d63e74e9cb..9ad1a9035458a 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -788,6 +788,10 @@ def _get_axes_layout(self): for ax in axes: # check axes coordinates to estimate layout points = ax.get_position().get_points() + # in IPython inline backend, get_points returns + # floats. Unless rounded they won't match + # in the set + points = np.round(points, 3) x_set.add(points[0][0]) y_set.add(points[0][1]) return (len(y_set), len(x_set)) @@ -870,12 +874,6 @@ def _make_plot(self): scatter = ax.scatter(data[x].values, data[y].values, c=c_values, label=label, cmap=cmap, **self.kwds) if cb: - # The following attribute determines which axes belong to - # colorbars. When sharex = True, this allows `_handle_shared_axes` - # to skip them. Otherwise colobars will cause x-axis label and - # tick labels to disappear. - ax._pandas_colorbar_axes = True - img = ax.collections[0] kws = dict(ax=ax) if self.mpl_ge_1_3_1(): @@ -922,12 +920,6 @@ def _make_plot(self): ax.hexbin(data[x].values, data[y].values, C=c_values, cmap=cmap, **self.kwds) if cb: - # The following attribute determines which axes belong to - # colorbars. When sharex = True, this allows `_handle_shared_axes` - # to skip them. Otherwise colobars will cause x-axis label and - # tick labels to disappear. - ax._pandas_colorbar_axes = True - img = ax.collections[0] self.fig.colorbar(img, ax=ax) diff --git a/pandas/plotting/_tools.py b/pandas/plotting/_tools.py index 15e3c048a21a6..5b85675c5ff3f 100644 --- a/pandas/plotting/_tools.py +++ b/pandas/plotting/_tools.py @@ -311,8 +311,7 @@ def _handle_shared_axes(axarr, nplots, naxes, nrows, ncols, sharex, sharey): # only the last row of subplots should get x labels -> all # other off layout handles the case that the subplot is # the last in the column, because below is no subplot/gap. - if (not layout[ax.rowNum + 1, ax.colNum] or - getattr(ax, '_pandas_colorbar_axes', False)): + if (not layout[ax.rowNum + 1, ax.colNum]): continue if sharex or len(ax.get_shared_x_axes() .get_siblings(ax)) > 1: diff --git a/pandas/tests/plotting/test_frame.py b/pandas/tests/plotting/test_frame.py index 174d8e4fddc6f..9e7bed8ffba47 100644 --- a/pandas/tests/plotting/test_frame.py +++ b/pandas/tests/plotting/test_frame.py @@ -1033,7 +1033,8 @@ def test_plot_scatter(self): @pytest.mark.slow def test_if_scatterplot_colorbar_affects_xaxis_visibility(self): # addressing issue #10611, to ensure colobar does not - # interfere with x-axis label and ticklabels + # interfere with x-axis label and ticklabels with + # ipython inline backend. random_array = np.random.random((1000, 3)) df = pd.DataFrame(random_array, columns=['A label', 'B label', 'C label']) @@ -1059,7 +1060,8 @@ def test_if_scatterplot_colorbar_affects_xaxis_visibility(self): @pytest.mark.slow def test_if_hexbin_xaxis_label_is_visible(self): # addressing issue #10678, to ensure colobar does not - # interfere with x-axis label and ticklabels + # interfere with x-axis label and ticklabels with + # ipython inline backend. random_array = np.random.random((1000, 3)) df = pd.DataFrame(random_array, columns=['A label', 'B label', 'C label']) From eb123be25f0c778e5ec4db127c810de0c9f17b67 Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Tue, 10 Apr 2018 06:50:20 -0400 Subject: [PATCH 68/81] Update _tools.py --- pandas/plotting/_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/plotting/_tools.py b/pandas/plotting/_tools.py index 5b85675c5ff3f..816586fbb82f5 100644 --- a/pandas/plotting/_tools.py +++ b/pandas/plotting/_tools.py @@ -311,7 +311,7 @@ def _handle_shared_axes(axarr, nplots, naxes, nrows, ncols, sharex, sharey): # only the last row of subplots should get x labels -> all # other off layout handles the case that the subplot is # the last in the column, because below is no subplot/gap. - if (not layout[ax.rowNum + 1, ax.colNum]): + if not layout[ax.rowNum + 1, ax.colNum]: continue if sharex or len(ax.get_shared_x_axes() .get_siblings(ax)) > 1: From 1fb4eec7b18c41d4d771bde0795cefc9f2d1e98b Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Tue, 10 Apr 2018 06:51:08 -0400 Subject: [PATCH 69/81] Update _core.py --- pandas/plotting/_core.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 9ad1a9035458a..2a29fda793300 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -906,7 +906,6 @@ def __init__(self, data, x, y, C=None, **kwargs): def _make_plot(self): x, y, data, C = self.x, self.y, self.data, self.C ax = self.axes[0] - # pandas uses colormap, matplotlib uses cmap. cmap = self.colormap or 'BuGn' cmap = self.plt.cm.get_cmap(cmap) From 0397999745b74e9e6e02c121fe78c7c71df9c7a4 Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Tue, 10 Apr 2018 06:52:58 -0400 Subject: [PATCH 70/81] Update _core.py --- pandas/plotting/_core.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 2a29fda793300..ed7cd5da1ccae 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -2809,7 +2809,6 @@ def __call__(self, x=None, y=None, kind='line', ax=None, rot=None, fontsize=None, colormap=None, table=False, yerr=None, xerr=None, secondary_y=False, sort_columns=False, **kwds): - return plot_frame(self._data, kind=kind, x=x, y=y, ax=ax, subplots=subplots, sharex=sharex, sharey=sharey, layout=layout, figsize=figsize, use_index=use_index, @@ -3338,7 +3337,6 @@ def scatter(self, x, y, s=None, c=None, **kwds): ... c='species', ... colormap='viridis') """ - return self(kind='scatter', x=x, y=y, c=c, s=s, **kwds) def hexbin(self, x, y, C=None, reduce_C_function=None, gridsize=None, @@ -3424,7 +3422,6 @@ def hexbin(self, x, y, C=None, reduce_C_function=None, gridsize=None, ... gridsize=10, ... cmap="viridis") """ - if reduce_C_function is not None: kwds['reduce_C_function'] = reduce_C_function if gridsize is not None: From 5ba105f9600ca09bc9b47148442585a47322b38e Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Tue, 10 Apr 2018 06:55:26 -0400 Subject: [PATCH 71/81] Update v0.23.0.txt --- doc/source/whatsnew/v0.23.0.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index 73986ff25d725..c0cbecc39d16b 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -1029,7 +1029,7 @@ Plotting - Bug in formatting tick labels with ``datetime.time()`` and fractional seconds (:issue:`18478`). - :meth:`Series.plot.kde` has exposed the args ``ind`` and ``bw_method`` in the docstring (:issue:`18461`). The argument ``ind`` may now also be an integer (number of sample points). - :func:`DataFrame.plot` now supports multiple columns to the ``y`` argument (:issue:`19699`) -- Bug in :func:'DataFrame.plot.scatter' and :func:'DataFrame.plot.hexbin' caused x-axis label and ticklabels to disappear when colorbar was on (:issue:`10611` and :issue:`10678`) +- Bug in :func:'DataFrame.plot.scatter' and :func:'DataFrame.plot.hexbin' caused x-axis label and ticklabels to disappear when colorbar was on in IPython inline backend (:issue:`10611` and :issue:`10678`) Groupby/Resample/Rolling ^^^^^^^^^^^^^^^^^^^^^^^^ From ad1b4950dca8cc8bcd308e7941f2a552aa605709 Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Tue, 10 Apr 2018 06:58:26 -0400 Subject: [PATCH 72/81] Update _core.py --- pandas/plotting/_core.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index ed7cd5da1ccae..9d77fe87f036d 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -788,9 +788,10 @@ def _get_axes_layout(self): for ax in axes: # check axes coordinates to estimate layout points = ax.get_position().get_points() - # in IPython inline backend, get_points returns - # floats. Unless rounded they won't match - # in the set + # in IPython inline backend, floats returned by + # `get_points()` have too many ad hoc trailing + # digits. Unless rounded these values won't + # match in the following set operations points = np.round(points, 3) x_set.add(points[0][0]) y_set.add(points[0][1]) From bdc7f5a512d831c2fc56436c70d62e92d38543e9 Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Thu, 12 Apr 2018 23:23:10 -0400 Subject: [PATCH 73/81] Inline backend - fixing colorbar axis position roundoff error locally * minor change in comment * added condition on inline backend * added condition on inline backend * issue with floats determining the position of axes corners in ipython * fixing axis position roundoff error locally --- pandas/plotting/_core.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 9d77fe87f036d..95c4a8e7de51b 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -879,8 +879,13 @@ def _make_plot(self): kws = dict(ax=ax) if self.mpl_ge_1_3_1(): kws['label'] = c if c_is_column else '' - self.fig.colorbar(img, **kws) - + cbar = self.fig.colorbar(img, **kws) + points = ax.get_position().get_points() + cbar_points = cbar.ax.get_position().get_points() + cbar.ax.set_position([cbar_points[0, 0], + points[0, 1], + cbar_points[1, 0] - cbar_points[0, 0], + points[1, 1] - points[0, 1]]) if label is not None: self._add_legend_handle(scatter, label) else: @@ -921,7 +926,13 @@ def _make_plot(self): **self.kwds) if cb: img = ax.collections[0] - self.fig.colorbar(img, ax=ax) + cbar = self.fig.colorbar(img, ax=ax) + points = ax.get_position().get_points() + cbar_points = cbar.ax.get_position().get_points() + cbar.ax.set_position([cbar_points[0, 0], + points[0, 1], + cbar_points[1, 0] - cbar_points[0, 0], + points[1, 1] - points[0, 1]]) def _make_legend(self): pass From e656f6e384ce2c14d95291c6142e95bd72ed5b8d Mon Sep 17 00:00:00 2001 From: Javad Noorbakhsh Date: Thu, 12 Apr 2018 23:41:01 -0400 Subject: [PATCH 74/81] broken merge * minor change in comment * added condition on inline backend * added condition on inline backend * issue with floats determining the position of axes corners in ipython * fixing axis position roundoff error locally * broken merge --- pandas/plotting/_core.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 95c4a8e7de51b..907ba6d5266b5 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -788,11 +788,6 @@ def _get_axes_layout(self): for ax in axes: # check axes coordinates to estimate layout points = ax.get_position().get_points() - # in IPython inline backend, floats returned by - # `get_points()` have too many ad hoc trailing - # digits. Unless rounded these values won't - # match in the following set operations - points = np.round(points, 3) x_set.add(points[0][0]) y_set.add(points[0][1]) return (len(y_set), len(x_set)) From a802ecffdd50d314edcb53d99939bdb0209fbd06 Mon Sep 17 00:00:00 2001 From: Javad Date: Sat, 30 Jun 2018 15:28:10 -0400 Subject: [PATCH 75/81] refactoring --- pandas/plotting/_core.py | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 0f7fcad5f36ab..605ed0bc101b5 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -833,7 +833,23 @@ def _post_plot_logic(self, ax, data): ax.set_ylabel(pprint_thing(y)) ax.set_xlabel(pprint_thing(x)) - + def _plot_colorbar(ax, kws): + # In IPython inline backend the colorbar axis height + # tends to not exactly match the parent axis height. + # The difference is due to small fractional differences + # in floating points with similar representation. + # To deal with this, this method forces the colorbar + # height to take the height of the parent axes. + img = ax.collections[0] + cbar = self.fig.colorbar(img, **kws) + points = ax.get_position().get_points() + cbar_points = cbar.ax.get_position().get_points() + cbar.ax.set_position([cbar_points[0, 0], + points[0, 1], + cbar_points[1, 0] - cbar_points[0, 0], + points[1, 1] - points[0, 1]]) + print('testing colorbar...') + class ScatterPlot(PlanePlot): _kind = 'scatter' @@ -877,18 +893,13 @@ def _make_plot(self): label = None scatter = ax.scatter(data[x].values, data[y].values, c=c_values, label=label, cmap=cmap, **self.kwds) + print('hello') if cb: - img = ax.collections[0] kws = dict(ax=ax) if self.mpl_ge_1_3_1(): kws['label'] = c if c_is_column else '' - cbar = self.fig.colorbar(img, **kws) - points = ax.get_position().get_points() - cbar_points = cbar.ax.get_position().get_points() - cbar.ax.set_position([cbar_points[0, 0], - points[0, 1], - cbar_points[1, 0] - cbar_points[0, 0], - points[1, 1] - points[0, 1]]) + self._plot_colorbar(ax, kws) + if label is not None: self._add_legend_handle(scatter, label) else: @@ -928,15 +939,8 @@ def _make_plot(self): ax.hexbin(data[x].values, data[y].values, C=c_values, cmap=cmap, **self.kwds) if cb: - img = ax.collections[0] - cbar = self.fig.colorbar(img, ax=ax) - points = ax.get_position().get_points() - cbar_points = cbar.ax.get_position().get_points() - cbar.ax.set_position([cbar_points[0, 0], - points[0, 1], - cbar_points[1, 0] - cbar_points[0, 0], - points[1, 1] - points[0, 1]]) - + self._plot_colorbar(ax, kws) + def _make_legend(self): pass From 8d766ae8ea45f805bf774fa55cc357c20ac2b8b9 Mon Sep 17 00:00:00 2001 From: Javad Date: Sat, 30 Jun 2018 16:24:45 -0400 Subject: [PATCH 76/81] refactoring --- pandas/plotting/_core.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 605ed0bc101b5..3af681aa6b51e 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -833,13 +833,14 @@ def _post_plot_logic(self, ax, data): ax.set_ylabel(pprint_thing(y)) ax.set_xlabel(pprint_thing(x)) - def _plot_colorbar(ax, kws): + def _plot_colorbar(self, kws): # In IPython inline backend the colorbar axis height # tends to not exactly match the parent axis height. # The difference is due to small fractional differences # in floating points with similar representation. # To deal with this, this method forces the colorbar # height to take the height of the parent axes. + ax = kws['ax'] img = ax.collections[0] cbar = self.fig.colorbar(img, **kws) points = ax.get_position().get_points() @@ -848,7 +849,6 @@ def _plot_colorbar(ax, kws): points[0, 1], cbar_points[1, 0] - cbar_points[0, 0], points[1, 1] - points[0, 1]]) - print('testing colorbar...') class ScatterPlot(PlanePlot): _kind = 'scatter' @@ -893,12 +893,11 @@ def _make_plot(self): label = None scatter = ax.scatter(data[x].values, data[y].values, c=c_values, label=label, cmap=cmap, **self.kwds) - print('hello') if cb: kws = dict(ax=ax) if self.mpl_ge_1_3_1(): kws['label'] = c if c_is_column else '' - self._plot_colorbar(ax, kws) + self._plot_colorbar(kws) if label is not None: self._add_legend_handle(scatter, label) @@ -939,7 +938,8 @@ def _make_plot(self): ax.hexbin(data[x].values, data[y].values, C=c_values, cmap=cmap, **self.kwds) if cb: - self._plot_colorbar(ax, kws) + kws = dict(ax=ax) + self._plot_colorbar(kws) def _make_legend(self): pass From 4f380b2faf98a2555af3bcd64b5af0f31aa7eab7 Mon Sep 17 00:00:00 2001 From: Javad Date: Sat, 30 Jun 2018 16:28:06 -0400 Subject: [PATCH 77/81] updating whatsnew docs --- doc/source/whatsnew/v0.23.0.txt | 1 - doc/source/whatsnew/v0.24.0.txt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index e463d184ff703..ddf701a0119ba 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -1343,7 +1343,6 @@ Plotting - Bug in formatting tick labels with ``datetime.time()`` and fractional seconds (:issue:`18478`). - :meth:`Series.plot.kde` has exposed the args ``ind`` and ``bw_method`` in the docstring (:issue:`18461`). The argument ``ind`` may now also be an integer (number of sample points). - :func:`DataFrame.plot` now supports multiple columns to the ``y`` argument (:issue:`19699`) -- Bug in :func:'DataFrame.plot.scatter' and :func:'DataFrame.plot.hexbin' caused x-axis label and ticklabels to disappear when colorbar was on in IPython inline backend (:issue:`10611` and :issue:`10678`) Groupby/Resample/Rolling ^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/doc/source/whatsnew/v0.24.0.txt b/doc/source/whatsnew/v0.24.0.txt index 0ca5b9cdf1d57..33b2cb91085fe 100644 --- a/doc/source/whatsnew/v0.24.0.txt +++ b/doc/source/whatsnew/v0.24.0.txt @@ -304,7 +304,7 @@ I/O Plotting ^^^^^^^^ -- +-- Bug in :func:'DataFrame.plot.scatter' and :func:'DataFrame.plot.hexbin' caused x-axis label and ticklabels to disappear when colorbar was on in IPython inline backend (:issue:`10611` and :issue:`10678`) - - From 04ff7682c8ff75f7adf6321bfbce80e59da688c2 Mon Sep 17 00:00:00 2001 From: Javad Date: Sat, 30 Jun 2018 16:33:19 -0400 Subject: [PATCH 78/81] updating whatsnew docs --- doc/source/whatsnew/v0.23.0.txt | 1 + doc/source/whatsnew/v0.24.0.txt | 2 +- pandas/plotting/_core.py | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index ddf701a0119ba..63cd9e3fbd086 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -1344,6 +1344,7 @@ Plotting - :meth:`Series.plot.kde` has exposed the args ``ind`` and ``bw_method`` in the docstring (:issue:`18461`). The argument ``ind`` may now also be an integer (number of sample points). - :func:`DataFrame.plot` now supports multiple columns to the ``y`` argument (:issue:`19699`) +- Groupby/Resample/Rolling ^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/doc/source/whatsnew/v0.24.0.txt b/doc/source/whatsnew/v0.24.0.txt index 33b2cb91085fe..68f634bd5f85f 100644 --- a/doc/source/whatsnew/v0.24.0.txt +++ b/doc/source/whatsnew/v0.24.0.txt @@ -304,7 +304,7 @@ I/O Plotting ^^^^^^^^ --- Bug in :func:'DataFrame.plot.scatter' and :func:'DataFrame.plot.hexbin' caused x-axis label and ticklabels to disappear when colorbar was on in IPython inline backend (:issue:`10611` and :issue:`10678`) +- Bug in :func:'DataFrame.plot.scatter' and :func:'DataFrame.plot.hexbin' caused x-axis label and ticklabels to disappear when colorbar was on in IPython inline backend (:issue:`10611` and :issue:`10678`) - - diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 3af681aa6b51e..75a921999bac3 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -898,7 +898,7 @@ def _make_plot(self): if self.mpl_ge_1_3_1(): kws['label'] = c if c_is_column else '' self._plot_colorbar(kws) - + if label is not None: self._add_legend_handle(scatter, label) else: @@ -940,7 +940,7 @@ def _make_plot(self): if cb: kws = dict(ax=ax) self._plot_colorbar(kws) - + def _make_legend(self): pass From 8c0f2e5bf590d323f1b56eee86d41b14d4f3143e Mon Sep 17 00:00:00 2001 From: Javad Date: Sat, 30 Jun 2018 16:36:05 -0400 Subject: [PATCH 79/81] updating whatsnew docs --- doc/source/whatsnew/v0.23.0.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index 63cd9e3fbd086..2430b6ac2bbd4 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -1344,7 +1344,7 @@ Plotting - :meth:`Series.plot.kde` has exposed the args ``ind`` and ``bw_method`` in the docstring (:issue:`18461`). The argument ``ind`` may now also be an integer (number of sample points). - :func:`DataFrame.plot` now supports multiple columns to the ``y`` argument (:issue:`19699`) -- + Groupby/Resample/Rolling ^^^^^^^^^^^^^^^^^^^^^^^^ From 564790fbe645c069df6772cf9b8e4a53563746e7 Mon Sep 17 00:00:00 2001 From: Javad Date: Sat, 30 Jun 2018 16:42:49 -0400 Subject: [PATCH 80/81] updating whatsnew docs --- pandas/plotting/_core.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 75a921999bac3..ca7e37522e6a8 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -837,8 +837,8 @@ def _plot_colorbar(self, kws): # In IPython inline backend the colorbar axis height # tends to not exactly match the parent axis height. # The difference is due to small fractional differences - # in floating points with similar representation. - # To deal with this, this method forces the colorbar + # in floating points with similar representation. + # To deal with this, this method forces the colorbar # height to take the height of the parent axes. ax = kws['ax'] img = ax.collections[0] @@ -849,7 +849,8 @@ def _plot_colorbar(self, kws): points[0, 1], cbar_points[1, 0] - cbar_points[0, 0], points[1, 1] - points[0, 1]]) - + + class ScatterPlot(PlanePlot): _kind = 'scatter' From 7196d6ed324b4c50d152721e9baa469bcbc00724 Mon Sep 17 00:00:00 2001 From: Javad Date: Tue, 3 Jul 2018 23:04:15 -0400 Subject: [PATCH 81/81] added more comments --- pandas/plotting/_core.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index ca7e37522e6a8..8c2ee90014302 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -833,22 +833,31 @@ def _post_plot_logic(self, ax, data): ax.set_ylabel(pprint_thing(y)) ax.set_xlabel(pprint_thing(x)) - def _plot_colorbar(self, kws): - # In IPython inline backend the colorbar axis height - # tends to not exactly match the parent axis height. + def _plot_colorbar(self, ax, **kwds): + # Addresses issues #10611 and #10678: + # When plotting scatterplots and hexbinplots in IPython + # inline backend the colorbar axis height tends not to + # exactly match the parent axis height. # The difference is due to small fractional differences # in floating points with similar representation. # To deal with this, this method forces the colorbar # height to take the height of the parent axes. - ax = kws['ax'] + # For a more detailed description of the issue + # see the following link: + # https://github.com/ipython/ipython/issues/11215 + img = ax.collections[0] - cbar = self.fig.colorbar(img, **kws) + cbar = self.fig.colorbar(img, **kwds) points = ax.get_position().get_points() cbar_points = cbar.ax.get_position().get_points() cbar.ax.set_position([cbar_points[0, 0], points[0, 1], cbar_points[1, 0] - cbar_points[0, 0], points[1, 1] - points[0, 1]]) + # To see the discrepancy in axis heights uncomment + # the following two lines: + # print(points[1, 1] - points[0, 1]) + # print(cbar_points[1, 1] - cbar_points[0, 1]) class ScatterPlot(PlanePlot): @@ -895,10 +904,9 @@ def _make_plot(self): scatter = ax.scatter(data[x].values, data[y].values, c=c_values, label=label, cmap=cmap, **self.kwds) if cb: - kws = dict(ax=ax) if self.mpl_ge_1_3_1(): - kws['label'] = c if c_is_column else '' - self._plot_colorbar(kws) + cbar_label = c if c_is_column else '' + self._plot_colorbar(ax, label=cbar_label) if label is not None: self._add_legend_handle(scatter, label) @@ -939,8 +947,7 @@ def _make_plot(self): ax.hexbin(data[x].values, data[y].values, C=c_values, cmap=cmap, **self.kwds) if cb: - kws = dict(ax=ax) - self._plot_colorbar(kws) + self._plot_colorbar(ax) def _make_legend(self): pass