mirror of
https://github.com/django-components/django-components.git
synced 2025-08-30 10:47:20 +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 as views](#use-components-as-views)
|
||||
- [Pre-defined components](#pre-defined-components)
|
||||
- [Typing and validating components](#typing-and-validating-components)
|
||||
- [Pre-defined components](#pre-defined-components)
|
||||
- [Registering components](#registering-components)
|
||||
- [Autodiscovery](#autodiscovery)
|
||||
- [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`
|
||||
|
||||
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)
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
### Dynamic components
|
||||
|
|
|
@ -37,6 +37,10 @@ from django_components.tag_formatter import (
|
|||
component_shorthand_formatter as component_shorthand_formatter,
|
||||
)
|
||||
import django_components.types as types
|
||||
from django_components.types import (
|
||||
EmptyTuple as EmptyTuple,
|
||||
EmptyDict as EmptyDict,
|
||||
)
|
||||
|
||||
# isort: on
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ from typing import (
|
|||
Dict,
|
||||
Generic,
|
||||
List,
|
||||
Literal,
|
||||
Mapping,
|
||||
Optional,
|
||||
Protocol,
|
||||
|
@ -60,7 +61,7 @@ from django_components.slots import (
|
|||
resolve_fill_nodes,
|
||||
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
|
||||
# isort: off
|
||||
|
@ -196,6 +197,8 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
|
|||
self.component_id = component_id or gen_id()
|
||||
self.registry = registry or registry_
|
||||
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:
|
||||
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)
|
||||
self._validate_outputs(context_data)
|
||||
|
||||
with context.update(context_data):
|
||||
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."""
|
||||
slot_fills = {}
|
||||
for slot_name, content in slots_data.items():
|
||||
if isinstance(content, (str, SafeString)):
|
||||
if not callable(content):
|
||||
content_func = _nodelist_to_slot_render_func(
|
||||
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
|
||||
|
||||
######################
|
||||
# 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):
|
||||
"""Django.template.Node subclass that renders a django-components component"""
|
||||
|
|
|
@ -18,6 +18,7 @@ from typing import (
|
|||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
from django.template import Context, Template
|
||||
|
@ -54,6 +55,7 @@ SLOT_DEFAULT_KEYWORD = "default"
|
|||
SlotResult = Union[str, SafeString]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SlotFunc(Protocol, Generic[TSlotData]):
|
||||
def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotRef") -> SlotResult: ... # noqa E704
|
||||
|
||||
|
|
|
@ -1,7 +1,14 @@
|
|||
"""Helper types for IDEs."""
|
||||
|
||||
import sys
|
||||
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:
|
||||
from typing import Annotated # type: ignore
|
||||
|
@ -28,3 +35,9 @@ except ImportError:
|
|||
css = Annotated[str, "css"]
|
||||
django_html = Annotated[str, "django_html"]
|
||||
js = Annotated[str, "js"]
|
||||
|
||||
EmptyTuple = Tuple[()]
|
||||
|
||||
|
||||
class EmptyDict(TypedDict):
|
||||
pass
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import sys
|
||||
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
|
||||
|
||||
|
@ -36,3 +37,86 @@ def watch_files_for_autoreload(watch_list: Sequence[Union[str, Path]]) -> None:
|
|||
watch(Path(file))
|
||||
|
||||
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`
|
||||
"""
|
||||
|
||||
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.http import HttpRequest, HttpResponse
|
||||
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_test_setup import setup_test_config
|
||||
|
@ -18,6 +28,34 @@ from .testutils import BaseTestCase, parametrize_context_behavior
|
|||
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 ParentComponent(Component):
|
||||
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):
|
||||
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):
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_render_minimal(self):
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue