Skip to content

Commit 6590fc0

Browse files
committed
API/CLN: add in common operations to Series/Index, refactored as a OpsMixin
(GH4551, GH4056, GH5519)
1 parent c6ad1cd commit 6590fc0

File tree

9 files changed

+279
-42
lines changed

9 files changed

+279
-42
lines changed

doc/source/api.rst

+19-2
Original file line numberDiff line numberDiff line change
@@ -424,10 +424,25 @@ Time series-related
424424
Series.shift
425425
Series.first_valid_index
426426
Series.last_valid_index
427-
Series.weekday
428427
Series.resample
429428
Series.tz_convert
430429
Series.tz_localize
430+
Series.year
431+
Series.month
432+
Series.day
433+
Series.hour
434+
Series.minute
435+
Series.second
436+
Series.microsecond
437+
Series.nanosecond
438+
Series.date
439+
Series.time
440+
Series.dayofyear
441+
Series.weekofyear
442+
Series.week
443+
Series.dayofweek
444+
Series.weekday
445+
Series.quarter
431446

432447
String handling
433448
~~~~~~~~~~~~~~~~~~~
@@ -1129,7 +1144,9 @@ Time/Date Components
11291144
DatetimeIndex.dayofweek
11301145
DatetimeIndex.weekday
11311146
DatetimeIndex.quarter
1132-
1147+
DatetimeIndex.tz
1148+
DatetimeIndex.freq
1149+
DatetimeIndex.freqstr
11331150

11341151
Selecting
11351152
~~~~~~~~~

doc/source/release.rst

+8
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ API Changes
6969
- ``dtypes`` and ``ftypes`` now return a series with ``dtype=object`` on empty containers (:issue:`5740`)
7070
- The ``interpolate`` ``downcast`` keyword default has been changed from ``infer`` to
7171
``None``. This is to preseve the original dtype unless explicitly requested otherwise (:issue:`6290`).
72+
- allow a Series to utilize index methods for its index type, e.g. ``Series.year`` is now defined
73+
for a Series with a ``DatetimeIndex`` or a ``PeriodIndex``; trying this on a non-supported Index type will
74+
now raise a ``TypeError``. (:issue:`4551`, :issue:`4056`, :issue:`5519`)
75+
76+
The following affected:
77+
- ``date,time,year,month,day,hour,minute,second,weekofyear``
78+
- ``week,dayofweek,dayofyear,quarter,microsecond,nanosecond,qyear``
79+
- ``min(),max()``
7280

7381
Experimental Features
7482
~~~~~~~~~~~~~~~~~~~~~

doc/source/v0.14.0.txt

+15
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,21 @@ API changes
3131

3232
- The ``DataFrame.interpolate()`` ``downcast`` keyword default has been changed from ``infer`` to
3333
``None``. This is to preseve the original dtype unless explicitly requested otherwise (:issue:`6290`).
34+
- allow a Series to utilize index methods for its index type, e.g. ``Series.year`` is now defined
35+
for a Series with a ``DatetimeIndex`` or a ``PeriodIndex``; trying this on a non-supported Index type will
36+
now raise a ``TypeError``. (:issue:`4551`, :issue:`4056`, :issue:`5519`)
37+
38+
The following affected:
39+
- ``date,time,year,month,day,hour,minute,second,weekofyear``
40+
- ``week,dayofweek,dayofyear,quarter,microsecond,nanosecond,qyear``
41+
- ``min(),max()``
42+
43+
.. ipython:: python
44+
45+
s = Series(np.random.randn(5),index=tm.makeDateIndex(5))
46+
s
47+
s.year
48+
s.index.year
3449

3550
MultiIndexing Using Slicers
3651
~~~~~~~~~~~~~~~~~~~~~~~~~~~

pandas/core/base.py

+87-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import numpy as np
66
from pandas.core import common as com
77

8-
98
class StringMixin(object):
109

1110
"""implements string methods so long as object defines a `__unicode__`
@@ -200,3 +199,90 @@ def __unicode__(self):
200199
prepr = com.pprint_thing(self, escape_chars=('\t', '\r', '\n'),
201200
quote_strings=True)
202201
return "%s(%s, dtype='%s')" % (type(self).__name__, prepr, self.dtype)
202+
203+
204+
# facilitate the properties on the wrapped ops
205+
def _field_accessor(name, docstring=None):
206+
op_accessor = '_{0}'.format(name)
207+
def f(self):
208+
return self._ops_compat(name,op_accessor)
209+
210+
f.__name__ = name
211+
f.__doc__ = docstring
212+
return property(f)
213+
214+
class IndexOpsMixin(object):
215+
""" common ops mixin to support a unified inteface / docs for Series / Index """
216+
217+
def _is_allowed_index_op(self, name):
218+
if not self._allow_index_ops:
219+
raise TypeError("cannot perform an {name} operations on this type {typ}".format(
220+
name=name,typ=type(self._get_access_object())))
221+
222+
def _is_allowed_datetime_index_op(self, name):
223+
if not self._allow_datetime_index_ops:
224+
raise TypeError("cannot perform an {name} operations on this type {typ}".format(
225+
name=name,typ=type(self._get_access_object())))
226+
227+
def _is_allowed_period_index_op(self, name):
228+
if not self._allow_period_index_ops:
229+
raise TypeError("cannot perform an {name} operations on this type {typ}".format(
230+
name=name,typ=type(self._get_access_object())))
231+
232+
def _ops_compat(self, name, op_accessor):
233+
from pandas.tseries.index import DatetimeIndex
234+
from pandas.tseries.period import PeriodIndex
235+
obj = self._get_access_object()
236+
if isinstance(obj, DatetimeIndex):
237+
self._is_allowed_datetime_index_op(name)
238+
elif isinstance(obj, PeriodIndex):
239+
self._is_allowed_period_index_op(name)
240+
try:
241+
return self._wrap_access_object(getattr(obj,op_accessor))
242+
except AttributeError:
243+
raise TypeError("cannot perform an {name} operations on this type {typ}".format(
244+
name=name,typ=type(obj)))
245+
246+
def _get_access_object(self):
247+
if isinstance(self, com.ABCSeries):
248+
return self.index
249+
return self
250+
251+
def _wrap_access_object(self, obj):
252+
# we may need to coerce the input as we don't want non int64 if
253+
# we have an integer result
254+
if hasattr(obj,'dtype') and com.is_integer_dtype(obj):
255+
obj = obj.astype(np.int64)
256+
257+
if isinstance(self, com.ABCSeries):
258+
return self._constructor(obj,index=self.index).__finalize__(self)
259+
260+
return obj
261+
262+
def max(self):
263+
""" The maximum value of the object """
264+
self._is_allowed_index_op('max')
265+
return self.values.max()
266+
267+
def min(self):
268+
""" The minimum value of the object """
269+
self._is_allowed_index_op('min')
270+
return self.values.min()
271+
272+
date = _field_accessor('date','Returns numpy array of datetime.date. The date part of the Timestamps')
273+
time = _field_accessor('time','Returns numpy array of datetime.time. The time part of the Timestamps')
274+
year = _field_accessor('year', "The year of the datetime")
275+
month = _field_accessor('month', "The month as January=1, December=12")
276+
day = _field_accessor('day', "The days of the datetime")
277+
hour = _field_accessor('hour', "The hours of the datetime")
278+
minute = _field_accessor('minute', "The minutes of the datetime")
279+
second = _field_accessor('second', "The seconds of the datetime")
280+
microsecond = _field_accessor('microsecond', "The microseconds of the datetime")
281+
nanosecond = _field_accessor('nanosecond', "The nanoseconds of the datetime")
282+
weekofyear = _field_accessor('weekofyear', "The week ordinal of the year")
283+
week = weekofyear
284+
dayofweek = _field_accessor('dayofweek', "The day of the week with Monday=0, Sunday=6")
285+
weekday = dayofweek
286+
dayofyear = _field_accessor('dayofyear', "The ordinal day of the year")
287+
quarter = _field_accessor('quarter', "The quarter of the date")
288+
qyear = _field_accessor('qyear')

pandas/core/index.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import pandas.algos as _algos
1111
import pandas.index as _index
1212
from pandas.lib import Timestamp, is_datetime_array
13-
from pandas.core.base import FrozenList, FrozenNDArray
13+
from pandas.core.base import FrozenList, FrozenNDArray, IndexOpsMixin
1414

1515
from pandas.util.decorators import cache_readonly, deprecate
1616
from pandas.core.common import isnull
@@ -57,7 +57,7 @@ def _shouldbe_timestamp(obj):
5757
_Identity = object
5858

5959

60-
class Index(FrozenNDArray):
60+
class Index(IndexOpsMixin, FrozenNDArray):
6161

6262
"""
6363
Immutable ndarray implementing an ordered, sliceable set. The basic object
@@ -92,6 +92,9 @@ class Index(FrozenNDArray):
9292
name = None
9393
asi8 = None
9494
_comparables = ['name']
95+
_allow_index_ops = True
96+
_allow_datetime_index_ops = False
97+
_allow_period_index_ops = False
9598

9699
_engine_type = _index.ObjectEngine
97100

pandas/core/series.py

+11-7
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from pandas.core.indexing import (
3131
_check_bool_indexer, _check_slice_bounds,
3232
_is_index_slice, _maybe_convert_indices)
33-
from pandas.core import generic
33+
from pandas.core import generic, base
3434
from pandas.core.internals import SingleBlockManager
3535
from pandas.core.categorical import Categorical
3636
from pandas.tseries.index import DatetimeIndex
@@ -91,7 +91,7 @@ def f(self, *args, **kwargs):
9191
# Series class
9292

9393

94-
class Series(generic.NDFrame):
94+
class Series(base.IndexOpsMixin, generic.NDFrame):
9595

9696
"""
9797
One-dimensional ndarray with axis labels (including time series).
@@ -122,6 +122,15 @@ class Series(generic.NDFrame):
122122
Copy input data
123123
"""
124124
_metadata = ['name']
125+
_allow_index_ops = True
126+
127+
@property
128+
def _allow_datetime_index_ops(self):
129+
return self.index.is_all_dates and isinstance(self.index, DatetimeIndex)
130+
131+
@property
132+
def _allow_period_index_ops(self):
133+
return self.index.is_all_dates and isinstance(self.index, PeriodIndex)
125134

126135
def __init__(self, data=None, index=None, dtype=None, name=None,
127136
copy=False, fastpath=False):
@@ -2297,11 +2306,6 @@ def asof(self, where):
22972306
new_values = com.take_1d(values, locs)
22982307
return self._constructor(new_values, index=where).__finalize__(self)
22992308

2300-
@property
2301-
def weekday(self):
2302-
return self._constructor([d.weekday() for d in self.index],
2303-
index=self.index).__finalize__(self)
2304-
23052309
@cache_readonly
23062310
def str(self):
23072311
from pandas.core.strings import StringMethods

pandas/tests/test_base.py

+100
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import re
22
import numpy as np
33
import pandas.compat as compat
4+
import pandas as pd
45
from pandas.compat import u
56
from pandas.core.base import FrozenList, FrozenNDArray
67
from pandas.util.testing import assertRaisesRegexp, assert_isinstance
8+
from pandas import Series, Index, DatetimeIndex, PeriodIndex
9+
from pandas import _np_version_under1p7
10+
import nose
11+
712
import pandas.util.testing as tm
813

914
class CheckStringMixin(object):
@@ -120,6 +125,101 @@ def test_values(self):
120125
self.assert_numpy_array_equal(self.container, original)
121126
self.assertEqual(vals[0], n)
122127

128+
class Ops(tm.TestCase):
129+
def setUp(self):
130+
self.int_index = tm.makeIntIndex(10)
131+
self.float_index = tm.makeFloatIndex(10)
132+
self.dt_index = tm.makeDateIndex(10)
133+
self.period_index = tm.makePeriodIndex(10)
134+
self.string_index = tm.makeStringIndex(10)
135+
136+
arr = np.random.randn(10)
137+
self.int_series = Series(arr, index=self.int_index)
138+
self.float_series = Series(arr, index=self.int_index)
139+
self.dt_series = Series(arr, index=self.dt_index)
140+
self.period_series = Series(arr, index=self.period_index)
141+
self.string_series = Series(arr, index=self.string_index)
142+
143+
self.objs = [ getattr(self,"{0}_{1}".format(t,f)) for t in ['int','float','dt','period','string'] for f in ['index','series'] ]
144+
145+
def check_ops_properties(self, props, filter=None, ignore_failures=False):
146+
for op in props:
147+
for o in self.is_valid_objs:
148+
149+
# if a filter, skip if it doesn't match
150+
if filter is not None:
151+
filt = o.index if isinstance(o, Series) else o
152+
if not filter(filt):
153+
continue
154+
155+
try:
156+
if isinstance(o, Series):
157+
expected = Series(getattr(o.index,op),index=o.index)
158+
else:
159+
expected = getattr(o,op)
160+
except (AttributeError):
161+
if ignore_failures:
162+
continue
163+
164+
result = getattr(o,op)
165+
166+
# these couuld be series, arrays or scalars
167+
if isinstance(result,Series) and isinstance(expected,Series):
168+
tm.assert_series_equal(result,expected)
169+
elif isinstance(result,Index) and isinstance(expected,Index):
170+
tm.assert_index_equal(result,expected)
171+
elif isinstance(result,np.ndarray) and isinstance(expected,np.ndarray):
172+
self.assert_numpy_array_equal(result,expected)
173+
else:
174+
self.assertEqual(result, expected)
175+
176+
# freq raises AttributeError on an Int64Index because its not defined
177+
# we mostly care about Series hwere anyhow
178+
if not ignore_failures:
179+
for o in self.not_valid_objs:
180+
self.assertRaises(TypeError, lambda : getattr(o,op))
181+
182+
class TestIndexOps(Ops):
183+
184+
def setUp(self):
185+
super(TestIndexOps, self).setUp()
186+
self.is_valid_objs = [ o for o in self.objs if o._allow_index_ops ]
187+
self.not_valid_objs = [ o for o in self.objs if not o._allow_index_ops ]
188+
189+
def test_ops(self):
190+
if _np_version_under1p7:
191+
raise nose.SkipTest("test only valid in numpy >= 1.7")
192+
for op in ['max','min']:
193+
for o in self.objs:
194+
result = getattr(o,op)()
195+
expected = getattr(o.values,op)()
196+
self.assertEqual(result, expected)
197+
198+
class TestDatetimeIndexOps(Ops):
199+
_allowed = '_allow_datetime_index_ops'
200+
201+
def setUp(self):
202+
super(TestDatetimeIndexOps, self).setUp()
203+
mask = lambda x: x._allow_datetime_index_ops or x._allow_period_index_ops
204+
self.is_valid_objs = [ o for o in self.objs if mask(o) ]
205+
self.not_valid_objs = [ o for o in self.objs if not mask(o) ]
206+
207+
def test_ops_properties(self):
208+
self.check_ops_properties(['year','month','day','hour','minute','second','weekofyear','week','dayofweek','dayofyear','quarter'])
209+
self.check_ops_properties(['date','time','microsecond','nanosecond'], lambda x: isinstance(x,DatetimeIndex))
210+
211+
class TestPeriodIndexOps(Ops):
212+
_allowed = '_allow_period_index_ops'
213+
214+
def setUp(self):
215+
super(TestPeriodIndexOps, self).setUp()
216+
mask = lambda x: x._allow_datetime_index_ops or x._allow_period_index_ops
217+
self.is_valid_objs = [ o for o in self.objs if mask(o) ]
218+
self.not_valid_objs = [ o for o in self.objs if not mask(o) ]
219+
220+
def test_ops_properties(self):
221+
self.check_ops_properties(['year','month','day','hour','minute','second','weekofyear','week','dayofweek','dayofyear','quarter'])
222+
self.check_ops_properties(['qyear'], lambda x: isinstance(x,PeriodIndex))
123223

124224
if __name__ == '__main__':
125225
import nose

0 commit comments

Comments
 (0)