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:
Juro Oravec 2024-08-29 23:09:36 +02:00 committed by GitHub
parent 682bfc4239
commit 4a9cf7e26d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 641 additions and 98 deletions

226
README.md
View file

@ -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

View file

@ -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

View file

@ -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"""

View file

@ -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

View file

@ -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

View file

@ -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}")

View file

@ -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):