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
- Pre-/post-process component inputs, outputs, and templates
- 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/).

View file

@ -15,10 +15,10 @@ Extensions can be set by either as an import string or by passing in a class:
```python
# settings.py
class MyExtension(ComponentsExtension):
class MyExtension(ComponentExtension):
name = "my_extension"
class ExtensionClass(BaseExtensionClass):
class ExtensionClass(ComponentExtension.ExtensionClass):
...
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:
```python
from django_components.extension import ComponentExtension, BaseExtensionClass
from django_components.extension import ComponentExtension
class ViewExtension(ComponentExtension):
name = "view"
# The default behavior of the `View` extension class.
class ExtensionClass(BaseExtensionClass):
class ExtensionClass(ComponentExtension.ExtensionClass):
def get(self, request):
return self.component.get(request)
@ -229,7 +229,7 @@ class MyTable(Component):
!!! 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.
@ -276,7 +276,7 @@ from django_components.extension import (
OnComponentInputContext,
)
class ColorLoggerExtensionClass(BaseExtensionClass):
class ColorLoggerExtensionClass(ComponentExtension.ExtensionClass):
color: str
@ -356,7 +356,7 @@ This subclass should define:
- `handle` - the logic to execute when the command is run
```python
from django_components import ComponentCommand, ComponentsExtension
from django_components import ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand):
name = "hello"
@ -365,7 +365,7 @@ class HelloCommand(ComponentCommand):
def handle(self, *args, **kwargs):
print("Hello, world!")
class MyExt(ComponentsExtension):
class MyExt(ComponentExtension):
name = "my_ext"
commands = [HelloCommand]
```
@ -382,7 +382,7 @@ as keyword arguments to the [`handle`](../../../reference/api#django_components.
method of the command.
```python
from django_components import CommandArg, ComponentCommand, ComponentsExtension
from django_components import CommandArg, ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand):
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).
```python
from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentsExtension
from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand):
name = "hello"
@ -502,7 +502,7 @@ attribute of the extension, you define them in the
attribute of the parent command:
```python
from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentsExtension
from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentExtension
class ChildCommand(ComponentCommand):
name = "child"
@ -612,3 +612,128 @@ def test_hello_command(self):
output = out.getvalue()
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:
show_if_no_docstring: true
::: django_components.ComponentCommand
options:
show_if_no_docstring: true
::: django_components.ComponentExtension
options:
show_if_no_docstring: true
@ -63,10 +67,6 @@
options:
show_if_no_docstring: true
::: django_components.ComponentCommand
options:
show_if_no_docstring: true
::: django_components.ComponentsSettings
options:
show_if_no_docstring: true
@ -147,6 +147,14 @@
options:
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
options:
show_if_no_docstring: true
@ -182,4 +190,3 @@
::: django_components.template_tag
options:
show_if_no_docstring: true

View file

@ -51,7 +51,9 @@ python manage.py components ext run <extension> <command>
## `components create`
```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:
```python
from django_components ComponentCommand, ComponentsExtension
from django_components import ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand):
name = "hello"
@ -301,7 +303,7 @@ class HelloCommand(ComponentCommand):
def handle(self, *args, **kwargs):
print("Hello, world!")
class MyExt(ComponentsExtension):
class MyExt(ComponentExtension):
name = "my_ext"
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.
```python
from django_components import CommandArg, ComponentCommand, ComponentsExtension
from django_components import CommandArg, ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand):
name = "hello"
@ -349,14 +351,15 @@ python manage.py components ext run my_ext hello --name John --shout
## `upgradecomponent`
```txt
usage: upgradecomponent [-h] [--path PATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH]
[--traceback] [--no-color] [--force-color] [--skip-checks]
usage: upgradecomponent [-h] [--path PATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS]
[--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`
```txt
usage: startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose] [--dry-run]
[--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback]
[--no-color] [--force-color] [--skip-checks]
usage: startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force]
[--verbose] [--dry-run] [--version] [-v {0,1,2,3}] [--settings SETTINGS]
[--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color]
[--skip-checks]
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
import django_components.types as types
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
# isort: on
@ -113,4 +114,6 @@ __all__ = [
"TagResult",
"template_tag",
"types",
"URLRoute",
"URLRouteHandler",
]

View file

@ -55,7 +55,7 @@ class ExtRunCommand(ComponentCommand):
For example, if you define and install the following extension:
```python
from django_components ComponentCommand, ComponentsExtension
from django_components import ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand):
name = "hello"
@ -63,7 +63,7 @@ class ExtRunCommand(ComponentCommand):
def handle(self, *args, **kwargs):
print("Hello, world!")
class MyExt(ComponentsExtension):
class MyExt(ComponentExtension):
name = "my_ext"
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.
```python
from django_components import CommandArg, ComponentCommand, ComponentsExtension
from django_components import CommandArg, ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand):
name = "hello"

View file

@ -1,8 +1,10 @@
from argparse import ArgumentParser
from typing import Any, Optional, Type
from typing import Any, List, Optional, Type
import django
import django.urls as django_urls
from django.core.management.base import BaseCommand as DjangoCommand
from django.urls import URLPattern
from django_components.util.command import (
CommandArg,
@ -10,6 +12,11 @@ from django_components.util.command import (
_setup_command_arg,
setup_parser_from_command,
)
from django_components.util.routing import URLRoute
################################################
# COMMANDS
################################################
# Django command arguments added to all commands
# 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__
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
if sys.version_info >= (3, 9):
AllComponents = List[ReferenceType["ComponentRegistry"]]
AllComponents = List[ReferenceType["Component"]]
else:
AllComponents = List[ReferenceType]

View file

@ -1,11 +1,15 @@
from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Tuple, Type, TypeVar
import django.urls
from django.template import Context
from django.urls import URLResolver
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.misc import snake_to_pascal
from django_components.util.routing import URLRoute
if TYPE_CHECKING:
from django_components import Component
@ -93,6 +97,11 @@ class OnComponentDataContext(NamedTuple):
"""Dictionary of CSS data from `Component.get_css_data()`"""
################################################
# EXTENSIONS CORE
################################################
class BaseExtensionClass:
def __init__(self, component: "Component") -> None:
self.component = component
@ -178,7 +187,7 @@ class ComponentExtension:
```python
class MyComp(Component):
class MyExtension(BaseExtensionClass):
class MyExtension(ComponentExtension.ExtensionClass):
...
```
@ -244,6 +253,8 @@ class ComponentExtension:
```
"""
urls: List[URLRoute] = []
def __init_subclass__(cls) -> None:
if not cls.name.isidentifier():
raise ValueError(f"Extension name must be a valid Python identifier, got {cls.name}")
@ -562,6 +573,12 @@ class ExtensionManager:
extension_instance = used_ext_class(component)
setattr(component, extension.name, extension_instance)
def _init_app(self) -> None:
if self._initialized:
return
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
@ -576,12 +593,6 @@ class ExtensionManager:
# 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:
if self._initialized:
return
self._initialized = True
for hook, data in self._events:
if hook == "on_component_class_created":
on_component_created_data: OnComponentClassCreatedContext = data
@ -589,6 +600,24 @@ class ExtensionManager:
getattr(self, hook)(data)
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:
for extension in self.extensions:
if extension.name == name:
@ -651,3 +680,25 @@ class ExtensionManager:
# NOTE: This is a singleton which is takes the extensions from `app_settings.EXTENSIONS`
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.views.generic import View
from django_components.extension import BaseExtensionClass, ComponentExtension
from django_components.extension import ComponentExtension
if TYPE_CHECKING:
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
class ComponentView(BaseExtensionClass, View):
class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore
"""
Subclass of `django.views.View` where the `Component` instance is available
via `self.component`.
@ -24,7 +24,7 @@ class ComponentView(BaseExtensionClass, View):
component = cast("Component", None)
def __init__(self, component: "Component", **kwargs: Any) -> None:
BaseExtensionClass.__init__(self, component)
ComponentExtension.ExtensionClass.__init__(self, component)
View.__init__(self, **kwargs)
# 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_components.dependencies import urlpatterns as dependencies_urlpatterns
from django_components.extension import urlpatterns as extension_urlpatterns
urlpatterns = [
path("components/", include("django_components.dependencies")),
path(
"components/",
include(
[
*dependencies_urlpatterns,
*extension_urlpatterns,
]
),
),
]
__all__ = ["urlpatterns"]

View file

@ -228,7 +228,7 @@ class ComponentCommand:
For example, if you define and install the following extension:
```python
from django_components ComponentCommand, ComponentsExtension
from django_components ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand):
name = "hello"
@ -236,7 +236,7 @@ class ComponentCommand:
def handle(self, *args, **kwargs):
print("Hello, world!")
class MyExt(ComponentsExtension):
class MyExt(ComponentExtension):
name = "my_ext"
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.
```python
from django_components import CommandArg, ComponentCommand, ComponentsExtension
from django_components import CommandArg, ComponentCommand, ComponentExtension
class HelloCommand(ComponentCommand):
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
app_settings._load_settings()
extensions._initialized = False
extensions._init_app()

View file

@ -1,12 +1,15 @@
import gc
from typing import Any, Callable, Dict, List, cast
from django.http import HttpRequest, HttpResponse
from django.template import Context
from django.test import Client
from django_components import Component, Slot, register, registry
from django_components.app_settings import app_settings
from django_components.component_registry import ComponentRegistry
from django_components.extension import (
URLRoute,
ComponentExtension,
OnComponentClassCreatedContext,
OnComponentClassDeletedContext,
@ -25,6 +28,16 @@ from .testutils import setup_test_config
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):
"""
Test extension that tracks all hook calls and their arguments.
@ -44,6 +57,11 @@ class DummyExtension(ComponentExtension):
"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:
# NOTE: Store only component name to avoid strong references
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)
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):
class TestComponent(Component):
template = "Hello {{ name }}!"
@ -91,17 +123,16 @@ def with_registry(on_created: Callable):
@djc_test
class TestExtension:
@djc_test(
components_settings={"extensions": [DummyExtension]}
)
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_extensios_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]}
)
@djc_test
class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_component_class_lifecycle_hooks(self):
extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
@ -130,9 +161,7 @@ class TestExtension:
assert len(extension.calls["on_component_class_deleted"]) == 1
assert extension.calls["on_component_class_deleted"][0] == "TestComponent"
@djc_test(
components_settings={"extensions": [DummyExtension]}
)
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_registry_lifecycle_hooks(self):
extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
@ -162,9 +191,7 @@ class TestExtension:
assert len(extension.calls["on_registry_deleted"]) == 1
assert extension.calls["on_registry_deleted"][0] == reg_id
@djc_test(
components_settings={"extensions": [DummyExtension]}
)
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_component_registration_hooks(self):
class TestComponent(Component):
template = "Hello {{ name }}!"
@ -191,9 +218,7 @@ class TestExtension:
assert unreg_call.name == "test_comp"
assert unreg_call.component_cls == TestComponent
@djc_test(
components_settings={"extensions": [DummyExtension]}
)
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_component_render_hooks(self):
@register("test_comp")
class TestComponent(Component):
@ -211,9 +236,7 @@ class TestExtension:
# Render the component with some args and kwargs
test_context = Context({"foo": "bar"})
test_slots = {"content": "Some content"}
TestComponent.render(
context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots
)
TestComponent.render(context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots)
extension = cast(DummyExtension, app_settings.EXTENSIONS[1])
@ -236,3 +259,34 @@ class TestExtension:
assert data_call.context_data == {"name": "Test"}
assert data_call.js_data == {"script": "console.log('Hello!')"}
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"