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

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