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

@ -6,7 +6,7 @@
# isort: off
from django_components.app_settings import ContextBehavior, ComponentsSettings
from django_components.autodiscovery import autodiscover, import_libraries
from django_components.component import Component, ComponentVars, ComponentView
from django_components.component import Component, ComponentVars
from django_components.component_media import ComponentMediaInput, ComponentMediaInputPath
from django_components.component_registry import (
AlreadyRegistered,
@ -18,6 +18,18 @@ from django_components.component_registry import (
)
from django_components.components import DynamicComponent
from django_components.dependencies import render_dependencies
from django_components.extension import (
ComponentExtension,
OnComponentRegisteredContext,
OnComponentUnregisteredContext,
OnRegistryCreatedContext,
OnRegistryDeletedContext,
OnComponentClassCreatedContext,
OnComponentClassDeletedContext,
OnComponentInputContext,
OnComponentDataContext,
)
from django_components.extensions.view import ComponentView
from django_components.library import TagProtectedError
from django_components.node import BaseNode, template_tag
from django_components.slots import SlotContent, Slot, SlotFunc, SlotRef, SlotResult
@ -45,6 +57,7 @@ __all__ = [
"ContextBehavior",
"ComponentsSettings",
"Component",
"ComponentExtension",
"ComponentFileEntry",
"ComponentFormatter",
"ComponentMediaInput",
@ -61,6 +74,14 @@ __all__ = [
"get_component_files",
"import_libraries",
"NotRegistered",
"OnComponentClassCreatedContext",
"OnComponentClassDeletedContext",
"OnComponentDataContext",
"OnComponentInputContext",
"OnComponentRegisteredContext",
"OnComponentUnregisteredContext",
"OnRegistryCreatedContext",
"OnRegistryDeletedContext",
"register",
"registry",
"RegistrySettings",

View file

@ -1,6 +1,7 @@
import re
from dataclasses import dataclass
from enum import Enum
from importlib import import_module
from os import PathLike
from pathlib import Path
from typing import (
@ -15,6 +16,7 @@ from typing import (
Optional,
Sequence,
Tuple,
Type,
TypeVar,
Union,
cast,
@ -25,6 +27,7 @@ from django.conf import settings
from django_components.util.misc import default
if TYPE_CHECKING:
from django_components.extension import ComponentExtension
from django_components.tag_formatter import TagFormatterABC
@ -146,6 +149,25 @@ class ComponentsSettings(NamedTuple):
```
"""
extensions: Optional[Sequence[Union[Type["ComponentExtension"], str]]] = None
"""
List of [extensions](../../concepts/advanced/extensions) to be loaded.
The extensions can be specified as:
- Python import path, e.g. `"path.to.my_extension.MyExtension"`.
- Extension class, e.g. `my_extension.MyExtension`.
```python
COMPONENTS = ComponentsSettings(
extensions=[
"path.to.my_extension.MyExtension",
StorybookExtension,
],
)
```
"""
autodiscover: Optional[bool] = None
"""
Toggle whether to run [autodiscovery](../../concepts/fundamentals/autodiscovery) at the Django server startup.
@ -647,6 +669,7 @@ defaults = ComponentsSettings(
debug_highlight_components=False,
debug_highlight_slots=False,
dynamic_component_name="dynamic",
extensions=[],
libraries=[], # E.g. ["mysite.components.forms", ...]
multiline_tags=True,
reload_on_file_change=False,
@ -709,6 +732,9 @@ class InternalSettings:
components_settings.dynamic_component_name, defaults.dynamic_component_name
),
libraries=default(components_settings.libraries, defaults.libraries),
# NOTE: Internally we store the extensions as a list of instances, but the user
# can pass in either a list of classes or a list of import strings.
extensions=self._prepare_extensions(components_settings), # type: ignore[arg-type]
multiline_tags=default(components_settings.multiline_tags, defaults.multiline_tags),
reload_on_file_change=self._prepare_reload_on_file_change(components_settings),
template_cache_size=default(components_settings.template_cache_size, defaults.template_cache_size),
@ -718,6 +744,33 @@ class InternalSettings:
tag_formatter=default(components_settings.tag_formatter, defaults.tag_formatter), # type: ignore[arg-type]
)
def _prepare_extensions(self, new_settings: ComponentsSettings) -> List["ComponentExtension"]:
extensions: Sequence[Union[Type["ComponentExtension"], str]] = default(
new_settings.extensions, cast(List[str], defaults.extensions)
)
# Prepend built-in extensions
from django_components.extensions.view import ViewExtension
extensions = [ViewExtension] + list(extensions)
# Extensions may be passed in either as classes or import strings.
extension_instances: List["ComponentExtension"] = []
for extension in extensions:
if isinstance(extension, str):
import_path, class_name = extension.rsplit(".", 1)
extension_module = import_module(import_path)
extension = cast(Type["ComponentExtension"], getattr(extension_module, class_name))
if isinstance(extension, type):
extension_instance = extension()
else:
extension_instances.append(extension)
extension_instances.append(extension_instance)
return extension_instances
def _prepare_reload_on_file_change(self, new_settings: ComponentsSettings) -> bool:
val = new_settings.reload_on_file_change
# TODO_REMOVE_IN_V1
@ -780,6 +833,10 @@ class InternalSettings:
def LIBRARIES(self) -> List[str]:
return self._settings.libraries # type: ignore[return-value]
@property
def EXTENSIONS(self) -> List["ComponentExtension"]:
return self._settings.extensions # type: ignore[return-value]
@property
def MULTILINE_TAGS(self) -> bool:
return self._settings.multiline_tags # type: ignore[return-value]

View file

@ -17,6 +17,7 @@ class ComponentsConfig(AppConfig):
from django_components.autodiscovery import autodiscover, import_libraries
from django_components.component_registry import registry
from django_components.components.dynamic import DynamicComponent
from django_components.extension import extensions
from django_components.util.django_monkeypatch import monkeypatch_template_cls
app_settings._load_settings()
@ -57,6 +58,9 @@ class ComponentsConfig(AppConfig):
# Register the dynamic component under the name as given in settings
registry.register(app_settings.DYNAMIC_COMPONENT_NAME, DynamicComponent)
# Let extensions process any components which may have been created before the app was ready
extensions._init_app()
# See https://github.com/django-components/django-components/issues/586#issue-2472678136
def _watch_component_files_for_autoreload() -> None:

View file

@ -16,14 +16,13 @@ from typing import (
Mapping,
NamedTuple,
Optional,
Protocol,
Tuple,
Type,
TypeVar,
Union,
cast,
)
from weakref import ReferenceType
from weakref import ReferenceType, finalize
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media as MediaCls
@ -54,6 +53,14 @@ from django_components.dependencies import render_dependencies as _render_depend
from django_components.dependencies import (
set_component_attrs_for_js_and_css,
)
from django_components.extension import (
OnComponentClassCreatedContext,
OnComponentClassDeletedContext,
OnComponentDataContext,
OnComponentInputContext,
extensions,
)
from django_components.extensions.view import ViewFn
from django_components.node import BaseNode
from django_components.perfutil.component import ComponentRenderer, component_context_cache, component_post_render
from django_components.perfutil.provide import register_provide_reference, unregister_provide_reference
@ -131,10 +138,6 @@ class MetadataItem(Generic[ArgsType, KwargsType, SlotsType]):
request: Optional[HttpRequest]
class ViewFn(Protocol):
def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704
class ComponentVars(NamedTuple):
"""
Type for the variables available inside the component templates.
@ -199,40 +202,10 @@ class ComponentMeta(ComponentMediaMeta):
return super().__new__(mcs, name, bases, attrs)
# NOTE: We use metaclass to automatically define the HTTP methods as defined
# in `View.http_method_names`.
class ComponentViewMeta(type):
def __new__(mcs, name: str, bases: Any, dct: Dict) -> Any:
# Default implementation shared by all HTTP methods
def create_handler(method: str) -> Callable:
def handler(self, request: HttpRequest, *args: Any, **kwargs: Any): # type: ignore[no-untyped-def]
component: "Component" = self.component
return getattr(component, method)(request, *args, **kwargs)
return handler
# Add methods to the class
for method_name in View.http_method_names:
if method_name not in dct:
dct[method_name] = create_handler(method_name)
return super().__new__(mcs, name, bases, dct)
class ComponentView(View, metaclass=ComponentViewMeta):
"""
Subclass of `django.views.View` where the `Component` instance is available
via `self.component`.
"""
# NOTE: This attribute must be declared on the class for `View.as_view` to allow
# us to pass `component` kwarg.
component = cast("Component", None)
def __init__(self, component: "Component", **kwargs: Any) -> None:
super().__init__(**kwargs)
self.component = component
# This runs when a Component class is being deleted
def __del__(cls) -> None:
comp_cls = cast(Type["Component"], cls)
extensions.on_component_class_deleted(OnComponentClassDeletedContext(comp_cls))
# Internal data that are made available within the component's template
@ -562,7 +535,6 @@ class Component(
response_class = HttpResponse
"""This allows to configure what class is used to generate response from `render_to_response`"""
View = ComponentView
# #####################################
# PUBLIC API - HOOKS
@ -623,11 +595,15 @@ class Component(
# None == uninitialized, False == No types, Tuple == types
self._types: Optional[Union[Tuple[Any, Any, Any, Any, Any, Any], Literal[False]]] = None
extensions._init_component_instance(self)
def __init_subclass__(cls, **kwargs: Any) -> None:
cls._class_hash = hash_comp_cls(cls)
comp_hash_mapping[cls._class_hash] = cls
ALL_COMPONENTS.append(cached_ref(cls)) # type: ignore[arg-type]
extensions._init_component_class(cls)
extensions.on_component_class_created(OnComponentClassCreatedContext(cls))
@contextmanager
def _with_metadata(self, item: MetadataItem) -> Generator[None, None, None]:
@ -922,8 +898,10 @@ class Component(
else:
comp = cls()
# Allow the View class to access this component via `self.component`
return comp.View.as_view(**initkwargs, component=comp)
# `view` is a built-in extension defined in `extensions.view`. It subclasses
# from Django's `View` class, and adds the `component` attribute to it.
view_inst = cast(View, comp.view) # type: ignore[attr-defined]
return view_inst.__class__.as_view(**initkwargs, component=comp)
# #####################################
# RENDERING
@ -1152,6 +1130,19 @@ class Component(
request=request,
)
# Allow plugins to modify or validate the inputs
extensions.on_component_input(
OnComponentInputContext(
component=self,
component_cls=self.__class__,
component_id=render_id,
args=args, # type: ignore[arg-type]
kwargs=kwargs, # type: ignore[arg-type]
slots=slots, # type: ignore[arg-type]
context=context,
)
)
# We pass down the components the info about the component's parent.
# This is used for correctly resolving slot fills, correct rendering order,
# or CSS scoping.
@ -1215,6 +1206,17 @@ class Component(
css_data = self.get_css_data(*args, **kwargs) if hasattr(self, "get_css_data") else {} # type: ignore
self._validate_outputs(data=context_data)
extensions.on_component_data(
OnComponentDataContext(
component=self,
component_cls=self.__class__,
component_id=render_id,
context_data=cast(Dict, context_data),
js_data=cast(Dict, js_data),
css_data=cast(Dict, css_data),
)
)
# Process Component's JS and CSS
cache_component_js(self.__class__)
js_input_hash = cache_component_js_vars(self.__class__, js_data) if js_data else None
@ -1700,6 +1702,10 @@ class ComponentNode(BaseNode):
subcls: Type[ComponentNode] = type(subcls_name, (cls,), {"tag": start_tag, "end_tag": end_tag})
component_node_subclasses_by_name[start_tag] = (subcls, registry)
# Remove the cache entry when either the registry or the component are deleted
finalize(subcls, lambda: component_node_subclasses_by_name.pop(start_tag, None))
finalize(registry, lambda: component_node_subclasses_by_name.pop(start_tag, None))
cached_subcls, cached_registry = component_node_subclasses_by_name[start_tag]
if cached_registry is not registry:

View file

@ -6,6 +6,13 @@ from django.template import Library
from django.template.base import Parser, Token
from django_components.app_settings import ContextBehaviorType, app_settings
from django_components.extension import (
OnComponentRegisteredContext,
OnComponentUnregisteredContext,
OnRegistryCreatedContext,
OnRegistryDeletedContext,
extensions,
)
from django_components.library import is_tag_protected, mark_protected_tags, register_tag
from django_components.tag_formatter import TagFormatterABC, get_tag_formatter
from django_components.util.weakref import cached_ref
@ -237,7 +244,19 @@ class ComponentRegistry:
ALL_REGISTRIES.append(cached_ref(self))
extensions.on_registry_created(
OnRegistryCreatedContext(
registry=self,
)
)
def __del__(self) -> None:
extensions.on_registry_deleted(
OnRegistryDeletedContext(
registry=self,
)
)
# Unregister all components when the registry is deleted
self.clear()
@ -336,6 +355,14 @@ class ComponentRegistry:
# If the component class is deleted, unregister it from this registry.
finalize(entry.cls, lambda: self.unregister(name) if name in self._registry else None)
extensions.on_component_registered(
OnComponentRegisteredContext(
registry=self,
name=name,
component_cls=entry.cls,
)
)
def unregister(self, name: str) -> None:
"""
Unregister the [`Component`](../api#django_components.Component) class
@ -389,6 +416,14 @@ class ComponentRegistry:
entry = self._registry[name]
del self._registry[name]
extensions.on_component_unregistered(
OnComponentUnregisteredContext(
registry=self,
name=name,
component_cls=entry.cls,
)
)
def get(self, name: str) -> Type["Component"]:
"""
Retrieve a [`Component`](../api#django_components.Component)

View file

@ -0,0 +1,580 @@
from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Tuple, Type, TypeVar
from django.template import Context
from django_components.app_settings import app_settings
from django_components.util.misc import snake_to_pascal
if TYPE_CHECKING:
from django_components import Component
from django_components.component_registry import ComponentRegistry
TCallable = TypeVar("TCallable", bound=Callable)
################################################
# HOOK TYPES
#
# This is the source of truth for what data is available in each hook.
# NOTE: These types are also used in docs generation, see `docs/scripts/reference.py`.
################################################
class OnComponentClassCreatedContext(NamedTuple):
component_cls: Type["Component"]
"""The created Component class"""
class OnComponentClassDeletedContext(NamedTuple):
component_cls: Type["Component"]
"""The to-be-deleted Component class"""
class OnRegistryCreatedContext(NamedTuple):
registry: "ComponentRegistry"
"""The created ComponentRegistry instance"""
class OnRegistryDeletedContext(NamedTuple):
registry: "ComponentRegistry"
"""The to-be-deleted ComponentRegistry instance"""
class OnComponentRegisteredContext(NamedTuple):
registry: "ComponentRegistry"
"""The registry the component was registered to"""
name: str
"""The name the component was registered under"""
component_cls: Type["Component"]
"""The registered Component class"""
class OnComponentUnregisteredContext(NamedTuple):
registry: "ComponentRegistry"
"""The registry the component was unregistered from"""
name: str
"""The name the component was registered under"""
component_cls: Type["Component"]
"""The unregistered Component class"""
class OnComponentInputContext(NamedTuple):
component: "Component"
"""The Component instance that received the input and is being rendered"""
component_cls: Type["Component"]
"""The Component class"""
component_id: str
"""The unique identifier for this component instance"""
args: List
"""List of positional arguments passed to the component"""
kwargs: Dict
"""Dictionary of keyword arguments passed to the component"""
slots: Dict
"""Dictionary of slot definitions"""
context: Context
"""The Django template Context object"""
class OnComponentDataContext(NamedTuple):
component: "Component"
"""The Component instance that is being rendered"""
component_cls: Type["Component"]
"""The Component class"""
component_id: str
"""The unique identifier for this component instance"""
context_data: Dict
"""Dictionary of context data from `Component.get_context_data()`"""
js_data: Dict
"""Dictionary of JavaScript data from `Component.get_js_data()`"""
css_data: Dict
"""Dictionary of CSS data from `Component.get_css_data()`"""
class BaseExtensionClass:
def __init__(self, component: "Component") -> None:
self.component = component
# NOTE: This class is used for generating documentation for the extension hooks API.
# To be recognized, all hooks must start with `on_` prefix.
class ComponentExtension:
"""
Base class for all extensions.
Read more on [Extensions](../../concepts/advanced/extensions).
"""
name: str
"""
Name of the extension.
Name must be lowercase, and must be a valid Python identifier (e.g. `"my_extension"`).
The extension may add new features to the [`Component`](../api#django_components.Component)
class by allowing users to define and access a nested class in the `Component` class.
The extension name determines the name of the nested class in the `Component` class, and the attribute
under which the extension will be accessible.
E.g. if the extension name is `"my_extension"`, then the nested class in the `Component` class
will be `MyExtension`, and the extension will be accessible as `MyComp.my_extension`.
```python
class MyComp(Component):
class MyExtension:
...
def get_context_data(self):
return {
"my_extension": self.my_extension.do_something(),
}
```
"""
class_name: str
"""
Name of the extension class.
By default, this is the same as `name`, but with snake_case converted to PascalCase.
So if the extension name is `"my_extension"`, then the extension class name will be `"MyExtension"`.
```python
class MyComp(Component):
class MyExtension: # <--- This is the extension class
...
```
"""
ExtensionClass = BaseExtensionClass
"""
Base class that the "extension class" nested within a [`Component`](../api#django_components.Component)
class will inherit from.
This is where you can define new methods and attributes that will be available to the component
instance.
Background:
The extension may add new features to the `Component` class by allowing users to
define and access a nested class in the `Component` class. E.g.:
```python
class MyComp(Component):
class MyExtension:
...
def get_context_data(self):
return {
"my_extension": self.my_extension.do_something(),
}
```
When rendering a component, the nested extension class will be set as a subclass of `ExtensionClass`.
So it will be same as if the user had directly inherited from `ExtensionClass`. E.g.:
```python
class MyComp(Component):
class MyExtension(BaseExtensionClass):
...
```
This setting decides what the extension class will inherit from.
"""
def __init_subclass__(cls) -> None:
if not cls.name.isidentifier():
raise ValueError(f"Extension name must be a valid Python identifier, got {cls.name}")
if not cls.name.islower():
raise ValueError(f"Extension name must be lowercase, got {cls.name}")
if not getattr(cls, "class_name", None):
cls.class_name = snake_to_pascal(cls.name)
###########################
# Component lifecycle hooks
###########################
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
"""
Called when a new [`Component`](../api#django_components.Component) class is created.
This hook is called after the [`Component`](../api#django_components.Component) class
is fully defined but before it's registered.
Use this hook to perform any initialization or validation of the
[`Component`](../api#django_components.Component) class.
**Example:**
```python
from django_components import ComponentExtension, OnComponentClassCreatedContext
class MyExtension(ComponentExtension):
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
# Add a new attribute to the Component class
ctx.component_cls.my_attr = "my_value"
```
"""
pass
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
"""
Called when a [`Component`](../api#django_components.Component) class is being deleted.
This hook is called before the [`Component`](../api#django_components.Component) class
is deleted from memory.
Use this hook to perform any cleanup related to the [`Component`](../api#django_components.Component) class.
**Example:**
```python
from django_components import ComponentExtension, OnComponentClassDeletedContext
class MyExtension(ComponentExtension):
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
# Remove Component class from the extension's cache on deletion
self.cache.pop(ctx.component_cls, None)
```
"""
pass
def on_registry_created(self, ctx: OnRegistryCreatedContext) -> None:
"""
Called when a new [`ComponentRegistry`](../api#django_components.ComponentRegistry) is created.
This hook is called after a new
[`ComponentRegistry`](../api#django_components.ComponentRegistry) instance is initialized.
Use this hook to perform any initialization needed for the registry.
**Example:**
```python
from django_components import ComponentExtension, OnRegistryCreatedContext
class MyExtension(ComponentExtension):
def on_registry_created(self, ctx: OnRegistryCreatedContext) -> None:
# Add a new attribute to the registry
ctx.registry.my_attr = "my_value"
```
"""
pass
def on_registry_deleted(self, ctx: OnRegistryDeletedContext) -> None:
"""
Called when a [`ComponentRegistry`](../api#django_components.ComponentRegistry) is being deleted.
This hook is called before
a [`ComponentRegistry`](../api#django_components.ComponentRegistry) instance is deleted.
Use this hook to perform any cleanup related to the registry.
**Example:**
```python
from django_components import ComponentExtension, OnRegistryDeletedContext
class MyExtension(ComponentExtension):
def on_registry_deleted(self, ctx: OnRegistryDeletedContext) -> None:
# Remove registry from the extension's cache on deletion
self.cache.pop(ctx.registry, None)
```
"""
pass
def on_component_registered(self, ctx: OnComponentRegisteredContext) -> None:
"""
Called when a [`Component`](../api#django_components.Component) class is
registered with a [`ComponentRegistry`](../api#django_components.ComponentRegistry).
This hook is called after a [`Component`](../api#django_components.Component) class
is successfully registered.
**Example:**
```python
from django_components import ComponentExtension, OnComponentRegisteredContext
class MyExtension(ComponentExtension):
def on_component_registered(self, ctx: OnComponentRegisteredContext) -> None:
print(f"Component {ctx.component_cls} registered to {ctx.registry} as '{ctx.name}'")
```
"""
pass
def on_component_unregistered(self, ctx: OnComponentUnregisteredContext) -> None:
"""
Called when a [`Component`](../api#django_components.Component) class is
unregistered from a [`ComponentRegistry`](../api#django_components.ComponentRegistry).
This hook is called after a [`Component`](../api#django_components.Component) class
is removed from the registry.
**Example:**
```python
from django_components import ComponentExtension, OnComponentUnregisteredContext
class MyExtension(ComponentExtension):
def on_component_unregistered(self, ctx: OnComponentUnregisteredContext) -> None:
print(f"Component {ctx.component_cls} unregistered from {ctx.registry} as '{ctx.name}'")
```
"""
pass
###########################
# Component render hooks
###########################
def on_component_input(self, ctx: OnComponentInputContext) -> None:
"""
Called when a [`Component`](../api#django_components.Component) was triggered to render,
but before a component's context and data methods are invoked.
This hook is called before
[`Component.get_context_data()`](../api#django_components.Component.get_context_data),
[`Component.get_js_data()`](../api#django_components.Component.get_js_data)
and [`Component.get_css_data()`](../api#django_components.Component.get_css_data).
Use this hook to modify or validate component inputs before they're processed.
**Example:**
```python
from django_components import ComponentExtension, OnComponentInputContext
class MyExtension(ComponentExtension):
def on_component_input(self, ctx: OnComponentInputContext) -> None:
# Add extra kwarg to all components when they are rendered
ctx.kwargs["my_input"] = "my_value"
```
"""
pass
def on_component_data(self, ctx: OnComponentDataContext) -> None:
"""
Called when a Component was triggered to render, after a component's context
and data methods have been processed.
This hook is called after
[`Component.get_context_data()`](../api#django_components.Component.get_context_data),
[`Component.get_js_data()`](../api#django_components.Component.get_js_data)
and [`Component.get_css_data()`](../api#django_components.Component.get_css_data).
This hook runs after [`on_component_input`](../api#django_components.ComponentExtension.on_component_input).
Use this hook to modify or validate the component's data before rendering.
**Example:**
```python
from django_components import ComponentExtension, OnComponentDataContext
class MyExtension(ComponentExtension):
def on_component_data(self, ctx: OnComponentDataContext) -> None:
# Add extra template variable to all components when they are rendered
ctx.context_data["my_template_var"] = "my_value"
```
"""
pass
# Decorator to store events in `ExtensionManager._events` when django_components is not yet initialized.
def store_events(func: TCallable) -> TCallable:
fn_name = func.__name__
@wraps(func)
def wrapper(self: "ExtensionManager", ctx: Any) -> Any:
if not self._initialized:
self._events.append((fn_name, ctx))
return
return func(self, ctx)
return wrapper # type: ignore[return-value]
# Manage all extensions from a single place
class ExtensionManager:
###########################
# Internal
###########################
_initialized = False
_events: List[Tuple[str, Any]] = []
@property
def extensions(self) -> List[ComponentExtension]:
return app_settings.EXTENSIONS
def _init_component_class(self, component_cls: Type["Component"]) -> None:
# If not yet initialized, this class will be initialized later once we run `_init_app`
if not self._initialized:
return
for extension in self.extensions:
ext_class_name = extension.class_name
# If a Component class has an extension class, e.g.
# ```python
# class MyComp(Component):
# class MyExtension:
# ...
# ```
# then create a dummy class to make `MyComp.MyExtension` extend
# the base class `extension.ExtensionClass`.
#
# So it will be same as if the user had directly inherited from `extension.ExtensionClass`.
# ```python
# class MyComp(Component):
# class MyExtension(MyExtension.ExtensionClass):
# ...
# ```
component_ext_subclass = getattr(component_cls, ext_class_name, None)
# Add escape hatch, so that user can override the extension class
# from within the component class. E.g.:
# ```python
# class MyExtDifferentStillSame(MyExtension.ExtensionClass):
# ...
#
# class MyComp(Component):
# my_extension_class = MyExtDifferentStillSame
# class MyExtension:
# ...
# ```
#
# Will be effectively the same as:
# ```python
# class MyComp(Component):
# class MyExtension(MyExtDifferentStillSame):
# ...
# ```
ext_class_override_attr = extension.name + "_class" # "my_extension_class"
ext_base_class = getattr(component_cls, ext_class_override_attr, extension.ExtensionClass)
if component_ext_subclass:
bases: tuple[Type, ...] = (component_ext_subclass, ext_base_class)
else:
bases = (ext_base_class,)
component_ext_subclass = type(ext_class_name, bases, {})
# Finally, reassign the new class extension class on the component class.
setattr(component_cls, ext_class_name, component_ext_subclass)
def _init_component_instance(self, component: "Component") -> None:
# Each extension has different class defined nested on the Component class:
# ```python
# class MyComp(Component):
# class MyExtension:
# ...
# class MyOtherExtension:
# ...
# ```
#
# We instantiate them all, passing the component instance to each. These are then
# available under the extension name on the component instance.
# ```python
# component.my_extension
# component.my_other_extension
# ```
for extension in self.extensions:
# NOTE: `_init_component_class` creates extension-specific nested classes
# on the created component classes, e.g.:
# ```py
# class MyComp(Component):
# class MyExtension:
# ...
# ```
# It should NOT happen in production, but in tests it may happen, if some extensions
# are test-specific, then the built-in component classes (like DynamicComponent) will
# be initialized BEFORE the extension is set in the settings. As such, they will be missing
# the nested class. In that case, we retroactively create the extension-specific nested class,
# so that we may proceed.
if not hasattr(component, extension.class_name):
self._init_component_class(component.__class__)
used_ext_class = getattr(component, extension.class_name)
extension_instance = used_ext_class(component)
setattr(component, extension.name, extension_instance)
# The triggers for following hooks may occur before the `apps.py` `ready()` hook is called.
# - on_component_class_created
# - on_component_class_deleted
# - on_registry_created
# - on_registry_deleted
# - on_component_registered
# - on_component_unregistered
#
# The problem is that the extensions are set up only at the initialization (`ready()` hook in `apps.py`).
#
# So in the case that these hooks are triggered before initialization,
# we store these "events" in a list, and then "flush" them all when `ready()` is called.
#
# This way, we can ensure that all extensions are present before any hooks are called.
def _init_app(self) -> None:
if self._initialized:
return
self._initialized = True
for hook, data in self._events:
if hook == "on_component_class_created":
on_component_created_data: OnComponentClassCreatedContext = data
self._init_component_class(on_component_created_data.component_cls)
getattr(self, hook)(data)
self._events = []
#############################
# Component lifecycle hooks
#############################
@store_events
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
for extension in self.extensions:
extension.on_component_class_created(ctx)
@store_events
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
for extension in self.extensions:
extension.on_component_class_deleted(ctx)
@store_events
def on_registry_created(self, ctx: OnRegistryCreatedContext) -> None:
for extension in self.extensions:
extension.on_registry_created(ctx)
@store_events
def on_registry_deleted(self, ctx: OnRegistryDeletedContext) -> None:
for extension in self.extensions:
extension.on_registry_deleted(ctx)
@store_events
def on_component_registered(self, ctx: OnComponentRegisteredContext) -> None:
for extension in self.extensions:
extension.on_component_registered(ctx)
@store_events
def on_component_unregistered(self, ctx: OnComponentUnregisteredContext) -> None:
for extension in self.extensions:
extension.on_component_unregistered(ctx)
###########################
# Component render hooks
###########################
def on_component_input(self, ctx: OnComponentInputContext) -> None:
for extension in self.extensions:
extension.on_component_input(ctx)
def on_component_data(self, ctx: OnComponentDataContext) -> None:
for extension in self.extensions:
extension.on_component_data(ctx)
# NOTE: This is a singleton which is takes the extensions from `app_settings.EXTENSIONS`
extensions = ExtensionManager()

View file

@ -0,0 +1,80 @@
from typing import TYPE_CHECKING, Any, Protocol, cast
from django.http import HttpRequest, HttpResponse
from django.views.generic import View
from django_components.extension import BaseExtensionClass, ComponentExtension
if TYPE_CHECKING:
from django_components.component import Component
class ViewFn(Protocol):
def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704
class ComponentView(BaseExtensionClass, View):
"""
Subclass of `django.views.View` where the `Component` instance is available
via `self.component`.
"""
# NOTE: This attribute must be declared on the class for `View.as_view()` to allow
# us to pass `component` kwarg.
component = cast("Component", None)
def __init__(self, component: "Component", **kwargs: Any) -> None:
BaseExtensionClass.__init__(self, component)
View.__init__(self, **kwargs)
# NOTE: The methods below are defined to satisfy the `View` class. All supported methods
# are defined in `View.http_method_names`.
#
# Each method actually delegates to the component's method of the same name.
# E.g. When `get()` is called, it delegates to `component.get()`.
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "get")(request, *args, **kwargs)
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "post")(request, *args, **kwargs)
def put(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "put")(request, *args, **kwargs)
def patch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "patch")(request, *args, **kwargs)
def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "delete")(request, *args, **kwargs)
def head(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "head")(request, *args, **kwargs)
def options(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "options")(request, *args, **kwargs)
def trace(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
component: "Component" = self.component
return getattr(component, "trace")(request, *args, **kwargs)
class ViewExtension(ComponentExtension):
"""
This extension adds a nested `View` class to each `Component`.
This nested class is a subclass of `django.views.View`, and allows the component
to be used as a view by calling `ComponentView.as_view()`.
This extension is automatically added to all components.
"""
name = "view"
ExtensionClass = ComponentView

View file

@ -28,6 +28,10 @@ def is_str_wrapped_in_quotes(s: str) -> bool:
return s.startswith(('"', "'")) and s[0] == s[-1] and len(s) >= 2
def snake_to_pascal(name: str) -> str:
return "".join(word.title() for word in name.split("_"))
def is_identifier(value: Any) -> bool:
if not isinstance(value, str):
return False

View file

@ -1,3 +1,4 @@
import gc
import inspect
import sys
from functools import wraps
@ -12,6 +13,7 @@ from django.test import override_settings
from django_components.component import ALL_COMPONENTS, Component, component_node_subclasses_by_name
from django_components.component_media import ComponentMedia
from django_components.component_registry import ALL_REGISTRIES, ComponentRegistry
from django_components.extension import extensions
# NOTE: `ReferenceType` is NOT a generic pre-3.9
if sys.version_info >= (3, 9):
@ -100,6 +102,7 @@ def djc_test(
],
]
] = None,
gc_collect: bool = True,
) -> Callable:
"""
Decorator for testing components from django-components.
@ -237,6 +240,9 @@ def djc_test(
...
```
- `gc_collect`: By default `djc_test` runs garbage collection after each test to force the state cleanup.
Set this to `False` to skip this.
**Settings resolution:**
`@djc_test` accepts settings from different sources. The settings are resolved in the following order:
@ -328,6 +334,7 @@ def djc_test(
csrf_token_patcher,
_ALL_COMPONENTS, # type: ignore[arg-type]
_ALL_REGISTRIES_COPIES,
gc_collect,
)
try:
@ -431,6 +438,7 @@ def _setup_djc_global_state(
from django_components.app_settings import app_settings
app_settings._load_settings()
extensions._init_app()
def _clear_djc_global_state(
@ -438,6 +446,7 @@ def _clear_djc_global_state(
csrf_token_patcher: CsrfTokenPatcher,
initial_components: InitialComponents,
initial_registries_copies: RegistriesCopies,
gc_collect: bool = False,
) -> None:
gen_id_patcher.stop()
csrf_token_patcher.stop()
@ -485,14 +494,16 @@ def _clear_djc_global_state(
for index in range(all_comps_len):
reverse_index = all_comps_len - index - 1
comp_cls_ref = ALL_COMPONENTS[reverse_index]
if comp_cls_ref not in initial_components_set:
is_ref_deleted = comp_cls_ref() is None
if is_ref_deleted or comp_cls_ref not in initial_components_set:
del ALL_COMPONENTS[reverse_index]
# Remove registries that were created during the test
initial_registries_set: Set[RegistryRef] = set([reg_ref for reg_ref, init_keys in initial_registries_copies])
for index in range(len(ALL_REGISTRIES)):
registry_ref = ALL_REGISTRIES[len(ALL_REGISTRIES) - index - 1]
if registry_ref not in initial_registries_set:
is_ref_deleted = registry_ref() is None
if is_ref_deleted or registry_ref not in initial_registries_set:
del ALL_REGISTRIES[len(ALL_REGISTRIES) - index - 1]
# For the remaining registries, unregistr components that were registered
@ -521,5 +532,11 @@ def _clear_djc_global_state(
sys.modules.pop(mod, None)
LOADED_MODULES.clear()
# Force garbage collection, so that any finalizers are run.
# If garbage collection is skipped, then in some cases the finalizers
# are run too late, in the context of the next test, causing flaky tests.
if gc_collect:
gc.collect()
global IS_TESTING
IS_TESTING = False

View file

@ -2,7 +2,7 @@ import sys
from typing import Any, Dict, TypeVar, overload
from weakref import ReferenceType, finalize, ref
GLOBAL_REFS: Dict[Any, ReferenceType] = {}
GLOBAL_REFS: Dict[int, ReferenceType] = {}
T = TypeVar("T")
@ -16,14 +16,15 @@ if sys.version_info >= (3, 9):
def cached_ref(obj: Any) -> ReferenceType:
"""
Same as `weakref.ref()`, creating a weak reference to a given objet.
Same as `weakref.ref()`, creating a weak reference to a given object.
But unlike `weakref.ref()`, this function also caches the result,
so it returns the same reference for the same object.
"""
if obj not in GLOBAL_REFS:
GLOBAL_REFS[obj] = ref(obj)
obj_id = id(obj)
if obj_id not in GLOBAL_REFS:
GLOBAL_REFS[obj_id] = ref(obj)
# Remove this entry from GLOBAL_REFS when the object is deleted.
finalize(obj, lambda: GLOBAL_REFS.pop(obj))
finalize(obj, lambda: GLOBAL_REFS.pop(obj_id, None))
return GLOBAL_REFS[obj]
return GLOBAL_REFS[obj_id]