mirror of
https://github.com/django-components/django-components.git
synced 2025-07-07 17:34:59 +00:00

* refactor: deprecate template caching, get_template_name, get_template, assoc template with Comp cls * refactor: change implementation * refactor: handle cached template loader * refactor: fix tests * refactor: fix test on windows * refactor: try to fix type errors * refactor: Re-cast `context` to fix type errors * refactor: fix linter error * refactor: fix typing * refactor: more linter fixes * refactor: more linter errors * refactor: revert extra node metadata
475 lines
21 KiB
Python
475 lines
21 KiB
Python
import sys
|
|
from contextlib import contextmanager
|
|
from typing import TYPE_CHECKING, Any, Dict, Generator, List, Optional, Type, Union, cast
|
|
from weakref import ReferenceType, ref
|
|
|
|
from django.core.exceptions import ImproperlyConfigured
|
|
from django.template import Context, Origin, Template
|
|
from django.template.loader import get_template as django_get_template
|
|
|
|
from django_components.cache import get_template_cache
|
|
from django_components.util.django_monkeypatch import is_template_cls_patched
|
|
from django_components.util.loader import get_component_dirs
|
|
from django_components.util.logger import trace_component_msg
|
|
from django_components.util.misc import get_import_path, get_module_info
|
|
|
|
if TYPE_CHECKING:
|
|
from django_components.component import Component
|
|
|
|
|
|
# TODO_V1 - Remove, won't be needed once we remove `get_template_string()`, `get_template_name()`, `get_template()`
|
|
# Legacy logic for creating Templates from string
|
|
def cached_template(
|
|
template_string: str,
|
|
template_cls: Optional[Type[Template]] = None,
|
|
origin: Optional[Origin] = None,
|
|
name: Optional[str] = None,
|
|
engine: Optional[Any] = None,
|
|
) -> Template:
|
|
"""
|
|
DEPRECATED. Template caching will be removed in v1.
|
|
|
|
Create a Template instance that will be cached as per the
|
|
[`COMPONENTS.template_cache_size`](../settings#django_components.app_settings.ComponentsSettings.template_cache_size)
|
|
setting.
|
|
|
|
Args:
|
|
template_string (str): Template as a string, same as the first argument to Django's\
|
|
[`Template`](https://docs.djangoproject.com/en/5.2/topics/templates/#template). Required.
|
|
template_cls (Type[Template], optional): Specify the Template class that should be instantiated.\
|
|
Defaults to Django's [`Template`](https://docs.djangoproject.com/en/5.2/topics/templates/#template) class.
|
|
origin (Type[Origin], optional): Sets \
|
|
[`Template.Origin`](https://docs.djangoproject.com/en/5.2/howto/custom-template-backend/#origin-api-and-3rd-party-integration).
|
|
name (Type[str], optional): Sets `Template.name`
|
|
engine (Type[Any], optional): Sets `Template.engine`
|
|
|
|
```python
|
|
from django_components import cached_template
|
|
|
|
template = cached_template("Variable: {{ variable }}")
|
|
|
|
# You can optionally specify Template class, and other Template inputs:
|
|
class MyTemplate(Template):
|
|
pass
|
|
|
|
template = cached_template(
|
|
"Variable: {{ variable }}",
|
|
template_cls=MyTemplate,
|
|
name=...
|
|
origin=...
|
|
engine=...
|
|
)
|
|
```
|
|
""" # noqa: E501
|
|
template_cache = get_template_cache()
|
|
|
|
template_cls = template_cls or Template
|
|
template_cls_path = get_import_path(template_cls)
|
|
engine_cls_path = get_import_path(engine.__class__) if engine else None
|
|
cache_key = (template_cls_path, template_string, engine_cls_path)
|
|
|
|
maybe_cached_template: Optional[Template] = template_cache.get(cache_key)
|
|
if maybe_cached_template is None:
|
|
template = template_cls(template_string, origin=origin, name=name, engine=engine)
|
|
template_cache.set(cache_key, template)
|
|
else:
|
|
template = maybe_cached_template
|
|
|
|
return template
|
|
|
|
|
|
########################################################
|
|
# PREPARING COMPONENT TEMPLATES FOR RENDERING
|
|
########################################################
|
|
|
|
|
|
@contextmanager
|
|
def prepare_component_template(
|
|
component: "Component",
|
|
template_data: Any,
|
|
) -> Generator[Optional[Template], Any, None]:
|
|
context = component.context
|
|
with context.update(template_data):
|
|
template = _get_component_template(component)
|
|
|
|
if template is None:
|
|
# If template is None, then the component is "template-less",
|
|
# and we skip template processing.
|
|
yield template
|
|
return
|
|
|
|
if not is_template_cls_patched(template):
|
|
raise RuntimeError(
|
|
"Django-components received a Template instance which was not patched."
|
|
"If you are using Django's Template class, check if you added django-components"
|
|
"to INSTALLED_APPS. If you are using a custom template class, then you need to"
|
|
"manually patch the class."
|
|
)
|
|
|
|
with _maybe_bind_template(context, template):
|
|
yield template
|
|
|
|
|
|
# `_maybe_bind_template()` handles two problems:
|
|
#
|
|
# 1. Initially, the binding the template was needed for the context processor data
|
|
# to work when using `RequestContext` (See `RequestContext.bind_template()` in e.g. Django v4.2 or v5.1).
|
|
# But as of djc v0.140 (possibly earlier) we generate and apply the context processor data
|
|
# ourselves in `Component._render_impl()`.
|
|
#
|
|
# Now, we still want to "bind the template" by setting the `Context.template` attribute.
|
|
# This is for compatibility with Django, because we don't know if there isn't some code that relies
|
|
# on the `Context.template` attribute being set.
|
|
#
|
|
# But we don't call `context.bind_template()` explicitly. If we did, then we would
|
|
# be generating and applying the context processor data twice if the context was `RequestContext`.
|
|
# Instead, we only run the same logic as `Context.bind_template()` but inlined.
|
|
#
|
|
# The downstream effect of this is that if the user or some third-party library
|
|
# uses custom subclass of `Context` with custom logic for `Context.bind_template()`,
|
|
# then this custom logic will NOT be applied. In such case they should open an issue.
|
|
#
|
|
# See https://github.com/django-components/django-components/issues/580
|
|
# and https://github.com/django-components/django-components/issues/634
|
|
#
|
|
# 2. Not sure if I (Juro) remember right, but I think that with the binding of templates
|
|
# there was also an issue that in *some* cases the template was already bound to the context
|
|
# by the time we got to rendering the component. This is why we need to check if `context.template`
|
|
# is already set.
|
|
#
|
|
# The cause of this may have been compatibility with Django's `{% extends %}` tag, or
|
|
# maybe when using the "isolated" context behavior. But not sure.
|
|
@contextmanager
|
|
def _maybe_bind_template(context: Context, template: Template) -> Generator[None, Any, None]:
|
|
if context.template is not None:
|
|
yield
|
|
return
|
|
|
|
# This code is taken from `Context.bind_template()` from Django v5.1
|
|
context.template = template
|
|
try:
|
|
yield
|
|
finally:
|
|
context.template = None
|
|
|
|
|
|
########################################################
|
|
# LOADING TEMPLATES FROM FILEPATH
|
|
########################################################
|
|
|
|
|
|
# Remember which Component class is currently being loaded
|
|
# This is important, because multiple Components may define the same `template_file`.
|
|
# So we need this global state to help us decide which Component class of the list of components
|
|
# that matched for the given `template_file` should be associated with the template.
|
|
#
|
|
# NOTE: Implemented as a list (stack) to handle the case when calling Django's `get_template()`
|
|
# could lead to more components being loaded at once.
|
|
# (For this to happen, user would have to define a Django template loader that renders other components
|
|
# while resolving the template file.)
|
|
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
|
|
|
|
loading_components.append(ref(component_cls))
|
|
|
|
# Use Django's `get_template()` to load the template
|
|
template = _load_django_template(filepath)
|
|
|
|
# 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:
|
|
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
|
|
|
|
|
|
def _get_component_template(component: "Component") -> Optional[Template]:
|
|
trace_component_msg("COMP_LOAD", component_name=component.name, component_id=component.id, slot_name=None)
|
|
|
|
# TODO_V1 - Remove, not needed once we remove `get_template_string()`, `get_template_name()`, `get_template()`
|
|
template_sources: Dict[str, Optional[Union[str, Template]]] = {}
|
|
|
|
# TODO_V1 - Remove `get_template_name()` in v1
|
|
template_sources["get_template_name"] = component.get_template_name(component.context)
|
|
|
|
# TODO_V1 - Remove `get_template_string()` in v1
|
|
if hasattr(component, "get_template_string"):
|
|
template_string_getter = getattr(component, "get_template_string")
|
|
template_body_from_getter = template_string_getter(component.context)
|
|
else:
|
|
template_body_from_getter = None
|
|
template_sources["get_template_string"] = template_body_from_getter
|
|
|
|
# TODO_V1 - Remove `get_template()` in v1
|
|
template_sources["get_template"] = component.get_template(component.context)
|
|
|
|
# NOTE: `component.template` should be populated whether user has set `template` or `template_file`
|
|
# so we discern between the two cases by checking `component.template_file`
|
|
if component.template_file is not None:
|
|
template_sources["template_file"] = component.template_file
|
|
else:
|
|
template_sources["template"] = component.template
|
|
|
|
# TODO_V1 - Remove this check in v1
|
|
# Raise if there are multiple sources for the component template
|
|
sources_with_values = [k for k, v in template_sources.items() if v is not None]
|
|
if len(sources_with_values) > 1:
|
|
raise ImproperlyConfigured(
|
|
f"Component template was set multiple times in Component {component.name}."
|
|
f"Sources: {sources_with_values}"
|
|
)
|
|
|
|
# Load the template based on the source
|
|
if template_sources["get_template_name"]:
|
|
template_name = template_sources["get_template_name"]
|
|
template: Optional[Template] = _load_django_template(template_name)
|
|
template_string: Optional[str] = None
|
|
elif template_sources["get_template_string"]:
|
|
template_string = template_sources["get_template_string"]
|
|
template = None
|
|
elif template_sources["get_template"]:
|
|
# `Component.get_template()` returns either string or Template instance
|
|
if hasattr(template_sources["get_template"], "render"):
|
|
template = template_sources["get_template"]
|
|
template_string = None
|
|
else:
|
|
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`.
|
|
#
|
|
# 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
|
|
# No template
|
|
else:
|
|
template = None
|
|
template_string = None
|
|
|
|
# We already have a template instance, so we can return it
|
|
if template is not None:
|
|
return template
|
|
# Create the template from the string
|
|
elif template_string is not None:
|
|
return _create_template_from_string(component, template_string)
|
|
|
|
# Otherwise, Component has no template - this is valid, as it may be instead rendered
|
|
# via `Component.on_render()`
|
|
return None
|
|
|
|
|
|
def _create_template_from_string(
|
|
component: "Component",
|
|
template_string: str,
|
|
is_component_template: bool = False,
|
|
) -> Template:
|
|
# Generate a valid Origin instance.
|
|
# When an Origin instance is created by Django when using Django's loaders, it looks like this:
|
|
# ```
|
|
# {
|
|
# 'name': '/path/to/project/django-components/sampleproject/calendarapp/templates/calendarapp/calendar.html',
|
|
# 'template_name': 'calendarapp/calendar.html',
|
|
# 'loader': <django.template.loaders.app_directories.Loader object at 0x10b441d90>
|
|
# }
|
|
# ```
|
|
#
|
|
# Since our template is inlined, we will format as `filepath::ComponentName`
|
|
#
|
|
# ```
|
|
# /path/to/project/django-components/src/calendarapp/calendar.html::Calendar
|
|
# ```
|
|
#
|
|
# See https://docs.djangoproject.com/en/5.2/howto/custom-template-backend/#template-origin-api
|
|
_, _, module_filepath = get_module_info(component.__class__)
|
|
origin = Origin(
|
|
name=f"{module_filepath}::{component.__class__.__name__}",
|
|
template_name=None,
|
|
loader=None,
|
|
)
|
|
|
|
set_component_to_origin(origin, component.__class__)
|
|
|
|
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
|
|
template = cached_template(
|
|
template_string=template_string,
|
|
name=origin.template_name,
|
|
origin=origin,
|
|
)
|
|
|
|
return template
|
|
|
|
|
|
# When loading a template, use Django's `get_template()` to ensure it triggers Django template loaders
|
|
# See https://github.com/django-components/django-components/issues/901
|
|
#
|
|
# This may raise `TemplateDoesNotExist` if the template doesn't exist.
|
|
# See https://docs.djangoproject.com/en/5.2/ref/templates/api/#template-loaders
|
|
# And https://docs.djangoproject.com/en/5.2/ref/templates/api/#custom-template-loaders
|
|
#
|
|
# TODO_v3 - Instead of loading templates with Django's `get_template()`,
|
|
# we should simply read the files directly (same as we do for JS and CSS).
|
|
# This has the implications that:
|
|
# - We would no longer support Django's template loaders
|
|
# - Instead if users are using template loaders, they should re-create them as djc extensions
|
|
# - We would no longer need to set `TEMPLATES.OPTIONS.loaders` to include
|
|
# `django_components.template_loader.Loader`
|
|
def _load_django_template(template_name: str) -> Template:
|
|
return django_get_template(template_name).template
|
|
|
|
|
|
########################################################
|
|
# ASSOCIATING COMPONENT CLASSES WITH TEMPLATES
|
|
#
|
|
# See https://github.com/django-components/django-components/pull/1222
|
|
########################################################
|
|
|
|
# NOTE: `ReferenceType` is NOT a generic pre-3.9
|
|
if sys.version_info >= (3, 9):
|
|
ComponentRef = ReferenceType[Type["Component"]]
|
|
else:
|
|
ComponentRef = ReferenceType
|
|
|
|
|
|
# Remember which Component classes defined `template_file`. Since multiple Components may
|
|
# define the same `template_file`, we store a list of weak references to the Component classes.
|
|
component_template_file_cache: Dict[str, List[ComponentRef]] = {}
|
|
component_template_file_cache_initialized = False
|
|
|
|
|
|
# Remember the mapping of `Component.template_file` -> `Component` class, so that we can associate
|
|
# the `Template` instances with the correct Component class in our monkepatched `Template.__init__()`.
|
|
def cache_component_template_file(component_cls: Type["Component"]) -> None:
|
|
# When a Component class is created before Django is set up,
|
|
# then `component_template_file_cache_initialized` is False and we leave it for later.
|
|
# This is necessary because:
|
|
# 1. We might need to resolve the template_file as relative to the file where the Component class is defined.
|
|
# 2. To be able to resolve the template_file, Django needs to be set up, because we need to access Django settings.
|
|
# 3. Django settings may not be available at the time of Component class creation.
|
|
if not component_template_file_cache_initialized:
|
|
return
|
|
|
|
# NOTE: Avoids circular import
|
|
from django_components.component_media import ComponentMedia, _resolve_component_relative_files
|
|
|
|
# If we access the `Component.template_file` attribute, then this triggers media resolution if it was not done yet.
|
|
# The problem is that this also causes the loading of the Template, if Component has defined `template_file`.
|
|
# This triggers `Template.__init__()`, which then triggers another call to `cache_component_template_file()`.
|
|
#
|
|
# At the same time, at this point we don't need the media files to be loaded. But we DO need for the relative
|
|
# file path to be resolved.
|
|
#
|
|
# So for this reason, `ComponentMedia.resolved_relative_files` was added to track if the media files were resolved.
|
|
# Once relative files were resolved, we can safely access the template file from `ComponentMedia` instance
|
|
# directly, thus avoiding the triggering of the Template loading.
|
|
comp_media: ComponentMedia = component_cls._component_media # type: ignore[attr-defined]
|
|
if comp_media.resolved and comp_media.resolved_relative_files:
|
|
template_file = component_cls.template_file
|
|
else:
|
|
# NOTE: This block of code is based on `_resolve_media()` in `component_media.py`
|
|
if not comp_media.resolved_relative_files:
|
|
comp_dirs = get_component_dirs()
|
|
_resolve_component_relative_files(component_cls, comp_media, comp_dirs=comp_dirs)
|
|
|
|
template_file = comp_media.template_file
|
|
|
|
if template_file is None:
|
|
return
|
|
|
|
if template_file not in component_template_file_cache:
|
|
component_template_file_cache[template_file] = []
|
|
|
|
component_template_file_cache[template_file].append(ref(component_cls))
|
|
|
|
|
|
def get_component_by_template_file(template_file: str) -> Optional[Type["Component"]]:
|
|
# This function is called from within `Template.__init__()`. At that point, Django MUST be already set up,
|
|
# because Django's `Template.__init__()` accesses the templating engines.
|
|
#
|
|
# So at this point we want to call `cache_component_template_file()` for all Components for which
|
|
# we skipped it earlier.
|
|
global component_template_file_cache_initialized
|
|
if not component_template_file_cache_initialized:
|
|
component_template_file_cache_initialized = True
|
|
|
|
# NOTE: Avoids circular import
|
|
from django_components.component import all_components
|
|
|
|
components = all_components()
|
|
for component in components:
|
|
cache_component_template_file(component)
|
|
|
|
if template_file not in component_template_file_cache or not len(component_template_file_cache[template_file]):
|
|
return None
|
|
|
|
# There is at least one Component class that has this `template_file`.
|
|
matched_component_refs = component_template_file_cache[template_file]
|
|
|
|
# There may be multiple components that define the same `template_file`.
|
|
# So to find the correct one, we need to check if the currently loading component
|
|
# is one of the ones that define the `template_file`.
|
|
#
|
|
# If there are NO currently loading components, then `Template.__init__()` was NOT triggered by us,
|
|
# in which case we don't associate any Component class with this Template.
|
|
if not len(loading_components):
|
|
return None
|
|
|
|
loading_component = loading_components[-1]()
|
|
if loading_component is None:
|
|
return None
|
|
|
|
for component_ref in matched_component_refs:
|
|
comp_cls = component_ref()
|
|
if comp_cls is loading_component:
|
|
return comp_cls
|
|
|
|
return None
|
|
|
|
|
|
# NOTE: Used by `@djc_test` to reset the component template file cache
|
|
def _reset_component_template_file_cache() -> None:
|
|
global component_template_file_cache
|
|
component_template_file_cache = {}
|
|
|
|
global component_template_file_cache_initialized
|
|
component_template_file_cache_initialized = False
|
|
|
|
|
|
# Helpers so we know where in the codebase we set / access the `Origin.component_cls` attribute
|
|
def set_component_to_origin(origin: Origin, component_cls: Type["Component"]) -> None:
|
|
origin.component_cls = component_cls
|
|
|
|
|
|
def get_component_from_origin(origin: Origin) -> Optional[Type["Component"]]:
|
|
return getattr(origin, "component_cls", None)
|