Implement #231 – default slot filling (#269)

* Add 'default' slot option + implicit fills; tests; docs
* Differentiate between standard fillnodes and implicitfillnodes on type lvl
* Reworking slot-fill rendering logic. Simplifying component interfact. Add new get_string_template method
* First working implementation of chainmap instead of stacks for slot resolution
* Stop passing FillNode to Component initalizer -> better decoupling
* Treat fill name and alias and component name as filterexpression, dropping namedvariable
* Name arg of if_filled tags and slots must be string literal
This commit is contained in:
adriaan 2023-05-18 14:58:46 +02:00 committed by GitHub
parent 349e9fe65f
commit 2d86f042da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 843 additions and 422 deletions

View file

@ -1,38 +1,35 @@
from typing import (
TYPE_CHECKING,
ClassVar,
Dict,
Iterator,
List,
Optional,
TypeVar,
)
from collections import ChainMap
from typing import Any, ClassVar, Dict, Iterable, Optional, Tuple, Union
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import MediaDefiningClass
from django.template import Context, TemplateSyntaxError
from django.template.base import Node, NodeList, Template
from django.forms.widgets import Media, MediaDefiningClass
from django.template.base import NodeList, Template
from django.template.context import Context
from django.template.exceptions import TemplateSyntaxError
from django.template.loader import get_template
from django.utils.safestring import mark_safe
# Allow "component.AlreadyRegistered" instead of having to import these everywhere
from django_components.component_registry import ( # noqa
# Global registry var and register() function moved to separate module.
# Defining them here made little sense, since 1) component_tags.py and component.py
# rely on them equally, and 2) it made it difficult to avoid circularity in the
# way the two modules depend on one another.
from django_components.component_registry import ( # NOQA
AlreadyRegistered,
ComponentRegistry,
NotRegistered,
register,
registry,
)
from django_components.templatetags.component_tags import (
FILLED_SLOTS_CONTENT_CONTEXT_KEY,
DefaultFillContent,
FillContent,
FilledSlotsContext,
IfSlotFilledConditionBranchNode,
NamedFillContent,
SlotName,
SlotNode,
)
if TYPE_CHECKING:
from django_components.templatetags.component_tags import (
FillNode,
SlotNode,
)
T = TypeVar("T")
FILLED_SLOTS_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
@ -65,23 +62,41 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
# Must be set on subclass OR subclass must implement get_template_name() with
# non-null return.
template_name: ClassVar[str]
media: Media
def __init__(self, component_name):
self._component_name: str = component_name
self._instance_fills: Optional[List["FillNode"]] = None
self._outer_context: Optional[dict] = None
class Media:
css = {}
js = []
def get_context_data(self, *args, **kwargs):
def __init__(
self,
registered_name: Optional[str] = None,
outer_context: Optional[Context] = None,
fill_content: Union[
DefaultFillContent, Iterable[NamedFillContent]
] = (),
):
self.registered_name: Optional[str] = registered_name
self.outer_context: Context = outer_context or Context()
self.fill_content = fill_content
def get_context_data(self, *args, **kwargs) -> Dict[str, Any]:
return {}
# Can be overridden for dynamic templates
def get_template_name(self, context):
if not hasattr(self, "template_name") or not self.template_name:
def get_template_name(self, context) -> str:
try:
name = self.template_name
except AttributeError:
raise ImproperlyConfigured(
f"Template name is not set for Component {self.__class__.__name__}"
f"Template name is not set for Component {type(self).__name__}. "
f"Note: this attribute is not required if you are overriding any of "
f"the class's `get_template*()` methods."
)
return name
return self.template_name
def get_template_string(self, context) -> str:
...
def render_dependencies(self):
"""Helper function to access media.render()"""
@ -95,125 +110,106 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
"""Render only JS dependencies available in the media class."""
return mark_safe("\n".join(self.media.render_js()))
def get_declared_slots(
self, context: Context, template: Optional[Template] = None
) -> List["SlotNode"]:
if template is None:
template = self.get_template(context)
return list(
dfs_iter_slots_in_nodelist(template.nodelist, template.name)
)
def get_template(self, context, template_name: Optional[str] = None):
if template_name is None:
def get_template(self, context) -> Template:
template_string = self.get_template_string(context)
if template_string is not None:
return Template(template_string)
else:
template_name = self.get_template_name(context)
template = get_template(template_name).template
return template
def set_instance_fills(self, fills: Dict[str, "FillNode"]) -> None:
self._instance_fills = fills
def set_outer_context(self, context):
self._outer_context = context
@property
def instance_fills(self):
return self._instance_fills or {}
@property
def outer_context(self):
return self._outer_context or {}
def get_updated_fill_stacks(self, context):
current_fill_stacks: Optional[Dict[str, List[FillNode]]] = context.get(
FILLED_SLOTS_CONTEXT_KEY, None
)
updated_fill_stacks = {}
if current_fill_stacks:
for name, fill_nodes in current_fill_stacks.items():
updated_fill_stacks[name] = list(fill_nodes)
for name, fill in self.instance_fills.items():
if name in updated_fill_stacks:
updated_fill_stacks[name].append(fill)
else:
updated_fill_stacks[name] = [fill]
return updated_fill_stacks
def validate_fills_and_slots_(
self,
context,
template: Template,
fills: Optional[Dict[str, "FillNode"]] = None,
) -> None:
if fills is None:
fills = self.instance_fills
all_slots: List["SlotNode"] = self.get_declared_slots(
context, template
)
slots: Dict[str, "SlotNode"] = {}
# Each declared slot must have a unique name.
for slot in all_slots:
slot_name = slot.name
if slot_name in slots:
raise TemplateSyntaxError(
f"Encountered non-unique slot '{slot_name}' in template "
f"'{template.name}' of component '{self._component_name}'."
)
slots[slot_name] = slot
# All fill nodes must correspond to a declared slot.
unmatchable_fills = fills.keys() - slots.keys()
if unmatchable_fills:
msg = (
f"Component '{self._component_name}' passed fill(s) "
f"refering to undefined slot(s). Bad fills: {list(unmatchable_fills)}."
)
raise TemplateSyntaxError(msg)
# Note: Requirement that 'required' slots be filled is enforced
# in SlotNode.render().
template: Template = get_template(template_name).template
return template
def render(self, context):
template_name = self.get_template_name(context)
template = self.get_template(context, template_name)
self.validate_fills_and_slots_(context, template)
updated_fill_stacks = self.get_updated_fill_stacks(context)
with context.update({FILLED_SLOTS_CONTEXT_KEY: updated_fill_stacks}):
template = self.get_template(context)
updated_filled_slots_context: FilledSlotsContext = (
self._process_template_and_update_filled_slot_context(
context, template
)
)
with context.update(
{FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_filled_slots_context}
):
return template.render(context)
class Media:
css = {}
js = []
def dfs_iter_slots_in_nodelist(
nodelist: NodeList, template_name: str = None
) -> Iterator["SlotNode"]:
from django_components.templatetags.component_tags import SlotNode
nodes: List[Node] = list(nodelist)
while nodes:
node = nodes.pop()
if isinstance(node, SlotNode):
yield node
for nodelist_name in node.child_nodelists:
nodes.extend(reversed(getattr(node, nodelist_name, [])))
# This variable represents the global component registry
registry = ComponentRegistry()
def register(name):
"""Class decorator to register a component.
Usage:
@register("my_component")
class MyComponent(component.Component):
...
"""
def decorator(component):
registry.register(name=name, component=component)
return component
return decorator
def _process_template_and_update_filled_slot_context(
self, context: Context, template: Template
) -> FilledSlotsContext:
fill_target2content: Dict[Optional[str], FillContent]
if isinstance(self.fill_content, NodeList):
fill_target2content = {None: (self.fill_content, None)}
else:
fill_target2content = {
name: (nodelist, alias)
for name, nodelist, alias in self.fill_content
}
slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {}
default_slot_already_encountered: bool = False
for node in template.nodelist.get_nodes_by_type(
(SlotNode, IfSlotFilledConditionBranchNode) # type: ignore
):
if isinstance(node, SlotNode):
# Give slot node knowledge of its parent template.
node.template = template
slot_name = node.name
if slot_name in slot_name2fill_content:
raise TemplateSyntaxError(
f"Slot name '{slot_name}' re-used within the same template. "
f"Slot names must be unique."
f"To fix, check template '{template.name}' "
f"of component '{self.registered_name}'."
)
content_data: Optional[
FillContent
] = None # `None` -> unfilled
if node.is_default:
if default_slot_already_encountered:
raise TemplateSyntaxError(
"Only one component slot may be marked as 'default'. "
f"To fix, check template '{template.name}' "
f"of component '{self.registered_name}'."
)
default_slot_already_encountered = True
content_data = fill_target2content.get(None)
if not content_data:
content_data = fill_target2content.get(node.name)
if not content_data and node.is_required:
raise TemplateSyntaxError(
f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), "
f"yet no fill is provided. Check template.'"
)
slot_name2fill_content[slot_name] = content_data
elif isinstance(node, IfSlotFilledConditionBranchNode):
node.template = template
else:
raise RuntimeError(
f"Node of {type(node).__name__} does not require linking."
)
# Check fills
if (
None in fill_target2content
and not default_slot_already_encountered
):
raise TemplateSyntaxError(
f"Component '{self.registered_name}' passed default fill content "
f"(i.e. without explicit 'fill' tag), "
f"even though none of its slots is marked as 'default'."
)
for fill_name in filter(None, fill_target2content.keys()):
if fill_name not in slot_name2fill_content:
raise TemplateSyntaxError(
f"Component '{self.registered_name}' passed fill "
f"that refers to undefined slot: {fill_name}"
)
# Return updated FILLED_SLOTS_CONTEXT map
filled_slots_map: Dict[Tuple[SlotName, Template], FillContent] = {
(slot_name, template): content_data
for slot_name, content_data in slot_name2fill_content.items()
if content_data # (is not None)
}
try:
prev_context: FilledSlotsContext = context[
FILLED_SLOTS_CONTENT_CONTEXT_KEY
]
return prev_context.new_child(filled_slots_map)
except KeyError:
return ChainMap(filled_slots_map)

View file

@ -34,3 +34,24 @@ class ComponentRegistry(object):
def clear(self):
self._registry = {}
# This variable represents the global component registry
registry = ComponentRegistry()
def register(name):
"""Class decorator to register a component.
Usage:
@register("my_component")
class MyComponent(component.Component):
...
"""
def decorator(component):
registry.register(name=name, component=component)
return component
return decorator

View file

@ -4,6 +4,8 @@ from django.conf import settings
from django.forms import Media
from django.http import StreamingHttpResponse
from django_components.component_registry import registry
RENDERED_COMPONENTS_CONTEXT_KEY = "_COMPONENT_DEPENDENCIES"
CSS_DEPENDENCY_PLACEHOLDER = '<link name="CSS_PLACEHOLDER">'
JS_DEPENDENCY_PLACEHOLDER = '<script name="JS_PLACEHOLDER"></script>'
@ -39,8 +41,6 @@ class ComponentDependencyMiddleware:
def process_response_content(content):
from django_components.component import registry
component_names_seen = {
match.group("name")
for match in COMPONENT_COMMENT_REGEX.finditer(content)

View file

@ -1,20 +1,28 @@
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
import sys
from typing import TYPE_CHECKING, Iterable, List, Optional, Tuple, Type, Union
from django import template
if sys.version_info[:2] < (3, 9):
from typing import ChainMap
else:
from collections import ChainMap
import django.template
from django.conf import settings
from django.template import Context
from django.template import Context, Template
from django.template.base import (
FilterExpression,
Node,
NodeList,
TemplateSyntaxError,
TextNode,
TokenType,
Variable,
VariableDoesNotExist,
)
from django.template.defaulttags import CommentNode
from django.template.exceptions import TemplateSyntaxError
from django.template.library import parse_bits
from django.utils.safestring import mark_safe
from django_components.component import FILLED_SLOTS_CONTEXT_KEY, registry
from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as component_registry
from django_components.middleware import (
CSS_DEPENDENCY_PLACEHOLDER,
JS_DEPENDENCY_PLACEHOLDER,
@ -23,13 +31,30 @@ from django_components.middleware import (
if TYPE_CHECKING:
from django_components.component import Component
register = template.Library()
register = django.template.Library()
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
SLOT_REQUIRED_OPTION_KEYWORD = "required"
SLOT_DEFAULT_OPTION_KEYWORD = "default"
def get_components_from_registry(registry):
FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
# Type aliases
SlotName = str
AliasName = str
DefaultFillContent = NodeList
NamedFillContent = Tuple[SlotName, NodeList, Optional[AliasName]]
FillContent = Tuple[NodeList, Optional[AliasName]]
FilledSlotsContext = ChainMap[Tuple[SlotName, Template], FillContent]
def get_components_from_registry(registry: ComponentRegistry):
"""Returns a list unique components from the registry."""
unique_component_classes = set(registry.all().values())
@ -49,7 +74,7 @@ def get_components_from_preload_str(preload_str):
component_name = component_name.strip()
if not component_name:
continue
component_class = registry.get(component_name)
component_class = component_registry.get(component_name)
components.append(component_class(component_name))
return components
@ -64,7 +89,7 @@ def component_dependencies_tag(preload=""):
for component in get_components_from_preload_str(preload):
preloaded_dependencies.append(
RENDERED_COMMENT_TEMPLATE.format(
name=component._component_name
name=component.registered_name
)
)
return mark_safe(
@ -74,7 +99,7 @@ def component_dependencies_tag(preload=""):
)
else:
rendered_dependencies = []
for component in get_components_from_registry(registry):
for component in get_components_from_registry(component_registry):
rendered_dependencies.append(component.render_dependencies())
return mark_safe("\n".join(rendered_dependencies))
@ -89,7 +114,7 @@ def component_css_dependencies_tag(preload=""):
for component in get_components_from_preload_str(preload):
preloaded_dependencies.append(
RENDERED_COMMENT_TEMPLATE.format(
name=component._component_name
name=component.registered_name
)
)
return mark_safe(
@ -97,7 +122,7 @@ def component_css_dependencies_tag(preload=""):
)
else:
rendered_dependencies = []
for component in get_components_from_registry(registry):
for component in get_components_from_registry(component_registry):
rendered_dependencies.append(component.render_css_dependencies())
return mark_safe("\n".join(rendered_dependencies))
@ -112,7 +137,7 @@ def component_js_dependencies_tag(preload=""):
for component in get_components_from_preload_str(preload):
preloaded_dependencies.append(
RENDERED_COMMENT_TEMPLATE.format(
name=component._component_name
name=component.registered_name
)
)
return mark_safe(
@ -120,7 +145,7 @@ def component_js_dependencies_tag(preload=""):
)
else:
rendered_dependencies = []
for component in get_components_from_registry(registry):
for component in get_components_from_registry(component_registry):
rendered_dependencies.append(component.render_js_dependencies())
return mark_safe("\n".join(rendered_dependencies))
@ -134,7 +159,7 @@ def do_component(parser, token):
parser, bits, "component"
)
return ComponentNode(
NameVariable(component_name, tag="component"),
FilterExpression(component_name, parser),
context_args,
context_kwargs,
isolated_context=isolated_context,
@ -161,44 +186,78 @@ class UserSlotVar:
return mark_safe(self._slot.nodelist.render(self._context))
class SlotNode(Node):
class TemplateAwareNodeMixin:
_template: Template
@property
def template(self) -> Template:
try:
return self._template
except AttributeError:
raise RuntimeError(
f"Internal error: Instance of {type(self).__name__} was not "
"linked to Template before use in render() context."
)
@template.setter
def template(self, value) -> None:
self._template = value
class SlotNode(Node, TemplateAwareNodeMixin):
def __init__(
self, name, nodelist, template_name: str = "", required=False
self,
name: str,
nodelist: NodeList,
is_required: bool = False,
is_default: bool = False,
):
self.name = name
self.nodelist = nodelist
self.template_name = template_name
self.is_required = required
self.is_required = is_required
self.is_default = is_default
@property
def active_flags(self):
m = []
if self.is_required:
m.append("required")
if self.is_default:
m.append("default")
return m
def __repr__(self):
return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}>"
return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>"
def render(self, context):
if FILLED_SLOTS_CONTEXT_KEY not in context:
try:
filled_slots_map: FilledSlotsContext = context[
FILLED_SLOTS_CONTENT_CONTEXT_KEY
]
except KeyError:
raise TemplateSyntaxError(
f"Attempted to render SlotNode '{self.name}' outside a parent component."
)
filled_slots: Dict[str, List[FillNode]] = context[
FILLED_SLOTS_CONTEXT_KEY
]
fill_node_stack = filled_slots.get(self.name, None)
extra_context = {}
if not fill_node_stack: # if None or []
nodelist = self.nodelist
# Raise if slot is 'required'
try:
slot_fill_content: Optional[FillContent] = filled_slots_map[
(self.name, self.template)
]
except KeyError:
if self.is_required:
raise TemplateSyntaxError(
f"Slot '{self.name}' is marked as 'required' (i.e. non-optional), "
f"yet no fill is provided. Check template '{self.template_name}'"
f"yet no fill is provided. "
)
nodelist = self.nodelist
else:
fill_node = fill_node_stack.pop()
nodelist = fill_node.nodelist
nodelist, alias = slot_fill_content
if alias:
if not alias.isidentifier():
raise TemplateSyntaxError()
extra_context[alias] = UserSlotVar(self, context)
if fill_node.alias_var is not None:
aliased_slot_var = UserSlotVar(self, context)
resolved_alias_name = fill_node.alias_var.resolve(context)
extra_context[resolved_alias_name] = aliased_slot_var
with context.update(extra_context):
return nodelist.render(context)
@ -208,60 +267,88 @@ def do_slot(parser, token):
bits = token.split_contents()
args = bits[1:]
# e.g. {% slot <name> %}
if len(args) == 1:
slot_name: str = args[0]
required = False
elif len(args) == 2:
slot_name: str = args[0]
required_keyword = args[1]
if required_keyword != "required":
is_required = False
is_default = False
if 1 <= len(args) <= 3:
slot_name, *options = args
if not is_wrapped_in_quotes(slot_name):
raise TemplateSyntaxError(
f"'{bits[0]}' only accepts 'required' keyword as optional second argument"
f"'{bits[0]}' name must be a string 'literal'."
)
slot_name = strip_quotes(slot_name)
modifiers_count = len(options)
if SLOT_REQUIRED_OPTION_KEYWORD in options:
is_required = True
modifiers_count -= 1
if SLOT_DEFAULT_OPTION_KEYWORD in options:
is_default = True
modifiers_count -= 1
if modifiers_count != 0:
keywords = [
SLOT_REQUIRED_OPTION_KEYWORD,
SLOT_DEFAULT_OPTION_KEYWORD,
]
raise TemplateSyntaxError(
f"Invalid options passed to 'slot' tag. Valid choices: {keywords}."
)
else:
required = True
else:
raise TemplateSyntaxError(
f"{bits[0]}' tag takes only one argument (the slot name)"
"'slot' tag does not match pattern "
"{% slot <name> ['default'] ['required'] %}. "
"Order of options is free."
)
if not is_wrapped_in_quotes(slot_name):
raise TemplateSyntaxError(
f"'{bits[0]}' name must be a string 'literal'."
)
slot_name = strip_quotes(slot_name)
raise_if_not_py_identifier(slot_name, bits[0])
nodelist = parser.parse(parse_until=["endslot"])
parser.delete_first_token()
template_name = parser.origin.template_name
return SlotNode(slot_name, nodelist, template_name, required)
return SlotNode(
slot_name,
nodelist,
is_required=is_required,
is_default=is_default,
)
class FillNode(Node):
def __init__(
self,
name_var: "NameVariable",
nodelist: NodeList,
alias_var: Optional["NameVariable"] = None,
):
self.name_var = name_var
self.nodelist = nodelist
self.alias_var: Optional[NameVariable] = alias_var
class BaseFillNode(Node):
def __init__(self, nodelist: NodeList):
self.nodelist: NodeList = nodelist
def __repr__(self):
return f"<Fill Node: {self.name_var}. Contents: {repr(self.nodelist)}>"
raise NotImplementedError
def render(self, context):
raise TemplateSyntaxError(
f"{{% fill {self.name_var} %}} blocks cannot be rendered directly. "
f"You are probably seeing this because you have used one outside "
f"a {{% component_block %}} context."
"{% fill ... %} block cannot be rendered directly. "
"You are probably seeing this because you have used one outside "
"a {% component_block %} context."
)
class NamedFillNode(BaseFillNode):
def __init__(
self,
nodelist: NodeList,
name_fexp: FilterExpression,
alias_fexp: Optional[FilterExpression] = None,
):
super().__init__(nodelist)
self.name_fexp = name_fexp
self.alias_fexp = alias_fexp
def __repr__(self):
return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>"
class ImplicitFillNode(BaseFillNode):
"""
Instantiated when a `component_block` tag pair is passed template content that
excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked
as 'default'.
"""
def __repr__(self):
return f"<{type(self)} Contents: {repr(self.nodelist)}.>"
@register.tag("fill")
def do_fill(parser, token):
"""Block tag whose contents 'fill' (are inserted into) an identically named
@ -275,7 +362,7 @@ def do_fill(parser, token):
tag = bits[0]
args = bits[1:]
# e.g. {% fill <name> %}
alias_var = None
alias_fexp: Optional[FilterExpression] = None
if len(args) == 1:
tgt_slot_name: str = args[0]
# e.g. {% fill <name> as <alias> %}
@ -285,49 +372,56 @@ def do_fill(parser, token):
raise TemplateSyntaxError(
f"{tag} tag args do not conform to pattern '<target slot> as <alias>'"
)
raise_if_not_py_identifier(strip_quotes(alias), tag="alias")
alias_var = NameVariable(alias, tag="alias")
alias_fexp = FilterExpression(alias, parser)
else:
raise TemplateSyntaxError(
f"'{tag}' tag takes either 1 or 3 arguments: Received {len(args)}."
)
raise_if_not_py_identifier(strip_quotes(tgt_slot_name), tag=tag)
nodelist = parser.parse(parse_until=["endfill"])
parser.delete_first_token()
return FillNode(NameVariable(tgt_slot_name, tag), nodelist, alias_var)
return NamedFillNode(
nodelist,
name_fexp=FilterExpression(tgt_slot_name, tag),
alias_fexp=alias_fexp,
)
class ComponentNode(Node):
child_nodelists = ("fill_nodes",)
def __init__(
self,
name_var: "NameVariable",
name_fexp: FilterExpression,
context_args,
context_kwargs,
isolated_context=False,
fill_nodes: Union[ImplicitFillNode, Iterable[NamedFillNode]] = (),
):
self.name_var = name_var
self.name_fexp = name_fexp
self.context_args = context_args or []
self.context_kwargs = context_kwargs or {}
self.fill_nodes: NodeList[FillNode] = NodeList()
self.isolated_context = isolated_context
self.fill_nodes = fill_nodes
@property
def nodelist(self) -> Union[NodeList, Node]:
if isinstance(self.fill_nodes, ImplicitFillNode):
return NodeList([self.fill_nodes])
else:
return NodeList(self.fill_nodes)
def __repr__(self):
return "<Component Node: %s. Contents: %r>" % (
self.name_var,
return "<ComponentNode: %s. Contents: %r>" % (
self.name_fexp,
getattr(
self, "nodelist", None
), # 'nodelist' attribute only assigned later.
)
def render(self, context):
resolved_component_name = self.name_var.resolve(context)
component_cls = registry.get(resolved_component_name)
component: Component = component_cls(resolved_component_name)
def render(self, context: Context):
resolved_component_name = self.name_fexp.resolve(context)
component_cls: Type[Component] = component_registry.get(
resolved_component_name
)
# Resolve FilterExpressions and Variables that were passed as args to the
# component, then call component's context method
@ -340,17 +434,38 @@ class ComponentNode(Node):
for key, kwarg in self.context_kwargs.items()
}
resolved_fills = {
fill_node.name_var.resolve(context): fill_node
for fill_node in self.fill_nodes
}
if isinstance(self.fill_nodes, ImplicitFillNode):
fill_content = self.fill_nodes.nodelist
else:
fill_content = []
for fill_node in self.fill_nodes:
# Note that outer component context is used to resolve variables in
# fill tag.
resolved_name = fill_node.name_fexp.resolve(context)
if fill_node.alias_fexp:
resolved_alias: str = fill_node.alias_fexp.resolve(context)
if not resolved_alias.isidentifier():
raise TemplateSyntaxError(
f"Fill tag alias '{fill_node.alias_fexp.var}' in component "
f"{resolved_component_name} does not resolve to "
f"a valid Python identifier. Got: '{resolved_alias}'."
)
else:
resolved_alias: None = None
fill_content.append(
(resolved_name, fill_node.nodelist, resolved_alias)
)
component.set_instance_fills(resolved_fills)
component.set_outer_context(context)
component: Component = component_cls(
registered_name=resolved_component_name,
outer_context=context,
fill_content=fill_content,
)
component_context = component.get_context_data(
component_context: dict = component.get_context_data(
*resolved_context_args, **resolved_context_kwargs
)
if self.isolated_context:
context = context.new()
with context.update(component_context):
@ -358,9 +473,7 @@ class ComponentNode(Node):
if is_dependency_middleware_active():
return (
RENDERED_COMMENT_TEMPLATE.format(
name=component._component_name
)
RENDERED_COMMENT_TEMPLATE.format(name=resolved_component_name)
+ rendered_component
)
else:
@ -387,71 +500,100 @@ def do_component_block(parser, token):
component_name, context_args, context_kwargs = parse_component_with_args(
parser, bits, "component_block"
)
body: NodeList = parser.parse(parse_until=["endcomponent_block"])
parser.delete_first_token()
fill_nodes = ()
if block_has_content(body):
for parse_fn in (
try_parse_as_default_fill,
try_parse_as_named_fill_tag_set,
):
fill_nodes = parse_fn(body)
if fill_nodes:
break
else:
raise TemplateSyntaxError(
"Illegal content passed to 'component_block' tag pair. "
"Possible causes: 1) Explicit 'fill' tags cannot occur alongside other "
"tags except comment tags; 2) Default (default slot-targeting) content "
"is mixed with explict 'fill' tags."
)
component_node = ComponentNode(
NameVariable(component_name, "component"),
FilterExpression(component_name, parser),
context_args,
context_kwargs,
isolated_context=isolated_context,
fill_nodes=fill_nodes,
)
seen_fill_name_vars = set()
fill_nodes = component_node.fill_nodes
for token in fill_tokens(parser):
fill_node = do_fill(parser, token)
fill_node.parent_component = component_node
if fill_node.name_var.var in seen_fill_name_vars:
raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name: "
f"Detected duplicate fill tag name '{fill_node.name_var}'."
)
seen_fill_name_vars.add(fill_node.name_var.var)
fill_nodes.append(fill_node)
return component_node
def fill_tokens(parser):
"""Yield each 'fill' token appearing before the next 'endcomponent_block' token.
def try_parse_as_named_fill_tag_set(
nodelist: NodeList,
) -> Optional[Iterable[NamedFillNode]]:
result = []
seen_name_fexps = set()
for node in nodelist:
if isinstance(node, NamedFillNode):
if node.name_fexp in seen_name_fexps:
raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name: "
f"Detected duplicate fill tag name '{node.name_fexp}'."
)
result.append(node)
elif isinstance(node, CommentNode):
pass
elif isinstance(node, TextNode) and node.s.isspace():
pass
else:
return None
return result
Raises TemplateSyntaxError if:
- there are other content tokens
- there is no endcomponent_block token.
- a (deprecated) 'slot' token is encountered.
"""
def is_whitespace(token):
return (
token.token_type == TokenType.TEXT and not token.contents.strip()
)
def try_parse_as_default_fill(
nodelist: NodeList,
) -> Optional[ImplicitFillNode]:
# nodelist.get_nodes_by_type()
nodes_stack: List[Node] = list(nodelist)
while nodes_stack:
node = nodes_stack.pop()
if isinstance(node, NamedFillNode):
return None
elif isinstance(node, ComponentNode):
# Stop searching here, as fill tags are permitted inside component blocks
# embedded within a default fill node.
continue
for nodelist_attr_name in node.child_nodelists:
nodes_stack.extend(getattr(node, nodelist_attr_name, []))
else:
return ImplicitFillNode(nodelist=nodelist)
def is_block_tag(token, name):
return (
token.token_type == TokenType.BLOCK
and token.split_contents()[0] == name
)
while True:
try:
token = parser.next_token()
except IndexError:
raise TemplateSyntaxError("Unclosed component_block tag")
if is_block_tag(token, name="endcomponent_block"):
return
elif is_block_tag(token, name="fill"):
yield token
elif is_block_tag(token, name="slot"):
raise TemplateSyntaxError(
"Use of {% slot %} to pass slot content is deprecated. "
"Use {% fill % } instead."
)
elif (
not is_whitespace(token) and token.token_type != TokenType.COMMENT
):
raise TemplateSyntaxError(
"Component block EITHER contains illegal tokens tag that are not "
"{{% fill ... %}} tags OR the proper closing tag -- "
"{{% endcomponent_block %}} -- is missing."
)
def block_has_content(nodelist) -> bool:
for node in nodelist:
if isinstance(node, TextNode) and node.s.isspace():
pass
elif isinstance(node, CommentNode):
pass
else:
return True
return False
def is_whitespace_node(node: Node) -> bool:
return isinstance(node, TextNode) and node.s.isspace()
def is_whitespace_token(token):
return token.token_type == TokenType.TEXT and not token.contents.strip()
def is_block_tag_token(token, name):
return (
token.token_type == TokenType.BLOCK
and token.split_contents()[0] == name
)
@register.tag(name="if_filled")
@ -480,11 +622,12 @@ def do_if_filled_block(parser, token):
"""
bits = token.split_contents()
starting_tag = bits[0]
slot_name_var: Optional[NameVariable]
slot_name_var, is_positive = parse_if_filled_bits(bits)
slot_name, is_positive = parse_if_filled_bits(bits)
nodelist = parser.parse(("elif_filled", "else_filled", "endif_filled"))
branches: List[Tuple[Optional[NameVariable], NodeList, Optional[bool]]] = [
(slot_name_var, nodelist, is_positive)
branches: List[_IfSlotFilledBranchNode] = [
IfSlotFilledConditionBranchNode(
slot_name=slot_name, nodelist=nodelist, is_positive=is_positive
)
]
token = parser.next_token()
@ -492,11 +635,16 @@ def do_if_filled_block(parser, token):
# {% elif_filled <slot> (<is_positive>) %} (repeatable)
while token.contents.startswith("elif_filled"):
bits = token.split_contents()
slot_name_var, is_positive = parse_if_filled_bits(bits)
slot_name, is_positive = parse_if_filled_bits(bits)
nodelist: NodeList = parser.parse(
("elif_filled", "else_filled", "endif_filled")
)
branches.append((slot_name_var, nodelist, is_positive))
branches.append(
IfSlotFilledConditionBranchNode(
slot_name=slot_name, nodelist=nodelist, is_positive=is_positive
)
)
token = parser.next_token()
# {% else_filled %} (optional)
@ -504,7 +652,7 @@ def do_if_filled_block(parser, token):
bits = token.split_contents()
_, _ = parse_if_filled_bits(bits)
nodelist = parser.parse(("endif_filled",))
branches.append((None, nodelist, None))
branches.append(IfSlotFilledElseBranchNode(nodelist))
token = parser.next_token()
# {% endif_filled %}
@ -519,13 +667,12 @@ def do_if_filled_block(parser, token):
def parse_if_filled_bits(
bits: List[str],
) -> Tuple[Optional["NameVariable"], Optional[bool]]:
) -> Tuple[Optional[str], Optional[bool]]:
tag, args = bits[0], bits[1:]
if tag in ("else_filled", "endif_filled"):
if len(args) != 0:
raise TemplateSyntaxError(
f"Tag '{tag}' takes no arguments. "
f"Received '{' '.join(args)}'"
f"Tag '{tag}' takes no arguments. Received '{' '.join(args)}'"
)
else:
return None, None
@ -540,48 +687,81 @@ def parse_if_filled_bits(
f"{bits[0]} tag arguments '{' '.join(args)}' do not match pattern "
f"'<slotname> (<is_positive>)'"
)
raise_if_not_py_identifier(strip_quotes(slot_name), tag=tag)
slot_name_var = NameVariable(slot_name, tag)
return slot_name_var, is_positive
if not is_wrapped_in_quotes(slot_name):
raise TemplateSyntaxError(
f"First argument of '{bits[0]}' must be a quoted string 'literal'."
)
slot_name = strip_quotes(slot_name)
return slot_name, is_positive
class _IfSlotFilledBranchNode(Node):
def __init__(self, nodelist: NodeList) -> None:
self.nodelist = nodelist
def render(self, context: Context) -> str:
return self.nodelist.render(context)
def evaluate(self, context) -> bool:
raise NotImplementedError
class IfSlotFilledConditionBranchNode(
_IfSlotFilledBranchNode, TemplateAwareNodeMixin
):
def __init__(
self,
slot_name: str,
nodelist: NodeList,
is_positive=True,
) -> None:
self.slot_name = slot_name
self.is_positive: bool = is_positive
super().__init__(nodelist)
def evaluate(self, context) -> bool:
try:
filled_slots: FilledSlotsContext = context[
FILLED_SLOTS_CONTENT_CONTEXT_KEY
]
except KeyError:
raise TemplateSyntaxError(
f"Attempted to render {type(self).__name__} outside a Component rendering context."
)
slot_key = (self.slot_name, self.template)
is_filled = filled_slots.get(slot_key, None) is not None
# Make polarity switchable.
# i.e. if slot name is NOT filled and is_positive=False,
# then False == False -> True
return is_filled == self.is_positive
class IfSlotFilledElseBranchNode(_IfSlotFilledBranchNode):
def evaluate(self, context) -> bool:
return True
class IfSlotFilledNode(Node):
def __init__(
self,
branches: List[
Tuple[Optional["NameVariable"], NodeList, Optional[bool]]
],
branches: List[_IfSlotFilledBranchNode],
):
# [(<slot name var | None (= condition)>, nodelist, <is_positive>)]
self.branches = branches
def __iter__(self):
for _, nodelist, _ in self.branches:
for node in nodelist:
yield node
def __repr__(self):
return f"<{self.__class__.__name__}>"
@property
def nodelist(self):
return NodeList(self)
return NodeList(self.branches)
def render(self, context):
current_fills = context.get(FILLED_SLOTS_CONTEXT_KEY)
for slot_name_var, nodelist, is_positive in self.branches:
# None indicates {% else_filled %} has been reached.
# This means all other branches have been exhausted.
if slot_name_var is None:
return nodelist.render(context)
# Make polarity switchable.
# i.e. if slot name is NOT filled and is_positive=False,
# then False == False -> True
slot_name = slot_name_var.resolve(context)
if (slot_name in current_fills) == is_positive:
return nodelist.render(context)
else:
continue
for node in self.branches:
if isinstance(node, IfSlotFilledElseBranchNode):
return node.render(context)
elif isinstance(node, IfSlotFilledConditionBranchNode):
if node.evaluate(context):
return node.render(context)
return ""
@ -649,7 +829,7 @@ def is_dependency_middleware_active():
)
def norm_and_validate_name(name: str, tag: str, context: str = None):
def norm_and_validate_name(name: str, tag: str, context: Optional[str] = None):
"""
Notes:
- Value of `tag` in {"slot", "fill", "alias"}
@ -664,19 +844,6 @@ def norm_and_validate_name(name: str, tag: str, context: str = None):
return name
def raise_if_not_py_identifier(name: str, tag: str, content: str = None):
"""
Notes:
- Value of `tag` in {"slot", "fill", "alias", "component"}
"""
if not name.isidentifier():
content = f" in '{{% {content} ...'" if content else ""
raise TemplateSyntaxError(
f"'{tag}' name '{name}'{content} with/without quotes "
"is not a valid Python identifier."
)
def strip_quotes(s: str) -> str:
return s.strip("\"'")
@ -689,18 +856,3 @@ def bool_from_string(s: str):
return False
else:
raise TemplateSyntaxError(f"Expected a bool value. Received: '{s}'")
class NameVariable(Variable):
def __init__(self, var: str, tag: str):
super().__init__(var)
self._tag = tag
def resolve(self, context):
try:
return super().resolve(context)
except VariableDoesNotExist:
raise TemplateSyntaxError(
f"<name> = '{self.var}' in '{{% {self._tag} <name> ...' can't be resolved "
f"against context."
)