diff --git a/doc/source/whatsnew/v1.3.0.rst b/doc/source/whatsnew/v1.3.0.rst index 34269185bccd6..cbd3b39a9a0f7 100644 --- a/doc/source/whatsnew/v1.3.0.rst +++ b/doc/source/whatsnew/v1.3.0.rst @@ -224,6 +224,7 @@ Other enhancements - :meth:`.GroupBy.any` and :meth:`.GroupBy.all` return a ``BooleanDtype`` for columns with nullable data types (:issue:`33449`) - Constructing a :class:`DataFrame` or :class:`Series` with the ``data`` argument being a Python iterable that is *not* a NumPy ``ndarray`` consisting of NumPy scalars will now result in a dtype with a precision the maximum of the NumPy scalars; this was already the case when ``data`` is a NumPy ``ndarray`` (:issue:`40908`) - Add keyword ``sort`` to :func:`pivot_table` to allow non-sorting of the result (:issue:`39143`) +- Date offset :class:`MonthBegin` can now be used as a period (:issue:`38859`) - .. --------------------------------------------------------------------------- @@ -801,7 +802,7 @@ I/O Period ^^^^^^ - Comparisons of :class:`Period` objects or :class:`Index`, :class:`Series`, or :class:`DataFrame` with mismatched ``PeriodDtype`` now behave like other mismatched-type comparisons, returning ``False`` for equals, ``True`` for not-equal, and raising ``TypeError`` for inequality checks (:issue:`39274`) -- +- Timestamp.to_period() fails for freq="MS" (:issue:`38914`) - Plotting diff --git a/pandas/_libs/tslibs/dtypes.pyx b/pandas/_libs/tslibs/dtypes.pyx index 415bdf74db80a..c611a792f6756 100644 --- a/pandas/_libs/tslibs/dtypes.pyx +++ b/pandas/_libs/tslibs/dtypes.pyx @@ -77,7 +77,7 @@ _period_code_map = { "Q-OCT": 2010, # Quarterly - October year end "Q-NOV": 2011, # Quarterly - November year end - "M": 3000, # Monthly + "M": 3000, # Monthly - month end "W-SUN": 4000, # Weekly - Sunday end of week "W-MON": 4001, # Weekly - Monday end of week @@ -110,6 +110,7 @@ _period_code_map.update({ "A": 1000, # Annual "W": 4000, # Weekly "C": 5000, # Custom Business Day + "MS": 3000, # Monthly - beginning of month }) cdef set _month_names = { diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index 4e6e5485b2ade..8de885ee78306 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -2156,6 +2156,8 @@ cdef class QuarterBegin(QuarterOffset): # Month-Based Offset Classes cdef class MonthOffset(SingleConstructorOffset): + # _period_dtype_code = PeriodDtypeCode.M + def is_on_offset(self, dt: datetime) -> bool: if self.normalize and not _is_normalized(dt): return False @@ -2185,21 +2187,44 @@ cdef class MonthOffset(SingleConstructorOffset): BaseOffset.__setstate__(self, state) +cdef class MonthBegin(MonthOffset): + """ + DateOffset of one month at beginning. + """ + + _period_dtype_code = PeriodDtypeCode.M + _prefix = "MS" + _day_opt = "start" + + cdef class MonthEnd(MonthOffset): """ DateOffset of one month end. """ + _period_dtype_code = PeriodDtypeCode.M _prefix = "M" _day_opt = "end" -cdef class MonthBegin(MonthOffset): +cdef class BusinessMonthBegin(MonthOffset): """ - DateOffset of one month at beginning. + DateOffset of one month at the first business day. + + Examples + -------- + >>> from pandas.tseries.offsets import BusinessMonthBegin + >>> ts=pd.Timestamp('2020-05-24 05:01:15') + >>> ts + BusinessMonthBegin() + Timestamp('2020-06-01 05:01:15') + >>> ts + BusinessMonthBegin(2) + Timestamp('2020-07-01 05:01:15') + >>> ts + BusinessMonthBegin(-3) + Timestamp('2020-03-02 05:01:15') """ - _prefix = "MS" - _day_opt = "start" + + _prefix = "BMS" + _day_opt = "business_start" cdef class BusinessMonthEnd(MonthOffset): @@ -2208,38 +2233,20 @@ cdef class BusinessMonthEnd(MonthOffset): Examples -------- - >>> from pandas.tseries.offset import BMonthEnd + >>> from pandas.tseries.offsets import BusinessMonthEnd >>> ts = pd.Timestamp('2020-05-24 05:01:15') - >>> ts + BMonthEnd() + >>> ts + BusinessMonthEnd() Timestamp('2020-05-29 05:01:15') - >>> ts + BMonthEnd(2) + >>> ts + BusinessMonthEnd(2) Timestamp('2020-06-30 05:01:15') - >>> ts + BMonthEnd(-2) + >>> ts + BusinessMonthEnd(-2) Timestamp('2020-03-31 05:01:15') """ + _prefix = "BM" _day_opt = "business_end" -cdef class BusinessMonthBegin(MonthOffset): - """ - DateOffset of one month at the first business day. - - Examples - -------- - >>> from pandas.tseries.offset import BMonthBegin - >>> ts=pd.Timestamp('2020-05-24 05:01:15') - >>> ts + BMonthBegin() - Timestamp('2020-06-01 05:01:15') - >>> ts + BMonthBegin(2) - Timestamp('2020-07-01 05:01:15') - >>> ts + BMonthBegin(-3) - Timestamp('2020-03-02 05:01:15') - """ - _prefix = "BMS" - _day_opt = "business_start" - - # --------------------------------------------------------------------- # Semi-Month Based Offsets @@ -2261,7 +2268,7 @@ cdef class SemiMonthOffset(SingleConstructorOffset): if not self._min_day_of_month <= self.day_of_month <= 27: raise ValueError( "day_of_month must be " - f"{self._min_day_of_month}<=day_of_month<=27, " + f"{self._min_day_of_month} <= day_of_month <= 27, " f"got {self.day_of_month}" ) diff --git a/pandas/tests/scalar/period/test_asfreq.py b/pandas/tests/scalar/period/test_asfreq.py index 9110352d33c26..aecb95c5937f6 100644 --- a/pandas/tests/scalar/period/test_asfreq.py +++ b/pandas/tests/scalar/period/test_asfreq.py @@ -1,6 +1,5 @@ import pytest -from pandas._libs.tslibs.dtypes import _period_code_map from pandas._libs.tslibs.period import INVALID_FREQ_ERR_MSG from pandas.errors import OutOfBoundsDatetime @@ -14,7 +13,7 @@ class TestFreqConversion: """Test frequency conversion of date objects""" - @pytest.mark.parametrize("freq", ["A", "Q", "M", "W", "B", "D"]) + @pytest.mark.parametrize("freq", ["A", "Q", "M", "MS", "W", "B", "D"]) def test_asfreq_near_zero(self, freq): # GH#19643, GH#19650 per = Period("0001-01-01", freq=freq) @@ -215,6 +214,7 @@ def test_conv_monthly(self): ival_M_to_S_end = Period( freq="S", year=2007, month=1, day=31, hour=23, minute=59, second=59 ) + ival_MS = Period(freq="MS", year=2007, month=1) assert ival_M.asfreq("A") == ival_M_to_A assert ival_M_end_of_year.asfreq("A") == ival_M_to_A @@ -235,6 +235,13 @@ def test_conv_monthly(self): assert ival_M.asfreq("S", "E") == ival_M_to_S_end assert ival_M.asfreq("M") == ival_M + assert ival_M.asfreq("M") != ival_MS + assert ival_M.asfreq("MS") == ival_MS + assert ival_M.asfreq("MS") != ival_M + assert ival_MS.asfreq("MS") == ival_MS + assert ival_MS.asfreq("MS") != ival_M + assert ival_MS.asfreq("M") == ival_M + assert ival_MS.asfreq("M") != ival_MS def test_conv_weekly(self): # frequency conversion tests: from Weekly Frequency @@ -269,6 +276,7 @@ def test_conv_weekly(self): ival_W_to_A = Period(freq="A", year=2007) ival_W_to_Q = Period(freq="Q", year=2007, quarter=1) ival_W_to_M = Period(freq="M", year=2007, month=1) + ival_W_to_MS = Period(freq="MS", year=2007, month=1) if Period(freq="D", year=2007, month=12, day=31).weekday == 6: ival_W_to_A_end_of_year = Period(freq="A", year=2007) @@ -311,6 +319,7 @@ def test_conv_weekly(self): assert ival_W_end_of_quarter.asfreq("Q") == ival_W_to_Q_end_of_quarter assert ival_W.asfreq("M") == ival_W_to_M + assert ival_W.asfreq("MS") == ival_W_to_MS assert ival_W_end_of_month.asfreq("M") == ival_W_to_M_end_of_month assert ival_W.asfreq("B", "S") == ival_W_to_B_start @@ -789,16 +798,19 @@ def test_asfreq_combined(self): assert result2.ordinal == expected.ordinal assert result2.freq == expected.freq - def test_asfreq_MS(self): - initial = Period("2013") - - assert initial.asfreq(freq="M", how="S") == Period("2013-01", "M") - - msg = INVALID_FREQ_ERR_MSG - with pytest.raises(ValueError, match=msg): - initial.asfreq(freq="MS", how="S") - - with pytest.raises(ValueError, match=msg): - Period("2013-01", "MS") - - assert _period_code_map.get("MS") is None + @pytest.mark.parametrize( + "year_month", ["2013-01", "2021-02", "2022-02", "2020-07", "2027-12"] + ) + def test_asfreq_M_vs_MS(self, year_month): + year = year_month.split("-")[0] + initial = Period(year) + ts0 = Period(year_month, freq="MS").to_timestamp() + ts1 = Period(year_month, freq="M").to_timestamp() + + assert initial.asfreq(freq="M", how="S") == Period(f"{year}-01", "M") + assert initial.asfreq(freq="M", how="S") != Period(f"{year}-01", "MS") + assert initial.asfreq(freq="MS", how="S") == Period(f"{year}-01", "MS") + assert initial.asfreq(freq="MS", how="S") != Period(f"{year}-01", "M") + assert initial.asfreq(freq="M", how="E") == Period(f"{year}-12", "M") + assert initial.asfreq(freq="MS", how="E") == Period(f"{year}-12", "MS") + assert ts0 == ts1 diff --git a/pandas/tests/scalar/period/test_period.py b/pandas/tests/scalar/period/test_period.py index 3cc81ef851306..da2b9ec170cf0 100644 --- a/pandas/tests/scalar/period/test_period.py +++ b/pandas/tests/scalar/period/test_period.py @@ -220,6 +220,7 @@ def test_period_constructor_offsets(self): ) assert Period(200701, freq=offsets.MonthEnd()) == Period(200701, freq="M") + assert Period(200701, freq=offsets.MonthBegin()) == Period(200701, freq="MS") i1 = Period(ordinal=200701, freq=offsets.MonthEnd()) i2 = Period(ordinal=200701, freq="M")