routing.py•9.93 kB
from __future__ import annotations
import inspect
import re
from collections.abc import Iterable
from enum import Enum
from pathlib import Path
from typing import Any
from typing import Literal
from django.urls import get_resolver
from django.urls.resolvers import URLPattern
from django.urls.resolvers import URLResolver
from pydantic import BaseModel
class ViewType(Enum):
CLASS = "class"
FUNCTION = "function"
class ViewMethod(Enum):
DELETE = "DELETE"
GET = "GET"
HEAD = "HEAD"
PATCH = "PATCH"
POST = "POST"
PUT = "PUT"
OPTIONS = "OPTIONS"
TRACE = "TRACE"
class FunctionViewSchema(BaseModel):
"""Schema for function-based view.
Fields:
name: Fully qualified view name (module.function)
type: Always ViewType.FUNCTION
source_path: Path to source file, or Path("unknown")
methods: List of allowed HTTP methods. Empty list indicates methods
could not be determined. Django's built-in method decorators
(@require_GET, @require_POST, @require_http_methods) are
automatically detected via closure inspection.
"""
name: str
type: Literal[ViewType.FUNCTION]
source_path: Path
methods: list[ViewMethod]
@classmethod
def from_callback(cls, callback: Any):
view_func = get_view_func(callback)
name = get_view_name(view_func)
source_path = get_source_file_path(view_func)
methods = _extract_methods_from_closure(callback)
if methods is None:
methods = []
return FunctionViewSchema(
name=name,
type=ViewType.FUNCTION,
source_path=source_path,
methods=methods,
)
class ClassViewSchema(BaseModel):
"""Schema for class-based view.
Fields:
name: Fully qualified view name (module.ClassName)
type: Always ViewType.CLASS
source_path: Path to source file, or Path("unknown")
methods: List of HTTP methods actually implemented by the view.
Determined by checking which method handlers (get, post, etc.)
are defined on the class.
class_bases: List of base class names, excluding 'object'.
Example: ['ListView', 'LoginRequiredMixin']
"""
name: str
type: Literal[ViewType.CLASS]
source_path: Path
methods: list[ViewMethod]
class_bases: list[str]
@classmethod
def from_callback(cls, callback: Any):
view_func = get_view_func(callback)
name = get_view_name(view_func)
source_path = get_source_file_path(view_func)
bases = [
base.__name__ for base in view_func.__bases__ if base.__name__ != "object"
]
class_bases = bases if bases else []
implemented_methods = [
method for method in ViewMethod if hasattr(view_func, method.value.lower())
]
methods = implemented_methods if implemented_methods else []
return cls(
name=name,
type=ViewType.CLASS,
source_path=source_path,
class_bases=class_bases,
methods=methods,
)
ViewSchema = FunctionViewSchema | ClassViewSchema
class RouteSchema(BaseModel):
"""Schema for a complete Django URL route.
Fields:
pattern: Full URL pattern string (e.g., "blog/<int:pk>/")
name: Route name for reverse URL lookup, or None if unnamed
namespace: URL namespace (e.g., "admin"), or None if not namespaced
parameters: List of URL parameter names extracted from pattern
view: View handler (FunctionViewSchema or ClassViewSchema)
"""
pattern: str
name: str | None
namespace: str | None
parameters: list[str]
view: ViewSchema
def get_source_file_path(obj: Any) -> Path:
"""Get the source file path for a function or class.
Returns Path("unknown") if the source cannot be determined.
"""
try:
return Path(inspect.getfile(obj))
except (TypeError, OSError):
return Path("unknown")
def extract_url_parameters(pattern: str) -> list[str]:
"""Extract parameter names from a URL pattern.
Supports Django's standard path converters (int, str, slug, uuid, path)
and any custom converter names using word characters (a-z, A-Z, 0-9, _).
Args:
pattern: Django URL pattern string
Returns:
List of parameter names extracted from the pattern
Examples:
>>> extract_url_parameters("blog/<int:pk>/")
['pk']
>>> extract_url_parameters("blog/<pk>/")
['pk']
>>> extract_url_parameters("api/<uuid:id>/posts/<int:post_id>/")
['id', 'post_id']
"""
# Matches <converter:name> or <name> and captures the parameter name
param_regex = r"<(?:\w+:)?(\w+)>"
return re.findall(param_regex, pattern)
def _extract_methods_from_closure(view_func: Any) -> list[ViewMethod] | None:
"""Extract allowed HTTP methods from Django decorator closure.
Django's method decorators (@require_GET, @require_http_methods, etc.)
store the allowed methods in the function's closure. This extracts them.
Returns:
List of allowed ViewMethod enums if decorator found, None otherwise.
"""
if not hasattr(view_func, "__closure__") or not view_func.__closure__:
return None
for cell in view_func.__closure__:
try:
content = cell.cell_contents
if isinstance(content, list) and content:
if all(isinstance(m, str) for m in content):
methods = []
for method_str in content:
if method_str in ViewMethod.__members__:
methods.append(ViewMethod[method_str])
if methods:
return methods
except ValueError: # pragma: no cover
continue
return None
def get_view_func(callback: Any):
"""Extract the actual view function or class from a callback.
Unwraps decorators and extracts view_class from .as_view() callbacks.
Returns:
The underlying function or class object
"""
view_func = callback
if hasattr(view_func, "view_class"):
view_func = view_func.view_class
while hasattr(view_func, "__wrapped__"):
view_func = view_func.__wrapped__
return view_func
def get_view_name(view_func: Any):
"""Get the fully qualified name of a view function or class.
Returns:
Fully qualified name (module.name)
"""
module = inspect.getmodule(view_func)
assert module is not None, f"Could not determine module for {view_func}"
return f"{module.__name__}.{view_func.__name__}"
def extract_routes(
patterns: Iterable[URLPattern | URLResolver],
prefix: str = "",
namespace: str | None = None,
) -> list[RouteSchema]:
"""Recursively extract routes from URL patterns."""
routes = []
for pattern in patterns:
if isinstance(pattern, URLResolver):
current_namespace = pattern.namespace
full_namespace: str | None
if namespace and current_namespace:
full_namespace = f"{namespace}:{current_namespace}"
elif current_namespace:
full_namespace = current_namespace
else:
full_namespace = None
extracted_routes = extract_routes(
pattern.url_patterns,
prefix + str(pattern.pattern),
full_namespace,
)
routes.extend(extracted_routes)
elif isinstance(pattern, URLPattern):
full_pattern = prefix + str(pattern.pattern)
parameters = extract_url_parameters(full_pattern)
view_func = get_view_func(pattern.callback)
if inspect.isclass(view_func):
view_schema = ClassViewSchema.from_callback(pattern.callback)
else:
view_schema = FunctionViewSchema.from_callback(pattern.callback)
route = RouteSchema(
pattern=full_pattern,
name=pattern.name,
namespace=namespace,
parameters=parameters,
view=view_schema,
)
routes.append(route)
return routes
def get_all_routes() -> list[RouteSchema]:
"""Get all Django URL routes by recursively walking URLconf.
Traverses the entire URL resolver tree starting from ROOT_URLCONF,
extracting route patterns, view metadata, namespaces, and parameters.
Returns:
List of RouteSchema objects, one per URL pattern
Note:
For projects with many routes (1000+), this may take a few seconds
on first call. Results are not cached.
"""
resolver = get_resolver()
routes = extract_routes(resolver.url_patterns)
return routes
def filter_routes(
routes: list[RouteSchema],
method: ViewMethod | None = None,
name: str | None = None,
pattern: str | None = None,
) -> list[RouteSchema]:
"""Filter routes using contains matching on each parameter.
All filters are AND'd together - routes must match all provided filters.
Args:
method: ViewMethod enum or None for filtering by HTTP method
name: Route name substring for filtering (case-sensitive)
pattern: URL pattern substring for filtering (case-sensitive)
Returns:
Filtered list of routes matching all provided criteria
Raises:
ValueError: If method is not a valid HTTP method name
"""
if method:
routes = [r for r in routes if not r.view.methods or method in r.view.methods]
if name is not None:
routes = [r for r in routes if r.name and name in r.name]
if pattern is not None:
routes = [r for r in routes if pattern in r.pattern]
return routes