Repository: pandas-dev/pandas
Pull request #56910 (2024-01-16 14:21:35)

DEPR: lowercase freqs 'ye', 'qe', etc. raise a ValueError
xref #56346, #56847, #9586

Using lowercase frequencies such as ‘y’, ‘ye’, ‘q’, ‘qe’, etc. raise a`ValueError` on main, though it worked before. Using those was never documented, but if people are use these lowercase frequencies, we should keep them work.

For example:
```
>>> pd.period_range("2020-01-01", "2020-08-01", freq="y")
ValueError: Invalid frequency: y, failed to parse with error message: ValueError("Invalid frequency: Y, failed to parse with error message: KeyError('Y')")
```
```
>>> pd.date_range("1/1/2000", periods=4, freq="qe")
ValueError: Invalid frequency: qe, failed to parse with error message: ValueError("Invalid frequency: qe, failed to parse with error message: KeyError('qe')")
```
The definition of `to_offset` is corrected, so now we can see the `FutureWarning`.
```
>>> pd.period_range("2020-01-01", "2020-08-01", freq="y")
<stdin>:1: FutureWarning: 'y' is deprecated and will be removed in a future version, please use 'Y' instead.
PeriodIndex(['2020'], dtype='period[Y-DEC]')
```

```
>>> pd.date_range("1/1/2000", periods=4, freq="qe")
<stdin>:1: FutureWarning: 'qe' is deprecated and will be removed in a future version, please use 'QE' instead.
DatetimeIndex(['2000-03-31', '2000-06-30', '2000-09-30', '2000-12-31'], dtype='datetime64[ns]', freq='QE-DEC')
```

- [x] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests) if fixing a bug or adding a new feature
- [x] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).



natmokval: @MarcoGorelli, could you please take a look at this PR?

Patch:
diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx
index 446088821b..e1615a1ceb 100644
--- a/pandas/_libs/tslibs/offsets.pyx
+++ b/pandas/_libs/tslibs/offsets.pyx
@@ -4711,29 +4711,7 @@ _lite_rule_alias = {
     "ns": "ns",
 }
 
-_dont_uppercase = {
-    "h",
-    "bh",
-    "cbh",
-    "MS",
-    "ms",
-    "s",
-    "me",
-    "qe",
-    "qe-dec",
-    "qe-jan",
-    "qe-feb",
-    "qe-mar",
-    "qe-apr",
-    "qe-may",
-    "qe-jun",
-    "qe-jul",
-    "qe-aug",
-    "qe-sep",
-    "qe-oct",
-    "qe-nov",
-    "ye",
-}
+_dont_uppercase = _dont_uppercase = {"h", "bh", "cbh", "MS", "ms", "s"}
 
 
 INVALID_FREQ_ERR_MSG = "Invalid frequency: {0}"
@@ -4752,7 +4730,29 @@ def _get_offset(name: str) -> BaseOffset:
     --------
     _get_offset('EOM') --> BMonthEnd(1)
     """
-    if name.lower() not in _dont_uppercase:
+    if (
+        name not in _lite_rule_alias
+        and (name.upper() in _lite_rule_alias)
+        and name != "ms"
+    ):
+        warnings.warn(
+            f"\'{name}\' is deprecated and will be removed "
+            f"in a future version, please use \'{name.upper()}\' instead.",
+            FutureWarning,
+            stacklevel=find_stack_level(),
+        )
+    elif (
+        name not in _lite_rule_alias
+        and (name.lower() in _lite_rule_alias)
+        and name != "MS"
+    ):
+        warnings.warn(
+            f"\'{name}\' is deprecated and will be removed "
+            f"in a future version, please use \'{name.lower()}\' instead.",
+            FutureWarning,
+            stacklevel=find_stack_level(),
+        )
+    if name not in _dont_uppercase:
         name = name.upper()
         name = _lite_rule_alias.get(name, name)
         name = _lite_rule_alias.get(name.lower(), name)
@@ -4860,7 +4860,7 @@ cpdef to_offset(freq, bint is_period=False):
 
             tups = zip(split[0::4], split[1::4], split[2::4])
             for n, (sep, stride, name) in enumerate(tups):
-                if is_period is False and name.upper() in c_OFFSET_DEPR_FREQSTR:
+                if not is_period and name.upper() in c_OFFSET_DEPR_FREQSTR:
                     warnings.warn(
                         f"\'{name}\' is deprecated and will be removed "
                         f"in a future version, please use "
@@ -4869,31 +4869,52 @@ cpdef to_offset(freq, bint is_period=False):
                         stacklevel=find_stack_level(),
                     )
                     name = c_OFFSET_DEPR_FREQSTR[name.upper()]
-                if is_period is True and name in c_REVERSE_OFFSET_DEPR_FREQSTR:
-                    if name.startswith("Y"):
+                if (not is_period and
+                        name != name.upper() and
+                        name.lower() not in {"s", "ms", "us", "ns"} and
+                        name.upper().split("-")[0].endswith(("S", "E"))):
+                    warnings.warn(
+                        f"\'{name}\' is deprecated and will be removed "
+                        f"in a future version, please use "
+                        f"\'{name.upper()}\' instead.",
+                        FutureWarning,
+                        stacklevel=find_stack_level(),
+                    )
+                    name = name.upper()
+                if is_period and name.upper() in c_REVERSE_OFFSET_DEPR_FREQSTR:
+                    if name.upper().startswith("Y"):
                         raise ValueError(
-                            f"for Period, please use \'Y{name[2:]}\' "
+                            f"for Period, please use \'Y{name.upper()[2:]}\' "
                             f"instead of \'{name}\'"
                         )
-                    if (name.startswith("B") or
-                            name.startswith("S") or name.startswith("C")):
+                    if (name.upper().startswith("B") or
+                            name.upper().startswith("S") or
+                            name.upper().startswith("C")):
                         raise ValueError(INVALID_FREQ_ERR_MSG.format(name))
                     else:
                         raise ValueError(
                             f"for Period, please use "
-                            f"\'{c_REVERSE_OFFSET_DEPR_FREQSTR.get(name)}\' "
+                            f"\'{c_REVERSE_OFFSET_DEPR_FREQSTR.get(name.upper())}\' "
                             f"instead of \'{name}\'"
                         )
-                elif is_period is True and name in c_OFFSET_DEPR_FREQSTR:
-                    if name.startswith("A"):
+                elif is_period and name.upper() in c_OFFSET_DEPR_FREQSTR:
+                    if name.upper().startswith("A"):
                         warnings.warn(
                             f"\'{name}\' is deprecated and will be removed in a future "
-                            f"version, please use \'{c_DEPR_ABBREVS.get(name)}\' "
+                            f"version, please use "
+                            f"\'{c_DEPR_ABBREVS.get(name.upper())}\' instead.",
+                            FutureWarning,
+                            stacklevel=find_stack_level(),
+                        )
+                    if name.upper() != name:
+                        warnings.warn(
+                            f"\'{name}\' is deprecated and will be removed in "
+                            f"a future version, please use \'{name.upper()}\' "
                             f"instead.",
                             FutureWarning,
                             stacklevel=find_stack_level(),
                         )
-                    name = c_OFFSET_DEPR_FREQSTR.get(name)
+                    name = c_OFFSET_DEPR_FREQSTR.get(name.upper())
 
                 if sep != "" and not sep.isspace():
                     raise ValueError("separator must be spaces")


Test patch:
diff --git a/pandas/tests/arrays/test_datetimes.py b/pandas/tests/arrays/test_datetimes.py
index 9a576be10d..8f0576cc65 100644
--- a/pandas/tests/arrays/test_datetimes.py
+++ b/pandas/tests/arrays/test_datetimes.py
@@ -766,12 +766,18 @@ class TestDatetimeArray:
         "freq, freq_depr",
         [
             ("2ME", "2M"),
+            ("2SME", "2SM"),
+            ("2SME", "2sm"),
             ("2QE", "2Q"),
             ("2QE-SEP", "2Q-SEP"),
             ("1YE", "1Y"),
             ("2YE-MAR", "2Y-MAR"),
             ("1YE", "1A"),
             ("2YE-MAR", "2A-MAR"),
+            ("2ME", "2m"),
+            ("2QE-SEP", "2q-sep"),
+            ("2YE-MAR", "2a-mar"),
+            ("2YE", "2y"),
         ],
     )
     def test_date_range_frequency_M_Q_Y_A_deprecated(self, freq, freq_depr):
@@ -784,6 +790,42 @@ class TestDatetimeArray:
             result = pd.date_range("1/1/2000", periods=4, freq=freq_depr)
         tm.assert_index_equal(result, expected)
 
+    @pytest.mark.parametrize("freq_depr", ["2H", "2CBH", "2MIN", "2S", "2mS", "2Us"])
+    def test_date_range_uppercase_frequency_deprecated(self, freq_depr):
+        # GH#9586, GH#54939
+        depr_msg = f"'{freq_depr[1:]}' is deprecated and will be removed in a "
+        f"future version. Please use '{freq_depr.lower()[1:]}' instead."
+
+        expected = pd.date_range("1/1/2000", periods=4, freq=freq_depr.lower())
+        with tm.assert_produces_warning(FutureWarning, match=depr_msg):
+            result = pd.date_range("1/1/2000", periods=4, freq=freq_depr)
+        tm.assert_index_equal(result, expected)
+
+    @pytest.mark.parametrize(
+        "freq_depr",
+        [
+            "2ye-mar",
+            "2ys",
+            "2qe",
+            "2qs-feb",
+            "2bqs",
+            "2sms",
+            "2bms",
+            "2cbme",
+            "2me",
+            "2w",
+        ],
+    )
+    def test_date_range_lowercase_frequency_deprecated(self, freq_depr):
+        # GH#9586, GH#54939
+        depr_msg = f"'{freq_depr[1:]}' is deprecated and will be removed in a "
+        f"future version, please use '{freq_depr.upper()[1:]}' instead."
+
+        expected = pd.date_range("1/1/2000", periods=4, freq=freq_depr.upper())
+        with tm.assert_produces_warning(FutureWarning, match=depr_msg):
+            result = pd.date_range("1/1/2000", periods=4, freq=freq_depr)
+        tm.assert_index_equal(result, expected)
+
 
 def test_factorize_sort_without_freq():
     dta = DatetimeArray._from_sequence([0, 2, 1], dtype="M8[ns]")
diff --git a/pandas/tests/indexes/datetimes/test_partial_slicing.py b/pandas/tests/indexes/datetimes/test_partial_slicing.py
index 18a5d7db37..bb2c3d921e 100644
--- a/pandas/tests/indexes/datetimes/test_partial_slicing.py
+++ b/pandas/tests/indexes/datetimes/test_partial_slicing.py
@@ -236,7 +236,7 @@ class TestSlicing:
         rng = date_range(
             start=datetime(2005, 1, 1, 0, 0, 59, microsecond=999990),
             periods=20,
-            freq="US",
+            freq="us",
         )
         s = Series(np.arange(20), rng)
 
diff --git a/pandas/tests/indexes/period/test_constructors.py b/pandas/tests/indexes/period/test_constructors.py
index 0cd4ddef5a..892eb7b4a0 100644
--- a/pandas/tests/indexes/period/test_constructors.py
+++ b/pandas/tests/indexes/period/test_constructors.py
@@ -26,9 +26,12 @@ class TestPeriodIndexDisallowedFreqs:
             ("2M", "2ME"),
             ("2Q-MAR", "2QE-MAR"),
             ("2Y-FEB", "2YE-FEB"),
+            ("2M", "2me"),
+            ("2Q-MAR", "2qe-MAR"),
+            ("2Y-FEB", "2yE-feb"),
         ],
     )
-    def test_period_index_frequency_ME_error_message(self, freq, freq_depr):
+    def test_period_index_offsets_frequency_error_message(self, freq, freq_depr):
         # GH#52064
         msg = f"for Period, please use '{freq[1:]}' instead of '{freq_depr[1:]}'"
 
@@ -38,7 +41,7 @@ class TestPeriodIndexDisallowedFreqs:
         with pytest.raises(ValueError, match=msg):
             period_range(start="2020-01-01", end="2020-01-02", freq=freq_depr)
 
-    @pytest.mark.parametrize("freq_depr", ["2SME", "2CBME", "2BYE"])
+    @pytest.mark.parametrize("freq_depr", ["2SME", "2sme", "2CBME", "2BYE", "2Bye"])
     def test_period_index_frequency_invalid_freq(self, freq_depr):
         # GH#9586
         msg = f"Invalid frequency: {freq_depr[1:]}"
@@ -547,7 +550,9 @@ class TestPeriodIndex:
         assert i1.freq == end_intv.freq
         assert i1[-1] == end_intv
 
-        end_intv = Period("2006-12-31", "1w")
+        msg = "'w' is deprecated and will be removed in a future version."
+        with tm.assert_produces_warning(FutureWarning, match=msg):
+            end_intv = Period("2006-12-31", "1w")
         i2 = period_range(end=end_intv, periods=10)
         assert len(i1) == len(i2)
         assert (i1 == i2).all()
@@ -576,7 +581,9 @@ class TestPeriodIndex:
         with tm.assert_produces_warning(FutureWarning, match=msg):
             end_intv = Period("2005-05-01", "B")
 
-        vals = [end_intv, Period("2006-12-31", "w")]
+        msg = "'w' is deprecated and will be removed in a future version."
+        with tm.assert_produces_warning(FutureWarning, match=msg):
+            vals = [end_intv, Period("2006-12-31", "w")]
         msg = r"Input has different freq=W-SUN from PeriodIndex\(freq=B\)"
         depr_msg = r"PeriodDtype\[B\] is deprecated"
         with pytest.raises(IncompatibleFrequency, match=msg):
diff --git a/pandas/tests/indexes/period/test_period_range.py b/pandas/tests/indexes/period/test_period_range.py
index 2543b49089..6f8e6d07da 100644
--- a/pandas/tests/indexes/period/test_period_range.py
+++ b/pandas/tests/indexes/period/test_period_range.py
@@ -181,7 +181,9 @@ class TestPeriodRange:
 
     def test_mismatched_start_end_freq_raises(self):
         depr_msg = "Period with BDay freq is deprecated"
-        end_w = Period("2006-12-31", "1w")
+        msg = "'w' is deprecated and will be removed in a future version."
+        with tm.assert_produces_warning(FutureWarning, match=msg):
+            end_w = Period("2006-12-31", "1w")
 
         with tm.assert_produces_warning(FutureWarning, match=depr_msg):
             start_b = Period("02-Apr-2005", "B")
@@ -203,19 +205,37 @@ class TestPeriodRangeDisallowedFreqs:
         with pytest.raises(ValueError, match="Invalid frequency: X"):
             period_range("2007-1-1", periods=500, freq="X")
 
-    def test_H_deprecated_from_time_series(self):
+    @pytest.mark.parametrize(
+        "freq,freq_depr",
+        [
+            ("2Y", "2A"),
+            ("2Y", "2a"),
+            ("2Y-AUG", "2A-AUG"),
+            ("2Y-AUG", "2A-aug"),
+        ],
+    )
+    def test_a_deprecated_from_time_series(self, freq, freq_depr):
         # GH#52536
-        msg = "'H' is deprecated and will be removed in a future version."
+        msg = f"'{freq_depr[1:]}' is deprecated and will be removed in a "
+        f"future version. Please use '{freq[1:]}' instead."
+
+        with tm.assert_produces_warning(FutureWarning, match=msg):
+            period_range(freq=freq_depr, start="1/1/2001", end="12/1/2009")
+
+    @pytest.mark.parametrize("freq_depr", ["2H", "2MIN", "2S", "2US", "2NS"])
+    def test_uppercase_freq_deprecated_from_time_series(self, freq_depr):
+        # GH#52536, GH#54939
+        msg = f"'{freq_depr[1:]}' is deprecated and will be removed in a "
+        f"future version. Please use '{freq_depr.lower()[1:]}' instead."
+
         with tm.assert_produces_warning(FutureWarning, match=msg):
-            period_range(freq="2H", start="1/1/2001", end="12/1/2009")
+            period_range("2020-01-01 00:00:00 00:00", periods=2, freq=freq_depr)
+
+    @pytest.mark.parametrize("freq_depr", ["2m", "2q-sep", "2y", "2w"])
+    def test_lowercase_freq_deprecated_from_time_series(self, freq_depr):
+        # GH#52536, GH#54939
+        msg = f"'{freq_depr[1:]}' is deprecated and will be removed in a "
+        f"future version. Please use '{freq_depr.upper()[1:]}' instead."
 
-    @pytest.mark.parametrize("freq_depr", ["2A", "A-DEC", "200A-AUG"])
-    def test_a_deprecated_from_time_series(self, freq_depr):
-        # GH#52536
-        freq_msg = freq_depr[freq_depr.index("A") :]
-        msg = (
-            f"'{freq_msg}' is deprecated and will be removed in a future version, "
-            f"please use 'Y{freq_msg[1:]}' instead."
-        )
         with tm.assert_produces_warning(FutureWarning, match=msg):
             period_range(freq=freq_depr, start="1/1/2001", end="12/1/2009")
diff --git a/pandas/tests/resample/test_period_index.py b/pandas/tests/resample/test_period_index.py
index 3be11e0a5a..7b3edbad66 100644
--- a/pandas/tests/resample/test_period_index.py
+++ b/pandas/tests/resample/test_period_index.py
@@ -998,6 +998,32 @@ class TestPeriodIndex:
             result = ser.resample("T").mean()
         tm.assert_series_equal(result, expected)
 
+    @pytest.mark.parametrize(
+        "freq, freq_depr, freq_res, freq_depr_res, data",
+        [
+            ("2Q", "2q", "2Y", "2y", [0.5]),
+            ("2M", "2m", "2Q", "2q", [1.0, 3.0]),
+        ],
+    )
+    def test_resample_lowercase_frequency_deprecated(
+        self, freq, freq_depr, freq_res, freq_depr_res, data
+    ):
+        depr_msg = f"'{freq_depr[1:]}' is deprecated and will be removed in a "
+        f"future version. Please use '{freq[1:]}' instead."
+        depr_msg_res = f"'{freq_depr_res[1:]}' is deprecated and will be removed in a "
+        f"future version. Please use '{freq_res[1:]}' instead."
+
+        with tm.assert_produces_warning(FutureWarning, match=depr_msg):
+            rng_l = period_range("2020-01-01", "2020-08-01", freq=freq_depr)
+        ser = Series(np.arange(len(rng_l)), index=rng_l)
+
+        rng = period_range("2020-01-01", "2020-08-01", freq=freq_res)
+        expected = Series(data=data, index=rng)
+
+        with tm.assert_produces_warning(FutureWarning, match=depr_msg_res):
+            result = ser.resample(freq_depr_res).mean()
+        tm.assert_series_equal(result, expected)
+
     @pytest.mark.parametrize(
         "offset",
         [
@@ -1023,6 +1049,9 @@ class TestPeriodIndex:
         ("2Q-FEB", "2QE-FEB"),
         ("2Y", "2YE"),
         ("2Y-MAR", "2YE-MAR"),
+        ("2M", "2me"),
+        ("2Q", "2qe"),
+        ("2Y-MAR", "2ye-mar"),
     ],
 )
 def test_resample_frequency_ME_QE_YE_error_message(frame_or_series, freq, freq_depr):
diff --git a/pandas/tests/scalar/period/test_period.py b/pandas/tests/scalar/period/test_period.py
index aa4a8b152b..d819e903a0 100644
--- a/pandas/tests/scalar/period/test_period.py
+++ b/pandas/tests/scalar/period/test_period.py
@@ -106,7 +106,9 @@ class TestPeriodConstruction:
         assert i1 == i3
 
         i1 = Period("1982", freq="min")
-        i2 = Period("1982", freq="MIN")
+        msg = "'MIN' is deprecated and will be removed in a future version."
+        with tm.assert_produces_warning(FutureWarning, match=msg):
+            i2 = Period("1982", freq="MIN")
         assert i1 == i2
 
         i1 = Period(year=2005, month=3, day=1, freq="D")
diff --git a/pandas/tests/tslibs/test_to_offset.py b/pandas/tests/tslibs/test_to_offset.py
index c5a2f08933..fd7fe81978 100644
--- a/pandas/tests/tslibs/test_to_offset.py
+++ b/pandas/tests/tslibs/test_to_offset.py
@@ -172,3 +172,47 @@ def test_to_offset_pd_timedelta(kwargs, expected):
 def test_anchored_shortcuts(shortcut, expected):
     result = to_offset(shortcut)
     assert result == expected
+
+
+@pytest.mark.parametrize(
+    "freq_depr",
+    [
+        "2ye-mar",
+        "2ys",
+        "2qe",
+        "2qs-feb",
+        "2bqs",
+        "2sms",
+        "2bms",
+        "2cbme",
+        "2me",
+        "2w",
+    ],
+)
+def test_to_offset_lowercase_frequency_deprecated(freq_depr):
+    # GH#54939
+    depr_msg = f"'{freq_depr[1:]}' is deprecated and will be removed in a "
+    f"future version, please use '{freq_depr.upper()[1:]}' instead."
+
+    with pytest.raises(FutureWarning, match=depr_msg):
+        to_offset(freq_depr)
+
+
+@pytest.mark.parametrize(
+    "freq_depr",
+    [
+        "2H",
+        "2BH",
+        "2MIN",
+        "2S",
+        "2Us",
+        "2NS",
+    ],
+)
+def test_to_offset_uppercase_frequency_deprecated(freq_depr):
+    # GH#54939
+    depr_msg = f"'{freq_depr[1:]}' is deprecated and will be removed in a "
+    f"future version, please use '{freq_depr.lower()[1:]}' instead."
+
+    with pytest.raises(FutureWarning, match=depr_msg):
+        to_offset(freq_depr)