Skip to content

Commit

Permalink
ENH: add is_leap_year property for datetime-like
Browse files Browse the repository at this point in the history
closes #13727

Author: sinhrks <sinhrks@gmail.com>

Closes #13739 from sinhrks/is_leapyear and squashes the following commits:

5d227ee [sinhrks] ENH: add is_leapyear property for datetime-like
  • Loading branch information
sinhrks authored and jreback committed Jul 25, 2016
1 parent 474fd05 commit 5f524d6
Show file tree
Hide file tree
Showing 12 changed files with 149 additions and 47 deletions.
2 changes: 2 additions & 0 deletions doc/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ These can be accessed like ``Series.dt.<property>``.
Series.dt.is_quarter_end
Series.dt.is_year_start
Series.dt.is_year_end
Series.dt.is_leap_year
Series.dt.daysinmonth
Series.dt.days_in_month
Series.dt.tz
Expand Down Expand Up @@ -1497,6 +1498,7 @@ Time/Date Components
DatetimeIndex.is_quarter_end
DatetimeIndex.is_year_start
DatetimeIndex.is_year_end
DatetimeIndex.is_leap_year
DatetimeIndex.inferred_freq

Selecting
Expand Down
1 change: 1 addition & 0 deletions doc/source/timeseries.rst
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ There are several time/date properties that one can access from ``Timestamp`` or
is_quarter_end,"Logical indicating if last day of quarter (defined by frequency)"
is_year_start,"Logical indicating if first day of year (defined by frequency)"
is_year_end,"Logical indicating if last day of year (defined by frequency)"
is_leap_year,"Logical indicating if the date belongs to a leap year"

Furthermore, if you have a ``Series`` with datetimelike values, then you can access these properties via the ``.dt`` accessor, see the :ref:`docs <basics.dt_accessors>`

Expand Down
6 changes: 5 additions & 1 deletion doc/source/whatsnew/v0.19.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,8 @@ API changes
- ``astype()`` will now accept a dict of column name to data types mapping as the ``dtype`` argument. (:issue:`12086`)
- The ``pd.read_json`` and ``DataFrame.to_json`` has gained support for reading and writing json lines with ``lines`` option see :ref:`Line delimited json <io.jsonl>` (:issue:`9180`)
- ``pd.Timedelta(None)`` is now accepted and will return ``NaT``, mirroring ``pd.Timestamp`` (:issue:`13687`)
- ``Timestamp``, ``Period``, ``DatetimeIndex``, ``PeriodIndex`` and ``.dt`` accessor have ``.is_leap_year`` property to check whether the date belongs to a leap year. (:issue:`13727`)


.. _whatsnew_0190.api.tolist:

Expand Down Expand Up @@ -609,7 +611,9 @@ Deprecations
- ``as_recarray`` has been deprecated in ``pd.read_csv()`` and will be removed in a future version (:issue:`13373`)
- top-level ``pd.ordered_merge()`` has been renamed to ``pd.merge_ordered()`` and the original name will be removed in a future version (:issue:`13358`)
- ``Timestamp.offset`` property (and named arg in the constructor), has been deprecated in favor of ``freq`` (:issue:`12160`)
- ``pivot_annual`` is deprecated. Use ``pivot_table`` as alternative, an example is :ref:`here <cookbook.pivot>` (:issue:`736`)
- ``pd.tseries.util.pivot_annual`` is deprecated. Use ``pivot_table`` as alternative, an example is :ref:`here <cookbook.pivot>` (:issue:`736`)
- ``pd.tseries.util.isleapyear`` has been deprecated and will be removed in a subsequent release. Datetime-likes now have a ``.is_leap_year`` property. (:issue:`13727`)


.. _whatsnew_0190.prior_deprecations:

Expand Down
3 changes: 3 additions & 0 deletions pandas/src/period.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,9 @@ cdef class _Period(object):
property daysinmonth:
def __get__(self):
return self.days_in_month
property is_leap_year:
def __get__(self):
return bool(is_leapyear(self._field(0)))

@classmethod
def now(cls, freq=None):
Expand Down
2 changes: 1 addition & 1 deletion pandas/tests/series/test_datetime_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def test_dt_namespace_accessor(self):
ok_for_base = ['year', 'month', 'day', 'hour', 'minute', 'second',
'weekofyear', 'week', 'dayofweek', 'weekday',
'dayofyear', 'quarter', 'freq', 'days_in_month',
'daysinmonth']
'daysinmonth', 'is_leap_year']
ok_for_period = ok_for_base + ['qyear', 'start_time', 'end_time']
ok_for_period_methods = ['strftime', 'to_timestamp', 'asfreq']
ok_for_dt = ok_for_base + ['date', 'time', 'microsecond', 'nanosecond',
Expand Down
53 changes: 24 additions & 29 deletions pandas/tseries/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,14 @@ def f(self):
self.freq.kwds.get('month', 12))
if self.freq else 12)

result = tslib.get_start_end_field(
values, field, self.freqstr, month_kw)
result = tslib.get_start_end_field(values, field, self.freqstr,
month_kw)
elif field in ['weekday_name']:
result = tslib.get_date_name_field(values, field)
return self._maybe_mask_results(result)
elif field in ['is_leap_year']:
# no need to mask NaT
return tslib.get_date_field(values, field)
else:
result = tslib.get_date_field(values, field)

Expand Down Expand Up @@ -227,7 +230,8 @@ def _join_i8_wrapper(joinf, **kwargs):
'daysinmonth', 'date', 'time', 'microsecond',
'nanosecond', 'is_month_start', 'is_month_end',
'is_quarter_start', 'is_quarter_end', 'is_year_start',
'is_year_end', 'tz', 'freq', 'weekday_name']
'is_year_end', 'tz', 'freq', 'weekday_name',
'is_leap_year']
_is_numeric_dtype = False
_infer_as_myclass = True

Expand Down Expand Up @@ -1521,44 +1525,31 @@ def _set_freq(self, value):
doc="get/set the frequncy of the Index")

year = _field_accessor('year', 'Y', "The year of the datetime")
month = _field_accessor(
'month', 'M', "The month as January=1, December=12")
month = _field_accessor('month', 'M',
"The month as January=1, December=12")
day = _field_accessor('day', 'D', "The days of the datetime")
hour = _field_accessor('hour', 'h', "The hours of the datetime")
minute = _field_accessor('minute', 'm', "The minutes of the datetime")
second = _field_accessor('second', 's', "The seconds of the datetime")
microsecond = _field_accessor(
'microsecond',
'us',
"The microseconds of the datetime")
nanosecond = _field_accessor(
'nanosecond',
'ns',
"The nanoseconds of the datetime")
weekofyear = _field_accessor(
'weekofyear',
'woy',
"The week ordinal of the year")
microsecond = _field_accessor('microsecond', 'us',
"The microseconds of the datetime")
nanosecond = _field_accessor('nanosecond', 'ns',
"The nanoseconds of the datetime")
weekofyear = _field_accessor('weekofyear', 'woy',
"The week ordinal of the year")
week = weekofyear
dayofweek = _field_accessor(
'dayofweek',
'dow',
"The day of the week with Monday=0, Sunday=6")
dayofweek = _field_accessor('dayofweek', 'dow',
"The day of the week with Monday=0, Sunday=6")
weekday = dayofweek

weekday_name = _field_accessor(
'weekday_name',
'weekday_name',
"The name of day in a week (ex: Friday)\n\n.. versionadded:: 0.18.1")

dayofyear = _field_accessor(
'dayofyear',
'doy',
"The ordinal day of the year")
quarter = _field_accessor(
'quarter',
'q',
"The quarter of the date")
dayofyear = _field_accessor('dayofyear', 'doy',
"The ordinal day of the year")
quarter = _field_accessor('quarter', 'q', "The quarter of the date")
days_in_month = _field_accessor(
'days_in_month',
'dim',
Expand Down Expand Up @@ -1588,6 +1579,10 @@ def _set_freq(self, value):
'is_year_end',
'is_year_end',
"Logical indicating if last day of year (defined by frequency)")
is_leap_year = _field_accessor(
'is_leap_year',
'is_leap_year',
"Logical indicating if the date belongs to a leap year")

@property
def time(self):
Expand Down
20 changes: 13 additions & 7 deletions pandas/tseries/period.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ class PeriodIndex(DatelikeOps, DatetimeIndexOpsMixin, Int64Index):
'weekofyear', 'week', 'dayofweek', 'weekday',
'dayofyear', 'quarter', 'qyear', 'freq',
'days_in_month', 'daysinmonth',
'to_timestamp', 'asfreq', 'start_time', 'end_time']
'to_timestamp', 'asfreq', 'start_time', 'end_time',
'is_leap_year']
_is_numeric_dtype = False
_infer_as_myclass = True

Expand Down Expand Up @@ -509,17 +510,22 @@ def to_datetime(self, dayfirst=False):
second = _field_accessor('second', 7, "The second of the period")
weekofyear = _field_accessor('week', 8, "The week ordinal of the year")
week = weekofyear
dayofweek = _field_accessor(
'dayofweek', 10, "The day of the week with Monday=0, Sunday=6")
dayofweek = _field_accessor('dayofweek', 10,
"The day of the week with Monday=0, Sunday=6")
weekday = dayofweek
dayofyear = day_of_year = _field_accessor(
'dayofyear', 9, "The ordinal day of the year")
dayofyear = day_of_year = _field_accessor('dayofyear', 9,
"The ordinal day of the year")
quarter = _field_accessor('quarter', 2, "The quarter of the date")
qyear = _field_accessor('qyear', 1)
days_in_month = _field_accessor(
'days_in_month', 11, "The number of days in the month")
days_in_month = _field_accessor('days_in_month', 11,
"The number of days in the month")
daysinmonth = days_in_month

@property
def is_leap_year(self):
""" Logical indicating if the date belongs to a leap year """
return tslib._isleapyear_arr(self.year)

@property
def start_time(self):
return self.to_timestamp(how='start')
Expand Down
28 changes: 27 additions & 1 deletion pandas/tseries/tests/test_period.py
Original file line number Diff line number Diff line change
Expand Up @@ -1514,6 +1514,22 @@ def test_asfreq_mult(self):
self.assertEqual(result.ordinal, expected.ordinal)
self.assertEqual(result.freq, expected.freq)

def test_is_leap_year(self):
# GH 13727
for freq in ['A', 'M', 'D', 'H']:
p = Period('2000-01-01 00:00:00', freq=freq)
self.assertTrue(p.is_leap_year)
self.assertIsInstance(p.is_leap_year, bool)

p = Period('1999-01-01 00:00:00', freq=freq)
self.assertFalse(p.is_leap_year)

p = Period('2004-01-01 00:00:00', freq=freq)
self.assertTrue(p.is_leap_year)

p = Period('2100-01-01 00:00:00', freq=freq)
self.assertFalse(p.is_leap_year)


class TestPeriodIndex(tm.TestCase):
def setUp(self):
Expand Down Expand Up @@ -3130,16 +3146,25 @@ def test_fields(self):
def _check_all_fields(self, periodindex):
fields = ['year', 'month', 'day', 'hour', 'minute', 'second',
'weekofyear', 'week', 'dayofweek', 'weekday', 'dayofyear',
'quarter', 'qyear', 'days_in_month']
'quarter', 'qyear', 'days_in_month', 'is_leap_year']

periods = list(periodindex)
s = pd.Series(periodindex)

for field in fields:
field_idx = getattr(periodindex, field)
self.assertEqual(len(periodindex), len(field_idx))
for x, val in zip(periods, field_idx):
self.assertEqual(getattr(x, field), val)

if len(s) == 0:
continue

field_s = getattr(s.dt, field)
self.assertEqual(len(periodindex), len(field_s))
for x, val in zip(periods, field_s):
self.assertEqual(getattr(x, field), val)

def test_is_full(self):
index = PeriodIndex([2005, 2007, 2009], freq='A')
self.assertFalse(index.is_full)
Expand Down Expand Up @@ -4569,6 +4594,7 @@ def test_get_period_field_array_raises_on_out_of_range(self):
self.assertRaises(ValueError, _period.get_period_field_arr, -1,
np.empty(1), 0)


if __name__ == '__main__':
import nose
nose.runmodule(argv=[__file__, '-vvs', '-x', '--pdb', '--pdb-failure'],
Expand Down
32 changes: 29 additions & 3 deletions pandas/tseries/tests/test_timeseries.py
Original file line number Diff line number Diff line change
Expand Up @@ -969,13 +969,20 @@ def test_nat_vector_field_access(self):

fields = ['year', 'quarter', 'month', 'day', 'hour', 'minute',
'second', 'microsecond', 'nanosecond', 'week', 'dayofyear',
'days_in_month']
'days_in_month', 'is_leap_year']

for field in fields:
result = getattr(idx, field)
expected = [getattr(x, field) if x is not NaT else np.nan
for x in idx]
expected = [getattr(x, field) for x in idx]
self.assert_numpy_array_equal(result, np.array(expected))

s = pd.Series(idx)

for field in fields:
result = getattr(s.dt, field)
expected = [getattr(x, field) for x in idx]
self.assert_series_equal(result, pd.Series(expected))

def test_nat_scalar_field_access(self):
fields = ['year', 'quarter', 'month', 'day', 'hour', 'minute',
'second', 'microsecond', 'nanosecond', 'week', 'dayofyear',
Expand Down Expand Up @@ -4761,6 +4768,25 @@ def test_timestamp_compare_series(self):
result = right_f(Timestamp('nat'), s_nat)
tm.assert_series_equal(result, expected)

def test_is_leap_year(self):
# GH 13727
for tz in [None, 'UTC', 'US/Eastern', 'Asia/Tokyo']:
dt = Timestamp('2000-01-01 00:00:00', tz=tz)
self.assertTrue(dt.is_leap_year)
self.assertIsInstance(dt.is_leap_year, bool)

dt = Timestamp('1999-01-01 00:00:00', tz=tz)
self.assertFalse(dt.is_leap_year)

dt = Timestamp('2004-01-01 00:00:00', tz=tz)
self.assertTrue(dt.is_leap_year)

dt = Timestamp('2100-01-01 00:00:00', tz=tz)
self.assertFalse(dt.is_leap_year)

self.assertFalse(pd.NaT.is_leap_year)
self.assertIsInstance(pd.NaT.is_leap_year, bool)


class TestSlicing(tm.TestCase):
def test_slice_year(self):
Expand Down
18 changes: 16 additions & 2 deletions pandas/tseries/tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ def test_daily(self):
annual = pivot_annual(ts, 'D')

doy = ts.index.dayofyear
doy[(~isleapyear(ts.index.year)) & (doy >= 60)] += 1

with tm.assert_produces_warning(FutureWarning, check_stacklevel=False):
doy[(~isleapyear(ts.index.year)) & (doy >= 60)] += 1

for i in range(1, 367):
subset = ts[doy == i]
Expand All @@ -51,7 +53,9 @@ def test_hourly(self):
grouped = ts_hourly.groupby(ts_hourly.index.year)
hoy = grouped.apply(lambda x: x.reset_index(drop=True))
hoy = hoy.index.droplevel(0).values
hoy[~isleapyear(ts_hourly.index.year) & (hoy >= 1416)] += 24

with tm.assert_produces_warning(FutureWarning, check_stacklevel=False):
hoy[~isleapyear(ts_hourly.index.year) & (hoy >= 1416)] += 24
hoy += 1

with tm.assert_produces_warning(FutureWarning, check_stacklevel=False):
Expand Down Expand Up @@ -100,6 +104,16 @@ def test_period_daily(self):
def test_period_weekly(self):
pass

def test_isleapyear_deprecate(self):
with tm.assert_produces_warning(FutureWarning, check_stacklevel=False):
self.assertTrue(isleapyear(2000))

with tm.assert_produces_warning(FutureWarning, check_stacklevel=False):
self.assertFalse(isleapyear(2001))

with tm.assert_produces_warning(FutureWarning, check_stacklevel=False):
self.assertTrue(isleapyear(2004))


def test_normalize_date():
value = date(2012, 9, 7)
Expand Down
4 changes: 4 additions & 0 deletions pandas/tseries/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ def isleapyear(year):
year : integer / sequence
A given (list of) year(s).
"""

msg = "isleapyear is deprecated. Use .is_leap_year property instead"
warnings.warn(msg, FutureWarning)

year = np.asarray(year)
return np.logical_or(year % 400 == 0,
np.logical_and(year % 4 == 0, year % 100 > 0))
Loading

0 comments on commit 5f524d6

Please sign in to comment.