diff --git a/doc/source/api.rst b/doc/source/api.rst index e8fe26e8a525d..7b9fbb9b41a79 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -472,6 +472,7 @@ These can be accessed like ``Series.dt.``. 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 @@ -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 diff --git a/doc/source/timeseries.rst b/doc/source/timeseries.rst index da19c6a7d2bec..b8f747757987c 100644 --- a/doc/source/timeseries.rst +++ b/doc/source/timeseries.rst @@ -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 ` diff --git a/doc/source/whatsnew/v0.19.0.txt b/doc/source/whatsnew/v0.19.0.txt index 317383e866464..0d70ff47a416e 100644 --- a/doc/source/whatsnew/v0.19.0.txt +++ b/doc/source/whatsnew/v0.19.0.txt @@ -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 ` (: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: @@ -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 ` (:issue:`736`) +- ``pd.tseries.util.pivot_annual`` is deprecated. Use ``pivot_table`` as alternative, an example is :ref:`here ` (: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: diff --git a/pandas/src/period.pyx b/pandas/src/period.pyx index 45743d1cf70ff..965ed53a4b802 100644 --- a/pandas/src/period.pyx +++ b/pandas/src/period.pyx @@ -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): diff --git a/pandas/tests/series/test_datetime_values.py b/pandas/tests/series/test_datetime_values.py index c25895548dcb9..6211597b4a91b 100644 --- a/pandas/tests/series/test_datetime_values.py +++ b/pandas/tests/series/test_datetime_values.py @@ -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', diff --git a/pandas/tseries/index.py b/pandas/tseries/index.py index a1775c11d2226..4a7ba0286aab1 100644 --- a/pandas/tseries/index.py +++ b/pandas/tseries/index.py @@ -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) @@ -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 @@ -1521,29 +1525,21 @@ 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( @@ -1551,14 +1547,9 @@ def _set_freq(self, value): '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', @@ -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): diff --git a/pandas/tseries/period.py b/pandas/tseries/period.py index dffb71cff526a..810c89b3f969b 100644 --- a/pandas/tseries/period.py +++ b/pandas/tseries/period.py @@ -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 @@ -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') diff --git a/pandas/tseries/tests/test_period.py b/pandas/tseries/tests/test_period.py index 7077a61092b9e..88ab239790aa1 100644 --- a/pandas/tseries/tests/test_period.py +++ b/pandas/tseries/tests/test_period.py @@ -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): @@ -3130,9 +3146,10 @@ 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) @@ -3140,6 +3157,14 @@ def _check_all_fields(self, periodindex): 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) @@ -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'], diff --git a/pandas/tseries/tests/test_timeseries.py b/pandas/tseries/tests/test_timeseries.py index 7b9999bd05c83..09fb4beb74f28 100644 --- a/pandas/tseries/tests/test_timeseries.py +++ b/pandas/tseries/tests/test_timeseries.py @@ -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', @@ -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): diff --git a/pandas/tseries/tests/test_util.py b/pandas/tseries/tests/test_util.py index 9d992995df3a7..96da32a4a845c 100644 --- a/pandas/tseries/tests/test_util.py +++ b/pandas/tseries/tests/test_util.py @@ -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] @@ -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): @@ -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) diff --git a/pandas/tseries/util.py b/pandas/tseries/util.py index 7bac0567ea5c6..59daa8d7780b4 100644 --- a/pandas/tseries/util.py +++ b/pandas/tseries/util.py @@ -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)) diff --git a/pandas/tslib.pyx b/pandas/tslib.pyx index bc42adbab62b1..56a007bfa352c 100644 --- a/pandas/tslib.pyx +++ b/pandas/tslib.pyx @@ -546,6 +546,10 @@ class Timestamp(_Timestamp): def is_year_end(self): return self._get_start_end_field('is_year_end') + @property + def is_leap_year(self): + return bool(is_leapyear(self.year)) + def tz_localize(self, tz, ambiguous='raise', errors='raise'): """ Convert naive Timestamp to local time zone, or remove @@ -753,6 +757,10 @@ class NaTType(_NaT): # GH 10939 return np.nan + @property + def is_leap_year(self): + return False + def __rdiv__(self, other): return _nat_rdivide_op(self, other) @@ -771,7 +779,8 @@ class NaTType(_NaT): fields = ['year', 'quarter', 'month', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond', - 'week', 'dayofyear', 'days_in_month', 'daysinmonth', 'dayofweek', 'weekday_name'] + 'week', 'dayofyear', 'days_in_month', 'daysinmonth', 'dayofweek', + 'weekday_name'] for field in fields: prop = property(fget=lambda self: np.nan) setattr(NaTType, field, prop) @@ -4431,6 +4440,8 @@ def get_date_field(ndarray[int64_t] dtindex, object field): pandas_datetime_to_datetimestruct(dtindex[i], PANDAS_FR_ns, &dts) out[i] = days_in_month(dts) return out + elif field == 'is_leap_year': + return _isleapyear_arr(get_date_field(dtindex, 'Y')) raise ValueError("Field %s not supported" % field) @@ -4821,8 +4832,18 @@ def dates_normalized(ndarray[int64_t] stamps, tz=None): # Some general helper functions #---------------------------------------------------------------------- -def isleapyear(int64_t year): - return is_leapyear(year) + +cpdef _isleapyear_arr(ndarray years): + cdef: + ndarray[int8_t] out + + # to make NaT result as False + out = np.zeros(len(years), dtype='int8') + out[np.logical_or(years % 400 == 0, + np.logical_and(years % 4 == 0, + years % 100 > 0))] = 1 + return out.view(bool) + def monthrange(int64_t year, int64_t month): cdef: