Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: add is_leap_year property for datetime-like #13739

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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