mirror of
https://github.com/django-components/django-components.git
synced 2025-08-17 12:40:15 +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
|
||||
|
||||
**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.
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
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)
|
||||
|
||||
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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
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"])
|
||||
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>
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue