feat: on_xx_loaded extension hooks (#1242)
Some checks failed
Run tests / test_sampleproject (3.13) (push) Has been cancelled
Run tests / build (ubuntu-latest, 3.13) (push) Has been cancelled
Run tests / build (ubuntu-latest, 3.8) (push) Has been cancelled
Run tests / build (ubuntu-latest, 3.9) (push) Has been cancelled
Run tests / build (windows-latest, 3.10) (push) Has been cancelled
Run tests / build (windows-latest, 3.11) (push) Has been cancelled
Run tests / build (windows-latest, 3.12) (push) Has been cancelled
Run tests / build (windows-latest, 3.13) (push) Has been cancelled
Run tests / build (windows-latest, 3.8) (push) Has been cancelled
Run tests / build (windows-latest, 3.9) (push) Has been cancelled
Run tests / test_docs (3.13) (push) Has been cancelled
Docs - build & deploy / docs (push) Has been cancelled
Run tests / build (ubuntu-latest, 3.10) (push) Has been cancelled
Run tests / build (ubuntu-latest, 3.11) (push) Has been cancelled
Run tests / build (ubuntu-latest, 3.12) (push) Has been cancelled

* feat: on_xx_loaded extension hooks

* refactor: fix tests
This commit is contained in:
Juro Oravec 2025-06-08 17:28:10 +02:00 committed by GitHub
parent efe5eb0ba5
commit 09bcf8dbcc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 720 additions and 113 deletions

View file

@ -1,5 +1,39 @@
# Release notes # Release notes
## v0.141.0
#### Feat
- New extension hooks `on_template_loaded`, `on_js_loaded`, `on_css_loaded`, and `on_template_compiled`
The first 3 hooks are called when Component's template / JS / CSS is loaded as a string.
The `on_template_compiled` hook is called when Component's template is compiled to a Template.
The `on_xx_loaded` hooks can modify the content by returning the new value.
```py
class MyExtension(ComponentExtension):
def on_template_loaded(self, ctx: OnTemplateLoadedContext) -> Optional[str]:
return ctx.content + "<!-- Hello! -->"
def on_js_loaded(self, ctx: OnJsLoadedContext) -> Optional[str]:
return ctx.content + "// Hello!"
def on_css_loaded(self, ctx: OnCssLoadedContext) -> Optional[str]:
return ctx.content + "/* Hello! */"
```
See all [Extension hooks](https://django-components.github.io/django-components/0.141.0/reference/extension_hooks/).
#### Fix
- Subclassing - Previously, if a parent component defined `Component.template` or `Component.template_file`, it's subclass would use the same `Template` instance.
This could lead to unexpected behavior, where a change to the template of the subclass would also change the template of the parent class.
Now, each subclass has it's own `Template` instance, and changes to the template of the subclass do not affect the template of the parent class.
## v0.140.1 ## v0.140.1
#### Fix #### Fix

View file

@ -148,6 +148,42 @@ name | type | description
`name` | `str` | The name the component was registered under `name` | `str` | The name the component was registered under
`registry` | [`ComponentRegistry`](../api#django_components.ComponentRegistry) | The registry the component was unregistered from `registry` | [`ComponentRegistry`](../api#django_components.ComponentRegistry) | The registry the component was unregistered from
::: django_components.extension.ComponentExtension.on_css_loaded
options:
heading_level: 3
show_root_heading: true
show_signature: true
separate_signature: true
show_symbol_type_heading: false
show_symbol_type_toc: false
show_if_no_docstring: true
show_labels: false
**Available data:**
name | type | description
--|--|--
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The Component class whose CSS was loaded
`content` | `str` | The CSS content (string)
::: django_components.extension.ComponentExtension.on_js_loaded
options:
heading_level: 3
show_root_heading: true
show_signature: true
separate_signature: true
show_symbol_type_heading: false
show_symbol_type_toc: false
show_if_no_docstring: true
show_labels: false
**Available data:**
name | type | description
--|--|--
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The Component class whose JS was loaded
`content` | `str` | The JS content (string)
::: django_components.extension.ComponentExtension.on_registry_created ::: django_components.extension.ComponentExtension.on_registry_created
options: options:
heading_level: 3 heading_level: 3
@ -207,6 +243,44 @@ name | type | description
`slot_name` | `str` | The name of the `{% slot %}` tag `slot_name` | `str` | The name of the `{% slot %}` tag
`slot_node` | `SlotNode` | The node instance of the `{% slot %}` tag `slot_node` | `SlotNode` | The node instance of the `{% slot %}` tag
::: django_components.extension.ComponentExtension.on_template_compiled
options:
heading_level: 3
show_root_heading: true
show_signature: true
separate_signature: true
show_symbol_type_heading: false
show_symbol_type_toc: false
show_if_no_docstring: true
show_labels: false
**Available data:**
name | type | description
--|--|--
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The Component class whose template was loaded
`template` | `django.template.base.Template` | The compiled template object
::: django_components.extension.ComponentExtension.on_template_loaded
options:
heading_level: 3
show_root_heading: true
show_signature: true
separate_signature: true
show_symbol_type_heading: false
show_symbol_type_toc: false
show_if_no_docstring: true
show_labels: false
**Available data:**
name | type | description
--|--|--
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The Component class whose template was loaded
`content` | `str` | The template string
`name` | `Optional[str]` | The name of the template
`origin` | `Optional[django.template.base.Origin]` | The origin of the template
## Objects ## Objects
::: django_components.extension.OnComponentClassCreatedContext ::: django_components.extension.OnComponentClassCreatedContext

View file

@ -32,7 +32,7 @@ from django_components.component_media import ComponentMediaInput, ComponentMedi
from django_components.component_registry import ComponentRegistry from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as registry_ from django_components.component_registry import registry as registry_
from django_components.constants import COMP_ID_PREFIX from django_components.constants import COMP_ID_PREFIX
from django_components.context import _COMPONENT_CONTEXT_KEY, make_isolated_context_copy from django_components.context import _COMPONENT_CONTEXT_KEY, COMPONENT_IS_NESTED_KEY, make_isolated_context_copy
from django_components.dependencies import ( from django_components.dependencies import (
DependenciesStrategy, DependenciesStrategy,
cache_component_css, cache_component_css,
@ -2314,9 +2314,6 @@ class Component(metaclass=ComponentMeta):
cls.class_id = hash_comp_cls(cls) cls.class_id = hash_comp_cls(cls)
comp_cls_id_mapping[cls.class_id] = cls comp_cls_id_mapping[cls.class_id] = cls
# Make sure that subclassed component will store it's own template, not the parent's.
cls._template = None
ALL_COMPONENTS.append(cached_ref(cls)) # type: ignore[arg-type] ALL_COMPONENTS.append(cached_ref(cls)) # type: ignore[arg-type]
extensions._init_component_class(cls) extensions._init_component_class(cls)
extensions.on_component_class_created(OnComponentClassCreatedContext(cls)) extensions.on_component_class_created(OnComponentClassCreatedContext(cls))
@ -3517,14 +3514,14 @@ class Component(metaclass=ComponentMeta):
# Then we can simply apply `template_data` to the context in the same layer # Then we can simply apply `template_data` to the context in the same layer
# where we apply `context_processor_data` and `component_vars`. # where we apply `context_processor_data` and `component_vars`.
with prepare_component_template(component, template_data) as template: with prepare_component_template(component, template_data) as template:
# Set `Template._djc_is_component_nested` based on whether we're currently INSIDE # Set `_DJC_COMPONENT_IS_NESTED` based on whether we're currently INSIDE
# the `{% extends %}` tag. # the `{% extends %}` tag.
# Part of fix for https://github.com/django-components/django-components/issues/508 # Part of fix for https://github.com/django-components/django-components/issues/508
# See django_monkeypatch.py # See django_monkeypatch.py
if template is not None: if template is not None:
template._djc_is_component_nested = bool( comp_is_nested = bool(context.render_context.get(BLOCK_CONTEXT_KEY)) # type: ignore[union-attr]
context.render_context.get(BLOCK_CONTEXT_KEY) # type: ignore[union-attr] else:
) comp_is_nested = False
# Capture the template name so we can print better error messages (currently used in slots) # Capture the template name so we can print better error messages (currently used in slots)
component_ctx.template_name = template.name if template else None component_ctx.template_name = template.name if template else None
@ -3535,6 +3532,7 @@ class Component(metaclass=ComponentMeta):
**component.context_processors_data, **component.context_processors_data,
# Private context fields # Private context fields
_COMPONENT_CONTEXT_KEY: render_id, _COMPONENT_CONTEXT_KEY: render_id,
COMPONENT_IS_NESTED_KEY: comp_is_nested,
# NOTE: Public API for variables accessible from within a component's template # NOTE: Public API for variables accessible from within a component's template
# See https://github.com/django-components/django-components/issues/280#issuecomment-2081180940 # See https://github.com/django-components/django-components/issues/280#issuecomment-2081180940
"component_vars": ComponentVars( "component_vars": ComponentVars(

View file

@ -27,10 +27,12 @@ from weakref import WeakKeyDictionary
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media as MediaCls from django.forms.widgets import Media as MediaCls
from django.template import Template
from django.utils.safestring import SafeData from django.utils.safestring import SafeData
from typing_extensions import TypeGuard from typing_extensions import TypeGuard
from django_components.template import load_component_template from django_components.extension import OnCssLoadedContext, OnJsLoadedContext, extensions
from django_components.template import ensure_unique_template, load_component_template
from django_components.util.loader import get_component_dirs, resolve_file from django_components.util.loader import get_component_dirs, resolve_file
from django_components.util.logger import logger from django_components.util.logger import logger
from django_components.util.misc import flatten, get_import_path, get_module_info, is_glob from django_components.util.misc import flatten, get_import_path, get_module_info, is_glob
@ -43,7 +45,7 @@ T = TypeVar("T")
# These are all the attributes that are handled by ComponentMedia and lazily-resolved # These are all the attributes that are handled by ComponentMedia and lazily-resolved
COMP_MEDIA_LAZY_ATTRS = ("media", "template", "template_file", "js", "js_file", "css", "css_file") COMP_MEDIA_LAZY_ATTRS = ("media", "template", "template_file", "js", "js_file", "css", "css_file", "_template")
# Sentinel value to indicate that a media attribute is not set. # Sentinel value to indicate that a media attribute is not set.
@ -267,6 +269,8 @@ class ComponentMedia:
js_file: Union[str, Unset, None] = UNSET js_file: Union[str, Unset, None] = UNSET
css: Union[str, Unset, None] = UNSET css: Union[str, Unset, None] = UNSET
css_file: Union[str, Unset, None] = UNSET css_file: Union[str, Unset, None] = UNSET
# Template instance that was loaded for this component
_template: Union[Template, Unset, None] = UNSET
def __post_init__(self) -> None: def __post_init__(self) -> None:
for inlined_attr in ("template", "js", "css"): for inlined_attr in ("template", "js", "css"):
@ -299,6 +303,7 @@ class ComponentMedia:
def reset(self) -> None: def reset(self) -> None:
self.__dict__.update(self._original.__dict__) self.__dict__.update(self._original.__dict__)
self.resolved = False self.resolved = False
self.resolved_relative_files = False
# This metaclass is all about one thing - lazily resolving the media files. # This metaclass is all about one thing - lazily resolving the media files.
@ -487,6 +492,10 @@ def _get_comp_cls_media(comp_cls: Type["Component"]) -> Any:
if curr_cls in media_cache: if curr_cls in media_cache:
continue continue
comp_media: Optional[ComponentMedia] = getattr(curr_cls, "_component_media", None)
if comp_media is not None and not comp_media.resolved:
_resolve_media(curr_cls, comp_media)
# Prepare base classes # Prepare base classes
# NOTE: If the `Component.Media` class is explicitly set to `None`, then we should not inherit # NOTE: If the `Component.Media` class is explicitly set to `None`, then we should not inherit
# from any parent classes. # from any parent classes.
@ -611,20 +620,39 @@ def _resolve_media(comp_cls: Type["Component"], comp_media: ComponentMedia) -> N
# Effectively, even if the Component class defined `js_file` (or others), at "runtime" the `js` attribute # Effectively, even if the Component class defined `js_file` (or others), at "runtime" the `js` attribute
# will be set to the content of the file. # will be set to the content of the file.
# So users can access `Component.js` even if they defined `Component.js_file`. # So users can access `Component.js` even if they defined `Component.js_file`.
comp_media.template = _get_asset( template_str, template_obj = _get_asset(
comp_cls, comp_cls,
comp_media, comp_media,
inlined_attr="template", inlined_attr="template",
file_attr="template_file", file_attr="template_file",
comp_dirs=comp_dirs, comp_dirs=comp_dirs,
type="template",
)
comp_media.js = _get_asset(
comp_cls, comp_media, inlined_attr="js", file_attr="js_file", comp_dirs=comp_dirs, type="static"
)
comp_media.css = _get_asset(
comp_cls, comp_media, inlined_attr="css", file_attr="css_file", comp_dirs=comp_dirs, type="static"
) )
comp_media.template = template_str
js_str, _ = _get_asset(comp_cls, comp_media, inlined_attr="js", file_attr="js_file", comp_dirs=comp_dirs)
comp_media.js = js_str
css_str, _ = _get_asset(comp_cls, comp_media, inlined_attr="css", file_attr="css_file", comp_dirs=comp_dirs)
comp_media.css = css_str
# If `Component.template` or `Component.template_file` were explicitly set on this class,
# then Template instance was already created.
#
# Otherwise, search for Template instance in parent classes, and make a copy of it.
if not isinstance(template_obj, Unset):
comp_media._template = template_obj
else:
parent_template = _get_comp_cls_attr(comp_cls, "_template")
# One of base classes has set `template` or `template_file` to `None`,
# or none of the base classes had set `template` or `template_file`
if parent_template is None:
comp_media._template = parent_template
# One of base classes has set `template` or `template_file` to string.
# Make a copy of the Template instance.
else:
comp_media._template = ensure_unique_template(comp_cls, parent_template)
def _normalize_media(media: Type[ComponentMediaInput]) -> None: def _normalize_media(media: Type[ComponentMediaInput]) -> None:
@ -973,11 +1001,10 @@ def _find_component_dir_containing_file(
def _get_asset( def _get_asset(
comp_cls: Type["Component"], comp_cls: Type["Component"],
comp_media: ComponentMedia, comp_media: ComponentMedia,
inlined_attr: str, inlined_attr: Literal["template", "js", "css"],
file_attr: str, file_attr: Literal["template_file", "js_file", "css_file"],
comp_dirs: List[Path], comp_dirs: List[Path],
type: Literal["template", "static"], ) -> Tuple[Union[str, Unset, None], Union[Template, Unset, None]]: # Tuple of (content, Template)
) -> Union[str, Unset, None]:
""" """
In case of Component's JS or CSS, one can either define that as "inlined" or as a file. In case of Component's JS or CSS, one can either define that as "inlined" or as a file.
@ -1010,7 +1037,7 @@ def _get_asset(
# pass # pass
# ``` # ```
if asset_content is UNSET and asset_file is UNSET: if asset_content is UNSET and asset_file is UNSET:
return UNSET return UNSET, UNSET
# Either file or content attr was set to `None` # Either file or content attr was set to `None`
# ```py # ```py
@ -1031,7 +1058,7 @@ def _get_asset(
if (asset_content in (UNSET, None) and asset_file is None) or ( if (asset_content in (UNSET, None) and asset_file is None) or (
asset_content is None and asset_file in (UNSET, None) asset_content is None and asset_file in (UNSET, None)
): ):
return None return None, None
# Received both inlined content and file name # Received both inlined content and file name
# ```py # ```py
@ -1061,42 +1088,68 @@ def _get_asset(
# At this point we can tell that only EITHER `asset_content` OR `asset_file` is set. # At this point we can tell that only EITHER `asset_content` OR `asset_file` is set.
# If the content was inlined into the component (e.g. `Component.template = "..."`) # If the content was inlined into the component (e.g. `Component.template = "..."`)
# then there's nothing to resolve. Return as is. # then there's nothing to resolve. Use it as is.
if asset_content is not UNSET: if not isinstance(asset_content, Unset):
return asset_content if asset_content is None:
return None, None
if asset_file is None: content: str = asset_content
return None
# The rest of the code assumes that we were given only a file name # If we got inlined `Component.template`, then create a Template instance from it
asset_file = cast(str, asset_file) # to trigger the extension hooks that may modify the template string.
if inlined_attr == "template":
# NOTE: `load_component_template()` applies `on_template_loaded()` and `on_template_compiled()` hooks.
template = load_component_template(comp_cls, filepath=None, content=content)
return template.source, template
if type == "template": # This else branch assumes that we were given a file name (possibly None)
# NOTE: While we return on the "source" (plain string) of the template, # Load the contents of the file.
# by calling `load_component_template()`, we also cache the Template instance. else:
# So later in Component's `render_impl()`, we don't have to re-compile the Template. if asset_file is None:
template = load_component_template(comp_cls, asset_file) return None, None
return template.source
# For static files, we have a few options: asset_file = cast(str, asset_file)
# 1. Check if the file is in one of the components' directories
full_path = resolve_file(asset_file, comp_dirs)
# 2. If not, check if it's in the static files if inlined_attr == "template":
if full_path is None: # NOTE: `load_component_template()` applies `on_template_loaded()` and `on_template_compiled()` hooks.
full_path = finders.find(asset_file) template = load_component_template(comp_cls, filepath=asset_file, content=None)
return template.source, template
if full_path is None: # Following code concerns with loading JS / CSS files.
# NOTE: The short name, e.g. `js` or `css` is used in the error message for convenience # Here we have a few options:
raise ValueError(f"Could not find {inlined_attr} file {asset_file}") #
# 1. Check if the file is in one of the components' directories
full_path = resolve_file(asset_file, comp_dirs)
# NOTE: Use explicit encoding for compat with Windows, see #1074 # 2. If not, check if it's in the static files
asset_content = Path(full_path).read_text(encoding="utf8") if full_path is None:
full_path = finders.find(asset_file)
# TODO: Apply `extensions.on_js_preprocess()` and `extensions.on_css_preprocess()` if full_path is None:
# NOTE: `on_template_preprocess()` is already applied inside `load_component_template()` # NOTE: The short name, e.g. `js` or `css` is used in the error message for convenience
raise ValueError(f"Could not find {inlined_attr} file {asset_file}")
return asset_content # NOTE: Use explicit encoding for compat with Windows, see #1074
content = Path(full_path).read_text(encoding="utf8")
# NOTE: `on_template_loaded()` is already applied inside `load_component_template()`
# but we still need to call extension hooks for JS / CSS content (whether inlined or not).
if inlined_attr == "js":
content = extensions.on_js_loaded(
OnJsLoadedContext(
component_cls=comp_cls,
content=content,
)
)
elif inlined_attr == "css":
content = extensions.on_css_loaded(
OnCssLoadedContext(
component_cls=comp_cls,
content=content,
)
)
return content, None
def is_set(value: Union[T, Unset, None]) -> TypeGuard[T]: def is_set(value: Union[T, Unset, None]) -> TypeGuard[T]:

View file

@ -11,6 +11,7 @@ from django.template import Context
from django_components.util.misc import get_last_index from django_components.util.misc import get_last_index
_COMPONENT_CONTEXT_KEY = "_DJC_COMPONENT_CTX" _COMPONENT_CONTEXT_KEY = "_DJC_COMPONENT_CTX"
COMPONENT_IS_NESTED_KEY = "_DJC_COMPONENT_IS_NESTED"
_STRATEGY_CONTEXT_KEY = "DJC_DEPS_STRATEGY" _STRATEGY_CONTEXT_KEY = "DJC_DEPS_STRATEGY"
_INJECT_CONTEXT_KEY_PREFIX = "_DJC_INJECT__" _INJECT_CONTEXT_KEY_PREFIX = "_DJC_INJECT__"

View file

@ -16,7 +16,7 @@ from typing import (
) )
import django.urls import django.urls
from django.template import Context from django.template import Context, Origin, Template
from django.urls import URLPattern, URLResolver, get_resolver, get_urlconf from django.urls import URLPattern, URLResolver, get_resolver, get_urlconf
from django_components.app_settings import app_settings from django_components.app_settings import app_settings
@ -168,6 +168,42 @@ class OnSlotRenderedContext(NamedTuple):
"""The rendered result of the slot""" """The rendered result of the slot"""
@mark_extension_hook_api
class OnTemplateLoadedContext(NamedTuple):
component_cls: Type["Component"]
"""The Component class whose template was loaded"""
content: str
"""The template string"""
origin: Optional[Origin]
"""The origin of the template"""
name: Optional[str]
"""The name of the template"""
@mark_extension_hook_api
class OnTemplateCompiledContext(NamedTuple):
component_cls: Type["Component"]
"""The Component class whose template was loaded"""
template: Template
"""The compiled template object"""
@mark_extension_hook_api
class OnCssLoadedContext(NamedTuple):
component_cls: Type["Component"]
"""The Component class whose CSS was loaded"""
content: str
"""The CSS content (string)"""
@mark_extension_hook_api
class OnJsLoadedContext(NamedTuple):
component_cls: Type["Component"]
"""The Component class whose JS was loaded"""
content: str
"""The JS content (string)"""
################################################ ################################################
# EXTENSIONS CORE # EXTENSIONS CORE
################################################ ################################################
@ -763,6 +799,108 @@ class ComponentExtension(metaclass=ExtensionMeta):
""" """
pass pass
##########################
# Template / JS / CSS hooks
##########################
def on_template_loaded(self, ctx: OnTemplateLoadedContext) -> Optional[str]:
"""
Called when a Component's template is loaded as a string.
This hook runs only once per [`Component`](../api#django_components.Component) class and works for both
[`Component.template`](../api#django_components.Component.template) and
[`Component.template_file`](../api#django_components.Component.template_file).
Use this hook to read or modify the template before it's compiled.
To modify the template, return a new string from this hook.
**Example:**
```python
from django_components import ComponentExtension, OnTemplateLoadedContext
class MyExtension(ComponentExtension):
def on_template_loaded(self, ctx: OnTemplateLoadedContext) -> Optional[str]:
# Modify the template
return ctx.content.replace("Hello", "Hi")
```
"""
pass
def on_template_compiled(self, ctx: OnTemplateCompiledContext) -> None:
"""
Called when a Component's template is compiled
into a [`Template`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Template) object.
This hook runs only once per [`Component`](../api#django_components.Component) class and works for both
[`Component.template`](../api#django_components.Component.template) and
[`Component.template_file`](../api#django_components.Component.template_file).
Use this hook to read or modify the template (in-place) after it's compiled.
**Example:**
```python
from django_components import ComponentExtension, OnTemplateCompiledContext
class MyExtension(ComponentExtension):
def on_template_compiled(self, ctx: OnTemplateCompiledContext) -> None:
print(f"Template origin: {ctx.template.origin.name}")
```
"""
pass
def on_css_loaded(self, ctx: OnCssLoadedContext) -> Optional[str]:
"""
Called when a Component's CSS is loaded as a string.
This hook runs only once per [`Component`](../api#django_components.Component) class and works for both
[`Component.css`](../api#django_components.Component.css) and
[`Component.css_file`](../api#django_components.Component.css_file).
Use this hook to read or modify the CSS.
To modify the CSS, return a new string from this hook.
**Example:**
```python
from django_components import ComponentExtension, OnCssLoadedContext
class MyExtension(ComponentExtension):
def on_css_loaded(self, ctx: OnCssLoadedContext) -> Optional[str]:
# Modify the CSS
return ctx.content.replace("Hello", "Hi")
```
"""
pass
def on_js_loaded(self, ctx: OnJsLoadedContext) -> Optional[str]:
"""
Called when a Component's JS is loaded as a string.
This hook runs only once per [`Component`](../api#django_components.Component) class and works for both
[`Component.js`](../api#django_components.Component.js) and
[`Component.js_file`](../api#django_components.Component.js_file).
Use this hook to read or modify the JS.
To modify the JS, return a new string from this hook.
**Example:**
```python
from django_components import ComponentExtension, OnCssLoadedContext
class MyExtension(ComponentExtension):
def on_js_loaded(self, ctx: OnJsLoadedContext) -> Optional[str]:
# Modify the JS
return ctx.content.replace("Hello", "Hi")
```
"""
pass
########################## ##########################
# Tags lifecycle hooks # Tags lifecycle hooks
########################## ##########################
@ -1169,9 +1307,34 @@ class ExtensionManager:
return ctx.result, ctx.error return ctx.result, ctx.error
########################## ##########################
# Tags lifecycle hooks # Template / JS / CSS hooks
########################## ##########################
def on_template_loaded(self, ctx: OnTemplateLoadedContext) -> str:
for extension in self.extensions:
content = extension.on_template_loaded(ctx)
if content is not None:
ctx = ctx._replace(content=content)
return ctx.content
def on_template_compiled(self, ctx: OnTemplateCompiledContext) -> None:
for extension in self.extensions:
extension.on_template_compiled(ctx)
def on_css_loaded(self, ctx: OnCssLoadedContext) -> str:
for extension in self.extensions:
content = extension.on_css_loaded(ctx)
if content is not None:
ctx = ctx._replace(content=content)
return ctx.content
def on_js_loaded(self, ctx: OnJsLoadedContext) -> str:
for extension in self.extensions:
content = extension.on_js_loaded(ctx)
if content is not None:
ctx = ctx._replace(content=content)
return ctx.content
def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]: def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]:
for extension in self.extensions: for extension in self.extensions:
result = extension.on_slot_rendered(ctx) result = extension.on_slot_rendered(ctx)

View file

@ -28,7 +28,7 @@ from django.utils.html import conditional_escape
from django.utils.safestring import SafeString, mark_safe from django.utils.safestring import SafeString, mark_safe
from django_components.app_settings import ContextBehavior from django_components.app_settings import ContextBehavior
from django_components.context import _COMPONENT_CONTEXT_KEY, _INJECT_CONTEXT_KEY_PREFIX from django_components.context import _COMPONENT_CONTEXT_KEY, _INJECT_CONTEXT_KEY_PREFIX, COMPONENT_IS_NESTED_KEY
from django_components.extension import OnSlotRenderedContext, extensions from django_components.extension import OnSlotRenderedContext, extensions
from django_components.node import BaseNode from django_components.node import BaseNode
from django_components.perfutil.component import component_context_cache from django_components.perfutil.component import component_context_cache
@ -1585,8 +1585,6 @@ def _nodelist_to_slot(
# and binds the context. # and binds the context.
template = Template("") template = Template("")
template.nodelist = nodelist template.nodelist = nodelist
# This allows the template to access current RenderContext layer.
template._djc_is_component_nested = True
def render_func(ctx: SlotContext) -> SlotResult: def render_func(ctx: SlotContext) -> SlotResult:
context = ctx.context or Context() context = ctx.context or Context()
@ -1639,10 +1637,12 @@ def _nodelist_to_slot(
trace_component_msg("RENDER_NODELIST", component_name, component_id=None, slot_name=slot_name) trace_component_msg("RENDER_NODELIST", component_name, component_id=None, slot_name=slot_name)
# We wrap the slot nodelist in Template. However, we also override Django's `Template.render()` # NOTE 1: We wrap the slot nodelist in Template. However, we also override Django's `Template.render()`
# to call `render_dependencies()` on the results. So we need to set the strategy to `ignore` # to call `render_dependencies()` on the results. So we need to set the strategy to `ignore`
# so that the dependencies are processed only once the whole component tree is rendered. # so that the dependencies are processed only once the whole component tree is rendered.
with context.push({"DJC_DEPS_STRATEGY": "ignore"}): # NOTE 2: We also set `_DJC_COMPONENT_IS_NESTED` to `True` so that the template can access
# current RenderContext layer.
with context.push({"DJC_DEPS_STRATEGY": "ignore", COMPONENT_IS_NESTED_KEY: True}):
rendered = template.render(context) rendered = template.render(context)
# After the rendering is done, remove the `extra_context` from the context stack # After the rendering is done, remove the `extra_context` from the context stack

View file

@ -1,6 +1,6 @@
import sys import sys
from contextlib import contextmanager from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Dict, Generator, List, Optional, Type, Union, cast from typing import TYPE_CHECKING, Any, Dict, Generator, List, Optional, Type, Union
from weakref import ReferenceType, ref from weakref import ReferenceType, ref
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
@ -170,30 +170,45 @@ def _maybe_bind_template(context: Context, template: Template) -> Generator[None
loading_components: List["ComponentRef"] = [] loading_components: List["ComponentRef"] = []
def load_component_template(component_cls: Type["Component"], filepath: str) -> Template: def load_component_template(
if component_cls._template is not None: component_cls: Type["Component"],
return component_cls._template filepath: Optional[str] = None,
content: Optional[str] = None,
) -> Template:
if filepath is None and content is None:
raise ValueError("Either `filepath` or `content` must be provided.")
loading_components.append(ref(component_cls)) loading_components.append(ref(component_cls))
# Use Django's `get_template()` to load the template if filepath is not None:
template = _load_django_template(filepath) # Use Django's `get_template()` to load the template file
template = _load_django_template(filepath)
template = ensure_unique_template(component_cls, template)
# If template.origin.component_cls is already set, then this elif content is not None:
# Template instance was cached by Django / template loaders. template = _create_template_from_string(component_cls, content, is_component_template=True)
# In that case we want to make a copy of the template which would else:
# be owned by the current Component class. raise ValueError("Received both `filepath` and `content`. These are mutually exclusive.")
# Thus each Component has it's own Template instance with their own Origins
# pointing to the correct Component class. loading_components.pop()
if get_component_from_origin(template.origin) is not None:
return template
# When loading a Template instance, it may be cached by Django / template loaders.
# In that case we want to make a copy of the template which would
# be owned by the current Component class.
# Thus each Component has it's own Template instance with their own Origins
# pointing to the correct Component class.
def ensure_unique_template(component_cls: Type["Component"], template: Template) -> Template:
# Use `template.origin.component_cls` to check if the template was cached by Django / template loaders.
if get_component_from_origin(template.origin) is None:
set_component_to_origin(template.origin, component_cls)
else:
origin_copy = Origin(template.origin.name, template.origin.template_name, template.origin.loader) origin_copy = Origin(template.origin.name, template.origin.template_name, template.origin.loader)
set_component_to_origin(origin_copy, component_cls) set_component_to_origin(origin_copy, component_cls)
template = Template(template.source, origin=origin_copy, name=template.name, engine=template.engine) template = Template(template.source, origin=origin_copy, name=template.name, engine=template.engine)
component_cls._template = template
loading_components.pop()
return template return template
@ -250,24 +265,13 @@ def _get_component_template(component: "Component") -> Optional[Template]:
template = None template = None
template_string = template_sources["get_template"] template_string = template_sources["get_template"]
elif component.template or component.template_file: elif component.template or component.template_file:
# If the template was loaded from `Component.template_file`, then the Template # If the template was loaded from `Component.template` or `Component.template_file`,
# instance was already created and cached in `Component._template`. # then the Template instance was already created and cached in `Component._template`.
# #
# NOTE: This is important to keep in mind, because the implication is that we should # NOTE: This is important to keep in mind, because the implication is that we should
# treat Templates AND their nodelists as IMMUTABLE. # treat Templates AND their nodelists as IMMUTABLE.
if component.__class__._template is not None: template = component.__class__._component_media._template # type: ignore[attr-defined]
template = component.__class__._template template_string = None
template_string = None
# Otherwise user have set `Component.template` as string and we still need to
# create the instance.
else:
template = _create_template_from_string(
component,
# NOTE: We can't reach this branch if `Component.template` is None
cast(str, component.template),
is_component_template=True,
)
template_string = None
# No template # No template
else: else:
template = None template = None
@ -278,7 +282,7 @@ def _get_component_template(component: "Component") -> Optional[Template]:
return template return template
# Create the template from the string # Create the template from the string
elif template_string is not None: elif template_string is not None:
return _create_template_from_string(component, template_string) return _create_template_from_string(component.__class__, template_string)
# Otherwise, Component has no template - this is valid, as it may be instead rendered # Otherwise, Component has no template - this is valid, as it may be instead rendered
# via `Component.on_render()` # via `Component.on_render()`
@ -286,7 +290,7 @@ def _get_component_template(component: "Component") -> Optional[Template]:
def _create_template_from_string( def _create_template_from_string(
component: "Component", component: Type["Component"],
template_string: str, template_string: str,
is_component_template: bool = False, is_component_template: bool = False,
) -> Template: ) -> Template:
@ -307,18 +311,17 @@ def _create_template_from_string(
# ``` # ```
# #
# See https://docs.djangoproject.com/en/5.2/howto/custom-template-backend/#template-origin-api # See https://docs.djangoproject.com/en/5.2/howto/custom-template-backend/#template-origin-api
_, _, module_filepath = get_module_info(component.__class__) _, _, module_filepath = get_module_info(component)
origin = Origin( origin = Origin(
name=f"{module_filepath}::{component.__class__.__name__}", name=f"{module_filepath}::{component.__name__}",
template_name=None, template_name=None,
loader=None, loader=None,
) )
set_component_to_origin(origin, component.__class__) set_component_to_origin(origin, component)
if is_component_template: if is_component_template:
template = Template(template_string, name=origin.template_name, origin=origin) template = Template(template_string, name=origin.template_name, origin=origin)
component.__class__._template = template
else: else:
# TODO_V1 - `cached_template()` won't be needed as there will be only 1 template per component # TODO_V1 - `cached_template()` won't be needed as there will be only 1 template per component
# so we will be able to instead use `template_cache` to store the template # so we will be able to instead use `template_cache` to store the template

View file

@ -3,21 +3,25 @@ from typing import Any, Optional, Type
from django.template import Context, NodeList, Template from django.template import Context, NodeList, Template
from django.template.base import Origin, Parser from django.template.base import Origin, Parser
from django_components.context import _COMPONENT_CONTEXT_KEY, _STRATEGY_CONTEXT_KEY from django_components.context import _COMPONENT_CONTEXT_KEY, _STRATEGY_CONTEXT_KEY, COMPONENT_IS_NESTED_KEY
from django_components.dependencies import COMPONENT_COMMENT_REGEX, render_dependencies from django_components.dependencies import COMPONENT_COMMENT_REGEX, render_dependencies
from django_components.extension import OnTemplateCompiledContext, OnTemplateLoadedContext, extensions
from django_components.util.template_parser import parse_template from django_components.util.template_parser import parse_template
# In some cases we can't work around Django's design, and need to patch the template class. # In some cases we can't work around Django's design, and need to patch the template class.
def monkeypatch_template_cls(template_cls: Type[Template]) -> None: def monkeypatch_template_cls(template_cls: Type[Template]) -> None:
if is_template_cls_patched(template_cls):
return
monkeypatch_template_init(template_cls) monkeypatch_template_init(template_cls)
monkeypatch_template_compile_nodelist(template_cls) monkeypatch_template_compile_nodelist(template_cls)
monkeypatch_template_render(template_cls) monkeypatch_template_render(template_cls)
template_cls._djc_patched = True template_cls._djc_patched = True
# Patch `Template.__init__` to apply `extensions.on_template_preprocess()` if the template # Patch `Template.__init__` to apply `on_template_loaded()` and `on_template_compiled()`
# belongs to a Component. # extension hooks if the template belongs to a Component.
def monkeypatch_template_init(template_cls: Type[Template]) -> None: def monkeypatch_template_init(template_cls: Type[Template]) -> None:
original_init = template_cls.__init__ original_init = template_cls.__init__
@ -27,6 +31,7 @@ def monkeypatch_template_init(template_cls: Type[Template]) -> None:
self: Template, self: Template,
template_string: Any, template_string: Any,
origin: Optional[Origin] = None, origin: Optional[Origin] = None,
name: Optional[str] = None,
*args: Any, *args: Any,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
@ -56,11 +61,27 @@ def monkeypatch_template_init(template_cls: Type[Template]) -> None:
component_cls = None component_cls = None
if component_cls is not None: if component_cls is not None:
# TODO - Apply extensions.on_template_preprocess() here. template_string = str(template_string)
# Then also test both cases when template as `template` or `template_file`. template_string = extensions.on_template_loaded(
pass OnTemplateLoadedContext(
component_cls=component_cls,
content=template_string,
origin=origin,
name=name,
)
)
original_init(self, template_string, origin, *args, **kwargs) # type: ignore[misc] # Calling original `Template.__init__` should also compile the template into a Nodelist
# via `Template.compile_nodelist()`.
original_init(self, template_string, origin, name, *args, **kwargs) # type: ignore[misc]
if component_cls is not None:
extensions.on_template_compiled(
OnTemplateCompiledContext(
component_cls=component_cls,
template=self,
)
)
template_cls.__init__ = __init__ template_cls.__init__ = __init__
@ -110,7 +131,7 @@ def monkeypatch_template_compile_nodelist(template_cls: Type[Template]) -> None:
def monkeypatch_template_render(template_cls: Type[Template]) -> None: def monkeypatch_template_render(template_cls: Type[Template]) -> None:
# Modify `Template.render` to set `isolated_context` kwarg of `push_state` # Modify `Template.render` to set `isolated_context` kwarg of `push_state`
# based on our custom `Template._djc_is_component_nested`. # based on our custom `_DJC_COMPONENT_IS_NESTED`.
# #
# Part of fix for https://github.com/django-components/django-components/issues/508 # Part of fix for https://github.com/django-components/django-components/issues/508
# #
@ -125,7 +146,7 @@ def monkeypatch_template_render(template_cls: Type[Template]) -> None:
# doesn't require the source to be parsed multiple times. User can pass extra args/kwargs, # doesn't require the source to be parsed multiple times. User can pass extra args/kwargs,
# and can modify the rendering behavior by overriding the `_render` method. # and can modify the rendering behavior by overriding the `_render` method.
# #
# NOTE 2: Instead of setting `Template._djc_is_component_nested`, alternatively we could # NOTE 2: Instead of setting `_DJC_COMPONENT_IS_NESTED` context key, alternatively we could
# have passed the value to `monkeypatch_template_render` directly. However, we intentionally # have passed the value to `monkeypatch_template_render` directly. However, we intentionally
# did NOT do that, so the monkey-patched method is more robust, and can be e.g. copied # did NOT do that, so the monkey-patched method is more robust, and can be e.g. copied
# to other. # to other.
@ -137,12 +158,12 @@ def monkeypatch_template_render(template_cls: Type[Template]) -> None:
def _template_render(self: Template, context: Context, *args: Any, **kwargs: Any) -> str: def _template_render(self: Template, context: Context, *args: Any, **kwargs: Any) -> str:
"Display stage -- can be called many times" "Display stage -- can be called many times"
# We parametrized `isolated_context`, which was `True` in the original method. # We parametrized `isolated_context`, which was `True` in the original method.
if not hasattr(self, "_djc_is_component_nested"): if COMPONENT_IS_NESTED_KEY not in context:
isolated_context = True isolated_context = True
else: else:
# MUST be `True` for templates that are NOT import with `{% extends %}` tag, # MUST be `True` for templates that are NOT import with `{% extends %}` tag,
# and `False` otherwise. # and `False` otherwise.
isolated_context = not self._djc_is_component_nested isolated_context = not context[COMPONENT_IS_NESTED_KEY]
# This is original implementation, except we override `isolated_context`, # This is original implementation, except we override `isolated_context`,
# and we post-process the result with `render_dependencies()`. # and we post-process the result with `render_dependencies()`.

View file

@ -46,7 +46,7 @@ class TestMainMedia:
rendered = TestComponent.render() rendered = TestComponent.render()
assertInHTML( assertInHTML(
'<div class="html-css-only" data-djc-id-ca1bc3e>Content</div>', '<div class="html-css-only" data-djc-id-ca1bc40>Content</div>',
rendered, rendered,
) )
assertInHTML( assertInHTML(
@ -70,6 +70,9 @@ class TestMainMedia:
assert TestComponent.css == ".html-css-only { color: blue; }" assert TestComponent.css == ".html-css-only { color: blue; }"
assert TestComponent.js == "console.log('HTML and JS only');" assert TestComponent.js == "console.log('HTML and JS only');"
assert isinstance(TestComponent._template, Template)
assert TestComponent._template.origin.component_cls is TestComponent
@djc_test( @djc_test(
django_settings={ django_settings={
"STATICFILES_DIRS": [ "STATICFILES_DIRS": [
@ -127,6 +130,9 @@ class TestMainMedia:
assert TestComponent.css == ".html-css-only {\n color: blue;\n}\n" assert TestComponent.css == ".html-css-only {\n color: blue;\n}\n"
assert TestComponent.js == 'console.log("JS file");\n' assert TestComponent.js == 'console.log("JS file");\n'
assert isinstance(TestComponent._template, Template)
assert TestComponent._template.origin.component_cls is TestComponent
@djc_test( @djc_test(
django_settings={ django_settings={
"STATICFILES_DIRS": [ "STATICFILES_DIRS": [
@ -151,6 +157,9 @@ class TestMainMedia:
assert ".html-css-only {\n color: blue;\n}" in TestComponent.css # type: ignore[operator] assert ".html-css-only {\n color: blue;\n}" in TestComponent.css # type: ignore[operator]
assert 'console.log("HTML and JS only");' in TestComponent.js # type: ignore[operator] assert 'console.log("HTML and JS only");' in TestComponent.js # type: ignore[operator]
assert isinstance(TestComponent._template, Template)
assert TestComponent._template.origin.component_cls is TestComponent
rendered = Template( rendered = Template(
""" """
{% load component_tags %} {% load component_tags %}
@ -199,6 +208,7 @@ class TestMainMedia:
# the corresponding ComponentMedia instance is also on the parent class. # the corresponding ComponentMedia instance is also on the parent class.
assert AppLvlCompComponent._component_media.css is UNSET # type: ignore[attr-defined] assert AppLvlCompComponent._component_media.css is UNSET # type: ignore[attr-defined]
assert AppLvlCompComponent._component_media.css_file == "app_lvl_comp.css" # type: ignore[attr-defined] assert AppLvlCompComponent._component_media.css_file == "app_lvl_comp.css" # type: ignore[attr-defined]
assert AppLvlCompComponent._component_media._template is UNSET # type: ignore[attr-defined]
# Access the property to load the CSS # Access the property to load the CSS
_ = TestComponent.css _ = TestComponent.css
@ -218,6 +228,9 @@ class TestMainMedia:
assert AppLvlCompComponent._component_media.js == 'console.log("JS file");\n' # type: ignore[attr-defined] assert AppLvlCompComponent._component_media.js == 'console.log("JS file");\n' # type: ignore[attr-defined]
assert AppLvlCompComponent._component_media.js_file == "app_lvl_comp/app_lvl_comp.js" # type: ignore[attr-defined] assert AppLvlCompComponent._component_media.js_file == "app_lvl_comp/app_lvl_comp.js" # type: ignore[attr-defined]
assert isinstance(AppLvlCompComponent._component_media._template, Template) # type: ignore[attr-defined]
assert AppLvlCompComponent._component_media._template.origin.component_cls is AppLvlCompComponent # type: ignore[attr-defined]
def test_html_variable_filtered(self): def test_html_variable_filtered(self):
class FilteredComponent(Component): class FilteredComponent(Component):
template: types.django_html = """ template: types.django_html = """
@ -1037,73 +1050,121 @@ class TestSubclassingAttributes:
class TestComp(Component): class TestComp(Component):
js = None js = None
js_file = None js_file = None
template = None
template_file = None
assert TestComp.js is None assert TestComp.js is None
assert TestComp.js_file is None assert TestComp.js_file is None
assert TestComp.template is None
assert TestComp.template_file is None
def test_mixing_none_and_non_none_raises(self): def test_mixing_none_and_non_none_raises(self):
with pytest.raises( with pytest.raises(
ImproperlyConfigured, ImproperlyConfigured,
match=re.escape("Received non-empty value from both 'js' and 'js_file' in Component TestComp"), match=re.escape("Received non-empty value from both 'template' and 'template_file' in Component TestComp"),
): ):
class TestComp(Component): class TestComp(Component):
js = "console.log('hi')" js = "console.log('hi')"
js_file = None js_file = None
template = "<h1>hi</h1>"
template_file = None
def test_both_non_none_raises(self): def test_both_non_none_raises(self):
with pytest.raises( with pytest.raises(
ImproperlyConfigured, ImproperlyConfigured,
match=re.escape("Received non-empty value from both 'js' and 'js_file' in Component TestComp"), match=re.escape("Received non-empty value from both 'template' and 'template_file' in Component TestComp"),
): ):
class TestComp(Component): class TestComp(Component):
js = "console.log('hi')" js = "console.log('hi')"
js_file = "file.js" js_file = "file.js"
template = "<h1>hi</h1>"
template_file = "file.html"
def test_parent_non_null_child_non_null(self): def test_parent_non_null_child_non_null(self):
class ParentComp(Component): class ParentComp(Component):
js = "console.log('parent')" js = "console.log('parent')"
template = "<h1>parent</h1>"
class TestComp(ParentComp): class TestComp(ParentComp):
js = "console.log('child')" js = "console.log('child')"
template = "<h1>child</h1>"
assert TestComp.js == "console.log('child')" assert TestComp.js == "console.log('child')"
assert TestComp.js_file is None assert TestComp.js_file is None
assert TestComp.template == "<h1>child</h1>"
assert TestComp.template_file is None
assert isinstance(ParentComp._template, Template)
assert ParentComp._template.source == "<h1>parent</h1>"
assert ParentComp._template.origin.component_cls == ParentComp
assert isinstance(TestComp._template, Template)
assert TestComp._template.source == "<h1>child</h1>"
assert TestComp._template.origin.component_cls == TestComp
def test_parent_null_child_non_null(self): def test_parent_null_child_non_null(self):
class ParentComp(Component): class ParentComp(Component):
js = None js = None
template = None
class TestComp(ParentComp): class TestComp(ParentComp):
js = "console.log('child')" js = "console.log('child')"
template = "<h1>child</h1>"
assert TestComp.js == "console.log('child')" assert TestComp.js == "console.log('child')"
assert TestComp.js_file is None assert TestComp.js_file is None
assert TestComp.template == "<h1>child</h1>"
assert TestComp.template_file is None
assert ParentComp._template is None
assert isinstance(TestComp._template, Template)
assert TestComp._template.source == "<h1>child</h1>"
assert TestComp._template.origin.component_cls == TestComp
def test_parent_non_null_child_null(self): def test_parent_non_null_child_null(self):
class ParentComp(Component): class ParentComp(Component):
js: Optional[str] = "console.log('parent')" js: Optional[str] = "console.log('parent')"
template: Optional[str] = "<h1>parent</h1>"
class TestComp(ParentComp): class TestComp(ParentComp):
js = None js = None
template = None
assert TestComp.js is None assert TestComp.js is None
assert TestComp.js_file is None assert TestComp.js_file is None
assert TestComp.template is None
assert TestComp.template_file is None
assert TestComp._template is None
assert isinstance(ParentComp._template, Template)
assert ParentComp._template.source == "<h1>parent</h1>"
assert ParentComp._template.origin.component_cls == ParentComp
def test_parent_null_child_null(self): def test_parent_null_child_null(self):
class ParentComp(Component): class ParentComp(Component):
js = None js = None
template = None
class TestComp(ParentComp): class TestComp(ParentComp):
js = None js = None
template = None
assert TestComp.js is None assert TestComp.js is None
assert TestComp.js_file is None assert TestComp.js_file is None
assert TestComp.template is None
assert TestComp.template_file is None
assert TestComp._template is None
assert ParentComp._template is None
def test_grandparent_non_null_parent_pass_child_pass(self): def test_grandparent_non_null_parent_pass_child_pass(self):
class GrandParentComp(Component): class GrandParentComp(Component):
js = "console.log('grandparent')" js = "console.log('grandparent')"
template = "<h1>grandparent</h1>"
class ParentComp(GrandParentComp): class ParentComp(GrandParentComp):
pass pass
@ -1113,45 +1174,97 @@ class TestSubclassingAttributes:
assert TestComp.js == "console.log('grandparent')" assert TestComp.js == "console.log('grandparent')"
assert TestComp.js_file is None assert TestComp.js_file is None
assert TestComp.template == "<h1>grandparent</h1>"
assert TestComp.template_file is None
assert isinstance(GrandParentComp._template, Template)
assert GrandParentComp._template.source == "<h1>grandparent</h1>"
assert GrandParentComp._template.origin.component_cls == GrandParentComp
assert isinstance(ParentComp._template, Template)
assert ParentComp._template.source == "<h1>grandparent</h1>"
assert ParentComp._template.origin.component_cls == ParentComp
assert isinstance(TestComp._template, Template)
assert TestComp._template.source == "<h1>grandparent</h1>"
assert TestComp._template.origin.component_cls == TestComp
def test_grandparent_non_null_parent_null_child_pass(self): def test_grandparent_non_null_parent_null_child_pass(self):
class GrandParentComp(Component): class GrandParentComp(Component):
js: Optional[str] = "console.log('grandparent')" js: Optional[str] = "console.log('grandparent')"
template: Optional[str] = "<h1>grandparent</h1>"
class ParentComp(GrandParentComp): class ParentComp(GrandParentComp):
js = None js = None
template = None
class TestComp(ParentComp): class TestComp(ParentComp):
pass pass
assert TestComp.js is None assert TestComp.js is None
assert TestComp.js_file is None assert TestComp.js_file is None
assert TestComp.template is None
assert TestComp.template_file is None
assert isinstance(GrandParentComp._template, Template)
assert GrandParentComp._template.source == "<h1>grandparent</h1>"
assert GrandParentComp._template.origin.component_cls == GrandParentComp
assert ParentComp._template is None
assert TestComp._template is None
def test_grandparent_non_null_parent_pass_child_non_null(self): def test_grandparent_non_null_parent_pass_child_non_null(self):
class GrandParentComp(Component): class GrandParentComp(Component):
js = "console.log('grandparent')" js = "console.log('grandparent')"
template = "<h1>grandparent</h1>"
class ParentComp(GrandParentComp): class ParentComp(GrandParentComp):
pass pass
class TestComp(ParentComp): class TestComp(ParentComp):
js = "console.log('child')" js = "console.log('child')"
template = "<h1>child</h1>"
assert TestComp.js == "console.log('child')" assert TestComp.js == "console.log('child')"
assert TestComp.js_file is None assert TestComp.js_file is None
assert TestComp.template == "<h1>child</h1>"
assert TestComp.template_file is None
assert isinstance(GrandParentComp._template, Template)
assert GrandParentComp._template.source == "<h1>grandparent</h1>"
assert GrandParentComp._template.origin.component_cls == GrandParentComp
assert isinstance(ParentComp._template, Template)
assert ParentComp._template.source == "<h1>grandparent</h1>"
assert ParentComp._template.origin.component_cls == ParentComp
assert isinstance(TestComp._template, Template)
assert TestComp._template.source == "<h1>child</h1>"
assert TestComp._template.origin.component_cls == TestComp
def test_grandparent_null_parent_pass_child_non_null(self): def test_grandparent_null_parent_pass_child_non_null(self):
class GrandParentComp(Component): class GrandParentComp(Component):
js = None js = None
template = None
class ParentComp(GrandParentComp): class ParentComp(GrandParentComp):
pass pass
class TestComp(ParentComp): class TestComp(ParentComp):
js = "console.log('child')" js = "console.log('child')"
template = "<h1>child</h1>"
assert TestComp.js == "console.log('child')" assert TestComp.js == "console.log('child')"
assert TestComp.js_file is None assert TestComp.js_file is None
assert TestComp.template == "<h1>child</h1>"
assert TestComp.template_file is None
assert GrandParentComp._template is None
assert ParentComp._template is None
assert isinstance(TestComp._template, Template)
assert TestComp._template.source == "<h1>child</h1>"
assert TestComp._template.origin.component_cls == TestComp
@djc_test @djc_test

View file

@ -3,7 +3,7 @@ from typing import Any, Callable, Dict, List, cast
import pytest import pytest
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.template import Context from django.template import Context, Origin, Template
from django.test import Client from django.test import Client
from django_components import Component, Slot, SlotNode, register, registry from django_components import Component, Slot, SlotNode, register, registry
@ -23,6 +23,10 @@ from django_components.extension import (
OnComponentDataContext, OnComponentDataContext,
OnComponentRenderedContext, OnComponentRenderedContext,
OnSlotRenderedContext, OnSlotRenderedContext,
OnTemplateLoadedContext,
OnTemplateCompiledContext,
OnJsLoadedContext,
OnCssLoadedContext,
) )
from django_components.extensions.cache import CacheExtension from django_components.extensions.cache import CacheExtension
from django_components.extensions.debug_highlight import DebugHighlightExtension from django_components.extensions.debug_highlight import DebugHighlightExtension
@ -85,6 +89,10 @@ class DummyExtension(ComponentExtension):
"on_component_data": [], "on_component_data": [],
"on_component_rendered": [], "on_component_rendered": [],
"on_slot_rendered": [], "on_slot_rendered": [],
"on_template_loaded": [],
"on_template_compiled": [],
"on_js_loaded": [],
"on_css_loaded": [],
} }
urls = [ urls = [
@ -126,6 +134,18 @@ class DummyExtension(ComponentExtension):
def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> None: def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> None:
self.calls["on_slot_rendered"].append(ctx) self.calls["on_slot_rendered"].append(ctx)
def on_template_loaded(self, ctx):
self.calls["on_template_loaded"].append(ctx)
def on_template_compiled(self, ctx):
self.calls["on_template_compiled"].append(ctx)
def on_js_loaded(self, ctx):
self.calls["on_js_loaded"].append(ctx)
def on_css_loaded(self, ctx):
self.calls["on_css_loaded"].append(ctx)
class DummyNestedExtension(ComponentExtension): class DummyNestedExtension(ComponentExtension):
name = "test_nested_extension" name = "test_nested_extension"
@ -182,6 +202,19 @@ def with_registry(on_created: Callable):
on_created(registry) on_created(registry)
class OverrideAssetExtension(ComponentExtension):
name = "override_asset_extension"
def on_template_loaded(self, ctx):
return "OVERRIDDEN TEMPLATE"
def on_js_loaded(self, ctx):
return "OVERRIDDEN JS"
def on_css_loaded(self, ctx):
return "OVERRIDDEN CSS"
@djc_test @djc_test
class TestExtension: class TestExtension:
@djc_test(components_settings={"extensions": [DummyExtension]}) @djc_test(components_settings={"extensions": [DummyExtension]})
@ -469,6 +502,120 @@ class TestExtensionHooks:
rendered = TestComponent.render(args=(), kwargs={"name": "Test"}) rendered = TestComponent.render(args=(), kwargs={"name": "Test"})
assert rendered == "<div>OVERRIDDEN: Hello Test!</div>" assert rendered == "<div>OVERRIDDEN: Hello Test!</div>"
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_asset_hooks__inlined(self):
@register("test_comp_hooks")
class TestComponent(Component):
template = "Hello {{ name }}!"
js = "console.log('hi');"
css = "body { color: red; }"
def get_template_data(self, args, kwargs, slots, context):
return {"name": kwargs.get("name", "World")}
# Render the component to trigger all hooks
TestComponent.render(args=(), kwargs={"name": "Test"})
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
# on_template_loaded
assert len(extension.calls["on_template_loaded"]) == 1
ctx1: OnTemplateLoadedContext = extension.calls["on_template_loaded"][0]
assert ctx1.component_cls == TestComponent
assert ctx1.content == "Hello {{ name }}!"
assert isinstance(ctx1.origin, Origin)
assert ctx1.origin.name.endswith("test_extension.py::TestComponent")
assert ctx1.name is None
# on_template_compiled
assert len(extension.calls["on_template_compiled"]) == 1
ctx2: OnTemplateCompiledContext = extension.calls["on_template_compiled"][0]
assert ctx2.component_cls == TestComponent
assert isinstance(ctx2.template, Template)
# on_js_loaded
assert len(extension.calls["on_js_loaded"]) == 1
ctx3: OnJsLoadedContext = extension.calls["on_js_loaded"][0]
assert ctx3.component_cls == TestComponent
assert ctx3.content == "console.log('hi');"
# on_css_loaded
assert len(extension.calls["on_css_loaded"]) == 1
ctx4: OnCssLoadedContext = extension.calls["on_css_loaded"][0]
assert ctx4.component_cls == TestComponent
assert ctx4.content == "body { color: red; }"
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_asset_hooks__file(self):
@register("test_comp_hooks")
class TestComponent(Component):
template_file = "relative_file/relative_file.html"
js_file = "relative_file/relative_file.js"
css_file = "relative_file/relative_file.css"
def get_template_data(self, args, kwargs, slots, context):
return {"name": kwargs.get("name", "World")}
# Render the component to trigger all hooks
TestComponent.render(args=(), kwargs={"name": "Test"})
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
# on_template_loaded
# NOTE: The template file gets picked up by 'django.template.loaders.filesystem.Loader',
# as well as our own loader, so we get two calls here.
assert len(extension.calls["on_template_loaded"]) == 2
ctx1: OnTemplateLoadedContext = extension.calls["on_template_loaded"][0]
assert ctx1.component_cls == TestComponent
assert ctx1.content == (
'<form method="post">\n'
' {% csrf_token %}\n'
' <input type="text" name="variable" value="{{ variable }}">\n'
' <input type="submit">\n'
'</form>\n'
)
assert isinstance(ctx1.origin, Origin)
assert ctx1.origin.name.endswith("relative_file.html")
assert ctx1.name == "relative_file/relative_file.html"
# on_template_compiled
assert len(extension.calls["on_template_compiled"]) == 2
ctx2: OnTemplateCompiledContext = extension.calls["on_template_compiled"][0]
assert ctx2.component_cls == TestComponent
assert isinstance(ctx2.template, Template)
# on_js_loaded
assert len(extension.calls["on_js_loaded"]) == 1
ctx3: OnJsLoadedContext = extension.calls["on_js_loaded"][0]
assert ctx3.component_cls == TestComponent
assert ctx3.content == 'console.log("JS file");\n'
# on_css_loaded
assert len(extension.calls["on_css_loaded"]) == 1
ctx4: OnCssLoadedContext = extension.calls["on_css_loaded"][0]
assert ctx4.component_cls == TestComponent
assert ctx4.content == (
'.html-css-only {\n'
' color: blue;\n'
'}\n'
)
@djc_test(components_settings={"extensions": [OverrideAssetExtension]})
def test_asset_hooks_override(self):
@register("test_comp_override")
class TestComponent(Component):
template = "Hello {{ name }}!"
js = "console.log('hi');"
css = "body { color: red; }"
def get_template_data(self, args, kwargs, slots, context):
return {"name": kwargs.get("name", "World")}
# No need to render, accessing the attributes should trigger the hooks
assert TestComponent.template == "OVERRIDDEN TEMPLATE"
assert TestComponent.js == "OVERRIDDEN JS"
assert TestComponent.css == "OVERRIDDEN CSS"
@djc_test @djc_test
class TestExtensionViews: class TestExtensionViews: