feat: registry.has(); helpers to get all components and registries; access component from ext class (#1030)

* feat: registry.has(); helpers to get all components and registries; access component from ext class

* refactor: add missing import
This commit is contained in:
Juro Oravec 2025-03-18 11:30:53 +01:00 committed by GitHub
parent 944bef2d95
commit 107284f474
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 217 additions and 15 deletions

View file

@ -28,6 +28,13 @@
See the API reference for [`@djc_test`](https://django-components.github.io/django-components/0.131/reference/testing_api/#djc_test) for more details. See the API reference for [`@djc_test`](https://django-components.github.io/django-components/0.131/reference/testing_api/#djc_test) for more details.
- `ComponentRegistry` now has a `has()` method to check if a component is registered
without raising an error.
- Get all created `Component` classes with `all_components()`.
- Get all created `ComponentRegistry` instances with `all_registries()`.
#### Refactor #### Refactor
- The `startcomponent` and `upgradecomponent` commands are deprecated, and will be removed in v1. - The `startcomponent` and `upgradecomponent` commands are deprecated, and will be removed in v1.

View file

@ -80,6 +80,9 @@ registry.register("card", CardComponent)
registry.all() # {"button": ButtonComponent, "card": CardComponent} registry.all() # {"button": ButtonComponent, "card": CardComponent}
registry.get("card") # CardComponent registry.get("card") # CardComponent
# Check if component is registered
registry.has("button") # True
# Unregister single component # Unregister single component
registry.unregister("card") registry.unregister("card")

View file

@ -330,6 +330,51 @@ class MyComponent(Component):
This will log the component name and color when the component is created, deleted, or rendered. This will log the component name and color when the component is created, deleted, or rendered.
### Utility functions
django-components provides a few utility functions to help with writing extensions:
- [`all_components()`](../../../reference/api#django_components.all_components) - returns a list of all created component classes.
- [`all_registries()`](../../../reference/api#django_components.all_registries) - returns a list of all created registry instances.
### Accessing the component class from within an extension
When you are writing the extension class that will be nested inside a Component class, e.g.
```py
class MyTable(Component):
class MyExtension:
def some_method(self):
...
```
You can access the owner Component class (`MyTable`) from within methods of the extension class (`MyExtension`) by using the `component_class` attribute:
```py
class MyTable(Component):
class MyExtension:
def some_method(self):
print(self.component_class)
```
Here is how the `component_class` attribute may be used with our `ColorLogger`
extension shown above:
```python
class ColorLoggerExtensionClass(ComponentExtension.ExtensionClass):
color: str
def log(self, msg: str) -> None:
print(f"{self.component_class.name}: {msg}")
class ColorLoggerExtension(ComponentExtension):
name = "color_logger"
# All `Component.ColorLogger` classes will inherit from this class.
ExtensionClass = ColorLoggerExtensionClass
```
## Extension Commands ## Extension Commands
Extensions in django-components can define custom commands that can be executed via the Django management command interface. This allows for powerful automation and customization capabilities. Extensions in django-components can define custom commands that can be executed via the Django management command interface. This allows for powerful automation and customization capabilities.

View file

@ -155,6 +155,14 @@
options: options:
show_if_no_docstring: true show_if_no_docstring: true
::: django_components.all_components
options:
show_if_no_docstring: true
::: django_components.all_registries
options:
show_if_no_docstring: true
::: django_components.autodiscover ::: django_components.autodiscover
options: options:
show_if_no_docstring: true show_if_no_docstring: true

View file

@ -15,7 +15,7 @@ from django_components.util.command import (
CommandSubcommand, CommandSubcommand,
ComponentCommand, ComponentCommand,
) )
from django_components.component import Component, ComponentVars from django_components.component import Component, ComponentVars, all_components
from django_components.component_media import ComponentMediaInput, ComponentMediaInputPath from django_components.component_media import ComponentMediaInput, ComponentMediaInputPath
from django_components.component_registry import ( from django_components.component_registry import (
AlreadyRegistered, AlreadyRegistered,
@ -24,6 +24,7 @@ from django_components.component_registry import (
RegistrySettings, RegistrySettings,
register, register,
registry, registry,
all_registries,
) )
from django_components.components import DynamicComponent from django_components.components import DynamicComponent
from django_components.dependencies import render_dependencies from django_components.dependencies import render_dependencies
@ -60,6 +61,8 @@ from django_components.util.types import EmptyTuple, EmptyDict
__all__ = [ __all__ = [
"all_components",
"all_registries",
"AlreadyRegistered", "AlreadyRegistered",
"autodiscover", "autodiscover",
"BaseNode", "BaseNode",

View file

@ -111,7 +111,7 @@ CssDataType = TypeVar("CssDataType", bound=Mapping[str, Any])
# NOTE: `ReferenceType` is NOT a generic pre-3.9 # NOTE: `ReferenceType` is NOT a generic pre-3.9
if sys.version_info >= (3, 9): if sys.version_info >= (3, 9):
AllComponents = List[ReferenceType["Component"]] AllComponents = List[ReferenceType[Type["Component"]]]
else: else:
AllComponents = List[ReferenceType] AllComponents = List[ReferenceType]
@ -120,6 +120,16 @@ else:
ALL_COMPONENTS: AllComponents = [] ALL_COMPONENTS: AllComponents = []
def all_components() -> List[Type["Component"]]:
"""Get a list of all created [`Component`](../api#django_components.Component) classes."""
components: List[Type["Component"]] = []
for comp_ref in ALL_COMPONENTS:
comp = comp_ref()
if comp is not None:
components.append(comp)
return components
@dataclass(frozen=True) @dataclass(frozen=True)
class RenderInput(Generic[ArgsType, KwargsType, SlotsType]): class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
context: Context context: Context

View file

@ -5,6 +5,7 @@ from copy import copy
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Protocol, Tuple, Type, Union, cast from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Protocol, Tuple, Type, Union, cast
from weakref import WeakKeyDictionary
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
@ -398,7 +399,11 @@ def _get_comp_cls_attr(comp_cls: Type["Component"], attr: str) -> Any:
return None return None
media_cache: Dict[Type["Component"], MediaCls] = {} # NOTE: We use weakref to avoid issues with lingering references.
if sys.version_info >= (3, 9):
media_cache: WeakKeyDictionary[Type["Component"], MediaCls] = WeakKeyDictionary()
else:
media_cache: WeakKeyDictionary = WeakKeyDictionary()
def _get_comp_cls_media(comp_cls: Type["Component"]) -> Any: def _get_comp_cls_media(comp_cls: Type["Component"]) -> Any:

View file

@ -150,6 +150,18 @@ class InternalRegistrySettings(NamedTuple):
ALL_REGISTRIES: AllRegistries = [] ALL_REGISTRIES: AllRegistries = []
def all_registries() -> List["ComponentRegistry"]:
"""
Get a list of all created [`ComponentRegistry`](../api#django_components.ComponentRegistry) instances.
"""
registries: List["ComponentRegistry"] = []
for reg_ref in ALL_REGISTRIES:
reg = reg_ref()
if reg is not None:
registries.append(reg)
return registries
class ComponentRegistry: class ComponentRegistry:
""" """
Manages [components](../api#django_components.Component) and makes them available Manages [components](../api#django_components.Component) and makes them available
@ -201,7 +213,8 @@ class ComponentRegistry:
registry.register("card", CardComponent) registry.register("card", CardComponent)
registry.all() registry.all()
registry.clear() registry.clear()
registry.get() registry.get("button")
registry.has("button")
``` ```
# Using registry to share components # Using registry to share components
@ -455,6 +468,29 @@ class ComponentRegistry:
return self._registry[name].cls return self._registry[name].cls
def has(self, name: str) -> bool:
"""
Check if a [`Component`](../api#django_components.Component)
class is registered under the given name.
Args:
name (str): The name under which the component was registered. Required.
Returns:
bool: `True` if the component is registered, `False` otherwise.
**Example:**
```python
# First register component
registry.register("button", ButtonComponent)
# Then check
registry.has("button")
# > True
```
"""
return name in self._registry
def all(self) -> Dict[str, Type["Component"]]: def all(self) -> Dict[str, Type["Component"]]:
""" """
Retrieve all registered [`Component`](../api#django_components.Component) classes. Retrieve all registered [`Component`](../api#django_components.Component) classes.
@ -563,6 +599,9 @@ registry.get("button")
# Get all # Get all
registry.all() registry.all()
# Check if component is registered
registry.has("button")
# Unregister single # Unregister single
registry.unregister("button") registry.unregister("button")

View file

@ -103,6 +103,11 @@ class OnComponentDataContext(NamedTuple):
class BaseExtensionClass: class BaseExtensionClass:
"""Base class for all extension classes."""
component_class: Type["Component"]
"""The Component class that this extension is defined on."""
def __init__(self, component: "Component") -> None: def __init__(self, component: "Component") -> None:
self.component = component self.component = component
@ -116,6 +121,10 @@ class ComponentExtension:
Read more on [Extensions](../../concepts/advanced/extensions). Read more on [Extensions](../../concepts/advanced/extensions).
""" """
###########################
# USER INPUT
###########################
name: str name: str
""" """
Name of the extension. Name of the extension.
@ -255,6 +264,10 @@ class ComponentExtension:
urls: List[URLRoute] = [] urls: List[URLRoute] = []
###########################
# Misc
###########################
def __init_subclass__(cls) -> None: def __init_subclass__(cls) -> None:
if not cls.name.isidentifier(): if not cls.name.isidentifier():
raise ValueError(f"Extension name must be a valid Python identifier, got {cls.name}") raise ValueError(f"Extension name must be a valid Python identifier, got {cls.name}")
@ -532,7 +545,10 @@ class ExtensionManager:
bases: tuple[Type, ...] = (component_ext_subclass, ext_base_class) bases: tuple[Type, ...] = (component_ext_subclass, ext_base_class)
else: else:
bases = (ext_base_class,) bases = (ext_base_class,)
component_ext_subclass = type(ext_class_name, bases, {})
# 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})
# Finally, reassign the new class extension class on the component class. # Finally, reassign the new class extension class on the component class.
setattr(component_cls, ext_class_name, component_ext_subclass) setattr(component_cls, ext_class_name, component_ext_subclass)

View file

@ -56,9 +56,6 @@ class SlotFunc(Protocol, Generic[TSlotData]):
def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotRef") -> SlotResult: ... # noqa E704 def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotRef") -> SlotResult: ... # noqa E704
SlotContent = Union[SlotResult, SlotFunc[TSlotData], "Slot[TSlotData]"]
@dataclass @dataclass
class Slot(Generic[TSlotData]): class Slot(Generic[TSlotData]):
"""This class holds the slot content function along with related metadata.""" """This class holds the slot content function along with related metadata."""
@ -100,6 +97,11 @@ class Slot(Generic[TSlotData]):
return f"<{self.__class__.__name__} component_name={comp_name} slot_name={slot_name}>" return f"<{self.__class__.__name__} component_name={comp_name} slot_name={slot_name}>"
# NOTE: This must be defined here, so we don't have any forward references
# otherwise Pydantic has problem resolving the types.
SlotContent = Union[SlotResult, SlotFunc[TSlotData], Slot[TSlotData]]
# Internal type aliases # Internal type aliases
SlotName = str SlotName = str

View file

@ -265,7 +265,7 @@ def djc_test(
""" # noqa: E501 """ # noqa: E501
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
if isinstance(func, type): if isinstance(func, type) and func.__name__.lower().startswith("test"):
# If `djc_test` is applied to a class, we need to apply it to each test method # If `djc_test` is applied to a class, we need to apply it to each test method
# individually. # individually.
# The rest of this function addresses `func` being a function # The rest of this function addresses `func` being a function

View file

@ -26,7 +26,7 @@ from django.urls import path
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from pytest_django.asserts import assertHTMLEqual, assertInHTML from pytest_django.asserts import assertHTMLEqual, assertInHTML
from django_components import Component, ComponentView, Slot, SlotFunc, register, types from django_components import Component, ComponentView, Slot, SlotFunc, all_components, register, types
from django_components.slots import SlotRef from django_components.slots import SlotRef
from django_components.urls import urlpatterns as dc_urlpatterns from django_components.urls import urlpatterns as dc_urlpatterns
@ -1497,3 +1497,28 @@ class TestComponentHook:
assert context_in_before == context_in_after assert context_in_before == context_in_after
assert "from_on_before" in context_in_before # type: ignore[operator] assert "from_on_before" in context_in_before # type: ignore[operator]
assert "from_on_after" in context_in_after # type: ignore[operator] assert "from_on_after" in context_in_after # type: ignore[operator]
@djc_test
class TestComponentHelpers:
def test_all_components(self):
# NOTE: When running all tests, this list may already have some components
# as some components in test files are defined on module level, outside of
# `djc_test` decorator.
all_comps_before = len(all_components())
# Components don't have to be registered to be included in the list
class TestComponent(Component):
template: types.django_html = """
Hello from test
"""
assert len(all_components()) == all_comps_before + 1
@register("test2")
class Test2Component(Component):
template: types.django_html = """
Hello from test2
"""
assert len(all_components()) == all_comps_before + 2

View file

@ -106,7 +106,7 @@ class DummyNestedExtension(ComponentExtension):
def with_component_cls(on_created: Callable): def with_component_cls(on_created: Callable):
class TestComponent(Component): class TempComponent(Component):
template = "Hello {{ name }}!" template = "Hello {{ name }}!"
def get_context_data(self, name="World"): def get_context_data(self, name="World"):
@ -124,11 +124,27 @@ def with_registry(on_created: Callable):
@djc_test @djc_test
class TestExtension: class TestExtension:
@djc_test(components_settings={"extensions": [DummyExtension]}) @djc_test(components_settings={"extensions": [DummyExtension]})
def test_extensios_setting(self): def test_extensions_setting(self):
assert len(app_settings.EXTENSIONS) == 2 assert len(app_settings.EXTENSIONS) == 2
assert isinstance(app_settings.EXTENSIONS[0], ViewExtension) assert isinstance(app_settings.EXTENSIONS[0], ViewExtension)
assert isinstance(app_settings.EXTENSIONS[1], DummyExtension) assert isinstance(app_settings.EXTENSIONS[1], DummyExtension)
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_access_component_from_extension(self):
class TestAccessComp(Component):
template = "Hello {{ name }}!"
def get_context_data(self, arg1, arg2, name="World"):
return {"name": name}
ext_class = TestAccessComp.TestExtension # type: ignore[attr-defined]
assert issubclass(ext_class, ComponentExtension.ExtensionClass)
assert ext_class.component_class is TestAccessComp
# NOTE: Required for test_component_class_lifecycle_hooks to work
del TestAccessComp
gc.collect()
@djc_test @djc_test
class TestExtensionHooks: class TestExtensionHooks:
@ -147,7 +163,7 @@ class TestExtensionHooks:
# Verify on_component_class_created was called # Verify on_component_class_created was called
assert len(extension.calls["on_component_class_created"]) == 1 assert len(extension.calls["on_component_class_created"]) == 1
assert extension.calls["on_component_class_created"][0] == "TestComponent" assert extension.calls["on_component_class_created"][0] == "TempComponent"
# Create a component class in a separate scope, to avoid any references from within # Create a component class in a separate scope, to avoid any references from within
# this test function, so we can garbage collect it after the function returns # this test function, so we can garbage collect it after the function returns
@ -158,8 +174,11 @@ class TestExtensionHooks:
gc.collect() gc.collect()
# Verify on_component_class_deleted was called # Verify on_component_class_deleted was called
assert len(extension.calls["on_component_class_deleted"]) == 1 # NOTE: The previous test, `test_access_component_from_extension`, is sometimes
assert extension.calls["on_component_class_deleted"][0] == "TestComponent" # garbage-collected too late, in which case it's included in `on_component_class_deleted`.
# So in the test we check only for the last call.
assert len(extension.calls["on_component_class_deleted"]) >= 1
assert extension.calls["on_component_class_deleted"][-1] == "TempComponent"
@djc_test(components_settings={"extensions": [DummyExtension]}) @djc_test(components_settings={"extensions": [DummyExtension]})
def test_registry_lifecycle_hooks(self): def test_registry_lifecycle_hooks(self):

View file

@ -9,6 +9,7 @@ from django_components import (
NotRegistered, NotRegistered,
RegistrySettings, RegistrySettings,
TagProtectedError, TagProtectedError,
all_registries,
component_formatter, component_formatter,
component_shorthand_formatter, component_shorthand_formatter,
register, register,
@ -39,14 +40,18 @@ class MockComponentView(Component):
@djc_test @djc_test
class TestComponentRegistry: class TestComponentRegistry:
def test_register_class_decorator(self): def test_register_class_decorator(self):
assert not registry.has("decorated_component")
@register("decorated_component") @register("decorated_component")
class TestComponent(Component): class TestComponent(Component):
pass pass
assert registry.has("decorated_component")
assert registry.get("decorated_component") == TestComponent assert registry.get("decorated_component") == TestComponent
# Cleanup # Cleanup
registry.unregister("decorated_component") registry.unregister("decorated_component")
assert not registry.has("decorated_component")
def test_register_class_decorator_custom_registry(self): def test_register_class_decorator_custom_registry(self):
my_lib = Library() my_lib = Library()
@ -245,3 +250,18 @@ class TestProtectedTags:
# Cleanup # Cleanup
registry.unregister("sth_else") registry.unregister("sth_else")
@djc_test
class TestRegistryHelpers:
def test_all_registries(self):
# Default registry
assert len(all_registries()) == 1
reg = ComponentRegistry()
assert len(all_registries()) == 2
del reg
assert len(all_registries()) == 1