diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..aff066e
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,17 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
+
+version: 2
+updates:
+ - package-ecosystem: "pip" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
+ - package-ecosystem: github-actions
+ # This actually targets ./.github/workflows/
+ # See https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#directories-or-directory--
+ directory: "/"
+ schedule:
+ interval: monthly
diff --git a/.github/workflows/automate-dependabot.yml b/.github/workflows/automate-dependabot.yml
new file mode 100644
index 0000000..5b0e03c
--- /dev/null
+++ b/.github/workflows/automate-dependabot.yml
@@ -0,0 +1,22 @@
+name: Dependabot auto-merge
+on: pull_request
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ dependabot:
+ runs-on: ubuntu-latest
+ if: github.actor == 'dependabot[bot]'
+ steps:
+ - name: Dependabot metadata
+ id: metadata
+ uses: dependabot/fetch-metadata@v2
+ with:
+ github-token: "${{ secrets.GITHUB_TOKEN }}"
+ - name: Enable auto-merge for Dependabot PRs
+ run: gh pr merge --auto --merge "$PR_URL"
+ env:
+ PR_URL: ${{ github.event.pull_request.html_url }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml
new file mode 100644
index 0000000..c8d2083
--- /dev/null
+++ b/.github/workflows/publish-to-pypi.yml
@@ -0,0 +1,45 @@
+name: Publish to PyPI
+
+on:
+ push:
+ tags:
+ - '*'
+
+ # Allows you to run this workflow manually from the Actions tab
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ if: github.repository == 'django-components/djc-ext-pydantic'
+ steps:
+ - name: Checkout the repo
+ uses: actions/checkout@v2
+
+ - name: Setup python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.9'
+
+ - name: Install pypa/build
+ run: >-
+ python -m
+ pip install
+ build
+ --user
+
+ - name: Build a binary wheel and a source tarball
+ run: >-
+ python -m
+ build
+ --sdist
+ --wheel
+ --outdir dist/
+ .
+
+ - name: Publish distribution to PyPI
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ user: __token__
+ password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..4fd3ace
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,31 @@
+name: Run tests
+
+on:
+ push:
+ branches:
+ - 'main'
+ - 'dev'
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-20.04
+ strategy:
+ matrix:
+ python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13']
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: "pip"
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install -r requirements-ci.txt
+ - name: Run tests
+ run: tox
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1d20d10
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,80 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+*.sqlite3
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# VSCode
+.vscode
+
+# Poetry
+# lock file is not needed for development
+# as project supports variety of Django versions
+poetry.lock
+
+# PyCharm
+.idea/
+
+# Python environment
+.venv/
+.DS_Store
+.python-version
+site
+
+# JS, NPM Dependency directories
+node_modules/
+jspm_packages/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..1854167
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,14 @@
+repos:
+ - repo: https://github.com/pycqa/isort
+ rev: 5.13.2
+ hooks:
+ - id: isort
+ - repo: https://github.com/psf/black
+ rev: 24.10.0
+ hooks:
+ - id: black
+ - repo: https://github.com/pycqa/flake8
+ rev: 7.1.1
+ hooks:
+ - id: flake8
+ additional_dependencies: [flake8-pyproject]
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..48db4c3
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,7 @@
+# Release notes
+
+## v1.0.0 - First release
+
+### Feat
+
+- Validate the inputs and outputs of Django components using Pydantic.
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..a83376d
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,76 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, sex characteristics, gender identity and expression,
+level of experience, education, socio-economic status, nationality, personal
+appearance, race, religion, or sexual identity and orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+ advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+ address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the project team at juraj.oravec.josefson@gmail.com. All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see
+https://www.contributor-covenant.org/faq
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c0ebbd8
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Juro Oravec
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..84ae40a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,134 @@
+# djc-ext-pydantic
+
+[](https://pypi.org/project/djc-ext-pydantic/) [](https://pypi.org/project/djc-ext-pydantic/) [](https://github.com/django-components/djc-ext-pydantic/blob/main/LICENSE) [](https://pypistats.org/packages/djc-ext-pydantic) [](https://github.com/django-components/djc-ext-pydantic/actions/workflows/tests.yml)
+
+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.
+
+### Validated Inputs and Outputs
+
+- **Inputs:**
+
+ - `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).
+
+- **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).
+
+### Example Usage
+
+```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
+MyComponent.render(
+ # ERROR: Expects a string
+ args=(123,),
+ kwargs={
+ "name": "John",
+ # ERROR: Expects an integer
+ "age": "invalid",
+ },
+ slots={
+ "header": "...",
+ # ERROR: Expects key "footer"
+ "foo": "invalid",
+ },
+)
+```
+
+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
+pip install djc-ext-pydantic
+```
+
+Then add the extension to your project:
+
+```python
+# settings.py
+COMPONENTS = {
+ "extensions": [
+ "djc_ext_pydantic.PydanticExtension",
+ ],
+}
+```
+
+or by reference:
+
+```python
+# settings.py
+from djc_ext_pydantic import PydanticExtension
+
+COMPONENTS = {
+ "extensions": [
+ PydanticExtension,
+ ],
+}
+```
+
+## Release notes
+
+Read the [Release Notes](https://github.com/django-components/djc-ext-pydantic/tree/main/CHANGELOG.md)
+to see the latest features and fixes.
+
+## Development
+
+### Tests
+
+To run tests, use:
+
+```bash
+pytest
+```
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..2f741a4
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,103 @@
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "djc-ext-pydantic"
+version = "0.1.0"
+requires-python = ">=3.8, <4.0"
+description = "Input validation with Pydantic for Django Components"
+keywords = ["pydantic", "django-components", "djc", "django", "components"]
+readme = "README.md"
+authors = [
+ {name = "Juro Oravec", email = "juraj.oravec.josefson@gmail.com"},
+]
+classifiers = [
+ "Framework :: Django",
+ "Framework :: Django :: 4.2",
+ "Framework :: Django :: 5.0",
+ "Framework :: Django :: 5.1",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Environment :: Web Environment",
+ "Topic :: Internet :: WWW/HTTP",
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+]
+dependencies = [
+ 'django-components>=0.131',
+ 'pydantic>=2.9',
+]
+license = {text = "MIT"}
+
+[project.urls]
+Homepage = "https://github.com/django-components/djc-ext-pydantic/"
+
+
+[tool.setuptools.packages.find]
+where = ["src"]
+include = ["djc_ext_pydantic*"]
+exclude = ["djc_ext_pydantic.tests*"]
+namespaces = false
+
+[tool.black]
+line-length = 119
+include = '\.pyi?$'
+exclude = '''
+/(
+ \.git
+ | \.hg
+ | \.mypy_cache
+ | \.tox
+ | \.venv
+ | activate
+ | _build
+ | buck-out
+ | build
+ | dist
+)/
+'''
+
+[tool.isort]
+profile = "black"
+line_length = 119
+multi_line_output = 3
+include_trailing_comma = "True"
+known_first_party = "djc_ext_pydantic"
+
+[tool.flake8]
+ignore = ['E302', 'W503']
+max-line-length = 119
+exclude = [
+ '__pycache__',
+ 'env',
+ '.env',
+ '.venv',
+ '.tox',
+ 'build',
+]
+
+[tool.mypy]
+check_untyped_defs = true
+ignore_missing_imports = true
+exclude = [
+ 'build',
+]
+
+[[tool.mypy.overrides]]
+module = "djc_ext_pydantic.*"
+disallow_untyped_defs = true
+
+
+[tool.pytest.ini_options]
+testpaths = [
+ "tests"
+]
diff --git a/requirements-ci.in b/requirements-ci.in
new file mode 100644
index 0000000..c11cfbd
--- /dev/null
+++ b/requirements-ci.in
@@ -0,0 +1,6 @@
+tox
+tox-gh-actions
+django
+django-components
+pydantic
+pytest
diff --git a/requirements-ci.txt b/requirements-ci.txt
new file mode 100644
index 0000000..5c45a34
--- /dev/null
+++ b/requirements-ci.txt
@@ -0,0 +1,67 @@
+#
+# This file is autogenerated by pip-compile with Python 3.11
+# by the following command:
+#
+# pip-compile requirements-ci.in
+#
+annotated-types==0.7.0
+ # via pydantic
+asgiref==3.8.1
+ # via django
+cachetools==5.5.2
+ # via tox
+chardet==5.2.0
+ # via tox
+colorama==0.4.6
+ # via tox
+distlib==0.3.9
+ # via virtualenv
+django==4.2.20
+ # via
+ # -r requirements-ci.in
+ # django-components
+django-components==0.135
+ # via -r requirements-ci.in
+djc-core-html-parser==1.0.2
+ # via django-components
+filelock==3.16.1
+ # via
+ # tox
+ # virtualenv
+iniconfig==2.1.0
+ # via pytest
+packaging==24.2
+ # via
+ # pyproject-api
+ # pytest
+ # tox
+platformdirs==4.3.6
+ # via
+ # tox
+ # virtualenv
+pluggy==1.5.0
+ # via
+ # pytest
+ # tox
+pydantic==2.10.6
+ # via -r requirements-ci.in
+pydantic-core==2.27.2
+ # via pydantic
+pyproject-api==1.8.0
+ # via tox
+pytest==8.3.5
+ # via -r requirements-ci.in
+sqlparse==0.5.3
+ # via django
+tox==4.24.2
+ # via
+ # -r requirements-ci.in
+ # tox-gh-actions
+tox-gh-actions==3.3.0
+ # via -r requirements-ci.in
+typing-extensions==4.12.2
+ # via
+ # pydantic
+ # pydantic-core
+virtualenv==20.29.3
+ # via tox
diff --git a/requirements-dev.in b/requirements-dev.in
new file mode 100644
index 0000000..7574f09
--- /dev/null
+++ b/requirements-dev.in
@@ -0,0 +1,11 @@
+tox
+pytest
+flake8
+flake8-pyproject
+isort
+pre-commit
+black
+mypy
+django
+django-components
+pydantic
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..21f9dcc
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,117 @@
+#
+# This file is autogenerated by pip-compile with Python 3.11
+# by the following command:
+#
+# pip-compile requirements-dev.in
+#
+annotated-types==0.7.0
+ # via pydantic
+asgiref==3.8.1
+ # via django
+black==25.1.0
+ # via -r requirements-dev.in
+cachetools==5.5.2
+ # via tox
+cfgv==3.4.0
+ # via pre-commit
+chardet==5.2.0
+ # via tox
+click==8.1.8
+ # via black
+colorama==0.4.6
+ # via tox
+distlib==0.3.9
+ # via virtualenv
+django==4.2.20
+ # via
+ # -r requirements-dev.in
+ # django-components
+django-components==0.135
+ # via -r requirements-dev.in
+djc-core-html-parser==1.0.2
+ # via django-components
+exceptiongroup==1.2.2
+ # via pytest
+filelock==3.16.1
+ # via
+ # tox
+ # virtualenv
+flake8==7.1.2
+ # via
+ # -r requirements-dev.in
+ # flake8-pyproject
+flake8-pyproject==1.2.3
+ # via -r requirements-dev.in
+identify==2.6.9
+ # via pre-commit
+iniconfig==2.0.0
+ # via pytest
+isort==6.0.1
+ # via -r requirements-dev.in
+mccabe==0.7.0
+ # via flake8
+mypy==1.15.0
+ # via -r requirements-dev.in
+mypy-extensions==1.0.0
+ # via
+ # black
+ # mypy
+nodeenv==1.9.1
+ # via pre-commit
+packaging==24.2
+ # via
+ # black
+ # pyproject-api
+ # pytest
+ # tox
+pathspec==0.12.1
+ # via black
+platformdirs==4.3.6
+ # via
+ # black
+ # tox
+ # virtualenv
+pluggy==1.5.0
+ # via
+ # pytest
+ # tox
+pre-commit==4.1.0
+ # via -r requirements-dev.in
+pycodestyle==2.12.1
+ # via flake8
+pydantic==2.10.6
+ # via -r requirements-dev.in
+pydantic-core==2.27.2
+ # via pydantic
+pyflakes==3.2.0
+ # via flake8
+pyproject-api==1.8.0
+ # via tox
+pytest==8.3.5
+ # via -r requirements-dev.in
+pyyaml==6.0.2
+ # via pre-commit
+sqlparse==0.5.3
+ # via django
+tomli==2.2.1
+ # via
+ # black
+ # flake8-pyproject
+ # mypy
+ # pyproject-api
+ # pytest
+ # tox
+tox==4.24.2
+ # via -r requirements-dev.in
+typing-extensions==4.12.2
+ # via
+ # asgiref
+ # black
+ # mypy
+ # pydantic
+ # pydantic-core
+ # tox
+virtualenv==20.29.3
+ # via
+ # pre-commit
+ # tox
diff --git a/src/djc_ext_pydantic/__init__.py b/src/djc_ext_pydantic/__init__.py
new file mode 100644
index 0000000..fc17c2a
--- /dev/null
+++ b/src/djc_ext_pydantic/__init__.py
@@ -0,0 +1,9 @@
+from djc_ext_pydantic.monkeypatch import monkeypatch_pydantic_core_schema
+from djc_ext_pydantic.extension import PydanticExtension
+
+
+monkeypatch_pydantic_core_schema()
+
+__all__ = [
+ "PydanticExtension",
+]
diff --git a/src/djc_ext_pydantic/extension.py b/src/djc_ext_pydantic/extension.py
new file mode 100644
index 0000000..ce03be5
--- /dev/null
+++ b/src/djc_ext_pydantic/extension.py
@@ -0,0 +1,106 @@
+from django_components import ComponentExtension
+from django_components.extension import (
+ OnComponentInputContext,
+ OnComponentDataContext,
+)
+
+from djc_ext_pydantic.validation import get_component_typing, validate_type
+
+
+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`.
+
+ **Example:**
+
+ ```python
+ 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
+
+ class MyComponent(Component[MyCompArgs, MyCompKwargs, MyCompSlots, MyCompData, MyCompJsData, MyCompCssData]):
+ ...
+ ```
+
+ To exclude a field from validation, set its type to `Any`.
+
+ ```python
+ class MyComponent(Component[MyCompArgs, MyCompKwargs, MyCompSlots, Any, Any, Any]):
+ ...
+ ```
+ """
+
+ name = "pydantic"
+
+ # Validate inputs to the component on `Component.render()`
+ 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:
+ 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")
diff --git a/src/djc_ext_pydantic/monkeypatch.py b/src/djc_ext_pydantic/monkeypatch.py
new file mode 100644
index 0000000..d94e1e9
--- /dev/null
+++ b/src/djc_ext_pydantic/monkeypatch.py
@@ -0,0 +1,50 @@
+from typing import Any, Type
+
+from django.template import NodeList
+from django.utils.safestring import SafeString
+from django_components import Component, Slot, SlotFunc
+from pydantic_core import core_schema
+
+
+# For custom classes, Pydantic needs to be told how to handle them.
+# We achieve that by setting the `__get_pydantic_core_schema__` attribute on the classes.
+def monkeypatch_pydantic_core_schema() -> None:
+ # Allow to use Component class inside Pydantic models
+ def component_core_schema(cls: Type[Component], _source_type: Any, _handler: Any) -> Any:
+ return core_schema.json_or_python_schema(
+ # Inside a Python object, the field must be an instance of Component 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(),
+ )
+
+ Component.__get_pydantic_core_schema__ = classmethod(component_core_schema)
+
+ # Allow to use Slot class inside Pydantic models
+ def slot_core_schema(cls: Type[Slot], _source_type: Any, _handler: Any) -> Any:
+ return core_schema.json_or_python_schema(
+ # Inside a Python object, the field must be an instance of Slot 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(),
+ )
+
+ Slot.__get_pydantic_core_schema__ = classmethod(slot_core_schema) # type: ignore[attr-defined]
+
+ # Tell Pydantic to handle SafeString as regular string
+ def safestring_core_schema(*args: Any, **kwargs: Any) -> Any:
+ return core_schema.str_schema()
+
+ SafeString.__get_pydantic_core_schema__ = safestring_core_schema
+
+ # Tell Pydantic to handle SlotFunc as regular function
+ def slotfunc_core_schema(*args: Any, **kwargs: Any) -> Any:
+ return core_schema.callable_schema()
+
+ SlotFunc.__get_pydantic_core_schema__ = slotfunc_core_schema # type: ignore[attr-defined]
+
+ # Tell Pydantic to handle NodeList as regular list
+ def nodelist_core_schema(*args: Any, **kwargs: Any) -> Any:
+ return core_schema.list_schema()
+
+ NodeList.__get_pydantic_core_schema__ = nodelist_core_schema
diff --git a/src/djc_ext_pydantic/validation.py b/src/djc_ext_pydantic/validation.py
new file mode 100644
index 0000000..aa2657e
--- /dev/null
+++ b/src/djc_ext_pydantic/validation.py
@@ -0,0 +1,106 @@
+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/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_validation.py b/tests/test_validation.py
new file mode 100644
index 0000000..2176a03
--- /dev/null
+++ b/tests/test_validation.py
@@ -0,0 +1,514 @@
+from typing import Any, Dict, List, Tuple, Type, Union
+
+import pytest
+from django_components import Component, SlotContent, types
+from django_components.testing import djc_test
+# from pydantic import ValidationError # TODO: Set more specific error message
+from typing_extensions import TypedDict
+
+from djc_ext_pydantic.extension import PydanticExtension
+from tests.testutils import setup_test_config
+
+setup_test_config()
+
+
+@djc_test
+class TestValidation:
+ @djc_test(
+ components_settings={"extensions": [PydanticExtension]},
+ )
+ def test_no_validation_on_no_typing(self):
+ class TestComponent(Component):
+ 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_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]
+
+ class TestComponent(Component[TestArgs, 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" / %}
+ """
+
+ # TODO: Set more specific error message
+ # with pytest.raises(
+ # ValidationError,
+ # match=re.escape("Positional arguments of component 'TestComponent' failed validation"),
+ # ):
+ with pytest.raises(Exception):
+ TestComponent.render(
+ args=(123, "str"), # type: ignore
+ 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_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" / %}
+ """
+
+ TestComponent.render(
+ args=(123, "str", 456),
+ 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_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):
+ 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" / %}
+ """
+
+ # TODO: Set more specific error message
+ # with pytest.raises(
+ # ValidationError,
+ # match=re.escape("Keyword arguments of component 'TestComponent' failed validation"),
+ # ):
+ with pytest.raises(Exception):
+ TestComponent.render(
+ args=(123, "str"),
+ kwargs={"variable": "test", "another": 1}, # type: ignore
+ slots={
+ "my_slot": "MY_SLOT",
+ "my_slot2": lambda ctx, data, ref: "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" / %}
+ """
+
+ TestComponent.render(
+ args=(123, "str", 456),
+ kwargs={"var1": 1, "var2": "str", "var3": 456},
+ slots={
+ "my_slot": "MY_SLOT",
+ "my_slot2": lambda ctx, data, ref: "abc",
+ },
+ )
+
+ @djc_test(
+ components_settings={"extensions": [PydanticExtension]},
+ )
+ def test_invalid_slots(self):
+ class TestSlots(TypedDict):
+ slot1: SlotContent
+ slot2: SlotContent
+
+ class TestComponent(Component[Any, Any, TestSlots, 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 "slot1" / %}
+ 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):
+ TestComponent.render(
+ args=(123, "str"),
+ kwargs={"variable": "test", "another": 1},
+ slots={
+ "my_slot": "MY_SLOT",
+ "my_slot2": lambda ctx, data, ref: "abc",
+ }, # type: ignore
+ )
+
+ @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" / %}
+ """
+
+ TestComponent.render(
+ args=(123, "str", 456),
+ kwargs={"var1": 1, "var2": "str", "var3": 456},
+ slots={
+ "slot1": "SLOT1",
+ "slot2": lambda ctx, data, ref: "abc",
+ },
+ )
+
+ @djc_test(
+ components_settings={"extensions": [PydanticExtension]},
+ )
+ def test_invalid_data(self):
+ class TestData(TypedDict):
+ data1: int
+ data2: str
+
+ class TestComponent(Component[Any, Any, Any, TestData, 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 "slot1" / %}
+ 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):
+ TestComponent.render(
+ args=(123, "str"),
+ kwargs={"variable": "test", "another": 1},
+ slots={
+ "slot1": "SLOT1",
+ "slot2": lambda ctx, data, ref: "abc",
+ },
+ )
+
+ @djc_test(
+ components_settings={"extensions": [PydanticExtension]},
+ )
+ def test_valid_data(self):
+ class TestData(TypedDict):
+ data1: int
+ data2: str
+
+ class TestComponent(Component[Any, Any, Any, TestData, Any, Any]):
+ def get_context_data(self, a, b, c, var1, var2, **attrs):
+ return {
+ "data1": var1,
+ "data2": var2,
+ }
+
+ template: types.django_html = """
+ {% load component_tags %}
+ Data 1: {{ data1 }}
+ Data 2: {{ data2 }}
+ Slot 1: {% slot "slot1" / %}
+ Slot 2: {% slot "slot2" / %}
+ """
+
+ TestComponent.render(
+ args=(123, "str", 456),
+ kwargs={"var1": 1, "var2": "str", "var3": 456},
+ slots={
+ "slot1": "SLOT1",
+ "slot2": lambda ctx, data, ref: "abc",
+ },
+ )
+
+ @djc_test(
+ components_settings={"extensions": [PydanticExtension]},
+ )
+ def test_validate_all(self):
+ TestArgs = Tuple[int, str, int]
+
+ class TestKwargs(TypedDict):
+ var1: int
+ var2: str
+ var3: int
+
+ class TestSlots(TypedDict):
+ slot1: SlotContent
+ slot2: SlotContent
+
+ class TestData(TypedDict):
+ data1: int
+ data2: str
+
+ class TestComponent(Component[TestArgs, TestKwargs, TestSlots, TestData, Any, Any]):
+ def get_context_data(self, a, b, c, var1, var2, **attrs):
+ return {
+ "data1": var1,
+ "data2": var2,
+ }
+
+ template: types.django_html = """
+ {% load component_tags %}
+ Data 1: {{ data1 }}
+ Data 2: {{ data2 }}
+ Slot 1: {% slot "slot1" / %}
+ Slot 2: {% slot "slot2" / %}
+ """
+
+ TestComponent.render(
+ args=(123, "str", 456),
+ kwargs={"var1": 1, "var2": "str", "var3": 456},
+ slots={
+ "slot1": "SLOT1",
+ "slot2": lambda ctx, data, ref: "abc",
+ },
+ )
+
+ @djc_test(
+ components_settings={"extensions": [PydanticExtension]},
+ )
+ def test_handles_nested_types(self):
+ class NestedDict(TypedDict):
+ 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[TestArgs, TestKwargs, Any, Any, Any, Any]):
+ def get_context_data(self, a, b, c, var1, var2, **attrs):
+ return {
+ "data1": var1,
+ "data2": var2,
+ }
+
+ template: types.django_html = """
+ {% load component_tags %}
+ Data 1: {{ data1 }}
+ Data 2: {{ data2 }}
+ Slot 1: {% slot "slot1" / %}
+ 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):
+ TestComponent.render(
+ args=(123, "str", 456), # type: ignore
+ kwargs={"var1": 1, "var2": "str", "var3": 456}, # type: ignore
+ slots={
+ "slot1": "SLOT1",
+ "slot2": lambda ctx, data, ref: "abc",
+ },
+ )
+
+ TestComponent.render(
+ args=({"nested": 1}, (1, "str", 456), ({"nested": 1}, (1, "str", 456), 456)),
+ kwargs={"var1": {"nested": 1}, "var2": (1, "str", 456), "var3": ({"nested": 1}, (1, "str", 456), 456)},
+ slots={
+ "slot1": "SLOT1",
+ "slot2": lambda ctx, data, ref: "abc",
+ },
+ )
+
+ @djc_test(
+ components_settings={"extensions": [PydanticExtension]},
+ )
+ def test_handles_component_types(self):
+ TestArgs = Tuple[Type[Component]]
+
+ class TestKwargs(TypedDict):
+ component: Type[Component]
+
+ class TestComponent(Component[TestArgs, TestKwargs, Any, Any, Any, Any]):
+ def get_context_data(self, a, component, **attrs):
+ return {
+ "component": component,
+ }
+
+ template: types.django_html = """
+ {% load component_tags %}
+ 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):
+ TestComponent.render(
+ args=[123], # type: ignore
+ kwargs={"component": 1}, # type: ignore
+ )
+
+ TestComponent.render(
+ args=(TestComponent,),
+ kwargs={"component": TestComponent},
+ )
+
+ 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,
+ }
+
+ template = ""
+
+ TestComponent.render(
+ args=("str", {"str": 123}, ["a", "b", "c"], (123, "123")),
+ kwargs={
+ "one": "str",
+ "two": {"str": 123},
+ "three": ["a", "b", "c"],
+ "four": (123, "123"),
+ },
+ )
diff --git a/tests/testutils.py b/tests/testutils.py
new file mode 100644
index 0000000..ce600eb
--- /dev/null
+++ b/tests/testutils.py
@@ -0,0 +1,54 @@
+from pathlib import Path
+from typing import Dict, Optional
+
+import django
+from django.conf import settings
+
+
+def setup_test_config(
+ components: Optional[Dict] = None,
+ extra_settings: Optional[Dict] = None,
+):
+ if settings.configured:
+ return
+
+ default_settings = {
+ "BASE_DIR": Path(__file__).resolve().parent,
+ "INSTALLED_APPS": ("django_components",),
+ "TEMPLATES": [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [
+ "tests/templates/",
+ "tests/components/", # Required for template relative imports in tests
+ ],
+ "OPTIONS": {
+ "builtins": [
+ "django_components.templatetags.component_tags",
+ ]
+ },
+ }
+ ],
+ "COMPONENTS": {
+ "template_cache_size": 128,
+ "autodiscover": False,
+ **(components or {}),
+ },
+ "DATABASES": {
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": ":memory:",
+ }
+ },
+ "SECRET_KEY": "secret",
+ "ROOT_URLCONF": "django_components.urls",
+ }
+
+ settings.configure(
+ **{
+ **default_settings,
+ **(extra_settings or {}),
+ }
+ )
+
+ django.setup()
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..4f901c2
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,61 @@
+[tox]
+envlist =
+ py{38,39,310,311,312,313}
+ flake8
+ isort
+ coverage
+ mypy
+ black
+
+[gh-actions]
+python =
+ 3.8: py38
+ 3.9: py39
+ 3.10: py310
+ 3.11: py311
+ 3.12: py312
+ 3.13: py313, flake8, isort, coverage, mypy, black
+
+isolated_build = true
+
+[testenv]
+package = wheel
+wheel_build_env = .pkg
+deps =
+ django42: Django>=4.2,<4.3
+ django50: Django>=5.0,<5.1
+ django51: Django>=5.1,<5.2
+ django-components==0.135
+ pytest
+ pytest-xdist
+ pytest-django
+ pytest-asyncio
+ syrupy # pytest snapshot testing
+commands = pytest {posargs}
+
+[testenv:flake8]
+deps = flake8
+ flake8-pyproject
+commands = flake8 .
+
+[testenv:isort]
+deps = isort
+commands = isort --check-only --diff src/djc_ext_pydantic
+
+[testenv:coverage]
+deps =
+ pytest-cov
+ pytest-django
+ pytest-asyncio
+ syrupy # snapshot testing
+commands =
+ pytest --cov=djc_ext_pydantic --cov-fail-under=87 --cov-branch
+
+[testenv:mypy]
+deps =
+ mypy
+commands = mypy .
+
+[testenv:black]
+deps = black
+commands = black --check src/djc_ext_pydantic