import inspect
import types
from collections import deque
from dataclasses import dataclass
from typing import (
Any,
Callable,
ClassVar,
Deque,
Dict,
Generic,
List,
Mapping,
Optional,
Protocol,
Tuple,
Type,
TypeVar,
Union,
cast,
)
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media
from django.http import HttpRequest, HttpResponse
from django.template.base import NodeList, Template, TextNode
from django.template.context import Context
from django.template.loader import get_template
from django.template.loader_tags import BLOCK_CONTEXT_KEY
from django.utils.html import conditional_escape
from django.utils.safestring import SafeString, mark_safe
from django.views import View
from django_components.component_media import ComponentMediaInput, MediaMeta
from django_components.component_registry import registry
from django_components.context import (
_FILLED_SLOTS_CONTENT_CONTEXT_KEY,
_PARENT_COMP_CONTEXT_KEY,
_ROOT_CTX_CONTEXT_KEY,
get_injected_context_var,
make_isolated_context_copy,
prepare_context,
)
from django_components.expression import Expression, RuntimeKwargs, safe_resolve_list
from django_components.logger import trace_msg
from django_components.middleware import is_dependency_middleware_active
from django_components.node import BaseNode
from django_components.slots import (
DEFAULT_SLOT_KEY,
FillContent,
FillNode,
SlotContent,
SlotName,
SlotRef,
SlotResult,
_nodelist_to_slot_render_func,
resolve_fill_nodes,
resolve_slots,
)
from django_components.utils import gen_id
# TODO_DEPRECATE_V1 - REMOVE IN V1, users should use top-level import instead
# isort: off
from django_components.component_registry import AlreadyRegistered as AlreadyRegistered # NOQA
from django_components.component_registry import ComponentRegistry as ComponentRegistry # NOQA
from django_components.component_registry import NotRegistered as NotRegistered # NOQA
from django_components.component_registry import register as register # NOQA
from django_components.component_registry import registry as registry # NOQA
# isort: on
RENDERED_COMMENT_TEMPLATE = ""
# Define TypeVars for args and kwargs
ArgsType = TypeVar("ArgsType", bound=tuple, contravariant=True)
KwargsType = TypeVar("KwargsType", bound=Mapping[str, Any], contravariant=True)
DataType = TypeVar("DataType", bound=Mapping[str, Any], covariant=True)
SlotsType = TypeVar("SlotsType", bound=Mapping[SlotName, SlotContent])
@dataclass(frozen=True)
class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
context: Context
args: ArgsType
kwargs: KwargsType
slots: SlotsType
escape_slots_content: bool
class ViewFn(Protocol):
def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704
class ComponentMeta(MediaMeta):
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)
return super().__new__(mcs, name, bases, attrs)
# NOTE: We use metaclass to automatically define the HTTP methods as defined
# in `View.http_method_names`.
class ComponentViewMeta(type):
def __new__(cls, name: str, bases: Any, dct: Dict) -> Any:
# Default implementation shared by all HTTP methods
def create_handler(method: str) -> Callable:
def handler(self, request: HttpRequest, *args: Any, **kwargs: Any): # type: ignore[no-untyped-def]
component: "Component" = self.component
return getattr(component, method)(request, *args, **kwargs)
return handler
# Add methods to the class
for method_name in View.http_method_names:
if method_name not in dct:
dct[method_name] = create_handler(method_name)
return super().__new__(cls, name, bases, dct)
class ComponentView(View, metaclass=ComponentViewMeta):
"""
Subclass of `django.views.View` where the `Component` instance is available
via `self.component`.
"""
# NOTE: This attribute must be declared on the class for `View.as_view` to allow
# us to pass `component` kwarg.
component = cast("Component", None)
def __init__(self, component: "Component", **kwargs: Any) -> None:
super().__init__(**kwargs)
self.component = component
class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=ComponentMeta):
# Either template_name or template must be set on subclass OR subclass must implement get_template() with
# non-null return.
_class_hash: ClassVar[int]
template_name: ClassVar[Optional[str]] = None
"""Relative filepath to the Django template associated with this component."""
template: Optional[str] = None
"""Inlined Django template associated with this component."""
js: Optional[str] = None
"""Inlined JS associated with this component."""
css: Optional[str] = None
"""Inlined CSS associated with this component."""
media: Media
"""
Normalized definition of JS and CSS media files associated with this component.
NOTE: This field is generated from Component.Media class.
"""
media_class: Media = Media
response_class = HttpResponse
"""This allows to configure what class is used to generate response from `render_to_response`"""
Media = ComponentMediaInput
"""Defines JS and CSS media files associated with this component."""
View = ComponentView
def __init__(
self,
registered_name: Optional[str] = None,
component_id: Optional[str] = None,
outer_context: Optional[Context] = None,
fill_content: Optional[Dict[str, FillContent]] = None,
):
# When user first instantiates the component class before calling
# `render` or `render_to_response`, then we want to allow the render
# function to make use of the instantiated object.
#
# So while `MyComp.render()` creates a new instance of MyComp internally,
# if we do `MyComp(registered_name="abc").render()`, then we use the
# already-instantiated object.
#
# To achieve that, we want to re-assign the class methods as instance methods.
# For that we have to "unwrap" the class methods via __func__.
# See https://stackoverflow.com/a/76706399/9788634
self.render_to_response = types.MethodType(self.__class__.render_to_response.__func__, self) # type: ignore
self.render = types.MethodType(self.__class__.render.__func__, self) # type: ignore
self.registered_name: Optional[str] = registered_name
self.outer_context: Context = outer_context or Context()
self.fill_content = fill_content or {}
self.component_id = component_id or gen_id()
self._render_stack: Deque[RenderInput[ArgsType, KwargsType, SlotsType]] = deque()
def __init_subclass__(cls, **kwargs: Any) -> None:
cls._class_hash = hash(inspect.getfile(cls) + cls.__name__)
@property
def name(self) -> str:
return self.registered_name or self.__class__.__name__
@property
def input(self) -> Optional[RenderInput[ArgsType, KwargsType, SlotsType]]:
"""
Input holds the data (like arg, kwargs, slots) that were passsed to
the current execution of the `render` method.
"""
# NOTE: Input is managed as a stack, so if `render` is called within another `render`,
# the propertes below will return only the inner-most state.
return self._render_stack[-1] if len(self._render_stack) else None
def get_context_data(self, *args: Any, **kwargs: Any) -> DataType:
return cast(DataType, {})
def get_template_name(self, context: Context) -> Optional[str]:
return self.template_name
def get_template_string(self, context: Context) -> Optional[str]:
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:
"""Helper function to render all dependencies for a component."""
dependencies = []
css_deps = self.render_css_dependencies()
if css_deps:
dependencies.append(css_deps)
js_deps = self.render_js_dependencies()
if js_deps:
dependencies.append(js_deps)
return mark_safe("\n".join(dependencies))
def render_css_dependencies(self) -> SafeString:
"""Render only CSS dependencies available in the media class or provided as a string."""
if self.css is not None:
return mark_safe(f"")
return mark_safe("\n".join(self.media.render_css()))
def render_js_dependencies(self) -> SafeString:
"""Render only JS dependencies available in the media class or provided as a string."""
if self.js is not None:
return mark_safe(f"")
return mark_safe("\n".join(self.media.render_js()))
def inject(self, key: str, default: Optional[Any] = None) -> Any:
"""
Use this method to retrieve the data that was passed to a `{% provide %}` tag
with the corresponding key.
To retrieve the data, `inject()` must be called inside a component that's
inside the `{% provide %}` tag.
You may also pass a default that will be used if the `provide` tag with given
key was NOT found.
This method mut be used inside the `get_context_data()` method and raises
an error if called elsewhere.
Example:
Given this template:
```django
{% provide "provider" hello="world" %}
{% component "my_comp" %}
{% endcomponent %}
{% endprovide %}
```
And given this definition of "my_comp" component:
```py
from django_components import Component, register
@register("my_comp")
class MyComp(Component):
template = "hi {{ data.hello }}!"
def get_context_data(self):
data = self.inject("provider")
return {"data": data}
```
This renders into:
```
hi world!
```
As the `{{ data.hello }}` is taken from the "provider".
"""
if self.input is None:
raise RuntimeError(
f"Method 'inject()' of component '{self.name}' was called outside of 'get_context_data()'"
)
return get_injected_context_var(self.name, self.input.context, key, default)
@classmethod
def as_view(cls, **initkwargs: Any) -> ViewFn:
"""
Shortcut for calling `Component.View.as_view` and passing component instance to it.
"""
# Allow the View class to access this component via `self.component`
component = cls()
return component.View.as_view(**initkwargs, component=component)
@classmethod
def render_to_response(
cls,
context: Optional[Union[Dict[str, Any], Context]] = None,
slots: Optional[SlotsType] = None,
escape_slots_content: bool = True,
args: Optional[ArgsType] = None,
kwargs: Optional[KwargsType] = None,
*response_args: Any,
**response_kwargs: Any,
) -> HttpResponse:
"""
Render the component and wrap the content in the response class.
The response class is taken from `Component.response_class`. Defaults to `django.http.HttpResponse`.
This is the interface for the `django.views.View` class which allows us to
use components as Django views with `component.as_view()`.
Inputs:
- `args` - Positional args for the component. This is the same as calling the component
as `{% component "my_comp" arg1 arg2 ... %}`
- `kwargs` - Kwargs for the component. This is the same as calling the component
as `{% component "my_comp" key1=val1 key2=val2 ... %}`
- `slots` - Component slot fills. This is the same as pasing `{% fill %}` tags to the component.
Accepts a dictionary of `{ slot_name: slot_content }` where `slot_content` can be a string
or render function.
- `escape_slots_content` - Whether the content from `slots` should be escaped.
- `context` - A context (dictionary or Django's Context) within which the component
is rendered. The keys on the context can be accessed from within the template.
- NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via
component's args and kwargs.
Any additional args and kwargs are passed to the `response_class`.
Example:
```py
MyComponent.render_to_response(
args=[1, "two", {}],
kwargs={
"key": 123,
},
slots={
"header": 'STATIC TEXT HERE',
"footer": lambda ctx, slot_kwargs, slot_ref: f'CTX: {ctx['hello']} SLOT_DATA: {slot_kwargs['abc']}',
},
escape_slots_content=False,
# HttpResponse input
status=201,
headers={...},
)
# HttpResponse(content=..., status=201, headers=...)
```
"""
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[ArgsType] = None,
kwargs: Optional[KwargsType] = None,
slots: Optional[SlotsType] = None,
escape_slots_content: bool = True,
) -> str:
"""
Render the component into a string.
Inputs:
- `args` - Positional args for the component. This is the same as calling the component
as `{% component "my_comp" arg1 arg2 ... %}`
- `kwargs` - Kwargs for the component. This is the same as calling the component
as `{% component "my_comp" key1=val1 key2=val2 ... %}`
- `slots` - Component slot fills. This is the same as pasing `{% fill %}` tags to the component.
Accepts a dictionary of `{ slot_name: slot_content }` where `slot_content` can be a string
or render function.
- `escape_slots_content` - Whether the content from `slots` should be escaped.
- `context` - A context (dictionary or Django's Context) within which the component
is rendered. The keys on the context can be accessed from within the template.
- NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via
component's args and kwargs.
Example:
```py
MyComponent.render(
args=[1, "two", {}],
kwargs={
"key": 123,
},
slots={
"header": 'STATIC TEXT HERE',
"footer": lambda ctx, slot_kwargs, slot_ref: f'CTX: {ctx['hello']} SLOT_DATA: {slot_kwargs['abc']}',
},
escape_slots_content=False,
)
```
"""
# This method may be called as class method or as instance method.
# If called as class method, create a new instance.
if isinstance(cls, Component):
comp: Component = cls
else:
comp = cls()
return comp._render(context, args, kwargs, slots, escape_slots_content)
# This is the internal entrypoint for the render function
def _render(
self,
context: Optional[Union[Dict[str, Any], Context]] = None,
args: Optional[ArgsType] = None,
kwargs: Optional[KwargsType] = None,
slots: Optional[SlotsType] = None,
escape_slots_content: bool = True,
) -> str:
try:
return self._render_impl(context, args, kwargs, slots, escape_slots_content)
except Exception as err:
raise type(err)(f"An error occured while rendering component '{self.name}':\n{repr(err)}") from err
def _render_impl(
self,
context: Optional[Union[Dict[str, Any], Context]] = None,
args: Optional[ArgsType] = None,
kwargs: Optional[KwargsType] = None,
slots: Optional[SlotsType] = None,
escape_slots_content: bool = True,
) -> str:
has_slots = slots is not None
# Allow to provide no args/kwargs/slots/context
args = cast(ArgsType, args or ())
kwargs = cast(KwargsType, kwargs or {})
slots = cast(SlotsType, slots or {})
context = context or Context()
# 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)
# By adding the current input to the stack, we temporarily allow users
# to access the provided context, slots, etc. Also required so users can
# call `self.inject()` from within `get_context_data()`.
self._render_stack.append(
RenderInput(
context=context,
slots=slots,
args=args,
kwargs=kwargs,
escape_slots_content=escape_slots_content,
)
)
context_data = self.get_context_data(*args, **kwargs)
with context.update(context_data):
template = self.get_template(context)
_monkeypatch_template(template)
if context.template is None:
# Associate the newly-created Context with a Template, otherwise we get
# an error when we try to use `{% include %}` tag inside the template?
# See https://github.com/EmilStenstrom/django-components/issues/580
context.template = template
context.template_name = template.name
# Set `Template._dc_is_component_nested` based on whether we're currently INSIDE
# the `{% extends %}` tag.
# Part of fix for https://github.com/EmilStenstrom/django-components/issues/508
template._dc_is_component_nested = bool(context.render_context.get(BLOCK_CONTEXT_KEY))
# Support passing slots explicitly to `render` method
if has_slots:
fill_content = self._fills_from_slots_data(
slots,
escape_slots_content,
)
else:
fill_content = self.fill_content
# 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]:
slot_context_data = self.outer_context.flatten()
_, resolved_fills = resolve_slots(
context,
template,
component_name=self.name,
context_data=slot_context_data,
fill_content=fill_content,
)
# Available slot fills - this is internal to us
updated_slots = {
**context.get(_FILLED_SLOTS_CONTENT_CONTEXT_KEY, {}),
**resolved_fills,
}
# For users, we expose boolean variables that they may check
# to see if given slot was filled, e.g.:
# `{% if variable > 8 and component_vars.is_filled.header %}`
slot_bools = {slot_fill.escaped_name: slot_fill.is_filled for slot_fill in resolved_fills.values()}
with context.update(
{
_ROOT_CTX_CONTEXT_KEY: self.outer_context,
_FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_slots,
# NOTE: Public API for variables accessible from within a component's template
# See https://github.com/EmilStenstrom/django-components/issues/280#issuecomment-2081180940
"component_vars": {
"is_filled": slot_bools,
},
}
):
rendered_component = template.render(context)
if is_dependency_middleware_active():
output = RENDERED_COMMENT_TEMPLATE.format(name=self.name) + rendered_component
else:
output = rendered_component
# After rendering is done, remove the current state from the stack, which means
# properties like `self.context` will no longer return the current state.
self._render_stack.pop()
return output
def _fills_from_slots_data(
self,
slots_data: Mapping[SlotName, SlotContent],
escape_content: bool = True,
) -> Dict[SlotName, FillContent]:
"""Fill component slots outside of template rendering."""
slot_fills = {}
for slot_name, content in slots_data.items():
if isinstance(content, (str, SafeString)):
content_func = _nodelist_to_slot_render_func(
NodeList([TextNode(conditional_escape(content) if escape_content else content)])
)
else:
def content_func( # type: ignore[misc]
ctx: Context,
kwargs: Dict[str, Any],
slot_ref: SlotRef,
) -> SlotResult:
rendered = content(ctx, kwargs, slot_ref)
return conditional_escape(rendered) if escape_content else rendered
slot_fills[slot_name] = FillContent(
content_func=content_func,
slot_default_var=None,
slot_data_var=None,
)
return slot_fills
class ComponentNode(BaseNode):
"""Django.template.Node subclass that renders a django-components component"""
def __init__(
self,
name: str,
args: List[Expression],
kwargs: RuntimeKwargs,
isolated_context: bool = False,
fill_nodes: Optional[List[FillNode]] = None,
node_id: Optional[str] = None,
) -> None:
super().__init__(nodelist=NodeList(fill_nodes), args=args, kwargs=kwargs, node_id=node_id)
self.name = name
self.isolated_context = isolated_context
self.fill_nodes = fill_nodes or []
def __repr__(self) -> str:
return "".format(
self.name,
getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later.
)
def render(self, context: Context) -> str:
trace_msg("RENDR", "COMP", self.name, self.node_id)
component_cls: Type[Component] = registry.get(self.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
args = safe_resolve_list(context, self.args)
kwargs = self.kwargs.resolve(context)
is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit
if is_default_slot:
fill_content: Dict[str, FillContent] = {
DEFAULT_SLOT_KEY: FillContent(
content_func=_nodelist_to_slot_render_func(self.fill_nodes[0].nodelist),
slot_data_var=None,
slot_default_var=None,
),
}
else:
fill_content = resolve_fill_nodes(context, self.fill_nodes, self.name)
component: Component = component_cls(
registered_name=self.name,
outer_context=context,
fill_content=fill_content,
component_id=self.node_id,
)
# Prevent outer context from leaking into the template of the component
if self.isolated_context:
context = make_isolated_context_copy(context)
output = component._render(
context=context,
args=args,
kwargs=kwargs,
)
trace_msg("RENDR", "COMP", self.name, self.node_id, "...Done!")
return output
def _monkeypatch_template(template: Template) -> None:
# Modify `Template.render` to set `isolated_context` kwarg of `push_state`
# based on our custom `Template._dc_is_component_nested`.
#
# Part of fix for https://github.com/EmilStenstrom/django-components/issues/508
#
# NOTE 1: While we could've subclassed Template, then we would need to either
# 1) ask the user to change the backend, so all templates are of our subclass, or
# 2) copy the data from user's Template class instance to our subclass instance,
# which could lead to doubly parsing the source, and could be problematic if users
# used more exotic subclasses of Template.
#
# Instead, modifying only the `render` method of an already-existing instance
# should work well with any user-provided custom subclasses of Template, and it
# doesn't require the source to be parsed multiple times. User can pass extra args/kwargs,
# and can modify the rendering behavior by overriding the `_render` method.
#
# NOTE 2: Instead of setting `Template._dc_is_component_nested`, alternatively we could
# have passed the value to `_monkeypatch_template` directly. However, we intentionally
# did NOT do that, so the monkey-patched method is more robust, and can be e.g. copied
# to other.
if hasattr(template, "_dc_patched"):
# Do not patch if done so already. This helps us avoid RecursionError
return
def _template_render(self: Template, context: Context, *args: Any, **kwargs: Any) -> str:
# ---------------- OUR CHANGES START ----------------
# We parametrized `isolated_context`, which was `True` in the original method.
if not hasattr(self, "_dc_is_component_nested"):
isolated_context = True
else:
# MUST be `True` for templates that are NOT import with `{% extends %}` tag,
# and `False` otherwise.
isolated_context = not self._dc_is_component_nested
# ---------------- OUR CHANGES END ----------------
with context.render_context.push_state(self, isolated_context=isolated_context):
if context.template is None:
with context.bind_template(self):
context.template_name = self.name
return self._render(context, *args, **kwargs)
else:
return self._render(context, *args, **kwargs)
# See https://stackoverflow.com/a/42154067/9788634
template.render = types.MethodType(_template_render, template)