"""System checks for the pages subsystem."""
from __future__ import annotations
import ast
import importlib.util
import inspect
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, cast, get_origin
from django.conf import settings
from django.core.checks import (
CheckMessage,
Error,
Tags,
Warning as DjangoWarning,
register,
)
from next.checks.common import get_router_manager, iter_scanned_page_pairs
from next.conf import import_class_cached, next_framework_settings
from .loaders import TemplateLoader, _load_python_module, build_registered_loaders
if TYPE_CHECKING:
import types
from collections.abc import Callable
from pathlib import Path
from next.urls import FileRouterBackend, RouterBackend
REQUEST_CONTEXT_PROCESSOR = "django.template.context_processors.request"
EXPECTED_PARAMETER_PARTS = 2
[docs]
@register(Tags.templates)
def check_request_in_context(
*_args: object,
**_kwargs: object,
) -> list[CheckMessage]:
"""Ensure `request` is in the template context (required for `{% form %}`)."""
if "next" not in settings.INSTALLED_APPS:
return []
errors: list[CheckMessage] = []
templates = getattr(settings, "TEMPLATES", [])
for i, config in enumerate(templates):
if not isinstance(config, dict):
continue
options = config.get("OPTIONS", {})
processors = options.get("context_processors", [])
if REQUEST_CONTEXT_PROCESSOR not in processors:
msg = (
f"TEMPLATES[{i}]: 'request' must be in template context "
"when using next (required for {% form %} and CSRF). Add "
"'django.template.context_processors.request' to "
"OPTIONS.context_processors."
)
errors.append(
Error(
msg,
obj=settings,
id="next.E019",
),
)
return errors
[docs]
@register(Tags.compatibility)
def check_pages_structure(
*_args: object,
**_kwargs: object,
) -> list[CheckMessage]:
"""Check each router's pages tree for layouts, naming, and structure."""
errors: list[CheckMessage] = []
warnings: list[CheckMessage] = []
router_manager, init_errors = get_router_manager()
if router_manager is None:
return init_errors + warnings
for router in router_manager._backends:
try:
if hasattr(router, "app_dirs") and router.app_dirs:
_check_app_pages(router, errors, warnings)
else:
_check_root_pages(router, errors, warnings)
except (AttributeError, OSError) as e:
errors.append(
Error(
f"Error checking router pages: {e}",
obj=settings,
id="next.E030",
),
)
return errors + warnings
def _check_app_pages(
router: RouterBackend,
errors: list[CheckMessage],
warnings: list[CheckMessage],
) -> None:
"""Check app pages for `router`."""
if not hasattr(router, "_get_installed_apps"):
return
file_router = cast("FileRouterBackend", router)
for app_name in file_router._get_installed_apps():
if not hasattr(file_router, "_get_app_pages_path"):
continue
pages_path = file_router._get_app_pages_path(app_name)
if not pages_path or not hasattr(file_router, "pages_dir"):
continue
app_errors, app_warnings = _check_pages_directory(
pages_path,
f"App '{app_name}'",
file_router.pages_dir,
)
errors.extend(app_errors)
warnings.extend(app_warnings)
def _check_root_pages(
router: RouterBackend,
errors: list[CheckMessage],
warnings: list[CheckMessage],
) -> None:
"""Check root pages for `router` across all configured root paths."""
if not hasattr(router, "_get_root_pages_paths"):
return
if not hasattr(router, "pages_dir"):
return
file_router = cast("FileRouterBackend", router)
for i, pages_path in enumerate(router._get_root_pages_paths()):
context = "Root" if i == 0 else f"Root ({pages_path})"
root_errors, root_warnings = _check_pages_directory(
pages_path,
context,
file_router.pages_dir,
)
errors.extend(root_errors)
warnings.extend(root_warnings)
def _check_directory_syntax(pages_path: Path, context: str) -> list[CheckMessage]:
"""Check directory names under `pages_path` for valid bracket syntax."""
errors: list[CheckMessage] = []
for item in pages_path.rglob("*"):
if not item.is_dir():
continue
dir_name_str = item.name
relative_path = item.relative_to(pages_path)
if dir_name_str.startswith("[") and dir_name_str.endswith("]"):
if not _is_valid_parameter_syntax(dir_name_str):
errors.append(
Error(
f"{context} pages: Invalid parameter syntax "
f'"{dir_name_str}" in {relative_path}. '
f"Use [param] or [type:param] format.",
obj=settings,
id="next.E008",
),
)
elif dir_name_str.startswith("[[") and dir_name_str.endswith("]]"):
if not _is_valid_args_syntax(dir_name_str):
errors.append(
Error(
f"{context} pages: Invalid args syntax "
f'"{dir_name_str}" in {relative_path}. '
f"Use [[args]] format.",
obj=settings,
id="next.E009",
),
)
elif dir_name_str.startswith("["):
errors.append(
Error(
f"{context} pages: Incomplete args syntax "
f'"{dir_name_str}" in {relative_path}. '
f"Use [[args]] format.",
obj=settings,
id="next.E009",
),
)
return errors
def _check_missing_page_files(pages_path: Path, context: str) -> list[CheckMessage]:
"""Check for missing `page.py` files inside parameter directories."""
errors: list[CheckMessage] = []
for item in pages_path.rglob("*"):
if not item.is_dir():
continue
dir_name_str = item.name
if (dir_name_str.startswith("[") and dir_name_str.endswith("]")) or (
dir_name_str.startswith("[[") and dir_name_str.endswith("]]")
):
page_file = item / "page.py"
layout_file = item / "layout.djx"
template_file = item / "template.djx"
if page_file.exists() or layout_file.exists() or template_file.exists():
continue
# Check if parameter directory has child routes
has_child_routes = False
for child in item.iterdir():
if child.is_dir() and (child / "page.py").exists():
has_child_routes = True
break
if not has_child_routes:
errors.append(
Error(
f"{context} pages: Parameter directory "
f'"{item.relative_to(pages_path)}" is missing page.py file.',
obj=settings,
id="next.E010",
),
)
return errors
def _check_pages_directory(
pages_path: Path,
context: str,
_dir_name: str,
) -> tuple[list[CheckMessage], list[CheckMessage]]:
"""Check a specific pages directory for issues."""
if not pages_path.exists():
return [], []
errors: list[CheckMessage] = []
warnings: list[CheckMessage] = []
errors.extend(_check_directory_syntax(pages_path, context))
errors.extend(_check_missing_page_files(pages_path, context))
return errors, warnings
def _is_valid_parameter_syntax(param_str: str) -> bool:
"""Return True when single-bracket parameter syntax is valid."""
if not (param_str.startswith("[") and param_str.endswith("]")):
return False
content = param_str[1:-1]
if ":" in content:
parts = content.split(":", 1)
if len(parts) != EXPECTED_PARAMETER_PARTS:
return False
type_name, param_name = parts
if ":" in param_name:
return False
return bool(type_name.strip() and param_name.strip())
return bool(content.strip())
def _is_valid_args_syntax(args_str: str) -> bool:
"""Return True when double-bracket args syntax is valid."""
if not (args_str.startswith("[[") and args_str.endswith("]]")):
return False
content = args_str[2:-2]
return bool(content.strip())
[docs]
@register(Tags.compatibility)
def check_page_functions(
*_args: object,
**_kwargs: object,
) -> list[CheckMessage]:
"""Validate each page module for `render` or `template`. Warn when empty."""
errors: list[CheckMessage] = []
warnings: list[CheckMessage] = []
router_manager, init_errors = get_router_manager()
if router_manager is None:
return init_errors
for router in router_manager._backends:
try:
if hasattr(router, "app_dirs") and router.app_dirs:
_check_app_page_functions(router, errors, warnings)
else:
_check_root_page_functions(router, errors, warnings)
except (AttributeError, OSError) as e:
errors.append(
Error(
f"Error checking page functions: {e}",
obj=settings,
id="next.E011",
),
)
return errors + warnings
def _check_app_page_functions(
router: RouterBackend,
errors: list[CheckMessage],
warnings: list[CheckMessage],
) -> None:
"""Check app page functions for `router`."""
if not hasattr(router, "_get_installed_apps"):
return
file_router: FileRouterBackend = router # type: ignore[assignment]
for app_name in file_router._get_installed_apps():
if not hasattr(file_router, "_get_app_pages_path"):
continue
pages_path = file_router._get_app_pages_path(app_name)
if not pages_path:
continue
e, w = _check_page_functions_in_directory(
pages_path,
f"App '{app_name}'",
)
errors.extend(e)
warnings.extend(w)
def _check_root_page_functions(
router: RouterBackend,
errors: list[CheckMessage],
warnings: list[CheckMessage],
) -> None:
"""Check root page functions for `router` across all root paths."""
if not hasattr(router, "_get_root_pages_paths"):
return
for i, pages_path in enumerate(router._get_root_pages_paths()):
context = "Root" if i == 0 else f"Root ({pages_path})"
e, w = _check_page_functions_in_directory(pages_path, context)
errors.extend(e)
warnings.extend(w)
def _check_page_functions_in_directory(
pages_path: Path,
context: str,
) -> tuple[list[CheckMessage], list[CheckMessage]]:
"""Check `page.py` files for render/template rules."""
errors: list[CheckMessage] = []
warnings: list[CheckMessage] = []
if not pages_path.exists():
return errors, warnings
for page_file in pages_path.rglob("page.py"):
render_func = _load_render_function(page_file)
has_template = _has_template_or_djx(page_file)
hard_error = False
if render_func is None and not has_template:
errors.append(
Error(
f"{context} pages: {page_file.relative_to(pages_path)} "
"has no body source. Add a render function, a template "
"attribute, a sibling template.djx, or a sibling layout.djx.",
obj=settings,
id="next.E012",
),
)
hard_error = True
elif render_func is not None and not callable(render_func):
errors.append(
Error(
f"{context} pages: {page_file.relative_to(pages_path)} "
f"render attribute is not callable.",
obj=settings,
id="next.E013",
),
)
hard_error = True
if not hard_error:
shadow_warning = _check_body_source_conflicts(page_file)
if shadow_warning is not None:
warnings.append(shadow_warning)
return errors, warnings
def _active_body_sources(page_file: Path) -> list[str]:
"""Return the body sources declared on `page_file` in priority order.
The priority order starts with `render()`, then the `template`
module attribute, and finally registered loaders in the order
declared under `NEXT_FRAMEWORK["TEMPLATE_LOADERS"]`. Each loader
reports its file name via `TemplateLoader.source_name`.
"""
module = _load_python_module(page_file)
sources: list[str] = []
if module is not None:
if callable(getattr(module, "render", None)):
sources.append("render()")
template_attr = getattr(module, "template", None)
if isinstance(template_attr, str):
sources.append("template")
sources.extend(
loader.source_name
for loader in build_registered_loaders()
if loader.can_load(page_file) and loader.source_name
)
return sources
def _check_body_source_conflicts(page_file: Path) -> CheckMessage | None:
"""Warn (`next.W043`) when more than one body source is declared for `page_file`."""
sources = _active_body_sources(page_file)
if len(sources) < 2: # noqa: PLR2004
return None
winner = sources[0]
shadowed = ", ".join(sources[1:])
return DjangoWarning(
f"{page_file} declares multiple body sources: {', '.join(sources)}. "
f"{winner} takes priority and {shadowed} will not be used. "
"Priority order: render() > template > registered TEMPLATE_LOADERS.",
obj=str(page_file),
id="next.W043",
)
def _load_render_function(file_path: Path) -> object:
"""Load the `render` callable from a `page.py` file."""
try:
if (
spec := importlib.util.spec_from_file_location("page_module", file_path)
) is None or spec.loader is None:
return None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return getattr(module, "render", None)
except (ImportError, AttributeError, OSError, SyntaxError):
return None
def _has_template_or_djx(file_path: Path) -> bool:
"""Return True when the page has a body source or a sibling ``layout.djx``."""
if (file_path.parent / "layout.djx").exists():
return True
try:
if (
spec := importlib.util.spec_from_file_location("page_module", file_path)
) is None or spec.loader is None:
return False
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
if hasattr(module, "template"):
return True
return any(loader.can_load(file_path) for loader in build_registered_loaders())
except (ImportError, AttributeError, OSError, SyntaxError):
return False
def _check_layout_file(layout_file: Path) -> CheckMessage | None:
"""Check if layout file has required `{% block template %}`."""
try:
content = layout_file.read_text(encoding="utf-8")
if "{% block template %}" not in content:
return DjangoWarning(
f"Layout file {layout_file} does not contain required "
"{% block template %} block. "
"This may cause template inheritance issues.",
obj=str(layout_file),
id="next.W001",
)
except (OSError, UnicodeDecodeError):
pass
return None
[docs]
@register(Tags.templates)
def check_layout_templates(
*_args: object,
**_kwargs: object,
) -> list[CheckMessage]:
"""Check `layout.djx` files for the `{% block template %}` structure."""
warnings: list[CheckMessage] = []
router_manager, init_errors = get_router_manager()
if router_manager is None:
return init_errors + warnings
for router in router_manager._backends:
for _url_path, page_path in iter_scanned_page_pairs(router):
layout_file = page_path.parent / "layout.djx"
if not layout_file.exists():
continue
warning = _check_layout_file(layout_file)
if warning:
warnings.append(warning)
return warnings
def _has_context_decorator_without_key(func: Callable[..., Any]) -> bool:
"""Return True when `func` has the `@context` decorator applied without a key."""
try:
source = inspect.getsource(func)
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
for decorator in node.decorator_list:
if isinstance(decorator, ast.Name) and decorator.id == "context":
return True
except (SyntaxError, OSError, UnicodeDecodeError):
pass
return False
_DICT_ANNOTATION_NAMES = frozenset({"dict", "Dict", "Mapping", "MutableMapping"})
def _annotation_is_dict_like(annotation: object) -> bool:
"""Return True when the return annotation maps to a dict-like result."""
if annotation is inspect.Signature.empty:
return True
if annotation is dict or annotation is None:
return annotation is dict
origin = get_origin(annotation)
if origin is not None:
candidate: object = origin
else:
candidate = annotation
if isinstance(candidate, type):
try:
return issubclass(candidate, Mapping)
except TypeError:
return False
name = getattr(candidate, "_name", None) or getattr(candidate, "__name__", None)
if isinstance(name, str):
return name in _DICT_ANNOTATION_NAMES
return False
def _check_context_function(
func_name: str,
func: Callable[..., Any],
page_path: Path,
) -> CheckMessage | None:
"""Emit an error when keyless context callables are not annotated dict-like.
The check is static: executing user code at ``manage.py check`` time
is expensive and can hit databases that have not been migrated yet.
Callables without a return annotation are accepted — the runtime
emits a clear ``TypeError`` on first render if the result is not a
mapping.
"""
try:
annotation = inspect.signature(func).return_annotation
except (TypeError, ValueError):
return None
if _annotation_is_dict_like(annotation):
return None
annotation_name = getattr(annotation, "__name__", None) or repr(annotation)
return Error(
f"Context function '{func_name}' in {page_path} "
"must return a dictionary "
f"when used with @context decorator (without key). "
f"Got return annotation {annotation_name} instead.",
obj=str(page_path),
id="next.E029",
)
def _check_module_context_functions(
module: types.ModuleType,
page_path: Path,
) -> list[CheckMessage]:
"""Collect keyless `@context` functions declared in one page module."""
errors: list[CheckMessage] = []
for name, obj in inspect.getmembers(module, inspect.isfunction):
if not _has_context_decorator_without_key(obj):
continue
error = _check_context_function(name, obj, page_path)
if error:
errors.append(error)
return errors
def _check_router_context_functions(router: RouterBackend) -> list[CheckMessage]:
"""Return errors from `page.py` modules under one router's pages tree."""
errors: list[CheckMessage] = []
for _url_path, page_path in iter_scanned_page_pairs(router):
if not page_path.exists():
continue
module = _load_python_module(page_path)
if not module:
continue
module_errors = _check_module_context_functions(module, page_path)
errors.extend(module_errors)
return errors
[docs]
@register(Tags.templates)
def check_context_functions(
*_args: object,
**_kwargs: object,
) -> list[CheckMessage]:
"""Require keyless `@context` callables to return a dict when invoked."""
router_manager, init_errors = get_router_manager()
if router_manager is None:
return init_errors
errors: list[CheckMessage] = []
for router in router_manager._backends:
router_errors = _check_router_context_functions(router)
errors.extend(router_errors)
return errors
[docs]
@register(Tags.templates)
def check_context_processor_signature(
*_args: object,
**_kwargs: object,
) -> list[CheckMessage]:
"""Warn when a configured context processor has no `request` parameter."""
errors: list[CheckMessage] = []
for backend_index, backend in _iter_page_backend_configs():
processors = backend.get("OPTIONS", {}).get("context_processors") or []
for processor_index, path in enumerate(processors):
if not isinstance(path, str):
continue
loc = (
f"NEXT_FRAMEWORK['DEFAULT_PAGE_BACKENDS'][{backend_index}]"
f".OPTIONS.context_processors[{processor_index}]"
)
message = _check_processor_request_parameter(path, loc)
if message is not None:
errors.append(message)
return errors
def _iter_page_backend_configs() -> list[tuple[int, dict[str, Any]]]:
"""Return indexed page backend dicts from `NEXT_FRAMEWORK`."""
raw = getattr(settings, "NEXT_FRAMEWORK", {}) or {}
backends = raw.get("DEFAULT_PAGE_BACKENDS", []) if isinstance(raw, dict) else []
return [
(idx, backend)
for idx, backend in enumerate(backends)
if isinstance(backend, dict)
]
def _check_processor_request_parameter(
processor_path: str,
location: str,
) -> CheckMessage | None:
"""Return an error when the callable at `processor_path` lacks `request`."""
try:
processor = importlib.import_module(processor_path.rsplit(".", 1)[0])
except (ImportError, ValueError):
return None
attr_name = processor_path.rsplit(".", 1)[-1]
callable_obj = getattr(processor, attr_name, None)
if not callable(callable_obj):
return None
try:
sig = inspect.signature(callable_obj)
except (TypeError, ValueError):
return None
if "request" in sig.parameters:
return None
return Error(
f"{location} points at {processor_path!r} which does not accept a "
"'request' parameter. Context processors must accept request.",
obj=settings,
id="next.E040",
)
[docs]
@register(Tags.compatibility)
def check_template_loaders(
*_args: object,
**_kwargs: object,
) -> list[CheckMessage]:
"""Validate every `NEXT_FRAMEWORK['TEMPLATE_LOADERS']` entry."""
try:
configured = next_framework_settings.TEMPLATE_LOADERS
except (AttributeError, ImportError): # pragma: no cover
return []
messages: list[CheckMessage] = []
for index, entry in enumerate(configured):
if not isinstance(entry, str):
messages.append(
Error(
f"NEXT_FRAMEWORK['TEMPLATE_LOADERS'][{index}] must be a dotted "
f"path string, got {type(entry).__name__!r}.",
obj=settings,
id="next.E042",
),
)
continue
try:
cls = import_class_cached(entry)
except ImportError as exc:
messages.append(
Error(
f"NEXT_FRAMEWORK['TEMPLATE_LOADERS'][{index}]={entry!r} "
f"cannot be imported: {exc}.",
obj=settings,
id="next.E043",
),
)
continue
if not isinstance(cls, type) or not issubclass(cls, TemplateLoader):
messages.append(
Error(
f"NEXT_FRAMEWORK['TEMPLATE_LOADERS'][{index}]={entry!r} is "
"not a TemplateLoader subclass.",
obj=settings,
id="next.E043",
),
)
return messages
__all__ = [
"check_context_functions",
"check_context_processor_signature",
"check_layout_templates",
"check_page_functions",
"check_pages_structure",
"check_request_in_context",
"check_template_loaders",
]