"""System checks for the URL routing subsystem."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
from django.conf import settings
from django.core.checks import CheckMessage, Error, Tags, register
from django.utils.module_loading import import_string
from next.checks.common import (
errors_for_unknown_keys,
get_router_manager,
iter_scanned_page_pairs,
)
from next.conf import next_framework_settings
from .backends import FileRouterBackend, RouterBackend, RouterFactory
from .parser import URLPatternParser
if TYPE_CHECKING:
from pathlib import Path
FILE_ROUTER_BACKEND = "next.urls.FileRouterBackend"
_PAGE_BACKEND_SETTINGS_KEY = "DEFAULT_PAGE_BACKENDS"
_FILE_ROUTER_PAGE_CONFIG_KEYS = frozenset(
{
"BACKEND",
"APP_DIRS",
"DIRS",
"OPTIONS",
"PAGES_DIR",
},
)
_NON_FILE_ROUTER_PAGE_CONFIG_KEYS = frozenset({"BACKEND"})
def _router_backend_path_is_valid(backend_path: str) -> bool:
"""Return True when `backend_path` names a registered or importable backend."""
if backend_path in RouterFactory._backends:
return True
try:
resolved = import_string(backend_path)
except ImportError:
return False
return isinstance(resolved, type) and issubclass(resolved, RouterBackend)
def _validate_config_structure(
config: object,
index: int,
) -> list[CheckMessage]:
"""Validate required keys and types for one `DEFAULT_PAGE_BACKENDS` entry."""
errors: list[CheckMessage] = []
if not isinstance(config, dict):
errors.append(
Error(
f"NEXT_FRAMEWORK['{_PAGE_BACKEND_SETTINGS_KEY}'][{index}] "
"must be a dictionary.",
obj=settings,
id="next.E002",
),
)
return errors
if "BACKEND" not in config:
errors.append(
Error(
f"NEXT_FRAMEWORK['{_PAGE_BACKEND_SETTINGS_KEY}'][{index}] "
"must specify a BACKEND.",
obj=settings,
id="next.E003",
),
)
return errors
def _validate_file_router_backend_fields( # noqa: C901, PLR0912
config: dict[str, Any],
index: int,
) -> list[CheckMessage]:
"""Validate `DIRS`, `PAGES_DIR`, `APP_DIRS`, `OPTIONS` for the file router."""
errors: list[CheckMessage] = []
rf_routers = f"NEXT_FRAMEWORK['{_PAGE_BACKEND_SETTINGS_KEY}'][{index}]"
if "DIRS" in config and not isinstance(config["DIRS"], list):
errors.append(
Error(
f"{rf_routers}.DIRS must be a list.",
obj=settings,
id="next.E006",
),
)
if "PAGES_DIR" not in config:
errors.append(
Error(
f"{rf_routers} must specify PAGES_DIR when using FileRouterBackend.",
obj=settings,
id="next.E024",
),
)
elif not isinstance(config["PAGES_DIR"], str):
errors.append(
Error(
f"{rf_routers}.PAGES_DIR must be a string.",
obj=settings,
id="next.E027",
),
)
if "APP_DIRS" not in config:
errors.append(
Error(
f"{rf_routers} must specify APP_DIRS when using FileRouterBackend.",
obj=settings,
id="next.E025",
),
)
elif not isinstance(config["APP_DIRS"], bool):
errors.append(
Error(
f"{rf_routers}.APP_DIRS must be a boolean.",
obj=settings,
id="next.E005",
),
)
if "OPTIONS" not in config:
errors.append(
Error(
f"{rf_routers} must specify OPTIONS when using FileRouterBackend.",
obj=settings,
id="next.E026",
),
)
elif not isinstance(config["OPTIONS"], dict):
errors.append(
Error(
f"{rf_routers}.OPTIONS must be a dictionary.",
obj=settings,
id="next.E006",
),
)
else:
opts = config["OPTIONS"]
cp = opts.get("context_processors")
if cp is not None and not isinstance(cp, list):
errors.append(
Error(
f"{rf_routers}.OPTIONS['context_processors'] must be a list.",
obj=settings,
id="next.E006",
),
)
elif isinstance(cp, list):
for item in cp:
if not isinstance(item, str):
errors.append(
Error(
f"{rf_routers}.OPTIONS['context_processors'] must contain "
"only strings.",
obj=settings,
id="next.E006",
),
)
break
for key in opts:
if key == "context_processors":
continue
errors.append(
Error(
f"{rf_routers}.OPTIONS contains unknown key {key!r}. "
"OPTIONS only supports context_processors. "
"Use top-level DIRS for extra page roots.",
obj=settings,
id="next.E006",
),
)
break
errors.extend(
errors_for_unknown_keys(
config,
allowed=_FILE_ROUTER_PAGE_CONFIG_KEYS,
prefix=rf_routers,
),
)
return errors
def _validate_config_fields(
config: dict[str, Any],
index: int,
) -> list[CheckMessage]:
"""Validate specific fields of a single page-backend configuration."""
errors: list[CheckMessage] = []
backend = config.get("BACKEND")
if backend is not None and not _router_backend_path_is_valid(str(backend)):
errors.append(
Error(
f'NEXT_FRAMEWORK["{_PAGE_BACKEND_SETTINGS_KEY}"][{index}] specifies '
f'unknown backend "{backend}".',
obj=settings,
id="next.E004",
),
)
# Check if backend is FileRouterBackend or a subclass
is_file_router = False
if backend == FILE_ROUTER_BACKEND:
is_file_router = True
elif backend is not None and isinstance(backend, str):
try:
backend_class = import_string(backend)
is_file_router = isinstance(backend_class, type) and issubclass(
backend_class, FileRouterBackend
)
except (ImportError, AttributeError):
pass
if is_file_router:
errors.extend(_validate_file_router_backend_fields(config, index))
elif (
backend is not None
and isinstance(backend, str)
and _router_backend_path_is_valid(backend)
):
rf = f"NEXT_FRAMEWORK['{_PAGE_BACKEND_SETTINGS_KEY}'][{index}]"
errors.extend(
errors_for_unknown_keys(
config,
allowed=_NON_FILE_ROUTER_PAGE_CONFIG_KEYS,
prefix=rf,
),
)
return errors
[docs]
@register(Tags.compatibility)
def check_next_pages_configuration(
*_args: object,
**_kwargs: object,
) -> list[CheckMessage]:
"""Validate `DEFAULT_PAGE_BACKENDS` inside merged `NEXT_FRAMEWORK`."""
raw = getattr(settings, "NEXT_FRAMEWORK", None)
if raw is not None and not isinstance(raw, dict):
return [
Error(
"NEXT_FRAMEWORK must be a dictionary.",
obj=settings,
id="next.E001",
),
]
next_pages = next_framework_settings.DEFAULT_PAGE_BACKENDS
if not isinstance(next_pages, list):
return [
Error(
"NEXT_FRAMEWORK['DEFAULT_PAGE_BACKENDS'] must be a list of "
"configuration dictionaries.",
obj=settings,
id="next.E001",
),
]
if len(next_pages) == 0:
return [
Error(
"NEXT_FRAMEWORK['DEFAULT_PAGE_BACKENDS'] must contain at least one "
"router entry (configure the file router or another backend).",
obj=settings,
id="next.E022",
),
]
errors: list[CheckMessage] = []
for i, config in enumerate(next_pages):
errors.extend(_validate_config_structure(config, i))
if isinstance(config, dict):
errors.extend(_validate_config_fields(config, i))
return errors
def _get_duplicate_parameters(url_path: str, parser: URLPatternParser) -> list[str]:
"""Return parameter names that appear more than once in bracket segments."""
param_matches = parser._param_pattern.findall(url_path)
param_names = []
for param_str in param_matches:
param_name, _ = parser._parse_param_name_and_type(param_str)
param_names.append(param_name)
if len(param_names) == len(set(param_names)):
return []
return [name for name in set(param_names) if param_names.count(name) > 1]
[docs]
@register(Tags.urls)
def check_duplicate_url_parameters(
*_args: object,
**_kwargs: object,
) -> list[CheckMessage]:
"""Fail when the same bracket parameter name is repeated in one route."""
errors: list[CheckMessage] = []
router_manager, init_errors = get_router_manager()
if router_manager is None:
return init_errors
parser = URLPatternParser()
for router in router_manager._backends:
for url_path, page_path in iter_scanned_page_pairs(router):
if not page_path.exists():
continue
try:
parser.parse_url_pattern(url_path)
duplicates = _get_duplicate_parameters(url_path, parser)
if duplicates:
errors.append(
Error(
f"URL pattern '{url_path}' has duplicate parameter "
f"names: {duplicates}. "
"Each parameter must have a unique name.",
obj=str(page_path),
id="next.E028",
),
)
except (ValueError, TypeError, AttributeError):
continue
return errors
[docs]
@register(Tags.urls)
def check_url_patterns(
*_args: object,
**_kwargs: object,
) -> list[CheckMessage]:
"""Collect patterns from routers and flag duplicate Django path strings."""
errors: list[CheckMessage] = []
warnings: list[CheckMessage] = []
router_manager, init_errors = get_router_manager()
if router_manager is None:
return init_errors + warnings
all_patterns: list[tuple[str, str]] = []
for router in router_manager._backends:
try:
if hasattr(router, "app_dirs") and router.app_dirs:
_collect_app_patterns(router, all_patterns)
_collect_root_patterns(router, all_patterns)
except (AttributeError, OSError) as e:
errors.append(
Error(
f"Error collecting patterns from router: {e}",
obj=settings,
id="next.E016",
),
)
try:
_check_url_conflicts(all_patterns, errors, warnings)
except (ValueError, TypeError) as e:
errors.append(
Error(
f"Error checking URL conflicts: {e}",
obj=settings,
id="next.E014",
),
)
return errors + warnings
def _collect_app_patterns(
router: RouterBackend,
all_patterns: list[tuple[str, str]],
) -> None:
"""Append patterns discovered under each app's `pages_dir`."""
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
patterns = _collect_url_patterns(pages_path, f"App '{app_name}'")
all_patterns.extend(patterns)
def _collect_root_patterns(
router: RouterBackend,
all_patterns: list[tuple[str, str]],
) -> None:
"""Append patterns from each configured root pages directory."""
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})"
patterns = _collect_url_patterns(pages_path, context)
all_patterns.extend(patterns)
def _check_url_conflicts(
all_patterns: list[tuple[str, str]],
errors: list[CheckMessage],
_warnings: list[CheckMessage],
) -> None:
"""Report an error when the same Django path string comes from multiple sources."""
pattern_dict: dict[str, list[str]] = {}
for pattern, source in all_patterns:
if pattern in pattern_dict:
pattern_dict[pattern].append(source)
else:
pattern_dict[pattern] = [source]
for pattern, sources in pattern_dict.items():
if len(sources) > 1:
errors.append(
Error(
f'URL pattern conflict: "{pattern}" is defined in '
f"multiple locations: {', '.join(sources)}",
obj=settings,
id="next.E015",
),
)
def _collect_url_patterns(pages_path: Path, context: str) -> list[tuple[str, str]]:
"""Collect URL patterns from a pages directory for conflict comparison."""
patterns: list[tuple[str, str]] = []
if not pages_path.exists():
return patterns
for page_file in pages_path.rglob("page.py"):
try:
relative_path = page_file.relative_to(pages_path)
url_path = str(relative_path.parent)
if django_pattern := _convert_to_django_pattern(url_path):
patterns.append((django_pattern, f"{context}: {relative_path}"))
except (OSError, ValueError):
continue
return patterns
def _convert_to_django_pattern(url_path: str) -> str | None:
"""Convert bracket syntax to `<str:>` / `<path:>` for conflict comparison."""
if not url_path:
return ""
args_pattern = re.compile(r"\[\[([^\[\]]+)\]\]")
url_path = args_pattern.sub(r"<path:\1>", url_path)
param_pattern = re.compile(r"\[([^\[\]]+)\]")
return param_pattern.sub(r"<str:\1>", url_path)
__all__ = [
"check_duplicate_url_parameters",
"check_next_pages_configuration",
"check_url_patterns",
]