mirror of
https://github.com/django-components/django-components.git
synced 2025-09-26 15:39:08 +00:00
feat: allow extensions to add url views (#1025)
* feat: allow extensions to add url views * refactor: fix linter errors
This commit is contained in:
parent
d3d2d0ab08
commit
12a64f8e41
16 changed files with 443 additions and 76 deletions
|
@ -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/).
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
```
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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"
|
||||
|
|
62
src/django_components/util/routing.py
Normal file
62
src/django_components/util/routing.py
Normal 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")
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue