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")
```
- 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**
- 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/).
#### 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 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))
- Component classes now have `do_not_call_in_templates=True` to prevent them from being called as functions in templates.
## v0.139.1
#### 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'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)
```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
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
**Accessing the component instance from inside the nested classes:**
@ -61,10 +67,10 @@ to configure the extensions on a per-component basis.
```python
class MyTable(Component):
class View:
class MyExtension:
def get(self, request):
# `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
@ -78,10 +84,14 @@ You can override the `get()`, `post()`, etc methods to customize the behavior of
class MyTable(Component):
class View:
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):
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 Storybook:
def title(self):
return self.component.__class__.__name__
return self.component_cls.__name__
def parameters(self) -> Parameters:
return {
"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.
class ExtensionClass(ComponentExtension.ExtensionClass):
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):
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:
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
@ -166,7 +166,7 @@ class Frag(Component):
class View:
def get(self, request):
return self.component.render_to_response(
return self.component_cls.render_to_response(
request=request,
# IMPORTANT: Don't forget `deps_strategy="fragment"`
deps_strategy="fragment",
@ -228,7 +228,7 @@ class MyPage(Component):
class View:
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
@ -271,7 +271,7 @@ class Frag(Component):
class View:
def get(self, request):
return self.component.render_to_response(
return self.component_cls.render_to_response(
request=request,
# IMPORTANT: Don't forget `deps_strategy="fragment"`
deps_strategy="fragment",
@ -332,7 +332,7 @@ class MyPage(Component):
class View:
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
@ -362,7 +362,7 @@ class Frag(Component):
class View:
def get(self, request):
return self.component.render_to_response(
return self.component_cls.render_to_response(
request=request,
# IMPORTANT: Don't forget `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):
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.
### 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
class Calendar(Component):
@ -91,20 +91,9 @@ class Calendar(Component):
class View:
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
To register the component as a route / endpoint in Django, add an entry to your
@ -134,7 +123,7 @@ class MyComponent(Component):
public = True
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.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
class ProfileCard(Component):
@ -343,16 +343,20 @@ class ProfileCard(Component):
## 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.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.input`](./render_api/#component-inputs) - All the component inputs
- [`self.id`](./render_api/#component-id) - The unique ID for the current render call
- [`self.request`](./render_api/#request-object-and-context-processors) - The request object (if available)
- [`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
- [`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.slots`](../render_api/#slots) - The slots for the current render call
- [`self.context`](../render_api/#context) - The context for the current render call
- [`self.input`](../render_api/#other-inputs) - All the component inputs
- [`self.id`](../render_api/#component-id) - The unique ID for the current render call
- [`self.request`](../render_api/#request-and-context-processors) - The request object
- [`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

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_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:
```python
@ -46,16 +42,31 @@ rendered = Table.render(
The Render API includes:
- [`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.slots`](../render_api/#slots) - The slots for the current render call
- [`self.input`](../render_api/#component-inputs) - All the component inputs
- [`self.id`](../render_api/#component-id) - The unique ID for the current render call
- [`self.request`](../render_api/#request-object-and-context-processors) - The request object (if available)
- [`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
- Component inputs:
- [`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.slots`](../render_api/#slots) - The slots for the current render call
- [`self.context`](../render_api/#context) - The context for the current render call
- [`self.input`](../render_api/#other-inputs) - All the component inputs
## 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
[`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.
Raises `RuntimeError` if accessed outside of rendering execution.
**Example:**
With `Args` class:
@ -99,7 +108,7 @@ class Table(Component):
assert self.args[1] == 10
```
## Kwargs
### Kwargs
The `kwargs` argument as passed to
[`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.
Raises `RuntimeError` if accessed outside of rendering execution.
**Example:**
With `Kwargs` class:
@ -143,7 +150,7 @@ class Table(Component):
assert self.kwargs["per_page"] == 10
```
## Slots
### Slots
The `slots` argument as passed to
[`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.
Raises `RuntimeError` if accessed outside of rendering execution.
**Example:**
With `Slots` class:
@ -190,7 +195,27 @@ class Table(Component):
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),
[`self.kwargs`](../render_api/#kwargs),
@ -255,7 +280,7 @@ class Table(Component):
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:
@ -306,3 +331,32 @@ class Table(Component):
data = self.inject("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)
- `deps_strategy` - [Dependencies rendering strategy](#dependencies-rendering) (default: `"document"`)
- `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.

View file

@ -770,10 +770,10 @@ print(slot.nodelist) # <django.template.Nodelist: ['Hello!']>
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
from django.utils.safestring import mark_safe
from django.utils.html import escape
class Calendar(Component):
template = """
@ -784,24 +784,28 @@ class Calendar(Component):
Calendar.render(
slots={
"date": mark_safe("<b>Hello</b>"),
"date": escape("<b>Hello</b>"),
}
)
```
To disable escaping, you can pass `escape_slots_content=False` to
[`Component.render()`](../../../reference/api#django_components.Component.render)
or [`Component.render_to_response()`](../../../reference/api#django_components.Component.render_to_response)
methods.
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):
!!! 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,
especially if it comes from user input!
# function
"date": lambda ctx: mark_safe("<b>Hello</b>"),
}
)
```
!!! 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)
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.
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
from django.utils.safestring import mark_safe
from django.utils.html import escape
class Calendar(Component):
template = """
@ -178,24 +178,28 @@ class Calendar(Component):
Calendar.render(
slots={
"date": mark_safe("<b>Hello</b>"),
"date": escape("<b>Hello</b>"),
}
)
```
To disable escaping, you can pass `escape_slots_content=False` to
[`Component.render()`](../../reference/api#django_components.Component.render)
or [`Component.render_to_response()`](../../reference/api#django_components.Component.render_to_response)
methods.
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):
!!! 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,
especially if it comes from user input!
# function
"date": lambda ctx: mark_safe("<b>Hello</b>"),
}
)
```
!!! 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)
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
- Installation: installation.md
- Security notes 🚨: security_notes.md
- Migrating: migrating.md
- Community: community.md
- Contributing: contributing.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'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)
```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 = comp_class(
registered_name=self.registered_name,
outer_context=self.outer_context,
registry=self.registry,
)
output = comp.render(
output = comp_class.render(
context=self.input.context,
args=self.input.args,
kwargs=cleared_kwargs,
slots=self.input.slots,
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.

View file

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

View file

@ -129,10 +129,10 @@ class OnComponentRenderedContext(NamedTuple):
"""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
class OnSlotRenderedContext(NamedTuple):
component: "Component"
"""The Component instance that contains the `{% slot %}` tag"""
component_cls: Type["Component"]
"""The Component class that contains the `{% slot %}` tag"""
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 weakref import WeakKeyDictionary
from django_components.extension import ComponentExtension, OnComponentClassCreatedContext, OnComponentInputContext
from django_components.extension import ComponentExtension, OnComponentClassCreatedContext
if TYPE_CHECKING:
from django_components.component import Component
@ -99,7 +99,7 @@ def _extract_defaults(defaults: Optional[Type]) -> List[ComponentDefaultField]:
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`.
@ -171,11 +171,3 @@ class DefaultsExtension(ComponentExtension):
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
defaults_cls = getattr(ctx.component_cls, "Defaults", None)
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
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
import django.urls
@ -82,8 +82,8 @@ class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore
This class is a subclass of
[`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
via `self.component`.
The [`Component`](../api#django_components.Component) class is available
via `self.component_cls`.
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/`.
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
url = get_component_url(MyComponent)
```
"""
# NOTE: This class attribute must be declared on the class for `View.as_view()` to allow
# us to pass `component` kwarg.
# NOTE: The `component` / `component_cls` attributes are NOT user input, but still must be declared
# 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)
"""
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.
@ -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.
@ -155,26 +199,13 @@ class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore
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
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
# 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
# where each extension should keep its methods in the extension class.
# 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:
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:
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:
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:
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:
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:
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:
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:
return getattr(self.component, "trace")(request, *args, **kwargs)
return getattr(self.component_cls(), "trace")(request, *args, **kwargs)
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"
# is immutable. This ensures that the data returned from `inject` will always
# have all the keys that were passed to the `provide` tag.
tpl_cls = namedtuple("DepInject", provided_kwargs.keys()) # type: ignore[misc]
payload = tpl_cls(**provided_kwargs)
tuple_cls = namedtuple("DepInject", provided_kwargs.keys()) # type: ignore[misc]
payload = tuple_cls(**provided_kwargs)
# 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.

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
if TYPE_CHECKING:
from django_components.component import ComponentContext, ComponentNode
from django_components.component import Component, ComponentNode
TSlotData = TypeVar("TSlotData", bound=Mapping)
@ -597,11 +597,19 @@ class SlotNode(BaseNode):
f"SlotNode: {self.__repr__()}"
)
# Component info
component_id: str = context[_COMPONENT_CONTEXT_KEY]
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
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
is_default = self.flags[SLOT_DEFAULT_FLAG]
is_required = self.flags[SLOT_REQUIRED_FLAG]
@ -617,7 +625,7 @@ class SlotNode(BaseNode):
)
# 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
# the same name. If there is multiple 'default' slots with different names, raise.
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,
# and use the fills from that context.
if (
component_ctx.registry.settings.context_behavior == ContextBehavior.DJANGO
and component_ctx.outer_context is None
and (slot_name not in component_ctx.fills)
component.registry.settings.context_behavior == ContextBehavior.DJANGO
and outer_context is None
and (slot_name not in slot_fills)
):
# When we have nested components with fills, the context layers are added in
# the following order:
@ -726,10 +734,10 @@ class SlotNode(BaseNode):
trace_component_msg(
"SLOT_PARENT_INDEX",
component_name=component_ctx.component_name,
component_id=component_ctx.component_id,
component_name=component_name,
component_id=component_id,
slot_name=name,
component_path=component_ctx.component_path,
component_path=component_path,
extra=(
f"Parent index: {parent_index}, Current index: {curr_index}, "
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.
# Higher values make matching stricter. This is probably preferable, as it
# 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 = (
f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), "
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.
if (
component_ctx.registry.settings.context_behavior == ContextBehavior.DJANGO
and component_ctx.outer_context is not None
and _COMPONENT_CONTEXT_KEY in component_ctx.outer_context
component.registry.settings.context_behavior == ContextBehavior.DJANGO
and outer_context is not None
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
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
# 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
# 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):
# Required for compatibility with Django's {% extends %} tag
# 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
output = extensions.on_slot_rendered(
OnSlotRenderedContext(
component_cls=component_ctx.component_class,
component_id=component_ctx.component_id,
component=component,
component_cls=component.__class__,
component_id=component_id,
slot=slot,
slot_name=slot_name,
slot_is_required=is_required,
@ -878,7 +887,8 @@ class SlotNode(BaseNode):
self,
context: Context,
slot_is_filled: bool,
component_ctx: "ComponentContext",
component: "Component",
outer_context: Optional[Context],
) -> Context:
"""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
@ -887,11 +897,10 @@ class SlotNode(BaseNode):
if not slot_is_filled:
return context
registry_settings = component_ctx.registry.settings
registry_settings = component.registry.settings
if registry_settings.context_behavior == ContextBehavior.DJANGO:
return context
elif registry_settings.context_behavior == ContextBehavior.ISOLATED:
outer_context = component_ctx.outer_context
return outer_context if outer_context is not None else Context()
else:
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 itertools import chain
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 django_components.constants import UID_LENGTH
@ -15,6 +15,7 @@ if TYPE_CHECKING:
from django_components.component import Component
T = TypeVar("T")
U = TypeVar("U")
# Based on nanoid implementation from
@ -91,8 +92,13 @@ def get_module_info(
return module, module_name, module_file_path
def default(val: Optional[T], default: T) -> T:
return val if val is not None else default
def default(val: Optional[T], default: Union[U, Callable[[], U], Type[T]], factory: bool = False) -> Union[T, U]:
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]:

View file

@ -238,7 +238,7 @@ def djc_test(
)
)
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
# Used for testing the template_loader
@register("app_lvl_comp")
class AppLvlCompComponent(Component):
template_file: Optional[str] = "app_lvl_comp.html"
template_file = "app_lvl_comp.html"
js_file = "app_lvl_comp.js"
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
from typing import NamedTuple
from typing import Any, NamedTuple
import pytest
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
class TestComponent:
@ -112,7 +161,7 @@ class TestComponent:
pass
with pytest.raises(ImproperlyConfigured):
EmptyComponent("empty_component")._get_template(Context({}), "123")
EmptyComponent.render(args=["123"])
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_template_string_static_inlined(self, components_settings):
@ -200,6 +249,7 @@ class TestComponent:
css = "style.css"
js = "script.js"
# Access fields on Component class
assert SimpleComponent.template_name == "simple_template.html"
assert SimpleComponent.template_file == "simple_template.html"
@ -216,13 +266,14 @@ class TestComponent:
""",
)
# Access fields on Component instance
comp = SimpleComponent()
assert comp.template_name == "simple_template.html"
assert comp.template_file == "simple_template.html"
# NOTE: Setting `template_file` on INSTANCE is not supported, as users should work
# 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_file == "other_template_2.html"
assert SimpleComponent.template_name == "other_template_2.html"
@ -233,7 +284,7 @@ class TestComponent:
assertHTMLEqual(
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
def test_args_kwargs_slots__raises_outside_render(self):
def test_args_kwargs_slots__available_outside_render(self):
comp: Any = None
class TestComponent(Component):
template = ""
comp = TestComponent()
with pytest.raises(RuntimeError):
comp.args
with pytest.raises(RuntimeError):
comp.kwargs
with pytest.raises(RuntimeError):
comp.slots
def get_template_data(self, args, kwargs, slots, context):
nonlocal comp
comp = self
assert comp is None
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
@ -953,7 +1011,7 @@ class TestComponentRender:
# """
assertInHTML(
"""
<kbd data-djc-id-ca1bc3e>
<kbd data-djc-id-ca1bc3f>
Rendered via GET request
</kbd>
""",

View file

@ -48,8 +48,8 @@ class TestComponentCache:
# Check if the cache entry is set
cache_key = component.cache.get_cache_key([], {}, {})
assert cache_key == "components:cache:c98bf483e9a1937732d4542c714462ac"
assert component.cache.get_entry(cache_key) == "<!-- _RENDERED TestComponent_c9770f,ca1bc3e,, -->Hello"
assert caches["default"].get(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,ca1bc3f,, -->Hello"
# Second render
did_call_get = False
@ -105,7 +105,7 @@ class TestComponentCache:
cache_instance = component.cache
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
time.sleep(0.2)
@ -140,7 +140,7 @@ class TestComponentCache:
assert component.cache.get_cache() is caches["custom"]
assert (
component.cache.get_entry("components:cache:bcb4b049d8556e06871b39e0e584e452")
== "<!-- _RENDERED TestComponent_90ef7a,ca1bc3e,, -->Hello"
== "<!-- _RENDERED TestComponent_90ef7a,ca1bc3f,, -->Hello"
)
def test_cache_by_input(self):
@ -168,11 +168,11 @@ class TestComponentCache:
assert len(cache._cache) == 2
assert (
component.cache.get_entry("components:cache:3535e1d1e5f6fa5bc521e7fe203a68d0")
== "<!-- _RENDERED TestComponent_648b95,ca1bc3e,, -->Hello world"
== "<!-- _RENDERED TestComponent_648b95,ca1bc3f,, -->Hello world"
)
assert (
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):
@ -206,7 +206,7 @@ class TestComponentCache:
# The key should use the custom hash methods
expected_key = "components:cache:3d54974c467a578c509efec189b0d14b"
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):
@ -283,7 +283,7 @@ class TestComponentCache:
assert len(cache._cache) == 2
assert (
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):
@ -324,7 +324,7 @@ class TestComponentCache:
assert len(cache._cache) == 2
assert (
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):

View file

@ -215,10 +215,9 @@ class TestMainMedia:
def get_template(self, context):
return Template("<div class='variable-html'>{{ variable }}</div>")
comp = VariableHTMLComponent("variable_html_component")
context = Context({"variable": "Dynamic Content"})
rendered = VariableHTMLComponent.render(context=Context({"variable": "Dynamic Content"}))
assertHTMLEqual(
comp.render(context),
rendered,
'<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="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):
class GrandParentComponent(Component):
template: types.django_html = """
@ -1075,6 +1081,13 @@ class TestSubclassingMedia:
assertInHTML('<script src="child.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):
class GrandParentComponent(Component):
template: types.django_html = """
@ -1103,6 +1116,13 @@ class TestSubclassingMedia:
assertInHTML('<script src="parent.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):
class GrandParent1Component(Component):
class Media:
@ -1154,6 +1174,17 @@ class TestSubclassingMedia:
assertInHTML('<script src="grandparent1.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):
class Parent1Component(Component):
template: types.django_html = """
@ -1187,6 +1218,11 @@ class TestSubclassingMedia:
assert "parent2.js" not in 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):
class GrandParentComponent(Component):
class Media:
@ -1227,6 +1263,15 @@ class TestSubclassingMedia:
assertInHTML('<script src="parent2.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):
class Parent1Component(Component):
template: types.django_html = """
@ -1274,6 +1319,15 @@ class TestSubclassingMedia:
assertInHTML('<script src="other2.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):
class Other1Component(Component):
class Media:
@ -1327,3 +1381,16 @@ class TestSubclassingMedia:
assertInHTML('<script src="parent1.js"></script>', rendered)
assertInHTML('<script src="parent2.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:
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/")
self.assertEqual(response.status_code, 200)
self.assertInHTML(
'<input type="text" name="variable" value="my_comp">',
'<input type="text" name="variable" value="MockComponentRequest">',
response.content.decode(),
)

View file

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

View file

@ -20,6 +20,7 @@ from django_components.extension import (
OnComponentUnregisteredContext,
OnComponentInputContext,
OnComponentDataContext,
OnSlotRenderedContext,
)
from django_components.extensions.cache import CacheExtension
from django_components.extensions.debug_highlight import DebugHighlightExtension
@ -59,6 +60,7 @@ class DummyExtension(ComponentExtension):
"on_component_unregistered": [],
"on_component_input": [],
"on_component_data": [],
"on_slot_rendered": [],
}
urls = [
@ -94,6 +96,9 @@ class DummyExtension(ComponentExtension):
def on_component_data(self, ctx: OnComponentDataContext) -> None:
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):
name = "test_nested_extension"
@ -306,6 +311,36 @@ class TestExtensionHooks:
assert data_call.js_data == {"script": "console.log('Hello!')"}
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
class TestExtensionViews:

View file

@ -500,7 +500,7 @@ class TestDynamicComponentTemplateTag:
)
@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)
simple_tag_template: types.django_html = """

View file

@ -772,7 +772,7 @@ class TestInject:
self._assert_clear_cache()
@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")
class InjectComponent(Component):
template: types.django_html = """
@ -783,9 +783,8 @@ class TestInject:
var = self.inject("abc", "default")
return {"var": var}
comp = InjectComponent("")
with pytest.raises(RuntimeError):
comp.inject("abc", "def")
comp = InjectComponent()
comp.inject("abc", "def")
self._assert_clear_cache()