mirror of
https://github.com/django-components/django-components.git
synced 2025-11-02 16:21:18 +00:00
refactor: use lamba with yield in Component.on_render() (#1428)
Some checks are pending
Docs - build & deploy / docs (push) Waiting to run
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
Some checks are pending
Docs - build & deploy / docs (push) Waiting to run
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
This commit is contained in:
parent
3e837e20c6
commit
eee3910b54
6 changed files with 277 additions and 116 deletions
37
CHANGELOG.md
37
CHANGELOG.md
|
|
@ -4,23 +4,48 @@
|
||||||
|
|
||||||
#### Feat
|
#### Feat
|
||||||
|
|
||||||
|
- Wrap the template rendering in `Component.on_render()` in a lambda function.
|
||||||
|
|
||||||
|
When you wrap the rendering call in a lambda function, and the rendering fails,
|
||||||
|
the error will be yielded back in the `(None, Exception)` tuple.
|
||||||
|
|
||||||
|
Before:
|
||||||
|
|
||||||
|
```py
|
||||||
|
class MyTable(Component):
|
||||||
|
def on_render(self, context, template):
|
||||||
|
try:
|
||||||
|
intermediate = template.render(context)
|
||||||
|
html, error = yield intermediate
|
||||||
|
except Exception as e:
|
||||||
|
html, error = None, e
|
||||||
|
```
|
||||||
|
|
||||||
|
After:
|
||||||
|
|
||||||
|
```py
|
||||||
|
class MyTable(Component):
|
||||||
|
def on_render(self, context, template):
|
||||||
|
html, error = yield lambda: template.render(context)
|
||||||
|
```
|
||||||
|
|
||||||
- Multiple yields in `Component.on_render()` - You can now yield multiple times within the same `on_render` method for complex rendering scenarios.
|
- Multiple yields in `Component.on_render()` - You can now yield multiple times within the same `on_render` method for complex rendering scenarios.
|
||||||
|
|
||||||
```py
|
```py
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render(self, context, template):
|
def on_render(self, context, template):
|
||||||
# First yield - render with one context
|
# First yield
|
||||||
with context.push({"mode": "header"}):
|
with context.push({"mode": "header"}):
|
||||||
header_html, header_error = yield template.render(context)
|
header_html, header_error = yield lambda: template.render(context)
|
||||||
|
|
||||||
# Second yield - render with different context
|
# Second yield
|
||||||
with context.push({"mode": "body"}):
|
with context.push({"mode": "body"}):
|
||||||
body_html, body_error = yield template.render(context)
|
body_html, body_error = yield lambda: template.render(context)
|
||||||
|
|
||||||
# Third yield - render a string directly
|
# Third yield
|
||||||
footer_html, footer_error = yield "Footer content"
|
footer_html, footer_error = yield "Footer content"
|
||||||
|
|
||||||
# Process all results and return final output
|
# Process all results
|
||||||
if header_error or body_error or footer_error:
|
if header_error or body_error or footer_error:
|
||||||
return "Error occurred during rendering"
|
return "Error occurred during rendering"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,6 @@ class MyTable(Component):
|
||||||
|
|
||||||
Do NOT modify the template in this hook. The template is reused across renders.
|
Do NOT modify the template in this hook. The template is reused across renders.
|
||||||
|
|
||||||
Since this hook is called for every component, this means that the template would be modified
|
|
||||||
every time a component is rendered.
|
|
||||||
|
|
||||||
### `on_render`
|
### `on_render`
|
||||||
|
|
||||||
_New in version 0.140_
|
_New in version 0.140_
|
||||||
|
|
@ -86,9 +83,7 @@ with the given
|
||||||
```py
|
```py
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render(self, context, template):
|
def on_render(self, context, template):
|
||||||
if template is None:
|
if template:
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return template.render(context)
|
return template.render(context)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -98,14 +93,26 @@ The `template` argument is `None` if the component has no template.
|
||||||
|
|
||||||
To change what gets rendered, you can:
|
To change what gets rendered, you can:
|
||||||
|
|
||||||
- Render a different template
|
|
||||||
- Render a component
|
- Render a component
|
||||||
- Return a different string or SafeString
|
- Render a template
|
||||||
|
- Return a string or SafeString
|
||||||
|
|
||||||
```py
|
```py
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render(self, context, template):
|
def on_render(self, context, template):
|
||||||
return "Hello"
|
# Return a string
|
||||||
|
return "<p>Hello</p>"
|
||||||
|
|
||||||
|
# Render a component
|
||||||
|
return MyOtherTable.render(
|
||||||
|
args=self.args,
|
||||||
|
kwargs=self.kwargs,
|
||||||
|
slots=self.slots,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Render a template
|
||||||
|
return get_template("my_other_table.html").render(context)
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also use [`on_render()`](../../../reference/api#django_components.Component.on_render) as a router,
|
You can also use [`on_render()`](../../../reference/api#django_components.Component.on_render) as a router,
|
||||||
|
|
@ -145,14 +152,19 @@ are not rendered yet.
|
||||||
Instead, django-components needs to take this result and process it
|
Instead, django-components needs to take this result and process it
|
||||||
to actually render the child components.
|
to actually render the child components.
|
||||||
|
|
||||||
To access the final output, you can `yield` the result instead of returning it.
|
This is not a problem when you return the result directly as above. Django-components will take care of rendering the child components.
|
||||||
|
|
||||||
This will return a tuple of (rendered HTML, error). The error is `None` if the rendering succeeded.
|
But if you want to access the final output, you must `yield` the result instead of returning it.
|
||||||
|
|
||||||
|
Yielding the result will return a tuple of `(rendered_html, error)`:
|
||||||
|
|
||||||
|
- On success, the error is `None` - `(string, None)`
|
||||||
|
- On failure, the rendered HTML is `None` - `(None, Exception)`
|
||||||
|
|
||||||
```py
|
```py
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render(self, context, template):
|
def on_render(self, context, template):
|
||||||
html, error = yield template.render(context)
|
html, error = yield lambda: template.render(context)
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
# The rendering succeeded
|
# The rendering succeeded
|
||||||
|
|
@ -162,23 +174,34 @@ class MyTable(Component):
|
||||||
print(f"Error: {error}")
|
print(f"Error: {error}")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
Notice that we actually yield a **lambda function** instead of the result itself.
|
||||||
|
This is because calling `template.render(context)` may raise an exception.
|
||||||
|
|
||||||
|
When you wrap the result in a lambda function, and the rendering fails,
|
||||||
|
the error will be yielded back in the `(None, Exception)` tuple.
|
||||||
|
|
||||||
At this point you can do 3 things:
|
At this point you can do 3 things:
|
||||||
|
|
||||||
1. Return a new HTML
|
1. Return new HTML
|
||||||
|
|
||||||
The new HTML will be used as the final output.
|
The new HTML will be used as the final output.
|
||||||
|
|
||||||
If the original template raised an error, it will be ignored.
|
If the original template raised an error, the original error will be ignored.
|
||||||
|
|
||||||
```py
|
```py
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render(self, context, template):
|
def on_render(self, context, template):
|
||||||
html, error = yield template.render(context)
|
html, error = yield lambda: template.render(context)
|
||||||
|
|
||||||
return "NEW HTML"
|
# Fallback if rendering failed
|
||||||
|
# Otherwise, we keep the original HTML
|
||||||
|
if error is not None:
|
||||||
|
return "FALLBACK HTML"
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Raise a new exception
|
2. Raise new exception
|
||||||
|
|
||||||
The new exception is what will bubble up from the component.
|
The new exception is what will bubble up from the component.
|
||||||
|
|
||||||
|
|
@ -187,48 +210,55 @@ At this point you can do 3 things:
|
||||||
```py
|
```py
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render(self, context, template):
|
def on_render(self, context, template):
|
||||||
html, error = yield template.render(context)
|
html, error = yield lambda: template.render(context)
|
||||||
|
|
||||||
raise Exception("Error message")
|
# Override the original error
|
||||||
|
# Otherwise, we keep the original HTML
|
||||||
|
if error is not None:
|
||||||
|
raise Exception("My new error") from error
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Return nothing (or `None`) to handle the result as usual
|
3. No change - Return nothing or `None`
|
||||||
|
|
||||||
If you don't raise an exception, and neither return a new HTML,
|
If you neither raise an exception, nor return a new HTML,
|
||||||
then original HTML / error will be used:
|
then the original HTML / error will be used:
|
||||||
|
|
||||||
- If rendering succeeded, the original HTML will be used as the final output.
|
- If rendering succeeded, the original HTML will be used as the final output.
|
||||||
- If rendering failed, the original error will be propagated.
|
- If rendering failed, the original error will be propagated.
|
||||||
|
|
||||||
|
This can be useful for side effects like tracking the errors that occurred during the rendering:
|
||||||
|
|
||||||
```py
|
```py
|
||||||
|
from myapp.metrics import track_rendering_error
|
||||||
|
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render(self, context, template):
|
def on_render(self, context, template):
|
||||||
html, error = yield template.render(context)
|
html, error = yield lambda: template.render(context)
|
||||||
|
|
||||||
|
# Track how many times the rendering failed
|
||||||
if error is not None:
|
if error is not None:
|
||||||
# The rendering failed
|
track_rendering_error(error)
|
||||||
print(f"Error: {error}")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Multiple yields
|
#### Multiple yields
|
||||||
|
|
||||||
You can yield multiple times within the same `on_render` method. This is useful for complex rendering scenarios where you need to render different templates or handle multiple rendering operations:
|
You can yield multiple times within the same [`on_render()`](../../../reference/api#django_components.Component.on_render) method. This is useful for complex rendering scenarios:
|
||||||
|
|
||||||
```py
|
```py
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render(self, context, template):
|
def on_render(self, context, template):
|
||||||
# First yield - render with one context
|
# First yield
|
||||||
with context.push({"mode": "header"}):
|
with context.push({"mode": "header"}):
|
||||||
header_html, header_error = yield template.render(context)
|
header_html, header_error = yield lambda: template.render(context)
|
||||||
|
|
||||||
# Second yield - render with different context
|
# Second yield
|
||||||
with context.push({"mode": "body"}):
|
with context.push({"mode": "body"}):
|
||||||
body_html, body_error = yield template.render(context)
|
body_html, body_error = yield lambda: template.render(context)
|
||||||
|
|
||||||
# Third yield - render a string directly
|
# Third yield
|
||||||
footer_html, footer_error = yield "Footer content"
|
footer_html, footer_error = yield "Footer content"
|
||||||
|
|
||||||
# Process all results and return final output
|
# Process all
|
||||||
if header_error or body_error or footer_error:
|
if header_error or body_error or footer_error:
|
||||||
return "Error occurred during rendering"
|
return "Error occurred during rendering"
|
||||||
|
|
||||||
|
|
@ -246,7 +276,7 @@ That is, a component that catches errors in nested components and displays a fal
|
||||||
|
|
||||||
```django
|
```django
|
||||||
{% component "error_boundary" %}
|
{% component "error_boundary" %}
|
||||||
{% fill "content" %}
|
{% fill "default" %}
|
||||||
{% component "nested_component" %}
|
{% component "nested_component" %}
|
||||||
{% endfill %}
|
{% endfill %}
|
||||||
{% fill "fallback" %}
|
{% fill "fallback" %}
|
||||||
|
|
@ -259,10 +289,13 @@ To implement this, we render the fallback slot in [`on_render()`](../../../refer
|
||||||
and return it if an error occured:
|
and return it if an error occured:
|
||||||
|
|
||||||
```djc_py
|
```djc_py
|
||||||
class ErrorFallback(Component):
|
from typing import NamedTuple, Optional
|
||||||
class Kwargs(NamedTuple):
|
|
||||||
fallback: Optional[str] = None
|
|
||||||
|
|
||||||
|
from django.template import Context, Template
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from django_components import Component, OnRenderGenerator, SlotInput, types
|
||||||
|
|
||||||
|
class ErrorFallback(Component):
|
||||||
class Slots(NamedTuple):
|
class Slots(NamedTuple):
|
||||||
default: Optional[SlotInput] = None
|
default: Optional[SlotInput] = None
|
||||||
fallback: Optional[SlotInput] = None
|
fallback: Optional[SlotInput] = None
|
||||||
|
|
@ -280,30 +313,22 @@ class ErrorFallback(Component):
|
||||||
context: Context,
|
context: Context,
|
||||||
template: Template,
|
template: Template,
|
||||||
) -> OnRenderGenerator:
|
) -> OnRenderGenerator:
|
||||||
fallback_kwarg = cast(ErrorFallback.Kwargs, self.kwargs).fallback
|
fallback_slot = self.slots.default
|
||||||
fallback_slot = cast(ErrorFallback.Slots, self.slots).default
|
|
||||||
|
|
||||||
if fallback_kwarg is not None and fallback_slot is not None:
|
result, error = yield lambda: template.render(context)
|
||||||
raise TemplateSyntaxError(
|
|
||||||
"The 'fallback' argument and slot cannot both be provided. Please provide only one.",
|
|
||||||
)
|
|
||||||
|
|
||||||
result, error = yield template.render(context)
|
# No error, return the original result
|
||||||
|
|
||||||
# No error, return the result
|
|
||||||
if error is None:
|
if error is None:
|
||||||
return result
|
return None
|
||||||
|
|
||||||
# Error, return the fallback
|
# Error, return the fallback
|
||||||
if fallback_kwarg is not None:
|
if fallback_slot is not None:
|
||||||
return fallback_kwarg
|
# Render the template second time, this time rendering
|
||||||
elif fallback_slot is not None:
|
# the fallback branch
|
||||||
# Render the template second time, this time with the error
|
|
||||||
# So that we render the fallback slot with proper access to the outer context and whatnot.
|
|
||||||
with context.push({"error": error}):
|
with context.push({"error": error}):
|
||||||
return template.render(context)
|
return template.render(context)
|
||||||
else:
|
else:
|
||||||
return ""
|
return mark_safe("<pre>An error occurred</pre>")
|
||||||
```
|
```
|
||||||
|
|
||||||
### `on_render_after`
|
### `on_render_after`
|
||||||
|
|
@ -333,30 +358,31 @@ as the second part of [`on_render()`](#on_render) (after the `yield`).
|
||||||
```py
|
```py
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render_after(self, context, template, result, error):
|
def on_render_after(self, context, template, result, error):
|
||||||
if error is None:
|
# If rendering succeeded, keep the original result
|
||||||
# The rendering succeeded
|
# Otherwise, print the error
|
||||||
return result
|
if error is not None:
|
||||||
else:
|
|
||||||
# The rendering failed
|
|
||||||
print(f"Error: {error}")
|
print(f"Error: {error}")
|
||||||
```
|
```
|
||||||
|
|
||||||
Same as [`on_render()`](#on_render),
|
Same as [`on_render()`](#on_render),
|
||||||
you can return a new HTML, raise a new exception, or return nothing:
|
you can return a new HTML, raise a new exception, or return nothing:
|
||||||
|
|
||||||
1. Return a new HTML
|
1. Return new HTML
|
||||||
|
|
||||||
The new HTML will be used as the final output.
|
The new HTML will be used as the final output.
|
||||||
|
|
||||||
If the original template raised an error, it will be ignored.
|
If the original template raised an error, the original error will be ignored.
|
||||||
|
|
||||||
```py
|
```py
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render_after(self, context, template, result, error):
|
def on_render_after(self, context, template, result, error):
|
||||||
return "NEW HTML"
|
# Fallback if rendering failed
|
||||||
|
# Otherwise, we keep the original HTML
|
||||||
|
if error is not None:
|
||||||
|
return "FALLBACK HTML"
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Raise a new exception
|
2. Raise new exception
|
||||||
|
|
||||||
The new exception is what will bubble up from the component.
|
The new exception is what will bubble up from the component.
|
||||||
|
|
||||||
|
|
@ -365,26 +391,33 @@ you can return a new HTML, raise a new exception, or return nothing:
|
||||||
```py
|
```py
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render_after(self, context, template, result, error):
|
def on_render_after(self, context, template, result, error):
|
||||||
raise Exception("Error message")
|
# Override the original error
|
||||||
|
# Otherwise, we keep the original HTML
|
||||||
|
if error is not None:
|
||||||
|
raise Exception("My new error") from error
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Return nothing (or `None`) to handle the result as usual
|
3. No change - Return nothing or `None`
|
||||||
|
|
||||||
If you don't raise an exception, and neither return a new HTML,
|
If you neither raise an exception, nor return a new HTML,
|
||||||
then original HTML / error will be used:
|
then the original HTML / error will be used:
|
||||||
|
|
||||||
- If rendering succeeded, the original HTML will be used as the final output.
|
- If rendering succeeded, the original HTML will be used as the final output.
|
||||||
- If rendering failed, the original error will be propagated.
|
- If rendering failed, the original error will be propagated.
|
||||||
|
|
||||||
|
This can be useful for side effects like tracking the errors that occurred during the rendering:
|
||||||
|
|
||||||
```py
|
```py
|
||||||
|
from myapp.metrics import track_rendering_error
|
||||||
|
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render_after(self, context, template, result, error):
|
def on_render_after(self, context, template, result, error):
|
||||||
|
# Track how many times the rendering failed
|
||||||
if error is not None:
|
if error is not None:
|
||||||
# The rendering failed
|
track_rendering_error(error)
|
||||||
print(f"Error: {error}")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Example
|
## Example: Tabs
|
||||||
|
|
||||||
You can use hooks together with [provide / inject](#how-to-use-provide--inject) to create components
|
You can use hooks together with [provide / inject](#how-to-use-provide--inject) to create components
|
||||||
that accept a list of items via a slot.
|
that accept a list of items via a slot.
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@ ignore = [
|
||||||
"PLR0913", # Too many arguments in function definition (6 > 5)
|
"PLR0913", # Too many arguments in function definition (6 > 5)
|
||||||
"PLR2004", # Magic value used in comparison, consider replacing `123` with a constant variable
|
"PLR2004", # Magic value used in comparison, consider replacing `123` with a constant variable
|
||||||
"RET504", # Unnecessary assignment to `collected` before `return` statement
|
"RET504", # Unnecessary assignment to `collected` before `return` statement
|
||||||
|
"RET505", # Unnecessary `elif` after `return` statement
|
||||||
"S308", # Use of `mark_safe` may expose cross-site scripting vulnerabilities
|
"S308", # Use of `mark_safe` may expose cross-site scripting vulnerabilities
|
||||||
"S603", # `subprocess` call: check for execution of untrusted input
|
"S603", # `subprocess` call: check for execution of untrusted input
|
||||||
"SIM108", # Use ternary operator `...` instead of `if`-`else`-block
|
"SIM108", # Use ternary operator `...` instead of `if`-`else`-block
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ else:
|
||||||
|
|
||||||
|
|
||||||
OnRenderGenerator = Generator[
|
OnRenderGenerator = Generator[
|
||||||
Optional[SlotResult],
|
Optional[Union[SlotResult, Callable[[], SlotResult]]],
|
||||||
Tuple[Optional[SlotResult], Optional[Exception]],
|
Tuple[Optional[SlotResult], Optional[Exception]],
|
||||||
Optional[SlotResult],
|
Optional[SlotResult],
|
||||||
]
|
]
|
||||||
|
|
@ -122,7 +122,7 @@ method if it yields (and thus returns a generator).
|
||||||
|
|
||||||
When `on_render()` is a generator then it:
|
When `on_render()` is a generator then it:
|
||||||
|
|
||||||
- Yields a rendered template (string or `None`)
|
- Yields a rendered template (string or `None`) or a lambda function to be called later.
|
||||||
|
|
||||||
- Receives back a tuple of `(final_output, error)`.
|
- Receives back a tuple of `(final_output, error)`.
|
||||||
|
|
||||||
|
|
@ -151,13 +151,15 @@ class MyTable(Component):
|
||||||
# Same as `Component.on_render_before()`
|
# Same as `Component.on_render_before()`
|
||||||
context["hello"] = "world"
|
context["hello"] = "world"
|
||||||
|
|
||||||
# Yield rendered template to receive fully-rendered template or error
|
# Yield a function that renders the template
|
||||||
html, error = yield template.render(context)
|
# to receive fully-rendered template or error.
|
||||||
|
html, error = yield lambda: template.render(context)
|
||||||
|
|
||||||
# Do something AFTER rendering template, or post-process
|
# Do something AFTER rendering template, or post-process
|
||||||
# the rendered template.
|
# the rendered template.
|
||||||
# Same as `Component.on_render_after()`
|
# Same as `Component.on_render_after()`
|
||||||
return html + "<p>Hello</p>"
|
if html is not None:
|
||||||
|
return html + "<p>Hello</p>"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Multiple yields example:**
|
**Multiple yields example:**
|
||||||
|
|
@ -165,18 +167,18 @@ class MyTable(Component):
|
||||||
```py
|
```py
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render(self, context, template) -> OnRenderGenerator:
|
def on_render(self, context, template) -> OnRenderGenerator:
|
||||||
# First yield - render with one context
|
# First yield
|
||||||
with context.push({"mode": "header"}):
|
with context.push({"mode": "header"}):
|
||||||
header_html, header_error = yield template.render(context)
|
header_html, header_error = yield lambda: template.render(context)
|
||||||
|
|
||||||
# Second yield - render with different context
|
# Second yield
|
||||||
with context.push({"mode": "body"}):
|
with context.push({"mode": "body"}):
|
||||||
body_html, body_error = yield template.render(context)
|
body_html, body_error = yield lambda: template.render(context)
|
||||||
|
|
||||||
# Third yield - render a string directly
|
# Third yield
|
||||||
footer_html, footer_error = yield "Footer content"
|
footer_html, footer_error = yield "Footer content"
|
||||||
|
|
||||||
# Process all results and return final output
|
# Process all results
|
||||||
if header_error or body_error or footer_error:
|
if header_error or body_error or footer_error:
|
||||||
return "Error occurred during rendering"
|
return "Error occurred during rendering"
|
||||||
|
|
||||||
|
|
@ -2023,7 +2025,7 @@ class Component(metaclass=ComponentMeta):
|
||||||
```py
|
```py
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render(self, context, template):
|
def on_render(self, context, template):
|
||||||
html, error = yield template.render(context)
|
html, error = yield lambda: template.render(context)
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
# The rendering succeeded
|
# The rendering succeeded
|
||||||
|
|
@ -2044,7 +2046,7 @@ class Component(metaclass=ComponentMeta):
|
||||||
```py
|
```py
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render(self, context, template):
|
def on_render(self, context, template):
|
||||||
html, error = yield template.render(context)
|
html, error = yield lambda: template.render(context)
|
||||||
|
|
||||||
return "NEW HTML"
|
return "NEW HTML"
|
||||||
```
|
```
|
||||||
|
|
@ -2058,7 +2060,7 @@ class Component(metaclass=ComponentMeta):
|
||||||
```py
|
```py
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render(self, context, template):
|
def on_render(self, context, template):
|
||||||
html, error = yield template.render(context)
|
html, error = yield lambda: template.render(context)
|
||||||
|
|
||||||
raise Exception("Error message")
|
raise Exception("Error message")
|
||||||
```
|
```
|
||||||
|
|
@ -2074,7 +2076,7 @@ class Component(metaclass=ComponentMeta):
|
||||||
```py
|
```py
|
||||||
class MyTable(Component):
|
class MyTable(Component):
|
||||||
def on_render(self, context, template):
|
def on_render(self, context, template):
|
||||||
html, error = yield template.render(context)
|
html, error = yield lambda: template.render(context)
|
||||||
|
|
||||||
if error is not None:
|
if error is not None:
|
||||||
# The rendering failed
|
# The rendering failed
|
||||||
|
|
@ -2091,11 +2093,11 @@ class Component(metaclass=ComponentMeta):
|
||||||
def on_render(self, context, template):
|
def on_render(self, context, template):
|
||||||
# First yield - render with one context
|
# First yield - render with one context
|
||||||
with context.push({"mode": "header"}):
|
with context.push({"mode": "header"}):
|
||||||
header_html, header_error = yield template.render(context)
|
header_html, header_error = yield lambda: template.render(context)
|
||||||
|
|
||||||
# Second yield - render with different context
|
# Second yield - render with different context
|
||||||
with context.push({"mode": "body"}):
|
with context.push({"mode": "body"}):
|
||||||
body_html, body_error = yield template.render(context)
|
body_html, body_error = yield lambda: template.render(context)
|
||||||
|
|
||||||
# Third yield - render a string directly
|
# Third yield - render a string directly
|
||||||
footer_html, footer_error = yield "Footer content"
|
footer_html, footer_error = yield "Footer content"
|
||||||
|
|
@ -3852,7 +3854,7 @@ class Component(metaclass=ComponentMeta):
|
||||||
# ```
|
# ```
|
||||||
# class MyTable(Component):
|
# class MyTable(Component):
|
||||||
# def on_render(self, context, template):
|
# def on_render(self, context, template):
|
||||||
# html, error = yield template.render(context)
|
# html, error = yield lamba: template.render(context)
|
||||||
# return html + "<p>Hello</p>"
|
# return html + "<p>Hello</p>"
|
||||||
# ```
|
# ```
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import re
|
import re
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import TYPE_CHECKING, Callable, Deque, Dict, List, NamedTuple, Optional, Set, Tuple, Union
|
from typing import TYPE_CHECKING, Callable, Deque, Dict, List, Literal, NamedTuple, Optional, Set, Tuple, Union
|
||||||
|
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
|
@ -90,7 +90,7 @@ class ErrorPart(NamedTuple):
|
||||||
class GeneratorResult(NamedTuple):
|
class GeneratorResult(NamedTuple):
|
||||||
html: Optional[str]
|
html: Optional[str]
|
||||||
error: Optional[Exception]
|
error: Optional[Exception]
|
||||||
needs_processing: bool
|
action: Literal["needs_processing", "rerender", "stop"]
|
||||||
spent: bool
|
spent: bool
|
||||||
"""Whether the generator has been "spent" - e.g. reached its end with `StopIteration`."""
|
"""Whether the generator has been "spent" - e.g. reached its end with `StopIteration`."""
|
||||||
|
|
||||||
|
|
@ -431,7 +431,7 @@ def component_post_render(
|
||||||
|
|
||||||
# The generator yielded or returned a new HTML. We want to process it as if
|
# The generator yielded or returned a new HTML. We want to process it as if
|
||||||
# it's a new component's HTML.
|
# it's a new component's HTML.
|
||||||
if result.needs_processing:
|
if result.action == "needs_processing":
|
||||||
# Ignore the old version of the component
|
# Ignore the old version of the component
|
||||||
ignored_components.add(item_id)
|
ignored_components.add(item_id)
|
||||||
|
|
||||||
|
|
@ -455,8 +455,20 @@ def component_post_render(
|
||||||
parts_to_process = parse_component_result(new_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))
|
process_queue.extendleft(reversed(parts_to_process))
|
||||||
return
|
return
|
||||||
# If we don't need to re-do the processing, then we can just use the result.
|
elif result.action == "rerender":
|
||||||
component_html, error = new_html, result.error
|
# Ignore the old version of the component
|
||||||
|
ignored_components.add(item_id)
|
||||||
|
|
||||||
|
new_version = item_id.version + 1
|
||||||
|
new_item_id = QueueItemId(component_id=item_id.component_id, version=new_version)
|
||||||
|
# Set the current parent as the parent of the new version
|
||||||
|
child_to_parent[new_item_id] = parent_id
|
||||||
|
|
||||||
|
next_renderer_result(item_id=new_item_id, error=result.error, full_path=full_path)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# If we don't need to re-do the processing, then we can just use the result.
|
||||||
|
component_html, error = new_html, result.error
|
||||||
|
|
||||||
# Allow to optionally override/modify the rendered content from `Component.on_render_after()`
|
# Allow to optionally override/modify the rendered content from `Component.on_render_after()`
|
||||||
# and by extensions' `on_component_rendered` hooks.
|
# and by extensions' `on_component_rendered` hooks.
|
||||||
|
|
@ -590,22 +602,55 @@ def _call_generator(
|
||||||
# The return value is on `StopIteration.value`
|
# The return value is on `StopIteration.value`
|
||||||
new_output = generator_err.value
|
new_output = generator_err.value
|
||||||
if new_output is not None:
|
if new_output is not None:
|
||||||
return GeneratorResult(html=new_output, error=None, needs_processing=True, spent=True)
|
return GeneratorResult(html=new_output, error=None, action="needs_processing", spent=True)
|
||||||
# Nothing returned at the end of the generator, keep the original HTML and error
|
# 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)
|
return GeneratorResult(html=html, error=error, action="stop", spent=True)
|
||||||
|
|
||||||
# Catch if `Component.on_render()` raises an exception, in which case this becomes
|
# Catch if `Component.on_render()` raises an exception, in which case this becomes
|
||||||
# the new error.
|
# the new error.
|
||||||
except Exception as new_error: # noqa: BLE001
|
except Exception as new_error: # noqa: BLE001
|
||||||
set_component_error_message(new_error, full_path[1:])
|
set_component_error_message(new_error, full_path[1:])
|
||||||
return GeneratorResult(html=None, error=new_error, needs_processing=False, spent=True)
|
return GeneratorResult(html=None, error=new_error, action="stop", spent=True)
|
||||||
|
|
||||||
# If the generator didn't raise an error then `Component.on_render()` yielded a new HTML result,
|
# If the generator didn't raise an error then `Component.on_render()` yielded a new HTML result,
|
||||||
# that we need to process.
|
# that we need to process.
|
||||||
else:
|
else:
|
||||||
|
# NOTE: Users may yield a function from `on_render()` instead of rendered template:
|
||||||
|
# ```py
|
||||||
|
# class MyTable(Component):
|
||||||
|
# def on_render(self, context, template):
|
||||||
|
# html, error = yield lambda: template.render(context)
|
||||||
|
# return html + "<p>Hello</p>"
|
||||||
|
# ```
|
||||||
|
# This is so that we can keep the API simple, handling the errors in template rendering.
|
||||||
|
# Otherwise, people would have to write out:
|
||||||
|
# ```py
|
||||||
|
# try:
|
||||||
|
# intermediate = template.render(context)
|
||||||
|
# except Exception as err:
|
||||||
|
# result = None
|
||||||
|
# error = err
|
||||||
|
# else:
|
||||||
|
# result, error = yield intermediate
|
||||||
|
# ```
|
||||||
|
if callable(new_result):
|
||||||
|
try:
|
||||||
|
new_result = new_result()
|
||||||
|
except Exception as new_err: # noqa: BLE001
|
||||||
|
started_generators_cache[on_render_generator] = True
|
||||||
|
set_component_error_message(new_err, full_path[1:])
|
||||||
|
# In other cases, when a component raises an error during rendering,
|
||||||
|
# we discard the errored component and move up to the parent component
|
||||||
|
# to decide what to do (propagate or return a new HTML).
|
||||||
|
#
|
||||||
|
# But if user yielded a function from `Component.on_render()`,
|
||||||
|
# we want to let the CURRENT component decide what to do.
|
||||||
|
# Hence why the action is "rerender" instead of "stop".
|
||||||
|
return GeneratorResult(html=None, error=new_err, action="rerender", spent=False)
|
||||||
|
|
||||||
if is_first_send or new_result is not None:
|
if is_first_send or new_result is not None:
|
||||||
started_generators_cache[on_render_generator] = True
|
started_generators_cache[on_render_generator] = True
|
||||||
return GeneratorResult(html=new_result, error=None, needs_processing=True, spent=False)
|
return GeneratorResult(html=new_result, error=None, action="needs_processing", spent=False)
|
||||||
|
|
||||||
# Generator yielded `None`, keep the previous HTML and error
|
# Generator yielded `None`, keep the previous HTML and error
|
||||||
return GeneratorResult(html=html, error=error, needs_processing=False, spent=False)
|
return GeneratorResult(html=html, error=error, action="stop", spent=False)
|
||||||
|
|
|
||||||
|
|
@ -1533,7 +1533,7 @@ class TestComponentHook:
|
||||||
|
|
||||||
def on_render(self, context: Context, template: Optional[Template]):
|
def on_render(self, context: Context, template: Optional[Template]):
|
||||||
calls.append("slotted__on_render_pre")
|
calls.append("slotted__on_render_pre")
|
||||||
_html, _error = yield template.render(context) # type: ignore[union-attr]
|
_html, _error = yield lambda: template.render(context) # type: ignore[union-attr]
|
||||||
|
|
||||||
calls.append("slotted__on_render_post")
|
calls.append("slotted__on_render_post")
|
||||||
|
|
||||||
|
|
@ -1566,7 +1566,7 @@ class TestComponentHook:
|
||||||
if template is None:
|
if template is None:
|
||||||
yield None
|
yield None
|
||||||
else:
|
else:
|
||||||
_html, _error = yield template.render(context)
|
_html, _error = yield lambda: template.render(context)
|
||||||
|
|
||||||
calls.append("inner__on_render_post")
|
calls.append("inner__on_render_post")
|
||||||
|
|
||||||
|
|
@ -1600,7 +1600,7 @@ class TestComponentHook:
|
||||||
|
|
||||||
def on_render(self, context: Context, template: Optional[Template]):
|
def on_render(self, context: Context, template: Optional[Template]):
|
||||||
calls.append("middle__on_render_pre")
|
calls.append("middle__on_render_pre")
|
||||||
_html, _error = yield template.render(context) # type: ignore[union-attr]
|
_html, _error = yield lambda: template.render(context) # type: ignore[union-attr]
|
||||||
|
|
||||||
calls.append("middle__on_render_post")
|
calls.append("middle__on_render_post")
|
||||||
|
|
||||||
|
|
@ -1632,7 +1632,7 @@ class TestComponentHook:
|
||||||
|
|
||||||
def on_render(self, context: Context, template: Optional[Template]):
|
def on_render(self, context: Context, template: Optional[Template]):
|
||||||
calls.append("outer__on_render_pre")
|
calls.append("outer__on_render_pre")
|
||||||
_html, _error = yield template.render(context) # type: ignore[union-attr]
|
_html, _error = yield lambda: template.render(context) # type: ignore[union-attr]
|
||||||
|
|
||||||
calls.append("outer__on_render_post")
|
calls.append("outer__on_render_post")
|
||||||
|
|
||||||
|
|
@ -1752,7 +1752,7 @@ class TestComponentHook:
|
||||||
# Check we can modify entries set by other methods
|
# Check we can modify entries set by other methods
|
||||||
context["from_on_before__edited1"] = context["from_on_before"] + " (on_render)"
|
context["from_on_before__edited1"] = context["from_on_before"] + " (on_render)"
|
||||||
|
|
||||||
_html, _error = yield template.render(context)
|
_html, _error = yield lambda: template.render(context)
|
||||||
|
|
||||||
context["from_on_render_post"] = "3"
|
context["from_on_render_post"] = "3"
|
||||||
|
|
||||||
|
|
@ -1804,7 +1804,7 @@ class TestComponentHook:
|
||||||
def on_render(self, context: Context, template: Template):
|
def on_render(self, context: Context, template: Template):
|
||||||
template.nodelist.append(TextNode("\n---\nFROM_ON_RENDER_PRE"))
|
template.nodelist.append(TextNode("\n---\nFROM_ON_RENDER_PRE"))
|
||||||
|
|
||||||
_html, _error = yield template.render(context)
|
_html, _error = yield lambda: template.render(context)
|
||||||
|
|
||||||
template.nodelist.append(TextNode("\n---\nFROM_ON_RENDER_POST"))
|
template.nodelist.append(TextNode("\n---\nFROM_ON_RENDER_POST"))
|
||||||
|
|
||||||
|
|
@ -1831,6 +1831,61 @@ class TestComponentHook:
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_lambda_yield(self):
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
template: types.django_html = """
|
||||||
|
text
|
||||||
|
"""
|
||||||
|
|
||||||
|
def on_render(self, context: Context, template: Template):
|
||||||
|
html, _error = yield lambda: template.render(context)
|
||||||
|
return html + "<p>Hello</p>"
|
||||||
|
|
||||||
|
rendered = SimpleComponent.render()
|
||||||
|
assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"text<p data-djc-id-ca1bc3e>Hello</p>",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Works without lambda
|
||||||
|
class SimpleComponent2(SimpleComponent):
|
||||||
|
def on_render(self, context: Context, template: Template):
|
||||||
|
html, _error = yield template.render(context)
|
||||||
|
return html + "<p>Hello</p>"
|
||||||
|
|
||||||
|
rendered2 = SimpleComponent2.render()
|
||||||
|
assertHTMLEqual(
|
||||||
|
rendered2,
|
||||||
|
"text<p data-djc-id-ca1bc3f>Hello</p>",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_lambda_yield_error(self):
|
||||||
|
def broken_template():
|
||||||
|
raise ValueError("BROKEN")
|
||||||
|
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
def on_render(self, context: Context, template: Template):
|
||||||
|
_html, error = yield lambda: broken_template()
|
||||||
|
error.args = ("ERROR MODIFIED",)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError, match=re.escape("An error occured while rendering components SimpleComponent:\nERROR MODIFIED")
|
||||||
|
):
|
||||||
|
SimpleComponent.render()
|
||||||
|
|
||||||
|
# Does NOT work without lambda
|
||||||
|
class SimpleComponent2(SimpleComponent):
|
||||||
|
def on_render(self, context: Context, template: Template):
|
||||||
|
# This raises an error instead of capturing it,
|
||||||
|
# so we never get to modifying the error.
|
||||||
|
_html, error = yield broken_template()
|
||||||
|
error.args = ("ERROR MODIFIED",)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError, match=re.escape("An error occured while rendering components SimpleComponent2:\nBROKEN")
|
||||||
|
):
|
||||||
|
SimpleComponent2.render()
|
||||||
|
|
||||||
def test_on_render_no_yield(self):
|
def test_on_render_no_yield(self):
|
||||||
class SimpleComponent(Component):
|
class SimpleComponent(Component):
|
||||||
template: types.django_html = """
|
template: types.django_html = """
|
||||||
|
|
@ -1852,7 +1907,7 @@ class TestComponentHook:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def on_render(self, context: Context, template: Template):
|
def on_render(self, context: Context, template: Template):
|
||||||
_html, error = yield template.render(context)
|
_html, error = yield lambda: template.render(context)
|
||||||
|
|
||||||
raise error from None # Re-raise original error
|
raise error from None # Re-raise original error
|
||||||
|
|
||||||
|
|
@ -1879,15 +1934,15 @@ class TestComponentHook:
|
||||||
assert template is not None
|
assert template is not None
|
||||||
|
|
||||||
with context.push({"case": 1}):
|
with context.push({"case": 1}):
|
||||||
html1, error1 = yield template.render(context)
|
html1, error1 = yield lambda: template.render(context)
|
||||||
results.append((html1, error1))
|
results.append((html1, error1))
|
||||||
|
|
||||||
with context.push({"case": 2}):
|
with context.push({"case": 2}):
|
||||||
html2, error2 = yield template.render(context)
|
html2, error2 = yield lambda: template.render(context)
|
||||||
results.append((html2.strip(), error2))
|
results.append((html2.strip(), error2))
|
||||||
|
|
||||||
with context.push({"case": 3}):
|
with context.push({"case": 3}):
|
||||||
html3, error3 = yield template.render(context)
|
html3, error3 = yield lambda: template.render(context)
|
||||||
results.append((html3.strip(), error3))
|
results.append((html3.strip(), error3))
|
||||||
|
|
||||||
html4, error4 = yield "<div>Other result</div>"
|
html4, error4 = yield "<div>Other result</div>"
|
||||||
|
|
@ -1990,7 +2045,7 @@ class TestComponentHook:
|
||||||
if template is None:
|
if template is None:
|
||||||
yield None
|
yield None
|
||||||
else:
|
else:
|
||||||
_html, _error = yield template.render(context)
|
_html, _error = yield lambda: template.render(context)
|
||||||
return None # noqa: PLR1711
|
return None # noqa: PLR1711
|
||||||
|
|
||||||
elif action == "no_return":
|
elif action == "no_return":
|
||||||
|
|
@ -2000,7 +2055,7 @@ class TestComponentHook:
|
||||||
if template is None:
|
if template is None:
|
||||||
yield None
|
yield None
|
||||||
else:
|
else:
|
||||||
_html, _error = yield template.render(context)
|
_html, _error = yield lambda: template.render(context)
|
||||||
|
|
||||||
elif action == "raise_error":
|
elif action == "raise_error":
|
||||||
|
|
||||||
|
|
@ -2009,7 +2064,7 @@ class TestComponentHook:
|
||||||
if template is None:
|
if template is None:
|
||||||
yield None
|
yield None
|
||||||
else:
|
else:
|
||||||
_html, _error = yield template.render(context)
|
_html, _error = yield lambda: template.render(context)
|
||||||
raise ValueError("ERROR_FROM_ON_RENDER")
|
raise ValueError("ERROR_FROM_ON_RENDER")
|
||||||
|
|
||||||
elif action == "return_html":
|
elif action == "return_html":
|
||||||
|
|
@ -2019,7 +2074,7 @@ class TestComponentHook:
|
||||||
if template is None:
|
if template is None:
|
||||||
yield None
|
yield None
|
||||||
else:
|
else:
|
||||||
_html, _error = yield template.render(context)
|
_html, _error = yield lambda: template.render(context)
|
||||||
return "HTML_FROM_ON_RENDER"
|
return "HTML_FROM_ON_RENDER"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue