mirror of
https://github.com/django-components/django-components.git
synced 2025-10-09 21:41:59 +00:00
feat: validate component inputs if types are given (#629)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
682bfc4239
commit
4a9cf7e26d
7 changed files with 641 additions and 98 deletions
226
README.md
226
README.md
|
@ -46,6 +46,8 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
|
||||||
- [Use components outside of templates](#use-components-outside-of-templates)
|
- [Use components outside of templates](#use-components-outside-of-templates)
|
||||||
- [Use components as views](#use-components-as-views)
|
- [Use components as views](#use-components-as-views)
|
||||||
- [Pre-defined components](#pre-defined-components)
|
- [Pre-defined components](#pre-defined-components)
|
||||||
|
- [Typing and validating components](#typing-and-validating-components)
|
||||||
|
- [Pre-defined components](#pre-defined-components)
|
||||||
- [Registering components](#registering-components)
|
- [Registering components](#registering-components)
|
||||||
- [Autodiscovery](#autodiscovery)
|
- [Autodiscovery](#autodiscovery)
|
||||||
- [Using slots in templates](#using-slots-in-templates)
|
- [Using slots in templates](#using-slots-in-templates)
|
||||||
|
@ -632,58 +634,6 @@ MyComponent.render_to_response(
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Adding type hints with Generics
|
|
||||||
|
|
||||||
The `Component` class optionally accepts type parameters
|
|
||||||
that allow you to specify the types of args, kwargs, slots, and
|
|
||||||
data.
|
|
||||||
|
|
||||||
```py
|
|
||||||
from typing import NotRequired, Tuple, TypedDict, SlotFunc
|
|
||||||
|
|
||||||
# Positional inputs - Tuple
|
|
||||||
Args = Tuple[int, str]
|
|
||||||
|
|
||||||
# Kwargs inputs - Mapping
|
|
||||||
class Kwargs(TypedDict):
|
|
||||||
variable: str
|
|
||||||
another: int
|
|
||||||
maybe_var: NotRequired[int]
|
|
||||||
|
|
||||||
# Data returned from `get_context_data` - Mapping
|
|
||||||
class Data(TypedDict):
|
|
||||||
variable: str
|
|
||||||
|
|
||||||
# The data available to the `my_slot` scoped slot
|
|
||||||
class MySlotData(TypedDict):
|
|
||||||
value: int
|
|
||||||
|
|
||||||
# Slot functions - Mapping
|
|
||||||
class Slots(TypedDict):
|
|
||||||
# Use SlotFunc for slot functions.
|
|
||||||
# The generic specifies the `data` dictionary
|
|
||||||
my_slot: NotRequired[SlotFunc[MySlotData]]
|
|
||||||
|
|
||||||
class Button(Component[Args, Kwargs, Data, Slots]):
|
|
||||||
def get_context_data(self, variable, another):
|
|
||||||
return {
|
|
||||||
"variable": variable,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When you then call `Component.render` or `Component.render_to_response`, you will get type hints:
|
|
||||||
|
|
||||||
```py
|
|
||||||
Button.render(
|
|
||||||
# Error: First arg must be `int`, got `float`
|
|
||||||
args=(1.25, "abc"),
|
|
||||||
# Error: Key "another" is missing
|
|
||||||
kwargs={
|
|
||||||
"variable": "text",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Response class of `render_to_response`
|
### Response class of `render_to_response`
|
||||||
|
|
||||||
While `render` method returns a plain string, `render_to_response` wraps the rendered content in a "Response" class. By default, this is `django.http.HttpResponse`.
|
While `render` method returns a plain string, `render_to_response` wraps the rendered content in a "Response" class. By default, this is `django.http.HttpResponse`.
|
||||||
|
@ -855,6 +805,178 @@ class MyComponent(Component):
|
||||||
do_something_extra(request, *args, **kwargs)
|
do_something_extra(request, *args, **kwargs)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Typing and validating components
|
||||||
|
|
||||||
|
### Adding type hints with Generics
|
||||||
|
|
||||||
|
The `Component` class optionally accepts type parameters
|
||||||
|
that allow you to specify the types of args, kwargs, slots, and
|
||||||
|
data:
|
||||||
|
|
||||||
|
```py
|
||||||
|
class Button(Component[Args, Kwargs, Data, Slots]):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Args` - Must be a `Tuple` or `Any`
|
||||||
|
- `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:
|
||||||
|
|
||||||
|
```py
|
||||||
|
from typing import NotRequired, Tuple, TypedDict, SlotContent, SlotFunc
|
||||||
|
|
||||||
|
# Positional inputs
|
||||||
|
Args = Tuple[int, str]
|
||||||
|
|
||||||
|
# Kwargs inputs
|
||||||
|
class Kwargs(TypedDict):
|
||||||
|
variable: str
|
||||||
|
another: int
|
||||||
|
maybe_var: NotRequired[int] # May be ommited
|
||||||
|
|
||||||
|
# Data returned from `get_context_data`
|
||||||
|
class Data(TypedDict):
|
||||||
|
variable: str
|
||||||
|
|
||||||
|
# The data available to the `my_slot` scoped slot
|
||||||
|
class MySlotData(TypedDict):
|
||||||
|
value: int
|
||||||
|
|
||||||
|
# Slots
|
||||||
|
class Slots(TypedDict):
|
||||||
|
# Use SlotFunc for slot functions.
|
||||||
|
# The generic specifies the `data` dictionary
|
||||||
|
my_slot: NotRequired[SlotFunc[MySlotData]]
|
||||||
|
# SlotContent == Union[str, SafeString]
|
||||||
|
another_slot: SlotContent
|
||||||
|
|
||||||
|
class Button(Component[Args, Kwargs, Data, Slots]):
|
||||||
|
def get_context_data(self, variable, another):
|
||||||
|
return {
|
||||||
|
"variable": variable,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When you then call `Component.render` or `Component.render_to_response`, you will get type hints:
|
||||||
|
|
||||||
|
```py
|
||||||
|
Button.render(
|
||||||
|
# Error: First arg must be `int`, got `float`
|
||||||
|
args=(1.25, "abc"),
|
||||||
|
# Error: Key "another" is missing
|
||||||
|
kwargs={
|
||||||
|
"variable": "text",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Usage for Python <3.11
|
||||||
|
|
||||||
|
On Python 3.8-3.10, use `typing_extensions`
|
||||||
|
|
||||||
|
```py
|
||||||
|
from typing_extensions import TypedDict, NotRequired
|
||||||
|
```
|
||||||
|
|
||||||
|
Additionally on Python 3.8-3.9, also import `annotations`:
|
||||||
|
|
||||||
|
```py
|
||||||
|
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.
|
||||||
|
|
||||||
|
[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, Data, Slots]):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime input validation with types
|
||||||
|
|
||||||
|
> 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, Data, Slots]):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Same applies to kwargs, data, and slots.
|
||||||
|
|
||||||
## Pre-defined components
|
## Pre-defined components
|
||||||
|
|
||||||
### Dynamic components
|
### Dynamic components
|
||||||
|
|
|
@ -37,6 +37,10 @@ from django_components.tag_formatter import (
|
||||||
component_shorthand_formatter as component_shorthand_formatter,
|
component_shorthand_formatter as component_shorthand_formatter,
|
||||||
)
|
)
|
||||||
import django_components.types as types
|
import django_components.types as types
|
||||||
|
from django_components.types import (
|
||||||
|
EmptyTuple as EmptyTuple,
|
||||||
|
EmptyDict as EmptyDict,
|
||||||
|
)
|
||||||
|
|
||||||
# isort: on
|
# isort: on
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ from typing import (
|
||||||
Dict,
|
Dict,
|
||||||
Generic,
|
Generic,
|
||||||
List,
|
List,
|
||||||
|
Literal,
|
||||||
Mapping,
|
Mapping,
|
||||||
Optional,
|
Optional,
|
||||||
Protocol,
|
Protocol,
|
||||||
|
@ -60,7 +61,7 @@ from django_components.slots import (
|
||||||
resolve_fill_nodes,
|
resolve_fill_nodes,
|
||||||
resolve_slots,
|
resolve_slots,
|
||||||
)
|
)
|
||||||
from django_components.utils import gen_id
|
from django_components.utils import gen_id, validate_typed_dict, validate_typed_tuple
|
||||||
|
|
||||||
# TODO_DEPRECATE_V1 - REMOVE IN V1, users should use top-level import instead
|
# TODO_DEPRECATE_V1 - REMOVE IN V1, users should use top-level import instead
|
||||||
# isort: off
|
# isort: off
|
||||||
|
@ -196,6 +197,8 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
|
||||||
self.component_id = component_id or gen_id()
|
self.component_id = component_id or gen_id()
|
||||||
self.registry = registry or registry_
|
self.registry = registry or registry_
|
||||||
self._render_stack: Deque[RenderInput[ArgsType, KwargsType, SlotsType]] = deque()
|
self._render_stack: Deque[RenderInput[ArgsType, KwargsType, SlotsType]] = deque()
|
||||||
|
# None == uninitialized, False == No types, Tuple == types
|
||||||
|
self._types: Optional[Union[Tuple[Any, Any, Any, Any], Literal[False]]] = None
|
||||||
|
|
||||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||||
cls._class_hash = hash(inspect.getfile(cls) + cls.__name__)
|
cls._class_hash = hash(inspect.getfile(cls) + cls.__name__)
|
||||||
|
@ -491,7 +494,10 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._validate_inputs()
|
||||||
|
|
||||||
context_data = self.get_context_data(*args, **kwargs)
|
context_data = self.get_context_data(*args, **kwargs)
|
||||||
|
self._validate_outputs(context_data)
|
||||||
|
|
||||||
with context.update(context_data):
|
with context.update(context_data):
|
||||||
template = self.get_template(context)
|
template = self.get_template(context)
|
||||||
|
@ -578,7 +584,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
|
||||||
"""Fill component slots outside of template rendering."""
|
"""Fill component slots outside of template rendering."""
|
||||||
slot_fills = {}
|
slot_fills = {}
|
||||||
for slot_name, content in slots_data.items():
|
for slot_name, content in slots_data.items():
|
||||||
if isinstance(content, (str, SafeString)):
|
if not callable(content):
|
||||||
content_func = _nodelist_to_slot_render_func(
|
content_func = _nodelist_to_slot_render_func(
|
||||||
NodeList([TextNode(conditional_escape(content) if escape_content else content)])
|
NodeList([TextNode(conditional_escape(content) if escape_content else content)])
|
||||||
)
|
)
|
||||||
|
@ -599,6 +605,100 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
|
||||||
)
|
)
|
||||||
return slot_fills
|
return slot_fills
|
||||||
|
|
||||||
|
######################
|
||||||
|
# VALIDATION
|
||||||
|
######################
|
||||||
|
|
||||||
|
def _get_types(self) -> Optional[Tuple[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, Any, MySlots]):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Then we want to extract the tuple (MyArgs, MyKwargs, Any, MySlots).
|
||||||
|
|
||||||
|
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)`
|
||||||
|
#
|
||||||
|
# By accessing the __args__, we access individual types between the brackets, so
|
||||||
|
#
|
||||||
|
# (Tuple[int], MyKwargs, MySlots, Any)
|
||||||
|
args_type, kwargs_type, data_type, slots_type = component_generics_base.__args__
|
||||||
|
|
||||||
|
self._types = args_type, kwargs_type, data_type, slots_type
|
||||||
|
return self._types
|
||||||
|
|
||||||
|
def _validate_inputs(self) -> None:
|
||||||
|
|
||||||
|
maybe_inputs = self._get_types()
|
||||||
|
if maybe_inputs is None:
|
||||||
|
return
|
||||||
|
args_type, kwargs_type, data_type, slots_type = maybe_inputs
|
||||||
|
|
||||||
|
# Validate args
|
||||||
|
validate_typed_tuple(self.input.args, args_type, f"Component '{self.name}'", "positional argument")
|
||||||
|
# Validate kwargs
|
||||||
|
validate_typed_dict(self.input.kwargs, kwargs_type, f"Component '{self.name}'", "keyword argument")
|
||||||
|
# Validate slots
|
||||||
|
validate_typed_dict(self.input.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, data_type, slots_type = maybe_inputs
|
||||||
|
|
||||||
|
# Validate data
|
||||||
|
validate_typed_dict(data, data_type, f"Component '{self.name}'", "data")
|
||||||
|
|
||||||
|
|
||||||
class ComponentNode(BaseNode):
|
class ComponentNode(BaseNode):
|
||||||
"""Django.template.Node subclass that renders a django-components component"""
|
"""Django.template.Node subclass that renders a django-components component"""
|
||||||
|
|
|
@ -18,6 +18,7 @@ from typing import (
|
||||||
Type,
|
Type,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
Union,
|
Union,
|
||||||
|
runtime_checkable,
|
||||||
)
|
)
|
||||||
|
|
||||||
from django.template import Context, Template
|
from django.template import Context, Template
|
||||||
|
@ -54,6 +55,7 @@ SLOT_DEFAULT_KEYWORD = "default"
|
||||||
SlotResult = Union[str, SafeString]
|
SlotResult = Union[str, SafeString]
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
class SlotFunc(Protocol, Generic[TSlotData]):
|
class SlotFunc(Protocol, Generic[TSlotData]):
|
||||||
def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotRef") -> SlotResult: ... # noqa E704
|
def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotRef") -> SlotResult: ... # noqa E704
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,14 @@
|
||||||
"""Helper types for IDEs."""
|
"""Helper types for IDEs."""
|
||||||
|
|
||||||
|
import sys
|
||||||
import typing
|
import typing
|
||||||
from typing import Any
|
from typing import Any, Tuple
|
||||||
|
|
||||||
|
# See https://peps.python.org/pep-0655/#usage-in-python-3-11
|
||||||
|
if sys.version_info >= (3, 11):
|
||||||
|
from typing import TypedDict
|
||||||
|
else:
|
||||||
|
from typing_extensions import TypedDict # for Python <3.11 with (Not)Required
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from typing import Annotated # type: ignore
|
from typing import Annotated # type: ignore
|
||||||
|
@ -28,3 +35,9 @@ except ImportError:
|
||||||
css = Annotated[str, "css"]
|
css = Annotated[str, "css"]
|
||||||
django_html = Annotated[str, "django_html"]
|
django_html = Annotated[str, "django_html"]
|
||||||
js = Annotated[str, "js"]
|
js = Annotated[str, "js"]
|
||||||
|
|
||||||
|
EmptyTuple = Tuple[()]
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyDict(TypedDict):
|
||||||
|
pass
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, List, Sequence, Union
|
from typing import Any, Callable, List, Mapping, Sequence, Tuple, Union, get_type_hints
|
||||||
|
|
||||||
from django.utils.autoreload import autoreload_started
|
from django.utils.autoreload import autoreload_started
|
||||||
|
|
||||||
|
@ -36,3 +37,86 @@ def watch_files_for_autoreload(watch_list: Sequence[Union[str, Path]]) -> None:
|
||||||
watch(Path(file))
|
watch(Path(file))
|
||||||
|
|
||||||
autoreload_started.connect(autoreload_hook)
|
autoreload_started.connect(autoreload_hook)
|
||||||
|
|
||||||
|
|
||||||
|
# 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]
|
||||||
|
if 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]
|
||||||
|
|
||||||
|
# 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}")
|
||||||
|
|
|
@ -3,13 +3,23 @@ Tests focusing on the Component class.
|
||||||
For tests focusing on the `component` tag, see `test_templatetags_component.py`
|
For tests focusing on the `component` tag, see `test_templatetags_component.py`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Tuple, TypedDict, no_type_check
|
import sys
|
||||||
|
from typing import Any, Dict, 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
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.template import Context, RequestContext, Template, TemplateSyntaxError
|
from django.template import Context, RequestContext, Template, TemplateSyntaxError
|
||||||
|
from django.utils.safestring import SafeString
|
||||||
|
|
||||||
from django_components import Component, registry, types
|
from django_components import Component, SlotFunc, registry, types
|
||||||
from django_components.slots import SlotRef
|
from django_components.slots import SlotRef
|
||||||
|
|
||||||
from .django_test_setup import setup_test_config
|
from .django_test_setup import setup_test_config
|
||||||
|
@ -18,6 +28,34 @@ from .testutils import BaseTestCase, parametrize_context_behavior
|
||||||
setup_test_config({"autodiscover": False})
|
setup_test_config({"autodiscover": False})
|
||||||
|
|
||||||
|
|
||||||
|
# Component typings
|
||||||
|
CompArgs = Tuple[int, str]
|
||||||
|
|
||||||
|
|
||||||
|
class CompData(TypedDict):
|
||||||
|
variable: str
|
||||||
|
|
||||||
|
|
||||||
|
class CompSlots(TypedDict):
|
||||||
|
my_slot: Union[str, int]
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
class ComponentTest(BaseTestCase):
|
class ComponentTest(BaseTestCase):
|
||||||
class ParentComponent(Component):
|
class ParentComponent(Component):
|
||||||
template: types.django_html = """
|
template: types.django_html = """
|
||||||
|
@ -186,46 +224,6 @@ class ComponentTest(BaseTestCase):
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_typed(self):
|
|
||||||
TestCompArgs = Tuple[int, str]
|
|
||||||
|
|
||||||
class TestCompKwargs(TypedDict):
|
|
||||||
variable: str
|
|
||||||
another: int
|
|
||||||
|
|
||||||
class TestCompData(TypedDict):
|
|
||||||
abc: int
|
|
||||||
|
|
||||||
class TestCompSlots(TypedDict):
|
|
||||||
my_slot: str
|
|
||||||
|
|
||||||
class TestComponent(Component[TestCompArgs, TestCompKwargs, TestCompData, TestCompSlots]):
|
|
||||||
def get_context_data(self, var1, var2, variable, another, **attrs):
|
|
||||||
return {
|
|
||||||
"variable": variable,
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_template(self, context):
|
|
||||||
template_str: types.django_html = """
|
|
||||||
{% load component_tags %}
|
|
||||||
Variable: <strong>{{ variable }}</strong>
|
|
||||||
{% slot 'my_slot' / %}
|
|
||||||
"""
|
|
||||||
return Template(template_str)
|
|
||||||
|
|
||||||
rendered = TestComponent.render(
|
|
||||||
kwargs={"variable": "test", "another": 1},
|
|
||||||
args=(123, "str"),
|
|
||||||
slots={"my_slot": "MY_SLOT"},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertHTMLEqual(
|
|
||||||
rendered,
|
|
||||||
"""
|
|
||||||
Variable: <strong>test</strong> MY_SLOT
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_input(self):
|
def test_input(self):
|
||||||
tester = self
|
tester = self
|
||||||
|
|
||||||
|
@ -269,6 +267,226 @@ class ComponentTest(BaseTestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ComponentValidationTest(BaseTestCase):
|
||||||
|
def test_validate_input_passes(self):
|
||||||
|
class TestComponent(Component[CompArgs, CompKwargs, CompData, CompSlots]):
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
Variable: <strong>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, CompData, CompSlots]):
|
||||||
|
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 self.assertRaisesMessage(TypeError, "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 self.assertRaisesMessage(TypeError, "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 self.assertRaisesMessage(
|
||||||
|
TypeError,
|
||||||
|
"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 self.assertRaisesMessage(TypeError, "Component 'TestComponent' expected 2 positional arguments, got 0"):
|
||||||
|
TestComponent.render()
|
||||||
|
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
TypeError,
|
||||||
|
"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 self.assertRaisesMessage(
|
||||||
|
TypeError, "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 self.assertRaisesMessage(
|
||||||
|
TypeError,
|
||||||
|
"Component 'TestComponent' expected slot 'my_slot' to be typing.Union[str, int], 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 self.assertRaisesMessage(TypeError, "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, CompData, 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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
Variable: <strong>test</strong>
|
||||||
|
Slot 1: 123.5
|
||||||
|
Slot 2: abc
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validate_output_passes(self):
|
||||||
|
class TestComponent(Component[CompArgs, CompKwargs, CompData, CompSlots]):
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
Variable: <strong>test</strong>
|
||||||
|
Slot 1: MY_SLOT
|
||||||
|
Slot 2: abc
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validate_output_fails(self):
|
||||||
|
class TestComponent(Component[CompArgs, CompKwargs, CompData, CompSlots]):
|
||||||
|
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 self.assertRaisesMessage(TypeError, "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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ComponentRenderTest(BaseTestCase):
|
class ComponentRenderTest(BaseTestCase):
|
||||||
@parametrize_context_behavior(["django", "isolated"])
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
def test_render_minimal(self):
|
def test_render_minimal(self):
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue