Skip to content

Commit b9ec72b

Browse files
dataxerikphofl
authored andcommitted
ENH: move an exception and add a prehook to check for exception place… (pandas-dev#48088)
* ENH: move an exception and add a prehook to check for exception placement * ENH: fix import * ENH: revert moving error * ENH: add docstring and fix import for test * ENH: re-design approach based on feedback * ENH: update whatsnew rst * ENH: apply feedback changes * ENH: refactor to remove exception_warning_list and ignore _version.py * ENH: remove NotThisMethod from tests and all
1 parent f38383f commit b9ec72b

File tree

12 files changed

+233
-45
lines changed

12 files changed

+233
-45
lines changed

.pre-commit-config.yaml

+8
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,14 @@ repos:
236236
entry: python scripts/validate_min_versions_in_sync.py
237237
language: python
238238
files: ^(ci/deps/actions-.*-minimum_versions\.yaml|pandas/compat/_optional\.py)$
239+
- id: validate-errors-locations
240+
name: Validate errors locations
241+
description: Validate errors are in approriate locations.
242+
entry: python scripts/validate_exception_location.py
243+
language: python
244+
files: ^pandas/
245+
exclude: ^(pandas/_libs/|pandas/tests/|pandas/errors/__init__.py$|pandas/_version.py)
246+
types: [python]
239247
- id: flake8-pyi
240248
name: flake8-pyi
241249
entry: flake8 --extend-ignore=E301,E302,E305,E701,E704

doc/source/reference/testing.rst

+4
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,13 @@ Exceptions and warnings
3838
errors.IncompatibilityWarning
3939
errors.IndexingError
4040
errors.InvalidColumnName
41+
errors.InvalidComparison
4142
errors.InvalidIndexError
43+
errors.InvalidVersion
4244
errors.IntCastingNaNError
45+
errors.LossySetitemError
4346
errors.MergeError
47+
errors.NoBufferPresent
4448
errors.NullFrequencyError
4549
errors.NumbaUtilError
4650
errors.NumExprClobberingError

doc/source/whatsnew/v1.6.0.rst

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Other enhancements
3434
- :func:`assert_frame_equal` now shows the first element where the DataFrames differ, analogously to ``pytest``'s output (:issue:`47910`)
3535
- Added ``index`` parameter to :meth:`DataFrame.to_dict` (:issue:`46398`)
3636
- Added metadata propagation for binary operators on :class:`DataFrame` (:issue:`28283`)
37+
- :class:`.CategoricalConversionWarning`, :class:`.InvalidComparison`, :class:`.InvalidVersion`, :class:`.LossySetitemError`, and :class:`.NoBufferPresent` are now exposed in ``pandas.errors`` (:issue:`27656`)
3738
-
3839

3940
.. ---------------------------------------------------------------------------

pandas/core/arrays/datetimelike.py

+1-9
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
from pandas.compat.numpy import function as nv
6767
from pandas.errors import (
6868
AbstractMethodError,
69+
InvalidComparison,
6970
NullFrequencyError,
7071
PerformanceWarning,
7172
)
@@ -154,15 +155,6 @@
154155
DatetimeLikeArrayT = TypeVar("DatetimeLikeArrayT", bound="DatetimeLikeArrayMixin")
155156

156157

157-
class InvalidComparison(Exception):
158-
"""
159-
Raised by _validate_comparison_value to indicate to caller it should
160-
return invalid_comparison.
161-
"""
162-
163-
pass
164-
165-
166158
class DatetimeLikeArrayMixin(OpsMixin, NDArrayBackedExtensionArray):
167159
"""
168160
Shared Base/Mixin class for DatetimeArray, TimedeltaArray, PeriodArray

pandas/core/dtypes/cast.py

+4-9
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@
4040
DtypeObj,
4141
Scalar,
4242
)
43-
from pandas.errors import IntCastingNaNError
43+
from pandas.errors import (
44+
IntCastingNaNError,
45+
LossySetitemError,
46+
)
4447
from pandas.util._exceptions import find_stack_level
4548
from pandas.util._validators import validate_bool_kwarg
4649

@@ -2103,11 +2106,3 @@ def _dtype_can_hold_range(rng: range, dtype: np.dtype) -> bool:
21032106
if not len(rng):
21042107
return True
21052108
return np.can_cast(rng[0], dtype) and np.can_cast(rng[-1], dtype)
2106-
2107-
2108-
class LossySetitemError(Exception):
2109-
"""
2110-
Raised when trying to do a __setitem__ on an np.ndarray that is not lossless.
2111-
"""
2112-
2113-
pass

pandas/core/interchange/column.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from pandas._libs.lib import infer_dtype
88
from pandas._libs.tslibs import iNaT
9+
from pandas.errors import NoBufferPresent
910
from pandas.util._decorators import cache_readonly
1011

1112
import pandas as pd
@@ -23,7 +24,6 @@
2324
from pandas.core.interchange.utils import (
2425
ArrowCTypes,
2526
Endianness,
26-
NoBufferPresent,
2727
dtype_to_arrow_c_fmt,
2828
)
2929

pandas/core/interchange/utils.py

-4
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,3 @@ def dtype_to_arrow_c_fmt(dtype: DtypeObj) -> str:
8989
raise NotImplementedError(
9090
f"Conversion of {dtype} to Arrow C format string is not implemented."
9191
)
92-
93-
94-
class NoBufferPresent(Exception):
95-
"""Exception to signal that there is no requested buffer."""

pandas/errors/__init__.py

+24
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
OutOfBoundsTimedelta,
1313
)
1414

15+
from pandas.util.version import InvalidVersion
16+
1517

1618
class IntCastingNaNError(ValueError):
1719
"""
@@ -535,6 +537,24 @@ class CategoricalConversionWarning(Warning):
535537
"""
536538

537539

540+
class LossySetitemError(Exception):
541+
"""
542+
Raised when trying to do a __setitem__ on an np.ndarray that is not lossless.
543+
"""
544+
545+
546+
class NoBufferPresent(Exception):
547+
"""
548+
Exception is raised in _get_data_buffer to signal that there is no requested buffer.
549+
"""
550+
551+
552+
class InvalidComparison(Exception):
553+
"""
554+
Exception is raised by _validate_comparison_value to indicate an invalid comparison.
555+
"""
556+
557+
538558
__all__ = [
539559
"AbstractMethodError",
540560
"AccessorRegistrationWarning",
@@ -550,9 +570,13 @@ class CategoricalConversionWarning(Warning):
550570
"IncompatibilityWarning",
551571
"IntCastingNaNError",
552572
"InvalidColumnName",
573+
"InvalidComparison",
553574
"InvalidIndexError",
575+
"InvalidVersion",
554576
"IndexingError",
577+
"LossySetitemError",
555578
"MergeError",
579+
"NoBufferPresent",
556580
"NullFrequencyError",
557581
"NumbaUtilError",
558582
"NumExprClobberingError",

pandas/tests/test_errors.py

+25-21
Original file line numberDiff line numberDiff line change
@@ -11,33 +11,37 @@
1111
@pytest.mark.parametrize(
1212
"exc",
1313
[
14-
"UnsupportedFunctionCall",
15-
"UnsortedIndexError",
16-
"OutOfBoundsDatetime",
17-
"ParserError",
18-
"PerformanceWarning",
14+
"AttributeConflictWarning",
15+
"CSSWarning",
16+
"CategoricalConversionWarning",
17+
"ClosedFileError",
18+
"DataError",
19+
"DatabaseError",
1920
"DtypeWarning",
2021
"EmptyDataError",
21-
"ParserWarning",
22+
"IncompatibilityWarning",
23+
"IndexingError",
24+
"InvalidColumnName",
25+
"InvalidComparison",
26+
"InvalidVersion",
27+
"LossySetitemError",
2228
"MergeError",
23-
"OptionError",
24-
"NumbaUtilError",
25-
"DataError",
26-
"SpecificationError",
27-
"SettingWithCopyError",
28-
"SettingWithCopyWarning",
29+
"NoBufferPresent",
2930
"NumExprClobberingError",
30-
"IndexingError",
31-
"PyperclipException",
32-
"CSSWarning",
33-
"ClosedFileError",
31+
"NumbaUtilError",
32+
"OptionError",
33+
"OutOfBoundsDatetime",
34+
"ParserError",
35+
"ParserWarning",
36+
"PerformanceWarning",
3437
"PossibleDataLossError",
35-
"IncompatibilityWarning",
36-
"AttributeConflictWarning",
37-
"DatabaseError",
3838
"PossiblePrecisionLoss",
39-
"CategoricalConversionWarning",
40-
"InvalidColumnName",
39+
"PyperclipException",
40+
"SettingWithCopyError",
41+
"SettingWithCopyWarning",
42+
"SpecificationError",
43+
"UnsortedIndexError",
44+
"UnsupportedFunctionCall",
4145
"ValueLabelTypeMismatch",
4246
],
4347
)

scripts/pandas_errors_documented.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Check that doc/source/reference/general_utility_functions.rst documents
2+
Check that doc/source/reference/testing.rst documents
33
all exceptions and warnings in pandas/errors/__init__.py.
44
55
This is meant to be run as a pre-commit hook - to run it manually, you can do:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import pytest
2+
3+
from scripts.validate_exception_location import (
4+
ERROR_MESSAGE,
5+
validate_exception_and_warning_placement,
6+
)
7+
8+
PATH = "t.py"
9+
10+
# ERRORS_IN_TESTING_RST is the set returned when parsing testing.rst for all the
11+
# exceptions and warnings.
12+
CUSTOM_EXCEPTION_NOT_IN_TESTING_RST = "MyException"
13+
CUSTOM_EXCEPTION__IN_TESTING_RST = "MyOldException"
14+
ERRORS_IN_TESTING_RST = {CUSTOM_EXCEPTION__IN_TESTING_RST}
15+
16+
TEST_CODE = """
17+
import numpy as np
18+
import sys
19+
20+
def my_func():
21+
pass
22+
23+
class {custom_name}({error_type}):
24+
pass
25+
26+
"""
27+
28+
29+
# Test with various python-defined exceptions to ensure they are all flagged.
30+
@pytest.fixture(params=["Exception", "ValueError", "Warning", "UserWarning"])
31+
def error_type(request):
32+
return request.param
33+
34+
35+
def test_class_that_inherits_an_exception_and_is_not_in_the_testing_rst_is_flagged(
36+
capsys, error_type
37+
):
38+
content = TEST_CODE.format(
39+
custom_name=CUSTOM_EXCEPTION_NOT_IN_TESTING_RST, error_type=error_type
40+
)
41+
expected_msg = ERROR_MESSAGE.format(errors=CUSTOM_EXCEPTION_NOT_IN_TESTING_RST)
42+
with pytest.raises(SystemExit, match=None):
43+
validate_exception_and_warning_placement(PATH, content, ERRORS_IN_TESTING_RST)
44+
result_msg, _ = capsys.readouterr()
45+
assert result_msg == expected_msg
46+
47+
48+
def test_class_that_inherits_an_exception_but_is_in_the_testing_rst_is_not_flagged(
49+
capsys, error_type
50+
):
51+
content = TEST_CODE.format(
52+
custom_name=CUSTOM_EXCEPTION__IN_TESTING_RST, error_type=error_type
53+
)
54+
validate_exception_and_warning_placement(PATH, content, ERRORS_IN_TESTING_RST)
55+
56+
57+
def test_class_that_does_not_inherit_an_exception_is_not_flagged(capsys):
58+
content = "class MyClass(NonExceptionClass): pass"
59+
validate_exception_and_warning_placement(PATH, content, ERRORS_IN_TESTING_RST)
+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""
2+
Validate that the exceptions and warnings are in appropriate places.
3+
4+
Checks for classes that inherit a python exception and warning and
5+
flags them, unless they are exempted from checking. Exempt meaning
6+
the exception/warning is defined in testing.rst. Testing.rst contains
7+
a list of pandas defined exceptions and warnings. This list is kept
8+
current by other pre-commit hook, pandas_errors_documented.py.
9+
This hook maintains that errors.__init__.py and testing.rst are in-sync.
10+
Therefore, the exception or warning should be defined or imported in
11+
errors.__init__.py. Ideally, the exception or warning is defined unless
12+
there's special reason to import it.
13+
14+
Prints the exception/warning that do not follow this convention.
15+
16+
Usage::
17+
18+
As a pre-commit hook:
19+
pre-commit run validate-errors-locations --all-files
20+
"""
21+
from __future__ import annotations
22+
23+
import argparse
24+
import ast
25+
import pathlib
26+
import sys
27+
from typing import Sequence
28+
29+
API_PATH = pathlib.Path("doc/source/reference/testing.rst").resolve()
30+
ERROR_MESSAGE = (
31+
"The following exception(s) and/or warning(s): {errors} exist(s) outside of "
32+
"pandas/errors/__init__.py. Please either define them in "
33+
"pandas/errors/__init__.py. Or, if not possible then import them in "
34+
"pandas/errors/__init__.py.\n"
35+
)
36+
37+
38+
def get_warnings_and_exceptions_from_api_path() -> set[str]:
39+
with open(API_PATH) as f:
40+
doc_errors = {
41+
line.split(".")[1].strip() for line in f.readlines() if "errors" in line
42+
}
43+
return doc_errors
44+
45+
46+
class Visitor(ast.NodeVisitor):
47+
def __init__(self, path: str, exception_set: set[str]) -> None:
48+
self.path = path
49+
self.exception_set = exception_set
50+
self.found_exceptions = set()
51+
52+
def visit_ClassDef(self, node) -> None:
53+
def is_an_exception_subclass(base_id: str) -> bool:
54+
return (
55+
base_id == "Exception"
56+
or base_id.endswith("Warning")
57+
or base_id.endswith("Error")
58+
)
59+
60+
exception_classes = []
61+
62+
# Go through the class's bases and check if they are an Exception or Warning.
63+
for base in node.bases:
64+
base_id = getattr(base, "id", None)
65+
if base_id and is_an_exception_subclass(base_id):
66+
exception_classes.append(base_id)
67+
68+
# The class subclassed an Exception or Warning so add it to the list.
69+
if exception_classes:
70+
self.found_exceptions.add(node.name)
71+
72+
73+
def validate_exception_and_warning_placement(
74+
file_path: str, file_content: str, errors: set[str]
75+
):
76+
tree = ast.parse(file_content)
77+
visitor = Visitor(file_path, errors)
78+
visitor.visit(tree)
79+
80+
misplaced_exceptions = visitor.found_exceptions.difference(errors)
81+
82+
# If misplaced_exceptions isn't an empty list then there exists
83+
# pandas-defined Exception or Warnings outside of pandas/errors/__init__.py, so
84+
# we should flag them.
85+
if misplaced_exceptions:
86+
msg = ERROR_MESSAGE.format(errors=", ".join(misplaced_exceptions))
87+
sys.stdout.write(msg)
88+
sys.exit(1)
89+
90+
91+
def main(argv: Sequence[str] | None = None) -> None:
92+
parser = argparse.ArgumentParser()
93+
parser.add_argument("paths", nargs="*")
94+
args = parser.parse_args(argv)
95+
96+
error_set = get_warnings_and_exceptions_from_api_path()
97+
98+
for path in args.paths:
99+
with open(path, encoding="utf-8") as fd:
100+
content = fd.read()
101+
validate_exception_and_warning_placement(path, content, error_set)
102+
103+
104+
if __name__ == "__main__":
105+
main()

0 commit comments

Comments
 (0)