import inspect
import os
import sys
import types
from pathlib import Path
from typing import Any, ClassVar, Dict, 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 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.template.loader_tags import BLOCK_CONTEXT_KEY
from django.utils.html import escape
from django.utils.safestring import SafeString, mark_safe
from django.views import View
# Global registry var and register() function moved to separate module.
# Defining them here made little sense, since 1) component_tags.py and component.py
# rely on them equally, and 2) it made it difficult to avoid circularity in the
# way the two modules depend on one another.
from django_components.component_registry import 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 # NOQA
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 safe_resolve_dict, safe_resolve_list
from django_components.logger import logger, trace_msg
from django_components.middleware import is_dependency_middleware_active
from django_components.slots import (
DEFAULT_SLOT_KEY,
FillContent,
FillNode,
SlotContent,
SlotName,
SlotRef,
SlotRenderedContent,
_nodelist_to_slot_render_func,
resolve_slots,
)
from django_components.template_parser import process_aggregate_kwargs
from django_components.utils import gen_id, search
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"]
# Allow: class Media: css = "style.css"
if hasattr(media, "css") and isinstance(media.css, str):
media.css = [media.css]
# Allow: class Media: css = ["style.css"]
if hasattr(media, "css") and isinstance(media.css, list):
media.css = {"all": media.css}
# Allow: class Media: css = {"all": "style.css"}
if hasattr(media, "css") and isinstance(media.css, dict):
for media_type, path_list in media.css.items():
if isinstance(path_list, str):
media.css[media_type] = [path_list] # type: ignore
# Allow: class Media: js = "script.js"
if hasattr(media, "js") and isinstance(media.js, str):
media.js = [media.js]
_resolve_component_relative_files(attrs)
return super().__new__(mcs, name, bases, attrs)
def _resolve_component_relative_files(attrs: MutableMapping) -> None:
"""
Check if component's HTML, JS and CSS files refer to files in the same directory
as the component class. If so, modify the attributes so the class Django's rendering
will pick up these files correctly.
"""
component_name = attrs["__qualname__"]
# Derive the full path of the file where the component was defined
module_name = attrs["__module__"]
module_obj = sys.modules[module_name]
file_path = module_obj.__file__
if not file_path:
logger.debug(
f"Could not resolve the path to the file for component '{component_name}'."
" Paths for HTML, JS or CSS templates will NOT be resolved relative to the component file."
)
return
# Prepare all possible directories we need to check when searching for
# component's template and media files
components_dirs = search().searched_dirs
# Get the directory where the component class is defined
try:
comp_dir_abs, comp_dir_rel = _get_dir_path_from_component_path(file_path, components_dirs)
except RuntimeError:
# If no dir was found, we assume that the path is NOT relative to the component dir
logger.debug(
f"No component directory found for component '{component_name}' in {file_path}"
" If this component defines HTML, JS or CSS templates relatively to the component file,"
" then check that the component's directory is accessible from one of the paths"
" specified in the Django's 'STATICFILES_DIRS' settings."
)
return
# Check if filepath refers to a file that's in the same directory as the component class.
# If yes, modify the path to refer to the relative file.
# If not, don't modify anything.
def resolve_file(filepath: str) -> str:
maybe_resolved_filepath = os.path.join(comp_dir_abs, filepath)
component_import_filepath = os.path.join(comp_dir_rel, filepath)
if os.path.isfile(maybe_resolved_filepath):
logger.debug(
f"Interpreting template '{filepath}' of component '{module_name}' relatively to component file"
)
return component_import_filepath
logger.debug(
f"Interpreting template '{filepath}' of component '{module_name}' relatively to components directory"
)
return filepath
# Check if template name is a local file or not
if "template_name" in attrs and attrs["template_name"]:
attrs["template_name"] = resolve_file(attrs["template_name"])
if "Media" in attrs:
media = attrs["Media"]
# Now check the same for CSS files
if hasattr(media, "css") and isinstance(media.css, dict):
for media_type, path_list in media.css.items():
media.css[media_type] = [resolve_file(filepath) for filepath in path_list]
# And JS
if hasattr(media, "js") and isinstance(media.js, list):
media.js = [resolve_file(filepath) for filepath in media.js]
def _get_dir_path_from_component_path(
abs_component_file_path: str,
candidate_dirs: Union[List[str], List[Path]],
) -> Tuple[str, str]:
comp_dir_path_abs = os.path.dirname(abs_component_file_path)
# From all dirs defined in settings.STATICFILES_DIRS, find one that's the parent
# to the component file.
root_dir_abs = None
for candidate_dir in candidate_dirs:
candidate_dir_abs = os.path.abspath(candidate_dir)
if comp_dir_path_abs.startswith(candidate_dir_abs):
root_dir_abs = candidate_dir_abs
break
if root_dir_abs is None:
raise RuntimeError(
f"Failed to resolve template directory for component file '{abs_component_file_path}'",
)
# Derive the path from matched STATICFILES_DIRS to the dir where the current component file is.
comp_dir_path_rel = os.path.relpath(comp_dir_path_abs, candidate_dir_abs)
# Return both absolute and relative paths:
# - Absolute path is used to check if the file exists
# - Relative path is used for defining the import on the component class
return comp_dir_path_abs, comp_dir_path_rel
class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
# 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.
"""
response_class = HttpResponse
"""This allows to configure what class is used to generate response from `render_to_response`"""
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
js: Optional[Union[str, List[str]]] = None
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,
):
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._context: Optional[Context] = None
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__
def get_context_data(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
return {}
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
@component.register("my_comp")
class MyComp(component.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._context 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._context, key, default)
@classmethod
def render_to_response(
cls,
context: Union[Dict[str, Any], Context] = None,
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:
"""
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[Union[List, Tuple]] = None,
kwargs: Optional[Dict[str, Any]] = None,
slots: Optional[Mapping[SlotName, SlotContent]] = 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,
)
```
"""
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
# within `get_context_data()`
self._context = context
context_data = self.get_context_data(*args, **kwargs)
self._context = None
with context.update(context_data):
template = self.get_template(context)
_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
# the `{% extends %}` tag.
# Part of fix for https://github.com/EmilStenstrom/django-components/issues/508
template._is_component_nested = bool(context.render_context.get(BLOCK_CONTEXT_KEY))
# Support passing slots explicitly to `render` method
if 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()
slots, 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
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(escape(content) if escape_content else content)])
)
else:
def content_func(ctx: Context, kwargs: Dict[str, Any], slot_ref: SlotRef) -> SlotRenderedContent:
rendered = content(ctx, kwargs, slot_ref)
return 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(Node):
"""Django.template.Node subclass that renders a django-components component"""
def __init__(
self,
name_fexp: FilterExpression,
context_args: List[FilterExpression],
context_kwargs: Mapping[str, FilterExpression],
isolated_context: bool = False,
fill_nodes: Optional[List[FillNode]] = None,
component_id: Optional[str] = None,
) -> None:
self.component_id = component_id or gen_id()
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 or []
self.nodelist = 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:
trace_msg("RENDR", "COMP", self.name_fexp, self.component_id)
resolved_component_name = self.name_fexp.resolve(context)
component_cls: Type[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_list(self.context_args, context)
resolved_context_kwargs = safe_resolve_dict(self.context_kwargs, context)
resolved_context_kwargs = process_aggregate_kwargs(resolved_context_kwargs)
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 = {}
for fill_node in self.fill_nodes:
# Note that outer component context is used to resolve variables in
# fill tag.
resolved_name = fill_node.name_fexp.resolve(context)
if resolved_name in fill_content:
raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name: "
f"Detected duplicate fill tag name '{resolved_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)
fill_content[resolved_name] = FillContent(
content_func=_nodelist_to_slot_render_func(fill_node.nodelist),
slot_default_var=resolved_slot_default_var,
slot_data_var=resolved_slot_data_var,
)
component: Component = component_cls(
registered_name=resolved_component_name,
outer_context=context,
fill_content=fill_content,
component_id=self.component_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=resolved_context_args,
kwargs=resolved_context_kwargs,
)
trace_msg("RENDR", "COMP", self.name_fexp, self.component_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._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._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.
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, "_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._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)