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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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