This is an automated email from the ASF dual-hosted git repository.

jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 585d7aa8e51 Add prek hook to enforce HTTPException is imported from 
fastapi (#67367)
585d7aa8e51 is described below

commit 585d7aa8e51a9832235f18a4613386e632da6e58
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Tue May 26 13:02:37 2026 +0800

    Add prek hook to enforce HTTPException is imported from fastapi (#67367)
    
    * Add prek hook to enforce HTTPException is imported from fastapi
    
    * Move HTTPException-import hook into per-distribution prek configs
    
    Splits the single root-level hook entry into per-distribution
    `.pre-commit-config.yaml` files (airflow-core, providers/amazon,
    providers/common/ai, providers/edge3, providers/fab,
    providers/keycloak). Each entry is scoped to the subtree that actually
    wires a FastAPI app, so edge3's `cli/` (client) is excluded and only
    `worker_api/` and `plugins/` are checked.
    
    Also fixes a latent bug the hook caught in
    `airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py`,
    where `HTTPException` was imported from `http.client` and called
    with FastAPI's `(status_code, detail)` signature -- the route would
    return 500 instead of the intended 400 for `dag_id == "~"`.
    
    * Cover serve_logs FastAPI app and add unit tests for the import-guard hook
    
    Widens the ``airflow-core`` hook scope to ``src/airflow/utils/serve_logs/``
    (and its test file) so the worker log-serving FastAPI app -- which raises
    ``HTTPException`` in ~10 places but lives outside ``api_fastapi/`` -- is
    also guarded against ``starlette.exceptions`` / ``http.client`` imports
    of ``HTTPException``.
    
    Adds parametrized unit tests at 
``scripts/tests/ci/prek/test_check_http_exception_import_from_fastapi.py``
    covering plain, aliased, and dotted (``fastapi.exceptions``) good
    imports, ``starlette.exceptions`` / ``http.client`` violations,
    aliased violations, and graceful handling of syntax errors and
    missing files.
---
 airflow-core/.pre-commit-config.yaml               |  11 ++
 .../{keycloak => amazon}/.pre-commit-config.yaml   |  13 +-
 providers/common/ai/.pre-commit-config.yaml        |   9 ++
 providers/edge3/.pre-commit-config.yaml            |   9 ++
 providers/fab/.pre-commit-config.yaml              |   9 ++
 providers/keycloak/.pre-commit-config.yaml         |   9 ++
 .../check_http_exception_import_from_fastapi.py    | 133 +++++++++++++++++++++
 ...est_check_http_exception_import_from_fastapi.py |  91 ++++++++++++++
 8 files changed, 279 insertions(+), 5 deletions(-)

diff --git a/airflow-core/.pre-commit-config.yaml 
b/airflow-core/.pre-commit-config.yaml
index aea65d35b0e..949db83a81e 100644
--- a/airflow-core/.pre-commit-config.yaml
+++ b/airflow-core/.pre-commit-config.yaml
@@ -148,6 +148,17 @@ repos:
         language: python
         pass_filenames: true
         files: ^src/airflow/.*\.py$
+      - id: check-http-exception-import-from-fastapi
+        name: Check HTTPException is imported from fastapi
+        entry: ../scripts/ci/prek/check_http_exception_import_from_fastapi.py
+        language: python
+        pass_filenames: true
+        files: >
+          (?x)
+          ^src/airflow/api_fastapi/.*\.py$|
+          ^src/airflow/utils/serve_logs/.*\.py$|
+          ^tests/unit/api_fastapi/.*\.py$|
+          ^tests/unit/utils/test_serve_logs\.py$
       - id: create-missing-init-py-files-tests
         name: Create missing init.py files in tests
         entry: ../scripts/ci/prek/check_init_in_tests.py
diff --git a/providers/keycloak/.pre-commit-config.yaml 
b/providers/amazon/.pre-commit-config.yaml
similarity index 72%
copy from providers/keycloak/.pre-commit-config.yaml
copy to providers/amazon/.pre-commit-config.yaml
index 68cdb82835c..3e634c3ef2d 100644
--- a/providers/keycloak/.pre-commit-config.yaml
+++ b/providers/amazon/.pre-commit-config.yaml
@@ -24,9 +24,12 @@ default_language_version:
 repos:
   - repo: local
     hooks:
-      - id: generate-openapi-spec-keycloak
-        name: Generate the FastAPI API spec for Keycloak
+      - id: check-http-exception-import-from-fastapi
+        name: Check HTTPException is imported from fastapi
+        entry: 
../../scripts/ci/prek/check_http_exception_import_from_fastapi.py
         language: python
-        entry: ../../scripts/ci/prek/generate_openapi_spec_providers.py 
keycloak
-        pass_filenames: false
-        files: ^src/airflow/providers/keycloak/auth_manager/.*\.py$
+        pass_filenames: true
+        files: >
+          (?x)
+          ^src/airflow/providers/amazon/aws/auth_manager/.*\.py$|
+          ^tests/unit/amazon/aws/auth_manager/.*\.py$
diff --git a/providers/common/ai/.pre-commit-config.yaml 
b/providers/common/ai/.pre-commit-config.yaml
index 1a7bc73596f..9676e56363d 100644
--- a/providers/common/ai/.pre-commit-config.yaml
+++ b/providers/common/ai/.pre-commit-config.yaml
@@ -47,3 +47,12 @@ repos:
         entry: ../../../scripts/ci/prek/compile_provider_assets.py ai
         pass_filenames: false
         additional_dependencies: ['[email protected]']
+      - id: check-http-exception-import-from-fastapi
+        name: Check HTTPException is imported from fastapi
+        entry: 
../../../scripts/ci/prek/check_http_exception_import_from_fastapi.py
+        language: python
+        pass_filenames: true
+        files: >
+          (?x)
+          ^src/airflow/providers/common/ai/plugins/.*\.py$|
+          ^tests/unit/common/ai/plugins/.*\.py$
diff --git a/providers/edge3/.pre-commit-config.yaml 
b/providers/edge3/.pre-commit-config.yaml
index 90f5a0344da..ec6accf2a86 100644
--- a/providers/edge3/.pre-commit-config.yaml
+++ b/providers/edge3/.pre-commit-config.yaml
@@ -30,6 +30,15 @@ repos:
         entry: ../../scripts/ci/prek/generate_openapi_spec_providers.py edge
         pass_filenames: false
         files: ^src/airflow/providers/edge3/worker_api/.*\.py$
+      - id: check-http-exception-import-from-fastapi
+        name: Check HTTPException is imported from fastapi
+        entry: 
../../scripts/ci/prek/check_http_exception_import_from_fastapi.py
+        language: python
+        pass_filenames: true
+        files: >
+          (?x)
+          ^src/airflow/providers/edge3/(worker_api|plugins)/.*\.py$|
+          ^tests/unit/edge3/(worker_api|plugins)/.*\.py$
       - id: ts-compile-lint-edge-ui
         name: Compile / format / lint edge UI
         description: TS types generation / ESLint / Prettier new UI files in 
Edge Provider
diff --git a/providers/fab/.pre-commit-config.yaml 
b/providers/fab/.pre-commit-config.yaml
index 5d59ede01f8..4396285f744 100644
--- a/providers/fab/.pre-commit-config.yaml
+++ b/providers/fab/.pre-commit-config.yaml
@@ -42,6 +42,15 @@ repos:
         entry: ../../scripts/ci/prek/generate_openapi_spec_providers.py fab
         pass_filenames: false
         files: ^src/airflow/providers/fab/auth_manager/api_fastapi/.*\.py$
+      - id: check-http-exception-import-from-fastapi
+        name: Check HTTPException is imported from fastapi
+        entry: 
../../scripts/ci/prek/check_http_exception_import_from_fastapi.py
+        language: python
+        pass_filenames: true
+        files: >
+          (?x)
+          ^src/airflow/providers/fab/auth_manager/api_fastapi/.*\.py$|
+          ^tests/unit/fab/auth_manager/api_fastapi/.*\.py$
       - id: update-migration-references-fab
         name: Update migration ref doc for FAB
         language: python
diff --git a/providers/keycloak/.pre-commit-config.yaml 
b/providers/keycloak/.pre-commit-config.yaml
index 68cdb82835c..2215a6cd5a0 100644
--- a/providers/keycloak/.pre-commit-config.yaml
+++ b/providers/keycloak/.pre-commit-config.yaml
@@ -30,3 +30,12 @@ repos:
         entry: ../../scripts/ci/prek/generate_openapi_spec_providers.py 
keycloak
         pass_filenames: false
         files: ^src/airflow/providers/keycloak/auth_manager/.*\.py$
+      - id: check-http-exception-import-from-fastapi
+        name: Check HTTPException is imported from fastapi
+        entry: 
../../scripts/ci/prek/check_http_exception_import_from_fastapi.py
+        language: python
+        pass_filenames: true
+        files: >
+          (?x)
+          ^src/airflow/providers/keycloak/auth_manager/.*\.py$|
+          ^tests/unit/keycloak/auth_manager/.*\.py$
diff --git a/scripts/ci/prek/check_http_exception_import_from_fastapi.py 
b/scripts/ci/prek/check_http_exception_import_from_fastapi.py
new file mode 100755
index 00000000000..0e74e747758
--- /dev/null
+++ b/scripts/ci/prek/check_http_exception_import_from_fastapi.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Check that ``HTTPException`` is imported from ``fastapi`` in fastapi-using 
trees.
+
+The hook is wired into per-distribution ``.pre-commit-config.yaml`` files
+(``airflow-core``, ``providers/amazon``, ``providers/common/ai``,
+``providers/edge3``, ``providers/fab``, ``providers/keycloak``), each
+scoped to the subtree that actually wires a FastAPI app. In
+``airflow-core`` that includes ``api_fastapi/`` and
+``utils/serve_logs/`` (the worker log-serving FastAPI app). Provider
+trees that mix client and server code (e.g. edge3's ``cli/`` is a
+client) are scoped to the server-side subfolders only to avoid false
+positives on stdlib HTTP usage in the client. Within those scopes,
+every ``HTTPException`` must come from ``fastapi`` (which re-exports
+the Starlette class). Two common mistakes this hook catches:
+
+* ``from starlette.exceptions import HTTPException`` — a different class at
+  runtime; ``isinstance(exc, fastapi.HTTPException)`` and
+  ``pytest.raises(fastapi.HTTPException)`` will not match it.
+* ``from http.client import HTTPException`` — an unrelated stdlib exception
+  whose constructor signature differs, so the route returns 500 instead of
+  the intended HTTP status.
+"""
+
+# /// script
+# requires-python = ">=3.10,<3.11"
+# dependencies = [
+#   "rich>=13.6.0",
+# ]
+# ///
+from __future__ import annotations
+
+import argparse
+import ast
+import sys
+from pathlib import Path
+
+from common_prek_utils import console
+
+
+def _is_fastapi_module(module: str) -> bool:
+    """Return True if *module* is ``fastapi`` or a submodule of it."""
+    return module == "fastapi" or module.startswith("fastapi.")
+
+
+def check_file(file_path: Path) -> list[tuple[int, str]]:
+    """Return list of ``(line_number, import_statement)`` violations."""
+    try:
+        source = file_path.read_text(encoding="utf-8")
+        tree = ast.parse(source, filename=str(file_path))
+    except (OSError, UnicodeDecodeError, SyntaxError):
+        return []
+
+    violations: list[tuple[int, str]] = []
+
+    for node in ast.walk(tree):
+        if not isinstance(node, ast.ImportFrom) or not node.module:
+            continue
+        if _is_fastapi_module(node.module):
+            continue
+        bad_aliases = [alias for alias in node.names if alias.name == 
"HTTPException"]
+        if not bad_aliases:
+            continue
+        rendered = ", ".join(
+            alias.name if not alias.asname else f"{alias.name} as 
{alias.asname}" for alias in bad_aliases
+        )
+        violations.append((node.lineno, f"from {node.module} import 
{rendered}"))
+
+    return violations
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Check that HTTPException is 
imported from fastapi")
+    parser.add_argument("files", nargs="*", help="Files to check")
+    args = parser.parse_args()
+
+    if not args.files:
+        return
+
+    total_violations = 0
+
+    for file_path in [Path(f) for f in args.files]:
+        violations = check_file(file_path)
+        if not violations:
+            continue
+        if console:
+            console.print(f"[red]{file_path}[/red]:")
+            for line_num, statement in violations:
+                console.print(f"  [yellow]Line {line_num}[/yellow]: 
{statement}")
+        else:
+            print(f"{file_path}:")
+            for line_num, statement in violations:
+                print(f"  Line {line_num}: {statement}")
+        total_violations += len(violations)
+
+    if total_violations:
+        message = (
+            f"Found {total_violations} HTTPException import(s) not coming from 
`fastapi`.\n"
+            "Use `from fastapi import HTTPException` instead. Importing it 
from "
+            "`starlette.exceptions`, `http.client`, or any other module yields 
a "
+            "different class at runtime and breaks `isinstance` / 
`pytest.raises` "
+            "checks against `fastapi.HTTPException` (and, for `http.client`, 
calls "
+            "the wrong constructor so the route returns 500 instead of the 
intended "
+            "status)."
+        )
+        if console:
+            console.print()
+            console.print(f"[red]{message}[/red]")
+        else:
+            print()
+            print(message)
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
+    sys.exit(0)
diff --git 
a/scripts/tests/ci/prek/test_check_http_exception_import_from_fastapi.py 
b/scripts/tests/ci/prek/test_check_http_exception_import_from_fastapi.py
new file mode 100644
index 00000000000..7fde9d7ad9e
--- /dev/null
+++ b/scripts/tests/ci/prek/test_check_http_exception_import_from_fastapi.py
@@ -0,0 +1,91 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from check_http_exception_import_from_fastapi import check_file
+
+
+class TestCheckFile:
+    @pytest.mark.parametrize(
+        "code, expected",
+        [
+            pytest.param(
+                "from starlette.exceptions import HTTPException\n",
+                [(1, "from starlette.exceptions import HTTPException")],
+                id="starlette-exceptions",
+            ),
+            pytest.param(
+                "from http.client import HTTPException\n",
+                [(1, "from http.client import HTTPException")],
+                id="http-client",
+            ),
+            pytest.param(
+                "from starlette.exceptions import HTTPException as 
StarletteHTTPException\n",
+                [(1, "from starlette.exceptions import HTTPException as 
StarletteHTTPException")],
+                id="aliased-starlette",
+            ),
+            pytest.param(
+                "from http.client import HTTPException, HTTPSConnection\n",
+                [(1, "from http.client import HTTPException")],
+                id="mixed-import-with-extra-names",
+            ),
+            pytest.param(
+                "from fastapi import HTTPException\n"
+                "from starlette.exceptions import HTTPException as 
StarletteHTTPException\n",
+                [(2, "from starlette.exceptions import HTTPException as 
StarletteHTTPException")],
+                id="good-and-bad-mixed",
+            ),
+        ],
+    )
+    def test_violations_detected(self, write_python_file, code: str, expected: 
list[tuple[int, str]]):
+        f = write_python_file(code)
+        assert check_file(f) == expected
+
+    @pytest.mark.parametrize(
+        "code",
+        [
+            pytest.param("from fastapi import HTTPException\n", 
id="from-fastapi"),
+            pytest.param(
+                "from fastapi.exceptions import HTTPException\n",
+                id="from-fastapi-exceptions",
+            ),
+            pytest.param(
+                "from fastapi import Depends, HTTPException, status\n",
+                id="multi-name-from-fastapi",
+            ),
+            pytest.param(
+                "from fastapi import HTTPException as HTTPExc\n",
+                id="aliased-from-fastapi",
+            ),
+            pytest.param("import fastapi\n", id="import-fastapi-module"),
+            pytest.param("from http.client import HTTPSConnection\n", 
id="unrelated-import"),
+            pytest.param("x = 1\n", id="no-imports"),
+        ],
+    )
+    def test_no_violation(self, write_python_file, code: str):
+        f = write_python_file(code)
+        assert check_file(f) == []
+
+    def test_syntax_error_is_silently_skipped(self, write_python_file):
+        f = write_python_file("def broken(:\n")
+        assert check_file(f) == []
+
+    def test_missing_file_is_silently_skipped(self, tmp_path: Path):
+        assert check_file(tmp_path / "does_not_exist.py") == []

Reply via email to