"""Component renderers and render-time helpers.
`ComponentTemplateLoader` reads the raw source for a component. The
Protocol `ComponentRenderStrategy` plus `SimpleComponentRenderer` and
`CompositeComponentRenderer` are the two built-in renderers chosen by
`ComponentRenderer`.
"""
from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING, Any, Protocol
from django.http import HttpResponse
from django.middleware.csrf import get_token
from django.template import Context as DjangoTemplateContext, Template
from django.utils.functional import SimpleLazyObject
from next.deps import get_request_dep_cache, resolver
from next.deps.cache import DependencyCache
from .context import component
if TYPE_CHECKING:
from collections.abc import Callable, Mapping, Sequence
from django.http import HttpRequest
from next.static import StaticCollector
from .info import ComponentInfo
from .loading import ModuleLoader
[docs]
class ComponentTemplateLoader:
"""Read template source from a `.djx` file or a `component` module string."""
[docs]
def __init__(self, module_loader: ModuleLoader) -> None:
"""Bind this loader to a shared `ModuleLoader`."""
self._module_loader = module_loader
[docs]
def load(self, info: ComponentInfo) -> str | None:
"""Return raw template text for `info` or `None` when unavailable."""
if info.template_path is not None and info.template_path.suffix == ".djx":
with contextlib.suppress(OSError, UnicodeDecodeError):
return info.template_path.read_text(encoding="utf-8")
if info.module_path is not None:
module = self._module_loader.load(info.module_path)
if module is not None and hasattr(module, "component"):
return getattr(module, "component", None)
return None
[docs]
def _render_template_string(template_str: str, context_dict: dict[str, Any]) -> str:
return Template(template_str).render(DjangoTemplateContext(context_dict))
[docs]
def _merge_csrf_context(
context_dict: dict[str, Any],
request: HttpRequest | None,
) -> None:
"""Add a lazy `csrf_token` matching the request context processor."""
if request is None or "csrf_token" in context_dict:
return
context_dict["csrf_token"] = SimpleLazyObject(lambda: get_token(request))
[docs]
def _inject_component_context(
info: ComponentInfo,
context_data: dict[str, Any],
request: HttpRequest | None,
) -> None:
if info.module_path is None:
return
ctx_funcs = component.get_functions(info.module_path)
if not ctx_funcs:
return
collector: StaticCollector | None = context_data.get("_static_collector")
shared = get_request_dep_cache(request)
cache = DependencyCache(backing_dict=shared) if shared else DependencyCache()
stack: list[str] = []
for ctx_func in ctx_funcs:
resolved = resolver.resolve_with_template_context(
ctx_func.func,
request=request,
template_context=context_data,
_cache=cache,
_stack=stack,
)
if ctx_func.key is None:
data = ctx_func.func(**resolved)
if isinstance(data, dict):
context_data.update(data)
if ctx_func.serialize and collector is not None:
for k, v in data.items():
collector.add_js_context(k, v, serializer=ctx_func.serializer)
else:
result = ctx_func.func(**resolved)
context_data[ctx_func.key] = result
if ctx_func.serialize and collector is not None:
collector.add_js_context(
ctx_func.key, result, serializer=ctx_func.serializer
)
[docs]
class ComponentRenderStrategy(Protocol):
"""Optional render path for a `ComponentInfo`."""
[docs]
def can_render(self, info: ComponentInfo) -> bool:
"""Return True when this strategy handles `info`."""
raise NotImplementedError
[docs]
def render(
self,
info: ComponentInfo,
context_data: Mapping[str, Any],
request: HttpRequest | None,
) -> str:
"""Return the rendered HTML for `info`."""
raise NotImplementedError
[docs]
class SimpleComponentRenderer:
"""Uses the template string only (no `component.py`)."""
[docs]
def __init__(self, template_loader: ComponentTemplateLoader) -> None:
"""Bind this renderer to a shared `ComponentTemplateLoader`."""
self._loader = template_loader
[docs]
def can_render(self, info: ComponentInfo) -> bool:
"""Return True for simple components and for missing module files."""
return info.is_simple or info.module_path is None
[docs]
def render(
self,
info: ComponentInfo,
context_data: Mapping[str, Any],
request: HttpRequest | None,
) -> str:
"""Render `info` by plain template string rendering."""
template_str = self._loader.load(info)
if template_str is None:
return ""
context_dict = dict(context_data)
if request is not None:
context_dict.setdefault("request", request)
_merge_csrf_context(context_dict, request)
return _render_template_string(template_str, context_dict)
[docs]
class CompositeComponentRenderer:
"""Uses `render()` in `component.py` when present, otherwise the template."""
[docs]
def __init__(
self,
module_loader: ModuleLoader,
template_loader: ComponentTemplateLoader,
) -> None:
"""Bind the renderer to shared module and template loaders."""
self._module_loader = module_loader
self._template_loader = template_loader
[docs]
def can_render(self, info: ComponentInfo) -> bool:
"""Return True for composite components with a loadable `component.py`."""
return not info.is_simple and info.module_path is not None
[docs]
def render(
self,
info: ComponentInfo,
context_data: Mapping[str, Any],
request: HttpRequest | None,
) -> str:
"""Render `info` via `component.py:render` or fall back to the template."""
if info.module_path is None:
return ""
module = self._module_loader.load(info.module_path)
if module is None:
return self._fallback_to_template(info, context_data)
render_func = getattr(module, "render", None)
if callable(render_func):
return self._render_with_function(render_func, context_data, request)
return self._render_with_template(info, context_data, request)
def _render_with_function(
self,
render_func: Callable[..., Any],
context_data: Mapping[str, Any],
request: HttpRequest | None,
) -> str:
cache = DependencyCache()
stack: list[str] = []
resolved = resolver.resolve_with_template_context(
render_func,
request=request,
template_context=dict(context_data),
_cache=cache,
_stack=stack,
)
result = render_func(**resolved)
if isinstance(result, HttpResponse):
return result.content.decode()
return str(result)
def _render_with_template(
self,
info: ComponentInfo,
context_data: Mapping[str, Any],
request: HttpRequest | None,
) -> str:
template_str = self._template_loader.load(info)
if template_str is None:
return ""
context_dict = dict(context_data)
if request is not None:
context_dict["request"] = request
_merge_csrf_context(context_dict, request)
_inject_component_context(info, context_dict, request)
return _render_template_string(template_str, context_dict)
def _fallback_to_template(
self,
info: ComponentInfo,
context_data: Mapping[str, Any],
) -> str:
template_str = self._template_loader.load(info)
if template_str is None:
return ""
return _render_template_string(template_str, dict(context_data))
[docs]
class ComponentRenderer:
"""Picks the first renderer that accepts this component."""
[docs]
def __init__(self, strategies: Sequence[ComponentRenderStrategy]) -> None:
"""Bind the renderer to an ordered list of render strategies."""
self._strategies = strategies
[docs]
def render(
self,
info: ComponentInfo,
context_data: Mapping[str, Any],
request: HttpRequest | None = None,
) -> str:
"""Return HTML from the first matching render strategy."""
for strategy in self._strategies:
if strategy.can_render(info):
return strategy.render(info, context_data, request)
return ""
__all__ = [
"ComponentRenderStrategy",
"ComponentRenderer",
"ComponentTemplateLoader",
"CompositeComponentRenderer",
"SimpleComponentRenderer",
]