├── .editorconfig ├── .github └── workflows │ ├── python-publish.yml │ └── python-test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── django_pydantic_field ├── __init__.py ├── _migration_serializers.py ├── compat │ ├── __init__.py │ ├── deprecation.py │ ├── django.py │ ├── functools.py │ ├── imports.py │ ├── pydantic.py │ └── typing.py ├── fields.py ├── fields.pyi ├── forms.py ├── forms.pyi ├── py.typed ├── rest_framework.py ├── rest_framework.pyi ├── v1 │ ├── __init__.py │ ├── base.py │ ├── fields.py │ ├── forms.py │ ├── rest_framework.py │ └── utils.py └── v2 │ ├── __init__.py │ ├── fields.py │ ├── forms.py │ ├── rest_framework │ ├── __init__.py │ ├── coreapi.py │ ├── fields.py │ ├── mixins.py │ ├── openapi.py │ ├── parsers.py │ └── renderers.py │ ├── types.py │ └── utils.py ├── pyproject.toml └── tests ├── __init__.py ├── conftest.py ├── sample_app ├── .gitignore ├── __init__.py ├── apps.py ├── dbrouters.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py └── models.py ├── settings ├── .gitignore ├── __init__.py ├── django_test_settings.py └── urls.py ├── test_app ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py └── models.py ├── test_e2e_models.py ├── test_fields.py ├── test_imports.py ├── test_migration_serializers.py ├── test_model_admin.py ├── test_sample_app_migrations.py ├── v1 ├── __init__.py ├── test_base.py ├── test_fields.py ├── test_forms.py └── test_rest_framework.py └── v2 ├── __init__.py ├── rest_framework ├── __init__.py ├── __snapshots__ │ └── test_openapi │ │ ├── test_openapi_schema_generators[GET-class].json │ │ ├── test_openapi_schema_generators[GET-func].json │ │ ├── test_openapi_schema_generators[POST-func].json │ │ └── test_openapi_schema_generators[PUT-class].json ├── test_coreapi.py ├── test_e2e_views.py ├── test_fields.py ├── test_openapi.py ├── test_parsers.py ├── test_renderers.py └── view_fixtures.py ├── test_forms.py └── test_types.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 4 10 | 11 | [*.py] 12 | max_line_length = 120 13 | 14 | [Makefile] 15 | indent_style = tab 16 | indent_size = 4 17 | 18 | 19 | [{*.json,*.yml,*.yaml}] 20 | indent_size = 2 21 | insert_final_newline = false 22 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Upload Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | pypi-publish: 12 | name: upload release to PyPI 13 | runs-on: ubuntu-latest 14 | environment: release 15 | permissions: 16 | id-token: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: '3.x' 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | python -m pip install build 27 | - name: Build package 28 | run: python -m build 29 | - name: Publish package distributions to PyPI 30 | uses: pypa/gh-action-pypi-publish@release/v1 -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Test Package 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | - master 8 | pull_request: 9 | branches: 10 | - dev 11 | - master 12 | types: 13 | - opened 14 | - synchronize 15 | - reopened 16 | - ready_for_review 17 | workflow_dispatch: 18 | 19 | permissions: 20 | contents: read 21 | 22 | jobs: 23 | lint: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Set up Python 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: '3.x' 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install -e .[dev,test] 34 | - name: Lint package 35 | run: python -m mypy . 36 | 37 | pre-commit: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | - name: Set up Python 42 | uses: actions/setup-python@v5 43 | with: 44 | python-version: '3.x' 45 | - name: Install dependencies 46 | run: | 47 | python -m pip install -e .[dev] 48 | - name: Run pre-commit 49 | run: pre-commit run --show-diff-on-failure --color=always --all-files 50 | 51 | test: 52 | runs-on: ubuntu-latest 53 | strategy: 54 | matrix: 55 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 56 | pydantic-version: ["1.10.*", "2.*"] 57 | 58 | services: 59 | postgres: 60 | image: postgres:15-alpine 61 | env: 62 | POSTGRES_USER: postgres 63 | POSTGRES_PASSWORD: pass 64 | POSTGRES_DB: test_db 65 | ports: 66 | - 5432:5432 67 | mariadb: 68 | image: mariadb:11-jammy 69 | env: 70 | MARIADB_DATABASE: test_db 71 | MYSQL_ROOT_PASSWORD: pass 72 | ports: 73 | - 3306:3306 74 | 75 | env: 76 | POSTGRES_DSN: postgresql://postgres:pass@127.0.0.1:5432/test_db 77 | MYSQL_DSN: mysql://root:pass@127.0.0.1:3306/test_db 78 | 79 | steps: 80 | - uses: actions/checkout@v4 81 | - name: Set up Python ${{ matrix.python-version }} 82 | uses: actions/setup-python@v5 83 | with: 84 | python-version: ${{ matrix.python-version }} 85 | - name: Install dependencies 86 | run: | 87 | sudo apt update && sudo apt install -qy python3-dev default-libmysqlclient-dev build-essential 88 | python -m pip install --upgrade pip 89 | python -m pip install -e .[dev,test,ci] 90 | - name: Install Pydantic ${{ matrix.pydantic-version }} 91 | run: python -m pip install "pydantic==${{ matrix.pydantic-version }}" 92 | - name: Test package 93 | run: pytest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | *.log 4 | tmp/ 5 | 6 | dist/ 7 | .env*/ 8 | .venv*/ 9 | .mypy_cache/ 10 | *.py[cod] 11 | *.egg 12 | *.egg-info/ 13 | build 14 | htmlcov 15 | 16 | .python-version 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.12 3 | 4 | repos: 5 | - repo: meta 6 | hooks: 7 | - id: check-hooks-apply 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | # Ruff version. 10 | rev: v0.9.1 11 | hooks: 12 | # Run the linter. 13 | - id: ruff 14 | files: "^django_pydantic_field/" 15 | exclude: ^.*\b(\.pytest_cache|\.venv|venv).*\b.*$ 16 | args: [ --fix ] 17 | # Run the formatter. 18 | - id: ruff-format 19 | files: "^django_pydantic_field/" 20 | exclude: ^.*\b(\.pytest_cache|\.venv|venv).*\b.*$ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Savva Surenkov and django-pydantic-field contributors. 4 | See the contributors at https://github.com/surenkov/django-pydantic-field/contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export DJANGO_SETTINGS_MODULE=tests.settings.django_test_settings 2 | 3 | .PHONY: install 4 | install: 5 | python3 -m pip install build twine 6 | python3 -m pip install -e .[dev,test] 7 | 8 | .PHONY: build 9 | build: 10 | python3 -m build 11 | 12 | .PHONY: migrations 13 | migrations: 14 | python3 -m django makemigrations --noinput 15 | 16 | .PHONY: runserver 17 | runserver: 18 | python3 -m django migrate && \ 19 | python3 -m django runserver 20 | 21 | .PHONY: check 22 | check: 23 | python3 -m django check 24 | 25 | .PHONY: test 26 | test: A= 27 | test: 28 | pytest $(A) 29 | 30 | .PHONY: lint 31 | lint: A=. 32 | lint: 33 | python3 -m mypy $(A) 34 | 35 | .PHONY: upload 36 | upload: 37 | python3 -m twine upload dist/* 38 | 39 | .PHONY: upload-test 40 | upload-test: 41 | python3 -m twine upload --repository testpypi dist/* 42 | 43 | .PHONY: clean 44 | clean: 45 | rm -rf dist/* 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI Version](https://img.shields.io/pypi/v/django-pydantic-field)](https://pypi.org/project/django-pydantic-field/) 2 | [![Lint and Test Package](https://github.com/surenkov/django-pydantic-field/actions/workflows/python-test.yml/badge.svg)](https://github.com/surenkov/django-pydantic-field/actions/workflows/python-test.yml) 3 | [![PyPI - Downloads](https://img.shields.io/pypi/dm/django-pydantic-field)](https://pypistats.org/packages/django-pydantic-field) 4 | [![Supported Python Versions](https://img.shields.io/pypi/pyversions/django-pydantic-field)](https://pypi.org/project/django-pydantic-field/) 5 | [![Supported Django Versions](https://img.shields.io/pypi/frameworkversions/django/django-pydantic-field)](https://pypi.org/project/django-pydantic-field/) 6 | 7 | # Django + Pydantic = 🖤 8 | 9 | Django JSONField with Pydantic models as a Schema. 10 | 11 | **Now supports both Pydantic v1 and v2!** [Please join the discussion](https://github.com/surenkov/django-pydantic-field/discussions/36) if you have any thoughts or suggestions! 12 | 13 | ## Usage 14 | 15 | Install the package with `pip install django-pydantic-field`. 16 | 17 | ``` python 18 | import pydantic 19 | from datetime import date 20 | from uuid import UUID 21 | 22 | from django.db import models 23 | from django_pydantic_field import SchemaField 24 | 25 | 26 | class Foo(pydantic.BaseModel): 27 | count: int 28 | size: float = 1.0 29 | 30 | 31 | class Bar(pydantic.BaseModel): 32 | slug: str = "foo_bar" 33 | 34 | 35 | class MyModel(models.Model): 36 | # Infer schema from field annotation 37 | foo_field: Foo = SchemaField() 38 | 39 | # or explicitly pass schema to the field 40 | bar_list: typing.Sequence[Bar] = SchemaField(schema=list[Bar]) 41 | 42 | # Pydantic exportable types are supported 43 | raw_date_map: dict[int, date] = SchemaField() 44 | raw_uids: set[UUID] = SchemaField() 45 | 46 | ... 47 | 48 | model = MyModel( 49 | foo_field={"count": "5"}, 50 | bar_list=[{}], 51 | raw_date_map={1: "1970-01-01"}, 52 | raw_uids={"17a25db0-27a4-11ed-904a-5ffb17f92734"} 53 | ) 54 | model.save() 55 | 56 | assert model.foo_field == Foo(count=5, size=1.0) 57 | assert model.bar_list == [Bar(slug="foo_bar")] 58 | assert model.raw_date_map == {1: date(1970, 1, 1)} 59 | assert model.raw_uids == {UUID("17a25db0-27a4-11ed-904a-5ffb17f92734")} 60 | ``` 61 | 62 | Practically, schema could be of any type supported by Pydantic. 63 | In addition, an external `config` class can be passed for such schemes. 64 | 65 | ### Forward referencing annotations 66 | 67 | It is also possible to use `SchemaField` with forward references and string literals, e.g the code below is also valid: 68 | 69 | ``` python 70 | 71 | class MyModel(models.Model): 72 | foo_field: "Foo" = SchemaField() 73 | bar_list: typing.Sequence["Bar"] = SchemaField(schema=typing.ForwardRef("list[Bar]")) 74 | 75 | 76 | class Foo(pydantic.BaseModel): 77 | count: int 78 | size: float = 1.0 79 | 80 | 81 | class Bar(pydantic.BaseModel): 82 | slug: str = "foo_bar" 83 | ``` 84 | 85 | **Pydantic v2 specific**: this behaviour is achieved by the fact that the exact type resolution will be postponed until the initial access to the field. Usually this happens on the first instantiation of the model. 86 | 87 | To reduce the number of runtime errors related to the postponed resolution, the field itself performs a few checks against the passed schema during `./manage.py check` command invocation, and consequently, in `runserver` and `makemigrations` commands. 88 | 89 | Here's the list of currently implemented checks: 90 | - `pydantic.E001`: The passed schema could not be resolved. Most likely it does not exist in the scope of the defined field. 91 | - `pydantic.E002`: `default=` value could not be serialized to the schema. 92 | - `pydantic.W003`: The default value could not be reconstructed to the schema due to `include`/`exclude` configuration. 93 | 94 | 95 | ### `typing.Annotated` support 96 | As of `v0.3.5`, SchemaField also supports `typing.Annotated[...]` expressions, both through `schema=` attribute or field annotation syntax; though I find the `schema=typing.Annotated[...]` variant highly discouraged. 97 | 98 | **The current limitation** is not in the field itself, but in possible `Annotated` metadata -- practically it can contain anything, and Django migrations serializers could refuse to write it to migrations. 99 | For most relevant types in context of Pydantic, I wrote the specific serializers (particularly for `pydantic.FieldInfo`, `pydantic.Representation` and raw dataclasses), thus it should cover the majority of `Annotated` use cases. 100 | 101 | ## Django Forms support 102 | 103 | It is possible to create Django forms, which would validate against the given schema: 104 | 105 | ``` python 106 | from django import forms 107 | from django_pydantic_field.forms import SchemaField 108 | 109 | 110 | class Foo(pydantic.BaseModel): 111 | slug: str = "foo_bar" 112 | 113 | 114 | class FooForm(forms.Form): 115 | field = SchemaField(Foo) # `typing.ForwardRef("Foo")` is fine too, but only in Django 4+ 116 | 117 | 118 | form = FooForm(data={"field": '{"slug": "asdf"}'}) 119 | assert form.is_valid() 120 | assert form.cleaned_data["field"] == Foo(slug="asdf") 121 | ``` 122 | 123 | `django_pydantic_field` also supports auto-generated fields for `ModelForm` and `modelform_factory`: 124 | 125 | ``` python 126 | class MyModelForm(forms.ModelForm): 127 | class Meta: 128 | model = MyModel 129 | fields = ["foo_field"] 130 | 131 | form = MyModelForm(data={"foo_field": '{"count": 5}'}) 132 | assert form.is_valid() 133 | assert form.cleaned_data["foo_field"] == Foo(count=5) 134 | 135 | ... 136 | 137 | # ModelForm factory support 138 | AnotherModelForm = modelform_factory(MyModel, fields=["foo_field"]) 139 | form = AnotherModelForm(data={"foo_field": '{"count": 5}'}) 140 | 141 | assert form.is_valid() 142 | assert form.cleaned_data["foo_field"] == Foo(count=5) 143 | ``` 144 | 145 | Note, that forward references would be resolved until field is being bound to the form instance. 146 | 147 | ### `django-jsonform` widgets 148 | [`django-jsonform`](https://django-jsonform.readthedocs.io) offers a dynamic form construction based on the specified JSONSchema. 149 | `django_pydantic_field.forms.SchemaField` plays nicely with its widgets, but only for Pydantic v2: 150 | 151 | ``` python 152 | from django_pydantic_field.forms import SchemaField 153 | from django_jsonform.widgets import JSONFormWidget 154 | 155 | class FooForm(forms.Form): 156 | field = SchemaField(Foo, widget=JSONFormWidget) 157 | ``` 158 | 159 | It is also possible to override the default form widget for Django Admin site, without writing custom admin forms: 160 | 161 | ``` python 162 | from django.contrib import admin 163 | from django_jsonform.widgets import JSONFormWidget 164 | 165 | # NOTE: Importing direct field class instead of `SchemaField` wrapper. 166 | from django_pydantic_field.v2.fields import PydanticSchemaField 167 | 168 | @admin.site.register(MyModel) 169 | class MyModelAdmin(admin.ModelAdmin): 170 | formfield_overrides = { 171 | PydanticSchemaField: {"widget": JSONFormWidget}, 172 | } 173 | ``` 174 | 175 | ## Django REST Framework support 176 | 177 | ``` python 178 | from rest_framework import generics, serializers 179 | from django_pydantic_field.rest_framework import SchemaField, AutoSchema 180 | 181 | 182 | class MyModelSerializer(serializers.ModelSerializer): 183 | foo_field = SchemaField(schema=Foo) 184 | 185 | class Meta: 186 | model = MyModel 187 | fields = '__all__' 188 | 189 | 190 | class SampleView(generics.RetrieveAPIView): 191 | serializer_class = MyModelSerializer 192 | 193 | # optional support of OpenAPI schema generation for Pydantic fields 194 | schema = AutoSchema() 195 | ``` 196 | 197 | Global approach with typed `parser` and `renderer` classes 198 | ``` python 199 | from rest_framework import views 200 | from rest_framework.decorators import api_view, parser_classes, renderer_classes 201 | from django_pydantic_field.rest_framework import SchemaRenderer, SchemaParser, AutoSchema 202 | 203 | 204 | @api_view(["POST"]) 205 | @parser_classes([SchemaParser[Foo]]): 206 | @renderer_classes([SchemaRenderer[list[Foo]]]) 207 | def foo_view(request): 208 | assert isinstance(request.data, Foo) 209 | 210 | count = request.data.count + 1 211 | return Response([Foo(count=count)]) 212 | 213 | 214 | class FooClassBasedView(views.APIView): 215 | parser_classes = [SchemaParser[Foo]] 216 | renderer_classes = [SchemaRenderer[list[Foo]]] 217 | 218 | # optional support of OpenAPI schema generation for Pydantic parsers/renderers 219 | schema = AutoSchema() 220 | 221 | def get(self, request, *args, **kwargs): 222 | assert isinstance(request.data, Foo) 223 | return Response([request.data]) 224 | 225 | def put(self, request, *args, **kwargs): 226 | assert isinstance(request.data, Foo) 227 | 228 | count = request.data.count + 1 229 | return Response([request.data]) 230 | ``` 231 | 232 | ## Contributing 233 | To get `django-pydantic-field` up and running in development mode: 234 | 1. Clone this repo; 235 | 1. Create a virtual environment: `python -m venv .venv`; 236 | 1. Activate `.venv`: `. .venv/bin/activate`; 237 | 1. Install the project and its dependencies: `pip install -e .[dev,test]`; 238 | 1. Setup `pre-commit`: `pre-commit install`. 239 | 240 | ## Acknowledgement 241 | 242 | * [Churkin Oleg](https://gist.github.com/Bahus/98a9848b1f8e2dcd986bf9f05dbf9c65) for his Gist as a source of inspiration; 243 | * Boutique Air Flight Operations platform as a test ground; 244 | -------------------------------------------------------------------------------- /django_pydantic_field/__init__.py: -------------------------------------------------------------------------------- 1 | from .fields import SchemaField as SchemaField 2 | 3 | 4 | def __getattr__(name): 5 | if name == "_migration_serializers": 6 | module = __import__("django_pydantic_field._migration_serializers", fromlist=["*"]) 7 | return module 8 | 9 | raise AttributeError(f"Module {__name__!r} has no attribute {name!r}") 10 | -------------------------------------------------------------------------------- /django_pydantic_field/_migration_serializers.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from .compat.django import * # noqa: F403 4 | 5 | DEPRECATION_MSG = ( 6 | "Module 'django_pydantic_field._migration_serializers' is deprecated " 7 | "and will be removed in version 1.0.0. " 8 | "Please replace it with 'django_pydantic_field.compat.django' in migrations." 9 | ) 10 | warnings.warn(DEPRECATION_MSG, category=DeprecationWarning) 11 | -------------------------------------------------------------------------------- /django_pydantic_field/compat/__init__.py: -------------------------------------------------------------------------------- 1 | from .django import GenericContainer as GenericContainer 2 | from .django import MigrationWriter as MigrationWriter 3 | from .pydantic import PYDANTIC_V1 as PYDANTIC_V1 4 | from .pydantic import PYDANTIC_V2 as PYDANTIC_V2 5 | from .pydantic import PYDANTIC_VERSION as PYDANTIC_VERSION 6 | -------------------------------------------------------------------------------- /django_pydantic_field/compat/deprecation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as ty 4 | import warnings 5 | 6 | _MISSING = object() 7 | _DEPRECATED_KWARGS = ( 8 | "allow_nan", 9 | "indent", 10 | "separators", 11 | "skipkeys", 12 | "sort_keys", 13 | ) 14 | _DEPRECATED_KWARGS_MESSAGE = ( 15 | "The `%s=` argument is not supported by Pydantic v2 and will be removed in the future versions." 16 | ) 17 | 18 | 19 | def truncate_deprecated_v1_export_kwargs(kwargs: dict[str, ty.Any]) -> None: 20 | for kwarg in _DEPRECATED_KWARGS: 21 | maybe_present_kwarg = kwargs.pop(kwarg, _MISSING) 22 | if maybe_present_kwarg is not _MISSING: 23 | warnings.warn(_DEPRECATED_KWARGS_MESSAGE % (kwarg,), DeprecationWarning, stacklevel=2) 24 | -------------------------------------------------------------------------------- /django_pydantic_field/compat/functools.py: -------------------------------------------------------------------------------- 1 | try: 2 | from functools import cached_property as cached_property 3 | except ImportError: 4 | from django.utils.functional import cached_property as cached_property # type: ignore 5 | -------------------------------------------------------------------------------- /django_pydantic_field/compat/imports.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import importlib 3 | import types 4 | 5 | from .pydantic import PYDANTIC_V1, PYDANTIC_V2, PYDANTIC_VERSION 6 | 7 | __all__ = ("compat_getattr", "compat_dir") 8 | 9 | 10 | def compat_getattr(module_name: str): 11 | module = _import_compat_module(module_name) 12 | return functools.partial(getattr, module) 13 | 14 | 15 | def compat_dir(module_name: str): 16 | compat_module = _import_compat_module(module_name) 17 | module_ns = vars(compat_module) 18 | 19 | if "__dir__" in module_ns: 20 | return module_ns["__dir__"] 21 | 22 | if "__all__" in module_ns: 23 | return functools.partial(list, module_ns["__all__"]) 24 | 25 | return functools.partial(dir, compat_module) 26 | 27 | 28 | def _import_compat_module(module_name: str) -> types.ModuleType: 29 | try: 30 | package, _, module = module_name.partition(".") 31 | except ValueError: 32 | package, module = module_name, "" 33 | 34 | module_path_parts = [package] 35 | if PYDANTIC_V2: 36 | module_path_parts.append("v2") 37 | elif PYDANTIC_V1: 38 | module_path_parts.append("v1") 39 | else: 40 | raise RuntimeError(f"Pydantic {PYDANTIC_VERSION} is not supported") 41 | 42 | if module: 43 | module_path_parts.append(module) 44 | 45 | module_path = ".".join(module_path_parts) 46 | return importlib.import_module(module_path) 47 | -------------------------------------------------------------------------------- /django_pydantic_field/compat/pydantic.py: -------------------------------------------------------------------------------- 1 | from pydantic.version import VERSION as PYDANTIC_VERSION 2 | 3 | __all__ = ("PYDANTIC_V2", "PYDANTIC_V1", "PYDANTIC_VERSION") 4 | 5 | PYDANTIC_V2 = PYDANTIC_VERSION.startswith("2.") 6 | PYDANTIC_V1 = PYDANTIC_VERSION.startswith("1.") 7 | -------------------------------------------------------------------------------- /django_pydantic_field/compat/typing.py: -------------------------------------------------------------------------------- 1 | try: 2 | from typing import get_args as get_args 3 | from typing import get_origin as get_origin 4 | except ImportError: 5 | from typing_extensions import get_args as get_args # type: ignore 6 | from typing_extensions import get_origin as get_origin # type: ignore 7 | -------------------------------------------------------------------------------- /django_pydantic_field/fields.py: -------------------------------------------------------------------------------- 1 | from .compat.imports import compat_dir, compat_getattr 2 | 3 | __getattr__ = compat_getattr(__name__) 4 | __dir__ = compat_dir(__name__) 5 | -------------------------------------------------------------------------------- /django_pydantic_field/fields.pyi: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import typing as ty 5 | 6 | import typing_extensions as te 7 | from django.db.models.expressions import BaseExpression 8 | from pydantic import BaseConfig, BaseModel, ConfigDict 9 | 10 | try: 11 | from pydantic.dataclasses import DataclassClassOrWrapper as PydanticDataclass 12 | except ImportError: 13 | from pydantic._internal._dataclasses import PydanticDataclass as PydanticDataclass 14 | 15 | __all__ = ("SchemaField",) 16 | 17 | SchemaT: ty.TypeAlias = ty.Union[ 18 | BaseModel, 19 | PydanticDataclass, 20 | ty.Sequence[ty.Any], 21 | ty.Mapping[str, ty.Any], 22 | ty.Set[ty.Any], 23 | ty.FrozenSet[ty.Any], 24 | ] 25 | OptSchemaT: ty.TypeAlias = ty.Optional[SchemaT] 26 | ST = ty.TypeVar("ST", bound=SchemaT) 27 | IncEx = ty.Union[ty.Set[int], ty.Set[str], ty.Dict[int, ty.Any], ty.Dict[str, ty.Any]] 28 | ConfigType = ty.Union[ConfigDict, ty.Type[BaseConfig], type] 29 | 30 | class _FieldKwargs(te.TypedDict, total=False): 31 | name: str | None 32 | verbose_name: str | None 33 | primary_key: bool 34 | max_length: int | None 35 | unique: bool 36 | blank: bool 37 | db_index: bool 38 | rel: ty.Any 39 | editable: bool 40 | serialize: bool 41 | unique_for_date: str | None 42 | unique_for_month: str | None 43 | unique_for_year: str | None 44 | choices: ty.Sequence[ty.Tuple[str, str]] | None 45 | help_text: str | None 46 | db_column: str | None 47 | db_tablespace: str | None 48 | auto_created: bool 49 | validators: ty.Sequence[ty.Callable] | None 50 | error_messages: ty.Mapping[str, str] | None 51 | db_comment: str | None 52 | 53 | class _JSONFieldKwargs(_FieldKwargs, total=False): 54 | encoder: ty.Callable[[], json.JSONEncoder] 55 | decoder: ty.Callable[[], json.JSONDecoder] 56 | 57 | class _ExportKwargs(te.TypedDict, total=False): 58 | strict: bool 59 | from_attributes: bool 60 | mode: te.Literal["json", "python"] 61 | include: IncEx | None 62 | exclude: IncEx | None 63 | by_alias: bool 64 | exclude_unset: bool 65 | exclude_defaults: bool 66 | exclude_none: bool 67 | round_trip: bool 68 | warnings: bool 69 | 70 | class _SchemaFieldKwargs(_JSONFieldKwargs, _ExportKwargs, total=False): ... 71 | 72 | class _DeprecatedSchemaFieldKwargs(_SchemaFieldKwargs, total=False): 73 | allow_nan: ty.Any 74 | indent: ty.Any 75 | separators: ty.Any 76 | skipkeys: ty.Any 77 | sort_keys: ty.Any 78 | 79 | @ty.overload 80 | def SchemaField( 81 | schema: ty.Type[ST | None] | ty.ForwardRef = ..., 82 | config: ConfigType = ..., 83 | default: OptSchemaT | ty.Callable[[], OptSchemaT] | BaseExpression = ..., 84 | *args, 85 | null: ty.Literal[True], 86 | **kwargs: te.Unpack[_SchemaFieldKwargs], 87 | ) -> ST | None: ... 88 | @ty.overload 89 | def SchemaField( 90 | schema: te.Annotated[ty.Type[ST | None], ...] = ..., 91 | config: ConfigType = ..., 92 | default: OptSchemaT | ty.Callable[[], OptSchemaT] | BaseExpression = ..., 93 | *args, 94 | null: ty.Literal[True], 95 | **kwargs: te.Unpack[_SchemaFieldKwargs], 96 | ) -> ST | None: ... 97 | @ty.overload 98 | def SchemaField( 99 | schema: ty.Type[ST] | ty.ForwardRef = ..., 100 | config: ConfigType = ..., 101 | default: SchemaT | ty.Callable[[], SchemaT] | BaseExpression = ..., 102 | *args, 103 | null: ty.Literal[False] = ..., 104 | **kwargs: te.Unpack[_SchemaFieldKwargs], 105 | ) -> ST: ... 106 | @ty.overload 107 | def SchemaField( 108 | schema: te.Annotated[ty.Type[ST], ...] = ..., 109 | config: ConfigType = ..., 110 | default: SchemaT | ty.Callable[[], SchemaT] | BaseExpression = ..., 111 | *args, 112 | null: ty.Literal[False] = ..., 113 | **kwargs: te.Unpack[_SchemaFieldKwargs], 114 | ) -> ST: ... 115 | @ty.overload 116 | @te.deprecated( 117 | "Passing `json.dump` kwargs to `SchemaField` is not supported by " 118 | "Pydantic 2 and will be removed in the future versions." 119 | ) 120 | def SchemaField( 121 | schema: ty.Type[ST | None] | ty.ForwardRef = ..., 122 | config: ConfigType = ..., 123 | default: SchemaT | ty.Callable[[], SchemaT] | BaseExpression = ..., 124 | *args, 125 | null: ty.Literal[True], 126 | **kwargs: te.Unpack[_DeprecatedSchemaFieldKwargs], 127 | ) -> ST | None: ... 128 | @ty.overload 129 | @te.deprecated( 130 | "Passing `json.dump` kwargs to `SchemaField` is not supported by " 131 | "Pydantic 2 and will be removed in the future versions." 132 | ) 133 | def SchemaField( 134 | schema: ty.Type[ST] | ty.ForwardRef = ..., 135 | config: ConfigType = ..., 136 | default: SchemaT | ty.Callable[[], SchemaT] | BaseExpression = ..., 137 | *args, 138 | null: ty.Literal[False] = ..., 139 | **kwargs: te.Unpack[_DeprecatedSchemaFieldKwargs], 140 | ) -> ST: ... 141 | -------------------------------------------------------------------------------- /django_pydantic_field/forms.py: -------------------------------------------------------------------------------- 1 | from .compat.imports import compat_dir, compat_getattr 2 | 3 | __getattr__ = compat_getattr(__name__) 4 | __dir__ = compat_dir(__name__) 5 | -------------------------------------------------------------------------------- /django_pydantic_field/forms.pyi: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import typing as ty 5 | 6 | import typing_extensions as te 7 | from django.forms.fields import JSONField 8 | from django.forms.widgets import Widget 9 | from django.utils.functional import _StrOrPromise 10 | 11 | from .fields import _ExportKwargs, ConfigType, ST 12 | 13 | __all__ = ("SchemaField",) 14 | 15 | class _FieldKwargs(ty.TypedDict, total=False): 16 | required: bool 17 | widget: Widget | type[Widget] | None 18 | label: _StrOrPromise | None 19 | initial: ty.Any | None 20 | help_text: _StrOrPromise 21 | error_messages: ty.Mapping[str, _StrOrPromise] | None 22 | show_hidden_initial: bool 23 | validators: ty.Sequence[ty.Callable[[ty.Any], None]] 24 | localize: bool 25 | disabled: bool 26 | label_suffix: str | None 27 | 28 | class _CharFieldKwargs(_FieldKwargs, total=False): 29 | max_length: int | None 30 | min_length: int | None 31 | strip: bool 32 | empty_value: ty.Any 33 | 34 | class _JSONFieldKwargs(_CharFieldKwargs, total=False): 35 | encoder: ty.Callable[[], json.JSONEncoder] | None 36 | decoder: ty.Callable[[], json.JSONDecoder] | None 37 | 38 | class _SchemaFieldKwargs(_ExportKwargs, _JSONFieldKwargs, total=False): 39 | allow_null: bool | None 40 | 41 | class _DeprecatedSchemaFieldKwargs(_SchemaFieldKwargs, total=False): 42 | allow_nan: ty.Any 43 | indent: ty.Any 44 | separators: ty.Any 45 | skipkeys: ty.Any 46 | sort_keys: ty.Any 47 | 48 | class SchemaField(JSONField, ty.Generic[ST]): 49 | @ty.overload 50 | def __init__( 51 | self, 52 | schema: ty.Type[ST] | ty.ForwardRef | str, 53 | config: ConfigType | None = ..., 54 | *args, 55 | **kwargs: te.Unpack[_SchemaFieldKwargs], 56 | ) -> None: ... 57 | @ty.overload 58 | @te.deprecated( 59 | "Passing `json.dump` kwargs to `SchemaField` is not supported by " 60 | "Pydantic 2 and will be removed in the future versions." 61 | ) 62 | def __init__( 63 | self, 64 | schema: ty.Type[ST] | ty.ForwardRef | str, 65 | config: ConfigType | None = ..., 66 | *args, 67 | **kwargs: te.Unpack[_DeprecatedSchemaFieldKwargs], 68 | ) -> None: ... 69 | -------------------------------------------------------------------------------- /django_pydantic_field/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surenkov/django-pydantic-field/ca90e8921705bd6a069623cd76a23f970d3ab87e/django_pydantic_field/py.typed -------------------------------------------------------------------------------- /django_pydantic_field/rest_framework.py: -------------------------------------------------------------------------------- 1 | from .compat.imports import compat_dir, compat_getattr 2 | 3 | __getattr__ = compat_getattr(__name__) 4 | __dir__ = compat_dir(__name__) 5 | -------------------------------------------------------------------------------- /django_pydantic_field/rest_framework.pyi: -------------------------------------------------------------------------------- 1 | import typing as ty 2 | 3 | import typing_extensions as te 4 | from django.utils.functional import _StrOrPromise 5 | from rest_framework import parsers, renderers 6 | from rest_framework.fields import _DefaultInitial, Field 7 | from rest_framework.schemas.openapi import AutoSchema as _OpenAPIAutoSchema 8 | from rest_framework.validators import Validator 9 | 10 | from .fields import _ExportKwargs, ConfigType, ST 11 | 12 | __all__ = ("SchemaField", "SchemaParser", "SchemaRenderer", "AutoSchema") 13 | 14 | class _FieldKwargs(te.TypedDict, ty.Generic[ST], total=False): 15 | read_only: bool 16 | write_only: bool 17 | required: bool 18 | default: _DefaultInitial[ST] 19 | initial: _DefaultInitial[ST] 20 | source: str 21 | label: _StrOrPromise 22 | help_text: _StrOrPromise 23 | style: dict[str, ty.Any] 24 | error_messages: dict[str, _StrOrPromise] 25 | validators: ty.Sequence[Validator[ST]] 26 | allow_null: bool 27 | 28 | class _SchemaFieldKwargs(_FieldKwargs[ST], _ExportKwargs, total=False): 29 | pass 30 | 31 | class _DeprecatedSchemaFieldKwargs(_SchemaFieldKwargs[ST], total=False): 32 | allow_nan: ty.Any 33 | indent: ty.Any 34 | separators: ty.Any 35 | skipkeys: ty.Any 36 | sort_keys: ty.Any 37 | 38 | class SchemaField(Field, ty.Generic[ST]): 39 | @ty.overload 40 | def __init__( 41 | self, 42 | schema: ty.Type[ST] | ty.ForwardRef | str, 43 | config: ConfigType | None = ..., 44 | *args, 45 | **kwargs: te.Unpack[_SchemaFieldKwargs[ST]], 46 | ) -> None: ... 47 | @ty.overload 48 | @te.deprecated( 49 | "Passing `json.dump` kwargs to `SchemaField` is not supported by Pydantic 2 and will be removed in the future versions." 50 | ) 51 | def __init__( 52 | self, 53 | schema: ty.Type[ST] | ty.ForwardRef | str, 54 | config: ConfigType | None = ..., 55 | *args, 56 | **kwargs: te.Unpack[_DeprecatedSchemaFieldKwargs[ST]], 57 | ) -> None: ... 58 | 59 | class SchemaParser(parsers.JSONParser, ty.Generic[ST]): 60 | schema_context_key: ty.ClassVar[str] 61 | config_context_key: ty.ClassVar[str] 62 | 63 | class SchemaRenderer(renderers.JSONRenderer, ty.Generic[ST]): 64 | schema_context_key: ty.ClassVar[str] 65 | config_context_key: ty.ClassVar[str] 66 | 67 | class AutoSchema(_OpenAPIAutoSchema): ... 68 | -------------------------------------------------------------------------------- /django_pydantic_field/v1/__init__.py: -------------------------------------------------------------------------------- 1 | from django_pydantic_field.compat.pydantic import PYDANTIC_V1 2 | 3 | if not PYDANTIC_V1: 4 | raise ImportError("django_pydantic_field.v1 package is only compatible with Pydantic v1") 5 | 6 | from .fields import * # noqa: F403 7 | -------------------------------------------------------------------------------- /django_pydantic_field/v1/base.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import pydantic 4 | from django.core.serializers.json import DjangoJSONEncoder 5 | from pydantic.json import pydantic_encoder 6 | from pydantic.typing import display_as_type 7 | 8 | from .utils import get_local_namespace, inherit_configs 9 | 10 | __all__ = ( 11 | "SchemaEncoder", 12 | "SchemaDecoder", 13 | "wrap_schema", 14 | "prepare_schema", 15 | "extract_export_kwargs", 16 | ) 17 | 18 | ST = t.TypeVar("ST", bound="SchemaT") 19 | 20 | if t.TYPE_CHECKING: 21 | from pydantic.dataclasses import DataclassClassOrWrapper 22 | 23 | SchemaT = t.Union[ 24 | pydantic.BaseModel, 25 | DataclassClassOrWrapper, 26 | t.Sequence[t.Any], 27 | t.Mapping[str, t.Any], 28 | t.Set[t.Any], 29 | t.FrozenSet[t.Any], 30 | ] 31 | 32 | ModelType = t.Type[pydantic.BaseModel] 33 | ConfigType = t.Union[pydantic.ConfigDict, t.Type[pydantic.BaseConfig], t.Type] 34 | 35 | 36 | class SchemaEncoder(DjangoJSONEncoder): 37 | def __init__( 38 | self, 39 | *args, 40 | schema: "ModelType", 41 | export=None, 42 | raise_errors: bool = False, 43 | **kwargs, 44 | ): 45 | super().__init__(*args, **kwargs) 46 | self.schema = schema 47 | self.export_params = export or {} 48 | self.raise_errors = raise_errors 49 | 50 | def encode(self, obj): 51 | try: 52 | data = self.schema(__root__=obj).json(**self.export_params) 53 | except pydantic.ValidationError: 54 | if self.raise_errors: 55 | raise 56 | 57 | # This branch used for expressions like .filter(data__contains={}). 58 | # We don't want that lookup expression to be parsed as a schema 59 | try: 60 | # Attempting to encode with pydantic encoder first, to make sure 61 | # the output conform with pydantic's built-in serialization 62 | data = pydantic_encoder(obj) 63 | except TypeError: 64 | data = super().encode(obj) 65 | 66 | return data 67 | 68 | 69 | class SchemaDecoder(t.Generic[ST]): 70 | def __init__(self, schema: "ModelType"): 71 | self.schema = schema 72 | 73 | def decode(self, obj: t.Any) -> "ST": 74 | if isinstance(obj, (str, bytes)): 75 | value = self.schema.parse_raw(obj).__root__ # type: ignore 76 | else: 77 | value = self.schema.parse_obj(obj).__root__ # type: ignore 78 | return value 79 | 80 | 81 | def wrap_schema( 82 | schema: t.Union[t.Type["ST"], t.ForwardRef], 83 | config: t.Optional["ConfigType"] = None, 84 | allow_null: bool = False, 85 | **kwargs, 86 | ) -> "ModelType": 87 | type_name = _get_field_schema_name(schema) 88 | params = _get_field_schema_params(schema, config, allow_null, **kwargs) 89 | return pydantic.create_model(type_name, **params) 90 | 91 | 92 | def prepare_schema(schema: "ModelType", owner: t.Any = None) -> None: 93 | namespace = get_local_namespace(owner) 94 | schema.update_forward_refs(**namespace) 95 | 96 | 97 | def extract_export_kwargs(ctx: dict, extractor=dict.get) -> t.Dict[str, t.Any]: 98 | """Extract ``BaseModel.json()`` kwargs from ctx for field deconstruction/reconstruction.""" 99 | 100 | export_ctx = dict( 101 | exclude_defaults=extractor(ctx, "exclude_defaults", None), 102 | exclude_none=extractor(ctx, "exclude_none", None), 103 | exclude_unset=extractor(ctx, "exclude_unset", None), 104 | by_alias=extractor(ctx, "by_alias", None), 105 | # extract json.dumps(...) kwargs, see: https://docs.pydantic.dev/1.10/usage/exporting_models/#modeljson 106 | skipkeys=extractor(ctx, "skipkeys", None), 107 | indent=extractor(ctx, "indent", None), 108 | separators=extractor(ctx, "separators", None), 109 | allow_nan=extractor(ctx, "allow_nan", None), 110 | sort_keys=extractor(ctx, "sort_keys", None), 111 | ) 112 | include_fields = extractor(ctx, "include", None) 113 | if include_fields is not None: 114 | export_ctx["include"] = {"__root__": include_fields} 115 | 116 | exclude_fields = extractor(ctx, "exclude", None) 117 | if exclude_fields is not None: 118 | export_ctx["exclude"] = {"__root__": exclude_fields} 119 | 120 | return {k: v for k, v in export_ctx.items() if v is not None} 121 | 122 | 123 | def deconstruct_export_kwargs(ctx: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: 124 | # We want to invert the work that was done in extract_export_kwargs 125 | export_ctx = dict(ctx) 126 | 127 | include_fields = ctx.get("include") 128 | if include_fields is not None: 129 | export_ctx["include"] = include_fields["__root__"] 130 | 131 | exclude_fields = ctx.get("exclude") 132 | if exclude_fields is not None: 133 | export_ctx["exclude"] = exclude_fields["__root__"] 134 | 135 | return export_ctx 136 | 137 | 138 | def _get_field_schema_name(schema) -> str: 139 | return f"FieldSchema[{display_as_type(schema)}]" 140 | 141 | 142 | def _get_field_schema_params(schema, config=None, allow_null=False, **kwargs) -> dict: 143 | root_model = t.Optional[schema] if allow_null else schema 144 | params: t.Dict[str, t.Any] = dict( 145 | kwargs, 146 | __root__=(root_model, ...), 147 | __config__=inherit_configs(schema, config), 148 | ) 149 | return params 150 | -------------------------------------------------------------------------------- /django_pydantic_field/v1/fields.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import json 3 | import typing as t 4 | from functools import partial 5 | 6 | import pydantic 7 | from django.core import exceptions as django_exceptions 8 | from django.db.models.expressions import BaseExpression, Value 9 | from django.db.models.fields import NOT_PROVIDED 10 | from django.db.models.fields.json import JSONField 11 | from django.db.models.query_utils import DeferredAttribute 12 | 13 | from django_pydantic_field.compat.django import BaseContainer, GenericContainer 14 | 15 | from . import base, forms, utils 16 | 17 | __all__ = ("SchemaField",) 18 | 19 | 20 | class SchemaAttribute(DeferredAttribute): 21 | """ 22 | Forces Django to call to_python on fields when setting them. 23 | This is useful when you want to add some custom field data postprocessing. 24 | 25 | Should be added to field like a so: 26 | 27 | ``` 28 | def contribute_to_class(self, cls, name, *args, **kwargs): 29 | super().contribute_to_class(cls, name, *args, **kwargs) 30 | setattr(cls, name, SchemaDeferredAttribute(self)) 31 | ``` 32 | """ 33 | 34 | field: "PydanticSchemaField" 35 | 36 | def __set__(self, obj, value): 37 | obj.__dict__[self.field.attname] = self.field.to_python(value) 38 | 39 | 40 | class UninitializedSchemaAttribute(SchemaAttribute): 41 | def __set__(self, obj, value): 42 | if value is not None: 43 | value = self.field.to_python(value) 44 | obj.__dict__[self.field.attname] = value 45 | 46 | 47 | class PydanticSchemaField(JSONField, t.Generic[base.ST]): 48 | _is_prepared_schema: bool = False 49 | 50 | def __init__( 51 | self, 52 | *args, 53 | schema: t.Union[t.Type["base.ST"], "BaseContainer", "t.ForwardRef", str, None] = None, 54 | config: t.Optional["base.ConfigType"] = None, 55 | **kwargs, 56 | ): 57 | self.export_params = base.extract_export_kwargs(kwargs, dict.pop) 58 | super().__init__(*args, **kwargs) 59 | 60 | self.config = config 61 | self._resolve_schema(schema) 62 | 63 | def __copy__(self): 64 | _, _, args, kwargs = self.deconstruct() 65 | copied = type(self)(*args, **kwargs) 66 | copied.set_attributes_from_name(self.name) 67 | return copied 68 | 69 | def get_default(self): 70 | default_value = super().get_default() 71 | if self.has_default(): 72 | return self.to_python(default_value) 73 | return default_value 74 | 75 | def to_python(self, value) -> "base.SchemaT": 76 | # Attempt to resolve forward referencing schema if it was not succesful 77 | # during `.contribute_to_class` call 78 | if not self._is_prepared_schema: 79 | self._prepare_model_schema() 80 | try: 81 | assert self.decoder is not None 82 | return self.decoder().decode(value) 83 | except pydantic.ValidationError as e: 84 | raise django_exceptions.ValidationError(str(e)) from e 85 | 86 | def get_prep_value(self, value): 87 | if not self._is_prepared_schema: 88 | self._prepare_model_schema() 89 | 90 | if isinstance(value, Value) and isinstance(value.output_field, self.__class__): 91 | # Prepare inner value for `Value`-wrapped expressions. 92 | value = Value(self.get_prep_value(value.value), value.output_field) 93 | elif not isinstance(value, BaseExpression): 94 | # Prepare the value if it is not a query expression. 95 | with contextlib.suppress(Exception): 96 | value = self.to_python(value) 97 | value = json.loads(self.encoder().encode(value)) 98 | 99 | return super().get_prep_value(value) 100 | 101 | def deconstruct(self): 102 | name, path, args, kwargs = super().deconstruct() 103 | if path.startswith("django_pydantic_field.v1."): 104 | path = path.replace("django_pydantic_field.v1", "django_pydantic_field", 1) 105 | 106 | self._deconstruct_schema(kwargs) 107 | self._deconstruct_default(kwargs) 108 | self._deconstruct_config(kwargs) 109 | 110 | kwargs.pop("decoder") 111 | kwargs.pop("encoder") 112 | 113 | return name, path, args, kwargs 114 | 115 | @staticmethod 116 | def descriptor_class(field: "PydanticSchemaField") -> DeferredAttribute: 117 | if field.has_default(): 118 | return SchemaAttribute(field) 119 | return UninitializedSchemaAttribute(field) 120 | 121 | def contribute_to_class(self, cls, name, private_only=False): 122 | if self.schema is None: 123 | self._resolve_schema_from_type_hints(cls, name) 124 | 125 | try: 126 | self._prepare_model_schema(cls) 127 | except NameError: 128 | # Pydantic was not able to resolve forward references, which means 129 | # that it should be postponed until initial access to the field 130 | self._is_prepared_schema = False 131 | 132 | super().contribute_to_class(cls, name, private_only) 133 | 134 | def formfield(self, **kwargs): 135 | if self.schema is None: 136 | self._resolve_schema_from_type_hints(self.model, self.attname) 137 | 138 | owner_model = getattr(self, "model", None) 139 | field_kwargs = dict( 140 | form_class=forms.SchemaField, 141 | schema=self.schema, 142 | config=self.config, 143 | __module__=getattr(owner_model, "__module__", None), 144 | **self.export_params, 145 | ) 146 | field_kwargs.update(kwargs) 147 | return super().formfield(**field_kwargs) 148 | 149 | def value_to_string(self, obj): 150 | value = self.value_from_object(obj) 151 | return self.get_prep_value(value) 152 | 153 | def _resolve_schema(self, schema): 154 | schema = t.cast(t.Type["base.ST"], BaseContainer.unwrap(schema)) 155 | 156 | self.schema = schema 157 | if schema is not None: 158 | self.serializer_schema = serializer = base.wrap_schema(schema, self.config, self.null) 159 | self.decoder = partial(base.SchemaDecoder, serializer) # type: ignore 160 | self.encoder = partial(base.SchemaEncoder, schema=serializer, export=self.export_params) # type: ignore 161 | 162 | def _resolve_schema_from_type_hints(self, cls, name): 163 | annotated_schema = utils.get_annotated_type(cls, name) 164 | if annotated_schema is None: 165 | raise django_exceptions.FieldError( 166 | f"{cls._meta.label}.{name} needs to be either annotated " 167 | "or `schema=` field attribute should be explicitly passed" 168 | ) 169 | self._resolve_schema(annotated_schema) 170 | 171 | def _prepare_model_schema(self, cls=None): 172 | cls = cls or getattr(self, "model", None) 173 | if cls is not None: 174 | base.prepare_schema(self.serializer_schema, cls) 175 | self._is_prepared_schema = True 176 | 177 | def _deconstruct_default(self, kwargs): 178 | default = kwargs.get("default", NOT_PROVIDED) 179 | if default is not NOT_PROVIDED and not callable(default): 180 | if self._is_prepared_schema: 181 | default = self.get_prep_value(default) 182 | kwargs.update(default=default) 183 | 184 | def _deconstruct_schema(self, kwargs): 185 | kwargs.update(schema=GenericContainer.wrap(self.schema)) 186 | 187 | def _deconstruct_config(self, kwargs): 188 | kwargs.update(base.deconstruct_export_kwargs(self.export_params)) 189 | kwargs.update(config=self.config) 190 | 191 | 192 | if t.TYPE_CHECKING: 193 | OptSchemaT = t.Optional[base.SchemaT] 194 | 195 | 196 | @t.overload 197 | def SchemaField( 198 | schema: "t.Union[t.Type[t.Optional[base.ST]], t.ForwardRef]" = ..., 199 | config: "base.ConfigType" = ..., 200 | default: "t.Union[OptSchemaT, t.Callable[[], OptSchemaT]]" = ..., 201 | *args, 202 | null: "t.Literal[True]", 203 | **kwargs, 204 | ) -> "t.Optional[base.ST]": ... 205 | 206 | 207 | @t.overload 208 | def SchemaField( 209 | schema: "t.Union[t.Type[base.ST], t.ForwardRef]" = ..., 210 | config: "base.ConfigType" = ..., 211 | default: "t.Union[base.SchemaT, t.Callable[[], base.SchemaT]]" = ..., 212 | *args, 213 | null: "t.Literal[False]" = ..., 214 | **kwargs, 215 | ) -> "base.ST": ... 216 | 217 | 218 | def SchemaField(schema=None, config=None, default=NOT_PROVIDED, *args, **kwargs) -> t.Any: # type: ignore 219 | kwargs.update(schema=schema, config=config, default=default) 220 | return PydanticSchemaField(*args, **kwargs) 221 | -------------------------------------------------------------------------------- /django_pydantic_field/v1/forms.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | from functools import partial 5 | 6 | import pydantic 7 | from django.core.exceptions import ValidationError 8 | from django.forms.fields import InvalidJSONInput, JSONField 9 | from django.utils.translation import gettext_lazy as _ 10 | 11 | from . import base 12 | 13 | __all__ = ("SchemaField",) 14 | 15 | 16 | class SchemaField(JSONField, t.Generic[base.ST]): 17 | default_error_messages = { 18 | "schema_error": _("Schema didn't match. Detail: %(detail)s"), 19 | } 20 | decoder: partial[base.SchemaDecoder] 21 | encoder: partial[base.SchemaEncoder] 22 | 23 | def __init__( 24 | self, 25 | schema: t.Union[t.Type["base.ST"], t.ForwardRef], 26 | config: t.Optional["base.ConfigType"] = None, 27 | __module__: t.Optional[str] = None, 28 | **kwargs, 29 | ): 30 | self.schema = base.wrap_schema( 31 | schema, 32 | config, 33 | allow_null=not kwargs.get("required", True), 34 | __module__=__module__, 35 | ) 36 | export_params = base.extract_export_kwargs(kwargs, dict.pop) 37 | decoder: partial[base.SchemaDecoder] = partial(base.SchemaDecoder, self.schema) 38 | encoder: partial[base.SchemaEncoder] = partial( 39 | base.SchemaEncoder, 40 | schema=self.schema, 41 | export=export_params, 42 | raise_errors=True, 43 | ) 44 | kwargs.update(encoder=encoder, decoder=decoder) 45 | super().__init__(**kwargs) 46 | 47 | def to_python(self, value): 48 | try: 49 | return super().to_python(value) 50 | except pydantic.ValidationError as e: 51 | raise ValidationError( 52 | self.error_messages["schema_error"], 53 | code="invalid", 54 | params={ 55 | "value": value, 56 | "detail": str(e), 57 | "errors": e.errors(), 58 | "json": e.json(), 59 | }, 60 | ) 61 | 62 | def bound_data(self, data, initial): 63 | try: 64 | return super().bound_data(data, initial) 65 | except pydantic.ValidationError: 66 | return InvalidJSONInput(data) 67 | 68 | def get_bound_field(self, form, field_name): 69 | base.prepare_schema(self.schema, form) 70 | return super().get_bound_field(form, field_name) 71 | -------------------------------------------------------------------------------- /django_pydantic_field/v1/rest_framework.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from django.conf import settings 4 | from pydantic import BaseModel, ValidationError 5 | from rest_framework import exceptions, parsers, renderers, serializers 6 | from rest_framework.schemas import openapi 7 | from rest_framework.schemas.utils import is_list_view 8 | 9 | from django_pydantic_field.compat.typing import get_args 10 | 11 | from . import base 12 | 13 | __all__ = ( 14 | "SchemaField", 15 | "SchemaRenderer", 16 | "SchemaParser", 17 | "AutoSchema", 18 | ) 19 | 20 | if t.TYPE_CHECKING: 21 | RequestResponseContext = t.Mapping[str, t.Any] 22 | 23 | 24 | class AnnotatedSchemaT(t.Generic[base.ST]): 25 | schema_ctx_attr: t.ClassVar[str] = "schema" 26 | require_explicit_schema: t.ClassVar[bool] = False 27 | _cached_annotation_schema: t.Type[BaseModel] 28 | 29 | def get_schema(self, ctx: "RequestResponseContext") -> t.Optional[t.Type[BaseModel]]: 30 | schema = self.get_context_schema(ctx) 31 | if schema is None: 32 | schema = self.get_annotation_schema(ctx) 33 | 34 | if self.require_explicit_schema and schema is None: 35 | raise ValueError("Schema should be either explicitly set with annotation or passed in the context") 36 | 37 | return schema 38 | 39 | def get_context_schema(self, ctx: "RequestResponseContext"): 40 | schema = ctx.get(self.schema_ctx_attr) 41 | if schema is not None: 42 | schema = base.wrap_schema(schema) 43 | base.prepare_schema(schema, ctx.get("view")) 44 | 45 | return schema 46 | 47 | def get_annotation_schema(self, ctx: "RequestResponseContext"): 48 | try: 49 | schema = self._cached_annotation_schema 50 | except AttributeError: 51 | try: 52 | schema = get_args(self.__orig_class__)[0] # type: ignore 53 | except (AttributeError, IndexError): 54 | return None 55 | 56 | self._cached_annotation_schema = schema = base.wrap_schema(schema) 57 | base.prepare_schema(schema, ctx.get("view")) 58 | 59 | return schema 60 | 61 | 62 | class SchemaField(serializers.Field, t.Generic[base.ST]): 63 | decoder: "base.SchemaDecoder[base.ST]" 64 | _is_prepared_schema: bool = False 65 | 66 | def __init__( 67 | self, 68 | schema: t.Type["base.ST"], 69 | config: t.Optional["base.ConfigType"] = None, 70 | **kwargs, 71 | ): 72 | nullable = kwargs.get("allow_null", False) 73 | 74 | self.schema = field_schema = base.wrap_schema(schema, config, nullable) 75 | self.export_params = base.extract_export_kwargs(kwargs, dict.pop) 76 | self.decoder = base.SchemaDecoder(field_schema) 77 | super().__init__(**kwargs) 78 | 79 | def bind(self, field_name, parent): 80 | if not self._is_prepared_schema: 81 | base.prepare_schema(self.schema, parent) 82 | self._is_prepared_schema = True 83 | 84 | super().bind(field_name, parent) 85 | 86 | def to_internal_value(self, data: t.Any) -> t.Optional["base.ST"]: 87 | try: 88 | return self.decoder.decode(data) 89 | except ValidationError as e: 90 | raise serializers.ValidationError(e.errors(), self.field_name) # type: ignore[arg-type] 91 | 92 | def to_representation(self, value: t.Optional["base.ST"]) -> t.Any: 93 | obj = self.schema.parse_obj(value) 94 | raw_obj = obj.dict(**self.export_params) 95 | return raw_obj["__root__"] 96 | 97 | 98 | class SchemaRenderer(AnnotatedSchemaT[base.ST], renderers.JSONRenderer): 99 | schema_ctx_attr = "render_schema" 100 | 101 | def render(self, data, accepted_media_type=None, renderer_context=None): 102 | renderer_context = renderer_context or {} 103 | response = renderer_context.get("response") 104 | if response is not None and response.exception: 105 | return super().render(data, accepted_media_type, renderer_context) 106 | 107 | try: 108 | json_str = self.render_data(data, renderer_context) 109 | except ValidationError as e: 110 | json_str = e.json().encode() 111 | except AttributeError: 112 | json_str = super().render(data, accepted_media_type, renderer_context) 113 | 114 | return json_str 115 | 116 | def render_data(self, data, renderer_ctx) -> bytes: 117 | schema = self.get_schema(renderer_ctx or {}) 118 | if schema is not None: 119 | data = schema(__root__=data) 120 | 121 | export_kw = base.extract_export_kwargs(renderer_ctx) 122 | json_str = data.json(**export_kw, ensure_ascii=self.ensure_ascii) 123 | return json_str.encode() 124 | 125 | 126 | class SchemaParser(AnnotatedSchemaT[base.ST], parsers.JSONParser): 127 | schema_ctx_attr = "parser_schema" 128 | renderer_class = SchemaRenderer 129 | require_explicit_schema = True 130 | 131 | def parse(self, stream, media_type=None, parser_context=None): 132 | parser_context = parser_context or {} 133 | encoding = parser_context.get("encoding", settings.DEFAULT_CHARSET) 134 | schema = t.cast(BaseModel, self.get_schema(parser_context)) 135 | 136 | try: 137 | return schema.parse_raw(stream.read(), encoding=encoding).__root__ 138 | except ValidationError as e: 139 | raise exceptions.ParseError(e.errors()) 140 | 141 | 142 | class AutoSchema(openapi.AutoSchema): 143 | get_request_serializer: t.Callable 144 | _get_reference: t.Callable 145 | 146 | def map_field(self, field: serializers.Field): 147 | if isinstance(field, SchemaField): 148 | return field.schema.schema() 149 | return super().map_field(field) 150 | 151 | def map_parsers(self, path: str, method: str): 152 | request_types: t.List[t.Any] = [] 153 | parser_ctx = self.view.get_parser_context(None) 154 | 155 | for parser_type in self.view.parser_classes: 156 | parser = parser_type() 157 | 158 | if isinstance(parser, SchemaParser): 159 | schema = self._extract_openapi_schema(parser, parser_ctx) 160 | if schema is not None: 161 | request_types.append((parser.media_type, schema)) 162 | else: 163 | request_types.append(parser.media_type) 164 | else: 165 | request_types.append(parser.media_type) 166 | 167 | return request_types 168 | 169 | def map_renderers(self, path: str, method: str): 170 | response_types: t.List[t.Any] = [] 171 | renderer_ctx = self.view.get_renderer_context() 172 | 173 | for renderer_type in self.view.renderer_classes: 174 | renderer = renderer_type() 175 | 176 | if isinstance(renderer, SchemaRenderer): 177 | schema = self._extract_openapi_schema(renderer, renderer_ctx) 178 | if schema is not None: 179 | response_types.append((renderer.media_type, schema)) 180 | else: 181 | response_types.append(renderer.media_type) 182 | 183 | elif not isinstance(renderer, renderers.BrowsableAPIRenderer): 184 | response_types.append(renderer.media_type) 185 | 186 | return response_types 187 | 188 | def get_request_body(self, path: str, method: str): 189 | if method not in ("PUT", "PATCH", "POST"): 190 | return {} 191 | 192 | self.request_media_types = self.map_parsers(path, method) 193 | serializer = self.get_request_serializer(path, method) 194 | content_schemas = {} 195 | 196 | for request_type in self.request_media_types: 197 | if isinstance(request_type, tuple): 198 | media_type, request_schema = request_type 199 | content_schemas[media_type] = {"schema": request_schema} 200 | else: 201 | serializer_ref = self._get_reference(serializer) 202 | content_schemas[request_type] = {"schema": serializer_ref} 203 | 204 | return {"content": content_schemas} 205 | 206 | def get_responses(self, path: str, method: str): 207 | if method == "DELETE": 208 | return {"204": {"description": ""}} 209 | 210 | self.response_media_types = self.map_renderers(path, method) 211 | status_code = "201" if method == "POST" else "200" 212 | content_types = {} 213 | 214 | for response_type in self.response_media_types: 215 | if isinstance(response_type, tuple): 216 | media_type, response_schema = response_type 217 | content_types[media_type] = {"schema": response_schema} 218 | else: 219 | response_schema = self._get_serializer_response_schema(path, method) 220 | content_types[response_type] = {"schema": response_schema} 221 | 222 | return { 223 | status_code: { 224 | "content": content_types, 225 | "description": "", 226 | } 227 | } 228 | 229 | def _extract_openapi_schema(self, schemable: AnnotatedSchemaT, ctx: "RequestResponseContext"): 230 | schema_model = schemable.get_schema(ctx) 231 | if schema_model is not None: 232 | return schema_model.schema() 233 | return None 234 | 235 | def _get_serializer_response_schema(self, path, method): 236 | serializer = self.get_response_serializer(path, method) 237 | 238 | if not isinstance(serializer, serializers.Serializer): 239 | item_schema = {} 240 | else: 241 | item_schema = self._get_reference(serializer) 242 | 243 | if is_list_view(path, method, self.view): 244 | response_schema = { 245 | "type": "array", 246 | "items": item_schema, 247 | } 248 | paginator = self.get_paginator() 249 | if paginator: 250 | response_schema = paginator.get_paginated_response_schema(response_schema) 251 | else: 252 | response_schema = item_schema 253 | return response_schema 254 | -------------------------------------------------------------------------------- /django_pydantic_field/v1/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import typing as t 5 | 6 | from pydantic.config import BaseConfig, inherit_config 7 | 8 | if t.TYPE_CHECKING: 9 | from pydantic import BaseModel 10 | 11 | 12 | def get_annotated_type(obj, field, default=None) -> t.Any: 13 | try: 14 | if isinstance(obj, type): 15 | annotations = obj.__dict__["__annotations__"] 16 | else: 17 | annotations = obj.__annotations__ 18 | 19 | return annotations[field] 20 | except (AttributeError, KeyError): 21 | return default 22 | 23 | 24 | def get_local_namespace(cls) -> t.Dict[str, t.Any]: 25 | try: 26 | module = cls.__module__ 27 | return vars(sys.modules[module]) 28 | except (KeyError, AttributeError): 29 | return {} 30 | 31 | 32 | def inherit_configs(parent: t.Type[BaseModel], config: t.Type | dict | None = None) -> t.Type[BaseConfig]: 33 | parent_config = t.cast(t.Type[BaseConfig], getattr(parent, "Config", BaseConfig)) 34 | if config is None: 35 | return parent_config 36 | if isinstance(config, dict): 37 | config = type("Config", (BaseConfig,), config) 38 | return inherit_config(config, parent_config) 39 | -------------------------------------------------------------------------------- /django_pydantic_field/v2/__init__.py: -------------------------------------------------------------------------------- 1 | from django_pydantic_field.compat.pydantic import PYDANTIC_V2 2 | 3 | if not PYDANTIC_V2: 4 | raise ImportError("django_pydantic_field.v2 package is only compatible with Pydantic v2") 5 | 6 | from .fields import SchemaField as SchemaField 7 | -------------------------------------------------------------------------------- /django_pydantic_field/v2/forms.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as ty 4 | import warnings 5 | 6 | import pydantic 7 | from django.core.exceptions import ValidationError 8 | from django.forms.fields import InvalidJSONInput, JSONField, JSONString 9 | from django.utils.translation import gettext_lazy as _ 10 | 11 | from django_pydantic_field.compat import deprecation 12 | 13 | from . import types 14 | 15 | if ty.TYPE_CHECKING: 16 | import typing_extensions as te 17 | from django.forms.widgets import Widget 18 | 19 | 20 | __all__ = ("SchemaField", "JSONFormSchemaWidget") 21 | 22 | 23 | class SchemaField(JSONField, ty.Generic[types.ST]): 24 | adapter: types.SchemaAdapter[types.ST] 25 | default_error_messages = { 26 | "schema_error": _("Schema didn't match for %(title)s."), 27 | } 28 | 29 | def __init__( 30 | self, 31 | schema: type[types.ST] | te.Annotated[type[types.ST], ...] | ty.ForwardRef | str, 32 | config: pydantic.ConfigDict | None = None, 33 | allow_null: bool | None = None, 34 | *args, 35 | **kwargs, 36 | ): 37 | deprecation.truncate_deprecated_v1_export_kwargs(kwargs) 38 | 39 | self.schema = schema 40 | self.config = config 41 | self.export_kwargs = types.SchemaAdapter.extract_export_kwargs(kwargs) 42 | self.adapter = types.SchemaAdapter(schema, config, None, None, allow_null, **self.export_kwargs) 43 | 44 | widget = kwargs.get("widget") 45 | if widget is not None: 46 | kwargs["widget"] = _prepare_jsonform_widget(widget, self.adapter) 47 | 48 | super().__init__(*args, **kwargs) 49 | 50 | def get_bound_field(self, form: ty.Any, field_name: str): 51 | if not self.adapter.is_bound: 52 | self.adapter.bind(form, field_name) 53 | return super().get_bound_field(form, field_name) 54 | 55 | def bound_data(self, data: ty.Any, initial: ty.Any): 56 | if self.disabled: 57 | return self.adapter.validate_python(initial) 58 | if data is None: 59 | return None 60 | try: 61 | return self.adapter.validate_json(data) 62 | except pydantic.ValidationError: 63 | return InvalidJSONInput(data) 64 | 65 | def to_python(self, value: ty.Any) -> ty.Any: 66 | if self.disabled: 67 | return value 68 | if value in self.empty_values: 69 | return None 70 | 71 | try: 72 | value = self._try_coerce(value) 73 | except pydantic.ValidationError as exc: 74 | error_params = {"value": value, "title": exc.title, "detail": exc.json(), "errors": exc.errors()} 75 | raise ValidationError(self.error_messages["schema_error"], code="invalid", params=error_params) from exc 76 | 77 | if isinstance(value, str): 78 | value = JSONString(value) 79 | 80 | return value 81 | 82 | def prepare_value(self, value): 83 | if value is None: 84 | return None 85 | 86 | if isinstance(value, InvalidJSONInput): 87 | return value 88 | 89 | value = self._try_coerce(value) 90 | return self.adapter.dump_json(value).decode() 91 | 92 | def has_changed(self, initial: ty.Any | None, data: ty.Any | None) -> bool: 93 | try: 94 | initial = self._try_coerce(initial) 95 | data = self._try_coerce(data) 96 | return self.adapter.dump_python(initial) != self.adapter.dump_python(data) 97 | except pydantic.ValidationError: 98 | return True 99 | 100 | def _try_coerce(self, value): 101 | if not isinstance(value, (str, bytes)): 102 | # The form data may contain python objects for some cases (e.g. using django-constance). 103 | value = self.adapter.validate_python(value) 104 | elif not isinstance(value, JSONString): 105 | # Otherwise, try to parse incoming JSON according to the schema. 106 | value = self.adapter.validate_json(value) 107 | 108 | return value 109 | 110 | 111 | try: 112 | from django_jsonform.widgets import JSONFormWidget as _JSONFormWidget # type: ignore[import-untyped] 113 | except ImportError: 114 | from django.forms.widgets import Textarea 115 | 116 | def _prepare_jsonform_widget(widget, adapter: types.SchemaAdapter[types.ST]) -> Widget | type[Widget]: 117 | return widget 118 | 119 | class JSONFormSchemaWidget(Textarea): 120 | def __init__(self, *args, **kwargs): 121 | warnings.warn( 122 | "The 'django_jsonform' package is not installed. Please install it to use the widget.", 123 | ImportWarning, 124 | ) 125 | super().__init__(*args, **kwargs) 126 | 127 | else: 128 | 129 | def _prepare_jsonform_widget(widget, adapter: types.SchemaAdapter[types.ST]) -> Widget | type[Widget]: # type: ignore[no-redef] 130 | if not isinstance(widget, type): 131 | return widget 132 | 133 | if issubclass(widget, JSONFormSchemaWidget): 134 | widget = widget( 135 | schema=adapter.prepared_schema, 136 | config=adapter.config, 137 | export_kwargs=adapter.export_kwargs, 138 | allow_null=adapter.allow_null, 139 | ) 140 | elif issubclass(widget, _JSONFormWidget): 141 | widget = widget(schema=adapter.json_schema()) # type: ignore[call-arg] 142 | 143 | return widget 144 | 145 | class JSONFormSchemaWidget(_JSONFormWidget, ty.Generic[types.ST]): # type: ignore[no-redef] 146 | def __init__( 147 | self, 148 | schema: type[types.ST] | te.Annotated[type[types.ST], ...] | ty.ForwardRef | str, 149 | config: pydantic.ConfigDict | None = None, 150 | allow_null: bool | None = None, 151 | export_kwargs: types.ExportKwargs | None = None, 152 | **kwargs, 153 | ): 154 | if export_kwargs is None: 155 | export_kwargs = {} 156 | adapter = types.SchemaAdapter[types.ST](schema, config, None, None, allow_null, **export_kwargs) 157 | super().__init__(adapter.json_schema(), **kwargs) 158 | -------------------------------------------------------------------------------- /django_pydantic_field/v2/rest_framework/__init__.py: -------------------------------------------------------------------------------- 1 | from django_pydantic_field.compat import PYDANTIC_V2 2 | 3 | from . import coreapi as coreapi 4 | from . import openapi as openapi 5 | from .fields import SchemaField as SchemaField 6 | from .parsers import SchemaParser as SchemaParser 7 | from .renderers import SchemaRenderer as SchemaRenderer 8 | 9 | _DEPRECATED_MESSAGE = ( 10 | "`django_pydantic_field.rest_framework.AutoSchema` is deprecated, " 11 | "please use explicit imports for `django_pydantic_field.rest_framework.openapi.AutoSchema` " 12 | "or `django_pydantic_field.rest_framework.coreapi.AutoSchema` instead." 13 | ) 14 | 15 | __all__ = ( 16 | "coreapi", 17 | "openapi", 18 | "SchemaField", 19 | "SchemaParser", 20 | "SchemaRenderer", 21 | "AutoSchema", # type: ignore 22 | ) 23 | 24 | 25 | def __getattr__(key): 26 | if key == "AutoSchema" and PYDANTIC_V2: 27 | import warnings 28 | 29 | from .openapi import AutoSchema 30 | 31 | warnings.warn(_DEPRECATED_MESSAGE, DeprecationWarning) 32 | return AutoSchema 33 | 34 | raise AttributeError(key) 35 | -------------------------------------------------------------------------------- /django_pydantic_field/v2/rest_framework/coreapi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as ty 4 | 5 | from rest_framework.compat import coreapi, coreschema 6 | from rest_framework.schemas.coreapi import AutoSchema as _CoreAPIAutoSchema 7 | 8 | from .fields import SchemaField 9 | 10 | if ty.TYPE_CHECKING: 11 | from coreschema.schemas import Schema as _CoreAPISchema # type: ignore[import-untyped] 12 | from rest_framework.serializers import Serializer 13 | 14 | __all__ = ("AutoSchema",) 15 | 16 | 17 | class AutoSchema(_CoreAPIAutoSchema): 18 | """Not implemented yet.""" 19 | 20 | def get_serializer_fields(self, path: str, method: str) -> list[coreapi.Field]: 21 | base_field_schemas = super().get_serializer_fields(path, method) 22 | if not base_field_schemas: 23 | return [] 24 | 25 | serializer: Serializer = self.view.get_serializer() 26 | pydantic_schema_fields: dict[str, coreapi.Field] = {} 27 | 28 | for field_name, field in serializer.fields.items(): 29 | if not field.read_only and isinstance(field, SchemaField): 30 | pydantic_schema_fields[field_name] = self._prepare_schema_field(field) 31 | 32 | if not pydantic_schema_fields: 33 | return base_field_schemas 34 | 35 | return [pydantic_schema_fields.get(field.name, field) for field in base_field_schemas] 36 | 37 | def _prepare_schema_field(self, field: SchemaField) -> coreapi.Field: 38 | build_core_schema = SimpleCoreSchemaTransformer(field.adapter.json_schema()) 39 | return coreapi.Field( 40 | name=field.field_name, 41 | location="form", 42 | required=field.required, 43 | schema=build_core_schema(), 44 | description=field.help_text, 45 | ) 46 | 47 | 48 | class SimpleCoreSchemaTransformer: 49 | def __init__(self, json_schema: dict[str, ty.Any]): 50 | self.root_schema = json_schema 51 | 52 | def __call__(self) -> _CoreAPISchema: 53 | definitions = self._populate_definitions() 54 | root_schema = self._transform(self.root_schema) 55 | 56 | if definitions: 57 | if isinstance(root_schema, coreschema.Ref): 58 | schema_name = root_schema.ref_name 59 | else: 60 | schema_name = root_schema.title or "Schema" 61 | definitions[schema_name] = root_schema 62 | 63 | root_schema = coreschema.RefSpace(definitions, schema_name) 64 | 65 | return root_schema 66 | 67 | def _populate_definitions(self): 68 | schemas = self.root_schema.get("$defs", {}) 69 | return {ref_name: self._transform(schema) for ref_name, schema in schemas.items()} 70 | 71 | def _transform(self, schema) -> _CoreAPISchema: 72 | schemas = [ 73 | *self._transform_type_schema(schema), 74 | *self._transform_composite_types(schema), 75 | *self._transform_ref(schema), 76 | ] 77 | if not schemas: 78 | schema = self._transform_any(schema) 79 | elif len(schemas) == 1: 80 | schema = schemas[0] 81 | else: 82 | schema = coreschema.Intersection(schemas) 83 | return schema 84 | 85 | def _transform_type_schema(self, schema): 86 | schema_type = schema.get("type", None) 87 | 88 | if schema_type is not None: 89 | schema_types = schema_type if isinstance(schema_type, list) else [schema_type] 90 | 91 | for schema_type in schema_types: 92 | transformer = getattr(self, f"transform_{schema_type}") 93 | yield transformer(schema) 94 | 95 | def _transform_composite_types(self, schema): 96 | for operation, transform_name in self.COMBINATOR_TYPES.items(): 97 | value = schema.get(operation, None) 98 | 99 | if value is not None: 100 | transformer = getattr(self, transform_name) 101 | yield transformer(schema) 102 | 103 | def _transform_ref(self, schema): 104 | reference = schema.get("$ref", None) 105 | if reference is not None: 106 | yield coreschema.Ref(reference) 107 | 108 | def _transform_any(self, schema): 109 | attrs = self._get_common_attributes(schema) 110 | return coreschema.Anything(**attrs) 111 | 112 | # Simple types transformers 113 | 114 | def transform_object(self, schema) -> coreschema.Object: 115 | properties = schema.get("properties", None) 116 | if properties is not None: 117 | properties = {prop: self._transform(prop_schema) for prop, prop_schema in properties.items()} 118 | 119 | pattern_props = schema.get("patternProperties", None) 120 | if pattern_props is not None: 121 | pattern_props = {pattern: self._transform(prop_schema) for pattern, prop_schema in pattern_props.items()} 122 | 123 | extra_props = schema.get("additionalProperties", None) 124 | if extra_props is not None: 125 | if extra_props not in (True, False): 126 | extra_props = self._transform(schema) 127 | 128 | return coreschema.Object( 129 | properties=properties, 130 | pattern_properties=pattern_props, 131 | additional_properties=extra_props, # type: ignore 132 | min_properties=schema.get("minProperties"), 133 | max_properties=schema.get("maxProperties"), 134 | required=schema.get("required", []), 135 | **self._get_common_attributes(schema), 136 | ) 137 | 138 | def transform_array(self, schema) -> coreschema.Array: 139 | items = schema.get("items", None) 140 | if items is not None: 141 | if isinstance(items, list): 142 | items = list(map(self._transform, items)) 143 | elif items not in (True, False): 144 | items = self._transform(items) 145 | 146 | extra_items = schema.get("additionalItems") 147 | if extra_items is not None: 148 | if isinstance(items, list): 149 | items = list(map(self._transform, items)) 150 | elif items not in (True, False): 151 | items = self._transform(items) 152 | 153 | return coreschema.Array( 154 | items=items, 155 | additional_items=extra_items, 156 | min_items=schema.get("minItems"), 157 | max_items=schema.get("maxItems"), 158 | unique_items=schema.get("uniqueItems"), 159 | **self._get_common_attributes(schema), 160 | ) 161 | 162 | def transform_boolean(self, schema) -> coreschema.Boolean: 163 | attrs = self._get_common_attributes(schema) 164 | return coreschema.Boolean(**attrs) 165 | 166 | def transform_integer(self, schema) -> coreschema.Integer: 167 | return self._transform_numeric(schema, cls=coreschema.Integer) 168 | 169 | def transform_null(self, schema) -> coreschema.Null: 170 | attrs = self._get_common_attributes(schema) 171 | return coreschema.Null(**attrs) 172 | 173 | def transform_number(self, schema) -> coreschema.Number: 174 | return self._transform_numeric(schema, cls=coreschema.Number) 175 | 176 | def transform_string(self, schema) -> coreschema.String: 177 | return coreschema.String( 178 | min_length=schema.get("minLength"), 179 | max_length=schema.get("maxLength"), 180 | pattern=schema.get("pattern"), 181 | format=schema.get("format"), 182 | **self._get_common_attributes(schema), 183 | ) 184 | 185 | # Composite types transformers 186 | 187 | COMBINATOR_TYPES = { 188 | "anyOf": "transform_union", 189 | "oneOf": "transform_exclusive_union", 190 | "allOf": "transform_intersection", 191 | "not": "transform_not", 192 | } 193 | 194 | def transform_union(self, schema): 195 | return coreschema.Union([self._transform(option) for option in schema["anyOf"]]) 196 | 197 | def transform_exclusive_union(self, schema): 198 | return coreschema.ExclusiveUnion([self._transform(option) for option in schema["oneOf"]]) 199 | 200 | def transform_intersection(self, schema): 201 | return coreschema.Intersection([self._transform(option) for option in schema["allOf"]]) 202 | 203 | def transform_not(self, schema): 204 | return coreschema.Not(self._transform(schema["not"])) 205 | 206 | # Common schema transformations 207 | 208 | def _get_common_attributes(self, schema): 209 | return dict( 210 | title=schema.get("title"), 211 | description=schema.get("description"), 212 | default=schema.get("default"), 213 | ) 214 | 215 | def _transform_numeric(self, schema, cls): 216 | return cls( 217 | minimum=schema.get("minimum"), 218 | maximum=schema.get("maximum"), 219 | exclusive_minimum=schema.get("exclusiveMinimum"), 220 | exclusive_maximum=schema.get("exclusiveMaximum"), 221 | multiple_of=schema.get("multipleOf"), 222 | **self._get_common_attributes(schema), 223 | ) 224 | -------------------------------------------------------------------------------- /django_pydantic_field/v2/rest_framework/fields.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as ty 4 | 5 | import pydantic 6 | from rest_framework import exceptions, fields 7 | 8 | from django_pydantic_field.compat import deprecation 9 | from django_pydantic_field.v2 import types 10 | 11 | if ty.TYPE_CHECKING: 12 | from collections.abc import Mapping 13 | 14 | from rest_framework.serializers import BaseSerializer 15 | 16 | RequestResponseContext = Mapping[str, ty.Any] 17 | 18 | 19 | class SchemaField(fields.Field, ty.Generic[types.ST]): 20 | adapter: types.SchemaAdapter 21 | 22 | def __init__( 23 | self, 24 | schema: type[types.ST], 25 | config: pydantic.ConfigDict | None = None, 26 | **kwargs, 27 | ): 28 | deprecation.truncate_deprecated_v1_export_kwargs(kwargs) 29 | allow_null = kwargs.get("allow_null", False) 30 | 31 | self.schema = schema 32 | self.config = config 33 | self.export_kwargs = types.SchemaAdapter.extract_export_kwargs(kwargs) 34 | self.adapter = types.SchemaAdapter(schema, config, None, None, allow_null, **self.export_kwargs) 35 | super().__init__(**kwargs) 36 | 37 | def bind(self, field_name: str, parent: BaseSerializer): 38 | if not self.adapter.is_bound: 39 | self.adapter.bind(type(parent), field_name) 40 | super().bind(field_name, parent) 41 | 42 | def to_internal_value(self, data: ty.Any): 43 | try: 44 | if isinstance(data, (str, bytes)): 45 | return self.adapter.validate_json(data) 46 | return self.adapter.validate_python(data) 47 | except pydantic.ValidationError as exc: 48 | raise exceptions.ValidationError(exc.errors(), code="invalid") # type: ignore 49 | 50 | def to_representation(self, value: ty.Optional[types.ST]): 51 | try: 52 | prep_value = self.adapter.validate_python(value) 53 | return self.adapter.dump_python(prep_value) 54 | except pydantic.ValidationError as exc: 55 | raise exceptions.ValidationError(exc.errors(), code="invalid") # type: ignore 56 | -------------------------------------------------------------------------------- /django_pydantic_field/v2/rest_framework/mixins.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as ty 4 | 5 | from django_pydantic_field.compat.typing import get_args 6 | from django_pydantic_field.v2 import types 7 | 8 | if ty.TYPE_CHECKING: 9 | from collections.abc import Mapping 10 | 11 | RequestResponseContext = Mapping[str, ty.Any] 12 | 13 | 14 | class AnnotatedAdapterMixin(ty.Generic[types.ST]): 15 | media_type: ty.ClassVar[str] 16 | schema_context_key: ty.ClassVar[str] = "response_schema" 17 | config_context_key: ty.ClassVar[str] = "response_schema_config" 18 | 19 | def get_adapter(self, ctx: RequestResponseContext) -> types.SchemaAdapter[types.ST] | None: 20 | adapter = self._make_adapter_from_context(ctx) 21 | if adapter is None: 22 | adapter = self._make_adapter_from_annotation(ctx) 23 | 24 | return adapter 25 | 26 | def _make_adapter_from_context(self, ctx: RequestResponseContext) -> types.SchemaAdapter[types.ST] | None: 27 | schema = ctx.get(self.schema_context_key) 28 | if schema is not None: 29 | config = ctx.get(self.config_context_key) 30 | export_kwargs = types.SchemaAdapter.extract_export_kwargs(dict(ctx)) 31 | return types.SchemaAdapter(schema, config, type(ctx.get("view")), None, **export_kwargs) 32 | 33 | return schema 34 | 35 | def _make_adapter_from_annotation(self, ctx: RequestResponseContext) -> types.SchemaAdapter[types.ST] | None: 36 | try: 37 | schema = get_args(self.__orig_class__)[0] # type: ignore 38 | except (AttributeError, IndexError): 39 | return None 40 | 41 | config = ctx.get(self.config_context_key) 42 | export_kwargs = types.SchemaAdapter.extract_export_kwargs(dict(ctx)) 43 | return types.SchemaAdapter(schema, config, type(ctx.get("view")), None, **export_kwargs) 44 | -------------------------------------------------------------------------------- /django_pydantic_field/v2/rest_framework/openapi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as ty 4 | 5 | import pydantic 6 | from rest_framework import serializers 7 | from rest_framework.schemas import openapi 8 | from rest_framework.schemas import utils as drf_schema_utils 9 | from rest_framework.test import APIRequestFactory 10 | 11 | from django_pydantic_field.v2 import utils 12 | 13 | from . import fields, parsers, renderers 14 | 15 | if ty.TYPE_CHECKING: 16 | from collections.abc import Iterable 17 | 18 | from pydantic.json_schema import JsonSchemaMode 19 | 20 | from . import mixins 21 | 22 | 23 | class AutoSchema(openapi.AutoSchema): 24 | REF_TEMPLATE_PREFIX = "#/components/schemas/{model}" 25 | 26 | def __init__(self, tags=None, operation_id_base=None, component_name=None) -> None: 27 | super().__init__(tags, operation_id_base, component_name) 28 | self.collected_schema_defs: dict[str, ty.Any] = {} 29 | self.collected_adapter_schema_refs: dict[str, ty.Any] = {} 30 | self.adapter_mode: JsonSchemaMode = "validation" 31 | self.rf = APIRequestFactory() 32 | 33 | def get_components(self, path: str, method: str) -> dict[str, ty.Any]: 34 | if method.lower() == "delete": 35 | return {} 36 | 37 | request_serializer = self.get_request_serializer(path, method) # type: ignore[attr-defined] 38 | response_serializer = self.get_response_serializer(path, method) # type: ignore[attr-defined] 39 | 40 | components = { 41 | **self._collect_serializer_component(response_serializer, "serialization"), 42 | **self._collect_serializer_component(request_serializer, "validation"), 43 | } 44 | if self.collected_schema_defs: 45 | components.update(self.collected_schema_defs) 46 | self.collected_schema_defs = {} 47 | 48 | return components 49 | 50 | def get_request_body(self, path, method): 51 | if method not in ("PUT", "PATCH", "POST"): 52 | return {} 53 | 54 | self.request_media_types = self.map_parsers(path, method) 55 | 56 | request_schema = {} 57 | serializer = self.get_request_serializer(path, method) 58 | if isinstance(serializer, serializers.Serializer): 59 | request_schema = self.get_reference(serializer) 60 | 61 | schema_content = {} 62 | 63 | for parser, ct in zip(self.view.parser_classes, self.request_media_types): 64 | if issubclass(utils.get_origin_type(parser), parsers.SchemaParser): 65 | parser_schema = self.collected_adapter_schema_refs[repr(parser)] 66 | else: 67 | parser_schema = request_schema 68 | 69 | schema_content[ct] = {"schema": parser_schema} 70 | 71 | return {"content": schema_content} 72 | 73 | def get_responses(self, path, method): 74 | if method == "DELETE": 75 | return {"204": {"description": ""}} 76 | 77 | self.response_media_types = self.map_renderers(path, method) 78 | serializer = self.get_response_serializer(path, method) 79 | 80 | response_schema = {} 81 | if isinstance(serializer, serializers.Serializer): 82 | response_schema = self.get_reference(serializer) 83 | 84 | is_list_view = drf_schema_utils.is_list_view(path, method, self.view) 85 | if is_list_view: 86 | response_schema = self._get_paginated_schema(response_schema) 87 | 88 | schema_content = {} 89 | for renderer, ct in zip(self.view.renderer_classes, self.response_media_types): 90 | if issubclass(utils.get_origin_type(renderer), renderers.SchemaRenderer): 91 | renderer_schema = {"schema": self.collected_adapter_schema_refs[repr(renderer)]} 92 | if is_list_view: 93 | renderer_schema = self._get_paginated_schema(renderer_schema) 94 | schema_content[ct] = renderer_schema 95 | else: 96 | schema_content[ct] = response_schema 97 | 98 | status_code = "201" if method == "POST" else "200" 99 | return { 100 | status_code: { 101 | "content": schema_content, 102 | "description": "", 103 | } 104 | } 105 | 106 | def map_parsers(self, path: str, method: str) -> list[str]: 107 | schema_parsers = [] 108 | media_types = [] 109 | 110 | for parser in self.view.parser_classes: 111 | media_types.append(parser.media_type) 112 | if issubclass(utils.get_origin_type(parser), parsers.SchemaParser): 113 | schema_parsers.append(parser) 114 | 115 | if schema_parsers: 116 | self.adapter_mode = "validation" 117 | request = self.rf.generic(method, path) 118 | schemas = self._collect_adapter_components(schema_parsers, self.view.get_parser_context(request)) 119 | self.collected_adapter_schema_refs.update(schemas) 120 | 121 | return media_types 122 | 123 | def map_renderers(self, path: str, method: str) -> list[str]: 124 | schema_renderers = [] 125 | media_types = [] 126 | 127 | for renderer in self.view.renderer_classes: 128 | media_types.append(renderer.media_type) 129 | if issubclass(utils.get_origin_type(renderer), renderers.SchemaRenderer): 130 | schema_renderers.append(renderer) 131 | 132 | if schema_renderers: 133 | self.adapter_mode = "serialization" 134 | schemas = self._collect_adapter_components(schema_renderers, self.view.get_renderer_context()) 135 | self.collected_adapter_schema_refs.update(schemas) 136 | 137 | return media_types 138 | 139 | def map_serializer(self, serializer): 140 | component_content = super().map_serializer(serializer) 141 | field_adapters = [] 142 | 143 | for field in serializer.fields.values(): 144 | if isinstance(field, fields.SchemaField): 145 | field_adapters.append((field.field_name, self.adapter_mode, field.adapter.type_adapter)) 146 | 147 | if field_adapters: 148 | field_schemas = self._collect_type_adapter_schemas(field_adapters) 149 | for field_name, field_schema in field_schemas.items(): 150 | component_content["properties"][field_name] = field_schema 151 | 152 | return component_content 153 | 154 | def _collect_serializer_component(self, serializer: serializers.BaseSerializer | None, mode: JsonSchemaMode): 155 | schema_definition = {} 156 | if isinstance(serializer, serializers.Serializer): 157 | self.adapter_mode = mode 158 | component_name = self.get_component_name(serializer) 159 | schema_definition[component_name] = self.map_serializer(serializer) 160 | return schema_definition 161 | 162 | def _collect_adapter_components(self, components: Iterable[type[mixins.AnnotatedAdapterMixin]], context: dict): 163 | type_adapters = [] 164 | 165 | for component in components: 166 | schema_adapter = component().get_adapter(context) 167 | if schema_adapter is not None: 168 | type_adapters.append((repr(component), self.adapter_mode, schema_adapter.type_adapter)) 169 | 170 | if type_adapters: 171 | return self._collect_type_adapter_schemas(type_adapters) 172 | 173 | return {} 174 | 175 | def _collect_type_adapter_schemas(self, adapters: Iterable[tuple[str, JsonSchemaMode, pydantic.TypeAdapter]]): 176 | inner_schemas = {} 177 | 178 | schemas, common_schemas = pydantic.TypeAdapter.json_schemas(adapters, ref_template=self.REF_TEMPLATE_PREFIX) 179 | for (field_name, _), field_schema in schemas.items(): 180 | inner_schemas[field_name] = field_schema 181 | 182 | self.collected_schema_defs.update(common_schemas.get("$defs", {})) 183 | return inner_schemas 184 | 185 | def _get_paginated_schema(self, schema) -> ty.Any: 186 | response_schema = {"type": "array", "items": schema} 187 | paginator = self.get_paginator() 188 | if paginator: 189 | response_schema = paginator.get_paginated_response_schema(response_schema) # type: ignore 190 | return response_schema 191 | -------------------------------------------------------------------------------- /django_pydantic_field/v2/rest_framework/parsers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as ty 4 | 5 | import pydantic 6 | from rest_framework import exceptions, parsers 7 | 8 | from .. import types 9 | from . import mixins, renderers 10 | 11 | 12 | class SchemaParser(mixins.AnnotatedAdapterMixin[types.ST], parsers.JSONParser): 13 | schema_context_key = "parser_schema" 14 | config_context_key = "parser_config" 15 | renderer_class = renderers.SchemaRenderer 16 | 17 | def parse(self, stream: ty.IO[bytes], media_type=None, parser_context=None): 18 | parser_context = parser_context or {} 19 | adapter = self.get_adapter(parser_context) 20 | if adapter is None: 21 | raise RuntimeError("Schema should be either explicitly set with annotation or passed in the context") 22 | 23 | try: 24 | return adapter.validate_json(stream.read()) 25 | except pydantic.ValidationError as exc: 26 | raise exceptions.ParseError(exc.errors()) # type: ignore 27 | -------------------------------------------------------------------------------- /django_pydantic_field/v2/rest_framework/renderers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as ty 4 | 5 | import pydantic 6 | from rest_framework import renderers 7 | 8 | from .. import types 9 | from . import mixins 10 | 11 | if ty.TYPE_CHECKING: 12 | from collections.abc import Mapping 13 | 14 | RequestResponseContext = Mapping[str, ty.Any] 15 | 16 | __all__ = ("SchemaRenderer",) 17 | 18 | 19 | class SchemaRenderer(mixins.AnnotatedAdapterMixin[types.ST], renderers.JSONRenderer): 20 | schema_context_key = "renderer_schema" 21 | config_context_key = "renderer_config" 22 | 23 | def render(self, data: ty.Any, accepted_media_type=None, renderer_context=None): 24 | renderer_context = renderer_context or {} 25 | response = renderer_context.get("response") 26 | if response is not None and response.exception: 27 | return super().render(data, accepted_media_type, renderer_context) 28 | 29 | adapter = self.get_adapter(renderer_context) 30 | if adapter is None and isinstance(data, pydantic.BaseModel): 31 | return self.render_pydantic_model(data, renderer_context) 32 | if adapter is None: 33 | raise RuntimeError("Schema should be either explicitly set with annotation or passed in the context") 34 | 35 | try: 36 | prep_data = adapter.validate_python(data) 37 | return adapter.dump_json(prep_data) 38 | except pydantic.ValidationError as exc: 39 | return exc.json(indent=True, include_input=True).encode() 40 | 41 | def render_pydantic_model(self, instance: pydantic.BaseModel, renderer_context: Mapping[str, ty.Any]): 42 | export_kwargs = types.SchemaAdapter.extract_export_kwargs(dict(renderer_context)) 43 | export_kwargs.pop("strict", None) 44 | export_kwargs.pop("from_attributes", None) 45 | export_kwargs.pop("mode", None) 46 | 47 | json_dump = instance.model_dump_json(**export_kwargs) # type: ignore 48 | return json_dump.encode() 49 | -------------------------------------------------------------------------------- /django_pydantic_field/v2/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as ty 4 | from collections import ChainMap 5 | 6 | import pydantic 7 | import typing_extensions as te 8 | 9 | from django_pydantic_field.compat.django import BaseContainer, GenericContainer 10 | from django_pydantic_field.compat.functools import cached_property 11 | 12 | from . import utils 13 | 14 | if ty.TYPE_CHECKING: 15 | from collections.abc import Mapping, Sequence 16 | 17 | from django.db.models import Model 18 | from pydantic.dataclasses import DataclassClassOrWrapper 19 | from pydantic.type_adapter import IncEx 20 | 21 | ModelType = ty.Type[pydantic.BaseModel] 22 | DjangoModelType = ty.Type[Model] 23 | SchemaT = ty.Union[ 24 | pydantic.BaseModel, 25 | DataclassClassOrWrapper, 26 | Sequence[ty.Any], 27 | Mapping[str, ty.Any], 28 | set[ty.Any], 29 | frozenset[ty.Any], 30 | ] 31 | 32 | ST = ty.TypeVar("ST", bound="SchemaT") 33 | 34 | 35 | class ExportKwargs(te.TypedDict, total=False): 36 | strict: bool 37 | from_attributes: bool 38 | mode: ty.Literal["json", "python"] 39 | include: IncEx | None 40 | exclude: IncEx | None 41 | by_alias: bool 42 | exclude_unset: bool 43 | exclude_defaults: bool 44 | exclude_none: bool 45 | round_trip: bool 46 | warnings: bool 47 | 48 | 49 | class ImproperlyConfiguredSchema(ValueError): 50 | """Raised when the schema is improperly configured.""" 51 | 52 | 53 | class SchemaAdapter(ty.Generic[ST]): 54 | def __init__( 55 | self, 56 | schema: ty.Any, 57 | config: pydantic.ConfigDict | None, 58 | parent_type: type | None, 59 | attname: str | None, 60 | allow_null: bool | None = None, 61 | **export_kwargs: ty.Unpack[ExportKwargs], 62 | ): 63 | self.schema = BaseContainer.unwrap(schema) 64 | self.config = config 65 | self.parent_type = parent_type 66 | self.attname = attname 67 | self.allow_null = allow_null 68 | self.export_kwargs = export_kwargs 69 | 70 | @classmethod 71 | def from_type( 72 | cls, 73 | schema: ty.Any, 74 | config: pydantic.ConfigDict | None = None, 75 | **kwargs: ty.Unpack[ExportKwargs], 76 | ) -> SchemaAdapter[ST]: 77 | """Create an adapter from a type.""" 78 | return cls(schema, config, None, None, **kwargs) 79 | 80 | @classmethod 81 | def from_annotation( 82 | cls, 83 | parent_type: type, 84 | attname: str, 85 | config: pydantic.ConfigDict | None = None, 86 | **kwargs: ty.Unpack[ExportKwargs], 87 | ) -> SchemaAdapter[ST]: 88 | """Create an adapter from a type annotation.""" 89 | return cls(None, config, parent_type, attname, **kwargs) 90 | 91 | @staticmethod 92 | def extract_export_kwargs(kwargs: dict[str, ty.Any]) -> ExportKwargs: 93 | """Extract the export kwargs from the kwargs passed to the field. 94 | This method mutates passed kwargs by removing those that are used by the adapter.""" 95 | common_keys = kwargs.keys() & ExportKwargs.__annotations__.keys() 96 | export_kwargs = {key: kwargs.pop(key) for key in common_keys} 97 | return ty.cast(ExportKwargs, export_kwargs) 98 | 99 | @cached_property 100 | def type_adapter(self) -> pydantic.TypeAdapter: 101 | return pydantic.TypeAdapter(self.prepared_schema, config=self.config) # type: ignore 102 | 103 | @property 104 | def is_bound(self) -> bool: 105 | """Return True if the adapter is bound to a specific attribute of a `parent_type`.""" 106 | return self.parent_type is not None and self.attname is not None 107 | 108 | def bind(self, parent_type: type | None, attname: str | None) -> te.Self: 109 | """Bind the adapter to specific attribute of a `parent_type`.""" 110 | self.parent_type = parent_type 111 | self.attname = attname 112 | self.__dict__.pop("prepared_schema", None) 113 | self.__dict__.pop("type_adapter", None) 114 | return self 115 | 116 | def validate_schema(self) -> None: 117 | """Validate the schema and raise an exception if it is invalid.""" 118 | try: 119 | self._prepare_schema() 120 | except Exception as exc: 121 | if not isinstance(exc, ImproperlyConfiguredSchema): 122 | raise ImproperlyConfiguredSchema(*exc.args) from exc 123 | raise 124 | 125 | def validate_python(self, value: ty.Any, *, strict: bool | None = None, from_attributes: bool | None = None) -> ST: 126 | """Validate the value and raise an exception if it is invalid.""" 127 | if strict is None: 128 | strict = self.export_kwargs.get("strict", None) 129 | if from_attributes is None: 130 | from_attributes = self.export_kwargs.get("from_attributes", None) 131 | return self.type_adapter.validate_python(value, strict=strict, from_attributes=from_attributes) 132 | 133 | def validate_json(self, value: str | bytes, *, strict: bool | None = None) -> ST: 134 | if strict is None: 135 | strict = self.export_kwargs.get("strict", None) 136 | return self.type_adapter.validate_json(value, strict=strict) 137 | 138 | def dump_python(self, value: ty.Any, **override_kwargs: ty.Unpack[ExportKwargs]) -> ty.Any: 139 | """Dump the value to a Python object.""" 140 | union_kwargs = ChainMap(override_kwargs, self._dump_python_kwargs, {"mode": "json"}) # type: ignore 141 | return self.type_adapter.dump_python(value, **union_kwargs) 142 | 143 | def dump_json(self, value: ty.Any, **override_kwargs: ty.Unpack[ExportKwargs]) -> bytes: 144 | union_kwargs = ChainMap(override_kwargs, self._dump_python_kwargs) # type: ignore 145 | return self.type_adapter.dump_json(value, **union_kwargs) 146 | 147 | def json_schema(self) -> dict[str, ty.Any]: 148 | """Return the JSON schema for the field.""" 149 | by_alias = self.export_kwargs.get("by_alias", True) 150 | return self.type_adapter.json_schema(by_alias=by_alias) 151 | 152 | def get_default_value(self) -> ST | None: 153 | wrapped = self.type_adapter.get_default_value() 154 | if wrapped is not None: 155 | return wrapped.value 156 | return None 157 | 158 | def _prepare_schema(self) -> type[ST]: 159 | """Prepare the schema for the adapter. 160 | 161 | This method is called by `prepared_schema` property and should not be called directly. 162 | The intent is to resolve the real schema from an annotations or a forward references. 163 | """ 164 | schema = self.schema 165 | 166 | if schema is None and self.is_bound: 167 | schema = self._guess_schema_from_annotations() 168 | if isinstance(schema, str): 169 | schema = ty.ForwardRef(schema) 170 | 171 | schema = self._resolve_schema_forward_ref(schema) 172 | if schema is None: 173 | if self.is_bound: 174 | error_msg = f"Annotation is not provided for {self.parent_type.__name__}.{self.attname}" # type: ignore[union-attr] 175 | else: 176 | error_msg = "Cannot resolve the schema. The adapter is accessed before it was bound." 177 | raise ImproperlyConfiguredSchema(error_msg) 178 | 179 | if self.allow_null: 180 | schema = ty.Optional[schema] # type: ignore 181 | 182 | return ty.cast(ty.Type[ST], schema) 183 | 184 | prepared_schema = cached_property(_prepare_schema) 185 | 186 | def __copy__(self): 187 | instance = self.__class__( 188 | self.schema, 189 | self.config, 190 | self.parent_type, 191 | self.attname, 192 | self.allow_null, 193 | **self.export_kwargs, 194 | ) 195 | instance.__dict__.update(self.__dict__) 196 | return instance 197 | 198 | def __repr__(self) -> str: 199 | return f"{self.__class__.__name__}(bound={self.is_bound}, schema={self.schema!r}, config={self.config!r})" 200 | 201 | def __eq__(self, other: ty.Any) -> bool: 202 | if not isinstance(other, self.__class__): 203 | return NotImplemented 204 | 205 | self_fields: list[ty.Any] = [self.attname, self.export_kwargs] 206 | other_fields: list[ty.Any] = [other.attname, other.export_kwargs] 207 | try: 208 | self_fields.append(self.prepared_schema) 209 | other_fields.append(other.prepared_schema) 210 | except ImproperlyConfiguredSchema: 211 | if self.is_bound and other.is_bound: 212 | return False 213 | else: 214 | self_fields.extend((self.schema, self.config, self.allow_null)) 215 | other_fields.extend((other.schema, other.config, other.allow_null)) 216 | 217 | return self_fields == other_fields 218 | 219 | def _guess_schema_from_annotations(self) -> type[ST] | str | ty.ForwardRef | None: 220 | return utils.get_annotated_type(self.parent_type, self.attname) 221 | 222 | def _resolve_schema_forward_ref(self, schema: ty.Any) -> ty.Any: 223 | if schema is None: 224 | return None 225 | 226 | if isinstance(schema, ty.ForwardRef): 227 | globalns = utils.get_namespace(self.parent_type) 228 | return utils.evaluate_forward_ref(schema, globalns) 229 | 230 | wrapped_schema = GenericContainer.wrap(schema) 231 | if not isinstance(wrapped_schema, GenericContainer): 232 | return schema 233 | 234 | origin = self._resolve_schema_forward_ref(wrapped_schema.origin) 235 | args = map(self._resolve_schema_forward_ref, wrapped_schema.args) 236 | return GenericContainer.unwrap(GenericContainer(origin, tuple(args))) 237 | 238 | @cached_property 239 | def _dump_python_kwargs(self) -> dict[str, ty.Any]: 240 | export_kwargs = self.export_kwargs.copy() 241 | export_kwargs.pop("strict", None) 242 | export_kwargs.pop("from_attributes", None) 243 | return ty.cast(dict, export_kwargs) 244 | -------------------------------------------------------------------------------- /django_pydantic_field/v2/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import typing as ty 5 | from collections import ChainMap 6 | 7 | from django_pydantic_field.compat import typing 8 | 9 | if ty.TYPE_CHECKING: 10 | from collections.abc import Mapping 11 | 12 | 13 | def get_annotated_type(obj, field, default=None) -> ty.Any: 14 | try: 15 | if isinstance(obj, type): 16 | annotations = obj.__dict__["__annotations__"] 17 | else: 18 | annotations = obj.__annotations__ 19 | 20 | return annotations[field] 21 | except (AttributeError, KeyError): 22 | return default 23 | 24 | 25 | def get_namespace(cls) -> ChainMap[str, ty.Any]: 26 | return ChainMap(get_local_namespace(cls), get_global_namespace(cls)) 27 | 28 | 29 | def get_global_namespace(cls) -> dict[str, ty.Any]: 30 | try: 31 | module = cls.__module__ 32 | return vars(sys.modules[module]) 33 | except (KeyError, AttributeError): 34 | return {} 35 | 36 | 37 | def get_local_namespace(cls) -> dict[str, ty.Any]: 38 | try: 39 | return vars(cls) 40 | except TypeError: 41 | return {} 42 | 43 | 44 | def get_origin_type(cls: type): 45 | origin_tp = typing.get_origin(cls) 46 | if origin_tp is not None: 47 | return origin_tp 48 | return cls 49 | 50 | 51 | if sys.version_info >= (3, 9): 52 | 53 | def evaluate_forward_ref(ref: ty.ForwardRef, ns: Mapping[str, ty.Any]) -> ty.Any: 54 | return ref._evaluate(dict(ns), {}, recursive_guard=frozenset()) 55 | 56 | else: 57 | 58 | def evaluate_forward_ref(ref: ty.ForwardRef, ns: Mapping[str, ty.Any]) -> ty.Any: 59 | return ref._evaluate(dict(ns), {}) 60 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-pydantic-field" 7 | version = "0.3.12" 8 | description = "Django JSONField with Pydantic models as a Schema" 9 | readme = "README.md" 10 | license = { file = "LICENSE" } 11 | authors = [ 12 | { name = "Savva Surenkov", email = "savva@surenkov.space" }, 13 | ] 14 | 15 | keywords = ["django", "pydantic", "json", "schema"] 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Intended Audience :: Developers", 19 | "Framework :: Django", 20 | "Framework :: Django :: 3", 21 | "Framework :: Django :: 3.1", 22 | "Framework :: Django :: 3.2", 23 | "Framework :: Django :: 4", 24 | "Framework :: Django :: 4.0", 25 | "Framework :: Django :: 4.1", 26 | "Framework :: Django :: 4.2", 27 | "Framework :: Django :: 5.0", 28 | "Framework :: Django :: 5.1", 29 | "Framework :: Pydantic", 30 | "Framework :: Pydantic :: 1", 31 | "Framework :: Pydantic :: 2", 32 | "License :: OSI Approved :: MIT License", 33 | "Programming Language :: Python", 34 | "Programming Language :: Python :: 3", 35 | "Programming Language :: Python :: 3 :: Only", 36 | "Programming Language :: Python :: 3.8", 37 | "Programming Language :: Python :: 3.9", 38 | "Programming Language :: Python :: 3.10", 39 | "Programming Language :: Python :: 3.11", 40 | "Programming Language :: Python :: 3.12", 41 | "Programming Language :: Python :: 3.13", 42 | ] 43 | 44 | requires-python = ">=3.8" 45 | dependencies = [ 46 | "pydantic>=1.10,<3", 47 | "django>=3.1,<6", 48 | "typing_extensions", 49 | ] 50 | 51 | [project.optional-dependencies] 52 | openapi = ["uritemplate", "inflection"] 53 | coreapi = ["coreapi"] 54 | jsonform = ["django_jsonform>=2.0,<3"] 55 | dev = [ 56 | "build", 57 | "ruff", 58 | "mypy", 59 | "pre-commit", 60 | "pytest~=7.4", 61 | "djangorestframework>=3.11,<4", 62 | "django-stubs[compatible-mypy]~=4.2", 63 | "djangorestframework-stubs[compatible-mypy]~=3.14", 64 | "pytest-django>=4.5,<6", 65 | ] 66 | test = [ 67 | "django_pydantic_field[openapi,coreapi,jsonform]", 68 | "dj-database-url~=2.0", 69 | "djangorestframework>=3,<4", 70 | "pyyaml", 71 | "syrupy>=3,<5", 72 | ] 73 | ci = [ 74 | 'psycopg[binary]>=3.1,<4; python_version>="3.9"', 75 | 'psycopg2-binary>=2.7,<3; python_version<"3.9"', 76 | "mysqlclient>=2.1", 77 | ] 78 | 79 | [project.urls] 80 | Homepage = "https://github.com/surenkov/django-pydantic-field" 81 | Documentation = "https://github.com/surenkov/django-pydantic-field" 82 | Source = "https://github.com/surenkov/django-pydantic-field" 83 | Changelog = "https://github.com/surenkov/django-pydantic-field/releases" 84 | 85 | [tool.ruff] 86 | line-length = 120 87 | 88 | [tool.mypy] 89 | plugins = [ 90 | "mypy_django_plugin.main", 91 | "mypy_drf_plugin.main" 92 | ] 93 | exclude = [".env", ".venv", "tests"] 94 | 95 | [tool.django-stubs] 96 | django_settings_module = "tests.settings.django_test_settings" 97 | 98 | [tool.pytest.ini_options] 99 | DJANGO_SETTINGS_MODULE = "tests.settings.django_test_settings" 100 | 101 | addopts = "--capture=no" 102 | pythonpath = ["."] 103 | testpaths = ["tests"] 104 | python_files = ["test_*.py", "*_tests.py"] 105 | norecursedirs = [".*", "venv"] 106 | 107 | [tool.pyright] 108 | include = ["pydantic"] 109 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surenkov/django-pydantic-field/ca90e8921705bd6a069623cd76a23f970d3ab87e/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from datetime import date 3 | 4 | import pydantic 5 | import pytest 6 | from django.conf import settings 7 | from pydantic.dataclasses import dataclass 8 | from rest_framework.test import APIRequestFactory 9 | from syrupy.extensions.json import JSONSnapshotExtension 10 | 11 | from django_pydantic_field.compat import PYDANTIC_V2 12 | 13 | 14 | class InnerSchema(pydantic.BaseModel): 15 | stub_str: str 16 | stub_int: int = 1 17 | stub_list: t.List[date] 18 | 19 | class Config: 20 | allow_mutation = True 21 | frozen = False 22 | 23 | 24 | @dataclass 25 | class SampleDataclass: 26 | stub_str: str 27 | stub_list: t.List[date] 28 | stub_int: int = 1 29 | 30 | 31 | class SchemaWithCustomTypes(pydantic.BaseModel): 32 | url: pydantic.HttpUrl = "http://localhost/" 33 | uid: pydantic.UUID4 = "367388a6-9b3b-4ef0-af84-a27d61a05bc7" 34 | crd: pydantic.PaymentCardNumber = "4111111111111111" 35 | 36 | if PYDANTIC_V2: 37 | b64: pydantic.Base64Str = "YmFzZTY0" 38 | model_config = dict(validate_default=True) # type: ignore 39 | 40 | 41 | 42 | @pytest.fixture 43 | def request_factory(): 44 | return APIRequestFactory() 45 | 46 | 47 | # ============================== 48 | # PARAMETRIZED DATABASE BACKENDS 49 | # ============================== 50 | 51 | 52 | def sqlite_backend(settings): 53 | settings.CURRENT_TEST_DB = "default" 54 | 55 | 56 | def postgres_backend(settings): 57 | settings.CURRENT_TEST_DB = "postgres" 58 | 59 | 60 | def mysql_backend(settings): 61 | settings.CURRENT_TEST_DB = "mysql" 62 | 63 | 64 | @pytest.fixture( 65 | params=[ 66 | sqlite_backend, 67 | pytest.param( 68 | postgres_backend, 69 | marks=pytest.mark.skipif( 70 | "postgres" not in settings.DATABASES, 71 | reason="POSTGRES_DSN is not specified", 72 | ), 73 | ), 74 | pytest.param( 75 | mysql_backend, 76 | marks=pytest.mark.skipif( 77 | "mysql" not in settings.DATABASES, 78 | reason="MYSQL_DSN is not specified", 79 | ), 80 | ), 81 | ] 82 | ) 83 | def available_database_backends(request, settings): 84 | yield request.param(settings) 85 | 86 | 87 | @pytest.fixture 88 | def snapshot_json(snapshot): 89 | return snapshot.use_extension(JSONSnapshotExtension) 90 | -------------------------------------------------------------------------------- /tests/sample_app/.gitignore: -------------------------------------------------------------------------------- 1 | db.sqlite3 2 | -------------------------------------------------------------------------------- /tests/sample_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surenkov/django-pydantic-field/ca90e8921705bd6a069623cd76a23f970d3ab87e/tests/sample_app/__init__.py -------------------------------------------------------------------------------- /tests/sample_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SampleAppConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'tests.sample_app' 7 | -------------------------------------------------------------------------------- /tests/sample_app/dbrouters.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | class TestDBRouter: 5 | def db_for_read(self, model, **hints): 6 | return settings.CURRENT_TEST_DB 7 | 8 | def db_for_write(self, model, **hints): 9 | return settings.CURRENT_TEST_DB 10 | 11 | def allow_relation(self, obj1, obj2, **hints): 12 | return True 13 | -------------------------------------------------------------------------------- /tests/sample_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.23 on 2023-11-21 14:37 2 | 3 | import django.core.serializers.json 4 | from django.db import migrations, models 5 | import django_pydantic_field.compat.django 6 | import django_pydantic_field.fields 7 | import tests.sample_app.models 8 | import typing 9 | import typing_extensions 10 | 11 | 12 | class Migration(migrations.Migration): 13 | initial = True 14 | 15 | dependencies = [] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="Building", 20 | fields=[ 21 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 22 | ( 23 | "opt_meta", 24 | django_pydantic_field.fields.PydanticSchemaField( 25 | config=None, 26 | default={"buildingType": "frame"}, 27 | encoder=django.core.serializers.json.DjangoJSONEncoder, 28 | exclude={"type"}, 29 | null=True, 30 | schema=django_pydantic_field.compat.django.GenericContainer( 31 | typing.Union, 32 | ( 33 | tests.sample_app.models.BuildingMeta, 34 | type(None), 35 | ), 36 | ), 37 | ), 38 | ), 39 | ( 40 | "meta", 41 | django_pydantic_field.fields.PydanticSchemaField( 42 | by_alias=True, 43 | config=None, 44 | default={"buildingType": "frame"}, 45 | encoder=django.core.serializers.json.DjangoJSONEncoder, 46 | include={"type"}, 47 | schema=tests.sample_app.models.BuildingMeta, 48 | ), 49 | ), 50 | ( 51 | "meta_schema_list", 52 | django_pydantic_field.fields.PydanticSchemaField( 53 | config=None, 54 | default=list, 55 | encoder=django.core.serializers.json.DjangoJSONEncoder, 56 | schema=django_pydantic_field.compat.django.GenericContainer( 57 | list, (tests.sample_app.models.BuildingMeta,) 58 | ), 59 | ), 60 | ), 61 | ( 62 | "meta_typing_list", 63 | django_pydantic_field.fields.PydanticSchemaField( 64 | config=None, 65 | default=list, 66 | encoder=django.core.serializers.json.DjangoJSONEncoder, 67 | schema=django_pydantic_field.compat.django.GenericContainer( 68 | list, (tests.sample_app.models.BuildingMeta,) 69 | ), 70 | ), 71 | ), 72 | ( 73 | "meta_untyped_list", 74 | django_pydantic_field.fields.PydanticSchemaField( 75 | config=None, default=list, encoder=django.core.serializers.json.DjangoJSONEncoder, schema=list 76 | ), 77 | ), 78 | ( 79 | "meta_untyped_builtin_list", 80 | django_pydantic_field.fields.PydanticSchemaField( 81 | config=None, default=list, encoder=django.core.serializers.json.DjangoJSONEncoder, schema=list 82 | ), 83 | ), 84 | ], 85 | ), 86 | migrations.CreateModel( 87 | name="PostponedBuilding", 88 | fields=[ 89 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 90 | ( 91 | "meta", 92 | django_pydantic_field.fields.PydanticSchemaField( 93 | by_alias=True, 94 | config=None, 95 | default={"buildingType": tests.sample_app.models.BuildingTypes["FRAME"]}, 96 | encoder=django.core.serializers.json.DjangoJSONEncoder, 97 | schema=tests.sample_app.models.BuildingMeta, 98 | ), 99 | ), 100 | ( 101 | "meta_builtin_list", 102 | django_pydantic_field.fields.PydanticSchemaField( 103 | config=None, 104 | default=list, 105 | encoder=django.core.serializers.json.DjangoJSONEncoder, 106 | schema=django_pydantic_field.compat.django.GenericContainer( 107 | list, (tests.sample_app.models.BuildingMeta,) 108 | ), 109 | ), 110 | ), 111 | ( 112 | "meta_typing_list", 113 | django_pydantic_field.fields.PydanticSchemaField( 114 | config=None, 115 | default=list, 116 | encoder=django.core.serializers.json.DjangoJSONEncoder, 117 | schema=django_pydantic_field.compat.django.GenericContainer( 118 | list, (tests.sample_app.models.BuildingMeta,) 119 | ), 120 | ), 121 | ), 122 | ( 123 | "meta_untyped_list", 124 | django_pydantic_field.fields.PydanticSchemaField( 125 | config=None, default=list, encoder=django.core.serializers.json.DjangoJSONEncoder, schema=list 126 | ), 127 | ), 128 | ( 129 | "meta_untyped_builtin_list", 130 | django_pydantic_field.fields.PydanticSchemaField( 131 | config=None, default=list, encoder=django.core.serializers.json.DjangoJSONEncoder, schema=list 132 | ), 133 | ), 134 | ( 135 | "nested_generics", 136 | django_pydantic_field.fields.PydanticSchemaField( 137 | config=None, 138 | encoder=django.core.serializers.json.DjangoJSONEncoder, 139 | schema=django_pydantic_field.compat.django.GenericContainer( 140 | typing.Union, 141 | ( 142 | django_pydantic_field.compat.django.GenericContainer( 143 | list, 144 | ( 145 | django_pydantic_field.compat.django.GenericContainer( 146 | typing_extensions.Literal, ("foo",) 147 | ), 148 | ), 149 | ), 150 | django_pydantic_field.compat.django.GenericContainer( 151 | typing_extensions.Literal, ("bar",) 152 | ), 153 | ), 154 | ), 155 | ), 156 | ), 157 | ], 158 | ), 159 | ] 160 | -------------------------------------------------------------------------------- /tests/sample_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surenkov/django-pydantic-field/ca90e8921705bd6a069623cd76a23f970d3ab87e/tests/sample_app/migrations/__init__.py -------------------------------------------------------------------------------- /tests/sample_app/models.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import typing as t 3 | import typing_extensions as te 4 | 5 | import pydantic 6 | from django.db import models 7 | from django_pydantic_field import SchemaField 8 | 9 | 10 | class BuildingTypes(str, enum.Enum): 11 | FRAME = "frame" 12 | BRICK = "brick" 13 | STUCCO = "stucco" 14 | 15 | 16 | class Building(models.Model): 17 | opt_meta: t.Optional["BuildingMeta"] = SchemaField(default={"buildingType": "frame"}, exclude={"type"}, null=True) 18 | meta: "BuildingMeta" = SchemaField(default={"buildingType": "frame"}, include={"type"}, by_alias=True) 19 | 20 | meta_schema_list = SchemaField(schema=t.ForwardRef("t.List[BuildingMeta]"), default=list) 21 | meta_typing_list: t.List["BuildingMeta"] = SchemaField(default=list) 22 | meta_untyped_list: list = SchemaField(schema=t.List, default=list) 23 | meta_untyped_builtin_list: t.List = SchemaField(schema=list, default=list) 24 | 25 | 26 | class BuildingMeta(pydantic.BaseModel): 27 | type: t.Optional[BuildingTypes] = pydantic.Field(alias="buildingType") 28 | 29 | 30 | class PostponedBuilding(models.Model): 31 | meta: "BuildingMeta" = SchemaField(default=BuildingMeta(buildingType=BuildingTypes.FRAME), by_alias=True) 32 | meta_builtin_list: t.List[BuildingMeta] = SchemaField(schema=t.List[BuildingMeta], default=list) 33 | meta_typing_list: t.List["BuildingMeta"] = SchemaField(default=list) 34 | meta_untyped_list: list = SchemaField(schema=t.List, default=list) 35 | meta_untyped_builtin_list: t.List = SchemaField(schema=list, default=list) 36 | nested_generics: t.Union[t.List[te.Literal["foo"]], te.Literal["bar"]] = SchemaField() 37 | -------------------------------------------------------------------------------- /tests/settings/.gitignore: -------------------------------------------------------------------------------- 1 | db.sqlite3 2 | -------------------------------------------------------------------------------- /tests/settings/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/settings/django_test_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import dj_database_url 3 | import importlib.util 4 | 5 | SECRET_KEY = "1" 6 | SITE_ID = 1 7 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 8 | STATIC_URL = "/static/" 9 | DEBUG = True 10 | 11 | INSTALLED_APPS = [ 12 | "django.contrib.contenttypes", 13 | "django.contrib.auth", 14 | "django.contrib.sites", 15 | "django.contrib.sessions", 16 | "django.contrib.messages", 17 | "django.contrib.staticfiles", 18 | "django.contrib.admin", 19 | "tests.sample_app", 20 | "tests.test_app", 21 | ] 22 | 23 | if importlib.util.find_spec("django_jsonform") is not None: 24 | INSTALLED_APPS.append("django_jsonform") 25 | 26 | 27 | MIDDLEWARE = [ 28 | "django.contrib.sessions.middleware.SessionMiddleware", 29 | "django.contrib.auth.middleware.AuthenticationMiddleware", 30 | "django.contrib.messages.middleware.MessageMiddleware", 31 | ] 32 | TEMPLATES = [ 33 | { 34 | "BACKEND": "django.template.backends.django.DjangoTemplates", 35 | "DIRS": [], 36 | "APP_DIRS": True, 37 | "OPTIONS": { 38 | "context_processors": [ 39 | "django.template.context_processors.debug", 40 | "django.template.context_processors.request", 41 | "django.contrib.auth.context_processors.auth", 42 | "django.contrib.messages.context_processors.messages", 43 | ], 44 | }, 45 | }, 46 | ] 47 | 48 | DATABASES = { 49 | "default": { 50 | "ENGINE": "django.db.backends.sqlite3", 51 | "NAME": os.path.join(BASE_DIR, "settings", "db.sqlite3"), 52 | }, 53 | } 54 | 55 | if os.getenv("POSTGRES_DSN"): 56 | DATABASES["postgres"] = dj_database_url.config("POSTGRES_DSN") # type: ignore 57 | 58 | if os.getenv("MYSQL_DSN"): 59 | DATABASES["mysql"] = dj_database_url.config("MYSQL_DSN") # type: ignore 60 | 61 | DATABASE_ROUTERS = ["tests.sample_app.dbrouters.TestDBRouter"] 62 | CURRENT_TEST_DB = "default" 63 | 64 | REST_FRAMEWORK = {"COMPACT_JSON": True} 65 | ROOT_URLCONF = "tests.settings.urls" 66 | -------------------------------------------------------------------------------- /tests/settings/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.contrib import admin 3 | 4 | urlpatterns = [ 5 | path("admin/", admin.site.urls), 6 | ] 7 | -------------------------------------------------------------------------------- /tests/test_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surenkov/django-pydantic-field/ca90e8921705bd6a069623cd76a23f970d3ab87e/tests/test_app/__init__.py -------------------------------------------------------------------------------- /tests/test_app/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | try: 4 | from django_jsonform.widgets import JSONFormWidget 5 | from django_pydantic_field.v2.fields import PydanticSchemaField 6 | from django_pydantic_field.v2.forms import JSONFormSchemaWidget 7 | 8 | json_formfield_overrides = {PydanticSchemaField: {"widget": JSONFormWidget}} 9 | json_schema_formfield_overrides = {PydanticSchemaField: {"widget": JSONFormSchemaWidget}} 10 | except ImportError: 11 | json_formfield_overrides = {} 12 | json_schema_formfield_overrides = {} 13 | 14 | from . import models 15 | 16 | 17 | @admin.register(models.SampleModel) 18 | class SampleModelAdmin(admin.ModelAdmin): 19 | pass 20 | 21 | 22 | @admin.register(models.SampleForwardRefModel) 23 | class SampleForwardRefModelAdmin(admin.ModelAdmin): 24 | formfield_overrides = json_formfield_overrides # type: ignore 25 | 26 | 27 | @admin.register(models.SampleModelWithRoot) 28 | class SampleModelWithRootAdmin(admin.ModelAdmin): 29 | formfield_overrides = json_schema_formfield_overrides # type: ignore 30 | 31 | 32 | @admin.register(models.ExampleModel) 33 | class ExampleModelAdmin(admin.ModelAdmin): 34 | pass 35 | -------------------------------------------------------------------------------- /tests/test_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestAppConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "tests.test_app" 7 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.3 on 2024-03-25 22:22 2 | 3 | import typing_extensions 4 | import annotated_types 5 | import django.core.serializers.json 6 | import django_pydantic_field.compat.django 7 | import django_pydantic_field.fields 8 | import tests.conftest 9 | import tests.test_app.models 10 | import typing 11 | from django.db import migrations, models 12 | 13 | 14 | class Migration(migrations.Migration): 15 | 16 | initial = True 17 | 18 | dependencies = [] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name="ExampleModel", 23 | fields=[ 24 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 25 | ( 26 | "example_field", 27 | django_pydantic_field.fields.PydanticSchemaField( 28 | config=None, 29 | default={"count": 1}, 30 | encoder=django.core.serializers.json.DjangoJSONEncoder, 31 | schema=tests.test_app.models.ExampleSchema, 32 | ), 33 | ), 34 | ], 35 | ), 36 | migrations.CreateModel( 37 | name="SampleForwardRefModel", 38 | fields=[ 39 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 40 | ( 41 | "annotated_field", 42 | django_pydantic_field.fields.PydanticSchemaField( 43 | config=None, 44 | default=dict, 45 | encoder=django.core.serializers.json.DjangoJSONEncoder, 46 | schema=tests.test_app.models.SampleSchema, 47 | ), 48 | ), 49 | ( 50 | "field", 51 | django_pydantic_field.fields.PydanticSchemaField( 52 | config=None, 53 | default=dict, 54 | encoder=django.core.serializers.json.DjangoJSONEncoder, 55 | schema=tests.test_app.models.SampleSchema, 56 | ), 57 | ), 58 | ], 59 | ), 60 | migrations.CreateModel( 61 | name="SampleModel", 62 | fields=[ 63 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 64 | ( 65 | "sample_field", 66 | django_pydantic_field.fields.PydanticSchemaField( 67 | config=None, 68 | encoder=django.core.serializers.json.DjangoJSONEncoder, 69 | schema=tests.conftest.InnerSchema, 70 | ), 71 | ), 72 | ( 73 | "sample_list", 74 | django_pydantic_field.fields.PydanticSchemaField( 75 | config=None, 76 | encoder=django.core.serializers.json.DjangoJSONEncoder, 77 | schema=django_pydantic_field.compat.django.GenericContainer( 78 | list, (tests.conftest.InnerSchema,) 79 | ), 80 | ), 81 | ), 82 | ( 83 | "sample_seq", 84 | django_pydantic_field.fields.PydanticSchemaField( 85 | config=None, 86 | default=list, 87 | encoder=django.core.serializers.json.DjangoJSONEncoder, 88 | schema=django_pydantic_field.compat.django.GenericContainer( 89 | list, (tests.conftest.InnerSchema,) 90 | ), 91 | ), 92 | ), 93 | ], 94 | ), 95 | migrations.CreateModel( 96 | name="SampleModelAnnotated", 97 | fields=[ 98 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 99 | ( 100 | "annotated_field", 101 | django_pydantic_field.fields.PydanticSchemaField( 102 | config=None, 103 | encoder=django.core.serializers.json.DjangoJSONEncoder, 104 | schema=django_pydantic_field.compat.django.GenericContainer( 105 | typing_extensions.Annotated, 106 | ( 107 | django_pydantic_field.compat.django.GenericContainer(typing.Union, (int, float)), 108 | django_pydantic_field.compat.django.FieldInfoContainer( 109 | None, (annotated_types.Gt(gt=0),), {"title": "Annotated Field"} 110 | ), 111 | ), 112 | ), 113 | ), 114 | ), 115 | ( 116 | "annotated_schema", 117 | django_pydantic_field.fields.PydanticSchemaField( 118 | config=None, 119 | encoder=django.core.serializers.json.DjangoJSONEncoder, 120 | schema=django_pydantic_field.compat.django.GenericContainer( 121 | typing_extensions.Annotated, 122 | ( 123 | django_pydantic_field.compat.django.GenericContainer(typing.Union, (int, float)), 124 | django_pydantic_field.compat.django.FieldInfoContainer( 125 | None, (annotated_types.Gt(gt=0),), {} 126 | ), 127 | ), 128 | ), 129 | ), 130 | ), 131 | ], 132 | ), 133 | migrations.CreateModel( 134 | name="SampleModelWithRoot", 135 | fields=[ 136 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 137 | ( 138 | "root_field", 139 | django_pydantic_field.fields.PydanticSchemaField( 140 | config=None, 141 | default=list, 142 | encoder=django.core.serializers.json.DjangoJSONEncoder, 143 | schema=tests.test_app.models.RootSchema, 144 | ), 145 | ), 146 | ], 147 | ), 148 | ] 149 | -------------------------------------------------------------------------------- /tests/test_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surenkov/django-pydantic-field/ca90e8921705bd6a069623cd76a23f970d3ab87e/tests/test_app/migrations/__init__.py -------------------------------------------------------------------------------- /tests/test_app/models.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | import typing_extensions as te 3 | 4 | import pydantic 5 | from django.db import models 6 | from django_pydantic_field import SchemaField 7 | from django_pydantic_field.compat import PYDANTIC_V2 8 | 9 | from ..conftest import InnerSchema 10 | 11 | 12 | class FrozenInnerSchema(InnerSchema): 13 | model_config = pydantic.ConfigDict({"frozen": True}) 14 | 15 | 16 | class SampleModel(models.Model): 17 | sample_field: InnerSchema = SchemaField() 18 | sample_list: t.List[InnerSchema] = SchemaField() 19 | sample_seq: t.Sequence[InnerSchema] = SchemaField(schema=t.List[InnerSchema], default=list) 20 | 21 | class Meta: 22 | app_label = "test_app" 23 | 24 | 25 | class SampleForwardRefModel(models.Model): 26 | annotated_field: "SampleSchema" = SchemaField(default=dict) 27 | field = SchemaField(schema=t.ForwardRef("SampleSchema"), default=dict) 28 | 29 | class Meta: 30 | app_label = "test_app" 31 | 32 | 33 | class SampleSchema(pydantic.BaseModel): 34 | field: int = 1 35 | 36 | 37 | class ExampleSchema(pydantic.BaseModel): 38 | count: int 39 | 40 | class ExampleModel(models.Model): 41 | example_field: ExampleSchema = SchemaField(default=ExampleSchema(count=1)) 42 | 43 | 44 | if PYDANTIC_V2: 45 | class RootSchema(pydantic.RootModel): 46 | root: t.List[int] 47 | 48 | else: 49 | class RootSchema(pydantic.BaseModel): 50 | __root__: t.List[int] 51 | 52 | 53 | class SampleModelWithRoot(models.Model): 54 | root_field = SchemaField(schema=RootSchema, default=list) 55 | 56 | 57 | class SampleModelAnnotated(models.Model): 58 | annotated_field: te.Annotated[t.Union[int, float], pydantic.Field(gt=0, title="Annotated Field")] = SchemaField() 59 | annotated_schema = SchemaField(schema=te.Annotated[t.Union[int, float], pydantic.Field(gt=0)]) 60 | -------------------------------------------------------------------------------- /tests/test_e2e_models.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | import pytest 4 | from django.core import serializers 5 | from django.db.models import F, Q, JSONField, Value 6 | 7 | from tests.conftest import InnerSchema 8 | from tests.test_app.models import ExampleModel, SampleModel 9 | 10 | pytestmark = [ 11 | pytest.mark.usefixtures("available_database_backends"), 12 | pytest.mark.django_db(databases="__all__"), 13 | ] 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "initial_payload,expected_values", 18 | [ 19 | ( 20 | { 21 | "sample_field": InnerSchema(stub_str="abc", stub_list=[date(2023, 6, 1)]), 22 | "sample_list": [InnerSchema(stub_str="abc", stub_list=[])], 23 | }, 24 | { 25 | "sample_field": InnerSchema(stub_str="abc", stub_list=[date(2023, 6, 1)]), 26 | "sample_list": [InnerSchema(stub_str="abc", stub_list=[])], 27 | }, 28 | ), 29 | ( 30 | { 31 | "sample_field": {"stub_str": "abc", "stub_list": ["2023-06-01"]}, 32 | "sample_list": [{"stub_str": "abc", "stub_list": []}], 33 | }, 34 | { 35 | "sample_field": InnerSchema(stub_str="abc", stub_list=[date(2023, 6, 1)]), 36 | "sample_list": [InnerSchema(stub_str="abc", stub_list=[])], 37 | }, 38 | ), 39 | ], 40 | ) 41 | def test_model_db_serde(initial_payload, expected_values): 42 | instance = SampleModel(**initial_payload) 43 | instance.save() 44 | 45 | instance = SampleModel.objects.get(pk=instance.pk) 46 | instance_values = {k: getattr(instance, k) for k in expected_values.keys()} 47 | assert instance_values == expected_values 48 | 49 | 50 | @pytest.mark.parametrize( 51 | "Model,payload,update_fields", 52 | [ 53 | ( 54 | SampleModel, 55 | { 56 | "sample_field": InnerSchema(stub_str="abc", stub_list=[date(2023, 6, 1)]), 57 | "sample_list": [InnerSchema(stub_str="abc", stub_list=[])], 58 | }, 59 | ["sample_field", "sample_list", "sample_seq"], 60 | ), 61 | ( 62 | SampleModel, 63 | { 64 | "sample_field": {"stub_str": "abc", "stub_list": ["2023-06-01"]}, 65 | "sample_list": [{"stub_str": "abc", "stub_list": []}], 66 | }, 67 | ["sample_field", "sample_list", "sample_seq"], 68 | ), 69 | (ExampleModel, {}, ["example_field"]), 70 | (ExampleModel, {"example_field": {"count": 1}}, ["example_field"]), 71 | ], 72 | ) 73 | def test_model_bulk_operations(Model, payload, update_fields): 74 | models = [ 75 | Model(**payload), 76 | Model(**payload), 77 | Model(**payload), 78 | ] 79 | saved_models = Model.objects.bulk_create(models) 80 | fetched_models = Model.objects.order_by("pk") 81 | assert len(fetched_models) == len(saved_models) == 3 82 | 83 | Model.objects.bulk_update(fetched_models, update_fields) 84 | assert len(fetched_models.all()) == 3 85 | 86 | 87 | @pytest.mark.parametrize("format", ["python", "json", "yaml", "jsonl"]) 88 | @pytest.mark.parametrize( 89 | "payload", 90 | [ 91 | { 92 | "sample_field": InnerSchema(stub_str="abc", stub_list=[date(2023, 6, 1)]), 93 | "sample_list": [InnerSchema(stub_str="abc", stub_list=[])], 94 | }, 95 | { 96 | "sample_field": {"stub_str": "abc", "stub_list": ["2023-06-01"]}, 97 | "sample_list": [{"stub_str": "abc", "stub_list": []}], 98 | }, 99 | ], 100 | ) 101 | def test_model_serialization(payload, format): 102 | instance = SampleModel(**payload) 103 | instance_values = {k: getattr(instance, k) for k in payload.keys()} 104 | 105 | serialized_instances = serializers.serialize(format, [instance]) 106 | deserialized_instance = next(serializers.deserialize(format, serialized_instances)).object 107 | deserialized_values = {k: getattr(deserialized_instance, k) for k in payload.keys()} 108 | 109 | assert instance_values == deserialized_values 110 | assert serialized_instances == serializers.serialize(format, [deserialized_instance]) 111 | 112 | 113 | @pytest.mark.parametrize( 114 | "lookup", 115 | [ 116 | Q(), 117 | Q(sample_field=InnerSchema(stub_str="abc", stub_list=[date(2023, 6, 1)])), 118 | Q(sample_field={"stub_str": "abc", "stub_list": ["2023-06-01"]}), 119 | Q(sample_field__stub_int=1), 120 | Q(sample_field__stub_str="abc"), 121 | Q(sample_field__stub_list=[date(2023, 6, 1)]), 122 | Q(sample_field__stub_str=F("sample_field__stub_str")), 123 | Q(sample_field__stub_int=F("sample_field__stub_int")), 124 | Q(sample_field__stub_int=Value(1, output_field=JSONField())), 125 | Q(sample_field__stub_str=Value("abc", output_field=JSONField())), 126 | ~Q(sample_field__stub_int=Value("abcd", output_field=JSONField())), 127 | ], 128 | ) 129 | def test_model_field_lookup_succeeded(lookup): 130 | instance = SampleModel( 131 | sample_field=dict(stub_str="abc", stub_list=["2023-06-01"]), 132 | sample_list=[], 133 | ) 134 | instance.save() 135 | 136 | filtered_instance = SampleModel.objects.get(lookup) 137 | assert filtered_instance.pk == instance.pk 138 | 139 | 140 | @pytest.mark.parametrize( 141 | "lookup", 142 | [ 143 | Q(sample_field=InnerSchema(stub_str="abcd", stub_list=[date(2023, 6, 1)])), 144 | Q(sample_field=InnerSchema(stub_str="abc", stub_list=[date(2023, 6, 2)])), 145 | Q(sample_field={"stub_str": "abcd", "stub_list": ["2023-06-01"]}), 146 | Q(sample_field={"stub_str": "abc", "stub_list": ["2023-06-02"]}), 147 | Q(sample_field__stub_int=2), 148 | Q(sample_field__stub_str="abcd"), 149 | Q(sample_field__stub_list=[date(2023, 6, 2)]), 150 | Q(sample_field__stub_int=F("sample_field__stub_str")), 151 | Q(sample_field__stub_int=Value(2, output_field=JSONField())), 152 | Q(sample_field__stub_int=Value("abcd", output_field=JSONField())), 153 | Q(sample_field__stub_str=Value("abcd", output_field=JSONField())), 154 | ], 155 | ) 156 | def test_model_field_lookup_failed(lookup): 157 | instance = SampleModel( 158 | sample_field=dict(stub_str="abc", stub_list=["2023-06-01"]), 159 | sample_list=[], 160 | ) 161 | instance.save() 162 | 163 | with pytest.raises(SampleModel.DoesNotExist): 164 | SampleModel.objects.get(lookup) 165 | -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | import typing as ty 4 | from collections import abc 5 | from copy import copy 6 | from datetime import date 7 | 8 | import pydantic 9 | import pytest 10 | from django.core.exceptions import ValidationError 11 | from django.db import connection, models 12 | from django.db.migrations.writer import MigrationWriter 13 | 14 | from django_pydantic_field import fields 15 | from django_pydantic_field.compat.pydantic import PYDANTIC_V1, PYDANTIC_V2 16 | 17 | from .conftest import InnerSchema, SampleDataclass, SchemaWithCustomTypes # noqa 18 | from .sample_app.models import Building 19 | from .test_app.models import SampleForwardRefModel, SampleModel, SampleSchema 20 | 21 | 22 | if PYDANTIC_V2: 23 | 24 | class SampleRootModel(pydantic.RootModel): 25 | root: ty.List[str] 26 | 27 | else: 28 | 29 | class SampleRootModel(pydantic.BaseModel): 30 | __root__: ty.List[str] 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "exported_primitive_name", 35 | ["SchemaField"], 36 | ) 37 | def test_module_imports(exported_primitive_name): 38 | assert exported_primitive_name in dir(fields) 39 | assert getattr(fields, exported_primitive_name, None) is not None 40 | 41 | 42 | def test_sample_field(): 43 | sample_field = fields.PydanticSchemaField(schema=InnerSchema) 44 | existing_instance = InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)]) 45 | 46 | expected_encoded = {"stub_str": "abc", "stub_int": 1, "stub_list": ["2022-07-01"]} 47 | expected_prepared = json.dumps(expected_encoded) 48 | 49 | assert sample_field.get_db_prep_value(existing_instance, connection) == expected_prepared 50 | assert sample_field.to_python(expected_encoded) == existing_instance 51 | 52 | 53 | def test_sample_field_with_raw_data(): 54 | sample_field = fields.PydanticSchemaField(schema=InnerSchema) 55 | existing_raw = {"stub_str": "abc", "stub_list": [date(2022, 7, 1)]} 56 | 57 | expected_encoded = {"stub_str": "abc", "stub_int": 1, "stub_list": ["2022-07-01"]} 58 | expected_prepared = json.dumps(expected_encoded) 59 | 60 | assert sample_field.get_db_prep_value(existing_raw, connection) == expected_prepared 61 | assert sample_field.to_python(expected_encoded) == InnerSchema(**existing_raw) 62 | 63 | 64 | def test_null_field(): 65 | field = fields.SchemaField(InnerSchema, null=True, default=None) 66 | assert field.to_python(None) is None 67 | assert field.get_prep_value(None) is None 68 | 69 | field = fields.SchemaField(ty.Optional[InnerSchema], null=True, default=None) 70 | assert field.get_prep_value(None) is None 71 | 72 | 73 | def test_forwardrefs_deferred_resolution(): 74 | obj = SampleForwardRefModel(field={}, annotated_field={}) 75 | assert isinstance(obj.field, SampleSchema) 76 | assert isinstance(obj.annotated_field, SampleSchema) 77 | 78 | 79 | @pytest.mark.parametrize( 80 | "forward_ref", 81 | [ 82 | "InnerSchema", 83 | ty.ForwardRef("SampleDataclass"), 84 | ty.List["int"], 85 | ], 86 | ) 87 | def test_resolved_forwardrefs(forward_ref): 88 | class ModelWithForwardRefs(models.Model): 89 | field: forward_ref = fields.SchemaField() 90 | 91 | class Meta: 92 | app_label = "test_app" 93 | 94 | 95 | @pytest.mark.parametrize( 96 | "field", 97 | [ 98 | fields.PydanticSchemaField( 99 | schema=InnerSchema, 100 | default=InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)]), 101 | ), 102 | fields.PydanticSchemaField( 103 | schema=InnerSchema, 104 | default={"stub_str": "abc", "stub_list": [date(2022, 7, 1)]}, 105 | ), 106 | fields.PydanticSchemaField(schema=InnerSchema, null=True, default=None), 107 | fields.PydanticSchemaField( 108 | schema=SampleDataclass, 109 | default={"stub_str": "abc", "stub_list": [date(2022, 7, 1)]}, 110 | ), 111 | fields.PydanticSchemaField(schema=ty.Optional[InnerSchema], null=True, default=None), 112 | fields.PydanticSchemaField(schema=SampleRootModel, default=[""]), 113 | fields.PydanticSchemaField(schema=ty.Optional[SampleRootModel], default=[""]), 114 | fields.PydanticSchemaField(schema=ty.Optional[SampleRootModel], null=True, default=None), 115 | fields.PydanticSchemaField(schema=ty.Optional[SampleRootModel], null=True, blank=True), 116 | fields.PydanticSchemaField(schema=SchemaWithCustomTypes, default={}), 117 | pytest.param( 118 | fields.PydanticSchemaField(schema=ty.Optional[SampleRootModel], default=SampleRootModel.parse_obj([])), 119 | marks=pytest.mark.xfail( 120 | PYDANTIC_V1, 121 | reason="Prepared root-model based defaults are not supported with Pydantic v1", 122 | raises=ValidationError, 123 | ), 124 | ), 125 | pytest.param( 126 | fields.PydanticSchemaField(schema=SampleRootModel, default=SampleRootModel.parse_obj([""])), 127 | marks=pytest.mark.xfail( 128 | PYDANTIC_V1, 129 | reason="Prepared root-model based defaults are not supported with Pydantic v1", 130 | raises=ValidationError, 131 | ), 132 | ), 133 | pytest.param( 134 | fields.PydanticSchemaField( 135 | schema=InnerSchema, 136 | default=(("stub_str", "abc"), ("stub_list", [date(2022, 7, 1)])), 137 | ), 138 | marks=pytest.mark.xfail( 139 | PYDANTIC_V2, 140 | reason="Tuple-based default reconstruction is not supported with Pydantic 2", 141 | raises=pydantic.ValidationError, 142 | ), 143 | ), 144 | ], 145 | ) 146 | def test_field_serialization(field): 147 | _test_field_serialization(field) 148 | 149 | 150 | @pytest.mark.skipif(sys.version_info < (3, 9), reason="Built-in type subscription supports only in 3.9+") 151 | @pytest.mark.parametrize( 152 | "field_factory", 153 | [ 154 | lambda: fields.PydanticSchemaField(schema=list[InnerSchema], default=list), 155 | lambda: fields.PydanticSchemaField(schema=dict[str, InnerSchema], default=dict), 156 | lambda: fields.PydanticSchemaField(schema=abc.Sequence[InnerSchema], default=list), 157 | lambda: fields.PydanticSchemaField(schema=abc.Mapping[str, InnerSchema], default=dict), 158 | ], 159 | ) 160 | def test_field_builtin_annotations_serialization(field_factory): 161 | _test_field_serialization(field_factory()) 162 | 163 | 164 | @pytest.mark.skipif(sys.version_info < (3, 10), reason="Union type syntax supported only in 3.10+") 165 | def test_field_union_type_serialization(): 166 | field = fields.PydanticSchemaField(schema=(InnerSchema | None), null=True, default=None) 167 | _test_field_serialization(field) 168 | 169 | 170 | @pytest.mark.skipif(sys.version_info >= (3, 9), reason="Should test against builtin generic types") 171 | @pytest.mark.parametrize( 172 | "field", 173 | [ 174 | fields.PydanticSchemaField(schema=ty.List[InnerSchema], default=list), 175 | fields.PydanticSchemaField(schema=ty.Dict[str, InnerSchema], default=dict), 176 | fields.PydanticSchemaField(schema=ty.Sequence[InnerSchema], default=list), 177 | fields.PydanticSchemaField(schema=ty.Mapping[str, InnerSchema], default=dict), 178 | ], 179 | ) 180 | def test_field_typing_annotations_serialization(field): 181 | _test_field_serialization(field) 182 | 183 | 184 | @pytest.mark.skipif( 185 | sys.version_info < (3, 9), 186 | reason="Typing-to-builtin migrations is reasonable only on py >= 3.9", 187 | ) 188 | @pytest.mark.parametrize( 189 | "old_field, new_field", 190 | [ 191 | ( 192 | lambda: fields.PydanticSchemaField(schema=ty.List[InnerSchema], default=list), 193 | lambda: fields.PydanticSchemaField(schema=list[InnerSchema], default=list), 194 | ), 195 | ( 196 | lambda: fields.PydanticSchemaField(schema=ty.Dict[str, InnerSchema], default=dict), 197 | lambda: fields.PydanticSchemaField(schema=dict[str, InnerSchema], default=dict), 198 | ), 199 | ( 200 | lambda: fields.PydanticSchemaField(schema=ty.Sequence[InnerSchema], default=list), 201 | lambda: fields.PydanticSchemaField(schema=abc.Sequence[InnerSchema], default=list), 202 | ), 203 | ( 204 | lambda: fields.PydanticSchemaField(schema=ty.Mapping[str, InnerSchema], default=dict), 205 | lambda: fields.PydanticSchemaField(schema=abc.Mapping[str, InnerSchema], default=dict), 206 | ), 207 | ( 208 | lambda: fields.PydanticSchemaField(schema=ty.Mapping[str, InnerSchema], default=dict), 209 | lambda: fields.PydanticSchemaField(schema=abc.Mapping[str, InnerSchema], default=dict), 210 | ), 211 | ], 212 | ) 213 | def test_field_typing_to_builtin_serialization(old_field, new_field): 214 | old_field, new_field = old_field(), new_field() 215 | 216 | _, _, args, kwargs = old_field.deconstruct() 217 | 218 | reconstructed_field = fields.PydanticSchemaField(*args, **kwargs) 219 | assert old_field.get_default() == new_field.get_default() == reconstructed_field.get_default() 220 | assert new_field.schema == reconstructed_field.schema 221 | 222 | deserialized_field = reconstruct_field(serialize_field(old_field)) 223 | assert old_field.get_default() == deserialized_field.get_default() == new_field.get_default() 224 | assert new_field.schema == deserialized_field.schema 225 | 226 | 227 | @pytest.mark.parametrize( 228 | "field, flawed_data", 229 | [ 230 | (fields.PydanticSchemaField(schema=InnerSchema), {}), 231 | (fields.PydanticSchemaField(schema=ty.List[InnerSchema]), [{}]), 232 | (fields.PydanticSchemaField(schema=ty.Dict[int, float]), {"1": "abc"}), 233 | ], 234 | ) 235 | def test_field_validation_exceptions(field, flawed_data): 236 | with pytest.raises(ValidationError): 237 | field.to_python(flawed_data) 238 | 239 | 240 | def test_model_validation_exceptions(): 241 | with pytest.raises(ValidationError): 242 | SampleModel(sample_field=1) 243 | with pytest.raises(ValidationError): 244 | SampleModel(sample_field={"stub_list": {}, "stub_str": ""}) 245 | 246 | valid_initial = SampleModel( 247 | sample_field={"stub_list": [], "stub_str": ""}, 248 | sample_list=[], 249 | sample_seq=[], 250 | ) 251 | with pytest.raises(ValidationError): 252 | valid_initial.sample_field = 1 253 | 254 | 255 | @pytest.mark.parametrize( 256 | "export_kwargs", 257 | [ 258 | {"include": {"stub_str", "stub_int"}}, 259 | {"exclude": {"stub_list"}}, 260 | {"by_alias": True}, 261 | {"exclude_unset": True}, 262 | {"exclude_defaults": True}, 263 | {"exclude_none": True}, 264 | ], 265 | ) 266 | def test_export_kwargs_support(export_kwargs): 267 | field = fields.PydanticSchemaField( 268 | schema=InnerSchema, 269 | default=InnerSchema(stub_str="", stub_list=[]), 270 | **export_kwargs, 271 | ) 272 | _test_field_serialization(field) 273 | 274 | existing_instance = InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)]) 275 | assert field.get_prep_value(existing_instance) 276 | 277 | 278 | def _test_field_serialization(field): 279 | _, _, args, kwargs = field_data = field.deconstruct() 280 | 281 | reconstructed_field = fields.PydanticSchemaField(*args, **kwargs) 282 | assert field.get_default() == reconstructed_field.get_default() 283 | 284 | if PYDANTIC_V2: 285 | assert reconstructed_field.deconstruct() == field_data 286 | elif PYDANTIC_V1: 287 | assert reconstructed_field.schema == field.schema 288 | else: 289 | pytest.fail("Unsupported Pydantic version") 290 | 291 | deserialized_field = reconstruct_field(serialize_field(field)) 292 | assert deserialized_field.get_default() == field.get_default() 293 | 294 | if PYDANTIC_V2: 295 | assert deserialized_field.deconstruct() == field_data 296 | elif PYDANTIC_V1: 297 | assert deserialized_field.schema == field.schema 298 | else: 299 | pytest.fail("Unsupported Pydantic version") 300 | 301 | 302 | def serialize_field(field: fields.PydanticSchemaField) -> str: 303 | serialized_field, _ = MigrationWriter.serialize(field) 304 | return serialized_field 305 | 306 | 307 | def reconstruct_field(field_repr: str) -> fields.PydanticSchemaField: 308 | return eval(field_repr, globals(), sys.modules) 309 | 310 | 311 | def test_copy_field(): 312 | copied = copy(Building.meta.field) 313 | 314 | assert copied.name == Building.meta.field.name 315 | assert copied.attname == Building.meta.field.attname 316 | assert copied.concrete == Building.meta.field.concrete 317 | 318 | 319 | def test_model_init_no_default(): 320 | try: 321 | SampleModel() 322 | except Exception: 323 | pytest.fail("Model with schema field without a default value should be able to initialize") 324 | -------------------------------------------------------------------------------- /tests/test_imports.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import pytest 4 | 5 | import django_pydantic_field 6 | from django_pydantic_field import fields, forms, rest_framework 7 | from django_pydantic_field.compat import PYDANTIC_V1, PYDANTIC_V2 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "module, exported_primitive_name", 12 | [ 13 | (django_pydantic_field, "SchemaField"), 14 | (fields, "SchemaField"), 15 | (forms, "SchemaField"), 16 | (rest_framework, "SchemaParser"), 17 | (rest_framework, "SchemaRenderer"), 18 | (rest_framework, "SchemaField"), 19 | (rest_framework, "AutoSchema"), 20 | pytest.param( 21 | rest_framework, 22 | "openapi", 23 | marks=pytest.mark.skipif( 24 | not PYDANTIC_V2, 25 | reason="`.rest_framework.openapi` module is only appearing in v2 layer", 26 | ), 27 | ), 28 | pytest.param( 29 | rest_framework, 30 | "coreapi", 31 | marks=pytest.mark.skipif( 32 | not PYDANTIC_V2, 33 | reason="`.rest_framework.coreapi` module is only appearing in v2 layer", 34 | ), 35 | ), 36 | ], 37 | ) 38 | def test_module_imports(module, exported_primitive_name): 39 | assert exported_primitive_name in dir(module) 40 | assert getattr(module, exported_primitive_name, None) is not None 41 | 42 | 43 | @pytest.mark.skipif(not PYDANTIC_V2, reason="AutoSchema import warning is only appearing in v2 layer") 44 | def test_rest_framework_autoschema_warning_v2(): 45 | with pytest.deprecated_call(match="`django_pydantic_field.rest_framework.AutoSchema` is deprecated.*"): 46 | rest_framework.AutoSchema 47 | 48 | 49 | @pytest.mark.skipif(not PYDANTIC_V1, reason="Deprecation warning should not be raised in v1 layer") 50 | def test_rest_framework_autoschema_no_warning_v1(): 51 | with warnings.catch_warnings(): 52 | warnings.simplefilter("error") 53 | rest_framework.AutoSchema 54 | -------------------------------------------------------------------------------- /tests/test_migration_serializers.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import typing as t 3 | import typing_extensions as te 4 | 5 | from django.db.migrations.writer import MigrationWriter 6 | import pytest 7 | 8 | import django_pydantic_field 9 | try: 10 | from django_pydantic_field.compat.django import GenericContainer 11 | except ImportError: 12 | from django_pydantic_field._migration_serializers import GenericContainer # noqa 13 | 14 | if sys.version_info < (3, 9): 15 | test_types = [ 16 | str, 17 | list, 18 | t.List[str], 19 | t.Union[te.Literal["foo"], t.List[str]], 20 | t.List[t.Union[int, bool]], 21 | t.Tuple[t.List[te.Literal[1]], t.Union[str, te.Literal["foo"]]], 22 | t.ForwardRef("str"), 23 | ] 24 | else: 25 | test_types = [ 26 | str, 27 | list, 28 | list[str], 29 | t.Union[t.Literal["foo"], list[str]], 30 | list[t.Union[int, bool]], 31 | tuple[list[t.Literal[1]], t.Union[str, t.Literal["foo"]]], 32 | t.ForwardRef("str"), 33 | ] 34 | 35 | 36 | @pytest.mark.parametrize("raw_type", test_types) 37 | def test_wrap_unwrap_idempotent(raw_type): 38 | wrapped_type = GenericContainer.wrap(raw_type) 39 | assert raw_type == GenericContainer.unwrap(wrapped_type) 40 | 41 | 42 | @pytest.mark.parametrize("raw_type", test_types) 43 | def test_serialize_eval_idempotent(raw_type): 44 | raw_type = GenericContainer.wrap(raw_type) 45 | expression, _ = MigrationWriter.serialize(GenericContainer.wrap(raw_type)) 46 | imports = dict(typing=t, typing_extensions=te, django_pydantic_field=django_pydantic_field) 47 | assert eval(expression, imports) == raw_type 48 | -------------------------------------------------------------------------------- /tests/test_model_admin.py: -------------------------------------------------------------------------------- 1 | from django.middleware.csrf import CsrfViewMiddleware 2 | import pytest 3 | 4 | from django.contrib.auth.models import AnonymousUser 5 | from django.contrib.admin import site 6 | 7 | from .test_app import admin, models 8 | 9 | pytestmark = [ 10 | pytest.mark.django_db(), 11 | ] 12 | 13 | all_admins = { 14 | # This model cannot be instantiated without reasonable defaults 15 | # models.SampleModel: admin.SampleModelAdmin, 16 | # 17 | models.SampleForwardRefModel: admin.SampleForwardRefModelAdmin, 18 | models.SampleModelWithRoot: admin.SampleModelWithRootAdmin, 19 | models.ExampleModel: admin.ExampleModelAdmin 20 | } 21 | 22 | 23 | @pytest.fixture 24 | def user(): 25 | return AnonymousUser() 26 | 27 | 28 | def patch_model_admin(admin_view, monkeypatch): 29 | monkeypatch.setattr(CsrfViewMiddleware, "process_view", lambda self, req, *a, **kw: self._accept(req)) 30 | monkeypatch.setattr(admin_view, "has_view_permission", lambda self, *args: True) 31 | monkeypatch.setattr(admin_view, "has_view_or_change_permission", lambda self, *args: True) 32 | monkeypatch.setattr(admin_view, "has_add_permission", lambda self, *args: True) 33 | monkeypatch.setattr(admin_view, "has_change_permission", lambda self, *args: True) 34 | monkeypatch.setattr(admin_view, "has_delete_permission", lambda self, *args: True) 35 | 36 | 37 | @pytest.mark.parametrize("model, admin_view", all_admins.items()) 38 | def test_model_admin_view_not_failing(model, admin_view, rf, user, monkeypatch): 39 | patch_model_admin(admin_view, monkeypatch) 40 | 41 | request = rf.get("/") 42 | request.user = user 43 | 44 | response = admin_view(model, site).changeform_view(request) 45 | assert response.status_code == 200 46 | -------------------------------------------------------------------------------- /tests/test_sample_app_migrations.py: -------------------------------------------------------------------------------- 1 | import os 2 | from contextlib import contextmanager 3 | 4 | import pytest 5 | from django.core.management import call_command 6 | 7 | pytestmark = pytest.mark.django_db(databases="__all__") 8 | 9 | MIGRATIONS_DIR = "tests/sample_app/migrations/" 10 | 11 | 12 | def test_makemigrations_not_failing(): 13 | call_command("makemigrations", "sample_app", "--noinput", "--dry-run") 14 | 15 | 16 | def test_makemigrations_no_duplicates(capfd): 17 | with clean_dir(MIGRATIONS_DIR): 18 | call_command("makemigrations", "sample_app", "--noinput") 19 | capfd.readouterr() 20 | 21 | call_command("makemigrations", "sample_app", "--noinput", "--dry-run") 22 | out, _ = capfd.readouterr() 23 | 24 | assert "No changes detected in app 'sample_app'" in out, out 25 | 26 | 27 | @contextmanager 28 | def clean_dir(path): 29 | initial_files = dir_files(path) 30 | 31 | try: 32 | yield 33 | finally: 34 | new_files = dir_files(path) - initial_files 35 | for f_path in new_files: 36 | os.remove(f_path) 37 | 38 | 39 | def dir_files(path): 40 | return {f.path for f in os.scandir(path) if f.is_file()} 41 | -------------------------------------------------------------------------------- /tests/v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surenkov/django-pydantic-field/ca90e8921705bd6a069623cd76a23f970d3ab87e/tests/v1/__init__.py -------------------------------------------------------------------------------- /tests/v1/test_base.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import typing as t 3 | from datetime import date 4 | from uuid import UUID 5 | 6 | import pydantic 7 | import pytest 8 | 9 | from tests.conftest import InnerSchema, SampleDataclass 10 | 11 | base = pytest.importorskip("django_pydantic_field.v1.base") 12 | 13 | 14 | class SampleSchema(pydantic.BaseModel): 15 | __root__: InnerSchema 16 | 17 | 18 | def test_schema_encoder(): 19 | encoder = base.SchemaEncoder(schema=SampleSchema) 20 | existing_model_inst = InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)]) 21 | expected_encoded = '{"stub_str": "abc", "stub_int": 1, "stub_list": ["2022-07-01"]}' 22 | assert encoder.encode(existing_model_inst) == expected_encoded 23 | 24 | 25 | def test_schema_encoder_with_raw_dict(): 26 | encoder = base.SchemaEncoder(schema=SampleSchema) 27 | existing_raw = {"stub_str": "abc", "stub_list": [date(2022, 7, 1)]} 28 | expected_encoded = '{"stub_str": "abc", "stub_int": 1, "stub_list": ["2022-07-01"]}' 29 | assert encoder.encode(existing_raw) == expected_encoded 30 | 31 | 32 | def test_schema_encoder_with_custom_config(): 33 | encoder = base.SchemaEncoder(schema=SampleSchema, export={"exclude": {"__root__": {"stub_list"}}}) 34 | existing_raw = {"stub_str": "abc", "stub_list": [date(2022, 7, 1)]} 35 | expected_encoded = '{"stub_str": "abc", "stub_int": 1}' 36 | assert encoder.encode(existing_raw) == expected_encoded 37 | 38 | 39 | def test_schema_decoder(): 40 | decoder = base.SchemaDecoder(schema=SampleSchema) 41 | existing_encoded = '{"stub_str": "abc", "stub_int": 1, "stub_list": ["2022-07-01"]}' 42 | expected_decoded = InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)]) 43 | 44 | assert decoder.decode(existing_encoded) == expected_decoded 45 | 46 | 47 | def test_schema_decoder_error(): 48 | existing_flawed_encoded = '{"stub_str": "abc", "stub_list": 1}' 49 | 50 | decoder = base.SchemaDecoder(schema=SampleSchema) 51 | 52 | with pytest.raises(pydantic.ValidationError) as e: 53 | decoder.decode(existing_flawed_encoded) 54 | 55 | assert e.match(".*stub_list.*") 56 | 57 | 58 | def test_schema_wrapper_transformers(): 59 | existing_encoded = '{"stub_str": "abc", "stub_int": 1, "stub_list": ["2022-07-01"]}' 60 | expected_decoded = InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)]) 61 | 62 | parsed_wrapper = base.wrap_schema(InnerSchema).parse_raw(existing_encoded) 63 | assert parsed_wrapper.__root__ == expected_decoded 64 | 65 | existing_encoded = '[{"stub_str": "abc", "stub_int": 1, "stub_list": ["2022-07-01"]}]' 66 | parsed_wrapper = base.wrap_schema(t.List[InnerSchema]).parse_raw(existing_encoded) 67 | assert parsed_wrapper.__root__ == [expected_decoded] 68 | 69 | 70 | def test_schema_wrapper_config_inheritance(): 71 | parsed_wrapper = base.wrap_schema(InnerSchema, config={"allow_mutation": False}) 72 | assert not parsed_wrapper.Config.allow_mutation 73 | assert not parsed_wrapper.Config.frozen 74 | 75 | parsed_wrapper = base.wrap_schema(t.List[InnerSchema], config={"frozen": True}) 76 | assert parsed_wrapper.Config.allow_mutation 77 | assert parsed_wrapper.Config.frozen 78 | 79 | 80 | @pytest.mark.parametrize( 81 | "type_, encoded, decoded", 82 | [ 83 | ( 84 | InnerSchema, 85 | '{"stub_str": "abc", "stub_list": ["2022-07-01"], "stub_int": 1}', 86 | InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)]), 87 | ), 88 | ( 89 | SampleDataclass, 90 | '{"stub_str": "abc", "stub_list": ["2022-07-01"], "stub_int": 1}', 91 | SampleDataclass(stub_str="abc", stub_list=[date(2022, 7, 1)]), 92 | ), 93 | (t.List[int], "[1, 2, 3]", [1, 2, 3]), 94 | (t.Mapping[int, date], '{"1": "1970-01-01"}', {1: date(1970, 1, 1)}), 95 | (t.Set[UUID], '["ba6eb330-4f7f-11eb-a2fb-67c34e9ac07c"]', {UUID("ba6eb330-4f7f-11eb-a2fb-67c34e9ac07c")}), 96 | ], 97 | ) 98 | def test_concrete_types(type_, encoded, decoded): 99 | schema = base.wrap_schema(type_) 100 | encoder = base.SchemaEncoder(schema=schema) 101 | decoder = base.SchemaDecoder(schema=schema) 102 | 103 | existing_decoded = decoder.decode(encoded) 104 | assert existing_decoded == decoded 105 | 106 | existing_encoded = encoder.encode(decoded) 107 | assert decoder.decode(existing_encoded) == decoded 108 | 109 | 110 | @pytest.mark.skipif(sys.version_info < (3, 9), reason="Should test against builtin generic types") 111 | @pytest.mark.parametrize( 112 | "type_factory, encoded, decoded", 113 | [ 114 | ( 115 | lambda: list[InnerSchema], 116 | '[{"stub_str": "abc", "stub_list": ["2022-07-01"], "stub_int": 1}]', 117 | [InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)])], 118 | ), 119 | (lambda: list[SampleDataclass], '[{"stub_str": "abc", "stub_list": ["2022-07-01"], "stub_int": 1}]', [SampleDataclass(stub_str="abc", stub_list=[date(2022, 7, 1)])]), # type: ignore 120 | (lambda: list[int], "[1, 2, 3]", [1, 2, 3]), 121 | (lambda: dict[int, date], '{"1": "1970-01-01"}', {1: date(1970, 1, 1)}), 122 | (lambda: set[UUID], '["ba6eb330-4f7f-11eb-a2fb-67c34e9ac07c"]', {UUID("ba6eb330-4f7f-11eb-a2fb-67c34e9ac07c")}), 123 | ], 124 | ) 125 | def test_concrete_raw_types(type_factory, encoded, decoded): 126 | type_ = type_factory() 127 | 128 | schema = base.wrap_schema(type_) 129 | encoder = base.SchemaEncoder(schema=schema) 130 | decoder = base.SchemaDecoder(schema=schema) 131 | 132 | existing_decoded = decoder.decode(encoded) 133 | assert existing_decoded == decoded 134 | 135 | existing_encoded = encoder.encode(decoded) 136 | assert decoder.decode(existing_encoded) == decoded 137 | 138 | 139 | @pytest.mark.parametrize( 140 | "forward_ref, sample_data", 141 | [ 142 | (t.ForwardRef("t.List[int]"), "[1, 2]"), 143 | (t.ForwardRef("InnerSchema"), '{"stub_str": "abc", "stub_int": 1, "stub_list": ["2022-07-01"]}'), 144 | (t.ForwardRef("PostponedSchema"), '{"stub_str": "abc", "stub_int": 1, "stub_list": ["2022-07-01"]}'), 145 | ], 146 | ) 147 | def test_forward_refs_preparation(forward_ref, sample_data): 148 | schema = base.wrap_schema(forward_ref) 149 | base.prepare_schema(schema, test_forward_refs_preparation) 150 | assert schema.parse_raw(sample_data).json() == sample_data 151 | 152 | 153 | class PostponedSchema(pydantic.BaseModel): 154 | __root__: InnerSchema 155 | -------------------------------------------------------------------------------- /tests/v1/test_fields.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import typing as t 3 | from datetime import date 4 | 5 | from django.core.exceptions import FieldError 6 | from django.db import models 7 | 8 | from tests.conftest import InnerSchema 9 | from tests.test_app.models import SampleModel 10 | 11 | fields = pytest.importorskip("django_pydantic_field.v1.fields") 12 | 13 | 14 | def test_simple_model_field(): 15 | sample_field = SampleModel._meta.get_field("sample_field") 16 | assert sample_field.schema == InnerSchema 17 | 18 | sample_list_field = SampleModel._meta.get_field("sample_list") 19 | assert sample_list_field.schema == t.List[InnerSchema] 20 | 21 | sample_seq_field = SampleModel._meta.get_field("sample_seq") 22 | assert sample_seq_field.schema == t.List[InnerSchema] 23 | 24 | existing_raw_field = {"stub_str": "abc", "stub_list": [date(2022, 7, 1)]} 25 | existing_raw_list = [{"stub_str": "abc", "stub_list": []}] 26 | 27 | instance = SampleModel(sample_field=existing_raw_field, sample_list=existing_raw_list) 28 | 29 | expected_instance = InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)]) 30 | expected_list = [InnerSchema(stub_str="abc", stub_list=[])] 31 | 32 | assert instance.sample_field == expected_instance 33 | assert instance.sample_list == expected_list 34 | 35 | 36 | def test_untyped_model_field_raises(): 37 | with pytest.raises(FieldError): 38 | 39 | class UntypedModel(models.Model): 40 | sample_field = fields.SchemaField() 41 | 42 | class Meta: 43 | app_label = "test_app" 44 | -------------------------------------------------------------------------------- /tests/v1/test_forms.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import django 4 | import pytest 5 | from django.core.exceptions import ValidationError 6 | from django.forms import Form, modelform_factory 7 | 8 | from tests.conftest import InnerSchema 9 | from tests.test_app.models import SampleForwardRefModel, SampleSchema 10 | 11 | fields = pytest.importorskip("django_pydantic_field.v1.fields") 12 | forms = pytest.importorskip("django_pydantic_field.v1.forms") 13 | 14 | 15 | class SampleForm(Form): 16 | field = forms.SchemaField(t.ForwardRef("SampleSchema")) 17 | 18 | 19 | def test_form_schema_field(): 20 | field = forms.SchemaField(InnerSchema) 21 | 22 | cleaned_data = field.clean('{"stub_str": "abc", "stub_list": ["1970-01-01"]}') 23 | assert cleaned_data == InnerSchema.parse_obj({"stub_str": "abc", "stub_list": ["1970-01-01"]}) 24 | 25 | 26 | def test_empty_form_values(): 27 | field = forms.SchemaField(InnerSchema, required=False) 28 | assert field.clean("") is None 29 | assert field.clean(None) is None 30 | 31 | 32 | def test_prepare_value(): 33 | field = forms.SchemaField(InnerSchema, required=False) 34 | expected = '{"stub_str": "abc", "stub_int": 1, "stub_list": ["1970-01-01"]}' 35 | assert expected == field.prepare_value({"stub_str": "abc", "stub_list": ["1970-01-01"]}) 36 | 37 | 38 | def test_prepare_value_export_params(): 39 | field = forms.SchemaField(InnerSchema, required=False, indent=2, sort_keys=True, separators=('', ' > ')) 40 | expected = """{ 41 | "stub_int" > 1 42 | "stub_list" > [ 43 | "1970-01-01" 44 | ] 45 | "stub_str" > "abc" 46 | }""" 47 | assert expected == field.prepare_value({"stub_str": "abc", "stub_list": ["1970-01-01"]}) 48 | 49 | 50 | def test_empty_required_raises(): 51 | field = forms.SchemaField(InnerSchema) 52 | with pytest.raises(ValidationError) as e: 53 | field.clean("") 54 | 55 | assert e.match("This field is required") 56 | 57 | 58 | def test_invalid_schema_raises(): 59 | field = forms.SchemaField(InnerSchema) 60 | with pytest.raises(ValidationError) as e: 61 | field.clean('{"stub_list": "abc"}') 62 | 63 | assert e.match("stub_str") 64 | assert e.match("stub_list") 65 | 66 | 67 | def test_invalid_json_raises(): 68 | field = forms.SchemaField(InnerSchema) 69 | with pytest.raises(ValidationError) as e: 70 | field.clean('{"stub_list": "abc}') 71 | 72 | assert e.match('type=value_error.jsondecode') 73 | 74 | 75 | @pytest.mark.xfail( 76 | django.VERSION[:2] < (4, 0), 77 | reason="Django < 4 has it's own feeling on bound fields resolution", 78 | ) 79 | def test_forwardref_field(): 80 | form = SampleForm(data={"field": '{"field": "2"}'}) 81 | assert form.is_valid() 82 | 83 | 84 | def test_model_formfield(): 85 | field = fields.PydanticSchemaField(schema=InnerSchema) 86 | assert isinstance(field.formfield(), forms.SchemaField) 87 | 88 | 89 | def test_forwardref_model_formfield(): 90 | form_cls = modelform_factory(SampleForwardRefModel, exclude=("field",)) 91 | form = form_cls(data={"annotated_field": '{"field": "2"}'}) 92 | 93 | assert form.is_valid(), form.errors 94 | cleaned_data = form.cleaned_data 95 | 96 | assert cleaned_data is not None 97 | assert cleaned_data["annotated_field"] == SampleSchema(field=2) 98 | 99 | 100 | @pytest.mark.parametrize("export_kwargs", [ 101 | {"include": {"stub_str", "stub_int"}}, 102 | {"exclude": {"stub_list"}}, 103 | {"exclude_unset": True}, 104 | {"exclude_defaults": True}, 105 | {"exclude_none": True}, 106 | {"by_alias": True}, 107 | {"indent": 4}, 108 | {"separators": (',', ': ')}, 109 | {"sort_keys": True}, 110 | ]) 111 | def test_form_field_export_kwargs(export_kwargs): 112 | field = forms.SchemaField(InnerSchema, required=False, **export_kwargs) 113 | value = InnerSchema.parse_obj({"stub_str": "abc", "stub_list": ["1970-01-01"]}) 114 | assert field.prepare_value(value) 115 | -------------------------------------------------------------------------------- /tests/v2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surenkov/django-pydantic-field/ca90e8921705bd6a069623cd76a23f970d3ab87e/tests/v2/__init__.py -------------------------------------------------------------------------------- /tests/v2/rest_framework/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surenkov/django-pydantic-field/ca90e8921705bd6a069623cd76a23f970d3ab87e/tests/v2/rest_framework/__init__.py -------------------------------------------------------------------------------- /tests/v2/rest_framework/__snapshots__/test_openapi/test_openapi_schema_generators[GET-class].json: -------------------------------------------------------------------------------- 1 | { 2 | "components": { 3 | "schemas": { 4 | "InnerSchema": { 5 | "properties": { 6 | "stub_int": { 7 | "default": 1, 8 | "title": "Stub Int", 9 | "type": "integer" 10 | }, 11 | "stub_list": { 12 | "items": { 13 | "format": "date", 14 | "type": "string" 15 | }, 16 | "title": "Stub List", 17 | "type": "array" 18 | }, 19 | "stub_str": { 20 | "title": "Stub Str", 21 | "type": "string" 22 | } 23 | }, 24 | "required": [ 25 | "stub_str", 26 | "stub_list" 27 | ], 28 | "title": "InnerSchema", 29 | "type": "object" 30 | }, 31 | "Sample": { 32 | "properties": { 33 | "field": { 34 | "items": { 35 | "$ref": "#/components/schemas/InnerSchema" 36 | }, 37 | "type": "array" 38 | } 39 | }, 40 | "required": [ 41 | "field" 42 | ], 43 | "type": "object" 44 | } 45 | } 46 | }, 47 | "info": { 48 | "title": "", 49 | "version": "" 50 | }, 51 | "openapi": "3.0.2", 52 | "paths": { 53 | "/class": { 54 | "get": { 55 | "description": "", 56 | "operationId": "retrieveSample", 57 | "parameters": [], 58 | "responses": { 59 | "200": { 60 | "content": { 61 | "application/json": { 62 | "$ref": "#/components/schemas/Sample" 63 | }, 64 | "text/html": { 65 | "$ref": "#/components/schemas/Sample" 66 | } 67 | }, 68 | "description": "" 69 | } 70 | }, 71 | "tags": [ 72 | "class" 73 | ] 74 | }, 75 | "patch": { 76 | "description": "", 77 | "operationId": "partialUpdateSample", 78 | "parameters": [], 79 | "requestBody": { 80 | "content": { 81 | "application/json": { 82 | "schema": { 83 | "$ref": "#/components/schemas/Sample" 84 | } 85 | }, 86 | "application/x-www-form-urlencoded": { 87 | "schema": { 88 | "$ref": "#/components/schemas/Sample" 89 | } 90 | }, 91 | "multipart/form-data": { 92 | "schema": { 93 | "$ref": "#/components/schemas/Sample" 94 | } 95 | } 96 | } 97 | }, 98 | "responses": { 99 | "200": { 100 | "content": { 101 | "application/json": { 102 | "$ref": "#/components/schemas/Sample" 103 | }, 104 | "text/html": { 105 | "$ref": "#/components/schemas/Sample" 106 | } 107 | }, 108 | "description": "" 109 | } 110 | }, 111 | "tags": [ 112 | "class" 113 | ] 114 | }, 115 | "put": { 116 | "description": "", 117 | "operationId": "updateSample", 118 | "parameters": [], 119 | "requestBody": { 120 | "content": { 121 | "application/json": { 122 | "schema": { 123 | "$ref": "#/components/schemas/Sample" 124 | } 125 | }, 126 | "application/x-www-form-urlencoded": { 127 | "schema": { 128 | "$ref": "#/components/schemas/Sample" 129 | } 130 | }, 131 | "multipart/form-data": { 132 | "schema": { 133 | "$ref": "#/components/schemas/Sample" 134 | } 135 | } 136 | } 137 | }, 138 | "responses": { 139 | "200": { 140 | "content": { 141 | "application/json": { 142 | "$ref": "#/components/schemas/Sample" 143 | }, 144 | "text/html": { 145 | "$ref": "#/components/schemas/Sample" 146 | } 147 | }, 148 | "description": "" 149 | } 150 | }, 151 | "tags": [ 152 | "class" 153 | ] 154 | } 155 | }, 156 | "/func": { 157 | "get": { 158 | "description": "", 159 | "operationId": "listsample_views", 160 | "parameters": [], 161 | "responses": { 162 | "200": { 163 | "content": { 164 | "application/json": { 165 | "items": { 166 | "schema": { 167 | "items": { 168 | "$ref": "#/components/schemas/InnerSchema" 169 | }, 170 | "type": "array" 171 | } 172 | }, 173 | "type": "array" 174 | } 175 | }, 176 | "description": "" 177 | } 178 | }, 179 | "tags": [ 180 | "func" 181 | ] 182 | }, 183 | "post": { 184 | "description": "", 185 | "operationId": "createsample_view", 186 | "parameters": [], 187 | "requestBody": { 188 | "content": { 189 | "application/json": { 190 | "schema": { 191 | "$ref": "#/components/schemas/InnerSchema" 192 | } 193 | } 194 | } 195 | }, 196 | "responses": { 197 | "201": { 198 | "content": { 199 | "application/json": { 200 | "schema": { 201 | "items": { 202 | "$ref": "#/components/schemas/InnerSchema" 203 | }, 204 | "type": "array" 205 | } 206 | } 207 | }, 208 | "description": "" 209 | } 210 | }, 211 | "tags": [ 212 | "func" 213 | ] 214 | } 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /tests/v2/rest_framework/__snapshots__/test_openapi/test_openapi_schema_generators[GET-func].json: -------------------------------------------------------------------------------- 1 | { 2 | "components": { 3 | "schemas": { 4 | "InnerSchema": { 5 | "properties": { 6 | "stub_int": { 7 | "default": 1, 8 | "title": "Stub Int", 9 | "type": "integer" 10 | }, 11 | "stub_list": { 12 | "items": { 13 | "format": "date", 14 | "type": "string" 15 | }, 16 | "title": "Stub List", 17 | "type": "array" 18 | }, 19 | "stub_str": { 20 | "title": "Stub Str", 21 | "type": "string" 22 | } 23 | }, 24 | "required": [ 25 | "stub_str", 26 | "stub_list" 27 | ], 28 | "title": "InnerSchema", 29 | "type": "object" 30 | }, 31 | "Sample": { 32 | "properties": { 33 | "field": { 34 | "items": { 35 | "$ref": "#/components/schemas/InnerSchema" 36 | }, 37 | "type": "array" 38 | } 39 | }, 40 | "required": [ 41 | "field" 42 | ], 43 | "type": "object" 44 | } 45 | } 46 | }, 47 | "info": { 48 | "title": "", 49 | "version": "" 50 | }, 51 | "openapi": "3.0.2", 52 | "paths": { 53 | "/class": { 54 | "get": { 55 | "description": "", 56 | "operationId": "retrieveSample", 57 | "parameters": [], 58 | "responses": { 59 | "200": { 60 | "content": { 61 | "application/json": { 62 | "$ref": "#/components/schemas/Sample" 63 | }, 64 | "text/html": { 65 | "$ref": "#/components/schemas/Sample" 66 | } 67 | }, 68 | "description": "" 69 | } 70 | }, 71 | "tags": [ 72 | "class" 73 | ] 74 | }, 75 | "patch": { 76 | "description": "", 77 | "operationId": "partialUpdateSample", 78 | "parameters": [], 79 | "requestBody": { 80 | "content": { 81 | "application/json": { 82 | "schema": { 83 | "$ref": "#/components/schemas/Sample" 84 | } 85 | }, 86 | "application/x-www-form-urlencoded": { 87 | "schema": { 88 | "$ref": "#/components/schemas/Sample" 89 | } 90 | }, 91 | "multipart/form-data": { 92 | "schema": { 93 | "$ref": "#/components/schemas/Sample" 94 | } 95 | } 96 | } 97 | }, 98 | "responses": { 99 | "200": { 100 | "content": { 101 | "application/json": { 102 | "$ref": "#/components/schemas/Sample" 103 | }, 104 | "text/html": { 105 | "$ref": "#/components/schemas/Sample" 106 | } 107 | }, 108 | "description": "" 109 | } 110 | }, 111 | "tags": [ 112 | "class" 113 | ] 114 | }, 115 | "put": { 116 | "description": "", 117 | "operationId": "updateSample", 118 | "parameters": [], 119 | "requestBody": { 120 | "content": { 121 | "application/json": { 122 | "schema": { 123 | "$ref": "#/components/schemas/Sample" 124 | } 125 | }, 126 | "application/x-www-form-urlencoded": { 127 | "schema": { 128 | "$ref": "#/components/schemas/Sample" 129 | } 130 | }, 131 | "multipart/form-data": { 132 | "schema": { 133 | "$ref": "#/components/schemas/Sample" 134 | } 135 | } 136 | } 137 | }, 138 | "responses": { 139 | "200": { 140 | "content": { 141 | "application/json": { 142 | "$ref": "#/components/schemas/Sample" 143 | }, 144 | "text/html": { 145 | "$ref": "#/components/schemas/Sample" 146 | } 147 | }, 148 | "description": "" 149 | } 150 | }, 151 | "tags": [ 152 | "class" 153 | ] 154 | } 155 | }, 156 | "/func": { 157 | "get": { 158 | "description": "", 159 | "operationId": "listsample_views", 160 | "parameters": [], 161 | "responses": { 162 | "200": { 163 | "content": { 164 | "application/json": { 165 | "items": { 166 | "schema": { 167 | "items": { 168 | "$ref": "#/components/schemas/InnerSchema" 169 | }, 170 | "type": "array" 171 | } 172 | }, 173 | "type": "array" 174 | } 175 | }, 176 | "description": "" 177 | } 178 | }, 179 | "tags": [ 180 | "func" 181 | ] 182 | }, 183 | "post": { 184 | "description": "", 185 | "operationId": "createsample_view", 186 | "parameters": [], 187 | "requestBody": { 188 | "content": { 189 | "application/json": { 190 | "schema": { 191 | "$ref": "#/components/schemas/InnerSchema" 192 | } 193 | } 194 | } 195 | }, 196 | "responses": { 197 | "201": { 198 | "content": { 199 | "application/json": { 200 | "schema": { 201 | "items": { 202 | "$ref": "#/components/schemas/InnerSchema" 203 | }, 204 | "type": "array" 205 | } 206 | } 207 | }, 208 | "description": "" 209 | } 210 | }, 211 | "tags": [ 212 | "func" 213 | ] 214 | } 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /tests/v2/rest_framework/__snapshots__/test_openapi/test_openapi_schema_generators[POST-func].json: -------------------------------------------------------------------------------- 1 | { 2 | "components": { 3 | "schemas": { 4 | "InnerSchema": { 5 | "properties": { 6 | "stub_int": { 7 | "default": 1, 8 | "title": "Stub Int", 9 | "type": "integer" 10 | }, 11 | "stub_list": { 12 | "items": { 13 | "format": "date", 14 | "type": "string" 15 | }, 16 | "title": "Stub List", 17 | "type": "array" 18 | }, 19 | "stub_str": { 20 | "title": "Stub Str", 21 | "type": "string" 22 | } 23 | }, 24 | "required": [ 25 | "stub_str", 26 | "stub_list" 27 | ], 28 | "title": "InnerSchema", 29 | "type": "object" 30 | }, 31 | "Sample": { 32 | "properties": { 33 | "field": { 34 | "items": { 35 | "$ref": "#/components/schemas/InnerSchema" 36 | }, 37 | "type": "array" 38 | } 39 | }, 40 | "required": [ 41 | "field" 42 | ], 43 | "type": "object" 44 | } 45 | } 46 | }, 47 | "info": { 48 | "title": "", 49 | "version": "" 50 | }, 51 | "openapi": "3.0.2", 52 | "paths": { 53 | "/class": { 54 | "get": { 55 | "description": "", 56 | "operationId": "retrieveSample", 57 | "parameters": [], 58 | "responses": { 59 | "200": { 60 | "content": { 61 | "application/json": { 62 | "$ref": "#/components/schemas/Sample" 63 | }, 64 | "text/html": { 65 | "$ref": "#/components/schemas/Sample" 66 | } 67 | }, 68 | "description": "" 69 | } 70 | }, 71 | "tags": [ 72 | "class" 73 | ] 74 | }, 75 | "patch": { 76 | "description": "", 77 | "operationId": "partialUpdateSample", 78 | "parameters": [], 79 | "requestBody": { 80 | "content": { 81 | "application/json": { 82 | "schema": { 83 | "$ref": "#/components/schemas/Sample" 84 | } 85 | }, 86 | "application/x-www-form-urlencoded": { 87 | "schema": { 88 | "$ref": "#/components/schemas/Sample" 89 | } 90 | }, 91 | "multipart/form-data": { 92 | "schema": { 93 | "$ref": "#/components/schemas/Sample" 94 | } 95 | } 96 | } 97 | }, 98 | "responses": { 99 | "200": { 100 | "content": { 101 | "application/json": { 102 | "$ref": "#/components/schemas/Sample" 103 | }, 104 | "text/html": { 105 | "$ref": "#/components/schemas/Sample" 106 | } 107 | }, 108 | "description": "" 109 | } 110 | }, 111 | "tags": [ 112 | "class" 113 | ] 114 | }, 115 | "put": { 116 | "description": "", 117 | "operationId": "updateSample", 118 | "parameters": [], 119 | "requestBody": { 120 | "content": { 121 | "application/json": { 122 | "schema": { 123 | "$ref": "#/components/schemas/Sample" 124 | } 125 | }, 126 | "application/x-www-form-urlencoded": { 127 | "schema": { 128 | "$ref": "#/components/schemas/Sample" 129 | } 130 | }, 131 | "multipart/form-data": { 132 | "schema": { 133 | "$ref": "#/components/schemas/Sample" 134 | } 135 | } 136 | } 137 | }, 138 | "responses": { 139 | "200": { 140 | "content": { 141 | "application/json": { 142 | "$ref": "#/components/schemas/Sample" 143 | }, 144 | "text/html": { 145 | "$ref": "#/components/schemas/Sample" 146 | } 147 | }, 148 | "description": "" 149 | } 150 | }, 151 | "tags": [ 152 | "class" 153 | ] 154 | } 155 | }, 156 | "/func": { 157 | "get": { 158 | "description": "", 159 | "operationId": "listsample_views", 160 | "parameters": [], 161 | "responses": { 162 | "200": { 163 | "content": { 164 | "application/json": { 165 | "items": { 166 | "schema": { 167 | "items": { 168 | "$ref": "#/components/schemas/InnerSchema" 169 | }, 170 | "type": "array" 171 | } 172 | }, 173 | "type": "array" 174 | } 175 | }, 176 | "description": "" 177 | } 178 | }, 179 | "tags": [ 180 | "func" 181 | ] 182 | }, 183 | "post": { 184 | "description": "", 185 | "operationId": "createsample_view", 186 | "parameters": [], 187 | "requestBody": { 188 | "content": { 189 | "application/json": { 190 | "schema": { 191 | "$ref": "#/components/schemas/InnerSchema" 192 | } 193 | } 194 | } 195 | }, 196 | "responses": { 197 | "201": { 198 | "content": { 199 | "application/json": { 200 | "schema": { 201 | "items": { 202 | "$ref": "#/components/schemas/InnerSchema" 203 | }, 204 | "type": "array" 205 | } 206 | } 207 | }, 208 | "description": "" 209 | } 210 | }, 211 | "tags": [ 212 | "func" 213 | ] 214 | } 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /tests/v2/rest_framework/__snapshots__/test_openapi/test_openapi_schema_generators[PUT-class].json: -------------------------------------------------------------------------------- 1 | { 2 | "components": { 3 | "schemas": { 4 | "InnerSchema": { 5 | "properties": { 6 | "stub_int": { 7 | "default": 1, 8 | "title": "Stub Int", 9 | "type": "integer" 10 | }, 11 | "stub_list": { 12 | "items": { 13 | "format": "date", 14 | "type": "string" 15 | }, 16 | "title": "Stub List", 17 | "type": "array" 18 | }, 19 | "stub_str": { 20 | "title": "Stub Str", 21 | "type": "string" 22 | } 23 | }, 24 | "required": [ 25 | "stub_str", 26 | "stub_list" 27 | ], 28 | "title": "InnerSchema", 29 | "type": "object" 30 | }, 31 | "Sample": { 32 | "properties": { 33 | "field": { 34 | "items": { 35 | "$ref": "#/components/schemas/InnerSchema" 36 | }, 37 | "type": "array" 38 | } 39 | }, 40 | "required": [ 41 | "field" 42 | ], 43 | "type": "object" 44 | } 45 | } 46 | }, 47 | "info": { 48 | "title": "", 49 | "version": "" 50 | }, 51 | "openapi": "3.0.2", 52 | "paths": { 53 | "/class": { 54 | "get": { 55 | "description": "", 56 | "operationId": "retrieveSample", 57 | "parameters": [], 58 | "responses": { 59 | "200": { 60 | "content": { 61 | "application/json": { 62 | "$ref": "#/components/schemas/Sample" 63 | }, 64 | "text/html": { 65 | "$ref": "#/components/schemas/Sample" 66 | } 67 | }, 68 | "description": "" 69 | } 70 | }, 71 | "tags": [ 72 | "class" 73 | ] 74 | }, 75 | "patch": { 76 | "description": "", 77 | "operationId": "partialUpdateSample", 78 | "parameters": [], 79 | "requestBody": { 80 | "content": { 81 | "application/json": { 82 | "schema": { 83 | "$ref": "#/components/schemas/Sample" 84 | } 85 | }, 86 | "application/x-www-form-urlencoded": { 87 | "schema": { 88 | "$ref": "#/components/schemas/Sample" 89 | } 90 | }, 91 | "multipart/form-data": { 92 | "schema": { 93 | "$ref": "#/components/schemas/Sample" 94 | } 95 | } 96 | } 97 | }, 98 | "responses": { 99 | "200": { 100 | "content": { 101 | "application/json": { 102 | "$ref": "#/components/schemas/Sample" 103 | }, 104 | "text/html": { 105 | "$ref": "#/components/schemas/Sample" 106 | } 107 | }, 108 | "description": "" 109 | } 110 | }, 111 | "tags": [ 112 | "class" 113 | ] 114 | }, 115 | "put": { 116 | "description": "", 117 | "operationId": "updateSample", 118 | "parameters": [], 119 | "requestBody": { 120 | "content": { 121 | "application/json": { 122 | "schema": { 123 | "$ref": "#/components/schemas/Sample" 124 | } 125 | }, 126 | "application/x-www-form-urlencoded": { 127 | "schema": { 128 | "$ref": "#/components/schemas/Sample" 129 | } 130 | }, 131 | "multipart/form-data": { 132 | "schema": { 133 | "$ref": "#/components/schemas/Sample" 134 | } 135 | } 136 | } 137 | }, 138 | "responses": { 139 | "200": { 140 | "content": { 141 | "application/json": { 142 | "$ref": "#/components/schemas/Sample" 143 | }, 144 | "text/html": { 145 | "$ref": "#/components/schemas/Sample" 146 | } 147 | }, 148 | "description": "" 149 | } 150 | }, 151 | "tags": [ 152 | "class" 153 | ] 154 | } 155 | }, 156 | "/func": { 157 | "get": { 158 | "description": "", 159 | "operationId": "listsample_views", 160 | "parameters": [], 161 | "responses": { 162 | "200": { 163 | "content": { 164 | "application/json": { 165 | "items": { 166 | "schema": { 167 | "items": { 168 | "$ref": "#/components/schemas/InnerSchema" 169 | }, 170 | "type": "array" 171 | } 172 | }, 173 | "type": "array" 174 | } 175 | }, 176 | "description": "" 177 | } 178 | }, 179 | "tags": [ 180 | "func" 181 | ] 182 | }, 183 | "post": { 184 | "description": "", 185 | "operationId": "createsample_view", 186 | "parameters": [], 187 | "requestBody": { 188 | "content": { 189 | "application/json": { 190 | "schema": { 191 | "$ref": "#/components/schemas/InnerSchema" 192 | } 193 | } 194 | } 195 | }, 196 | "responses": { 197 | "201": { 198 | "content": { 199 | "application/json": { 200 | "schema": { 201 | "items": { 202 | "$ref": "#/components/schemas/InnerSchema" 203 | }, 204 | "type": "array" 205 | } 206 | } 207 | }, 208 | "description": "" 209 | } 210 | }, 211 | "tags": [ 212 | "func" 213 | ] 214 | } 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /tests/v2/rest_framework/test_coreapi.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | from rest_framework import schemas 5 | from rest_framework.request import Request 6 | 7 | from .view_fixtures import create_views_urlconf 8 | 9 | coreapi = pytest.importorskip("django_pydantic_field.v2.rest_framework.coreapi") 10 | 11 | @pytest.mark.skipif(sys.version_info >= (3, 12), reason="CoreAPI is not compatible with 3.12") 12 | @pytest.mark.parametrize( 13 | "method, path", 14 | [ 15 | ("GET", "/func"), 16 | ("POST", "/func"), 17 | ("GET", "/class"), 18 | ("PUT", "/class"), 19 | ], 20 | ) 21 | def test_coreapi_schema_generators(request_factory, method, path): 22 | urlconf = create_views_urlconf(coreapi.AutoSchema) 23 | generator = schemas.SchemaGenerator(urlconf=urlconf) 24 | request = Request(request_factory.generic(method, path)) 25 | coreapi_schema = generator.get_schema(request) 26 | assert coreapi_schema 27 | -------------------------------------------------------------------------------- /tests/v2/rest_framework/test_e2e_views.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | import pytest 4 | 5 | from tests.conftest import InnerSchema 6 | 7 | from .view_fixtures import ( 8 | ClassBasedView, 9 | ClassBasedViewWithModel, 10 | ClassBasedViewWithSchemaContext, 11 | sample_view, 12 | ) 13 | 14 | rest_framework = pytest.importorskip("django_pydantic_field.v2.rest_framework") 15 | coreapi = pytest.importorskip("django_pydantic_field.v2.rest_framework.coreapi") 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "view", 20 | [ 21 | sample_view, 22 | ClassBasedView.as_view(), 23 | ClassBasedViewWithSchemaContext.as_view(), 24 | ], 25 | ) 26 | def test_end_to_end_api_view(view, request_factory): 27 | expected_instance = InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)]) 28 | existing_encoded = b'{"stub_str":"abc","stub_int":1,"stub_list":["2022-07-01"]}' 29 | 30 | request = request_factory.post("/", existing_encoded, content_type="application/json") 31 | response = view(request) 32 | 33 | assert response.data == [expected_instance] 34 | assert response.data[0] is not expected_instance 35 | 36 | assert response.rendered_content == b"[%s]" % existing_encoded 37 | 38 | 39 | @pytest.mark.django_db 40 | def test_end_to_end_list_create_api_view(request_factory): 41 | field_data = InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)]).json() 42 | expected_result = { 43 | "sample_field": {"stub_str": "abc", "stub_list": ["2022-07-01"], "stub_int": 1}, 44 | "sample_list": [{"stub_str": "abc", "stub_list": ["2022-07-01"], "stub_int": 1}], 45 | "sample_seq": [], 46 | } 47 | 48 | payload = '{"sample_field": %s, "sample_list": [%s], "sample_seq": []}' % ((field_data,) * 2) 49 | request = request_factory.post("/", payload.encode(), content_type="application/json") 50 | response = ClassBasedViewWithModel.as_view()(request) 51 | 52 | assert response.data == expected_result 53 | 54 | request = request_factory.get("/", content_type="application/json") 55 | response = ClassBasedViewWithModel.as_view()(request) 56 | assert response.data == [expected_result] 57 | -------------------------------------------------------------------------------- /tests/v2/rest_framework/test_fields.py: -------------------------------------------------------------------------------- 1 | import typing as ty 2 | from datetime import date 3 | 4 | import pydantic 5 | import pytest 6 | import typing_extensions as te 7 | from rest_framework import exceptions, serializers 8 | 9 | from tests.conftest import InnerSchema 10 | from tests.test_app.models import SampleModel 11 | 12 | rest_framework = pytest.importorskip("django_pydantic_field.v2.rest_framework") 13 | 14 | 15 | class SampleSerializer(serializers.Serializer): 16 | field = rest_framework.SchemaField(schema=ty.List[InnerSchema]) 17 | annotated = rest_framework.SchemaField( 18 | schema=te.Annotated[ty.List[InnerSchema], pydantic.Field(alias="annotated_field")], 19 | default=list, 20 | by_alias=True, 21 | ) 22 | 23 | 24 | class SampleModelSerializer(serializers.ModelSerializer): 25 | sample_field = rest_framework.SchemaField(schema=InnerSchema) 26 | sample_list = rest_framework.SchemaField(schema=ty.List[InnerSchema]) 27 | sample_seq = rest_framework.SchemaField(schema=ty.List[InnerSchema], default=list) 28 | 29 | class Meta: 30 | model = SampleModel 31 | fields = "sample_field", "sample_list", "sample_seq" 32 | 33 | 34 | def test_schema_field(): 35 | field = rest_framework.SchemaField(InnerSchema) 36 | existing_instance = InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)]) 37 | expected_encoded = { 38 | "stub_str": "abc", 39 | "stub_int": 1, 40 | "stub_list": ["2022-07-01"], 41 | } 42 | 43 | assert field.to_representation(existing_instance) == expected_encoded 44 | assert field.to_internal_value(expected_encoded) == existing_instance 45 | 46 | with pytest.raises(serializers.ValidationError): 47 | field.to_internal_value(None) 48 | 49 | with pytest.raises(serializers.ValidationError): 50 | field.to_internal_value("null") 51 | 52 | 53 | def test_field_schema_with_custom_config(): 54 | field = rest_framework.SchemaField(InnerSchema, allow_null=True, exclude={"stub_int"}) 55 | existing_instance = InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)]) 56 | expected_encoded = {"stub_str": "abc", "stub_list": ["2022-07-01"]} 57 | 58 | assert field.to_representation(existing_instance) == expected_encoded 59 | assert field.to_internal_value(expected_encoded) == existing_instance 60 | assert field.to_internal_value(None) is None 61 | assert field.to_internal_value("null") is None 62 | 63 | 64 | def test_serializer_marshalling_with_schema_field(): 65 | existing_instance = {"field": [InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)])], "annotated_field": []} 66 | expected_data = {"field": [{"stub_str": "abc", "stub_int": 1, "stub_list": ["2022-07-01"]}], "annotated": []} 67 | expected_validated_data = {"field": [InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)])], "annotated": []} 68 | 69 | serializer = SampleSerializer(instance=existing_instance) 70 | assert serializer.data == expected_data 71 | 72 | serializer = SampleSerializer(data=expected_data) 73 | serializer.is_valid(raise_exception=True) 74 | assert serializer.validated_data == expected_validated_data 75 | 76 | 77 | def test_model_serializer_marshalling_with_schema_field(): 78 | instance = SampleModel( 79 | sample_field=InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)]), 80 | sample_list=[InnerSchema(stub_str="abc", stub_int=2, stub_list=[date(2022, 7, 1)])] * 2, 81 | sample_seq=[InnerSchema(stub_str="abc", stub_int=3, stub_list=[date(2022, 7, 1)])] * 3, 82 | ) 83 | serializer = SampleModelSerializer(instance) 84 | 85 | expected_data = { 86 | "sample_field": {"stub_str": "abc", "stub_int": 1, "stub_list": ["2022-07-01"]}, 87 | "sample_list": [{"stub_str": "abc", "stub_int": 2, "stub_list": ["2022-07-01"]}] * 2, 88 | "sample_seq": [{"stub_str": "abc", "stub_int": 3, "stub_list": ["2022-07-01"]}] * 3, 89 | } 90 | assert serializer.data == expected_data 91 | 92 | 93 | def test_serializer_field_allow_null_passes(): 94 | class SampleSerializer(serializers.Serializer): 95 | foo = rest_framework.SchemaField(InnerSchema, allow_null=True) 96 | 97 | serializer = SampleSerializer(data={"foo": None}) 98 | try: 99 | serializer.is_valid(raise_exception=True) 100 | except exceptions.ValidationError: 101 | pytest.fail("Null value should be accepted.") 102 | 103 | 104 | def test_serializer_field_disallow_null_fails(): 105 | class SampleSerializer(serializers.Serializer): 106 | foo = rest_framework.SchemaField(InnerSchema, allow_null=False) 107 | 108 | serializer = SampleSerializer(data={"foo": None}) 109 | 110 | with pytest.raises(exceptions.ValidationError, match=".*This field may not be null.*"): 111 | serializer.is_valid(raise_exception=True) 112 | 113 | 114 | @pytest.mark.parametrize( 115 | "export_kwargs", 116 | [ 117 | {"include": {"stub_str", "stub_int"}}, 118 | {"exclude": {"stub_list"}}, 119 | {"exclude_unset": True}, 120 | {"exclude_defaults": True}, 121 | {"exclude_none": True}, 122 | {"by_alias": True}, 123 | ], 124 | ) 125 | def test_field_export_kwargs(export_kwargs): 126 | field = rest_framework.SchemaField(InnerSchema, **export_kwargs) 127 | assert field.to_representation(InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)])) 128 | 129 | 130 | def test_invalid_data_serialization(): 131 | invalid_data = {"field": [{"stub_int": "abc", "stub_list": ["abc"]}]} 132 | serializer = SampleSerializer(data=invalid_data) 133 | 134 | with pytest.raises(exceptions.ValidationError) as e: 135 | serializer.is_valid(raise_exception=True) 136 | 137 | assert e.match(r".*stub_str.*stub_int.*stub_list.*") 138 | -------------------------------------------------------------------------------- /tests/v2/rest_framework/test_openapi.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rest_framework.schemas.openapi import SchemaGenerator 3 | from rest_framework.request import Request 4 | 5 | from .view_fixtures import create_views_urlconf 6 | 7 | openapi = pytest.importorskip("django_pydantic_field.v2.rest_framework.openapi") 8 | 9 | @pytest.mark.parametrize( 10 | "method, path", 11 | [ 12 | ("GET", "/func"), 13 | ("POST", "/func"), 14 | ("GET", "/class"), 15 | ("PUT", "/class"), 16 | ], 17 | ) 18 | def test_openapi_schema_generators(request_factory, method, path, snapshot_json): 19 | urlconf = create_views_urlconf(openapi.AutoSchema) 20 | generator = SchemaGenerator(urlconf=urlconf) 21 | request = Request(request_factory.generic(method, path)) 22 | assert snapshot_json() == generator.get_schema(request) 23 | -------------------------------------------------------------------------------- /tests/v2/rest_framework/test_parsers.py: -------------------------------------------------------------------------------- 1 | import io 2 | from datetime import date 3 | 4 | import pytest 5 | 6 | from tests.conftest import InnerSchema 7 | 8 | rest_framework = pytest.importorskip("django_pydantic_field.v2.rest_framework") 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "schema_type, existing_encoded, expected_decoded", 13 | [ 14 | ( 15 | InnerSchema, 16 | '{"stub_str": "abc", "stub_int": 1, "stub_list": ["2022-07-01"]}', 17 | InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)]), 18 | ) 19 | ], 20 | ) 21 | def test_schema_parser(schema_type, existing_encoded, expected_decoded): 22 | parser = rest_framework.SchemaParser[schema_type]() 23 | assert parser.parse(io.StringIO(existing_encoded)) == expected_decoded 24 | -------------------------------------------------------------------------------- /tests/v2/rest_framework/test_renderers.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | import pytest 4 | 5 | from tests.conftest import InnerSchema 6 | 7 | rest_framework = pytest.importorskip("django_pydantic_field.v2.rest_framework") 8 | 9 | 10 | def test_schema_renderer(): 11 | renderer = rest_framework.SchemaRenderer() 12 | existing_instance = InnerSchema(stub_str="abc", stub_list=[date(2022, 7, 1)]) 13 | expected_encoded = b'{"stub_str":"abc","stub_int":1,"stub_list":["2022-07-01"]}' 14 | 15 | assert renderer.render(existing_instance) == expected_encoded 16 | 17 | 18 | def test_typed_schema_renderer(): 19 | renderer = rest_framework.SchemaRenderer[InnerSchema]() 20 | existing_data = {"stub_str": "abc", "stub_list": [date(2022, 7, 1)]} 21 | expected_encoded = b'{"stub_str":"abc","stub_int":1,"stub_list":["2022-07-01"]}' 22 | 23 | assert renderer.render(existing_data) == expected_encoded 24 | -------------------------------------------------------------------------------- /tests/v2/rest_framework/view_fixtures.py: -------------------------------------------------------------------------------- 1 | import typing as ty 2 | from types import SimpleNamespace 3 | 4 | import pytest 5 | from django.urls import path 6 | from rest_framework import generics, serializers, views 7 | from rest_framework.decorators import api_view, parser_classes, renderer_classes, schema 8 | from rest_framework.response import Response 9 | 10 | from tests.conftest import InnerSchema 11 | from tests.test_app.models import SampleModel 12 | 13 | rest_framework = pytest.importorskip("django_pydantic_field.v2.rest_framework") 14 | coreapi = pytest.importorskip("django_pydantic_field.v2.rest_framework.coreapi") 15 | 16 | 17 | class SampleSerializer(serializers.Serializer): 18 | field = rest_framework.SchemaField(schema=ty.List[InnerSchema]) 19 | 20 | 21 | class SampleModelSerializer(serializers.ModelSerializer): 22 | sample_field = rest_framework.SchemaField(schema=InnerSchema) 23 | sample_list = rest_framework.SchemaField(schema=ty.List[InnerSchema]) 24 | sample_seq = rest_framework.SchemaField(schema=ty.List[InnerSchema], default=list) 25 | 26 | class Meta: 27 | model = SampleModel 28 | fields = "sample_field", "sample_list", "sample_seq" 29 | 30 | 31 | class ClassBasedView(views.APIView): 32 | parser_classes = [rest_framework.SchemaParser[InnerSchema]] 33 | renderer_classes = [rest_framework.SchemaRenderer[ty.List[InnerSchema]]] 34 | 35 | def post(self, request, *args, **kwargs): 36 | assert isinstance(request.data, InnerSchema) 37 | return Response([request.data]) 38 | 39 | 40 | class ClassBasedViewWithSerializer(generics.RetrieveUpdateAPIView): 41 | serializer_class = SampleSerializer 42 | 43 | 44 | class ClassBasedViewWithModel(generics.ListCreateAPIView): 45 | queryset = SampleModel.objects.all() 46 | serializer_class = SampleModelSerializer 47 | 48 | 49 | class ClassBasedViewWithSchemaContext(ClassBasedView): 50 | parser_classes = [rest_framework.SchemaParser] 51 | renderer_classes = [rest_framework.SchemaRenderer] 52 | 53 | def get_renderer_context(self): 54 | ctx = super().get_renderer_context() 55 | return dict(ctx, renderer_schema=ty.List[InnerSchema]) 56 | 57 | def get_parser_context(self, http_request): 58 | ctx = super().get_parser_context(http_request) 59 | return dict(ctx, parser_schema=InnerSchema) 60 | 61 | 62 | @api_view(["GET", "POST"]) 63 | @parser_classes([rest_framework.SchemaParser[InnerSchema]]) 64 | @renderer_classes([rest_framework.SchemaRenderer[ty.List[InnerSchema]]]) 65 | def sample_view(request): 66 | assert isinstance(request.data, InnerSchema) 67 | return Response([request.data]) 68 | 69 | 70 | def create_views_urlconf(schema_view_inspector): 71 | @api_view(["GET", "POST"]) 72 | @schema(schema_view_inspector()) 73 | @parser_classes([rest_framework.SchemaParser[InnerSchema]]) 74 | @renderer_classes([rest_framework.SchemaRenderer[ty.List[InnerSchema]]]) 75 | def sample_view(request): 76 | assert isinstance(request.data, InnerSchema) 77 | return Response([request.data]) 78 | 79 | class ClassBasedViewWithSerializer(generics.RetrieveUpdateAPIView): 80 | serializer_class = SampleSerializer 81 | schema = schema_view_inspector() 82 | 83 | return SimpleNamespace( 84 | urlpatterns=[ 85 | path("/func", sample_view), 86 | path("/class", ClassBasedViewWithSerializer.as_view()), 87 | ], 88 | ) 89 | -------------------------------------------------------------------------------- /tests/v2/test_forms.py: -------------------------------------------------------------------------------- 1 | import typing as ty 2 | from datetime import date 3 | 4 | import django 5 | import pydantic 6 | import pytest 7 | import typing_extensions as te 8 | from django.core.exceptions import ValidationError 9 | from django.forms import Form, modelform_factory 10 | 11 | from tests.conftest import InnerSchema 12 | from tests.test_app.models import SampleForwardRefModel, SampleSchema, ExampleSchema 13 | 14 | fields = pytest.importorskip("django_pydantic_field.v2.fields") 15 | forms = pytest.importorskip("django_pydantic_field.v2.forms") 16 | 17 | 18 | class SampleForm(Form): 19 | field = forms.SchemaField(ty.ForwardRef("SampleSchema")) 20 | 21 | 22 | class NoDefaultForm(Form): 23 | field = forms.SchemaField(schema=ExampleSchema) 24 | 25 | 26 | @pytest.mark.parametrize( 27 | "raw_data, clean_data", 28 | [ 29 | ('{"stub_str": "abc", "stub_list": ["1970-01-01"]}', {"stub_str": "abc", "stub_list": ["1970-01-01"]}), 30 | (b'{"stub_str": "abc", "stub_list": ["1970-01-01"]}', {"stub_str": "abc", "stub_list": ["1970-01-01"]}), 31 | ({"stub_str": "abc", "stub_list": ["1970-01-01"]}, {"stub_str": "abc", "stub_list": ["1970-01-01"]}), 32 | (InnerSchema(stub_str="abc", stub_list=[date(1970, 1, 1)]), {"stub_str": "abc", "stub_list": ["1970-01-01"]}), 33 | ], 34 | ) 35 | def test_form_schema_field(raw_data, clean_data): 36 | field = forms.SchemaField(InnerSchema) 37 | 38 | cleaned_data = field.clean(raw_data) 39 | assert cleaned_data == InnerSchema.model_validate(clean_data) 40 | 41 | 42 | def test_empty_form_values(): 43 | field = forms.SchemaField(InnerSchema, required=False) 44 | assert field.clean("") is None 45 | assert field.clean(None) is None 46 | 47 | 48 | def test_prepare_value(): 49 | field = forms.SchemaField(InnerSchema, required=False) 50 | expected = '{"stub_str":"abc","stub_int":1,"stub_list":["1970-01-01"]}' 51 | assert expected == field.prepare_value({"stub_str": "abc", "stub_list": ["1970-01-01"]}) 52 | 53 | 54 | @pytest.mark.parametrize( 55 | "value, expected", 56 | [ 57 | ([], "[]"), 58 | ([42], "[42]"), 59 | ("[42]", "[42]"), 60 | ], 61 | ) 62 | def test_root_value_passes(value, expected): 63 | RootModel = pydantic.RootModel[ty.List[int]] 64 | field = forms.SchemaField(RootModel) 65 | assert field.prepare_value(value) == expected 66 | 67 | 68 | @pytest.mark.parametrize( 69 | "value, initial, expected", 70 | [ 71 | ("[]", "[]", False), 72 | ([], [], False), 73 | ([], [42], True), 74 | ("[]", [], False), 75 | ("[42]", [], True), 76 | ([42], "[42]", False), 77 | ("[42]", "[42]", False), 78 | ("[42]", "[41]", True), 79 | ], 80 | ) 81 | def test_root_value_has_changed(value, initial, expected): 82 | RootModel = pydantic.RootModel[ty.List[int]] 83 | field = forms.SchemaField(RootModel) 84 | assert field.has_changed(initial, value) is expected 85 | 86 | 87 | def test_empty_required_raises(): 88 | field = forms.SchemaField(InnerSchema) 89 | with pytest.raises(ValidationError) as e: 90 | field.clean("") 91 | 92 | assert e.match("This field is required") 93 | 94 | 95 | def test_invalid_schema_raises(): 96 | field = forms.SchemaField(InnerSchema) 97 | with pytest.raises(ValidationError) as e: 98 | field.clean('{"stub_list": "abc"}') 99 | 100 | assert e.match("Schema didn't match for") 101 | assert "stub_list" in e.value.params["detail"] # type: ignore 102 | assert "stub_str" in e.value.params["detail"] # type: ignore 103 | 104 | 105 | def test_invalid_json_raises(): 106 | field = forms.SchemaField(InnerSchema) 107 | with pytest.raises(ValidationError) as e: 108 | field.clean('{"stub_list": "abc}') 109 | 110 | assert e.match("Schema didn't match for") 111 | assert '"type":"json_invalid"' in e.value.params["detail"] # type: ignore 112 | 113 | 114 | @pytest.mark.xfail( 115 | django.VERSION[:2] < (4, 0), 116 | reason="Django < 4 has it's own feeling on bound fields resolution", 117 | ) 118 | def test_forwardref_field(): 119 | form = SampleForm(data={"field": '{"field": "2"}'}) 120 | assert form.is_valid() 121 | 122 | 123 | def test_model_formfield(): 124 | field = fields.PydanticSchemaField(schema=InnerSchema) 125 | assert isinstance(field.formfield(), forms.SchemaField) 126 | 127 | 128 | def test_forwardref_model_formfield(): 129 | form_cls = modelform_factory(SampleForwardRefModel, exclude=("field",)) 130 | form = form_cls(data={"annotated_field": '{"field": "2"}'}) 131 | 132 | assert form.is_valid(), form.errors 133 | cleaned_data = form.cleaned_data 134 | 135 | assert cleaned_data is not None 136 | assert cleaned_data["annotated_field"] == SampleSchema(field=2) 137 | 138 | 139 | @pytest.mark.parametrize( 140 | "export_kwargs", 141 | [ 142 | {"include": {"stub_str", "stub_int"}}, 143 | {"exclude": {"stub_list"}}, 144 | {"exclude_unset": True}, 145 | {"exclude_defaults": True}, 146 | {"exclude_none": True}, 147 | {"by_alias": True}, 148 | ], 149 | ) 150 | def test_form_field_export_kwargs(export_kwargs): 151 | field = forms.SchemaField(InnerSchema, required=False, **export_kwargs) 152 | value = InnerSchema.model_validate({"stub_str": "abc", "stub_list": ["1970-01-01"]}) 153 | assert field.prepare_value(value) 154 | 155 | 156 | def test_annotated_acceptance(): 157 | field = forms.SchemaField(te.Annotated[InnerSchema, pydantic.Field(title="Inner Schema")]) 158 | value = InnerSchema.model_validate({"stub_str": "abc", "stub_list": ["1970-01-01"]}) 159 | assert field.prepare_value(value) 160 | 161 | 162 | def test_form_render_without_default(): 163 | form = NoDefaultForm() 164 | form.as_p() 165 | -------------------------------------------------------------------------------- /tests/v2/test_types.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import pydantic 3 | import pytest 4 | import typing as ty 5 | 6 | from ..conftest import InnerSchema, SampleDataclass 7 | 8 | types = pytest.importorskip("django_pydantic_field.v2.types") 9 | skip_unsupported_builtin_subscription = pytest.mark.skipif( 10 | sys.version_info < (3, 9), 11 | reason="Built-in type subscription supports only in 3.9+", 12 | ) 13 | 14 | 15 | # fmt: off 16 | @pytest.mark.parametrize( 17 | "ctor, args, kwargs", 18 | [ 19 | pytest.param(types.SchemaAdapter, ["list[int]", None, None, None], {}, marks=skip_unsupported_builtin_subscription), 20 | pytest.param(types.SchemaAdapter, ["list[int]", {"strict": True}, None, None], {}, marks=skip_unsupported_builtin_subscription), 21 | (types.SchemaAdapter, [ty.List[int], None, None, None], {}), 22 | (types.SchemaAdapter, [ty.List[int], {"strict": True}, None, None], {}), 23 | (types.SchemaAdapter, [None, None, InnerSchema, "stub_int"], {}), 24 | (types.SchemaAdapter, [None, None, SampleDataclass, "stub_int"], {}), 25 | pytest.param(types.SchemaAdapter.from_type, ["list[int]"], {}, marks=skip_unsupported_builtin_subscription), 26 | pytest.param(types.SchemaAdapter.from_type, ["list[int]", {"strict": True}], {}, marks=skip_unsupported_builtin_subscription), 27 | (types.SchemaAdapter.from_type, [ty.List[int]], {}), 28 | (types.SchemaAdapter.from_type, [ty.List[int], {"strict": True}], {}), 29 | (types.SchemaAdapter.from_annotation, [InnerSchema, "stub_int"], {}), 30 | (types.SchemaAdapter.from_annotation, [InnerSchema, "stub_int", {"strict": True}], {}), 31 | (types.SchemaAdapter.from_annotation, [SampleDataclass, "stub_int"], {}), 32 | (types.SchemaAdapter.from_annotation, [SampleDataclass, "stub_int", {"strict": True}], {}), 33 | ], 34 | ) 35 | # fmt: on 36 | def test_schema_adapter_constructors(ctor, args, kwargs): 37 | adapter = ctor(*args, **kwargs) 38 | adapter.validate_schema() 39 | assert isinstance(adapter.type_adapter, pydantic.TypeAdapter) 40 | 41 | 42 | def test_schema_adapter_is_bound(): 43 | adapter = types.SchemaAdapter(None, None, None, None) 44 | with pytest.raises(types.ImproperlyConfiguredSchema): 45 | adapter.validate_schema() # Schema cannot be resolved for fully unbound adapter 46 | 47 | adapter = types.SchemaAdapter(ty.List[int], None, None, None) 48 | assert not adapter.is_bound, "SchemaAdapter should not be bound" 49 | adapter.validate_schema() # Schema should be resolved from direct argument 50 | 51 | adapter.bind(InnerSchema, "stub_int") 52 | assert adapter.is_bound, "SchemaAdapter should be bound" 53 | adapter.validate_schema() # Schema should be resolved from direct argument 54 | 55 | adapter = types.SchemaAdapter(None, None, InnerSchema, "stub_int") 56 | assert adapter.is_bound, "SchemaAdapter should be bound" 57 | adapter.validate_schema() # Schema should be resolved from bound attribute 58 | 59 | 60 | # fmt: off 61 | @pytest.mark.parametrize( 62 | "kwargs, expected_export_kwargs", 63 | [ 64 | ({}, {}), 65 | ({"strict": True}, {"strict": True}), 66 | ({"strict": True, "by_alias": False}, {"strict": True, "by_alias": False}), 67 | ({"strict": True, "from_attributes": False, "on_delete": "CASCADE"}, {"strict": True, "from_attributes": False}), 68 | ], 69 | ) 70 | # fmt: on 71 | def test_schema_adapter_extract_export_kwargs(kwargs, expected_export_kwargs): 72 | orig_kwargs = dict(kwargs) 73 | assert types.SchemaAdapter.extract_export_kwargs(kwargs) == expected_export_kwargs 74 | assert kwargs == {key: orig_kwargs[key] for key in orig_kwargs.keys() - expected_export_kwargs.keys()} 75 | 76 | 77 | def test_schema_adapter_validate_python(): 78 | adapter = types.SchemaAdapter.from_type(ty.List[int]) 79 | assert adapter.validate_python([1, 2, 3]) == [1, 2, 3] 80 | assert adapter.validate_python([1, 2, 3], strict=True) == [1, 2, 3] 81 | assert adapter.validate_python([1, 2, 3], strict=False) == [1, 2, 3] 82 | 83 | adapter = types.SchemaAdapter.from_type(ty.List[int], {"strict": True}) 84 | assert adapter.validate_python([1, 2, 3]) == [1, 2, 3] 85 | assert adapter.validate_python(["1", "2", "3"], strict=False) == [1, 2, 3] 86 | assert sorted(adapter.validate_python({1, 2, 3}, strict=False)) == [1, 2, 3] 87 | with pytest.raises(pydantic.ValidationError): 88 | assert adapter.validate_python(["1", "2", "3"]) == [1, 2, 3] 89 | 90 | adapter = types.SchemaAdapter.from_type(ty.List[int], {"strict": False}) 91 | assert adapter.validate_python([1, 2, 3]) == [1, 2, 3] 92 | assert adapter.validate_python([1, 2, 3], strict=False) == [1, 2, 3] 93 | assert sorted(adapter.validate_python({1, 2, 3})) == [1, 2, 3] 94 | with pytest.raises(pydantic.ValidationError): 95 | assert adapter.validate_python({1, 2, 3}, strict=True) == [1, 2, 3] 96 | 97 | 98 | def test_schema_adapter_validate_json(): 99 | adapter = types.SchemaAdapter.from_type(ty.List[int]) 100 | assert adapter.validate_json("[1, 2, 3]") == [1, 2, 3] 101 | assert adapter.validate_json("[1, 2, 3]", strict=True) == [1, 2, 3] 102 | assert adapter.validate_json("[1, 2, 3]", strict=False) == [1, 2, 3] 103 | 104 | adapter = types.SchemaAdapter.from_type(ty.List[int], {"strict": True}) 105 | assert adapter.validate_json("[1, 2, 3]") == [1, 2, 3] 106 | assert adapter.validate_json('["1", "2", "3"]', strict=False) == [1, 2, 3] 107 | with pytest.raises(pydantic.ValidationError): 108 | assert adapter.validate_json('["1", "2", "3"]') == [1, 2, 3] 109 | 110 | adapter = types.SchemaAdapter.from_type(ty.List[int], {"strict": False}) 111 | assert adapter.validate_json("[1, 2, 3]") == [1, 2, 3] 112 | assert adapter.validate_json("[1, 2, 3]", strict=False) == [1, 2, 3] 113 | with pytest.raises(pydantic.ValidationError): 114 | assert adapter.validate_json('["1", "2", "3"]', strict=True) == [1, 2, 3] 115 | 116 | 117 | def test_schema_adapter_dump_python(): 118 | adapter = types.SchemaAdapter.from_type(ty.List[int]) 119 | assert adapter.dump_python([1, 2, 3]) == [1, 2, 3] 120 | 121 | adapter = types.SchemaAdapter.from_type(ty.List[int], {}) 122 | assert adapter.dump_python([1, 2, 3]) == [1, 2, 3] 123 | assert sorted(adapter.dump_python({1, 2, 3})) == [1, 2, 3] 124 | with pytest.warns(UserWarning): 125 | assert adapter.dump_python(["1", "2", "3"]) == ["1", "2", "3"] 126 | 127 | adapter = types.SchemaAdapter.from_type(ty.List[int], {}) 128 | assert adapter.dump_python([1, 2, 3]) == [1, 2, 3] 129 | assert sorted(adapter.dump_python({1, 2, 3})) == [1, 2, 3] 130 | with pytest.warns(UserWarning): 131 | assert adapter.dump_python(["1", "2", "3"]) == ["1", "2", "3"] 132 | 133 | 134 | def test_schema_adapter_dump_json(): 135 | adapter = types.SchemaAdapter.from_type(ty.List[int]) 136 | assert adapter.dump_json([1, 2, 3]) == b"[1,2,3]" 137 | 138 | adapter = types.SchemaAdapter.from_type(ty.List[int], {}) 139 | assert adapter.dump_json([1, 2, 3]) == b"[1,2,3]" 140 | assert adapter.dump_json({1, 2, 3}) == b"[1,2,3]" 141 | with pytest.warns(UserWarning): 142 | assert adapter.dump_json(["1", "2", "3"]) == b'["1","2","3"]' 143 | 144 | adapter = types.SchemaAdapter.from_type(ty.List[int], {}) 145 | assert adapter.dump_json([1, 2, 3]) == b"[1,2,3]" 146 | assert adapter.dump_json({1, 2, 3}) == b"[1,2,3]" 147 | with pytest.warns(UserWarning): 148 | assert adapter.dump_json(["1", "2", "3"]) == b'["1","2","3"]' 149 | --------------------------------------------------------------------------------