mirror of
https://github.com/django-components/django-components.git
synced 2025-09-19 04:09:44 +00:00
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:
parent
ef15117459
commit
b6994e9ad3
17 changed files with 655 additions and 45 deletions
26
CHANGELOG.md
26
CHANGELOG.md
|
@ -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.
|
||||
|
||||
|
|
18
README.md
18
README.md
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
105
docs/concepts/advanced/component_caching.md
Normal file
105
docs/concepts/advanced/component_caching.md
Normal 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.
|
|
@ -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:
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"] = []
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
|
140
src/django_components/extensions/cache.py
Normal file
140
src/django_components/extensions/cache.py
Normal 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)
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
209
tests/test_component_cache.py
Normal file
209
tests/test_component_cache.py
Normal 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
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue