diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f174f1..43e4e675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # 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 #### Feat diff --git a/README.md b/README.md index 35ee93ad..6bcb761a 100644 --- a/README.md +++ b/README.md @@ -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. - 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: - Caching - AlpineJS integration - Storybook integration -- Pydantic validation - Component-level benchmarking with asv ### Simple testing diff --git a/docs/concepts/advanced/typing_and_validation.md b/docs/concepts/advanced/typing_and_validation.md index 83252790..63832414 100644 --- a/docs/concepts/advanced/typing_and_validation.md +++ b/docs/concepts/advanced/typing_and_validation.md @@ -2,71 +2,205 @@ _New in version 0.92_ -The `Component` class optionally accepts type parameters -that allow you to specify the types of args, kwargs, slots, and -data: +The [`Component`](../../../reference/api#django_components.Component) class optionally accepts type parameters +that allow you to specify the types of args, kwargs, slots, and data. + +Use this to add type hints to your components, or to validate component inputs. ```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` -- `Kwargs` - Must be a `TypedDict` or `Any` -- `Data` - Must be a `TypedDict` or `Any` -- `Slots` - Must be a `TypedDict` or `Any` +The generic parameters are: -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 -from typing import NotRequired, Tuple, TypedDict, SlotContent, SlotFunc +## Example + +```python +from typing import NotRequired, Tuple, TypedDict +from pydantic import BaseModel +from django_components import Component, SlotContent, SlotFunc + +########################################### +# 1. Define the types +########################################### # Positional inputs -Args = Tuple[int, str] +ButtonArgs = Tuple[str, ...] -# Kwargs inputs -class Kwargs(TypedDict): - variable: str - another: int +# Keyword inputs +class ButtonKwargs(TypedDict): + name: str + age: 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): +# The data available to the `footer` scoped slot +class ButtonFooterSlotData(TypedDict): value: int # Slots -class Slots(TypedDict): +class ButtonSlots(TypedDict): + # SlotContent == str or slot func + header: SlotContent # Use SlotFunc for slot functions. - # The generic specifies the `data` dictionary - my_slot: NotRequired[SlotFunc[MySlotData]] - # SlotContent == Union[str, SafeString] - another_slot: SlotContent + # The generic specifies the data available to the slot function + footer: NotRequired[SlotFunc[ButtonFooterSlotData]] -class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]): - def get_context_data(self, variable, another): - return { - "variable": variable, - } +# Data returned from `get_context_data` +class ButtonData(BaseModel): + data1: str + 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( - # Error: First arg must be `int`, got `float` - args=(1.25, "abc"), - # Error: Key "another" is missing + # ERROR: Expects a string + args=(123,), 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` @@ -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. [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 , got 1.25 of type -``` - -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. diff --git a/docs/overview/welcome.md b/docs/overview/welcome.md index 5b93df5e..db2a82d0 100644 --- a/docs/overview/welcome.md +++ b/docs/overview/welcome.md @@ -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. - 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: - Caching - AlpineJS integration - Storybook integration -- Pydantic validation - Component-level benchmarking with asv ### Simple testing diff --git a/src/django_components/component.py b/src/django_components/component.py index 41d33ab5..6923f0c6 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -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.misc import gen_id, get_import_path, hash_comp_cls 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 # TODO_REMOVE_IN_V1 - Users should use top-level import instead @@ -1093,10 +1092,6 @@ class Component( render_dependencies: bool = True, request: Optional[HttpRequest] = None, ) -> 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. # `context` may be passed explicitly via `Component.render()` and `Component.render_to_response()`, # or implicitly via `{% component %}` tag. @@ -1230,7 +1225,6 @@ class Component( # 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 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( OnComponentDataContext( @@ -1489,99 +1483,6 @@ class Component( 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 # Each component may use different start and end tags. We represent this diff --git a/src/django_components/util/validation.py b/src/django_components/util/validation.py deleted file mode 100644 index 2ffc8a53..00000000 --- a/src/django_components/util/validation.py +++ /dev/null @@ -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 , got 123.5 of type ` # 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 , got 123.4 of type ` # noqa: E501 - # `Component 'name' expected slot 'key' to be , got 123.4 of type ` - # `Component 'name' expected data 'key' to be , got 123.4 of type ` - 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}") diff --git a/tests/test_component.py b/tests/test_component.py index f68c98c7..3168e991 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -4,16 +4,7 @@ For tests focusing on the `component` tag, see `test_templatetags_component.py` """ import re -import sys -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 +from typing import Any, Dict, no_type_check import pytest 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.test import Client from django.urls import path -from django.utils.safestring import SafeString 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.urls import urlpatterns as dc_urlpatterns @@ -51,34 +41,6 @@ class CustomClient(Client): 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 @djc_test class TestComponentOldTemplateApi: @@ -395,354 +357,6 @@ class TestComponent: 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: {{ variable }} - 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: test - 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: {{ variable }} - 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 , got 1 of type " # 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 , got 1 of type " # 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 " # 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: {{ variable }} - 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: test - 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: {{ variable }} - 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: test - 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: {{ variable }} - 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: {{ self.name }} - """ - - rendered = TestComponent.render(args=(Inner(),), kwargs={"inner": Inner()}) - - assertHTMLEqual( - rendered, - """ - Name: TestComponent - """, - ) - - 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 class TestComponentRender: @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)