refactor: use typevar defaults + raise on conflicting extensions (#1125)

* refactor: use typevar defaults + raise on conflicting extensions

* refactor: fix linter errors
This commit is contained in:
Juro Oravec 2025-04-14 10:00:18 +02:00 committed by GitHub
parent 61528ef0ad
commit 06cad2ec64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 321 additions and 93 deletions

View file

@ -1,6 +1,32 @@
# Release notes # Release notes
## v0.139 ## v0.139.1
#### Refactor
- When typing a Component, you can now specify as few or as many parameters as you want.
```py
Component[Args]
Component[Args, Kwargs]
Component[Args, Kwargs, Slots]
Component[Args, Kwargs, Slots, Data]
Component[Args, Kwargs, Slots, Data, JsData]
Component[Args, Kwargs, Slots, Data, JsData, CssData]
```
All omitted parameters will default to `Any`.
- Added `typing_extensions` to the project as a dependency
- Multiple extensions with the same name (case-insensitive) now raise an error
- Extension names (case-insensitive) also MUST NOT conflict with existing Component class API.
So if you name an extension `render`, it will conflict with the `render()` method of the `Component` class,
and thus raise an error.
## v0.139.0
#### Fix #### Fix

View file

@ -396,7 +396,7 @@ Components API is fully typed, and supports [static type hints](https://django-c
To opt-in to static type hints, define types for component's args, kwargs, slots, and more: To opt-in to static type hints, define types for component's args, kwargs, slots, and more:
```py ```py
from typing import NotRequired, Tuple, TypedDict, SlotContent, SlotFunc from typing import NotRequired, Tuple, TypedDict, SlotContent, Slot
from django_components import Component from django_components import Component
@ -407,22 +407,19 @@ class ButtonKwargs(TypedDict):
another: int another: int
maybe_var: NotRequired[int] # May be omitted maybe_var: NotRequired[int] # May be omitted
class ButtonData(TypedDict):
variable: str
class ButtonSlots(TypedDict): class ButtonSlots(TypedDict):
my_slot: NotRequired[SlotFunc] # Use `Slot` for slot functions.
my_slot: NotRequired[Slot]
# Use `SlotContent` when you want to allow either `Slot` instance or plain string
another_slot: SlotContent another_slot: SlotContent
ButtonType = Component[ButtonArgs, ButtonKwargs, ButtonSlots, ButtonData, JsData, CssData] ButtonType = Component[ButtonArgs, ButtonKwargs, ButtonSlots]
class Button(ButtonType): class Button(ButtonType):
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
self.input.args[0] # int self.input.args[0] # int
self.input.kwargs["variable"] # str self.input.kwargs["variable"] # str
self.input.slots["my_slot"] # SlotFunc[MySlotData] self.input.slots["my_slot"] # Slot[MySlotData]
return {} # Error: Key "variable" is missing
``` ```
When you then call When you then call

View file

@ -103,7 +103,7 @@ For live examples, see the [Community examples](../../overview/community.md#comm
```djc_py ```djc_py
from typing import Dict, NotRequired, Optional, Tuple, TypedDict from typing import Dict, NotRequired, Optional, Tuple, TypedDict
from django_components import Component, SlotFunc, register, types from django_components import Component, SlotContent, register, types
from myapp.templatetags.mytags import comp_registry from myapp.templatetags.mytags import comp_registry
@ -114,7 +114,7 @@ For live examples, see the [Community examples](../../overview/community.md#comm
type MyMenuArgs = Tuple[int, str] type MyMenuArgs = Tuple[int, str]
class MyMenuSlots(TypedDict): class MyMenuSlots(TypedDict):
default: NotRequired[Optional[SlotFunc[EmptyDict]]] default: NotRequired[Optional[SlotContent[EmptyDict]]]
class MyMenuProps(TypedDict): class MyMenuProps(TypedDict):
vertical: NotRequired[bool] vertical: NotRequired[bool]
@ -124,7 +124,7 @@ For live examples, see the [Community examples](../../overview/community.md#comm
# Define the component # Define the component
# NOTE: Don't forget to set the `registry`! # NOTE: Don't forget to set the `registry`!
@register("my_menu", registry=comp_registry) @register("my_menu", registry=comp_registry)
class MyMenu(Component[MyMenuArgs, MyMenuProps, MyMenuSlots, Any, Any, Any]): class MyMenu(Component[MyMenuArgs, MyMenuProps, MyMenuSlots]):
def get_context_data( def get_context_data(
self, self,
*args, *args,

View file

@ -169,6 +169,14 @@ class MyExtension(ComponentExtension):
ctx.component_cls.my_attr = "my_value" ctx.component_cls.my_attr = "my_value"
``` ```
!!! warning
The `name` attribute MUST be unique across all extensions.
Moreover, the `name` attribute MUST NOT conflict with existing Component class API.
So if you name an extension `render`, it will conflict with the [`render()`](../../../reference/api/#django_components.Component.render) method of the `Component` class.
### Defining the extension class ### Defining the extension class
In previous sections we've seen the `View` and `Storybook` extensions classes that were nested within the `Component` class: In previous sections we've seen the `View` and `Storybook` extensions classes that were nested within the `Component` class:

View file

@ -10,7 +10,7 @@ Use this to add type hints to your components, or to validate component inputs.
```py ```py
from django_components import Component from django_components import Component
ButtonType = Component[Args, Kwargs, Slots, Data, JsData, CssData] ButtonType = Component[Args, Kwargs, Slots, TemplateData, JsData, CssData]
class Button(ButtonType): class Button(ButtonType):
template_file = "button.html" template_file = "button.html"
@ -24,16 +24,34 @@ The generic parameters are:
- `Args` - Positional arguments, must be a `Tuple` or `Any` - `Args` - Positional arguments, must be a `Tuple` or `Any`
- `Kwargs` - Keyword arguments, must be a `TypedDict` or `Any` - `Kwargs` - Keyword arguments, must be a `TypedDict` or `Any`
- `Slots` - Slots, must be a `TypedDict` or `Any` - `Slots` - Slots, must be a `TypedDict` or `Any`
- `Data` - Data returned from [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data), must be a `TypedDict` or `Any` - `TemplateData` - Data returned from [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data), must be a `TypedDict` or `Any`
- `JsData` - Data returned from [`get_js_data()`](../../../reference/api#django_components.Component.get_js_data), must be a `TypedDict` or `Any` - `JsData` - Data returned from [`get_js_data()`](../../../reference/api#django_components.Component.get_js_data), must be a `TypedDict` or `Any`
- `CssData` - Data returned from [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data), must be a `TypedDict` or `Any` - `CssData` - Data returned from [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data), must be a `TypedDict` or `Any`
## Example You can specify as many or as few of the parameters as you want. The rest will default to `Any`.
All of these are valid:
```py
ButtonType = Component[Args, Kwargs, Slots, TemplateData, JsData, CssData]
ButtonType = Component[Args, Kwargs, Slots, TemplateData, JsData]
ButtonType = Component[Args, Kwargs, Slots, TemplateData]
ButtonType = Component[Args, Kwargs, Slots]
ButtonType = Component[Args, Kwargs]
ButtonType = Component[Args]
```
!!! info
Setting a type parameter to `Any` will disable typing and validation for that part.
## Typing inputs
You can use the first 3 parameters to type inputs.
```python ```python
from typing import NotRequired, Tuple, TypedDict from typing_extensions import NotRequired, Tuple, TypedDict
from pydantic import BaseModel from django_components import Component, Slot, SlotContent
from django_components import Component, SlotContent, SlotFunc
########################################### ###########################################
# 1. Define the types # 1. Define the types
@ -54,39 +72,17 @@ class ButtonFooterSlotData(TypedDict):
# Slots # Slots
class ButtonSlots(TypedDict): class ButtonSlots(TypedDict):
# SlotContent == str or slot func # Use `SlotContent` when you want to allow either `Slot` instance or plain string
header: SlotContent header: SlotContent
# Use SlotFunc for slot functions. # Use `Slot` for slot functions.
# The generic specifies the data available to the slot function # The generic specifies the data available to the slot function
footer: NotRequired[SlotFunc[ButtonFooterSlotData]] footer: NotRequired[Slot[ButtonFooterSlotData]]
# Data returned from `get_context_data`
class ButtonData(BaseModel):
data1: str
data2: int
# Data returned from `get_js_data`
class ButtonJsData(BaseModel):
js_data1: str
js_data2: int
# Data returned from `get_css_data`
class ButtonCssData(BaseModel):
css_data1: str
css_data2: int
########################################### ###########################################
# 2. Define the component with those types # 2. Define the component with those types
########################################### ###########################################
ButtonType = Component[ ButtonType = Component[ButtonArgs, ButtonKwargs, ButtonSlots]
ButtonArgs,
ButtonKwargs,
ButtonSlots,
ButtonData,
ButtonJsData,
ButtonCssData,
]
class Button(ButtonType): class Button(ButtonType):
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
@ -109,23 +105,81 @@ Button.render(
}, },
slots={ slots={
"header": "...", "header": "...",
# ERROR: Expects key "footer" "footer": lambda ctx, slot_data, slot_ref: slot_data.value + 1,
# ERROR: Unexpected key "foo"
"foo": "invalid", "foo": "invalid",
}, },
) )
``` ```
If you don't want to validate some parts, set them to [`Any`](https://docs.python.org/3/library/typing.html#typing.Any). If you don't want to validate some parts, set them to [`Any`](https://docs.python.org/3/library/typing.html#typing.Any) or omit them.
The following will validate only the keyword inputs:
```python ```python
ButtonType = Component[ ButtonType = Component[Any, ButtonKwargs]
ButtonArgs,
ButtonKwargs, class Button(ButtonType):
ButtonSlots, ...
Any, ```
Any,
Any, ## Typing data
]
You can use the last 3 parameters to type the data returned from [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data), [`get_js_data()`](../../../reference/api#django_components.Component.get_js_data), and [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data).
Use this together with the [Pydantic integration](#runtime-input-validation-with-types) to have runtime validation of the data.
```python
from typing_extensions import NotRequired, Tuple, TypedDict
from django_components import Component, Slot, SlotContent
###########################################
# 1. Define the types
###########################################
# Data returned from `get_context_data`
class ButtonData(TypedDict):
data1: str
data2: int
# Data returned from `get_js_data`
class ButtonJsData(TypedDict):
js_data1: str
js_data2: int
# Data returned from `get_css_data`
class ButtonCssData(TypedDict):
css_data1: str
css_data2: int
###########################################
# 2. Define the component with those types
###########################################
ButtonType = Component[Any, Any, Any, ButtonData, ButtonJsData, ButtonCssData]
class Button(ButtonType):
def get_context_data(self, *args, **kwargs):
...
def get_js_data(self, *args, **kwargs):
...
def get_css_data(self, *args, **kwargs):
...
```
When you then call
[`Component.render`](../../../reference/api#django_components.Component.render)
or [`Component.render_to_response`](../../../reference/api#django_components.Component.render_to_response),
the component will raise an error if the data is not valid.
If you don't want to validate some parts, set them to [`Any`](https://docs.python.org/3/library/typing.html#typing.Any).
The following will validate only the data returned from `get_js_data`:
```python
ButtonType = Component[Any, Any, Any, Any, ButtonJsData]
class Button(ButtonType): class Button(ButtonType):
... ...
@ -182,6 +236,9 @@ class Button(Component[EmptyTuple, EmptyDict, EmptyDict, EmptyDict, EmptyDict, E
Since v0.136, input validation is available as a separate extension. Since v0.136, input validation is available as a separate extension.
By defualt, when you add types to your component, this will only set up static type hints,
but it will not validate the inputs.
To enable input validation, you need to install the [`djc-ext-pydantic`](https://github.com/django-components/djc-ext-pydantic) extension: To enable input validation, you need to install the [`djc-ext-pydantic`](https://github.com/django-components/djc-ext-pydantic) extension:
```bash ```bash

View file

@ -47,7 +47,7 @@ Component.render(
context: Mapping | django.template.Context | None = None, context: Mapping | django.template.Context | None = None,
args: List[Any] | None = None, args: List[Any] | None = None,
kwargs: Dict[str, Any] | None = None, kwargs: Dict[str, Any] | None = None,
slots: Dict[str, str | SafeString | SlotFunc] | None = None, slots: Dict[str, str | SafeString | SlotContent] | None = None,
escape_slots_content: bool = True escape_slots_content: bool = True
) -> str: ) -> str:
``` ```
@ -60,7 +60,7 @@ Component.render(
- _`slots`_ - Component slot fills. This is the same as pasing `{% fill %}` tags to the component. - _`slots`_ - Component slot fills. This is the same as pasing `{% fill %}` tags to the component.
Accepts a dictionary of `{ slot_name: slot_content }` where `slot_content` can be a string Accepts a dictionary of `{ slot_name: slot_content }` where `slot_content` can be a string
or [`SlotFunc`](#slotfunc). or `Slot`.
- _`escape_slots_content`_ - Whether the content from `slots` should be escaped. `True` by default to prevent XSS attacks. If you disable escaping, you should make sure that any content you pass to the slots is safe, especially if it comes from user input. - _`escape_slots_content`_ - Whether the content from `slots` should be escaped. `True` by default to prevent XSS attacks. If you disable escaping, you should make sure that any content you pass to the slots is safe, especially if it comes from user input.

View file

@ -386,7 +386,7 @@ Components API is fully typed, and supports [static type hints](https://django-c
To opt-in to static type hints, define types for component's args, kwargs, slots, and more: To opt-in to static type hints, define types for component's args, kwargs, slots, and more:
```py ```py
from typing import NotRequired, Tuple, TypedDict, SlotContent, SlotFunc from typing import NotRequired, Tuple, TypedDict, SlotContent, Slot
from django_components import Component from django_components import Component
@ -397,22 +397,19 @@ class ButtonKwargs(TypedDict):
another: int another: int
maybe_var: NotRequired[int] # May be omitted maybe_var: NotRequired[int] # May be omitted
class ButtonData(TypedDict):
variable: str
class ButtonSlots(TypedDict): class ButtonSlots(TypedDict):
my_slot: NotRequired[SlotFunc] # Use `Slot` for slot functions.
my_slot: NotRequired[Slot]
# Use `SlotContent` when you want to allow either `Slot` instance or plain string
another_slot: SlotContent another_slot: SlotContent
ButtonType = Component[ButtonArgs, ButtonKwargs, ButtonSlots, ButtonData, JsData, CssData] ButtonType = Component[ButtonArgs, ButtonKwargs, ButtonSlots]
class Button(ButtonType): class Button(ButtonType):
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
self.input.args[0] # int self.input.args[0] # int
self.input.kwargs["variable"] # str self.input.kwargs["variable"] # str
self.input.slots["my_slot"] # SlotFunc[MySlotData] self.input.slots["my_slot"] # Slot
return {} # Error: Key "variable" is missing
``` ```
When you then call When you then call

View file

@ -19,10 +19,6 @@
options: options:
show_if_no_docstring: true show_if_no_docstring: true
::: django_components.ComponentCommand
options:
show_if_no_docstring: true
::: django_components.ComponentDefaults ::: django_components.ComponentDefaults
options: options:
show_if_no_docstring: true show_if_no_docstring: true
@ -35,6 +31,10 @@
options: options:
show_if_no_docstring: true show_if_no_docstring: true
::: django_components.ComponentInput
options:
show_if_no_docstring: true
::: django_components.ComponentMediaInput ::: django_components.ComponentMediaInput
options: options:
show_if_no_docstring: true show_if_no_docstring: true

View file

@ -462,8 +462,8 @@ ProjectDashboardAction project.components.dashboard_action.ProjectDashboardAc
## `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]
``` ```
@ -507,9 +507,9 @@ 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] [--verbose]
[--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--dry-run] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH]
[--no-color] [--force-color] [--skip-checks] [--traceback] [--no-color] [--force-color] [--skip-checks]
name name
``` ```

View file

@ -109,6 +109,7 @@ name | type | description
::: django_components.extension.ComponentExtension.on_component_rendered ::: django_components.extension.ComponentExtension.on_component_rendered
options: options:
heading_level: 3
show_root_heading: true show_root_heading: true
show_signature: true show_signature: true
separate_signature: true separate_signature: true

View file

@ -20,7 +20,7 @@ Import as
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1037" target="_blank">See source code</a> <a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1064" target="_blank">See source code</a>
@ -43,7 +43,7 @@ If you insert this tag multiple times, ALL CSS links will be duplicately inserte
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1059" target="_blank">See source code</a> <a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1086" 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#L1564" target="_blank">See source code</a> <a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1669" target="_blank">See source code</a>

View file

@ -31,6 +31,7 @@ classifiers = [
dependencies = [ dependencies = [
'Django>=4.2', 'Django>=4.2',
'djc-core-html-parser>=1.0.2', 'djc-core-html-parser>=1.0.2',
'typing-extensions>=4.12.2',
] ]
license = {text = "MIT"} license = {text = "MIT"}

View file

@ -7,3 +7,4 @@ whitenoise
asv asv
pytest-asyncio pytest-asyncio
pytest-django pytest-django
typing-extensions>=4.12.2

View file

@ -95,6 +95,7 @@ types-requests==2.32.0.20241016
# via -r requirements-ci.in # via -r requirements-ci.in
typing-extensions==4.12.2 typing-extensions==4.12.2
# via # via
# -r requirements-ci.in
# pyee # pyee
# tox # tox
urllib3==2.2.3 urllib3==2.2.3

View file

@ -18,3 +18,4 @@ whitenoise
pygments pygments
pygments-djc pygments-djc
asv asv
typing-extensions>=4.12.2

View file

@ -150,6 +150,7 @@ types-requests==2.32.0.20241016
# via -r requirements-dev.in # via -r requirements-dev.in
typing-extensions==4.12.2 typing-extensions==4.12.2
# via # via
# -r requirements-dev.in
# asgiref # asgiref
# black # black
# mypy # mypy

View file

@ -18,7 +18,6 @@ from typing import (
Optional, Optional,
Tuple, Tuple,
Type, Type,
TypeVar,
Union, Union,
cast, cast,
) )
@ -34,6 +33,7 @@ from django.template.loader_tags import BLOCK_CONTEXT_KEY, BlockContext
from django.test.signals import template_rendered from django.test.signals import template_rendered
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
from django.views import View from django.views import View
from typing_extensions import TypeVar
from django_components.app_settings import ContextBehavior, app_settings from django_components.app_settings import ContextBehavior, app_settings
from django_components.component_media import ComponentMediaInput, ComponentMediaMeta from django_components.component_media import ComponentMediaInput, ComponentMediaMeta
@ -103,12 +103,12 @@ from django_components.component_registry import registry as registry # NOQA
COMP_ONLY_FLAG = "only" COMP_ONLY_FLAG = "only"
# Define TypeVars for args and kwargs # Define TypeVars for args and kwargs
ArgsType = TypeVar("ArgsType", bound=tuple, contravariant=True) ArgsType = TypeVar("ArgsType", bound=tuple, contravariant=True, default=Any)
KwargsType = TypeVar("KwargsType", bound=Mapping[str, Any], contravariant=True) KwargsType = TypeVar("KwargsType", bound=Mapping[str, Any], contravariant=True, default=Any)
SlotsType = TypeVar("SlotsType", bound=Mapping[SlotName, SlotContent]) SlotsType = TypeVar("SlotsType", bound=Mapping[SlotName, SlotContent], default=Any)
DataType = TypeVar("DataType", bound=Mapping[str, Any], covariant=True) DataType = TypeVar("DataType", bound=Mapping[str, Any], covariant=True, default=Any)
JsDataType = TypeVar("JsDataType", bound=Mapping[str, Any]) JsDataType = TypeVar("JsDataType", bound=Mapping[str, Any], default=Any)
CssDataType = TypeVar("CssDataType", bound=Mapping[str, Any]) CssDataType = TypeVar("CssDataType", bound=Mapping[str, Any], default=Any)
# NOTE: `ReferenceType` is NOT a generic pre-3.9 # NOTE: `ReferenceType` is NOT a generic pre-3.9

View file

@ -1,5 +1,5 @@
from functools import wraps from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Tuple, Type, TypeVar, Union from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Set, Tuple, Type, TypeVar, Union
import django.urls import django.urls
from django.template import Context from django.template import Context
@ -661,7 +661,20 @@ class ExtensionManager:
# Populate the `urlpatterns` with URLs specified by the extensions # Populate the `urlpatterns` with URLs specified by the extensions
# TODO_V3 - Django-specific logic - replace with hook # TODO_V3 - Django-specific logic - replace with hook
urls: List[URLResolver] = [] urls: List[URLResolver] = []
seen_names: Set[str] = set()
from django_components import Component
for extension in self.extensions: for extension in self.extensions:
# Ensure that the extension name won't conflict with existing Component class API
if hasattr(Component, extension.name) or hasattr(Component, extension.class_name):
raise ValueError(f"Extension name '{extension.name}' conflicts with existing Component class API")
if extension.name.lower() in seen_names:
raise ValueError(f"Multiple extensions cannot have the same name '{extension.name}'")
seen_names.add(extension.name.lower())
# NOTE: The empty list is a placeholder for the URLs that will be added later # NOTE: The empty list is a placeholder for the URLs that will be added later
curr_ext_url_resolver = django.urls.path(f"{extension.name}/", django.urls.include([])) curr_ext_url_resolver = django.urls.path(f"{extension.name}/", django.urls.include([]))
urls.append(curr_ext_url_resolver) urls.append(curr_ext_url_resolver)

View file

@ -1,12 +1,10 @@
from typing import Any, Optional, Type, TypeVar from typing import Any, Optional, Type
from django.template import Origin, Template from django.template import Origin, Template
from django_components.cache import get_template_cache from django_components.cache import get_template_cache
from django_components.util.misc import get_import_path from django_components.util.misc import get_import_path
TTemplate = TypeVar("TTemplate", bound=Template)
# Central logic for creating Templates from string, so we can cache the results # Central logic for creating Templates from string, so we can cache the results
def cached_template( def cached_template(

View file

@ -4,7 +4,8 @@ For tests focusing on the `component` tag, see `test_templatetags_component.py`
""" """
import re import re
from typing import Any, Dict, no_type_check from typing import Any, Dict, Tuple, no_type_check
from typing_extensions import NotRequired, TypedDict
import pytest import pytest
from django.conf import settings from django.conf import settings
@ -16,7 +17,16 @@ from django.test import Client
from django.urls import path from django.urls import path
from pytest_django.asserts import assertHTMLEqual, assertInHTML from pytest_django.asserts import assertHTMLEqual, assertInHTML
from django_components import Component, ComponentView, all_components, get_component_by_class_id, register, types from django_components import (
Component,
ComponentView,
SlotContent,
Slot,
all_components,
get_component_by_class_id,
register,
types,
)
from django_components.slots import SlotRef from django_components.slots import SlotRef
from django_components.urls import urlpatterns as dc_urlpatterns from django_components.urls import urlpatterns as dc_urlpatterns
@ -375,6 +385,101 @@ class TestComponent:
assert SimpleComponent.render() == "Hello" assert SimpleComponent.render() == "Hello"
def test_typing(self):
# Types
ButtonArgs = Tuple[str, ...]
class ButtonKwargs(TypedDict):
name: str
age: int
maybe_var: NotRequired[int]
class ButtonFooterSlotData(TypedDict):
value: int
class ButtonSlots(TypedDict):
# Use `SlotContent` when you want to allow either function (`Slot` instance)
# or plain string.
header: SlotContent
# Use `Slot` for slot functions. The generic specifies the data available to the slot function.
footer: NotRequired[Slot[ButtonFooterSlotData]]
# Data returned from `get_context_data`
class ButtonData(TypedDict):
data1: str
data2: int
# Data returned from `get_js_data`
class ButtonJsData(TypedDict):
js_data1: str
js_data2: int
# Data returned from `get_css_data`
class ButtonCssData(TypedDict):
css_data1: str
css_data2: int
# Tests - We simply check that these don't raise any errors
# nor any type errors.
ButtonType1 = Component[ButtonArgs, ButtonKwargs, ButtonSlots, ButtonData, ButtonJsData, ButtonCssData]
ButtonType2 = Component[ButtonArgs, ButtonKwargs, ButtonSlots, ButtonData, ButtonJsData]
ButtonType3 = Component[ButtonArgs, ButtonKwargs, ButtonSlots, ButtonData]
ButtonType4 = Component[ButtonArgs, ButtonKwargs, ButtonSlots]
ButtonType5 = Component[ButtonArgs, ButtonKwargs]
ButtonType6 = Component[ButtonArgs]
class Button1(ButtonType1):
template = "<button>Click me!</button>"
class Button2(ButtonType2):
template = "<button>Click me!</button>"
class Button3(ButtonType3):
template = "<button>Click me!</button>"
class Button4(ButtonType4):
template = "<button>Click me!</button>"
class Button5(ButtonType5):
template = "<button>Click me!</button>"
class Button6(ButtonType6):
template = "<button>Click me!</button>"
Button1.render(
args=("arg1", "arg2"),
kwargs={"name": "name", "age": 123},
slots={"header": "HEADER", "footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER")},
)
Button2.render(
args=("arg1", "arg2"),
kwargs={"name": "name", "age": 123},
slots={"header": "HEADER", "footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER")},
)
Button3.render(
args=("arg1", "arg2"),
kwargs={"name": "name", "age": 123},
slots={"header": "HEADER", "footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER")},
)
Button4.render(
args=("arg1", "arg2"),
kwargs={"name": "name", "age": 123},
slots={"header": "HEADER", "footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER")},
)
Button5.render(
args=("arg1", "arg2"),
kwargs={"name": "name", "age": 123},
)
Button6.render(
args=("arg1", "arg2"),
)
@djc_test @djc_test
class TestComponentRender: class TestComponentRender:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)

View file

@ -1,6 +1,7 @@
import gc import gc
from typing import Any, Callable, Dict, List, cast from typing import Any, Callable, Dict, List, cast
import pytest
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.template import Context from django.template import Context
from django.test import Client from django.test import Client
@ -108,6 +109,10 @@ class DummyNestedExtension(ComponentExtension):
] ]
class RenderExtension(ComponentExtension):
name = "render"
def with_component_cls(on_created: Callable): def with_component_cls(on_created: Callable):
class TempComponent(Component): class TempComponent(Component):
template = "Hello {{ name }}!" template = "Hello {{ name }}!"
@ -151,6 +156,22 @@ class TestExtension:
del TestAccessComp del TestAccessComp
gc.collect() gc.collect()
def test_raises_on_extension_name_conflict(self):
@djc_test(components_settings={"extensions": [RenderExtension]})
def inner():
pass
with pytest.raises(ValueError, match="Extension name 'render' conflicts with existing Component class API"):
inner()
def test_raises_on_multiple_extensions_with_same_name(self):
@djc_test(components_settings={"extensions": [DummyExtension, DummyExtension]})
def inner():
pass
with pytest.raises(ValueError, match="Multiple extensions cannot have the same name 'test_extension'"):
inner()
@djc_test @djc_test
class TestExtensionHooks: class TestExtensionHooks: