feat: refactor render fn and allow slots as functions

This commit is contained in:
Juro Oravec 2024-06-02 16:22:38 +02:00
parent 3a7d5355cf
commit fee26ec1d8
5 changed files with 243 additions and 140 deletions

View file

@ -9,7 +9,7 @@ class Greeting(component.Component):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
slots = {"message": "Hello, world!"} slots = {"message": "Hello, world!"}
context = {"name": request.GET.get("name", "")} context = {"name": request.GET.get("name", "")}
return self.render_to_response(context, slots) return self.render_to_response(context=context, slots=slots)
def get_context_data(self, name, *args, **kwargs) -> Dict[str, Any]: def get_context_data(self, name, *args, **kwargs) -> Dict[str, Any]:
return {"name": name} return {"name": name}

View file

@ -37,7 +37,8 @@ from django_components.context import (
from django_components.expression import safe_resolve_dict, safe_resolve_list from django_components.expression import safe_resolve_dict, safe_resolve_list
from django_components.logger import logger, trace_msg from django_components.logger import logger, trace_msg
from django_components.middleware import is_dependency_middleware_active from django_components.middleware import is_dependency_middleware_active
from django_components.slots import DEFAULT_SLOT_KEY, FillContent, FillNode, SlotName, resolve_slots from django_components.node import RenderedContent, nodelist_to_render_func
from django_components.slots import DEFAULT_SLOT_KEY, FillContent, FillNode, SlotContent, SlotName, resolve_slots
from django_components.template_parser import process_aggregate_kwargs from django_components.template_parser import process_aggregate_kwargs
from django_components.utils import gen_id, search from django_components.utils import gen_id, search
@ -180,14 +181,27 @@ def _get_dir_path_from_component_path(
class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
# Either template_name or template must be set on subclass OR subclass must implement get_template() with # Either template_name or template must be set on subclass OR subclass must implement get_template() with
# non-null return. # non-null return.
class_hash: ClassVar[int] _class_hash: ClassVar[int]
template_name: ClassVar[Optional[str]] = None template_name: ClassVar[Optional[str]] = None
"""Relative filepath to the Django template associated with this component."""
template: Optional[str] = None template: Optional[str] = None
"""Inlined Django template associated with this component."""
js: Optional[str] = None js: Optional[str] = None
"""Inlined JS associated with this component."""
css: Optional[str] = None css: Optional[str] = None
"""Inlined CSS associated with this component."""
media: Media media: Media
"""
Normalized definition of JS and CSS media files associated with this component.
NOTE: This field is generated from Component.Media class.
"""
response_class = HttpResponse
"""This allows to configure what class is used to generate response from `render_to_response`"""
class Media: class Media:
"""Defines JS and CSS media files associated with this component."""
css: Optional[Union[str, List[str], Dict[str, str], Dict[str, List[str]]]] = None css: Optional[Union[str, List[str], Dict[str, str], Dict[str, List[str]]]] = None
js: Optional[Union[str, List[str]]] = None js: Optional[Union[str, List[str]]] = None
@ -205,17 +219,39 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
self._context: Optional[Context] = None self._context: Optional[Context] = None
def __init_subclass__(cls, **kwargs: Any) -> None: def __init_subclass__(cls, **kwargs: Any) -> None:
cls.class_hash = hash(inspect.getfile(cls) + cls.__name__) cls._class_hash = hash(inspect.getfile(cls) + cls.__name__)
@property
def name(self) -> str:
return self.registered_name or self.__class__.__name__
def get_context_data(self, *args: Any, **kwargs: Any) -> Dict[str, Any]: def get_context_data(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
return {} return {}
def get_template_name(self, context: Mapping) -> Optional[str]: def get_template_name(self, context: Context) -> Optional[str]:
return self.template_name return self.template_name
def get_template_string(self, context: Mapping) -> Optional[str]: def get_template_string(self, context: Context) -> Optional[str]:
return self.template return self.template
# NOTE: When the template is taken from a file (AKA specified via `template_name`),
# then we leverage Django's template caching. This means that the same instance
# of Template is reused. This is important to keep in mind, because the implication
# is that we should treat Templates AND their nodelists as IMMUTABLE.
def get_template(self, context: Context) -> Template:
template_string = self.get_template_string(context)
if template_string is not None:
return Template(template_string)
template_name = self.get_template_name(context)
if template_name is not None:
return get_template(template_name).template
raise ImproperlyConfigured(
f"Either 'template_name' or 'template' must be set for Component {type(self).__name__}."
f"Note: this attribute is not required if you are overriding the class's `get_template*()` methods."
)
def render_dependencies(self) -> SafeString: def render_dependencies(self) -> SafeString:
"""Helper function to render all dependencies for a component.""" """Helper function to render all dependencies for a component."""
dependencies = [] dependencies = []
@ -242,26 +278,6 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
return mark_safe(f"<script>{self.js}</script>") return mark_safe(f"<script>{self.js}</script>")
return mark_safe("\n".join(self.media.render_js())) return mark_safe("\n".join(self.media.render_js()))
# NOTE: When the template is taken from a file (AKA
# specified via `template_name`), then we leverage
# Django's template caching. This means that the same
# instance of Template is reused. This is important to keep
# in mind, because the implication is that we should
# treat Templates AND their nodelists as IMMUTABLE.
def get_template(self, context: Mapping) -> Template:
template_string = self.get_template_string(context)
if template_string is not None:
return Template(template_string)
template_name = self.get_template_name(context)
if template_name is not None:
return get_template(template_name).template
raise ImproperlyConfigured(
f"Either 'template_name' or 'template' must be set for Component {type(self).__name__}."
f"Note: this attribute is not required if you are overriding the class's `get_template*()` methods."
)
def inject(self, key: str, default: Optional[Any] = None) -> Any: def inject(self, key: str, default: Optional[Any] = None) -> Any:
""" """
Use this method to retrieve the data that was passed to a `{% provide %}` tag Use this method to retrieve the data that was passed to a `{% provide %}` tag
@ -303,20 +319,102 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
As the `{{ data.hello }}` is taken from the "provider". As the `{{ data.hello }}` is taken from the "provider".
""" """
comp_name = self.registered_name or self.__class__.__name__
if self._context is None: if self._context is None:
raise RuntimeError( raise RuntimeError(
f"Method 'inject()' of component '{comp_name}' was called outside of 'get_context_data()'" f"Method 'inject()' of component '{self.name}' was called outside of 'get_context_data()'"
) )
return get_injected_context_var(comp_name, self._context, key, default) return get_injected_context_var(self.name, self._context, key, default)
def render_from_input( @classmethod
self, def render_to_response(
context: Context, cls,
args: Union[List, Tuple], context: Union[Dict[str, Any], Context] = None,
kwargs: Dict[str, Any], slots: Optional[Mapping[SlotName, SlotContent]] = None,
escape_slots_content: bool = True,
args: Optional[Union[List, Tuple]] = None,
kwargs: Optional[Dict[str, Any]] = None,
*response_args: Any,
**response_kwargs: Any,
) -> HttpResponse:
"""
This is the interface for the `django.views.View` class which allows us to
use components as Django views with `component.as_view()`.
Example:
```py
MyComponent.render_to_response(
args=[1, "two", {}],
kwargs={
"key"=123,
},
slots={
header='STATIC TEXT HERE',
},
escape_slots_content=False,
)
```
"""
content = cls.render(
args=args,
kwargs=kwargs,
context=context,
slots=slots,
escape_slots_content=escape_slots_content,
)
return cls.response_class(content, *response_args, **response_kwargs)
@classmethod
def render(
cls,
context: Optional[Union[Dict[str, Any], Context]] = None,
args: Optional[Union[List, Tuple]] = None,
kwargs: Optional[Dict[str, Any]] = None,
slots: Optional[Mapping[SlotName, SlotContent]] = None,
escape_slots_content: bool = True,
) -> str: ) -> str:
"""
Example:
```py
MyComponent.render(
args=[1, "two", {}],
kwargs={
"key"=123,
},
slots={
header='STATIC TEXT HERE',
},
escape_slots_content=False,
)
```
"""
comp = cls()
return comp._render(context, args, kwargs, slots, escape_slots_content)
def _render(
self,
context: Union[Dict[str, Any], Context] = None,
args: Optional[Union[List, Tuple]] = None,
kwargs: Optional[Dict[str, Any]] = None,
slots: Optional[Mapping[SlotName, SlotContent]] = None,
escape_slots_content: bool = True,
) -> str:
# Allow to provide no args/kwargs
args = args or []
kwargs = kwargs or {}
# Allow to provide no Context, so we can render component just with args + kwargs
context_was_given = True
if context is None:
context = Context()
context_was_given = False
# Allow to provide a dict instead of Context
# NOTE: This if/else is important to avoid nested Contexts,
# See https://github.com/EmilStenstrom/django-components/issues/414
context = context if isinstance(context, Context) else Context(context)
prepare_context(context, self.component_id)
# Temporarily populate _context so user can call `self.inject()` from # Temporarily populate _context so user can call `self.inject()` from
# within `get_context_data()` # within `get_context_data()`
self._context = context self._context = context
@ -324,32 +422,13 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
self._context = None self._context = None
with context.update(context_data): with context.update(context_data):
rendered_component = self.render(
context=context,
context_data=context_data,
)
if is_dependency_middleware_active():
output = RENDERED_COMMENT_TEMPLATE.format(name=self.registered_name) + rendered_component
else:
output = rendered_component
return output
def render(
self,
context: Union[Dict[str, Any], Context],
slots_data: Optional[Dict[SlotName, str]] = None,
escape_slots_content: bool = True,
context_data: Optional[Dict[str, Any]] = None,
) -> str:
# NOTE: This if/else is important to avoid nested Contexts,
# See https://github.com/EmilStenstrom/django-components/issues/414
context = context if isinstance(context, Context) else Context(context)
prepare_context(context, self.component_id)
template = self.get_template(context) template = self.get_template(context)
_monkeypatch_template(template) _monkeypatch_template(template)
if not context_was_given:
# Associate the newly-created Context with a Template, otherwise we get
# an error when we try to use `{% include %}` tag inside the template?
context.template = template
context.template_name = template.name
# Set `Template._is_component_nested` based on whether we're currently INSIDE # Set `Template._is_component_nested` based on whether we're currently INSIDE
# the `{% extends %}` tag. # the `{% extends %}` tag.
@ -357,22 +436,21 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
template._is_component_nested = bool(context.render_context.get(BLOCK_CONTEXT_KEY)) template._is_component_nested = bool(context.render_context.get(BLOCK_CONTEXT_KEY))
# Support passing slots explicitly to `render` method # Support passing slots explicitly to `render` method
if slots_data: if slots:
fill_content = self._fills_from_slots_data(slots_data, escape_slots_content) fill_content = self._fills_from_slots_data(slots, escape_slots_content)
else: else:
fill_content = self.fill_content fill_content = self.fill_content
# If this is top-level component and it has no parent, use outer context instead # If this is top-level component and it has no parent, use outer context instead
slot_context_data = context_data
if not context[_PARENT_COMP_CONTEXT_KEY]: if not context[_PARENT_COMP_CONTEXT_KEY]:
context_data = self.outer_context.flatten() slot_context_data = self.outer_context.flatten()
if context_data is None:
context_data = {}
slots, resolved_fills = resolve_slots( slots, resolved_fills = resolve_slots(
context, context,
template, template,
component_name=self.registered_name, component_name=self.name,
context_data=context_data, context_data=slot_context_data,
fill_content=fill_content, fill_content=fill_content,
) )
@ -398,40 +476,39 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
}, },
} }
): ):
return template.render(context) rendered_component = template.render(context)
def render_to_response( if is_dependency_middleware_active():
self, output = RENDERED_COMMENT_TEMPLATE.format(name=self.name) + rendered_component
context_data: Union[Dict[str, Any], Context], else:
slots_data: Optional[Dict[SlotName, str]] = None, output = rendered_component
escape_slots_content: bool = True,
*args: Any, return output
**kwargs: Any,
) -> HttpResponse:
"""
This is the interface for the `django.views.View` class which allows us to
use components as Django views with `component.as_view()`.
"""
return HttpResponse(
self.render(context_data, slots_data, escape_slots_content),
*args,
**kwargs,
)
def _fills_from_slots_data( def _fills_from_slots_data(
self, self,
slots_data: Dict[SlotName, str], slots_data: Mapping[SlotName, SlotContent],
escape_content: bool = True, escape_content: bool = True,
) -> Dict[SlotName, FillContent]: ) -> Dict[SlotName, FillContent]:
"""Fill component slots outside of template rendering.""" """Fill component slots outside of template rendering."""
slot_fills = { slot_fills = {}
slot_name: FillContent( for (slot_name, content) in slots_data.items():
nodes=NodeList([TextNode(escape(content) if escape_content else content)]), if isinstance(content, (str, SafeString)):
content_func = nodelist_to_render_func(
NodeList([
TextNode(escape(content) if escape_content else content)
])
)
else:
def content_func(ctx: Context) -> RenderedContent:
rendered = content(ctx)
return escape(rendered) if escape_content else rendered
slot_fills[slot_name] = FillContent(
content_func=content_func,
slot_default_var=None, slot_default_var=None,
slot_data_var=None, slot_data_var=None,
) )
for (slot_name, content) in slots_data.items()
}
return slot_fills return slot_fills
@ -477,7 +554,11 @@ class ComponentNode(Node):
is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit
if is_default_slot: if is_default_slot:
fill_content: Dict[str, FillContent] = { fill_content: Dict[str, FillContent] = {
DEFAULT_SLOT_KEY: FillContent(self.fill_nodes[0].nodelist, None, None), DEFAULT_SLOT_KEY: FillContent(
content_func=nodelist_to_render_func(self.fill_nodes[0].nodelist),
slot_data_var=None,
slot_default_var=None,
),
} }
else: else:
fill_content = {} fill_content = {}
@ -494,7 +575,7 @@ class ComponentNode(Node):
resolved_slot_default_var = fill_node.resolve_slot_default(context, resolved_component_name) resolved_slot_default_var = fill_node.resolve_slot_default(context, resolved_component_name)
resolved_slot_data_var = fill_node.resolve_slot_data(context, resolved_component_name) resolved_slot_data_var = fill_node.resolve_slot_data(context, resolved_component_name)
fill_content[resolved_name] = FillContent( fill_content[resolved_name] = FillContent(
nodes=fill_node.nodelist, content_func=nodelist_to_render_func(fill_node.nodelist),
slot_default_var=resolved_slot_default_var, slot_default_var=resolved_slot_default_var,
slot_data_var=resolved_slot_data_var, slot_data_var=resolved_slot_data_var,
) )
@ -510,7 +591,11 @@ class ComponentNode(Node):
if self.isolated_context: if self.isolated_context:
context = make_isolated_context_copy(context) context = make_isolated_context_copy(context)
output = component.render_from_input(context, resolved_context_args, resolved_context_kwargs) output = component._render(
context=context,
args=resolved_context_args,
kwargs=resolved_context_kwargs,
)
trace_msg("RENDR", "COMP", self.name_fexp, self.component_id, "...Done!") trace_msg("RENDR", "COMP", self.name_fexp, self.component_id, "...Done!")
return output return output

View file

@ -20,7 +20,7 @@ class ComponentRegistry:
def register(self, name: str, component: Type["component.Component"]) -> None: def register(self, name: str, component: Type["component.Component"]) -> None:
existing_component = self._registry.get(name) existing_component = self._registry.get(name)
if existing_component and existing_component.class_hash != component.class_hash: if existing_component and existing_component._class_hash != component._class_hash:
raise AlreadyRegistered('The component "%s" has already been registered' % name) raise AlreadyRegistered('The component "%s" has already been registered' % name)
self._registry[name] = component self._registry[name] = component

View file

@ -1,9 +1,19 @@
from typing import Callable, List, NamedTuple, Optional from typing import Callable, List, NamedTuple, Optional, Union
from django.template import Context, Template from django.template import Context, Template
from django.template.base import Node, NodeList, TextNode from django.template.base import Node, NodeList, TextNode
from django.template.defaulttags import CommentNode from django.template.defaulttags import CommentNode
from django.template.loader_tags import ExtendsNode, IncludeNode, construct_relative_path from django.template.loader_tags import ExtendsNode, IncludeNode, construct_relative_path
from django.utils.safestring import SafeText
RenderedContent = Union[str, SafeText]
RenderFunc = Callable[[Context], RenderedContent]
def nodelist_to_render_func(nodelist: NodeList) -> RenderFunc:
def render_func(ctx: Context) -> RenderedContent:
return nodelist.render(ctx)
return render_func
def nodelist_has_content(nodelist: NodeList) -> bool: def nodelist_has_content(nodelist: NodeList) -> bool:

View file

@ -2,7 +2,7 @@ import difflib
import json import json
import re import re
from collections import deque from collections import deque
from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple, Type from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple, Type, Union
from django.template import Context, Template from django.template import Context, Template
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode
@ -14,7 +14,13 @@ from django_components.app_settings import ContextBehavior, app_settings
from django_components.context import _FILLED_SLOTS_CONTENT_CONTEXT_KEY, _ROOT_CTX_CONTEXT_KEY from django_components.context import _FILLED_SLOTS_CONTENT_CONTEXT_KEY, _ROOT_CTX_CONTEXT_KEY
from django_components.expression import resolve_expression_as_identifier, safe_resolve_dict from django_components.expression import resolve_expression_as_identifier, safe_resolve_dict
from django_components.logger import trace_msg from django_components.logger import trace_msg
from django_components.node import NodeTraverse, nodelist_has_content, walk_nodelist from django_components.node import (
NodeTraverse,
RenderFunc,
nodelist_has_content,
nodelist_to_render_func,
walk_nodelist,
)
from django_components.template_parser import process_aggregate_kwargs from django_components.template_parser import process_aggregate_kwargs
from django_components.utils import gen_id from django_components.utils import gen_id
@ -26,6 +32,7 @@ SlotId = str
SlotName = str SlotName = str
SlotDefaultName = str SlotDefaultName = str
SlotDataName = str SlotDataName = str
SlotContent = Union[str, SafeString, RenderFunc]
class FillContent(NamedTuple): class FillContent(NamedTuple):
@ -43,7 +50,7 @@ class FillContent(NamedTuple):
``` ```
""" """
nodes: NodeList content_func: RenderFunc
slot_default_var: Optional[SlotDefaultName] slot_default_var: Optional[SlotDefaultName]
slot_data_var: Optional[SlotDataName] slot_data_var: Optional[SlotDataName]
@ -78,7 +85,7 @@ class SlotFill(NamedTuple):
name: str name: str
escaped_name: str escaped_name: str
is_filled: bool is_filled: bool
nodelist: NodeList content_func: RenderFunc
context_data: Dict context_data: Dict
slot_default_var: Optional[SlotDefaultName] slot_default_var: Optional[SlotDefaultName]
slot_data_var: Optional[SlotDataName] slot_data_var: Optional[SlotDataName]
@ -134,7 +141,7 @@ class SlotNode(Node):
def render(self, context: Context) -> SafeString: def render(self, context: Context) -> SafeString:
trace_msg("RENDR", "SLOT", self.name, self.node_id) trace_msg("RENDR", "SLOT", self.name, self.node_id)
slots: dict[SlotId, "SlotFill"] = context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] slots: Dict[SlotId, "SlotFill"] = context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY]
# NOTE: Slot entry MUST be present. If it's missing, there was an issue upstream. # NOTE: Slot entry MUST be present. If it's missing, there was an issue upstream.
slot_fill = slots[self.node_id] slot_fill = slots[self.node_id]
@ -167,7 +174,8 @@ class SlotNode(Node):
# came from (or current context if configured so) # came from (or current context if configured so)
used_ctx = self._resolve_slot_context(context, slot_fill) used_ctx = self._resolve_slot_context(context, slot_fill)
with used_ctx.update(extra_context): with used_ctx.update(extra_context):
output = slot_fill.nodelist.render(used_ctx) # Render slot as a function
output = slot_fill.content_func(used_ctx)
trace_msg("RENDR", "SLOT", self.name, self.node_id, msg="...Done!") trace_msg("RENDR", "SLOT", self.name, self.node_id, msg="...Done!")
return output return output
@ -368,7 +376,7 @@ def resolve_slots(
name=name, name=name,
escaped_name=_escape_slot_name(name), escaped_name=_escape_slot_name(name),
is_filled=True, is_filled=True,
nodelist=fill.nodes, content_func=fill.content_func,
context_data=context_data, context_data=context_data,
slot_default_var=fill.slot_default_var, slot_default_var=fill.slot_default_var,
slot_data_var=fill.slot_data_var, slot_data_var=fill.slot_data_var,
@ -457,7 +465,7 @@ def resolve_slots(
name=slot.name, name=slot.name,
escaped_name=_escape_slot_name(slot.name), escaped_name=_escape_slot_name(slot.name),
is_filled=False, is_filled=False,
nodelist=slot.nodelist, content_func=nodelist_to_render_func(slot.nodelist),
context_data=context_data, context_data=context_data,
slot_default_var=None, slot_default_var=None,
slot_data_var=None, slot_data_var=None,
@ -505,7 +513,7 @@ def _resolve_default_slot(
# `NamedTuple._replace`, because `_replace` is not typed. # `NamedTuple._replace`, because `_replace` is not typed.
named_fills[slot.name] = SlotFill( named_fills[slot.name] = SlotFill(
is_filled=default_fill.is_filled, is_filled=default_fill.is_filled,
nodelist=default_fill.nodelist, content_func=default_fill.content_func,
context_data=default_fill.context_data, context_data=default_fill.context_data,
slot_default_var=default_fill.slot_default_var, slot_default_var=default_fill.slot_default_var,
slot_data_var=default_fill.slot_data_var, slot_data_var=default_fill.slot_data_var,