refactor: prepare registry for custom template tags and docs (#566)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2024-08-05 22:31:49 +02:00 committed by GitHub
parent c202c5a901
commit d6dec450ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 491 additions and 94 deletions

1
.gitignore vendored
View file

@ -64,7 +64,6 @@ target/
# lock file is not needed for development
# as project supports variety of Django versions
poetry.lock
pyproject.toml
# PyCharm
.idea/

122
README.md
View file

@ -29,6 +29,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
- [Using single-file components](#using-single-file-components)
- [Use components in templates](#use-components-in-templates)
- [Use components outside of templates](#use-components-outside-of-templates)
- [Registering components](#registering-components)
- [Use components as views](#use-components-as-views)
- [Autodiscovery](#autodiscovery)
- [Using slots in templates](#using-slots-in-templates)
@ -643,6 +644,127 @@ Note: slots content are automatically escaped by default to prevent XSS attacks.
If you're planning on passing an HTML string, check Django's use of [`format_html`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.html.format_html) and [`mark_safe`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.safestring.mark_safe).
## Registering components
In previous examples you could repeatedly see us using `@register()` to "register"
the components. In this section we dive deeper into what it actually means and how you can
manage (add or remove) components.
As a reminder, we may have a component like this:
```python
from django_components import Component, register
@register("calendar")
class Calendar(Component):
template_name = "template.html"
# This component takes one parameter, a date string to show in the template
def get_context_data(self, date):
return {
"date": date,
}
```
which we then render in the template as:
```django
{% component "calendar" date="1970-01-01" %}
{% endcomponent %}
```
As you can see, `@register` links up the component class
with the `{% component %}` template tag. So when the template tag comes across
a component called `"calendar"`, it can look up it's class and instantiate it.
### What is ComponentRegistry
The `@register` decorator is a shortcut for working with the `ComponentRegistry`.
`ComponentRegistry` manages which components can be used in the template tags.
Each `ComponentRegistry` instance is associated with an instance
of Django's `Library`. And Libraries are inserted into Django template
using the `{% load %}` tags.
The `@register` decorator accepts an optional kwarg `registry`, which specifies, the `ComponentRegistry` to register components into.
If omitted, the default `ComponentRegistry` instance defined in django_components is used.
```py
my_registry = ComponentRegistry()
@register(registry=my_registry)
class MyComponent(Component):
...
```
The default `ComponentRegistry` is associated with the `Library` that
you load when you call `{% load component_tags %}` inside your template, or when you
add `django_components.templatetags.component_tags` to the template builtins.
So when you register or unregister a component to/from a component registry,
then behind the scenes the registry automatically adds/removes the component's
template tags to/from the Library, so you can call the component from within the templates
such as `{% component "my_comp" %}`.
### Working with ComponentRegistry
The default `ComponentRegistry` instance can be imported as:
```py
from django_components import registry
```
You can use the registry to manually add/remove/get components:
```py
from django_components import registry
# Register components
registry.register("button", ButtonComponent)
registry.register("card", CardComponent)
# Get all or single
registry.all() # {"button": ButtonComponent, "card": CardComponent}
registry.get("card") # CardComponent
# Unregister single component
registry.unregister("card")
# Unregister all components
registry.clear()
```
### Registering components to custom ComponentRegistry
In rare cases, you may want to manage your own instance of `ComponentRegistry`,
or register components onto a different `Library` instance than the default one.
The `Library` instance can be set at instantiation of `ComponentRegistry`. If omitted,
then the default Library instance from django_components is used.
```py
from django.template import Library
from django_components import ComponentRegistry
my_library = Library(...)
my_registry = ComponentRegistry(library=my_library)
```
When you have defined your own `ComponentRegistry`, you can either register the components
with `my_registry.register()`, or pass the registry to the `@component.register()` decorator
via the `registry` kwarg:
```py
from path.to.my.registry import my_registry
@register("my_component", registry=my_registry)
class MyComponent(Component):
...
```
NOTE: The Library instance can be accessed under `library` attribute of `ComponentRegistry`.
## Autodiscovery
Every component that you want to use in the template with the `{% component %}` tag needs to be registered with the ComponentRegistry. Normally, we use the `@register` decorator for that:

View file

@ -1,9 +1,23 @@
from typing import TYPE_CHECKING, Callable, Dict, Type, TypeVar
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, TypeVar
from django.template import Library
if TYPE_CHECKING:
from django_components import component
from django_components.component import Component
_TC = TypeVar("_TC", bound=Type["component.Component"])
_TComp = TypeVar("_TComp", bound=Type["Component"])
PROTECTED_TAGS = [
"component",
"component_dependencies",
"component_css_dependencies",
"component_js_dependencies",
"fill",
"html_attrs",
"provide",
"slot",
]
class AlreadyRegistered(Exception):
@ -14,50 +28,311 @@ class NotRegistered(Exception):
pass
class ComponentRegistry:
def __init__(self) -> None:
self._registry: Dict[str, Type["component.Component"]] = {} # component name -> component_class mapping
# Why do we store the tags with the component?
#
# Each component may be associated with two template tags - One for "block"
# and one for "inline" usage. E.g. in the following snippets, the template
# tags are `component` and `#component`:
#
# `{% component "abc" %}{% endcomponent %}`
# `{% #component "abc" %}`
#
# (NOTE: While `endcomponent` also looks like a template tag, we don't have to register
# it, because it simply marks the end of body.)
#
# With the component tag formatter (configurable tags per component class),
# each component may have a unique set of template tags.
#
# For user's convenience, we automatically add/remove the tags from Django's tag Library,
# when a component is (un)registered.
#
# Thus we need to remember which component used which template tags.
class ComponentRegistryEntry(NamedTuple):
cls: Type["Component"]
block_tag: str
inline_tag: str
def register(self, name: str, component: Type["component.Component"]) -> None:
@property
def tags(self) -> List[str]:
return [self.block_tag, self.inline_tag]
class ComponentRegistry:
"""
Manages which components can be used in the template tags.
Each ComponentRegistry instance is associated with an instance
of Django's Library. So when you register or unregister a component
to/from a component registry, behind the scenes the registry
automatically adds/removes the component's template tag to/from
the Library.
The Library instance can be set at instantiation. If omitted, then
the default Library instance from django_components is used. The
Library instance can be accessed under `library` attribute.
Example:
```py
# Use with default Library
registry = ComponentRegistry()
# Or a custom one
my_lib = Library()
registry = ComponentRegistry(library=my_lib)
# Usage
registry.register("button", ButtonComponent)
registry.register("card", CardComponent)
registry.all()
registry.clear()
registry.get()
```
"""
def __init__(self, library: Optional[Library] = None) -> None:
self._registry: Dict[str, ComponentRegistryEntry] = {} # component name -> component_entry mapping
self._tags: Dict[str, Set[str]] = {} # tag -> list[component names]
self._library = library
@property
def library(self) -> Library:
"""
The template tag library with which the component registry is associated.
"""
# Lazily use the default library if none was passed
if self._library is not None:
lib = self._library
else:
from django_components.templatetags.component_tags import register as tag_library
# For the default library, we want to protect our template tags from
# being overriden.
# On the other hand, if user provided their own Library instance,
# it is up to the user to use `mark_protected_tags` if they want
# to protect any tags.
mark_protected_tags(tag_library, PROTECTED_TAGS)
lib = self._library = tag_library
return lib
def register(self, name: str, component: Type["Component"]) -> None:
"""
Register a component with this registry under the given name.
A component MUST be registered before it can be used in a template such as:
```django
{% component "my_comp" %}{% endcomponent %}
```
Raises `AlreadyRegistered` if a different component was already registered
under the same name.
Example:
```py
registry.register("button", ButtonComponent)
```
"""
existing_component = self._registry.get(name)
if existing_component and existing_component._class_hash != component._class_hash:
if existing_component and existing_component.cls._class_hash != component._class_hash:
raise AlreadyRegistered('The component "%s" has already been registered' % name)
self._registry[name] = component
block_tag = "component"
inline_tag = "#component"
entry = ComponentRegistryEntry(
cls=component,
block_tag=block_tag,
inline_tag=inline_tag,
)
# Keep track of which components use which tags, because multiple components may
# use the same tag.
for tag in entry.tags:
if tag not in self._tags:
self._tags[tag] = set()
self._tags[tag].add(name)
self._registry[name] = entry
def unregister(self, name: str) -> None:
"""
Unlinks a previously-registered component from the registry under the given name.
Once a component is unregistered, it CANNOT be used in a template anymore.
Following would raise an error:
```django
{% component "my_comp" %}{% endcomponent %}
```
Raises `NotRegistered` if the given name is not registered.
Example:
```py
# First register component
registry.register("button", ButtonComponent)
# Then unregister
registry.unregister("button")
```
"""
# Validate
self.get(name)
entry = self._registry[name]
# Unregister the tag from library if this was the last component using this tag
for tag in entry.tags:
# Unlink component from tag
self._tags[tag].remove(name)
# Cleanup
is_tag_empty = not len(self._tags[tag])
if is_tag_empty:
del self._tags[tag]
# Do NOT unregister tag if it's protected
is_protected = is_tag_protected(self.library, tag)
if is_protected:
continue
# Unregister the tag from library if this was the last component using this tag
if is_tag_empty and tag in self.library.tags:
del self.library.tags[tag]
del self._registry[name]
def get(self, name: str) -> Type["component.Component"]:
def get(self, name: str) -> Type["Component"]:
"""
Retrieve a component class registered under the given name.
Raises `NotRegistered` if the given name is not registered.
Example:
```py
# First register component
registry.register("button", ButtonComponent)
# Then get
registry.get("button")
# > ButtonComponent
```
"""
if name not in self._registry:
raise NotRegistered('The component "%s" is not registered' % name)
return self._registry[name]
return self._registry[name].cls
def all(self) -> Dict[str, Type["component.Component"]]:
return self._registry
def all(self) -> Dict[str, Type["Component"]]:
"""
Retrieve all registered component classes.
Example:
```py
# First register components
registry.register("button", ButtonComponent)
registry.register("card", CardComponent)
# Then get all
registry.all()
# > {
# > "button": ButtonComponent,
# > "card": CardComponent,
# > }
```
"""
comps = {key: entry.cls for key, entry in self._registry.items()}
return comps
def clear(self) -> None:
"""
Clears the registry, unregistering all components.
Example:
```py
# First register components
registry.register("button", ButtonComponent)
registry.register("card", CardComponent)
# Then clear
registry.clear()
# Then get all
registry.all()
# > {}
```
"""
all_comp_names = list(self._registry.keys())
for comp_name in all_comp_names:
self.unregister(comp_name)
self._registry = {}
self._tags = {}
# This variable represents the global component registry
registry: ComponentRegistry = ComponentRegistry()
"""
The default and global component registry. Use this instance to directly
register or remove components:
```py
# Register components
registry.register("button", ButtonComponent)
registry.register("card", CardComponent)
# Get single
registry.get("button")
# Get all
registry.all()
# Unregister single
registry.unregister("button")
# Unregister all
registry.clear()
```
"""
# NOTE: Aliased so that the arg to `@register` can also be called `registry`
_the_registry = registry
def register(name: str) -> Callable[[_TC], _TC]:
"""Class decorator to register a component.
def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callable[[_TComp], _TComp]:
"""
Class decorator to register a component.
Usage:
```py
@register("my_component")
class MyComponent(component.Component):
class MyComponent(Component):
...
"""
```
def decorator(component: _TC) -> _TC:
Optionally specify which `ComponentRegistry` the component should be registered to by
setting the `registry` kwarg:
```py
my_lib = django.template.Library()
my_reg = ComponentRegistry(library=my_lib)
@register("my_component", registry=my_reg)
class MyComponent(Component):
...
```
"""
if registry is None:
registry = _the_registry
def decorator(component: _TComp) -> _TComp:
registry.register(name=name, component=component)
return component
return decorator
def mark_protected_tags(lib: Library, tags: List[str]) -> None:
# By marking the library as default,
lib._protected_tags = [*tags]
def is_tag_protected(lib: Library, tag: str) -> bool:
protected_tags = getattr(lib, "_protected_tags", [])
return tag in protected_tags

View file

@ -73,14 +73,11 @@ class AppendAttributesTest(BaseTestCase):
class HtmlAttrsTests(BaseTestCase):
def setUp(self):
super().setUp()
self.template_str: types.django_html = """
{% load component_tags %}
{% component "test" attrs:@click.stop="dispatch('click_event')" attrs:x-data="{hello: 'world'}" attrs:class=class_var %}
{% endcomponent %}
""" # noqa: E501
template_str: types.django_html = """
{% load component_tags %}
{% component "test" attrs:@click.stop="dispatch('click_event')" attrs:x-data="{hello: 'world'}" attrs:class=class_var %}
{% endcomponent %}
""" # noqa: E501
@parametrize_context_behavior(["django", "isolated"])
def test_tag_positional_args(self):

View file

@ -615,10 +615,6 @@ class ContextVarsIsFilledTests(BaseTestCase):
)
registry.register("negated_conditional_slot", self.ComponentWithNegatedConditionalSlot)
def tearDown(self) -> None:
super().tearDown()
registry.clear()
@parametrize_context_behavior(["django", "isolated"])
def test_is_filled_vars(self):
template: types.django_html = """

View file

@ -67,10 +67,6 @@ class MultistyleComponent(Component):
@override_settings(COMPONENTS={"RENDER_DEPENDENCIES": True})
class ComponentMediaRenderingTests(BaseTestCase):
def setUp(self):
# NOTE: registry is global, so need to clear before each test
registry.clear()
def test_no_dependencies_when_no_components_used(self):
registry.register(name="test", component=SimpleComponent)

View file

@ -1,5 +1,7 @@
import unittest
from django.template import Library
from django_components import AlreadyRegistered, Component, ComponentRegistry, NotRegistered, register, registry
from .django_test_setup import setup_test_config
@ -22,6 +24,7 @@ class MockComponentView(Component):
class ComponentRegistryTest(unittest.TestCase):
def setUp(self):
super().setUp()
self.registry = ComponentRegistry()
def test_register_class_decorator(self):
@ -31,6 +34,23 @@ class ComponentRegistryTest(unittest.TestCase):
self.assertEqual(registry.get("decorated_component"), TestComponent)
# Cleanup
registry.unregister("decorated_component")
def test_register_class_decorator_custom_registry(self):
my_lib = Library()
my_reg = ComponentRegistry(library=my_lib)
self.assertDictEqual(my_reg.all(), {})
self.assertDictEqual(registry.all(), {})
@register("decorated_component", registry=my_reg)
class TestComponent(Component):
pass
self.assertDictEqual(my_reg.all(), {"decorated_component": TestComponent})
self.assertDictEqual(registry.all(), {})
def test_simple_register(self):
self.registry.register(name="testcomponent", component=MockComponent)
self.assertEqual(self.registry.all(), {"testcomponent": MockComponent})
@ -46,6 +66,44 @@ class ComponentRegistryTest(unittest.TestCase):
},
)
def test_unregisters_only_unused_tags(self):
self.assertDictEqual(self.registry._tags, {})
# NOTE: We preserve the default component tags
self.assertIn("component", self.registry.library.tags)
# Register two components that use the same tag
self.registry.register(name="testcomponent", component=MockComponent)
self.registry.register(name="testcomponent2", component=MockComponent)
self.assertDictEqual(
self.registry._tags,
{
"#component": {"testcomponent", "testcomponent2"},
"component": {"testcomponent", "testcomponent2"},
},
)
self.assertIn("component", self.registry.library.tags)
# Unregister only one of the components. The tags should remain
self.registry.unregister(name="testcomponent")
self.assertDictEqual(
self.registry._tags,
{
"#component": {"testcomponent2"},
"component": {"testcomponent2"},
},
)
self.assertIn("component", self.registry.library.tags)
# Unregister the second components. The tags should be removed
self.registry.unregister(name="testcomponent2")
self.assertDictEqual(self.registry._tags, {})
self.assertIn("component", self.registry.library.tags)
def test_prevent_registering_different_components_with_the_same_name(self):
self.registry.register(name="testcomponent", component=MockComponent)
with self.assertRaises(AlreadyRegistered):

View file

@ -25,6 +25,7 @@ class TemplateInstrumentationTest(BaseTestCase):
saved_render_method: Callable # Assigned during setup.
def tearDown(self):
super().tearDown()
Template._render = self.saved_render_method
def setUp(self):
@ -92,14 +93,6 @@ class TemplateInstrumentationTest(BaseTestCase):
class BlockCompatTests(BaseTestCase):
def setUp(self):
registry.clear()
super().setUp()
def tearDown(self):
super().tearDown()
registry.clear()
@parametrize_context_behavior(["django", "isolated"])
def test_slots_inside_extends(self):
registry.register("slotted_component", SlottedComponent)

View file

@ -47,10 +47,6 @@ class ComponentTemplateTagTest(BaseTestCase):
css = "style.css"
js = "script.js"
def setUp(self):
# NOTE: registry is global, so need to clear before each test
registry.clear()
@parametrize_context_behavior(["django", "isolated"])
def test_single_component(self):
registry.register(name="test", component=self.SimpleComponent)
@ -190,9 +186,6 @@ class ComponentTemplateTagTest(BaseTestCase):
class MultiComponentTests(BaseTestCase):
def setUp(self):
registry.clear()
def register_components(self):
registry.register("first_component", SlottedComponent)
registry.register("second_component", SlottedComponentWithContext)
@ -265,18 +258,19 @@ class MultiComponentTests(BaseTestCase):
class ComponentIsolationTests(BaseTestCase):
def setUp(self):
class SlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
<custom-template>
<header>{% slot "header" %}Default header{% endslot %}</header>
<main>{% slot "main" %}Default main{% endslot %}</main>
<footer>{% slot "footer" %}Default footer{% endslot %}</footer>
</custom-template>
"""
class SlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
<custom-template>
<header>{% slot "header" %}Default header{% endslot %}</header>
<main>{% slot "main" %}Default main{% endslot %}</main>
<footer>{% slot "footer" %}Default footer{% endslot %}</footer>
</custom-template>
"""
registry.register("test", SlottedComponent)
def setUp(self):
super().setUp()
registry.register("test", self.SlottedComponent)
@parametrize_context_behavior(["django", "isolated"])
def test_instances_of_component_do_not_share_slots(self):
@ -358,10 +352,6 @@ class ComponentTemplateSyntaxErrorTests(BaseTestCase):
super().setUp()
registry.register("test", SlottedComponent)
def tearDown(self) -> None:
super().tearDown()
registry.clear()
@parametrize_context_behavior(["django", "isolated"])
def test_variable_outside_fill_tag_compiles_w_out_error(self):
# As of v0.28 this is valid, provided the component registered under "test"

View file

@ -32,10 +32,6 @@ class SlottedComponentWithContext(SlottedComponent):
class ComponentSlottedTemplateTagTest(BaseTestCase):
def setUp(self):
# NOTE: registry is global, so need to clear before each test
registry.clear()
@parametrize_context_behavior(["django", "isolated"])
def test_slotted_template_basic(self):
registry.register(name="test1", component=SlottedComponent)
@ -477,10 +473,6 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):
class SlottedTemplateRegressionTests(BaseTestCase):
def setUp(self):
# NOTE: registry is global, so need to clear before each test
registry.clear()
@parametrize_context_behavior(["django", "isolated"])
def test_slotted_template_that_uses_missing_variable(self):
@register("test")
@ -517,13 +509,8 @@ class SlottedTemplateRegressionTests(BaseTestCase):
class SlotDefaultTests(BaseTestCase):
def setUp(self):
super().setUp()
registry.clear()
registry.register("test", SlottedComponent)
def tearDown(self):
super().tearDown()
registry.clear()
@parametrize_context_behavior(["django", "isolated"])
def test_basic(self):
template_str: types.django_html = """
@ -1115,10 +1102,6 @@ class SlotFillTemplateSyntaxErrorTests(BaseTestCase):
super().setUp()
registry.register("test", SlottedComponent)
def tearDown(self):
super().tearDown()
registry.clear()
@parametrize_context_behavior(["django", "isolated"])
def test_fill_with_no_parent_is_error(self):
with self.assertRaises(TemplateSyntaxError):

View file

@ -178,13 +178,8 @@ class ConditionalSlotTests(BaseTestCase):
def setUp(self):
super().setUp()
registry.clear()
registry.register("test", self.ConditionalComponent)
def tearDown(self):
super().tearDown()
registry.clear()
@parametrize_context_behavior(["django", "isolated"])
def test_no_content_if_branches_are_false(self):
template_str: types.django_html = """
@ -260,9 +255,6 @@ class SlotIterationTest(BaseTestCase):
"objects": objects,
}
def setUp(self):
registry.clear()
# NOTE: Second arg in tuple is expected result. In isolated mode, loops should NOT leak.
@parametrize_context_behavior(
[
@ -627,10 +619,6 @@ class ComponentNestingTests(BaseTestCase):
registry.register("complex_child", self.ComplexChildComponent)
registry.register("complex_parent", self.ComplexParentComponent)
def tearDown(self) -> None:
super().tearDown()
registry.clear()
# NOTE: Second arg in tuple are expected names in nested fills. In "django" mode,
# the value should be overridden by the component, while in "isolated" it should
# remain top-level context.