Skip to content

Commit 1ff10da

Browse files
committed
refactor: migrate to django-components v0.140
1 parent d9a3b07 commit 1ff10da

File tree

14 files changed

+339
-505
lines changed

14 files changed

+339
-505
lines changed

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ on:
1010

1111
jobs:
1212
build:
13-
runs-on: ubuntu-20.04
13+
runs-on: ubuntu-latest
1414
strategy:
1515
matrix:
1616
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13']

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Release notes
22

3+
## v1.1.0
4+
5+
### Refactor
6+
7+
- Pin django-components to v0.140.
8+
39
## v1.0.0 - First release
410

511
### Feat

README.md

Lines changed: 48 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -4,60 +4,43 @@
44

55
Validate components' inputs and outputs using Pydantic.
66

7-
`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.
7+
`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.
88

9-
### Validated Inputs and Outputs
9+
### Example Usage
1010

11-
- **Inputs:**
11+
```python
12+
from django_components import Component, SlotInput
13+
from djc_pydantic import ArgsBaseModel
14+
from pydantic import BaseModel
1215

13-
- `args`: Positional arguments, expected to be defined as a [`Tuple`](https://docs.python.org/3/library/typing.html#typing.Tuple) type.
14-
- `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).
15-
- `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).
16+
# 1. Define the Component with Pydantic models
17+
class MyComponent(Component):
18+
class Args(ArgsBaseModel):
19+
var1: str
1620

17-
- **Outputs:**
18-
- 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).
21+
class Kwargs(BaseModel):
22+
name: str
23+
age: int
1924

20-
### Example Usage
25+
class Slots(BaseModel):
26+
header: SlotInput
27+
footer: SlotInput
28+
29+
class TemplateData(BaseModel):
30+
data1: str
31+
data2: int
32+
33+
class JsData(BaseModel):
34+
js_data1: str
35+
js_data2: int
36+
37+
class CssData(BaseModel):
38+
css_data1: str
39+
css_data2: int
2140

22-
```python
23-
from pydantic import BaseModel
24-
from typing import Tuple, TypedDict
25-
26-
# 1. Define the types
27-
MyCompArgs = Tuple[str, ...]
28-
29-
class MyCompKwargs(TypedDict):
30-
name: str
31-
age: int
32-
33-
class MyCompSlots(TypedDict):
34-
header: SlotContent
35-
footer: SlotContent
36-
37-
class MyCompData(BaseModel):
38-
data1: str
39-
data2: int
40-
41-
class MyCompJsData(BaseModel):
42-
js_data1: str
43-
js_data2: int
44-
45-
class MyCompCssData(BaseModel):
46-
css_data1: str
47-
css_data2: int
48-
49-
# 2. Define the component with those types
50-
class MyComponent(Component[
51-
MyCompArgs,
52-
MyCompKwargs,
53-
MyCompSlots,
54-
MyCompData,
55-
MyCompJsData,
56-
MyCompCssData,
57-
]):
5841
...
5942

60-
# 3. Render the component
43+
# 2. Render the component
6144
MyComponent.render(
6245
# ERROR: Expects a string
6346
args=(123,),
@@ -74,20 +57,6 @@ MyComponent.render(
7457
)
7558
```
7659

77-
If you don't want to validate some parts, set them to [`Any`](https://docs.python.org/3/library/typing.html#typing.Any).
78-
79-
```python
80-
class MyComponent(Component[
81-
MyCompArgs,
82-
MyCompKwargs,
83-
MyCompSlots,
84-
Any,
85-
Any,
86-
Any,
87-
]):
88-
...
89-
```
90-
9160
## Installation
9261

9362
```bash
@@ -118,6 +87,25 @@ COMPONENTS = {
11887
}
11988
```
12089

90+
## Validating args
91+
92+
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`:
93+
94+
```python
95+
from pydantic import BaseModel
96+
from djc_pydantic import ArgsBaseModel
97+
98+
class MyTable(Component):
99+
class Args(ArgsBaseModel):
100+
a: int
101+
b: str
102+
c: float
103+
104+
MyTable.render(
105+
args=[1, "hello", 3.14],
106+
)
107+
```
108+
121109
## Release notes
122110

123111
Read the [Release Notes](https://github.com/django-components/djc-ext-pydantic/tree/main/CHANGELOG.md)

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "djc-ext-pydantic"
7-
version = "1.0.0"
7+
version = "1.1.0"
88
requires-python = ">=3.8, <4.0"
99
description = "Input validation with Pydantic for Django Components"
1010
keywords = ["pydantic", "django-components", "djc", "django", "components"]
@@ -33,7 +33,7 @@ classifiers = [
3333
"License :: OSI Approved :: MIT License",
3434
]
3535
dependencies = [
36-
'django-components>=0.136',
36+
'django-components>=0.140',
3737
'pydantic>=2.9',
3838
]
3939
license = {text = "MIT"}

requirements-ci.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ django==4.2.20
2020
# via
2121
# -r requirements-ci.in
2222
# django-components
23-
django-components==0.136
23+
django-components==0.140
2424
# via -r requirements-ci.in
2525
djc-core-html-parser==1.0.2
2626
# via django-components

requirements-dev.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ django==4.2.20
2626
# via
2727
# -r requirements-dev.in
2828
# django-components
29-
django-components==0.136
29+
django-components==0.140
3030
# via -r requirements-dev.in
3131
djc-core-html-parser==1.0.2
3232
# via django-components

src/djc_pydantic/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from djc_pydantic.extension import PydanticExtension
22
from djc_pydantic.monkeypatch import monkeypatch_pydantic_core_schema
3+
from djc_pydantic.utils import ArgsBaseModel
34

45
monkeypatch_pydantic_core_schema()
56

67
__all__ = [
8+
"ArgsBaseModel",
79
"PydanticExtension",
810
]

src/djc_pydantic/extension.py

Lines changed: 68 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,95 @@
1-
from django_components import ComponentExtension
2-
from django_components.extension import (
3-
OnComponentDataContext,
4-
OnComponentInputContext,
5-
)
1+
from typing import Any, Optional, Set
62

7-
from djc_pydantic.validation import get_component_typing, validate_type
3+
from django_components import ComponentExtension, ComponentNode, OnComponentInputContext # noqa: F401
4+
from pydantic import BaseModel
85

96

107
class PydanticExtension(ComponentExtension):
118
"""
129
A Django component extension that integrates Pydantic for input and data validation.
1310
14-
This extension uses the types defined on the component's class to validate the inputs
15-
and outputs of Django components.
16-
17-
The following are validated:
18-
19-
- Inputs:
20-
21-
- `args`
22-
- `kwargs`
23-
- `slots`
24-
25-
- Outputs (data returned from):
26-
27-
- `get_context_data()`
28-
- `get_js_data()`
29-
- `get_css_data()`
30-
31-
Validation is done using Pydantic's `TypeAdapter`. As such, the following are expected:
32-
33-
- Positional arguments (`args`) should be defined as a `Tuple` type.
34-
- Other data (`kwargs`, `slots`, ...) are all objects or dictionaries, and can be defined
35-
using either `TypedDict` or Pydantic's `BaseModel`.
11+
NOTE: As of v0.140 the extension only ensures that the classes from django-components
12+
can be used with pydantic. For the actual validation, subclass `Kwargs`, `Slots`, etc
13+
from Pydantic's `BaseModel`, and use `ArgsBaseModel` for `Args`.
3614
3715
**Example:**
3816
3917
```python
40-
MyCompArgs = Tuple[str, ...]
41-
42-
class MyCompKwargs(TypedDict):
43-
name: str
44-
age: int
18+
from django_components import Component, SlotInput
19+
from pydantic import BaseModel
4520
46-
class MyCompSlots(TypedDict):
47-
header: SlotContent
48-
footer: SlotContent
21+
class MyComponent(Component):
22+
class Args(ArgsBaseModel):
23+
var1: str
4924
50-
class MyCompData(BaseModel):
51-
data1: str
52-
data2: int
25+
class Kwargs(BaseModel):
26+
name: str
27+
age: int
5328
54-
class MyCompJsData(BaseModel):
55-
js_data1: str
56-
js_data2: int
29+
class Slots(BaseModel):
30+
header: SlotInput
31+
footer: SlotInput
5732
58-
class MyCompCssData(BaseModel):
59-
css_data1: str
60-
css_data2: int
33+
class TemplateData(BaseModel):
34+
data1: str
35+
data2: int
6136
62-
class MyComponent(Component[MyCompArgs, MyCompKwargs, MyCompSlots, MyCompData, MyCompJsData, MyCompCssData]):
63-
...
64-
```
37+
class JsData(BaseModel):
38+
js_data1: str
39+
js_data2: int
6540
66-
To exclude a field from validation, set its type to `Any`.
41+
class CssData(BaseModel):
42+
css_data1: str
43+
css_data2: int
6744
68-
```python
69-
class MyComponent(Component[MyCompArgs, MyCompKwargs, MyCompSlots, Any, Any, Any]):
7045
...
7146
```
7247
"""
7348

7449
name = "pydantic"
7550

76-
# Validate inputs to the component on `Component.render()`
51+
def __init__(self, *args: Any, **kwargs: Any):
52+
super().__init__(*args, **kwargs)
53+
self.rebuilt_comp_cls_ids: Set[str] = set()
54+
55+
# If the Pydantic models reference other types with forward references,
56+
# the models may not be complete / "built" when the component is first loaded.
57+
#
58+
# Component classes may be created when other modules are still being imported,
59+
# so we have to wait until we start rendering components to ensure that everything
60+
# is loaded.
61+
#
62+
# At that point, we check for components whether they have any Pydantic models,
63+
# and whether those models need to be rebuilt.
64+
#
65+
# See https://errors.pydantic.dev/2.11/u/class-not-fully-defined
66+
#
67+
# Otherwise, we get an error like:
68+
#
69+
# ```
70+
# pydantic.errors.PydanticUserError: `Slots` is not fully defined; you should define
71+
# `ComponentNode`, then call `Slots.model_rebuild()`
72+
# ```
7773
def on_component_input(self, ctx: OnComponentInputContext) -> None:
78-
maybe_inputs = get_component_typing(ctx.component_cls)
79-
if maybe_inputs is None:
80-
return
81-
82-
args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs
83-
comp_name = ctx.component_cls.__name__
84-
85-
# Validate args
86-
validate_type(ctx.args, args_type, f"Positional arguments of component '{comp_name}' failed validation")
87-
# Validate kwargs
88-
validate_type(ctx.kwargs, kwargs_type, f"Keyword arguments of component '{comp_name}' failed validation")
89-
# Validate slots
90-
validate_type(ctx.slots, slots_type, f"Slots of component '{comp_name}' failed validation")
91-
92-
# Validate the data generated from `get_context_data()`, `get_js_data()` and `get_css_data()`
93-
def on_component_data(self, ctx: OnComponentDataContext) -> None:
94-
maybe_inputs = get_component_typing(ctx.component_cls)
95-
if maybe_inputs is None:
74+
if ctx.component.class_id in self.rebuilt_comp_cls_ids:
9675
return
9776

98-
args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs
99-
comp_name = ctx.component_cls.__name__
100-
101-
# Validate data
102-
validate_type(ctx.context_data, data_type, f"Data of component '{comp_name}' failed validation")
103-
# Validate JS data
104-
validate_type(ctx.js_data, js_data_type, f"JS data of component '{comp_name}' failed validation")
105-
# Validate CSS data
106-
validate_type(ctx.css_data, css_data_type, f"CSS data of component '{comp_name}' failed validation")
77+
for name in ["Args", "Kwargs", "Slots", "TemplateData", "JsData", "CssData"]:
78+
cls: Optional[BaseModel] = getattr(ctx.component, name, None)
79+
if cls is None:
80+
continue
81+
82+
if hasattr(cls, "__pydantic_complete__") and not cls.__pydantic_complete__:
83+
# When resolving forward references, Pydantic needs the module globals,
84+
# AKA a dict that resolves those forward references.
85+
#
86+
# There's 2 problems here - user may define their own types which may need
87+
# resolving. And we define the Slot type which needs to access `ComponentNode`.
88+
#
89+
# So as a solution, we provide to Pydantic a dictionary that contains globals
90+
# from both 1. the component file and 2. this file (where we import `ComponentNode`).
91+
mod = __import__(cls.__module__)
92+
module_globals = mod.__dict__
93+
cls.model_rebuild(_types_namespace={**globals(), **module_globals})
94+
95+
self.rebuilt_comp_cls_ids.add(ctx.component.class_id)

0 commit comments

Comments
 (0)