diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 51be4c3f77973..9dbb6f0458ba4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -266,6 +266,11 @@ repos: language: python entry: python scripts/validate_unwanted_patterns.py --validation-type="nodefault_used_not_only_for_typing" types: [python] + - id: unwanted-patterns-doesnt-use-pandas-warnings + name: Check that warning classes for deprecations use pandas' warning classes + language: python + entry: python scripts/validate_unwanted_patterns.py --validation-type="doesnt_use_pandas_warnings" + types: [ python ] - id: no-return-exception name: Use raise instead of return for exceptions language: pygrep diff --git a/pandas/_config/config.py b/pandas/_config/config.py index 50dac1925c936..f42e69a786d9f 100644 --- a/pandas/_config/config.py +++ b/pandas/_config/config.py @@ -73,6 +73,7 @@ class DeprecatedOption(NamedTuple): key: str + category: type[Warning] msg: str | None rkey: str | None removal_ver: str | None @@ -589,6 +590,7 @@ def register_option( def deprecate_option( key: str, + category: type[Warning], msg: str | None = None, rkey: str | None = None, removal_ver: str | None = None, @@ -608,6 +610,8 @@ def deprecate_option( key : str Name of the option to be deprecated. must be a fully-qualified option name (e.g "x.y.z.rkey"). + category : Warning + Warning class for the deprecation. msg : str, optional Warning message to output when the key is referenced. if no message is given a default message will be emitted. @@ -631,7 +635,7 @@ def deprecate_option( if key in _deprecated_options: raise OptionError(f"Option '{key}' has already been defined as deprecated.") - _deprecated_options[key] = DeprecatedOption(key, msg, rkey, removal_ver) + _deprecated_options[key] = DeprecatedOption(key, category, msg, rkey, removal_ver) # @@ -716,7 +720,7 @@ def _warn_if_deprecated(key: str) -> bool: if d.msg: warnings.warn( d.msg, - FutureWarning, + d.category, stacklevel=find_stack_level(), ) else: @@ -728,7 +732,11 @@ def _warn_if_deprecated(key: str) -> bool: else: msg += ", please refrain from using it." - warnings.warn(msg, FutureWarning, stacklevel=find_stack_level()) + warnings.warn( + msg, + d.category, + stacklevel=find_stack_level(), + ) return True return False diff --git a/pandas/core/arrays/string_.py b/pandas/core/arrays/string_.py index a52a729a0dce4..561daedff9043 100644 --- a/pandas/core/arrays/string_.py +++ b/pandas/core/arrays/string_.py @@ -176,7 +176,7 @@ def __init__( "'pd.options.future.infer_string = True' option globally and use " 'the "str" alias as a shorthand notation to specify a dtype ' '(instead of "string[pyarrow_numpy]").', - FutureWarning, + FutureWarning, # pdlint: ignore[warning_class] stacklevel=find_stack_level(), ) storage = "pyarrow" diff --git a/pandas/core/arrays/string_arrow.py b/pandas/core/arrays/string_arrow.py index bca7224ffc2f5..6e29848171ace 100644 --- a/pandas/core/arrays/string_arrow.py +++ b/pandas/core/arrays/string_arrow.py @@ -245,7 +245,7 @@ def _convert_bool_result(self, values, na=lib.no_default, method_name=None): warnings.warn( f"Allowing a non-bool 'na' in obj.str.{method_name} is deprecated " "and will raise in a future version.", - FutureWarning, + FutureWarning, # pdlint: ignore[warning_class] stacklevel=find_stack_level(), ) na = bool(na) diff --git a/pandas/core/dtypes/dtypes.py b/pandas/core/dtypes/dtypes.py index eb5c7739e5132..74f95cc7f52b4 100644 --- a/pandas/core/dtypes/dtypes.py +++ b/pandas/core/dtypes/dtypes.py @@ -1053,7 +1053,7 @@ def __new__(cls, freq) -> PeriodDtype: # noqa: PYI034 warnings.warn( "PeriodDtype[B] is deprecated and will be removed in a future " "version. Use a DatetimeIndex with freq='B' instead", - FutureWarning, + FutureWarning, # pdlint: ignore[warning_class] stacklevel=find_stack_level(), ) diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 6557388d88f20..427e9594c3a24 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -9139,7 +9139,7 @@ def resample( "deprecated and will be removed in a future version. " "Explicitly cast PeriodIndex to DatetimeIndex before resampling " "instead.", - FutureWarning, + FutureWarning, # pdlint: ignore[warning_class] stacklevel=find_stack_level(), ) else: diff --git a/pandas/core/groupby/groupby.py b/pandas/core/groupby/groupby.py index ce347140edd04..0b899d47e23d9 100644 --- a/pandas/core/groupby/groupby.py +++ b/pandas/core/groupby/groupby.py @@ -61,6 +61,7 @@ class providing the base-class of operations. from pandas.errors import ( AbstractMethodError, DataError, + Pandas4Warning, ) from pandas.util._decorators import ( Appender, @@ -557,7 +558,7 @@ def groups(self) -> dict[Hashable, Index]: "and will be removed. In a future version `groups` by one element " "list will return tuple. Use ``df.groupby(by='a').groups`` " "instead of ``df.groupby(by=['a']).groups`` to avoid this warning", - FutureWarning, + Pandas4Warning, stacklevel=find_stack_level(), ) return self._grouper.groups diff --git a/pandas/core/resample.py b/pandas/core/resample.py index c4035ee941fbe..5ae88cff55d6d 100644 --- a/pandas/core/resample.py +++ b/pandas/core/resample.py @@ -1949,7 +1949,7 @@ def _resampler_for_grouping(self): warnings.warn( "Resampling a groupby with a PeriodIndex is deprecated. " "Cast to DatetimeIndex before resampling instead.", - FutureWarning, + FutureWarning, # pdlint: ignore[warning_class] stacklevel=find_stack_level(), ) return PeriodIndexResamplerGroupby @@ -2293,11 +2293,12 @@ def _get_resampler(self, obj: NDFrame) -> Resampler: ) elif isinstance(ax, PeriodIndex): if isinstance(ax, PeriodIndex): + # TODO: Enforce in 3.0 (#53481) # GH#53481 warnings.warn( "Resampling with a PeriodIndex is deprecated. " "Cast index to DatetimeIndex before resampling instead.", - FutureWarning, + FutureWarning, # pdlint: ignore[warning_class] stacklevel=find_stack_level(), ) return PeriodIndexResampler( diff --git a/pandas/core/strings/object_array.py b/pandas/core/strings/object_array.py index 9f6baaf691577..397fdcc5cac38 100644 --- a/pandas/core/strings/object_array.py +++ b/pandas/core/strings/object_array.py @@ -159,11 +159,12 @@ def _str_contains( upper_pat = pat.upper() f = lambda x: upper_pat in x.upper() if na is not lib.no_default and not isna(na) and not isinstance(na, bool): + # TODO: Enforce in 3.0 (#59615) # GH#59561 warnings.warn( "Allowing a non-bool 'na' in obj.str.contains is deprecated " "and will raise in a future version.", - FutureWarning, + FutureWarning, # pdlint: ignore[warning_class] stacklevel=find_stack_level(), ) return self._str_map(f, na, dtype=np.dtype("bool")) @@ -171,11 +172,12 @@ def _str_contains( def _str_startswith(self, pat, na=lib.no_default): f = lambda x: x.startswith(pat) if na is not lib.no_default and not isna(na) and not isinstance(na, bool): + # TODO: Enforce in 3.0 (#59615) # GH#59561 warnings.warn( "Allowing a non-bool 'na' in obj.str.startswith is deprecated " "and will raise in a future version.", - FutureWarning, + FutureWarning, # pdlint: ignore[warning_class] stacklevel=find_stack_level(), ) return self._str_map(f, na_value=na, dtype=np.dtype(bool)) @@ -183,11 +185,12 @@ def _str_startswith(self, pat, na=lib.no_default): def _str_endswith(self, pat, na=lib.no_default): f = lambda x: x.endswith(pat) if na is not lib.no_default and not isna(na) and not isinstance(na, bool): + # TODO: Enforce in 3.0 (#59615) # GH#59561 warnings.warn( "Allowing a non-bool 'na' in obj.str.endswith is deprecated " "and will raise in a future version.", - FutureWarning, + FutureWarning, # pdlint: ignore[warning_class] stacklevel=find_stack_level(), ) return self._str_map(f, na_value=na, dtype=np.dtype(bool)) diff --git a/pandas/tests/config/test_config.py b/pandas/tests/config/test_config.py index a6bc40469cada..242a367dbff2b 100644 --- a/pandas/tests/config/test_config.py +++ b/pandas/tests/config/test_config.py @@ -75,14 +75,14 @@ def test_register_option(self): def test_describe_option(self): cf.register_option("a", 1, "doc") cf.register_option("b", 1, "doc2") - cf.deprecate_option("b") + cf.deprecate_option("b", FutureWarning) cf.register_option("c.d.e1", 1, "doc3") cf.register_option("c.d.e2", 1, "doc4") cf.register_option("f", 1) cf.register_option("g.h", 1) cf.register_option("k", 2) - cf.deprecate_option("g.h", rkey="k") + cf.deprecate_option("g.h", FutureWarning, rkey="k") cf.register_option("l", "foo") # non-existent keys raise KeyError @@ -111,7 +111,8 @@ def test_describe_option(self): cf.set_option("l", "bar") assert "bar" in cf.describe_option("l", _print_desc=False) - def test_case_insensitive(self): + @pytest.mark.parametrize("category", [DeprecationWarning, FutureWarning]) + def test_case_insensitive(self, category): cf.register_option("KanBAN", 1, "doc") assert "doc" in cf.describe_option("kanbaN", _print_desc=False) @@ -124,9 +125,9 @@ def test_case_insensitive(self): with pytest.raises(OptionError, match=msg): cf.get_option("no_such_option") - cf.deprecate_option("KanBan") + cf.deprecate_option("KanBan", category) msg = "'kanban' is deprecated, please refrain from using it." - with pytest.raises(FutureWarning, match=msg): + with pytest.raises(category, match=msg): cf.get_option("kAnBaN") def test_get_option(self): @@ -285,7 +286,7 @@ def test_reset_option_all(self): def test_deprecate_option(self): # we can deprecate non-existent options - cf.deprecate_option("foo") + cf.deprecate_option("foo", FutureWarning) with tm.assert_produces_warning(FutureWarning, match="deprecated"): with pytest.raises(KeyError, match="No such keys.s.: 'foo'"): @@ -295,15 +296,15 @@ def test_deprecate_option(self): cf.register_option("b.c", "hullo", "doc2") cf.register_option("foo", "hullo", "doc2") - cf.deprecate_option("a", removal_ver="nifty_ver") + cf.deprecate_option("a", FutureWarning, removal_ver="nifty_ver") with tm.assert_produces_warning(FutureWarning, match="eprecated.*nifty_ver"): cf.get_option("a") msg = "Option 'a' has already been defined as deprecated" with pytest.raises(OptionError, match=msg): - cf.deprecate_option("a") + cf.deprecate_option("a", FutureWarning) - cf.deprecate_option("b.c", "zounds!") + cf.deprecate_option("b.c", FutureWarning, "zounds!") with tm.assert_produces_warning(FutureWarning, match="zounds!"): cf.get_option("b.c") @@ -313,7 +314,7 @@ def test_deprecate_option(self): assert cf.get_option("d.a") == "foo" assert cf.get_option("d.dep") == "bar" - cf.deprecate_option("d.dep", rkey="d.a") # reroute d.dep to d.a + cf.deprecate_option("d.dep", FutureWarning, rkey="d.a") # reroute d.dep to d.a with tm.assert_produces_warning(FutureWarning, match="eprecated"): assert cf.get_option("d.dep") == "foo" diff --git a/pandas/tests/groupby/test_grouping.py b/pandas/tests/groupby/test_grouping.py index 53e9c53efebf7..77e71a5d2e4d0 100644 --- a/pandas/tests/groupby/test_grouping.py +++ b/pandas/tests/groupby/test_grouping.py @@ -10,7 +10,10 @@ import numpy as np import pytest -from pandas.errors import SpecificationError +from pandas.errors import ( + Pandas4Warning, + SpecificationError, +) import pandas as pd from pandas import ( @@ -545,21 +548,21 @@ def test_multiindex_columns_empty_level(self): grouped = df.groupby("to filter").groups assert grouped["A"] == [0] - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning(Pandas4Warning, match=msg): grouped = df.groupby([("to filter", "")]).groups assert grouped["A"] == [0] df = DataFrame([[1, "A"], [2, "B"]], columns=midx) expected = df.groupby("to filter").groups - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning(Pandas4Warning, match=msg): result = df.groupby([("to filter", "")]).groups assert result == expected df = DataFrame([[1, "A"], [2, "A"]], columns=midx) expected = df.groupby("to filter").groups - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning(Pandas4Warning, match=msg): result = df.groupby([("to filter", "")]).groups tm.assert_dict_equal(result, expected) @@ -571,7 +574,7 @@ def test_groupby_multiindex_tuple(self): ) msg = "`groups` by one element list returns scalar is deprecated" - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning(Pandas4Warning, match=msg): expected = df.groupby([("b", 1)]).groups result = df.groupby(("b", 1)).groups tm.assert_dict_equal(expected, result) @@ -583,14 +586,14 @@ def test_groupby_multiindex_tuple(self): ), ) - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning(Pandas4Warning, match=msg): expected = df2.groupby([("b", "d")]).groups result = df.groupby(("b", 1)).groups tm.assert_dict_equal(expected, result) df3 = DataFrame(df.values, columns=[("a", "d"), ("b", "d"), ("b", "e"), "c"]) - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning(Pandas4Warning, match=msg): expected = df3.groupby([("b", "d")]).groups result = df.groupby(("b", 1)).groups tm.assert_dict_equal(expected, result) @@ -623,7 +626,7 @@ def test_groupby_multiindex_partial_indexing_equivalence(self): tm.assert_frame_equal(expected_max, result_max) msg = "`groups` by one element list returns scalar is deprecated" - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning(Pandas4Warning, match=msg): expected_groups = df.groupby([("a", 1)])[[("b", 1), ("b", 2)]].groups result_groups = df.groupby([("a", 1)])["b"].groups tm.assert_dict_equal(expected_groups, result_groups) @@ -737,7 +740,7 @@ def test_list_grouper_with_nat(self): # Grouper in a list grouping gb = df.groupby([grouper]) expected = {Timestamp("2011-01-01"): Index(list(range(364)))} - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning(Pandas4Warning, match=msg): result = gb.groups tm.assert_dict_equal(result, expected) @@ -1019,7 +1022,7 @@ def test_groups(self, df): grouped = df.groupby(["A"]) msg = "`groups` by one element list returns scalar is deprecated" - with tm.assert_produces_warning(FutureWarning, match=msg): + with tm.assert_produces_warning(Pandas4Warning, match=msg): groups = grouped.groups assert groups is grouped.groups # caching works diff --git a/pandas/tests/util/test_assert_produces_warning.py b/pandas/tests/util/test_assert_produces_warning.py index 5b917dbbe7ba7..9316f1452477c 100644 --- a/pandas/tests/util/test_assert_produces_warning.py +++ b/pandas/tests/util/test_assert_produces_warning.py @@ -38,7 +38,7 @@ def pair_different_warnings(request): def f(): - warnings.warn("f1", FutureWarning) + warnings.warn("f1", FutureWarning) # pdlint: ignore[warning_class] warnings.warn("f2", RuntimeWarning) @@ -175,7 +175,7 @@ def test_match_multiple_warnings(): # https://github.com/pandas-dev/pandas/issues/47829 category = (FutureWarning, UserWarning) with tm.assert_produces_warning(category, match=r"^Match this"): - warnings.warn("Match this", FutureWarning) + warnings.warn("Match this", FutureWarning) # pdlint: ignore[warning_class] warnings.warn("Match this too", UserWarning) @@ -185,7 +185,7 @@ def test_must_match_multiple_warnings(): msg = "Did not see expected warning of class 'UserWarning'" with pytest.raises(AssertionError, match=msg): with tm.assert_produces_warning(category, match=r"^Match this"): - warnings.warn("Match this", FutureWarning) + warnings.warn("Match this", FutureWarning) # pdlint: ignore[warning_class] def test_must_match_multiple_warnings_messages(): @@ -194,7 +194,7 @@ def test_must_match_multiple_warnings_messages(): msg = r"The emitted warning messages are \[UserWarning\('Not this'\)\]" with pytest.raises(AssertionError, match=msg): with tm.assert_produces_warning(category, match=r"^Match this"): - warnings.warn("Match this", FutureWarning) + warnings.warn("Match this", FutureWarning) # pdlint: ignore[warning_class] warnings.warn("Not this", UserWarning) @@ -204,7 +204,7 @@ def test_allow_partial_match_for_multiple_warnings(): with tm.assert_produces_warning( category, match=r"^Match this", must_find_all_warnings=False ): - warnings.warn("Match this", FutureWarning) + warnings.warn("Match this", FutureWarning) # pdlint: ignore[warning_class] def test_allow_partial_match_for_multiple_warnings_messages(): @@ -213,7 +213,7 @@ def test_allow_partial_match_for_multiple_warnings_messages(): with tm.assert_produces_warning( category, match=r"^Match this", must_find_all_warnings=False ): - warnings.warn("Match this", FutureWarning) + warnings.warn("Match this", FutureWarning) # pdlint: ignore[warning_class] warnings.warn("Not this", UserWarning) @@ -250,13 +250,17 @@ def test_raises_during_exception(): with pytest.raises(AssertionError, match=msg): with tm.assert_produces_warning(UserWarning): - warnings.warn("FutureWarning", FutureWarning) + warnings.warn( + "FutureWarning", FutureWarning + ) # pdlint: ignore[warning_class] raise IndexError msg = "Caused unexpected warning" with pytest.raises(AssertionError, match=msg): with tm.assert_produces_warning(None): - warnings.warn("FutureWarning", FutureWarning) + warnings.warn( + "FutureWarning", FutureWarning + ) # pdlint: ignore[warning_class] raise SystemError @@ -267,5 +271,7 @@ def test_passes_during_exception(): with pytest.raises(ValueError, match="Error"): with tm.assert_produces_warning(FutureWarning, match="FutureWarning"): - warnings.warn("FutureWarning", FutureWarning) + warnings.warn( + "FutureWarning", FutureWarning + ) # pdlint: ignore[warning_class] raise ValueError("Error") diff --git a/pandas/tests/util/test_rewrite_warning.py b/pandas/tests/util/test_rewrite_warning.py index f847a06d8ea8d..3db5e44d4fcea 100644 --- a/pandas/tests/util/test_rewrite_warning.py +++ b/pandas/tests/util/test_rewrite_warning.py @@ -36,4 +36,7 @@ def test_rewrite_warning(target_category, target_message, hit, new_category): with rewrite_warning( target_message, target_category, new_message, new_category ): - warnings.warn(message="Target message", category=FutureWarning) + warnings.warn( + message="Target message", + category=FutureWarning, # pdlint: ignore[warning_class] + ) diff --git a/scripts/tests/test_validate_unwanted_patterns.py b/scripts/tests/test_validate_unwanted_patterns.py index e3f5209e843cb..16a2a052910fd 100644 --- a/scripts/tests/test_validate_unwanted_patterns.py +++ b/scripts/tests/test_validate_unwanted_patterns.py @@ -296,3 +296,37 @@ def test_nodefault_used_not_only_for_typing_raises(self, data, expected) -> None fd = io.StringIO(data.strip()) result = list(validate_unwanted_patterns.nodefault_used_not_only_for_typing(fd)) assert result == expected + + +@pytest.mark.parametrize("function", ["warnings.warn", "warn"]) +@pytest.mark.parametrize("positional", [True, False]) +@pytest.mark.parametrize( + "category", + [ + "FutureWarning", + "DeprecationWarning", + "PendingDeprecationWarning", + "Pandas4Warning", + "RuntimeWarning" + ], +) +@pytest.mark.parametrize("pdlint_ignore", [True, False]) +def test_doesnt_use_pandas_warnings(function, positional, category, pdlint_ignore): + code = ( + f"{function}({' # pdlint: ignore[warning_class]' if pdlint_ignore else ''}\n" + f' "message",\n' + f" {'' if positional else 'category='}{category},\n" + f")\n" + ) + flag_issue = ( + category in ["FutureWarning", "DeprecationWarning", "PendingDeprecationWarning"] + and not pdlint_ignore + ) + fd = io.StringIO(code) + result = list(validate_unwanted_patterns.doesnt_use_pandas_warnings(fd)) + if flag_issue: + assert len(result) == 1 + assert result[0][0] == 1 + assert result[0][1].startswith(f"Don't use {category}") + else: + assert len(result) == 0 diff --git a/scripts/validate_unwanted_patterns.py b/scripts/validate_unwanted_patterns.py index 8475747a80367..39aa0fcd759af 100755 --- a/scripts/validate_unwanted_patterns.py +++ b/scripts/validate_unwanted_patterns.py @@ -16,11 +16,15 @@ Callable, Iterable, ) +import re import sys import token import tokenize from typing import IO +DEPRECATION_WARNINGS_PATTERN = re.compile( + r"(PendingDeprecation|Deprecation|Future)Warning" +) PRIVATE_IMPORTS_TO_IGNORE: set[str] = { "_extension_array_shared_docs", "_index_shared_docs", @@ -344,6 +348,59 @@ def nodefault_used_not_only_for_typing(file_obj: IO[str]) -> Iterable[tuple[int, if isinstance(value, ast.AST) ) +def doesnt_use_pandas_warnings(file_obj: IO[str]) -> Iterable[tuple[int, str]]: + """ + Checking that pandas-specific warnings are used for deprecations. + + Parameters + ---------- + file_obj : IO + File-like object containing the Python code to validate. + + Yields + ------ + line_number : int + Line number of the warning. + msg : str + Explanation of the error. + """ + contents = file_obj.read() + lines = contents.split("\n") + tree = ast.parse(contents) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + + if ( + isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + ): + # Check for `warnings.warn`. + if node.func.value.id != "warnings" or node.func.attr != "warn": + continue + elif isinstance(node.func, ast.Name): + # Check for just `warn` when using `from warnings import warn`. + if node.func.id != "warn": + continue + if any( + "# pdlint: ignore[warning_class]" in lines[k] + for k in range(node.lineno - 1, node.end_lineno + 1) + ): + continue + values = ( + [arg.id for arg in node.args if isinstance(arg, ast.Name)] + + [kw.value.id for kw in node.keywords if kw.arg == "category"] + ) + for value in values: + matches = re.match(DEPRECATION_WARNINGS_PATTERN, value) + if matches is not None: + yield ( + node.lineno, + f"Don't use {matches[0]}, use a pandas-specific warning in " + f"pd.errors instead. You can add " + f"`# pdlint: ignore[warning_class]` to override." + ) + def main( function: Callable[[IO[str]], Iterable[tuple[int, str]]], @@ -397,6 +454,7 @@ def main( "private_import_across_module", "strings_with_wrong_placed_whitespace", "nodefault_used_not_only_for_typing", + "doesnt_use_pandas_warnings", ] parser = argparse.ArgumentParser(description="Unwanted patterns checker.")