diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 5c53267158eab..6de0319873290 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -64,6 +64,7 @@ Other enhancements - :meth:`Series.nlargest` uses a 'stable' sort internally and will preserve original ordering. - :class:`ArrowDtype` now supports ``pyarrow.JsonType`` (:issue:`60958`) - :class:`DataFrameGroupBy` and :class:`SeriesGroupBy` methods ``sum``, ``mean``, ``median``, ``prod``, ``min``, ``max``, ``std``, ``var`` and ``sem`` now accept ``skipna`` parameter (:issue:`15675`) +- :class:`Easter` has gained a new constructor argument ``method`` which specifies the method used to calculate Easter — for example, Orthodox Easter (:issue:`61665`) - :class:`Holiday` has gained the constructor argument and field ``exclude_dates`` to exclude specific datetimes from a custom holiday calendar (:issue:`54382`) - :class:`Rolling` and :class:`Expanding` now support ``nunique`` (:issue:`26958`) - :class:`Rolling` and :class:`Expanding` now support aggregations ``first`` and ``last`` (:issue:`33155`) diff --git a/pandas/_libs/tslibs/offsets.pyi b/pandas/_libs/tslibs/offsets.pyi index ad579a5e41522..a71aa42b4f671 100644 --- a/pandas/_libs/tslibs/offsets.pyi +++ b/pandas/_libs/tslibs/offsets.pyi @@ -230,7 +230,13 @@ class FY5253Quarter(FY5253Mixin): variation: Literal["nearest", "last"] = ..., ) -> None: ... -class Easter(SingleConstructorOffset): ... +class Easter(SingleConstructorOffset): + def __init__( + self, + n: int = ..., + normalize: bool = ..., + method: int = ..., + ) -> None: ... class _CustomBusinessMonth(BusinessMixin): def __init__( diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index a16964435ef50..87214c3758d5c 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -4520,6 +4520,12 @@ cdef class Easter(SingleConstructorOffset): The number of years represented. normalize : bool, default False Normalize start/end dates to midnight before generating date range. + method : int, default 3 + The method used to calculate the date of Easter. Valid options are: + - 1 (EASTER_JULIAN): Original calculation in Julian calendar + - 2 (EASTER_ORTHODOX): Original method, date converted to Gregorian calendar + - 3 (EASTER_WESTERN): Revised method, in Gregorian calendar + These constants are defined in the `dateutil.easter` module. See Also -------- @@ -4532,15 +4538,32 @@ cdef class Easter(SingleConstructorOffset): Timestamp('2022-04-17 00:00:00') """ + _attributes = tuple(["n", "normalize", "method"]) + + cdef readonly: + int method + + from dateutil.easter import EASTER_WESTERN + + def __init__(self, n=1, normalize=False, method=EASTER_WESTERN): + BaseOffset.__init__(self, n, normalize) + + self.method = method + + if method < 1 or method > 3: + raise ValueError(f"Method must be 1<=method<=3, got {method}") + cpdef __setstate__(self, state): + from dateutil.easter import EASTER_WESTERN self.n = state.pop("n") self.normalize = state.pop("normalize") + self.method = state.pop("method", EASTER_WESTERN) @apply_wraps def _apply(self, other: datetime) -> datetime: from dateutil.easter import easter - current_easter = easter(other.year) + current_easter = easter(other.year, method=self.method) current_easter = datetime( current_easter.year, current_easter.month, current_easter.day ) @@ -4555,7 +4578,7 @@ cdef class Easter(SingleConstructorOffset): # NOTE: easter returns a datetime.date so we have to convert to type of # other - new = easter(other.year + n) + new = easter(other.year + n, method=self.method) new = datetime( new.year, new.month, @@ -4573,7 +4596,7 @@ cdef class Easter(SingleConstructorOffset): from dateutil.easter import easter - return date(dt.year, dt.month, dt.day) == easter(dt.year) + return date(dt.year, dt.month, dt.day) == easter(dt.year, method=self.method) # ---------------------------------------------------------------------- diff --git a/pandas/tests/tseries/offsets/test_easter.py b/pandas/tests/tseries/offsets/test_easter.py index ada72d94434a3..309411ceb5be2 100644 --- a/pandas/tests/tseries/offsets/test_easter.py +++ b/pandas/tests/tseries/offsets/test_easter.py @@ -7,6 +7,10 @@ from datetime import datetime +from dateutil.easter import ( + EASTER_ORTHODOX, + EASTER_WESTERN, +) import pytest from pandas.tests.tseries.offsets.common import assert_offset_equal @@ -32,3 +36,115 @@ class TestEaster: ) def test_offset(self, offset, date, expected): assert_offset_equal(offset, date, expected) + + @pytest.mark.parametrize( + "offset,date,expected", + [ + (Easter(method=EASTER_WESTERN), datetime(2010, 1, 1), datetime(2010, 4, 4)), + ( + Easter(method=EASTER_WESTERN), + datetime(2010, 4, 5), + datetime(2011, 4, 24), + ), + ( + Easter(2, method=EASTER_WESTERN), + datetime(2010, 1, 1), + datetime(2011, 4, 24), + ), + ( + Easter(method=EASTER_WESTERN), + datetime(2010, 4, 4), + datetime(2011, 4, 24), + ), + ( + Easter(2, method=EASTER_WESTERN), + datetime(2010, 4, 4), + datetime(2012, 4, 8), + ), + ( + -Easter(method=EASTER_WESTERN), + datetime(2011, 1, 1), + datetime(2010, 4, 4), + ), + ( + -Easter(method=EASTER_WESTERN), + datetime(2010, 4, 5), + datetime(2010, 4, 4), + ), + ( + -Easter(2, method=EASTER_WESTERN), + datetime(2011, 1, 1), + datetime(2009, 4, 12), + ), + ( + -Easter(method=EASTER_WESTERN), + datetime(2010, 4, 4), + datetime(2009, 4, 12), + ), + ( + -Easter(2, method=EASTER_WESTERN), + datetime(2010, 4, 4), + datetime(2008, 3, 23), + ), + ], + ) + def test_western_easter_offset(self, offset, date, expected): + assert_offset_equal(offset, date, expected) + + @pytest.mark.parametrize( + "offset,date,expected", + [ + ( + Easter(method=EASTER_ORTHODOX), + datetime(2010, 1, 1), + datetime(2010, 4, 4), + ), + ( + Easter(method=EASTER_ORTHODOX), + datetime(2010, 4, 5), + datetime(2011, 4, 24), + ), + ( + Easter(2, method=EASTER_ORTHODOX), + datetime(2010, 1, 1), + datetime(2011, 4, 24), + ), + ( + Easter(method=EASTER_ORTHODOX), + datetime(2010, 4, 4), + datetime(2011, 4, 24), + ), + ( + Easter(2, method=EASTER_ORTHODOX), + datetime(2010, 4, 4), + datetime(2012, 4, 15), + ), + ( + -Easter(method=EASTER_ORTHODOX), + datetime(2011, 1, 1), + datetime(2010, 4, 4), + ), + ( + -Easter(method=EASTER_ORTHODOX), + datetime(2010, 4, 5), + datetime(2010, 4, 4), + ), + ( + -Easter(2, method=EASTER_ORTHODOX), + datetime(2011, 1, 1), + datetime(2009, 4, 19), + ), + ( + -Easter(method=EASTER_ORTHODOX), + datetime(2010, 4, 4), + datetime(2009, 4, 19), + ), + ( + -Easter(2, method=EASTER_ORTHODOX), + datetime(2010, 4, 4), + datetime(2008, 4, 27), + ), + ], + ) + def test_orthodox_easter_offset(self, offset, date, expected): + assert_offset_equal(offset, date, expected) diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py index f5c2c06162fcb..0b2e66a2b3a0d 100644 --- a/pandas/tests/tseries/offsets/test_offsets.py +++ b/pandas/tests/tseries/offsets/test_offsets.py @@ -239,7 +239,7 @@ def test_offset_freqstr(self, offset_types): offset = _create_offset(offset_types) freqstr = offset.freqstr - if freqstr not in ("", "", "LWOM-SAT"): + if freqstr not in ("", "", "LWOM-SAT"): code = _get_offset(freqstr) assert offset.rule_code == code