refactor: Instantiate component when rendering, and remove metadata stack (#1212)

* refactor: Instantiate component when rendering, and remove metadata stack

* refactor: update test

* refactor: fix linter errors

* docs: remove example from changelog
This commit is contained in:
Juro Oravec 2025-05-25 23:33:38 +02:00 committed by GitHub
parent 2e08af9a13
commit bae0f28813
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1123 additions and 707 deletions

View file

@ -430,6 +430,58 @@ Summary:
Calendar.render_to_response(deps_strategy="ignore") Calendar.render_to_response(deps_strategy="ignore")
``` ```
- Support for `Component` constructor kwargs `registered_name`, `outer_context`, and `registry` is deprecated, and will be removed in v1.
Before, you could instantiate a standalone component,
and then call `render()` on the instance:
```py
comp = MyComponent(
registered_name="my_component",
outer_context=my_context,
registry=my_registry,
)
comp.render(
args=[1, 2, 3],
kwargs={"a": 1, "b": 2},
slots={"my_slot": "CONTENT"},
)
```
Now you should instead pass all that data to `Component.render()` / `Component.render_to_response()`:
```py
MyComponent.render(
args=[1, 2, 3],
kwargs={"a": 1, "b": 2},
slots={"my_slot": "CONTENT"},
# NEW
registered_name="my_component",
outer_context=my_context,
registry=my_registry,
)
```
- If you are using the Components as views, the way to access the component class is now different.
Instead of `self.component`, use `self.component_cls`. `self.component` will be removed in v1.
Before:
```py
class MyView(View):
def get(self, request):
return self.component.render_to_response(request=request)
```
After:
```py
class MyView(View):
def get(self, request):
return self.component_cls.render_to_response(request=request)
```
**Extensions** **Extensions**
- In the `on_component_data()` extension hook, the `context_data` field of the context object was superseded by `template_data`. - In the `on_component_data()` extension hook, the `context_data` field of the context object was superseded by `template_data`.
@ -805,12 +857,24 @@ Summary:
See all [Extension hooks](https://django-components.github.io/django-components/0.140/reference/extension_hooks/). See all [Extension hooks](https://django-components.github.io/django-components/0.140/reference/extension_hooks/).
#### Refactor
- When a component is being rendered, a proper `Component` instance is now created.
Previously, the `Component` state was managed as half-instance, half-stack.
- Component's "Render API" (args, kwargs, slots, context, inputs, request, context data, etc)
can now be accessed also outside of the render call. So now its possible to take the component
instance out of `get_template_data()` (although this is not recommended).
#### Fix #### Fix
- Fix bug: Context processors data was being generated anew for each component. Now the data is correctly created once and reused across components with the same request ([#1165](https://github.com/django-components/django-components/issues/1165)). - Fix bug: Context processors data was being generated anew for each component. Now the data is correctly created once and reused across components with the same request ([#1165](https://github.com/django-components/django-components/issues/1165)).
- Fix KeyError on `component_context_cache` when slots are rendered outside of the component's render context. ([#1189](https://github.com/django-components/django-components/issues/1189)) - Fix KeyError on `component_context_cache` when slots are rendered outside of the component's render context. ([#1189](https://github.com/django-components/django-components/issues/1189))
- Component classes now have `do_not_call_in_templates=True` to prevent them from being called as functions in templates.
## v0.139.1 ## v0.139.1
#### Fix #### Fix

View file

@ -218,7 +218,7 @@ When you render a component, you can access everything about the component:
- Component input: [args, kwargs, slots and context](https://django-components.github.io/django-components/latest/concepts/fundamentals/render_api/#component-inputs) - Component input: [args, kwargs, slots and context](https://django-components.github.io/django-components/latest/concepts/fundamentals/render_api/#component-inputs)
- Component's template, CSS and JS - Component's template, CSS and JS
- Django's [context processors](https://django-components.github.io/django-components/latest/concepts/fundamentals/render_api/#request-object-and-context-processors) - Django's [context processors](https://django-components.github.io/django-components/latest/concepts/fundamentals/render_api/#request-and-context-processors)
- Unique [render ID](https://django-components.github.io/django-components/latest/concepts/fundamentals/render_api/#component-id) - Unique [render ID](https://django-components.github.io/django-components/latest/concepts/fundamentals/render_api/#component-id)
```python ```python

View file

@ -52,6 +52,12 @@ See the full list in [Extension Hooks Reference](../../../reference/extension_ho
Each extension has a corresponding nested class within the [`Component`](../../../reference/api#django_components.Component) class. These allow Each extension has a corresponding nested class within the [`Component`](../../../reference/api#django_components.Component) class. These allow
to configure the extensions on a per-component basis. to configure the extensions on a per-component basis.
E.g.:
- `"view"` extension -> [`Component.View`](../../../reference/api#django_components.Component.View)
- `"cache"` extension -> [`Component.Cache`](../../../reference/api#django_components.Component.Cache)
- `"defaults"` extension -> [`Component.Defaults`](../../../reference/api#django_components.Component.Defaults)
!!! note !!! note
**Accessing the component instance from inside the nested classes:** **Accessing the component instance from inside the nested classes:**
@ -61,10 +67,10 @@ to configure the extensions on a per-component basis.
```python ```python
class MyTable(Component): class MyTable(Component):
class View: class MyExtension:
def get(self, request): def get(self, request):
# `self.component` points to the instance of `MyTable` Component. # `self.component` points to the instance of `MyTable` Component.
return self.component.get(request) return self.component.render_to_response(request=request)
``` ```
### Example: Component as View ### Example: Component as View
@ -78,10 +84,14 @@ You can override the `get()`, `post()`, etc methods to customize the behavior of
class MyTable(Component): class MyTable(Component):
class View: class View:
def get(self, request): def get(self, request):
return self.component.get(request) # TO BE IMPLEMENTED BY USER
# return self.component_cls.render_to_response(request=request)
raise NotImplementedError("You must implement the `get` method.")
def post(self, request): def post(self, request):
return self.component.post(request) # TO BE IMPLEMENTED BY USER
# return self.component_cls.render_to_response(request=request)
raise NotImplementedError("You must implement the `post` method.")
... ...
``` ```
@ -97,12 +107,12 @@ JSON file from the component.
class MyTable(Component): class MyTable(Component):
class Storybook: class Storybook:
def title(self): def title(self):
return self.component.__class__.__name__ return self.component_cls.__name__
def parameters(self) -> Parameters: def parameters(self) -> Parameters:
return { return {
"server": { "server": {
"id": self.component.__class__.__name__, "id": self.component_cls.__name__,
} }
} }
@ -208,10 +218,14 @@ class ViewExtension(ComponentExtension):
# The default behavior of the `View` extension class. # The default behavior of the `View` extension class.
class ExtensionClass(ComponentExtension.ExtensionClass): class ExtensionClass(ComponentExtension.ExtensionClass):
def get(self, request): def get(self, request):
return self.component.get(request) # TO BE IMPLEMENTED BY USER
# return self.component_cls.render_to_response(request=request)
raise NotImplementedError("You must implement the `get` method.")
def post(self, request): def post(self, request):
return self.component.post(request) # TO BE IMPLEMENTED BY USER
# return self.component_cls.render_to_response(request=request)
raise NotImplementedError("You must implement the `post` method.")
... ...
``` ```

View file

@ -136,7 +136,7 @@ class MyPage(Component):
class View: class View:
def get(self, request): def get(self, request):
return self.component.render_to_response(request=request) return self.component_cls.render_to_response(request=request)
``` ```
### 2. Define fragment HTML ### 2. Define fragment HTML
@ -166,7 +166,7 @@ class Frag(Component):
class View: class View:
def get(self, request): def get(self, request):
return self.component.render_to_response( return self.component_cls.render_to_response(
request=request, request=request,
# IMPORTANT: Don't forget `deps_strategy="fragment"` # IMPORTANT: Don't forget `deps_strategy="fragment"`
deps_strategy="fragment", deps_strategy="fragment",
@ -228,7 +228,7 @@ class MyPage(Component):
class View: class View:
def get(self, request): def get(self, request):
return self.component.render_to_response(request=request) return self.component_cls.render_to_response(request=request)
``` ```
### 2. Define fragment HTML ### 2. Define fragment HTML
@ -271,7 +271,7 @@ class Frag(Component):
class View: class View:
def get(self, request): def get(self, request):
return self.component.render_to_response( return self.component_cls.render_to_response(
request=request, request=request,
# IMPORTANT: Don't forget `deps_strategy="fragment"` # IMPORTANT: Don't forget `deps_strategy="fragment"`
deps_strategy="fragment", deps_strategy="fragment",
@ -332,7 +332,7 @@ class MyPage(Component):
class View: class View:
def get(self, request): def get(self, request):
return self.component.render_to_response(request=request) return self.component_cls.render_to_response(request=request)
``` ```
### 2. Define fragment HTML ### 2. Define fragment HTML
@ -362,7 +362,7 @@ class Frag(Component):
class View: class View:
def get(self, request): def get(self, request):
return self.component.render_to_response( return self.component_cls.render_to_response(
request=request, request=request,
# IMPORTANT: Don't forget `deps_strategy="fragment"` # IMPORTANT: Don't forget `deps_strategy="fragment"`
deps_strategy="fragment", deps_strategy="fragment",

View file

@ -110,6 +110,6 @@ from django_components.testing import djc_test
) )
) )
def test_context_behavior(components_settings): def test_context_behavior(components_settings):
rendered = MyComponent().render() rendered = MyComponent.render()
... ...
``` ```

View file

@ -81,9 +81,9 @@ class Calendar(Component):
This is deprecated from v0.137 onwards, and will be removed in v1.0. This is deprecated from v0.137 onwards, and will be removed in v1.0.
### Acccessing component instance ### Acccessing component class
You can access the component instance from within the View methods by using the [`View.component`](../../../reference/api#django_components.ComponentView.component) attribute: You can access the component class from within the View methods by using the [`View.component_cls`](../../../reference/api#django_components.ComponentView.component_cls) attribute:
```py ```py
class Calendar(Component): class Calendar(Component):
@ -91,20 +91,9 @@ class Calendar(Component):
class View: class View:
def get(self, request): def get(self, request):
return self.component.render_to_response(request=request) return self.component_cls.render_to_response(request=request)
``` ```
!!! note
The [`View.component`](../../../reference/api#django_components.ComponentView.component) instance is a dummy instance created solely for the View methods.
It is the same as if you instantiated the component class directly:
```py
component = Calendar()
component.render_to_response(request=request)
```
## Register URLs manually ## Register URLs manually
To register the component as a route / endpoint in Django, add an entry to your To register the component as a route / endpoint in Django, add an entry to your
@ -134,7 +123,7 @@ class MyComponent(Component):
public = True public = True
def get(self, request): def get(self, request):
return self.component.render_to_response(request=request) return self.component_cls.render_to_response(request=request)
... ...
``` ```

View file

@ -260,7 +260,7 @@ This includes:
- [`input.type`](../../../reference/api/#django_components.ComponentInput.type) - The type of the component (document, fragment) - [`input.type`](../../../reference/api/#django_components.ComponentInput.type) - The type of the component (document, fragment)
- [`input.render_dependencies`](../../../reference/api/#django_components.ComponentInput.render_dependencies) - Whether to render dependencies (CSS, JS) - [`input.render_dependencies`](../../../reference/api/#django_components.ComponentInput.render_dependencies) - Whether to render dependencies (CSS, JS)
For more details, see [Component inputs](../render_api/#component-inputs). For more details, see [Component inputs](../render_api/#other-inputs).
```python ```python
class ProfileCard(Component): class ProfileCard(Component):
@ -343,16 +343,20 @@ class ProfileCard(Component):
## Accessing Render API ## Accessing Render API
All three data methods have access to the Component's [Render API](./render_api.md), which includes: All three data methods have access to the Component's [Render API](../render_api), which includes:
- [`self.args`](./render_api/#args) - The positional arguments for the current render call - [`self.args`](../render_api/#args) - The positional arguments for the current render call
- [`self.kwargs`](./render_api/#kwargs) - The keyword arguments for the current render call - [`self.kwargs`](../render_api/#kwargs) - The keyword arguments for the current render call
- [`self.slots`](./render_api/#slots) - The slots for the current render call - [`self.slots`](../render_api/#slots) - The slots for the current render call
- [`self.input`](./render_api/#component-inputs) - All the component inputs - [`self.context`](../render_api/#context) - The context for the current render call
- [`self.id`](./render_api/#component-id) - The unique ID for the current render call - [`self.input`](../render_api/#other-inputs) - All the component inputs
- [`self.request`](./render_api/#request-object-and-context-processors) - The request object (if available) - [`self.id`](../render_api/#component-id) - The unique ID for the current render call
- [`self.context_processors_data`](./render_api/#request-object-and-context-processors) - Data from Django's context processors (if request is available) - [`self.request`](../render_api/#request-and-context-processors) - The request object
- [`self.inject()`](./render_api/#provide-inject) - Inject data into the component - [`self.context_processors_data`](../render_api/#request-and-context-processors) - Data from Django's context processors
- [`self.inject()`](../render_api/#provide-inject) - Inject data into the component
- [`self.registry`](../render_api/#template-tag-metadata) - The [`ComponentRegistry`](../../../reference/api/#django_components.ComponentRegistry) instance
- [`self.registered_name`](../render_api/#template-tag-metadata) - The name under which the component was registered
- [`self.outer_context`](../render_api/#template-tag-metadata) - The context outside of the [`{% component %}`](../../../reference/template_tags#component) tag
## Type hints ## Type hints

View file

@ -12,10 +12,6 @@ Render API is available inside these [`Component`](../../../reference/api#django
- [`on_render_before()`](../../../reference/api#django_components.Component.on_render_before) - [`on_render_before()`](../../../reference/api#django_components.Component.on_render_before)
- [`on_render_after()`](../../../reference/api#django_components.Component.on_render_after) - [`on_render_after()`](../../../reference/api#django_components.Component.on_render_after)
!!! note
If you try to access the Render API outside of these methods, you will get a `RuntimeError`.
Example: Example:
```python ```python
@ -46,16 +42,31 @@ rendered = Table.render(
The Render API includes: The Render API includes:
- [`self.args`](../render_api/#args) - The positional arguments for the current render call - Component inputs:
- [`self.kwargs`](../render_api/#kwargs) - The keyword arguments for the current render call - [`self.args`](../render_api/#args) - The positional arguments for the current render call
- [`self.slots`](../render_api/#slots) - The slots for the current render call - [`self.kwargs`](../render_api/#kwargs) - The keyword arguments for the current render call
- [`self.input`](../render_api/#component-inputs) - All the component inputs - [`self.slots`](../render_api/#slots) - The slots for the current render call
- [`self.id`](../render_api/#component-id) - The unique ID for the current render call - [`self.context`](../render_api/#context) - The context for the current render call
- [`self.request`](../render_api/#request-object-and-context-processors) - The request object (if available) - [`self.input`](../render_api/#other-inputs) - All the component inputs
- [`self.context_processors_data`](../render_api/#request-object-and-context-processors) - Data from Django's context processors (if request is available)
- [`self.inject()`](../render_api/#provide-inject) - Inject data into the component
## Args - Request-related:
- [`self.request`](../render_api/#request-and-context-processors) - The request object (if available)
- [`self.context_processors_data`](../render_api/#request-and-context-processors) - Data from Django's context processors
- Provide / inject:
- [`self.inject()`](../render_api/#provide-inject) - Inject data into the component
- Template tag metadata:
- [`self.registry`](../render_api/#template-tag-metadata) - The [`ComponentRegistry`](../../../reference/api/#django_components.ComponentRegistry) instance
- [`self.registered_name`](../render_api/#template-tag-metadata) - The name under which the component was registered
- [`self.outer_context`](../render_api/#template-tag-metadata) - The context outside of the [`{% component %}`](../../../reference/template_tags#component) tag
- Other metadata:
- [`self.id`](../render_api/#component-id) - The unique ID for the current render call
## Component inputs
### Args
The `args` argument as passed to The `args` argument as passed to
[`Component.get_template_data()`](../../../reference/api/#django_components.Component.get_template_data). [`Component.get_template_data()`](../../../reference/api/#django_components.Component.get_template_data).
@ -65,8 +76,6 @@ then the [`Component.args`](../../../reference/api/#django_components.Component.
Otherwise, `args` will be a plain list. Otherwise, `args` will be a plain list.
Raises `RuntimeError` if accessed outside of rendering execution.
**Example:** **Example:**
With `Args` class: With `Args` class:
@ -99,7 +108,7 @@ class Table(Component):
assert self.args[1] == 10 assert self.args[1] == 10
``` ```
## Kwargs ### Kwargs
The `kwargs` argument as passed to The `kwargs` argument as passed to
[`Component.get_template_data()`](../../../reference/api/#django_components.Component.get_template_data). [`Component.get_template_data()`](../../../reference/api/#django_components.Component.get_template_data).
@ -109,8 +118,6 @@ then the [`Component.kwargs`](../../../reference/api/#django_components.Componen
Otherwise, `kwargs` will be a plain dictionary. Otherwise, `kwargs` will be a plain dictionary.
Raises `RuntimeError` if accessed outside of rendering execution.
**Example:** **Example:**
With `Kwargs` class: With `Kwargs` class:
@ -143,7 +150,7 @@ class Table(Component):
assert self.kwargs["per_page"] == 10 assert self.kwargs["per_page"] == 10
``` ```
## Slots ### Slots
The `slots` argument as passed to The `slots` argument as passed to
[`Component.get_template_data()`](../../../reference/api/#django_components.Component.get_template_data). [`Component.get_template_data()`](../../../reference/api/#django_components.Component.get_template_data).
@ -153,8 +160,6 @@ then the [`Component.slots`](../../../reference/api/#django_components.Component
Otherwise, `slots` will be a plain dictionary. Otherwise, `slots` will be a plain dictionary.
Raises `RuntimeError` if accessed outside of rendering execution.
**Example:** **Example:**
With `Slots` class: With `Slots` class:
@ -190,7 +195,27 @@ class Table(Component):
assert isinstance(self.slots["footer"], Slot) assert isinstance(self.slots["footer"], Slot)
``` ```
## Component inputs ### Context
The `context` argument as passed to
[`Component.get_template_data()`](../../../reference/api/#django_components.Component.get_template_data).
This is Django's [Context](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context)
with which the component template is rendered.
If the root component or template was rendered with
[`RequestContext`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.RequestContext)
then this will be an instance of `RequestContext`.
Whether the context variables defined in `context` are available to the template depends on the
[context behavior mode](../../../reference/settings#django_components.app_settings.ComponentsSettings.context_behavior):
- In `"django"` context behavior mode, the template will have access to the keys of this context.
- In `"isolated"` context behavior mode, the template will NOT have access to this context,
and data MUST be passed via component's args and kwargs.
### Other inputs
You can access the most important inputs via [`self.args`](../render_api/#args), You can access the most important inputs via [`self.args`](../render_api/#args),
[`self.kwargs`](../render_api/#kwargs), [`self.kwargs`](../render_api/#kwargs),
@ -255,7 +280,7 @@ class Table(Component):
assert self.id == "c1A2b3c" assert self.id == "c1A2b3c"
``` ```
## Request object and context processors ## Request and context processors
Components have access to the request object and context processors data if the component was: Components have access to the request object and context processors data if the component was:
@ -306,3 +331,32 @@ class Table(Component):
data = self.inject("some_data") data = self.inject("some_data")
assert data.some_data == "some_data" assert data.some_data == "some_data"
``` ```
## Template tag metadata
If the component is rendered with [`{% component %}`](../../../reference/template_tags#component) template tag,
the following metadata is available:
- [`self.registry`](../../../reference/api/#django_components.Component.registry) - The [`ComponentRegistry`](../../../reference/api/#django_components.ComponentRegistry) instance
that was used to render the component
- [`self.registered_name`](../../../reference/api/#django_components.Component.registered_name) - The name under which the component was registered
- [`self.outer_context`](../../../reference/api/#django_components.Component.outer_context) - The context outside of the [`{% component %}`](../../../reference/template_tags#component) tag
```django
{% with abc=123 %}
{{ abc }} {# <--- This is in outer context #}
{% component "my_component" / %}
{% endwith %}
```
You can use these to check whether the component was rendered inside a template with [`{% component %}`](../../../reference/template_tags#component) tag
or in Python with [`Component.render()`](../../../reference/api/#django_components.Component.render).
```python
class MyComponent(Component):
def get_template_data(self, args, kwargs, slots, context):
if self.registered_name is None:
# Do something for the render() function
else:
# Do something for the {% component %} template tag
```

View file

@ -246,7 +246,6 @@ Button.render(
- `context` - Django context for rendering (can be a dictionary or a `Context` object) - `context` - Django context for rendering (can be a dictionary or a `Context` object)
- `deps_strategy` - [Dependencies rendering strategy](#dependencies-rendering) (default: `"document"`) - `deps_strategy` - [Dependencies rendering strategy](#dependencies-rendering) (default: `"document"`)
- `request` - [HTTP request object](../http_request), used for context processors (optional) - `request` - [HTTP request object](../http_request), used for context processors (optional)
- `escape_slots_content` - Whether to HTML-escape slot content (default: `True`)
All arguments are optional. If not provided, they default to empty values or sensible defaults. All arguments are optional. If not provided, they default to empty values or sensible defaults.

View file

@ -770,10 +770,10 @@ print(slot.nodelist) # <django.template.Nodelist: ['Hello!']>
Slots content are automatically escaped by default to prevent XSS attacks. Slots content are automatically escaped by default to prevent XSS attacks.
In other words, it's as if you would be using Django's [`mark_safe()`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.safestring.mark_safe) function on the slot content: In other words, it's as if you would be using Django's [`escape()`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#std-templatefilter-escape) on the slot contents / result:
```python ```python
from django.utils.safestring import mark_safe from django.utils.html import escape
class Calendar(Component): class Calendar(Component):
template = """ template = """
@ -784,24 +784,28 @@ class Calendar(Component):
Calendar.render( Calendar.render(
slots={ slots={
"date": mark_safe("<b>Hello</b>"), "date": escape("<b>Hello</b>"),
} }
) )
``` ```
To disable escaping, you can pass `escape_slots_content=False` to To disable escaping, you can wrap the slot string or slot result in Django's [`mark_safe()`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.safestring.mark_safe):
[`Component.render()`](../../../reference/api#django_components.Component.render)
or [`Component.render_to_response()`](../../../reference/api#django_components.Component.render_to_response)
methods.
!!! warning ```py
Calendar.render(
slots={
# string
"date": mark_safe("<b>Hello</b>"),
If you disable escaping, you should make sure that any content you pass to the slots is safe, # function
especially if it comes from user input! "date": lambda ctx: mark_safe("<b>Hello</b>"),
}
)
```
!!! info !!! info
If you're planning on passing an HTML string, check Django's use of Read more about Django's
[`format_html`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.html.format_html) [`format_html`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.html.format_html)
and [`mark_safe`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.safestring.mark_safe). and [`mark_safe`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.safestring.mark_safe).

View file

@ -164,10 +164,10 @@ def my_view(request):
Slots content are automatically escaped by default to prevent XSS attacks. Slots content are automatically escaped by default to prevent XSS attacks.
In other words, it's as if you would be using Django's [`mark_safe()`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.safestring.mark_safe) function on the slot content: In other words, it's as if you would be using Django's [`escape()`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#std-templatefilter-escape) on the slot contents / result:
```python ```python
from django.utils.safestring import mark_safe from django.utils.html import escape
class Calendar(Component): class Calendar(Component):
template = """ template = """
@ -178,24 +178,28 @@ class Calendar(Component):
Calendar.render( Calendar.render(
slots={ slots={
"date": mark_safe("<b>Hello</b>"), "date": escape("<b>Hello</b>"),
} }
) )
``` ```
To disable escaping, you can pass `escape_slots_content=False` to To disable escaping, you can wrap the slot string or slot result in Django's [`mark_safe()`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.safestring.mark_safe):
[`Component.render()`](../../reference/api#django_components.Component.render)
or [`Component.render_to_response()`](../../reference/api#django_components.Component.render_to_response)
methods.
!!! warning ```py
Calendar.render(
slots={
# string
"date": mark_safe("<b>Hello</b>"),
If you disable escaping, you should make sure that any content you pass to the slots is safe, # function
especially if it comes from user input! "date": lambda ctx: mark_safe("<b>Hello</b>"),
}
)
```
!!! info !!! info
If you're planning on passing an HTML string, check Django's use of Read more about Django's
[`format_html`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.html.format_html) [`format_html`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.html.format_html)
and [`mark_safe`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.safestring.mark_safe). and [`mark_safe`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.safestring.mark_safe).

View file

@ -4,6 +4,7 @@ nav:
- Compatibility: compatibility.md - Compatibility: compatibility.md
- Installation: installation.md - Installation: installation.md
- Security notes 🚨: security_notes.md - Security notes 🚨: security_notes.md
- Migrating: migrating.md
- Community: community.md - Community: community.md
- Contributing: contributing.md - Contributing: contributing.md
- Development: development.md - Development: development.md

View file

@ -0,0 +1,25 @@
Django-components is still in active development.
Since django-components is in pre-1.0 development, the public API is not yet frozen.
This means that there may be breaking changes between minor versions.
We try to minimize the number of breaking changes, but sometimes it's unavoidable.
When upgrading, please read the [Release notes](../../release_notes).
## Migrating in pre-v1.0
If you're on older pre-v1.0 versions of django-components, we recommend doing step-wise
upgrades in the following order:
- [v0.26](../../release_notes/#v026)
- [v0.50](../../release_notes/#v050)
- [v0.70](../../release_notes/#v070)
- [v0.77](../../release_notes/#v077)
- [v0.81](../../release_notes/#v081)
- [v0.85](../../release_notes/#v085)
- [v0.92](../../release_notes/#v092)
- [v0.100](../../release_notes/#v0100)
- [v0.110](../../release_notes/#v0110)
- [v0.140](../../release_notes/#v01400)
These versions introduced breaking changes that are not backwards compatible.

View file

@ -208,7 +208,7 @@ When you render a component, you can access everything about the component:
- Component input: [args, kwargs, slots and context](https://django-components.github.io/django-components/latest/concepts/fundamentals/render_api/#component-inputs) - Component input: [args, kwargs, slots and context](https://django-components.github.io/django-components/latest/concepts/fundamentals/render_api/#component-inputs)
- Component's template, CSS and JS - Component's template, CSS and JS
- Django's [context processors](https://django-components.github.io/django-components/latest/concepts/fundamentals/render_api/#request-object-and-context-processors) - Django's [context processors](https://django-components.github.io/django-components/latest/concepts/fundamentals/render_api/#request-and-context-processors)
- Unique [render ID](https://django-components.github.io/django-components/latest/concepts/fundamentals/render_api/#component-id) - Unique [render ID](https://django-components.github.io/django-components/latest/concepts/fundamentals/render_api/#component-id)
```python ```python

File diff suppressed because it is too large Load diff

View file

@ -118,17 +118,15 @@ class DynamicComponent(Component):
comp_class = self._resolve_component(comp_name_or_class, registry) comp_class = self._resolve_component(comp_name_or_class, registry)
comp = comp_class( output = comp_class.render(
registered_name=self.registered_name,
outer_context=self.outer_context,
registry=self.registry,
)
output = comp.render(
context=self.input.context, context=self.input.context,
args=self.input.args, args=self.input.args,
kwargs=cleared_kwargs, kwargs=cleared_kwargs,
slots=self.input.slots, slots=self.input.slots,
deps_strategy=self.input.deps_strategy, deps_strategy=self.input.deps_strategy,
registered_name=self.registered_name,
outer_context=self.outer_context,
registry=self.registry,
) )
# Set the output to the context so it can be accessed from within the template. # Set the output to the context so it can be accessed from within the template.

View file

@ -582,9 +582,7 @@ def _process_dep_declarations(content: bytes, strategy: DependenciesStrategy) ->
from django_components.component import get_component_by_class_id from django_components.component import get_component_by_class_id
comp_cls = get_component_by_class_id(comp_cls_id) comp_cls = get_component_by_class_id(comp_cls_id)
# NOTE: We instantiate the component classes so the `Media` are processed into `media` return comp_cls.media
comp = comp_cls()
return comp.media
all_medias = [ all_medias = [
# JS / CSS files from Component.Media.js/css. # JS / CSS files from Component.Media.js/css.

View file

@ -129,10 +129,10 @@ class OnComponentRenderedContext(NamedTuple):
"""The rendered component""" """The rendered component"""
# TODO - Add `component` once we create instances inside `render()`
# See https://github.com/django-components/django-components/issues/1186
@mark_extension_hook_api @mark_extension_hook_api
class OnSlotRenderedContext(NamedTuple): class OnSlotRenderedContext(NamedTuple):
component: "Component"
"""The Component instance that contains the `{% slot %}` tag"""
component_cls: Type["Component"] component_cls: Type["Component"]
"""The Component class that contains the `{% slot %}` tag""" """The Component class that contains the `{% slot %}` tag"""
component_id: str component_id: str

View file

@ -3,7 +3,7 @@ from dataclasses import MISSING, Field, dataclass
from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Type from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Type
from weakref import WeakKeyDictionary from weakref import WeakKeyDictionary
from django_components.extension import ComponentExtension, OnComponentClassCreatedContext, OnComponentInputContext from django_components.extension import ComponentExtension, OnComponentClassCreatedContext
if TYPE_CHECKING: if TYPE_CHECKING:
from django_components.component import Component from django_components.component import Component
@ -99,7 +99,7 @@ def _extract_defaults(defaults: Optional[Type]) -> List[ComponentDefaultField]:
return defaults_fields return defaults_fields
def _apply_defaults(kwargs: Dict, defaults: List[ComponentDefaultField]) -> None: def apply_defaults(kwargs: Dict, defaults: List[ComponentDefaultField]) -> None:
""" """
Apply the defaults from `Component.Defaults` to the given `kwargs`. Apply the defaults from `Component.Defaults` to the given `kwargs`.
@ -171,11 +171,3 @@ class DefaultsExtension(ComponentExtension):
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None: def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
defaults_cls = getattr(ctx.component_cls, "Defaults", None) defaults_cls = getattr(ctx.component_cls, "Defaults", None)
defaults_by_component[ctx.component_cls] = _extract_defaults(defaults_cls) defaults_by_component[ctx.component_cls] = _extract_defaults(defaults_cls)
# Apply defaults to missing or `None` values in `kwargs`
def on_component_input(self, ctx: OnComponentInputContext) -> None:
defaults = defaults_by_component.get(ctx.component_cls, None)
if defaults is None:
return
_apply_defaults(ctx.kwargs, defaults)

View file

@ -1,5 +1,5 @@
import sys import sys
from typing import TYPE_CHECKING, Any, Dict, Optional, Protocol, Type, Union, cast from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional, Protocol, Type, Union, cast
from weakref import WeakKeyDictionary from weakref import WeakKeyDictionary
import django.urls import django.urls
@ -82,8 +82,8 @@ class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore
This class is a subclass of This class is a subclass of
[`django.views.View`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#view). [`django.views.View`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#view).
The [`Component`](../api#django_components.Component) instance is available The [`Component`](../api#django_components.Component) class is available
via `self.component`. via `self.component_cls`.
Override the methods of this class to define the behavior of the component. Override the methods of this class to define the behavior of the component.
@ -116,18 +116,21 @@ class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore
Will create a URL route like `/components/ext/view/components/a1b2c3/`. Will create a URL route like `/components/ext/view/components/a1b2c3/`.
To get the URL for the component, use `get_component_url`: To get the URL for the component, use [`get_component_url()`](../api#django_components.get_component_url):
```py ```py
url = get_component_url(MyComponent) url = get_component_url(MyComponent)
``` ```
""" """
# NOTE: This class attribute must be declared on the class for `View.as_view()` to allow # NOTE: The `component` / `component_cls` attributes are NOT user input, but still must be declared
# us to pass `component` kwarg. # on this class for Django's `View.as_view()` to allow us to pass `component` kwarg.
# TODO_v1 - Remove. Superseded by `component_cls` attribute because we don't actually have access to an instance.
component = cast("Component", None) component = cast("Component", None)
""" """
The component instance. DEPRECATED: Will be removed in v1.0.
Use [`component_cls`](../api#django_components.ComponentView.component_cls) instead.
This is a dummy instance created solely for the View methods. This is a dummy instance created solely for the View methods.
@ -139,7 +142,48 @@ class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore
``` ```
""" """
public = False component_cls = cast(Type["Component"], None)
"""
The parent component class.
**Example:**
```py
class MyComponent(Component):
class View:
def get(self, request):
return self.component_cls.render_to_response(request=request)
```
"""
def __init__(self, component: "Component", **kwargs: Any) -> None:
ComponentExtension.ExtensionClass.__init__(self, component)
View.__init__(self, **kwargs)
@property
def url(self) -> str:
"""
The URL for the component.
Raises `RuntimeError` if the component is not public.
This is the same as calling [`get_component_url()`](../api#django_components.get_component_url)
with the parent [`Component`](../api#django_components.Component) class:
```py
class MyComponent(Component):
class View:
def get(self, request):
assert self.url == get_component_url(self.component_cls)
```
"""
return get_component_url(self.component_cls)
# #####################################
# PUBLIC API (Configurable by users)
# #####################################
public: ClassVar[bool] = False
""" """
Whether the component should be available via a URL. Whether the component should be available via a URL.
@ -155,26 +199,13 @@ class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore
Will create a URL route like `/components/ext/view/components/a1b2c3/`. Will create a URL route like `/components/ext/view/components/a1b2c3/`.
To get the URL for the component, use `get_component_url`: To get the URL for the component, use [`get_component_url()`](../api#django_components.get_component_url):
```py ```py
url = get_component_url(MyComponent) url = get_component_url(MyComponent)
``` ```
""" """
def __init__(self, component: "Component", **kwargs: Any) -> None:
ComponentExtension.ExtensionClass.__init__(self, component)
View.__init__(self, **kwargs)
@property
def url(self) -> str:
"""
The URL for the component.
Raises `RuntimeError` if the component is not public.
"""
return get_component_url(self.component.__class__)
# NOTE: The methods below are defined to satisfy the `View` class. All supported methods # NOTE: The methods below are defined to satisfy the `View` class. All supported methods
# are defined in `View.http_method_names`. # are defined in `View.http_method_names`.
# #
@ -185,30 +216,31 @@ class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore
# not the Component class directly. This is to align Views with the extensions API # not the Component class directly. This is to align Views with the extensions API
# where each extension should keep its methods in the extension class. # where each extension should keep its methods in the extension class.
# Instead, the defaults for these methods should be something like # Instead, the defaults for these methods should be something like
# `return self.component.render_to_response()` or similar. # `return self.component_cls.render_to_response(request, *args, **kwargs)` or similar
# or raise NotImplementedError.
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component, "get")(request, *args, **kwargs) return getattr(self.component_cls(), "get")(request, *args, **kwargs)
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component, "post")(request, *args, **kwargs) return getattr(self.component_cls(), "post")(request, *args, **kwargs)
def put(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def put(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component, "put")(request, *args, **kwargs) return getattr(self.component_cls(), "put")(request, *args, **kwargs)
def patch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def patch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component, "patch")(request, *args, **kwargs) return getattr(self.component_cls(), "patch")(request, *args, **kwargs)
def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component, "delete")(request, *args, **kwargs) return getattr(self.component_cls(), "delete")(request, *args, **kwargs)
def head(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def head(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component, "head")(request, *args, **kwargs) return getattr(self.component_cls(), "head")(request, *args, **kwargs)
def options(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def options(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component, "options")(request, *args, **kwargs) return getattr(self.component_cls(), "options")(request, *args, **kwargs)
def trace(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def trace(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component, "trace")(request, *args, **kwargs) return getattr(self.component_cls(), "trace")(request, *args, **kwargs)
class ViewExtension(ComponentExtension): class ViewExtension(ComponentExtension):

View file

@ -155,8 +155,8 @@ def set_provided_context_var(
# We turn the kwargs into a NamedTuple so that the object that's "provided" # We turn the kwargs into a NamedTuple so that the object that's "provided"
# is immutable. This ensures that the data returned from `inject` will always # is immutable. This ensures that the data returned from `inject` will always
# have all the keys that were passed to the `provide` tag. # have all the keys that were passed to the `provide` tag.
tpl_cls = namedtuple("DepInject", provided_kwargs.keys()) # type: ignore[misc] tuple_cls = namedtuple("DepInject", provided_kwargs.keys()) # type: ignore[misc]
payload = tpl_cls(**provided_kwargs) payload = tuple_cls(**provided_kwargs)
# Instead of storing the provided data on the Context object, we store it # Instead of storing the provided data on the Context object, we store it
# in a separate dictionary, and we set only the key to the data on the Context. # in a separate dictionary, and we set only the key to the data on the Context.

View file

@ -36,7 +36,7 @@ from django_components.util.logger import trace_component_msg
from django_components.util.misc import get_index, get_last_index, is_identifier from django_components.util.misc import get_index, get_last_index, is_identifier
if TYPE_CHECKING: if TYPE_CHECKING:
from django_components.component import ComponentContext, ComponentNode from django_components.component import Component, ComponentNode
TSlotData = TypeVar("TSlotData", bound=Mapping) TSlotData = TypeVar("TSlotData", bound=Mapping)
@ -597,11 +597,19 @@ class SlotNode(BaseNode):
f"SlotNode: {self.__repr__()}" f"SlotNode: {self.__repr__()}"
) )
# Component info
component_id: str = context[_COMPONENT_CONTEXT_KEY] component_id: str = context[_COMPONENT_CONTEXT_KEY]
component_ctx = component_context_cache[component_id] component_ctx = component_context_cache[component_id]
component_name = component_ctx.component_name component = component_ctx.component
component_name = component.name
component_path = component_ctx.component_path component_path = component_ctx.component_path
slot_fills = component_ctx.fills is_dynamic_component = getattr(component, "_is_dynamic_component", False)
# NOTE: Use `ComponentContext.outer_context`, and NOT `Component.outer_context`.
# The first is a SNAPSHOT of the outer context.
outer_context = component_ctx.outer_context
# Slot info
slot_fills = component.input.slots
slot_name = name slot_name = name
is_default = self.flags[SLOT_DEFAULT_FLAG] is_default = self.flags[SLOT_DEFAULT_FLAG]
is_required = self.flags[SLOT_REQUIRED_FLAG] is_required = self.flags[SLOT_REQUIRED_FLAG]
@ -617,7 +625,7 @@ class SlotNode(BaseNode):
) )
# Check for errors # Check for errors
if is_default and not component_ctx.is_dynamic_component: if is_default and not is_dynamic_component:
# Allow one slot to be marked as 'default', or multiple slots but with # Allow one slot to be marked as 'default', or multiple slots but with
# the same name. If there is multiple 'default' slots with different names, raise. # the same name. If there is multiple 'default' slots with different names, raise.
default_slot_name = component_ctx.default_slot default_slot_name = component_ctx.default_slot
@ -677,9 +685,9 @@ class SlotNode(BaseNode):
# In this case, we need to find the context that was used to render the component, # In this case, we need to find the context that was used to render the component,
# and use the fills from that context. # and use the fills from that context.
if ( if (
component_ctx.registry.settings.context_behavior == ContextBehavior.DJANGO component.registry.settings.context_behavior == ContextBehavior.DJANGO
and component_ctx.outer_context is None and outer_context is None
and (slot_name not in component_ctx.fills) and (slot_name not in slot_fills)
): ):
# When we have nested components with fills, the context layers are added in # When we have nested components with fills, the context layers are added in
# the following order: # the following order:
@ -726,10 +734,10 @@ class SlotNode(BaseNode):
trace_component_msg( trace_component_msg(
"SLOT_PARENT_INDEX", "SLOT_PARENT_INDEX",
component_name=component_ctx.component_name, component_name=component_name,
component_id=component_ctx.component_id, component_id=component_id,
slot_name=name, slot_name=name,
component_path=component_ctx.component_path, component_path=component_path,
extra=( extra=(
f"Parent index: {parent_index}, Current index: {curr_index}, " f"Parent index: {parent_index}, Current index: {curr_index}, "
f"Context stack: {[d.get(_COMPONENT_CONTEXT_KEY) for d in context.dicts]}" f"Context stack: {[d.get(_COMPONENT_CONTEXT_KEY) for d in context.dicts]}"
@ -775,7 +783,7 @@ class SlotNode(BaseNode):
# Note: Finding a good `cutoff` value may require further trial-and-error. # Note: Finding a good `cutoff` value may require further trial-and-error.
# Higher values make matching stricter. This is probably preferable, as it # Higher values make matching stricter. This is probably preferable, as it
# reduces false positives. # reduces false positives.
if is_required and not slot_is_filled and not component_ctx.is_dynamic_component: if is_required and not slot_is_filled and not is_dynamic_component:
msg = ( msg = (
f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), " f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), "
f"yet no fill is provided. Check template.'" f"yet no fill is provided. Check template.'"
@ -809,13 +817,13 @@ class SlotNode(BaseNode):
# #
# Hence, even in the "django" mode, we MUST use slots of the context of the parent component. # Hence, even in the "django" mode, we MUST use slots of the context of the parent component.
if ( if (
component_ctx.registry.settings.context_behavior == ContextBehavior.DJANGO component.registry.settings.context_behavior == ContextBehavior.DJANGO
and component_ctx.outer_context is not None and outer_context is not None
and _COMPONENT_CONTEXT_KEY in component_ctx.outer_context and _COMPONENT_CONTEXT_KEY in outer_context
): ):
extra_context[_COMPONENT_CONTEXT_KEY] = component_ctx.outer_context[_COMPONENT_CONTEXT_KEY] extra_context[_COMPONENT_CONTEXT_KEY] = outer_context[_COMPONENT_CONTEXT_KEY]
# This ensures that the ComponentVars API (e.g. `{{ component_vars.is_filled }}`) is accessible in the fill # This ensures that the ComponentVars API (e.g. `{{ component_vars.is_filled }}`) is accessible in the fill
extra_context["component_vars"] = component_ctx.outer_context["component_vars"] extra_context["component_vars"] = outer_context["component_vars"]
# Irrespective of which context we use ("root" context or the one passed to this # Irrespective of which context we use ("root" context or the one passed to this
# render function), pass down the keys used by inject/provide feature. This makes it # render function), pass down the keys used by inject/provide feature. This makes it
@ -831,7 +839,7 @@ class SlotNode(BaseNode):
# For the user-provided slot fill, we want to use the context of where the slot # For the user-provided slot fill, we want to use the context of where the slot
# came from (or current context if configured so) # came from (or current context if configured so)
used_ctx = self._resolve_slot_context(context, slot_is_filled, component_ctx) used_ctx = self._resolve_slot_context(context, slot_is_filled, component, outer_context)
with used_ctx.update(extra_context): with used_ctx.update(extra_context):
# Required for compatibility with Django's {% extends %} tag # Required for compatibility with Django's {% extends %} tag
# This makes sure that the render context used outside of a component # This makes sure that the render context used outside of a component
@ -853,8 +861,9 @@ class SlotNode(BaseNode):
# Allow plugins to post-process the slot's rendered output # Allow plugins to post-process the slot's rendered output
output = extensions.on_slot_rendered( output = extensions.on_slot_rendered(
OnSlotRenderedContext( OnSlotRenderedContext(
component_cls=component_ctx.component_class, component=component,
component_id=component_ctx.component_id, component_cls=component.__class__,
component_id=component_id,
slot=slot, slot=slot,
slot_name=slot_name, slot_name=slot_name,
slot_is_required=is_required, slot_is_required=is_required,
@ -878,7 +887,8 @@ class SlotNode(BaseNode):
self, self,
context: Context, context: Context,
slot_is_filled: bool, slot_is_filled: bool,
component_ctx: "ComponentContext", component: "Component",
outer_context: Optional[Context],
) -> Context: ) -> Context:
"""Prepare the context used in a slot fill based on the settings.""" """Prepare the context used in a slot fill based on the settings."""
# If slot is NOT filled, we use the slot's fallback AKA content between # If slot is NOT filled, we use the slot's fallback AKA content between
@ -887,11 +897,10 @@ class SlotNode(BaseNode):
if not slot_is_filled: if not slot_is_filled:
return context return context
registry_settings = component_ctx.registry.settings registry_settings = component.registry.settings
if registry_settings.context_behavior == ContextBehavior.DJANGO: if registry_settings.context_behavior == ContextBehavior.DJANGO:
return context return context
elif registry_settings.context_behavior == ContextBehavior.ISOLATED: elif registry_settings.context_behavior == ContextBehavior.ISOLATED:
outer_context = component_ctx.outer_context
return outer_context if outer_context is not None else Context() return outer_context if outer_context is not None else Context()
else: else:
raise ValueError(f"Unknown value for context_behavior: '{registry_settings.context_behavior}'") raise ValueError(f"Unknown value for context_behavior: '{registry_settings.context_behavior}'")

View file

@ -5,7 +5,7 @@ from hashlib import md5
from importlib import import_module from importlib import import_module
from itertools import chain from itertools import chain
from types import ModuleType from types import ModuleType
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple, Type, TypeVar, Union from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple, Type, TypeVar, Union, cast
from urllib import parse from urllib import parse
from django_components.constants import UID_LENGTH from django_components.constants import UID_LENGTH
@ -15,6 +15,7 @@ if TYPE_CHECKING:
from django_components.component import Component from django_components.component import Component
T = TypeVar("T") T = TypeVar("T")
U = TypeVar("U")
# Based on nanoid implementation from # Based on nanoid implementation from
@ -91,8 +92,13 @@ def get_module_info(
return module, module_name, module_file_path return module, module_name, module_file_path
def default(val: Optional[T], default: T) -> T: def default(val: Optional[T], default: Union[U, Callable[[], U], Type[T]], factory: bool = False) -> Union[T, U]:
return val if val is not None else default if val is not None:
return val
if factory:
default_func = cast(Callable[[], U], default)
return default_func()
return cast(U, default)
def get_index(lst: List, key: Callable[[Any], bool]) -> Optional[int]: def get_index(lst: List, key: Callable[[Any], bool]) -> Optional[int]:

View file

@ -238,7 +238,7 @@ def djc_test(
) )
) )
def test_context_behavior(components_settings): def test_context_behavior(components_settings):
rendered = MyComponent().render() rendered = MyComponent.render()
... ...
``` ```

View file

@ -1,12 +1,10 @@
from typing import Optional
from django_components import Component, register from django_components import Component, register
# Used for testing the template_loader # Used for testing the template_loader
@register("app_lvl_comp") @register("app_lvl_comp")
class AppLvlCompComponent(Component): class AppLvlCompComponent(Component):
template_file: Optional[str] = "app_lvl_comp.html" template_file = "app_lvl_comp.html"
js_file = "app_lvl_comp.js" js_file = "app_lvl_comp.js"
css_file = "app_lvl_comp.css" css_file = "app_lvl_comp.css"

View file

@ -4,7 +4,7 @@ For tests focusing on the `component` tag, see `test_templatetags_component.py`
""" """
import re import re
from typing import NamedTuple from typing import Any, NamedTuple
import pytest import pytest
from django.conf import settings from django.conf import settings
@ -103,6 +103,55 @@ class TestComponentLegacyApi:
""", """,
) )
# TODO_REMOVE_IN_V1 - Registry and registered name should be passed to `Component.render()`,
# not to the constructor.
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_component_instantiation(self, components_settings):
class SimpleComponent(Component):
template = """
<div>
Name: {{ name }}
</div>
"""
def get_template_data(self, args, kwargs, slots, context):
return {
"name": self.name,
}
# Old syntax
rendered = SimpleComponent("simple").render()
assertHTMLEqual(
rendered,
"""
<div data-djc-id-ca1bc3f>
Name: simple
</div>
""",
)
# New syntax
rendered = SimpleComponent.render(registered_name="simple")
assertHTMLEqual(
rendered,
"""
<div data-djc-id-ca1bc40>
Name: simple
</div>
""",
)
# Sanity check
rendered = SimpleComponent.render()
assertHTMLEqual(
rendered,
"""
<div data-djc-id-ca1bc41>
Name: SimpleComponent
</div>
""",
)
@djc_test @djc_test
class TestComponent: class TestComponent:
@ -112,7 +161,7 @@ class TestComponent:
pass pass
with pytest.raises(ImproperlyConfigured): with pytest.raises(ImproperlyConfigured):
EmptyComponent("empty_component")._get_template(Context({}), "123") EmptyComponent.render(args=["123"])
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_template_string_static_inlined(self, components_settings): def test_template_string_static_inlined(self, components_settings):
@ -200,6 +249,7 @@ class TestComponent:
css = "style.css" css = "style.css"
js = "script.js" js = "script.js"
# Access fields on Component class
assert SimpleComponent.template_name == "simple_template.html" assert SimpleComponent.template_name == "simple_template.html"
assert SimpleComponent.template_file == "simple_template.html" assert SimpleComponent.template_file == "simple_template.html"
@ -216,13 +266,14 @@ class TestComponent:
""", """,
) )
# Access fields on Component instance
comp = SimpleComponent() comp = SimpleComponent()
assert comp.template_name == "simple_template.html" assert comp.template_name == "simple_template.html"
assert comp.template_file == "simple_template.html" assert comp.template_file == "simple_template.html"
# NOTE: Setting `template_file` on INSTANCE is not supported, as users should work # NOTE: Setting `template_file` on INSTANCE is not supported, as users should work
# with classes and not instances. This is tested for completeness. # with classes and not instances. This is tested for completeness.
comp.template_name = "other_template_2.html" comp.template_name = "other_template_2.html" # type: ignore[misc]
assert comp.template_name == "other_template_2.html" assert comp.template_name == "other_template_2.html"
assert comp.template_file == "other_template_2.html" assert comp.template_file == "other_template_2.html"
assert SimpleComponent.template_name == "other_template_2.html" assert SimpleComponent.template_name == "other_template_2.html"
@ -233,7 +284,7 @@ class TestComponent:
assertHTMLEqual( assertHTMLEqual(
rendered, rendered,
""" """
Variable: <strong data-djc-id-ca1bc3f>test</strong> Variable: <strong data-djc-id-ca1bc40>test</strong>
""", """,
) )
@ -422,17 +473,24 @@ class TestComponentRenderAPI:
assert called assert called
def test_args_kwargs_slots__raises_outside_render(self): def test_args_kwargs_slots__available_outside_render(self):
comp: Any = None
class TestComponent(Component): class TestComponent(Component):
template = "" template = ""
comp = TestComponent() def get_template_data(self, args, kwargs, slots, context):
with pytest.raises(RuntimeError): nonlocal comp
comp.args comp = self
with pytest.raises(RuntimeError):
comp.kwargs assert comp is None
with pytest.raises(RuntimeError):
comp.slots TestComponent.render()
assert comp.args == [] # type: ignore[attr-defined]
assert comp.kwargs == {} # type: ignore[attr-defined]
assert comp.slots == {} # type: ignore[attr-defined]
assert comp.context == Context() # type: ignore[attr-defined]
@djc_test @djc_test
@ -953,7 +1011,7 @@ class TestComponentRender:
# """ # """
assertInHTML( assertInHTML(
""" """
<kbd data-djc-id-ca1bc3e> <kbd data-djc-id-ca1bc3f>
Rendered via GET request Rendered via GET request
</kbd> </kbd>
""", """,

View file

@ -48,8 +48,8 @@ class TestComponentCache:
# Check if the cache entry is set # Check if the cache entry is set
cache_key = component.cache.get_cache_key([], {}, {}) cache_key = component.cache.get_cache_key([], {}, {})
assert cache_key == "components:cache:c98bf483e9a1937732d4542c714462ac" assert cache_key == "components:cache:c98bf483e9a1937732d4542c714462ac"
assert component.cache.get_entry(cache_key) == "<!-- _RENDERED TestComponent_c9770f,ca1bc3e,, -->Hello" assert component.cache.get_entry(cache_key) == "<!-- _RENDERED TestComponent_c9770f,ca1bc3f,, -->Hello"
assert caches["default"].get(cache_key) == "<!-- _RENDERED TestComponent_c9770f,ca1bc3e,, -->Hello" assert caches["default"].get(cache_key) == "<!-- _RENDERED TestComponent_c9770f,ca1bc3f,, -->Hello"
# Second render # Second render
did_call_get = False did_call_get = False
@ -105,7 +105,7 @@ class TestComponentCache:
cache_instance = component.cache cache_instance = component.cache
cache_key = cache_instance.get_cache_key([], {}, {}) cache_key = cache_instance.get_cache_key([], {}, {})
assert cache_instance.get_entry(cache_key) == "<!-- _RENDERED TestComponent_42aca9,ca1bc3e,, -->Hello" assert cache_instance.get_entry(cache_key) == "<!-- _RENDERED TestComponent_42aca9,ca1bc3f,, -->Hello"
# Wait for TTL to expire # Wait for TTL to expire
time.sleep(0.2) time.sleep(0.2)
@ -140,7 +140,7 @@ class TestComponentCache:
assert component.cache.get_cache() is caches["custom"] assert component.cache.get_cache() is caches["custom"]
assert ( assert (
component.cache.get_entry("components:cache:bcb4b049d8556e06871b39e0e584e452") component.cache.get_entry("components:cache:bcb4b049d8556e06871b39e0e584e452")
== "<!-- _RENDERED TestComponent_90ef7a,ca1bc3e,, -->Hello" == "<!-- _RENDERED TestComponent_90ef7a,ca1bc3f,, -->Hello"
) )
def test_cache_by_input(self): def test_cache_by_input(self):
@ -168,11 +168,11 @@ class TestComponentCache:
assert len(cache._cache) == 2 assert len(cache._cache) == 2
assert ( assert (
component.cache.get_entry("components:cache:3535e1d1e5f6fa5bc521e7fe203a68d0") component.cache.get_entry("components:cache:3535e1d1e5f6fa5bc521e7fe203a68d0")
== "<!-- _RENDERED TestComponent_648b95,ca1bc3e,, -->Hello world" == "<!-- _RENDERED TestComponent_648b95,ca1bc3f,, -->Hello world"
) )
assert ( assert (
component.cache.get_entry("components:cache:a98a8bd5e72a544d7601798d5e777a77") component.cache.get_entry("components:cache:a98a8bd5e72a544d7601798d5e777a77")
== "<!-- _RENDERED TestComponent_648b95,ca1bc3f,, -->Hello cake" == "<!-- _RENDERED TestComponent_648b95,ca1bc40,, -->Hello cake"
) )
def test_cache_input_hashing(self): def test_cache_input_hashing(self):
@ -206,7 +206,7 @@ class TestComponentCache:
# The key should use the custom hash methods # The key should use the custom hash methods
expected_key = "components:cache:3d54974c467a578c509efec189b0d14b" expected_key = "components:cache:3d54974c467a578c509efec189b0d14b"
assert component.cache.get_cache_key([1, 2], {"key": "value"}, {}) == expected_key assert component.cache.get_cache_key([1, 2], {"key": "value"}, {}) == expected_key
assert component.cache.get_entry(expected_key) == "<!-- _RENDERED TestComponent_28880f,ca1bc3e,, -->Hello" assert component.cache.get_entry(expected_key) == "<!-- _RENDERED TestComponent_28880f,ca1bc3f,, -->Hello"
def test_cached_component_inside_include(self): def test_cached_component_inside_include(self):
@ -283,7 +283,7 @@ class TestComponentCache:
assert len(cache._cache) == 2 assert len(cache._cache) == 2
assert ( assert (
component.cache.get_entry("components:cache:1d7e3a58972550cf9bec18f457fb1a61") component.cache.get_entry("components:cache:1d7e3a58972550cf9bec18f457fb1a61")
== '<!-- _RENDERED TestComponent_dd1dee,ca1bc44,, -->Hello cake <div data-djc-id-ca1bc44="">\n TWO\n </div>' # noqa: E501 == '<!-- _RENDERED TestComponent_dd1dee,ca1bc45,, -->Hello cake <div data-djc-id-ca1bc45="">\n TWO\n </div>' # noqa: E501
) )
def test_cache_slots__strings(self): def test_cache_slots__strings(self):
@ -324,7 +324,7 @@ class TestComponentCache:
assert len(cache._cache) == 2 assert len(cache._cache) == 2
assert ( assert (
component.cache.get_entry("components:cache:468e3f122ac305cff5d9096a3c548faf") component.cache.get_entry("components:cache:468e3f122ac305cff5d9096a3c548faf")
== '<!-- _RENDERED TestComponent_34b6d1,ca1bc41,, -->Hello cake <div data-djc-id-ca1bc41="">TWO</div>' == '<!-- _RENDERED TestComponent_34b6d1,ca1bc42,, -->Hello cake <div data-djc-id-ca1bc42="">TWO</div>'
) )
def test_cache_slots_raises_on_func(self): def test_cache_slots_raises_on_func(self):

View file

@ -215,10 +215,9 @@ class TestMainMedia:
def get_template(self, context): def get_template(self, context):
return Template("<div class='variable-html'>{{ variable }}</div>") return Template("<div class='variable-html'>{{ variable }}</div>")
comp = VariableHTMLComponent("variable_html_component") rendered = VariableHTMLComponent.render(context=Context({"variable": "Dynamic Content"}))
context = Context({"variable": "Dynamic Content"})
assertHTMLEqual( assertHTMLEqual(
comp.render(context), rendered,
'<div class="variable-html" data-djc-id-ca1bc3e>Dynamic Content</div>', '<div class="variable-html" data-djc-id-ca1bc3e>Dynamic Content</div>',
) )
@ -1047,6 +1046,13 @@ class TestSubclassingMedia:
assertInHTML('<script src="child.js"></script>', rendered) assertInHTML('<script src="child.js"></script>', rendered)
assertInHTML('<script src="parent.js"></script>', rendered) assertInHTML('<script src="parent.js"></script>', rendered)
assert str(ChildComponent.media) == (
'<link href="child.css" media="all" rel="stylesheet">\n'
'<link href="parent.css" media="all" rel="stylesheet">\n'
'<script src="child.js"></script>\n'
'<script src="parent.js"></script>'
)
def test_media_in_child_and_grandparent(self): def test_media_in_child_and_grandparent(self):
class GrandParentComponent(Component): class GrandParentComponent(Component):
template: types.django_html = """ template: types.django_html = """
@ -1075,6 +1081,13 @@ class TestSubclassingMedia:
assertInHTML('<script src="child.js"></script>', rendered) assertInHTML('<script src="child.js"></script>', rendered)
assertInHTML('<script src="grandparent.js"></script>', rendered) assertInHTML('<script src="grandparent.js"></script>', rendered)
assert str(ChildComponent.media) == (
'<link href="child.css" media="all" rel="stylesheet">\n'
'<link href="grandparent.css" media="all" rel="stylesheet">\n'
'<script src="child.js"></script>\n'
'<script src="grandparent.js"></script>'
)
def test_media_in_parent_and_grandparent(self): def test_media_in_parent_and_grandparent(self):
class GrandParentComponent(Component): class GrandParentComponent(Component):
template: types.django_html = """ template: types.django_html = """
@ -1103,6 +1116,13 @@ class TestSubclassingMedia:
assertInHTML('<script src="parent.js"></script>', rendered) assertInHTML('<script src="parent.js"></script>', rendered)
assertInHTML('<script src="grandparent.js"></script>', rendered) assertInHTML('<script src="grandparent.js"></script>', rendered)
assert str(ChildComponent.media) == (
'<link href="parent.css" media="all" rel="stylesheet">\n'
'<link href="grandparent.css" media="all" rel="stylesheet">\n'
'<script src="parent.js"></script>\n'
'<script src="grandparent.js"></script>'
)
def test_media_in_multiple_bases(self): def test_media_in_multiple_bases(self):
class GrandParent1Component(Component): class GrandParent1Component(Component):
class Media: class Media:
@ -1154,6 +1174,17 @@ class TestSubclassingMedia:
assertInHTML('<script src="grandparent1.js"></script>', rendered) assertInHTML('<script src="grandparent1.js"></script>', rendered)
assertInHTML('<script src="grandparent3.js"></script>', rendered) assertInHTML('<script src="grandparent3.js"></script>', rendered)
assert str(ChildComponent.media) == (
'<link href="child.css" media="all" rel="stylesheet">\n'
'<link href="grandparent3.css" media="all" rel="stylesheet">\n'
'<link href="parent1.css" media="all" rel="stylesheet">\n'
'<link href="grandparent1.css" media="all" rel="stylesheet">\n'
'<script src="child.js"></script>\n'
'<script src="grandparent3.js"></script>\n'
'<script src="parent1.js"></script>\n'
'<script src="grandparent1.js"></script>'
)
def test_extend_false_in_child(self): def test_extend_false_in_child(self):
class Parent1Component(Component): class Parent1Component(Component):
template: types.django_html = """ template: types.django_html = """
@ -1187,6 +1218,11 @@ class TestSubclassingMedia:
assert "parent2.js" not in rendered assert "parent2.js" not in rendered
assertInHTML('<script src="child.js"></script>', rendered) assertInHTML('<script src="child.js"></script>', rendered)
assert str(ChildComponent.media) == (
'<link href="child.css" media="all" rel="stylesheet">\n'
'<script src="child.js"></script>'
)
def test_extend_false_in_parent(self): def test_extend_false_in_parent(self):
class GrandParentComponent(Component): class GrandParentComponent(Component):
class Media: class Media:
@ -1227,6 +1263,15 @@ class TestSubclassingMedia:
assertInHTML('<script src="parent2.js"></script>', rendered) assertInHTML('<script src="parent2.js"></script>', rendered)
assertInHTML('<script src="child.js"></script>', rendered) assertInHTML('<script src="child.js"></script>', rendered)
assert str(ChildComponent.media) == (
'<link href="child.css" media="all" rel="stylesheet">\n'
'<link href="parent2.css" media="all" rel="stylesheet">\n'
'<link href="parent1.css" media="all" rel="stylesheet">\n'
'<script src="child.js"></script>\n'
'<script src="parent2.js"></script>\n'
'<script src="parent1.js"></script>'
)
def test_extend_list_in_child(self): def test_extend_list_in_child(self):
class Parent1Component(Component): class Parent1Component(Component):
template: types.django_html = """ template: types.django_html = """
@ -1274,6 +1319,15 @@ class TestSubclassingMedia:
assertInHTML('<script src="other2.js"></script>', rendered) assertInHTML('<script src="other2.js"></script>', rendered)
assertInHTML('<script src="child.js"></script>', rendered) assertInHTML('<script src="child.js"></script>', rendered)
assert str(ChildComponent.media) == (
'<link href="child.css" media="all" rel="stylesheet">\n'
'<link href="other2.css" media="all" rel="stylesheet">\n'
'<link href="other1.css" media="all" rel="stylesheet">\n'
'<script src="child.js"></script>\n'
'<script src="other2.js"></script>\n'
'<script src="other1.js"></script>'
)
def test_extend_list_in_parent(self): def test_extend_list_in_parent(self):
class Other1Component(Component): class Other1Component(Component):
class Media: class Media:
@ -1327,3 +1381,16 @@ class TestSubclassingMedia:
assertInHTML('<script src="parent1.js"></script>', rendered) assertInHTML('<script src="parent1.js"></script>', rendered)
assertInHTML('<script src="parent2.js"></script>', rendered) assertInHTML('<script src="parent2.js"></script>', rendered)
assertInHTML('<script src="child.js"></script>', rendered) assertInHTML('<script src="child.js"></script>', rendered)
assert str(ChildComponent.media) == (
'<link href="child.css" media="all" rel="stylesheet">\n'
'<link href="parent2.css" media="all" rel="stylesheet">\n'
'<link href="parent1.css" media="all" rel="stylesheet">\n'
'<link href="other2.css" media="all" rel="stylesheet">\n'
'<link href="other1.css" media="all" rel="stylesheet">\n'
'<script src="child.js"></script>\n'
'<script src="parent2.js"></script>\n'
'<script src="parent1.js"></script>\n'
'<script src="other2.js"></script>\n'
'<script src="other1.js"></script>'
)

View file

@ -195,11 +195,12 @@ class TestComponentAsView(SimpleTestCase):
def get(self, request, *args, **kwargs) -> HttpResponse: def get(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response(kwargs={"variable": self.name}) return self.render_to_response(kwargs={"variable": self.name})
client = CustomClient(urlpatterns=[path("test/", MockComponentRequest("my_comp").as_view())]) view = MockComponentRequest.as_view()
client = CustomClient(urlpatterns=[path("test/", view)])
response = client.get("/test/") response = client.get("/test/")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertInHTML( self.assertInHTML(
'<input type="text" name="variable" value="my_comp">', '<input type="text" name="variable" value="MockComponentRequest">',
response.content.decode(), response.content.decode(),
) )

View file

@ -1,7 +1,5 @@
import re
from typing import Dict, Optional, cast from typing import Dict, Optional, cast
import pytest
from django.http import HttpRequest from django.http import HttpRequest
from django.template import Context, RequestContext, Template from django.template import Context, RequestContext, Template
from pytest_django.asserts import assertHTMLEqual, assertInHTML from pytest_django.asserts import assertHTMLEqual, assertInHTML
@ -916,25 +914,24 @@ class TestContextProcessors:
assert child_data["dummy"] == "a1bc3f" assert child_data["dummy"] == "a1bc3f"
assert parent_data["csrf_token"] == child_data["csrf_token"] assert parent_data["csrf_token"] == child_data["csrf_token"]
def test_raises_on_accessing_context_processors_data_outside_of_rendering(self): def test_context_processors_data_outside_of_rendering(self):
class TestComponent(Component): class TestComponent(Component):
template: types.django_html = """{% csrf_token %}""" template: types.django_html = """{% csrf_token %}"""
with pytest.raises( request = HttpRequest()
RuntimeError, component = TestComponent(request=request)
match=re.escape("Tried to access Component's `context_processors_data` attribute while outside of rendering execution"), # noqa: E501 data = component.context_processors_data
):
TestComponent().context_processors_data
def test_raises_on_accessing_request_outside_of_rendering(self): assert list(data.keys()) == ["csrf_token"]
def test_request_outside_of_rendering(self):
class TestComponent(Component): class TestComponent(Component):
template: types.django_html = """{% csrf_token %}""" template: types.django_html = """{% csrf_token %}"""
with pytest.raises( request = HttpRequest()
RuntimeError, component = TestComponent(request=request)
match=re.escape("Tried to access Component's `request` attribute while outside of rendering execution"),
): assert component.request == request
TestComponent().request
@djc_test @djc_test

View file

@ -20,6 +20,7 @@ from django_components.extension import (
OnComponentUnregisteredContext, OnComponentUnregisteredContext,
OnComponentInputContext, OnComponentInputContext,
OnComponentDataContext, OnComponentDataContext,
OnSlotRenderedContext,
) )
from django_components.extensions.cache import CacheExtension from django_components.extensions.cache import CacheExtension
from django_components.extensions.debug_highlight import DebugHighlightExtension from django_components.extensions.debug_highlight import DebugHighlightExtension
@ -59,6 +60,7 @@ class DummyExtension(ComponentExtension):
"on_component_unregistered": [], "on_component_unregistered": [],
"on_component_input": [], "on_component_input": [],
"on_component_data": [], "on_component_data": [],
"on_slot_rendered": [],
} }
urls = [ urls = [
@ -94,6 +96,9 @@ class DummyExtension(ComponentExtension):
def on_component_data(self, ctx: OnComponentDataContext) -> None: def on_component_data(self, ctx: OnComponentDataContext) -> None:
self.calls["on_component_data"].append(ctx) self.calls["on_component_data"].append(ctx)
def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> None:
self.calls["on_slot_rendered"].append(ctx)
class DummyNestedExtension(ComponentExtension): class DummyNestedExtension(ComponentExtension):
name = "test_nested_extension" name = "test_nested_extension"
@ -306,6 +311,36 @@ class TestExtensionHooks:
assert data_call.js_data == {"script": "console.log('Hello!')"} assert data_call.js_data == {"script": "console.log('Hello!')"}
assert data_call.css_data == {"style": "body { color: blue; }"} assert data_call.css_data == {"style": "body { color: blue; }"}
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_on_slot_rendered(self):
@register("test_comp")
class TestComponent(Component):
template = "Hello {% slot 'content' required default / %}!"
# Render the component with some args and kwargs
test_context = Context({"foo": "bar"})
TestComponent.render(
context=test_context,
args=("arg1", "arg2"),
kwargs={"name": "Test"},
slots={"content": "Some content"},
)
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
# Verify on_slot_rendered was called with correct args
assert len(extension.calls["on_slot_rendered"]) == 1
slot_call: OnSlotRenderedContext = extension.calls["on_slot_rendered"][0]
assert isinstance(slot_call.component, TestComponent)
assert slot_call.component_cls == TestComponent
assert slot_call.component_id == "ca1bc3e"
assert isinstance(slot_call.slot, Slot)
assert slot_call.slot_name == "content"
assert slot_call.slot_is_required is True
assert slot_call.slot_is_default is True
assert slot_call.result == "Some content"
@djc_test @djc_test
class TestExtensionViews: class TestExtensionViews:

View file

@ -500,7 +500,7 @@ class TestDynamicComponentTemplateTag:
) )
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_raises_on_invalid_args(self, components_settings): def test_raises_on_invalid_input(self, components_settings):
registry.register(name="test", component=self.SimpleComponent) registry.register(name="test", component=self.SimpleComponent)
simple_tag_template: types.django_html = """ simple_tag_template: types.django_html = """

View file

@ -772,7 +772,7 @@ class TestInject:
self._assert_clear_cache() self._assert_clear_cache()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_inject_raises_on_called_outside_get_context_data(self, components_settings): def test_inject_called_outside_rendering(self, components_settings):
@register("injectee") @register("injectee")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
@ -783,8 +783,7 @@ class TestInject:
var = self.inject("abc", "default") var = self.inject("abc", "default")
return {"var": var} return {"var": var}
comp = InjectComponent("") comp = InjectComponent()
with pytest.raises(RuntimeError):
comp.inject("abc", "def") comp.inject("abc", "def")
self._assert_clear_cache() self._assert_clear_cache()