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.
- `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
- 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.get("card") # CardComponent
# Check if component is registered
registry.has("button") # True
# Unregister single component
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.
### 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
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:
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
options:
show_if_no_docstring: true

View file

@ -15,7 +15,7 @@ from django_components.util.command import (
CommandSubcommand,
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_registry import (
AlreadyRegistered,
@ -24,6 +24,7 @@ from django_components.component_registry import (
RegistrySettings,
register,
registry,
all_registries,
)
from django_components.components import DynamicComponent
from django_components.dependencies import render_dependencies
@ -60,6 +61,8 @@ from django_components.util.types import EmptyTuple, EmptyDict
__all__ = [
"all_components",
"all_registries",
"AlreadyRegistered",
"autodiscover",
"BaseNode",

View file

@ -111,7 +111,7 @@ CssDataType = TypeVar("CssDataType", bound=Mapping[str, Any])
# NOTE: `ReferenceType` is NOT a generic pre-3.9
if sys.version_info >= (3, 9):
AllComponents = List[ReferenceType["Component"]]
AllComponents = List[ReferenceType[Type["Component"]]]
else:
AllComponents = List[ReferenceType]
@ -120,6 +120,16 @@ else:
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)
class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
context: Context

View file

@ -5,6 +5,7 @@ from copy import copy
from dataclasses import dataclass
from pathlib import Path
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.core.exceptions import ImproperlyConfigured
@ -398,7 +399,11 @@ def _get_comp_cls_attr(comp_cls: Type["Component"], attr: str) -> Any:
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:

View file

@ -150,6 +150,18 @@ class InternalRegistrySettings(NamedTuple):
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:
"""
Manages [components](../api#django_components.Component) and makes them available
@ -201,7 +213,8 @@ class ComponentRegistry:
registry.register("card", CardComponent)
registry.all()
registry.clear()
registry.get()
registry.get("button")
registry.has("button")
```
# Using registry to share components
@ -455,6 +468,29 @@ class ComponentRegistry:
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"]]:
"""
Retrieve all registered [`Component`](../api#django_components.Component) classes.
@ -563,6 +599,9 @@ registry.get("button")
# Get all
registry.all()
# Check if component is registered
registry.has("button")
# Unregister single
registry.unregister("button")

View file

@ -103,6 +103,11 @@ class OnComponentDataContext(NamedTuple):
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:
self.component = component
@ -116,6 +121,10 @@ class ComponentExtension:
Read more on [Extensions](../../concepts/advanced/extensions).
"""
###########################
# USER INPUT
###########################
name: str
"""
Name of the extension.
@ -255,6 +264,10 @@ class ComponentExtension:
urls: List[URLRoute] = []
###########################
# Misc
###########################
def __init_subclass__(cls) -> None:
if not cls.name.isidentifier():
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)
else:
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.
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
SlotContent = Union[SlotResult, SlotFunc[TSlotData], "Slot[TSlotData]"]
@dataclass
class Slot(Generic[TSlotData]):
"""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}>"
# 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
SlotName = str

View file

@ -265,7 +265,7 @@ def djc_test(
""" # noqa: E501
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
# individually.
# 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 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.urls import urlpatterns as dc_urlpatterns
@ -1497,3 +1497,28 @@ class TestComponentHook:
assert context_in_before == context_in_after
assert "from_on_before" in context_in_before # 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):
class TestComponent(Component):
class TempComponent(Component):
template = "Hello {{ name }}!"
def get_context_data(self, name="World"):
@ -124,11 +124,27 @@ def with_registry(on_created: Callable):
@djc_test
class TestExtension:
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_extensios_setting(self):
def test_extensions_setting(self):
assert len(app_settings.EXTENSIONS) == 2
assert isinstance(app_settings.EXTENSIONS[0], ViewExtension)
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
class TestExtensionHooks:
@ -147,7 +163,7 @@ class TestExtensionHooks:
# Verify on_component_class_created was called
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
# this test function, so we can garbage collect it after the function returns
@ -158,8 +174,11 @@ class TestExtensionHooks:
gc.collect()
# Verify on_component_class_deleted was called
assert len(extension.calls["on_component_class_deleted"]) == 1
assert extension.calls["on_component_class_deleted"][0] == "TestComponent"
# NOTE: The previous test, `test_access_component_from_extension`, is sometimes
# 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]})
def test_registry_lifecycle_hooks(self):

View file

@ -9,6 +9,7 @@ from django_components import (
NotRegistered,
RegistrySettings,
TagProtectedError,
all_registries,
component_formatter,
component_shorthand_formatter,
register,
@ -39,14 +40,18 @@ class MockComponentView(Component):
@djc_test
class TestComponentRegistry:
def test_register_class_decorator(self):
assert not registry.has("decorated_component")
@register("decorated_component")
class TestComponent(Component):
pass
assert registry.has("decorated_component")
assert registry.get("decorated_component") == TestComponent
# Cleanup
registry.unregister("decorated_component")
assert not registry.has("decorated_component")
def test_register_class_decorator_custom_registry(self):
my_lib = Library()
@ -245,3 +250,18 @@ class TestProtectedTags:
# Cleanup
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