"""Context registration for `component.py` modules.
`ComponentContextManager` is the public handle used by decorator
`@component.context` inside a `component.py` file. It records the
caller's path so the right context callables run when the matching
component template is rendered.
"""
from __future__ import annotations
import inspect
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from next.deps import resolver
from next.utils import caller_source_path
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from next.static.serializers import JsContextSerializer
[docs]
@dataclass(frozen=True, slots=True)
class ContextFunction:
"""One function registered to add variables before a component template runs.
The optional `serializer` overrides the global JS context
serializer for the value this callable produces, but only when
`serialize` is true.
"""
func: Callable[..., Any]
key: str | None
serialize: bool = False
serializer: JsContextSerializer | None = None
[docs]
class ComponentContextRegistry:
"""Maps `component.py` paths to functions that supply template variables."""
[docs]
def __init__(self) -> None:
"""Create an empty path-keyed context-function mapping."""
self._registry: dict[Path, dict[str | None, ContextFunction]] = {}
[docs]
def register(
self,
component_path: Path,
key: str | None,
func: Callable[..., Any],
*,
serialize: bool = False,
serializer: JsContextSerializer | None = None,
) -> None:
"""Register `func` under `key` for `component_path`, rejecting reserved keys."""
path = component_path.resolve()
if isinstance(key, str) and key in resolver.EXPLICIT_RESOLVE_KEYS:
msg = (
f"Component context key {key!r} is reserved for dependency injection. "
f"Use another name. Reserved: {sorted(resolver.EXPLICIT_RESOLVE_KEYS)}."
)
raise ValueError(msg)
component_registry = self._registry.setdefault(path, {})
if key in component_registry:
existing = component_registry[key]
if not self._is_same_function(existing.func, func):
if key is None:
dup_desc = "unkeyed @component.context"
else:
dup_desc = f"key {key!r}"
msg = (
f"Duplicate component context registration ({dup_desc}) for {path}"
)
raise ValueError(msg)
component_registry[key] = ContextFunction(
func=func, key=key, serialize=serialize, serializer=serializer
)
[docs]
def get_functions(self, component_path: Path) -> Sequence[ContextFunction]:
"""Return a tuple of registered context functions for `component_path`."""
path = component_path.resolve()
registry = self._registry.get(path, {})
return tuple(registry.values())
def _is_same_function(
self, func1: Callable[..., Any], func2: Callable[..., Any]
) -> bool:
if func1 is func2:
return True
name1 = getattr(func1, "__name__", None)
name2 = getattr(func2, "__name__", None)
if not name1 or not name2 or name1 != name2:
return False
try:
file1 = inspect.getsourcefile(func1)
file2 = inspect.getsourcefile(func2)
if not file1 or not file2:
return False
return Path(file1).resolve() == Path(file2).resolve()
except (OSError, TypeError, ValueError):
return False
[docs]
def __len__(self) -> int:
"""Return the total number of registered context functions."""
return sum(len(funcs) for funcs in self._registry.values())
[docs]
class ComponentContextManager:
"""Registers and looks up context helpers used from `component.py`."""
[docs]
def __init__(self) -> None:
"""Create an empty registry for context callables."""
self._registry = ComponentContextRegistry()
def _get_caller_path(self, back_count: int = 1) -> Path:
return caller_source_path(
back_count=back_count,
max_walk=10,
skip_framework_file=("context.py", "components"),
)
[docs]
def context(
self,
func_or_key: Callable[..., Any] | str | None = None,
*,
serialize: bool = False,
serializer: JsContextSerializer | None = None,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Mark a function so it fills template variables for this component module.
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._registry.register(
caller_path,
None,
func_or_key,
serialize=serialize,
serializer=serializer,
)
else:
caller_path = self._get_caller_path(1)
self._registry.register(
caller_path,
func_or_key,
func,
serialize=serialize,
serializer=serializer,
)
return func
return decorator(func_or_key) if callable(func_or_key) else decorator
[docs]
def get_functions(self, component_path: Path) -> Sequence[ContextFunction]:
"""Return context callables registered for this `component.py` path."""
return self._registry.get_functions(component_path)
component = ComponentContextManager()
context = component.context
__all__ = [
"ComponentContextManager",
"ComponentContextRegistry",
"ContextFunction",
"component",
"context",
]