Introduce {% fill %} replacing 'fill' func of 'slot' tag

Partial implementation fill-tags plus update tests

Implement {% fill %} tags. Next: update tests.

Bring back support for {%slot%} blocks for bckwrd-compat and implement ambig. resolution policy

Update tests to use fill blocks. Add extra checks that raise errors

Add new tests for fill-slot nesting

Update README. Editing still required

remove unused var ctxt after flake8 complaint

fix flake8 warning about slotless f-string

[pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Add new slot aliases in fill context. Clean up rendering logic in Component. Update docs.

fix flake8, isort, black errors

Refactor duplicated name validation

Add if_filled tag + elif_filled...else_filled...endif_filled for cond. slots

Fix mistake in do_if_filled() docstring

Upload templates for tests! D'oh

Incorporate PR feedback

Drop Literal type hint; Use isort off-on instead of skip in tests

Treat all fill,slot,if_filled,component names as variables

Reset sampleproject components

Add test for variable filled name

Update examples in docs
This commit is contained in:
lemontheme 2023-01-11 15:53:18 +01:00 committed by Emil Stenström
parent 714fc9edb0
commit a8dfcce24e
20 changed files with 1090 additions and 307 deletions

View file

@ -1,7 +1,7 @@
import glob
import importlib
import importlib.util
import sys
from importlib import import_module
from pathlib import Path
import django
@ -15,7 +15,7 @@ if django.VERSION < (3, 2):
def autodiscover():
from . import app_settings
from django_components.app_settings import app_settings
if app_settings.AUTODISCOVER:
# Autodetect a components.py file in each app directory
@ -30,7 +30,7 @@ def autodiscover():
import_file(path)
for path in app_settings.LIBRARIES:
import_module(path)
importlib.import_module(path)
def import_file(path):

View file

@ -1,5 +1,3 @@
import sys
from django.conf import settings
@ -19,7 +17,11 @@ class AppSettings:
def TEMPLATE_CACHE_SIZE(self):
return self.settings.setdefault("template_cache_size", 128)
@property
def STRICT_SLOTS(self):
"""If True, component slots that are declared must be explicitly filled; else
a TemplateSyntaxError is raised."""
return self.settings.setdefault("strict_slots", False)
app_settings = AppSettings()
app_settings.__name__ = __name__
sys.modules[__name__] = app_settings

View file

@ -1,13 +1,29 @@
from __future__ import annotations
import warnings
from collections import defaultdict
from contextlib import contextmanager
from functools import lru_cache
from typing import (
TYPE_CHECKING,
ClassVar,
Dict,
List,
Optional,
Tuple,
TypeVar,
)
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import MediaDefiningClass
from django.template.base import Node, TokenType
from django.template import TemplateSyntaxError
from django.template.base import Node, NodeList, Template
from django.template.loader import get_template
from django.utils.safestring import mark_safe
from django_components.app_settings import app_settings
# Allow "component.AlreadyRegistered" instead of having to import these everywhere
from django_components.component_registry import ( # noqa
AlreadyRegistered,
@ -15,10 +31,17 @@ from django_components.component_registry import ( # noqa
NotRegistered,
)
TEMPLATE_CACHE_SIZE = getattr(settings, "COMPONENTS", {}).get(
"TEMPLATE_CACHE_SIZE", 128
)
ACTIVE_SLOT_CONTEXT_KEY = "_DJANGO_COMPONENTS_ACTIVE_SLOTS"
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):
@ -48,18 +71,21 @@ class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
template_name = None
# Must be set on subclass OR subclass must implement get_template_name() with
# non-null return.
template_name: ClassVar[str]
def __init__(self, component_name):
self._component_name = component_name
self.instance_template = None
self.slots = {}
self._component_name: str = component_name
self._instance_fills: Optional[List[FillNode]] = None
self._outer_context: Optional[dict] = None
def get_context_data(self, *args, **kwargs):
return {}
def get_template_name(self, context=None):
if not self.template_name:
# Can be overridden for dynamic templates
def get_template_name(self, context):
if not hasattr(self, "template_name") or not self.template_name:
raise ImproperlyConfigured(
f"Template name is not set for Component {self.__class__.__name__}"
)
@ -68,94 +94,131 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
def render_dependencies(self):
"""Helper function to access media.render()"""
return self.media.render()
def render_css_dependencies(self):
"""Render only CSS dependencies available in the media class."""
return mark_safe("\n".join(self.media.render_css()))
def render_js_dependencies(self):
"""Render only JS dependencies available in the media class."""
return mark_safe("\n".join(self.media.render_js()))
@staticmethod
def slots_in_template(template):
return {
node.name: node.nodelist
for node in template.template.nodelist
if Component.is_slot_node(node)
}
@classmethod
@lru_cache(maxsize=app_settings.TEMPLATE_CACHE_SIZE)
def fetch_and_analyze_template(
cls, template_name: str
) -> Tuple[Template, Dict[str, SlotNode]]:
template: Template = get_template(template_name).template
slots = {}
for slot in iter_slots_in_nodelist(template.nodelist, template.name):
slot.component_cls = cls
slots[slot.name] = slot
return template, slots
@staticmethod
def is_slot_node(node):
return (
isinstance(node, Node)
and node.token.token_type == TokenType.BLOCK
and node.token.split_contents()[0] == "slot"
def get_processed_template(self, context):
template_name = self.get_template_name(context)
# Note: return of method below is cached.
template, slots = self.fetch_and_analyze_template(template_name)
self._raise_if_fills_do_not_match_slots(
slots, self.instance_fills, self._component_name
)
self._raise_if_declared_slots_are_unfilled(
slots, self.instance_fills, self._component_name
)
return template
@lru_cache(maxsize=TEMPLATE_CACHE_SIZE)
def get_processed_template(self, template_name):
"""Retrieve the requested template and check for unused slots."""
@staticmethod
def _raise_if_declared_slots_are_unfilled(
slots: Dict[str, SlotNode], fills: Dict[str, FillNode], comp_name: str
):
# 'unconditional_slots' are slots that were encountered within an 'if_filled'
# context. They are exempt from filling checks.
unconditional_slots = {
slot.name for slot in slots.values() if not slot.is_conditional
}
unused_slots = unconditional_slots - fills.keys()
if unused_slots:
msg = (
f"Component '{comp_name}' declares slots that "
f"are not filled: '{unused_slots}'"
)
if app_settings.STRICT_SLOTS:
raise TemplateSyntaxError(msg)
elif settings.DEBUG:
warnings.warn(msg)
component_template = get_template(template_name).template
@staticmethod
def _raise_if_fills_do_not_match_slots(
slots: Dict[str, SlotNode], fills: Dict[str, FillNode], comp_name: str
):
unmatchable_fills = fills.keys() - slots.keys()
if unmatchable_fills:
msg = (
f"Component '{comp_name}' passed fill(s) "
f"refering to undefined slot(s). Bad fills: {list(unmatchable_fills)}."
)
raise TemplateSyntaxError(msg)
# Traverse template nodes and descendants
visited_nodes = set()
nodes_to_visit = list(component_template.nodelist)
slots_seen = set()
while nodes_to_visit:
current_node = nodes_to_visit.pop()
if current_node in visited_nodes:
continue
visited_nodes.add(current_node)
for nodelist_name in current_node.child_nodelists:
nodes_to_visit.extend(getattr(current_node, nodelist_name, []))
if self.is_slot_node(current_node):
slots_seen.add(current_node.name)
@property
def instance_fills(self):
return self._instance_fills or {}
# Check and warn for unknown slots
if settings.DEBUG:
filled_slot_names = set(self.slots.keys())
unused_slots = filled_slot_names - slots_seen
if unused_slots:
warnings.warn(
"Component {} was provided with slots that were not used in a template: {}".format(
self._component_name, unused_slots
)
)
@property
def outer_context(self):
return self._outer_context or {}
return component_template
@contextmanager
def assign(
self: T,
fills: Optional[Dict[str, FillNode]] = None,
outer_context: Optional[dict] = None,
) -> T:
if fills is not None:
self._instance_fills = fills
if outer_context is not None:
self._outer_context = outer_context
yield self
self._instance_fills = None
self._outer_context = None
def render(self, context):
if hasattr(self, "context"):
warnings.warn(
f"{self.__class__.__name__}: `context` method is deprecated, use `get_context` instead",
DeprecationWarning,
)
if hasattr(self, "template"):
warnings.warn(
f"{self.__class__.__name__}: `template` method is deprecated, \
set `template_name` or override `get_template_name` instead",
DeprecationWarning,
)
template_name = self.template(context)
else:
template_name = self.get_template_name(context)
instance_template = self.get_processed_template(template_name)
with context.update({ACTIVE_SLOT_CONTEXT_KEY: self.slots}):
return instance_template.render(context)
template = self.get_processed_template(context)
current_fills_stack = context.get(
FILLED_SLOTS_CONTEXT_KEY, defaultdict(list)
)
for name, fill in self.instance_fills.items():
current_fills_stack[name].append(fill)
with context.update({FILLED_SLOTS_CONTEXT_KEY: current_fills_stack}):
return template.render(context)
class Media:
css = {}
js = []
def iter_slots_in_nodelist(nodelist: NodeList, template_name: str = None):
from django_components.templatetags.component_tags import SlotNode
nodes: List[Node] = list(nodelist)
slot_names = set()
while nodes:
node = nodes.pop()
if isinstance(node, SlotNode):
slot_name = node.name
if slot_name in slot_names:
context = (
f" in template '{template_name}'" if template_name else ""
)
raise TemplateSyntaxError(
f"Encountered non-unique slot '{slot_name}'{context}"
)
slot_names.add(slot_name)
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()
@ -163,13 +226,11 @@ registry = ComponentRegistry()
def register(name):
"""Class decorator to register a component.
Usage:
@register("my_component")
class MyComponent(component.Component):
...
"""
def decorator(component):

View file

@ -1,17 +1,30 @@
from collections import defaultdict
from __future__ import annotations
from typing import TYPE_CHECKING, DefaultDict, List, Optional, Tuple
from django import template
from django.conf import settings
from django.template.base import Node, NodeList, TemplateSyntaxError, TokenType
from django.template import Context
from django.template.base import (
Node,
NodeList,
TemplateSyntaxError,
TokenType,
Variable,
VariableDoesNotExist,
)
from django.template.library import parse_bits
from django.utils.safestring import mark_safe
from django_components.component import ACTIVE_SLOT_CONTEXT_KEY, registry
from django_components.component import FILLED_SLOTS_CONTEXT_KEY, registry
from django_components.middleware import (
CSS_DEPENDENCY_PLACEHOLDER,
JS_DEPENDENCY_PLACEHOLDER,
)
if TYPE_CHECKING:
from django_components.component import Component
register = template.Library()
@ -123,113 +136,185 @@ def do_component(parser, token):
parser, bits, "component"
)
return ComponentNode(
component_name,
NameVariable(component_name, tag="component"),
context_args,
context_kwargs,
isolated_context=isolated_context,
)
class UserSlotVar:
"""
Extensible mechanism for offering 'fill' blocks in template access to properties
of parent slot.
How it works: At render time, SlotNode(s) that have been aliased in the fill tag
of the component instance create an instance of UserSlotVar. This instance is made
available to the rendering context on a key matching the slot alias (see
SlotNode.render() for implementation).
"""
def __init__(self, slot: SlotNode, context: Context):
self._slot = slot
self._context = context
@property
def default(self) -> str:
return mark_safe(self._slot.nodelist.render(self._context))
class SlotNode(Node):
def __init__(self, name, nodelist):
self.name, self.nodelist = name, nodelist
self.parent_component = None
self.context = None
self.name = name
self.nodelist = nodelist
self.component_cls = None
self.is_conditional: bool = False
def __repr__(self):
return "<Slot Node: %s. Contents: %r>" % (self.name, self.nodelist)
return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}>"
def render(self, context):
# Thread safety: storing the context as a property of the cloned SlotNode without using
# the render_context facility should be thread-safe, since each cloned_node
# is only used for a single render.
cloned_node = SlotNode(self.name, self.nodelist)
cloned_node.parent_component = self.parent_component
cloned_node.context = context
with context.update({"slot": cloned_node}):
return self.get_nodelist(context).render(context)
def get_nodelist(self, context):
if ACTIVE_SLOT_CONTEXT_KEY not in context:
if FILLED_SLOTS_CONTEXT_KEY not in context:
raise TemplateSyntaxError(
f"Attempted to render SlotNode {self.name} outside of a parent Component or "
"without access to context provided by its parent Component. This will not"
"work properly."
f"Attempted to render SlotNode '{self.name}' outside a parent component."
)
overriding_nodelist = context[ACTIVE_SLOT_CONTEXT_KEY].get(
self.name, None
)
return (
overriding_nodelist
if overriding_nodelist is not None
else self.nodelist
)
def super(self):
"""Render default slot content."""
return mark_safe(self.nodelist.render(self.context))
filled_slots: DefaultDict[str, List[FillNode]] = context[
FILLED_SLOTS_CONTEXT_KEY
]
fill_node_stack = filled_slots[self.name]
extra_context = {}
if not fill_node_stack: # if []
nodelist = self.nodelist
else:
fill_node = fill_node_stack.pop()
nodelist = fill_node.nodelist
# context[FILLED_SLOTS_CONTEXT_KEY].pop(self.name)
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)
@register.tag("slot")
def do_slot(parser, token):
bits = token.split_contents()
if len(bits) != 2:
raise TemplateSyntaxError("'%s' tag takes only one argument" % bits[0])
args = bits[1:]
# e.g. {% slot <name> %}
if len(args) == 1:
slot_name: str = args[0]
else:
raise TemplateSyntaxError(
f"{bits[0]}' tag takes only one argument (the slot name)"
)
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])
slot_name = bits[1].strip('"')
nodelist = parser.parse(parse_until=["endslot"])
parser.delete_first_token()
return SlotNode(slot_name, nodelist)
class ComponentNode(Node):
class InvalidSlot:
def super(self):
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
def __repr__(self):
return f"<Fill Node: {self.name_var}. Contents: {repr(self.nodelist)}>"
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."
)
@register.tag("fill")
def do_fill(parser, token):
"""Block tag whose contents 'fill' (are inserted into) an identically named
'slot'-block in the component template referred to by a parent component_block.
It exists to make component nesting easier.
This tag is available only within a {% component_block %}..{% endcomponent_block %} block.
Runtime checks should prohibit other usages.
"""
bits = token.split_contents()
tag = bits[0]
args = bits[1:]
# e.g. {% fill <name> %}
alias_var = None
if len(args) == 1:
tgt_slot_name: str = args[0]
# e.g. {% fill <name> as <alias> %}
elif len(args) == 3:
tgt_slot_name, as_keyword, alias = args
if as_keyword.lower() != "as":
raise TemplateSyntaxError(
"slot.super may only be called within a {% slot %}/{% endslot %} block."
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")
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)
class ComponentNode(Node):
child_nodelists = ("fill_nodes",)
def __init__(
self,
component_name,
name_var: NameVariable,
context_args,
context_kwargs,
slots=None,
isolated_context=False,
):
self.name_var = name_var
self.context_args = context_args or []
self.context_kwargs = context_kwargs or {}
self.component_name, self.isolated_context = (
component_name,
isolated_context,
)
self.slots = slots
self.fill_nodes: List[FillNode] = []
self.isolated_context = isolated_context
def __repr__(self):
return "<Component Node: %s. Contents: %r>" % (
self.component_name,
self.nodelist,
self.name_var,
getattr(
self, "nodelist", None
), # 'nodelist' attribute only assigned later.
)
def render(self, context):
component_name = template.Variable(self.component_name).resolve(
context
)
component_class = registry.get(component_name)
component = component_class(component_name)
resolved_component_name = self.name_var.resolve(context)
component_cls = registry.get(resolved_component_name)
component: Component = component_cls(resolved_component_name)
# Group slot notes by name and concatenate their nodelists
component.slots = defaultdict(NodeList)
for slot in self.slots or []:
component.slots[slot.name].extend(slot.nodelist)
component.outer_context = context.flatten()
# Resolve FilterExpressions and Variables that were passed as args to the component, then call component's
# context method to get values to insert into the context
# Resolve FilterExpressions and Variables that were passed as args to the
# component, then call component's context method
# to get values to insert into the context
resolved_context_args = [
safe_resolve(arg, context) for arg in self.context_args
]
@ -237,25 +322,33 @@ class ComponentNode(Node):
key: safe_resolve(kwarg, context)
for key, kwarg in self.context_kwargs.items()
}
component_context = component.get_context_data(
*resolved_context_args, **resolved_context_kwargs
)
# Create a fresh context if requested
if self.isolated_context:
context = context.new()
resolved_fills = {
fill_node.name_var.resolve(context): fill_node
for fill_node in self.fill_nodes
}
with context.update(component_context):
rendered_component = component.render(context)
if is_dependency_middleware_active():
return (
RENDERED_COMMENT_TEMPLATE.format(
name=component._component_name
)
+ rendered_component
# Create a fresh isolated context if requested w 'only' keyword.
with component.assign(
fills=resolved_fills, outer_context=context.flatten()
):
component_context = component.get_context_data(
*resolved_context_args, **resolved_context_kwargs
)
if self.isolated_context:
context = context.new()
with context.update(component_context):
rendered_component = component.render(context)
if is_dependency_middleware_active():
return (
RENDERED_COMMENT_TEMPLATE.format(
name=component._component_name
)
else:
return rendered_component
+ rendered_component
)
else:
return rendered_component
@register.tag(name="component_block")
@ -278,21 +371,36 @@ def do_component_block(parser, token):
component_name, context_args, context_kwargs = parse_component_with_args(
parser, bits, "component_block"
)
return ComponentNode(
component_name,
component_node = ComponentNode(
NameVariable(component_name, "component"),
context_args,
context_kwargs,
slots=[
do_slot(parser, slot_token) for slot_token in slot_tokens(parser)
],
isolated_context=isolated_context,
)
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)
def slot_tokens(parser):
"""Yield each 'slot' token appearing before the next 'endcomponent_block' token.
return component_node
Raises TemplateSyntaxError if there are other content tokens or if there is no endcomponent_block token.
def fill_tokens(parser):
"""Yield each 'fill' token appearing before the next 'endcomponent_block' token.
Raises TemplateSyntaxError if:
- there are other content tokens
- there is no endcomponent_block token.
- a (deprecated) 'slot' token is encountered.
"""
def is_whitespace(token):
@ -313,16 +421,162 @@ def slot_tokens(parser):
raise TemplateSyntaxError("Unclosed component_block tag")
if is_block_tag(token, name="endcomponent_block"):
return
elif is_block_tag(token, name="slot"):
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(
f"Content tokens in component blocks must be inside of slot tags: {token}"
f"Content tokens in component blocks must be placed inside 'fill' tags: {token}"
)
@register.tag(name="if_filled")
def do_if_filled_block(parser, token):
"""
### Usage
Example:
```
{% if_filled <slot> (<bool>) %}
...
{% elif_filled <slot> (<bool>) %}
...
{% else_filled %}
...
{% endif_filled %}
```
Notes:
Optional arg `<bool>` is True by default.
If a False is provided instead, the effect is a negation of the `if_filled` check:
The behavior is analogous to `if not is_filled <slot>`.
This design prevents us having to define a separate `if_unfilled` tag.
"""
bits = token.split_contents()
starting_tag = bits[0]
slot_name_var: Optional[NameVariable]
slot_name_var, 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)
]
token = parser.next_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)
nodelist: NodeList = parser.parse(
("elif_filled", "else_filled", "endif_filled")
)
branches.append((slot_name_var, nodelist, is_positive))
token = parser.next_token()
# {% else_filled %} (optional)
if token.contents.startswith("else_filled"):
bits = token.split_contents()
_, _ = parse_if_filled_bits(bits)
nodelist = parser.parse(("endif_filled",))
branches.append((None, nodelist, None))
token = parser.next_token()
# {% endif_filled %}
if token.contents != "endif_filled":
raise TemplateSyntaxError(
f"{{% {starting_tag} %}} missing closing {{% endif_filled %}} tag"
f" at line {token.lineno}: '{token.contents}'"
)
return IfSlotFilledNode(branches)
def parse_if_filled_bits(
bits: List[str],
) -> Tuple[Optional[NameVariable], 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)}'"
)
else:
return None, None
if len(args) == 1:
slot_name = args[0]
is_positive = True
elif len(args) == 2:
slot_name = args[0]
is_positive = bool_from_string(args[1])
else:
raise TemplateSyntaxError(
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
class IfSlotFilledNode(Node):
def __init__(
self,
branches: List[
Tuple[Optional[NameVariable], NodeList, Optional[bool]]
],
):
# [(<slot name var | None (= condition)>, nodelist, <is_positive>)]
self.branches = branches
self.visit_and_mark_slots_as_conditional_()
def __iter__(self):
for _, nodelist, _ in self.branches:
for node in nodelist:
yield node
def __repr__(self):
return f"<{self.__class__.__name__}>"
def visit_and_mark_slots_as_conditional_(self):
stack = list(self.nodelist)
while stack:
node = stack.pop()
if isinstance(node, SlotNode):
node.is_conditional = True
for nodelist_name in node.child_nodelists:
stack.extend(getattr(node, nodelist_name, ()))
@property
def nodelist(self):
return NodeList(self)
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
return ""
def check_for_isolated_context_keyword(bits):
"""Return True and strip the last word if token ends with 'only' keyword."""
@ -344,14 +598,13 @@ def parse_component_with_args(parser, bits, tag_name):
kwonly=[],
kwonly_defaults=None,
)
assert (
tag_name == tag_args[0].token
), "Internal error: Expected tag_name to be {}, but it was {}".format(
tag_name, tag_args[0].token
)
if (
len(tag_args) > 1
): # At least one position arg, so take the first as the component name
if tag_name != tag_args[0].token:
raise RuntimeError(
f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}"
)
if len(tag_args) > 1:
# At least one position arg, so take the first as the component name
component_name = tag_args[1].token
context_args = tag_args[2:]
context_kwargs = tag_kwargs
@ -362,8 +615,7 @@ def parse_component_with_args(parser, bits, tag_name):
context_kwargs = tag_kwargs
except IndexError:
raise TemplateSyntaxError(
"Call the '%s' tag with a component name as the first parameter"
% tag_name
f"Call the '{tag_name}' tag with a component name as the first parameter"
)
return component_name, context_args, context_kwargs
@ -387,3 +639,60 @@ def is_dependency_middleware_active():
return getattr(settings, "COMPONENTS", {}).get(
"RENDER_DEPENDENCIES", False
)
def norm_and_validate_name(name: str, tag: str, context: str = None):
"""
Notes:
- Value of `tag` in {"slot", "fill", "alias"}
"""
name = strip_quotes(name)
if not name.isidentifier():
context = f" in '{context}'" if context else ""
raise TemplateSyntaxError(
f"{tag} name '{name}'{context} "
"is not a valid Python identifier."
)
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("\"'")
def bool_from_string(s: str):
s = strip_quotes(s.lower())
if s == "true":
return True
elif s == "false":
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."
)