Skip to content

Commit 5d227ee

Browse files
committed
ENH: add is_leapyear property for datetime-like
1 parent 474fd05 commit 5d227ee

File tree

12 files changed

+149
-47
lines changed

12 files changed

+149
-47
lines changed

doc/source/api.rst

+2
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,7 @@ These can be accessed like ``Series.dt.<property>``.
472472
Series.dt.is_quarter_end
473473
Series.dt.is_year_start
474474
Series.dt.is_year_end
475+
Series.dt.is_leap_year
475476
Series.dt.daysinmonth
476477
Series.dt.days_in_month
477478
Series.dt.tz
@@ -1497,6 +1498,7 @@ Time/Date Components
14971498
DatetimeIndex.is_quarter_end
14981499
DatetimeIndex.is_year_start
14991500
DatetimeIndex.is_year_end
1501+
DatetimeIndex.is_leap_year
15001502
DatetimeIndex.inferred_freq
15011503

15021504
Selecting

doc/source/timeseries.rst

+1
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,7 @@ There are several time/date properties that one can access from ``Timestamp`` or
560560
is_quarter_end,"Logical indicating if last day of quarter (defined by frequency)"
561561
is_year_start,"Logical indicating if first day of year (defined by frequency)"
562562
is_year_end,"Logical indicating if last day of year (defined by frequency)"
563+
is_leap_year,"Logical indicating if the date belongs to a leap year"
563564

564565
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>`
565566

doc/source/whatsnew/v0.19.0.txt

+5-1
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,8 @@ API changes
347347
- ``astype()`` will now accept a dict of column name to data types mapping as the ``dtype`` argument. (:issue:`12086`)
348348
- 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`)
349349
- ``pd.Timedelta(None)`` is now accepted and will return ``NaT``, mirroring ``pd.Timestamp`` (:issue:`13687`)
350+
- ``Timestamp``, ``Period``, ``DatetimeIndex``, ``PeriodIndex`` and ``.dt`` accessor have ``.is_leap_year`` property to check whether the date belongs to a leap year. (:issue:`13727`)
351+
350352

351353
.. _whatsnew_0190.api.tolist:
352354

@@ -609,7 +611,9 @@ Deprecations
609611
- ``as_recarray`` has been deprecated in ``pd.read_csv()`` and will be removed in a future version (:issue:`13373`)
610612
- 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`)
611613
- ``Timestamp.offset`` property (and named arg in the constructor), has been deprecated in favor of ``freq`` (:issue:`12160`)
612-
- ``pivot_annual`` is deprecated. Use ``pivot_table`` as alternative, an example is :ref:`here <cookbook.pivot>` (:issue:`736`)
614+
- ``pd.tseries.util.pivot_annual`` is deprecated. Use ``pivot_table`` as alternative, an example is :ref:`here <cookbook.pivot>` (:issue:`736`)
615+
- ``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`)
616+
613617

614618
.. _whatsnew_0190.prior_deprecations:
615619

pandas/src/period.pyx

+3
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,9 @@ cdef class _Period(object):
913913
property daysinmonth:
914914
def __get__(self):
915915
return self.days_in_month
916+
property is_leap_year:
917+
def __get__(self):
918+
return bool(is_leapyear(self._field(0)))
916919

917920
@classmethod
918921
def now(cls, freq=None):

pandas/tests/series/test_datetime_values.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def test_dt_namespace_accessor(self):
3232
ok_for_base = ['year', 'month', 'day', 'hour', 'minute', 'second',
3333
'weekofyear', 'week', 'dayofweek', 'weekday',
3434
'dayofyear', 'quarter', 'freq', 'days_in_month',
35-
'daysinmonth']
35+
'daysinmonth', 'is_leap_year']
3636
ok_for_period = ok_for_base + ['qyear', 'start_time', 'end_time']
3737
ok_for_period_methods = ['strftime', 'to_timestamp', 'asfreq']
3838
ok_for_dt = ok_for_base + ['date', 'time', 'microsecond', 'nanosecond',

pandas/tseries/index.py

+24-29
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,14 @@ def f(self):
7272
self.freq.kwds.get('month', 12))
7373
if self.freq else 12)
7474

75-
result = tslib.get_start_end_field(
76-
values, field, self.freqstr, month_kw)
75+
result = tslib.get_start_end_field(values, field, self.freqstr,
76+
month_kw)
7777
elif field in ['weekday_name']:
7878
result = tslib.get_date_name_field(values, field)
7979
return self._maybe_mask_results(result)
80+
elif field in ['is_leap_year']:
81+
# no need to mask NaT
82+
return tslib.get_date_field(values, field)
8083
else:
8184
result = tslib.get_date_field(values, field)
8285

@@ -227,7 +230,8 @@ def _join_i8_wrapper(joinf, **kwargs):
227230
'daysinmonth', 'date', 'time', 'microsecond',
228231
'nanosecond', 'is_month_start', 'is_month_end',
229232
'is_quarter_start', 'is_quarter_end', 'is_year_start',
230-
'is_year_end', 'tz', 'freq', 'weekday_name']
233+
'is_year_end', 'tz', 'freq', 'weekday_name',
234+
'is_leap_year']
231235
_is_numeric_dtype = False
232236
_infer_as_myclass = True
233237

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

15231527
year = _field_accessor('year', 'Y', "The year of the datetime")
1524-
month = _field_accessor(
1525-
'month', 'M', "The month as January=1, December=12")
1528+
month = _field_accessor('month', 'M',
1529+
"The month as January=1, December=12")
15261530
day = _field_accessor('day', 'D', "The days of the datetime")
15271531
hour = _field_accessor('hour', 'h', "The hours of the datetime")
15281532
minute = _field_accessor('minute', 'm', "The minutes of the datetime")
15291533
second = _field_accessor('second', 's', "The seconds of the datetime")
1530-
microsecond = _field_accessor(
1531-
'microsecond',
1532-
'us',
1533-
"The microseconds of the datetime")
1534-
nanosecond = _field_accessor(
1535-
'nanosecond',
1536-
'ns',
1537-
"The nanoseconds of the datetime")
1538-
weekofyear = _field_accessor(
1539-
'weekofyear',
1540-
'woy',
1541-
"The week ordinal of the year")
1534+
microsecond = _field_accessor('microsecond', 'us',
1535+
"The microseconds of the datetime")
1536+
nanosecond = _field_accessor('nanosecond', 'ns',
1537+
"The nanoseconds of the datetime")
1538+
weekofyear = _field_accessor('weekofyear', 'woy',
1539+
"The week ordinal of the year")
15421540
week = weekofyear
1543-
dayofweek = _field_accessor(
1544-
'dayofweek',
1545-
'dow',
1546-
"The day of the week with Monday=0, Sunday=6")
1541+
dayofweek = _field_accessor('dayofweek', 'dow',
1542+
"The day of the week with Monday=0, Sunday=6")
15471543
weekday = dayofweek
15481544

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

1554-
dayofyear = _field_accessor(
1555-
'dayofyear',
1556-
'doy',
1557-
"The ordinal day of the year")
1558-
quarter = _field_accessor(
1559-
'quarter',
1560-
'q',
1561-
"The quarter of the date")
1550+
dayofyear = _field_accessor('dayofyear', 'doy',
1551+
"The ordinal day of the year")
1552+
quarter = _field_accessor('quarter', 'q', "The quarter of the date")
15621553
days_in_month = _field_accessor(
15631554
'days_in_month',
15641555
'dim',
@@ -1588,6 +1579,10 @@ def _set_freq(self, value):
15881579
'is_year_end',
15891580
'is_year_end',
15901581
"Logical indicating if last day of year (defined by frequency)")
1582+
is_leap_year = _field_accessor(
1583+
'is_leap_year',
1584+
'is_leap_year',
1585+
"Logical indicating if the date belongs to a leap year")
15911586

15921587
@property
15931588
def time(self):

pandas/tseries/period.py

+13-7
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,8 @@ class PeriodIndex(DatelikeOps, DatetimeIndexOpsMixin, Int64Index):
165165
'weekofyear', 'week', 'dayofweek', 'weekday',
166166
'dayofyear', 'quarter', 'qyear', 'freq',
167167
'days_in_month', 'daysinmonth',
168-
'to_timestamp', 'asfreq', 'start_time', 'end_time']
168+
'to_timestamp', 'asfreq', 'start_time', 'end_time',
169+
'is_leap_year']
169170
_is_numeric_dtype = False
170171
_infer_as_myclass = True
171172

@@ -509,17 +510,22 @@ def to_datetime(self, dayfirst=False):
509510
second = _field_accessor('second', 7, "The second of the period")
510511
weekofyear = _field_accessor('week', 8, "The week ordinal of the year")
511512
week = weekofyear
512-
dayofweek = _field_accessor(
513-
'dayofweek', 10, "The day of the week with Monday=0, Sunday=6")
513+
dayofweek = _field_accessor('dayofweek', 10,
514+
"The day of the week with Monday=0, Sunday=6")
514515
weekday = dayofweek
515-
dayofyear = day_of_year = _field_accessor(
516-
'dayofyear', 9, "The ordinal day of the year")
516+
dayofyear = day_of_year = _field_accessor('dayofyear', 9,
517+
"The ordinal day of the year")
517518
quarter = _field_accessor('quarter', 2, "The quarter of the date")
518519
qyear = _field_accessor('qyear', 1)
519-
days_in_month = _field_accessor(
520-
'days_in_month', 11, "The number of days in the month")
520+
days_in_month = _field_accessor('days_in_month', 11,
521+
"The number of days in the month")
521522
daysinmonth = days_in_month
522523

524+
@property
525+
def is_leap_year(self):
526+
""" Logical indicating if the date belongs to a leap year """
527+
return tslib._isleapyear_arr(self.year)
528+
523529
@property
524530
def start_time(self):
525531
return self.to_timestamp(how='start')

pandas/tseries/tests/test_period.py

+27-1
Original file line numberDiff line numberDiff line change
@@ -1514,6 +1514,22 @@ def test_asfreq_mult(self):
15141514
self.assertEqual(result.ordinal, expected.ordinal)
15151515
self.assertEqual(result.freq, expected.freq)
15161516

1517+
def test_is_leap_year(self):
1518+
# GH 13727
1519+
for freq in ['A', 'M', 'D', 'H']:
1520+
p = Period('2000-01-01 00:00:00', freq=freq)
1521+
self.assertTrue(p.is_leap_year)
1522+
self.assertIsInstance(p.is_leap_year, bool)
1523+
1524+
p = Period('1999-01-01 00:00:00', freq=freq)
1525+
self.assertFalse(p.is_leap_year)
1526+
1527+
p = Period('2004-01-01 00:00:00', freq=freq)
1528+
self.assertTrue(p.is_leap_year)
1529+
1530+
p = Period('2100-01-01 00:00:00', freq=freq)
1531+
self.assertFalse(p.is_leap_year)
1532+
15171533

15181534
class TestPeriodIndex(tm.TestCase):
15191535
def setUp(self):
@@ -3130,16 +3146,25 @@ def test_fields(self):
31303146
def _check_all_fields(self, periodindex):
31313147
fields = ['year', 'month', 'day', 'hour', 'minute', 'second',
31323148
'weekofyear', 'week', 'dayofweek', 'weekday', 'dayofyear',
3133-
'quarter', 'qyear', 'days_in_month']
3149+
'quarter', 'qyear', 'days_in_month', 'is_leap_year']
31343150

31353151
periods = list(periodindex)
3152+
s = pd.Series(periodindex)
31363153

31373154
for field in fields:
31383155
field_idx = getattr(periodindex, field)
31393156
self.assertEqual(len(periodindex), len(field_idx))
31403157
for x, val in zip(periods, field_idx):
31413158
self.assertEqual(getattr(x, field), val)
31423159

3160+
if len(s) == 0:
3161+
continue
3162+
3163+
field_s = getattr(s.dt, field)
3164+
self.assertEqual(len(periodindex), len(field_s))
3165+
for x, val in zip(periods, field_s):
3166+
self.assertEqual(getattr(x, field), val)
3167+
31433168
def test_is_full(self):
31443169
index = PeriodIndex([2005, 2007, 2009], freq='A')
31453170
self.assertFalse(index.is_full)
@@ -4569,6 +4594,7 @@ def test_get_period_field_array_raises_on_out_of_range(self):
45694594
self.assertRaises(ValueError, _period.get_period_field_arr, -1,
45704595
np.empty(1), 0)
45714596

4597+
45724598
if __name__ == '__main__':
45734599
import nose
45744600
nose.runmodule(argv=[__file__, '-vvs', '-x', '--pdb', '--pdb-failure'],

pandas/tseries/tests/test_timeseries.py

+29-3
Original file line numberDiff line numberDiff line change
@@ -969,13 +969,20 @@ def test_nat_vector_field_access(self):
969969

970970
fields = ['year', 'quarter', 'month', 'day', 'hour', 'minute',
971971
'second', 'microsecond', 'nanosecond', 'week', 'dayofyear',
972-
'days_in_month']
972+
'days_in_month', 'is_leap_year']
973+
973974
for field in fields:
974975
result = getattr(idx, field)
975-
expected = [getattr(x, field) if x is not NaT else np.nan
976-
for x in idx]
976+
expected = [getattr(x, field) for x in idx]
977977
self.assert_numpy_array_equal(result, np.array(expected))
978978

979+
s = pd.Series(idx)
980+
981+
for field in fields:
982+
result = getattr(s.dt, field)
983+
expected = [getattr(x, field) for x in idx]
984+
self.assert_series_equal(result, pd.Series(expected))
985+
979986
def test_nat_scalar_field_access(self):
980987
fields = ['year', 'quarter', 'month', 'day', 'hour', 'minute',
981988
'second', 'microsecond', 'nanosecond', 'week', 'dayofyear',
@@ -4761,6 +4768,25 @@ def test_timestamp_compare_series(self):
47614768
result = right_f(Timestamp('nat'), s_nat)
47624769
tm.assert_series_equal(result, expected)
47634770

4771+
def test_is_leap_year(self):
4772+
# GH 13727
4773+
for tz in [None, 'UTC', 'US/Eastern', 'Asia/Tokyo']:
4774+
dt = Timestamp('2000-01-01 00:00:00', tz=tz)
4775+
self.assertTrue(dt.is_leap_year)
4776+
self.assertIsInstance(dt.is_leap_year, bool)
4777+
4778+
dt = Timestamp('1999-01-01 00:00:00', tz=tz)
4779+
self.assertFalse(dt.is_leap_year)
4780+
4781+
dt = Timestamp('2004-01-01 00:00:00', tz=tz)
4782+
self.assertTrue(dt.is_leap_year)
4783+
4784+
dt = Timestamp('2100-01-01 00:00:00', tz=tz)
4785+
self.assertFalse(dt.is_leap_year)
4786+
4787+
self.assertFalse(pd.NaT.is_leap_year)
4788+
self.assertIsInstance(pd.NaT.is_leap_year, bool)
4789+
47644790

47654791
class TestSlicing(tm.TestCase):
47664792
def test_slice_year(self):

pandas/tseries/tests/test_util.py

+16-2
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ def test_daily(self):
2525
annual = pivot_annual(ts, 'D')
2626

2727
doy = ts.index.dayofyear
28-
doy[(~isleapyear(ts.index.year)) & (doy >= 60)] += 1
28+
29+
with tm.assert_produces_warning(FutureWarning, check_stacklevel=False):
30+
doy[(~isleapyear(ts.index.year)) & (doy >= 60)] += 1
2931

3032
for i in range(1, 367):
3133
subset = ts[doy == i]
@@ -51,7 +53,9 @@ def test_hourly(self):
5153
grouped = ts_hourly.groupby(ts_hourly.index.year)
5254
hoy = grouped.apply(lambda x: x.reset_index(drop=True))
5355
hoy = hoy.index.droplevel(0).values
54-
hoy[~isleapyear(ts_hourly.index.year) & (hoy >= 1416)] += 24
56+
57+
with tm.assert_produces_warning(FutureWarning, check_stacklevel=False):
58+
hoy[~isleapyear(ts_hourly.index.year) & (hoy >= 1416)] += 24
5559
hoy += 1
5660

5761
with tm.assert_produces_warning(FutureWarning, check_stacklevel=False):
@@ -100,6 +104,16 @@ def test_period_daily(self):
100104
def test_period_weekly(self):
101105
pass
102106

107+
def test_isleapyear_deprecate(self):
108+
with tm.assert_produces_warning(FutureWarning, check_stacklevel=False):
109+
self.assertTrue(isleapyear(2000))
110+
111+
with tm.assert_produces_warning(FutureWarning, check_stacklevel=False):
112+
self.assertFalse(isleapyear(2001))
113+
114+
with tm.assert_produces_warning(FutureWarning, check_stacklevel=False):
115+
self.assertTrue(isleapyear(2004))
116+
103117

104118
def test_normalize_date():
105119
value = date(2012, 9, 7)

pandas/tseries/util.py

+4
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ def isleapyear(year):
9595
year : integer / sequence
9696
A given (list of) year(s).
9797
"""
98+
99+
msg = "isleapyear is deprecated. Use .is_leap_year property instead"
100+
warnings.warn(msg, FutureWarning)
101+
98102
year = np.asarray(year)
99103
return np.logical_or(year % 400 == 0,
100104
np.logical_and(year % 4 == 0, year % 100 > 0))

0 commit comments

Comments
 (0)