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..43e01dafc5005 100644 --- a/pandas/_config/config.py +++ b/pandas/_config/config.py @@ -716,7 +716,7 @@ def _warn_if_deprecated(key: str) -> bool: if d.msg: warnings.warn( d.msg, - FutureWarning, + FutureWarning, # pdlint: ignore[warning_class] stacklevel=find_stack_level(), ) else: @@ -728,7 +728,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, + FutureWarning, # pdlint: ignore[warning_class] + 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/frame.py b/pandas/core/frame.py index 0dcb15738c276..217aad35a80a7 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -13262,7 +13262,7 @@ def idxmin( f"The behavior of {type(self).__name__}.idxmin with all-NA " "values, or any-NA and skipna=False, is deprecated. In a future " "version this will raise ValueError", - FutureWarning, + FutureWarning, # pdlint: ignore[warning_class] stacklevel=find_stack_level(), ) @@ -13369,7 +13369,7 @@ def idxmax( f"The behavior of {type(self).__name__}.idxmax with all-NA " "values, or any-NA and skipna=False, is deprecated. In a future " "version this will raise ValueError", - 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 b0ae89b1b954d..c81d4839fae11 100644 --- a/pandas/core/groupby/groupby.py +++ b/pandas/core/groupby/groupby.py @@ -557,7 +557,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, + FutureWarning, # pdlint: ignore[warning_class] stacklevel=find_stack_level(), ) return self._grouper.groups diff --git a/pandas/core/resample.py b/pandas/core/resample.py index c4035ee941fbe..fc54a07c1396b 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 @@ -2297,7 +2297,7 @@ def _get_resampler(self, obj: NDFrame) -> Resampler: 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..55baff1dbdb64 100644 --- a/pandas/core/strings/object_array.py +++ b/pandas/core/strings/object_array.py @@ -163,7 +163,7 @@ def _str_contains( 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")) @@ -175,7 +175,7 @@ def _str_startswith(self, pat, na=lib.no_default): 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)) @@ -187,7 +187,7 @@ def _str_endswith(self, pat, na=lib.no_default): 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/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 4c433d03aff4d..6ade3d2833e16 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..6bd6401cc1f70 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,54 @@ 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): + if node.func.attr != "warn": + continue + elif isinstance(node.func, ast.Name): + 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 +449,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.")