mirror of
https://github.com/django-components/django-components.git
synced 2025-08-09 16:57:59 +00:00
feat: extensions (#1009)
* feat: extensions * refactor: remove support for passing in extensions as instances
This commit is contained in:
parent
cff252c566
commit
4d35bc97a2
24 changed files with 1884 additions and 57 deletions
|
@ -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",
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
580
src/django_components/extension.py
Normal file
580
src/django_components/extension.py
Normal 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()
|
0
src/django_components/extensions/__init__.py
Normal file
0
src/django_components/extensions/__init__.py
Normal file
80
src/django_components/extensions/view.py
Normal file
80
src/django_components/extensions/view.py
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue