feat: extension defaults + docs + API cleanup (#1215)

This commit is contained in:
Juro Oravec 2025-05-26 23:36:19 +02:00 committed by GitHub
parent 7df8019544
commit bb129aefab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 858 additions and 144 deletions

View file

@ -506,6 +506,65 @@ Summary:
ctx.template_data["my_template_var"] = "my_value"
```
- When creating extensions, the `ComponentExtension.ExtensionClass` attribute was renamed to `ComponentConfig`.
The old name is deprecated and will be removed in v1.
Before:
```py
from django_components import ComponentExtension
class MyExtension(ComponentExtension):
class ExtensionClass(ComponentExtension.ComponentConfig):
pass
```
After:
```py
from django_components import ComponentExtension, ExtensionComponentConfig
class MyExtension(ComponentExtension):
class ComponentConfig(ExtensionComponentConfig):
pass
```
- When creating extensions, to access the Component class from within the methods of the extension nested classes,
use `component_cls`.
Previously this field was named `component_class`. The old name is deprecated and will be removed in v1.
`ComponentExtension.ExtensionClass` attribute was renamed to `ComponentConfig`.
The old name is deprecated and will be removed in v1.
Before:
```py
from django_components import ComponentExtension, ExtensionComponentConfig
class LoggerExtension(ComponentExtension):
name = "logger"
class ComponentConfig(ExtensionComponentConfig):
def log(self, msg: str) -> None:
print(f"{self.component_class.__name__}: {msg}")
```
After:
```py
from django_components import ComponentExtension, ExtensionComponentConfig
class LoggerExtension(ComponentExtension):
name = "logger"
class ComponentConfig(ExtensionComponentConfig):
def log(self, msg: str) -> None:
print(f"{self.component_cls.__name__}: {msg}")
```
**Slots**
- `SlotContent` was renamed to `SlotInput`. The old name is deprecated and will be removed in v1.
@ -590,6 +649,47 @@ Summary:
NOTE: `Component.is_filled` automatically escaped slot names, so that even slot names that are
not valid python identifiers could be set as slot names. `Component.slots` no longer does that.
**Miscellaneous**
- The `debug_highlight_components` and `debug_highlight_slots` settings are deprecated.
These will be removed in v1.
The debug highlighting feature was re-implemented as an extension.
As such, the recommended way for enabling it has changed:
Before:
```python
COMPONENTS = ComponentsSettings(
debug_highlight_components=True,
debug_highlight_slots=True,
)
```
After:
Set `extensions_defaults` in your `settings.py` file.
```python
COMPONENTS = ComponentsSettings(
extensions_defaults={
"debug_highlight": {
"highlight_components": True,
"highlight_slots": True,
},
},
)
```
Alternatively, you can enable highlighting for specific components by setting `Component.DebugHighlight.highlight_components` to `True`:
```python
class MyComponent(Component):
class DebugHighlight:
highlight_components = True
highlight_slots = True
```
#### Feat
- New method to render template variables - `get_template_data()`
@ -859,6 +959,31 @@ Summary:
See all [Extension hooks](https://django-components.github.io/django-components/0.140/reference/extension_hooks/).
- When creating extensions, the previous syntax with `ComponentExtension.ExtensionClass` was causing
Mypy errors, because Mypy doesn't allow using class attributes as bases:
Before:
```py
from django_components import ComponentExtension
class MyExtension(ComponentExtension):
class ExtensionClass(ComponentExtension.ComponentConfig): # Error!
pass
```
Instead, you can import `ExtensionComponentConfig` directly:
After:
```py
from django_components import ComponentExtension, ExtensionComponentConfig
class MyExtension(ComponentExtension):
class ComponentConfig(ExtensionComponentConfig):
pass
```
#### Refactor
- When a component is being rendered, a proper `Component` instance is now created.

View file

@ -10,7 +10,7 @@ Django-components functionality can be extended with "extensions". Extensions al
- [djc-ext-pydantic](https://github.com/django-components/djc-ext-pydantic)
## Setting up extensions
## Install extensions
Extensions are configured in the Django settings under [`COMPONENTS.extensions`](../../../reference/settings#django_components.app_settings.ComponentsSettings.extensions).
@ -22,7 +22,7 @@ Extensions can be set by either as an import string or by passing in a class:
class MyExtension(ComponentExtension):
name = "my_extension"
class ExtensionClass(ComponentExtension.ExtensionClass):
class ComponentConfig(ExtensionComponentConfig):
...
COMPONENTS = ComponentsSettings(
@ -47,7 +47,7 @@ Extensions can define methods to hook into lifecycle events, such as:
See the full list in [Extension Hooks Reference](../../../reference/extension_hooks).
## Configuring extensions per component
## Per-component configuration
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.
@ -84,18 +84,15 @@ You can override the `get()`, `post()`, etc methods to customize the behavior of
class MyTable(Component):
class View:
def get(self, request):
# TO BE IMPLEMENTED BY USER
# return self.component_cls.render_to_response(request=request)
raise NotImplementedError("You must implement the `get` method.")
return self.component_class.render_to_response(request=request)
def post(self, request):
# TO BE IMPLEMENTED BY USER
# return self.component_cls.render_to_response(request=request)
raise NotImplementedError("You must implement the `post` method.")
return self.component_class.render_to_response(request=request)
...
```
<!-- TODO - LINK TO IT ONCE RELEASED -->
### Example: Storybook integration
The Storybook integration (work in progress) is an extension that is configured by a `Storybook` nested class.
@ -122,12 +119,79 @@ class MyTable(Component):
...
```
## Accessing extensions in components
### Extension defaults
Extensions are incredibly flexible, but configuring the same extension for every component can be a pain.
For this reason, django-components allows for extension defaults. This is like setting the extension config for every component.
To set extension defaults, use the [`COMPONENTS.extensions_defaults`](../../../reference/settings#django_components.app_settings.ComponentsSettings.extensions_defaults) setting.
The `extensions_defaults` setting is a dictionary where the key is the extension name and the value is a dictionary of config attributes:
```python
COMPONENTS = ComponentsSettings(
extensions=[
"my_extension.MyExtension",
"storybook.StorybookExtension",
],
extensions_defaults={
"my_extension": {
"key": "value",
},
"view": {
"public": True,
},
"cache": {
"ttl": 60,
},
"storybook": {
"title": lambda self: self.component_cls.__name__,
},
},
)
```
Which is equivalent to setting the following for every component:
```python
class MyTable(Component):
class MyExtension:
key = "value"
class View:
public = True
class Cache:
ttl = 60
class Storybook:
def title(self):
return self.component_cls.__name__
```
!!! info
If you define an attribute as a function, it is like defining a method on the extension class.
E.g. in the example above, `title` is a method on the `Storybook` extension class.
As the name suggests, these are defaults, and so you can still selectively override them on a per-component basis:
```python
class MyTable(Component):
class View:
public = False
```
### Extensions in component instances
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.
Extensions are available under their names (e.g. `self.view`, `self.storybook`).
For example, the View extension is available as `self.view`:
```python
@ -150,23 +214,23 @@ class MyTable(Component):
}
```
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
### Extension class
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.
- The extension may implement any of the [hook methods](../../../reference/extension_hooks).
Each hook method receives a context object with relevant data.
- Extension may own [URLs](#extension-urls) or [CLI commands](#extension-commands).
```python
from django_components.extension import ComponentExtension, OnComponentClassCreatedContext
@ -187,9 +251,10 @@ class MyExtension(ComponentExtension):
So if you name an extension `render`, it will conflict with the [`render()`](../../../reference/api/#django_components.Component.render) method of the `Component` class.
### Defining the extension class
### Component config
In previous sections we've seen the `View` and `Storybook` extensions classes that were nested within the `Component` class:
In previous sections we've seen the `View` and `Storybook` extensions classes that were nested
within the [`Component`](../../../reference/api/#django_components.Component) class:
```python
class MyComponent(Component):
@ -202,36 +267,30 @@ class MyComponent(Component):
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).
Whether it's `Component.View` or `Component.Storybook`, their respective extensions
defined how these nested classes will behave.
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:
For example, the View extension defines the API that users may override in `ViewExtension.ComponentConfig`:
```python
from django_components.extension import ComponentExtension
from django_components.extension import ComponentExtension, ExtensionComponentConfig
class ViewExtension(ComponentExtension):
name = "view"
# The default behavior of the `View` extension class.
class ExtensionClass(ComponentExtension.ExtensionClass):
class ComponentConfig(ExtensionComponentConfig):
def get(self, request):
# TO BE IMPLEMENTED BY USER
# return self.component_cls.render_to_response(request=request)
raise NotImplementedError("You must implement the `get` method.")
def post(self, request):
# TO BE IMPLEMENTED BY USER
# return self.component_cls.render_to_response(request=request)
raise NotImplementedError("You must implement the `post` method.")
...
```
In any component that then defines a nested `View` extension class, the `View` extension class will actually
subclass from the `ViewExtension.ExtensionClass` class.
In any component that then defines a nested `Component.View` extension class, the resulting `View` class
will actually subclass from the `ViewExtension.ComponentConfig` class.
In other words, when you define a component like this:
@ -243,11 +302,11 @@ class MyTable(Component):
...
```
It will actually be implemented as if the `View` class subclassed from base class `ViewExtension.ExtensionClass`:
Behind the scenes it is as if you defined the following:
```python
class MyTable(Component):
class View(ViewExtension.ExtensionClass):
class View(ViewExtension.ComponentConfig):
def get(self, request):
# Do something
...
@ -255,13 +314,13 @@ class MyTable(Component):
!!! warning
When writing an extension, the `ExtensionClass` MUST subclass the base class [`ComponentExtension.ExtensionClass`](../../../reference/api/#django_components.ComponentExtension.ExtensionClass).
When writing an extension, the `ComponentConfig` MUST subclass the base class [`ExtensionComponentConfig`](../../../reference/api/#django_components.ExtensionComponentConfig).
This base class ensures that the extension class will have access to the component instance.
### Registering extensions
### Install your extension
Once the extension is defined, it needs to be registered in the Django settings to be used by the application.
Once the extension is defined, it needs to be installed in the Django settings to be used by the application.
Extensions can be given either as an extension class, or its import string:
@ -297,30 +356,30 @@ To tie it all together, here's an example of a custom logging extension that log
```python
from django_components.extension import (
ComponentExtension,
ExtensionComponentConfig,
OnComponentClassCreatedContext,
OnComponentClassDeletedContext,
OnComponentInputContext,
)
class ColorLoggerExtensionClass(ComponentExtension.ExtensionClass):
color: str
class ColorLoggerExtension(ComponentExtension):
name = "color_logger"
# All `Component.ColorLogger` classes will inherit from this class.
ExtensionClass = ColorLoggerExtensionClass
class ComponentConfig(ExtensionComponentConfig):
color: str
# 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:
# 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):
log.info(
f"Component {ctx.component_cls} created.",
color=ctx.component_cls.ColorLogger.color,
)
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext):
log.info(
f"Component {ctx.component_cls} deleted.",
color=ctx.component_cls.ColorLogger.color,
@ -328,7 +387,7 @@ class ColorLoggerExtension(ComponentExtension):
# 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:
def on_component_input(self, ctx: OnComponentInputContext):
log.info(
f"Rendering component {ctx.component_cls}.",
color=ctx.component.color_logger.color,
@ -346,7 +405,7 @@ COMPONENTS = {
}
```
Once registered, in any component, you can define a `ColorLogger` attribute:
Once installed, in any component, you can define a `ColorLogger` attribute:
```python
class MyComponent(Component):
@ -363,45 +422,38 @@ django-components provides a few utility functions to help with writing extensio
- [`all_components()`](../../../reference/api#django_components.all_components) - returns a list of all created component classes.
- [`all_registries()`](../../../reference/api#django_components.all_registries) - returns a list of all created registry instances.
### Accessing the component class from within an extension
### Access component class
When you are writing the extension class that will be nested inside a Component class, e.g.
You can access the owner [`Component`](../../../reference/api/#django_components.Component) class (`MyTable`) from within methods
of the extension class (`MyExtension`) by using
the [`component_cls`](../../../reference/api/#django_components.ExtensionComponentConfig.component_cls) attribute:
```py
class MyTable(Component):
class MyExtension:
def some_method(self):
...
print(self.component_cls)
```
You can access the owner Component class (`MyTable`) from within methods of the extension class (`MyExtension`) by using the `component_class` attribute:
```py
class MyTable(Component):
class MyExtension:
def some_method(self):
print(self.component_class)
```
Here is how the `component_class` attribute may be used with our `ColorLogger`
Here is how the `component_cls` attribute may be used with our `ColorLogger`
extension shown above:
```python
class ColorLoggerExtensionClass(ComponentExtension.ExtensionClass):
class ColorLoggerComponentConfig(ExtensionComponentConfig):
color: str
def log(self, msg: str) -> None:
print(f"{self.component_class.name}: {msg}")
print(f"{self.component_cls.__name__}: {msg}")
class ColorLoggerExtension(ComponentExtension):
name = "color_logger"
# All `Component.ColorLogger` classes will inherit from this class.
ExtensionClass = ColorLoggerExtensionClass
ComponentConfig = ColorLoggerComponentConfig
```
## Extension Commands
## Extension commands
Extensions in django-components can define custom commands that can be executed via the Django management command interface. This allows for powerful automation and customization capabilities.
@ -418,7 +470,7 @@ Where:
- `my_ext` - is the extension name
- `hello` - is the command name
### Defining Commands
### Define commands
To define a command, subclass from [`ComponentCommand`](../../../reference/extension_commands#django_components.ComponentCommand).
This subclass should define:
@ -442,7 +494,7 @@ class MyExt(ComponentExtension):
commands = [HelloCommand]
```
### Defining Command Arguments and Options
### Define arguments and options
Commands can accept positional arguments and options (e.g. `--foo`), which are defined using the
[`arguments`](../../../reference/extension_commands#django_components.ComponentCommand.arguments)
@ -507,7 +559,7 @@ python manage.py components ext run my_ext hello John --shout
If a command doesn't have the [`handle`](../../../reference/extension_commands#django_components.ComponentCommand.handle)
method defined, the command will print a help message and exit.
### Grouping Arguments
### Argument groups
Arguments can be grouped using [`CommandArgGroup`](../../../reference/extension_commands#django_components.CommandArgGroup)
to provide better organization and help messages.
@ -626,7 +678,7 @@ python manage.py components ext run parent child
python manage.py components ext run parent child --foo --bar
```
### Print command help
### Help message
By default, all commands will print their help message when run with the `--help` / `-h` flag.
@ -636,7 +688,7 @@ python manage.py components ext run my_ext --help
The help message prints out all the arguments and options available for the command, as well as any subcommands.
### Testing Commands
### Testing commands
Commands can be tested using Django's [`call_command()`](https://docs.djangoproject.com/en/5.2/ref/django-admin/#running-management-commands-from-your-code)
function, which allows you to simulate running the command in tests.
@ -721,7 +773,7 @@ class MyExtension(ComponentExtension):
As of v0.131, `URLRoute` objects are directly converted to Django's `URLPattern` and `URLResolver` objects.
### Accessing Extension URLs
### URL paths
The URLs defined in an extension are available under the path
@ -766,7 +818,7 @@ In this example, the URL
would call the `my_view` handler with the parameter `name` set to `"John"`.
### Passing kwargs and other extra fields to URL routes
### Extra URL data
The [`URLRoute`](../../../reference/extension_urls#django_components.URLRoute) class is framework-agnostic,
so that extensions could be used with non-Django frameworks in the future.

View file

@ -4,20 +4,30 @@ As larger projects get more complex, it can be hard to debug issues. Django Comp
Django Components provides a visual debugging feature that helps you understand the structure and boundaries of your components and slots. When enabled, it adds a colored border and a label around each component and slot on your rendered page.
To enable component and slot highlighting, set
[`debug_highlight_components`](../../../reference/settings/#django_components.app_settings.ComponentsSettings.debug_highlight_components)
and/or [`debug_highlight_slots`](../../../reference/settings/#django_components.app_settings.ComponentsSettings.debug_highlight_slots)
to `True` in your `settings.py` file:
To enable component and slot highlighting for all components and slots, set `highlight_components` and `highlight_slots` to `True` in [extensions defaults](../../../reference/settings/#django_components.app_settings.ComponentsSettings.extensions_defaults) in your `settings.py` file:
```python
from django_components import ComponentsSettings
COMPONENTS = ComponentsSettings(
debug_highlight_components=True,
debug_highlight_slots=True,
extensions_defaults={
"debug_highlight": {
"highlight_slots": True,
"highlight_components": True,
},
},
)
```
Alternatively, you can enable highlighting for specific components by setting `Component.DebugHighlight.highlight_components` to `True`:
```python
class MyComponent(Component):
class DebugHighlight:
highlight_components = True
highlight_slots = True
```
Components will be highlighted with a **blue** border and label:
![Component highlighting example](../../images/debug-highlight-components.png)

View file

@ -19,6 +19,10 @@
options:
show_if_no_docstring: true
::: django_components.ComponentDebugHighlight
options:
show_if_no_docstring: true
::: django_components.ComponentDefaults
options:
show_if_no_docstring: true
@ -75,6 +79,10 @@
options:
show_if_no_docstring: true
::: django_components.ExtensionComponentConfig
options:
show_if_no_docstring: true
::: django_components.RegistrySettings
options:
show_if_no_docstring: true

View file

@ -43,6 +43,7 @@ defaults = ComponentsSettings(
debug_highlight_slots=False,
dynamic_component_name="dynamic",
extensions=[],
extensions_defaults={},
libraries=[], # E.g. ["mysite.components.forms", ...]
multiline_tags=True,
reload_on_file_change=False,
@ -157,6 +158,16 @@ defaults = ComponentsSettings(
show_if_no_docstring: true
show_labels: false
::: django_components.app_settings.ComponentsSettings.extensions_defaults
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#L3164" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L3172" target="_blank">See source code</a>
@ -169,7 +169,7 @@ COMPONENTS = {
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L949" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L948" target="_blank">See source code</a>
@ -467,7 +467,7 @@ user = self.inject("user_data")["user"]
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L475" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L474" target="_blank">See source code</a>

View file

@ -36,6 +36,7 @@ from django_components.components import DynamicComponent
from django_components.dependencies import DependenciesStrategy, render_dependencies
from django_components.extension import (
ComponentExtension,
ExtensionComponentConfig,
OnComponentRegisteredContext,
OnComponentUnregisteredContext,
OnRegistryCreatedContext,
@ -47,6 +48,7 @@ from django_components.extension import (
)
from django_components.extensions.cache import ComponentCache
from django_components.extensions.defaults import ComponentDefaults, Default
from django_components.extensions.debug_highlight import ComponentDebugHighlight
from django_components.extensions.view import ComponentView, get_component_url
from django_components.library import TagProtectedError
from django_components.node import BaseNode, template_tag
@ -93,6 +95,7 @@ __all__ = [
"Component",
"ComponentCache",
"ComponentCommand",
"ComponentDebugHighlight",
"ComponentDefaults",
"ComponentExtension",
"ComponentFileEntry",
@ -111,6 +114,7 @@ __all__ = [
"DependenciesStrategy",
"DynamicComponent",
"Empty",
"ExtensionComponentConfig",
"format_attributes",
"get_component_by_class_id",
"get_component_dirs",

View file

@ -158,6 +158,10 @@ class ComponentsSettings(NamedTuple):
- Python import path, e.g. `"path.to.my_extension.MyExtension"`.
- Extension class, e.g. `my_extension.MyExtension`.
Read more about [extensions](../../concepts/advanced/extensions).
**Example:**
```python
COMPONENTS = ComponentsSettings(
extensions=[
@ -168,6 +172,29 @@ class ComponentsSettings(NamedTuple):
```
"""
extensions_defaults: Optional[Dict[str, Any]] = None
"""
Global defaults for the extension classes.
Read more about [Extension defaults](../../concepts/advanced/extensions#extension-defaults).
**Example:**
```python
COMPONENTS = ComponentsSettings(
extensions_defaults={
"my_extension": {
"my_setting": "my_value",
},
"cache": {
"enabled": True,
"ttl": 60,
},
},
)
```
"""
autodiscover: Optional[bool] = None
"""
Toggle whether to run [autodiscovery](../../concepts/fundamentals/autodiscovery) at the Django server startup.
@ -282,8 +309,13 @@ class ComponentsSettings(NamedTuple):
> [here](https://github.com/django-components/django-components/issues/498).
"""
# TODO_v1 - remove. Users should use extension defaults instead.
debug_highlight_components: Optional[bool] = None
"""
DEPRECATED. Use
[`extensions_defaults`](../settings/#django_components.app_settings.ComponentsSettings.extensions_defaults)
instead. Will be removed in v1.
Enable / disable component highlighting.
See [Troubleshooting](../../guides/other/troubleshooting#component-highlighting) for more details.
@ -296,8 +328,13 @@ class ComponentsSettings(NamedTuple):
```
"""
# TODO_v1 - remove. Users should use extension defaults instead.
debug_highlight_slots: Optional[bool] = None
"""
DEPRECATED. Use
[`extensions_defaults`](../settings/#django_components.app_settings.ComponentsSettings.extensions_defaults)
instead. Will be removed in v1.
Enable / disable slot highlighting.
See [Troubleshooting](../../guides/other/troubleshooting#slot-highlighting) for more details.
@ -670,6 +707,7 @@ defaults = ComponentsSettings(
debug_highlight_slots=False,
dynamic_component_name="dynamic",
extensions=[],
extensions_defaults={},
libraries=[], # E.g. ["mysite.components.forms", ...]
multiline_tags=True,
reload_on_file_change=False,
@ -735,6 +773,7 @@ class InternalSettings:
# 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]
extensions_defaults=default(components_settings.extensions_defaults, defaults.extensions_defaults),
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),
@ -755,7 +794,15 @@ class InternalSettings:
from django_components.extensions.defaults import DefaultsExtension
from django_components.extensions.view import ViewExtension
extensions = [CacheExtension, DefaultsExtension, ViewExtension, DebugHighlightExtension] + list(extensions)
extensions = cast(
List[Type["ComponentExtension"]],
[
CacheExtension,
DefaultsExtension,
ViewExtension,
DebugHighlightExtension,
],
) + list(extensions)
# Extensions may be passed in either as classes or import strings.
extension_instances: List["ComponentExtension"] = []

View file

@ -56,6 +56,7 @@ from django_components.extension import (
extensions,
)
from django_components.extensions.cache import ComponentCache
from django_components.extensions.debug_highlight import ComponentDebugHighlight
from django_components.extensions.defaults import ComponentDefaults
from django_components.extensions.view import ComponentView, ViewFn
from django_components.node import BaseNode
@ -1727,6 +1728,13 @@ class Component(metaclass=ComponentMeta):
"""
Instance of [`ComponentView`](../api#django_components.ComponentView) available at component render time.
"""
DebugHighlight: ClassVar[Type[ComponentDebugHighlight]]
"""
The fields of this class are used to configure the component debug highlighting.
Read more about [Component debug highlighting](../../guides/other/troubleshooting#component-and-slot-highlighting).
"""
debug_highlight: ComponentDebugHighlight
# #####################################
# MISC

View file

@ -1,5 +1,19 @@
from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Set, Tuple, Type, TypeVar, Union
from typing import (
TYPE_CHECKING,
Any,
Callable,
ClassVar,
Dict,
List,
NamedTuple,
Optional,
Set,
Tuple,
Type,
TypeVar,
Union,
)
import django.urls
from django.template import Context
@ -154,43 +168,159 @@ class OnSlotRenderedContext(NamedTuple):
################################################
class BaseExtensionClass:
"""Base class for all extension classes."""
class ExtensionComponentConfig:
"""
`ExtensionComponentConfig` is the base class for all extension component configs.
Extensions can define nested classes on the component class,
such as [`Component.View`](../api#django_components.Component.View) or
[`Component.Cache`](../api#django_components.Component.Cache):
```py
class MyComp(Component):
class View:
def get(self, request):
...
class Cache:
ttl = 60
```
This allows users to configure extension behavior per component.
Behind the scenes, the nested classes that users define on their components
are merged with the extension's "base" class.
So the example above is the same as:
```py
class MyComp(Component):
class View(ViewExtension.ComponentConfig):
def get(self, request):
...
class Cache(CacheExtension.ComponentConfig):
ttl = 60
```
Where both `ViewExtension.ComponentConfig` and `CacheExtension.ComponentConfig` are
subclasses of `ExtensionComponentConfig`.
"""
component_cls: Type["Component"]
"""The [`Component`](../api#django_components.Component) class that this extension is defined on."""
# TODO_v1 - Remove, superseded by `component_cls`
component_class: Type["Component"]
"""The Component class that this extension is defined on."""
"""The [`Component`](../api#django_components.Component) class that this extension is defined on."""
component: "Component"
"""
When a [`Component`](../api#django_components.Component) is instantiated,
also the nested extension classes (such as `Component.View`) are instantiated,
receiving the component instance as an argument.
This attribute holds the owner [`Component`](../api#django_components.Component) instance
that this extension is defined on.
"""
def __init__(self, component: "Component") -> None:
self.component = component
# TODO_v1 - Delete
BaseExtensionClass = ExtensionComponentConfig
"""
Deprecated. Will be removed in v1.0. Use
[`ComponentConfig`](../api#django_components.ExtensionComponentConfig) instead.
"""
# TODO_V1 - Delete, meta class was needed only for backwards support for ExtensionClass.
class ExtensionMeta(type):
def __new__(mcs, name: Any, bases: Tuple, attrs: Dict) -> Any:
# Rename `ExtensionClass` to `ComponentConfig`
if "ExtensionClass" in attrs:
attrs["ComponentConfig"] = attrs.pop("ExtensionClass")
return super().__new__(mcs, name, bases, attrs)
# 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:
class ComponentExtension(metaclass=ExtensionMeta):
"""
Base class for all extensions.
Read more on [Extensions](../../concepts/advanced/extensions).
**Example:**
```python
class ExampleExtension(ComponentExtension):
name = "example"
# Component-level behavior and settings. User will be able to override
# the attributes and methods defined here on the component classes.
class ComponentConfig(ComponentExtension.ComponentConfig):
foo = "1"
bar = "2"
def baz(cls):
return "3"
# URLs
urls = [
URLRoute(path="dummy-view/", handler=dummy_view, name="dummy"),
URLRoute(path="dummy-view-2/<int:id>/<str:name>/", handler=dummy_view_2, name="dummy-2"),
]
# Commands
commands = [
HelloWorldCommand,
]
# Hooks
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
print(ctx.component_cls.__name__)
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
print(ctx.component_cls.__name__)
```
Which users then can override on a per-component basis. E.g.:
```python
class MyComp(Component):
class Example:
foo = "overridden"
def baz(self):
return "overridden baz"
```
"""
###########################
# USER INPUT
###########################
name: str
name: ClassVar[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.
class by allowing users to define and access a nested class in
the [`Component`](../api#django_components.Component) class.
The extension name determines the name of the nested class in the `Component` class, and the attribute
The extension name determines the name of the nested class in
the [`Component`](../api#django_components.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`.
E.g. if the extension name is `"my_extension"`, then the nested class in
the [`Component`](../api#django_components.Component) class will be
`MyExtension`, and the extension will be accessible as `MyComp.my_extension`.
```python
class MyComp(Component):
@ -202,13 +332,20 @@ class ComponentExtension:
"my_extension": self.my_extension.do_something(),
}
```
!!! info
The extension class name can be customized by setting
the [`class_name`](../api#django_components.ComponentExtension.class_name) attribute.
"""
class_name: str
class_name: ClassVar[str]
"""
Name of the extension class.
By default, this is the same as `name`, but with snake_case converted to PascalCase.
By default, this is set automatically at class creation. The class name is the same as
the [`name`](../api#django_components.ComponentExtension.name) attribute, but with snake_case
converted to PascalCase.
So if the extension name is `"my_extension"`, then the extension class name will be `"MyExtension"`.
@ -217,20 +354,41 @@ class ComponentExtension:
class MyExtension: # <--- This is the extension class
...
```
To customize the class name, you can manually set the `class_name` attribute.
The class name must be a valid Python identifier.
**Example:**
```python
class MyExt(ComponentExtension):
name = "my_extension"
class_name = "MyCustomExtension"
```
This will make the extension class name `"MyCustomExtension"`.
```python
class MyComp(Component):
class MyCustomExtension: # <--- This is the extension class
...
```
"""
ExtensionClass = BaseExtensionClass
ComponentConfig: ClassVar[Type[ExtensionComponentConfig]] = ExtensionComponentConfig
"""
Base class that the "extension class" nested within a [`Component`](../api#django_components.Component)
class will inherit from.
Base class that the "component-level" extension config 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.:
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`](../api#django_components.Component) class. E.g.:
```python
class MyComp(Component):
@ -243,19 +401,20 @@ class ComponentExtension:
}
```
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.:
When rendering a component, the nested extension class will be set as a subclass of
`ComponentConfig`. So it will be same as if the user had directly inherited from extension's
`ComponentConfig`. E.g.:
```python
class MyComp(Component):
class MyExtension(ComponentExtension.ExtensionClass):
class MyExtension(ComponentExtension.ComponentConfig):
...
```
This setting decides what the extension class will inherit from.
"""
commands: List[Type[ComponentCommand]] = []
commands: ClassVar[List[Type[ComponentCommand]]] = []
"""
List of commands that can be run by the extension.
@ -315,7 +474,7 @@ class ComponentExtension:
```
"""
urls: List[URLRoute] = []
urls: ClassVar[List[URLRoute]] = []
###########################
# Misc
@ -629,19 +788,19 @@ class ExtensionManager:
for extension in self.extensions:
ext_class_name = extension.class_name
# If a Component class has an extension class, e.g.
# If a Component class has a nested 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`.
# the base class `extension.ComponentConfig`.
#
# So it will be same as if the user had directly inherited from `extension.ExtensionClass`.
# So it will be same as if the user had directly inherited from `extension.ComponentConfig`.
# ```python
# class MyComp(Component):
# class MyExtension(MyExtension.ExtensionClass):
# class MyExtension(MyExtension.ComponentConfig):
# ...
# ```
component_ext_subclass = getattr(component_cls, ext_class_name, None)
@ -649,11 +808,11 @@ class ExtensionManager:
# Add escape hatch, so that user can override the extension class
# from within the component class. E.g.:
# ```python
# class MyExtDifferentStillSame(MyExtension.ExtensionClass):
# class MyExtDifferentButStillSame(MyExtension.ComponentConfig):
# ...
#
# class MyComp(Component):
# my_extension_class = MyExtDifferentStillSame
# my_extension_class = MyExtDifferentButStillSame
# class MyExtension:
# ...
# ```
@ -661,20 +820,54 @@ class ExtensionManager:
# Will be effectively the same as:
# ```python
# class MyComp(Component):
# class MyExtension(MyExtDifferentStillSame):
# class MyExtension(MyExtDifferentButStillSame):
# ...
# ```
ext_class_override_attr = extension.name + "_class" # "my_extension_class"
ext_base_class = getattr(component_cls, ext_class_override_attr, extension.ExtensionClass)
ext_base_class = getattr(component_cls, ext_class_override_attr, extension.ComponentConfig)
# Extensions have 3 levels of configuration:
# 1. Factory defaults - The values that the extension author set on the extension class
# 2. User global defaults with `COMPONENTS.extensions_defaults`
# 3. User component-level settings - The values that the user set on the component class
#
# The component-level settings override the global defaults, which in turn override
# the factory defaults.
#
# To apply these defaults, we set them as bases for our new extension class.
#
# The final class will look like this:
# ```
# class MyExtension(MyComp.MyExtension, MyExtensionDefaults, MyExtensionBase):
# component_cls = MyComp
# ...
# ```
# Where:
# - `MyComp.MyExtension` is the extension class that the user defined on the component class.
# - `MyExtensionDefaults` is a dummy class that holds the extension defaults from settings.
# - `MyExtensionBase` is the base class that the extension class inherits from.
bases_list = [ext_base_class]
all_extensions_defaults = app_settings._settings.extensions_defaults or {}
extension_defaults = all_extensions_defaults.get(extension.name, None)
if extension_defaults:
# Create dummy class that holds the extension defaults
defaults_class = type(f"{ext_class_name}Defaults", tuple(), extension_defaults.copy())
bases_list.insert(0, defaults_class)
if component_ext_subclass:
bases: tuple[Type, ...] = (component_ext_subclass, ext_base_class)
else:
bases = (ext_base_class,)
bases_list.insert(0, component_ext_subclass)
# Allow to extension class to access the owner `Component` class that via
# `ExtensionClass.component_class`.
component_ext_subclass = type(ext_class_name, bases, {"component_class": component_cls})
bases: tuple[Type, ...] = tuple(bases_list)
# Allow component-level extension class to access the owner `Component` class that via
# `component_cls`.
component_ext_subclass = type(
ext_class_name,
bases,
# TODO_v1 - Remove `component_class`, superseded by `component_cls`
{"component_cls": component_cls, "component_class": component_cls},
)
# Finally, reassign the new class extension class on the component class.
setattr(component_cls, ext_class_name, component_ext_subclass)

View file

@ -5,6 +5,7 @@ from django.core.cache import BaseCache, caches
from django_components.extension import (
ComponentExtension,
ExtensionComponentConfig,
OnComponentInputContext,
OnComponentRenderedContext,
)
@ -15,7 +16,7 @@ from django_components.slots import Slot
CACHE_KEY_PREFIX = "components:cache:"
class ComponentCache(ComponentExtension.ExtensionClass): # type: ignore
class ComponentCache(ExtensionComponentConfig):
"""
The interface for `Component.Cache`.
@ -172,7 +173,7 @@ class CacheExtension(ComponentExtension):
name = "cache"
ExtensionClass = ComponentCache
ComponentConfig = ComponentCache
def __init__(self, *args: Any, **kwargs: Any):
self.render_id_to_cache_key: dict[str, str] = {}

View file

@ -1,7 +1,12 @@
from typing import Any, Literal, NamedTuple, Optional, Type
from django_components.app_settings import app_settings
from django_components.extension import ComponentExtension, OnComponentRenderedContext, OnSlotRenderedContext
from django_components.extension import (
ComponentExtension,
ExtensionComponentConfig,
OnComponentRenderedContext,
OnSlotRenderedContext,
)
from django_components.util.misc import gen_id
@ -45,14 +50,6 @@ def apply_component_highlight(type: Literal["component", "slot"], output: str, n
return output
# TODO - Deprecate `DEBUG_HIGHLIGHT_SLOTS` and `DEBUG_HIGHLIGHT_COMPONENTS` (with removal in v1)
# once `extension_defaults` is implemented.
# That way people will be able to set the highlighting from single place.
# At that point also document this extension in the docs:
# - Exposing `ComponentDebugHighlight` from `__init__.py`
# - Adding `Component.DebugHighlight` and `Component.debug_highlight` attributes to Component class
# so it's easier to find.
# - Check docstring of `ComponentDebugHighlight` in the docs and make sure it's correct.
class HighlightComponentsDescriptor:
def __get__(self, obj: Optional[Any], objtype: Type) -> bool:
return app_settings.DEBUG_HIGHLIGHT_COMPONENTS
@ -63,14 +60,14 @@ class HighlightSlotsDescriptor:
return app_settings.DEBUG_HIGHLIGHT_SLOTS
class ComponentDebugHighlight(ComponentExtension.ExtensionClass): # type: ignore
class ComponentDebugHighlight(ExtensionComponentConfig):
"""
The interface for `Component.DebugHighlight`.
The fields of this class are used to configure the component debug highlighting for this component
and its direct slots.
Read more about [Component debug highlighting](../../concepts/advanced/component_debug_highlighting).
Read more about [Component debug highlighting](../../guides/other/troubleshooting#component-and-slot-highlighting).
**Example:**
@ -84,10 +81,22 @@ class ComponentDebugHighlight(ComponentExtension.ExtensionClass): # type: ignor
```
To highlight ALL components and slots, set
[`ComponentsSettings.DEBUG_HIGHLIGHT_SLOTS`](../../settings/components_settings.md#debug_highlight_slots) and
[`ComponentsSettings.DEBUG_HIGHLIGHT_COMPONENTS`](../../settings/components_settings.md#debug_highlight_components)
to `True`.
"""
[extension defaults](../../reference/settings/#django_components.app_settings.ComponentsSettings.extensions_defaults)
in your settings:
```python
from django_components import ComponentsSettings
COMPONENTS = ComponentsSettings(
extensions_defaults={
"debug_highlight": {
"highlight_components": True,
"highlight_slots": True,
},
},
)
```
""" # noqa: E501
# TODO_v1 - Remove `DEBUG_HIGHLIGHT_COMPONENTS` and `DEBUG_HIGHLIGHT_SLOTS`
# Instead set this as plain boolean fields.
@ -112,7 +121,7 @@ class DebugHighlightExtension(ComponentExtension):
"""
name = "debug_highlight"
ExtensionClass = ComponentDebugHighlight
ComponentConfig = ComponentDebugHighlight
# Apply highlight to the slot's rendered output
def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]:

View file

@ -3,7 +3,12 @@ from dataclasses import MISSING, Field, dataclass
from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Type
from weakref import WeakKeyDictionary
from django_components.extension import ComponentExtension, OnComponentClassCreatedContext, OnComponentInputContext
from django_components.extension import (
ComponentExtension,
ExtensionComponentConfig,
OnComponentClassCreatedContext,
OnComponentInputContext,
)
if TYPE_CHECKING:
from django_components.component import Component
@ -62,7 +67,8 @@ def _extract_defaults(defaults: Optional[Type]) -> List[ComponentDefaultField]:
for default_field_key in dir(defaults):
# Iterate only over fields set by the user (so non-dunder fields).
# Plus ignore `component_class` because that was set by the extension system.
if default_field_key.startswith("__") or default_field_key == "component_class":
# TODO_V1 - Remove `component_class`
if default_field_key.startswith("__") or default_field_key in {"component_class", "component_cls"}:
continue
default_field = getattr(defaults, default_field_key)
@ -119,7 +125,7 @@ def _apply_defaults(kwargs: Dict, defaults: List[ComponentDefaultField]) -> None
kwargs[default_field.key] = default_value
class ComponentDefaults(ComponentExtension.ExtensionClass): # type: ignore[misc,valid-type]
class ComponentDefaults(ExtensionComponentConfig):
"""
The interface for `Component.Defaults`.
@ -164,7 +170,7 @@ class DefaultsExtension(ComponentExtension):
"""
name = "defaults"
ExtensionClass = ComponentDefaults
ComponentConfig = ComponentDefaults
# Preprocess the `Component.Defaults` class, if given, so we don't have to do it
# each time a component is rendered.

View file

@ -8,6 +8,7 @@ from django.views.generic import View
from django_components.extension import (
ComponentExtension,
ExtensionComponentConfig,
OnComponentClassCreatedContext,
OnComponentClassDeletedContext,
URLRoute,
@ -74,7 +75,7 @@ def get_component_url(
return format_url(url, query=query, fragment=fragment)
class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore
class ComponentView(ExtensionComponentConfig, View):
"""
The interface for `Component.View`.
@ -157,7 +158,7 @@ class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore
"""
def __init__(self, component: "Component", **kwargs: Any) -> None:
ComponentExtension.ExtensionClass.__init__(self, component)
ComponentExtension.ComponentConfig.__init__(self, component)
View.__init__(self, **kwargs)
@property
@ -257,7 +258,7 @@ class ViewExtension(ComponentExtension):
name = "view"
ExtensionClass = ComponentView
ComponentConfig = ComponentView
def __init__(self) -> None:
# Remember which route belongs to which component

View file

@ -82,7 +82,13 @@ class TestComponentHighlight:
assert COLORS["slot"].text_color in result
assert COLORS["slot"].border_color in result
@djc_test(components_settings={"debug_highlight_components": True})
@djc_test(
components_settings={
"extensions_defaults": {
"debug_highlight": {"highlight_components": True},
},
}
)
def test_component_highlight_extension(self):
template = _prepare_template()
rendered = template.render(Context({"items": [1, 2]}))
@ -151,7 +157,83 @@ class TestComponentHighlight:
"""
assertHTMLEqual(rendered, expected)
@djc_test(components_settings={"debug_highlight_slots": True})
# TODO_v1 - Remove this test once we've removed the `debug_highlight_components` setting.
@djc_test(components_settings={"debug_highlight_components": True})
def test_component_highlight_extension__legacy(self):
template = _prepare_template()
rendered = template.render(Context({"items": [1, 2]}))
expected = """
<div class="item">
<style>
.component-highlight-a1bc45::before {
content: "outer (ca1bc3f): ";
font-weight: bold;
color: #2f14bb;
}
</style>
<div class="component-highlight-a1bc45" style="border: 1px solid blue">
<div class="outer" data-djc-id-ca1bc3f="">
<style>
.component-highlight-a1bc44::before {
content: "inner (ca1bc41): ";
font-weight: bold;
color: #2f14bb;
}
</style>
<div class="component-highlight-a1bc44" style="border: 1px solid blue">
<div class="inner" data-djc-id-ca1bc41="">
<div>
1: 1
</div>
<div>
2: 1
</div>
</div>
</div>
</div>
</div>
</div>
<div class="item">
<style>
.component-highlight-a1bc49::before {
content: "outer (ca1bc46): ";
font-weight: bold;
color: #2f14bb;
}
</style>
<div class="component-highlight-a1bc49" style="border: 1px solid blue">
<div class="outer" data-djc-id-ca1bc46="">
<style>
.component-highlight-a1bc48::before {
content: "inner (ca1bc47): ";
font-weight: bold;
color: #2f14bb;
}
</style>
<div class="component-highlight-a1bc48" style="border: 1px solid blue">
<div class="inner" data-djc-id-ca1bc47="">
<div>
1: 2
</div>
<div>
2: 2
</div>
</div>
</div>
</div>
</div>
</div>
"""
assertHTMLEqual(rendered, expected)
@djc_test(
components_settings={
"extensions_defaults": {
"debug_highlight": {"highlight_slots": True},
},
}
)
def test_slot_highlight_extension(self):
template = _prepare_template()
rendered = template.render(Context({"items": [1, 2]}))
@ -224,6 +306,80 @@ class TestComponentHighlight:
"""
assertHTMLEqual(rendered, expected)
# TODO_v1 - Remove this test once we've removed the `debug_highlight_slots` setting.
@djc_test(components_settings={"debug_highlight_slots": True})
def test_slot_highlight_extension__legacy(self):
template = _prepare_template()
rendered = template.render(Context({"items": [1, 2]}))
expected = """
<div class="item">
<div class="outer" data-djc-id-ca1bc3f="">
<div class="inner" data-djc-id-ca1bc41="">
<div>
1:
<style>
.slot-highlight-a1bc44::before {
content: "InnerComponent - content: ";
font-weight: bold;
color: #bb1414;
}
</style>
<div class="slot-highlight-a1bc44" style="border: 1px solid #e40c0c">
1
</div>
</div>
<div>
2:
<style>
.slot-highlight-a1bc45::before {
content: "InnerComponent - content: ";
font-weight: bold;
color: #bb1414;
}
</style>
<div class="slot-highlight-a1bc45" style="border: 1px solid #e40c0c">
1
</div>
</div>
</div>
</div>
</div>
<div class="item">
<div class="outer" data-djc-id-ca1bc46="">
<div class="inner" data-djc-id-ca1bc47="">
<div>
1:
<style>
.slot-highlight-a1bc48::before {
content: "InnerComponent - content: ";
font-weight: bold;
color: #bb1414;
}
</style>
<div class="slot-highlight-a1bc48" style="border: 1px solid #e40c0c">
2
</div>
</div>
<div>
2:
<style>
.slot-highlight-a1bc49::before {
content: "InnerComponent - content: ";
font-weight: bold;
color: #bb1414;
}
</style>
<div class="slot-highlight-a1bc49" style="border: 1px solid #e40c0c">
2
</div>
</div>
</div>
</div>
</div>
"""
assertHTMLEqual(rendered, expected)
def test_highlight_on_component_class(self):
@register("inner")
class InnerComponent(Component):

View file

@ -12,6 +12,7 @@ from django_components.component_registry import ComponentRegistry
from django_components.extension import (
URLRoute,
ComponentExtension,
ExtensionComponentConfig,
OnComponentClassCreatedContext,
OnComponentClassDeletedContext,
OnRegistryCreatedContext,
@ -43,6 +44,19 @@ def dummy_view_2(request: HttpRequest, id: int, name: str):
return HttpResponse(f"Hello, world! {id} {name}")
# TODO_V1 - Remove
class LegacyExtension(ComponentExtension):
name = "legacy"
class ExtensionClass(ExtensionComponentConfig):
foo = "1"
bar = "2"
@classmethod
def baz(cls):
return "3"
class DummyExtension(ComponentExtension):
"""
Test extension that tracks all hook calls and their arguments.
@ -50,6 +64,14 @@ class DummyExtension(ComponentExtension):
name = "test_extension"
class ComponentConfig(ExtensionComponentConfig):
foo = "1"
bar = "2"
@classmethod
def baz(cls):
return "3"
def __init__(self) -> None:
self.calls: Dict[str, List[Any]] = {
"on_component_class_created": [],
@ -154,7 +176,7 @@ class TestExtension:
return {"name": kwargs.get("name", "World")}
ext_class = TestAccessComp.TestExtension # type: ignore[attr-defined]
assert issubclass(ext_class, ComponentExtension.ExtensionClass)
assert issubclass(ext_class, ComponentExtension.ComponentConfig)
assert ext_class.component_class is TestAccessComp
# NOTE: Required for test_component_class_lifecycle_hooks to work
@ -371,3 +393,64 @@ class TestExtensionViews:
response2 = client.get("/components/ext/test_nested_extension/nested-view/123/John/")
assert response2.status_code == 200
assert response2.content == b"Hello, world! 123 John"
@djc_test
class TestExtensionDefaults:
@djc_test(
components_settings={
"extensions": [DummyExtension],
"extensions_defaults": {
"test_extension": {},
},
}
)
def test_no_defaults(self):
class TestComponent(Component):
template = "Hello"
dummy_ext_cls: DummyExtension.ComponentConfig = TestComponent.TestExtension # type: ignore[attr-defined]
assert dummy_ext_cls.foo == "1"
assert dummy_ext_cls.bar == "2"
assert dummy_ext_cls.baz() == "3"
@djc_test(
components_settings={
"extensions": [DummyExtension],
"extensions_defaults": {
"test_extension": {
"foo": "NEW_FOO",
"baz": classmethod(lambda self: "OVERRIDEN"),
},
"nonexistent": {
"1": "2",
},
},
}
)
def test_defaults(self):
class TestComponent(Component):
template = "Hello"
dummy_ext_cls: DummyExtension.ComponentConfig = TestComponent.TestExtension # type: ignore[attr-defined]
assert dummy_ext_cls.foo == "NEW_FOO"
assert dummy_ext_cls.bar == "2"
assert dummy_ext_cls.baz() == "OVERRIDEN"
@djc_test
class TestLegacyApi:
# TODO_V1 - Remove
@djc_test(
components_settings={
"extensions": [LegacyExtension],
}
)
def test_extension_class(self):
class TestComponent(Component):
template = "Hello"
dummy_ext_cls: LegacyExtension.ExtensionClass = TestComponent.Legacy # type: ignore[attr-defined]
assert dummy_ext_cls.foo == "1"
assert dummy_ext_cls.bar == "2"
assert dummy_ext_cls.baz() == "3"