feat: allow different template settings for ComponentRegistries (#615)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2024-08-27 06:18:50 +02:00 committed by GitHub
parent ee5c92ba00
commit 3b1f6088a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 478 additions and 44 deletions

View file

@ -5,6 +5,7 @@ import django
# Public API
# isort: off
from django_components.app_settings import ContextBehavior as ContextBehavior
from django_components.autodiscover import (
autodiscover as autodiscover,
import_libraries as import_libraries,
@ -17,6 +18,7 @@ from django_components.component_registry import (
AlreadyRegistered as AlreadyRegistered,
ComponentRegistry as ComponentRegistry,
NotRegistered as NotRegistered,
RegistrySettings as RegistrySettings,
register as register,
registry as registry,
)

View file

@ -31,11 +31,14 @@ from django.utils.html import conditional_escape
from django.utils.safestring import SafeString, mark_safe
from django.views import View
from django_components.app_settings import ContextBehavior
from django_components.component_media import ComponentMediaInput, MediaMeta
from django_components.component_registry import registry
from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as registry_
from django_components.context import (
_FILLED_SLOTS_CONTENT_CONTEXT_KEY,
_PARENT_COMP_CONTEXT_KEY,
_REGISTRY_CONTEXT_KEY,
_ROOT_CTX_CONTEXT_KEY,
get_injected_context_var,
make_isolated_context_copy,
@ -70,6 +73,7 @@ from django_components.component_registry import registry as registry # NOQA
# isort: on
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
COMP_ONLY_FLAG = "only"
# Define TypeVars for args and kwargs
ArgsType = TypeVar("ArgsType", bound=tuple, contravariant=True)
@ -170,6 +174,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
component_id: Optional[str] = None,
outer_context: Optional[Context] = None,
fill_content: Optional[Dict[str, FillContent]] = None,
registry: Optional[ComponentRegistry] = None, # noqa F811
):
# When user first instantiates the component class before calling
# `render` or `render_to_response`, then we want to allow the render
@ -189,6 +194,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
self.outer_context: Context = outer_context or Context()
self.fill_content = fill_content or {}
self.component_id = component_id or gen_id()
self.registry = registry or registry_
self._render_stack: Deque[RenderInput[ArgsType, KwargsType, SlotsType]] = deque()
def __init_subclass__(cls, **kwargs: Any) -> None:
@ -535,8 +541,10 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
with context.update(
{
# Private context fields
_ROOT_CTX_CONTEXT_KEY: self.outer_context,
_FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_slots,
_REGISTRY_CONTEXT_KEY: self.registry,
# NOTE: Public API for variables accessible from within a component's template
# See https://github.com/EmilStenstrom/django-components/issues/280#issuecomment-2081180940
"component_vars": {
@ -595,6 +603,7 @@ class ComponentNode(BaseNode):
name: str,
args: List[Expression],
kwargs: RuntimeKwargs,
registry: ComponentRegistry, # noqa F811
isolated_context: bool = False,
fill_nodes: Optional[List[FillNode]] = None,
node_id: Optional[str] = None,
@ -604,6 +613,7 @@ class ComponentNode(BaseNode):
self.name = name
self.isolated_context = isolated_context
self.fill_nodes = fill_nodes or []
self.registry = registry
def __repr__(self) -> str:
return "<ComponentNode: {}. Contents: {!r}>".format(
@ -614,7 +624,7 @@ class ComponentNode(BaseNode):
def render(self, context: Context) -> str:
trace_msg("RENDR", "COMP", self.name, self.node_id)
component_cls: Type[Component] = registry.get(self.name)
component_cls: Type[Component] = self.registry.get(self.name)
# Resolve FilterExpressions and Variables that were passed as args to the
# component, then call component's context method
@ -639,10 +649,11 @@ class ComponentNode(BaseNode):
outer_context=context,
fill_content=fill_content,
component_id=self.node_id,
registry=self.registry,
)
# Prevent outer context from leaking into the template of the component
if self.isolated_context:
if self.isolated_context or self.registry.settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED:
context = make_isolated_context_copy(context)
output = component._render(

View file

@ -1,9 +1,10 @@
from typing import TYPE_CHECKING, Callable, Dict, NamedTuple, Optional, Set, Type, TypeVar
from typing import TYPE_CHECKING, Callable, Dict, NamedTuple, Optional, Set, Type, TypeVar, Union
from django.template import Library
from django_components.app_settings import ContextBehavior, app_settings
from django_components.library import is_tag_protected, mark_protected_tags, register_tag_from_formatter
from django_components.tag_formatter import get_tag_formatter
from django_components.tag_formatter import TagFormatterABC, get_tag_formatter
if TYPE_CHECKING:
from django_components.component import Component
@ -33,6 +34,16 @@ class ComponentRegistryEntry(NamedTuple):
tag: str
class RegistrySettings(NamedTuple):
CONTEXT_BEHAVIOR: Optional[ContextBehavior] = None
TAG_FORMATTER: Optional[Union["TagFormatterABC", str]] = None
class InternalRegistrySettings(NamedTuple):
CONTEXT_BEHAVIOR: ContextBehavior
TAG_FORMATTER: Union["TagFormatterABC", str]
class ComponentRegistry:
"""
Manages which components can be used in the template tags.
@ -66,10 +77,16 @@ class ComponentRegistry:
```
"""
def __init__(self, library: Optional[Library] = None) -> None:
def __init__(
self,
library: Optional[Library] = None,
settings: Optional[Union[RegistrySettings, Callable[["ComponentRegistry"], RegistrySettings]]] = None,
) -> None:
self._registry: Dict[str, ComponentRegistryEntry] = {} # component name -> component_entry mapping
self._tags: Dict[str, Set[str]] = {} # tag -> list[component names]
self._library = library
self._settings_input = settings
self._settings: Optional[Callable[[], InternalRegistrySettings]] = None
@property
def library(self) -> Library:
@ -91,6 +108,37 @@ class ComponentRegistry:
lib = self._library = tag_library
return lib
@property
def settings(self) -> InternalRegistrySettings:
# This is run on subsequent calls
if self._settings is not None:
# NOTE: Registry's settings can be a function, so we always take
# the latest value from Django's settings.
settings = self._settings()
# First-time initialization
# NOTE: We allow the settings to be given as a getter function
# so the settings can respond to changes.
# So we wrapp that in our getter, which assigns default values from the settings.
else:
def get_settings() -> InternalRegistrySettings:
if callable(self._settings_input):
settings_input: Optional[RegistrySettings] = self._settings_input(self)
else:
settings_input = self._settings_input
return InternalRegistrySettings(
CONTEXT_BEHAVIOR=(settings_input and settings_input.CONTEXT_BEHAVIOR)
or app_settings.CONTEXT_BEHAVIOR,
TAG_FORMATTER=(settings_input and settings_input.TAG_FORMATTER) or app_settings.TAG_FORMATTER,
)
self._settings = get_settings
settings = self._settings()
return settings
def register(self, name: str, component: Type["Component"]) -> None:
"""
Register a component with this registry under the given name.
@ -243,8 +291,8 @@ class ComponentRegistry:
# Lazily import to avoid circular dependencies
from django_components.templatetags.component_tags import component as do_component
formatter = get_tag_formatter()
tag = register_tag_from_formatter(self.library, do_component, formatter, comp_name)
formatter = get_tag_formatter(self)
tag = register_tag_from_formatter(self, do_component, formatter, comp_name)
return ComponentRegistryEntry(cls=component, tag=tag)

View file

@ -14,6 +14,7 @@ from django_components.utils import find_last_index
_FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
_ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_ROOT_CTX"
_REGISTRY_CONTEXT_KEY = "_DJANGO_COMPONENTS_REGISTRY"
_PARENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_PARENT_COMP"
_CURRENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_CURRENT_COMP"
_INJECT_CONTEXT_KEY_PREFIX = "_DJANGO_COMPONENTS_INJECT__"
@ -38,6 +39,7 @@ def make_isolated_context_copy(context: Context) -> Context:
# Pass through our internal keys
context_copy[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = context.get(_FILLED_SLOTS_CONTENT_CONTEXT_KEY, {})
context_copy[_REGISTRY_CONTEXT_KEY] = context.get(_REGISTRY_CONTEXT_KEY, None)
if _ROOT_CTX_CONTEXT_KEY in context:
context_copy[_ROOT_CTX_CONTEXT_KEY] = context[_ROOT_CTX_CONTEXT_KEY]

View file

@ -1,12 +1,15 @@
"""Module for interfacing with Django's Library (`django.template.library`)"""
from typing import Callable, List, Optional
from typing import TYPE_CHECKING, Callable, List, Optional
from django.template.base import Node, Parser, Token
from django.template.library import Library
from django_components.tag_formatter import InternalTagFormatter
if TYPE_CHECKING:
from django_components.component_registry import ComponentRegistry
class TagProtectedError(Exception):
pass
@ -28,25 +31,25 @@ as they would conflict with other tags in the Library.
def register_tag(
library: Library,
registry: "ComponentRegistry",
tag: str,
tag_fn: Callable[[Parser, Token, str], Node],
tag_fn: Callable[[Parser, Token, "ComponentRegistry", str], Node],
) -> None:
# Register inline tag
if is_tag_protected(library, tag):
if is_tag_protected(registry.library, tag):
raise TagProtectedError('Cannot register tag "%s", this tag name is protected' % tag)
else:
library.tag(tag, lambda parser, token: tag_fn(parser, token, tag))
registry.library.tag(tag, lambda parser, token: tag_fn(parser, token, registry, tag))
def register_tag_from_formatter(
library: Library,
tag_fn: Callable[[Parser, Token, str], Node],
registry: "ComponentRegistry",
tag_fn: Callable[[Parser, Token, "ComponentRegistry", str], Node],
formatter: InternalTagFormatter,
component_name: str,
) -> str:
tag = formatter.start_tag(component_name)
register_tag(library, tag, tag_fn)
register_tag(registry, tag, tag_fn)
return tag

View file

@ -3,7 +3,22 @@ import json
import re
from collections import deque
from dataclasses import dataclass
from typing import Any, Dict, Generic, List, Mapping, NamedTuple, Optional, Protocol, Set, Tuple, Type, TypeVar, Union
from typing import (
TYPE_CHECKING,
Any,
Dict,
Generic,
List,
Mapping,
NamedTuple,
Optional,
Protocol,
Set,
Tuple,
Type,
TypeVar,
Union,
)
from django.template import Context, Template
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode
@ -11,16 +26,20 @@ from django.template.defaulttags import CommentNode
from django.template.exceptions import TemplateSyntaxError
from django.utils.safestring import SafeString, mark_safe
from django_components.app_settings import ContextBehavior, app_settings
from django_components.app_settings import ContextBehavior
from django_components.context import (
_FILLED_SLOTS_CONTENT_CONTEXT_KEY,
_INJECT_CONTEXT_KEY_PREFIX,
_REGISTRY_CONTEXT_KEY,
_ROOT_CTX_CONTEXT_KEY,
)
from django_components.expression import RuntimeKwargs, is_identifier
from django_components.logger import trace_msg
from django_components.node import BaseNode, NodeTraverse, nodelist_has_content, walk_nodelist
if TYPE_CHECKING:
from django_components.component_registry import ComponentRegistry
TSlotData = TypeVar("TSlotData", bound=Mapping, contravariant=True)
DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
@ -215,12 +234,13 @@ class SlotNode(BaseNode):
if not slot_fill.is_filled:
return context
if app_settings.CONTEXT_BEHAVIOR == ContextBehavior.DJANGO:
registry: "ComponentRegistry" = context[_REGISTRY_CONTEXT_KEY]
if registry.settings.CONTEXT_BEHAVIOR == ContextBehavior.DJANGO:
return context
elif app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED:
elif registry.settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED:
return context[_ROOT_CTX_CONTEXT_KEY]
else:
raise ValueError(f"Unknown value for CONTEXT_BEHAVIOR: '{app_settings.CONTEXT_BEHAVIOR}'")
raise ValueError(f"Unknown value for CONTEXT_BEHAVIOR: '{registry.settings.CONTEXT_BEHAVIOR}'")
def resolve_kwargs(
self,

View file

@ -1,15 +1,18 @@
import abc
import re
from typing import List, NamedTuple
from typing import TYPE_CHECKING, List, NamedTuple
from django.template import TemplateSyntaxError
from django.utils.module_loading import import_string
from django_components.app_settings import app_settings
from django_components.expression import resolve_string
from django_components.template_parser import VAR_CHARS
from django_components.utils import is_str_wrapped_in_quotes
if TYPE_CHECKING:
from django_components.component_registry import ComponentRegistry
TAG_RE = re.compile(r"^[{chars}]+$".format(chars=VAR_CHARS))
@ -201,10 +204,10 @@ class ShorthandComponentFormatter(TagFormatterABC):
return TagResult(name, tokens)
def get_tag_formatter() -> InternalTagFormatter:
def get_tag_formatter(registry: "ComponentRegistry") -> InternalTagFormatter:
"""Returns an instance of the currently configured component tag formatter."""
# Allow users to configure the component TagFormatter
formatter_cls_or_str = app_settings.TAG_FORMATTER
formatter_cls_or_str = registry.settings.TAG_FORMATTER
if isinstance(formatter_cls_or_str, str):
tag_formatter: TagFormatterABC = import_string(formatter_cls_or_str)

View file

@ -6,9 +6,8 @@ from django.template.exceptions import TemplateSyntaxError
from django.utils.safestring import SafeString, mark_safe
from django.utils.text import smart_split
from django_components.app_settings import ContextBehavior, app_settings
from django_components.attributes import HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY, HtmlAttrsNode
from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
from django_components.component import COMP_ONLY_FLAG, RENDERED_COMMENT_TEMPLATE, ComponentNode
from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as component_registry
from django_components.expression import (
@ -204,7 +203,7 @@ def fill(parser: Parser, token: Token) -> FillNode:
return fill_node
def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
def component(parser: Parser, token: Token, registry: ComponentRegistry, tag_name: str) -> ComponentNode:
"""
To give the component access to the template context:
```#!htmldjango {% component "name" positional_arg keyword_arg=value ... %}```
@ -221,7 +220,7 @@ def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
bits = token.split_contents()
# Let the TagFormatter pre-process the tokens
formatter = get_tag_formatter()
formatter = get_tag_formatter(registry)
result = formatter.parse([*bits])
end_tag = formatter.end_tag(result.component_name)
@ -234,14 +233,14 @@ def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
parser,
token,
params=True, # Allow many args
flags=["only"],
flags=[COMP_ONLY_FLAG],
keywordonly_kwargs=True,
repeatable_kwargs=False,
end_tag=end_tag,
)
# Check for isolated context keyword
isolated_context = tag.flags["only"] or app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED
isolated_context = tag.flags[COMP_ONLY_FLAG]
trace_msg("PARSE", "COMP", result.component_name, tag.id)
@ -260,6 +259,7 @@ def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
isolated_context=isolated_context,
fill_nodes=fill_nodes,
node_id=tag.id,
registry=registry,
)
trace_msg("PARSE", "COMP", result.component_name, tag.id, "...Done!")