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