|
| 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