|
| 1 | +#!/usr/bin/env python |
| 2 | +# |
| 3 | +# Licensed to the Apache Software Foundation (ASF) under one |
| 4 | +# or more contributor license agreements. See the NOTICE file |
| 5 | +# distributed with this work for additional information |
| 6 | +# regarding copyright ownership. The ASF licenses this file |
| 7 | +# to you under the Apache License, Version 2.0 (the |
| 8 | +# "License"); you may not use this file except in compliance |
| 9 | +# with the License. You may obtain a copy of the License at |
| 10 | +# |
| 11 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 12 | +# |
| 13 | +# Unless required by applicable law or agreed to in writing, |
| 14 | +# software distributed under the License is distributed on an |
| 15 | +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 16 | +# KIND, either express or implied. See the License for the |
| 17 | +# specific language governing permissions and limitations |
| 18 | +# under the License. |
| 19 | +"""Check that ``HTTPException`` is imported from ``fastapi`` in fastapi-using trees. |
| 20 | +
|
| 21 | +The hook is wired into per-distribution ``.pre-commit-config.yaml`` files |
| 22 | +(``airflow-core``, ``providers/amazon``, ``providers/common/ai``, |
| 23 | +``providers/edge3``, ``providers/fab``, ``providers/keycloak``), each |
| 24 | +scoped to the subtree that actually wires a FastAPI app. In |
| 25 | +``airflow-core`` that includes ``api_fastapi/`` and |
| 26 | +``utils/serve_logs/`` (the worker log-serving FastAPI app). Provider |
| 27 | +trees that mix client and server code (e.g. edge3's ``cli/`` is a |
| 28 | +client) are scoped to the server-side subfolders only to avoid false |
| 29 | +positives on stdlib HTTP usage in the client. Within those scopes, |
| 30 | +every ``HTTPException`` must come from ``fastapi`` (which re-exports |
| 31 | +the Starlette class). Two common mistakes this hook catches: |
| 32 | +
|
| 33 | +* ``from starlette.exceptions import HTTPException`` — a different class at |
| 34 | + runtime; ``isinstance(exc, fastapi.HTTPException)`` and |
| 35 | + ``pytest.raises(fastapi.HTTPException)`` will not match it. |
| 36 | +* ``from http.client import HTTPException`` — an unrelated stdlib exception |
| 37 | + whose constructor signature differs, so the route returns 500 instead of |
| 38 | + the intended HTTP status. |
| 39 | +""" |
| 40 | + |
| 41 | +# /// script |
| 42 | +# requires-python = ">=3.10,<3.11" |
| 43 | +# dependencies = [ |
| 44 | +# "rich>=13.6.0", |
| 45 | +# ] |
| 46 | +# /// |
| 47 | +from __future__ import annotations |
| 48 | + |
| 49 | +import argparse |
| 50 | +import ast |
| 51 | +import sys |
| 52 | +from pathlib import Path |
| 53 | + |
| 54 | +from common_prek_utils import console |
| 55 | + |
| 56 | + |
| 57 | +def _is_fastapi_module(module: str) -> bool: |
| 58 | + """Return True if *module* is ``fastapi`` or a submodule of it.""" |
| 59 | + return module == "fastapi" or module.startswith("fastapi.") |
| 60 | + |
| 61 | + |
| 62 | +def check_file(file_path: Path) -> list[tuple[int, str]]: |
| 63 | + """Return list of ``(line_number, import_statement)`` violations.""" |
| 64 | + try: |
| 65 | + source = file_path.read_text(encoding="utf-8") |
| 66 | + tree = ast.parse(source, filename=str(file_path)) |
| 67 | + except (OSError, UnicodeDecodeError, SyntaxError): |
| 68 | + return [] |
| 69 | + |
| 70 | + violations: list[tuple[int, str]] = [] |
| 71 | + |
| 72 | + for node in ast.walk(tree): |
| 73 | + if not isinstance(node, ast.ImportFrom) or not node.module: |
| 74 | + continue |
| 75 | + if _is_fastapi_module(node.module): |
| 76 | + continue |
| 77 | + bad_aliases = [alias for alias in node.names if alias.name == "HTTPException"] |
| 78 | + if not bad_aliases: |
| 79 | + continue |
| 80 | + rendered = ", ".join( |
| 81 | + alias.name if not alias.asname else f"{alias.name} as {alias.asname}" for alias in bad_aliases |
| 82 | + ) |
| 83 | + violations.append((node.lineno, f"from {node.module} import {rendered}")) |
| 84 | + |
| 85 | + return violations |
| 86 | + |
| 87 | + |
| 88 | +def main() -> None: |
| 89 | + parser = argparse.ArgumentParser(description="Check that HTTPException is imported from fastapi") |
| 90 | + parser.add_argument("files", nargs="*", help="Files to check") |
| 91 | + args = parser.parse_args() |
| 92 | + |
| 93 | + if not args.files: |
| 94 | + return |
| 95 | + |
| 96 | + total_violations = 0 |
| 97 | + |
| 98 | + for file_path in [Path(f) for f in args.files]: |
| 99 | + violations = check_file(file_path) |
| 100 | + if not violations: |
| 101 | + continue |
| 102 | + if console: |
| 103 | + console.print(f"[red]{file_path}[/red]:") |
| 104 | + for line_num, statement in violations: |
| 105 | + console.print(f" [yellow]Line {line_num}[/yellow]: {statement}") |
| 106 | + else: |
| 107 | + print(f"{file_path}:") |
| 108 | + for line_num, statement in violations: |
| 109 | + print(f" Line {line_num}: {statement}") |
| 110 | + total_violations += len(violations) |
| 111 | + |
| 112 | + if total_violations: |
| 113 | + message = ( |
| 114 | + f"Found {total_violations} HTTPException import(s) not coming from `fastapi`.\n" |
| 115 | + "Use `from fastapi import HTTPException` instead. Importing it from " |
| 116 | + "`starlette.exceptions`, `http.client`, or any other module yields a " |
| 117 | + "different class at runtime and breaks `isinstance` / `pytest.raises` " |
| 118 | + "checks against `fastapi.HTTPException` (and, for `http.client`, calls " |
| 119 | + "the wrong constructor so the route returns 500 instead of the intended " |
| 120 | + "status)." |
| 121 | + ) |
| 122 | + if console: |
| 123 | + console.print() |
| 124 | + console.print(f"[red]{message}[/red]") |
| 125 | + else: |
| 126 | + print() |
| 127 | + print(message) |
| 128 | + sys.exit(1) |
| 129 | + |
| 130 | + |
| 131 | +if __name__ == "__main__": |
| 132 | + main() |
| 133 | + sys.exit(0) |
0 commit comments