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

bbovenzi 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 4da2ad1e2b3 Document HTTP statuses that route helpers raise (#74027)
4da2ad1e2b3 is described below

commit 4da2ad1e2b3dee214d564a2b365da8986e9918aa
Author: Pushkal Gupta <[email protected]>
AuthorDate: Thu Oct 8 01:45:22 2026 +0530

    Document HTTP statuses that route helpers raise (#74027)
    
    A route's `responses=` block is the only thing the generated OpenAPI spec 
and
    its clients have to model error responses, but the check added in #71647 
only
    looked at statuses a handler raised directly. Most route code reaches its 
error
    paths through a helper, so the statuses that matter most to a client were 
the
    ones the check could not see, and six of them were undeclared across the 
public,
    UI and execution APIs.
    
    Calls are followed from a handler's body only. A route decorator's 
`Depends(...)`
    security dependencies resolve to functions that raise 400 on an invalid
    identifier, and those statuses come from the router rather than the route, 
so
    following them would report a status the route is not responsible for 
declaring.
    
    Co-authored-by: Pierre Jeambrun <[email protected]>
---
 .../api_fastapi/core_api/openapi/_private_ui.yaml  |   6 +
 .../core_api/openapi/v2-rest-api-generated.yaml    |   6 +
 .../core_api/routes/public/task_state_store.py     |   2 +-
 .../api_fastapi/core_api/routes/ui/calendar.py     |   4 +-
 .../api_fastapi/execution_api/routes/hitl.py       |   6 +-
 .../execution_api/routes/task_instances.py         |  12 +-
 .../ui/openapi-gen/requests/services.gen.ts        |   2 +
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |   8 +
 .../ci/prek/check_openapi_exception_doc_in_sync.py | 201 ++++++++++++++++++---
 .../test_check_openapi_exception_doc_in_sync.py    | 114 +++++++++++-
 10 files changed, 321 insertions(+), 40 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 3cf4d8866d2..cfcabb0d409 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -2384,6 +2384,12 @@ paths:
             application/json:
               schema:
                 $ref: 
'#/components/schemas/CalendarTimeRangeCollectionResponse'
+        '404':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPExceptionResponse'
+          description: Not Found
         '422':
           description: Validation Error
           content:
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 671ac401abc..ba08ddd4fe6 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -6603,6 +6603,12 @@ paths:
               schema:
                 $ref: '#/components/schemas/HTTPExceptionResponse'
           description: Forbidden
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPExceptionResponse'
+          description: Bad Request
         '404':
           content:
             application/json:
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_state_store.py
 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_state_store.py
index 48699605ad5..71780415bd8 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_state_store.py
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_state_store.py
@@ -221,7 +221,7 @@ def get_task_state_store(
 @task_state_store_router.put(
     "/{key:path}",
     status_code=status.HTTP_204_NO_CONTENT,
-    responses=create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]),
+    responses=create_openapi_http_exception_doc([status.HTTP_400_BAD_REQUEST, 
status.HTTP_404_NOT_FOUND]),
     dependencies=[Depends(requires_access_dag(method="PUT", 
access_entity=DagAccessEntity.TASK_INSTANCE))],
 )
 def set_task_state_store(
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/calendar.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/calendar.py
index 066a4845b82..3afc5fb5e9d 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/calendar.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/calendar.py
@@ -18,7 +18,7 @@ from __future__ import annotations
 
 from typing import Annotated, Literal
 
-from fastapi import Depends
+from fastapi import Depends, status
 
 from airflow.api_fastapi.auth.managers.models.resource_details import 
DagAccessEntity
 from airflow.api_fastapi.common.dagbag import DagBagDep, 
get_latest_version_of_dag
@@ -29,6 +29,7 @@ from airflow.api_fastapi.core_api.datamodels.ui.calendar 
import (
     CalendarDeadlineCollectionResponse,
     CalendarTimeRangeCollectionResponse,
 )
+from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
 from airflow.api_fastapi.core_api.security import requires_access_dag
 from airflow.api_fastapi.core_api.services.ui.calendar import CalendarService
 from airflow.models.dagrun import DagRun
@@ -39,6 +40,7 @@ calendar_router = AirflowRouter(prefix="/calendar", 
tags=["Calendar"])
 
 @calendar_router.get(
     "/{dag_id}",
+    responses=create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]),
     dependencies=[
         Depends(
             requires_access_dag(
diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/routes/hitl.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/hitl.py
index 395a3ea30e8..e27349684e3 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/routes/hitl.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/routes/hitl.py
@@ -123,7 +123,10 @@ def _check_hitl_detail_exists(hitl_detail_model: 
HITLDetail | None) -> HITLDetai
 @router.patch(
     "/{task_instance_id}",
     responses=create_openapi_http_exception_doc(
-        [(status.HTTP_409_CONFLICT, "A response has already been received for 
this HITLDetail")]
+        [
+            (status.HTTP_404_NOT_FOUND, "HITLDetail not found"),
+            (status.HTTP_409_CONFLICT, "A response has already been received 
for this HITLDetail"),
+        ]
     ),
 )
 def update_hitl_detail(
@@ -153,6 +156,7 @@ def update_hitl_detail(
 @router.get(
     "/{task_instance_id}",
     status_code=status.HTTP_200_OK,
+    responses=create_openapi_http_exception_doc([(status.HTTP_404_NOT_FOUND, 
"HITLDetail not found")]),
 )
 async def get_hitl_detail(
     task_instance_id: UUID,
diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
index 1525be1c4bc..d5606864cd8 100644
--- 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
+++ 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
@@ -1257,7 +1257,11 @@ async def get_previous_successful_dagrun(
     return PrevSuccessfulDagRunResponse.model_validate(dag_run)
 
 
[email protected]("/count", status_code=status.HTTP_200_OK)
[email protected](
+    "/count",
+    status_code=status.HTTP_200_OK,
+    responses=create_openapi_http_exception_doc([(status.HTTP_404_NOT_FOUND, 
"Task group not found")]),
+)
 def get_task_instance_count(
     dag_id: str,
     session: SessionDep,
@@ -1366,7 +1370,11 @@ async def get_previous_task_instance(
     )
 
 
[email protected]("/states", status_code=status.HTTP_200_OK)
[email protected](
+    "/states",
+    status_code=status.HTTP_200_OK,
+    responses=create_openapi_http_exception_doc([(status.HTTP_404_NOT_FOUND, 
"Task group not found")]),
+)
 def get_task_instance_states(
     dag_id: str,
     session: SessionDep,
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
index df90ed6a832..1c89adc1f8d 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
@@ -4297,6 +4297,7 @@ export class TaskStateStoreService {
             body: data.requestBody,
             mediaType: 'application/json',
             errors: {
+                400: 'Bad Request',
                 401: 'Unauthorized',
                 403: 'Forbidden',
                 404: 'Not Found',
@@ -5535,6 +5536,7 @@ export class CalendarService {
                 partition_date_lt: data.partitionDateLt
             },
             errors: {
+                404: 'Not Found',
                 422: 'Validation Error'
             }
         });
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index e6bea43bda0..490a2c00d27 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -8595,6 +8595,10 @@ export type $OpenApiTs = {
                  * Successful Response
                  */
                 204: void;
+                /**
+                 * Bad Request
+                 */
+                400: HTTPExceptionResponse;
                 /**
                  * Unauthorized
                  */
@@ -9453,6 +9457,10 @@ export type $OpenApiTs = {
                  * Successful Response
                  */
                 200: CalendarTimeRangeCollectionResponse;
+                /**
+                 * Not Found
+                 */
+                404: HTTPExceptionResponse;
                 /**
                  * Validation Error
                  */
diff --git a/scripts/ci/prek/check_openapi_exception_doc_in_sync.py 
b/scripts/ci/prek/check_openapi_exception_doc_in_sync.py
index e4f2dea46ba..409f0d67487 100755
--- a/scripts/ci/prek/check_openapi_exception_doc_in_sync.py
+++ b/scripts/ci/prek/check_openapi_exception_doc_in_sync.py
@@ -23,13 +23,18 @@ it — uses to model error responses, but nothing ties it to 
the statuses a
 handler actually raises. The two drift apart silently, and that drift has been
 patched by hand repeatedly (#67570, #67571, #70992, #71011).
 
-A handler violates the rule when it raises ``HTTPException(<status>)`` in its
-own body with a status neither its own ``responses=`` block nor its router's
-declares. The check is deliberately conservative so it can gate CI: ``401``,
-``403`` and ``422`` are never required (FastAPI and the routers' auth
-dependencies supply them), only the handler's own body is inspected, and
-anything it cannot resolve statically is skipped rather than guessed at. It
-therefore under-reports rather than over-reports.
+A handler violates the rule when it raises ``HTTPException(<status>)`` -- in 
its
+own body, or in a helper it calls -- with a status neither its own 
``responses=``
+block nor its router's declares. Helper calls are followed across modules up to
+``MAX_CALL_DEPTH``, which is how a status raised by something like
+``get_latest_version_of_dag`` is attributed to the route that calls it.
+
+The check is deliberately conservative so it can gate CI: ``401``, ``403`` and
+``422`` are never required (FastAPI and the routers' auth dependencies supply
+them), only calls in a handler's *body* are followed, so the security
+dependencies in a route decorator stay exempt, and anything that cannot be
+resolved statically is skipped rather than guessed at. It therefore
+under-reports rather than over-reports.
 """
 
 # /// script
@@ -45,6 +50,7 @@ import ast
 import re
 import sys
 from pathlib import Path
+from typing import NamedTuple
 
 from common_prek_utils import console
 
@@ -55,9 +61,24 @@ DOC_HELPER = "create_openapi_http_exception_doc"
 # dependencies, declared once on a router this file-scoped check often cannot 
reach.
 ALWAYS_DOCUMENTED = {401, 403, 422}
 
+# Helper chains in these routes are shallow; the bound only stops pathological 
recursion.
+MAX_CALL_DEPTH = 3
+
 _STATUS_CONSTANT = re.compile(r"^HTTP_(\d{3})_")
 
 
+class Violation(NamedTuple):
+    handler: str
+    status: int
+    lineno: int
+    # Name of the helper that raises the status, or None when the handler 
raises it itself.
+    via: str | None
+
+    def describe(self) -> str:
+        source = f" via {self.via}()" if self.via else ""
+        return f"  Line {self.lineno}: {self.handler}() raises 
{self.status}{source} but never declares it"
+
+
 def _resolve_status(node: ast.expr) -> int | None:
     """Resolve a status code expression to its numeric value, or None if 
unknown."""
     if isinstance(node, ast.Attribute):
@@ -137,13 +158,23 @@ def _router_statuses(tree: ast.Module) -> dict[str, 
set[int] | None]:
     return routers
 
 
-def _raised_statuses(handler: ast.FunctionDef | ast.AsyncFunctionDef) -> 
dict[int, int]:
+FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef
+
+
+def _body_calls(function: FunctionNode) -> list[ast.Call]:
+    """Return calls made in the function's body, excluding its decorators.
+
+    Route decorators carry the ``Depends(...)`` security dependencies, whose 
statuses
+    the router supplies and this check does not require, so they must not be 
followed.
+    """
+    return [node for statement in function.body for node in 
ast.walk(statement) if isinstance(node, ast.Call)]
+
+
+def _raised_statuses(handler: FunctionNode) -> dict[int, int]:
     """Map each status raised as ``HTTPException`` in the body to its first 
line."""
     raised: dict[int, int] = {}
-    for node in ast.walk(handler):
-        if not (isinstance(node, ast.Call) and isinstance(node.func, 
ast.Name)):
-            continue
-        if node.func.id != "HTTPException":
+    for node in _body_calls(handler):
+        if not isinstance(node.func, ast.Name) or node.func.id != 
"HTTPException":
             continue
         argument = next(
             (kw.value for kw in node.keywords if kw.arg == "status_code"),
@@ -156,7 +187,123 @@ def _raised_statuses(handler: ast.FunctionDef | 
ast.AsyncFunctionDef) -> dict[in
     return raised
 
 
-def _route_decorators(handler: ast.FunctionDef | ast.AsyncFunctionDef) -> 
list[ast.Call]:
+def _source_root(file_path: Path) -> Path | None:
+    """Return the import root (the ``src`` directory) this file lives under."""
+    return next((parent for parent in file_path.parents if parent.name == 
"src"), None)
+
+
+class _ModuleIndex:
+    """Lazily parsed view of the functions each module defines, keyed by 
import path."""
+
+    def __init__(self, source_root: Path | None) -> None:
+        self._source_root = source_root
+        self._cache: dict[str, dict[str, FunctionNode]] = {}
+
+    def functions(self, module: str) -> dict[str, FunctionNode]:
+        if (source_root := self._source_root) is None:
+            return {}
+        if module not in self._cache:
+            self._cache[module] = self._parse(source_root, module)
+        return self._cache[module]
+
+    @staticmethod
+    def _parse(source_root: Path, module: str) -> dict[str, FunctionNode]:
+        relative = Path(*module.split("."))
+        for candidate in (
+            source_root / relative.with_suffix(".py"),
+            source_root / relative / "__init__.py",
+        ):
+            try:
+                tree = ast.parse(candidate.read_text(encoding="utf-8"), 
filename=str(candidate))
+            except (OSError, UnicodeDecodeError, SyntaxError):
+                continue
+            return {
+                node.name: node
+                for node in ast.walk(tree)
+                if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
+            }
+        return {}
+
+
+def _imported_functions(tree: ast.Module) -> dict[str, tuple[str, str]]:
+    """Map each name bound by ``from <module> import <name>`` to ``(module, 
original)``."""
+    imported: dict[str, tuple[str, str]] = {}
+    for node in ast.walk(tree):
+        # ``level`` is non-zero for relative imports, which this resolver does 
not handle.
+        if isinstance(node, ast.ImportFrom) and node.module and not node.level:
+            for alias in node.names:
+                imported[alias.asname or alias.name] = (node.module, 
alias.name)
+    return imported
+
+
+class _CallGraph:
+    """Resolves the statuses a handler raises through the helpers it calls."""
+
+    def __init__(self, tree: ast.Module, index: _ModuleIndex) -> None:
+        self._index = index
+        self._local = {
+            node.name: node
+            for node in ast.walk(tree)
+            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
+        }
+        self._imported = _imported_functions(tree)
+
+    def _resolve(
+        self, name: str, namespace: dict[str, FunctionNode]
+    ) -> tuple[FunctionNode, dict[str, FunctionNode]] | None:
+        """Return the called function and the namespace defining it.
+
+        Imports are followed only from the route module itself; inside an 
imported
+        module just that module's own definitions are visible, which is what 
keeps a
+        chain from fanning out across the whole package.
+        """
+        if (function := namespace.get(name)) is not None:
+            return function, namespace
+        if namespace is not self._local or (origin := 
self._imported.get(name)) is None:
+            return None
+        module, original = origin
+        imported = self._index.functions(module)
+        if (function := imported.get(original)) is None:
+            return None
+        return function, imported
+
+    def statuses_via_helpers(self, handler: FunctionNode) -> dict[int, str]:
+        """Map each status raised only by a helper to the name of the helper 
raising it."""
+        found: dict[int, str] = {}
+        self._walk(handler, self._local, depth=0, seen={handler.name}, 
attribute_to=None, found=found)
+        for status in _raised_statuses(handler):
+            found.pop(status, None)
+        return found
+
+    def _walk(
+        self,
+        function: FunctionNode,
+        namespace: dict[str, FunctionNode],
+        depth: int,
+        seen: set[str],
+        attribute_to: str | None,
+        found: dict[int, str],
+    ) -> None:
+        if depth >= MAX_CALL_DEPTH:
+            return
+        for call in _body_calls(function):
+            if not isinstance(call.func, ast.Name):
+                continue
+            name = call.func.id
+            if name in seen:
+                continue
+            resolved = self._resolve(name, namespace)
+            if resolved is None:
+                continue
+            callee, callee_namespace = resolved
+            # The first helper in the chain is the one worth naming in the 
message.
+            credit = attribute_to or name
+            for status in _raised_statuses(callee):
+                found.setdefault(status, credit)
+            self._walk(callee, callee_namespace, depth + 1, seen | {name}, 
credit, found)
+
+
+def _route_decorators(handler: FunctionNode) -> list[ast.Call]:
     return [
         decorator
         for decorator in handler.decorator_list
@@ -166,15 +313,16 @@ def _route_decorators(handler: ast.FunctionDef | 
ast.AsyncFunctionDef) -> list[a
     ]
 
 
-def check_file(file_path: Path) -> list[tuple[str, int, int]]:
-    """Return ``(handler_name, status, line_number)`` for each undeclared 
status."""
+def check_file(file_path: Path) -> list[Violation]:
+    """Return a :class:`Violation` for each status a route raises but never 
declares."""
     try:
         tree = ast.parse(file_path.read_text(encoding="utf-8"), 
filename=str(file_path))
     except (OSError, UnicodeDecodeError, SyntaxError):
         return []
 
     routers = _router_statuses(tree)
-    violations: list[tuple[str, int, int]] = []
+    call_graph = _CallGraph(tree, _ModuleIndex(_source_root(file_path)))
+    violations: list[Violation] = []
     for handler in ast.walk(tree):
         if not isinstance(handler, (ast.FunctionDef, ast.AsyncFunctionDef)):
             continue
@@ -185,12 +333,18 @@ def check_file(file_path: Path) -> list[tuple[str, int, 
int]]:
             inherited = routers.get(router.id, set()) if isinstance(router, 
ast.Name) else set()
             if declared is None or inherited is None:
                 continue
-            undeclared = {
-                status: lineno
+            documented = declared | inherited | ALWAYS_DOCUMENTED
+            found = [
+                Violation(handler.name, status, lineno, None)
                 for status, lineno in _raised_statuses(handler).items()
-                if status not in declared | inherited | ALWAYS_DOCUMENTED
-            }
-            violations.extend((handler.name, status, lineno) for status, 
lineno in sorted(undeclared.items()))
+                if status not in documented
+            ]
+            found += [
+                Violation(handler.name, status, handler.lineno, helper)
+                for status, helper in 
call_graph.statuses_via_helpers(handler).items()
+                if status not in documented
+            ]
+            violations.extend(sorted(found, key=lambda violation: 
violation.status))
     return violations
 
 
@@ -205,10 +359,7 @@ def main() -> int:
         if not violations:
             continue
         total += len(violations)
-        lines = [
-            f"  Line {lineno}: {handler}() raises {status} but never declares 
it"
-            for handler, status, lineno in violations
-        ]
+        lines = [violation.describe() for violation in violations]
         if console:
             console.print(f"[red]{file_path}[/red]:")
             for line in lines:
diff --git a/scripts/tests/ci/prek/test_check_openapi_exception_doc_in_sync.py 
b/scripts/tests/ci/prek/test_check_openapi_exception_doc_in_sync.py
index ef9adc6bb20..2aaf69991ed 100644
--- a/scripts/tests/ci/prek/test_check_openapi_exception_doc_in_sync.py
+++ b/scripts/tests/ci/prek/test_check_openapi_exception_doc_in_sync.py
@@ -32,7 +32,7 @@ class TestCheckFile:
                 def handler():
                     raise HTTPException(status.HTTP_404_NOT_FOUND, "nope")
                 """,
-                [("handler", 404, 4)],
+                [("handler", 404, 4, None)],
                 id="no-responses-block-at-all",
             ),
             pytest.param(
@@ -44,7 +44,7 @@ class TestCheckFile:
                 def handler():
                     raise HTTPException(status.HTTP_404_NOT_FOUND, "nope")
                 """,
-                [("handler", 404, 7)],
+                [("handler", 404, 7, None)],
                 id="status-missing-from-responses",
             ),
             pytest.param(
@@ -55,7 +55,7 @@ class TestCheckFile:
                         raise 
HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="bad")
                     raise HTTPException(status_code=status.HTTP_409_CONFLICT, 
detail="taken")
                 """,
-                [("handler", 400, 5), ("handler", 409, 6)],
+                [("handler", 400, 5, None), ("handler", 409, 6, None)],
                 id="several-undeclared-statuses-are-all-reported",
             ),
             pytest.param(
@@ -64,7 +64,7 @@ class TestCheckFile:
                 def handler():
                     raise HTTPException(HTTP_404_NOT_FOUND, "nope")
                 """,
-                [("handler", 404, 4)],
+                [("handler", 404, 4, None)],
                 id="bare-status-constant",
             ),
             pytest.param(
@@ -73,7 +73,7 @@ class TestCheckFile:
                 def handler():
                     raise HTTPException(404, "nope")
                 """,
-                [("handler", 404, 4)],
+                [("handler", 404, 4, None)],
                 id="literal-status-code",
             ),
             pytest.param(
@@ -82,7 +82,7 @@ class TestCheckFile:
                 async def handler():
                     raise HTTPException(status.HTTP_404_NOT_FOUND, "nope")
                 """,
-                [("handler", 404, 4)],
+                [("handler", 404, 4, None)],
                 id="async-handler",
             ),
             pytest.param(
@@ -93,7 +93,7 @@ class TestCheckFile:
                         raise HTTPException(status.HTTP_404_NOT_FOUND, "nope")
                     fail()
                 """,
-                [("handler", 404, 5)],
+                [("handler", 404, 5, None)],
                 id="raise-nested-inside-handler",
             ),
             pytest.param(
@@ -107,7 +107,7 @@ class TestCheckFile:
                 def handler():
                     raise HTTPException(status.HTTP_404_NOT_FOUND, "nope")
                 """,
-                [("handler", 404, 9)],
+                [("handler", 404, 9, None)],
                 id="tuple-form-declares-a-different-status",
             ),
             pytest.param(
@@ -120,7 +120,7 @@ class TestCheckFile:
                 def handler():
                     raise HTTPException(status.HTTP_404_NOT_FOUND, "nope")
                 """,
-                [("handler", 404, 8)],
+                [("handler", 404, 8, None)],
                 id="router-declares-a-different-status",
             ),
             pytest.param(
@@ -134,7 +134,7 @@ class TestCheckFile:
                 def handler():
                     raise HTTPException(status.HTTP_404_NOT_FOUND, "nope")
                 """,
-                [("handler", 404, 9)],
+                [("handler", 404, 9, None)],
                 id="mapping-responses-without-the-status",
             ),
         ],
@@ -142,6 +142,58 @@ class TestCheckFile:
     def test_violations_detected(self, write_python_file, code: str, expected):
         assert check_file(write_python_file(code)) == expected
 
+    @pytest.mark.parametrize(
+        "code, expected",
+        [
+            pytest.param(
+                """
+                def _find_it(value):
+                    if value is None:
+                        raise HTTPException(status.HTTP_404_NOT_FOUND, "nope")
+
+                @router.get("/x")
+                def handler():
+                    return _find_it(None)
+                """,
+                [("handler", 404, 7, "_find_it")],
+                id="status-raised-by-a-same-module-helper",
+            ),
+            pytest.param(
+                """
+                def _inner():
+                    raise HTTPException(status.HTTP_409_CONFLICT, "nope")
+
+                def _outer():
+                    return _inner()
+
+                @router.get("/x")
+                def handler():
+                    return _outer()
+                """,
+                [("handler", 409, 9, "_outer")],
+                id="helper-chain-is-followed-and-credited-to-the-first-hop",
+            ),
+            pytest.param(
+                """
+                def _find_it():
+                    raise HTTPException(status.HTTP_404_NOT_FOUND, "nope")
+
+                @router.get(
+                    "/x",
+                    
responses=create_openapi_http_exception_doc([status.HTTP_400_BAD_REQUEST]),
+                )
+                def handler():
+                    _find_it()
+                    raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, 
"boom")
+                """,
+                [("handler", 404, 9, "_find_it"), ("handler", 500, 11, None)],
+                id="handler-and-helper-statuses-are-both-reported",
+            ),
+        ],
+    )
+    def test_statuses_raised_through_helpers(self, write_python_file, code: 
str, expected):
+        assert sorted(check_file(write_python_file(code))) == sorted(expected)
+
     @pytest.mark.parametrize(
         "code",
         [
@@ -217,6 +269,48 @@ class TestCheckFile:
                 """,
                 id="cadwyn-versioned-router-declares-the-status",
             ),
+            pytest.param(
+                """
+                def requires_access(method):
+                    raise HTTPException(status.HTTP_400_BAD_REQUEST, "bad")
+
+                @router.get(
+                    "/x",
+                    dependencies=[Depends(requires_access(method="GET"))],
+                )
+                def handler():
+                    return None
+                """,
+                id="security-dependency-in-the-decorator-is-not-followed",
+            ),
+            pytest.param(
+                """
+                def _helper():
+                    raise HTTPException(status.HTTP_404_NOT_FOUND, "nope")
+
+                @router.get(
+                    "/x",
+                    
responses=create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]),
+                )
+                def handler():
+                    return _helper()
+                """,
+                id="helper-status-already-declared-on-the-route",
+            ),
+            pytest.param(
+                """
+                def _a():
+                    return _b()
+
+                def _b():
+                    return _a()
+
+                @router.get("/x")
+                def handler():
+                    return _a()
+                """,
+                id="recursive-helpers-terminate",
+            ),
             pytest.param(
                 """
                 teams_router = AirflowRouter()

Reply via email to