10 changed files with 1443 additions and 64 deletions
@ -0,0 +1,96 @@ |
|||||
|
"""Static dependency-graph introspection for the debug endpoint. |
||||
|
|
||||
|
Nothing in this module executes any user callable. It walks the already-built |
||||
|
Dependant trees that live on each APIRoute and serialises them to plain dicts. |
||||
|
""" |
||||
|
|
||||
|
from collections.abc import Sequence |
||||
|
from typing import Any |
||||
|
|
||||
|
from fastapi.dependencies.models import Dependant, _impartial, _unwrapped_call |
||||
|
|
||||
|
|
||||
|
def _resolve_callable_name(call: Any) -> str: |
||||
|
"""Return the display name of a callable, unwrapping partials and decorators.""" |
||||
|
if call is None: |
||||
|
return "<unknown>" |
||||
|
unwrapped = _unwrapped_call(call) |
||||
|
if unwrapped is None: # pragma: no cover |
||||
|
return "<unknown>" |
||||
|
# For classes the __name__ is the class name; for functions it is the |
||||
|
# function name. Both are what we want. |
||||
|
return getattr(unwrapped, "__name__", type(unwrapped).__name__) |
||||
|
|
||||
|
|
||||
|
def _resolve_callable_module(call: Any) -> str: |
||||
|
"""Return the module path of a callable, unwrapping partials and decorators.""" |
||||
|
if call is None: |
||||
|
return "<unknown>" |
||||
|
unwrapped = _unwrapped_call(call) |
||||
|
if unwrapped is None: # pragma: no cover |
||||
|
return "<unknown>" |
||||
|
return getattr(unwrapped, "__module__", "<unknown>") |
||||
|
|
||||
|
|
||||
|
def build_dependency_graph( |
||||
|
dependant: Dependant, |
||||
|
*, |
||||
|
_path_seen: frozenset[Any] | None = None, |
||||
|
) -> dict[str, Any]: |
||||
|
"""Serialise one Dependant node (and its entire sub-tree) to a plain dict. |
||||
|
|
||||
|
Cycle / repeat detection operates on the *current root-to-node path* only. |
||||
|
The same callable appearing in two sibling branches is NOT flagged — only |
||||
|
an actual ancestor repeat on the same path triggers ``cached_repeat: true``. |
||||
|
""" |
||||
|
if _path_seen is None: |
||||
|
_path_seen = frozenset() |
||||
|
|
||||
|
cache_key = dependant.cache_key |
||||
|
is_repeat = cache_key in _path_seen |
||||
|
|
||||
|
node: dict[str, Any] = { |
||||
|
"callable_name": _resolve_callable_name(dependant.call), |
||||
|
"callable_module": _resolve_callable_module(dependant.call), |
||||
|
"scope": dependant.computed_scope, |
||||
|
"is_yield": dependant.is_gen_callable or dependant.is_async_gen_callable, |
||||
|
"is_async": dependant.is_async_gen_callable or dependant.is_coroutine_callable, |
||||
|
"security_scopes": list(dependant.oauth_scopes) if dependant.oauth_scopes else [], |
||||
|
"is_security_scheme": dependant._is_security_scheme, |
||||
|
} |
||||
|
|
||||
|
if is_repeat: |
||||
|
# Ancestor repeat on this path — stop recursion here. |
||||
|
node["cached_repeat"] = True |
||||
|
node["sub_dependencies"] = [] |
||||
|
else: |
||||
|
# Extend the path with the current node before recursing into children. |
||||
|
extended_path = _path_seen | {cache_key} |
||||
|
node["sub_dependencies"] = [ |
||||
|
build_dependency_graph(sub, _path_seen=extended_path) |
||||
|
for sub in dependant.dependencies |
||||
|
] |
||||
|
|
||||
|
return node |
||||
|
|
||||
|
|
||||
|
def get_all_route_graphs(routes: Sequence[Any]) -> list[dict[str, Any]]: |
||||
|
"""Build the full debug payload from a list of registered routes. |
||||
|
|
||||
|
Non-APIRoute entries (e.g. Mount, plain Starlette Route) are skipped. |
||||
|
""" |
||||
|
# Import here to avoid circular imports at module load time. |
||||
|
from fastapi.routing import APIRoute |
||||
|
|
||||
|
result: list[dict[str, Any]] = [] |
||||
|
for route in routes: |
||||
|
if not isinstance(route, APIRoute): |
||||
|
continue |
||||
|
entry: dict[str, Any] = { |
||||
|
"path": route.path_format, |
||||
|
"methods": sorted(route.methods) if route.methods else [], |
||||
|
"operation_id": route.unique_id, |
||||
|
"dependency_graph": build_dependency_graph(route.dependant), |
||||
|
} |
||||
|
result.append(entry) |
||||
|
return result |
||||
@ -0,0 +1,800 @@ |
|||||
|
"""Regression tests for deterministic dependency-cleanup ordering (Task 2). |
||||
|
|
||||
|
Testing technique: every yield dep appends its own name to a closure-local list |
||||
|
*after* its yield (i.e. during cleanup). BackgroundTasks do the same. After |
||||
|
the HTTP round-trip completes the test inspects the list to assert ordering. |
||||
|
|
||||
|
The expected ordering is derived from the invariant documented in |
||||
|
``fastapi/routing.py`` (request_response) and |
||||
|
``fastapi/dependencies/utils.py`` (solve_dependencies): |
||||
|
|
||||
|
• Deps are pushed onto AsyncExitStack in dependant.dependencies iteration |
||||
|
order (route-level deps first, then signature deps left-to-right). |
||||
|
• Sub-deps of a dep are recursed *before* the parent is pushed. |
||||
|
• AsyncExitStack is LIFO → last-pushed finaliser runs first. |
||||
|
• function_stack exits BEFORE ``await response(…)``. |
||||
|
• BackgroundTasks run *inside* ``response(…)``. |
||||
|
• request_stack (inner_astack) exits AFTER ``response(…)``. |
||||
|
""" |
||||
|
|
||||
|
import json |
||||
|
import logging |
||||
|
from typing import Annotated |
||||
|
|
||||
|
from fastapi import BackgroundTasks, Depends, FastAPI, Request |
||||
|
from fastapi.responses import StreamingResponse |
||||
|
from fastapi.testclient import TestClient |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_sibling_cleanup_is_lifo |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_sibling_cleanup_is_lifo() -> None: |
||||
|
"""Two yield deps declared as (dep_a, dep_b) in the signature. |
||||
|
dep_a is pushed first, dep_b second. LIFO → dep_b cleans up first.""" |
||||
|
cleanup_order: list[str] = [] |
||||
|
|
||||
|
def dep_a(): # type: ignore[no-untyped-def] |
||||
|
yield "a" |
||||
|
cleanup_order.append("dep_a") |
||||
|
|
||||
|
def dep_b(): # type: ignore[no-untyped-def] |
||||
|
yield "b" |
||||
|
cleanup_order.append("dep_b") |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.get("/") |
||||
|
def endpoint( |
||||
|
_a: Annotated[str, Depends(dep_a)], |
||||
|
_b: Annotated[str, Depends(dep_b)], |
||||
|
) -> dict: |
||||
|
return {"ok": True} |
||||
|
|
||||
|
with TestClient(app) as client: |
||||
|
resp = client.get("/") |
||||
|
assert resp.status_code == 200 |
||||
|
assert cleanup_order == ["dep_b", "dep_a"] |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_route_level_dep_cleanup_after_signature_deps |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_route_level_dep_cleanup_after_signature_deps() -> None: |
||||
|
"""Route-level dep is prepended (pushed first) → cleaned up last.""" |
||||
|
cleanup_order: list[str] = [] |
||||
|
|
||||
|
def route_dep(): # type: ignore[no-untyped-def] |
||||
|
yield |
||||
|
cleanup_order.append("route_dep") |
||||
|
|
||||
|
def sig_dep(): # type: ignore[no-untyped-def] |
||||
|
yield |
||||
|
cleanup_order.append("sig_dep") |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.get("/", dependencies=[Depends(route_dep)]) |
||||
|
def endpoint(_s: Annotated[None, Depends(sig_dep)]) -> dict: |
||||
|
return {"ok": True} |
||||
|
|
||||
|
with TestClient(app) as client: |
||||
|
resp = client.get("/") |
||||
|
assert resp.status_code == 200 |
||||
|
# sig_dep pushed after route_dep → exits first (LIFO) |
||||
|
assert cleanup_order == ["sig_dep", "route_dep"] |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_nested_dep_cleanup_order |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_nested_dep_cleanup_order() -> None: |
||||
|
"""endpoint → dep_a (yield) → dep_b (yield). |
||||
|
dep_b is recursed first (pushed first). dep_a is pushed second. |
||||
|
LIFO: dep_a cleans up first, then dep_b.""" |
||||
|
cleanup_order: list[str] = [] |
||||
|
|
||||
|
def dep_b(): # type: ignore[no-untyped-def] |
||||
|
yield "b" |
||||
|
cleanup_order.append("dep_b") |
||||
|
|
||||
|
def dep_a(b: Annotated[str, Depends(dep_b)]): # type: ignore[no-untyped-def] |
||||
|
yield "a" |
||||
|
cleanup_order.append("dep_a") |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.get("/") |
||||
|
def endpoint(_a: Annotated[str, Depends(dep_a)]) -> dict: |
||||
|
return {"ok": True} |
||||
|
|
||||
|
with TestClient(app) as client: |
||||
|
resp = client.get("/") |
||||
|
assert resp.status_code == 200 |
||||
|
assert cleanup_order == ["dep_a", "dep_b"] |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_async_and_sync_generators_share_lifo |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_async_and_sync_generators_share_lifo() -> None: |
||||
|
"""A sync yield dep and an async yield dep, in that signature order. |
||||
|
Both land on the same stack via enter_async_context; ordering is purely |
||||
|
push order → async one (pushed second) exits first.""" |
||||
|
cleanup_order: list[str] = [] |
||||
|
|
||||
|
def dep_sync(): # type: ignore[no-untyped-def] |
||||
|
yield "sync" |
||||
|
cleanup_order.append("dep_sync") |
||||
|
|
||||
|
async def dep_async(): # type: ignore[no-untyped-def] |
||||
|
yield "async" |
||||
|
cleanup_order.append("dep_async") |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.get("/") |
||||
|
def endpoint( |
||||
|
_s: Annotated[str, Depends(dep_sync)], |
||||
|
_a: Annotated[str, Depends(dep_async)], |
||||
|
) -> dict: |
||||
|
return {"ok": True} |
||||
|
|
||||
|
with TestClient(app) as client: |
||||
|
resp = client.get("/") |
||||
|
assert resp.status_code == 200 |
||||
|
assert cleanup_order == ["dep_async", "dep_sync"] |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_cleanup_runs_on_endpoint_exception |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_cleanup_runs_on_endpoint_exception() -> None: |
||||
|
"""All yield-dep finalisers still run even when the endpoint raises.""" |
||||
|
cleanup_order: list[str] = [] |
||||
|
|
||||
|
def dep_a(): # type: ignore[no-untyped-def] |
||||
|
try: |
||||
|
yield "a" |
||||
|
finally: |
||||
|
cleanup_order.append("dep_a") |
||||
|
|
||||
|
def dep_b(): # type: ignore[no-untyped-def] |
||||
|
try: |
||||
|
yield "b" |
||||
|
finally: |
||||
|
cleanup_order.append("dep_b") |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.get("/") |
||||
|
def endpoint( |
||||
|
_a: Annotated[str, Depends(dep_a)], |
||||
|
_b: Annotated[str, Depends(dep_b)], |
||||
|
) -> dict: |
||||
|
raise RuntimeError("endpoint boom") |
||||
|
|
||||
|
# raise_server_exceptions=False so the 500 doesn't propagate into the test |
||||
|
with TestClient(app, raise_server_exceptions=False) as client: |
||||
|
resp = client.get("/") |
||||
|
assert resp.status_code == 500 |
||||
|
# Both deps cleaned up, in LIFO order |
||||
|
assert cleanup_order == ["dep_b", "dep_a"] |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_function_scope_cleans_before_response_streaming |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_function_scope_cleans_before_response_streaming() -> None: |
||||
|
"""A function-scoped yield dep's finaliser runs *before* the response |
||||
|
body is streamed. The streaming iterator can observe the side-effect.""" |
||||
|
flag: dict[str, bool] = {"cleaned": False} |
||||
|
|
||||
|
def func_dep(): # type: ignore[no-untyped-def] |
||||
|
yield |
||||
|
flag["cleaned"] = True |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.get("/stream") |
||||
|
def stream_endpoint(_: Annotated[None, Depends(func_dep, scope="function")]) -> StreamingResponse: # type: ignore[no-untyped-def] |
||||
|
def body(): # type: ignore[no-untyped-def] |
||||
|
# By the time this runs, function_stack has already exited |
||||
|
yield json.dumps({"cleaned": flag["cleaned"]}) |
||||
|
|
||||
|
return StreamingResponse(body(), media_type="application/json") |
||||
|
|
||||
|
with TestClient(app) as client: |
||||
|
resp = client.get("/stream") |
||||
|
assert resp.status_code == 200 |
||||
|
assert resp.json()["cleaned"] is True |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_request_scope_cleans_after_response_streaming |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_request_scope_cleans_after_response_streaming() -> None: |
||||
|
"""A request-scoped (default) yield dep's finaliser runs *after* the |
||||
|
response body is streamed. The streaming iterator sees the flag as False.""" |
||||
|
flag: dict[str, bool] = {"cleaned": False} |
||||
|
|
||||
|
def req_dep(): # type: ignore[no-untyped-def] |
||||
|
yield |
||||
|
flag["cleaned"] = True |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.get("/stream") |
||||
|
def stream_endpoint(_: Annotated[None, Depends(req_dep)]) -> StreamingResponse: # type: ignore[no-untyped-def] |
||||
|
def body(): # type: ignore[no-untyped-def] |
||||
|
# request_stack has NOT exited yet while the stream runs |
||||
|
yield json.dumps({"cleaned": flag["cleaned"]}) |
||||
|
|
||||
|
return StreamingResponse(body(), media_type="application/json") |
||||
|
|
||||
|
with TestClient(app) as client: |
||||
|
resp = client.get("/stream") |
||||
|
assert resp.status_code == 200 |
||||
|
assert resp.json()["cleaned"] is False |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_background_task_runs_between_scopes |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_background_task_runs_between_scopes() -> None: |
||||
|
"""The three phases interleave in exactly this order: |
||||
|
1. function-scoped finalisers (function_stack exits) |
||||
|
2. BackgroundTasks (inside response.__call__) |
||||
|
3. request-scoped finalisers (request_stack exits) |
||||
|
""" |
||||
|
cleanup_order: list[str] = [] |
||||
|
|
||||
|
def func_dep(): # type: ignore[no-untyped-def] |
||||
|
yield |
||||
|
cleanup_order.append("func_cleanup") |
||||
|
|
||||
|
def req_dep(): # type: ignore[no-untyped-def] |
||||
|
yield |
||||
|
cleanup_order.append("req_cleanup") |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.get("/") |
||||
|
def endpoint( |
||||
|
_f: Annotated[None, Depends(func_dep, scope="function")], |
||||
|
_r: Annotated[None, Depends(req_dep, scope="request")], |
||||
|
bg: BackgroundTasks, |
||||
|
) -> dict: |
||||
|
bg.add_task(lambda: cleanup_order.append("bg_task")) |
||||
|
return {"ok": True} |
||||
|
|
||||
|
with TestClient(app) as client: |
||||
|
resp = client.get("/") |
||||
|
assert resp.status_code == 200 |
||||
|
assert cleanup_order == ["func_cleanup", "bg_task", "req_cleanup"] |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_cleanup_order_metadata_populated |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_cleanup_order_metadata_populated() -> None: |
||||
|
"""scope["fastapi_dependency_cleanup_order"] is populated with one entry |
||||
|
per generator dep that was successfully pushed onto a stack.""" |
||||
|
captured: list[list[dict]] = [] # type: ignore[type-arg] |
||||
|
|
||||
|
async def capture(request: Request): # type: ignore[no-untyped-def] |
||||
|
yield |
||||
|
# By the time this finaliser runs, all pushes have been recorded. |
||||
|
captured.append( |
||||
|
list(request.scope.get("fastapi_dependency_cleanup_order", [])) |
||||
|
) |
||||
|
|
||||
|
def dep_a(): # type: ignore[no-untyped-def] |
||||
|
yield "a" |
||||
|
|
||||
|
def dep_b(): # type: ignore[no-untyped-def] |
||||
|
yield "b" |
||||
|
|
||||
|
app = FastAPI(dependency_debug_url="/_debug/deps") |
||||
|
|
||||
|
@app.get("/") |
||||
|
def endpoint( |
||||
|
_cap: Annotated[None, Depends(capture)], |
||||
|
_a: Annotated[str, Depends(dep_a)], |
||||
|
_b: Annotated[str, Depends(dep_b)], |
||||
|
) -> dict: |
||||
|
return {"ok": True} |
||||
|
|
||||
|
with TestClient(app) as client: |
||||
|
resp = client.get("/") |
||||
|
assert resp.status_code == 200 |
||||
|
|
||||
|
assert len(captured) == 1 |
||||
|
metadata = captured[0] |
||||
|
|
||||
|
# Three generators were pushed: capture, dep_a, dep_b |
||||
|
assert len(metadata) == 3 |
||||
|
|
||||
|
# Order indices are monotonically increasing from 0 |
||||
|
assert [m["order"] for m in metadata] == [0, 1, 2] |
||||
|
|
||||
|
# Names match push order (signature order) |
||||
|
assert metadata[0]["callable_name"] == "capture" |
||||
|
assert metadata[1]["callable_name"] == "dep_a" |
||||
|
assert metadata[2]["callable_name"] == "dep_b" |
||||
|
|
||||
|
# All are request-scoped (no explicit scope on generators → default "request") |
||||
|
for m in metadata: |
||||
|
assert m["scope"] == "request" |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_cleanup_order_metadata_survives_exception |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_cleanup_order_metadata_survives_exception() -> None: |
||||
|
"""If a generator dep raises *before* its yield (setup failure), it is |
||||
|
never pushed onto the stack and therefore never recorded. Deps that were |
||||
|
successfully pushed before it still appear in the metadata.""" |
||||
|
captured: list[list[dict]] = [] # type: ignore[type-arg] |
||||
|
|
||||
|
async def capture(request: Request): # type: ignore[no-untyped-def] |
||||
|
try: |
||||
|
yield |
||||
|
finally: |
||||
|
captured.append( |
||||
|
list(request.scope.get("fastapi_dependency_cleanup_order", [])) |
||||
|
) |
||||
|
|
||||
|
def dep_ok(): # type: ignore[no-untyped-def] |
||||
|
yield "ok" |
||||
|
|
||||
|
def dep_raises(): # type: ignore[no-untyped-def] |
||||
|
raise ValueError("setup failure") |
||||
|
yield "never" # pragma: no cover # noqa: RET503 |
||||
|
|
||||
|
app = FastAPI(dependency_debug_url="/_debug/deps") |
||||
|
|
||||
|
@app.get("/") |
||||
|
def endpoint( |
||||
|
_cap: Annotated[None, Depends(capture)], |
||||
|
_ok: Annotated[str, Depends(dep_ok)], |
||||
|
_bad: Annotated[str, Depends(dep_raises)], |
||||
|
) -> dict: |
||||
|
return {"ok": True} # pragma: no cover |
||||
|
|
||||
|
with TestClient(app, raise_server_exceptions=False) as client: |
||||
|
resp = client.get("/") |
||||
|
# The ValueError propagates → 500 |
||||
|
assert resp.status_code == 500 |
||||
|
|
||||
|
# capture and dep_ok were pushed; dep_raises was not |
||||
|
assert len(captured) == 1 |
||||
|
metadata = captured[0] |
||||
|
assert len(metadata) == 2 |
||||
|
names = [m["callable_name"] for m in metadata] |
||||
|
assert "capture" in names |
||||
|
assert "dep_ok" in names |
||||
|
assert "dep_raises" not in names |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_use_cache_false_shared_dep_cleanup_runs_per_encounter |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_use_cache_false_shared_dep_cleanup_runs_per_encounter() -> None: |
||||
|
"""use_cache=False on a shared yield dep bypasses the cache check |
||||
|
(utils.py:674) on every encounter. Each encounter calls |
||||
|
enter_async_context independently → one push per encounter, one |
||||
|
finaliser per push. The two finalisers are NOT adjacent in the LIFO |
||||
|
sequence; they are interleaved with the branch deps that triggered them. |
||||
|
|
||||
|
Expected full event sequence (setup + cleanup): |
||||
|
shared:setup branch_a:setup shared:setup branch_b:setup |
||||
|
branch_b:cleanup shared:cleanup branch_a:cleanup shared:cleanup |
||||
|
|
||||
|
Failure mode guarded: an optimisation that deduplicates |
||||
|
enter_async_context calls regardless of use_cache would collapse the |
||||
|
two shared finalisers into one, silently dropping cleanup work.""" |
||||
|
order: list[str] = [] |
||||
|
|
||||
|
def shared(): # type: ignore[no-untyped-def] |
||||
|
order.append("shared:setup") |
||||
|
yield "s" |
||||
|
order.append("shared:cleanup") |
||||
|
|
||||
|
def branch_a(s: Annotated[str, Depends(shared, use_cache=False)]): # type: ignore[no-untyped-def] |
||||
|
order.append("branch_a:setup") |
||||
|
yield "a" |
||||
|
order.append("branch_a:cleanup") |
||||
|
|
||||
|
def branch_b(s: Annotated[str, Depends(shared, use_cache=False)]): # type: ignore[no-untyped-def] |
||||
|
order.append("branch_b:setup") |
||||
|
yield "b" |
||||
|
order.append("branch_b:cleanup") |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.get("/") |
||||
|
def endpoint( |
||||
|
_a: Annotated[str, Depends(branch_a)], |
||||
|
_b: Annotated[str, Depends(branch_b)], |
||||
|
) -> dict: |
||||
|
return {} |
||||
|
|
||||
|
with TestClient(app) as client: |
||||
|
resp = client.get("/") |
||||
|
assert resp.status_code == 200 |
||||
|
assert order == [ |
||||
|
"shared:setup", |
||||
|
"branch_a:setup", |
||||
|
"shared:setup", |
||||
|
"branch_b:setup", |
||||
|
"branch_b:cleanup", |
||||
|
"shared:cleanup", |
||||
|
"branch_a:cleanup", |
||||
|
"shared:cleanup", |
||||
|
] |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_background_task_observes_function_scoped_resource_as_invalidated |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_background_task_observes_function_scoped_resource_as_invalidated() -> None: |
||||
|
"""function_stack exits before response() is called. BackgroundTasks |
||||
|
execute inside response(). A function-scoped dep that invalidates a |
||||
|
shared mutable resource during its cleanup does so *before* any |
||||
|
BackgroundTask reads it. |
||||
|
|
||||
|
Expected state seen by BackgroundTask: 'closed' (not 'open'). |
||||
|
|
||||
|
Failure mode guarded: moving ``await response(…)`` inside the |
||||
|
function_stack async-with block would make function-scoped resources |
||||
|
still alive during BackgroundTask execution, masking use-after-close |
||||
|
bugs in production.""" |
||||
|
state: dict[str, str] = {} |
||||
|
bg_saw: list[str] = [] |
||||
|
|
||||
|
def func_dep(): # type: ignore[no-untyped-def] |
||||
|
state["status"] = "open" |
||||
|
yield state |
||||
|
state["status"] = "closed" |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.get("/") |
||||
|
def endpoint( |
||||
|
res: Annotated[dict, Depends(func_dep, scope="function")], |
||||
|
bg: BackgroundTasks, |
||||
|
) -> dict: |
||||
|
bg.add_task(lambda: bg_saw.append(state["status"])) |
||||
|
return {} |
||||
|
|
||||
|
with TestClient(app) as client: |
||||
|
resp = client.get("/") |
||||
|
assert resp.status_code == 200 |
||||
|
assert bg_saw == ["closed"] |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_function_scope_cleanup_can_enqueue_background_task |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_function_scope_cleanup_can_enqueue_background_task() -> None: |
||||
|
"""function_stack exits before BackgroundTasks.__call__ snapshots |
||||
|
self.tasks and sets _executed. A function-scoped dep's cleanup code |
||||
|
that calls bg.add_task() runs before the snapshot → the task is |
||||
|
captured and executed. |
||||
|
|
||||
|
Expected run order: endpoint, from_func_cleanup. |
||||
|
|
||||
|
Failure mode guarded: moving the _executed flag or the task snapshot |
||||
|
to before function_stack exit would silently drop tasks enqueued |
||||
|
during function-scoped cleanup.""" |
||||
|
ran: list[str] = [] |
||||
|
|
||||
|
def func_dep(bg: BackgroundTasks): # type: ignore[no-untyped-def] |
||||
|
yield |
||||
|
bg.add_task(lambda: ran.append("from_func_cleanup")) |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.get("/") |
||||
|
def endpoint(_f: Annotated[None, Depends(func_dep, scope="function")]) -> dict: |
||||
|
ran.append("endpoint") |
||||
|
return {} |
||||
|
|
||||
|
with TestClient(app) as client: |
||||
|
resp = client.get("/") |
||||
|
assert resp.status_code == 200 |
||||
|
assert ran == ["endpoint", "from_func_cleanup"] |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_request_scope_cleanup_cannot_enqueue_background_task |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_request_scope_cleanup_cannot_enqueue_background_task(caplog) -> None: |
||||
|
"""request_stack exits after BackgroundTasks.__call__ has already set |
||||
|
_executed=True. A request-scoped dep's cleanup call to add_task hits |
||||
|
the guard in background.py, logs a warning, and the task is never |
||||
|
added to the list. |
||||
|
|
||||
|
Expected: 'from_req_cleanup' does NOT appear in ran; a log warning |
||||
|
containing 'already been executed' is emitted. |
||||
|
|
||||
|
Failure mode guarded: removing or deferring the _executed guard would |
||||
|
allow request-scoped cleanup to silently enqueue tasks that execute |
||||
|
in an undefined order relative to the response lifecycle.""" |
||||
|
ran: list[str] = [] |
||||
|
|
||||
|
def req_dep(bg: BackgroundTasks): # type: ignore[no-untyped-def] |
||||
|
yield |
||||
|
bg.add_task(lambda: ran.append("from_req_cleanup")) |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.get("/") |
||||
|
def endpoint(_r: Annotated[None, Depends(req_dep)]) -> dict: |
||||
|
ran.append("endpoint") |
||||
|
return {} |
||||
|
|
||||
|
with TestClient(app) as client: |
||||
|
with caplog.at_level(logging.WARNING, logger="fastapi.background"): |
||||
|
resp = client.get("/") |
||||
|
assert resp.status_code == 200 |
||||
|
assert "from_req_cleanup" not in ran |
||||
|
assert any("already been executed" in r.message for r in caplog.records) |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_transitive_scope_leak_through_plain_wrapper |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_transitive_scope_leak_through_plain_wrapper() -> None: |
||||
|
"""The DependencyScopeError guard (utils.py:284-287) checks only the |
||||
|
direct child's scope field. A plain (non-generator) function inserted |
||||
|
between a request-scoped generator parent and a function-scoped |
||||
|
generator grandchild has scope=None; the guard does not fire. The |
||||
|
plain wrapper returns the same object the function-scoped dep yielded. |
||||
|
By the time the request-scoped parent's cleanup runs, the |
||||
|
function-scoped grandchild has already invalidated that object. |
||||
|
|
||||
|
Expected: route registers without DependencyScopeError; request_dep's |
||||
|
cleanup observes resource status == 'closed'. |
||||
|
|
||||
|
Failure mode guarded (documentation): this test documents a known |
||||
|
single-depth limitation. If transitive scope validation is added, |
||||
|
this test must be updated to reflect the new behaviour.""" |
||||
|
cleanup_saw: list[str] = [] |
||||
|
resource: dict[str, str] = {} |
||||
|
|
||||
|
def func_dep(): # type: ignore[no-untyped-def] |
||||
|
resource["status"] = "open" |
||||
|
yield resource |
||||
|
resource["status"] = "closed" |
||||
|
|
||||
|
def plain_wrapper( |
||||
|
res: Annotated[dict, Depends(func_dep, scope="function")], |
||||
|
) -> dict: |
||||
|
return res # pass-through; not a generator |
||||
|
|
||||
|
def request_dep(res: Annotated[dict, Depends(plain_wrapper)]): # type: ignore[no-untyped-def] |
||||
|
yield |
||||
|
cleanup_saw.append(res["status"]) |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.get("/") |
||||
|
def endpoint(_r: Annotated[None, Depends(request_dep)]) -> dict: |
||||
|
return {} |
||||
|
|
||||
|
with TestClient(app) as client: |
||||
|
resp = client.get("/") |
||||
|
assert resp.status_code == 200 |
||||
|
assert cleanup_saw == ["closed"] |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_multiple_route_deps_exit_in_reverse_declaration_order |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_multiple_route_deps_exit_in_reverse_declaration_order() -> None: |
||||
|
"""Route-level deps are prepended via ``dependencies[::-1]`` + |
||||
|
``insert(0, …)`` (routing.py:479-483), which preserves their original |
||||
|
declaration order at the front of dependant.dependencies. They are |
||||
|
therefore pushed first and exit last, in reverse declaration order |
||||
|
(LIFO). |
||||
|
|
||||
|
Declaration order: r1, r2, r3. |
||||
|
Push order: r1, r2, r3. |
||||
|
Cleanup order: r3, r2, r1. |
||||
|
|
||||
|
Failure mode guarded: changing the reversal + insert pattern (e.g., |
||||
|
a plain append loop) would reverse the declaration order of route |
||||
|
deps at the front of the list, producing cleanup order r1, r2, r3.""" |
||||
|
order: list[str] = [] |
||||
|
|
||||
|
def r1(): # type: ignore[no-untyped-def] |
||||
|
yield |
||||
|
order.append("r1") |
||||
|
|
||||
|
def r2(): # type: ignore[no-untyped-def] |
||||
|
yield |
||||
|
order.append("r2") |
||||
|
|
||||
|
def r3(): # type: ignore[no-untyped-def] |
||||
|
yield |
||||
|
order.append("r3") |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.get("/", dependencies=[Depends(r1), Depends(r2), Depends(r3)]) |
||||
|
def endpoint() -> dict: |
||||
|
return {} |
||||
|
|
||||
|
with TestClient(app) as client: |
||||
|
resp = client.get("/") |
||||
|
assert resp.status_code == 200 |
||||
|
assert order == ["r3", "r2", "r1"] |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_shared_dep_cleanup_position_is_first_dfs_encounter |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_shared_dep_cleanup_position_is_first_dfs_encounter() -> None: |
||||
|
"""With use_cache=True (the default) a shared dep is pushed exactly |
||||
|
once — during the first branch whose DFS recursion reaches it. |
||||
|
Subsequent encounters hit the cache (utils.py:674) and skip the push. |
||||
|
The shared dep's cleanup position in the LIFO sequence is therefore |
||||
|
anchored to whichever branch appears first in the signature, not to |
||||
|
where the shared dep is declared. |
||||
|
|
||||
|
Both sub-cases below put shared as the deepest node. Swapping which |
||||
|
branch is listed first swaps the two branches' cleanup positions but |
||||
|
shared always exits last (it was pushed first). |
||||
|
|
||||
|
Failure mode guarded: changing cache-store or cache-check semantics |
||||
|
(e.g., storing only when use_cache=True) would move the shared dep's |
||||
|
cleanup to a different position depending on encounter order.""" |
||||
|
|
||||
|
# ── sub-case: branch_a listed first ── |
||||
|
order_a: list[str] = [] |
||||
|
|
||||
|
def shared_a(): # type: ignore[no-untyped-def] |
||||
|
yield "s" |
||||
|
order_a.append("shared") |
||||
|
|
||||
|
def ba(s: Annotated[str, Depends(shared_a)]): # type: ignore[no-untyped-def] |
||||
|
yield "a" |
||||
|
order_a.append("branch_a") |
||||
|
|
||||
|
def bb_a(s: Annotated[str, Depends(shared_a)]): # type: ignore[no-untyped-def] |
||||
|
yield "b" |
||||
|
order_a.append("branch_b") |
||||
|
|
||||
|
app_a = FastAPI() |
||||
|
|
||||
|
@app_a.get("/") |
||||
|
def ep_a( |
||||
|
_a: Annotated[str, Depends(ba)], |
||||
|
_b: Annotated[str, Depends(bb_a)], |
||||
|
) -> dict: |
||||
|
return {} |
||||
|
|
||||
|
with TestClient(app_a) as client: |
||||
|
client.get("/") |
||||
|
# push: shared, branch_a, branch_b → LIFO: branch_b, branch_a, shared |
||||
|
assert order_a == ["branch_b", "branch_a", "shared"] |
||||
|
|
||||
|
# ── sub-case: branch_b listed first ── |
||||
|
order_b: list[str] = [] |
||||
|
|
||||
|
def shared_b(): # type: ignore[no-untyped-def] |
||||
|
yield "s" |
||||
|
order_b.append("shared") |
||||
|
|
||||
|
def ba_b(s: Annotated[str, Depends(shared_b)]): # type: ignore[no-untyped-def] |
||||
|
yield "a" |
||||
|
order_b.append("branch_a") |
||||
|
|
||||
|
def bb(s: Annotated[str, Depends(shared_b)]): # type: ignore[no-untyped-def] |
||||
|
yield "b" |
||||
|
order_b.append("branch_b") |
||||
|
|
||||
|
app_b = FastAPI() |
||||
|
|
||||
|
@app_b.get("/") |
||||
|
def ep_b( |
||||
|
_b: Annotated[str, Depends(bb)], |
||||
|
_a: Annotated[str, Depends(ba_b)], |
||||
|
) -> dict: |
||||
|
return {} |
||||
|
|
||||
|
with TestClient(app_b) as client: |
||||
|
client.get("/") |
||||
|
# push: shared, branch_b, branch_a → LIFO: branch_a, branch_b, shared |
||||
|
assert order_b == ["branch_a", "branch_b", "shared"] |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_dep_setup_failure_partial_cleanup |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_dep_setup_failure_partial_cleanup() -> None: |
||||
|
"""When a dep raises before its yield (setup failure) the exception |
||||
|
propagates out of solve_dependencies. Deps that were already pushed |
||||
|
onto the stack before the failing dep are unwound normally (LIFO). |
||||
|
Deps declared after the failing dep in the signature are never |
||||
|
reached and never pushed — their cleanup does not run. |
||||
|
|
||||
|
Signature order: dep_ok_1, dep_boom, dep_ok_2. |
||||
|
dep_ok_1 is pushed. dep_boom raises during setup — never pushed. |
||||
|
dep_ok_2 is never reached. |
||||
|
|
||||
|
Expected cleanup set: dep_ok_1 only. |
||||
|
|
||||
|
Failure mode guarded: an 'all or nothing' error handler that skips |
||||
|
stack unwinding on setup failure would prevent dep_ok_1 from |
||||
|
cleaning up, leaking its resource.""" |
||||
|
order: list[str] = [] |
||||
|
|
||||
|
def dep_ok_1(): # type: ignore[no-untyped-def] |
||||
|
try: |
||||
|
yield "ok1" |
||||
|
finally: |
||||
|
order.append("dep_ok_1:cleanup") |
||||
|
|
||||
|
def dep_boom(): # type: ignore[no-untyped-def] |
||||
|
raise RuntimeError("setup boom") |
||||
|
yield # pragma: no cover # noqa: RET503 |
||||
|
|
||||
|
def dep_ok_2(): # type: ignore[no-untyped-def] |
||||
|
try: |
||||
|
yield "ok2" |
||||
|
finally: |
||||
|
order.append("dep_ok_2:cleanup") |
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.get("/") |
||||
|
def endpoint( |
||||
|
_a: Annotated[str, Depends(dep_ok_1)], |
||||
|
_b: Annotated[str, Depends(dep_boom)], |
||||
|
_c: Annotated[str, Depends(dep_ok_2)], |
||||
|
) -> dict: |
||||
|
return {} # pragma: no cover |
||||
|
|
||||
|
with TestClient(app, raise_server_exceptions=False) as client: |
||||
|
resp = client.get("/") |
||||
|
assert resp.status_code == 500 |
||||
|
assert order == ["dep_ok_1:cleanup"] |
||||
@ -0,0 +1,341 @@ |
|||||
|
"""Tests for the dependency-graph debug endpoint (Task 1).""" |
||||
|
|
||||
|
import functools |
||||
|
from typing import Annotated |
||||
|
|
||||
|
from fastapi import Depends, FastAPI, Security |
||||
|
from fastapi.security import OAuth2PasswordBearer |
||||
|
from fastapi.testclient import TestClient |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Helpers reused across tests |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
_DEBUG_PATH = "/_debug/deps" |
||||
|
|
||||
|
|
||||
|
def _app(**kwargs) -> FastAPI: # type: ignore[no-untyped-def] |
||||
|
return FastAPI(dependency_debug_url=_DEBUG_PATH, **kwargs) |
||||
|
|
||||
|
|
||||
|
def _find_route(payload: dict, path: str) -> dict: # type: ignore[no-untyped-def] |
||||
|
for r in payload["routes"]: |
||||
|
if r["path"] == path: |
||||
|
return r |
||||
|
raise AssertionError(f"Route {path!r} not found in debug payload") # pragma: no cover |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_debug_disabled_by_default |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_debug_disabled_by_default() -> None: |
||||
|
app = FastAPI() # dependency_debug_url not set → None |
||||
|
|
||||
|
@app.get("/hello") |
||||
|
def hello() -> dict: |
||||
|
return {"hi": True} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
# The default path and a few guesses must all 404 |
||||
|
assert client.get("/_debug/deps").status_code == 404 |
||||
|
assert client.get("/_debug/dependencies").status_code == 404 |
||||
|
assert client.get("/debug").status_code == 404 |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_debug_enabled_returns_200 |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_debug_enabled_returns_200() -> None: |
||||
|
app = _app() |
||||
|
|
||||
|
@app.get("/hello") |
||||
|
def hello() -> dict: |
||||
|
return {"hi": True} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
resp = client.get(_DEBUG_PATH) |
||||
|
assert resp.status_code == 200 |
||||
|
|
||||
|
payload = resp.json() |
||||
|
assert "routes" in payload |
||||
|
route = _find_route(payload, "/hello") |
||||
|
assert "GET" in route["methods"] |
||||
|
assert route["dependency_graph"]["callable_name"] == "hello" |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_debug_nested_three_levels |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_debug_nested_three_levels() -> None: |
||||
|
def dep_c() -> str: |
||||
|
return "c" |
||||
|
|
||||
|
def dep_b(c: Annotated[str, Depends(dep_c)]) -> str: |
||||
|
return "b" |
||||
|
|
||||
|
def dep_a(b: Annotated[str, Depends(dep_b)]) -> str: |
||||
|
return "a" |
||||
|
|
||||
|
app = _app() |
||||
|
|
||||
|
@app.get("/nested") |
||||
|
def nested(a: Annotated[str, Depends(dep_a)]) -> dict: |
||||
|
return {"a": a} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
route = _find_route(client.get(_DEBUG_PATH).json(), "/nested") |
||||
|
graph = route["dependency_graph"] |
||||
|
|
||||
|
# Root is the endpoint itself |
||||
|
assert graph["callable_name"] == "nested" |
||||
|
|
||||
|
# Level 1: dep_a |
||||
|
assert len(graph["sub_dependencies"]) == 1 |
||||
|
a_node = graph["sub_dependencies"][0] |
||||
|
assert a_node["callable_name"] == "dep_a" |
||||
|
|
||||
|
# Level 2: dep_b |
||||
|
assert len(a_node["sub_dependencies"]) == 1 |
||||
|
b_node = a_node["sub_dependencies"][0] |
||||
|
assert b_node["callable_name"] == "dep_b" |
||||
|
|
||||
|
# Level 3: dep_c — leaf |
||||
|
assert len(b_node["sub_dependencies"]) == 1 |
||||
|
c_node = b_node["sub_dependencies"][0] |
||||
|
assert c_node["callable_name"] == "dep_c" |
||||
|
assert c_node["sub_dependencies"] == [] |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_debug_yield_dep_flagged |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_debug_yield_dep_flagged() -> None: |
||||
|
def plain_dep() -> str: |
||||
|
return "plain" |
||||
|
|
||||
|
def yield_dep(): # type: ignore[no-untyped-def] |
||||
|
yield "yielded" |
||||
|
|
||||
|
app = _app() |
||||
|
|
||||
|
@app.get("/mix") |
||||
|
def mix( |
||||
|
p: Annotated[str, Depends(plain_dep)], |
||||
|
y: Annotated[str, Depends(yield_dep)], |
||||
|
) -> dict: |
||||
|
return {} |
||||
|
|
||||
|
route = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/mix") |
||||
|
subs = route["dependency_graph"]["sub_dependencies"] |
||||
|
|
||||
|
# Order matches signature order: plain first, yield second |
||||
|
plain_node = subs[0] |
||||
|
yield_node = subs[1] |
||||
|
|
||||
|
assert plain_node["callable_name"] == "plain_dep" |
||||
|
assert plain_node["is_yield"] is False |
||||
|
|
||||
|
assert yield_node["callable_name"] == "yield_dep" |
||||
|
assert yield_node["is_yield"] is True |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_debug_async_generator_flagged |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_debug_async_generator_flagged() -> None: |
||||
|
async def async_gen_dep(): # type: ignore[no-untyped-def] |
||||
|
yield "async" |
||||
|
|
||||
|
app = _app() |
||||
|
|
||||
|
@app.get("/agen") |
||||
|
def agen(v: Annotated[str, Depends(async_gen_dep)]) -> dict: |
||||
|
return {} |
||||
|
|
||||
|
node = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/agen")["dependency_graph"] |
||||
|
sub = node["sub_dependencies"][0] |
||||
|
|
||||
|
assert sub["callable_name"] == "async_gen_dep" |
||||
|
assert sub["is_yield"] is True |
||||
|
assert sub["is_async"] is True |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_debug_security_scopes |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_debug_security_scopes() -> None: |
||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") |
||||
|
|
||||
|
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> dict: |
||||
|
return {"user": "me"} |
||||
|
|
||||
|
app = _app() |
||||
|
|
||||
|
@app.get("/protected") |
||||
|
def protected( |
||||
|
user: Annotated[dict, Security(get_current_user, scopes=["read", "admin"])], |
||||
|
) -> dict: |
||||
|
return user |
||||
|
|
||||
|
route = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/protected") |
||||
|
# The Security node is a direct sub of the endpoint root |
||||
|
sec_node = route["dependency_graph"]["sub_dependencies"][0] |
||||
|
assert sec_node["callable_name"] == "get_current_user" |
||||
|
assert "read" in sec_node["security_scopes"] |
||||
|
assert "admin" in sec_node["security_scopes"] |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_debug_security_and_depends_siblings |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_debug_security_and_depends_siblings() -> None: |
||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") |
||||
|
|
||||
|
def plain_dep() -> str: |
||||
|
return "plain" |
||||
|
|
||||
|
app = _app() |
||||
|
|
||||
|
@app.get("/siblings") |
||||
|
def siblings( |
||||
|
token: Annotated[str, Security(oauth2_scheme, scopes=["x"])], |
||||
|
plain: Annotated[str, Depends(plain_dep)], |
||||
|
) -> dict: |
||||
|
return {} |
||||
|
|
||||
|
route = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/siblings") |
||||
|
subs = route["dependency_graph"]["sub_dependencies"] |
||||
|
|
||||
|
# First sub is the Security dep (oauth2_scheme instance) |
||||
|
sec_sub = subs[0] |
||||
|
assert sec_sub["is_security_scheme"] is True |
||||
|
|
||||
|
# Second sub is the plain Depends |
||||
|
plain_sub = subs[1] |
||||
|
assert plain_sub["callable_name"] == "plain_dep" |
||||
|
assert plain_sub["is_security_scheme"] is False |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_debug_route_level_deps_appear |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_debug_route_level_deps_appear() -> None: |
||||
|
def route_dep() -> None: |
||||
|
pass |
||||
|
|
||||
|
app = _app() |
||||
|
|
||||
|
@app.get("/with-route-dep", dependencies=[Depends(route_dep)]) |
||||
|
def with_route_dep() -> dict: |
||||
|
return {} |
||||
|
|
||||
|
route = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/with-route-dep") |
||||
|
subs = route["dependency_graph"]["sub_dependencies"] |
||||
|
|
||||
|
# route_dep is prepended, so it is the first child |
||||
|
assert subs[0]["callable_name"] == "route_dep" |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_debug_partial_callable_unwrapped |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_debug_partial_callable_unwrapped() -> None: |
||||
|
def real_function(x: int = 1) -> int: |
||||
|
return x |
||||
|
|
||||
|
partial_dep = functools.partial(real_function, x=42) |
||||
|
|
||||
|
app = _app() |
||||
|
|
||||
|
@app.get("/partial") |
||||
|
def partial_endpoint(v: Annotated[int, Depends(partial_dep)]) -> dict: |
||||
|
return {"v": v} |
||||
|
|
||||
|
route = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/partial") |
||||
|
sub = route["dependency_graph"]["sub_dependencies"][0] |
||||
|
|
||||
|
# Must resolve through the partial to the underlying function name |
||||
|
assert sub["callable_name"] == "real_function" |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_debug_class_based_dep |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_debug_class_based_dep() -> None: |
||||
|
class MyDep: |
||||
|
def __call__(self) -> str: |
||||
|
return "class-dep" |
||||
|
|
||||
|
app = _app() |
||||
|
|
||||
|
@app.get("/classdep") |
||||
|
def classdep(v: Annotated[str, Depends(MyDep)]) -> dict: |
||||
|
return {"v": v} |
||||
|
|
||||
|
route = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/classdep") |
||||
|
sub = route["dependency_graph"]["sub_dependencies"][0] |
||||
|
|
||||
|
assert sub["callable_name"] == "MyDep" |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# test_debug_cached_repeat_detection |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
def test_debug_cached_repeat_detection() -> None: |
||||
|
"""A shared dep used by two sibling branches must appear fully in both |
||||
|
(it is NOT on the same root→leaf path). This test verifies no spurious |
||||
|
``cached_repeat`` flag is set in that scenario.""" |
||||
|
|
||||
|
def shared() -> str: |
||||
|
return "shared" |
||||
|
|
||||
|
def branch_a(s: Annotated[str, Depends(shared)]) -> str: |
||||
|
return "a" |
||||
|
|
||||
|
def branch_b(s: Annotated[str, Depends(shared)]) -> str: |
||||
|
return "b" |
||||
|
|
||||
|
app = _app() |
||||
|
|
||||
|
@app.get("/shared") |
||||
|
def endpoint( |
||||
|
a: Annotated[str, Depends(branch_a)], |
||||
|
b: Annotated[str, Depends(branch_b)], |
||||
|
) -> dict: |
||||
|
return {} |
||||
|
|
||||
|
route = _find_route(TestClient(app).get(_DEBUG_PATH).json(), "/shared") |
||||
|
subs = route["dependency_graph"]["sub_dependencies"] |
||||
|
|
||||
|
# branch_a's child "shared" should be fully expanded (no cached_repeat) |
||||
|
shared_under_a = subs[0]["sub_dependencies"][0] |
||||
|
assert shared_under_a["callable_name"] == "shared" |
||||
|
assert "cached_repeat" not in shared_under_a |
||||
|
|
||||
|
# branch_b's child "shared" should also be fully expanded |
||||
|
shared_under_b = subs[1]["sub_dependencies"][0] |
||||
|
assert shared_under_b["callable_name"] == "shared" |
||||
|
assert "cached_repeat" not in shared_under_b |
||||
Loading…
Reference in new issue