Source code for next.urls.backends

"""Pluggable router backend contract, file router, and backend factory.

The `RouterBackend` ABC defines the contract every router
implementation must satisfy. `FileRouterBackend` is the built-in
implementation that discovers `page.py` (and virtual `template.djx`)
entries under app and optional root page trees. `RouterFactory` maps
dotted backend paths to classes and instantiates them from
`DEFAULT_PAGE_BACKENDS` config dicts.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar

from django.conf import settings

from next.conf import import_class_cached, next_framework_settings
from next.pages import page
from next.utils import classify_dirs_entries, resolve_base_dir

from .dispatcher import FilesystemTreeDispatcher
from .parser import default_url_parser
from .signals import route_registered


if TYPE_CHECKING:
    from collections.abc import Generator

    from django.urls import URLPattern, URLResolver


[docs] class RouterBackend(ABC): """Pluggable source of `URLPattern` and `URLResolver` entries."""
[docs] @abstractmethod def generate_urls(self) -> list[URLPattern | URLResolver]: """Patterns contributed by this backend to the project URLconf."""
def _narrow_file_router_options(options: dict[str, Any]) -> dict[str, Any]: """Keep only keys consumed by `next.pages` (e.g. `context_processors`).""" cp = options.get("context_processors") if not isinstance(cp, list): cp = [] cp = [x for x in cp if isinstance(x, str)] if not cp: return {} return {"context_processors": cp}
[docs] class FileRouterBackend(RouterBackend): """Discover `page.py` (and virtual pages) under app and optional root trees.""" DEFAULT_COMPONENTS_FOLDER_NAME: ClassVar[str] = "_components"
[docs] def __init__( # noqa: PLR0913 self, pages_dir: str | None = None, *, app_dirs: bool | None = None, extra_root_paths: list[Path] | None = None, skip_dir_names: frozenset[str] | None = None, components_folder_name: str | None = None, options: dict[str, Any] | None = None, ) -> None: """Configure pages dir, extra roots, skip-dir names, and narrowed OPTIONS.""" self.pages_dir = pages_dir if pages_dir is not None else "pages" self.app_dirs = app_dirs if app_dirs is not None else True raw_opts = dict(options) if options else {} base_dir = resolve_base_dir() comp_name = ( self.DEFAULT_COMPONENTS_FOLDER_NAME if components_folder_name is None else components_folder_name ) if extra_root_paths is None or skip_dir_names is None: dirs_list = list(extra_root_paths or []) path_roots, segment_names = classify_dirs_entries(dirs_list, base_dir) roots: list[Path] = path_roots skip = frozenset({comp_name, *segment_names}) else: roots = list(extra_root_paths) skip = skip_dir_names self._extra_root_paths = roots self._skip_dir_names = skip self._components_folder_name = comp_name self.options = _narrow_file_router_options(raw_opts) self._patterns_cache: dict[str, list[URLPattern | URLResolver]] = {} self._app_pages_path_cache: dict[str, Path | None] = {} self._url_parser = default_url_parser
@staticmethod def _resolve_components_folder_name() -> str: """Folder name to skip in URL scans. Taken from the first `DEFAULT_COMPONENT_BACKENDS` entry. """ cbs = next_framework_settings.DEFAULT_COMPONENT_BACKENDS _components_key = "COMPONENTS_DIR" if not isinstance(cbs, list) or not cbs: raise KeyError(_components_key) cb0 = cbs[0] if not isinstance(cb0, dict) or _components_key not in cb0: raise KeyError(_components_key) return str(cb0[_components_key])
[docs] def __repr__(self) -> str: """Debug representation.""" return ( f"<{self.__class__.__name__} pages_dir='{self.pages_dir}' " f"app_dirs={self.app_dirs}>" )
[docs] def __eq__(self, other: object) -> bool: """Return True when the other backend has the same pages configuration.""" if not isinstance(other, FileRouterBackend): return False return ( self.pages_dir == other.pages_dir and self.app_dirs == other.app_dirs and self.options == other.options and self._extra_root_paths == other._extra_root_paths and self._skip_dir_names == other._skip_dir_names and self._components_folder_name == other._components_folder_name )
[docs] def __hash__(self) -> int: """Hash from pages config including extra roots and skip names.""" cp = self.options.get("context_processors") cp_t = tuple(cp) if isinstance(cp, list) else () return hash( ( self.pages_dir, self.app_dirs, tuple(self._extra_root_paths), tuple(sorted(self._skip_dir_names)), self._components_folder_name, cp_t, ), )
[docs] def generate_urls(self) -> list[URLPattern | URLResolver]: """Yield app routes first when `app_dirs` is set, then root `pages` dirs.""" if self.app_dirs: urls = self._generate_app_urls() urls.extend(self._generate_root_urls()) return urls return self._generate_root_urls()
def _generate_app_urls(self) -> list[URLPattern | URLResolver]: """Return patterns from each installed app's `pages_dir` tree.""" urls: list[URLPattern | URLResolver] = [] for app_name in self._get_installed_apps(): if patterns := self._generate_urls_for_app(app_name): urls.extend(patterns) return urls def _generate_root_urls(self) -> list[URLPattern | URLResolver]: """Patterns from each configured root pages directory.""" urls: list[URLPattern | URLResolver] = [] for pages_path in self._get_root_pages_paths(): urls.extend(self._generate_patterns_from_directory(pages_path)) return urls def _get_installed_apps(self) -> Generator[str, None, None]: """Yield installed app names except django framework packages.""" for app in getattr(settings, "INSTALLED_APPS", []): if not app.startswith("django."): yield app def _get_app_pages_path(self, app_name: str) -> Path | None: """Return `<app>/pages_dir` when that directory exists.""" if app_name in self._app_pages_path_cache: return self._app_pages_path_cache[app_name] try: app_module = __import__(app_name, fromlist=[""]) if app_module.__file__ is None: self._app_pages_path_cache[app_name] = None return None app_path = Path(app_module.__file__).parent pages_path = app_path / self.pages_dir result = pages_path if pages_path.exists() else None except (ImportError, AttributeError): result = None self._app_pages_path_cache[app_name] = result return result def _get_root_pages_paths(self) -> list[Path]: """Return paths from `DIRS` plus optional `BASE_DIR` / `pages_dir`.""" result: list[Path] = [p.resolve() for p in self._extra_root_paths if p.exists()] if not self.app_dirs and not result: base_dir = resolve_base_dir() if base_dir is not None: pages_path = base_dir / self.pages_dir if pages_path.exists(): result.append(pages_path) return result def _generate_urls_for_app(self, app_name: str) -> list[URLPattern | URLResolver]: """Return cached patterns for one app, scanning on first use.""" if app_name in self._patterns_cache: return self._patterns_cache[app_name] if pages_path := self._get_app_pages_path(app_name): patterns: list[URLPattern | URLResolver] = list( self._generate_patterns_from_directory(pages_path), ) self._patterns_cache[app_name] = patterns return patterns return [] def _generate_patterns_from_directory( self, pages_path: Path, ) -> Generator[URLPattern, None, None]: """Yield one `URLPattern` per discovered page under `pages_path`.""" for url_path, file_path in self._scan_pages_directory(pages_path): if pattern := page.create_url_pattern( url_path, file_path, self._url_parser, ): route_registered.send( sender=FileRouterBackend, url_path=url_path, file_path=file_path, ) yield pattern def _scan_pages_directory( self, pages_path: Path, *, register_components: bool = True, ) -> Generator[tuple[str, Path], None, None]: """Yield `(url_path, page_file)` pairs discovered under `pages_path`.""" dispatcher = FilesystemTreeDispatcher( self._skip_dir_names, components_folder_name=self._components_folder_name, register_components=register_components, ) yield from dispatcher.walk(pages_path)
[docs] class RouterFactory: """Build `RouterBackend` instances from `DEFAULT_PAGE_BACKENDS`-style dicts.""" _backends: ClassVar[dict[str, type[RouterBackend]]] = { "next.urls.FileRouterBackend": FileRouterBackend, }
[docs] @classmethod def register_backend(cls, name: str, backend_class: type[RouterBackend]) -> None: """Map a dotted backend path to a class for `create_backend`.""" cls._backends[name] = backend_class
[docs] @classmethod def is_filesystem_discovery_router_class(cls, router_class: object) -> bool: """Return True if `router_class` implements the filesystem page-tree API.""" if not isinstance(router_class, type): return False if issubclass(router_class, FileRouterBackend): return True if not issubclass(router_class, RouterBackend): return False required = ( "generate_urls", "_get_installed_apps", "_get_app_pages_path", "_get_root_pages_paths", ) return all(hasattr(router_class, name) for name in required)
[docs] @classmethod def is_filesystem_discovery_router(cls, obj: object) -> bool: """Whether `obj` is a router instance that walks pages trees (duck typing).""" if obj is None: return False return ( hasattr(obj, "pages_dir") and hasattr(obj, "app_dirs") and hasattr(obj, "options") and hasattr(obj, "generate_urls") and callable(getattr(obj, "generate_urls", None)) and hasattr(obj, "_get_installed_apps") and callable(getattr(obj, "_get_installed_apps", None)) and hasattr(obj, "_get_app_pages_path") and callable(getattr(obj, "_get_app_pages_path", None)) and hasattr(obj, "_get_root_pages_paths") and callable(getattr(obj, "_get_root_pages_paths", None)) and hasattr(obj, "_skip_dir_names") )
[docs] @classmethod def create_backend(cls, config: dict[str, Any]) -> RouterBackend: """Instantiate the backend class named by `config["BACKEND"]`.""" backend_name = config["BACKEND"] backend_class: Any if backend_name in cls._backends: backend_class = cls._backends[backend_name] else: try: backend_class = import_class_cached(backend_name) except ImportError as e: msg = f"Unsupported backend: {backend_name}" raise ValueError(msg) from e if not isinstance(backend_class, type) or not issubclass( backend_class, RouterBackend, ): msg = f"Backend {backend_name!r} is not a RouterBackend subclass" raise TypeError(msg) if issubclass(backend_class, FileRouterBackend): for req in ("PAGES_DIR", "APP_DIRS", "OPTIONS", "DIRS"): if req not in config: raise KeyError(req) base_dir = resolve_base_dir() raw_opts = config.get("OPTIONS") if not isinstance(raw_opts, dict): raw_opts = {} dirs_list = list(config.get("DIRS") or []) path_roots, segment_names = classify_dirs_entries(dirs_list, base_dir) components_dir = FileRouterBackend._resolve_components_folder_name() skip_names = frozenset({components_dir, *segment_names}) narrow_opts = _narrow_file_router_options(raw_opts) return backend_class( pages_dir=config.get("PAGES_DIR", "pages"), app_dirs=bool(config.get("APP_DIRS", True)), extra_root_paths=path_roots, skip_dir_names=skip_names, components_folder_name=components_dir, options=narrow_opts, ) return backend_class()
__all__ = [ "FileRouterBackend", "RouterBackend", "RouterFactory", ]