django-components/src/django_components/util/django_monkeypatch.py

358 lines
16 KiB
Python

from typing import Any, Optional, Type
from django import VERSION as DJANGO_VERSION
from django.template import Context, NodeList, Template
from django.template.base import Node, Origin, Parser
from django.template.library import InclusionNode
from django.template.loader_tags import IncludeNode
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_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 `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__
# NOTE: Function signature of Template.__init__ hasn't changed in 11 years, so we can safely patch it.
# See https://github.com/django/django/blame/main/django/template/base.py#L139
def __init__( # noqa: N807
self: Template,
template_string: Any,
origin: Optional[Origin] = None,
name: Optional[str] = None,
*args: Any,
**kwargs: Any,
) -> None:
# NOTE: Avoids circular import
from django_components.template import ( # noqa: PLC0415
get_component_by_template_file,
get_component_from_origin,
set_component_to_origin,
)
# If this Template instance was created by us when loading a template file for a component
# with `load_component_template()`, then we do 2 things:
#
# 1. Associate the Component class with the template by setting it on the `Origin` instance
# (`template.origin.component_cls`). This way the `{% component%}` and `{% slot %}` tags
# will know inside which Component class they were defined.
#
# 2. Apply `extensions.on_template_preprocess()` to the template, so extensions can modify
# the template string before it's compiled into a nodelist.
if get_component_from_origin(origin) is not None:
component_cls = get_component_from_origin(origin)
elif origin is not None and origin.template_name is not None:
component_cls = get_component_by_template_file(origin.template_name)
if component_cls is not None:
set_component_to_origin(origin, component_cls)
else:
component_cls = None
if component_cls is not None:
template_string = str(template_string)
template_string = extensions.on_template_loaded(
OnTemplateLoadedContext(
component_cls=component_cls,
content=template_string,
origin=origin,
name=name,
),
)
# 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__
# Patch `Template.compile_nodelist` to use our custom parser. Our parser makes it possible
# to use template tags as inputs to the component tag:
#
# {% component "my-component" description="{% lorem 3 w %}" / %}
def monkeypatch_template_compile_nodelist(template_cls: Type[Template]) -> None:
def _compile_nodelist(self: Template) -> NodeList:
"""
Parse and compile the template source into a nodelist. If debug
is True and an exception occurs during parsing, the exception is
annotated with contextual line information where it occurred in the
template source.
"""
# ---------------- ORIGINAL (Django v5.1.3) ----------------
# if self.engine.debug:
# lexer = DebugLexer(self.source)
# else:
# lexer = Lexer(self.source)
# tokens = lexer.tokenize()
# ---------------- OUR CHANGES START ----------------
tokens = parse_template(self.source)
# ---------------- OUR CHANGES END ----------------
parser = Parser(
tokens,
self.engine.template_libraries,
self.engine.template_builtins,
self.origin,
)
try:
nodelist = parser.parse()
if DJANGO_VERSION >= (5, 1):
# ---------------- ADDED IN Django v5.1 - See https://github.com/django/django/commit/35bbb2c9c01882b1d77b0b8c737ac646144833d4 # noqa: E501
# NOTE: This must be enabled ONLY for 5.1 and later. Previously it was also for older
# Django versions, and this led to compatibility issue with django-template-partials.
# See https://github.com/carltongibson/django-template-partials/pull/85#issuecomment-3187354790
self.extra_data = getattr(parser, "extra_data", {})
# ---------------- END OF ADDED IN Django v5.1 ----------------
return nodelist
except Exception as e:
if self.engine.debug:
e.template_debug = self.get_exception_info(e, e.token) # type: ignore[attr-defined]
raise
template_cls.compile_nodelist = _compile_nodelist
def monkeypatch_template_render(template_cls: Type[Template]) -> None:
# Modify `Template.render` to set `isolated_context` kwarg of `push_state`
# based on our custom `_DJC_COMPONENT_IS_NESTED`.
#
# Part of fix for https://github.com/django-components/django-components/issues/508
#
# NOTE 1: While we could've subclassed Template, then we would need to either
# 1) ask the user to change the backend, so all templates are of our subclass, or
# 2) copy the data from user's Template class instance to our subclass instance,
# which could lead to doubly parsing the source, and could be problematic if users
# used more exotic subclasses of Template.
#
# Instead, modifying only the `render` method of an already-existing instance
# should work well with any user-provided custom subclasses of Template, and it
# 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 `_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.
if is_cls_patched(template_cls):
# Do not patch if done so already. This helps us avoid RecursionError
return
# NOTE: This implementation is based on Django v5.1.3)
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 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 context[COMPONENT_IS_NESTED_KEY]
# This is original implementation, except we override `isolated_context`,
# and we post-process the result with `render_dependencies()`.
with context.render_context.push_state(self, isolated_context=isolated_context):
if context.template is None:
with context.bind_template(self):
context.template_name = self.name
result: str = self._render(context, *args, **kwargs)
else:
result = self._render(context, *args, **kwargs)
# If the key is present, that means this Template is rendered as part of `Component.render()`
# or `{% component %}`. In that case the parent component will take care of rendering the
# dependencies, so we don't need to do that here.
if _COMPONENT_CONTEXT_KEY in context:
return result
# NOTE: Only process dependencies if the rendered result contains AT LEAST ONE rendered component.
# This has two reasons:
# 1. To keep the behavior consistent with the previous implementation, when `Template.render()`
# didn't call `render_dependencies()`.
# 2. To avoid unnecessary processing which otherwise has a considerable perf overhead.
# See https://github.com/django-components/django-components/pull/1166#issuecomment-2850899765
if not COMPONENT_COMMENT_REGEX.search(result.encode("utf-8")):
return result
# Don't post-process if this template was rendered with Django's InclusionNode.
# Fix for https://github.com/django-components/django-components/issues/1390
# NOTE: Lenght of 2 means a Context with single layer (+ layer with defaults)
if "_DJC_INSIDE_INCLUSION_TAG" in context and len(context.dicts) == 2:
return result
# Allow users to configure the `deps_strategy` kwarg of `render_dependencies()`, even if
# they render a Template directly with `Template.render()` or Django's `django.shortcuts.render()`.
#
# Example:
# ```
# result = render_dependencies(
# result,
# Context({ "DJC_DEPS_STRATEGY": "fragment" }),
# )
# ```
if _STRATEGY_CONTEXT_KEY in context and context[_STRATEGY_CONTEXT_KEY] is not None:
strategy = context[_STRATEGY_CONTEXT_KEY]
result = render_dependencies(result, strategy)
else:
result = render_dependencies(result)
return result
template_cls.render = _template_render
def monkeypatch_include_node(include_node_cls: Type[Node]) -> None:
if is_cls_patched(include_node_cls):
return
monkeypatch_include_render(include_node_cls)
include_node_cls._djc_patched = True
def monkeypatch_include_render(include_node_cls: Type[Node]) -> None:
# Modify `IncludeNode.render()` (what renders `{% include %}` tag) so that the included
# template does NOT render the JS/CSS by itself.
#
# Instead, we want the parent template
# (which contains the `{% component %}` tag) to decide whether to render the JS/CSS.
#
# We achieve this by setting `DJC_DEPS_STRATEGY` to `ignore` in the context.
#
# Fix for https://github.com/django-components/django-components/issues/1296
if is_cls_patched(include_node_cls):
# Do not patch if done so already. This helps us avoid RecursionError
return
orig_include_render = include_node_cls.render
# NOTE: This implementation is based on Django v5.1.3)
def _include_render(self: IncludeNode, context: Context, *args: Any, **kwargs: Any) -> str:
# NOTE: `_STRATEGY_CONTEXT_KEY` is used so that we defer the rendering of components' JS/CSS
# to until the parent template that used the `{% include %}` tag is rendered.
# NOTE: `{ COMPONENT_IS_NESTED_KEY: False }` is used so that a new RenderContext layer is created,
# so that inside each `{% include %}` the template can use the `{% extends %}` tag.
# Otherwise, the state leaks, and if both `{% include %}` templates use the `{% extends %}` tag,
# the second one raises, because it would be like using two `{% extends %}` tags in the same template.
# See https://github.com/django-components/django-components/issues/1389
with context.update({_STRATEGY_CONTEXT_KEY: "ignore", COMPONENT_IS_NESTED_KEY: False}):
return orig_include_render(self, context, *args, **kwargs)
include_node_cls.render = _include_render
def monkeypatch_inclusion_node(inclusion_node_cls: Type[Node]) -> None:
if is_cls_patched(inclusion_node_cls):
return
monkeypatch_inclusion_init(inclusion_node_cls)
monkeypatch_inclusion_render(inclusion_node_cls)
inclusion_node_cls._djc_patched = True
# Patch `InclusionNode.__init__` so that `InclusionNode.func` returns also `{"_DJC_INSIDE_INCLUSION_TAG": True}`.
# This is then used in `Template.render()` so that we can detect if template was rendered inside an inclusion tag.
# See https://github.com/django-components/django-components/issues/1390
def monkeypatch_inclusion_init(inclusion_node_cls: Type[Node]) -> None:
original_init = inclusion_node_cls.__init__
# NOTE: Function signature of InclusionNode.__init__ hasn't changed in 9 years, so we can safely patch it.
# See https://github.com/django/django/blame/main/django/template/library.py#L348
def __init__( # noqa: N807
self: InclusionNode,
func: Any,
takes_context: bool,
args: Any,
kwargs: Any,
filename: Any,
*future_args: Any,
**future_kwargs: Any,
) -> None:
original_init(self, func, takes_context, args, kwargs, filename, *future_args, **future_kwargs) # type: ignore[misc]
orig_func = self.func
def new_func(*args: Any, **kwargs: Any) -> Any:
result = orig_func(*args, **kwargs)
result["_DJC_INSIDE_INCLUSION_TAG"] = True
return result
self.func = new_func
inclusion_node_cls.__init__ = __init__
def monkeypatch_inclusion_render(inclusion_node_cls: Type[Node]) -> None:
# Modify `InclusionNode.render()` so that the included
# template does NOT render the JS/CSS by itself.
#
# Instead, we want the parent template to decide whether to render the JS/CSS.
#
# We achieve this by setting `_DJC_INSIDE_INCLUSION_TAG`.
#
# Fix for https://github.com/django-components/django-components/issues/1390
if is_cls_patched(inclusion_node_cls):
# Do not patch if done so already. This helps us avoid RecursionError
return
orig_inclusion_render = inclusion_node_cls.render
# NOTE: This implementation is based on Django v5.2.5)
def _inclusion_render(self: InclusionNode, context: Context, *args: Any, **kwargs: Any) -> str:
with context.update({"_DJC_INSIDE_INCLUSION_TAG": True}):
return orig_inclusion_render(self, context, *args, **kwargs)
inclusion_node_cls.render = _inclusion_render
# NOTE: Remove once Django v5.2 reaches end of life
# See https://github.com/django-components/django-components/issues/1323#issuecomment-3163478287
def monkeypatch_template_proxy_cls() -> None:
# Patch TemplateProxy if template_partials is installed
# See https://github.com/django-components/django-components/issues/1323#issuecomment-3164224042
try:
from template_partials.templatetags.partials import TemplateProxy # noqa: PLC0415
except ImportError:
# template_partials is in INSTALLED_APPS but not actually installed
# This is fine, just skip the patching
return
if is_cls_patched(TemplateProxy):
return
monkeypatch_template_proxy_render(TemplateProxy)
TemplateProxy._djc_patched = True
def monkeypatch_template_proxy_render(template_proxy_cls: Type[Any]) -> None:
# NOTE: TemplateProxy.render() is same logic as Template.render(), just duplicated.
# So we can instead reuse Template.render()
def _template_proxy_render(self: Any, context: Context, *_args: Any, **_kwargs: Any) -> str:
return Template.render(self, context)
template_proxy_cls.render = _template_proxy_render
def is_cls_patched(cls: Type[Any]) -> bool:
return getattr(cls, "_djc_patched", False)