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:
Juro Oravec 2024-09-06 22:40:39 +02:00 committed by GitHub
parent 589e802625
commit 841dd77e91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 347 additions and 56 deletions

View file

@ -71,6 +71,13 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
## 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**
- 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))
@ -93,7 +100,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
🚨📢 **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))
- 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))
@ -383,11 +390,13 @@ from django_components import Component, register
@register("calendar")
class Calendar(Component):
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
# will be automatically found. To customize which template to use based on context
# you can override method `get_template_name` instead of specifying `template_name`.
# will be automatically found.
#
# `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
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
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:
- `get_context_data`
- `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.
@ -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)
> NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70.

View file

@ -3,10 +3,14 @@ from django_components import Component, register
@register("calendar")
class Calendar(Component):
# Note that Django will look for templates inside `[your apps]/components` dir and
# `[project root]/components` dir. To customize which template to use based on context
# you can override def get_template_name() instead of specifying the below variable.
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
# will be automatically found.
#
# `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
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
def get_context_data(self, date):
@ -27,10 +31,14 @@ class Calendar(Component):
@register("calendar_relative")
class CalendarRelative(Component):
# Note that Django will look for templates inside `[your apps]/components` dir and
# `[project root]/components` dir. To customize which template to use based on context
# you can override def get_template_name() instead of specifying the below variable.
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
# will be automatically found.
#
# `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
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
def get_context_data(self, date):

View file

@ -3,10 +3,14 @@ from django_components import Component, register
@register("calendar_nested")
class CalendarNested(Component):
# Note that Django will look for templates inside `[your apps]/components` dir and
# `[project root]/components` dir. To customize which template to use based on context
# you can override def get_template_name() instead of specifying the below variable.
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
# will be automatically found.
#
# `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
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
def get_context_data(self, date):

View file

@ -2,8 +2,7 @@ from django_components import Component, register
@register("todo")
class Calendar(Component):
# Note that Django will look for templates inside `[your apps]/components` dir and
# `[project root]/components` dir. To customize which template to use based on context
# you can override def get_template_name() instead of specifying the below variable.
class Todo(Component):
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
# will be automatically found.
template_name = "todo/todo.html"

View file

@ -36,6 +36,7 @@ from django_components.tag_formatter import (
component_formatter as component_formatter,
component_shorthand_formatter as component_shorthand_formatter,
)
from django_components.template import cached_template as cached_template
import django_components.types as types
from django_components.types import (
EmptyTuple as EmptyTuple,

View file

@ -63,6 +63,7 @@ from django_components.slots import (
resolve_fill_nodes,
resolve_slots,
)
from django_components.template import cached_template
from django_components.utils import gen_id, validate_typed_dict, validate_typed_tuple
# 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.
_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."""
template_name: Optional[str] = None
"""
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.
"""
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
"""Inlined JS associated with this component."""
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:
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)
def _get_template(self, context: Context) -> Template:
# Resolve template name
template_name = self.template_name
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)
# 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."
)
template_name = self.get_template_name(context)
if template_name is not None:
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(
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:
@ -606,7 +666,6 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
self.on_render_before(context, template)
rendered_component = template.render(context)
new_output = self.on_render_after(context, template, 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?
# See https://github.com/EmilStenstrom/django-components/issues/580
# And https://github.com/EmilStenstrom/django-components/issues/634
template = component.get_template(context)
template = component._get_template(context)
_monkeypatch_template(template)
# Set `Template._dc_is_component_nested` based on whether we're currently INSIDE

View file

@ -3,6 +3,7 @@ import json
import re
from collections import deque
from dataclasses import dataclass
from functools import lru_cache
from typing import (
TYPE_CHECKING,
Any,
@ -27,7 +28,7 @@ from django.template.defaulttags import CommentNode
from django.template.exceptions import TemplateSyntaxError
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 (
_FILLED_SLOTS_CONTENT_CONTEXT_KEY,
_INJECT_CONTEXT_KEY_PREFIX,
@ -37,6 +38,7 @@ from django_components.context import (
from django_components.expression import RuntimeKwargs, is_identifier
from django_components.logger import trace_msg
from django_components.node import BaseNode, NodeTraverse, nodelist_has_content, walk_nodelist
from django_components.utils import lazy_cache
if TYPE_CHECKING:
from django_components.component_registry import ComponentRegistry
@ -332,9 +334,13 @@ class FillNode(BaseNode):
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(
component_nodelist: NodeList,
ComponentNodeCls: Type[Node],
nodes: Tuple[Node, ...],
ignored_nodes: Tuple[Type[Node]],
) -> List[FillNode]:
"""
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"`.
"""
fill_nodes: List[FillNode] = []
if nodelist_has_content(component_nodelist):
if nodelist_has_content(nodes):
for parse_fn in (
_try_parse_as_default_fill,
_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:
fill_nodes = curr_fill_nodes
break
@ -375,12 +381,12 @@ def parse_slot_fill_nodes_from_component_nodelist(
def _try_parse_as_named_fill_tag_set(
nodelist: NodeList,
ComponentNodeCls: Type[Node],
nodes: Tuple[Node, ...],
ignored_nodes: Tuple[Type[Node]],
) -> List[FillNode]:
result = []
seen_names: Set[str] = set()
for node in nodelist:
for node in nodes:
if isinstance(node, FillNode):
# If the fill name was defined statically, then check for no duplicates.
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(
nodelist: NodeList,
ComponentNodeCls: Type[Node],
nodes: Tuple[Node, ...],
ignored_nodes: Tuple[Type[Node]],
) -> List[FillNode]:
nodes_stack: List[Node] = list(nodelist)
nodes_stack: List[Node] = list(nodes)
while nodes_stack:
node = nodes_stack.pop()
if isinstance(node, FillNode):
return []
elif isinstance(node, ComponentNodeCls):
elif isinstance(node, ignored_nodes):
# Stop searching here, as fill tags are permitted inside component blocks
# embedded within a default fill node.
continue
@ -419,7 +425,7 @@ def _try_parse_as_default_fill(
else:
return [
FillNode(
nodelist=nodelist,
nodelist=NodeList(nodes),
kwargs=RuntimeKwargs(
{
# Wrap the default slot name in quotes so it's treated as FilterExpression

View 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

View file

@ -246,7 +246,7 @@ def component(parser: Parser, token: Token, registry: ComponentRegistry, tag_nam
trace_msg("PARSE", "COMP", result.component_name, tag.id)
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
for node in fill_nodes:

View file

@ -1,7 +1,8 @@
import functools
import sys
import typing
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
@ -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 data keys 'invalid_key'`
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

View file

@ -76,6 +76,35 @@ else:
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 ParentComponent(Component):
template: types.django_html = """
@ -123,7 +152,7 @@ class ComponentTest(BaseTestCase):
pass
with self.assertRaises(ImproperlyConfigured):
EmptyComponent("empty_component").get_template(Context({}))
EmptyComponent("empty_component")._get_template(Context({}))
@parametrize_context_behavior(["django", "isolated"])
def test_template_string_static_inlined(self):
@ -152,7 +181,7 @@ class ComponentTest(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_template_string_dynamic(self):
class SimpleComponent(Component):
def get_template_string(self, context):
def get_template(self, context):
content: types.django_html = """
Variable: <strong>{{ variable }}</strong>
"""
@ -225,7 +254,7 @@ class ComponentTest(BaseTestCase):
)
@parametrize_context_behavior(["django", "isolated"])
def test_allows_to_override_get_template(self):
def test_allows_to_return_template(self):
class TestComponent(Component):
def get_context_data(self, variable, **attrs):
return {
@ -1037,7 +1066,6 @@ class ComponentRenderTest(BaseTestCase):
class ComponentHookTest(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_on_render_before(self):
class SimpleComponent(Component):
template: types.django_html = """
@ -1075,7 +1103,6 @@ class ComponentHookTest(BaseTestCase):
)
# Check that modifying the context or template does nothing
@parametrize_context_behavior(["django", "isolated"])
def test_on_render_after(self):
captured_content = None

67
tests/test_template.py Normal file
View 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")

View file

@ -434,7 +434,7 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_component_template_cannot_have_multiple_default_slots(self):
class BadComponent(Component):
def get_template(self, context, template_name: Optional[str] = None) -> Template:
def get_template(self, context):
template_str: types.django_html = """
{% load django_components %}
<div>

View file

@ -24,6 +24,10 @@ class BaseTestCase(SimpleTestCase):
super().tearDown()
registry.clear()
from django_components.template import _create_template
_create_template.cache_remove() # type: ignore[attr-defined]
request = Mock()
mock_template = Mock()