You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

96 lines
3.5 KiB

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