Skip to content

ENH: move an exception and add a prehook to check for exception place… #48088

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,14 @@ repos:
entry: python scripts/validate_min_versions_in_sync.py
language: python
files: ^(ci/deps/actions-.*-minimum_versions\.yaml|pandas/compat/_optional\.py)$
- id: validate-errors-locations
name: Validate errors locations
description: Validate errors are in approriate locations.
entry: python scripts/validate_exception_location.py
language: python
files: ^pandas/
exclude: ^(pandas/_libs/|pandas/tests/|pandas/errors/__init__.py$)
types: [python]
- id: flake8-pyi
name: flake8-pyi
entry: flake8 --extend-ignore=E301,E302,E305,E701,E704
Expand Down
44 changes: 44 additions & 0 deletions scripts/tests/test_validate_exception_location.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import pytest

from scripts.validate_exception_location import validate_exception_and_warning_placement

PATH = "t.py"
CUSTOM_EXCEPTION = "MyException"

TEST_CODE = """
import numpy as np
import sys

def my_func():
pass

class {custom_name}({error_type}):
pass

"""

testdata = [
"Exception",
"ValueError",
"Warning",
"UserWarning",
]


@pytest.mark.parametrize("error_type", testdata)
def test_class_that_inherits_an_exception_is_flagged(capsys, error_type):
content = TEST_CODE.format(custom_name=CUSTOM_EXCEPTION, error_type=error_type)
result_msg = (
"t.py:8:0: {exception_name}: Please don't place exceptions or "
"warnings outside of pandas/errors/__init__.py or "
"pandas/_libs\n".format(exception_name=CUSTOM_EXCEPTION)
)
with pytest.raises(SystemExit, match=None):
validate_exception_and_warning_placement(PATH, content)
expected_msg, _ = capsys.readouterr()
assert result_msg == expected_msg


def test_class_that_does_not_inherit_an_exception_is_flagged(capsys):
content = "class MyClass(NonExceptionClass): pass"
validate_exception_and_warning_placement(PATH, content)
123 changes: 123 additions & 0 deletions scripts/validate_exception_location.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""
Validate that the exceptions and warnings are in approrirate places.

Checks for classes that inherit a python exception and warning and
flags them, unless they are exempted from checking.

Print the exception/warning that do not follow convention.

Usage::

As pre-commit hook (recommended):
pre-commit run validate-errors-locations --all-files
"""
from __future__ import annotations

import argparse
import ast
import sys
from typing import Sequence

ERROR_MESSAGE = (
"{path}:{lineno}:{col_offset}: {exception_name}: "
"Please don't place exceptions or warnings outside of pandas/errors/__init__.py or "
"pandas/_libs\n"
)
exception_warning_list = {
"ArithmeticError",
"AssertionError",
"AttributeError",
"EOFError",
"Exception",
"FloatingPointError",
"GeneratorExit",
"ImportError",
"IndentationError",
"IndexError",
"KeyboardInterrupt",
"KeyError",
"LookupError",
"MemoryError",
"NameError",
"NotImplementedError",
"OSError",
"OverflowError",
"ReferenceError",
"RuntimeError",
"StopIteration",
"SyntaxError",
"SystemError",
"SystemExit",
"TabError",
"TypeError",
"UnboundLocalError",
"UnicodeDecodeError",
"UnicodeEncodeError",
"UnicodeError",
"UnicodeTranslateError",
"ValueError",
"ZeroDivisionError",
"BytesWarning",
"DeprecationWarning",
"FutureWarning",
"ImportWarning",
"PendingDeprecationWarning",
"ResourceWarning",
"RuntimeWarning",
"SyntaxWarning",
"UnicodeWarning",
"UserWarning",
"Warning",
}

permisable_exception_warning_list = [
"LossySetitemError",
"NoBufferPresent",
"InvalidComparison",
"NotThisMethod",
"OptionError",
"InvalidVersion",
]


class Visitor(ast.NodeVisitor):
def __init__(self, path: str) -> None:
self.path = path

def visit_ClassDef(self, node):
classes = {getattr(n, "id", None) for n in node.bases}

if (
classes
and classes.issubset(exception_warning_list)
and node.name not in permisable_exception_warning_list
):
msg = ERROR_MESSAGE.format(
path=self.path,
lineno=node.lineno,
col_offset=node.col_offset,
exception_name=node.name,
)
sys.stdout.write(msg)
sys.exit(1)


def validate_exception_and_warning_placement(file_path: str, file_content: str):
tree = ast.parse(file_content)
visitor = Visitor(file_path)
visitor.visit(tree)


def main(argv: Sequence[str] | None = None) -> None:
parser = argparse.ArgumentParser()
parser.add_argument("paths", nargs="*")
args = parser.parse_args(argv)

for path in args.paths:
with open(path, encoding="utf-8") as fd:
content = fd.read()
validate_exception_and_warning_placement(path, content)


if __name__ == "__main__":
main()