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 + +[![PyPI - Version](https://img.shields.io/pypi/v/djc-ext-pydantic)](https://pypi.org/project/djc-ext-pydantic/) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/djc-ext-pydantic)](https://pypi.org/project/djc-ext-pydantic/) [![PyPI - License](https://img.shields.io/pypi/l/djc-ext-pydantic)](https://github.com/django-components/djc-ext-pydantic/blob/main/LICENSE) [![PyPI - Downloads](https://img.shields.io/pypi/dm/djc-ext-pydantic)](https://pypistats.org/packages/djc-ext-pydantic) [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/django-components/djc-ext-pydantic/tests.yml)](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