refactor: fix multiple yield in on_render + prepare for scoped CSS (#1425)
Some checks failed
Run tests / build (ubuntu-latest, 3.10) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.11) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.12) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.13) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.8) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.9) (push) Waiting to run
Run tests / build (windows-latest, 3.10) (push) Waiting to run
Run tests / build (windows-latest, 3.11) (push) Waiting to run
Run tests / build (windows-latest, 3.12) (push) Waiting to run
Run tests / build (windows-latest, 3.13) (push) Waiting to run
Run tests / build (windows-latest, 3.8) (push) Waiting to run
Run tests / build (windows-latest, 3.9) (push) Waiting to run
Run tests / test_docs (3.13) (push) Waiting to run
Run tests / test_sampleproject (3.13) (push) Waiting to run
Docs - build & deploy / docs (push) Has been cancelled

This commit is contained in:
Juro Oravec 2025-10-02 13:46:39 +02:00 committed by GitHub
parent cd7a9c9703
commit 91012829ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 330 additions and 268 deletions

View file

@ -98,6 +98,22 @@ pytest
tox -e py38
```
## Snapshot tests
Some tests rely on snapshot testing with [syrupy](https://github.com/syrupy-project/syrupy) to test the HTML output of the components.
If you need to update the snapshot tests, add `--snapshot-update` to the pytest command:
```sh
pytest --snapshot-update
```
Or with tox:
```sh
tox -e py39 -- --snapshot-update
```
## Dev server
How do you check that your changes to django-components project will work in an actual Django project?

View file

@ -19,7 +19,7 @@ from typing import (
Union,
cast,
)
from weakref import ReferenceType, WeakValueDictionary, finalize, ref
from weakref import ReferenceType, WeakKeyDictionary, WeakValueDictionary, finalize, ref
from django.forms.widgets import Media as MediaCls
from django.http import HttpRequest, HttpResponse
@ -58,7 +58,6 @@ from django_components.extensions.defaults import ComponentDefaults
from django_components.extensions.view import ComponentView, ViewFn
from django_components.node import BaseNode
from django_components.perfutil.component import (
ComponentRenderer,
OnComponentRenderedResult,
component_context_cache,
component_instance_cache,
@ -79,7 +78,7 @@ from django_components.template import cache_component_template_file, prepare_co
from django_components.util.context import gen_context_processors_data, snapshot_context
from django_components.util.exception import component_error_message
from django_components.util.logger import trace_component_msg
from django_components.util.misc import default, gen_id, hash_comp_cls, to_dict
from django_components.util.misc import default, gen_id, hash_comp_cls, is_generator, to_dict
from django_components.util.template_tag import TagAttr
from django_components.util.weakref import cached_ref
@ -104,10 +103,12 @@ if sys.version_info >= (3, 9):
AllComponents = List[ReferenceType[Type["Component"]]]
CompHashMapping = WeakValueDictionary[str, Type["Component"]]
ComponentRef = ReferenceType["Component"]
StartedGenerators = WeakKeyDictionary["OnRenderGenerator", bool]
else:
AllComponents = List[ReferenceType]
CompHashMapping = WeakValueDictionary
ComponentRef = ReferenceType
StartedGenerators = WeakKeyDictionary
OnRenderGenerator = Generator[
@ -541,6 +542,27 @@ class ComponentMeta(ComponentMediaMeta):
extensions.on_component_class_deleted(OnComponentClassDeletedContext(comp_cls))
# Internal data that's shared across the entire component tree
@dataclass
class ComponentTreeContext:
# HTML attributes that are passed from parent to child components
component_attrs: Dict[str, List[str]]
# When we render a component, the root component, together with all the nested Components,
# shares these dictionaries for storing callbacks.
# These callbacks are called from within `component_post_render`
on_component_intermediate_callbacks: Dict[str, Callable[[Optional[str]], Optional[str]]]
on_component_rendered_callbacks: Dict[
str,
Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult],
]
# Track which generators have been started. We need this info because the input to
# `Generator.send()` changes when calling it the first time vs subsequent times.
# Moreover, we can't simply store this directly on the generator object themselves
# (e.g. `generator.started = True`), because generator object does not allow setting
# extra attributes.
started_generators: StartedGenerators
# Internal data that are made available within the component's template
@dataclass
class ComponentContext:
@ -549,10 +571,7 @@ class ComponentContext:
template_name: Optional[str]
default_slot: Optional[str]
outer_context: Optional[Context]
# When we render a component, the root component, together with all the nested Components,
# shares this dictionary for storing callbacks that are called from within `component_post_render`.
# This is so that we can pass them all in when the root component is passed to `component_post_render`.
post_render_callbacks: Dict[str, Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult]]
tree: ComponentTreeContext
def on_component_garbage_collected(component_id: str) -> None:
@ -3501,10 +3520,15 @@ class Component(metaclass=ComponentMeta):
parent_id, parent_comp_ctx = _get_parent_component_context(context)
if parent_comp_ctx is not None:
component_path = [*parent_comp_ctx.component_path, component_name]
post_render_callbacks = parent_comp_ctx.post_render_callbacks
component_tree_context = parent_comp_ctx.tree
else:
component_path = [component_name]
post_render_callbacks = {}
component_tree_context = ComponentTreeContext(
component_attrs={},
on_component_intermediate_callbacks={},
on_component_rendered_callbacks={},
started_generators=WeakKeyDictionary(),
)
trace_component_msg(
"COMP_PREP_START",
@ -3537,7 +3561,7 @@ class Component(metaclass=ComponentMeta):
default_slot=None,
# NOTE: This is only a SNAPSHOT of the outer context.
outer_context=snapshot_context(outer_context) if outer_context is not None else None,
post_render_callbacks=post_render_callbacks,
tree=component_tree_context,
)
# Instead of passing the ComponentContext directly through the Context, the entry on the Context
@ -3645,26 +3669,70 @@ class Component(metaclass=ComponentMeta):
# Cleanup
context.render_context.pop() # type: ignore[union-attr]
trace_component_msg(
"COMP_PREP_END",
component_name=component_name,
component_id=render_id,
slot_name=None,
component_path=component_path,
)
######################################
# 5. Render component
#
# NOTE: To support infinite recursion, we don't directly call `Template.render()`.
# Instead, we defer rendering of the component - we prepare a callback that will
# be called when the rendering process reaches this component.
# Instead, we defer rendering of the component - we prepare a generator function
# that will be called when the rendering process reaches this component.
######################################
trace_component_msg(
"COMP_RENDER_START",
component_name=component.name,
component_id=component.id,
slot_name=None,
component_path=component_path,
)
component.on_render_before(context_snapshot, template)
# Emit signal that the template is about to be rendered
if template is not None:
template_rendered.send(sender=template, template=template, context=context_snapshot)
# Instead of rendering component at the time we come across the `{% component %}` tag
# in the template, we defer rendering in order to scalably handle deeply nested components.
#
# See `_gen_component_renderer()` for more details.
deferred_render = component._gen_component_renderer(
# See `_make_renderer_generator()` for more details.
renderer_generator = component._make_renderer_generator(
template=template,
context=context_snapshot,
component_path=component_path,
css_input_hash=css_input_hash,
js_input_hash=js_input_hash,
)
# This callback is called with the value that was yielded from `Component.on_render()`.
# It may be called multiple times for the same component, e.g. if `Component.on_render()`
# contains multiple `yield` keywords.
def on_component_intermediate(html_content: Optional[str]) -> Optional[str]:
# HTML attributes passed from parent to current component.
# NOTE: Is `None` for the root component.
curr_comp_attrs = component_tree_context.component_attrs.get(render_id, None)
if html_content:
# Add necessary HTML attributes to work with JS and CSS variables
html_content, child_components_attrs = set_component_attrs_for_js_and_css(
html_content=html_content,
component_id=render_id,
css_input_hash=css_input_hash,
root_attributes=curr_comp_attrs,
)
# Store the HTML attributes that will be passed from this component to its children's components
component_tree_context.component_attrs.update(child_components_attrs)
return html_content
component_tree_context.on_component_intermediate_callbacks[render_id] = on_component_intermediate
# `on_component_rendered` is triggered when a component is rendered.
# The component's parent(s) may not be fully rendered yet.
#
@ -3673,6 +3741,7 @@ class Component(metaclass=ComponentMeta):
# so that the component instance can be garbage collected.
component_instance_cache[render_id] = component
# NOTE: This is called only once for a single component instance.
def on_component_rendered(
html: Optional[str],
error: Optional[Exception],
@ -3697,6 +3766,17 @@ class Component(metaclass=ComponentMeta):
error = new_error
html = None
# Prepend an HTML comment to instruct how and what JS and CSS scripts are associated with it.
# E.g. `<!-- _RENDERED table,123,a92ef298,bd002c3 -->`
if html is not None:
html = insert_component_dependencies_comment(
html,
component_cls=comp_cls,
component_id=render_id,
js_input_hash=js_input_hash,
css_input_hash=css_input_hash,
)
# Allow extensions to either:
# - Override/modify the rendered HTML by returning new value
# - Raise an exception to discard the HTML and bubble up error
@ -3714,122 +3794,6 @@ class Component(metaclass=ComponentMeta):
if result is not None:
html, error = result
return html, error
post_render_callbacks[render_id] = on_component_rendered
# This is triggered after a full component tree was rendered, we resolve
# all inserted HTML comments into <script> and <link> tags.
def on_html_rendered(html: str) -> str:
html = _render_dependencies(html, deps_strategy)
return html
trace_component_msg(
"COMP_PREP_END",
component_name=component_name,
component_id=render_id,
slot_name=None,
component_path=component_path,
)
return component_post_render(
renderer=deferred_render,
render_id=render_id,
component_name=component_name,
parent_render_id=parent_id,
on_component_rendered_callbacks=post_render_callbacks,
on_html_rendered=on_html_rendered,
)
# Creates a renderer function that will be called only once, when the component is to be rendered.
#
# By encapsulating components' output as render function, we can render components top-down,
# starting from root component, and moving down.
#
# This way, when it comes to rendering a particular component, we have already rendered its parent,
# and we KNOW if there were any HTML attributes that were passed from parent to children.
#
# Thus, the returned renderer function accepts the extra HTML attributes that were passed from parent,
# and returns the updated HTML content.
#
# Because the HTML attributes are all boolean (e.g. `data-djc-id-ca1b3c4`), they are passed as a list.
#
# This whole setup makes it possible for multiple components to resolve to the same HTML element.
# E.g. if CompA renders CompB, and CompB renders a <div>, then the <div> element will have
# IDs of both CompA and CompB.
# ```html
# <div djc-id-a1b3cf djc-id-f3d3cf>...</div>
# ```
def _gen_component_renderer(
self,
template: Optional[Template],
context: Context,
component_path: List[str],
css_input_hash: Optional[str],
js_input_hash: Optional[str],
) -> ComponentRenderer:
component = self
render_id = component.id
component_name = component.name
component_cls = component.__class__
def renderer(
root_attributes: Optional[List[str]] = None,
) -> Tuple[str, Dict[str, List[str]], Optional[OnRenderGenerator]]:
trace_component_msg(
"COMP_RENDER_START",
component_name=component_name,
component_id=render_id,
slot_name=None,
component_path=component_path,
)
component.on_render_before(context, template)
# Emit signal that the template is about to be rendered
if template is not None:
template_rendered.send(sender=template, template=template, context=context)
# Get the component's HTML
# To access the *final* output (with all its children rendered) from within `Component.on_render()`,
# users may convert it to a generator by including a `yield` keyword. If they do so, the part of code
# AFTER the yield will be called once when the component's HTML is fully rendered.
#
# Hence we have to distinguish between the two, and pass the generator with the HTML content
html_content_or_generator = component.on_render(context, template)
if html_content_or_generator is None:
html_content: Optional[str] = None
on_render_generator: Optional[OnRenderGenerator] = None
elif isinstance(html_content_or_generator, str):
html_content = html_content_or_generator
on_render_generator = None
else:
# Move generator to the first yield
html_content = next(html_content_or_generator)
on_render_generator = html_content_or_generator
if html_content is not None:
# Add necessary HTML attributes to work with JS and CSS variables
updated_html, child_components = set_component_attrs_for_js_and_css(
html_content=html_content,
component_id=render_id,
css_input_hash=css_input_hash,
root_attributes=root_attributes,
)
# Prepend an HTML comment to instructs how and what JS and CSS scripts are associated with it.
updated_html = insert_component_dependencies_comment(
updated_html,
component_cls=component_cls,
component_id=render_id,
js_input_hash=js_input_hash,
css_input_hash=css_input_hash,
)
else:
updated_html = ""
child_components = {}
trace_component_msg(
"COMP_RENDER_END",
component_name=component_name,
@ -3838,9 +3802,90 @@ class Component(metaclass=ComponentMeta):
component_path=component_path,
)
return updated_html, child_components, on_render_generator
return html, error
return renderer
component_tree_context.on_component_rendered_callbacks[render_id] = on_component_rendered
# This is triggered after a full component tree was rendered, we resolve
# all inserted HTML comments into <script> and <link> tags.
def on_component_tree_rendered(html: str) -> str:
html = _render_dependencies(html, deps_strategy)
return html
return component_post_render(
renderer=renderer_generator,
render_id=render_id,
component_name=component_name,
parent_render_id=parent_id,
component_tree_context=component_tree_context,
on_component_tree_rendered=on_component_tree_rendered,
)
# Convert `Component.on_render()` to a generator function.
#
# By encapsulating components' output as a generator, we can render components top-down,
# starting from root component, and moving down.
#
# This allows us to pass HTML attributes from parent to children.
# Because by the time we get to a child component, its parent was already rendered.
#
# This whole setup makes it possible for multiple components to resolve to the same HTML element.
# E.g. if CompA renders CompB, and CompB renders a <div>, then the <div> element will have
# IDs of both CompA and CompB.
# ```html
# <div djc-id-a1b3cf djc-id-f3d3cf>...</div>
# ```
def _make_renderer_generator(
self,
template: Optional[Template],
context: Context,
component_path: List[str],
) -> Optional[OnRenderGenerator]:
component = self
# Convert the component's HTML to a generator function.
#
# To access the *final* output (with all its children rendered) from within `Component.on_render()`,
# users may convert it to a generator by including a `yield` keyword. If they do so, the part of code
# AFTER the yield will be called once when the component's HTML is fully rendered.
#
# ```
# class MyTable(Component):
# def on_render(self, context, template):
# html, error = yield template.render(context)
# return html + "<p>Hello</p>"
# ```
#
# However, the way Python works is that when you call a function that contains `yield` keyword,
# the function is NOT executed immediately. Instead it returns a generator object.
#
# On the other hand, if it's a regular function, the function is executed immediately.
#
# We must be careful not to execute the function immediately, because that will cause the
# entire component tree to be rendered recursively. Instead we want to defer the execution
# and render nested components via a flat stack, as done in `perfutils/component.py`.
# That allows us to create component trees of any depth, without hitting recursion limits.
#
# So we create a wrapper generator function that we KNOW is a generator when called.
def inner_generator() -> OnRenderGenerator:
# NOTE: May raise
html_content_or_generator = component.on_render(context, template)
# If we DIDN'T raise an exception
if html_content_or_generator is None:
return None
# Generator function (with `yield`) - yield multiple times with the result
elif is_generator(html_content_or_generator):
generator = cast("OnRenderGenerator", html_content_or_generator)
result = yield from generator
# If the generator had a return statement, `result` will contain that value.
# So we pass the return value through.
return result
# String (or other unknown type) - yield once with the result
else:
yield html_content_or_generator
return None
return inner_generator()
def _call_data_methods(
self,

View file

@ -5,10 +5,16 @@ from typing import TYPE_CHECKING, Callable, Deque, Dict, List, NamedTuple, Optio
from django.utils.safestring import mark_safe
from django_components.constants import COMP_ID_LENGTH
from django_components.util.exception import component_error_message
from django_components.util.exception import component_error_message, set_component_error_message
if TYPE_CHECKING:
from django_components.component import Component, ComponentContext, OnRenderGenerator
from django_components.component import (
Component,
ComponentContext,
ComponentTreeContext,
OnRenderGenerator,
StartedGenerators,
)
OnComponentRenderedResult = Tuple[Optional[str], Optional[Exception]]
@ -89,21 +95,9 @@ class GeneratorResult(NamedTuple):
"""Whether the generator has been "spent" - e.g. reached its end with `StopIteration`."""
# Function that accepts a list of extra HTML attributes to be set on the component's root elements
# and returns the component's HTML content and a dictionary of child components' IDs
# and their root elements' HTML attributes.
#
# In other words, we use this to "delay" the actual rendering of the component's HTML content,
# until we know what HTML attributes to apply to the root elements.
ComponentRenderer = Callable[
[Optional[List[str]]],
Tuple[str, Dict[str, List[str]], Optional["OnRenderGenerator"]],
]
# Render-time cache for component rendering
# See component_post_render()
component_renderer_cache: Dict[str, Tuple[ComponentRenderer, str]] = {}
child_component_attrs: Dict[str, List[str]] = {}
component_renderer_cache: "Dict[str, Tuple[Optional[OnRenderGenerator], str]]" = {}
nested_comp_pattern = re.compile(
r'<template [^>]*?djc-render-id="\w{{{COMP_ID_LENGTH}}}"[^>]*?></template>'.format(COMP_ID_LENGTH=COMP_ID_LENGTH), # noqa: UP032
@ -159,15 +153,12 @@ render_id_pattern = re.compile(
# to the root elements.
# 8. Lastly, we merge all the parts together, and return the final HTML.
def component_post_render(
renderer: ComponentRenderer,
renderer: "Optional[OnRenderGenerator]",
render_id: str,
component_name: str,
parent_render_id: Optional[str],
on_component_rendered_callbacks: Dict[
str,
Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult],
],
on_html_rendered: Callable[[str], str],
component_tree_context: "ComponentTreeContext",
on_component_tree_rendered: Callable[[str], str],
) -> str:
# Instead of rendering the component's HTML content immediately, we store it,
# so we can render the component only once we know if there are any HTML attributes
@ -407,11 +398,11 @@ def component_post_render(
),
)
def finalize_component(item_id: QueueItemId, error: Optional[Exception], full_path: List[str]) -> None:
def next_renderer_result(item_id: QueueItemId, error: Optional[Exception], full_path: List[str]) -> None:
parent_id = child_to_parent[item_id]
component_parts = pop_html_parts(item_id)
if error is None:
if error is None and component_parts:
component_html = "".join(component_parts) if component_parts else ""
else:
component_html = None
@ -425,7 +416,14 @@ def component_post_render(
# (and `on_component_rendered` extension hook) are called at the very end of component rendering.
on_render_generator = generators_by_component_id.pop(item_id.component_id, None)
if on_render_generator is not None:
result = _call_generator(on_render_generator, component_html, error)
result = _call_generator(
on_render_generator=on_render_generator,
html=component_html,
error=error,
started_generators_cache=component_tree_context.started_generators,
full_path=full_path,
)
new_html = result.html
# Component's `on_render()` contains multiple `yield` keywords, so keep the generator.
if not result.spent:
@ -443,17 +441,28 @@ def component_post_render(
# Set the current parent as the parent of the new version
child_to_parent[new_item_id] = parent_id
# Allow to optionally override/modify the intermediate result returned from `Component.on_render()`
# and by extensions' `on_component_intermediate` hooks.
on_component_intermediate = component_tree_context.on_component_intermediate_callbacks[
item_id.component_id
]
# NOTE: [1:] because the root component will be yet again added to the error's
# `components` list in `_render_with_error_trace` so we remove the first element from the path.
with component_error_message(full_path[1:]):
new_html = on_component_intermediate(new_html)
# Split the new HTML by placeholders, and put the parts into the queue.
parts_to_process = parse_component_result(result.html or "", new_item_id, full_path)
parts_to_process = parse_component_result(new_html or "", new_item_id, full_path)
process_queue.extendleft(reversed(parts_to_process))
return
# If we don't need to re-do the processing, then we can just use the result.
component_html, error = result.html, result.error
component_html, error = new_html, result.error
# Allow to optionally override/modify the rendered content from `Component.on_render_after()`
# and by extensions' `on_component_rendered` hooks.
on_component_rendered = on_component_rendered_callbacks[item_id.component_id]
component_html, error = on_component_rendered(component_html, error)
on_component_rendered = component_tree_context.on_component_rendered_callbacks[item_id.component_id]
with component_error_message(full_path[1:]):
component_html, error = on_component_rendered(component_html, error)
# If this component had an error, then we ignore this component's HTML, and instead
# bubble the error up to the parent component.
@ -462,7 +471,7 @@ def component_post_render(
return
if component_html is None:
raise RuntimeError("Unexpected `None` from `Component.on_render()`")
return
# At this point we have a component, and we've resolved all its children into strings.
# So the component's full HTML is now only strings.
@ -479,7 +488,7 @@ def component_post_render(
# Body of the iteration, scoped in a function to avoid spilling the state out of the loop.
def on_item(curr_item: Union[ErrorPart, TextPart, ComponentPart]) -> None:
# NOTE: When an error is bubbling up, when the flow goes between `handle_error()`, `finalize_component()`,
# NOTE: When an error is bubbling up, when the flow goes between `handle_error()`, `next_renderer_result()`,
# and this branch, until we reach the root component, where the error is finally raised.
#
# Any ancestor component of the one that raised can intercept the error and instead return a new string
@ -494,7 +503,7 @@ def component_post_render(
# This will make the parent component either handle the error and return a new string instead,
# or propagate the error to its parent.
finalize_component(item_id=parent_id, error=curr_item.error, full_path=curr_item.full_path)
next_renderer_result(item_id=parent_id, error=curr_item.error, full_path=curr_item.full_path)
return
# Skip parts that belong to component versions that error'd
@ -507,10 +516,10 @@ def component_post_render(
curr_html_parts.append(curr_item.text)
# In this case we've reached the end of the component's HTML content, and there's
# no more subcomponents to process. We can call `finalize_component()` to process
# no more subcomponents to process. We can call `next_renderer_result()` to process
# the component's HTML and eventually trigger `on_component_rendered` hook.
if curr_item.is_last:
finalize_component(item_id=curr_item.item_id, error=None, full_path=[])
next_renderer_result(item_id=curr_item.item_id, error=None, full_path=[])
return
@ -521,40 +530,12 @@ def component_post_render(
# to the parent component.
child_to_parent[curr_item.item_id] = curr_item.parent_id
# Generate component's content, applying the extra HTML attributes set by the parent component
curr_comp_renderer, curr_comp_name = component_renderer_cache.pop(component_id)
# NOTE: Attributes passed from parent to current component are `None` for the root component.
curr_comp_attrs = child_component_attrs.pop(component_id, None)
on_render_generator, curr_comp_name = component_renderer_cache.pop(component_id)
full_path = [*curr_item.full_path, curr_comp_name]
generators_by_component_id[component_id] = on_render_generator
# This is where we actually render the component
#
# NOTE: [1:] because the root component will be yet again added to the error's
# `components` list in `_render_with_error_trace` so we remove the first element from the path.
try:
with component_error_message(full_path[1:]):
comp_content, extra_child_component_attrs, on_render_generator = curr_comp_renderer(
curr_comp_attrs,
)
# This error may be triggered when any of following raises:
# - `Component.on_render()` (first part - before yielding)
# - `Component.on_render_before()`
# - Rendering of component's template
#
# In all cases, we want to mark the component as errored, and let the parent handle it.
except Exception as err: # noqa: BLE001
handle_error(item_id=curr_item.item_id, error=err, full_path=full_path)
return
if on_render_generator is not None:
generators_by_component_id[component_id] = on_render_generator
child_component_attrs.update(extra_child_component_attrs)
# Split the component's rendered HTML by placeholders, and put the parts into the queue.
parts_to_process = parse_component_result(comp_content, curr_item.item_id, full_path)
process_queue.extendleft(reversed(parts_to_process))
next_renderer_result(item_id=curr_item.item_id, error=None, full_path=full_path)
else:
raise TypeError("Unknown item type")
@ -576,7 +557,7 @@ def component_post_render(
output = "".join(content_parts)
# Allow to optionally modify the final output
output = on_html_rendered(output)
output = on_component_tree_rendered(output)
return mark_safe(output)
@ -585,10 +566,10 @@ def _call_generator(
on_render_generator: "OnRenderGenerator",
html: Optional[str],
error: Optional[Exception],
started_generators_cache: "StartedGenerators",
full_path: List[str],
) -> GeneratorResult:
generator_spent = False
needs_processing = False
is_first_send = not started_generators_cache.get(on_render_generator, False)
try:
# `Component.on_render()` may have any number of `yield` statements, so we need to
# call `.send()` any number of times.
@ -598,30 +579,33 @@ def _call_generator(
# - Yield a new HTML with `yield` - We return back to the user the processed HTML / error
# for them to process further
# - Raise a new error
new_result = on_render_generator.send((html, error))
if is_first_send:
new_result = on_render_generator.send(None) # type: ignore[arg-type]
else:
new_result = on_render_generator.send((html, error))
# If we've reached the end of `Component.on_render()` (or `return` statement), then we get `StopIteration`.
# In that case, we want to check if user returned new HTML from the `return` statement.
except StopIteration as generator_err:
generator_spent = True
# The return value is on `StopIteration.value`
new_output = generator_err.value
if new_output is not None:
html = new_output
error = None
needs_processing = True
return GeneratorResult(html=new_output, error=None, needs_processing=True, spent=True)
# Nothing returned at the end of the generator, keep the original HTML and error
return GeneratorResult(html=html, error=error, needs_processing=False, spent=True)
# Catch if `Component.on_render()` raises an exception, in which case this becomes
# the new error.
except Exception as new_error: # noqa: BLE001
error = new_error
html = None
set_component_error_message(new_error, full_path[1:])
return GeneratorResult(html=None, error=new_error, needs_processing=False, spent=True)
# If the generator didn't raise an error then `Component.on_render()` yielded a new HTML result,
# that we need to process.
else:
needs_processing = True
return GeneratorResult(html=new_result, error=None, needs_processing=needs_processing, spent=generator_spent)
if is_first_send or new_result is not None:
started_generators_cache[on_render_generator] = True
return GeneratorResult(html=new_result, error=None, needs_processing=True, spent=False)
return GeneratorResult(html=html, error=error, needs_processing=needs_processing, spent=generator_spent)
# Generator yielded `None`, keep the previous HTML and error
return GeneratorResult(html=html, error=error, needs_processing=False, spent=False)

View file

@ -2,6 +2,43 @@ from contextlib import contextmanager
from typing import Generator, List
def set_component_error_message(err: Exception, component_path: List[str]) -> None:
"""
Format the error message to include the component path. E.g.
```
KeyError: "An error occured while rendering components MyPage > MyComponent > MyComponent(slot:content)
```
"""
if not hasattr(err, "_components"):
err._components = [] # type: ignore[attr-defined]
components = getattr(err, "_components", [])
components = err._components = [*component_path, *components] # type: ignore[attr-defined]
# Format component path as
# "MyPage > MyComponent > MyComponent(slot:content) > Base(slot:tab)"
comp_path = " > ".join(components)
prefix = f"An error occured while rendering components {comp_path}:\n"
# Access the exception's message, see https://stackoverflow.com/a/75549200/9788634
if len(err.args) and err.args[0] is not None:
orig_msg = str(err.args[0])
if components and "An error occured while rendering components" in orig_msg:
orig_msg = str(err.args[0]).split("\n", 1)[-1]
else:
# When the exception has no message, it may be that the exception
# does NOT rely on the `args` attribute. Such case is for example
# Pydantic exceptions.
#
# In this case, we still try to use the `args` attribute, but
# it's not guaranteed to work. So we also print out the component
# path ourselves.
print(prefix) # noqa: T201
orig_msg = str(err)
err.args = (prefix + orig_msg,) # tuple of one
@contextmanager
def component_error_message(component_path: List[str]) -> Generator[None, None, None]:
"""
@ -14,34 +51,7 @@ def component_error_message(component_path: List[str]) -> Generator[None, None,
try:
yield
except Exception as err:
if not hasattr(err, "_components"):
err._components = [] # type: ignore[attr-defined]
components = getattr(err, "_components", [])
components = err._components = [*component_path, *components] # type: ignore[attr-defined]
# Format component path as
# "MyPage > MyComponent > MyComponent(slot:content) > Base(slot:tab)"
comp_path = " > ".join(components)
prefix = f"An error occured while rendering components {comp_path}:\n"
# Access the exception's message, see https://stackoverflow.com/a/75549200/9788634
if len(err.args) and err.args[0] is not None:
orig_msg = str(err.args[0])
if components and "An error occured while rendering components" in orig_msg:
orig_msg = str(err.args[0]).split("\n", 1)[-1]
else:
# When the exception has no message, it may be that the exception
# does NOT rely on the `args` attribute. Such case is for example
# Pydantic exceptions.
#
# In this case, we still try to use the `args` attribute, but
# it's not guaranteed to work. So we also print out the component
# path ourselves.
print(prefix) # noqa: T201
orig_msg = str(err)
err.args = (prefix + orig_msg,) # tuple of one
set_component_error_message(err, component_path)
# `from None` should still raise the original error, but without showing this
# line in the traceback.

View file

@ -5,7 +5,21 @@ from hashlib import md5
from importlib import import_module
from itertools import chain
from types import ModuleType
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Type, TypeVar, Union, cast
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Iterable,
List,
Optional,
Set,
Tuple,
Type,
TypeVar,
Union,
cast,
)
from urllib import parse
from django_components.constants import UID_LENGTH
@ -247,3 +261,9 @@ def format_as_ascii_table(
# Combine all parts into the final table
table = "\n".join([header_row, separator, *data_rows]) if include_headers else "\n".join(data_rows)
return table
# TODO - Convert to TypeGuard once Python 3.9 is dropped
def is_generator(obj: Any) -> bool:
"""Check if an object is a generator with send method."""
return hasattr(obj, "send")

View file

@ -909,9 +909,6 @@
class="flex flex-col"
>
<div class="prose flex flex-col gap-8" data-djc-id-ca1bc9b="" data-djc-id-ca1bccd="" data-djc-id-ca1bcd5="">
<div class="border-b border-neutral-300">
@ -1237,9 +1234,6 @@
class="flex flex-col"
>
<div class="prose" data-djc-id-ca1bc9b="" data-djc-id-ca1bcd0="" data-djc-id-ca1bcf2="">
<h3>Notes</h3>
@ -1410,9 +1404,6 @@
class="flex flex-col"
>
<div class="prose" data-djc-id-ca1bc9b="" data-djc-id-ca1bcd1="" data-djc-id-ca1bcff="">
<h3>Notes</h3>
@ -1516,9 +1507,6 @@
class="flex flex-col"
>
<div class="prose" data-djc-id-ca1bc9b="" data-djc-id-ca1bcd2="" data-djc-id-ca1bd05="">
<h3>Notes</h3>
@ -1622,9 +1610,6 @@
class="flex flex-col"
>
<div class="flex flex-col gap-y-3" data-djc-id-ca1bc9b="" data-djc-id-ca1bcd3="" data-djc-id-ca1bd0b="">

View file

@ -1694,8 +1694,10 @@ class TestComponentHook:
"outer__on_render_before",
"outer__on_render_pre",
"middle__on_render_before",
"middle__on_render_before",
"middle__on_render_pre",
"inner__on_render_before",
"inner__on_render_before",
"inner__on_render_pre",
"slotted__on_render_before",
"slotted__on_render_pre",
@ -1703,15 +1705,14 @@ class TestComponentHook:
"slotted__on_render_after",
"inner__on_render_post",
"inner__on_render_after",
"inner__on_render_before",
"inner__on_render_pre",
"inner__on_render_post",
"inner__on_render_after",
"middle__on_render_post",
"middle__on_render_after",
"middle__on_render_before",
"middle__on_render_pre",
"inner__on_render_before",
"inner__on_render_before",
"inner__on_render_pre",
"slotted__on_render_before",
"slotted__on_render_pre",
@ -1719,7 +1720,6 @@ class TestComponentHook:
"slotted__on_render_after",
"inner__on_render_post",
"inner__on_render_after",
"inner__on_render_before",
"inner__on_render_pre",
"inner__on_render_post",
"inner__on_render_after",
@ -1869,9 +1869,9 @@ class TestComponentHook:
{% if case == 1 %}
{% component "broken" / %}
{% elif case == 2 %}
Hello
<div>Hello</div>
{% elif case == 3 %}
There
<div>There</div>
{% endif %}
"""
@ -1890,23 +1890,25 @@ class TestComponentHook:
html3, error3 = yield template.render(context)
results.append((html3.strip(), error3))
html4, error4 = yield "Other result"
html4, error4 = yield "<div>Other result</div>"
results.append((html4, error4))
return "Final result"
return "<div>Final result</div>"
result = SimpleComponent.render()
assert result == "Final result"
assert result == '<div data-djc-id-ca1bc3e="">Final result</div>'
# NOTE: Exceptions are stubborn, comparison evaluates to False even with the same message.
assert results[0][0] is None
assert isinstance(results[0][1], ValueError)
assert results[0][1].args[0] == "An error occured while rendering components broken:\nBROKEN"
# NOTE: It's important that all the results are wrapped in `<div>`
# so we can check if the djc-id attribute was set.
assert results[1:] == [
("Hello", None),
("There", None),
("Other result", None),
('<div data-djc-id-ca1bc3e="">Hello</div>', None),
('<div data-djc-id-ca1bc3e="">There</div>', None),
('<div data-djc-id-ca1bc3e="">Other result</div>', None),
]
@djc_test(