Skip to content

refactor: migrate to django-components v0.140 #22

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Release notes

## v1.1.0

### Refactor

- Pin django-components to v0.140.

## v1.0.0 - First release

### Feat
Expand Down
108 changes: 48 additions & 60 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,),
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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"}
Expand Down
2 changes: 1 addition & 1 deletion requirements-ci.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/djc_pydantic/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
147 changes: 68 additions & 79 deletions src/djc_pydantic/extension.py
Original file line number Diff line number Diff line change
@@ -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)
Loading