mirror of
https://github.com/django-components/django-components.git
synced 2025-08-03 22:08:17 +00:00
feat: extensions (#1009)
* feat: extensions * refactor: remove support for passing in extensions as instances
This commit is contained in:
parent
cff252c566
commit
4d35bc97a2
24 changed files with 1884 additions and 57 deletions
|
@ -4,6 +4,10 @@
|
|||
|
||||
#### Feat
|
||||
|
||||
- Support for extensions (plugins) for django-components!
|
||||
|
||||
Read more on [Extensions](https://django-components.github.io/django-components/0.131/concepts/advanced/extensions/).
|
||||
|
||||
- `@djc_test` decorator for writing tests that involve Components.
|
||||
|
||||
- The decorator manages global state, ensuring that tests don't leak.
|
||||
|
|
37
README.md
37
README.md
|
@ -282,6 +282,43 @@ Button.render(
|
|||
)
|
||||
```
|
||||
|
||||
### Extensions
|
||||
|
||||
Django-components functionality can be extended with "extensions". Extensions allow for powerful customization and integrations. They can:
|
||||
|
||||
- Tap into lifecycle events, such as when a component is created, deleted, or registered.
|
||||
- Add new attributes and methods to the components under an extension-specific nested class.
|
||||
|
||||
Some of the planned extensions include:
|
||||
|
||||
- Caching
|
||||
- AlpineJS integration
|
||||
- Storybook integration
|
||||
- Pydantic validation
|
||||
- Component-level benchmarking with asv
|
||||
|
||||
### Simple testing
|
||||
|
||||
- Write tests for components with `@djc_test` decorator.
|
||||
- The decorator manages global state, ensuring that tests don't leak.
|
||||
- If using `pytest`, the decorator allows you to parametrize Django or Components settings.
|
||||
- The decorator also serves as a stand-in for Django's `@override_settings`.
|
||||
|
||||
```python
|
||||
from djc_test import djc_test
|
||||
|
||||
from components.my_component import MyTable
|
||||
|
||||
@djc_test
|
||||
def test_my_table():
|
||||
rendered = MyTable.render(
|
||||
kwargs={
|
||||
"title": "My table",
|
||||
},
|
||||
)
|
||||
assert rendered == "<table>My table</table>"
|
||||
```
|
||||
|
||||
### Handle large projects with ease
|
||||
|
||||
- Components can be infinitely nested.
|
||||
|
|
|
@ -8,5 +8,6 @@ nav:
|
|||
- Typing and validation: typing_and_validation.md
|
||||
- Custom template tags: template_tags.md
|
||||
- Tag formatters: tag_formatter.md
|
||||
- Extensions: extensions.md
|
||||
- Testing: testing.md
|
||||
- Authoring component libraries: authoring_component_libraries.md
|
||||
|
|
330
docs/concepts/advanced/extensions.md
Normal file
330
docs/concepts/advanced/extensions.md
Normal file
|
@ -0,0 +1,330 @@
|
|||
_New in version 0.131_
|
||||
|
||||
Django-components functionality can be extended with "extensions". Extensions allow for powerful customization and integrations. They can:
|
||||
|
||||
- Tap into lifecycle events, such as when a component is created, deleted, registered, or unregistered.
|
||||
- Add new attributes and methods to the components under an extension-specific nested class.
|
||||
|
||||
## Setting up extensions
|
||||
|
||||
Extensions are configured in the Django settings under [`COMPONENTS.extensions`](../../../reference/settings#django_components.app_settings.ComponentsSettings.extensions).
|
||||
|
||||
Extensions can be set by either as an import string or by passing in a class:
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
|
||||
class MyExtension(ComponentsExtension):
|
||||
name = "my_extension"
|
||||
|
||||
class ExtensionClass(BaseExtensionClass):
|
||||
...
|
||||
|
||||
COMPONENTS = ComponentsSettings(
|
||||
extensions=[
|
||||
MyExtension,
|
||||
"another_app.extensions.AnotherExtension",
|
||||
"my_app.extensions.ThirdExtension",
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
Extensions can define methods to hook into lifecycle events, such as:
|
||||
|
||||
- Component creation or deletion
|
||||
- Un/registering a component
|
||||
- Creating or deleting a registry
|
||||
- Pre-processing data passed to a component on render
|
||||
- Post-processing data returned from [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data)
|
||||
and others.
|
||||
|
||||
See the full list in [Extension Hooks Reference](../../../reference/extension_hooks).
|
||||
|
||||
## Configuring extensions per component
|
||||
|
||||
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.
|
||||
|
||||
!!! note
|
||||
|
||||
**Accessing the component instance from inside the nested classes:**
|
||||
|
||||
Each method of the nested classes has access to the `component` attribute,
|
||||
which points to the component instance.
|
||||
|
||||
```python
|
||||
class MyTable(Component):
|
||||
class View:
|
||||
def get(self, request):
|
||||
# `self.component` points to the instance of `MyTable` Component.
|
||||
return self.component.get(request)
|
||||
```
|
||||
|
||||
### Example: Component as View
|
||||
|
||||
The [Components as Views](../../fundamentals/components_as_views) feature is actually implemented as an extension
|
||||
that is configured by a `View` nested class.
|
||||
|
||||
You can override the `get`, `post`, etc methods to customize the behavior of the component as a view:
|
||||
|
||||
```python
|
||||
class MyTable(Component):
|
||||
class View:
|
||||
def get(self, request):
|
||||
return self.component.get(request)
|
||||
|
||||
def post(self, request):
|
||||
return self.component.post(request)
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
### Example: Storybook integration
|
||||
|
||||
The Storybook integration (work in progress) is an extension that is configured by a `Storybook` nested class.
|
||||
|
||||
You can override methods such as `title`, `parameters`, etc, to customize how to generate a Storybook
|
||||
JSON file from the component.
|
||||
|
||||
```python
|
||||
class MyTable(Component):
|
||||
class Storybook:
|
||||
def title(self):
|
||||
return self.component.__class__.__name__
|
||||
|
||||
def parameters(self) -> Parameters:
|
||||
return {
|
||||
"server": {
|
||||
"id": self.component.__class__.__name__,
|
||||
}
|
||||
}
|
||||
|
||||
def stories(self) -> List[StoryAnnotations]:
|
||||
return []
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
## Accessing extensions in components
|
||||
|
||||
Above, we've configured extensions `View` and `Storybook` for the `MyTable` component.
|
||||
|
||||
You can access the instances of these extension classes in the component instance.
|
||||
|
||||
For example, the View extension is available as `self.view`:
|
||||
|
||||
```python
|
||||
class MyTable(Component):
|
||||
def get_context_data(self, request):
|
||||
# `self.view` points to the instance of `View` extension.
|
||||
return {
|
||||
"view": self.view,
|
||||
}
|
||||
```
|
||||
|
||||
And the Storybook extension is available as `self.storybook`:
|
||||
|
||||
```python
|
||||
class MyTable(Component):
|
||||
def get_context_data(self, request):
|
||||
# `self.storybook` points to the instance of `Storybook` extension.
|
||||
return {
|
||||
"title": self.storybook.title(),
|
||||
}
|
||||
```
|
||||
|
||||
Thus, you can use extensions to add methods or attributes that will be available to all components
|
||||
in their component context.
|
||||
|
||||
## Writing extensions
|
||||
|
||||
Creating extensions in django-components involves defining a class that inherits from
|
||||
[`ComponentExtension`](../../../reference/api/#django_components.ComponentExtension).
|
||||
This class can implement various lifecycle hooks and define new attributes or methods to be added to components.
|
||||
|
||||
### Defining an extension
|
||||
|
||||
To create an extension, define a class that inherits from [`ComponentExtension`](../../../reference/api/#django_components.ComponentExtension)
|
||||
and implement the desired hooks.
|
||||
|
||||
- Each extension MUST have a `name` attribute. The name MUST be a valid Python identifier.
|
||||
- The extension MAY implement any of the [hook methods](../../../reference/extension_hooks).
|
||||
- Each hook method receives a context object with relevant data.
|
||||
|
||||
```python
|
||||
from django_components.extension import ComponentExtension, OnComponentClassCreatedContext
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
name = "my_extension"
|
||||
|
||||
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
|
||||
# Custom logic for when a component class is created
|
||||
ctx.component_cls.my_attr = "my_value"
|
||||
```
|
||||
|
||||
### Defining the extension class
|
||||
|
||||
In previous sections we've seen the `View` and `Storybook` extensions classes that were nested within the `Component` class:
|
||||
|
||||
```python
|
||||
class MyComponent(Component):
|
||||
class View:
|
||||
...
|
||||
|
||||
class Storybook:
|
||||
...
|
||||
```
|
||||
|
||||
These can be understood as component-specific overrides or configuration.
|
||||
|
||||
The nested extension classes like `View` or `Storybook` will actually subclass from a base extension
|
||||
class as defined on the [`ComponentExtension.ExtensionClass`](../../../reference/api/#django_components.ComponentExtension.ExtensionClass).
|
||||
|
||||
This is how extensions define the "default" behavior of their nested extension classes.
|
||||
|
||||
For example, the `View` base extension class defines the handlers for GET, POST, etc:
|
||||
|
||||
```python
|
||||
from django_components.extension import ComponentExtension, BaseExtensionClass
|
||||
|
||||
class ViewExtension(ComponentExtension):
|
||||
name = "view"
|
||||
|
||||
# The default behavior of the `View` extension class.
|
||||
class ExtensionClass(BaseExtensionClass):
|
||||
def get(self, request):
|
||||
return self.component.get(request)
|
||||
|
||||
def post(self, request):
|
||||
return self.component.post(request)
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
In any component that then defines a nested `View` extension class, the `View` extension class will actually
|
||||
subclass from the `ViewExtension.ExtensionClass` class.
|
||||
|
||||
In other words, when you define a component like this:
|
||||
|
||||
```python
|
||||
class MyTable(Component):
|
||||
class View:
|
||||
def get(self, request):
|
||||
# Do something
|
||||
...
|
||||
```
|
||||
|
||||
It will actually be implemented as if the `View` class subclassed from base class `ViewExtension.ExtensionClass`:
|
||||
|
||||
```python
|
||||
class MyTable(Component):
|
||||
class View(ViewExtension.ExtensionClass):
|
||||
def get(self, request):
|
||||
# Do something
|
||||
...
|
||||
```
|
||||
|
||||
!!! warning
|
||||
|
||||
When writing an extension, the `ExtensionClass` MUST subclass the base class [`BaseExtensionClass`](../../../reference/api/#django_components.ComponentExtension.BaseExtensionClass).
|
||||
|
||||
This base class ensures that the extension class will have access to the component instance.
|
||||
|
||||
### Registering extensions
|
||||
|
||||
Once the extension is defined, it needs to be registered in the Django settings to be used by the application.
|
||||
|
||||
Extensions can be given either as an extension class, or its import string:
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
COMPONENTS = {
|
||||
"extensions": [
|
||||
"my_app.extensions.MyExtension",
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Or by reference:
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
from my_app.extensions import MyExtension
|
||||
|
||||
COMPONENTS = {
|
||||
"extensions": [
|
||||
MyExtension,
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Full example: Custom logging extension
|
||||
|
||||
To tie it all together, here's an example of a custom logging extension that logs when components are created, deleted, or rendered:
|
||||
|
||||
- Each component can specify which color to use for the logging by setting `Component.ColorLogger.color`.
|
||||
- The extension will log the component name and color when the component is created, deleted, or rendered.
|
||||
|
||||
```python
|
||||
from django_components.extension import (
|
||||
ComponentExtension,
|
||||
OnComponentClassCreatedContext,
|
||||
OnComponentClassDeletedContext,
|
||||
OnComponentInputContext,
|
||||
)
|
||||
|
||||
class ColorLoggerExtensionClass(BaseExtensionClass):
|
||||
color: str
|
||||
|
||||
|
||||
class ColorLoggerExtension(ComponentExtension):
|
||||
name = "color_logger"
|
||||
|
||||
# All `Component.ColorLogger` classes will inherit from this class.
|
||||
ExtensionClass = ColorLoggerExtensionClass
|
||||
|
||||
# These hooks don't have access to the Component instance, only to the Component class,
|
||||
# so we access the color as `Component.ColorLogger.color`.
|
||||
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
|
||||
log.info(
|
||||
f"Component {ctx.component_cls} created.",
|
||||
color=ctx.component_cls.ColorLogger.color,
|
||||
)
|
||||
|
||||
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
|
||||
log.info(
|
||||
f"Component {ctx.component_cls} deleted.",
|
||||
color=ctx.component_cls.ColorLogger.color,
|
||||
)
|
||||
|
||||
# This hook has access to the Component instance, so we access the color
|
||||
# as `self.component.color_logger.color`.
|
||||
def on_component_input(self, ctx: OnComponentInputContext) -> None:
|
||||
log.info(
|
||||
f"Rendering component {ctx.component_cls}.",
|
||||
color=ctx.component.color_logger.color,
|
||||
)
|
||||
```
|
||||
|
||||
To use the `ColorLoggerExtension`, add it to your settings:
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
COMPONENTS = {
|
||||
"extensions": [
|
||||
ColorLoggerExtension,
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Once registered, in any component, you can define a `ColorLogger` attribute:
|
||||
|
||||
```python
|
||||
class MyComponent(Component):
|
||||
class ColorLogger:
|
||||
color = "red"
|
||||
```
|
||||
|
||||
This will log the component name and color when the component is created, deleted, or rendered.
|
|
@ -267,6 +267,43 @@ Button.render(
|
|||
)
|
||||
```
|
||||
|
||||
### Extensions
|
||||
|
||||
Django-components functionality can be extended with "extensions". Extensions allow for powerful customization and integrations. They can:
|
||||
|
||||
- Tap into lifecycle events, such as when a component is created, deleted, or registered.
|
||||
- Add new attributes and methods to the components under an extension-specific nested class.
|
||||
|
||||
Some of the planned extensions include:
|
||||
|
||||
- Caching
|
||||
- AlpineJS integration
|
||||
- Storybook integration
|
||||
- Pydantic validation
|
||||
- Component-level benchmarking with asv
|
||||
|
||||
### Simple testing
|
||||
|
||||
- Write tests for components with `@djc_test` decorator.
|
||||
- The decorator manages global state, ensuring that tests don't leak.
|
||||
- If using `pytest`, the decorator allows you to parametrize Django or Components settings.
|
||||
- The decorator also serves as a stand-in for Django's `@override_settings`.
|
||||
|
||||
```python
|
||||
from djc_test import djc_test
|
||||
|
||||
from components.my_component import MyTable
|
||||
|
||||
@djc_test
|
||||
def test_my_table():
|
||||
rendered = MyTable.render(
|
||||
kwargs={
|
||||
"title": "My table",
|
||||
},
|
||||
)
|
||||
assert rendered == "<table>My table</table>"
|
||||
```
|
||||
|
||||
### Handle large projects with ease
|
||||
|
||||
- Components can be infinitely nested.
|
||||
|
|
|
@ -4,6 +4,7 @@ nav:
|
|||
- Commands: commands.md
|
||||
- Components: components.md
|
||||
- Exceptions: exceptions.md
|
||||
- Extension hooks: extension_hooks.md
|
||||
- Middlewares: middlewares.md
|
||||
- Settings: settings.md
|
||||
- Signals: signals.md
|
||||
|
|
|
@ -11,6 +11,10 @@
|
|||
options:
|
||||
show_if_no_docstring: true
|
||||
|
||||
::: django_components.ComponentExtension
|
||||
options:
|
||||
show_if_no_docstring: true
|
||||
|
||||
::: django_components.ComponentFileEntry
|
||||
options:
|
||||
show_if_no_docstring: true
|
||||
|
@ -51,6 +55,38 @@
|
|||
options:
|
||||
show_if_no_docstring: true
|
||||
|
||||
::: django_components.OnComponentClassCreatedContext
|
||||
options:
|
||||
show_if_no_docstring: true
|
||||
|
||||
::: django_components.OnComponentClassDeletedContext
|
||||
options:
|
||||
show_if_no_docstring: true
|
||||
|
||||
::: django_components.OnComponentDataContext
|
||||
options:
|
||||
show_if_no_docstring: true
|
||||
|
||||
::: django_components.OnComponentInputContext
|
||||
options:
|
||||
show_if_no_docstring: true
|
||||
|
||||
::: django_components.OnComponentRegisteredContext
|
||||
options:
|
||||
show_if_no_docstring: true
|
||||
|
||||
::: django_components.OnComponentUnregisteredContext
|
||||
options:
|
||||
show_if_no_docstring: true
|
||||
|
||||
::: django_components.OnRegistryCreatedContext
|
||||
options:
|
||||
show_if_no_docstring: true
|
||||
|
||||
::: django_components.OnRegistryDeletedContext
|
||||
options:
|
||||
show_if_no_docstring: true
|
||||
|
||||
::: django_components.RegistrySettings
|
||||
options:
|
||||
show_if_no_docstring: true
|
||||
|
|
152
docs/reference/extension_hooks.md
Normal file
152
docs/reference/extension_hooks.md
Normal file
|
@ -0,0 +1,152 @@
|
|||
<!-- Autogenerated by reference.py -->
|
||||
|
||||
# Extension Hooks
|
||||
|
||||
Overview of all the extension hooks available in Django Components.
|
||||
|
||||
Read more on [Extensions](../../concepts/advanced/extensions).
|
||||
|
||||
|
||||
::: django_components.extension.ComponentExtension.on_component_class_created
|
||||
options:
|
||||
show_root_heading: true
|
||||
show_signature: true
|
||||
separate_signature: true
|
||||
show_symbol_type_heading: false
|
||||
show_symbol_type_toc: false
|
||||
show_if_no_docstring: true
|
||||
show_labels: false
|
||||
|
||||
**Available data:**
|
||||
|
||||
name | type | description
|
||||
--|--|--
|
||||
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The created Component class
|
||||
|
||||
::: django_components.extension.ComponentExtension.on_component_class_deleted
|
||||
options:
|
||||
show_root_heading: true
|
||||
show_signature: true
|
||||
separate_signature: true
|
||||
show_symbol_type_heading: false
|
||||
show_symbol_type_toc: false
|
||||
show_if_no_docstring: true
|
||||
show_labels: false
|
||||
|
||||
**Available data:**
|
||||
|
||||
name | type | description
|
||||
--|--|--
|
||||
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The to-be-deleted Component class
|
||||
|
||||
::: django_components.extension.ComponentExtension.on_component_data
|
||||
options:
|
||||
show_root_heading: true
|
||||
show_signature: true
|
||||
separate_signature: true
|
||||
show_symbol_type_heading: false
|
||||
show_symbol_type_toc: false
|
||||
show_if_no_docstring: true
|
||||
show_labels: false
|
||||
|
||||
**Available data:**
|
||||
|
||||
name | type | description
|
||||
--|--|--
|
||||
`component` | [`Component`](../api#django_components.Component) | The Component instance that is being rendered
|
||||
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The Component class
|
||||
`component_id` | `str` | The unique identifier for this component instance
|
||||
`context_data` | `Dict` | Dictionary of context data from `Component.get_context_data()`
|
||||
`css_data` | `Dict` | Dictionary of CSS data from `Component.get_css_data()`
|
||||
`js_data` | `Dict` | Dictionary of JavaScript data from `Component.get_js_data()`
|
||||
|
||||
::: django_components.extension.ComponentExtension.on_component_input
|
||||
options:
|
||||
show_root_heading: true
|
||||
show_signature: true
|
||||
separate_signature: true
|
||||
show_symbol_type_heading: false
|
||||
show_symbol_type_toc: false
|
||||
show_if_no_docstring: true
|
||||
show_labels: false
|
||||
|
||||
**Available data:**
|
||||
|
||||
name | type | description
|
||||
--|--|--
|
||||
`args` | `List` | List of positional arguments passed to the component
|
||||
`component` | [`Component`](../api#django_components.Component) | The Component instance that received the input and is being rendered
|
||||
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The Component class
|
||||
`component_id` | `str` | The unique identifier for this component instance
|
||||
`context` | [`Context`](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Context) | The Django template Context object
|
||||
`kwargs` | `Dict` | Dictionary of keyword arguments passed to the component
|
||||
`slots` | `Dict` | Dictionary of slot definitions
|
||||
|
||||
::: django_components.extension.ComponentExtension.on_component_registered
|
||||
options:
|
||||
show_root_heading: true
|
||||
show_signature: true
|
||||
separate_signature: true
|
||||
show_symbol_type_heading: false
|
||||
show_symbol_type_toc: false
|
||||
show_if_no_docstring: true
|
||||
show_labels: false
|
||||
|
||||
**Available data:**
|
||||
|
||||
name | type | description
|
||||
--|--|--
|
||||
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The registered Component class
|
||||
`name` | `str` | The name the component was registered under
|
||||
`registry` | [`ComponentRegistry`](../api#django_components.ComponentRegistry) | The registry the component was registered to
|
||||
|
||||
::: django_components.extension.ComponentExtension.on_component_unregistered
|
||||
options:
|
||||
show_root_heading: true
|
||||
show_signature: true
|
||||
separate_signature: true
|
||||
show_symbol_type_heading: false
|
||||
show_symbol_type_toc: false
|
||||
show_if_no_docstring: true
|
||||
show_labels: false
|
||||
|
||||
**Available data:**
|
||||
|
||||
name | type | description
|
||||
--|--|--
|
||||
`component_cls` | [`Type[Component]`](../api#django_components.Component) | The unregistered Component class
|
||||
`name` | `str` | The name the component was registered under
|
||||
`registry` | [`ComponentRegistry`](../api#django_components.ComponentRegistry) | The registry the component was unregistered from
|
||||
|
||||
::: django_components.extension.ComponentExtension.on_registry_created
|
||||
options:
|
||||
show_root_heading: true
|
||||
show_signature: true
|
||||
separate_signature: true
|
||||
show_symbol_type_heading: false
|
||||
show_symbol_type_toc: false
|
||||
show_if_no_docstring: true
|
||||
show_labels: false
|
||||
|
||||
**Available data:**
|
||||
|
||||
name | type | description
|
||||
--|--|--
|
||||
`registry` | [`ComponentRegistry`](../api#django_components.ComponentRegistry) | The created ComponentRegistry instance
|
||||
|
||||
::: django_components.extension.ComponentExtension.on_registry_deleted
|
||||
options:
|
||||
show_root_heading: true
|
||||
show_signature: true
|
||||
separate_signature: true
|
||||
show_symbol_type_heading: false
|
||||
show_symbol_type_toc: false
|
||||
show_if_no_docstring: true
|
||||
show_labels: false
|
||||
|
||||
**Available data:**
|
||||
|
||||
name | type | description
|
||||
--|--|--
|
||||
`registry` | [`ComponentRegistry`](../api#django_components.ComponentRegistry) | The to-be-deleted ComponentRegistry instance
|
||||
|
|
@ -42,6 +42,7 @@ defaults = ComponentsSettings(
|
|||
debug_highlight_components=False,
|
||||
debug_highlight_slots=False,
|
||||
dynamic_component_name="dynamic",
|
||||
extensions=[],
|
||||
libraries=[], # E.g. ["mysite.components.forms", ...]
|
||||
multiline_tags=True,
|
||||
reload_on_file_change=False,
|
||||
|
@ -146,6 +147,16 @@ defaults = ComponentsSettings(
|
|||
show_if_no_docstring: true
|
||||
show_labels: false
|
||||
|
||||
::: django_components.app_settings.ComponentsSettings.extensions
|
||||
options:
|
||||
show_root_heading: true
|
||||
show_signature: true
|
||||
separate_signature: true
|
||||
show_symbol_type_heading: false
|
||||
show_symbol_type_toc: false
|
||||
show_if_no_docstring: true
|
||||
show_labels: false
|
||||
|
||||
::: django_components.app_settings.ComponentsSettings.forbidden_static_files
|
||||
options:
|
||||
show_root_heading: true
|
||||
|
|
|
@ -67,7 +67,7 @@ If you insert this tag multiple times, ALL JS scripts will be duplicately insert
|
|||
|
||||
|
||||
|
||||
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1549" target="_blank">See source code</a>
|
||||
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1568" target="_blank">See source code</a>
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -125,10 +125,7 @@ def gen_reference_testing_api():
|
|||
f.write(preface + "\n\n")
|
||||
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if (
|
||||
name.startswith("_")
|
||||
or inspect.ismodule(obj)
|
||||
):
|
||||
if name.startswith("_") or inspect.ismodule(obj):
|
||||
continue
|
||||
|
||||
# For each entry, generate a mkdocstrings entry, e.g.
|
||||
|
@ -586,6 +583,178 @@ def gen_reference_templatevars():
|
|||
f.write(f"::: {ComponentVars.__module__}.{ComponentVars.__name__}.{field}\n\n")
|
||||
|
||||
|
||||
def gen_reference_extension_hooks():
|
||||
"""
|
||||
Generate documentation for the hooks that are available to the extensions.
|
||||
"""
|
||||
module = import_module("django_components.extension")
|
||||
|
||||
preface = "<!-- Autogenerated by reference.py -->\n\n"
|
||||
preface += (root / "docs/templates/reference_extension_hooks.md").read_text()
|
||||
out_file = root / "docs/reference/extension_hooks.md"
|
||||
|
||||
out_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out_file.open("w", encoding="utf-8") as f:
|
||||
# 1. Insert section from `reference_extension_hooks.md`
|
||||
f.write(preface + "\n\n")
|
||||
|
||||
# 2. Print each hook and their descriptions
|
||||
extension_cls = module.ComponentExtension
|
||||
class_name = get_import_path(extension_cls)
|
||||
|
||||
# NOTE: If no unique methods, just document the class itself without methods
|
||||
unique_methods = _get_unique_methods(NamedTuple, extension_cls)
|
||||
# All hooks start with `on_`, so filter out the rest
|
||||
unique_methods = [name for name in unique_methods if name.startswith("on_")]
|
||||
|
||||
for name in sorted(unique_methods):
|
||||
# Programmatically get the data available inside the hook, so we can generate
|
||||
# a table of available data.
|
||||
# Each hook receives a second "ctx" argument, so we access the typing of this "ctx",
|
||||
# and get its fields.
|
||||
method = getattr(extension_cls, name)
|
||||
ctx_type = method.__annotations__["ctx"]
|
||||
# The Context data class is defined in the same module as the hook, so we can
|
||||
# import it dynamically.
|
||||
ctx_class = getattr(module, ctx_type.__name__)
|
||||
fields = ctx_class._fields
|
||||
field_docstrings = _extract_property_docstrings(ctx_class)
|
||||
|
||||
# Generate the available data table
|
||||
available_data = "**Available data:**\n\n"
|
||||
available_data += "name | type | description\n"
|
||||
available_data += "--|--|--\n"
|
||||
for field in sorted(fields):
|
||||
field_type = _format_hook_type(str(ctx_class.__annotations__[field]))
|
||||
field_desc = field_docstrings[field]
|
||||
available_data += f"`{field}` | {field_type} | {field_desc}\n"
|
||||
|
||||
# For each entry, generate a mkdocstrings entry, e.g.
|
||||
# ```
|
||||
# ::: django_components.extension.ComponentExtension.on_component_registered
|
||||
# options:
|
||||
# ...
|
||||
# ```
|
||||
f.write(
|
||||
f"::: {class_name}.{name}\n"
|
||||
f" options:\n"
|
||||
f" show_root_heading: true\n"
|
||||
f" show_signature: true\n"
|
||||
f" separate_signature: true\n"
|
||||
f" show_symbol_type_heading: false\n"
|
||||
f" show_symbol_type_toc: false\n"
|
||||
f" show_if_no_docstring: true\n"
|
||||
f" show_labels: false\n"
|
||||
)
|
||||
f.write("\n")
|
||||
f.write(available_data)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
forward_ref_pattern = re.compile(r"ForwardRef\('(.+?)'\)")
|
||||
class_repr_pattern = re.compile(r"<class '(.+?)'>")
|
||||
typing_pattern = re.compile(r"typing\.(.+?)")
|
||||
|
||||
|
||||
def _format_hook_type(type_str: str) -> str:
|
||||
# Clean up the type string
|
||||
type_str = forward_ref_pattern.sub(r"\1", type_str)
|
||||
type_str = class_repr_pattern.sub(r"\1", type_str)
|
||||
type_str = typing_pattern.sub(r"\1", type_str)
|
||||
type_str = type_str.replace("django.template.context.Context", "Context")
|
||||
type_str = "`" + type_str + "`"
|
||||
|
||||
# Add links to non-builtin types
|
||||
if "ComponentRegistry" in type_str:
|
||||
type_str = f"[{type_str}](../api#django_components.ComponentRegistry)"
|
||||
elif "Component" in type_str:
|
||||
type_str = f"[{type_str}](../api#django_components.Component)"
|
||||
elif "Context" in type_str:
|
||||
type_str = f"[{type_str}](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Context)"
|
||||
|
||||
return type_str
|
||||
|
||||
|
||||
def _extract_property_docstrings(cls: Type) -> Dict[str, str]:
|
||||
"""
|
||||
Python doesn't provide a way to access docstrings of properties, e.g.:
|
||||
|
||||
```python
|
||||
class MyComponent(Component):
|
||||
my_property: str = "Hello, world!"
|
||||
'''
|
||||
My property docstring
|
||||
'''
|
||||
```
|
||||
|
||||
This function extracts the docstrings of properties from the source code.
|
||||
|
||||
Returns a dictionary with the property name as the key and the docstring as the value.
|
||||
|
||||
```python
|
||||
{
|
||||
"my_property": "My property docstring"
|
||||
}
|
||||
```
|
||||
|
||||
NOTE: This is a naive implementation and may not work for all cases:
|
||||
|
||||
- The function expects NO colons (`:`) inside class bases definition.
|
||||
- The function assumes that the docstring is defined with `\"\"\"` or `'''`, and that
|
||||
the docstring begins on a separate line.
|
||||
- The function assumes that the class is defined at the global scope (module level)
|
||||
and that the body is indented with 4 spaces.
|
||||
"""
|
||||
lines, start_line_index = inspect.getsourcelines(cls)
|
||||
attrs_lines = []
|
||||
ignore = True
|
||||
for line in lines:
|
||||
if ignore:
|
||||
if line.endswith("):\n"):
|
||||
ignore = False
|
||||
continue
|
||||
else:
|
||||
attrs_lines.append(line)
|
||||
|
||||
attrs_docstrings = {}
|
||||
curr_attr = None
|
||||
docstring_delimiter = None
|
||||
state = "before_attr"
|
||||
|
||||
while attrs_lines:
|
||||
line = attrs_lines.pop(0)
|
||||
# Exactly 1 indentation and not empty line
|
||||
is_one_indent = line.startswith(" " * 4) and not line.startswith(" " * 5) and line.strip()
|
||||
line = line.strip()
|
||||
|
||||
if state == "before_attr":
|
||||
if not is_one_indent:
|
||||
continue
|
||||
curr_attr = line.split(":", maxsplit=1)[0].strip()
|
||||
attrs_docstrings[curr_attr] = ""
|
||||
state = "before_attr_docstring"
|
||||
elif state == "before_attr_docstring":
|
||||
if not is_one_indent or not (line.startswith("'''") or line.startswith('"""')):
|
||||
continue
|
||||
# Found start of docstring
|
||||
docstring_delimiter = line[0:3]
|
||||
line = line[3:]
|
||||
attrs_lines.insert(0, line)
|
||||
state = "attr_docstring"
|
||||
elif state == "attr_docstring":
|
||||
# Not end of docstring
|
||||
if docstring_delimiter not in line: # type: ignore[operator]
|
||||
attrs_docstrings[curr_attr] += line # type: ignore[index]
|
||||
continue
|
||||
# Found end of docstring
|
||||
last_docstring_line, _ = line.split(docstring_delimiter, maxsplit=1)
|
||||
attrs_docstrings[curr_attr] += last_docstring_line # type: ignore[index]
|
||||
attrs_docstrings[curr_attr] = dedent(attrs_docstrings[curr_attr]) # type: ignore[index]
|
||||
state = "before_attr"
|
||||
|
||||
return attrs_docstrings
|
||||
|
||||
|
||||
# NOTE: Unlike other references, the API of Signals is not yet codified (AKA source of truth defined
|
||||
# as Python code). Instead, we manually list all signals that are sent by django-components.
|
||||
def gen_reference_signals():
|
||||
|
@ -774,6 +943,7 @@ def gen_reference():
|
|||
gen_reference_templatevars()
|
||||
gen_reference_signals()
|
||||
gen_reference_testing_api()
|
||||
gen_reference_extension_hooks()
|
||||
|
||||
|
||||
# This is run when `gen-files` plugin is run in mkdocs.yml
|
||||
|
|
5
docs/templates/reference_extension_hooks.md
vendored
Normal file
5
docs/templates/reference_extension_hooks.md
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Extension Hooks
|
||||
|
||||
Overview of all the extension hooks available in Django Components.
|
||||
|
||||
Read more on [Extensions](../../concepts/advanced/extensions).
|
|
@ -6,7 +6,7 @@
|
|||
# isort: off
|
||||
from django_components.app_settings import ContextBehavior, ComponentsSettings
|
||||
from django_components.autodiscovery import autodiscover, import_libraries
|
||||
from django_components.component import Component, ComponentVars, ComponentView
|
||||
from django_components.component import Component, ComponentVars
|
||||
from django_components.component_media import ComponentMediaInput, ComponentMediaInputPath
|
||||
from django_components.component_registry import (
|
||||
AlreadyRegistered,
|
||||
|
@ -18,6 +18,18 @@ from django_components.component_registry import (
|
|||
)
|
||||
from django_components.components import DynamicComponent
|
||||
from django_components.dependencies import render_dependencies
|
||||
from django_components.extension import (
|
||||
ComponentExtension,
|
||||
OnComponentRegisteredContext,
|
||||
OnComponentUnregisteredContext,
|
||||
OnRegistryCreatedContext,
|
||||
OnRegistryDeletedContext,
|
||||
OnComponentClassCreatedContext,
|
||||
OnComponentClassDeletedContext,
|
||||
OnComponentInputContext,
|
||||
OnComponentDataContext,
|
||||
)
|
||||
from django_components.extensions.view import ComponentView
|
||||
from django_components.library import TagProtectedError
|
||||
from django_components.node import BaseNode, template_tag
|
||||
from django_components.slots import SlotContent, Slot, SlotFunc, SlotRef, SlotResult
|
||||
|
@ -45,6 +57,7 @@ __all__ = [
|
|||
"ContextBehavior",
|
||||
"ComponentsSettings",
|
||||
"Component",
|
||||
"ComponentExtension",
|
||||
"ComponentFileEntry",
|
||||
"ComponentFormatter",
|
||||
"ComponentMediaInput",
|
||||
|
@ -61,6 +74,14 @@ __all__ = [
|
|||
"get_component_files",
|
||||
"import_libraries",
|
||||
"NotRegistered",
|
||||
"OnComponentClassCreatedContext",
|
||||
"OnComponentClassDeletedContext",
|
||||
"OnComponentDataContext",
|
||||
"OnComponentInputContext",
|
||||
"OnComponentRegisteredContext",
|
||||
"OnComponentUnregisteredContext",
|
||||
"OnRegistryCreatedContext",
|
||||
"OnRegistryDeletedContext",
|
||||
"register",
|
||||
"registry",
|
||||
"RegistrySettings",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from importlib import import_module
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
|
@ -15,6 +16,7 @@ from typing import (
|
|||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
|
@ -25,6 +27,7 @@ from django.conf import settings
|
|||
from django_components.util.misc import default
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django_components.extension import ComponentExtension
|
||||
from django_components.tag_formatter import TagFormatterABC
|
||||
|
||||
|
||||
|
@ -146,6 +149,25 @@ class ComponentsSettings(NamedTuple):
|
|||
```
|
||||
"""
|
||||
|
||||
extensions: Optional[Sequence[Union[Type["ComponentExtension"], str]]] = None
|
||||
"""
|
||||
List of [extensions](../../concepts/advanced/extensions) to be loaded.
|
||||
|
||||
The extensions can be specified as:
|
||||
|
||||
- Python import path, e.g. `"path.to.my_extension.MyExtension"`.
|
||||
- Extension class, e.g. `my_extension.MyExtension`.
|
||||
|
||||
```python
|
||||
COMPONENTS = ComponentsSettings(
|
||||
extensions=[
|
||||
"path.to.my_extension.MyExtension",
|
||||
StorybookExtension,
|
||||
],
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
autodiscover: Optional[bool] = None
|
||||
"""
|
||||
Toggle whether to run [autodiscovery](../../concepts/fundamentals/autodiscovery) at the Django server startup.
|
||||
|
@ -647,6 +669,7 @@ defaults = ComponentsSettings(
|
|||
debug_highlight_components=False,
|
||||
debug_highlight_slots=False,
|
||||
dynamic_component_name="dynamic",
|
||||
extensions=[],
|
||||
libraries=[], # E.g. ["mysite.components.forms", ...]
|
||||
multiline_tags=True,
|
||||
reload_on_file_change=False,
|
||||
|
@ -709,6 +732,9 @@ class InternalSettings:
|
|||
components_settings.dynamic_component_name, defaults.dynamic_component_name
|
||||
),
|
||||
libraries=default(components_settings.libraries, defaults.libraries),
|
||||
# NOTE: Internally we store the extensions as a list of instances, but the user
|
||||
# can pass in either a list of classes or a list of import strings.
|
||||
extensions=self._prepare_extensions(components_settings), # type: ignore[arg-type]
|
||||
multiline_tags=default(components_settings.multiline_tags, defaults.multiline_tags),
|
||||
reload_on_file_change=self._prepare_reload_on_file_change(components_settings),
|
||||
template_cache_size=default(components_settings.template_cache_size, defaults.template_cache_size),
|
||||
|
@ -718,6 +744,33 @@ class InternalSettings:
|
|||
tag_formatter=default(components_settings.tag_formatter, defaults.tag_formatter), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def _prepare_extensions(self, new_settings: ComponentsSettings) -> List["ComponentExtension"]:
|
||||
extensions: Sequence[Union[Type["ComponentExtension"], str]] = default(
|
||||
new_settings.extensions, cast(List[str], defaults.extensions)
|
||||
)
|
||||
|
||||
# Prepend built-in extensions
|
||||
from django_components.extensions.view import ViewExtension
|
||||
|
||||
extensions = [ViewExtension] + list(extensions)
|
||||
|
||||
# Extensions may be passed in either as classes or import strings.
|
||||
extension_instances: List["ComponentExtension"] = []
|
||||
for extension in extensions:
|
||||
if isinstance(extension, str):
|
||||
import_path, class_name = extension.rsplit(".", 1)
|
||||
extension_module = import_module(import_path)
|
||||
extension = cast(Type["ComponentExtension"], getattr(extension_module, class_name))
|
||||
|
||||
if isinstance(extension, type):
|
||||
extension_instance = extension()
|
||||
else:
|
||||
extension_instances.append(extension)
|
||||
|
||||
extension_instances.append(extension_instance)
|
||||
|
||||
return extension_instances
|
||||
|
||||
def _prepare_reload_on_file_change(self, new_settings: ComponentsSettings) -> bool:
|
||||
val = new_settings.reload_on_file_change
|
||||
# TODO_REMOVE_IN_V1
|
||||
|
@ -780,6 +833,10 @@ class InternalSettings:
|
|||
def LIBRARIES(self) -> List[str]:
|
||||
return self._settings.libraries # type: ignore[return-value]
|
||||
|
||||
@property
|
||||
def EXTENSIONS(self) -> List["ComponentExtension"]:
|
||||
return self._settings.extensions # type: ignore[return-value]
|
||||
|
||||
@property
|
||||
def MULTILINE_TAGS(self) -> bool:
|
||||
return self._settings.multiline_tags # type: ignore[return-value]
|
||||
|
|
|
@ -17,6 +17,7 @@ class ComponentsConfig(AppConfig):
|
|||
from django_components.autodiscovery import autodiscover, import_libraries
|
||||
from django_components.component_registry import registry
|
||||
from django_components.components.dynamic import DynamicComponent
|
||||
from django_components.extension import extensions
|
||||
from django_components.util.django_monkeypatch import monkeypatch_template_cls
|
||||
|
||||
app_settings._load_settings()
|
||||
|
@ -57,6 +58,9 @@ class ComponentsConfig(AppConfig):
|
|||
# Register the dynamic component under the name as given in settings
|
||||
registry.register(app_settings.DYNAMIC_COMPONENT_NAME, DynamicComponent)
|
||||
|
||||
# Let extensions process any components which may have been created before the app was ready
|
||||
extensions._init_app()
|
||||
|
||||
|
||||
# See https://github.com/django-components/django-components/issues/586#issue-2472678136
|
||||
def _watch_component_files_for_autoreload() -> None:
|
||||
|
|
|
@ -16,14 +16,13 @@ from typing import (
|
|||
Mapping,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
Protocol,
|
||||
Tuple,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
from weakref import ReferenceType
|
||||
from weakref import ReferenceType, finalize
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.forms.widgets import Media as MediaCls
|
||||
|
@ -54,6 +53,14 @@ from django_components.dependencies import render_dependencies as _render_depend
|
|||
from django_components.dependencies import (
|
||||
set_component_attrs_for_js_and_css,
|
||||
)
|
||||
from django_components.extension import (
|
||||
OnComponentClassCreatedContext,
|
||||
OnComponentClassDeletedContext,
|
||||
OnComponentDataContext,
|
||||
OnComponentInputContext,
|
||||
extensions,
|
||||
)
|
||||
from django_components.extensions.view import ViewFn
|
||||
from django_components.node import BaseNode
|
||||
from django_components.perfutil.component import ComponentRenderer, component_context_cache, component_post_render
|
||||
from django_components.perfutil.provide import register_provide_reference, unregister_provide_reference
|
||||
|
@ -131,10 +138,6 @@ class MetadataItem(Generic[ArgsType, KwargsType, SlotsType]):
|
|||
request: Optional[HttpRequest]
|
||||
|
||||
|
||||
class ViewFn(Protocol):
|
||||
def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704
|
||||
|
||||
|
||||
class ComponentVars(NamedTuple):
|
||||
"""
|
||||
Type for the variables available inside the component templates.
|
||||
|
@ -199,40 +202,10 @@ class ComponentMeta(ComponentMediaMeta):
|
|||
|
||||
return super().__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
# NOTE: We use metaclass to automatically define the HTTP methods as defined
|
||||
# in `View.http_method_names`.
|
||||
class ComponentViewMeta(type):
|
||||
def __new__(mcs, name: str, bases: Any, dct: Dict) -> Any:
|
||||
# Default implementation shared by all HTTP methods
|
||||
def create_handler(method: str) -> Callable:
|
||||
def handler(self, request: HttpRequest, *args: Any, **kwargs: Any): # type: ignore[no-untyped-def]
|
||||
component: "Component" = self.component
|
||||
return getattr(component, method)(request, *args, **kwargs)
|
||||
|
||||
return handler
|
||||
|
||||
# Add methods to the class
|
||||
for method_name in View.http_method_names:
|
||||
if method_name not in dct:
|
||||
dct[method_name] = create_handler(method_name)
|
||||
|
||||
return super().__new__(mcs, name, bases, dct)
|
||||
|
||||
|
||||
class ComponentView(View, metaclass=ComponentViewMeta):
|
||||
"""
|
||||
Subclass of `django.views.View` where the `Component` instance is available
|
||||
via `self.component`.
|
||||
"""
|
||||
|
||||
# NOTE: This attribute must be declared on the class for `View.as_view` to allow
|
||||
# us to pass `component` kwarg.
|
||||
component = cast("Component", None)
|
||||
|
||||
def __init__(self, component: "Component", **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.component = component
|
||||
# This runs when a Component class is being deleted
|
||||
def __del__(cls) -> None:
|
||||
comp_cls = cast(Type["Component"], cls)
|
||||
extensions.on_component_class_deleted(OnComponentClassDeletedContext(comp_cls))
|
||||
|
||||
|
||||
# Internal data that are made available within the component's template
|
||||
|
@ -562,7 +535,6 @@ class Component(
|
|||
|
||||
response_class = HttpResponse
|
||||
"""This allows to configure what class is used to generate response from `render_to_response`"""
|
||||
View = ComponentView
|
||||
|
||||
# #####################################
|
||||
# PUBLIC API - HOOKS
|
||||
|
@ -623,11 +595,15 @@ class Component(
|
|||
# None == uninitialized, False == No types, Tuple == types
|
||||
self._types: Optional[Union[Tuple[Any, Any, Any, Any, Any, Any], Literal[False]]] = None
|
||||
|
||||
extensions._init_component_instance(self)
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
cls._class_hash = hash_comp_cls(cls)
|
||||
comp_hash_mapping[cls._class_hash] = cls
|
||||
|
||||
ALL_COMPONENTS.append(cached_ref(cls)) # type: ignore[arg-type]
|
||||
extensions._init_component_class(cls)
|
||||
extensions.on_component_class_created(OnComponentClassCreatedContext(cls))
|
||||
|
||||
@contextmanager
|
||||
def _with_metadata(self, item: MetadataItem) -> Generator[None, None, None]:
|
||||
|
@ -922,8 +898,10 @@ class Component(
|
|||
else:
|
||||
comp = cls()
|
||||
|
||||
# Allow the View class to access this component via `self.component`
|
||||
return comp.View.as_view(**initkwargs, component=comp)
|
||||
# `view` is a built-in extension defined in `extensions.view`. It subclasses
|
||||
# from Django's `View` class, and adds the `component` attribute to it.
|
||||
view_inst = cast(View, comp.view) # type: ignore[attr-defined]
|
||||
return view_inst.__class__.as_view(**initkwargs, component=comp)
|
||||
|
||||
# #####################################
|
||||
# RENDERING
|
||||
|
@ -1152,6 +1130,19 @@ class Component(
|
|||
request=request,
|
||||
)
|
||||
|
||||
# Allow plugins to modify or validate the inputs
|
||||
extensions.on_component_input(
|
||||
OnComponentInputContext(
|
||||
component=self,
|
||||
component_cls=self.__class__,
|
||||
component_id=render_id,
|
||||
args=args, # type: ignore[arg-type]
|
||||
kwargs=kwargs, # type: ignore[arg-type]
|
||||
slots=slots, # type: ignore[arg-type]
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
|
||||
# We pass down the components the info about the component's parent.
|
||||
# This is used for correctly resolving slot fills, correct rendering order,
|
||||
# or CSS scoping.
|
||||
|
@ -1215,6 +1206,17 @@ class Component(
|
|||
css_data = self.get_css_data(*args, **kwargs) if hasattr(self, "get_css_data") else {} # type: ignore
|
||||
self._validate_outputs(data=context_data)
|
||||
|
||||
extensions.on_component_data(
|
||||
OnComponentDataContext(
|
||||
component=self,
|
||||
component_cls=self.__class__,
|
||||
component_id=render_id,
|
||||
context_data=cast(Dict, context_data),
|
||||
js_data=cast(Dict, js_data),
|
||||
css_data=cast(Dict, css_data),
|
||||
)
|
||||
)
|
||||
|
||||
# Process Component's JS and CSS
|
||||
cache_component_js(self.__class__)
|
||||
js_input_hash = cache_component_js_vars(self.__class__, js_data) if js_data else None
|
||||
|
@ -1700,6 +1702,10 @@ class ComponentNode(BaseNode):
|
|||
subcls: Type[ComponentNode] = type(subcls_name, (cls,), {"tag": start_tag, "end_tag": end_tag})
|
||||
component_node_subclasses_by_name[start_tag] = (subcls, registry)
|
||||
|
||||
# Remove the cache entry when either the registry or the component are deleted
|
||||
finalize(subcls, lambda: component_node_subclasses_by_name.pop(start_tag, None))
|
||||
finalize(registry, lambda: component_node_subclasses_by_name.pop(start_tag, None))
|
||||
|
||||
cached_subcls, cached_registry = component_node_subclasses_by_name[start_tag]
|
||||
|
||||
if cached_registry is not registry:
|
||||
|
|
|
@ -6,6 +6,13 @@ from django.template import Library
|
|||
from django.template.base import Parser, Token
|
||||
|
||||
from django_components.app_settings import ContextBehaviorType, app_settings
|
||||
from django_components.extension import (
|
||||
OnComponentRegisteredContext,
|
||||
OnComponentUnregisteredContext,
|
||||
OnRegistryCreatedContext,
|
||||
OnRegistryDeletedContext,
|
||||
extensions,
|
||||
)
|
||||
from django_components.library import is_tag_protected, mark_protected_tags, register_tag
|
||||
from django_components.tag_formatter import TagFormatterABC, get_tag_formatter
|
||||
from django_components.util.weakref import cached_ref
|
||||
|
@ -237,7 +244,19 @@ class ComponentRegistry:
|
|||
|
||||
ALL_REGISTRIES.append(cached_ref(self))
|
||||
|
||||
extensions.on_registry_created(
|
||||
OnRegistryCreatedContext(
|
||||
registry=self,
|
||||
)
|
||||
)
|
||||
|
||||
def __del__(self) -> None:
|
||||
extensions.on_registry_deleted(
|
||||
OnRegistryDeletedContext(
|
||||
registry=self,
|
||||
)
|
||||
)
|
||||
|
||||
# Unregister all components when the registry is deleted
|
||||
self.clear()
|
||||
|
||||
|
@ -336,6 +355,14 @@ class ComponentRegistry:
|
|||
# If the component class is deleted, unregister it from this registry.
|
||||
finalize(entry.cls, lambda: self.unregister(name) if name in self._registry else None)
|
||||
|
||||
extensions.on_component_registered(
|
||||
OnComponentRegisteredContext(
|
||||
registry=self,
|
||||
name=name,
|
||||
component_cls=entry.cls,
|
||||
)
|
||||
)
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""
|
||||
Unregister the [`Component`](../api#django_components.Component) class
|
||||
|
@ -389,6 +416,14 @@ class ComponentRegistry:
|
|||
entry = self._registry[name]
|
||||
del self._registry[name]
|
||||
|
||||
extensions.on_component_unregistered(
|
||||
OnComponentUnregisteredContext(
|
||||
registry=self,
|
||||
name=name,
|
||||
component_cls=entry.cls,
|
||||
)
|
||||
)
|
||||
|
||||
def get(self, name: str) -> Type["Component"]:
|
||||
"""
|
||||
Retrieve a [`Component`](../api#django_components.Component)
|
||||
|
|
580
src/django_components/extension.py
Normal file
580
src/django_components/extension.py
Normal file
|
@ -0,0 +1,580 @@
|
|||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Tuple, Type, TypeVar
|
||||
|
||||
from django.template import Context
|
||||
|
||||
from django_components.app_settings import app_settings
|
||||
from django_components.util.misc import snake_to_pascal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django_components import Component
|
||||
from django_components.component_registry import ComponentRegistry
|
||||
|
||||
|
||||
TCallable = TypeVar("TCallable", bound=Callable)
|
||||
|
||||
|
||||
################################################
|
||||
# HOOK TYPES
|
||||
#
|
||||
# This is the source of truth for what data is available in each hook.
|
||||
# NOTE: These types are also used in docs generation, see `docs/scripts/reference.py`.
|
||||
################################################
|
||||
|
||||
|
||||
class OnComponentClassCreatedContext(NamedTuple):
|
||||
component_cls: Type["Component"]
|
||||
"""The created Component class"""
|
||||
|
||||
|
||||
class OnComponentClassDeletedContext(NamedTuple):
|
||||
component_cls: Type["Component"]
|
||||
"""The to-be-deleted Component class"""
|
||||
|
||||
|
||||
class OnRegistryCreatedContext(NamedTuple):
|
||||
registry: "ComponentRegistry"
|
||||
"""The created ComponentRegistry instance"""
|
||||
|
||||
|
||||
class OnRegistryDeletedContext(NamedTuple):
|
||||
registry: "ComponentRegistry"
|
||||
"""The to-be-deleted ComponentRegistry instance"""
|
||||
|
||||
|
||||
class OnComponentRegisteredContext(NamedTuple):
|
||||
registry: "ComponentRegistry"
|
||||
"""The registry the component was registered to"""
|
||||
name: str
|
||||
"""The name the component was registered under"""
|
||||
component_cls: Type["Component"]
|
||||
"""The registered Component class"""
|
||||
|
||||
|
||||
class OnComponentUnregisteredContext(NamedTuple):
|
||||
registry: "ComponentRegistry"
|
||||
"""The registry the component was unregistered from"""
|
||||
name: str
|
||||
"""The name the component was registered under"""
|
||||
component_cls: Type["Component"]
|
||||
"""The unregistered Component class"""
|
||||
|
||||
|
||||
class OnComponentInputContext(NamedTuple):
|
||||
component: "Component"
|
||||
"""The Component instance that received the input and is being rendered"""
|
||||
component_cls: Type["Component"]
|
||||
"""The Component class"""
|
||||
component_id: str
|
||||
"""The unique identifier for this component instance"""
|
||||
args: List
|
||||
"""List of positional arguments passed to the component"""
|
||||
kwargs: Dict
|
||||
"""Dictionary of keyword arguments passed to the component"""
|
||||
slots: Dict
|
||||
"""Dictionary of slot definitions"""
|
||||
context: Context
|
||||
"""The Django template Context object"""
|
||||
|
||||
|
||||
class OnComponentDataContext(NamedTuple):
|
||||
component: "Component"
|
||||
"""The Component instance that is being rendered"""
|
||||
component_cls: Type["Component"]
|
||||
"""The Component class"""
|
||||
component_id: str
|
||||
"""The unique identifier for this component instance"""
|
||||
context_data: Dict
|
||||
"""Dictionary of context data from `Component.get_context_data()`"""
|
||||
js_data: Dict
|
||||
"""Dictionary of JavaScript data from `Component.get_js_data()`"""
|
||||
css_data: Dict
|
||||
"""Dictionary of CSS data from `Component.get_css_data()`"""
|
||||
|
||||
|
||||
class BaseExtensionClass:
|
||||
def __init__(self, component: "Component") -> None:
|
||||
self.component = component
|
||||
|
||||
|
||||
# NOTE: This class is used for generating documentation for the extension hooks API.
|
||||
# To be recognized, all hooks must start with `on_` prefix.
|
||||
class ComponentExtension:
|
||||
"""
|
||||
Base class for all extensions.
|
||||
|
||||
Read more on [Extensions](../../concepts/advanced/extensions).
|
||||
"""
|
||||
|
||||
name: str
|
||||
"""
|
||||
Name of the extension.
|
||||
|
||||
Name must be lowercase, and must be a valid Python identifier (e.g. `"my_extension"`).
|
||||
|
||||
The extension may add new features to the [`Component`](../api#django_components.Component)
|
||||
class by allowing users to define and access a nested class in the `Component` class.
|
||||
|
||||
The extension name determines the name of the nested class in the `Component` class, and the attribute
|
||||
under which the extension will be accessible.
|
||||
|
||||
E.g. if the extension name is `"my_extension"`, then the nested class in the `Component` class
|
||||
will be `MyExtension`, and the extension will be accessible as `MyComp.my_extension`.
|
||||
|
||||
```python
|
||||
class MyComp(Component):
|
||||
class MyExtension:
|
||||
...
|
||||
|
||||
def get_context_data(self):
|
||||
return {
|
||||
"my_extension": self.my_extension.do_something(),
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
class_name: str
|
||||
"""
|
||||
Name of the extension class.
|
||||
|
||||
By default, this is the same as `name`, but with snake_case converted to PascalCase.
|
||||
|
||||
So if the extension name is `"my_extension"`, then the extension class name will be `"MyExtension"`.
|
||||
|
||||
```python
|
||||
class MyComp(Component):
|
||||
class MyExtension: # <--- This is the extension class
|
||||
...
|
||||
```
|
||||
"""
|
||||
|
||||
ExtensionClass = BaseExtensionClass
|
||||
"""
|
||||
Base class that the "extension class" nested within a [`Component`](../api#django_components.Component)
|
||||
class will inherit from.
|
||||
|
||||
This is where you can define new methods and attributes that will be available to the component
|
||||
instance.
|
||||
|
||||
Background:
|
||||
|
||||
The extension may add new features to the `Component` class by allowing users to
|
||||
define and access a nested class in the `Component` class. E.g.:
|
||||
|
||||
```python
|
||||
class MyComp(Component):
|
||||
class MyExtension:
|
||||
...
|
||||
|
||||
def get_context_data(self):
|
||||
return {
|
||||
"my_extension": self.my_extension.do_something(),
|
||||
}
|
||||
```
|
||||
|
||||
When rendering a component, the nested extension class will be set as a subclass of `ExtensionClass`.
|
||||
So it will be same as if the user had directly inherited from `ExtensionClass`. E.g.:
|
||||
|
||||
```python
|
||||
class MyComp(Component):
|
||||
class MyExtension(BaseExtensionClass):
|
||||
...
|
||||
```
|
||||
|
||||
This setting decides what the extension class will inherit from.
|
||||
"""
|
||||
|
||||
def __init_subclass__(cls) -> None:
|
||||
if not cls.name.isidentifier():
|
||||
raise ValueError(f"Extension name must be a valid Python identifier, got {cls.name}")
|
||||
if not cls.name.islower():
|
||||
raise ValueError(f"Extension name must be lowercase, got {cls.name}")
|
||||
|
||||
if not getattr(cls, "class_name", None):
|
||||
cls.class_name = snake_to_pascal(cls.name)
|
||||
|
||||
###########################
|
||||
# Component lifecycle hooks
|
||||
###########################
|
||||
|
||||
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
|
||||
"""
|
||||
Called when a new [`Component`](../api#django_components.Component) class is created.
|
||||
|
||||
This hook is called after the [`Component`](../api#django_components.Component) class
|
||||
is fully defined but before it's registered.
|
||||
|
||||
Use this hook to perform any initialization or validation of the
|
||||
[`Component`](../api#django_components.Component) class.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
from django_components import ComponentExtension, OnComponentClassCreatedContext
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
|
||||
# Add a new attribute to the Component class
|
||||
ctx.component_cls.my_attr = "my_value"
|
||||
```
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
|
||||
"""
|
||||
Called when a [`Component`](../api#django_components.Component) class is being deleted.
|
||||
|
||||
This hook is called before the [`Component`](../api#django_components.Component) class
|
||||
is deleted from memory.
|
||||
|
||||
Use this hook to perform any cleanup related to the [`Component`](../api#django_components.Component) class.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
from django_components import ComponentExtension, OnComponentClassDeletedContext
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
|
||||
# Remove Component class from the extension's cache on deletion
|
||||
self.cache.pop(ctx.component_cls, None)
|
||||
```
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_registry_created(self, ctx: OnRegistryCreatedContext) -> None:
|
||||
"""
|
||||
Called when a new [`ComponentRegistry`](../api#django_components.ComponentRegistry) is created.
|
||||
|
||||
This hook is called after a new
|
||||
[`ComponentRegistry`](../api#django_components.ComponentRegistry) instance is initialized.
|
||||
|
||||
Use this hook to perform any initialization needed for the registry.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
from django_components import ComponentExtension, OnRegistryCreatedContext
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
def on_registry_created(self, ctx: OnRegistryCreatedContext) -> None:
|
||||
# Add a new attribute to the registry
|
||||
ctx.registry.my_attr = "my_value"
|
||||
```
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_registry_deleted(self, ctx: OnRegistryDeletedContext) -> None:
|
||||
"""
|
||||
Called when a [`ComponentRegistry`](../api#django_components.ComponentRegistry) is being deleted.
|
||||
|
||||
This hook is called before
|
||||
a [`ComponentRegistry`](../api#django_components.ComponentRegistry) instance is deleted.
|
||||
|
||||
Use this hook to perform any cleanup related to the registry.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
from django_components import ComponentExtension, OnRegistryDeletedContext
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
def on_registry_deleted(self, ctx: OnRegistryDeletedContext) -> None:
|
||||
# Remove registry from the extension's cache on deletion
|
||||
self.cache.pop(ctx.registry, None)
|
||||
```
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_component_registered(self, ctx: OnComponentRegisteredContext) -> None:
|
||||
"""
|
||||
Called when a [`Component`](../api#django_components.Component) class is
|
||||
registered with a [`ComponentRegistry`](../api#django_components.ComponentRegistry).
|
||||
|
||||
This hook is called after a [`Component`](../api#django_components.Component) class
|
||||
is successfully registered.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
from django_components import ComponentExtension, OnComponentRegisteredContext
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
def on_component_registered(self, ctx: OnComponentRegisteredContext) -> None:
|
||||
print(f"Component {ctx.component_cls} registered to {ctx.registry} as '{ctx.name}'")
|
||||
```
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_component_unregistered(self, ctx: OnComponentUnregisteredContext) -> None:
|
||||
"""
|
||||
Called when a [`Component`](../api#django_components.Component) class is
|
||||
unregistered from a [`ComponentRegistry`](../api#django_components.ComponentRegistry).
|
||||
|
||||
This hook is called after a [`Component`](../api#django_components.Component) class
|
||||
is removed from the registry.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
from django_components import ComponentExtension, OnComponentUnregisteredContext
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
def on_component_unregistered(self, ctx: OnComponentUnregisteredContext) -> None:
|
||||
print(f"Component {ctx.component_cls} unregistered from {ctx.registry} as '{ctx.name}'")
|
||||
```
|
||||
"""
|
||||
pass
|
||||
|
||||
###########################
|
||||
# Component render hooks
|
||||
###########################
|
||||
|
||||
def on_component_input(self, ctx: OnComponentInputContext) -> None:
|
||||
"""
|
||||
Called when a [`Component`](../api#django_components.Component) was triggered to render,
|
||||
but before a component's context and data methods are invoked.
|
||||
|
||||
This hook is called before
|
||||
[`Component.get_context_data()`](../api#django_components.Component.get_context_data),
|
||||
[`Component.get_js_data()`](../api#django_components.Component.get_js_data)
|
||||
and [`Component.get_css_data()`](../api#django_components.Component.get_css_data).
|
||||
|
||||
Use this hook to modify or validate component inputs before they're processed.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
from django_components import ComponentExtension, OnComponentInputContext
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
def on_component_input(self, ctx: OnComponentInputContext) -> None:
|
||||
# Add extra kwarg to all components when they are rendered
|
||||
ctx.kwargs["my_input"] = "my_value"
|
||||
```
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_component_data(self, ctx: OnComponentDataContext) -> None:
|
||||
"""
|
||||
Called when a Component was triggered to render, after a component's context
|
||||
and data methods have been processed.
|
||||
|
||||
This hook is called after
|
||||
[`Component.get_context_data()`](../api#django_components.Component.get_context_data),
|
||||
[`Component.get_js_data()`](../api#django_components.Component.get_js_data)
|
||||
and [`Component.get_css_data()`](../api#django_components.Component.get_css_data).
|
||||
|
||||
This hook runs after [`on_component_input`](../api#django_components.ComponentExtension.on_component_input).
|
||||
|
||||
Use this hook to modify or validate the component's data before rendering.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
from django_components import ComponentExtension, OnComponentDataContext
|
||||
|
||||
class MyExtension(ComponentExtension):
|
||||
def on_component_data(self, ctx: OnComponentDataContext) -> None:
|
||||
# Add extra template variable to all components when they are rendered
|
||||
ctx.context_data["my_template_var"] = "my_value"
|
||||
```
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# Decorator to store events in `ExtensionManager._events` when django_components is not yet initialized.
|
||||
def store_events(func: TCallable) -> TCallable:
|
||||
fn_name = func.__name__
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self: "ExtensionManager", ctx: Any) -> Any:
|
||||
if not self._initialized:
|
||||
self._events.append((fn_name, ctx))
|
||||
return
|
||||
|
||||
return func(self, ctx)
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
|
||||
# Manage all extensions from a single place
|
||||
class ExtensionManager:
|
||||
###########################
|
||||
# Internal
|
||||
###########################
|
||||
|
||||
_initialized = False
|
||||
_events: List[Tuple[str, Any]] = []
|
||||
|
||||
@property
|
||||
def extensions(self) -> List[ComponentExtension]:
|
||||
return app_settings.EXTENSIONS
|
||||
|
||||
def _init_component_class(self, component_cls: Type["Component"]) -> None:
|
||||
# If not yet initialized, this class will be initialized later once we run `_init_app`
|
||||
if not self._initialized:
|
||||
return
|
||||
|
||||
for extension in self.extensions:
|
||||
ext_class_name = extension.class_name
|
||||
|
||||
# If a Component class has an extension class, e.g.
|
||||
# ```python
|
||||
# class MyComp(Component):
|
||||
# class MyExtension:
|
||||
# ...
|
||||
# ```
|
||||
# then create a dummy class to make `MyComp.MyExtension` extend
|
||||
# the base class `extension.ExtensionClass`.
|
||||
#
|
||||
# So it will be same as if the user had directly inherited from `extension.ExtensionClass`.
|
||||
# ```python
|
||||
# class MyComp(Component):
|
||||
# class MyExtension(MyExtension.ExtensionClass):
|
||||
# ...
|
||||
# ```
|
||||
component_ext_subclass = getattr(component_cls, ext_class_name, None)
|
||||
|
||||
# Add escape hatch, so that user can override the extension class
|
||||
# from within the component class. E.g.:
|
||||
# ```python
|
||||
# class MyExtDifferentStillSame(MyExtension.ExtensionClass):
|
||||
# ...
|
||||
#
|
||||
# class MyComp(Component):
|
||||
# my_extension_class = MyExtDifferentStillSame
|
||||
# class MyExtension:
|
||||
# ...
|
||||
# ```
|
||||
#
|
||||
# Will be effectively the same as:
|
||||
# ```python
|
||||
# class MyComp(Component):
|
||||
# class MyExtension(MyExtDifferentStillSame):
|
||||
# ...
|
||||
# ```
|
||||
ext_class_override_attr = extension.name + "_class" # "my_extension_class"
|
||||
ext_base_class = getattr(component_cls, ext_class_override_attr, extension.ExtensionClass)
|
||||
|
||||
if component_ext_subclass:
|
||||
bases: tuple[Type, ...] = (component_ext_subclass, ext_base_class)
|
||||
else:
|
||||
bases = (ext_base_class,)
|
||||
component_ext_subclass = type(ext_class_name, bases, {})
|
||||
|
||||
# Finally, reassign the new class extension class on the component class.
|
||||
setattr(component_cls, ext_class_name, component_ext_subclass)
|
||||
|
||||
def _init_component_instance(self, component: "Component") -> None:
|
||||
# Each extension has different class defined nested on the Component class:
|
||||
# ```python
|
||||
# class MyComp(Component):
|
||||
# class MyExtension:
|
||||
# ...
|
||||
# class MyOtherExtension:
|
||||
# ...
|
||||
# ```
|
||||
#
|
||||
# We instantiate them all, passing the component instance to each. These are then
|
||||
# available under the extension name on the component instance.
|
||||
# ```python
|
||||
# component.my_extension
|
||||
# component.my_other_extension
|
||||
# ```
|
||||
for extension in self.extensions:
|
||||
# NOTE: `_init_component_class` creates extension-specific nested classes
|
||||
# on the created component classes, e.g.:
|
||||
# ```py
|
||||
# class MyComp(Component):
|
||||
# class MyExtension:
|
||||
# ...
|
||||
# ```
|
||||
# It should NOT happen in production, but in tests it may happen, if some extensions
|
||||
# are test-specific, then the built-in component classes (like DynamicComponent) will
|
||||
# be initialized BEFORE the extension is set in the settings. As such, they will be missing
|
||||
# the nested class. In that case, we retroactively create the extension-specific nested class,
|
||||
# so that we may proceed.
|
||||
if not hasattr(component, extension.class_name):
|
||||
self._init_component_class(component.__class__)
|
||||
|
||||
used_ext_class = getattr(component, extension.class_name)
|
||||
extension_instance = used_ext_class(component)
|
||||
setattr(component, extension.name, extension_instance)
|
||||
|
||||
# The triggers for following hooks may occur before the `apps.py` `ready()` hook is called.
|
||||
# - on_component_class_created
|
||||
# - on_component_class_deleted
|
||||
# - on_registry_created
|
||||
# - on_registry_deleted
|
||||
# - on_component_registered
|
||||
# - on_component_unregistered
|
||||
#
|
||||
# The problem is that the extensions are set up only at the initialization (`ready()` hook in `apps.py`).
|
||||
#
|
||||
# So in the case that these hooks are triggered before initialization,
|
||||
# we store these "events" in a list, and then "flush" them all when `ready()` is called.
|
||||
#
|
||||
# This way, we can ensure that all extensions are present before any hooks are called.
|
||||
def _init_app(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
|
||||
for hook, data in self._events:
|
||||
if hook == "on_component_class_created":
|
||||
on_component_created_data: OnComponentClassCreatedContext = data
|
||||
self._init_component_class(on_component_created_data.component_cls)
|
||||
getattr(self, hook)(data)
|
||||
self._events = []
|
||||
|
||||
#############################
|
||||
# Component lifecycle hooks
|
||||
#############################
|
||||
|
||||
@store_events
|
||||
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
|
||||
for extension in self.extensions:
|
||||
extension.on_component_class_created(ctx)
|
||||
|
||||
@store_events
|
||||
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
|
||||
for extension in self.extensions:
|
||||
extension.on_component_class_deleted(ctx)
|
||||
|
||||
@store_events
|
||||
def on_registry_created(self, ctx: OnRegistryCreatedContext) -> None:
|
||||
for extension in self.extensions:
|
||||
extension.on_registry_created(ctx)
|
||||
|
||||
@store_events
|
||||
def on_registry_deleted(self, ctx: OnRegistryDeletedContext) -> None:
|
||||
for extension in self.extensions:
|
||||
extension.on_registry_deleted(ctx)
|
||||
|
||||
@store_events
|
||||
def on_component_registered(self, ctx: OnComponentRegisteredContext) -> None:
|
||||
for extension in self.extensions:
|
||||
extension.on_component_registered(ctx)
|
||||
|
||||
@store_events
|
||||
def on_component_unregistered(self, ctx: OnComponentUnregisteredContext) -> None:
|
||||
for extension in self.extensions:
|
||||
extension.on_component_unregistered(ctx)
|
||||
|
||||
###########################
|
||||
# Component render hooks
|
||||
###########################
|
||||
|
||||
def on_component_input(self, ctx: OnComponentInputContext) -> None:
|
||||
for extension in self.extensions:
|
||||
extension.on_component_input(ctx)
|
||||
|
||||
def on_component_data(self, ctx: OnComponentDataContext) -> None:
|
||||
for extension in self.extensions:
|
||||
extension.on_component_data(ctx)
|
||||
|
||||
|
||||
# NOTE: This is a singleton which is takes the extensions from `app_settings.EXTENSIONS`
|
||||
extensions = ExtensionManager()
|
0
src/django_components/extensions/__init__.py
Normal file
0
src/django_components/extensions/__init__.py
Normal file
80
src/django_components/extensions/view.py
Normal file
80
src/django_components/extensions/view.py
Normal file
|
@ -0,0 +1,80 @@
|
|||
from typing import TYPE_CHECKING, Any, Protocol, cast
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.views.generic import View
|
||||
|
||||
from django_components.extension import BaseExtensionClass, ComponentExtension
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django_components.component import Component
|
||||
|
||||
|
||||
class ViewFn(Protocol):
|
||||
def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704
|
||||
|
||||
|
||||
class ComponentView(BaseExtensionClass, View):
|
||||
"""
|
||||
Subclass of `django.views.View` where the `Component` instance is available
|
||||
via `self.component`.
|
||||
"""
|
||||
|
||||
# NOTE: This attribute must be declared on the class for `View.as_view()` to allow
|
||||
# us to pass `component` kwarg.
|
||||
component = cast("Component", None)
|
||||
|
||||
def __init__(self, component: "Component", **kwargs: Any) -> None:
|
||||
BaseExtensionClass.__init__(self, component)
|
||||
View.__init__(self, **kwargs)
|
||||
|
||||
# NOTE: The methods below are defined to satisfy the `View` class. All supported methods
|
||||
# are defined in `View.http_method_names`.
|
||||
#
|
||||
# Each method actually delegates to the component's method of the same name.
|
||||
# E.g. When `get()` is called, it delegates to `component.get()`.
|
||||
|
||||
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
component: "Component" = self.component
|
||||
return getattr(component, "get")(request, *args, **kwargs)
|
||||
|
||||
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
component: "Component" = self.component
|
||||
return getattr(component, "post")(request, *args, **kwargs)
|
||||
|
||||
def put(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
component: "Component" = self.component
|
||||
return getattr(component, "put")(request, *args, **kwargs)
|
||||
|
||||
def patch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
component: "Component" = self.component
|
||||
return getattr(component, "patch")(request, *args, **kwargs)
|
||||
|
||||
def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
component: "Component" = self.component
|
||||
return getattr(component, "delete")(request, *args, **kwargs)
|
||||
|
||||
def head(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
component: "Component" = self.component
|
||||
return getattr(component, "head")(request, *args, **kwargs)
|
||||
|
||||
def options(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
component: "Component" = self.component
|
||||
return getattr(component, "options")(request, *args, **kwargs)
|
||||
|
||||
def trace(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
component: "Component" = self.component
|
||||
return getattr(component, "trace")(request, *args, **kwargs)
|
||||
|
||||
|
||||
class ViewExtension(ComponentExtension):
|
||||
"""
|
||||
This extension adds a nested `View` class to each `Component`.
|
||||
This nested class is a subclass of `django.views.View`, and allows the component
|
||||
to be used as a view by calling `ComponentView.as_view()`.
|
||||
|
||||
This extension is automatically added to all components.
|
||||
"""
|
||||
|
||||
name = "view"
|
||||
|
||||
ExtensionClass = ComponentView
|
|
@ -28,6 +28,10 @@ def is_str_wrapped_in_quotes(s: str) -> bool:
|
|||
return s.startswith(('"', "'")) and s[0] == s[-1] and len(s) >= 2
|
||||
|
||||
|
||||
def snake_to_pascal(name: str) -> str:
|
||||
return "".join(word.title() for word in name.split("_"))
|
||||
|
||||
|
||||
def is_identifier(value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import gc
|
||||
import inspect
|
||||
import sys
|
||||
from functools import wraps
|
||||
|
@ -12,6 +13,7 @@ from django.test import override_settings
|
|||
from django_components.component import ALL_COMPONENTS, Component, component_node_subclasses_by_name
|
||||
from django_components.component_media import ComponentMedia
|
||||
from django_components.component_registry import ALL_REGISTRIES, ComponentRegistry
|
||||
from django_components.extension import extensions
|
||||
|
||||
# NOTE: `ReferenceType` is NOT a generic pre-3.9
|
||||
if sys.version_info >= (3, 9):
|
||||
|
@ -100,6 +102,7 @@ def djc_test(
|
|||
],
|
||||
]
|
||||
] = None,
|
||||
gc_collect: bool = True,
|
||||
) -> Callable:
|
||||
"""
|
||||
Decorator for testing components from django-components.
|
||||
|
@ -237,6 +240,9 @@ def djc_test(
|
|||
...
|
||||
```
|
||||
|
||||
- `gc_collect`: By default `djc_test` runs garbage collection after each test to force the state cleanup.
|
||||
Set this to `False` to skip this.
|
||||
|
||||
**Settings resolution:**
|
||||
|
||||
`@djc_test` accepts settings from different sources. The settings are resolved in the following order:
|
||||
|
@ -328,6 +334,7 @@ def djc_test(
|
|||
csrf_token_patcher,
|
||||
_ALL_COMPONENTS, # type: ignore[arg-type]
|
||||
_ALL_REGISTRIES_COPIES,
|
||||
gc_collect,
|
||||
)
|
||||
|
||||
try:
|
||||
|
@ -431,6 +438,7 @@ def _setup_djc_global_state(
|
|||
from django_components.app_settings import app_settings
|
||||
|
||||
app_settings._load_settings()
|
||||
extensions._init_app()
|
||||
|
||||
|
||||
def _clear_djc_global_state(
|
||||
|
@ -438,6 +446,7 @@ def _clear_djc_global_state(
|
|||
csrf_token_patcher: CsrfTokenPatcher,
|
||||
initial_components: InitialComponents,
|
||||
initial_registries_copies: RegistriesCopies,
|
||||
gc_collect: bool = False,
|
||||
) -> None:
|
||||
gen_id_patcher.stop()
|
||||
csrf_token_patcher.stop()
|
||||
|
@ -485,14 +494,16 @@ def _clear_djc_global_state(
|
|||
for index in range(all_comps_len):
|
||||
reverse_index = all_comps_len - index - 1
|
||||
comp_cls_ref = ALL_COMPONENTS[reverse_index]
|
||||
if comp_cls_ref not in initial_components_set:
|
||||
is_ref_deleted = comp_cls_ref() is None
|
||||
if is_ref_deleted or comp_cls_ref not in initial_components_set:
|
||||
del ALL_COMPONENTS[reverse_index]
|
||||
|
||||
# Remove registries that were created during the test
|
||||
initial_registries_set: Set[RegistryRef] = set([reg_ref for reg_ref, init_keys in initial_registries_copies])
|
||||
for index in range(len(ALL_REGISTRIES)):
|
||||
registry_ref = ALL_REGISTRIES[len(ALL_REGISTRIES) - index - 1]
|
||||
if registry_ref not in initial_registries_set:
|
||||
is_ref_deleted = registry_ref() is None
|
||||
if is_ref_deleted or registry_ref not in initial_registries_set:
|
||||
del ALL_REGISTRIES[len(ALL_REGISTRIES) - index - 1]
|
||||
|
||||
# For the remaining registries, unregistr components that were registered
|
||||
|
@ -521,5 +532,11 @@ def _clear_djc_global_state(
|
|||
sys.modules.pop(mod, None)
|
||||
LOADED_MODULES.clear()
|
||||
|
||||
# Force garbage collection, so that any finalizers are run.
|
||||
# If garbage collection is skipped, then in some cases the finalizers
|
||||
# are run too late, in the context of the next test, causing flaky tests.
|
||||
if gc_collect:
|
||||
gc.collect()
|
||||
|
||||
global IS_TESTING
|
||||
IS_TESTING = False
|
||||
|
|
|
@ -2,7 +2,7 @@ import sys
|
|||
from typing import Any, Dict, TypeVar, overload
|
||||
from weakref import ReferenceType, finalize, ref
|
||||
|
||||
GLOBAL_REFS: Dict[Any, ReferenceType] = {}
|
||||
GLOBAL_REFS: Dict[int, ReferenceType] = {}
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
@ -16,14 +16,15 @@ if sys.version_info >= (3, 9):
|
|||
|
||||
def cached_ref(obj: Any) -> ReferenceType:
|
||||
"""
|
||||
Same as `weakref.ref()`, creating a weak reference to a given objet.
|
||||
Same as `weakref.ref()`, creating a weak reference to a given object.
|
||||
But unlike `weakref.ref()`, this function also caches the result,
|
||||
so it returns the same reference for the same object.
|
||||
"""
|
||||
if obj not in GLOBAL_REFS:
|
||||
GLOBAL_REFS[obj] = ref(obj)
|
||||
obj_id = id(obj)
|
||||
if obj_id not in GLOBAL_REFS:
|
||||
GLOBAL_REFS[obj_id] = ref(obj)
|
||||
|
||||
# Remove this entry from GLOBAL_REFS when the object is deleted.
|
||||
finalize(obj, lambda: GLOBAL_REFS.pop(obj))
|
||||
finalize(obj, lambda: GLOBAL_REFS.pop(obj_id, None))
|
||||
|
||||
return GLOBAL_REFS[obj]
|
||||
return GLOBAL_REFS[obj_id]
|
||||
|
|
238
tests/test_extension.py
Normal file
238
tests/test_extension.py
Normal file
|
@ -0,0 +1,238 @@
|
|||
import gc
|
||||
from typing import Any, Callable, Dict, List, cast
|
||||
|
||||
from django.template import Context
|
||||
|
||||
from django_components import Component, Slot, register, registry
|
||||
from django_components.app_settings import app_settings
|
||||
from django_components.component_registry import ComponentRegistry
|
||||
from django_components.extension import (
|
||||
ComponentExtension,
|
||||
OnComponentClassCreatedContext,
|
||||
OnComponentClassDeletedContext,
|
||||
OnRegistryCreatedContext,
|
||||
OnRegistryDeletedContext,
|
||||
OnComponentRegisteredContext,
|
||||
OnComponentUnregisteredContext,
|
||||
OnComponentInputContext,
|
||||
OnComponentDataContext,
|
||||
)
|
||||
from django_components.extensions.view import ViewExtension
|
||||
|
||||
from django_components.testing import djc_test
|
||||
from .testutils import setup_test_config
|
||||
|
||||
setup_test_config({"autodiscover": False})
|
||||
|
||||
|
||||
class DummyExtension(ComponentExtension):
|
||||
"""
|
||||
Test extension that tracks all hook calls and their arguments.
|
||||
"""
|
||||
|
||||
name = "test_extension"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: Dict[str, List[Any]] = {
|
||||
"on_component_class_created": [],
|
||||
"on_component_class_deleted": [],
|
||||
"on_registry_created": [],
|
||||
"on_registry_deleted": [],
|
||||
"on_component_registered": [],
|
||||
"on_component_unregistered": [],
|
||||
"on_component_input": [],
|
||||
"on_component_data": [],
|
||||
}
|
||||
|
||||
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
|
||||
# NOTE: Store only component name to avoid strong references
|
||||
self.calls["on_component_class_created"].append(ctx.component_cls.__name__)
|
||||
|
||||
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
|
||||
# NOTE: Store only component name to avoid strong references
|
||||
self.calls["on_component_class_deleted"].append(ctx.component_cls.__name__)
|
||||
|
||||
def on_registry_created(self, ctx: OnRegistryCreatedContext) -> None:
|
||||
# NOTE: Store only registry object ID to avoid strong references
|
||||
self.calls["on_registry_created"].append(id(ctx.registry))
|
||||
|
||||
def on_registry_deleted(self, ctx: OnRegistryDeletedContext) -> None:
|
||||
# NOTE: Store only registry object ID to avoid strong references
|
||||
self.calls["on_registry_deleted"].append(id(ctx.registry))
|
||||
|
||||
def on_component_registered(self, ctx: OnComponentRegisteredContext) -> None:
|
||||
self.calls["on_component_registered"].append(ctx)
|
||||
|
||||
def on_component_unregistered(self, ctx: OnComponentUnregisteredContext) -> None:
|
||||
self.calls["on_component_unregistered"].append(ctx)
|
||||
|
||||
def on_component_input(self, ctx: OnComponentInputContext) -> None:
|
||||
self.calls["on_component_input"].append(ctx)
|
||||
|
||||
def on_component_data(self, ctx: OnComponentDataContext) -> None:
|
||||
self.calls["on_component_data"].append(ctx)
|
||||
|
||||
|
||||
def with_component_cls(on_created: Callable):
|
||||
class TestComponent(Component):
|
||||
template = "Hello {{ name }}!"
|
||||
|
||||
def get_context_data(self, name="World"):
|
||||
return {"name": name}
|
||||
|
||||
on_created()
|
||||
|
||||
|
||||
def with_registry(on_created: Callable):
|
||||
registry = ComponentRegistry()
|
||||
|
||||
on_created(registry)
|
||||
|
||||
|
||||
@djc_test
|
||||
class TestExtension:
|
||||
@djc_test(
|
||||
components_settings={"extensions": [DummyExtension]}
|
||||
)
|
||||
def test_extensios_setting(self):
|
||||
assert len(app_settings.EXTENSIONS) == 2
|
||||
assert isinstance(app_settings.EXTENSIONS[0], ViewExtension)
|
||||
assert isinstance(app_settings.EXTENSIONS[1], DummyExtension)
|
||||
|
||||
@djc_test(
|
||||
components_settings={"extensions": [DummyExtension]}
|
||||
)
|
||||
def test_component_class_lifecycle_hooks(self):
|
||||
extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
|
||||
|
||||
assert len(extension.calls["on_component_class_created"]) == 0
|
||||
assert len(extension.calls["on_component_class_deleted"]) == 0
|
||||
|
||||
did_call_on_comp_cls_created = False
|
||||
|
||||
def on_comp_cls_created():
|
||||
nonlocal did_call_on_comp_cls_created
|
||||
did_call_on_comp_cls_created = True
|
||||
|
||||
# Verify on_component_class_created was called
|
||||
assert len(extension.calls["on_component_class_created"]) == 1
|
||||
assert extension.calls["on_component_class_created"][0] == "TestComponent"
|
||||
|
||||
# Create a component class in a separate scope, to avoid any references from within
|
||||
# this test function, so we can garbage collect it after the function returns
|
||||
with_component_cls(on_comp_cls_created)
|
||||
assert did_call_on_comp_cls_created
|
||||
|
||||
# This should trigger the garbage collection of the component class
|
||||
gc.collect()
|
||||
|
||||
# Verify on_component_class_deleted was called
|
||||
assert len(extension.calls["on_component_class_deleted"]) == 1
|
||||
assert extension.calls["on_component_class_deleted"][0] == "TestComponent"
|
||||
|
||||
@djc_test(
|
||||
components_settings={"extensions": [DummyExtension]}
|
||||
)
|
||||
def test_registry_lifecycle_hooks(self):
|
||||
extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
|
||||
|
||||
assert len(extension.calls["on_registry_created"]) == 0
|
||||
assert len(extension.calls["on_registry_deleted"]) == 0
|
||||
|
||||
did_call_on_registry_created = False
|
||||
reg_id = None
|
||||
|
||||
def on_registry_created(reg):
|
||||
nonlocal did_call_on_registry_created
|
||||
nonlocal reg_id
|
||||
did_call_on_registry_created = True
|
||||
reg_id = id(reg)
|
||||
|
||||
# Verify on_registry_created was called
|
||||
assert len(extension.calls["on_registry_created"]) == 1
|
||||
assert extension.calls["on_registry_created"][0] == reg_id
|
||||
|
||||
with_registry(on_registry_created)
|
||||
assert did_call_on_registry_created
|
||||
assert reg_id is not None
|
||||
|
||||
gc.collect()
|
||||
|
||||
# Verify on_registry_deleted was called
|
||||
assert len(extension.calls["on_registry_deleted"]) == 1
|
||||
assert extension.calls["on_registry_deleted"][0] == reg_id
|
||||
|
||||
@djc_test(
|
||||
components_settings={"extensions": [DummyExtension]}
|
||||
)
|
||||
def test_component_registration_hooks(self):
|
||||
class TestComponent(Component):
|
||||
template = "Hello {{ name }}!"
|
||||
|
||||
def get_context_data(self, name="World"):
|
||||
return {"name": name}
|
||||
|
||||
registry.register("test_comp", TestComponent)
|
||||
extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
|
||||
|
||||
# Verify on_component_registered was called
|
||||
assert len(extension.calls["on_component_registered"]) == 1
|
||||
reg_call: OnComponentRegisteredContext = extension.calls["on_component_registered"][0]
|
||||
assert reg_call.registry == registry
|
||||
assert reg_call.name == "test_comp"
|
||||
assert reg_call.component_cls == TestComponent
|
||||
|
||||
registry.unregister("test_comp")
|
||||
|
||||
# Verify on_component_unregistered was called
|
||||
assert len(extension.calls["on_component_unregistered"]) == 1
|
||||
unreg_call: OnComponentUnregisteredContext = extension.calls["on_component_unregistered"][0]
|
||||
assert unreg_call.registry == registry
|
||||
assert unreg_call.name == "test_comp"
|
||||
assert unreg_call.component_cls == TestComponent
|
||||
|
||||
@djc_test(
|
||||
components_settings={"extensions": [DummyExtension]}
|
||||
)
|
||||
def test_component_render_hooks(self):
|
||||
@register("test_comp")
|
||||
class TestComponent(Component):
|
||||
template = "Hello {{ name }}!"
|
||||
|
||||
def get_context_data(self, arg1, arg2, name="World"):
|
||||
return {"name": name}
|
||||
|
||||
def get_js_data(self, *args, **kwargs):
|
||||
return {"script": "console.log('Hello!')"}
|
||||
|
||||
def get_css_data(self, *args, **kwargs):
|
||||
return {"style": "body { color: blue; }"}
|
||||
|
||||
# Render the component with some args and kwargs
|
||||
test_context = Context({"foo": "bar"})
|
||||
test_slots = {"content": "Some content"}
|
||||
TestComponent.render(
|
||||
context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots
|
||||
)
|
||||
|
||||
extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
|
||||
|
||||
# Verify on_component_input was called with correct args
|
||||
assert len(extension.calls["on_component_input"]) == 1
|
||||
input_call: OnComponentInputContext = extension.calls["on_component_input"][0]
|
||||
assert input_call.component_cls == TestComponent
|
||||
assert isinstance(input_call.component_id, str)
|
||||
assert input_call.args == ("arg1", "arg2")
|
||||
assert input_call.kwargs == {"name": "Test"}
|
||||
assert len(input_call.slots) == 1
|
||||
assert isinstance(input_call.slots["content"], Slot)
|
||||
assert input_call.context == test_context
|
||||
|
||||
# Verify on_component_data was called with correct args
|
||||
assert len(extension.calls["on_component_data"]) == 1
|
||||
data_call: OnComponentDataContext = extension.calls["on_component_data"][0]
|
||||
assert data_call.component_cls == TestComponent
|
||||
assert isinstance(data_call.component_id, str)
|
||||
assert data_call.context_data == {"name": "Test"}
|
||||
assert data_call.js_data == {"script": "console.log('Hello!')"}
|
||||
assert data_call.css_data == {"style": "body { color: blue; }"}
|
Loading…
Add table
Add a link
Reference in a new issue