feat: extensions (#1009)

* feat: extensions

* refactor: remove support for passing in extensions as instances
This commit is contained in:
Juro Oravec 2025-03-08 09:41:28 +01:00 committed by GitHub
parent cff252c566
commit 4d35bc97a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1884 additions and 57 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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

View 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.

View file

@ -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.

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -0,0 +1,5 @@
# Extension Hooks
Overview of all the extension hooks available in Django Components.
Read more on [Extensions](../../concepts/advanced/extensions).

View file

@ -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",

View file

@ -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]

View file

@ -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:

View file

@ -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:

View file

@ -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)

View 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()

View 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

View file

@ -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

View file

@ -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

View file

@ -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
View 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; }"}