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

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