Skip to content
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()