feat: component caching (#1097)

* feat: allow to set defaults

* refactor: remove input validation and link to it

* feat: component URL

* feat: component caching

* refactor: Mark `OnComponentRenderedContext` as extension hook for docs

* docs: update changelog

* refactor: simplify hash methods
This commit is contained in:
Juro Oravec 2025-04-08 11:54:39 +02:00 committed by GitHub
parent ef15117459
commit b6994e9ad3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 655 additions and 45 deletions

View file

@ -33,7 +33,31 @@
This way you don't have to mix your app URLs with component URLs.
Read more on [Component views and URLs](https://django-components.github.io/django-components/0.135/concepts/fundamentals/component_views_urls/).
Read more on [Component views and URLs](https://django-components.github.io/django-components/0.137/concepts/fundamentals/component_views_urls/).
- Per-component caching - Set `Component.Cache.enabled` to `True` to enable caching for a component.
Component caching allows you to store the rendered output of a component. Next time the component is rendered
with the same input, the cached output is returned instead of re-rendering the component.
```py
class TestComponent(Component):
template = "Hello"
class Cache:
enabled = True
ttl = 0.1 # .1 seconds TTL
cache_name = "custom_cache"
# Custom hash method for args and kwargs
# NOTE: The default implementation simply serializes the input into a string.
# As such, it might not be suitable for complex objects like Models.
def hash(self, *args, **kwargs):
return f"{json.dumps(args)}:{json.dumps(kwargs)}"
```
Read more on [Component caching](https://django-components.github.io/django-components/0.137/concepts/advanced/component_caching/).
- `@djc_test` can now be called without first calling `django.setup()`, in which case it does it for you.

View file

@ -342,13 +342,13 @@ Django-components functionality can be extended with "extensions". Extensions al
Some of the extensions include:
- [Component caching](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/cache.py)
- [Django View integration](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/view.py)
- [Component defaults](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/defaults.py)
- [Pydantic integration (input validation)](https://github.com/django-components/djc-ext-pydantic)
Some of the planned extensions include:
- Caching
- AlpineJS integration
- Storybook integration
- Component-level benchmarking with asv
@ -375,10 +375,20 @@ def test_my_table():
assert rendered == "<table>My table</table>"
```
### Handle large projects with ease
### Caching
- Components can be infinitely nested.
- (Soon) Optimize performance with component-level caching
- Components can be cached using Django's cache framework.
- Components are cached based on their input. Or you can write custom caching logic.
- Caching rules can be configured on a per-component basis.
```py
from django_components import Component
class MyComponent(Component):
class Cache:
enabled = True
ttl = 60 * 60 * 24 # 1 day
```
### Debugging features

View file

@ -5,6 +5,7 @@ nav:
- Prop drilling and provide / inject: provide_inject.md
- Lifecycle hooks: hooks.md
- Registering components: component_registry.md
- Component caching: component_caching.md
- Typing and validation: typing_and_validation.md
- Custom template tags: template_tags.md
- Tag formatters: tag_formatter.md

View file

@ -0,0 +1,105 @@
Component caching allows you to store the rendered output of a component. Next time the component is rendered
with the same input, the cached output is returned instead of re-rendering the component.
This is particularly useful for components that are expensive to render or do not change frequently.
!!! info
Component caching uses Django's cache framework, so you can use any cache backend that is supported by Django.
### Enabling caching
Caching is disabled by default.
To enable caching for a component, set [`Component.Cache.enabled`](../../reference/api.md#django_components.ComponentCache.enabled) to `True`:
```python
from django_components import Component
class MyComponent(Component):
class Cache:
enabled = True
```
### Time-to-live (TTL)
You can specify a time-to-live (TTL) for the cache entry with [`Component.Cache.ttl`](../../reference/api.md#django_components.ComponentCache.ttl), which determines how long the entry remains valid. The TTL is specified in seconds.
```python
class MyComponent(Component):
class Cache:
enabled = True
ttl = 60 * 60 * 24 # 1 day
```
- If `ttl > 0`, entries are cached for the specified number of seconds.
- If `ttl = -1`, entries are cached indefinitely.
- If `ttl = 0`, entries are not cached.
- If `ttl = None`, the default TTL is used.
### Custom cache name
Since component caching uses Django's cache framework, you can specify a custom cache name with [`Component.Cache.cache_name`](../../reference/api.md#django_components.ComponentCache.cache_name) to use a different cache backend:
```python
class MyComponent(Component):
class Cache:
enabled = True
cache_name = "my_cache"
```
### Cache key generation
By default, the cache key is generated based on the component's input (args and kwargs). So the following two calls would generate separate entries in the cache:
```py
MyComponent.render(name="Alice")
MyComponent.render(name="Bob")
```
However, you have full control over the cache key generation. As such, you can:
- Cache the component on all inputs (default)
- Cache the component on particular inputs
- Cache the component irrespective of the inputs
To achieve that, you can override
the [`Component.Cache.hash()`](../../reference/api.md#django_components.ComponentCache.hash)
method to customize how arguments are hashed into the cache key.
```python
class MyComponent(Component):
class Cache:
enabled = True
def hash(self, *args, **kwargs):
return f"{json.dumps(args)}:{json.dumps(kwargs)}"
```
For even more control, you can override other methods available on the [`ComponentCache`](../../reference/api.md#django_components.ComponentCache) class.
!!! warning
The default implementation of `Cache.hash()` simply serializes the input into a string.
As such, it might not be suitable if you need to hash complex objects like Models.
### Example
Here's a complete example of a component with caching enabled:
```python
from django_components import Component
class MyComponent(Component):
template = "Hello, {{ name }}"
class Cache:
enabled = True
ttl = 300 # Cache for 5 minutes
cache_name = "my_cache"
def get_context_data(self, name, **kwargs):
return {"name": name}
```
In this example, the component's rendered output is cached for 5 minutes using the `my_cache` backend.

View file

@ -1,8 +1,20 @@
This page describes the kinds of assets that django-components caches and how to configure the cache backends.
## Component caching
You can cache the output of your components by setting the [`Component.Cache`](../../reference/api.md#django_components.Component.Cache) options.
Read more about [Component caching](../../concepts/advanced/component_caching.md).
## Component's JS and CSS files
django-components caches the JS and CSS files associated with your components. This enables components to be rendered as HTML fragments and still having the associated JS and CSS files loaded with them.
django-components simultaneously supports:
- Rendering and fetching components as HTML fragments
- Allowing components (even fragments) to have JS and CSS files associated with them
- Features like JS/CSS variables or CSS scoping
To achieve all this, django-components defines additional temporary JS and CSS files. These temporary files need to be stored somewhere, so that they can be fetched by the browser when the component is rendered as a fragment. And for that, django-components uses Django's cache framework.
This includes:

View file

@ -332,13 +332,13 @@ Django-components functionality can be extended with "extensions". Extensions al
Some of the extensions include:
- [Component caching](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/cache.py)
- [Django View integration](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/view.py)
- [Component defaults](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/defaults.py)
- [Pydantic integration (input validation)](https://github.com/django-components/djc-ext-pydantic)
Some of the planned extensions include:
- Caching
- AlpineJS integration
- Storybook integration
- Component-level benchmarking with asv
@ -365,10 +365,20 @@ def test_my_table():
assert rendered == "<table>My table</table>"
```
### Handle large projects with ease
### Caching
- Components can be infinitely nested.
- (Soon) Optimize performance with component-level caching
- Components can be cached using Django's cache framework.
- Components are cached based on their input. Or you can write custom caching logic.
- Caching rules can be configured on a per-component basis.
```py
from django_components import Component
class MyComponent(Component):
class Cache:
enabled = True
ttl = 60 * 60 * 24 # 1 day
```
### Debugging features

View file

@ -15,6 +15,18 @@
options:
show_if_no_docstring: true
::: django_components.ComponentCache
options:
show_if_no_docstring: true
::: django_components.ComponentCommand
options:
show_if_no_docstring: true
::: django_components.ComponentDefaults
options:
show_if_no_docstring: true
::: django_components.ComponentExtension
options:
show_if_no_docstring: true

View file

@ -124,7 +124,7 @@ name | type | description
`component` | [`Component`](../api#django_components.Component) | The Component instance that is being rendered
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The Component class
`component_id` | `str` | The unique identifier for this component instance
`template` | `str` | The rendered template
`result` | `str` | The rendered component
::: django_components.extension.ComponentExtension.on_component_unregistered
options:

View file

@ -40,7 +40,8 @@ from django_components.extension import (
OnComponentInputContext,
OnComponentDataContext,
)
from django_components.extensions.defaults import Default
from django_components.extensions.cache import ComponentCache
from django_components.extensions.defaults import ComponentDefaults, Default
from django_components.extensions.view import ComponentView
from django_components.extensions.url import ComponentUrl, get_component_url
from django_components.library import TagProtectedError
@ -76,9 +77,10 @@ __all__ = [
"CommandLiteralAction",
"CommandParserInput",
"CommandSubcommand",
"ComponentCommand",
"ComponentsSettings",
"Component",
"ComponentCache",
"ComponentCommand",
"ComponentDefaults",
"ComponentExtension",
"ComponentFileEntry",
"ComponentFormatter",
@ -88,6 +90,7 @@ __all__ = [
"ComponentVars",
"ComponentView",
"ComponentUrl",
"ComponentsSettings",
"component_formatter",
"component_shorthand_formatter",
"ContextBehavior",

View file

@ -750,11 +750,12 @@ class InternalSettings:
)
# Prepend built-in extensions
from django_components.extensions.cache import CacheExtension
from django_components.extensions.defaults import DefaultsExtension
from django_components.extensions.url import UrlExtension
from django_components.extensions.view import ViewExtension
extensions = [DefaultsExtension, ViewExtension, UrlExtension] + list(extensions)
extensions = [CacheExtension, DefaultsExtension, ViewExtension, UrlExtension] + list(extensions)
# Extensions may be passed in either as classes or import strings.
extension_instances: List["ComponentExtension"] = []

View file

@ -57,8 +57,10 @@ from django_components.extension import (
OnComponentClassDeletedContext,
OnComponentDataContext,
OnComponentInputContext,
OnComponentRenderedContext,
extensions,
)
from django_components.extensions.cache import ComponentCache
from django_components.extensions.defaults import ComponentDefaults
from django_components.extensions.url import ComponentUrl
from django_components.extensions.view import ComponentView, ViewFn
@ -620,6 +622,8 @@ class Component(
# NOTE: These are the classes and instances added by defaults extensions. These fields
# are actually set at runtime, and so here they are only marked for typing.
Cache: Type[ComponentCache]
cache: ComponentCache
Defaults: Type[ComponentDefaults]
defaults: ComponentDefaults
View: Type[ComponentView]
@ -1222,7 +1226,7 @@ class Component(
)
# Allow plugins to modify or validate the inputs
extensions.on_component_input(
result_override = extensions.on_component_input(
OnComponentInputContext(
component=self,
component_cls=self.__class__,
@ -1234,6 +1238,11 @@ class Component(
)
)
# The component rendering was short-circuited by an extension, skipping
# the rest of the rendering process. This may be for example a cached content.
if result_override is not None:
return result_override
# We pass down the components the info about the component's parent.
# This is used for correctly resolving slot fills, correct rendering order,
# or CSS scoping.
@ -1365,24 +1374,35 @@ class Component(
css_scope_id=None, # TODO - Implement CSS scoping
)
# Remove component from caches
# This is triggered when a component is rendered, but the component's parents
# may not have been rendered yet.
def on_component_rendered(html: str) -> str:
with self._with_metadata(metadata):
# Allow to optionally override/modify the rendered content
new_output = self.on_render_after(context_snapshot, template, html)
html = new_output if new_output is not None else html
# Remove component from caches
del component_context_cache[render_id] # type: ignore[arg-type]
unregister_provide_reference(render_id) # type: ignore[arg-type]
if app_settings.DEBUG_HIGHLIGHT_COMPONENTS:
html = apply_component_highlight("component", html, f"{self.name} ({render_id})")
html = extensions.on_component_rendered(
OnComponentRenderedContext(
component=self,
component_cls=self.__class__,
component_id=render_id,
result=html,
)
)
return html
post_render_callbacks[render_id] = on_component_rendered
# After the component and all its children are rendered, we resolve
# This is triggered after a full component tree was rendered, we resolve
# all inserted HTML comments into <script> and <link> tags (if render_dependencies=True)
def on_html_rendered(html: str) -> str:
if render_dependencies:

View file

@ -1,5 +1,5 @@
from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Tuple, Type, TypeVar, Union
from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Tuple, Type, TypeVar, Union
import django.urls
from django.template import Context
@ -112,6 +112,18 @@ class OnComponentDataContext(NamedTuple):
"""Dictionary of CSS data from `Component.get_css_data()`"""
@mark_extension_hook_api
class OnComponentRenderedContext(NamedTuple):
component: "Component"
"""The Component instance that is being rendered"""
component_cls: Type["Component"]
"""The Component class"""
component_id: str
"""The unique identifier for this component instance"""
result: str
"""The rendered component"""
################################################
# EXTENSIONS CORE
################################################
@ -430,18 +442,27 @@ class ComponentExtension:
# Component render hooks
###########################
def on_component_input(self, ctx: OnComponentInputContext) -> None:
def on_component_input(self, ctx: OnComponentInputContext) -> Optional[str]:
"""
Called when a [`Component`](../api#django_components.Component) was triggered to render,
but before a component's context and data methods are invoked.
This hook is called before
[`Component.get_context_data()`](../api#django_components.Component.get_context_data),
[`Component.get_js_data()`](../api#django_components.Component.get_js_data)
and [`Component.get_css_data()`](../api#django_components.Component.get_css_data).
Use this hook to modify or validate component inputs before they're processed.
This is the first hook that is called when rendering a component. As such this hook is called before
[`Component.get_context_data()`](../api#django_components.Component.get_context_data),
[`Component.get_js_data()`](../api#django_components.Component.get_js_data),
and [`Component.get_css_data()`](../api#django_components.Component.get_css_data) methods,
and the
[`on_component_data`](../extension_hooks#django_components.extension.ComponentExtension.on_component_data)
hook.
This hook also allows to skip the rendering of a component altogether. If the hook returns
a non-null value, this value will be used instead of rendering the component.
You can use this to implement a caching mechanism for components, or define components
that will be rendered conditionally.
**Example:**
```python
@ -457,8 +478,8 @@ class ComponentExtension:
def on_component_data(self, ctx: OnComponentDataContext) -> None:
"""
Called when a Component was triggered to render, after a component's context
and data methods have been processed.
Called when a [`Component`](../api#django_components.Component) was triggered to render,
after a component's context and data methods have been processed.
This hook is called after
[`Component.get_context_data()`](../api#django_components.Component.get_context_data),
@ -482,6 +503,28 @@ class ComponentExtension:
"""
pass
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
"""
Called when a [`Component`](../api#django_components.Component) was rendered, including
all its child components.
Use this hook to access or post-process the component's rendered output.
To modify the output, return a new string from this hook.
**Example:**
```python
from django_components import ComponentExtension, OnComponentRenderedContext
class MyExtension(ComponentExtension):
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
# Append a comment to the component's rendered output
return ctx.result + "<!-- MyExtension comment -->"
```
"""
pass
# Decorator to store events in `ExtensionManager._events` when django_components is not yet initialized.
def store_events(func: TCallable) -> TCallable:
@ -765,14 +808,25 @@ class ExtensionManager:
# Component render hooks
###########################
def on_component_input(self, ctx: OnComponentInputContext) -> None:
def on_component_input(self, ctx: OnComponentInputContext) -> Optional[str]:
for extension in self.extensions:
extension.on_component_input(ctx)
result = extension.on_component_input(ctx)
# The extension short-circuited the rendering process to return this
if result is not None:
return result
return None
def on_component_data(self, ctx: OnComponentDataContext) -> None:
for extension in self.extensions:
extension.on_component_data(ctx)
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> str:
for extension in self.extensions:
result = extension.on_component_rendered(ctx)
if result is not None:
ctx = ctx._replace(result=result)
return ctx.result
# NOTE: This is a singleton which is takes the extensions from `app_settings.EXTENSIONS`
extensions = ExtensionManager()

View file

@ -0,0 +1,140 @@
from typing import Any, Optional
from django.core.cache import BaseCache, caches
from django_components.extension import (
ComponentExtension,
OnComponentInputContext,
OnComponentRenderedContext,
)
# NOTE: We allow users to override cache key generation, but then we internally
# still prefix their key with our own prefix, so it's clear where it comes from.
CACHE_KEY_PREFIX = "components:cache:"
class ComponentCache(ComponentExtension.ExtensionClass): # type: ignore
"""
The interface for `Component.Cache`.
The fields of this class are used to configure the component caching.
Read more about [Component caching](../../concepts/advanced/component_caching).
**Example:**
```python
from django_components import Component
class MyComponent(Component):
class Cache:
enabled = True
ttl = 60 * 60 * 24 # 1 day
cache_name = "my_cache"
```
"""
enabled: bool = False
"""
Whether this Component should be cached. Defaults to `False`.
"""
ttl: Optional[int] = None
"""
The time-to-live (TTL) in seconds, i.e. for how long should an entry be valid in the cache.
- If `> 0`, the entries will be cached for the given number of seconds.
- If `-1`, the entries will be cached indefinitely.
- If `0`, the entries won't be cached.
- If `None`, the default TTL will be used.
"""
cache_name: Optional[str] = None
"""
The name of the cache to use. If `None`, the default cache will be used.
"""
def get_entry(self, cache_key: str) -> Any:
cache = self.get_cache()
return cache.get(cache_key)
def set_entry(self, cache_key: str, value: Any) -> None:
cache = self.get_cache()
cache.set(cache_key, value, timeout=self.ttl)
def get_cache(self) -> BaseCache:
cache_name = self.cache_name or "default"
cache = caches[cache_name]
return cache
def get_cache_key(self, *args: Any, **kwargs: Any) -> str:
# Allow user to override how the input is hashed into a cache key with `hash()`,
# but then still prefix it wih our own prefix, so it's clear where it comes from.
cache_key = self.hash(*args, **kwargs)
cache_key = CACHE_KEY_PREFIX + self.component._class_hash + ":" + cache_key
return cache_key
def hash(self, *args: Any, **kwargs: Any) -> str:
"""
Defines how the input (both args and kwargs) is hashed into a cache key.
By default, `hash()` serializes the input into a string. As such, the default
implementation might NOT be suitable if you need to hash complex objects.
"""
args_hash = ",".join(str(arg) for arg in args)
# Sort keys to ensure consistent ordering
sorted_items = sorted(kwargs.items())
kwargs_hash = ",".join(f"{k}-{v}" for k, v in sorted_items)
return f"{args_hash}:{kwargs_hash}"
class CacheExtension(ComponentExtension):
"""
This extension adds a nested `Cache` class to each `Component`.
This nested `Cache` class is used to configure component caching.
**Example:**
```python
from django_components import Component
class MyComponent(Component):
class Cache:
enabled = True
ttl = 60 * 60 * 24 # 1 day
cache_name = "my_cache"
```
This extension is automatically added to all components.
"""
name = "cache"
ExtensionClass = ComponentCache
def __init__(self, *args: Any, **kwargs: Any):
self.render_id_to_cache_key: dict[str, str] = {}
def on_component_input(self, ctx: OnComponentInputContext) -> Optional[Any]:
cache_instance: ComponentCache = ctx.component.cache
if not cache_instance.enabled:
return None
cache_key = cache_instance.get_cache_key(*ctx.args, **ctx.kwargs)
self.render_id_to_cache_key[ctx.component_id] = cache_key
# If cache entry exists, return it. This will short-circuit the rendering process.
cached_result = cache_instance.get_entry(cache_key)
if cached_result is not None:
return cached_result
return None
# Save the rendered component to cache
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> None:
cache_instance: ComponentCache = ctx.component.cache
if not cache_instance.enabled:
return None
cache_key = self.render_id_to_cache_key[ctx.component_id]
cache_instance.set_entry(cache_key, ctx.result)

View file

@ -8,6 +8,7 @@ from weakref import ReferenceType
import django
from django.conf import settings as _django_settings
from django.core.cache import BaseCache, caches
from django.template import engines
from django.test import override_settings
@ -532,6 +533,11 @@ def _clear_djc_global_state(
# Clear extensions caches
extensions._route_to_url.clear()
# Clear Django caches
all_caches: List[BaseCache] = list(caches.all())
for cache in all_caches:
cache.clear()
# Force garbage collection, so that any finalizers are run.
# If garbage collection is skipped, then in some cases the finalizers
# are run too late, in the context of the next test, causing flaky tests.

View file

@ -97,7 +97,7 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list")
output = out.getvalue()
assert output.strip() == "name \n========\ndefaults\nview \nurl"
assert output.strip() == "name \n========\ncache \ndefaults\nview \nurl"
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -108,7 +108,7 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list")
output = out.getvalue()
assert output.strip() == "name \n========\ndefaults\nview \nurl \nempty \ndummy"
assert output.strip() == "name \n========\ncache \ndefaults\nview \nurl \nempty \ndummy"
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -119,7 +119,7 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list", "--all")
output = out.getvalue()
assert output.strip() == "name \n========\ndefaults\nview \nurl \nempty \ndummy"
assert output.strip() == "name \n========\ncache \ndefaults\nview \nurl \nempty \ndummy"
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -130,7 +130,7 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list", "--columns", "name")
output = out.getvalue()
assert output.strip() == "name \n========\ndefaults\nview \nurl \nempty \ndummy"
assert output.strip() == "name \n========\ncache \ndefaults\nview \nurl \nempty \ndummy"
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -141,7 +141,7 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list", "--simple")
output = out.getvalue()
assert output.strip() == "defaults\nview \nurl \nempty \ndummy"
assert output.strip() == "cache \ndefaults\nview \nurl \nempty \ndummy"
@djc_test
@ -159,7 +159,7 @@ class TestExtensionsRunCommand:
output
== dedent(
f"""
usage: components ext run [-h] {{defaults,view,url,empty,dummy}} ...
usage: components ext run [-h] {{cache,defaults,view,url,empty,dummy}} ...
Run a command added by an extension.
@ -167,7 +167,8 @@ class TestExtensionsRunCommand:
-h, --help show this help message and exit
subcommands:
{{defaults,view,url,empty,dummy}}
{{cache,defaults,view,url,empty,dummy}}
cache Run commands added by the 'cache' extension.
defaults Run commands added by the 'defaults' extension.
view Run commands added by the 'view' extension.
url Run commands added by the 'url' extension.

View file

@ -0,0 +1,209 @@
import time
from typing import Any
from django.core.cache import caches
from django_components import Component
from django_components.testing import djc_test
from .testutils import setup_test_config
setup_test_config({"autodiscover": False})
# Common settings for all tests
@djc_test(
django_settings={
"CACHES": {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
},
},
},
)
class TestComponentCache:
def test_cache_enabled(self):
did_call_get = False
class TestComponent(Component):
template = "Hello"
class Cache:
enabled = True
def get_context_data(self, **kwargs: Any):
nonlocal did_call_get
did_call_get = True
return {}
# First render
component = TestComponent()
result = component.render()
assert did_call_get
assert result == "Hello"
# Check if the cache entry is set
cache_key = component.cache.get_cache_key()
assert cache_key == "components:cache:TestComponent_c9770f::"
assert component.cache.get_entry(cache_key) == "<!-- _RENDERED TestComponent_c9770f,a1bc3e,, -->Hello"
assert caches["default"].get(cache_key) == "<!-- _RENDERED TestComponent_c9770f,a1bc3e,, -->Hello"
# Second render
did_call_get = False
component.render()
# get_context_data not called because the cache entry was returned
assert not did_call_get
assert result == "Hello"
def test_cache_disabled(self):
did_call_get = False
class TestComponent(Component):
template = "Hello"
class Cache:
enabled = False
def get_context_data(self, **kwargs: Any):
nonlocal did_call_get
did_call_get = True
return {}
# First render
component = TestComponent()
result = component.render()
assert did_call_get
assert result == "Hello"
# Check if the cache entry is not set
cache_instance = component.cache
cache_key = cache_instance.get_cache_key()
assert cache_instance.get_entry(cache_key) is None
# Second render
did_call_get = False
result = component.render()
# get_context_data IS called because the cache is NOT used
assert did_call_get
assert result == "Hello"
def test_cache_ttl(self):
class TestComponent(Component):
template = "Hello"
class Cache:
enabled = True
ttl = 0.1 # .1 seconds TTL
component = TestComponent()
component.render()
cache_instance = component.cache
cache_key = cache_instance.get_cache_key()
assert cache_instance.get_entry(cache_key) == "<!-- _RENDERED TestComponent_42aca9,a1bc3e,, -->Hello"
# Wait for TTL to expire
time.sleep(0.2)
assert cache_instance.get_entry(cache_key) is None
@djc_test(
django_settings={
"CACHES": {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "default",
},
"custom": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "custom",
},
},
},
)
def test_custom_cache_name(self):
class TestComponent(Component):
template = "Hello"
class Cache:
enabled = True
cache_name = "custom"
component = TestComponent()
component.render()
assert component.cache.get_cache() is caches["custom"]
assert (
component.cache.get_entry("components:cache:TestComponent_90ef7a::")
== "<!-- _RENDERED TestComponent_90ef7a,a1bc3e,, -->Hello"
) # noqa: E501
def test_cache_by_input(self):
class TestComponent(Component):
template = "Hello {{ input }}"
class Cache:
enabled = True
def get_context_data(self, input, **kwargs: Any):
return {"input": input}
component = TestComponent()
component.render(
kwargs={"input": "world"},
)
component.render(
kwargs={"input": "cake"},
)
# Check if the cache entry is set
cache = caches["default"]
assert len(cache._cache) == 2
assert (
component.cache.get_entry("components:cache:TestComponent_648b95::input-world")
== "<!-- _RENDERED TestComponent_648b95,a1bc3e,, -->Hello world"
) # noqa: E501
assert (
component.cache.get_entry("components:cache:TestComponent_648b95::input-cake")
== "<!-- _RENDERED TestComponent_648b95,a1bc3f,, -->Hello cake"
) # noqa: E501
def test_cache_input_hashing(self):
class TestComponent(Component):
template = "Hello"
class Cache:
enabled = True
component = TestComponent()
component.render(args=(1, 2), kwargs={"key": "value"})
# The key consists of `component._class_hash`, hashed args, and hashed kwargs
expected_key = "1,2:key-value"
assert component.cache.hash(1, 2, key="value") == expected_key
def test_override_hash_methods(self):
class TestComponent(Component):
template = "Hello"
class Cache:
enabled = True
def hash(self, *args, **kwargs):
# Custom hash method for args and kwargs
return "custom-args-and-kwargs"
def get_context_data(self, *args, **kwargs: Any):
return {}
component = TestComponent()
component.render(args=(1, 2), kwargs={"key": "value"})
# The key should use the custom hash methods
expected_key = "components:cache:TestComponent_28880f:custom-args-and-kwargs"
assert component.cache.get_cache_key(1, 2, key="value") == expected_key

View file

@ -20,6 +20,7 @@ from django_components.extension import (
OnComponentInputContext,
OnComponentDataContext,
)
from django_components.extensions.cache import CacheExtension
from django_components.extensions.defaults import DefaultsExtension
from django_components.extensions.view import ViewExtension
from django_components.extensions.url import UrlExtension
@ -127,11 +128,12 @@ def with_registry(on_created: Callable):
class TestExtension:
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_extensions_setting(self):
assert len(app_settings.EXTENSIONS) == 4
assert isinstance(app_settings.EXTENSIONS[0], DefaultsExtension)
assert isinstance(app_settings.EXTENSIONS[1], ViewExtension)
assert isinstance(app_settings.EXTENSIONS[2], UrlExtension)
assert isinstance(app_settings.EXTENSIONS[3], DummyExtension)
assert len(app_settings.EXTENSIONS) == 5
assert isinstance(app_settings.EXTENSIONS[0], CacheExtension)
assert isinstance(app_settings.EXTENSIONS[1], DefaultsExtension)
assert isinstance(app_settings.EXTENSIONS[2], ViewExtension)
assert isinstance(app_settings.EXTENSIONS[3], UrlExtension)
assert isinstance(app_settings.EXTENSIONS[4], DummyExtension)
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_access_component_from_extension(self):
@ -154,7 +156,7 @@ class TestExtension:
class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_component_class_lifecycle_hooks(self):
extension = cast(DummyExtension, app_settings.EXTENSIONS[3])
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
assert len(extension.calls["on_component_class_created"]) == 0
assert len(extension.calls["on_component_class_deleted"]) == 0
@ -186,7 +188,7 @@ class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_registry_lifecycle_hooks(self):
extension = cast(DummyExtension, app_settings.EXTENSIONS[3])
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
assert len(extension.calls["on_registry_created"]) == 0
assert len(extension.calls["on_registry_deleted"]) == 0
@ -223,7 +225,7 @@ class TestExtensionHooks:
return {"name": name}
registry.register("test_comp", TestComponent)
extension = cast(DummyExtension, app_settings.EXTENSIONS[3])
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
# Verify on_component_registered was called
assert len(extension.calls["on_component_registered"]) == 1
@ -261,7 +263,7 @@ class TestExtensionHooks:
test_slots = {"content": "Some content"}
TestComponent.render(context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots)
extension = cast(DummyExtension, app_settings.EXTENSIONS[3])
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
# Verify on_component_input was called with correct args
assert len(extension.calls["on_component_input"]) == 1