refactor: remove input validation and link to it (#1082)

* feat: allow to set defaults

* refactor: remove input validation and link to it

* docs: update changelog

* Update typing_and_validation.md

* Update typing_and_validation.md
This commit is contained in:
Juro Oravec 2025-04-05 08:19:19 +02:00 committed by GitHub
parent 5e263ec143
commit 7e74831599
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 209 additions and 747 deletions

View file

@ -1,5 +1,22 @@
# Release notes # Release notes
## 🚨📢 v0.136
#### 🚨📢 BREAKING CHANGES
- Component input validation was moved to a separate extension [`djc-ext-pydantic`](https://github.com/django-components/djc-ext-pydantic).
If you relied on components raising errors when inputs were invalid, you need to install `djc-ext-pydantic` and add it to extensions:
```python
# settings.py
COMPONENTS = {
"extensions": [
"djc_ext_pydantic.PydanticExtension",
],
}
```
## v0.135 ## v0.135
#### Feat #### Feat

View file

@ -327,13 +327,20 @@ Django-components functionality can be extended with "extensions". Extensions al
- Tap into lifecycle events, such as when a component is created, deleted, or registered. - Tap into lifecycle events, such as when a component is created, deleted, or registered.
- Add new attributes and methods to the components under an extension-specific nested class. - Add new attributes and methods to the components under an extension-specific nested class.
- Add custom CLI commands.
- Add custom URLs.
Some of the extensions include:
- [Django View integration](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/view.py)
- [Component defaults](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/defaults.py)
- [Pydantic integration (input validation)](https://github.com/django-components/djc-ext-pydantic)
Some of the planned extensions include: Some of the planned extensions include:
- Caching - Caching
- AlpineJS integration - AlpineJS integration
- Storybook integration - Storybook integration
- Pydantic validation
- Component-level benchmarking with asv - Component-level benchmarking with asv
### Simple testing ### Simple testing

View file

@ -2,71 +2,205 @@
_New in version 0.92_ _New in version 0.92_
The `Component` class optionally accepts type parameters The [`Component`](../../../reference/api#django_components.Component) class optionally accepts type parameters
that allow you to specify the types of args, kwargs, slots, and that allow you to specify the types of args, kwargs, slots, and data.
data:
Use this to add type hints to your components, or to validate component inputs.
```py ```py
class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]): from django_components import Component
...
ButtonType = Component[Args, Kwargs, Slots, Data, JsData, CssData]
class Button(ButtonType):
template_file = "button.html"
def get_context_data(self, *args, **kwargs):
...
``` ```
- `Args` - Must be a `Tuple` or `Any` The generic parameters are:
- `Kwargs` - Must be a `TypedDict` or `Any`
- `Data` - Must be a `TypedDict` or `Any`
- `Slots` - Must be a `TypedDict` or `Any`
Here's a full example: - `Args` - Positional arguments, must be a `Tuple` or `Any`
- `Kwargs` - Keyword arguments, must be a `TypedDict` or `Any`
- `Slots` - Slots, must be a `TypedDict` or `Any`
- `Data` - Data returned from [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data), must be a `TypedDict` or `Any`
- `JsData` - Data returned from [`get_js_data()`](../../../reference/api#django_components.Component.get_js_data), must be a `TypedDict` or `Any`
- `CssData` - Data returned from [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data), must be a `TypedDict` or `Any`
```py ## Example
from typing import NotRequired, Tuple, TypedDict, SlotContent, SlotFunc
```python
from typing import NotRequired, Tuple, TypedDict
from pydantic import BaseModel
from django_components import Component, SlotContent, SlotFunc
###########################################
# 1. Define the types
###########################################
# Positional inputs # Positional inputs
Args = Tuple[int, str] ButtonArgs = Tuple[str, ...]
# Kwargs inputs # Keyword inputs
class Kwargs(TypedDict): class ButtonKwargs(TypedDict):
variable: str name: str
another: int age: int
maybe_var: NotRequired[int] # May be ommited maybe_var: NotRequired[int] # May be ommited
# Data returned from `get_context_data` # The data available to the `footer` scoped slot
class Data(TypedDict): class ButtonFooterSlotData(TypedDict):
variable: str
# The data available to the `my_slot` scoped slot
class MySlotData(TypedDict):
value: int value: int
# Slots # Slots
class Slots(TypedDict): class ButtonSlots(TypedDict):
# SlotContent == str or slot func
header: SlotContent
# Use SlotFunc for slot functions. # Use SlotFunc for slot functions.
# The generic specifies the `data` dictionary # The generic specifies the data available to the slot function
my_slot: NotRequired[SlotFunc[MySlotData]] footer: NotRequired[SlotFunc[ButtonFooterSlotData]]
# SlotContent == Union[str, SafeString]
another_slot: SlotContent
class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]): # Data returned from `get_context_data`
def get_context_data(self, variable, another): class ButtonData(BaseModel):
return { data1: str
"variable": variable, data2: int
}
# Data returned from `get_js_data`
class ButtonJsData(BaseModel):
js_data1: str
js_data2: int
# Data returned from `get_css_data`
class ButtonCssData(BaseModel):
css_data1: str
css_data2: int
###########################################
# 2. Define the component with those types
###########################################
ButtonType = Component[
ButtonArgs,
ButtonKwargs,
ButtonSlots,
ButtonData,
ButtonJsData,
ButtonCssData,
]
class Button(ButtonType):
def get_context_data(self, *args, **kwargs):
...
``` ```
When you then call `Component.render` or `Component.render_to_response`, you will get type hints: When you then call
[`Component.render`](../../../reference/api#django_components.Component.render)
or [`Component.render_to_response`](../../../reference/api#django_components.Component.render_to_response),
you will get type hints:
```py ```python
Button.render( Button.render(
# Error: First arg must be `int`, got `float` # ERROR: Expects a string
args=(1.25, "abc"), args=(123,),
# Error: Key "another" is missing
kwargs={ kwargs={
"variable": "text", "name": "John",
# ERROR: Expects an integer
"age": "invalid",
},
slots={
"header": "...",
# ERROR: Expects key "footer"
"foo": "invalid",
}, },
) )
``` ```
### Usage for Python <3.11 If you don't want to validate some parts, set them to [`Any`](https://docs.python.org/3/library/typing.html#typing.Any).
```python
ButtonType = Component[
ButtonArgs,
ButtonKwargs,
ButtonSlots,
Any,
Any,
Any,
]
class Button(ButtonType):
...
```
## Passing variadic args and kwargs
You may have a function that accepts a variable number of args or kwargs:
```py
def get_context_data(self, *args, **kwargs):
...
```
This is not supported with the typed components.
As a workaround:
- For a variable number of positional arguments (`*args`), set a positional argument that accepts a list of values:
```py
# Tuple of one member of list of strings
Args = Tuple[List[str]]
```
- For a variable number of keyword arguments (`**kwargs`), set a keyword argument that accepts a dictionary of values:
```py
class Kwargs(TypedDict):
variable: str
another: int
# Pass any extra keys under `extra`
extra: Dict[str, any]
```
## Handling no args or no kwargs
To declare that a component accepts no args, kwargs, etc, you can use the
[`EmptyTuple`](../../../reference/api#django_components.EmptyTuple) and
[`EmptyDict`](../../../reference/api#django_components.EmptyDict) types:
```py
from django_components import Component, EmptyDict, EmptyTuple
class Button(Component[EmptyTuple, EmptyDict, EmptyDict, EmptyDict, EmptyDict, EmptyDict]):
...
```
## Runtime input validation with types
!!! warning
Input validation was part of Django Components from version 0.96 to 0.135.
Since v0.136, input validation is available as a separate extension.
To enable input validation, you need to install the [`djc-ext-pydantic`](https://github.com/django-components/djc-ext-pydantic) extension:
```bash
pip install djc-ext-pydantic
```
And add the extension to your project:
```py
COMPONENTS = {
"extensions": [
"djc_ext_pydantic.PydanticExtension",
],
}
```
`djc-ext-pydantic` integrates [Pydantic](https://pydantic.dev/) for input and data validation. It uses the types defined on the component's class to validate inputs of Django components.
## Usage for Python <3.11
On Python 3.8-3.10, use `typing_extensions` On Python 3.8-3.10, use `typing_extensions`
@ -83,91 +217,3 @@ from __future__ import annotations
Moreover, on 3.10 and less, you may not be able to use `NotRequired`, and instead you will need to mark either all keys are required, or all keys as optional, using TypeDict's `total` kwarg. Moreover, on 3.10 and less, you may not be able to use `NotRequired`, and instead you will need to mark either all keys are required, or all keys as optional, using TypeDict's `total` kwarg.
[See PEP-655](https://peps.python.org/pep-0655) for more info. [See PEP-655](https://peps.python.org/pep-0655) for more info.
## Passing additional args or kwargs
You may have a function that supports any number of args or kwargs:
```py
def get_context_data(self, *args, **kwargs):
...
```
This is not supported with the typed components.
As a workaround:
- For `*args`, set a positional argument that accepts a list of values:
```py
# Tuple of one member of list of strings
Args = Tuple[List[str]]
```
- For `*kwargs`, set a keyword argument that accepts a dictionary of values:
```py
class Kwargs(TypedDict):
variable: str
another: int
# Pass any extra keys under `extra`
extra: Dict[str, any]
```
## Handling no args or no kwargs
To declare that a component accepts no Args, Kwargs, etc, you can use `EmptyTuple` and `EmptyDict` types:
```py
from django_components import Component, EmptyDict, EmptyTuple
Args = EmptyTuple
Kwargs = Data = Slots = EmptyDict
class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]):
...
```
## Runtime input validation with types
_New in version 0.96_
> NOTE: Kwargs, slots, and data validation is supported only for Python >=3.11
In Python 3.11 and later, when you specify the component types, you will get also runtime validation of the inputs you pass to `Component.render` or `Component.render_to_response`.
So, using the example from before, if you ignored the type errors and still ran the following code:
```py
Button.render(
# Error: First arg must be `int`, got `float`
args=(1.25, "abc"),
# Error: Key "another" is missing
kwargs={
"variable": "text",
},
)
```
This would raise a `TypeError`:
```txt
Component 'Button' expected positional argument at index 0 to be <class 'int'>, got 1.25 of type <class 'float'>
```
In case you need to skip these errors, you can either set the faulty member to `Any`, e.g.:
```py
# Changed `int` to `Any`
Args = Tuple[Any, str]
```
Or you can replace `Args` with `Any` altogether, to skip the validation of args:
```py
# Replaced `Args` with `Any`
class Button(Component[Any, Kwargs, Slots, Data, JsData, CssData]):
...
```
Same applies to kwargs, data, and slots.

View file

@ -317,13 +317,20 @@ Django-components functionality can be extended with "extensions". Extensions al
- Tap into lifecycle events, such as when a component is created, deleted, or registered. - Tap into lifecycle events, such as when a component is created, deleted, or registered.
- Add new attributes and methods to the components under an extension-specific nested class. - Add new attributes and methods to the components under an extension-specific nested class.
- Add custom CLI commands.
- Add custom URLs.
Some of the extensions include:
- [Django View integration](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/view.py)
- [Component defaults](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/defaults.py)
- [Pydantic integration (input validation)](https://github.com/django-components/djc-ext-pydantic)
Some of the planned extensions include: Some of the planned extensions include:
- Caching - Caching
- AlpineJS integration - AlpineJS integration
- Storybook integration - Storybook integration
- Pydantic validation
- Component-level benchmarking with asv - Component-level benchmarking with asv
### Simple testing ### Simple testing

View file

@ -85,7 +85,6 @@ from django_components.util.exception import component_error_message
from django_components.util.logger import trace_component_msg from django_components.util.logger import trace_component_msg
from django_components.util.misc import gen_id, get_import_path, hash_comp_cls from django_components.util.misc import gen_id, get_import_path, hash_comp_cls
from django_components.util.template_tag import TagAttr from django_components.util.template_tag import TagAttr
from django_components.util.validation import validate_typed_dict, validate_typed_tuple
from django_components.util.weakref import cached_ref from django_components.util.weakref import cached_ref
# TODO_REMOVE_IN_V1 - Users should use top-level import instead # TODO_REMOVE_IN_V1 - Users should use top-level import instead
@ -1093,10 +1092,6 @@ class Component(
render_dependencies: bool = True, render_dependencies: bool = True,
request: Optional[HttpRequest] = None, request: Optional[HttpRequest] = None,
) -> str: ) -> str:
# NOTE: We must run validation before we normalize the slots, because the normalization
# wraps them in functions.
self._validate_inputs(args or (), kwargs or {}, slots or {})
# Allow to pass down Request object via context. # Allow to pass down Request object via context.
# `context` may be passed explicitly via `Component.render()` and `Component.render_to_response()`, # `context` may be passed explicitly via `Component.render()` and `Component.render_to_response()`,
# or implicitly via `{% component %}` tag. # or implicitly via `{% component %}` tag.
@ -1230,7 +1225,6 @@ class Component(
# TODO - enable JS and CSS vars - EXPOSE AND DOCUMENT AND MAKE NON-NULL # TODO - enable JS and CSS vars - EXPOSE AND DOCUMENT AND MAKE NON-NULL
js_data = self.get_js_data(*args, **kwargs) if hasattr(self, "get_js_data") else {} # type: ignore js_data = self.get_js_data(*args, **kwargs) if hasattr(self, "get_js_data") else {} # type: ignore
css_data = self.get_css_data(*args, **kwargs) if hasattr(self, "get_css_data") else {} # type: ignore css_data = self.get_css_data(*args, **kwargs) if hasattr(self, "get_css_data") else {} # type: ignore
self._validate_outputs(data=context_data)
extensions.on_component_data( extensions.on_component_data(
OnComponentDataContext( OnComponentDataContext(
@ -1489,99 +1483,6 @@ class Component(
return norm_fills return norm_fills
# #####################################
# VALIDATION
# #####################################
def _get_types(self) -> Optional[Tuple[Any, Any, Any, Any, Any, Any]]:
"""
Extract the types passed to the Component class.
So if a component subclasses Component class like so
```py
class MyComp(Component[MyArgs, MyKwargs, MySlots, MyData, MyJsData, MyCssData]):
...
```
Then we want to extract the tuple (MyArgs, MyKwargs, MySlots, MyData, MyJsData, MyCssData).
Returns `None` if types were not provided. That is, the class was subclassed
as:
```py
class MyComp(Component):
...
```
"""
# For efficiency, the type extraction is done only once.
# If `self._types` is `False`, that means that the types were not specified.
# If `self._types` is `None`, then this is the first time running this method.
# Otherwise, `self._types` should be a tuple of (Args, Kwargs, Data, Slots)
if self._types == False: # noqa: E712
return None
elif self._types:
return self._types
# Since a class can extend multiple classes, e.g.
#
# ```py
# class MyClass(BaseOne, BaseTwo, ...):
# ...
# ```
#
# Then we need to find the base class that is our `Component` class.
#
# NOTE: __orig_bases__ is a tuple of _GenericAlias
# See https://github.com/python/cpython/blob/709ef004dffe9cee2a023a3c8032d4ce80513582/Lib/typing.py#L1244
# And https://github.com/python/cpython/issues/101688
generics_bases: Tuple[Any, ...] = self.__orig_bases__ # type: ignore[attr-defined]
component_generics_base = None
for base in generics_bases:
origin_cls = base.__origin__
if origin_cls == Component or issubclass(origin_cls, Component):
component_generics_base = base
break
if not component_generics_base:
# If we get here, it means that the Component class wasn't supplied any generics
self._types = False
return None
# If we got here, then we've found ourselves the typed Component class, e.g.
#
# `Component(Tuple[int], MyKwargs, MySlots, Any, Any, Any)`
#
# By accessing the __args__, we access individual types between the brackets, so
#
# (Tuple[int], MyKwargs, MySlots, Any, Any, Any)
args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = component_generics_base.__args__
self._types = args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type
return self._types
def _validate_inputs(self, args: Tuple, kwargs: Any, slots: Any) -> None:
maybe_inputs = self._get_types()
if maybe_inputs is None:
return
args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs
# Validate args
validate_typed_tuple(args, args_type, f"Component '{self.name}'", "positional argument")
# Validate kwargs
validate_typed_dict(kwargs, kwargs_type, f"Component '{self.name}'", "keyword argument")
# Validate slots
validate_typed_dict(slots, slots_type, f"Component '{self.name}'", "slot")
def _validate_outputs(self, data: Any) -> None:
maybe_inputs = self._get_types()
if maybe_inputs is None:
return
args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs
# Validate data
validate_typed_dict(data, data_type, f"Component '{self.name}'", "data")
# Perf # Perf
# Each component may use different start and end tags. We represent this # Each component may use different start and end tags. We represent this

View file

@ -1,130 +0,0 @@
import sys
import typing
from typing import Any, Mapping, Tuple, get_type_hints
# Get all types that users may use from the `typing` module.
#
# These are the types that we do NOT try to resolve when it's a typed generic,
# e.g. `Union[int, str]`.
# If we get a typed generic that's NOT part of this set, we assume it's a user-made
# generic, e.g. `Component[Args, Kwargs]`. In such case we assert that a given value
# is an instance of the base class, e.g. `Component`.
_typing_exports = frozenset(
[
value
for value in typing.__dict__.values()
if isinstance(
value,
(
typing._SpecialForm,
# Used in 3.8 and 3.9
getattr(typing, "_GenericAlias", ()),
# Used in 3.11+ (possibly 3.10?)
getattr(typing, "_SpecialGenericAlias", ()),
),
)
]
)
def _prepare_type_for_validation(the_type: Any) -> Any:
# If we got a typed generic (AKA "subscripted" generic), e.g.
# `Component[CompArgs, CompKwargs, ...]`
# then we cannot use that generic in `isintance()`, because we get this error:
# `TypeError("Subscripted generics cannot be used with class and instance checks")`
#
# Instead, we resolve the generic to its original class, e.g. `Component`,
# which can then be used in instance assertion.
if hasattr(the_type, "__origin__"):
is_custom_typing = the_type.__origin__ not in _typing_exports
if is_custom_typing:
return the_type.__origin__
else:
return the_type
else:
return the_type
# NOTE: tuple_type is a _GenericAlias - See https://stackoverflow.com/questions/74412803
def validate_typed_tuple(
value: Tuple[Any, ...],
tuple_type: Any,
prefix: str,
kind: str,
) -> None:
# `Any` type is the signal that we should skip validation
if tuple_type == Any:
return
# We do two kinds of validation with the given Tuple type:
# 1. We check whether there are any extra / missing positional args
# 2. We look at the members of the Tuple (which are types themselves),
# and check if our concrete list / tuple has correct types under correct indices.
expected_pos_args = len(tuple_type.__args__)
actual_pos_args = len(value)
if expected_pos_args > actual_pos_args:
# Generate errors like below (listed for searchability)
# `Component 'name' expected 3 positional arguments, got 2`
raise TypeError(f"{prefix} expected {expected_pos_args} {kind}s, got {actual_pos_args}")
for index, arg_type in enumerate(tuple_type.__args__):
arg = value[index]
arg_type = _prepare_type_for_validation(arg_type)
if sys.version_info >= (3, 11) and not isinstance(arg, arg_type):
# Generate errors like below (listed for searchability)
# `Component 'name' expected positional argument at index 0 to be <class 'int'>, got 123.5 of type <class 'float'>` # noqa: E501
raise TypeError(
f"{prefix} expected {kind} at index {index} to be {arg_type}, got {arg} of type {type(arg)}"
)
# NOTE:
# - `dict_type` can be a `TypedDict` or `Any` as the types themselves
# - `value` is expected to be TypedDict, the base `TypedDict` type cannot be used
# in function signature (only its subclasses can), so we specify the type as Mapping.
# See https://stackoverflow.com/questions/74412803
def validate_typed_dict(value: Mapping[str, Any], dict_type: Any, prefix: str, kind: str) -> None:
# `Any` type is the signal that we should skip validation
if dict_type == Any:
return
# See https://stackoverflow.com/a/76527675
# And https://stackoverflow.com/a/71231688
required_kwargs = dict_type.__required_keys__
unseen_keys = set(value.keys())
# For each entry in the TypedDict, we do two kinds of validation:
# 1. We check whether there are any extra / missing keys
# 2. We look at the values of TypedDict entries (which are types themselves),
# and check if our concrete dict has correct types under correct keys.
for key, kwarg_type in get_type_hints(dict_type).items():
if key not in value:
if key in required_kwargs:
# Generate errors like below (listed for searchability)
# `Component 'name' is missing a required keyword argument 'key'`
# `Component 'name' is missing a required slot argument 'key'`
# `Component 'name' is missing a required data argument 'key'`
raise TypeError(f"{prefix} is missing a required {kind} '{key}'")
else:
unseen_keys.remove(key)
kwarg = value[key]
kwarg_type = _prepare_type_for_validation(kwarg_type)
# NOTE: `isinstance()` cannot be used with the version of TypedDict prior to 3.11.
# So we do type validation for TypedDicts only in 3.11 and later.
if sys.version_info >= (3, 11) and not isinstance(kwarg, kwarg_type):
# Generate errors like below (listed for searchability)
# `Component 'name' expected keyword argument 'key' to be <class 'int'>, got 123.4 of type <class 'float'>` # noqa: E501
# `Component 'name' expected slot 'key' to be <class 'int'>, got 123.4 of type <class 'float'>`
# `Component 'name' expected data 'key' to be <class 'int'>, got 123.4 of type <class 'float'>`
raise TypeError(
f"{prefix} expected {kind} '{key}' to be {kwarg_type}, got {kwarg} of type {type(kwarg)}"
)
if unseen_keys:
formatted_keys = ", ".join([f"'{key}'" for key in unseen_keys])
# Generate errors like below (listed for searchability)
# `Component 'name' got unexpected keyword argument keys 'invalid_key'`
# `Component 'name' got unexpected slot keys 'invalid_key'`
# `Component 'name' got unexpected data keys 'invalid_key'`
raise TypeError(f"{prefix} got unexpected {kind} keys {formatted_keys}")

View file

@ -4,16 +4,7 @@ For tests focusing on the `component` tag, see `test_templatetags_component.py`
""" """
import re import re
import sys from typing import Any, Dict, no_type_check
from typing import Any, Dict, List, Tuple, Union, no_type_check
# See https://peps.python.org/pep-0655/#usage-in-python-3-11
if sys.version_info >= (3, 11):
from typing import NotRequired, TypedDict
else:
from typing_extensions import NotRequired, TypedDict # for Python <3.11 with (Not)Required
from unittest import skipIf
import pytest import pytest
from django.conf import settings from django.conf import settings
@ -23,10 +14,9 @@ from django.template import Context, RequestContext, Template, TemplateSyntaxErr
from django.template.base import TextNode from django.template.base import TextNode
from django.test import Client from django.test import Client
from django.urls import path from django.urls import path
from django.utils.safestring import SafeString
from pytest_django.asserts import assertHTMLEqual, assertInHTML from pytest_django.asserts import assertHTMLEqual, assertInHTML
from django_components import Component, ComponentView, Slot, SlotFunc, all_components, register, types from django_components import Component, ComponentView, all_components, register, types
from django_components.slots import SlotRef from django_components.slots import SlotRef
from django_components.urls import urlpatterns as dc_urlpatterns from django_components.urls import urlpatterns as dc_urlpatterns
@ -51,34 +41,6 @@ class CustomClient(Client):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Component typings
CompArgs = Tuple[int, str]
class CompData(TypedDict):
variable: str
class CompSlots(TypedDict):
my_slot: Union[str, int, Slot]
my_slot2: SlotFunc
if sys.version_info >= (3, 11):
class CompKwargs(TypedDict):
variable: str
another: int
optional: NotRequired[int]
else:
class CompKwargs(TypedDict, total=False):
variable: str
another: int
optional: NotRequired[int]
# TODO_REMOVE_IN_V1 - Superseded by `self.get_template` in v1 # TODO_REMOVE_IN_V1 - Superseded by `self.get_template` in v1
@djc_test @djc_test
class TestComponentOldTemplateApi: class TestComponentOldTemplateApi:
@ -395,354 +357,6 @@ class TestComponent:
Root.render() Root.render()
@djc_test
class TestComponentValidation:
def test_validate_input_passes(self):
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
def get_context_data(self, var1, var2, variable, another, **attrs):
return {
"variable": variable,
}
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot 1: {% slot "my_slot" / %}
Slot 2: {% slot "my_slot2" / %}
"""
rendered = TestComponent.render(
kwargs={"variable": "test", "another": 1},
args=(123, "str"),
slots={
"my_slot": SafeString("MY_SLOT"),
"my_slot2": lambda ctx, data, ref: "abc",
},
)
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3e>test</strong>
Slot 1: MY_SLOT
Slot 2: abc
""",
)
@skipIf(sys.version_info < (3, 11), "Requires >= 3.11")
def test_validate_input_fails(self):
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
def get_context_data(self, var1, var2, variable, another, **attrs):
return {
"variable": variable,
}
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot 1: {% slot "my_slot" / %}
Slot 2: {% slot "my_slot2" / %}
"""
with pytest.raises(
TypeError,
match=re.escape("Component 'TestComponent' expected 2 positional arguments, got 1"),
):
TestComponent.render(
kwargs={"variable": 1, "another": "test"}, # type: ignore
args=(123,), # type: ignore
slots={
"my_slot": "MY_SLOT",
"my_slot2": lambda ctx, data, ref: "abc",
},
)
with pytest.raises(
TypeError,
match=re.escape("Component 'TestComponent' expected 2 positional arguments, got 0"),
):
TestComponent.render(
kwargs={"variable": 1, "another": "test"}, # type: ignore
slots={
"my_slot": "MY_SLOT",
"my_slot2": lambda ctx, data, ref: "abc",
},
)
with pytest.raises(
TypeError,
match=re.escape(
"Component 'TestComponent' expected keyword argument 'variable' to be <class 'str'>, got 1 of type <class 'int'>" # noqa: E501
),
):
TestComponent.render(
kwargs={"variable": 1, "another": "test"}, # type: ignore
args=(123, "abc", 456), # type: ignore
slots={
"my_slot": "MY_SLOT",
"my_slot2": lambda ctx, data, ref: "abc",
},
)
with pytest.raises(
TypeError,
match=re.escape("Component 'TestComponent' expected 2 positional arguments, got 0"),
):
TestComponent.render()
with pytest.raises(
TypeError,
match=re.escape(
"Component 'TestComponent' expected keyword argument 'variable' to be <class 'str'>, got 1 of type <class 'int'>" # noqa: E501
),
):
TestComponent.render(
kwargs={"variable": 1, "another": "test"}, # type: ignore
args=(123, "str"),
slots={
"my_slot": "MY_SLOT",
"my_slot2": lambda ctx, data, ref: "abc",
},
)
with pytest.raises(
TypeError,
match=re.escape("Component 'TestComponent' is missing a required keyword argument 'another'"),
):
TestComponent.render(
kwargs={"variable": "abc"}, # type: ignore
args=(123, "str"),
slots={
"my_slot": "MY_SLOT",
"my_slot2": lambda ctx, data, ref: "abc",
},
)
with pytest.raises(
TypeError,
match=re.escape(
"Component 'TestComponent' expected slot 'my_slot' to be typing.Union[str, int, django_components.slots.Slot], got 123.5 of type <class 'float'>" # noqa: E501
),
):
TestComponent.render(
kwargs={"variable": "abc", "another": 1},
args=(123, "str"),
slots={
"my_slot": 123.5, # type: ignore
"my_slot2": lambda ctx, data, ref: "abc",
},
)
with pytest.raises(
TypeError,
match=re.escape("Component 'TestComponent' is missing a required slot 'my_slot2'"),
):
TestComponent.render(
kwargs={"variable": "abc", "another": 1},
args=(123, "str"),
slots={
"my_slot": "MY_SLOT",
}, # type: ignore
)
def test_validate_input_skipped(self):
class TestComponent(Component[Any, CompKwargs, Any, CompData, Any, Any]):
def get_context_data(self, var1, var2, variable, another, **attrs):
return {
"variable": variable,
}
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot 1: {% slot "my_slot" / %}
Slot 2: {% slot "my_slot2" / %}
"""
rendered = TestComponent.render(
kwargs={"variable": "test", "another": 1},
args=("123", "str"), # NOTE: Normally should raise
slots={
"my_slot": 123.5, # NOTE: Normally should raise
"my_slot2": lambda ctx, data, ref: "abc",
},
)
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3e>test</strong>
Slot 1: 123.5
Slot 2: abc
""",
)
def test_validate_output_passes(self):
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
def get_context_data(self, var1, var2, variable, another, **attrs):
return {
"variable": variable,
}
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot 1: {% slot "my_slot" / %}
Slot 2: {% slot "my_slot2" / %}
"""
rendered = TestComponent.render(
kwargs={"variable": "test", "another": 1},
args=(123, "str"),
slots={
"my_slot": SafeString("MY_SLOT"),
"my_slot2": lambda ctx, data, ref: "abc",
},
)
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3e>test</strong>
Slot 1: MY_SLOT
Slot 2: abc
""",
)
def test_validate_output_fails(self):
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
def get_context_data(self, var1, var2, variable, another, **attrs):
return {
"variable": variable,
"invalid_key": var1,
}
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot 1: {% slot "my_slot" / %}
Slot 2: {% slot "my_slot2" / %}
"""
with pytest.raises(
TypeError,
match=re.escape("Component 'TestComponent' got unexpected data keys 'invalid_key'"),
):
TestComponent.render(
kwargs={"variable": "test", "another": 1},
args=(123, "str"),
slots={
"my_slot": SafeString("MY_SLOT"),
"my_slot2": lambda ctx, data, ref: "abc",
},
)
def test_handles_components_in_typing(self):
class InnerKwargs(TypedDict):
one: str
class InnerData(TypedDict):
one: Union[str, int]
self: "InnerComp" # type: ignore[misc]
InnerComp = Component[Any, InnerKwargs, Any, InnerData, Any, Any] # type: ignore[misc]
class Inner(InnerComp):
def get_context_data(self, one):
return {
"self": self,
"one": one,
}
template = ""
TodoArgs = Tuple[Inner] # type: ignore[misc]
class TodoKwargs(TypedDict):
inner: Inner
class TodoData(TypedDict):
one: Union[str, int]
self: "TodoComp" # type: ignore[misc]
inner: str
TodoComp = Component[TodoArgs, TodoKwargs, Any, TodoData, Any, Any] # type: ignore[misc]
# NOTE: Since we're using ForwardRef for "TodoComp" and "InnerComp", we need
# to ensure that the actual types are set as globals, so the ForwardRef class
# can resolve them.
globals()["TodoComp"] = TodoComp
globals()["InnerComp"] = InnerComp
class TestComponent(TodoComp):
def get_context_data(self, var1, inner):
return {
"self": self,
"one": "2123",
# NOTE: All of this is typed
"inner": self.input.kwargs["inner"].render(kwargs={"one": "abc"}),
}
template: types.django_html = """
{% load component_tags %}
Name: <strong>{{ self.name }}</strong>
"""
rendered = TestComponent.render(args=(Inner(),), kwargs={"inner": Inner()})
assertHTMLEqual(
rendered,
"""
Name: <strong data-djc-id-a1bc3e>TestComponent</strong>
""",
)
def test_handles_typing_module(self):
TodoArgs = Tuple[
Union[str, int],
Dict[str, int],
List[str],
Tuple[int, Union[str, int]],
]
class TodoKwargs(TypedDict):
one: Union[str, int]
two: Dict[str, int]
three: List[str]
four: Tuple[int, Union[str, int]]
class TodoData(TypedDict):
one: Union[str, int]
two: Dict[str, int]
three: List[str]
four: Tuple[int, Union[str, int]]
TodoComp = Component[TodoArgs, TodoKwargs, Any, TodoData, Any, Any]
# NOTE: Since we're using ForwardRef for "TodoComp", we need
# to ensure that the actual types are set as globals, so the ForwardRef class
# can resolve them.
globals()["TodoComp"] = TodoComp
class TestComponent(TodoComp):
def get_context_data(self, *args, **kwargs):
return {
**kwargs,
}
template = ""
TestComponent.render(
args=("str", {"str": 123}, ["a", "b", "c"], (123, "123")),
kwargs={
"one": "str",
"two": {"str": 123},
"three": ["a", "b", "c"],
"four": (123, "123"),
},
)
@djc_test @djc_test
class TestComponentRender: class TestComponentRender:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)