From 09bcf8dbccd924da8f2e50542b599b5b132545da Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Sun, 8 Jun 2025 17:28:10 +0200 Subject: [PATCH] feat: on_xx_loaded extension hooks (#1242) * feat: on_xx_loaded extension hooks * refactor: fix tests --- CHANGELOG.md | 34 ++++ docs/reference/extension_hooks.md | 74 ++++++++ src/django_components/component.py | 14 +- src/django_components/component_media.py | 139 ++++++++++----- src/django_components/context.py | 1 + src/django_components/extension.py | 167 +++++++++++++++++- src/django_components/slots.py | 14 +- src/django_components/template.py | 79 +++++---- .../util/django_monkeypatch.py | 43 +++-- tests/test_component_media.py | 119 ++++++++++++- tests/test_extension.py | 149 +++++++++++++++- 11 files changed, 720 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d096e52..76ec4614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # 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 + "" + + 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 #### Fix diff --git a/docs/reference/extension_hooks.md b/docs/reference/extension_hooks.md index 43090eb0..e9f95c07 100644 --- a/docs/reference/extension_hooks.md +++ b/docs/reference/extension_hooks.md @@ -148,6 +148,42 @@ name | type | description `name` | `str` | The name the component was registered under `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 options: heading_level: 3 @@ -207,6 +243,44 @@ name | type | description `slot_name` | `str` | The name 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 ::: django_components.extension.OnComponentClassCreatedContext diff --git a/src/django_components/component.py b/src/django_components/component.py index 7caee9da..e0f84310 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -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 registry as registry_ 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 ( DependenciesStrategy, cache_component_css, @@ -2314,9 +2314,6 @@ class Component(metaclass=ComponentMeta): cls.class_id = hash_comp_cls(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] extensions._init_component_class(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 # where we apply `context_processor_data` and `component_vars`. 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. # Part of fix for https://github.com/django-components/django-components/issues/508 # See django_monkeypatch.py if template is not None: - template._djc_is_component_nested = bool( - context.render_context.get(BLOCK_CONTEXT_KEY) # type: ignore[union-attr] - ) + comp_is_nested = bool(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) component_ctx.template_name = template.name if template else None @@ -3535,6 +3532,7 @@ class Component(metaclass=ComponentMeta): **component.context_processors_data, # Private context fields _COMPONENT_CONTEXT_KEY: render_id, + COMPONENT_IS_NESTED_KEY: comp_is_nested, # NOTE: Public API for variables accessible from within a component's template # See https://github.com/django-components/django-components/issues/280#issuecomment-2081180940 "component_vars": ComponentVars( diff --git a/src/django_components/component_media.py b/src/django_components/component_media.py index 42442d83..d3957c78 100644 --- a/src/django_components/component_media.py +++ b/src/django_components/component_media.py @@ -27,10 +27,12 @@ from weakref import WeakKeyDictionary from django.contrib.staticfiles import finders from django.core.exceptions import ImproperlyConfigured from django.forms.widgets import Media as MediaCls +from django.template import Template from django.utils.safestring import SafeData 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.logger import logger 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 -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. @@ -267,6 +269,8 @@ class ComponentMedia: js_file: Union[str, Unset, None] = UNSET css: 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: for inlined_attr in ("template", "js", "css"): @@ -299,6 +303,7 @@ class ComponentMedia: def reset(self) -> None: self.__dict__.update(self._original.__dict__) self.resolved = False + self.resolved_relative_files = False # 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: 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 # NOTE: If the `Component.Media` class is explicitly set to `None`, then we should not inherit # 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 # will be set to the content of the 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_media, inlined_attr="template", file_attr="template_file", 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: @@ -973,11 +1001,10 @@ def _find_component_dir_containing_file( def _get_asset( comp_cls: Type["Component"], comp_media: ComponentMedia, - inlined_attr: str, - file_attr: str, + inlined_attr: Literal["template", "js", "css"], + file_attr: Literal["template_file", "js_file", "css_file"], comp_dirs: List[Path], - type: Literal["template", "static"], -) -> Union[str, Unset, None]: +) -> Tuple[Union[str, Unset, None], Union[Template, Unset, None]]: # Tuple of (content, Template) """ 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 # ``` if asset_content is UNSET and asset_file is UNSET: - return UNSET + return UNSET, UNSET # Either file or content attr was set to `None` # ```py @@ -1031,7 +1058,7 @@ def _get_asset( if (asset_content in (UNSET, None) and asset_file is None) or ( asset_content is None and asset_file in (UNSET, None) ): - return None + return None, None # Received both inlined content and file name # ```py @@ -1061,42 +1088,68 @@ def _get_asset( # 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 = "..."`) - # then there's nothing to resolve. Return as is. - if asset_content is not UNSET: - return asset_content + # then there's nothing to resolve. Use it as is. + if not isinstance(asset_content, Unset): + if asset_content is None: + return None, None - if asset_file is None: - return None + content: str = asset_content - # The rest of the code assumes that we were given only a file name - asset_file = cast(str, asset_file) + # If we got inlined `Component.template`, then create a Template instance from it + # 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": - # NOTE: While we return on the "source" (plain string) of the template, - # by calling `load_component_template()`, we also cache the Template instance. - # So later in Component's `render_impl()`, we don't have to re-compile the Template. - template = load_component_template(comp_cls, asset_file) - return template.source + # This else branch assumes that we were given a file name (possibly None) + # Load the contents of the file. + else: + if asset_file is None: + return None, None - # For static files, we have a few options: - # 1. Check if the file is in one of the components' directories - full_path = resolve_file(asset_file, comp_dirs) + asset_file = cast(str, asset_file) - # 2. If not, check if it's in the static files - if full_path is None: - full_path = finders.find(asset_file) + if inlined_attr == "template": + # NOTE: `load_component_template()` applies `on_template_loaded()` and `on_template_compiled()` hooks. + template = load_component_template(comp_cls, filepath=asset_file, content=None) + return template.source, template - if full_path is None: - # 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}") + # Following code concerns with loading JS / CSS files. + # Here we have a few options: + # + # 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 - asset_content = Path(full_path).read_text(encoding="utf8") + # 2. If not, check if it's in the static files + if full_path is None: + full_path = finders.find(asset_file) - # TODO: Apply `extensions.on_js_preprocess()` and `extensions.on_css_preprocess()` - # NOTE: `on_template_preprocess()` is already applied inside `load_component_template()` + if full_path is None: + # 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]: diff --git a/src/django_components/context.py b/src/django_components/context.py index 6dfe7cf6..71cb0c61 100644 --- a/src/django_components/context.py +++ b/src/django_components/context.py @@ -11,6 +11,7 @@ from django.template import Context from django_components.util.misc import get_last_index _COMPONENT_CONTEXT_KEY = "_DJC_COMPONENT_CTX" +COMPONENT_IS_NESTED_KEY = "_DJC_COMPONENT_IS_NESTED" _STRATEGY_CONTEXT_KEY = "DJC_DEPS_STRATEGY" _INJECT_CONTEXT_KEY_PREFIX = "_DJC_INJECT__" diff --git a/src/django_components/extension.py b/src/django_components/extension.py index 17750218..75ee2b5b 100644 --- a/src/django_components/extension.py +++ b/src/django_components/extension.py @@ -16,7 +16,7 @@ from typing import ( ) 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_components.app_settings import app_settings @@ -168,6 +168,42 @@ class OnSlotRenderedContext(NamedTuple): """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 ################################################ @@ -763,6 +799,108 @@ class ComponentExtension(metaclass=ExtensionMeta): """ 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 ########################## @@ -1169,9 +1307,34 @@ class ExtensionManager: 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]: for extension in self.extensions: result = extension.on_slot_rendered(ctx) diff --git a/src/django_components/slots.py b/src/django_components/slots.py index dfc653f0..69c60b10 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -28,7 +28,7 @@ from django.utils.html import conditional_escape from django.utils.safestring import SafeString, mark_safe 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.node import BaseNode from django_components.perfutil.component import component_context_cache @@ -1585,8 +1585,6 @@ def _nodelist_to_slot( # and binds the context. template = Template("") template.nodelist = nodelist - # This allows the template to access current RenderContext layer. - template._djc_is_component_nested = True def render_func(ctx: SlotContext) -> SlotResult: 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) - # 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` - # so that the dependencies are processed only once the whole component tree is rendered. - with context.push({"DJC_DEPS_STRATEGY": "ignore"}): + # 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` + # so that the dependencies are processed only once the whole component tree is rendered. + # 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) # After the rendering is done, remove the `extra_context` from the context stack diff --git a/src/django_components/template.py b/src/django_components/template.py index 24abce6d..838ed26f 100644 --- a/src/django_components/template.py +++ b/src/django_components/template.py @@ -1,6 +1,6 @@ import sys 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 django.core.exceptions import ImproperlyConfigured @@ -170,30 +170,45 @@ def _maybe_bind_template(context: Context, template: Template) -> Generator[None loading_components: List["ComponentRef"] = [] -def load_component_template(component_cls: Type["Component"], filepath: str) -> Template: - if component_cls._template is not None: - return component_cls._template +def load_component_template( + component_cls: Type["Component"], + 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)) - # Use Django's `get_template()` to load the template - template = _load_django_template(filepath) + if filepath is not None: + # 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 - # Template instance was 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. - if get_component_from_origin(template.origin) is not None: + elif content is not None: + template = _create_template_from_string(component_cls, content, is_component_template=True) + else: + raise ValueError("Received both `filepath` and `content`. These are mutually exclusive.") + + loading_components.pop() + + 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) set_component_to_origin(origin_copy, component_cls) template = Template(template.source, origin=origin_copy, name=template.name, engine=template.engine) - component_cls._template = template - - loading_components.pop() - return template @@ -250,24 +265,13 @@ def _get_component_template(component: "Component") -> Optional[Template]: template = None template_string = template_sources["get_template"] elif component.template or component.template_file: - # If the template was loaded from `Component.template_file`, then the Template - # instance was already created and cached in `Component._template`. + # If the template was loaded from `Component.template` or `Component.template_file`, + # 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 # treat Templates AND their nodelists as IMMUTABLE. - if component.__class__._template is not None: - template = component.__class__._template - 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 + template = component.__class__._component_media._template # type: ignore[attr-defined] + template_string = None # No template else: template = None @@ -278,7 +282,7 @@ def _get_component_template(component: "Component") -> Optional[Template]: return template # Create the template from the string 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 # via `Component.on_render()` @@ -286,7 +290,7 @@ def _get_component_template(component: "Component") -> Optional[Template]: def _create_template_from_string( - component: "Component", + component: Type["Component"], template_string: str, is_component_template: bool = False, ) -> 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 - _, _, module_filepath = get_module_info(component.__class__) + _, _, module_filepath = get_module_info(component) origin = Origin( - name=f"{module_filepath}::{component.__class__.__name__}", + name=f"{module_filepath}::{component.__name__}", template_name=None, loader=None, ) - set_component_to_origin(origin, component.__class__) + set_component_to_origin(origin, component) if is_component_template: template = Template(template_string, name=origin.template_name, origin=origin) - component.__class__._template = template else: # 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 diff --git a/src/django_components/util/django_monkeypatch.py b/src/django_components/util/django_monkeypatch.py index 6ee295b0..27b983e2 100644 --- a/src/django_components/util/django_monkeypatch.py +++ b/src/django_components/util/django_monkeypatch.py @@ -3,21 +3,25 @@ from typing import Any, Optional, Type from django.template import Context, NodeList, Template 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.extension import OnTemplateCompiledContext, OnTemplateLoadedContext, extensions 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. def monkeypatch_template_cls(template_cls: Type[Template]) -> None: + if is_template_cls_patched(template_cls): + return + monkeypatch_template_init(template_cls) monkeypatch_template_compile_nodelist(template_cls) monkeypatch_template_render(template_cls) template_cls._djc_patched = True -# Patch `Template.__init__` to apply `extensions.on_template_preprocess()` if the template -# belongs to a Component. +# Patch `Template.__init__` to apply `on_template_loaded()` and `on_template_compiled()` +# extension hooks if the template belongs to a Component. def monkeypatch_template_init(template_cls: Type[Template]) -> None: original_init = template_cls.__init__ @@ -27,6 +31,7 @@ def monkeypatch_template_init(template_cls: Type[Template]) -> None: self: Template, template_string: Any, origin: Optional[Origin] = None, + name: Optional[str] = None, *args: Any, **kwargs: Any, ) -> None: @@ -56,11 +61,27 @@ def monkeypatch_template_init(template_cls: Type[Template]) -> None: component_cls = None if component_cls is not None: - # TODO - Apply extensions.on_template_preprocess() here. - # Then also test both cases when template as `template` or `template_file`. - pass + template_string = str(template_string) + template_string = extensions.on_template_loaded( + 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__ @@ -110,7 +131,7 @@ def monkeypatch_template_compile_nodelist(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` - # 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 # @@ -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, # 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 # did NOT do that, so the monkey-patched method is more robust, and can be e.g. copied # 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: "Display stage -- can be called many times" # 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 else: # MUST be `True` for templates that are NOT import with `{% extends %}` tag, # 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`, # and we post-process the result with `render_dependencies()`. diff --git a/tests/test_component_media.py b/tests/test_component_media.py index 7b3dd3a2..fac30466 100644 --- a/tests/test_component_media.py +++ b/tests/test_component_media.py @@ -46,7 +46,7 @@ class TestMainMedia: rendered = TestComponent.render() assertInHTML( - '
Content
', + '
Content
', rendered, ) assertInHTML( @@ -70,6 +70,9 @@ class TestMainMedia: assert TestComponent.css == ".html-css-only { color: blue; }" assert TestComponent.js == "console.log('HTML and JS only');" + assert isinstance(TestComponent._template, Template) + assert TestComponent._template.origin.component_cls is TestComponent + @djc_test( django_settings={ "STATICFILES_DIRS": [ @@ -127,6 +130,9 @@ class TestMainMedia: assert TestComponent.css == ".html-css-only {\n color: blue;\n}\n" assert TestComponent.js == 'console.log("JS file");\n' + assert isinstance(TestComponent._template, Template) + assert TestComponent._template.origin.component_cls is TestComponent + @djc_test( django_settings={ "STATICFILES_DIRS": [ @@ -151,6 +157,9 @@ class TestMainMedia: 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 isinstance(TestComponent._template, Template) + assert TestComponent._template.origin.component_cls is TestComponent + rendered = Template( """ {% load component_tags %} @@ -199,6 +208,7 @@ class TestMainMedia: # 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_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 _ = 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_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): class FilteredComponent(Component): template: types.django_html = """ @@ -1037,73 +1050,121 @@ class TestSubclassingAttributes: class TestComp(Component): js = None js_file = None + template = None + template_file = None assert TestComp.js 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): with pytest.raises( 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): js = "console.log('hi')" js_file = None + template = "

hi

" + template_file = None def test_both_non_none_raises(self): with pytest.raises( 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): js = "console.log('hi')" js_file = "file.js" + template = "

hi

" + template_file = "file.html" def test_parent_non_null_child_non_null(self): class ParentComp(Component): js = "console.log('parent')" + template = "

parent

" class TestComp(ParentComp): js = "console.log('child')" + template = "

child

" assert TestComp.js == "console.log('child')" assert TestComp.js_file is None + assert TestComp.template == "

child

" + assert TestComp.template_file is None + + assert isinstance(ParentComp._template, Template) + assert ParentComp._template.source == "

parent

" + assert ParentComp._template.origin.component_cls == ParentComp + + assert isinstance(TestComp._template, Template) + assert TestComp._template.source == "

child

" + assert TestComp._template.origin.component_cls == TestComp def test_parent_null_child_non_null(self): class ParentComp(Component): js = None + template = None class TestComp(ParentComp): js = "console.log('child')" + template = "

child

" assert TestComp.js == "console.log('child')" assert TestComp.js_file is None + assert TestComp.template == "

child

" + assert TestComp.template_file is None + + assert ParentComp._template is None + + assert isinstance(TestComp._template, Template) + assert TestComp._template.source == "

child

" + assert TestComp._template.origin.component_cls == TestComp def test_parent_non_null_child_null(self): class ParentComp(Component): js: Optional[str] = "console.log('parent')" + template: Optional[str] = "

parent

" class TestComp(ParentComp): js = None + template = None assert TestComp.js 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 == "

parent

" + assert ParentComp._template.origin.component_cls == ParentComp def test_parent_null_child_null(self): class ParentComp(Component): js = None + template = None class TestComp(ParentComp): js = None + template = None assert TestComp.js 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): class GrandParentComp(Component): js = "console.log('grandparent')" + template = "

grandparent

" class ParentComp(GrandParentComp): pass @@ -1113,45 +1174,97 @@ class TestSubclassingAttributes: assert TestComp.js == "console.log('grandparent')" assert TestComp.js_file is None + assert TestComp.template == "

grandparent

" + assert TestComp.template_file is None + + assert isinstance(GrandParentComp._template, Template) + assert GrandParentComp._template.source == "

grandparent

" + assert GrandParentComp._template.origin.component_cls == GrandParentComp + + assert isinstance(ParentComp._template, Template) + assert ParentComp._template.source == "

grandparent

" + assert ParentComp._template.origin.component_cls == ParentComp + + assert isinstance(TestComp._template, Template) + assert TestComp._template.source == "

grandparent

" + assert TestComp._template.origin.component_cls == TestComp def test_grandparent_non_null_parent_null_child_pass(self): class GrandParentComp(Component): js: Optional[str] = "console.log('grandparent')" + template: Optional[str] = "

grandparent

" class ParentComp(GrandParentComp): js = None + template = None class TestComp(ParentComp): pass assert TestComp.js 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 == "

grandparent

" + 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): class GrandParentComp(Component): js = "console.log('grandparent')" + template = "

grandparent

" class ParentComp(GrandParentComp): pass class TestComp(ParentComp): js = "console.log('child')" + template = "

child

" assert TestComp.js == "console.log('child')" assert TestComp.js_file is None + assert TestComp.template == "

child

" + assert TestComp.template_file is None + + assert isinstance(GrandParentComp._template, Template) + assert GrandParentComp._template.source == "

grandparent

" + assert GrandParentComp._template.origin.component_cls == GrandParentComp + + assert isinstance(ParentComp._template, Template) + assert ParentComp._template.source == "

grandparent

" + assert ParentComp._template.origin.component_cls == ParentComp + + assert isinstance(TestComp._template, Template) + assert TestComp._template.source == "

child

" + assert TestComp._template.origin.component_cls == TestComp def test_grandparent_null_parent_pass_child_non_null(self): class GrandParentComp(Component): js = None + template = None class ParentComp(GrandParentComp): pass class TestComp(ParentComp): js = "console.log('child')" + template = "

child

" assert TestComp.js == "console.log('child')" assert TestComp.js_file is None + assert TestComp.template == "

child

" + 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 == "

child

" + assert TestComp._template.origin.component_cls == TestComp @djc_test diff --git a/tests/test_extension.py b/tests/test_extension.py index 0f7da373..a764e294 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -3,7 +3,7 @@ from typing import Any, Callable, Dict, List, cast import pytest 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_components import Component, Slot, SlotNode, register, registry @@ -23,6 +23,10 @@ from django_components.extension import ( OnComponentDataContext, OnComponentRenderedContext, OnSlotRenderedContext, + OnTemplateLoadedContext, + OnTemplateCompiledContext, + OnJsLoadedContext, + OnCssLoadedContext, ) from django_components.extensions.cache import CacheExtension from django_components.extensions.debug_highlight import DebugHighlightExtension @@ -85,6 +89,10 @@ class DummyExtension(ComponentExtension): "on_component_data": [], "on_component_rendered": [], "on_slot_rendered": [], + "on_template_loaded": [], + "on_template_compiled": [], + "on_js_loaded": [], + "on_css_loaded": [], } urls = [ @@ -126,6 +134,18 @@ class DummyExtension(ComponentExtension): def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> None: 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): name = "test_nested_extension" @@ -182,6 +202,19 @@ def with_registry(on_created: Callable): 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 class TestExtension: @djc_test(components_settings={"extensions": [DummyExtension]}) @@ -469,6 +502,120 @@ class TestExtensionHooks: rendered = TestComponent.render(args=(), kwargs={"name": "Test"}) assert rendered == "
OVERRIDDEN: Hello Test!
" + @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 == ( + '
\n' + ' {% csrf_token %}\n' + ' \n' + ' \n' + '
\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 class TestExtensionViews: