mirror of
https://github.com/django-components/django-components.git
synced 2025-09-26 15:39:08 +00:00
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:
parent
944bef2d95
commit
107284f474
14 changed files with 217 additions and 15 deletions
|
@ -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.
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue