feat: allow extensions to add url views (#1025)

* feat: allow extensions to add url views

* refactor: fix linter errors
This commit is contained in:
Juro Oravec 2025-03-17 08:36:47 +01:00 committed by GitHub
parent d3d2d0ab08
commit 12a64f8e41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 443 additions and 76 deletions

View file

@ -9,7 +9,8 @@
- Hook into lifecycle events of django-components - Hook into lifecycle events of django-components
- Pre-/post-process component inputs, outputs, and templates - Pre-/post-process component inputs, outputs, and templates
- Add extra methods or attributes to Components - Add extra methods or attributes to Components
- Add custom CLI commands to django-components - Add custom extension-specific CLI commands
- Add custom extension-specific URL routes
Read more on [Extensions](https://django-components.github.io/django-components/0.131/concepts/advanced/extensions/). Read more on [Extensions](https://django-components.github.io/django-components/0.131/concepts/advanced/extensions/).

View file

@ -15,10 +15,10 @@ Extensions can be set by either as an import string or by passing in a class:
```python ```python
# settings.py # settings.py
class MyExtension(ComponentsExtension): class MyExtension(ComponentExtension):
name = "my_extension" name = "my_extension"
class ExtensionClass(BaseExtensionClass): class ExtensionClass(ComponentExtension.ExtensionClass):
... ...
COMPONENTS = ComponentsSettings( COMPONENTS = ComponentsSettings(
@ -188,13 +188,13 @@ This is how extensions define the "default" behavior of their nested extension c
For example, the `View` base extension class defines the handlers for GET, POST, etc: For example, the `View` base extension class defines the handlers for GET, POST, etc:
```python ```python
from django_components.extension import ComponentExtension, BaseExtensionClass from django_components.extension import ComponentExtension
class ViewExtension(ComponentExtension): class ViewExtension(ComponentExtension):
name = "view" name = "view"
# The default behavior of the `View` extension class. # The default behavior of the `View` extension class.
class ExtensionClass(BaseExtensionClass): class ExtensionClass(ComponentExtension.ExtensionClass):
def get(self, request): def get(self, request):
return self.component.get(request) return self.component.get(request)
@ -229,7 +229,7 @@ class MyTable(Component):
!!! warning !!! warning
When writing an extension, the `ExtensionClass` MUST subclass the base class [`BaseExtensionClass`](../../../reference/api/#django_components.ComponentExtension.BaseExtensionClass). When writing an extension, the `ExtensionClass` MUST subclass the base class [`ComponentExtension.ExtensionClass`](../../../reference/api/#django_components.ComponentExtension.ExtensionClass).
This base class ensures that the extension class will have access to the component instance. This base class ensures that the extension class will have access to the component instance.
@ -276,7 +276,7 @@ from django_components.extension import (
OnComponentInputContext, OnComponentInputContext,
) )
class ColorLoggerExtensionClass(BaseExtensionClass): class ColorLoggerExtensionClass(ComponentExtension.ExtensionClass):
color: str color: str
@ -356,7 +356,7 @@ This subclass should define:
- `handle` - the logic to execute when the command is run - `handle` - the logic to execute when the command is run
```python ```python
from django_components import ComponentCommand, ComponentsExtension from django_components import ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand): class HelloCommand(ComponentCommand):
name = "hello" name = "hello"
@ -365,7 +365,7 @@ class HelloCommand(ComponentCommand):
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
print("Hello, world!") print("Hello, world!")
class MyExt(ComponentsExtension): class MyExt(ComponentExtension):
name = "my_ext" name = "my_ext"
commands = [HelloCommand] commands = [HelloCommand]
``` ```
@ -382,7 +382,7 @@ as keyword arguments to the [`handle`](../../../reference/api#django_components.
method of the command. method of the command.
```python ```python
from django_components import CommandArg, ComponentCommand, ComponentsExtension from django_components import CommandArg, ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand): class HelloCommand(ComponentCommand):
name = "hello" name = "hello"
@ -443,7 +443,7 @@ to provide better organization and help messages.
Read more on [argparse argument groups](https://docs.python.org/3/library/argparse.html#argument-groups). Read more on [argparse argument groups](https://docs.python.org/3/library/argparse.html#argument-groups).
```python ```python
from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentsExtension from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand): class HelloCommand(ComponentCommand):
name = "hello" name = "hello"
@ -502,7 +502,7 @@ attribute of the extension, you define them in the
attribute of the parent command: attribute of the parent command:
```python ```python
from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentsExtension from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentExtension
class ChildCommand(ComponentCommand): class ChildCommand(ComponentCommand):
name = "child" name = "child"
@ -612,3 +612,128 @@ def test_hello_command(self):
output = out.getvalue() output = out.getvalue()
assert output == "Hello, John!\n" assert output == "Hello, John!\n"
``` ```
## Extension URLs
Extensions can define custom views and endpoints that can be accessed through the Django application.
To define URLs for an extension, set them in the [`urls`](../../../reference/api#django_components.ComponentExtension.urls) attribute of your [`ComponentExtension`](../../../reference/api#django_components.ComponentExtension) class. Each URL is defined using the [`URLRoute`](../../../reference/api#django_components.URLRoute) class, which specifies the path, handler, and optional name for the route.
Here's an example of how to define URLs within an extension:
```python
from django_components.extension import ComponentExtension, URLRoute
from django.http import HttpResponse
def my_view(request):
return HttpResponse("Hello from my extension!")
class MyExtension(ComponentExtension):
name = "my_extension"
urls = [
URLRoute(path="my-view/", handler=my_view, name="my_view"),
URLRoute(path="another-view/<int:id>/", handler=my_view, name="another_view"),
]
```
!!! warning
The [`URLRoute`](../../../reference/api#django_components.URLRoute) objects
are different from objects created with Django's
[`django.urls.path()`](https://docs.djangoproject.com/en/5.1/ref/urls/#path).
Do NOT use `URLRoute` objects in Django's [`urlpatterns`](https://docs.djangoproject.com/en/5.1/topics/http/urls/#example)
and vice versa!
django-components uses a custom [`URLRoute`](../../../reference/api#django_components.URLRoute) class to define framework-agnostic routing rules.
As of v0.131, `URLRoute` objects are directly converted to Django's `URLPattern` and `URLResolver` objects.
### Accessing Extension URLs
The URLs defined in an extension are available under the path
```
/components/ext/<extension_name>/
```
For example, if you have defined a URL with the path `my-view/<str:name>/` in an extension named `my_extension`, it can be accessed at:
```
/components/ext/my_extension/my-view/john/
```
### Nested URLs
Extensions can also define nested URLs to allow for more complex routing structures.
To define nested URLs, set the [`children`](../../../reference/api#django_components.URLRoute.children)
attribute of the [`URLRoute`](../../../reference/api#django_components.URLRoute) object to
a list of child [`URLRoute`](../../../reference/api#django_components.URLRoute) objects:
```python
class MyExtension(ComponentExtension):
name = "my_extension"
urls = [
URLRoute(
path="parent/",
name="parent_view",
children=[
URLRoute(path="child/<str:name>/", handler=my_view, name="child_view"),
],
),
]
```
In this example, the URL
```
/components/ext/my_extension/parent/child/john/
```
would call the `my_view` handler with the parameter `name` set to `"John"`.
### Passing kwargs and other extra fields to URL routes
The [`URLRoute`](../../../reference/api#django_components.URLRoute) class is framework-agnostic,
so that extensions could be used with non-Django frameworks in the future.
However, that means that there may be some extra fields that Django's
[`django.urls.path()`](https://docs.djangoproject.com/en/5.1/ref/urls/#path)
accepts, but which are not defined on the `URLRoute` object.
To address this, the [`URLRoute`](../../../reference/api#django_components.URLRoute) object has
an [`extra`](../../../reference/api#django_components.URLRoute.extra) attribute,
which is a dictionary that can be used to pass any extra kwargs to `django.urls.path()`:
```python
URLRoute(
path="my-view/<str:name>/",
handler=my_view,
name="my_view",
extra={"kwargs": {"foo": "bar"} },
)
```
Is the same as:
```python
django.urls.path(
"my-view/<str:name>/",
view=my_view,
name="my_view",
kwargs={"foo": "bar"},
)
```
because `URLRoute` is converted to Django's route like so:
```python
django.urls.path(
route.path,
view=route.handler,
name=route.name,
**route.extra,
)
```

View file

@ -35,6 +35,10 @@
options: options:
show_if_no_docstring: true show_if_no_docstring: true
::: django_components.ComponentCommand
options:
show_if_no_docstring: true
::: django_components.ComponentExtension ::: django_components.ComponentExtension
options: options:
show_if_no_docstring: true show_if_no_docstring: true
@ -63,10 +67,6 @@
options: options:
show_if_no_docstring: true show_if_no_docstring: true
::: django_components.ComponentCommand
options:
show_if_no_docstring: true
::: django_components.ComponentsSettings ::: django_components.ComponentsSettings
options: options:
show_if_no_docstring: true show_if_no_docstring: true
@ -147,6 +147,14 @@
options: options:
show_if_no_docstring: true show_if_no_docstring: true
::: django_components.URLRoute
options:
show_if_no_docstring: true
::: django_components.URLRouteHandler
options:
show_if_no_docstring: true
::: django_components.autodiscover ::: django_components.autodiscover
options: options:
show_if_no_docstring: true show_if_no_docstring: true
@ -182,4 +190,3 @@
::: django_components.template_tag ::: django_components.template_tag
options: options:
show_if_no_docstring: true show_if_no_docstring: true

View file

@ -51,7 +51,9 @@ python manage.py components ext run <extension> <command>
## `components create` ## `components create`
```txt ```txt
usage: python manage.py components create [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose] [--dry-run] name usage: python manage.py components create [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose]
[--dry-run]
name
``` ```
@ -293,7 +295,7 @@ Each extension can add its own commands, which will be available to run with thi
For example, if you define and install the following extension: For example, if you define and install the following extension:
```python ```python
from django_components ComponentCommand, ComponentsExtension from django_components import ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand): class HelloCommand(ComponentCommand):
name = "hello" name = "hello"
@ -301,7 +303,7 @@ class HelloCommand(ComponentCommand):
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
print("Hello, world!") print("Hello, world!")
class MyExt(ComponentsExtension): class MyExt(ComponentExtension):
name = "my_ext" name = "my_ext"
commands = [HelloCommand] commands = [HelloCommand]
``` ```
@ -315,7 +317,7 @@ python manage.py components ext run my_ext hello
You can also define arguments for the command, which will be passed to the command's `handle` method. You can also define arguments for the command, which will be passed to the command's `handle` method.
```python ```python
from django_components import CommandArg, ComponentCommand, ComponentsExtension from django_components import CommandArg, ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand): class HelloCommand(ComponentCommand):
name = "hello" name = "hello"
@ -349,14 +351,15 @@ python manage.py components ext run my_ext hello --name John --shout
## `upgradecomponent` ## `upgradecomponent`
```txt ```txt
usage: upgradecomponent [-h] [--path PATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH] usage: upgradecomponent [-h] [--path PATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS]
[--traceback] [--no-color] [--force-color] [--skip-checks] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color]
[--skip-checks]
``` ```
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/management/commands/upgradecomponent.py#L83" target="_blank">See source code</a> <a href="https://github.com/django-components/django-components/tree/master/src/django_components/management/commands/upgradecomponent.py#L89" target="_blank">See source code</a>
@ -394,16 +397,17 @@ Deprecated. Use `components upgrade` instead.
## `startcomponent` ## `startcomponent`
```txt ```txt
usage: startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose] [--dry-run] usage: startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force]
[--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--verbose] [--dry-run] [--version] [-v {0,1,2,3}] [--settings SETTINGS]
[--no-color] [--force-color] [--skip-checks] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color]
[--skip-checks]
name name
``` ```
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/management/commands/startcomponent.py#L83" target="_blank">See source code</a> <a href="https://github.com/django-components/django-components/tree/master/src/django_components/management/commands/startcomponent.py#L89" target="_blank">See source code</a>

View file

@ -67,7 +67,7 @@ If you insert this tag multiple times, ALL JS scripts will be duplicately insert
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1573" target="_blank">See source code</a> <a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1478" target="_blank">See source code</a>
@ -175,7 +175,7 @@ can access only the data that was explicitly passed to it:
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L612" target="_blank">See source code</a> <a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L614" target="_blank">See source code</a>
@ -416,7 +416,7 @@ user = self.inject("user_data")["user"]
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L153" target="_blank">See source code</a> <a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L155" target="_blank">See source code</a>

View file

@ -53,6 +53,7 @@ from django_components.tag_formatter import (
from django_components.template import cached_template from django_components.template import cached_template
import django_components.types as types import django_components.types as types
from django_components.util.loader import ComponentFileEntry, get_component_dirs, get_component_files from django_components.util.loader import ComponentFileEntry, get_component_dirs, get_component_files
from django_components.util.routing import URLRoute, URLRouteHandler
from django_components.util.types import EmptyTuple, EmptyDict from django_components.util.types import EmptyTuple, EmptyDict
# isort: on # isort: on
@ -113,4 +114,6 @@ __all__ = [
"TagResult", "TagResult",
"template_tag", "template_tag",
"types", "types",
"URLRoute",
"URLRouteHandler",
] ]

View file

@ -55,7 +55,7 @@ class ExtRunCommand(ComponentCommand):
For example, if you define and install the following extension: For example, if you define and install the following extension:
```python ```python
from django_components ComponentCommand, ComponentsExtension from django_components import ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand): class HelloCommand(ComponentCommand):
name = "hello" name = "hello"
@ -63,7 +63,7 @@ class ExtRunCommand(ComponentCommand):
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
print("Hello, world!") print("Hello, world!")
class MyExt(ComponentsExtension): class MyExt(ComponentExtension):
name = "my_ext" name = "my_ext"
commands = [HelloCommand] commands = [HelloCommand]
``` ```
@ -77,7 +77,7 @@ class ExtRunCommand(ComponentCommand):
You can also define arguments for the command, which will be passed to the command's `handle` method. You can also define arguments for the command, which will be passed to the command's `handle` method.
```python ```python
from django_components import CommandArg, ComponentCommand, ComponentsExtension from django_components import CommandArg, ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand): class HelloCommand(ComponentCommand):
name = "hello" name = "hello"

View file

@ -1,8 +1,10 @@
from argparse import ArgumentParser from argparse import ArgumentParser
from typing import Any, Optional, Type from typing import Any, List, Optional, Type
import django import django
import django.urls as django_urls
from django.core.management.base import BaseCommand as DjangoCommand from django.core.management.base import BaseCommand as DjangoCommand
from django.urls import URLPattern
from django_components.util.command import ( from django_components.util.command import (
CommandArg, CommandArg,
@ -10,6 +12,11 @@ from django_components.util.command import (
_setup_command_arg, _setup_command_arg,
setup_parser_from_command, setup_parser_from_command,
) )
from django_components.util.routing import URLRoute
################################################
# COMMANDS
################################################
# Django command arguments added to all commands # Django command arguments added to all commands
# NOTE: Many of these MUST be present for the command to work with Django # NOTE: Many of these MUST be present for the command to work with Django
@ -114,3 +121,44 @@ def load_as_django_command(command: Type[ComponentCommand]) -> Type[DjangoComman
Command.__doc__ = command.__doc__ Command.__doc__ = command.__doc__
return Command return Command
################################################
# ROUTING
################################################
def routes_to_django(routes: List[URLRoute]) -> List[URLPattern]:
"""
Convert a list of `URLRoute` objects to a list of `URLPattern` objects.
The result is similar to Django's `django.urls.path()` function.
Nested routes are recursively converted to Django with `django.urls.include()`.
**Example:**
```python
urls_to_django([
URLPattern(
"/my/path",
handler=my_handler,
name="my_name",
extra={"kwargs": {"my_extra": "my_value"} },
),
])
```
"""
django_routes: List[URLPattern] = []
for route in routes:
# The handler is equivalent to `view` function in Django
if route.handler is not None:
django_handler = route.handler
else:
# If the URL has children paths, it's equivalent to "including" another `urlpatterns` in Django
subpaths = routes_to_django(route.children)
django_handler = django_urls.include(subpaths)
django_route = django_urls.path(route.path, django_handler, name=route.name, **route.extra)
django_routes.append(django_route)
return django_routes

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["ComponentRegistry"]] AllComponents = List[ReferenceType["Component"]]
else: else:
AllComponents = List[ReferenceType] AllComponents = List[ReferenceType]

View file

@ -1,11 +1,15 @@
from functools import wraps from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Tuple, Type, TypeVar from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Tuple, Type, TypeVar
import django.urls
from django.template import Context from django.template import Context
from django.urls import URLResolver
from django_components.app_settings import app_settings from django_components.app_settings import app_settings
from django_components.compat.django import routes_to_django
from django_components.util.command import ComponentCommand from django_components.util.command import ComponentCommand
from django_components.util.misc import snake_to_pascal from django_components.util.misc import snake_to_pascal
from django_components.util.routing import URLRoute
if TYPE_CHECKING: if TYPE_CHECKING:
from django_components import Component from django_components import Component
@ -93,6 +97,11 @@ class OnComponentDataContext(NamedTuple):
"""Dictionary of CSS data from `Component.get_css_data()`""" """Dictionary of CSS data from `Component.get_css_data()`"""
################################################
# EXTENSIONS CORE
################################################
class BaseExtensionClass: class BaseExtensionClass:
def __init__(self, component: "Component") -> None: def __init__(self, component: "Component") -> None:
self.component = component self.component = component
@ -178,7 +187,7 @@ class ComponentExtension:
```python ```python
class MyComp(Component): class MyComp(Component):
class MyExtension(BaseExtensionClass): class MyExtension(ComponentExtension.ExtensionClass):
... ...
``` ```
@ -244,6 +253,8 @@ class ComponentExtension:
``` ```
""" """
urls: List[URLRoute] = []
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}")
@ -562,26 +573,26 @@ class ExtensionManager:
extension_instance = used_ext_class(component) extension_instance = used_ext_class(component)
setattr(component, extension.name, extension_instance) 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: def _init_app(self) -> None:
if self._initialized: if self._initialized:
return return
self._initialized = True self._initialized = True
# 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.
for hook, data in self._events: for hook, data in self._events:
if hook == "on_component_class_created": if hook == "on_component_class_created":
on_component_created_data: OnComponentClassCreatedContext = data on_component_created_data: OnComponentClassCreatedContext = data
@ -589,6 +600,24 @@ class ExtensionManager:
getattr(self, hook)(data) getattr(self, hook)(data)
self._events = [] self._events = []
# Populate the `urlpatterns` with URLs specified by the extensions
# TODO_V3 - Django-specific logic - replace with hook
urls: List[URLResolver] = []
for extension in self.extensions:
ext_urls = routes_to_django(extension.urls)
ext_url_path = django.urls.path(f"{extension.name}/", django.urls.include(ext_urls))
urls.append(ext_url_path)
# NOTE: `urlconf_name` is the actual source of truth that holds either a list of URLPatterns
# or an import string thereof.
# However, Django's `URLResolver` caches the resolved value of `urlconf_name`
# under the key `url_patterns`.
# So we set both:
# - `urlconf_name` to update the source of truth
# - `url_patterns` to override the caching
ext_url_resolver.urlconf_name = urls
ext_url_resolver.url_patterns = urls
def get_extension(self, name: str) -> ComponentExtension: def get_extension(self, name: str) -> ComponentExtension:
for extension in self.extensions: for extension in self.extensions:
if extension.name == name: if extension.name == name:
@ -651,3 +680,25 @@ class ExtensionManager:
# NOTE: This is a singleton which is takes the extensions from `app_settings.EXTENSIONS` # NOTE: This is a singleton which is takes the extensions from `app_settings.EXTENSIONS`
extensions = ExtensionManager() extensions = ExtensionManager()
################################
# VIEW
################################
# Extensions can define their own URLs, which will be added to the `urlpatterns` list.
# These will be available under the `/components/ext/<extension_name>/` path, e.g.:
# `/components/ext/my_extension/path/to/route/<str:name>/<int:id>/`
urlpatterns = [
django.urls.path("ext/", django.urls.include([])),
]
# NOTE: Normally we'd pass all the routes introduced by extensions to `django.urls.include()` and
# `django.urls.path()` to construct the `URLResolver` objects that would take care of the rest.
#
# However, Django's `urlpatterns` are constructed BEFORE the `ready()` hook is called,
# and so before the extensions are ready.
#
# As such, we lazily set the extensions' routes to the `URLResolver` object. And we use the `include()
# and `path()` funtions above to ensure that the `URLResolver` object is created correctly.
ext_url_resolver: URLResolver = urlpatterns[0]

View file

@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, Protocol, cast
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.views.generic import View from django.views.generic import View
from django_components.extension import BaseExtensionClass, ComponentExtension from django_components.extension import ComponentExtension
if TYPE_CHECKING: if TYPE_CHECKING:
from django_components.component import Component from django_components.component import Component
@ -13,7 +13,7 @@ class ViewFn(Protocol):
def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704 def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704
class ComponentView(BaseExtensionClass, View): class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore
""" """
Subclass of `django.views.View` where the `Component` instance is available Subclass of `django.views.View` where the `Component` instance is available
via `self.component`. via `self.component`.
@ -24,7 +24,7 @@ class ComponentView(BaseExtensionClass, View):
component = cast("Component", None) component = cast("Component", None)
def __init__(self, component: "Component", **kwargs: Any) -> None: def __init__(self, component: "Component", **kwargs: Any) -> None:
BaseExtensionClass.__init__(self, component) ComponentExtension.ExtensionClass.__init__(self, component)
View.__init__(self, **kwargs) View.__init__(self, **kwargs)
# NOTE: The methods below are defined to satisfy the `View` class. All supported methods # NOTE: The methods below are defined to satisfy the `View` class. All supported methods

View file

@ -1,7 +1,18 @@
from django.urls import include, path from django.urls import include, path
from django_components.dependencies import urlpatterns as dependencies_urlpatterns
from django_components.extension import urlpatterns as extension_urlpatterns
urlpatterns = [ urlpatterns = [
path("components/", include("django_components.dependencies")), path(
"components/",
include(
[
*dependencies_urlpatterns,
*extension_urlpatterns,
]
),
),
] ]
__all__ = ["urlpatterns"] __all__ = ["urlpatterns"]

View file

@ -228,7 +228,7 @@ class ComponentCommand:
For example, if you define and install the following extension: For example, if you define and install the following extension:
```python ```python
from django_components ComponentCommand, ComponentsExtension from django_components ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand): class HelloCommand(ComponentCommand):
name = "hello" name = "hello"
@ -236,7 +236,7 @@ class ComponentCommand:
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
print("Hello, world!") print("Hello, world!")
class MyExt(ComponentsExtension): class MyExt(ComponentExtension):
name = "my_ext" name = "my_ext"
commands = [HelloCommand] commands = [HelloCommand]
``` ```
@ -250,7 +250,7 @@ class ComponentCommand:
You can also define arguments for the command, which will be passed to the command's `handle` method. You can also define arguments for the command, which will be passed to the command's `handle` method.
```python ```python
from django_components import CommandArg, ComponentCommand, ComponentsExtension from django_components import CommandArg, ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand): class HelloCommand(ComponentCommand):
name = "hello" name = "hello"

View file

@ -0,0 +1,62 @@
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Protocol
class URLRouteHandler(Protocol):
"""Framework-agnostic 'view' function for routes"""
def __call__(self, request: Any, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704
@dataclass
class URLRoute:
"""
Framework-agnostic route definition.
This is similar to Django's `URLPattern` object created with
[`django.urls.path()`](https://docs.djangoproject.com/en/5.1/ref/urls/#path).
The `URLRoute` must either define a `handler` function or have a list of child routes `children`.
If both are defined, an error will be raised.
**Example:**
```python
URLRoute("/my/path", handler=my_handler, name="my_name", extra={"kwargs": {"my_extra": "my_value"}})
```
Is equivalent to:
```python
django.urls.path("/my/path", my_handler, name="my_name", kwargs={"my_extra": "my_value"})
```
With children:
```python
URLRoute(
"/my/path",
name="my_name",
extra={"kwargs": {"my_extra": "my_value"}},
children=[
URLRoute(
"/child/<str:name>/",
handler=my_handler,
name="my_name",
extra={"kwargs": {"my_extra": "my_value"}},
),
URLRoute("/other/<int:id>/", handler=other_handler),
],
)
```
"""
path: str
handler: Optional[URLRouteHandler] = None
children: List["URLRoute"] = field(default_factory=list)
name: Optional[str] = None
extra: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.handler is not None and self.children:
raise ValueError("Cannot have both handler and children")

View file

@ -438,6 +438,7 @@ def _setup_djc_global_state(
from django_components.app_settings import app_settings from django_components.app_settings import app_settings
app_settings._load_settings() app_settings._load_settings()
extensions._initialized = False
extensions._init_app() extensions._init_app()

View file

@ -1,12 +1,15 @@
import gc import gc
from typing import Any, Callable, Dict, List, cast from typing import Any, Callable, Dict, List, cast
from django.http import HttpRequest, HttpResponse
from django.template import Context from django.template import Context
from django.test import Client
from django_components import Component, Slot, register, registry from django_components import Component, Slot, register, registry
from django_components.app_settings import app_settings from django_components.app_settings import app_settings
from django_components.component_registry import ComponentRegistry from django_components.component_registry import ComponentRegistry
from django_components.extension import ( from django_components.extension import (
URLRoute,
ComponentExtension, ComponentExtension,
OnComponentClassCreatedContext, OnComponentClassCreatedContext,
OnComponentClassDeletedContext, OnComponentClassDeletedContext,
@ -25,6 +28,16 @@ from .testutils import setup_test_config
setup_test_config({"autodiscover": False}) setup_test_config({"autodiscover": False})
def dummy_view(request: HttpRequest):
# Test that the request object is passed to the view
assert isinstance(request, HttpRequest)
return HttpResponse("Hello, world!")
def dummy_view_2(request: HttpRequest, id: int, name: str):
return HttpResponse(f"Hello, world! {id} {name}")
class DummyExtension(ComponentExtension): class DummyExtension(ComponentExtension):
""" """
Test extension that tracks all hook calls and their arguments. Test extension that tracks all hook calls and their arguments.
@ -44,6 +57,11 @@ class DummyExtension(ComponentExtension):
"on_component_data": [], "on_component_data": [],
} }
urls = [
URLRoute(path="dummy-view/", handler=dummy_view, name="dummy"),
URLRoute(path="dummy-view-2/<int:id>/<str:name>/", handler=dummy_view_2, name="dummy-2"),
]
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None: def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
# NOTE: Store only component name to avoid strong references # NOTE: Store only component name to avoid strong references
self.calls["on_component_class_created"].append(ctx.component_cls.__name__) self.calls["on_component_class_created"].append(ctx.component_cls.__name__)
@ -73,6 +91,20 @@ class DummyExtension(ComponentExtension):
self.calls["on_component_data"].append(ctx) self.calls["on_component_data"].append(ctx)
class DummyNestedExtension(ComponentExtension):
name = "test_nested_extension"
urls = [
URLRoute(
path="nested-view/",
children=[
URLRoute(path="<int:id>/<str:name>/", handler=dummy_view_2, name="dummy-2"),
],
name="dummy",
),
]
def with_component_cls(on_created: Callable): def with_component_cls(on_created: Callable):
class TestComponent(Component): class TestComponent(Component):
template = "Hello {{ name }}!" template = "Hello {{ name }}!"
@ -91,17 +123,16 @@ def with_registry(on_created: Callable):
@djc_test @djc_test
class TestExtension: class TestExtension:
@djc_test( @djc_test(components_settings={"extensions": [DummyExtension]})
components_settings={"extensions": [DummyExtension]}
)
def test_extensios_setting(self): def test_extensios_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]} @djc_test
) class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_component_class_lifecycle_hooks(self): def test_component_class_lifecycle_hooks(self):
extension = cast(DummyExtension, app_settings.EXTENSIONS[1]) extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
@ -130,9 +161,7 @@ class TestExtension:
assert len(extension.calls["on_component_class_deleted"]) == 1 assert len(extension.calls["on_component_class_deleted"]) == 1
assert extension.calls["on_component_class_deleted"][0] == "TestComponent" assert extension.calls["on_component_class_deleted"][0] == "TestComponent"
@djc_test( @djc_test(components_settings={"extensions": [DummyExtension]})
components_settings={"extensions": [DummyExtension]}
)
def test_registry_lifecycle_hooks(self): def test_registry_lifecycle_hooks(self):
extension = cast(DummyExtension, app_settings.EXTENSIONS[1]) extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
@ -162,9 +191,7 @@ class TestExtension:
assert len(extension.calls["on_registry_deleted"]) == 1 assert len(extension.calls["on_registry_deleted"]) == 1
assert extension.calls["on_registry_deleted"][0] == reg_id assert extension.calls["on_registry_deleted"][0] == reg_id
@djc_test( @djc_test(components_settings={"extensions": [DummyExtension]})
components_settings={"extensions": [DummyExtension]}
)
def test_component_registration_hooks(self): def test_component_registration_hooks(self):
class TestComponent(Component): class TestComponent(Component):
template = "Hello {{ name }}!" template = "Hello {{ name }}!"
@ -191,9 +218,7 @@ class TestExtension:
assert unreg_call.name == "test_comp" assert unreg_call.name == "test_comp"
assert unreg_call.component_cls == TestComponent assert unreg_call.component_cls == TestComponent
@djc_test( @djc_test(components_settings={"extensions": [DummyExtension]})
components_settings={"extensions": [DummyExtension]}
)
def test_component_render_hooks(self): def test_component_render_hooks(self):
@register("test_comp") @register("test_comp")
class TestComponent(Component): class TestComponent(Component):
@ -211,9 +236,7 @@ class TestExtension:
# Render the component with some args and kwargs # Render the component with some args and kwargs
test_context = Context({"foo": "bar"}) test_context = Context({"foo": "bar"})
test_slots = {"content": "Some content"} test_slots = {"content": "Some content"}
TestComponent.render( TestComponent.render(context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots)
context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots
)
extension = cast(DummyExtension, app_settings.EXTENSIONS[1]) extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
@ -236,3 +259,34 @@ class TestExtension:
assert data_call.context_data == {"name": "Test"} assert data_call.context_data == {"name": "Test"}
assert data_call.js_data == {"script": "console.log('Hello!')"} assert data_call.js_data == {"script": "console.log('Hello!')"}
assert data_call.css_data == {"style": "body { color: blue; }"} assert data_call.css_data == {"style": "body { color: blue; }"}
@djc_test
class TestExtensionViews:
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_views(self):
client = Client()
# Check basic view
response = client.get("/components/ext/test_extension/dummy-view/")
assert response.status_code == 200
assert response.content == b"Hello, world!"
# Check that URL parameters are passed to the view
response2 = client.get("/components/ext/test_extension/dummy-view-2/123/John/")
assert response2.status_code == 200
assert response2.content == b"Hello, world! 123 John"
@djc_test(components_settings={"extensions": [DummyNestedExtension]})
def test_nested_views(self):
client = Client()
# Check basic view
# NOTE: Since the parent route contains child routes, the parent route should not be matched
response = client.get("/components/ext/test_nested_extension/nested-view/")
assert response.status_code == 404
# Check that URL parameters are passed to the view
response2 = client.get("/components/ext/test_nested_extension/nested-view/123/John/")
assert response2.status_code == 200
assert response2.content == b"Hello, world! 123 John"