mirror of
https://github.com/django-components/django-components.git
synced 2025-08-10 01:08:00 +00:00
refactor: remove input validation and link to it (#1082)
* feat: allow to set defaults * refactor: remove input validation and link to it * docs: update changelog * Update typing_and_validation.md * Update typing_and_validation.md
This commit is contained in:
parent
5e263ec143
commit
7e74831599
7 changed files with 209 additions and 747 deletions
17
CHANGELOG.md
17
CHANGELOG.md
|
@ -1,5 +1,22 @@
|
||||||
# Release notes
|
# Release notes
|
||||||
|
|
||||||
|
## 🚨📢 v0.136
|
||||||
|
|
||||||
|
#### 🚨📢 BREAKING CHANGES
|
||||||
|
|
||||||
|
- Component input validation was moved to a separate extension [`djc-ext-pydantic`](https://github.com/django-components/djc-ext-pydantic).
|
||||||
|
|
||||||
|
If you relied on components raising errors when inputs were invalid, you need to install `djc-ext-pydantic` and add it to extensions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# settings.py
|
||||||
|
COMPONENTS = {
|
||||||
|
"extensions": [
|
||||||
|
"djc_ext_pydantic.PydanticExtension",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## v0.135
|
## v0.135
|
||||||
|
|
||||||
#### Feat
|
#### Feat
|
||||||
|
|
|
@ -327,13 +327,20 @@ Django-components functionality can be extended with "extensions". Extensions al
|
||||||
|
|
||||||
- Tap into lifecycle events, such as when a component is created, deleted, or registered.
|
- Tap into lifecycle events, such as when a component is created, deleted, or registered.
|
||||||
- Add new attributes and methods to the components under an extension-specific nested class.
|
- Add new attributes and methods to the components under an extension-specific nested class.
|
||||||
|
- Add custom CLI commands.
|
||||||
|
- Add custom URLs.
|
||||||
|
|
||||||
|
Some of the extensions include:
|
||||||
|
|
||||||
|
- [Django View integration](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/view.py)
|
||||||
|
- [Component defaults](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/defaults.py)
|
||||||
|
- [Pydantic integration (input validation)](https://github.com/django-components/djc-ext-pydantic)
|
||||||
|
|
||||||
Some of the planned extensions include:
|
Some of the planned extensions include:
|
||||||
|
|
||||||
- Caching
|
- Caching
|
||||||
- AlpineJS integration
|
- AlpineJS integration
|
||||||
- Storybook integration
|
- Storybook integration
|
||||||
- Pydantic validation
|
|
||||||
- Component-level benchmarking with asv
|
- Component-level benchmarking with asv
|
||||||
|
|
||||||
### Simple testing
|
### Simple testing
|
||||||
|
|
|
@ -2,71 +2,205 @@
|
||||||
|
|
||||||
_New in version 0.92_
|
_New in version 0.92_
|
||||||
|
|
||||||
The `Component` class optionally accepts type parameters
|
The [`Component`](../../../reference/api#django_components.Component) class optionally accepts type parameters
|
||||||
that allow you to specify the types of args, kwargs, slots, and
|
that allow you to specify the types of args, kwargs, slots, and data.
|
||||||
data:
|
|
||||||
|
Use this to add type hints to your components, or to validate component inputs.
|
||||||
|
|
||||||
```py
|
```py
|
||||||
class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]):
|
from django_components import Component
|
||||||
|
|
||||||
|
ButtonType = Component[Args, Kwargs, Slots, Data, JsData, CssData]
|
||||||
|
|
||||||
|
class Button(ButtonType):
|
||||||
|
template_file = "button.html"
|
||||||
|
|
||||||
|
def get_context_data(self, *args, **kwargs):
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
- `Args` - Must be a `Tuple` or `Any`
|
The generic parameters are:
|
||||||
- `Kwargs` - Must be a `TypedDict` or `Any`
|
|
||||||
- `Data` - Must be a `TypedDict` or `Any`
|
|
||||||
- `Slots` - Must be a `TypedDict` or `Any`
|
|
||||||
|
|
||||||
Here's a full example:
|
- `Args` - Positional arguments, must be a `Tuple` or `Any`
|
||||||
|
- `Kwargs` - Keyword arguments, 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`
|
||||||
|
- `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`
|
||||||
|
|
||||||
```py
|
## Example
|
||||||
from typing import NotRequired, Tuple, TypedDict, SlotContent, SlotFunc
|
|
||||||
|
```python
|
||||||
|
from typing import NotRequired, Tuple, TypedDict
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from django_components import Component, SlotContent, SlotFunc
|
||||||
|
|
||||||
|
###########################################
|
||||||
|
# 1. Define the types
|
||||||
|
###########################################
|
||||||
|
|
||||||
# Positional inputs
|
# Positional inputs
|
||||||
Args = Tuple[int, str]
|
ButtonArgs = Tuple[str, ...]
|
||||||
|
|
||||||
# Kwargs inputs
|
# Keyword inputs
|
||||||
class Kwargs(TypedDict):
|
class ButtonKwargs(TypedDict):
|
||||||
variable: str
|
name: str
|
||||||
another: int
|
age: int
|
||||||
maybe_var: NotRequired[int] # May be ommited
|
maybe_var: NotRequired[int] # May be ommited
|
||||||
|
|
||||||
# Data returned from `get_context_data`
|
# The data available to the `footer` scoped slot
|
||||||
class Data(TypedDict):
|
class ButtonFooterSlotData(TypedDict):
|
||||||
variable: str
|
|
||||||
|
|
||||||
# The data available to the `my_slot` scoped slot
|
|
||||||
class MySlotData(TypedDict):
|
|
||||||
value: int
|
value: int
|
||||||
|
|
||||||
# Slots
|
# Slots
|
||||||
class Slots(TypedDict):
|
class ButtonSlots(TypedDict):
|
||||||
|
# SlotContent == str or slot func
|
||||||
|
header: SlotContent
|
||||||
# Use SlotFunc for slot functions.
|
# Use SlotFunc for slot functions.
|
||||||
# The generic specifies the `data` dictionary
|
# The generic specifies the data available to the slot function
|
||||||
my_slot: NotRequired[SlotFunc[MySlotData]]
|
footer: NotRequired[SlotFunc[ButtonFooterSlotData]]
|
||||||
# SlotContent == Union[str, SafeString]
|
|
||||||
another_slot: SlotContent
|
|
||||||
|
|
||||||
class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]):
|
# Data returned from `get_context_data`
|
||||||
def get_context_data(self, variable, another):
|
class ButtonData(BaseModel):
|
||||||
return {
|
data1: str
|
||||||
"variable": variable,
|
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
|
||||||
|
###########################################
|
||||||
|
|
||||||
|
ButtonType = Component[
|
||||||
|
ButtonArgs,
|
||||||
|
ButtonKwargs,
|
||||||
|
ButtonSlots,
|
||||||
|
ButtonData,
|
||||||
|
ButtonJsData,
|
||||||
|
ButtonCssData,
|
||||||
|
]
|
||||||
|
|
||||||
|
class Button(ButtonType):
|
||||||
|
def get_context_data(self, *args, **kwargs):
|
||||||
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
When you then call `Component.render` or `Component.render_to_response`, you will get type hints:
|
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),
|
||||||
|
you will get type hints:
|
||||||
|
|
||||||
```py
|
```python
|
||||||
Button.render(
|
Button.render(
|
||||||
# Error: First arg must be `int`, got `float`
|
# ERROR: Expects a string
|
||||||
args=(1.25, "abc"),
|
args=(123,),
|
||||||
# Error: Key "another" is missing
|
|
||||||
kwargs={
|
kwargs={
|
||||||
"variable": "text",
|
"name": "John",
|
||||||
|
# ERROR: Expects an integer
|
||||||
|
"age": "invalid",
|
||||||
|
},
|
||||||
|
slots={
|
||||||
|
"header": "...",
|
||||||
|
# ERROR: Expects key "footer"
|
||||||
|
"foo": "invalid",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Usage for Python <3.11
|
If you don't want to validate some parts, set them to [`Any`](https://docs.python.org/3/library/typing.html#typing.Any).
|
||||||
|
|
||||||
|
```python
|
||||||
|
ButtonType = Component[
|
||||||
|
ButtonArgs,
|
||||||
|
ButtonKwargs,
|
||||||
|
ButtonSlots,
|
||||||
|
Any,
|
||||||
|
Any,
|
||||||
|
Any,
|
||||||
|
]
|
||||||
|
|
||||||
|
class Button(ButtonType):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Passing variadic args and kwargs
|
||||||
|
|
||||||
|
You may have a function that accepts a variable number of args or kwargs:
|
||||||
|
|
||||||
|
```py
|
||||||
|
def get_context_data(self, *args, **kwargs):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
This is not supported with the typed components.
|
||||||
|
|
||||||
|
As a workaround:
|
||||||
|
|
||||||
|
- For a variable number of positional arguments (`*args`), set a positional argument that accepts a list of values:
|
||||||
|
|
||||||
|
```py
|
||||||
|
# Tuple of one member of list of strings
|
||||||
|
Args = Tuple[List[str]]
|
||||||
|
```
|
||||||
|
|
||||||
|
- For a variable number of keyword arguments (`**kwargs`), set a keyword argument that accepts a dictionary of values:
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Kwargs(TypedDict):
|
||||||
|
variable: str
|
||||||
|
another: int
|
||||||
|
# Pass any extra keys under `extra`
|
||||||
|
extra: Dict[str, any]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Handling no args or no kwargs
|
||||||
|
|
||||||
|
To declare that a component accepts no args, kwargs, etc, you can use the
|
||||||
|
[`EmptyTuple`](../../../reference/api#django_components.EmptyTuple) and
|
||||||
|
[`EmptyDict`](../../../reference/api#django_components.EmptyDict) types:
|
||||||
|
|
||||||
|
```py
|
||||||
|
from django_components import Component, EmptyDict, EmptyTuple
|
||||||
|
|
||||||
|
class Button(Component[EmptyTuple, EmptyDict, EmptyDict, EmptyDict, EmptyDict, EmptyDict]):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime input validation with types
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
Input validation was part of Django Components from version 0.96 to 0.135.
|
||||||
|
|
||||||
|
Since v0.136, input validation is available as a separate extension.
|
||||||
|
|
||||||
|
To enable input validation, you need to install the [`djc-ext-pydantic`](https://github.com/django-components/djc-ext-pydantic) extension:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install djc-ext-pydantic
|
||||||
|
```
|
||||||
|
|
||||||
|
And add the extension to your project:
|
||||||
|
|
||||||
|
```py
|
||||||
|
COMPONENTS = {
|
||||||
|
"extensions": [
|
||||||
|
"djc_ext_pydantic.PydanticExtension",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`djc-ext-pydantic` integrates [Pydantic](https://pydantic.dev/) for input and data validation. It uses the types defined on the component's class to validate inputs of Django components.
|
||||||
|
|
||||||
|
## Usage for Python <3.11
|
||||||
|
|
||||||
On Python 3.8-3.10, use `typing_extensions`
|
On Python 3.8-3.10, use `typing_extensions`
|
||||||
|
|
||||||
|
@ -83,91 +217,3 @@ from __future__ import annotations
|
||||||
Moreover, on 3.10 and less, you may not be able to use `NotRequired`, and instead you will need to mark either all keys are required, or all keys as optional, using TypeDict's `total` kwarg.
|
Moreover, on 3.10 and less, you may not be able to use `NotRequired`, and instead you will need to mark either all keys are required, or all keys as optional, using TypeDict's `total` kwarg.
|
||||||
|
|
||||||
[See PEP-655](https://peps.python.org/pep-0655) for more info.
|
[See PEP-655](https://peps.python.org/pep-0655) for more info.
|
||||||
|
|
||||||
## Passing additional args or kwargs
|
|
||||||
|
|
||||||
You may have a function that supports any number of args or kwargs:
|
|
||||||
|
|
||||||
```py
|
|
||||||
def get_context_data(self, *args, **kwargs):
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
This is not supported with the typed components.
|
|
||||||
|
|
||||||
As a workaround:
|
|
||||||
|
|
||||||
- For `*args`, set a positional argument that accepts a list of values:
|
|
||||||
|
|
||||||
```py
|
|
||||||
# Tuple of one member of list of strings
|
|
||||||
Args = Tuple[List[str]]
|
|
||||||
```
|
|
||||||
|
|
||||||
- For `*kwargs`, set a keyword argument that accepts a dictionary of values:
|
|
||||||
|
|
||||||
```py
|
|
||||||
class Kwargs(TypedDict):
|
|
||||||
variable: str
|
|
||||||
another: int
|
|
||||||
# Pass any extra keys under `extra`
|
|
||||||
extra: Dict[str, any]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Handling no args or no kwargs
|
|
||||||
|
|
||||||
To declare that a component accepts no Args, Kwargs, etc, you can use `EmptyTuple` and `EmptyDict` types:
|
|
||||||
|
|
||||||
```py
|
|
||||||
from django_components import Component, EmptyDict, EmptyTuple
|
|
||||||
|
|
||||||
Args = EmptyTuple
|
|
||||||
Kwargs = Data = Slots = EmptyDict
|
|
||||||
|
|
||||||
class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]):
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Runtime input validation with types
|
|
||||||
|
|
||||||
_New in version 0.96_
|
|
||||||
|
|
||||||
> NOTE: Kwargs, slots, and data validation is supported only for Python >=3.11
|
|
||||||
|
|
||||||
In Python 3.11 and later, when you specify the component types, you will get also runtime validation of the inputs you pass to `Component.render` or `Component.render_to_response`.
|
|
||||||
|
|
||||||
So, using the example from before, if you ignored the type errors and still ran the following code:
|
|
||||||
|
|
||||||
```py
|
|
||||||
Button.render(
|
|
||||||
# Error: First arg must be `int`, got `float`
|
|
||||||
args=(1.25, "abc"),
|
|
||||||
# Error: Key "another" is missing
|
|
||||||
kwargs={
|
|
||||||
"variable": "text",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
This would raise a `TypeError`:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
Component 'Button' expected positional argument at index 0 to be <class 'int'>, got 1.25 of type <class 'float'>
|
|
||||||
```
|
|
||||||
|
|
||||||
In case you need to skip these errors, you can either set the faulty member to `Any`, e.g.:
|
|
||||||
|
|
||||||
```py
|
|
||||||
# Changed `int` to `Any`
|
|
||||||
Args = Tuple[Any, str]
|
|
||||||
```
|
|
||||||
|
|
||||||
Or you can replace `Args` with `Any` altogether, to skip the validation of args:
|
|
||||||
|
|
||||||
```py
|
|
||||||
# Replaced `Args` with `Any`
|
|
||||||
class Button(Component[Any, Kwargs, Slots, Data, JsData, CssData]):
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
Same applies to kwargs, data, and slots.
|
|
||||||
|
|
|
@ -317,13 +317,20 @@ Django-components functionality can be extended with "extensions". Extensions al
|
||||||
|
|
||||||
- Tap into lifecycle events, such as when a component is created, deleted, or registered.
|
- Tap into lifecycle events, such as when a component is created, deleted, or registered.
|
||||||
- Add new attributes and methods to the components under an extension-specific nested class.
|
- Add new attributes and methods to the components under an extension-specific nested class.
|
||||||
|
- Add custom CLI commands.
|
||||||
|
- Add custom URLs.
|
||||||
|
|
||||||
|
Some of the extensions include:
|
||||||
|
|
||||||
|
- [Django View integration](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/view.py)
|
||||||
|
- [Component defaults](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/defaults.py)
|
||||||
|
- [Pydantic integration (input validation)](https://github.com/django-components/djc-ext-pydantic)
|
||||||
|
|
||||||
Some of the planned extensions include:
|
Some of the planned extensions include:
|
||||||
|
|
||||||
- Caching
|
- Caching
|
||||||
- AlpineJS integration
|
- AlpineJS integration
|
||||||
- Storybook integration
|
- Storybook integration
|
||||||
- Pydantic validation
|
|
||||||
- Component-level benchmarking with asv
|
- Component-level benchmarking with asv
|
||||||
|
|
||||||
### Simple testing
|
### Simple testing
|
||||||
|
|
|
@ -85,7 +85,6 @@ from django_components.util.exception import component_error_message
|
||||||
from django_components.util.logger import trace_component_msg
|
from django_components.util.logger import trace_component_msg
|
||||||
from django_components.util.misc import gen_id, get_import_path, hash_comp_cls
|
from django_components.util.misc import gen_id, get_import_path, hash_comp_cls
|
||||||
from django_components.util.template_tag import TagAttr
|
from django_components.util.template_tag import TagAttr
|
||||||
from django_components.util.validation import validate_typed_dict, validate_typed_tuple
|
|
||||||
from django_components.util.weakref import cached_ref
|
from django_components.util.weakref import cached_ref
|
||||||
|
|
||||||
# TODO_REMOVE_IN_V1 - Users should use top-level import instead
|
# TODO_REMOVE_IN_V1 - Users should use top-level import instead
|
||||||
|
@ -1093,10 +1092,6 @@ class Component(
|
||||||
render_dependencies: bool = True,
|
render_dependencies: bool = True,
|
||||||
request: Optional[HttpRequest] = None,
|
request: Optional[HttpRequest] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
# NOTE: We must run validation before we normalize the slots, because the normalization
|
|
||||||
# wraps them in functions.
|
|
||||||
self._validate_inputs(args or (), kwargs or {}, slots or {})
|
|
||||||
|
|
||||||
# Allow to pass down Request object via context.
|
# Allow to pass down Request object via context.
|
||||||
# `context` may be passed explicitly via `Component.render()` and `Component.render_to_response()`,
|
# `context` may be passed explicitly via `Component.render()` and `Component.render_to_response()`,
|
||||||
# or implicitly via `{% component %}` tag.
|
# or implicitly via `{% component %}` tag.
|
||||||
|
@ -1230,7 +1225,6 @@ class Component(
|
||||||
# TODO - enable JS and CSS vars - EXPOSE AND DOCUMENT AND MAKE NON-NULL
|
# TODO - enable JS and CSS vars - EXPOSE AND DOCUMENT AND MAKE NON-NULL
|
||||||
js_data = self.get_js_data(*args, **kwargs) if hasattr(self, "get_js_data") else {} # type: ignore
|
js_data = self.get_js_data(*args, **kwargs) if hasattr(self, "get_js_data") else {} # type: ignore
|
||||||
css_data = self.get_css_data(*args, **kwargs) if hasattr(self, "get_css_data") else {} # type: ignore
|
css_data = self.get_css_data(*args, **kwargs) if hasattr(self, "get_css_data") else {} # type: ignore
|
||||||
self._validate_outputs(data=context_data)
|
|
||||||
|
|
||||||
extensions.on_component_data(
|
extensions.on_component_data(
|
||||||
OnComponentDataContext(
|
OnComponentDataContext(
|
||||||
|
@ -1489,99 +1483,6 @@ class Component(
|
||||||
|
|
||||||
return norm_fills
|
return norm_fills
|
||||||
|
|
||||||
# #####################################
|
|
||||||
# VALIDATION
|
|
||||||
# #####################################
|
|
||||||
|
|
||||||
def _get_types(self) -> Optional[Tuple[Any, Any, Any, Any, Any, Any]]:
|
|
||||||
"""
|
|
||||||
Extract the types passed to the Component class.
|
|
||||||
|
|
||||||
So if a component subclasses Component class like so
|
|
||||||
|
|
||||||
```py
|
|
||||||
class MyComp(Component[MyArgs, MyKwargs, MySlots, MyData, MyJsData, MyCssData]):
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
Then we want to extract the tuple (MyArgs, MyKwargs, MySlots, MyData, MyJsData, MyCssData).
|
|
||||||
|
|
||||||
Returns `None` if types were not provided. That is, the class was subclassed
|
|
||||||
as:
|
|
||||||
|
|
||||||
```py
|
|
||||||
class MyComp(Component):
|
|
||||||
...
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
# For efficiency, the type extraction is done only once.
|
|
||||||
# If `self._types` is `False`, that means that the types were not specified.
|
|
||||||
# If `self._types` is `None`, then this is the first time running this method.
|
|
||||||
# Otherwise, `self._types` should be a tuple of (Args, Kwargs, Data, Slots)
|
|
||||||
if self._types == False: # noqa: E712
|
|
||||||
return None
|
|
||||||
elif self._types:
|
|
||||||
return self._types
|
|
||||||
|
|
||||||
# Since a class can extend multiple classes, e.g.
|
|
||||||
#
|
|
||||||
# ```py
|
|
||||||
# class MyClass(BaseOne, BaseTwo, ...):
|
|
||||||
# ...
|
|
||||||
# ```
|
|
||||||
#
|
|
||||||
# Then we need to find the base class that is our `Component` class.
|
|
||||||
#
|
|
||||||
# NOTE: __orig_bases__ is a tuple of _GenericAlias
|
|
||||||
# See https://github.com/python/cpython/blob/709ef004dffe9cee2a023a3c8032d4ce80513582/Lib/typing.py#L1244
|
|
||||||
# And https://github.com/python/cpython/issues/101688
|
|
||||||
generics_bases: Tuple[Any, ...] = self.__orig_bases__ # type: ignore[attr-defined]
|
|
||||||
component_generics_base = None
|
|
||||||
for base in generics_bases:
|
|
||||||
origin_cls = base.__origin__
|
|
||||||
if origin_cls == Component or issubclass(origin_cls, Component):
|
|
||||||
component_generics_base = base
|
|
||||||
break
|
|
||||||
|
|
||||||
if not component_generics_base:
|
|
||||||
# If we get here, it means that the Component class wasn't supplied any generics
|
|
||||||
self._types = False
|
|
||||||
return None
|
|
||||||
|
|
||||||
# If we got here, then we've found ourselves the typed Component class, e.g.
|
|
||||||
#
|
|
||||||
# `Component(Tuple[int], MyKwargs, MySlots, Any, Any, Any)`
|
|
||||||
#
|
|
||||||
# By accessing the __args__, we access individual types between the brackets, so
|
|
||||||
#
|
|
||||||
# (Tuple[int], MyKwargs, MySlots, Any, Any, Any)
|
|
||||||
args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = component_generics_base.__args__
|
|
||||||
|
|
||||||
self._types = args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type
|
|
||||||
return self._types
|
|
||||||
|
|
||||||
def _validate_inputs(self, args: Tuple, kwargs: Any, slots: Any) -> None:
|
|
||||||
maybe_inputs = self._get_types()
|
|
||||||
if maybe_inputs is None:
|
|
||||||
return
|
|
||||||
args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs
|
|
||||||
|
|
||||||
# Validate args
|
|
||||||
validate_typed_tuple(args, args_type, f"Component '{self.name}'", "positional argument")
|
|
||||||
# Validate kwargs
|
|
||||||
validate_typed_dict(kwargs, kwargs_type, f"Component '{self.name}'", "keyword argument")
|
|
||||||
# Validate slots
|
|
||||||
validate_typed_dict(slots, slots_type, f"Component '{self.name}'", "slot")
|
|
||||||
|
|
||||||
def _validate_outputs(self, data: Any) -> None:
|
|
||||||
maybe_inputs = self._get_types()
|
|
||||||
if maybe_inputs is None:
|
|
||||||
return
|
|
||||||
args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs
|
|
||||||
|
|
||||||
# Validate data
|
|
||||||
validate_typed_dict(data, data_type, f"Component '{self.name}'", "data")
|
|
||||||
|
|
||||||
|
|
||||||
# Perf
|
# Perf
|
||||||
# Each component may use different start and end tags. We represent this
|
# Each component may use different start and end tags. We represent this
|
||||||
|
|
|
@ -1,130 +0,0 @@
|
||||||
import sys
|
|
||||||
import typing
|
|
||||||
from typing import Any, Mapping, Tuple, get_type_hints
|
|
||||||
|
|
||||||
# Get all types that users may use from the `typing` module.
|
|
||||||
#
|
|
||||||
# These are the types that we do NOT try to resolve when it's a typed generic,
|
|
||||||
# e.g. `Union[int, str]`.
|
|
||||||
# If we get a typed generic that's NOT part of this set, we assume it's a user-made
|
|
||||||
# generic, e.g. `Component[Args, Kwargs]`. In such case we assert that a given value
|
|
||||||
# is an instance of the base class, e.g. `Component`.
|
|
||||||
_typing_exports = frozenset(
|
|
||||||
[
|
|
||||||
value
|
|
||||||
for value in typing.__dict__.values()
|
|
||||||
if isinstance(
|
|
||||||
value,
|
|
||||||
(
|
|
||||||
typing._SpecialForm,
|
|
||||||
# Used in 3.8 and 3.9
|
|
||||||
getattr(typing, "_GenericAlias", ()),
|
|
||||||
# Used in 3.11+ (possibly 3.10?)
|
|
||||||
getattr(typing, "_SpecialGenericAlias", ()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _prepare_type_for_validation(the_type: Any) -> Any:
|
|
||||||
# If we got a typed generic (AKA "subscripted" generic), e.g.
|
|
||||||
# `Component[CompArgs, CompKwargs, ...]`
|
|
||||||
# then we cannot use that generic in `isintance()`, because we get this error:
|
|
||||||
# `TypeError("Subscripted generics cannot be used with class and instance checks")`
|
|
||||||
#
|
|
||||||
# Instead, we resolve the generic to its original class, e.g. `Component`,
|
|
||||||
# which can then be used in instance assertion.
|
|
||||||
if hasattr(the_type, "__origin__"):
|
|
||||||
is_custom_typing = the_type.__origin__ not in _typing_exports
|
|
||||||
if is_custom_typing:
|
|
||||||
return the_type.__origin__
|
|
||||||
else:
|
|
||||||
return the_type
|
|
||||||
else:
|
|
||||||
return the_type
|
|
||||||
|
|
||||||
|
|
||||||
# NOTE: tuple_type is a _GenericAlias - See https://stackoverflow.com/questions/74412803
|
|
||||||
def validate_typed_tuple(
|
|
||||||
value: Tuple[Any, ...],
|
|
||||||
tuple_type: Any,
|
|
||||||
prefix: str,
|
|
||||||
kind: str,
|
|
||||||
) -> None:
|
|
||||||
# `Any` type is the signal that we should skip validation
|
|
||||||
if tuple_type == Any:
|
|
||||||
return
|
|
||||||
|
|
||||||
# We do two kinds of validation with the given Tuple type:
|
|
||||||
# 1. We check whether there are any extra / missing positional args
|
|
||||||
# 2. We look at the members of the Tuple (which are types themselves),
|
|
||||||
# and check if our concrete list / tuple has correct types under correct indices.
|
|
||||||
expected_pos_args = len(tuple_type.__args__)
|
|
||||||
actual_pos_args = len(value)
|
|
||||||
if expected_pos_args > actual_pos_args:
|
|
||||||
# Generate errors like below (listed for searchability)
|
|
||||||
# `Component 'name' expected 3 positional arguments, got 2`
|
|
||||||
raise TypeError(f"{prefix} expected {expected_pos_args} {kind}s, got {actual_pos_args}")
|
|
||||||
|
|
||||||
for index, arg_type in enumerate(tuple_type.__args__):
|
|
||||||
arg = value[index]
|
|
||||||
arg_type = _prepare_type_for_validation(arg_type)
|
|
||||||
if sys.version_info >= (3, 11) and not isinstance(arg, arg_type):
|
|
||||||
# Generate errors like below (listed for searchability)
|
|
||||||
# `Component 'name' expected positional argument at index 0 to be <class 'int'>, got 123.5 of type <class 'float'>` # noqa: E501
|
|
||||||
raise TypeError(
|
|
||||||
f"{prefix} expected {kind} at index {index} to be {arg_type}, got {arg} of type {type(arg)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# NOTE:
|
|
||||||
# - `dict_type` can be a `TypedDict` or `Any` as the types themselves
|
|
||||||
# - `value` is expected to be TypedDict, the base `TypedDict` type cannot be used
|
|
||||||
# in function signature (only its subclasses can), so we specify the type as Mapping.
|
|
||||||
# See https://stackoverflow.com/questions/74412803
|
|
||||||
def validate_typed_dict(value: Mapping[str, Any], dict_type: Any, prefix: str, kind: str) -> None:
|
|
||||||
# `Any` type is the signal that we should skip validation
|
|
||||||
if dict_type == Any:
|
|
||||||
return
|
|
||||||
|
|
||||||
# See https://stackoverflow.com/a/76527675
|
|
||||||
# And https://stackoverflow.com/a/71231688
|
|
||||||
required_kwargs = dict_type.__required_keys__
|
|
||||||
unseen_keys = set(value.keys())
|
|
||||||
|
|
||||||
# For each entry in the TypedDict, we do two kinds of validation:
|
|
||||||
# 1. We check whether there are any extra / missing keys
|
|
||||||
# 2. We look at the values of TypedDict entries (which are types themselves),
|
|
||||||
# and check if our concrete dict has correct types under correct keys.
|
|
||||||
for key, kwarg_type in get_type_hints(dict_type).items():
|
|
||||||
if key not in value:
|
|
||||||
if key in required_kwargs:
|
|
||||||
# Generate errors like below (listed for searchability)
|
|
||||||
# `Component 'name' is missing a required keyword argument 'key'`
|
|
||||||
# `Component 'name' is missing a required slot argument 'key'`
|
|
||||||
# `Component 'name' is missing a required data argument 'key'`
|
|
||||||
raise TypeError(f"{prefix} is missing a required {kind} '{key}'")
|
|
||||||
else:
|
|
||||||
unseen_keys.remove(key)
|
|
||||||
kwarg = value[key]
|
|
||||||
kwarg_type = _prepare_type_for_validation(kwarg_type)
|
|
||||||
|
|
||||||
# NOTE: `isinstance()` cannot be used with the version of TypedDict prior to 3.11.
|
|
||||||
# So we do type validation for TypedDicts only in 3.11 and later.
|
|
||||||
if sys.version_info >= (3, 11) and not isinstance(kwarg, kwarg_type):
|
|
||||||
# Generate errors like below (listed for searchability)
|
|
||||||
# `Component 'name' expected keyword argument 'key' to be <class 'int'>, got 123.4 of type <class 'float'>` # noqa: E501
|
|
||||||
# `Component 'name' expected slot 'key' to be <class 'int'>, got 123.4 of type <class 'float'>`
|
|
||||||
# `Component 'name' expected data 'key' to be <class 'int'>, got 123.4 of type <class 'float'>`
|
|
||||||
raise TypeError(
|
|
||||||
f"{prefix} expected {kind} '{key}' to be {kwarg_type}, got {kwarg} of type {type(kwarg)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if unseen_keys:
|
|
||||||
formatted_keys = ", ".join([f"'{key}'" for key in unseen_keys])
|
|
||||||
# Generate errors like below (listed for searchability)
|
|
||||||
# `Component 'name' got unexpected keyword argument keys 'invalid_key'`
|
|
||||||
# `Component 'name' got unexpected slot keys 'invalid_key'`
|
|
||||||
# `Component 'name' got unexpected data keys 'invalid_key'`
|
|
||||||
raise TypeError(f"{prefix} got unexpected {kind} keys {formatted_keys}")
|
|
|
@ -4,16 +4,7 @@ For tests focusing on the `component` tag, see `test_templatetags_component.py`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import sys
|
from typing import Any, Dict, no_type_check
|
||||||
from typing import Any, Dict, List, Tuple, Union, no_type_check
|
|
||||||
|
|
||||||
# See https://peps.python.org/pep-0655/#usage-in-python-3-11
|
|
||||||
if sys.version_info >= (3, 11):
|
|
||||||
from typing import NotRequired, TypedDict
|
|
||||||
else:
|
|
||||||
from typing_extensions import NotRequired, TypedDict # for Python <3.11 with (Not)Required
|
|
||||||
|
|
||||||
from unittest import skipIf
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
@ -23,10 +14,9 @@ from django.template import Context, RequestContext, Template, TemplateSyntaxErr
|
||||||
from django.template.base import TextNode
|
from django.template.base import TextNode
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.utils.safestring import SafeString
|
|
||||||
from pytest_django.asserts import assertHTMLEqual, assertInHTML
|
from pytest_django.asserts import assertHTMLEqual, assertInHTML
|
||||||
|
|
||||||
from django_components import Component, ComponentView, Slot, SlotFunc, all_components, register, types
|
from django_components import Component, ComponentView, all_components, 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
|
||||||
|
|
||||||
|
@ -51,34 +41,6 @@ class CustomClient(Client):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
# Component typings
|
|
||||||
CompArgs = Tuple[int, str]
|
|
||||||
|
|
||||||
|
|
||||||
class CompData(TypedDict):
|
|
||||||
variable: str
|
|
||||||
|
|
||||||
|
|
||||||
class CompSlots(TypedDict):
|
|
||||||
my_slot: Union[str, int, Slot]
|
|
||||||
my_slot2: SlotFunc
|
|
||||||
|
|
||||||
|
|
||||||
if sys.version_info >= (3, 11):
|
|
||||||
|
|
||||||
class CompKwargs(TypedDict):
|
|
||||||
variable: str
|
|
||||||
another: int
|
|
||||||
optional: NotRequired[int]
|
|
||||||
|
|
||||||
else:
|
|
||||||
|
|
||||||
class CompKwargs(TypedDict, total=False):
|
|
||||||
variable: str
|
|
||||||
another: int
|
|
||||||
optional: NotRequired[int]
|
|
||||||
|
|
||||||
|
|
||||||
# TODO_REMOVE_IN_V1 - Superseded by `self.get_template` in v1
|
# TODO_REMOVE_IN_V1 - Superseded by `self.get_template` in v1
|
||||||
@djc_test
|
@djc_test
|
||||||
class TestComponentOldTemplateApi:
|
class TestComponentOldTemplateApi:
|
||||||
|
@ -395,354 +357,6 @@ class TestComponent:
|
||||||
Root.render()
|
Root.render()
|
||||||
|
|
||||||
|
|
||||||
@djc_test
|
|
||||||
class TestComponentValidation:
|
|
||||||
def test_validate_input_passes(self):
|
|
||||||
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
|
|
||||||
def get_context_data(self, var1, var2, variable, another, **attrs):
|
|
||||||
return {
|
|
||||||
"variable": variable,
|
|
||||||
}
|
|
||||||
|
|
||||||
template: types.django_html = """
|
|
||||||
{% load component_tags %}
|
|
||||||
Variable: <strong>{{ variable }}</strong>
|
|
||||||
Slot 1: {% slot "my_slot" / %}
|
|
||||||
Slot 2: {% slot "my_slot2" / %}
|
|
||||||
"""
|
|
||||||
|
|
||||||
rendered = TestComponent.render(
|
|
||||||
kwargs={"variable": "test", "another": 1},
|
|
||||||
args=(123, "str"),
|
|
||||||
slots={
|
|
||||||
"my_slot": SafeString("MY_SLOT"),
|
|
||||||
"my_slot2": lambda ctx, data, ref: "abc",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assertHTMLEqual(
|
|
||||||
rendered,
|
|
||||||
"""
|
|
||||||
Variable: <strong data-djc-id-a1bc3e>test</strong>
|
|
||||||
Slot 1: MY_SLOT
|
|
||||||
Slot 2: abc
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
|
|
||||||
@skipIf(sys.version_info < (3, 11), "Requires >= 3.11")
|
|
||||||
def test_validate_input_fails(self):
|
|
||||||
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
|
|
||||||
def get_context_data(self, var1, var2, variable, another, **attrs):
|
|
||||||
return {
|
|
||||||
"variable": variable,
|
|
||||||
}
|
|
||||||
|
|
||||||
template: types.django_html = """
|
|
||||||
{% load component_tags %}
|
|
||||||
Variable: <strong>{{ variable }}</strong>
|
|
||||||
Slot 1: {% slot "my_slot" / %}
|
|
||||||
Slot 2: {% slot "my_slot2" / %}
|
|
||||||
"""
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
TypeError,
|
|
||||||
match=re.escape("Component 'TestComponent' expected 2 positional arguments, got 1"),
|
|
||||||
):
|
|
||||||
TestComponent.render(
|
|
||||||
kwargs={"variable": 1, "another": "test"}, # type: ignore
|
|
||||||
args=(123,), # type: ignore
|
|
||||||
slots={
|
|
||||||
"my_slot": "MY_SLOT",
|
|
||||||
"my_slot2": lambda ctx, data, ref: "abc",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
TypeError,
|
|
||||||
match=re.escape("Component 'TestComponent' expected 2 positional arguments, got 0"),
|
|
||||||
):
|
|
||||||
TestComponent.render(
|
|
||||||
kwargs={"variable": 1, "another": "test"}, # type: ignore
|
|
||||||
slots={
|
|
||||||
"my_slot": "MY_SLOT",
|
|
||||||
"my_slot2": lambda ctx, data, ref: "abc",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
TypeError,
|
|
||||||
match=re.escape(
|
|
||||||
"Component 'TestComponent' expected keyword argument 'variable' to be <class 'str'>, got 1 of type <class 'int'>" # noqa: E501
|
|
||||||
),
|
|
||||||
):
|
|
||||||
TestComponent.render(
|
|
||||||
kwargs={"variable": 1, "another": "test"}, # type: ignore
|
|
||||||
args=(123, "abc", 456), # type: ignore
|
|
||||||
slots={
|
|
||||||
"my_slot": "MY_SLOT",
|
|
||||||
"my_slot2": lambda ctx, data, ref: "abc",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
TypeError,
|
|
||||||
match=re.escape("Component 'TestComponent' expected 2 positional arguments, got 0"),
|
|
||||||
):
|
|
||||||
TestComponent.render()
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
TypeError,
|
|
||||||
match=re.escape(
|
|
||||||
"Component 'TestComponent' expected keyword argument 'variable' to be <class 'str'>, got 1 of type <class 'int'>" # noqa: E501
|
|
||||||
),
|
|
||||||
):
|
|
||||||
TestComponent.render(
|
|
||||||
kwargs={"variable": 1, "another": "test"}, # type: ignore
|
|
||||||
args=(123, "str"),
|
|
||||||
slots={
|
|
||||||
"my_slot": "MY_SLOT",
|
|
||||||
"my_slot2": lambda ctx, data, ref: "abc",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
TypeError,
|
|
||||||
match=re.escape("Component 'TestComponent' is missing a required keyword argument 'another'"),
|
|
||||||
):
|
|
||||||
TestComponent.render(
|
|
||||||
kwargs={"variable": "abc"}, # type: ignore
|
|
||||||
args=(123, "str"),
|
|
||||||
slots={
|
|
||||||
"my_slot": "MY_SLOT",
|
|
||||||
"my_slot2": lambda ctx, data, ref: "abc",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
TypeError,
|
|
||||||
match=re.escape(
|
|
||||||
"Component 'TestComponent' expected slot 'my_slot' to be typing.Union[str, int, django_components.slots.Slot], got 123.5 of type <class 'float'>" # noqa: E501
|
|
||||||
),
|
|
||||||
):
|
|
||||||
TestComponent.render(
|
|
||||||
kwargs={"variable": "abc", "another": 1},
|
|
||||||
args=(123, "str"),
|
|
||||||
slots={
|
|
||||||
"my_slot": 123.5, # type: ignore
|
|
||||||
"my_slot2": lambda ctx, data, ref: "abc",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
TypeError,
|
|
||||||
match=re.escape("Component 'TestComponent' is missing a required slot 'my_slot2'"),
|
|
||||||
):
|
|
||||||
TestComponent.render(
|
|
||||||
kwargs={"variable": "abc", "another": 1},
|
|
||||||
args=(123, "str"),
|
|
||||||
slots={
|
|
||||||
"my_slot": "MY_SLOT",
|
|
||||||
}, # type: ignore
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_validate_input_skipped(self):
|
|
||||||
class TestComponent(Component[Any, CompKwargs, Any, CompData, Any, Any]):
|
|
||||||
def get_context_data(self, var1, var2, variable, another, **attrs):
|
|
||||||
return {
|
|
||||||
"variable": variable,
|
|
||||||
}
|
|
||||||
|
|
||||||
template: types.django_html = """
|
|
||||||
{% load component_tags %}
|
|
||||||
Variable: <strong>{{ variable }}</strong>
|
|
||||||
Slot 1: {% slot "my_slot" / %}
|
|
||||||
Slot 2: {% slot "my_slot2" / %}
|
|
||||||
"""
|
|
||||||
|
|
||||||
rendered = TestComponent.render(
|
|
||||||
kwargs={"variable": "test", "another": 1},
|
|
||||||
args=("123", "str"), # NOTE: Normally should raise
|
|
||||||
slots={
|
|
||||||
"my_slot": 123.5, # NOTE: Normally should raise
|
|
||||||
"my_slot2": lambda ctx, data, ref: "abc",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assertHTMLEqual(
|
|
||||||
rendered,
|
|
||||||
"""
|
|
||||||
Variable: <strong data-djc-id-a1bc3e>test</strong>
|
|
||||||
Slot 1: 123.5
|
|
||||||
Slot 2: abc
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_validate_output_passes(self):
|
|
||||||
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
|
|
||||||
def get_context_data(self, var1, var2, variable, another, **attrs):
|
|
||||||
return {
|
|
||||||
"variable": variable,
|
|
||||||
}
|
|
||||||
|
|
||||||
template: types.django_html = """
|
|
||||||
{% load component_tags %}
|
|
||||||
Variable: <strong>{{ variable }}</strong>
|
|
||||||
Slot 1: {% slot "my_slot" / %}
|
|
||||||
Slot 2: {% slot "my_slot2" / %}
|
|
||||||
"""
|
|
||||||
|
|
||||||
rendered = TestComponent.render(
|
|
||||||
kwargs={"variable": "test", "another": 1},
|
|
||||||
args=(123, "str"),
|
|
||||||
slots={
|
|
||||||
"my_slot": SafeString("MY_SLOT"),
|
|
||||||
"my_slot2": lambda ctx, data, ref: "abc",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assertHTMLEqual(
|
|
||||||
rendered,
|
|
||||||
"""
|
|
||||||
Variable: <strong data-djc-id-a1bc3e>test</strong>
|
|
||||||
Slot 1: MY_SLOT
|
|
||||||
Slot 2: abc
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_validate_output_fails(self):
|
|
||||||
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
|
|
||||||
def get_context_data(self, var1, var2, variable, another, **attrs):
|
|
||||||
return {
|
|
||||||
"variable": variable,
|
|
||||||
"invalid_key": var1,
|
|
||||||
}
|
|
||||||
|
|
||||||
template: types.django_html = """
|
|
||||||
{% load component_tags %}
|
|
||||||
Variable: <strong>{{ variable }}</strong>
|
|
||||||
Slot 1: {% slot "my_slot" / %}
|
|
||||||
Slot 2: {% slot "my_slot2" / %}
|
|
||||||
"""
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
TypeError,
|
|
||||||
match=re.escape("Component 'TestComponent' got unexpected data keys 'invalid_key'"),
|
|
||||||
):
|
|
||||||
TestComponent.render(
|
|
||||||
kwargs={"variable": "test", "another": 1},
|
|
||||||
args=(123, "str"),
|
|
||||||
slots={
|
|
||||||
"my_slot": SafeString("MY_SLOT"),
|
|
||||||
"my_slot2": lambda ctx, data, ref: "abc",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_handles_components_in_typing(self):
|
|
||||||
class InnerKwargs(TypedDict):
|
|
||||||
one: str
|
|
||||||
|
|
||||||
class InnerData(TypedDict):
|
|
||||||
one: Union[str, int]
|
|
||||||
self: "InnerComp" # type: ignore[misc]
|
|
||||||
|
|
||||||
InnerComp = Component[Any, InnerKwargs, Any, InnerData, Any, Any] # type: ignore[misc]
|
|
||||||
|
|
||||||
class Inner(InnerComp):
|
|
||||||
def get_context_data(self, one):
|
|
||||||
return {
|
|
||||||
"self": self,
|
|
||||||
"one": one,
|
|
||||||
}
|
|
||||||
|
|
||||||
template = ""
|
|
||||||
|
|
||||||
TodoArgs = Tuple[Inner] # type: ignore[misc]
|
|
||||||
|
|
||||||
class TodoKwargs(TypedDict):
|
|
||||||
inner: Inner
|
|
||||||
|
|
||||||
class TodoData(TypedDict):
|
|
||||||
one: Union[str, int]
|
|
||||||
self: "TodoComp" # type: ignore[misc]
|
|
||||||
inner: str
|
|
||||||
|
|
||||||
TodoComp = Component[TodoArgs, TodoKwargs, Any, TodoData, Any, Any] # type: ignore[misc]
|
|
||||||
|
|
||||||
# NOTE: Since we're using ForwardRef for "TodoComp" and "InnerComp", we need
|
|
||||||
# to ensure that the actual types are set as globals, so the ForwardRef class
|
|
||||||
# can resolve them.
|
|
||||||
globals()["TodoComp"] = TodoComp
|
|
||||||
globals()["InnerComp"] = InnerComp
|
|
||||||
|
|
||||||
class TestComponent(TodoComp):
|
|
||||||
def get_context_data(self, var1, inner):
|
|
||||||
return {
|
|
||||||
"self": self,
|
|
||||||
"one": "2123",
|
|
||||||
# NOTE: All of this is typed
|
|
||||||
"inner": self.input.kwargs["inner"].render(kwargs={"one": "abc"}),
|
|
||||||
}
|
|
||||||
|
|
||||||
template: types.django_html = """
|
|
||||||
{% load component_tags %}
|
|
||||||
Name: <strong>{{ self.name }}</strong>
|
|
||||||
"""
|
|
||||||
|
|
||||||
rendered = TestComponent.render(args=(Inner(),), kwargs={"inner": Inner()})
|
|
||||||
|
|
||||||
assertHTMLEqual(
|
|
||||||
rendered,
|
|
||||||
"""
|
|
||||||
Name: <strong data-djc-id-a1bc3e>TestComponent</strong>
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_handles_typing_module(self):
|
|
||||||
TodoArgs = Tuple[
|
|
||||||
Union[str, int],
|
|
||||||
Dict[str, int],
|
|
||||||
List[str],
|
|
||||||
Tuple[int, Union[str, int]],
|
|
||||||
]
|
|
||||||
|
|
||||||
class TodoKwargs(TypedDict):
|
|
||||||
one: Union[str, int]
|
|
||||||
two: Dict[str, int]
|
|
||||||
three: List[str]
|
|
||||||
four: Tuple[int, Union[str, int]]
|
|
||||||
|
|
||||||
class TodoData(TypedDict):
|
|
||||||
one: Union[str, int]
|
|
||||||
two: Dict[str, int]
|
|
||||||
three: List[str]
|
|
||||||
four: Tuple[int, Union[str, int]]
|
|
||||||
|
|
||||||
TodoComp = Component[TodoArgs, TodoKwargs, Any, TodoData, Any, Any]
|
|
||||||
|
|
||||||
# NOTE: Since we're using ForwardRef for "TodoComp", we need
|
|
||||||
# to ensure that the actual types are set as globals, so the ForwardRef class
|
|
||||||
# can resolve them.
|
|
||||||
globals()["TodoComp"] = TodoComp
|
|
||||||
|
|
||||||
class TestComponent(TodoComp):
|
|
||||||
def get_context_data(self, *args, **kwargs):
|
|
||||||
return {
|
|
||||||
**kwargs,
|
|
||||||
}
|
|
||||||
|
|
||||||
template = ""
|
|
||||||
|
|
||||||
TestComponent.render(
|
|
||||||
args=("str", {"str": 123}, ["a", "b", "c"], (123, "123")),
|
|
||||||
kwargs={
|
|
||||||
"one": "str",
|
|
||||||
"two": {"str": 123},
|
|
||||||
"three": ["a", "b", "c"],
|
|
||||||
"four": (123, "123"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@djc_test
|
@djc_test
|
||||||
class TestComponentRender:
|
class TestComponentRender:
|
||||||
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
|
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue