diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4fd3ace..22e672f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ on: jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: matrix: python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] diff --git a/CHANGELOG.md b/CHANGELOG.md index 48db4c3..dd93157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Release notes +## v1.1.0 + +### Refactor + +- Pin django-components to v0.140. + ## v1.0.0 - First release ### Feat diff --git a/README.md b/README.md index 74a3b74..cd3484a 100644 --- a/README.md +++ b/README.md @@ -4,60 +4,43 @@ Validate components' inputs and outputs using Pydantic. -`djc-ext-pydantic` is a [django-component](https://github.com/django-components/django-components) extension that integrates [Pydantic](https://pydantic.dev/) for input and data validation. It uses the types defined on the component's class to validate both inputs and outputs of Django components. +`djc-ext-pydantic` is a [django-component](https://github.com/django-components/django-components) extension that integrates [Pydantic](https://pydantic.dev/) for input and data validation. -### Validated Inputs and Outputs +### Example Usage -- **Inputs:** +```python +from django_components import Component, SlotInput +from djc_pydantic import ArgsBaseModel +from pydantic import BaseModel - - `args`: Positional arguments, expected to be defined as a [`Tuple`](https://docs.python.org/3/library/typing.html#typing.Tuple) type. - - `kwargs`: Keyword arguments, can be defined using [`TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) or Pydantic's [`BaseModel`](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel). - - `slots`: Can also be defined using [`TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) or Pydantic's [`BaseModel`](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel). +# 1. Define the Component with Pydantic models +class MyComponent(Component): + class Args(ArgsBaseModel): + var1: str -- **Outputs:** - - Data returned from `get_context_data()`, `get_js_data()`, and `get_css_data()`, which can be defined using [`TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) or Pydantic's [`BaseModel`](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel). + class Kwargs(BaseModel): + name: str + age: int -### Example Usage + class Slots(BaseModel): + header: SlotInput + footer: SlotInput + + class TemplateData(BaseModel): + data1: str + data2: int + + class JsData(BaseModel): + js_data1: str + js_data2: int + + class CssData(BaseModel): + css_data1: str + css_data2: int -```python -from pydantic import BaseModel -from typing import Tuple, TypedDict - -# 1. Define the types -MyCompArgs = Tuple[str, ...] - -class MyCompKwargs(TypedDict): - name: str - age: int - -class MyCompSlots(TypedDict): - header: SlotContent - footer: SlotContent - -class MyCompData(BaseModel): - data1: str - data2: int - -class MyCompJsData(BaseModel): - js_data1: str - js_data2: int - -class MyCompCssData(BaseModel): - css_data1: str - css_data2: int - -# 2. Define the component with those types -class MyComponent(Component[ - MyCompArgs, - MyCompKwargs, - MyCompSlots, - MyCompData, - MyCompJsData, - MyCompCssData, -]): ... -# 3. Render the component +# 2. Render the component MyComponent.render( # ERROR: Expects a string args=(123,), @@ -74,20 +57,6 @@ MyComponent.render( ) ``` -If you don't want to validate some parts, set them to [`Any`](https://docs.python.org/3/library/typing.html#typing.Any). - -```python -class MyComponent(Component[ - MyCompArgs, - MyCompKwargs, - MyCompSlots, - Any, - Any, - Any, -]): - ... -``` - ## Installation ```bash @@ -118,6 +87,25 @@ COMPONENTS = { } ``` +## Validating args + +By default, Pydantic's `BaseModel` requires all fields to be passed as keyword arguments. If you want to validate positional arguments, you can use a custom subclass `ArgsBaseModel`: + +```python +from pydantic import BaseModel +from djc_pydantic import ArgsBaseModel + +class MyTable(Component): + class Args(ArgsBaseModel): + a: int + b: str + c: float + +MyTable.render( + args=[1, "hello", 3.14], +) +``` + ## Release notes Read the [Release Notes](https://github.com/django-components/djc-ext-pydantic/tree/main/CHANGELOG.md) diff --git a/pyproject.toml b/pyproject.toml index 5b69f62..6abf6da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "djc-ext-pydantic" -version = "1.0.0" +version = "1.1.0" requires-python = ">=3.8, <4.0" description = "Input validation with Pydantic for Django Components" keywords = ["pydantic", "django-components", "djc", "django", "components"] @@ -33,7 +33,7 @@ classifiers = [ "License :: OSI Approved :: MIT License", ] dependencies = [ - 'django-components>=0.136', + 'django-components>=0.140', 'pydantic>=2.9', ] license = {text = "MIT"} diff --git a/requirements-ci.txt b/requirements-ci.txt index 2097337..d63a1de 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -20,7 +20,7 @@ django==4.2.20 # via # -r requirements-ci.in # django-components -django-components==0.136 +django-components==0.140 # via -r requirements-ci.in djc-core-html-parser==1.0.2 # via django-components diff --git a/requirements-dev.txt b/requirements-dev.txt index 0d295e8..da14cbc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -26,7 +26,7 @@ django==4.2.20 # via # -r requirements-dev.in # django-components -django-components==0.136 +django-components==0.140 # via -r requirements-dev.in djc-core-html-parser==1.0.2 # via django-components diff --git a/src/djc_pydantic/__init__.py b/src/djc_pydantic/__init__.py index 6c8ecc5..73dba40 100644 --- a/src/djc_pydantic/__init__.py +++ b/src/djc_pydantic/__init__.py @@ -1,8 +1,10 @@ from djc_pydantic.extension import PydanticExtension from djc_pydantic.monkeypatch import monkeypatch_pydantic_core_schema +from djc_pydantic.utils import ArgsBaseModel monkeypatch_pydantic_core_schema() __all__ = [ + "ArgsBaseModel", "PydanticExtension", ] diff --git a/src/djc_pydantic/extension.py b/src/djc_pydantic/extension.py index d2704c2..93425f1 100644 --- a/src/djc_pydantic/extension.py +++ b/src/djc_pydantic/extension.py @@ -1,106 +1,95 @@ -from django_components import ComponentExtension -from django_components.extension import ( - OnComponentDataContext, - OnComponentInputContext, -) +from typing import Any, Optional, Set -from djc_pydantic.validation import get_component_typing, validate_type +from django_components import ComponentExtension, ComponentNode, OnComponentInputContext # noqa: F401 +from pydantic import BaseModel class PydanticExtension(ComponentExtension): """ A Django component extension that integrates Pydantic for input and data validation. - This extension uses the types defined on the component's class to validate the inputs - and outputs of Django components. - - The following are validated: - - - Inputs: - - - `args` - - `kwargs` - - `slots` - - - Outputs (data returned from): - - - `get_context_data()` - - `get_js_data()` - - `get_css_data()` - - Validation is done using Pydantic's `TypeAdapter`. As such, the following are expected: - - - Positional arguments (`args`) should be defined as a `Tuple` type. - - Other data (`kwargs`, `slots`, ...) are all objects or dictionaries, and can be defined - using either `TypedDict` or Pydantic's `BaseModel`. + NOTE: As of v0.140 the extension only ensures that the classes from django-components + can be used with pydantic. For the actual validation, subclass `Kwargs`, `Slots`, etc + from Pydantic's `BaseModel`, and use `ArgsBaseModel` for `Args`. **Example:** ```python - MyCompArgs = Tuple[str, ...] - - class MyCompKwargs(TypedDict): - name: str - age: int + from django_components import Component, SlotInput + from pydantic import BaseModel - class MyCompSlots(TypedDict): - header: SlotContent - footer: SlotContent + class MyComponent(Component): + class Args(ArgsBaseModel): + var1: str - class MyCompData(BaseModel): - data1: str - data2: int + class Kwargs(BaseModel): + name: str + age: int - class MyCompJsData(BaseModel): - js_data1: str - js_data2: int + class Slots(BaseModel): + header: SlotInput + footer: SlotInput - class MyCompCssData(BaseModel): - css_data1: str - css_data2: int + class TemplateData(BaseModel): + data1: str + data2: int - class MyComponent(Component[MyCompArgs, MyCompKwargs, MyCompSlots, MyCompData, MyCompJsData, MyCompCssData]): - ... - ``` + class JsData(BaseModel): + js_data1: str + js_data2: int - To exclude a field from validation, set its type to `Any`. + class CssData(BaseModel): + css_data1: str + css_data2: int - ```python - class MyComponent(Component[MyCompArgs, MyCompKwargs, MyCompSlots, Any, Any, Any]): ... ``` """ name = "pydantic" - # Validate inputs to the component on `Component.render()` + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self.rebuilt_comp_cls_ids: Set[str] = set() + + # If the Pydantic models reference other types with forward references, + # the models may not be complete / "built" when the component is first loaded. + # + # Component classes may be created when other modules are still being imported, + # so we have to wait until we start rendering components to ensure that everything + # is loaded. + # + # At that point, we check for components whether they have any Pydantic models, + # and whether those models need to be rebuilt. + # + # See https://errors.pydantic.dev/2.11/u/class-not-fully-defined + # + # Otherwise, we get an error like: + # + # ``` + # pydantic.errors.PydanticUserError: `Slots` is not fully defined; you should define + # `ComponentNode`, then call `Slots.model_rebuild()` + # ``` def on_component_input(self, ctx: OnComponentInputContext) -> None: - maybe_inputs = get_component_typing(ctx.component_cls) - if maybe_inputs is None: - return - - args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs - comp_name = ctx.component_cls.__name__ - - # Validate args - validate_type(ctx.args, args_type, f"Positional arguments of component '{comp_name}' failed validation") - # Validate kwargs - validate_type(ctx.kwargs, kwargs_type, f"Keyword arguments of component '{comp_name}' failed validation") - # Validate slots - validate_type(ctx.slots, slots_type, f"Slots of component '{comp_name}' failed validation") - - # Validate the data generated from `get_context_data()`, `get_js_data()` and `get_css_data()` - def on_component_data(self, ctx: OnComponentDataContext) -> None: - maybe_inputs = get_component_typing(ctx.component_cls) - if maybe_inputs is None: + if ctx.component.class_id in self.rebuilt_comp_cls_ids: return - args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs - comp_name = ctx.component_cls.__name__ - - # Validate data - validate_type(ctx.context_data, data_type, f"Data of component '{comp_name}' failed validation") - # Validate JS data - validate_type(ctx.js_data, js_data_type, f"JS data of component '{comp_name}' failed validation") - # Validate CSS data - validate_type(ctx.css_data, css_data_type, f"CSS data of component '{comp_name}' failed validation") + for name in ["Args", "Kwargs", "Slots", "TemplateData", "JsData", "CssData"]: + cls: Optional[BaseModel] = getattr(ctx.component, name, None) + if cls is None: + continue + + if hasattr(cls, "__pydantic_complete__") and not cls.__pydantic_complete__: + # When resolving forward references, Pydantic needs the module globals, + # AKA a dict that resolves those forward references. + # + # There's 2 problems here - user may define their own types which may need + # resolving. And we define the Slot type which needs to access `ComponentNode`. + # + # So as a solution, we provide to Pydantic a dictionary that contains globals + # from both 1. the component file and 2. this file (where we import `ComponentNode`). + mod = __import__(cls.__module__) + module_globals = mod.__dict__ + cls.model_rebuild(_types_namespace={**globals(), **module_globals}) + + self.rebuilt_comp_cls_ids.add(ctx.component.class_id) diff --git a/src/djc_pydantic/monkeypatch.py b/src/djc_pydantic/monkeypatch.py index d94e1e9..982a743 100644 --- a/src/djc_pydantic/monkeypatch.py +++ b/src/djc_pydantic/monkeypatch.py @@ -2,7 +2,7 @@ from django.template import NodeList from django.utils.safestring import SafeString -from django_components import Component, Slot, SlotFunc +from django_components import BaseNode, Component, Slot, SlotFunc from pydantic_core import core_schema @@ -31,6 +31,17 @@ def slot_core_schema(cls: Type[Slot], _source_type: Any, _handler: Any) -> Any: Slot.__get_pydantic_core_schema__ = classmethod(slot_core_schema) # type: ignore[attr-defined] + # Allow to use BaseNode class inside Pydantic models + def basenode_core_schema(cls: Type[BaseNode], _source_type: Any, _handler: Any) -> Any: + return core_schema.json_or_python_schema( + # Inside a Python object, the field must be an instance of BaseNode class + python_schema=core_schema.is_instance_schema(cls), + # Inside a JSON, the field is represented as a string + json_schema=core_schema.str_schema(), + ) + + BaseNode.__get_pydantic_core_schema__ = classmethod(basenode_core_schema) + # Tell Pydantic to handle SafeString as regular string def safestring_core_schema(*args: Any, **kwargs: Any) -> Any: return core_schema.str_schema() diff --git a/src/djc_pydantic/utils.py b/src/djc_pydantic/utils.py new file mode 100644 index 0000000..dc7a7d9 --- /dev/null +++ b/src/djc_pydantic/utils.py @@ -0,0 +1,41 @@ +from typing import Any + +from pydantic import BaseModel + + +class ArgsBaseModel(BaseModel): + """ + Replacement for Pydantic's `BaseModel` for positional arguments that are passed to a component. + + `BaseModel` does not support positional arguments, so this class is used to ensure that + the arguments are passed in the correct order. + + **Example:** + + ```python + from django_components import Component + from djc_pydantic import ArgsBaseModel + from pydantic import BaseModel + + class MyComponent(Component): + class Args(ArgsBaseModel): + var1: str + var2: int + + class Kwargs(BaseModel): + var3: str + var4: int + ``` + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + # Get the field names in definition order + field_names = list(self.__class__.model_fields.keys()) + # Map positional args to field names + for i, value in enumerate(args): + if i >= len(field_names): + raise TypeError(f"Too many positional arguments for {self.__class__.__name__}") + if field_names[i] in kwargs: + raise TypeError(f"Multiple values for argument '{field_names[i]}'") + kwargs[field_names[i]] = value + super().__init__(**kwargs) diff --git a/src/djc_pydantic/validation.py b/src/djc_pydantic/validation.py deleted file mode 100644 index aa2657e..0000000 --- a/src/djc_pydantic/validation.py +++ /dev/null @@ -1,106 +0,0 @@ -import sys -from typing import Any, Literal, Optional, Tuple, Type, Union -from weakref import WeakKeyDictionary - -from django_components import Component -from pydantic import TypeAdapter, ValidationError - -ComponentTypes = Tuple[Any, Any, Any, Any, Any, Any] - - -# Cache the types for each component class. -# NOTE: `WeakKeyDictionary` can't be used as generic in Python 3.8 -if sys.version_info >= (3, 9): - types_store: WeakKeyDictionary[ - Type[Component], - Union[Optional[ComponentTypes], Literal[False]], - ] = WeakKeyDictionary() -else: - types_store = WeakKeyDictionary() - - -def get_component_typing(cls: Type[Component]) -> Optional[ComponentTypes]: - """ - 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 `class_types` is `False`, that means that the types were not specified. - # If `class_types` is `None`, then this is the first time running this method. - # Otherwise, `class_types` should be a tuple of (Args, Kwargs, Slots, Data, JsData, CssData) - class_types = types_store.get(cls, None) - if class_types is False: # noqa: E712 - return None - elif class_types is not None: - return class_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, ...] = cls.__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 - types_store[cls] = 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__ - - component_types = args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type - types_store[cls] = component_types - return component_types - - -def validate_type(value: Any, type: Any, msg: str) -> None: - """ - Validate that the value is of the given type. Uses Pydantic's `TypeAdapter` to - validate the type. - - If the value is not of the given type, raise a `ValidationError` with a note - about where the error occurred. - """ - try: - # See https://docs.pydantic.dev/2.3/usage/type_adapter/ - TypeAdapter(type).validate_python(value) - except ValidationError as err: - # Add note about where the error occurred - err.add_note(msg) - raise err diff --git a/tests/test_validation.py b/tests/test_validation.py index cde1b58..24d3ca7 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,11 +1,12 @@ -from typing import Any, Dict, List, Tuple, Type, Union +import re +from typing import Dict, List, Tuple, Type, Union import pytest -from django_components import Component, SlotContent, types +from django_components import Component, SlotInput, types from django_components.testing import djc_test -# from pydantic import ValidationError # TODO: Set more specific error message -from typing_extensions import TypedDict +from pydantic import BaseModel, ValidationError +from djc_pydantic import ArgsBaseModel from djc_pydantic.extension import PydanticExtension from tests.testutils import setup_test_config @@ -19,10 +20,10 @@ class TestValidation: ) def test_no_validation_on_no_typing(self): class TestComponent(Component): - def get_context_data(self, var1, var2, variable, another, **attrs): + def get_template_data(self, args, kwargs, slots, context): return { - "variable": variable, - "invalid_key": var1, + "variable": kwargs["variable"], + "invalid_key": args[0], } template: types.django_html = """ @@ -37,48 +38,24 @@ def get_context_data(self, var1, var2, variable, another, **attrs): kwargs={"variable": "test", "another": 1}, slots={ "my_slot": "MY_SLOT", - "my_slot2": lambda ctx, data, ref: "abc", + "my_slot2": lambda ctx: "abc", }, ) @djc_test( components_settings={"extensions": [PydanticExtension]}, ) - def test_no_validation_on_any(self): - class TestComponent(Component[Any, Any, Any, Any, 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" / %} - """ - - TestComponent.render( - args=(123, "str"), - kwargs={"variable": "test", "another": 1}, - slots={ - "my_slot": "MY_SLOT", - "my_slot2": lambda ctx, data, ref: "abc", - }, - ) - - @djc_test( - components_settings={"extensions": [PydanticExtension]}, - ) - def test_invalid_args(self): - TestArgs = Tuple[int, str, int] + def test_args(self): + class TestComponent(Component): + class Args(ArgsBaseModel): + var1: int + var2: str + var3: int - class TestComponent(Component[TestArgs, Any, Any, Any, Any, Any]): - def get_context_data(self, var1, var2, variable, another, **attrs): + def get_template_data(self, args: Args, kwargs, slots, context): return { - "variable": variable, - "invalid_key": var1, + "variable": args.var3, + "invalid_key": args.var1, } template: types.django_html = """ @@ -88,64 +65,41 @@ def get_context_data(self, var1, var2, variable, another, **attrs): Slot 2: {% slot "my_slot2" / %} """ - # TODO: Set more specific error message - # with pytest.raises( - # ValidationError, - # match=re.escape("Positional arguments of component 'TestComponent' failed validation"), - # ): - with pytest.raises(Exception): + # Invalid args + with pytest.raises(ValidationError, match="1 validation error for Args\nvar3"): TestComponent.render( - args=(123, "str"), # type: ignore + args=[123, "str"], kwargs={"variable": "test", "another": 1}, slots={ "my_slot": "MY_SLOT", - "my_slot2": lambda ctx, data, ref: "abc", + "my_slot2": lambda ctx: "abc", }, ) - @djc_test( - components_settings={"extensions": [PydanticExtension]}, - ) - def test_valid_args(self): - TestArgs = Tuple[int, str, int] - - class TestComponent(Component[TestArgs, Any, Any, Any, Any, Any]): - def get_context_data(self, var1, var2, var3, 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" / %} - """ - + # Valid args TestComponent.render( args=(123, "str", 456), kwargs={"variable": "test", "another": 1}, slots={ "my_slot": "MY_SLOT", - "my_slot2": lambda ctx, data, ref: "abc", + "my_slot2": lambda ctx: "abc", }, ) @djc_test( components_settings={"extensions": [PydanticExtension]}, ) - def test_invalid_kwargs(self): - class TestKwargs(TypedDict): - var1: int - var2: str - var3: int - - class TestComponent(Component[Any, TestKwargs, Any, Any, Any, Any]): - def get_context_data(self, var1, var2, variable, another, **attrs): + def test_kwargs(self): + class TestComponent(Component): + class Kwargs(BaseModel): + var1: int + var2: str + var3: int + + def get_template_data(self, args, kwargs: Kwargs, slots, context): return { - "variable": variable, - "invalid_key": var1, + "variable": kwargs.var3, + "invalid_key": kwargs.var1, } template: types.django_html = """ @@ -155,66 +109,40 @@ def get_context_data(self, var1, var2, variable, another, **attrs): Slot 2: {% slot "my_slot2" / %} """ - # TODO: Set more specific error message - # with pytest.raises( - # ValidationError, - # match=re.escape("Keyword arguments of component 'TestComponent' failed validation"), - # ): - with pytest.raises(Exception): + # Invalid kwargs + with pytest.raises(ValidationError, match="3 validation errors for Kwargs"): TestComponent.render( args=(123, "str"), - kwargs={"variable": "test", "another": 1}, # type: ignore + kwargs={"variable": "test", "another": 1}, slots={ "my_slot": "MY_SLOT", - "my_slot2": lambda ctx, data, ref: "abc", + "my_slot2": lambda ctx: "abc", }, ) - @djc_test( - components_settings={"extensions": [PydanticExtension]}, - ) - def test_valid_kwargs(self): - class TestKwargs(TypedDict): - var1: int - var2: str - var3: int - - class TestComponent(Component[Any, TestKwargs, Any, Any, Any, Any]): - def get_context_data(self, a, b, c, var1, var2, var3, **attrs): - return { - "variable": var1, - "invalid_key": var2, - } - - template: types.django_html = """ - {% load component_tags %} - Variable: {{ variable }} - Slot 1: {% slot "my_slot" / %} - Slot 2: {% slot "my_slot2" / %} - """ - + # Valid kwargs TestComponent.render( - args=(123, "str", 456), + args=(123, "str"), kwargs={"var1": 1, "var2": "str", "var3": 456}, slots={ "my_slot": "MY_SLOT", - "my_slot2": lambda ctx, data, ref: "abc", + "my_slot2": lambda ctx: "abc", }, ) @djc_test( components_settings={"extensions": [PydanticExtension]}, ) - def test_invalid_slots(self): - class TestSlots(TypedDict): - slot1: SlotContent - slot2: SlotContent + def test_slots(self): + class TestComponent(Component): + class Slots(BaseModel): + slot1: SlotInput + slot2: SlotInput - class TestComponent(Component[Any, Any, TestSlots, Any, Any, Any]): - def get_context_data(self, var1, var2, variable, another, **attrs): + def get_template_data(self, args, kwargs, slots, context): return { - "variable": variable, - "invalid_key": var1, + "variable": kwargs["var1"], + "invalid_key": args[0], } template: types.django_html = """ @@ -224,65 +152,43 @@ def get_context_data(self, var1, var2, variable, another, **attrs): Slot 2: {% slot "slot2" / %} """ - # TODO: Set more specific error message - # with pytest.raises( - # ValidationError, - # match=re.escape("Slots of component 'TestComponent' failed validation"), - # ): - with pytest.raises(Exception): + # Invalid slots + with pytest.raises( + ValidationError, + match=re.escape("2 validation errors for Slots\nslot1"), + ): TestComponent.render( args=(123, "str"), - kwargs={"variable": "test", "another": 1}, + kwargs={"var1": 1, "var2": "str"}, slots={ "my_slot": "MY_SLOT", - "my_slot2": lambda ctx, data, ref: "abc", - }, # type: ignore + "my_slot2": lambda ctx: "abc", + }, ) - @djc_test( - components_settings={"extensions": [PydanticExtension]}, - ) - def test_valid_slots(self): - class TestSlots(TypedDict): - slot1: SlotContent - slot2: SlotContent - - class TestComponent(Component[Any, Any, TestSlots, Any, Any, Any]): - def get_context_data(self, a, b, c, var1, var2, var3, **attrs): - return { - "variable": var1, - "invalid_key": var2, - } - - template: types.django_html = """ - {% load component_tags %} - Variable: {{ variable }} - Slot 1: {% slot "slot1" / %} - Slot 2: {% slot "slot2" / %} - """ - + # Valid slots TestComponent.render( args=(123, "str", 456), kwargs={"var1": 1, "var2": "str", "var3": 456}, slots={ "slot1": "SLOT1", - "slot2": lambda ctx, data, ref: "abc", + "slot2": lambda ctx: "abc", }, ) @djc_test( components_settings={"extensions": [PydanticExtension]}, ) - def test_invalid_data(self): - class TestData(TypedDict): - data1: int - data2: str + def test_data__invalid(self): + class TestComponent(Component): + class TemplateData(BaseModel): + data1: int + data2: str - class TestComponent(Component[Any, Any, Any, TestData, Any, Any]): - def get_context_data(self, var1, var2, variable, another, **attrs): + def get_template_data(self, args, kwargs, slots, context): return { - "variable": variable, - "invalid_key": var1, + "variable": kwargs["variable"], + "invalid_key": args[0], } template: types.django_html = """ @@ -292,12 +198,10 @@ def get_context_data(self, var1, var2, variable, another, **attrs): Slot 2: {% slot "slot2" / %} """ - # TODO: Set more specific error message - # with pytest.raises( - # ValidationError, - # match=re.escape("Data of component 'TestComponent' failed validation"), - # ): - with pytest.raises(Exception): + with pytest.raises( + ValidationError, + match=re.escape("2 validation errors for TemplateData\ndata1"), + ): TestComponent.render( args=(123, "str"), kwargs={"variable": "test", "another": 1}, @@ -310,16 +214,16 @@ def get_context_data(self, var1, var2, variable, another, **attrs): @djc_test( components_settings={"extensions": [PydanticExtension]}, ) - def test_valid_data(self): - class TestData(TypedDict): - data1: int - data2: str + def test_data__valid(self): + class TestComponent(Component): + class TemplateData(BaseModel): + data1: int + data2: str - class TestComponent(Component[Any, Any, Any, TestData, Any, Any]): - def get_context_data(self, a, b, c, var1, var2, **attrs): + def get_template_data(self, args, kwargs, slots, context): return { - "data1": var1, - "data2": var2, + "data1": kwargs["var1"], + "data2": kwargs["var2"], } template: types.django_html = """ @@ -335,7 +239,7 @@ def get_context_data(self, a, b, c, var1, var2, **attrs): kwargs={"var1": 1, "var2": "str", "var3": 456}, slots={ "slot1": "SLOT1", - "slot2": lambda ctx, data, ref: "abc", + "slot2": lambda ctx: "abc", }, ) @@ -343,26 +247,29 @@ def get_context_data(self, a, b, c, var1, var2, **attrs): components_settings={"extensions": [PydanticExtension]}, ) def test_validate_all(self): - TestArgs = Tuple[int, str, int] + class TestComponent(Component): + class Args(ArgsBaseModel): + var1: int + var2: str + var3: int - class TestKwargs(TypedDict): - var1: int - var2: str - var3: int + class Kwargs(BaseModel): + var1: int + var2: str + var3: int - class TestSlots(TypedDict): - slot1: SlotContent - slot2: SlotContent + class Slots(BaseModel): + slot1: SlotInput + slot2: SlotInput - class TestData(TypedDict): - data1: int - data2: str + class TemplateData(BaseModel): + data1: int + data2: str - class TestComponent(Component[TestArgs, TestKwargs, TestSlots, TestData, Any, Any]): - def get_context_data(self, a, b, c, var1, var2, **attrs): + def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context): return { - "data1": var1, - "data2": var2, + "data1": kwargs.var1, + "data2": kwargs.var2, } template: types.django_html = """ @@ -378,7 +285,7 @@ def get_context_data(self, a, b, c, var1, var2, **attrs): kwargs={"var1": 1, "var2": "str", "var3": 456}, slots={ "slot1": "SLOT1", - "slot2": lambda ctx, data, ref: "abc", + "slot2": lambda ctx: "abc", }, ) @@ -386,23 +293,27 @@ def get_context_data(self, a, b, c, var1, var2, **attrs): components_settings={"extensions": [PydanticExtension]}, ) def test_handles_nested_types(self): - class NestedDict(TypedDict): + class NestedDict(BaseModel): nested: int NestedTuple = Tuple[int, str, int] NestedNested = Tuple[NestedDict, NestedTuple, int] - TestArgs = Tuple[NestedDict, NestedTuple, NestedNested] - class TestKwargs(TypedDict): - var1: NestedDict - var2: NestedTuple - var3: NestedNested + class TestComponent(Component): + class Args(ArgsBaseModel): + a: NestedDict + b: NestedTuple + c: NestedNested + + class Kwargs(BaseModel): + var1: NestedDict + var2: NestedTuple + var3: NestedNested - class TestComponent(Component[TestArgs, TestKwargs, Any, Any, Any, Any]): - def get_context_data(self, a, b, c, var1, var2, **attrs): + def get_template_data(self, args: Args, kwargs: Kwargs, slots, context): return { - "data1": var1, - "data2": var2, + "data1": kwargs.var1, + "data2": kwargs.var2, } template: types.django_html = """ @@ -413,18 +324,16 @@ def get_context_data(self, a, b, c, var1, var2, **attrs): Slot 2: {% slot "slot2" / %} """ - # TODO: Set more specific error message - # with pytest.raises( - # ValidationError, - # match=re.escape("Positional arguments of component 'TestComponent' failed validation"), - # ): - with pytest.raises(Exception): + with pytest.raises( + ValidationError, + match=re.escape("3 validation errors for Args\na"), + ): TestComponent.render( - args=(123, "str", 456), # type: ignore - kwargs={"var1": 1, "var2": "str", "var3": 456}, # type: ignore + args=(123, "str", 456), + kwargs={"var1": 1, "var2": "str", "var3": 456}, slots={ "slot1": "SLOT1", - "slot2": lambda ctx, data, ref: "abc", + "slot2": lambda ctx: "abc", }, ) @@ -433,7 +342,7 @@ def get_context_data(self, a, b, c, var1, var2, **attrs): kwargs={"var1": {"nested": 1}, "var2": (1, "str", 456), "var3": ({"nested": 1}, (1, "str", 456), 456)}, slots={ "slot1": "SLOT1", - "slot2": lambda ctx, data, ref: "abc", + "slot2": lambda ctx: "abc", }, ) @@ -441,15 +350,16 @@ def get_context_data(self, a, b, c, var1, var2, **attrs): components_settings={"extensions": [PydanticExtension]}, ) def test_handles_component_types(self): - TestArgs = Tuple[Type[Component]] + class TestComponent(Component): + class Args(ArgsBaseModel): + a: Type[Component] - class TestKwargs(TypedDict): - component: Type[Component] + class Kwargs(BaseModel): + component: Type[Component] - class TestComponent(Component[TestArgs, TestKwargs, Any, Any, Any, Any]): - def get_context_data(self, a, component, **attrs): + def get_template_data(self, args: Args, kwargs: Kwargs, slots, context): return { - "component": component, + "component": kwargs.component, } template: types.django_html = """ @@ -457,15 +367,13 @@ def get_context_data(self, a, component, **attrs): Component: {{ component }} """ - # TODO: Set more specific error message - # with pytest.raises( - # ValidationError, - # match=re.escape("Positional arguments of component 'TestComponent' failed validation"), - # ): - with pytest.raises(Exception): + with pytest.raises( + ValidationError, + match=re.escape("1 validation error for Args\na"), + ): TestComponent.render( - args=[123], # type: ignore - kwargs={"component": 1}, # type: ignore + args=[123], + kwargs={"component": 1}, ) TestComponent.render( @@ -474,32 +382,27 @@ def get_context_data(self, a, component, **attrs): ) 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] - - class TestComponent(TodoComp): - def get_context_data(self, *args, **kwargs): - return { - **kwargs, - } + class TestComponent(Component): + class Args(ArgsBaseModel): + a: Union[str, int] + b: Dict[str, int] + c: List[str] + d: Tuple[int, Union[str, int]] + + class Kwargs(BaseModel): + one: Union[str, int] + two: Dict[str, int] + three: List[str] + four: Tuple[int, Union[str, int]] + + class TemplateData(BaseModel): + one: Union[str, int] + two: Dict[str, int] + three: List[str] + four: Tuple[int, Union[str, int]] + + def get_template_data(self, args: Args, kwargs: Kwargs, slots, context): + return kwargs template = "" diff --git a/tests/testutils.py b/tests/testutils.py index ce600eb..91a6678 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -30,7 +30,6 @@ def setup_test_config( } ], "COMPONENTS": { - "template_cache_size": 128, "autodiscover": False, **(components or {}), }, diff --git a/tox.ini b/tox.ini index f2dd0be..43f591b 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,8 @@ deps = django42: Django>=4.2,<4.3 django50: Django>=5.0,<5.1 django51: Django>=5.1,<5.2 - django-components>=0.136 + django52: Django>=5.2,<5.3 + django-components>=0.140 pytest pytest-xdist pytest-django