Source code for next.pages.manager

"""`Page` manager and its process-wide singleton.

`Page` orchestrates template loading, context collection, layout
composition, rendering, and URL-pattern wiring. `page` is the
application-wide singleton. `context` is a convenience alias for
`page.context` used by the `@context` decorator in user code.
"""

from __future__ import annotations

import contextlib
import inspect
import logging
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast

from django.http import HttpRequest, HttpResponse
from django.http.response import HttpResponseBase
from django.template import Context as DjangoTemplateContext, Template
from django.urls import URLPattern, path

from next.conf import next_framework_settings
from next.deps import DependencyResolver, resolver
from next.utils import caller_source_path

from .loaders import (
    LayoutManager,
    _load_python_module_memo,
    build_registered_loaders,
)
from .processors import _get_context_processors
from .registry import PageContextRegistry
from .signals import page_rendered, template_loaded


if TYPE_CHECKING:
    import types
    from collections.abc import Callable
    from pathlib import Path

    from next.static import StaticCollector
    from next.static.serializers import JsContextSerializer
    from next.urls import URLPatternParser


logger = logging.getLogger(__name__)


def _extract_request(
    args: tuple[object, ...],
    kwargs: dict[str, object],
) -> HttpRequest | None:
    """Return the `HttpRequest` from positional or keyword arguments.

    Most call sites pass the active request as the first positional
    argument, but programmatic callers of `Page.render` may also
    supply it through the `request` keyword. The helper accepts both
    forms and returns `None` when neither carries an `HttpRequest`.
    """
    if args and isinstance(args[0], HttpRequest):
        return args[0]
    candidate = kwargs.get("request")
    if isinstance(candidate, HttpRequest):
        return candidate
    return None


@dataclass(frozen=True, slots=True)
class _BodyResolution:
    """Per-request outcome of `Page._resolve_page_body`.

    `body` is a string that will be composed through the layout chain
    and rendered. `http_response` is a Django response that is returned
    verbatim. The framework uses the verbatim path as the `render()`
    escape hatch for redirects, streaming responses, JSON, and anything
    else. The type is `HttpResponseBase` so `StreamingHttpResponse` and
    `FileResponse` flow through unchanged alongside `HttpResponse`.
    """

    body: str | None = None
    http_response: HttpResponseBase | None = None


[docs] class Page: """Coordinate template loading, context, layouts, rendering, and URL wiring."""
[docs] def __init__(self) -> None: """Initialise fresh registries and layout manager. File-based template loaders are not held as an instance attribute. The module-level `build_registered_loaders()` helper caches them and invalidates on `settings_reloaded`. """ self._template_registry: dict[Path, str] = {} self._template_source_mtimes: dict[Path, dict[Path, float]] = {} self._resolver: DependencyResolver | None = None self._context_manager = PageContextRegistry(None) self._layout_manager = LayoutManager()
def _get_resolver(self) -> DependencyResolver: """Return the shared `resolver` singleton.""" return resolver
[docs] def register_template(self, file_path: Path, template_str: str) -> None: """Store rendered template source for `file_path`.""" self._template_registry[file_path] = template_str template_loaded.send(sender=Page, file_path=file_path)
def _get_caller_path(self, back_count: int = 1) -> Path: """Return the filesystem path of the user caller outside this module.""" return caller_source_path( back_count=back_count, max_walk=10, skip_while_filename_endswith=("manager.py",), )
[docs] def context( self, func_or_key: Callable[..., Any] | str | None = None, *, inherit_context: bool = False, serialize: bool = False, serializer: JsContextSerializer | None = None, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Register a keyed or dict-merge `@context` for the caller file. Pass `serialize=True` to include the return value in `Next.context` so JavaScript code on the page can read it via `window.Next.context`. Pass `serializer=` to route this key through a custom `JsContextSerializer` instead of the global `JS_CONTEXT_SERIALIZER` setting. """ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: if callable(func_or_key): caller_path = self._get_caller_path(2) self._context_manager.register_context( caller_path, None, func_or_key, inherit_context=inherit_context, serialize=serialize, serializer=serializer, ) else: caller_path = self._get_caller_path(1) self._context_manager.register_context( caller_path, func_or_key, func, inherit_context=inherit_context, serialize=serialize, serializer=serializer, ) return func return decorator(func_or_key) if callable(func_or_key) else decorator
[docs] def build_render_context( self, file_path: Path, *args: object, **kwargs: object, ) -> dict[str, object]: """Build the full render context dict used by `render`. The returned dict includes `_next_js_context` holding the subset of values marked `serialize=True`. `render` pops that key and seeds the `StaticCollector` with it before creating the Django template context. """ context_data: dict[str, object] = {} template_djx = file_path.parent / "template.djx" context_data["current_template_path"] = ( str(template_djx) if template_djx.exists() else str(file_path) ) context_data["current_page_module_path"] = str(file_path.resolve()) context_data.update(kwargs) context_result = self._context_manager.collect_context( file_path, *args, **kwargs ) context_data.update(context_result.context_data) context_data["_next_js_context"] = context_result.js_context context_data["_next_js_context_serializers"] = ( context_result.js_context_serializers ) request: HttpRequest | None = None if args and isinstance(args[0], HttpRequest): request = args[0] if request is not None: context_data["request"] = request context_processors = _get_context_processors() if request and context_processors: strict = bool(getattr(next_framework_settings, "STRICT_CONTEXT", False)) for processor in context_processors: try: processor_data = processor(request) if isinstance(processor_data, dict): context_data.update(processor_data) except (TypeError, ValueError, AttributeError, KeyError) as e: if strict: raise logger.warning( "Error in context processor %s: %s", processor.__name__, e, ) return context_data
def _load_static_body( self, file_path: Path, module: types.ModuleType | None, ) -> str: """Return the static body for `file_path` without invoking `render()`. The `module.template` attribute wins when set to a non-`None` string. Otherwise the framework consults registered `TemplateLoader` instances in the order declared under `NEXT_FRAMEWORK["TEMPLATE_LOADERS"]`. The first loader that can load the path returns the body. An empty string is returned when no source is present so an ancestor layout can still render with an empty slot. """ if module is not None: template_attr = getattr(module, "template", None) if isinstance(template_attr, str): return template_attr for loader in build_registered_loaders(): if loader.can_load(file_path): return loader.load_template(file_path) or "" return "" def _resolve_page_body( self, file_path: Path, module: types.ModuleType | None, *args: object, **kwargs: object, ) -> _BodyResolution: """Resolve the page body per-request. The resolution order is `render()`, then the `template` module attribute, then the registered `TemplateLoader` chain, then an empty body. `render()` may short-circuit by returning any `HttpResponseBase` subclass such as a redirect, a streaming response, a file response, or a JSON response. In that case the layout and static pipelines are bypassed entirely. """ if module is not None: render_func = getattr(module, "render", None) if callable(render_func): return self._call_render_function( render_func, file_path, *args, **kwargs ) return _BodyResolution(body=self._load_static_body(file_path, module)) def _call_render_function( self, render_func: Callable[..., object], file_path: Path, *args: object, **kwargs: object, ) -> _BodyResolution: """Invoke `render_func` with DI-resolved arguments and classify the result.""" request = args[0] if args and isinstance(args[0], HttpRequest) else None dep_cache: dict[str, Any] = {} dep_stack: list[str] = [] resolved = self._get_resolver().resolve_dependencies( render_func, request=request, _cache=dep_cache, _stack=dep_stack, **kwargs, ) result = render_func(**resolved) if isinstance(result, HttpResponseBase): return _BodyResolution(http_response=result) if isinstance(result, str): return _BodyResolution(body=result) msg = ( f"page.py render() at {file_path} must return str or " f"HttpResponseBase, got {type(result).__name__}." ) raise TypeError(msg)
[docs] def render_with_static_assets( self, file_path: Path, template_str: str, context_data: dict[str, object], *, request: HttpRequest | None = None, ) -> tuple[str, StaticCollector]: """Render `template_str` and inject collected static assets. The method seeds a fresh `StaticCollector`, hydrates it with the JS context that `build_render_context` left under the `_next_js_context` key, discovers co-located assets for the page, renders the Django template, and replaces placeholders through `default_manager.inject`. The active `request` reaches the static backend so request-aware subclasses can rewrite URLs. Both the rendered HTML and the collector are returned so callers can reuse the collector for telemetry without a second rendering pass. Suitable for the canonical page render path and for partial paths such as form-error rerenders. """ from next.static import default_manager # noqa: PLC0415 collector = default_manager.create_collector() js_context: dict[str, object] = context_data.pop("_next_js_context", {}) # type: ignore[assignment] js_serializers: dict[str, JsContextSerializer] = context_data.pop( "_next_js_context_serializers", {} ) # type: ignore[assignment] for js_key, js_value in js_context.items(): collector.add_js_context( js_key, js_value, serializer=js_serializers.get(js_key) ) default_manager.discover_page_assets(file_path, collector) context_data["_static_collector"] = collector html = Template(template_str).render(DjangoTemplateContext(context_data)) result = cast( "str", default_manager.inject( html, collector, page_path=file_path, request=request ), ) return result, collector
def _render_template_str( self, file_path: Path, template_str: str, start: float, *args: object, **kwargs: object, ) -> str: """Build context, render `template_str`, inject static assets, emit signal.""" context_data = self.build_render_context(file_path, *args, **kwargs) request = _extract_request(args, kwargs) result, collector = self.render_with_static_assets( file_path, template_str, context_data, request=request, ) if page_rendered.receivers: duration_ms = (time.perf_counter() - start) * 1000 page_rendered.send( sender=Page, file_path=file_path, duration_ms=duration_ms, styles_count=len(collector.assets_in_slot("styles")), scripts_count=len(collector.assets_in_slot("scripts")), context_keys=tuple(context_data.keys()), ) return result def _render_composed( self, file_path: Path, body: str, *args: object, **kwargs: object, ) -> str: """Compose `body` through layouts and render. The template-registry cache is bypassed so dynamic bodies produced by `render()` do not poison the cache. """ start = time.perf_counter() composed = self._layout_manager._layout_loader.compose_body(body, file_path) return self._render_template_str(file_path, composed, start, *args, **kwargs)
[docs] def render(self, file_path: Path, *args: object, **kwargs: object) -> str: """Render the page with Django `Template` and the static collector. The static body source is the `template` attribute or any registered file-based `TemplateLoader`. The result is composed through the ancestor layout chain and cached in `_template_registry`. Direct callers of `Page.render` do not invoke `render()`. The unified view handles that path so dynamic bodies skip the registry cache. """ start = time.perf_counter() if file_path not in self._template_registry or self._is_template_stale( file_path ): self._template_registry.pop(file_path, None) self._template_source_mtimes.pop(file_path, None) module = _load_python_module_memo(file_path) body = self._load_static_body(file_path, module) composed = self._layout_manager._layout_loader.compose_body(body, file_path) self.register_template(file_path, composed) self._record_template_source_mtimes(file_path) template_str = self._template_registry[file_path] return self._render_template_str( file_path, template_str, start, *args, **kwargs )
def _create_unified_view( self, file_path: Path, _parameters: dict[str, str], module: types.ModuleType | None, ) -> Callable[..., HttpResponseBase]: """Return a view that resolves the body, composes layouts, and renders.""" def view(request: HttpRequest, **kwargs: object) -> HttpResponseBase: resolution = self._resolve_page_body(file_path, module, request, **kwargs) if resolution.http_response is not None: return resolution.http_response body = resolution.body if resolution.body is not None else "" content = self._render_composed(file_path, body, request, **kwargs) return HttpResponse(content) return view
[docs] def has_template( self, file_path: Path, module: types.ModuleType | None = None ) -> bool: """Return whether any source can supply a template for this path.""" if self._layout_manager._layout_loader.can_load(file_path): return True if module is not None and hasattr(module, "template"): return True return any(loader.can_load(file_path) for loader in build_registered_loaders())
def _get_template_source_paths(self, file_path: Path) -> list[Path]: """Return file-based loader source files and layout files behind this page.""" loader_paths: list[Path] = [] for loader in build_registered_loaders(): source = loader.source_path(file_path) if source is not None: loader_paths.append(source) layout_files = self._layout_manager._layout_loader._find_layout_files(file_path) return loader_paths + (layout_files or []) def _record_template_source_mtimes(self, file_path: Path) -> None: """Snapshot mtimes of template source files for stale detection.""" paths = self._get_template_source_paths(file_path) if not paths: return mtimes: dict[Path, float] = {} for p in paths: with contextlib.suppress(OSError): mtimes[p] = p.stat().st_mtime if mtimes: self._template_source_mtimes[file_path] = mtimes def _is_template_stale(self, file_path: Path) -> bool: """Return whether any tracked source file changed on disk.""" stored = self._template_source_mtimes.get(file_path) if not stored: return False for p, old_mtime in stored.items(): try: if p.stat().st_mtime > old_mtime: return True except OSError as e: logger.debug("Cannot stat %s in stale check: %s", p, e) return False def _create_regular_page_pattern( self, file_path: Path, django_pattern: str, parameters: dict[str, str], clean_name: str, ) -> URLPattern | None: """Return the URL pattern for a real `page.py` that has any body source.""" module = _load_python_module_memo(file_path) if module is None: return None if not self._page_has_body_source(file_path, module): return None view = self._create_unified_view(file_path, parameters, module) return path( django_pattern, view, name=next_framework_settings.URL_NAME_TEMPLATE.format(name=clean_name), ) def _create_virtual_page_pattern( self, file_path: Path, django_pattern: str, parameters: dict[str, str], clean_name: str, ) -> URLPattern | None: """Return the URL pattern for a template-only page without `page.py`.""" if not self._page_has_body_source(file_path, module=None): return None view = self._create_unified_view(file_path, parameters, None) return path( django_pattern, view, name=next_framework_settings.URL_NAME_TEMPLATE.format(name=clean_name), ) def _page_has_body_source( self, file_path: Path, module: types.ModuleType | None, ) -> bool: """Return True when `file_path` can produce a body or layout body.""" if module is not None: if callable(getattr(module, "render", None)): return True if isinstance(getattr(module, "template", None), str): return True if any(loader.can_load(file_path) for loader in build_registered_loaders()): return True return self._layout_manager._layout_loader.can_load(file_path)
[docs] def create_url_pattern( self, url_path: str, file_path: Path, url_parser: URLPatternParser, ) -> URLPattern | None: """Return a `path()` pattern for a page, template, or virtual entry.""" django_pattern, parameters = url_parser.parse_url_pattern(url_path) clean_name = url_parser.prepare_url_name(url_path) if file_path.exists(): return self._create_regular_page_pattern( file_path, django_pattern, parameters, clean_name, ) return self._create_virtual_page_pattern( file_path, django_pattern, parameters, clean_name, )
_ = inspect # keep `inspect` import reachable for test-time patching page: Page = Page() context = page.context