mirror of
https://github.com/django-components/django-components.git
synced 2025-08-18 13:10:13 +00:00
refactor: Fix template caching, expose cached_template
, Component.template API changes (#647)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
589e802625
commit
841dd77e91
14 changed files with 347 additions and 56 deletions
37
README.md
37
README.md
|
@ -71,6 +71,13 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
|
||||||
|
|
||||||
## Release notes
|
## Release notes
|
||||||
|
|
||||||
|
**Version 0.97**
|
||||||
|
- Fixed template caching. You can now also manually create cached templates with [`cached_template()`](#template_cache_size---tune-the-template-cache)
|
||||||
|
- The previously undocumented `get_template` was made private.
|
||||||
|
- In it's place, there's a new `get_template`, which supersedes `get_template_string` (will be removed in v1). The new `get_template` is the same as `get_template_string`, except
|
||||||
|
it allows to return either a string or a Template instance.
|
||||||
|
- You now must use only one of `template`, `get_template`, `template_name`, or `get_template_name`.
|
||||||
|
|
||||||
**Version 0.96**
|
**Version 0.96**
|
||||||
- Run-time type validation for Python 3.11+ - If the `Component` class is typed, e.g. `Component[Args, Kwargs, ...]`, the args, kwargs, slots, and data are validated against the given types. (See [Runtime input validation with types](#runtime-input-validation-with-types))
|
- Run-time type validation for Python 3.11+ - If the `Component` class is typed, e.g. `Component[Args, Kwargs, ...]`, the args, kwargs, slots, and data are validated against the given types. (See [Runtime input validation with types](#runtime-input-validation-with-types))
|
||||||
- Render hooks - Set `on_render_before` and `on_render_after` methods on `Component` to intercept or modify the template or context before rendering, or the rendered result afterwards. (See [Component hooks](#component-hooks))
|
- Render hooks - Set `on_render_before` and `on_render_after` methods on `Component` to intercept or modify the template or context before rendering, or the rendered result afterwards. (See [Component hooks](#component-hooks))
|
||||||
|
@ -93,7 +100,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
|
||||||
🚨📢 **Version 0.92**
|
🚨📢 **Version 0.92**
|
||||||
- BREAKING CHANGE: `Component` class is no longer a subclass of `View`. To configure the `View` class, set the `Component.View` nested class. HTTP methods like `get` or `post` can still be defined directly on `Component` class, and `Component.as_view()` internally calls `Component.View.as_view()`. (See [Modifying the View class](#modifying-the-view-class))
|
- BREAKING CHANGE: `Component` class is no longer a subclass of `View`. To configure the `View` class, set the `Component.View` nested class. HTTP methods like `get` or `post` can still be defined directly on `Component` class, and `Component.as_view()` internally calls `Component.View.as_view()`. (See [Modifying the View class](#modifying-the-view-class))
|
||||||
|
|
||||||
- The inputs (args, kwargs, slots, context, ...) that you pass to `Component.render()` can be accessed from within `get_context_data`, `get_template_string` and `get_template_name` via `self.input`. (See [Accessing data passed to the component](#accessing-data-passed-to-the-component))
|
- The inputs (args, kwargs, slots, context, ...) that you pass to `Component.render()` can be accessed from within `get_context_data`, `get_template` and `get_template_name` via `self.input`. (See [Accessing data passed to the component](#accessing-data-passed-to-the-component))
|
||||||
|
|
||||||
- Typing: `Component` class supports generics that specify types for `Component.render` (See [Adding type hints with Generics](#adding-type-hints-with-generics))
|
- Typing: `Component` class supports generics that specify types for `Component.render` (See [Adding type hints with Generics](#adding-type-hints-with-generics))
|
||||||
|
|
||||||
|
@ -383,11 +390,13 @@ from django_components import Component, register
|
||||||
@register("calendar")
|
@register("calendar")
|
||||||
class Calendar(Component):
|
class Calendar(Component):
|
||||||
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
|
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
|
||||||
# will be automatically found. To customize which template to use based on context
|
# will be automatically found.
|
||||||
# you can override method `get_template_name` instead of specifying `template_name`.
|
|
||||||
#
|
#
|
||||||
# `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
|
# `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
|
||||||
template_name = "template.html"
|
template_name = "template.html"
|
||||||
|
# Or
|
||||||
|
def get_template_name(context):
|
||||||
|
return f"template-{context['name']}.html"
|
||||||
|
|
||||||
# This component takes one parameter, a date string to show in the template
|
# This component takes one parameter, a date string to show in the template
|
||||||
def get_context_data(self, date):
|
def get_context_data(self, date):
|
||||||
|
@ -1742,7 +1751,7 @@ When you call `Component.render` or `Component.render_to_response`, the inputs t
|
||||||
This means that you can use `self.input` inside:
|
This means that you can use `self.input` inside:
|
||||||
- `get_context_data`
|
- `get_context_data`
|
||||||
- `get_template_name`
|
- `get_template_name`
|
||||||
- `get_template_string`
|
- `get_template`
|
||||||
|
|
||||||
`self.input` is defined only for the duration of `Component.render`, and raises `RuntimeError` when called outside of this.
|
`self.input` is defined only for the duration of `Component.render`, and raises `RuntimeError` when called outside of this.
|
||||||
|
|
||||||
|
@ -3170,6 +3179,26 @@ COMPONENTS = {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you want add templates to the cache yourself, you can use `cached_template()`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
from django_components import cached_template
|
||||||
|
|
||||||
|
cached_template("Variable: {{ variable }}")
|
||||||
|
|
||||||
|
# You can optionally specify Template class, and other Template inputs:
|
||||||
|
class MyTemplate(Template):
|
||||||
|
pass
|
||||||
|
|
||||||
|
cached_template(
|
||||||
|
"Variable: {{ variable }}",
|
||||||
|
template_cls=MyTemplate,
|
||||||
|
name=...
|
||||||
|
origin=...
|
||||||
|
engine=...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
### `context_behavior` - Make components isolated (or not)
|
### `context_behavior` - Make components isolated (or not)
|
||||||
|
|
||||||
> NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70.
|
> NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70.
|
||||||
|
|
|
@ -3,10 +3,14 @@ from django_components import Component, register
|
||||||
|
|
||||||
@register("calendar")
|
@register("calendar")
|
||||||
class Calendar(Component):
|
class Calendar(Component):
|
||||||
# Note that Django will look for templates inside `[your apps]/components` dir and
|
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
|
||||||
# `[project root]/components` dir. To customize which template to use based on context
|
# will be automatically found.
|
||||||
# you can override def get_template_name() instead of specifying the below variable.
|
#
|
||||||
|
# `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
|
||||||
template_name = "calendar/calendar.html"
|
template_name = "calendar/calendar.html"
|
||||||
|
# Or
|
||||||
|
# def get_template_name(context):
|
||||||
|
# return f"template-{context['name']}.html"
|
||||||
|
|
||||||
# This component takes one parameter, a date string to show in the template
|
# This component takes one parameter, a date string to show in the template
|
||||||
def get_context_data(self, date):
|
def get_context_data(self, date):
|
||||||
|
@ -27,10 +31,14 @@ class Calendar(Component):
|
||||||
|
|
||||||
@register("calendar_relative")
|
@register("calendar_relative")
|
||||||
class CalendarRelative(Component):
|
class CalendarRelative(Component):
|
||||||
# Note that Django will look for templates inside `[your apps]/components` dir and
|
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
|
||||||
# `[project root]/components` dir. To customize which template to use based on context
|
# will be automatically found.
|
||||||
# you can override def get_template_name() instead of specifying the below variable.
|
#
|
||||||
|
# `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
|
||||||
template_name = "calendar.html"
|
template_name = "calendar.html"
|
||||||
|
# Or
|
||||||
|
# def get_template_name(context):
|
||||||
|
# return f"template-{context['name']}.html"
|
||||||
|
|
||||||
# This component takes one parameter, a date string to show in the template
|
# This component takes one parameter, a date string to show in the template
|
||||||
def get_context_data(self, date):
|
def get_context_data(self, date):
|
||||||
|
|
|
@ -3,10 +3,14 @@ from django_components import Component, register
|
||||||
|
|
||||||
@register("calendar_nested")
|
@register("calendar_nested")
|
||||||
class CalendarNested(Component):
|
class CalendarNested(Component):
|
||||||
# Note that Django will look for templates inside `[your apps]/components` dir and
|
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
|
||||||
# `[project root]/components` dir. To customize which template to use based on context
|
# will be automatically found.
|
||||||
# you can override def get_template_name() instead of specifying the below variable.
|
#
|
||||||
|
# `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
|
||||||
template_name = "calendar.html"
|
template_name = "calendar.html"
|
||||||
|
# Or
|
||||||
|
# def get_template_name(context):
|
||||||
|
# return f"template-{context['name']}.html"
|
||||||
|
|
||||||
# This component takes one parameter, a date string to show in the template
|
# This component takes one parameter, a date string to show in the template
|
||||||
def get_context_data(self, date):
|
def get_context_data(self, date):
|
||||||
|
|
|
@ -2,8 +2,7 @@ from django_components import Component, register
|
||||||
|
|
||||||
|
|
||||||
@register("todo")
|
@register("todo")
|
||||||
class Calendar(Component):
|
class Todo(Component):
|
||||||
# Note that Django will look for templates inside `[your apps]/components` dir and
|
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
|
||||||
# `[project root]/components` dir. To customize which template to use based on context
|
# will be automatically found.
|
||||||
# you can override def get_template_name() instead of specifying the below variable.
|
|
||||||
template_name = "todo/todo.html"
|
template_name = "todo/todo.html"
|
||||||
|
|
|
@ -36,6 +36,7 @@ from django_components.tag_formatter import (
|
||||||
component_formatter as component_formatter,
|
component_formatter as component_formatter,
|
||||||
component_shorthand_formatter as component_shorthand_formatter,
|
component_shorthand_formatter as component_shorthand_formatter,
|
||||||
)
|
)
|
||||||
|
from django_components.template import cached_template as cached_template
|
||||||
import django_components.types as types
|
import django_components.types as types
|
||||||
from django_components.types import (
|
from django_components.types import (
|
||||||
EmptyTuple as EmptyTuple,
|
EmptyTuple as EmptyTuple,
|
||||||
|
|
|
@ -63,6 +63,7 @@ from django_components.slots import (
|
||||||
resolve_fill_nodes,
|
resolve_fill_nodes,
|
||||||
resolve_slots,
|
resolve_slots,
|
||||||
)
|
)
|
||||||
|
from django_components.template import cached_template
|
||||||
from django_components.utils import gen_id, validate_typed_dict, validate_typed_tuple
|
from django_components.utils import gen_id, validate_typed_dict, validate_typed_tuple
|
||||||
|
|
||||||
# TODO_REMOVE_IN_V1 - Users should use top-level import instead
|
# TODO_REMOVE_IN_V1 - Users should use top-level import instead
|
||||||
|
@ -154,10 +155,42 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
|
||||||
# non-null return.
|
# non-null return.
|
||||||
_class_hash: ClassVar[int]
|
_class_hash: ClassVar[int]
|
||||||
|
|
||||||
template_name: ClassVar[Optional[str]] = None
|
template_name: Optional[str] = None
|
||||||
"""Relative filepath to the Django template associated with this component."""
|
"""
|
||||||
template: Optional[str] = None
|
Filepath to the Django template associated with this component.
|
||||||
"""Inlined Django template associated with this component."""
|
|
||||||
|
The filepath must be relative to either the file where the component class was defined,
|
||||||
|
or one of the roots of `STATIFILES_DIRS`.
|
||||||
|
|
||||||
|
Only one of `template_name`, `get_template_name`, `template` or `get_template` must be defined.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_template_name(self, context: Context) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Filepath to the Django template associated with this component.
|
||||||
|
|
||||||
|
The filepath must be relative to either the file where the component class was defined,
|
||||||
|
or one of the roots of `STATIFILES_DIRS`.
|
||||||
|
|
||||||
|
Only one of `template_name`, `get_template_name`, `template` or `get_template` must be defined.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
template: Optional[Union[str, Template]] = None
|
||||||
|
"""
|
||||||
|
Inlined Django template associated with this component. Can be a plain string or a Template instance.
|
||||||
|
|
||||||
|
Only one of `template_name`, `get_template_name`, `template` or `get_template` must be defined.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_template(self, context: Context) -> Optional[Union[str, Template]]:
|
||||||
|
"""
|
||||||
|
Inlined Django template associated with this component. Can be a plain string or a Template instance.
|
||||||
|
|
||||||
|
Only one of `template_name`, `get_template_name`, `template` or `get_template` must be defined.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
js: Optional[str] = None
|
js: Optional[str] = None
|
||||||
"""Inlined JS associated with this component."""
|
"""Inlined JS associated with this component."""
|
||||||
css: Optional[str] = None
|
css: Optional[str] = None
|
||||||
|
@ -274,28 +307,55 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
|
||||||
def get_context_data(self, *args: Any, **kwargs: Any) -> DataType:
|
def get_context_data(self, *args: Any, **kwargs: Any) -> DataType:
|
||||||
return cast(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`),
|
# 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
|
# 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
|
# 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.
|
# is that we should treat Templates AND their nodelists as IMMUTABLE.
|
||||||
def get_template(self, context: Context) -> Template:
|
def _get_template(self, context: Context) -> Template:
|
||||||
template_string = self.get_template_string(context)
|
# Resolve template name
|
||||||
if template_string is not None:
|
template_name = self.template_name
|
||||||
return Template(template_string)
|
if self.template_name is not None:
|
||||||
|
if self.get_template_name(context) is not None:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"Received non-null value from both 'template_name' and 'get_template_name' in"
|
||||||
|
f" Component {type(self).__name__}. Only one of the two must be set."
|
||||||
|
)
|
||||||
|
else:
|
||||||
template_name = self.get_template_name(context)
|
template_name = self.get_template_name(context)
|
||||||
|
|
||||||
|
# Resolve template str
|
||||||
|
template_input = self.template
|
||||||
|
if self.template is not None:
|
||||||
|
if self.get_template(context) is not None:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"Received non-null value from both 'template' and 'get_template' in"
|
||||||
|
f" Component {type(self).__name__}. Only one of the two must be set."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# TODO_REMOVE_IN_V1 - Remove `self.get_template_string` in v1
|
||||||
|
template_getter = getattr(self, "get_template_string", self.get_template)
|
||||||
|
template_input = template_getter(context)
|
||||||
|
|
||||||
|
if template_name is not None and template_input is not None:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
f"Received both 'template_name' and 'template' in Component {type(self).__name__}."
|
||||||
|
" Only one of the two must be set."
|
||||||
|
)
|
||||||
|
|
||||||
if template_name is not None:
|
if template_name is not None:
|
||||||
return get_template(template_name).template
|
return get_template(template_name).template
|
||||||
|
|
||||||
|
elif template_input is not None:
|
||||||
|
# We got template string, so we convert it to Template
|
||||||
|
if isinstance(template_input, str):
|
||||||
|
template: Template = cached_template(template_input)
|
||||||
|
else:
|
||||||
|
template = template_input
|
||||||
|
|
||||||
|
return template
|
||||||
|
|
||||||
raise ImproperlyConfigured(
|
raise ImproperlyConfigured(
|
||||||
f"Either 'template_name' or 'template' must be set for Component {type(self).__name__}."
|
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:
|
||||||
|
@ -606,7 +666,6 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
|
||||||
self.on_render_before(context, template)
|
self.on_render_before(context, template)
|
||||||
|
|
||||||
rendered_component = template.render(context)
|
rendered_component = template.render(context)
|
||||||
|
|
||||||
new_output = self.on_render_after(context, template, rendered_component)
|
new_output = self.on_render_after(context, template, rendered_component)
|
||||||
rendered_component = new_output if new_output is not None else rendered_component
|
rendered_component = new_output if new_output is not None else rendered_component
|
||||||
|
|
||||||
|
@ -884,7 +943,7 @@ def _prepare_template(
|
||||||
# an error when we try to use `{% include %}` tag inside the template?
|
# an error when we try to use `{% include %}` tag inside the template?
|
||||||
# See https://github.com/EmilStenstrom/django-components/issues/580
|
# See https://github.com/EmilStenstrom/django-components/issues/580
|
||||||
# And https://github.com/EmilStenstrom/django-components/issues/634
|
# And https://github.com/EmilStenstrom/django-components/issues/634
|
||||||
template = component.get_template(context)
|
template = component._get_template(context)
|
||||||
_monkeypatch_template(template)
|
_monkeypatch_template(template)
|
||||||
|
|
||||||
# Set `Template._dc_is_component_nested` based on whether we're currently INSIDE
|
# Set `Template._dc_is_component_nested` based on whether we're currently INSIDE
|
||||||
|
|
|
@ -3,6 +3,7 @@ import json
|
||||||
import re
|
import re
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Any,
|
Any,
|
||||||
|
@ -27,7 +28,7 @@ from django.template.defaulttags import CommentNode
|
||||||
from django.template.exceptions import TemplateSyntaxError
|
from django.template.exceptions import TemplateSyntaxError
|
||||||
from django.utils.safestring import SafeString, mark_safe
|
from django.utils.safestring import SafeString, mark_safe
|
||||||
|
|
||||||
from django_components.app_settings import ContextBehavior
|
from django_components.app_settings import ContextBehavior, app_settings
|
||||||
from django_components.context import (
|
from django_components.context import (
|
||||||
_FILLED_SLOTS_CONTENT_CONTEXT_KEY,
|
_FILLED_SLOTS_CONTENT_CONTEXT_KEY,
|
||||||
_INJECT_CONTEXT_KEY_PREFIX,
|
_INJECT_CONTEXT_KEY_PREFIX,
|
||||||
|
@ -37,6 +38,7 @@ from django_components.context import (
|
||||||
from django_components.expression import RuntimeKwargs, is_identifier
|
from django_components.expression import RuntimeKwargs, is_identifier
|
||||||
from django_components.logger import trace_msg
|
from django_components.logger import trace_msg
|
||||||
from django_components.node import BaseNode, NodeTraverse, nodelist_has_content, walk_nodelist
|
from django_components.node import BaseNode, NodeTraverse, nodelist_has_content, walk_nodelist
|
||||||
|
from django_components.utils import lazy_cache
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django_components.component_registry import ComponentRegistry
|
from django_components.component_registry import ComponentRegistry
|
||||||
|
@ -332,9 +334,13 @@ class FillNode(BaseNode):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: There may be more components per template, so using `app_settings.TEMPLATE_CACHE_SIZE`
|
||||||
|
# is not entirely correct. However, for now it's not worth it adding a separate setting
|
||||||
|
# to control this cache separately. So we use `TEMPLATE_CACHE_SIZE` so the cache is bounded.
|
||||||
|
@lazy_cache(lambda: lru_cache(maxsize=app_settings.TEMPLATE_CACHE_SIZE))
|
||||||
def parse_slot_fill_nodes_from_component_nodelist(
|
def parse_slot_fill_nodes_from_component_nodelist(
|
||||||
component_nodelist: NodeList,
|
nodes: Tuple[Node, ...],
|
||||||
ComponentNodeCls: Type[Node],
|
ignored_nodes: Tuple[Type[Node]],
|
||||||
) -> List[FillNode]:
|
) -> List[FillNode]:
|
||||||
"""
|
"""
|
||||||
Given a component body (`django.template.NodeList`), find all slot fills,
|
Given a component body (`django.template.NodeList`), find all slot fills,
|
||||||
|
@ -355,12 +361,12 @@ def parse_slot_fill_nodes_from_component_nodelist(
|
||||||
and `fill "second_fill"`.
|
and `fill "second_fill"`.
|
||||||
"""
|
"""
|
||||||
fill_nodes: List[FillNode] = []
|
fill_nodes: List[FillNode] = []
|
||||||
if nodelist_has_content(component_nodelist):
|
if nodelist_has_content(nodes):
|
||||||
for parse_fn in (
|
for parse_fn in (
|
||||||
_try_parse_as_default_fill,
|
_try_parse_as_default_fill,
|
||||||
_try_parse_as_named_fill_tag_set,
|
_try_parse_as_named_fill_tag_set,
|
||||||
):
|
):
|
||||||
curr_fill_nodes = parse_fn(component_nodelist, ComponentNodeCls)
|
curr_fill_nodes = parse_fn(nodes, ignored_nodes)
|
||||||
if curr_fill_nodes:
|
if curr_fill_nodes:
|
||||||
fill_nodes = curr_fill_nodes
|
fill_nodes = curr_fill_nodes
|
||||||
break
|
break
|
||||||
|
@ -375,12 +381,12 @@ def parse_slot_fill_nodes_from_component_nodelist(
|
||||||
|
|
||||||
|
|
||||||
def _try_parse_as_named_fill_tag_set(
|
def _try_parse_as_named_fill_tag_set(
|
||||||
nodelist: NodeList,
|
nodes: Tuple[Node, ...],
|
||||||
ComponentNodeCls: Type[Node],
|
ignored_nodes: Tuple[Type[Node]],
|
||||||
) -> List[FillNode]:
|
) -> List[FillNode]:
|
||||||
result = []
|
result = []
|
||||||
seen_names: Set[str] = set()
|
seen_names: Set[str] = set()
|
||||||
for node in nodelist:
|
for node in nodes:
|
||||||
if isinstance(node, FillNode):
|
if isinstance(node, FillNode):
|
||||||
# If the fill name was defined statically, then check for no duplicates.
|
# If the fill name was defined statically, then check for no duplicates.
|
||||||
maybe_fill_name = node.kwargs.kwargs.get(SLOT_NAME_KWARG)
|
maybe_fill_name = node.kwargs.kwargs.get(SLOT_NAME_KWARG)
|
||||||
|
@ -402,15 +408,15 @@ def _try_parse_as_named_fill_tag_set(
|
||||||
|
|
||||||
|
|
||||||
def _try_parse_as_default_fill(
|
def _try_parse_as_default_fill(
|
||||||
nodelist: NodeList,
|
nodes: Tuple[Node, ...],
|
||||||
ComponentNodeCls: Type[Node],
|
ignored_nodes: Tuple[Type[Node]],
|
||||||
) -> List[FillNode]:
|
) -> List[FillNode]:
|
||||||
nodes_stack: List[Node] = list(nodelist)
|
nodes_stack: List[Node] = list(nodes)
|
||||||
while nodes_stack:
|
while nodes_stack:
|
||||||
node = nodes_stack.pop()
|
node = nodes_stack.pop()
|
||||||
if isinstance(node, FillNode):
|
if isinstance(node, FillNode):
|
||||||
return []
|
return []
|
||||||
elif isinstance(node, ComponentNodeCls):
|
elif isinstance(node, ignored_nodes):
|
||||||
# Stop searching here, as fill tags are permitted inside component blocks
|
# Stop searching here, as fill tags are permitted inside component blocks
|
||||||
# embedded within a default fill node.
|
# embedded within a default fill node.
|
||||||
continue
|
continue
|
||||||
|
@ -419,7 +425,7 @@ def _try_parse_as_default_fill(
|
||||||
else:
|
else:
|
||||||
return [
|
return [
|
||||||
FillNode(
|
FillNode(
|
||||||
nodelist=nodelist,
|
nodelist=NodeList(nodes),
|
||||||
kwargs=RuntimeKwargs(
|
kwargs=RuntimeKwargs(
|
||||||
{
|
{
|
||||||
# Wrap the default slot name in quotes so it's treated as FilterExpression
|
# Wrap the default slot name in quotes so it's treated as FilterExpression
|
||||||
|
|
42
src/django_components/template.py
Normal file
42
src/django_components/template.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any, Optional, Type, TypeVar
|
||||||
|
|
||||||
|
from django.template import Origin, Template
|
||||||
|
from django.template.base import UNKNOWN_SOURCE
|
||||||
|
|
||||||
|
from django_components.app_settings import app_settings
|
||||||
|
from django_components.utils import lazy_cache
|
||||||
|
|
||||||
|
TTemplate = TypeVar("TTemplate", bound=Template)
|
||||||
|
|
||||||
|
|
||||||
|
# Lazily initialize the cache. The cached function takes only the parts that can
|
||||||
|
# affect how the template string is processed - Template class, template string, and engine
|
||||||
|
@lazy_cache(lambda: lru_cache(maxsize=app_settings.TEMPLATE_CACHE_SIZE))
|
||||||
|
def _create_template(
|
||||||
|
template_cls: Type[TTemplate],
|
||||||
|
template_string: str,
|
||||||
|
engine: Optional[Any] = None,
|
||||||
|
) -> TTemplate:
|
||||||
|
return template_cls(template_string, engine=engine)
|
||||||
|
|
||||||
|
|
||||||
|
# Central logic for creating Templates from string, so we can cache the results
|
||||||
|
def cached_template(
|
||||||
|
template_string: str,
|
||||||
|
template_cls: Optional[Type[Template]] = None,
|
||||||
|
origin: Optional[Origin] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
engine: Optional[Any] = None,
|
||||||
|
) -> Template:
|
||||||
|
"""Create a Template instance that will be cached as per the `TEMPLATE_CACHE_SIZE` setting."""
|
||||||
|
template = _create_template(template_cls or Template, template_string, engine)
|
||||||
|
|
||||||
|
# Assign the origin and name separately, so the caching doesn't depend on them
|
||||||
|
# Since we might be accessing a template from cache, we want to define these only once
|
||||||
|
if not getattr(template, "_dc_cached", False):
|
||||||
|
template.origin = origin or Origin(UNKNOWN_SOURCE)
|
||||||
|
template.name = name
|
||||||
|
template._dc_cached = True
|
||||||
|
|
||||||
|
return template
|
|
@ -246,7 +246,7 @@ def component(parser: Parser, token: Token, registry: ComponentRegistry, tag_nam
|
||||||
trace_msg("PARSE", "COMP", result.component_name, tag.id)
|
trace_msg("PARSE", "COMP", result.component_name, tag.id)
|
||||||
|
|
||||||
body = tag.parse_body()
|
body = tag.parse_body()
|
||||||
fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode)
|
fill_nodes = parse_slot_fill_nodes_from_component_nodelist(tuple(body), ignored_nodes=(ComponentNode,))
|
||||||
|
|
||||||
# Tag all fill nodes as children of this particular component instance
|
# Tag all fill nodes as children of this particular component instance
|
||||||
for node in fill_nodes:
|
for node in fill_nodes:
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
|
import functools
|
||||||
import sys
|
import sys
|
||||||
import typing
|
import typing
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, List, Mapping, Sequence, Tuple, Union, get_type_hints
|
from typing import Any, Callable, List, Mapping, Sequence, Tuple, TypeVar, Union, cast, get_type_hints
|
||||||
|
|
||||||
from django.utils.autoreload import autoreload_started
|
from django.utils.autoreload import autoreload_started
|
||||||
|
|
||||||
|
@ -166,3 +167,47 @@ def validate_typed_dict(value: Mapping[str, Any], dict_type: Any, prefix: str, k
|
||||||
# `Component 'name' got unexpected slot keys 'invalid_key'`
|
# `Component 'name' got unexpected slot keys 'invalid_key'`
|
||||||
# `Component 'name' got unexpected data keys 'invalid_key'`
|
# `Component 'name' got unexpected data keys 'invalid_key'`
|
||||||
raise TypeError(f"{prefix} got unexpected {kind} keys {formatted_keys}")
|
raise TypeError(f"{prefix} got unexpected {kind} keys {formatted_keys}")
|
||||||
|
|
||||||
|
|
||||||
|
TFunc = TypeVar("TFunc", bound=Callable)
|
||||||
|
|
||||||
|
|
||||||
|
def lazy_cache(
|
||||||
|
make_cache: Callable[[], Callable[[Callable], Callable]],
|
||||||
|
) -> Callable[[TFunc], TFunc]:
|
||||||
|
"""
|
||||||
|
Decorator that caches the given function similarly to `functools.lru_cache`.
|
||||||
|
But the cache is instantiated only at first invocation.
|
||||||
|
|
||||||
|
`cache` argument is a function that generates the cache function,
|
||||||
|
e.g. `functools.lru_cache()`.
|
||||||
|
"""
|
||||||
|
_cached_fn = None
|
||||||
|
|
||||||
|
def decorator(fn: TFunc) -> TFunc:
|
||||||
|
@functools.wraps(fn)
|
||||||
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
# Lazily initialize the cache
|
||||||
|
nonlocal _cached_fn
|
||||||
|
if not _cached_fn:
|
||||||
|
# E.g. `lambda: functools.lru_cache(maxsize=app_settings.TEMPLATE_CACHE_SIZE)`
|
||||||
|
cache = make_cache()
|
||||||
|
_cached_fn = cache(fn)
|
||||||
|
|
||||||
|
return _cached_fn(*args, **kwargs)
|
||||||
|
|
||||||
|
# Allow to access the LRU cache methods
|
||||||
|
# See https://stackoverflow.com/a/37654201/9788634
|
||||||
|
wrapper.cache_info = lambda: _cached_fn.cache_info() # type: ignore
|
||||||
|
wrapper.cache_clear = lambda: _cached_fn.cache_clear() # type: ignore
|
||||||
|
|
||||||
|
# And allow to remove the cache instance (mostly for tests)
|
||||||
|
def cache_remove() -> None:
|
||||||
|
nonlocal _cached_fn
|
||||||
|
_cached_fn = None
|
||||||
|
|
||||||
|
wrapper.cache_remove = cache_remove # type: ignore
|
||||||
|
|
||||||
|
return cast(TFunc, wrapper)
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
|
@ -76,6 +76,35 @@ else:
|
||||||
optional: NotRequired[int]
|
optional: NotRequired[int]
|
||||||
|
|
||||||
|
|
||||||
|
# TODO_REMOVE_IN_V1 - Superseded by `self.get_template` in v1
|
||||||
|
class ComponentOldTemplateApiTest(BaseTestCase):
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_get_template_string(self):
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
def get_template_string(self, context):
|
||||||
|
content: types.django_html = """
|
||||||
|
Variable: <strong>{{ variable }}</strong>
|
||||||
|
"""
|
||||||
|
return content
|
||||||
|
|
||||||
|
def get_context_data(self, variable=None):
|
||||||
|
return {
|
||||||
|
"variable": variable,
|
||||||
|
}
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = "style.css"
|
||||||
|
js = "script.js"
|
||||||
|
|
||||||
|
rendered = SimpleComponent.render(kwargs={"variable": "test"})
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
Variable: <strong>test</strong>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ComponentTest(BaseTestCase):
|
class ComponentTest(BaseTestCase):
|
||||||
class ParentComponent(Component):
|
class ParentComponent(Component):
|
||||||
template: types.django_html = """
|
template: types.django_html = """
|
||||||
|
@ -123,7 +152,7 @@ class ComponentTest(BaseTestCase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
with self.assertRaises(ImproperlyConfigured):
|
with self.assertRaises(ImproperlyConfigured):
|
||||||
EmptyComponent("empty_component").get_template(Context({}))
|
EmptyComponent("empty_component")._get_template(Context({}))
|
||||||
|
|
||||||
@parametrize_context_behavior(["django", "isolated"])
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
def test_template_string_static_inlined(self):
|
def test_template_string_static_inlined(self):
|
||||||
|
@ -152,7 +181,7 @@ class ComponentTest(BaseTestCase):
|
||||||
@parametrize_context_behavior(["django", "isolated"])
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
def test_template_string_dynamic(self):
|
def test_template_string_dynamic(self):
|
||||||
class SimpleComponent(Component):
|
class SimpleComponent(Component):
|
||||||
def get_template_string(self, context):
|
def get_template(self, context):
|
||||||
content: types.django_html = """
|
content: types.django_html = """
|
||||||
Variable: <strong>{{ variable }}</strong>
|
Variable: <strong>{{ variable }}</strong>
|
||||||
"""
|
"""
|
||||||
|
@ -225,7 +254,7 @@ class ComponentTest(BaseTestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
@parametrize_context_behavior(["django", "isolated"])
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
def test_allows_to_override_get_template(self):
|
def test_allows_to_return_template(self):
|
||||||
class TestComponent(Component):
|
class TestComponent(Component):
|
||||||
def get_context_data(self, variable, **attrs):
|
def get_context_data(self, variable, **attrs):
|
||||||
return {
|
return {
|
||||||
|
@ -1037,7 +1066,6 @@ class ComponentRenderTest(BaseTestCase):
|
||||||
|
|
||||||
|
|
||||||
class ComponentHookTest(BaseTestCase):
|
class ComponentHookTest(BaseTestCase):
|
||||||
@parametrize_context_behavior(["django", "isolated"])
|
|
||||||
def test_on_render_before(self):
|
def test_on_render_before(self):
|
||||||
class SimpleComponent(Component):
|
class SimpleComponent(Component):
|
||||||
template: types.django_html = """
|
template: types.django_html = """
|
||||||
|
@ -1075,7 +1103,6 @@ class ComponentHookTest(BaseTestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check that modifying the context or template does nothing
|
# Check that modifying the context or template does nothing
|
||||||
@parametrize_context_behavior(["django", "isolated"])
|
|
||||||
def test_on_render_after(self):
|
def test_on_render_after(self):
|
||||||
captured_content = None
|
captured_content = None
|
||||||
|
|
||||||
|
|
67
tests/test_template.py
Normal file
67
tests/test_template.py
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
from django.template import Context, Template
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
|
from django_components import Component, cached_template, types
|
||||||
|
|
||||||
|
from .django_test_setup import setup_test_config
|
||||||
|
from .testutils import BaseTestCase
|
||||||
|
|
||||||
|
setup_test_config({"autodiscover": False})
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateCacheTest(BaseTestCase):
|
||||||
|
def test_cached_template(self):
|
||||||
|
template_1 = cached_template("Variable: <strong>{{ variable }}</strong>")
|
||||||
|
template_1._test_id = "123"
|
||||||
|
|
||||||
|
template_2 = cached_template("Variable: <strong>{{ variable }}</strong>")
|
||||||
|
|
||||||
|
self.assertEqual(template_2._test_id, "123")
|
||||||
|
|
||||||
|
def test_cached_template_accepts_class(self):
|
||||||
|
class MyTemplate(Template):
|
||||||
|
pass
|
||||||
|
|
||||||
|
template = cached_template("Variable: <strong>{{ variable }}</strong>", MyTemplate)
|
||||||
|
self.assertIsInstance(template, MyTemplate)
|
||||||
|
|
||||||
|
@override_settings(COMPONENTS={"template_cache_size": 2})
|
||||||
|
def test_cache_discards_old_entries(self):
|
||||||
|
template_1 = cached_template("Variable: <strong>{{ variable }}</strong>")
|
||||||
|
template_1._test_id = "123"
|
||||||
|
|
||||||
|
template_2 = cached_template("Variable2")
|
||||||
|
template_2._test_id = "456"
|
||||||
|
|
||||||
|
# Templates 1 and 2 should still be available
|
||||||
|
template_1_copy = cached_template("Variable: <strong>{{ variable }}</strong>")
|
||||||
|
self.assertEqual(template_1_copy._test_id, "123")
|
||||||
|
|
||||||
|
template_2_copy = cached_template("Variable2")
|
||||||
|
self.assertEqual(template_2_copy._test_id, "456")
|
||||||
|
|
||||||
|
# But once we add the third template, template 1 should go
|
||||||
|
cached_template("Variable3")
|
||||||
|
|
||||||
|
template_1_copy2 = cached_template("Variable: <strong>{{ variable }}</strong>")
|
||||||
|
self.assertEqual(hasattr(template_1_copy2, "_test_id"), False)
|
||||||
|
|
||||||
|
def test_component_template_is_cached(self):
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
def get_template(self, context):
|
||||||
|
content: types.django_html = """
|
||||||
|
Variable: <strong>{{ variable }}</strong>
|
||||||
|
"""
|
||||||
|
return content
|
||||||
|
|
||||||
|
def get_context_data(self, variable=None):
|
||||||
|
return {
|
||||||
|
"variable": variable,
|
||||||
|
}
|
||||||
|
|
||||||
|
comp = SimpleComponent()
|
||||||
|
template_1 = comp._get_template(Context({}))
|
||||||
|
template_1._test_id = "123"
|
||||||
|
|
||||||
|
template_2 = comp._get_template(Context({}))
|
||||||
|
self.assertEqual(template_2._test_id, "123")
|
|
@ -434,7 +434,7 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):
|
||||||
@parametrize_context_behavior(["django", "isolated"])
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
def test_component_template_cannot_have_multiple_default_slots(self):
|
def test_component_template_cannot_have_multiple_default_slots(self):
|
||||||
class BadComponent(Component):
|
class BadComponent(Component):
|
||||||
def get_template(self, context, template_name: Optional[str] = None) -> Template:
|
def get_template(self, context):
|
||||||
template_str: types.django_html = """
|
template_str: types.django_html = """
|
||||||
{% load django_components %}
|
{% load django_components %}
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -24,6 +24,10 @@ class BaseTestCase(SimpleTestCase):
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
registry.clear()
|
registry.clear()
|
||||||
|
|
||||||
|
from django_components.template import _create_template
|
||||||
|
|
||||||
|
_create_template.cache_remove() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
request = Mock()
|
request = Mock()
|
||||||
mock_template = Mock()
|
mock_template = Mock()
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue