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

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