diff --git a/README.md b/README.md index 7f2f07fb..e5529f30 100644 --- a/README.md +++ b/README.md @@ -858,3 +858,18 @@ Use the [sampleproject](./sampleproject/) demo project to validate the changes: Once the server is up, it should be available at . To display individual components, add them to the `urls.py`, like in the case of + +## Development guides + +### Slot rendering flow + +1. Flow starts when a template string is being parsed into Django Template instance. + +2. When a `{% component %}` template tag is encountered, its body is searched for all `{% fill %}` nodes (explicit or implicit). and this is attached to the created `ComponentNode`. + + See the implementation of `component` template tag for details. + +3. Template rendering is a separate action from template parsing. When the template is being rendered, the `ComponentNode` creates an instance of the `Component` class and passes it the slot fills. + + It's at this point when `Component.render` is called, and the slots are + rendered. diff --git a/src/django_components/component.py b/src/django_components/component.py index fac343bb..70a8e5d6 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -1,30 +1,13 @@ -import difflib import inspect import os -from collections import ChainMap from pathlib import Path -from typing import ( - Any, - ClassVar, - Dict, - Iterable, - List, - Mapping, - MutableMapping, - Optional, - Set, - Tuple, - Type, - TypeVar, - Union, -) +from typing import Any, ClassVar, Dict, Iterable, List, Mapping, MutableMapping, Optional, Tuple, Type, Union from django.core.exceptions import ImproperlyConfigured from django.forms.widgets import Media, MediaDefiningClass from django.http import HttpResponse -from django.template.base import NodeList, Template, TextNode +from django.template.base import FilterExpression, Node, NodeList, Template, TextNode from django.template.context import Context -from django.template.exceptions import TemplateSyntaxError from django.template.loader import get_template from django.utils.html import escape from django.utils.safestring import SafeString, mark_safe @@ -34,31 +17,30 @@ from django.views import View # 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.component_registry import registry # NOQA +from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # NOQA from django_components.logger import logger -from django_components.templatetags.component_tags import ( - FILLED_SLOTS_CONTENT_CONTEXT_KEY, +from django_components.middleware import is_dependency_middleware_active +from django_components.slots import ( DefaultFillContent, - FillContent, - FilledSlotsContext, - IfSlotFilledConditionBranchNode, + ImplicitFillNode, NamedFillContent, + NamedFillNode, SlotName, - SlotNode, + render_component_template_with_slots, ) from django_components.utils import search -_T = TypeVar("_T") +RENDERED_COMMENT_TEMPLATE = "" class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass): def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type: + # NOTE: Skip template/media file resolution when then Component class ITSELF + # is being created. + if "__module__" in attrs and attrs["__module__"] == "django_components.component": + return super().__new__(mcs, name, bases, attrs) + if "Media" in attrs: media: Component.Media = attrs["Media"] @@ -270,13 +252,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): if slots_data: self._fill_slots(slots_data, escape_slots_content) - prev_filled_slots_context: Optional[FilledSlotsContext] = context.get(FILLED_SLOTS_CONTENT_CONTEXT_KEY) - updated_filled_slots_context = self._process_template_and_update_filled_slot_context( - template, - prev_filled_slots_context, - ) - with context.update({FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_filled_slots_context}): - return template.render(context) + return render_component_template_with_slots(template, context, self.fill_content, self.registered_name) def render_to_response( self, @@ -307,105 +283,78 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): for (slot_name, content) in slots_data.items() ] - def _process_template_and_update_filled_slot_context( + +class ComponentNode(Node): + """Django.template.Node subclass that renders a django-components component""" + + def __init__( self, - template: Template, - slots_context: Optional[FilledSlotsContext], - ) -> FilledSlotsContext: - if isinstance(self.fill_content, NodeList): - default_fill_content = (self.fill_content, None) - named_fills_content = {} + name_fexp: FilterExpression, + context_args: List[FilterExpression], + context_kwargs: Mapping[str, FilterExpression], + isolated_context: bool = False, + fill_nodes: Union[ImplicitFillNode, Iterable[NamedFillNode]] = (), + ) -> None: + self.name_fexp = name_fexp + self.context_args = context_args or [] + self.context_kwargs = context_kwargs or {} + self.isolated_context = isolated_context + self.fill_nodes = fill_nodes + self.nodelist = self._create_nodelist(fill_nodes) + + def _create_nodelist(self, fill_nodes: Union[Iterable[Node], ImplicitFillNode]) -> NodeList: + if isinstance(fill_nodes, ImplicitFillNode): + return NodeList([fill_nodes]) else: - default_fill_content = None - named_fills_content = {name: (nodelist, alias) for name, nodelist, alias in list(self.fill_content)} + return NodeList(fill_nodes) - # If value is `None`, then slot is unfilled. - slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {} - default_slot_encountered: bool = False - required_slot_names: Set[str] = set() + def __repr__(self) -> str: + return "".format( + self.name_fexp, + getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later. + ) - 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_required: - required_slot_names.add(node.name) - if node.is_default: - if default_slot_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}'." - ) - content_data = default_fill_content - default_slot_encountered = True - if not content_data: - content_data = named_fills_content.get(node.name) - 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.") + def render(self, context: Context) -> str: + resolved_component_name = self.name_fexp.resolve(context) + component_cls: Type[Component] = registry.get(resolved_component_name) - # Check: Only component templates that include a 'default' slot - # can be invoked with implicit filling. - if default_fill_content and not default_slot_encountered: - raise TemplateSyntaxError( - f"Component '{self.registered_name}' passed default fill content '{default_fill_content}'" - f"(i.e. without explicit 'fill' tag), " - f"even though none of its slots is marked as 'default'." - ) + # 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] + resolved_context_kwargs = {key: safe_resolve(kwarg, context) for key, kwarg in self.context_kwargs.items()} - unfilled_slots: Set[str] = {k for k, v in slot_name2fill_content.items() if v is None} - unmatched_fills: Set[str] = named_fills_content.keys() - slot_name2fill_content.keys() - - # Check that 'required' slots are filled. - for slot_name in unfilled_slots: - if slot_name in required_slot_names: - msg = ( - f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), " - f"yet no fill is provided. Check template.'" - ) - if unmatched_fills: - msg = f"{msg}\nPossible typo in unresolvable fills: {unmatched_fills}." - raise TemplateSyntaxError(msg) - - # Check that all fills can be matched to a slot on the component template. - # To help with easy-to-overlook typos, we fuzzy match unresolvable fills to - # those slots for which no matching fill was encountered. In the event of - # a close match, we include the name of the matched unfilled slot as a - # hint in the error message. - # - # Note: Finding a good `cutoff` value may require further trial-and-error. - # Higher values make matching stricter. This is probably preferable, as it - # reduces false positives. - for fill_name in unmatched_fills: - fuzzy_slot_name_matches = difflib.get_close_matches(fill_name, unfilled_slots, n=1, cutoff=0.7) - msg = ( - f"Component '{self.registered_name}' passed fill " - f"that refers to undefined slot: '{fill_name}'." - f"\nUnfilled slot names are: {sorted(unfilled_slots)}." - ) - if fuzzy_slot_name_matches: - msg += f"\nDid you mean '{fuzzy_slot_name_matches[0]}'?" - raise TemplateSyntaxError(msg) - - # 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 # Slots whose content is None (i.e. unfilled) are dropped. - } - if slots_context is not None: - return slots_context.new_child(filled_slots_map) + if isinstance(self.fill_nodes, ImplicitFillNode): + fill_content = self.fill_nodes.nodelist else: - return ChainMap(filled_slots_map) + 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) + resolved_fill_alias = fill_node.resolve_alias(context, resolved_component_name) + fill_content.append((resolved_name, fill_node.nodelist, resolved_fill_alias)) + + component: Component = component_cls( + registered_name=resolved_component_name, + outer_context=context, + fill_content=fill_content, + ) + + 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): + rendered_component = component.render(context) + + if is_dependency_middleware_active(): + return RENDERED_COMMENT_TEMPLATE.format(name=resolved_component_name) + rendered_component + else: + return rendered_component + + +def safe_resolve(context_item: FilterExpression, context: Context) -> Any: + """Resolve FilterExpressions and Variables in context if possible. Return other items unchanged.""" + + return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item diff --git a/src/django_components/middleware.py b/src/django_components/middleware.py index 3005827a..72f42270 100644 --- a/src/django_components/middleware.py +++ b/src/django_components/middleware.py @@ -82,3 +82,7 @@ def join_media(components: Iterable["Component"]) -> Media: """Return combined media object for iterable of components.""" return sum([component.media for component in components], Media()) + + +def is_dependency_middleware_active() -> bool: + return getattr(settings, "COMPONENTS", {}).get("RENDER_DEPENDENCIES", False) diff --git a/src/django_components/slots.py b/src/django_components/slots.py new file mode 100644 index 00000000..72551803 --- /dev/null +++ b/src/django_components/slots.py @@ -0,0 +1,467 @@ +import difflib +import sys +from typing import Dict, Iterable, List, Optional, Set, Tuple, Type, Union + +if sys.version_info[:2] < (3, 9): + from typing import ChainMap +else: + from collections import ChainMap + +if sys.version_info[:2] < (3, 10): + from typing_extensions import TypeAlias +else: + from typing import TypeAlias + +from django.template import Context, Template +from django.template.base import FilterExpression, Node, NodeList, TextNode +from django.template.defaulttags import CommentNode +from django.template.exceptions import TemplateSyntaxError +from django.utils.safestring import SafeString, mark_safe + +FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS" + +# Type aliases + +SlotName = str +AliasName = str + +DefaultFillContent: TypeAlias = NodeList +NamedFillContent = Tuple[SlotName, NodeList, Optional[AliasName]] + +FillContent = Tuple[NodeList, Optional[AliasName]] +FilledSlotsContext = ChainMap[Tuple[SlotName, Template], FillContent] + + +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 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: Template) -> None: + self._template = value + + +class SlotNode(Node, TemplateAwareNodeMixin): + def __init__( + self, + name: str, + nodelist: NodeList, + is_required: bool = False, + is_default: bool = False, + ): + self.name = name + self.nodelist = nodelist + self.is_required = is_required + self.is_default = is_default + + @property + def active_flags(self) -> List[str]: + m = [] + if self.is_required: + m.append("required") + if self.is_default: + m.append("default") + return m + + def __repr__(self) -> str: + return f"" + + def render(self, context: Context) -> SafeString: + 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.") + + extra_context = {} + try: + slot_fill_content: 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. " + ) + nodelist = self.nodelist + else: + nodelist, alias = slot_fill_content + if alias: + if not alias.isidentifier(): + raise TemplateSyntaxError() + extra_context[alias] = UserSlotVar(self, context) + + with context.update(extra_context): + return nodelist.render(context) + + +class BaseFillNode(Node): + def __init__(self, nodelist: NodeList): + self.nodelist: NodeList = nodelist + + def __repr__(self) -> str: + raise NotImplementedError + + def render(self, context: Context) -> str: + raise TemplateSyntaxError( + "{% fill ... %} block cannot be rendered directly. " + "You are probably seeing this because you have used one outside " + "a {% component %} 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) -> str: + return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>" + + def resolve_alias(self, context: Context, component_name: Optional[str] = None) -> Optional[str]: + if not self.alias_fexp: + return None + + resolved_alias: Optional[str] = self.alias_fexp.resolve(context) + if resolved_alias and not resolved_alias.isidentifier(): + raise TemplateSyntaxError( + f"Fill tag alias '{self.alias_fexp.var}' in component " + f"{component_name} does not resolve to " + f"a valid Python identifier. Got: '{resolved_alias}'." + ) + return resolved_alias + + +class ImplicitFillNode(BaseFillNode): + """ + Instantiated when a `component` 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) -> str: + return f"<{type(self)} Contents: {repr(self.nodelist)}.>" + + +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: Context) -> bool: + raise NotImplementedError + + +class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, TemplateAwareNodeMixin): + def __init__( + self, + slot_name: str, + nodelist: NodeList, + is_positive: Union[bool, None] = True, + ) -> None: + self.slot_name = slot_name + self.is_positive: Optional[bool] = is_positive + super().__init__(nodelist) + + def evaluate(self, context: 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: Context) -> bool: + return True + + +class IfSlotFilledNode(Node): + def __init__( + self, + branches: List[_IfSlotFilledBranchNode], + ): + self.branches = branches + self.nodelist = self._create_nodelist(branches) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}>" + + def _create_nodelist(self, branches: List[_IfSlotFilledBranchNode]) -> NodeList: + return NodeList(branches) + + def render(self, context: Context) -> str: + 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 "" + + +def parse_slot_fill_nodes_from_component_nodelist( + component_nodelist: NodeList, + ComponentNodeCls: Type[Node], +) -> Union[Iterable[NamedFillNode], ImplicitFillNode]: + """ + Given a component body (`django.template.NodeList`), find all slot fills, + whether defined explicitly with `{% fill %}` or implicitly. + + So if we have a component body: + ```django + {% component "mycomponent" %} + {% fill "first_fill" %} + Hello! + {% endfill %} + {% fill "second_fill" %} + Hello too! + {% endfill %} + {% endcomponent %} + ``` + Then this function returns the nodes (`django.template.Node`) for `fill "first_fill"` + and `fill "second_fill"`. + """ + fill_nodes: Union[Iterable[NamedFillNode], ImplicitFillNode] = [] + if _block_has_content(component_nodelist): + for parse_fn in ( + _try_parse_as_default_fill, + _try_parse_as_named_fill_tag_set, + ): + curr_fill_nodes = parse_fn(component_nodelist, ComponentNodeCls) + if curr_fill_nodes: + fill_nodes = curr_fill_nodes + break + else: + raise TemplateSyntaxError( + "Illegal content passed to 'component' 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." + ) + return fill_nodes + + +def _try_parse_as_named_fill_tag_set( + nodelist: NodeList, + ComponentNodeCls: Type[Node], +) -> Optional[Iterable[NamedFillNode]]: + result = [] + seen_name_fexps: Set[FilterExpression] = 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}'." + ) + seen_name_fexps.add(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 + + +def _try_parse_as_default_fill( + nodelist: NodeList, + ComponentNodeCls: Type[Node], +) -> Optional[ImplicitFillNode]: + nodes_stack: List[Node] = list(nodelist) + while nodes_stack: + node = nodes_stack.pop() + if isinstance(node, NamedFillNode): + return None + elif isinstance(node, ComponentNodeCls): + # 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 _block_has_content(nodelist: 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 render_component_template_with_slots( + template: Template, + context: Context, + fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]], + registered_name: Optional[str], +) -> str: + """ + Given a template, context, and slot fills, this function first prepares + the template to be able to render the fills in the place of slots, and then + renders the template with given context. + + NOTE: The template is mutated in the process! + """ + prev_filled_slots_context: Optional[FilledSlotsContext] = context.get(FILLED_SLOTS_CONTENT_CONTEXT_KEY) + updated_filled_slots_context = _prepare_component_template_filled_slot_context( + template, + fill_content, + prev_filled_slots_context, + registered_name, + ) + with context.update({FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_filled_slots_context}): + return template.render(context) + + +def _prepare_component_template_filled_slot_context( + template: Template, + fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]], + slots_context: Optional[FilledSlotsContext], + registered_name: Optional[str], +) -> FilledSlotsContext: + if isinstance(fill_content, NodeList): + default_fill_content = (fill_content, None) + named_fills_content = {} + else: + default_fill_content = None + named_fills_content = {name: (nodelist, alias) for name, nodelist, alias in list(fill_content)} + + # If value is `None`, then slot is unfilled. + slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {} + default_slot_encountered: bool = False + required_slot_names: Set[str] = set() + + # Collect fills and check for errors + 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 '{registered_name}'." + ) + content_data: Optional[FillContent] = None # `None` -> unfilled + if node.is_required: + required_slot_names.add(node.name) + if node.is_default: + if default_slot_encountered: + raise TemplateSyntaxError( + "Only one component slot may be marked as 'default'. " + f"To fix, check template '{template.name}' " + f"of component '{registered_name}'." + ) + content_data = default_fill_content + default_slot_encountered = True + if not content_data: + content_data = named_fills_content.get(node.name) + 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: Only component templates that include a 'default' slot + # can be invoked with implicit filling. + if default_fill_content and not default_slot_encountered: + raise TemplateSyntaxError( + f"Component '{registered_name}' passed default fill content '{default_fill_content}'" + f"(i.e. without explicit 'fill' tag), " + f"even though none of its slots is marked as 'default'." + ) + + unfilled_slots: Set[str] = {k for k, v in slot_name2fill_content.items() if v is None} + unmatched_fills: Set[str] = named_fills_content.keys() - slot_name2fill_content.keys() + + # Check that 'required' slots are filled. + for slot_name in unfilled_slots: + if slot_name in required_slot_names: + msg = ( + f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), " + f"yet no fill is provided. Check template.'" + ) + if unmatched_fills: + msg = f"{msg}\nPossible typo in unresolvable fills: {unmatched_fills}." + raise TemplateSyntaxError(msg) + + # Check that all fills can be matched to a slot on the component template. + # To help with easy-to-overlook typos, we fuzzy match unresolvable fills to + # those slots for which no matching fill was encountered. In the event of + # a close match, we include the name of the matched unfilled slot as a + # hint in the error message. + # + # Note: Finding a good `cutoff` value may require further trial-and-error. + # Higher values make matching stricter. This is probably preferable, as it + # reduces false positives. + for fill_name in unmatched_fills: + fuzzy_slot_name_matches = difflib.get_close_matches(fill_name, unfilled_slots, n=1, cutoff=0.7) + msg = ( + f"Component '{registered_name}' passed fill " + f"that refers to undefined slot: '{fill_name}'." + f"\nUnfilled slot names are: {sorted(unfilled_slots)}." + ) + if fuzzy_slot_name_matches: + msg += f"\nDid you mean '{fuzzy_slot_name_matches[0]}'?" + raise TemplateSyntaxError(msg) + + # 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 # Slots whose content is None (i.e. unfilled) are dropped. + } + if slots_context is not None: + return slots_context.new_child(filled_slots_map) + else: + return ChainMap(filled_slots_map) diff --git a/src/django_components/templatetags/component_tags.py b/src/django_components/templatetags/component_tags.py index ca0e4250..677248b2 100644 --- a/src/django_components/templatetags/component_tags.py +++ b/src/django_components/templatetags/component_tags.py @@ -1,24 +1,29 @@ -import sys -from typing import TYPE_CHECKING, Any, Iterable, List, Mapping, Optional, Set, Tuple, Type, Union - -if sys.version_info[:2] < (3, 9): - from typing import ChainMap -else: - from collections import ChainMap +from typing import TYPE_CHECKING, List, Mapping, Optional, Tuple import django.template -from django.conf import settings -from django.template import Context, Template from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode, Token, TokenType -from django.template.defaulttags import CommentNode from django.template.exceptions import TemplateSyntaxError from django.template.library import parse_bits from django.utils.safestring import SafeString, mark_safe from django_components.app_settings import app_settings +from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode 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 +from django_components.middleware import ( + CSS_DEPENDENCY_PLACEHOLDER, + JS_DEPENDENCY_PLACEHOLDER, + is_dependency_middleware_active, +) +from django_components.slots import ( + IfSlotFilledConditionBranchNode, + IfSlotFilledElseBranchNode, + IfSlotFilledNode, + NamedFillNode, + SlotNode, + _IfSlotFilledBranchNode, + parse_slot_fill_nodes_from_component_nodelist, +) if TYPE_CHECKING: from django_components.component import Component @@ -27,24 +32,9 @@ if TYPE_CHECKING: register = django.template.Library() -RENDERED_COMMENT_TEMPLATE = "" - SLOT_REQUIRED_OPTION_KEYWORD = "required" SLOT_DEFAULT_OPTION_KEYWORD = "default" -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) -> List["Component"]: """Returns a list unique components from the registry.""" @@ -123,95 +113,6 @@ def component_js_dependencies_tag(preload: str = "") -> SafeString: return mark_safe("\n".join(rendered_dependencies)) -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 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: Template) -> None: - self._template = value - - -class SlotNode(Node, TemplateAwareNodeMixin): - def __init__( - self, - name: str, - nodelist: NodeList, - is_required: bool = False, - is_default: bool = False, - ): - self.name = name - self.nodelist = nodelist - self.is_required = is_required - self.is_default = is_default - - @property - def active_flags(self) -> List[str]: - m = [] - if self.is_required: - m.append("required") - if self.is_default: - m.append("default") - return m - - def __repr__(self) -> str: - return f"" - - def render(self, context: Context) -> SafeString: - 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.") - - extra_context = {} - try: - slot_fill_content: 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. " - ) - nodelist = self.nodelist - else: - nodelist, alias = slot_fill_content - if alias: - if not alias.isidentifier(): - raise TemplateSyntaxError() - extra_context[alias] = UserSlotVar(self, context) - - with context.update(extra_context): - return nodelist.render(context) - - @register.tag("slot") def do_slot(parser: Parser, token: Token) -> SlotNode: bits = token.split_contents() @@ -254,47 +155,6 @@ def do_slot(parser: Parser, token: Token) -> SlotNode: ) -class BaseFillNode(Node): - def __init__(self, nodelist: NodeList): - self.nodelist: NodeList = nodelist - - def __repr__(self) -> str: - raise NotImplementedError - - def render(self, context: Context) -> str: - raise TemplateSyntaxError( - "{% fill ... %} block cannot be rendered directly. " - "You are probably seeing this because you have used one outside " - "a {% component %} 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) -> str: - return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>" - - -class ImplicitFillNode(BaseFillNode): - """ - Instantiated when a `component` 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) -> str: - return f"<{type(self)} Contents: {repr(self.nodelist)}.>" - - @register.tag("fill") def do_fill(parser: Parser, token: Token) -> NamedFillNode: """Block tag whose contents 'fill' (are inserted into) an identically named @@ -329,84 +189,6 @@ def do_fill(parser: Parser, token: Token) -> NamedFillNode: ) -class ComponentNode(Node): - def __init__( - self, - name_fexp: FilterExpression, - context_args: List[FilterExpression], - context_kwargs: Mapping[str, FilterExpression], - isolated_context: bool = False, - fill_nodes: Union[ImplicitFillNode, Iterable[NamedFillNode]] = (), - ) -> None: - self.name_fexp = name_fexp - self.context_args = context_args or [] - self.context_kwargs = context_kwargs or {} - self.isolated_context = isolated_context - self.fill_nodes = fill_nodes - self.nodelist = self._create_nodelist(fill_nodes) - - def _create_nodelist(self, fill_nodes: Union[Iterable[Node], ImplicitFillNode]) -> NodeList: - if isinstance(fill_nodes, ImplicitFillNode): - return NodeList([fill_nodes]) - else: - return NodeList(fill_nodes) - - def __repr__(self) -> str: - return "".format( - self.name_fexp, - getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later. - ) - - def render(self, context: Context) -> str: - 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 - # to get values to insert into the context - resolved_context_args = [safe_resolve(arg, context) for arg in self.context_args] - resolved_context_kwargs = {key: safe_resolve(kwarg, context) for key, kwarg in self.context_kwargs.items()} - - 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) - resolved_alias: Optional[str] - if fill_node.alias_fexp: - resolved_alias = fill_node.alias_fexp.resolve(context) - if resolved_alias and 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 - fill_content.append((resolved_name, fill_node.nodelist, resolved_alias)) - - component: Component = component_cls( - registered_name=resolved_component_name, - outer_context=context, - fill_content=fill_content, - ) - - 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): - rendered_component = component.render(context) - - if is_dependency_middleware_active(): - return RENDERED_COMMENT_TEMPLATE.format(name=resolved_component_name) + rendered_component - else: - return rendered_component - - @register.tag(name="component") def do_component(parser: Parser, token: Token) -> ComponentNode: """ @@ -427,23 +209,7 @@ def do_component(parser: Parser, token: Token) -> ComponentNode: component_name, context_args, context_kwargs = parse_component_with_args(parser, bits, "component") body: NodeList = parser.parse(parse_until=["endcomponent"]) parser.delete_first_token() - fill_nodes: Union[Iterable[NamedFillNode], ImplicitFillNode] = [] - if block_has_content(body): - for parse_fn in ( - try_parse_as_default_fill, - try_parse_as_named_fill_tag_set, - ): - curr_fill_nodes = parse_fn(body) - if curr_fill_nodes: - fill_nodes = curr_fill_nodes - break - else: - raise TemplateSyntaxError( - "Illegal content passed to 'component' 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." - ) + fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode) component_node = ComponentNode( FilterExpression(component_name, parser), context_args, @@ -455,59 +221,6 @@ def do_component(parser: Parser, token: Token) -> ComponentNode: return component_node -def try_parse_as_named_fill_tag_set( - nodelist: NodeList, -) -> Optional[Iterable[NamedFillNode]]: - result = [] - seen_name_fexps: Set[FilterExpression] = 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}'." - ) - seen_name_fexps.add(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 - - -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 block_has_content(nodelist: 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() @@ -616,72 +329,6 @@ def parse_if_filled_bits( 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: Context) -> bool: - raise NotImplementedError - - -class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, TemplateAwareNodeMixin): - def __init__( - self, - slot_name: str, - nodelist: NodeList, - is_positive: Union[bool, None] = True, - ) -> None: - self.slot_name = slot_name - self.is_positive: Optional[bool] = is_positive - super().__init__(nodelist) - - def evaluate(self, context: 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: Context) -> bool: - return True - - -class IfSlotFilledNode(Node): - def __init__( - self, - branches: List[_IfSlotFilledBranchNode], - ): - self.branches = branches - self.nodelist = self._create_nodelist(branches) - - def __repr__(self) -> str: - return f"<{self.__class__.__name__}>" - - def _create_nodelist(self, branches: List[_IfSlotFilledBranchNode]) -> NodeList: - return NodeList(branches) - - def render(self, context: Context) -> str: - 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 "" - - def check_for_isolated_context_keyword(bits: List[str]) -> Tuple[List[str], bool]: """Return True and strip the last word if token ends with 'only' keyword or if CONTEXT_BEHAVIOR is 'isolated'.""" @@ -728,32 +375,10 @@ def parse_component_with_args( return component_name, context_args, context_kwargs -def safe_resolve(context_item: FilterExpression, context: Context) -> Any: - """Resolve FilterExpressions and Variables in context if possible. Return other items unchanged.""" - - return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item - - def is_wrapped_in_quotes(s: str) -> bool: return s.startswith(('"', "'")) and s[0] == s[-1] -def is_dependency_middleware_active() -> bool: - return getattr(settings, "COMPONENTS", {}).get("RENDER_DEPENDENCIES", False) - - -def norm_and_validate_name(name: str, tag: str, context: Optional[str] = None) -> str: - """ - 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 strip_quotes(s: str) -> str: return s.strip("\"'")