├── .coveragerc ├── .dockerignore ├── .flake8 ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ ├── test.yml │ └── test_full.yml ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── ninja_schema ├── __init__.py ├── compat.py ├── errors.py ├── orm │ ├── __init__.py │ ├── factory.py │ ├── getters.py │ ├── mixins.py │ ├── model_schema.py │ ├── model_validators.py │ ├── schema.py │ ├── schema_registry.py │ └── utils │ │ ├── __init__.py │ │ ├── converter.py │ │ └── utils.py ├── pydanticutils │ └── __init__.py └── types.py ├── pyproject.toml ├── pytest.ini ├── requirements-tests.txt ├── requirements.txt └── tests ├── __init__.py ├── conftest.py ├── models.py ├── test_v1_pydantic ├── __init__.py ├── test_converters.py ├── test_custom_fields.py ├── test_model_schema.py └── test_schema.py ├── test_v2_pydantic ├── __init__.py ├── test_converters.py ├── test_custom_fields.py ├── test_factory.py ├── test_model_schema.py └── test_schema.py └── urls.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | def __repr__ 5 | def __str__ 6 | if self.debug: 7 | if IS_PYDANTIC_V1 8 | if TYPE_CHECKING: 9 | if t.TYPE_CHECKING: 10 | if settings.DEBUG 11 | raise AssertionError 12 | raise NotImplementedError 13 | if 0: 14 | if __name__ == .__main__.: 15 | class .*\bProtocol\): 16 | @(abc\.)?abstractmethod -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | *.pyc 3 | .venv* 4 | .vscode 5 | .mypy_cache 6 | .coverage 7 | htmlcov 8 | 9 | dist 10 | test.py -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | ignore = E501, W503, W391 4 | exclude = 5 | .git, 6 | __pycache__ 7 | .history -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: monthly 16 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: 3.8 17 | - name: Install Flit 18 | run: pip install flit 19 | - name: Install Dependencies 20 | run: make install 21 | - name: Install build dependencies 22 | run: pip install build 23 | - name: Build distribution 24 | run: python -m build 25 | - name: Publish 26 | uses: pypa/gh-action-pypi-publish@v1.12.4 27 | with: 28 | password: ${{ secrets.PYPI_API_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | test_coverage: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: 3.8 18 | - name: Install Flit 19 | run: pip install flit 20 | - name: Install Dependencies 21 | run: make install 22 | - name: Test 23 | run: pytest --cov=ninja_schema --cov-report=xml tests 24 | - name: Coverage 25 | uses: codecov/codecov-action@v5 26 | -------------------------------------------------------------------------------- /.github/workflows/test_full.yml: -------------------------------------------------------------------------------- 1 | name: Full Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | types: [assigned, opened, synchronize, reopened] 7 | 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ['3.8', '3.9', '3.10', '3.11'] 15 | django-version: ['<3.0', '<3.1', '<3.2', '<3.3', '<4.1', '<4.2'] 16 | pydantic-version: [ "pydantic-v1", "pydantic-v2" ] 17 | fail-fast: false 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install core 26 | run: pip install "Django${{ matrix.django-version }}" pytest pytest-django 27 | - name: Install Pydantic v1 28 | if: matrix.pydantic-version == 'pydantic-v1' 29 | run: pip install "pydantic>=1.10.0,<2.0.0" pydantic[email] 30 | - name: Install Pydantic v2 31 | if: matrix.pydantic-version == 'pydantic-v2' 32 | run: pip install "pydantic>=2.0.2,<3.0.0" pydantic[email] 33 | - name: Test 34 | run: pytest 35 | codestyle: 36 | runs-on: ubuntu-latest 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | - name: Set up Python 41 | uses: actions/setup-python@v5 42 | with: 43 | python-version: 3.8 44 | - name: Install Flit 45 | run: pip install flit 46 | - name: Install Dependencies 47 | run: make install 48 | - name: Ruff Linting Check 49 | run: ruff check ninja_schema tests 50 | - name: mypy 51 | run: mypy ninja_schema 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .venv 3 | .vscode 4 | .mypy_cache 5 | .coverage 6 | htmlcov 7 | 8 | dist 9 | test.py 10 | 11 | docs/site 12 | 13 | .DS_Store 14 | .idea 15 | local_install.sh -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile = black 3 | combine_as_imports = true -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: check-merge-conflict 6 | - repo: https://github.com/asottile/yesqa 7 | rev: v1.3.0 8 | hooks: 9 | - id: yesqa 10 | - repo: local 11 | hooks: 12 | - id: code_formatting 13 | args: [] 14 | name: Code Formatting 15 | entry: "make fmt" 16 | types: [python] 17 | language_version: python3 18 | language: python 19 | - id: code_linting 20 | args: [ ] 21 | name: Code Linting 22 | entry: "make lint" 23 | types: [ python ] 24 | language_version: python3 25 | language: python 26 | - repo: https://github.com/pre-commit/pre-commit-hooks 27 | rev: v2.3.0 28 | hooks: 29 | - id: end-of-file-fixer 30 | exclude: >- 31 | ^examples/[^/]*\.svg$ 32 | - id: requirements-txt-fixer 33 | - id: trailing-whitespace 34 | types: [python] 35 | - id: check-case-conflict 36 | - id: check-json 37 | - id: check-xml 38 | - id: check-executables-have-shebangs 39 | - id: check-toml 40 | - id: check-xml 41 | - id: check-yaml 42 | - id: debug-statements 43 | - id: check-added-large-files 44 | - id: check-symlinks 45 | - id: debug-statements 46 | exclude: ^tests/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Ezeudoh Tochukwu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help docs 2 | .DEFAULT_GOAL := help 3 | 4 | help: 5 | @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 6 | 7 | clean: ## Removing cached python compiled files 8 | find . -name \*pyc | xargs rm -fv 9 | find . -name \*pyo | xargs rm -fv 10 | find . -name \*~ | xargs rm -fv 11 | find . -name __pycache__ | xargs rm -rfv 12 | find . -name .ruff_cache | xargs rm -rfv 13 | 14 | install:clean ## Install dependencies 15 | pip install -r requirements.txt 16 | flit install --deps develop --symlink 17 | 18 | install-full:install ## Install dependencies with pre-commit 19 | pre-commit install -f 20 | 21 | lint:fmt ## Run code linters 22 | ruff check ninja_schema tests 23 | mypy ninja_schema 24 | 25 | fmt format:clean ## Run code formatters 26 | ruff format ninja_schema tests 27 | ruff check --fix ninja_schema tests 28 | 29 | test:clean ## Run tests 30 | pytest . 31 | 32 | test-cov:clean ## Run tests with coverage 33 | pytest --cov=ninja_schema --cov-report term-missing tests 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Test](https://github.com/eadwinCode/ninja-schema/workflows/Test/badge.svg) 2 | [![PyPI version](https://badge.fury.io/py/ninja-schema.svg)](https://badge.fury.io/py/ninja-schema) 3 | [![PyPI version](https://img.shields.io/pypi/pyversions/ninja-schema.svg)](https://pypi.python.org/pypi/ninja-schema) 4 | [![PyPI version](https://img.shields.io/pypi/djversions/ninja-schema.svg)](https://pypi.python.org/pypi/ninja-schema) 5 | [![Codecov](https://img.shields.io/codecov/c/gh/eadwinCode/ninja-schema)](https://codecov.io/gh/eadwinCode/ninja-schema) 6 | [![Downloads](https://static.pepy.tech/badge/ninja-schema)](https://pepy.tech/project/ninja-schema) 7 | 8 | # Ninja Schema 9 | Ninja Schema converts your Django ORM models to Pydantic schemas with more Pydantic features supported. 10 | 11 | **Inspired by**: [django-ninja](https://django-ninja.rest-framework.com/) and [djantic](https://jordaneremieff.github.io/djantic/) 12 | ### Notice 13 | Starting version `0.13.4`, Ninja schema will support both v1 and v2 of pydantic library and will closely monitor V1 support on pydantic package. 14 | 15 | ### Requirements 16 | Python >= 3.8 17 | django >= 3 18 | pydantic >= 1.6 19 | 20 | **Key features:** 21 | - **Custom Field Support**: Ninja Schema converts django model to native pydantic types which gives you quick field validation out of the box. eg Enums, email, IPAddress, URLs, JSON, etc 22 | - **Field Validator**: Fields can be validated with **model_validator** just like pydantic **[validator](https://pydantic-docs.helpmanual.io/usage/validators/)** or **[root_validator](https://pydantic-docs.helpmanual.io/usage/validators/)**. 23 | 24 | ## Installation 25 | 26 | ``` 27 | pip install ninja-schema 28 | ``` 29 | 30 | ## Example 31 | Checkout this sample project: https://github.com/eadwinCode/bookstoreapi 32 | 33 | 34 | ## Configuration Properties 35 | - **model**: Django Model 36 | - **include**: Fields to include, `default: '__all__'`. Please note that when include = `__all__`, model's **PK** becomes optional 37 | - **exclude**: Fields to exclude, `default: set()` 38 | - **optional**: Fields to mark optional,` default: set()` 39 | `optional = '__all__'` will make all schema fields optional 40 | - **depth**: defines depth to nested generated schema, `default: 0` 41 | 42 | ## `model_validator(*args, **kwargs)` 43 | **model_validator** is a substitute for **pydantic [validator](https://pydantic-docs.helpmanual.io/usage/validators/)** used for pre and post fields validation. 44 | There functionalities are the same. More info [pydantic validators](https://pydantic-docs.helpmanual.io/usage/validators/) 45 | ```Python 46 | from django.contrib.auth import get_user_model 47 | from ninja_schema import ModelSchema, model_validator 48 | 49 | UserModel = get_user_model() 50 | 51 | 52 | class CreateUserSchema(ModelSchema): 53 | class Config: 54 | model = UserModel 55 | include = ['username', 'email', 'password'] 56 | 57 | @model_validator('username') 58 | def validate_unique_username(cls, value_data: str) -> str: 59 | if UserModel.objects.filter(username__icontains=value_data).exists(): 60 | raise ValueError('Username exists') 61 | return value_data 62 | ``` 63 | ## `from_orm(cls, obj: Any)` 64 | You can generate a schema instance from your django model instance 65 | ```Python 66 | from typings import Optional 67 | from django.contrib.auth import get_user_model 68 | from ninja_schema import ModelSchema, model_validator 69 | 70 | UserModel = get_user_model() 71 | new_user = UserModel.objects.create_user( 72 | username='eadwin', email='eadwin@example.com', 73 | password='password', first_name='Emeka', last_name='Okoro' 74 | ) 75 | 76 | 77 | class UserSchema(ModelSchema): 78 | class Config: 79 | model = UserModel 80 | include = ['id','first_name', 'last_name', 'username', 'email'] 81 | 82 | schema = UserSchema.from_orm(new_user) 83 | print(schema.json(indent=2) 84 | { 85 | "id": 1, 86 | "first_name": "Emeka", 87 | "last_name": "Okoro", 88 | "email": "eadwin@example.com", 89 | "username": "eadwin", 90 | } 91 | ``` 92 | 93 | ## `apply(self, model_instance, **kwargs)` 94 | You can transfer data from your ModelSchema to Django Model instance using the `apply` function. 95 | The `apply` function uses Pydantic model `.dict` function, `dict` function filtering that can be passed as `kwargs` to the `.apply` function. 96 | 97 | For more info, visit [Pydantic model export](https://pydantic-docs.helpmanual.io/usage/exporting_models/) 98 | ```Python 99 | from typings import Optional 100 | from django.contrib.auth import get_user_model 101 | from ninja_schema import ModelSchema, model_validator 102 | 103 | UserModel = get_user_model() 104 | new_user = UserModel.objects.create_user(username='eadwin', email='eadwin@example.com', password='password') 105 | 106 | 107 | class UpdateUserSchema(ModelSchema): 108 | class Config: 109 | model = UserModel 110 | include = ['first_name', 'last_name', 'username'] 111 | optional = ['username'] # `username` is now optional 112 | 113 | schema = UpdateUserSchema(first_name='Emeka', last_name='Okoro') 114 | schema.apply(new_user, exclude_none=True) 115 | 116 | assert new_user.first_name == 'Emeka' # True 117 | assert new_user.username == 'eadwin' # True 118 | ``` 119 | 120 | ## Generated Schema Sample 121 | 122 | ```Python 123 | from django.contrib.auth import get_user_model 124 | from ninja_schema import ModelSchema, model_validator 125 | 126 | UserModel = get_user_model() 127 | 128 | 129 | class UserSchema(ModelSchema): 130 | class Config: 131 | model = UserModel 132 | include = '__all__' 133 | depth = 2 134 | 135 | 136 | print(UserSchema.schema()) 137 | 138 | { 139 | "title": "UserSchema", 140 | "type": "object", 141 | "properties": { 142 | "id": {"title": "Id", "extra": {}, "type": "integer"}, 143 | "password": {"title": "Password", "maxLength": 128, "type": "string"}, 144 | "last_login": {"title": "Last Login","type": "string", "format": "date-time"}, 145 | "is_superuser": {"title": "Superuser Status", 146 | "description": "Designates that this user has all permissions without explicitly assigning them.", 147 | "default": false, 148 | "type": "boolean" 149 | }, 150 | "username": { 151 | "title": "Username", 152 | "description": "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", 153 | "maxLength": 150, 154 | "type": "string" 155 | }, 156 | "first_name": { 157 | "title": "First Name", 158 | "maxLength": 150, 159 | "type": "string" 160 | }, 161 | "last_name": { 162 | "title": "Last Name", 163 | "maxLength": 150, 164 | "type": "string" 165 | }, 166 | "email": { 167 | "title": "Email Address", 168 | "type": "string", 169 | "format": "email" 170 | }, 171 | "is_staff": { 172 | "title": "Staff Status", 173 | "description": "Designates whether the user can log into this admin site.", 174 | "default": false, 175 | "type": "boolean" 176 | }, 177 | "is_active": { 178 | "title": "Active", 179 | "description": "Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", 180 | "default": true, 181 | "type": "boolean" 182 | }, 183 | "date_joined": { 184 | "title": "Date Joined", 185 | "type": "string", 186 | "format": "date-time" 187 | }, 188 | "groups": { 189 | "title": "Groups", 190 | "description": "The groups this user belongs to. A user will get all permissions granted to each of their groups.", 191 | "type": "array", 192 | "items": { 193 | "$ref": "#/definitions/Group" 194 | } 195 | }, 196 | "user_permissions": { 197 | "title": "User Permissions", 198 | "description": "Specific permissions for this user.", 199 | "type": "array", 200 | "items": { 201 | "$ref": "#/definitions/Permission" 202 | } 203 | } 204 | }, 205 | "required": [ 206 | "password", 207 | "username", 208 | "groups", 209 | "user_permissions" 210 | ], 211 | "definitions": { 212 | "Permission": { 213 | "title": "Permission", 214 | "type": "object", 215 | "properties": { 216 | "id": { 217 | "title": "Id", 218 | "extra": {}, 219 | "type": "integer" 220 | }, 221 | "name": { 222 | "title": "Name", 223 | "maxLength": 255, 224 | "type": "string" 225 | }, 226 | "content_type_id": { 227 | "title": "Content Type", 228 | "type": "integer" 229 | }, 230 | "codename": { 231 | "title": "Codename", 232 | "maxLength": 100, 233 | "type": "string" 234 | } 235 | }, 236 | "required": [ 237 | "name", 238 | "content_type_id", 239 | "codename" 240 | ] 241 | }, 242 | "Group": { 243 | "title": "Group", 244 | "type": "object", 245 | "properties": { 246 | "id": { 247 | "title": "Id", 248 | "extra": {}, 249 | "type": "integer" 250 | }, 251 | "name": { 252 | "title": "Name", 253 | "maxLength": 150, 254 | "type": "string" 255 | }, 256 | "permissions": { 257 | "title": "Permissions", 258 | "type": "array", 259 | "items": { 260 | "$ref": "#/definitions/Permission" 261 | } 262 | } 263 | }, 264 | "required": [ 265 | "name", 266 | "permissions" 267 | ] 268 | } 269 | } 270 | } 271 | ``` 272 | 273 | -------------------------------------------------------------------------------- /ninja_schema/__init__.py: -------------------------------------------------------------------------------- 1 | """Django Schema - Builds Pydantic Schemas from Django Models with default field type validations""" 2 | 3 | __version__ = "0.14.2" 4 | 5 | from .orm.factory import SchemaFactory 6 | from .orm.model_schema import ModelSchema 7 | from .orm.model_validators import model_validator 8 | from .orm.schema import Schema 9 | 10 | __all__ = ["SchemaFactory", "Schema", "ModelSchema", "model_validator"] 11 | -------------------------------------------------------------------------------- /ninja_schema/compat.py: -------------------------------------------------------------------------------- 1 | class MissingType(object): 2 | pass 3 | 4 | 5 | try: 6 | # Postgres fields are only available in Django with psycopg2 installed 7 | # and we cannot have psycopg2 on PyPy 8 | from django.contrib.postgres.fields import ( 9 | ArrayField, 10 | HStoreField, 11 | JSONField, 12 | RangeField, 13 | ) 14 | except ImportError: 15 | ArrayField, HStoreField, JSONField, RangeField = (MissingType,) * 4 # type: ignore 16 | -------------------------------------------------------------------------------- /ninja_schema/errors.py: -------------------------------------------------------------------------------- 1 | class ConfigError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /ninja_schema/orm/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ninja_schema/orm/factory.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from typing import TYPE_CHECKING, List, Optional, Type, Union, cast 3 | 4 | from django.db.models import Model 5 | 6 | from ninja_schema.errors import ConfigError 7 | from ninja_schema.pydanticutils import IS_PYDANTIC_V1 8 | from ninja_schema.types import DictStrAny 9 | 10 | from .schema_registry import SchemaRegister 11 | from .schema_registry import registry as schema_registry 12 | 13 | if TYPE_CHECKING: 14 | from .model_schema import ModelSchema 15 | from .schema import Schema 16 | 17 | __all__ = ["SchemaFactory"] 18 | 19 | 20 | class SchemaFactory: 21 | @classmethod 22 | def get_model_config(cls, **kwargs: DictStrAny) -> Type: 23 | class Config: 24 | pass 25 | 26 | for key, value in kwargs.items(): 27 | setattr(Config, key, value) 28 | return Config 29 | 30 | @classmethod 31 | def create_schema( 32 | cls, 33 | model: Type[Model], 34 | *, 35 | registry: SchemaRegister = schema_registry, 36 | name: str = "", 37 | depth: int = 0, 38 | fields: Optional[List[str]] = None, 39 | exclude: Optional[List[str]] = None, 40 | skip_registry: bool = False, 41 | optional_fields: Optional[Union[str, List[str]]] = None, 42 | **model_config_options: DictStrAny, 43 | ) -> Union[Type["ModelSchema"], Type["Schema"], None]: 44 | from .model_schema import ModelSchema 45 | 46 | name = name or model.__name__ 47 | 48 | if fields and exclude: 49 | raise ConfigError("Only one of 'include' or 'exclude' should be set.") 50 | 51 | schema = registry.get_model_schema(model) 52 | if schema and not skip_registry: 53 | return schema 54 | 55 | model_config_kwargs = { 56 | "model": model, 57 | "include": fields, 58 | "exclude": exclude, 59 | "skip_registry": skip_registry, 60 | "depth": depth, 61 | "registry": registry, 62 | "optional": optional_fields, 63 | **model_config_options, 64 | } 65 | cls.get_model_config(**model_config_kwargs) # type: ignore 66 | new_schema = ( 67 | cls._get_schema_v1(name, model_config_kwargs, ModelSchema) 68 | if IS_PYDANTIC_V1 69 | else cls._get_schema_v2(name, model_config_kwargs, ModelSchema) 70 | ) 71 | 72 | new_schema = cast(Type[ModelSchema], new_schema) 73 | if not skip_registry: 74 | registry.register_model(model, new_schema) 75 | return new_schema 76 | 77 | @classmethod 78 | def _get_schema_v1( 79 | cls, name: str, model_config_kwargs: typing.Dict, model_type: typing.Type 80 | ) -> Union[Type["ModelSchema"], Type["Schema"], None]: 81 | model_config = cls.get_model_config(**model_config_kwargs) 82 | 83 | attrs = {"Config": model_config} 84 | 85 | new_schema = type(name, (model_type,), attrs) 86 | new_schema = cast(Type["ModelSchema"], new_schema) 87 | return new_schema 88 | 89 | @classmethod 90 | def _get_schema_v2( 91 | cls, name: str, model_config_kwargs: typing.Dict, model_type: typing.Type 92 | ) -> Union[Type["ModelSchema"], Type["Schema"]]: 93 | model_config = cls.get_model_config(**model_config_kwargs) 94 | new_schema_result = {} # type:ignore[var-annotated] 95 | new_schema_string = f"""class {name}(model_type): 96 | class Config(model_config): 97 | pass """ 98 | 99 | exec(new_schema_string, locals(), new_schema_result) 100 | return new_schema_result.get(name) # type:ignore[return-value] 101 | -------------------------------------------------------------------------------- /ninja_schema/orm/getters.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import pydantic 4 | from django.db.models import Manager, QuerySet 5 | from django.db.models.fields.files import FieldFile 6 | 7 | from ninja_schema.pydanticutils import IS_PYDANTIC_V1 8 | 9 | __all__ = [ 10 | "DjangoGetter", 11 | ] 12 | 13 | 14 | class DjangoGetterMixin: 15 | def _convert_result(self, result: t.Any) -> t.Any: 16 | if isinstance(result, Manager): 17 | return list(result.all()) 18 | 19 | elif isinstance(result, getattr(QuerySet, "__origin__", QuerySet)): 20 | return list(result) 21 | 22 | elif isinstance(result, FieldFile): 23 | if not result: 24 | return None 25 | return result.url 26 | 27 | return result 28 | 29 | 30 | if IS_PYDANTIC_V1: 31 | from pydantic.utils import GetterDict 32 | 33 | pydantic_version = list(map(int, pydantic.VERSION.split(".")))[:2] 34 | assert pydantic_version >= [1, 6], "Pydantic 1.6+ required" 35 | 36 | class DjangoGetter(GetterDict, DjangoGetterMixin): 37 | def get(self, key: t.Any, default: t.Any = None) -> t.Any: 38 | result = super().get(key, default) 39 | return self._convert_result(result) 40 | 41 | else: 42 | 43 | class DjangoGetter(DjangoGetterMixin): # type:ignore[no-redef] 44 | __slots__ = ("_obj", "_schema_cls", "_context") 45 | 46 | def __init__(self, obj: t.Any, schema_cls: t.Any, context: t.Any = None): 47 | self._obj = obj 48 | self._schema_cls = schema_cls 49 | self._context = context 50 | 51 | def __getattr__(self, key: str) -> t.Any: 52 | # if key.startswith("__pydantic"): 53 | # return getattr(self._obj, key) 54 | if isinstance(self._obj, dict): 55 | if key not in self._obj: 56 | raise AttributeError(key) 57 | value = self._obj[key] 58 | else: 59 | try: 60 | value = getattr(self._obj, key) 61 | except AttributeError as e: 62 | raise AttributeError(key) from e 63 | 64 | return self._convert_result(value) 65 | -------------------------------------------------------------------------------- /ninja_schema/orm/mixins.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from django.db.models import Model as DjangoModel 4 | 5 | from ninja_schema.orm.getters import DjangoGetter 6 | from ninja_schema.pydanticutils import IS_PYDANTIC_V1 7 | from ninja_schema.types import DictStrAny 8 | 9 | if t.TYPE_CHECKING: 10 | from pydantic.functional_validators import ModelWrapValidatorHandler 11 | 12 | ModelWrapValidatorHandlerAny = t.TypeVar( 13 | "ModelWrapValidatorHandlerAny", bound=ModelWrapValidatorHandler[t.Any] 14 | ) 15 | 16 | 17 | class BaseMixins: 18 | def apply_to_model( 19 | self, model_instance: t.Type[DjangoModel], **kwargs: DictStrAny 20 | ) -> t.Type[DjangoModel]: 21 | for attr, value in self.dict(**kwargs).items(): # type:ignore[attr-defined] 22 | setattr(model_instance, attr, value) 23 | return model_instance 24 | 25 | 26 | if not IS_PYDANTIC_V1: 27 | from pydantic import BaseModel, model_validator 28 | from pydantic.json_schema import GenerateJsonSchema 29 | from pydantic_core.core_schema import ValidationInfo 30 | 31 | class BaseMixinsV2(BaseMixins): 32 | model_config: t.Dict[str, t.Any] 33 | 34 | @model_validator(mode="wrap") 35 | @classmethod 36 | def _run_root_validator( 37 | cls, 38 | values: t.Any, 39 | handler: "ModelWrapValidatorHandlerAny", 40 | info: ValidationInfo, 41 | ) -> t.Any: 42 | """ 43 | If Pydantic intends to validate against the __dict__ of the immediate Schema 44 | object, then we need to call `handler` directly on `values` before the conversion 45 | to DjangoGetter, since any checks or modifications on DjangoGetter's __dict__ 46 | will not persist to the original object. 47 | """ 48 | forbids_extra = cls.model_config.get("extra") == "forbid" 49 | should_validate_assignment = cls.model_config.get( 50 | "validate_assignment", False 51 | ) 52 | if forbids_extra or should_validate_assignment: 53 | handler(values) 54 | 55 | values = DjangoGetter(values, cls, info.context) 56 | return handler(values) 57 | 58 | # @model_validator(mode="before") 59 | # def _run_root_validator(cls, values: t.Any, info: ValidationInfo) -> t.Any: 60 | # values = DjangoGetter(values, cls, info.context) 61 | # return values 62 | 63 | @classmethod 64 | def from_orm(cls, obj: t.Any, **options: t.Any) -> BaseModel: 65 | return cls.model_validate( # type:ignore[attr-defined,no-any-return] 66 | obj, **options 67 | ) 68 | 69 | def dict(self, *a: t.Any, **kw: t.Any) -> DictStrAny: 70 | # Backward compatibility with pydantic 1.x 71 | return self.model_dump(*a, **kw) # type:ignore[attr-defined,no-any-return] 72 | 73 | @classmethod 74 | def json_schema(cls) -> DictStrAny: 75 | return cls.model_json_schema( # type:ignore[attr-defined,no-any-return] 76 | schema_generator=GenerateJsonSchema 77 | ) 78 | 79 | @classmethod 80 | def schema(cls) -> DictStrAny: 81 | return cls.json_schema() 82 | 83 | BaseMixins = BaseMixinsV2 # type:ignore[misc] 84 | 85 | 86 | class SchemaMixins(BaseMixins): 87 | pass 88 | -------------------------------------------------------------------------------- /ninja_schema/orm/model_schema.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | from typing import ( 3 | TYPE_CHECKING, 4 | Any, 5 | Callable, 6 | Dict, 7 | Iterator, 8 | List, 9 | Optional, 10 | Set, 11 | Type, 12 | Union, 13 | cast, 14 | no_type_check, 15 | ) 16 | 17 | from django.db.models import Field, ManyToManyRel, ManyToOneRel 18 | from pydantic.fields import FieldInfo 19 | 20 | from ..errors import ConfigError 21 | from ..pydanticutils import IS_PYDANTIC_V1, compute_field_annotations 22 | from .getters import DjangoGetter 23 | from .mixins import SchemaMixins 24 | from .model_validators import ModelValidatorGroup 25 | from .schema_registry import registry as global_registry 26 | from .utils.converter import convert_django_field_with_choices 27 | 28 | ALL_FIELDS = "__all__" 29 | 30 | __all__ = ["ModelSchema"] 31 | 32 | if IS_PYDANTIC_V1: 33 | from pydantic import BaseConfig, BaseModel, PyObject 34 | from pydantic.class_validators import ( 35 | extract_root_validators, 36 | extract_validators, 37 | inherit_validators, 38 | ) 39 | from pydantic.fields import ModelField, Undefined 40 | from pydantic.main import ( 41 | ANNOTATED_FIELD_UNTOUCHED_TYPES, 42 | UNTOUCHED_TYPES, 43 | ModelMetaclass, 44 | generate_hash_function, 45 | validate_custom_root_type, 46 | ) 47 | from pydantic.typing import get_args, get_origin, is_classvar, resolve_annotations 48 | from pydantic.utils import ( 49 | ROOT_KEY, 50 | ClassAttribute, 51 | generate_model_signature, 52 | is_valid_field, 53 | lenient_issubclass, 54 | unique_list, 55 | validate_field_name, 56 | ) 57 | 58 | namespace_keys = [ 59 | "__config__", 60 | "__fields__", 61 | "__validators__", 62 | "__pre_root_validators__", 63 | "__post_root_validators__", 64 | "__schema_cache__", 65 | "__json_encoder__", 66 | "__custom_root_type__", 67 | "__private_attributes__", 68 | "__slots__", 69 | "__hash__", 70 | "__class_vars__", 71 | "__annotations__", 72 | ] 73 | 74 | class PydanticNamespace: 75 | __annotations__: Dict = {} 76 | __config__: Optional[Type[BaseConfig]] = None 77 | __fields__: Dict[str, ModelField] = {} 78 | __validators__: ModelValidatorGroup = ModelValidatorGroup({}) 79 | __pre_root_validators__: List = [] 80 | __post_root_validators__: List = [] 81 | __custom_root_type__ = None 82 | __private_attributes__ = None 83 | __class_vars__: Set = set() 84 | 85 | def __init__(self, cls: Type): 86 | for key in namespace_keys: 87 | value = getattr(cls, key, getattr(self, key, None)) 88 | setattr(self, key, value) 89 | 90 | def update_class_missing_fields( 91 | cls: Type, bases: List[Type[BaseModel]], namespace: Dict 92 | ) -> Type[BaseModel]: 93 | old_namespace: PydanticNamespace = PydanticNamespace(cls) 94 | fields = old_namespace.__fields__ or {} 95 | config: Type[BaseConfig] = cast(Type[BaseConfig], old_namespace.__config__) 96 | validators: "ValidatorListDict" = {} 97 | 98 | pre_root_validators, post_root_validators = [], [] 99 | class_vars: Set[str] = set() 100 | hash_func: Optional[Callable[[Any], int]] = None 101 | untouched_types = ANNOTATED_FIELD_UNTOUCHED_TYPES 102 | 103 | def is_untouched(val: Any) -> bool: 104 | return ( 105 | isinstance(val, untouched_types) 106 | or val.__class__.__name__ == "cython_function_or_method" 107 | ) 108 | 109 | for base in reversed(bases): 110 | if base != BaseModel: 111 | validators = inherit_validators(base.__validators__, validators) 112 | pre_root_validators += base.__pre_root_validators__ 113 | post_root_validators += base.__post_root_validators__ 114 | class_vars.update(base.__class_vars__) 115 | hash_func = base.__hash__ 116 | 117 | validators = inherit_validators(extract_validators(namespace), validators) 118 | vg = ModelValidatorGroup(validators) 119 | new_annotations = resolve_annotations( 120 | namespace.get("__annotations__") or {}, getattr(cls, "__module__", None) 121 | ) 122 | # annotation only fields need to come first in fields 123 | for ann_name, ann_type in new_annotations.items(): 124 | if is_classvar(ann_type): 125 | class_vars.add(ann_name) 126 | elif is_valid_field(ann_name): 127 | validate_field_name(bases, ann_name) 128 | value = namespace.get(ann_name, Undefined) 129 | allowed_types = ( 130 | get_args(ann_type) if get_origin(ann_type) is Union else (ann_type,) 131 | ) 132 | if ( 133 | is_untouched(value) 134 | and ann_type != PyObject 135 | and not any( 136 | lenient_issubclass(get_origin(allowed_type), Type) # type: ignore 137 | for allowed_type in allowed_types 138 | ) 139 | ): 140 | continue 141 | fields[ann_name] = ModelField.infer( 142 | name=ann_name, 143 | value=value, 144 | annotation=ann_type, 145 | class_validators=vg.get_validators(ann_name), 146 | config=config, 147 | ) 148 | 149 | untouched_types = UNTOUCHED_TYPES + config.keep_untouched 150 | for var_name, value in namespace.items(): 151 | can_be_changed = var_name not in class_vars and not is_untouched(value) 152 | if ( 153 | is_valid_field(var_name) 154 | and var_name not in new_annotations 155 | and can_be_changed 156 | ): 157 | validate_field_name(bases, var_name) 158 | inferred = ModelField.infer( 159 | name=var_name, 160 | value=value, 161 | annotation=new_annotations.get(var_name, Undefined), 162 | class_validators=vg.get_validators(var_name), 163 | config=config, 164 | ) 165 | if var_name in fields and inferred.type_ != fields[var_name].type_: 166 | raise TypeError( 167 | f"The type of {cls.__name__}.{var_name} differs from the new default value; " 168 | f"if you wish to change the type of this field, please use a type annotation" 169 | ) 170 | fields[var_name] = inferred 171 | 172 | _custom_root_type = ROOT_KEY in fields 173 | if _custom_root_type: 174 | validate_custom_root_type(fields) 175 | vg.check_for_unused() 176 | 177 | old_namespace.__annotations__.update(new_annotations) 178 | pre_rv_new, post_rv_new = extract_root_validators(namespace) 179 | 180 | if hash_func is None: 181 | hash_func = generate_hash_function(config.frozen) 182 | pre_root_validators.extend(pre_rv_new) 183 | post_root_validators.extend(post_rv_new) 184 | 185 | new_namespace = { 186 | "__annotations__": old_namespace.__annotations__, 187 | "__config__": config, 188 | "__fields__": fields, 189 | "__validators__": vg.validators, 190 | "__pre_root_validators__": unique_list(pre_root_validators), 191 | "__post_root_validators__": unique_list(post_root_validators), 192 | "__class_vars__": class_vars, 193 | "__hash__": hash_func, 194 | } 195 | for k, v in new_namespace.items(): 196 | if hasattr(cls, k): 197 | setattr(cls, k, v) 198 | # set __signature__ attr only for model class, but not for its instances 199 | cls.__signature__ = ClassAttribute( 200 | "__signature__", generate_model_signature(cls.__init__, fields, config) 201 | ) 202 | return cls 203 | 204 | if TYPE_CHECKING: 205 | from pydantic.class_validators import ValidatorListDict 206 | else: 207 | from pydantic import BaseConfig, BaseModel 208 | from pydantic._internal._model_construction import ModelMetaclass 209 | from pydantic.fields import Field as ModelField 210 | from pydantic_core import PydanticUndefined as Undefined 211 | 212 | PydanticNamespace = None 213 | 214 | def update_class_missing_fields( 215 | cls: Type, bases: List[Type[BaseModel]], namespace: Dict 216 | ): # pragma: no cover 217 | return cls 218 | 219 | 220 | class ModelSchemaConfigAdapter: 221 | def __init__(self, config: Dict) -> None: 222 | self.__dict__ = config 223 | 224 | 225 | class ModelSchemaConfig: 226 | def __init__( 227 | self, 228 | schema_class_name: str, 229 | options: Optional[Union[Dict[str, Any], ModelSchemaConfigAdapter]] = None, 230 | ): 231 | self.model = getattr(options, "model", None) 232 | _include = getattr(options, "include", None) or ALL_FIELDS 233 | self.include = set() if _include == ALL_FIELDS else set(_include or ()) 234 | self.exclude = set(getattr(options, "exclude", None) or ()) 235 | self.skip_registry = getattr(options, "skip_registry", False) 236 | self.registry = getattr(options, "registry", global_registry) 237 | self.abstract = getattr(options, "ninja_schema_abstract", False) 238 | _optional = getattr(options, "optional", None) 239 | self.optional = ( 240 | {ALL_FIELDS} if _optional == ALL_FIELDS else set(_optional or ()) 241 | ) 242 | self.depth = int(getattr(options, "depth", 0)) 243 | self.schema_class_name = schema_class_name 244 | if not self.abstract: 245 | self.validate_configuration() 246 | self.process_build_schema_parameters() 247 | 248 | @classmethod 249 | def clone_field(cls, field: FieldInfo, **kwargs: Any) -> FieldInfo: 250 | field_dict = dict(field.__repr_args__()) 251 | field_dict.update(**kwargs) 252 | new_field = FieldInfo(**field_dict) # type: ignore 253 | return new_field 254 | 255 | def model_fields(self) -> Iterator[Field]: 256 | """returns iterator with all the fields that can be part of schema""" 257 | for fld in self.model._meta.get_fields(): # type: ignore 258 | if isinstance(fld, (ManyToOneRel, ManyToManyRel)): 259 | # skipping relations 260 | continue 261 | yield cast(Field, fld) 262 | 263 | def validate_configuration(self) -> None: 264 | self.include = set() if self.include == ALL_FIELDS else set(self.include or ()) 265 | 266 | if not self.model: 267 | raise ConfigError("Invalid Configuration. 'model' is required") 268 | 269 | if self.include and self.exclude: 270 | raise ConfigError( 271 | "Only one of 'include' or 'exclude' should be set in configuration." 272 | ) 273 | 274 | def check_invalid_keys(self, **field_names: Dict[str, Any]) -> None: 275 | keys = field_names.keys() 276 | invalid_include_exclude_fields = ( 277 | set(self.include or []) | set(self.exclude or []) 278 | ) - keys 279 | if invalid_include_exclude_fields: 280 | raise ConfigError( 281 | f"Field(s) {invalid_include_exclude_fields} are not in model." 282 | ) 283 | if ALL_FIELDS not in self.optional: 284 | invalid_options_fields = set(self.optional) - keys 285 | if invalid_options_fields: 286 | raise ConfigError( 287 | f"Field(s) {invalid_options_fields} are not in model." 288 | ) 289 | 290 | def is_field_in_optional(self, field_name: str) -> bool: 291 | if not self.optional: 292 | return False 293 | if ALL_FIELDS in self.optional: 294 | return True 295 | if ( 296 | isinstance(self.optional, (set, tuple, list)) 297 | and field_name in self.optional 298 | ): 299 | return True 300 | return False 301 | 302 | def process_build_schema_parameters(self) -> None: 303 | model_pk = getattr( 304 | self.model._meta.pk, # type: ignore 305 | "name", 306 | self.model._meta.pk.attname, # type: ignore 307 | ) # no type:ignore 308 | if ( 309 | model_pk not in self.include 310 | and model_pk not in self.exclude 311 | and ALL_FIELDS not in self.optional 312 | ): 313 | self.optional.add(str(model_pk)) 314 | 315 | 316 | class ModelSchemaMetaclass(ModelMetaclass): 317 | @no_type_check 318 | def __new__( 319 | mcs, 320 | name: str, 321 | bases: tuple, 322 | namespace: dict, 323 | ): 324 | if bases == (SchemaBaseModel,) or not namespace.get( 325 | "Config", namespace.get("model_config") 326 | ): 327 | return super().__new__(mcs, name, bases, namespace) 328 | 329 | config = namespace.get("Config") 330 | if not config: 331 | model_config = namespace.get("model_config") 332 | if model_config: 333 | config = ModelSchemaConfigAdapter(model_config) 334 | 335 | config_instance = None 336 | 337 | if config: 338 | config_instance = ModelSchemaConfig(name, config) 339 | 340 | if config_instance and config_instance.model and not config_instance.abstract: 341 | annotations = namespace.get("__annotations__", {}) 342 | try: 343 | fields = list(config_instance.model_fields()) 344 | except AttributeError as exc: 345 | raise ConfigError( 346 | f"{exc} (Is `Config.model` a valid Django model class?)" 347 | ) from exc 348 | 349 | field_values, _seen = {}, set() 350 | 351 | all_fields = {f.name: f for f in fields} 352 | config_instance.check_invalid_keys(**all_fields) 353 | 354 | for field in chain(fields, annotations.copy()): 355 | field_name = getattr( 356 | field, "name", getattr(field, "related_name", field) 357 | ) 358 | 359 | if ( 360 | field_name in _seen 361 | or ( 362 | ( 363 | config_instance.include 364 | and field_name not in config_instance.include 365 | ) 366 | or ( 367 | config_instance.exclude 368 | and field_name in config_instance.exclude 369 | ) 370 | ) 371 | and field_name not in annotations 372 | ): 373 | continue 374 | 375 | _seen.add(field_name) 376 | if field_name in annotations and field_name in namespace: 377 | python_type = annotations.pop(field_name) 378 | pydantic_field = namespace[field_name] 379 | if ( 380 | hasattr(pydantic_field, "default_factory") 381 | and pydantic_field.default_factory 382 | ): 383 | pydantic_field = pydantic_field.default_factory() 384 | 385 | elif field_name in annotations: 386 | python_type = annotations.pop(field_name) 387 | pydantic_field = ( 388 | None if Optional[python_type] == python_type else Ellipsis 389 | ) 390 | 391 | else: 392 | python_type, pydantic_field = convert_django_field_with_choices( 393 | field, 394 | registry=config_instance.registry, 395 | depth=config_instance.depth, 396 | skip_registry=config_instance.skip_registry, 397 | ) 398 | 399 | if pydantic_field.default is None: 400 | python_type = Optional[python_type] 401 | 402 | if config_instance.is_field_in_optional(field_name): 403 | pydantic_field = ModelSchemaConfig.clone_field( 404 | field=pydantic_field, default=None, default_factory=None 405 | ) 406 | python_type = Optional[python_type] 407 | 408 | field_values[field_name] = (python_type, pydantic_field) 409 | if IS_PYDANTIC_V1: 410 | cls = super().__new__(mcs, name, bases, namespace) 411 | return update_class_missing_fields( 412 | cls, 413 | list(bases), 414 | compute_field_annotations(namespace, **field_values), 415 | ) 416 | return super().__new__( 417 | mcs, name, bases, compute_field_annotations(namespace, **field_values) 418 | ) 419 | return super().__new__(mcs, name, bases, namespace) 420 | 421 | 422 | class SchemaBaseModel(SchemaMixins, BaseModel): 423 | if not IS_PYDANTIC_V1: 424 | 425 | @classmethod 426 | def from_orm(cls, obj, **options) -> BaseModel: 427 | return cls.model_validate( # type:ignore[attr-defined,no-any-return] 428 | obj, **options 429 | ) 430 | 431 | def dict(self, *a: Any, **kw: Any) -> Dict[str, Any]: 432 | # Backward compatibility with pydantic 1.x 433 | return self.model_dump(*a, **kw) # type:ignore[attr-defined,no-any-return] 434 | 435 | def json(self, *a, **kw) -> str: 436 | # Backward compatibility with pydantic 1.x 437 | return self.model_dump_json(*a, **kw) # type:ignore[attr-defined,no-any-return] 438 | 439 | @classmethod 440 | def json_schema(cls) -> Dict[str, Any]: 441 | return cls.model_json_schema() 442 | 443 | @classmethod 444 | def schema(cls) -> Dict[str, Any]: 445 | return cls.json_schema() 446 | 447 | 448 | class ModelSchema(SchemaBaseModel, metaclass=ModelSchemaMetaclass): 449 | if IS_PYDANTIC_V1: 450 | 451 | class Config: 452 | orm_mode = True 453 | getter_dict = DjangoGetter 454 | 455 | else: 456 | model_config = {"from_attributes": True} 457 | -------------------------------------------------------------------------------- /ninja_schema/orm/model_validators.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from itertools import chain 3 | from types import FunctionType 4 | from typing import Any, Callable, Optional 5 | 6 | from ..errors import ConfigError 7 | from ..pydanticutils import IS_PYDANTIC_V1 8 | 9 | __all__ = ["model_validator", "ModelValidatorGroup"] 10 | 11 | if IS_PYDANTIC_V1: 12 | from pydantic.class_validators import ( 13 | VALIDATOR_CONFIG_KEY, 14 | Validator, 15 | ValidatorGroup, 16 | _prepare_validator, 17 | ) 18 | 19 | class ModelValidator: 20 | @classmethod 21 | def model_validator( 22 | cls, 23 | *fields: str, 24 | pre: bool = False, 25 | each_item: bool = False, 26 | always: bool = False, 27 | check_fields: bool = False, 28 | ) -> Callable[[Callable], classmethod]: 29 | """ 30 | Decorate methods on the class indicating that they should be used to validate fields 31 | :param fields: which field(s) the method should be called on 32 | :param pre: whether or not this validator should be called before the standard validators (else after) 33 | :param each_item: for complex objects (sets, lists etc.) whether to validate individual elements rather than the 34 | whole object 35 | :param always: whether this method and other validators should be called even if the value is missing 36 | :param check_fields: whether to check that the fields actually exist on the model 37 | """ 38 | if not fields: 39 | raise ConfigError("validator with no fields specified") 40 | elif isinstance(fields[0], FunctionType): 41 | raise ConfigError( 42 | "validators should be used with fields and keyword arguments, not bare. " # noqa: Q000 43 | "E.g. usage should be `@validator('', ...)`" 44 | ) 45 | 46 | def dec(f: Any) -> classmethod: 47 | f_cls = _prepare_validator(f, True) 48 | setattr( 49 | f_cls, 50 | VALIDATOR_CONFIG_KEY, 51 | ( 52 | fields, 53 | Validator( 54 | func=f_cls.__func__, 55 | pre=pre, 56 | each_item=each_item, 57 | always=always, 58 | check_fields=check_fields, 59 | ), 60 | ), 61 | ) 62 | return f_cls # type:ignore[no-any-return] 63 | 64 | return dec 65 | 66 | model_validator = ModelValidator.model_validator 67 | 68 | class ModelValidatorGroup(ValidatorGroup): 69 | def check_for_unused(self) -> None: 70 | unused_validators = set( 71 | chain.from_iterable( 72 | (v.func.__name__ for v in self.validators[f]) 73 | for f in (self.validators.keys() - self.used_validators) 74 | ) 75 | ) 76 | if unused_validators: 77 | fn = ", ".join(unused_validators) 78 | raise ConfigError( 79 | f"Validators defined with incorrect fields: {fn} " # noqa: Q000 80 | f"(use check_fields=False if you're inheriting from the model and intended this)" 81 | ) 82 | 83 | else: 84 | from pydantic import field_validator 85 | from pydantic.functional_validators import FieldValidatorModes 86 | 87 | def model_validator( # type:ignore[misc] 88 | __field: str, 89 | *fields: str, 90 | mode: FieldValidatorModes = "after", 91 | check_fields: Optional[bool] = None, 92 | ) -> Callable[[Any], Any]: 93 | warnings.warn( 94 | f"'{model_validator}' is deprecated for pydantic version 2.x.x. " 95 | f"Use 'field_validator' for the pydantic package instead.", 96 | category=DeprecationWarning, 97 | stacklevel=1, 98 | ) 99 | return field_validator(__field, *fields, mode=mode, check_fields=check_fields) 100 | 101 | class ModelValidatorGroup: # type:ignore[no-redef] 102 | pass 103 | -------------------------------------------------------------------------------- /ninja_schema/orm/schema.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from ..pydanticutils import IS_PYDANTIC_V1 4 | from .getters import DjangoGetter 5 | from .mixins import SchemaMixins 6 | 7 | 8 | class Schema(SchemaMixins, BaseModel): 9 | if IS_PYDANTIC_V1: 10 | 11 | class Config: 12 | orm_mode = True 13 | getter_dict = DjangoGetter 14 | 15 | else: 16 | model_config = {"from_attributes": True} 17 | -------------------------------------------------------------------------------- /ninja_schema/orm/schema_registry.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Dict, Tuple, Type, Union 2 | 3 | from django.db.models import Model 4 | 5 | from .schema import Schema 6 | from .utils.utils import is_valid_class, is_valid_django_model 7 | 8 | if TYPE_CHECKING: 9 | from ninja_schema.orm.model_schema import ModelSchema 10 | 11 | __all__ = ["SchemaRegister", "registry"] 12 | 13 | 14 | class SchemaRegisterBorg: 15 | _shared_state: Dict[str, Dict] = {} 16 | 17 | def __init__(self) -> None: 18 | self.__dict__ = self._shared_state 19 | 20 | 21 | class SchemaRegister(SchemaRegisterBorg): 22 | schemas: Dict[Type[Model], Union[Type["ModelSchema"], Type[Schema]]] 23 | fields: Dict[str, Tuple] 24 | 25 | def __init__(self) -> None: 26 | SchemaRegisterBorg.__init__(self) 27 | if not hasattr(self, "schemas"): 28 | self._shared_state.update(schemas={}, fields={}) 29 | 30 | def register_model(self, model: Type[Model], schema: Type["ModelSchema"]) -> None: 31 | from ninja_schema.orm.model_schema import ModelSchema 32 | 33 | assert is_valid_class(schema) and issubclass(schema, (ModelSchema,)), ( 34 | "Only Schema can be" 'registered, received "{}"'.format(schema.__name__) 35 | ) 36 | assert is_valid_django_model( 37 | model 38 | ), "Only Django Models are allowed. {}".format(model.__name__) 39 | # TODO: register model as module_name.model_name 40 | self.register_schema(model, schema) 41 | 42 | def register_schema( 43 | self, name: Type[Model], schema: Union[Type["ModelSchema"], Type[Schema]] 44 | ) -> None: 45 | self.schemas[name] = schema 46 | 47 | def get_model_schema( 48 | self, model: Type[Model] 49 | ) -> Union[Type["ModelSchema"], Type[Schema], None]: 50 | if model in self.schemas: 51 | return self.schemas[model] 52 | return None 53 | 54 | 55 | registry = SchemaRegister() 56 | -------------------------------------------------------------------------------- /ninja_schema/orm/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eadwinCode/ninja-schema/99edde1b3c3d39377770497ce940eba7662535e0/ninja_schema/orm/utils/__init__.py -------------------------------------------------------------------------------- /ninja_schema/orm/utils/converter.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | import typing as t 4 | from decimal import Decimal 5 | from enum import Enum 6 | from functools import singledispatch 7 | from uuid import UUID 8 | 9 | import django 10 | from django.db import models 11 | from django.db.models.fields import Field 12 | from django.utils.encoding import force_str 13 | from pydantic import AnyUrl, EmailStr, IPvAnyAddress, Json 14 | from pydantic.fields import Field as PydanticField 15 | from typing_extensions import Annotated # F401 16 | 17 | from ninja_schema.compat import ArrayField, HStoreField, JSONField, RangeField 18 | from ninja_schema.orm.factory import SchemaFactory 19 | from ninja_schema.orm.schema_registry import SchemaRegister 20 | from ninja_schema.orm.schema_registry import registry as global_registry 21 | from ninja_schema.pydanticutils import IS_PYDANTIC_V1 22 | from ninja_schema.types import DictStrAny 23 | 24 | try: 25 | from pydantic.fields import Undefined 26 | except Exception: 27 | from pydantic import BeforeValidator 28 | from pydantic_core import PydanticUndefined as Undefined 29 | 30 | if t.TYPE_CHECKING: 31 | from ..model_schema import ModelSchema 32 | 33 | 34 | TModel = t.TypeVar("TModel") 35 | 36 | NAME_PATTERN = r"^[_a-zA-Z][_a-zA-Z0-9]*$" 37 | COMPILED_NAME_PATTERN = re.compile(NAME_PATTERN) 38 | 39 | 40 | def assert_valid_name(name: str) -> None: 41 | """Helper to assert that provided names are valid.""" 42 | assert COMPILED_NAME_PATTERN.match( 43 | name 44 | ), 'Names must match /{}/ but "{}" does not.'.format(NAME_PATTERN, name) 45 | 46 | 47 | def convert_choice_name(name: str) -> str: 48 | name = force_str(name) 49 | try: 50 | assert_valid_name(name) 51 | except AssertionError: 52 | name = "A_%s" % name 53 | return name 54 | 55 | 56 | def get_choices( 57 | choices: t.Iterable[ 58 | t.Union[t.Tuple[t.Any, t.Any], t.Tuple[str, t.Iterable[t.Tuple[t.Any, t.Any]]]] 59 | ], 60 | ) -> t.Iterator[t.Tuple[str, str, str]]: 61 | for value, help_text in choices: 62 | if isinstance(help_text, (tuple, list)): 63 | for choice in get_choices(help_text): 64 | yield choice 65 | else: 66 | name = convert_choice_name(value) 67 | description = force_str(help_text) 68 | yield name, value, description 69 | 70 | 71 | class FieldConversionProps: 72 | description: str 73 | blank: bool 74 | is_null: bool 75 | max_length: int 76 | alias: str 77 | title: str 78 | 79 | def __init__(self, field: Field): 80 | data = {} 81 | field_options = field.deconstruct()[3] # 3 are the keywords 82 | 83 | data["description"] = force_str( 84 | getattr(field, "help_text", field.verbose_name) 85 | ).strip() 86 | data["title"] = field.verbose_name.title() 87 | 88 | if not field.is_relation: 89 | data["blank"] = field_options.get("blank", False) 90 | data["is_null"] = field_options.get("null", False) 91 | data["max_length"] = field_options.get("max_length") 92 | data.update(alias=None) 93 | 94 | if field.is_relation and hasattr(field, "get_attname"): 95 | data["alias"] = field.get_attname() 96 | 97 | self.__dict__ = data 98 | 99 | 100 | def convert_django_field_with_choices( 101 | field: Field, 102 | *, 103 | registry: SchemaRegister, 104 | depth: int = 0, 105 | skip_registry: bool = False, 106 | ) -> t.Tuple[t.Type, PydanticField]: 107 | converted = convert_django_field( 108 | field, registry=registry, depth=depth, skip_registry=skip_registry 109 | ) 110 | return converted 111 | 112 | 113 | @singledispatch 114 | def convert_django_field( 115 | field: Field, **kwargs: t.Any 116 | ) -> t.Tuple[t.Type, PydanticField]: 117 | raise Exception( 118 | "Don't know how to convert the Django field %s (%s)" % (field, field.__class__) 119 | ) 120 | 121 | 122 | @t.no_type_check 123 | def create_m2m_link_type( 124 | type_: t.Type[TModel], related_model: models.Model 125 | ) -> t.Type[TModel]: 126 | class M2MLink(type_): # type: ignore 127 | @classmethod 128 | def __get_validators__(cls): 129 | yield cls.validate 130 | 131 | @classmethod 132 | def validate(cls, v): 133 | if isinstance(v, type_): 134 | return v 135 | if hasattr(v, "pk") and isinstance(v.pk, type_): 136 | return v.pk 137 | raise Exception("Invalid type") 138 | 139 | return M2MLink 140 | 141 | 142 | @t.no_type_check 143 | def construct_related_field_schema( 144 | field: Field, *, registry: SchemaRegister, depth: int, skip_registry=False 145 | ) -> t.Tuple[t.Type["ModelSchema"], PydanticField]: 146 | # create a sample config and return the type 147 | model = field.related_model 148 | schema = SchemaFactory.create_schema( 149 | model, depth=depth - 1, registry=registry, skip_registry=skip_registry 150 | ) 151 | default = ... 152 | if not field.concrete and field.auto_created or field.null: 153 | default = None 154 | if isinstance(field, models.ManyToManyField): 155 | schema = t.List[schema] # type: ignore 156 | 157 | return ( 158 | schema, 159 | PydanticField( 160 | default=default, 161 | description=force_str( 162 | getattr(field, "help_text", field.verbose_name) 163 | ).strip(), 164 | title=field.verbose_name.title(), 165 | ), 166 | ) 167 | 168 | 169 | @t.no_type_check 170 | def construct_relational_field_info( 171 | field: Field, 172 | *, 173 | registry: SchemaRegister, 174 | depth: int = 0, 175 | __module__: str = __name__, 176 | ) -> t.Tuple[t.Type, PydanticField]: 177 | default: t.Any = ... 178 | field_props = FieldConversionProps(field) 179 | 180 | inner_type, field_info = convert_django_field( 181 | field.related_model._meta.pk, registry=registry, depth=depth 182 | ) 183 | 184 | if not field.concrete and field.auto_created or field.null: 185 | default = None 186 | 187 | python_type = inner_type 188 | if field.one_to_many or field.many_to_many: 189 | m2m_type = create_m2m_link_type(inner_type, field.related_model) 190 | if IS_PYDANTIC_V1: 191 | python_type = t.List[m2m_type] 192 | else: 193 | python_type = t.List[ 194 | Annotated[inner_type, BeforeValidator(m2m_type.validate)] 195 | ] # type: ignore 196 | 197 | field_info = PydanticField( 198 | default=default, 199 | alias=field_props.alias, 200 | default_factory=None, 201 | title=field_props.title, 202 | description=field_props.description, 203 | max_length=None, 204 | ) 205 | return python_type, field_info 206 | 207 | 208 | @t.no_type_check 209 | def construct_field_info( 210 | python_type: type, 211 | field: Field, 212 | depth: int = 0, 213 | __module__: str = __name__, 214 | is_custom_type: bool = False, 215 | ) -> t.Tuple[t.Type, PydanticField]: 216 | default = ... 217 | default_factory = None 218 | 219 | field_props = FieldConversionProps(field) 220 | 221 | if field.choices: 222 | choices = list(get_choices(field.choices)) 223 | named_choices = [(c[2], c[1]) for c in choices] 224 | python_type = Enum( # type: ignore 225 | f"{field.name.title().replace('_', '')}Enum", 226 | named_choices, 227 | module=__module__, 228 | type=type(named_choices[0][1]), 229 | ) 230 | is_custom_type = True 231 | 232 | if field.has_default(): 233 | if callable(field.default): 234 | default_factory = field.default 235 | elif isinstance(field.default, Enum): 236 | default = field.default.value 237 | else: 238 | default = field.default 239 | elif field_props.blank or field_props.is_null: 240 | default = None 241 | 242 | if default_factory: 243 | default = Undefined 244 | 245 | return ( 246 | python_type, 247 | PydanticField( 248 | default=default, 249 | alias=field_props.alias, 250 | default_factory=default_factory, 251 | title=field_props.title, 252 | description=field_props.description, 253 | max_length=None if is_custom_type else field_props.max_length, 254 | ), 255 | ) 256 | 257 | 258 | @t.no_type_check 259 | @convert_django_field.register(models.CharField) 260 | @convert_django_field.register(models.TextField) 261 | @convert_django_field.register(models.SlugField) 262 | @convert_django_field.register(models.GenericIPAddressField) 263 | @convert_django_field.register(models.FileField) 264 | @convert_django_field.register(models.FilePathField) 265 | def convert_field_to_string( 266 | field: Field, **kwargs: DictStrAny 267 | ) -> t.Tuple[t.Type, PydanticField]: 268 | return construct_field_info(str, field) 269 | 270 | 271 | @t.no_type_check 272 | @convert_django_field.register(models.EmailField) 273 | def convert_field_to_email_string( 274 | field: Field, **kwargs: DictStrAny 275 | ) -> t.Tuple[t.Type, PydanticField]: 276 | return construct_field_info(EmailStr, field, is_custom_type=True) 277 | 278 | 279 | @t.no_type_check 280 | @convert_django_field.register(models.URLField) 281 | def convert_field_to_url_string( 282 | field: Field, **kwargs: DictStrAny 283 | ) -> t.Tuple[t.Type, PydanticField]: 284 | return construct_field_info(AnyUrl, field, is_custom_type=True) 285 | 286 | 287 | @t.no_type_check 288 | @convert_django_field.register(models.AutoField) 289 | def convert_field_to_id( 290 | field: Field, **kwargs: DictStrAny 291 | ) -> t.Tuple[t.Type, PydanticField]: 292 | return construct_field_info(int, field) 293 | 294 | 295 | @t.no_type_check 296 | @convert_django_field.register(models.UUIDField) 297 | def convert_field_to_uuid( 298 | field: Field, **kwargs: DictStrAny 299 | ) -> t.Tuple[t.Type, PydanticField]: 300 | return construct_field_info(UUID, field) 301 | 302 | 303 | @t.no_type_check 304 | @convert_django_field.register(models.PositiveIntegerField) 305 | @convert_django_field.register(models.PositiveSmallIntegerField) 306 | @convert_django_field.register(models.SmallIntegerField) 307 | @convert_django_field.register(models.BigIntegerField) 308 | @convert_django_field.register(models.IntegerField) 309 | def convert_field_to_int( 310 | field: Field, **kwargs: DictStrAny 311 | ) -> t.Tuple[t.Type, PydanticField]: 312 | return construct_field_info(int, field) 313 | 314 | 315 | @t.no_type_check 316 | @convert_django_field.register(models.BinaryField) 317 | def convert_field_to_byte( 318 | field: Field, **kwargs: DictStrAny 319 | ) -> t.Tuple[t.Type, PydanticField]: 320 | return construct_field_info(bytes, field) 321 | 322 | 323 | @t.no_type_check 324 | @convert_django_field.register(models.IPAddressField) 325 | @convert_django_field.register(models.GenericIPAddressField) 326 | def convert_field_to_ipaddress( 327 | field: Field, **kwargs: DictStrAny 328 | ) -> t.Tuple[t.Type, PydanticField]: 329 | return construct_field_info(IPvAnyAddress, field) 330 | 331 | 332 | @t.no_type_check 333 | @convert_django_field.register(models.FloatField) 334 | def convert_field_to_float( 335 | field: Field, **kwargs: DictStrAny 336 | ) -> t.Tuple[t.Type, PydanticField]: 337 | return construct_field_info(float, field) 338 | 339 | 340 | @t.no_type_check 341 | @convert_django_field.register(models.DecimalField) 342 | def convert_field_to_decimal( 343 | field: Field, **kwargs: DictStrAny 344 | ) -> t.Tuple[t.Type, PydanticField]: 345 | return construct_field_info(Decimal, field) 346 | 347 | 348 | @t.no_type_check 349 | @convert_django_field.register(models.BooleanField) 350 | def convert_field_to_boolean( 351 | field: Field, **kwargs: DictStrAny 352 | ) -> t.Tuple[t.Type, PydanticField]: 353 | return construct_field_info(bool, field) 354 | 355 | 356 | @t.no_type_check 357 | @convert_django_field.register(models.NullBooleanField) 358 | def convert_field_to_null_boolean( 359 | field: Field, **kwargs: DictStrAny 360 | ) -> t.Tuple[t.Type, PydanticField]: 361 | return construct_field_info(bool, field) 362 | 363 | 364 | @t.no_type_check 365 | @convert_django_field.register(models.DurationField) 366 | def convert_field_to_time_delta( 367 | field: Field, **kwargs: DictStrAny 368 | ) -> t.Tuple[t.Type, PydanticField]: 369 | return construct_field_info(datetime.timedelta, field) 370 | 371 | 372 | @t.no_type_check 373 | @convert_django_field.register(models.DateTimeField) 374 | def convert_datetime_to_string( 375 | field: Field, **kwargs: DictStrAny 376 | ) -> t.Tuple[t.Type, PydanticField]: 377 | return construct_field_info(datetime.datetime, field) 378 | 379 | 380 | @t.no_type_check 381 | @convert_django_field.register(models.DateField) 382 | def convert_date_to_string( 383 | field: Field, **kwargs: DictStrAny 384 | ) -> t.Tuple[t.Type, PydanticField]: 385 | return construct_field_info(datetime.date, field) 386 | 387 | 388 | @t.no_type_check 389 | @convert_django_field.register(models.TimeField) 390 | def convert_time_to_string( 391 | field: Field, **kwargs: DictStrAny 392 | ) -> t.Tuple[t.Type, PydanticField]: 393 | return construct_field_info(datetime.time, field) 394 | 395 | 396 | @t.no_type_check 397 | @convert_django_field.register(models.OneToOneRel) 398 | def convert_one_to_one_field_to_django_model( 399 | field: Field, registry=None, depth=0, **kwargs: DictStrAny 400 | ) -> t.Tuple[t.Type, PydanticField]: 401 | return construct_relational_field_info(field, registry=registry, depth=depth) 402 | 403 | 404 | @t.no_type_check 405 | @convert_django_field.register(models.ManyToManyField) 406 | @convert_django_field.register(models.ManyToManyRel) 407 | @convert_django_field.register(models.ManyToOneRel) 408 | def convert_field_to_list_or_connection( 409 | field: Field, registry=None, depth=0, skip_registry=False, **kwargs: DictStrAny 410 | ) -> t.Tuple[t.Type, PydanticField]: 411 | if depth > 0: 412 | return construct_related_field_schema( 413 | field, depth=depth, registry=registry, skip_registry=skip_registry 414 | ) 415 | return construct_relational_field_info(field, registry=registry, depth=depth) 416 | 417 | 418 | @t.no_type_check 419 | @convert_django_field.register(models.OneToOneField) 420 | @convert_django_field.register(models.ForeignKey) 421 | def convert_field_to_django_model( 422 | field: Field, 423 | registry: t.Optional[SchemaRegister] = None, 424 | depth: int = 0, 425 | skip_registry: bool = False, 426 | **kwargs: DictStrAny, 427 | ) -> t.Tuple[t.Type, PydanticField]: 428 | if depth > 0: 429 | return construct_related_field_schema( 430 | field, 431 | depth=depth, 432 | registry=registry or global_registry, 433 | skip_registry=skip_registry, 434 | ) 435 | return construct_relational_field_info(field, registry=registry, depth=depth) 436 | 437 | 438 | @t.no_type_check 439 | @convert_django_field.register(ArrayField) 440 | def convert_postgres_array_to_list( 441 | field: Field, **kwargs: DictStrAny 442 | ) -> t.Tuple[t.Type, PydanticField]: 443 | inner_type, field_info = convert_django_field(field.base_field) 444 | if not isinstance(inner_type, list): 445 | inner_type = t.List[inner_type] # type: ignore 446 | return inner_type, field_info 447 | 448 | 449 | @t.no_type_check 450 | @convert_django_field.register(HStoreField) 451 | @convert_django_field.register(JSONField) 452 | def convert_postgres_field_to_string( 453 | field: Field, **kwargs: DictStrAny 454 | ) -> t.Tuple[t.Type, PydanticField]: 455 | python_type = Json 456 | if field.null: 457 | python_type = t.Optional[Json] 458 | return construct_field_info(python_type, field) 459 | 460 | 461 | @t.no_type_check 462 | @convert_django_field.register(RangeField) 463 | def convert_postgres_range_to_string( 464 | field: Field, **kwargs: DictStrAny 465 | ) -> t.Tuple[t.Type, PydanticField]: 466 | inner_type, field_info = convert_django_field(field.base_field) 467 | if not isinstance(inner_type, list): 468 | inner_type = t.List[inner_type] # type: ignore 469 | return inner_type, field_info 470 | 471 | 472 | if django.VERSION >= (3, 1): 473 | 474 | @t.no_type_check 475 | @convert_django_field.register(models.JSONField) 476 | def convert_field_to_json_string( 477 | field: Field, **kwargs: DictStrAny 478 | ) -> t.Tuple[t.Type, PydanticField]: 479 | python_type = Json 480 | if field.null: 481 | python_type = t.Optional[Json] 482 | return construct_field_info(python_type, field) 483 | -------------------------------------------------------------------------------- /ninja_schema/orm/utils/utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Type 3 | 4 | from django.db import models 5 | from django.db.models import Model 6 | 7 | 8 | def is_valid_django_model(model: Type[Model]) -> bool: 9 | return is_valid_class(model) and issubclass(model, models.Model) 10 | 11 | 12 | def is_valid_class(klass: type) -> bool: 13 | return inspect.isclass(klass) 14 | -------------------------------------------------------------------------------- /ninja_schema/pydanticutils/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import warnings 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from pydantic.version import VERSION as _PYDANTIC_VERSION 6 | 7 | from ..errors import ConfigError 8 | 9 | if TYPE_CHECKING: 10 | from pydantic.typing import DictStrAny 11 | 12 | __all__ = ["compute_field_annotations", "IS_PYDANTIC_V1", "PYDANTIC_VERSION"] 13 | 14 | logger = logging.getLogger() 15 | 16 | PYDANTIC_VERSION = list(map(int, _PYDANTIC_VERSION.split(".")))[:2] 17 | IS_PYDANTIC_V1 = PYDANTIC_VERSION[0] == 1 18 | 19 | 20 | def is_valid_field_name(name: str) -> bool: 21 | return not name.startswith("_") 22 | 23 | 24 | def compute_field_annotations( 25 | namespace: "DictStrAny", 26 | **field_definitions: Any, 27 | ) -> "DictStrAny": 28 | fields = {} 29 | annotations = {} 30 | 31 | for f_name, f_def in field_definitions.items(): 32 | if not is_valid_field_name(f_name): # pragma: no cover 33 | warnings.warn( 34 | f'fields may not start with an underscore, ignoring "{f_name}"', 35 | RuntimeWarning, 36 | stacklevel=1, 37 | ) 38 | if isinstance(f_def, tuple): 39 | try: 40 | f_annotation, f_value = f_def 41 | except ValueError as e: # pragma: no cover 42 | raise ConfigError( 43 | "field definitions should either be a tuple of (, ) or just a " 44 | "default value, unfortunately this means tuples as " 45 | "default values are not allowed" 46 | ) from e 47 | else: 48 | f_annotation, f_value = None, f_def 49 | 50 | if f_annotation: 51 | annotations[f_name] = f_annotation 52 | fields[f_name] = f_value 53 | 54 | namespace.update(**{"__annotations__": annotations}) 55 | namespace.update(fields) 56 | 57 | return namespace 58 | -------------------------------------------------------------------------------- /ninja_schema/types.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | DictStrAny = Dict[str, Any] 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [tool.flit.metadata] 6 | module = "ninja_schema" 7 | dist-name = "ninja-schema" 8 | author = "Ezeudoh Tochukwu" 9 | author-email = "tochukwu.ezeudoh@gmail.com" 10 | home-page = "https://github.com/eadwinCode/ninja-schema" 11 | classifiers = [ 12 | "Intended Audience :: Information Technology", 13 | "Intended Audience :: System Administrators", 14 | "Operating System :: OS Independent", 15 | "Topic :: Internet", 16 | "Topic :: Software Development :: Libraries :: Application Frameworks", 17 | "Topic :: Software Development :: Libraries :: Python Modules", 18 | "Topic :: Software Development :: Libraries", 19 | "Topic :: Software Development", 20 | "Typing :: Typed", 21 | "Environment :: Web Environment", 22 | "Intended Audience :: Developers", 23 | "License :: OSI Approved :: MIT License", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Programming Language :: Python :: 3 :: Only", 31 | "Framework :: Django", 32 | "Framework :: Django :: 3.0", 33 | "Framework :: Django :: 3.1", 34 | "Framework :: Django :: 3.2", 35 | "Framework :: Django :: 4.1", 36 | "Framework :: Django :: 4.2", 37 | "Framework :: Django :: 5.0", 38 | "Framework :: Django :: 5.1", 39 | "Framework :: AsyncIO", 40 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers", 41 | "Topic :: Internet :: WWW/HTTP", 42 | ] 43 | 44 | requires = [ 45 | "Django >=2.0", 46 | "pydantic>=1.7.4,!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0", 47 | "pydantic[email]" 48 | ] 49 | description-file = "README.md" 50 | requires-python = ">=3.8" 51 | 52 | 53 | [tool.flit.metadata.urls] 54 | Documentation = "https://github.com/eadwinCode/ninja-schema" 55 | 56 | [tool.ruff] 57 | select = [ 58 | "E", # pycodestyle errors 59 | "W", # pycodestyle warnings 60 | "F", # pyflakes 61 | "I", # isort 62 | "C", # flake8-comprehensions 63 | "B", # flake8-bugbear 64 | ] 65 | ignore = [ 66 | "E501", # line too long, handled by black 67 | "B008", # do not perform function calls in argument defaults 68 | "C901", # too complex 69 | ] 70 | 71 | [tool.ruff.per-file-ignores] 72 | "__init__.py" = ["F401"] 73 | 74 | [tool.ruff.isort] 75 | known-third-party = ["pydantic", "Django"] 76 | 77 | [tool.mypy] 78 | show_column_numbers = true 79 | 80 | follow_imports = 'normal' 81 | ignore_missing_imports = true 82 | 83 | # be strict 84 | disallow_untyped_calls = true 85 | warn_return_any = true 86 | strict_optional = true 87 | warn_no_return = true 88 | warn_redundant_casts = true 89 | warn_unused_ignores = true 90 | 91 | disallow_untyped_defs = true 92 | check_untyped_defs = true 93 | no_implicit_reexport = true 94 | 95 | [[tool.mypy.overrides]] 96 | module = "ninja_schema.orm.utils.*" 97 | ignore_errors = true 98 | 99 | [[tool.mypy.overrides]] 100 | module = "ninja_schema.orm.model_schema" 101 | ignore_errors = true 102 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # python_paths = ./ ./tests 3 | addopts = --nomigrations -------------------------------------------------------------------------------- /requirements-tests.txt: -------------------------------------------------------------------------------- 1 | django-stubs 2 | mypy == 1.14.1 3 | pytest 4 | pytest-asyncio 5 | pytest-cov 6 | pytest-django 7 | ruff == 0.11.7 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | -r requirements-tests.txt 3 | 4 | pre-commit 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eadwinCode/ninja-schema/99edde1b3c3d39377770497ce940eba7662535e0/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | 4 | def pytest_configure(config): 5 | from django.conf import settings 6 | 7 | settings.configure( 8 | ALLOWED_HOSTS=["*"], 9 | DEBUG_PROPAGATE_EXCEPTIONS=True, 10 | DATABASES={ 11 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} 12 | }, 13 | SITE_ID=1, 14 | SECRET_KEY="not very secret in tests", 15 | USE_I18N=True, 16 | USE_L10N=True, 17 | STATIC_URL="/static/", 18 | ROOT_URLCONF="tests.urls", 19 | TEMPLATES=[ 20 | { 21 | "BACKEND": "django.template.backends.django.DjangoTemplates", 22 | "DIRS": [], 23 | "APP_DIRS": True, 24 | "OPTIONS": { 25 | "context_processors": [ 26 | "django.template.context_processors.debug", 27 | "django.template.context_processors.request", 28 | "django.contrib.auth.context_processors.auth", 29 | "django.contrib.messages.context_processors.messages", 30 | ], 31 | }, 32 | }, 33 | ], 34 | MIDDLEWARE=( 35 | "django.middleware.security.SecurityMiddleware", 36 | "django.contrib.sessions.middleware.SessionMiddleware", 37 | "django.middleware.common.CommonMiddleware", 38 | "django.middleware.csrf.CsrfViewMiddleware", 39 | "django.contrib.auth.middleware.AuthenticationMiddleware", 40 | "django.contrib.messages.middleware.MessageMiddleware", 41 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 42 | ), 43 | INSTALLED_APPS=( 44 | "django.contrib.admin", 45 | "django.contrib.auth", 46 | "django.contrib.contenttypes", 47 | "django.contrib.sessions", 48 | "django.contrib.sites", 49 | "django.contrib.staticfiles", 50 | "tests", 51 | ), 52 | PASSWORD_HASHERS=("django.contrib.auth.hashers.MD5PasswordHasher",), 53 | AUTHENTICATION_BACKENDS=("django.contrib.auth.backends.ModelBackend",), 54 | LANGUAGE_CODE="en-us", 55 | TIME_ZONE="UTC", 56 | ) 57 | 58 | django.setup() 59 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | SEMESTER_CHOICES = ( 4 | ("1", "One"), 5 | ("2", "Two"), 6 | ("3", "Three"), 7 | ) 8 | 9 | 10 | class Student(models.Model): 11 | semester = models.CharField(max_length=20, choices=SEMESTER_CHOICES, default="1") 12 | 13 | 14 | class StudentEmail(models.Model): 15 | email = models.EmailField(null=False, blank=False) 16 | 17 | 18 | class Category(models.Model): 19 | name = models.CharField(max_length=100) 20 | start_date = models.DateField() 21 | end_date = models.DateField() 22 | 23 | 24 | class Event(models.Model): 25 | title = models.CharField(max_length=100) 26 | category = models.OneToOneField( 27 | Category, null=True, blank=True, on_delete=models.SET_NULL 28 | ) 29 | start_date = models.DateField(auto_now=True) 30 | end_date = models.DateField(auto_now_add=True) 31 | 32 | def __str__(self): 33 | return self.title 34 | 35 | 36 | class Client(models.Model): 37 | key = models.CharField(max_length=20, unique=True) 38 | 39 | 40 | class Day(models.Model): 41 | name = models.CharField(max_length=20, unique=True) 42 | 43 | 44 | class Week(models.Model): 45 | name = models.CharField(max_length=20, unique=True) 46 | days = models.ManyToManyField(Day) 47 | -------------------------------------------------------------------------------- /tests/test_v1_pydantic/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eadwinCode/ninja-schema/99edde1b3c3d39377770497ce940eba7662535e0/tests/test_v1_pydantic/__init__.py -------------------------------------------------------------------------------- /tests/test_v1_pydantic/test_converters.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import Mock 3 | 4 | import django 5 | import pytest 6 | from django.db import models 7 | from django.db.models import Manager 8 | from pydantic import ValidationError 9 | 10 | from ninja_schema import ModelSchema 11 | from ninja_schema.pydanticutils import IS_PYDANTIC_V1 12 | from tests.models import Week 13 | 14 | 15 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 16 | def test_inheritance(): 17 | class ParentModel(models.Model): 18 | parent_field = models.CharField() 19 | 20 | class Meta: 21 | app_label = "tests" 22 | 23 | class ChildModel(ParentModel): 24 | child_field = models.CharField() 25 | 26 | class Meta: 27 | app_label = "tests" 28 | 29 | class ChildSchema(ModelSchema): 30 | class Config: 31 | model = ChildModel 32 | 33 | print(ChildSchema.schema()) 34 | 35 | assert ChildSchema.schema() == { 36 | "title": "ChildSchema", 37 | "type": "object", 38 | "properties": { 39 | "id": {"title": "Id", "type": "integer"}, 40 | "parent_field": {"title": "Parent Field", "type": "string"}, 41 | "parentmodel_ptr_id": { 42 | "title": "Parentmodel Ptr", 43 | "type": "integer", 44 | "extra": {}, 45 | }, 46 | "child_field": {"title": "Child Field", "type": "string"}, 47 | }, 48 | "required": ["id", "parent_field", "child_field"], 49 | } 50 | 51 | 52 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 53 | def test_all_fields(): 54 | # test all except relational field 55 | 56 | class AllFields(models.Model): 57 | bigintegerfield = models.BigIntegerField() 58 | binaryfield = models.BinaryField() 59 | booleanfield = models.BooleanField() 60 | charfield = models.CharField() 61 | commaseparatedintegerfield = models.CommaSeparatedIntegerField() 62 | datefield = models.DateField() 63 | datetimefield = models.DateTimeField() 64 | decimalfield = models.DecimalField() 65 | durationfield = models.DurationField() 66 | emailfield = models.EmailField() 67 | filefield = models.FileField() 68 | filepathfield = models.FilePathField() 69 | floatfield = models.FloatField() 70 | genericipaddressfield = models.GenericIPAddressField() 71 | ipaddressfield = models.IPAddressField() 72 | imagefield = models.ImageField() 73 | integerfield = models.IntegerField() 74 | nullbooleanfield = models.NullBooleanField() 75 | positiveintegerfield = models.PositiveIntegerField() 76 | positivesmallintegerfield = models.PositiveSmallIntegerField() 77 | slugfield = models.SlugField() 78 | smallintegerfield = models.SmallIntegerField() 79 | textfield = models.TextField() 80 | timefield = models.TimeField() 81 | urlfield = models.URLField() 82 | uuidfield = models.UUIDField() 83 | 84 | class Meta: 85 | app_label = "tests" 86 | 87 | class AllFieldsSchema(ModelSchema): 88 | class Config: 89 | model = AllFields 90 | 91 | # print(SchemaCls.schema()) 92 | assert AllFieldsSchema.schema() == { 93 | "title": "AllFieldsSchema", 94 | "type": "object", 95 | "properties": { 96 | "id": {"extra": {}, "title": "Id", "type": "integer"}, 97 | "bigintegerfield": {"title": "Bigintegerfield", "type": "integer"}, 98 | "binaryfield": { 99 | "title": "Binaryfield", 100 | "type": "string", 101 | "format": "binary", 102 | }, 103 | "booleanfield": {"title": "Booleanfield", "type": "boolean"}, 104 | "charfield": {"title": "Charfield", "type": "string"}, 105 | "commaseparatedintegerfield": { 106 | "title": "Commaseparatedintegerfield", 107 | "type": "string", 108 | }, 109 | "datefield": {"title": "Datefield", "type": "string", "format": "date"}, 110 | "datetimefield": { 111 | "title": "Datetimefield", 112 | "type": "string", 113 | "format": "date-time", 114 | }, 115 | "decimalfield": {"title": "Decimalfield", "type": "number"}, 116 | "durationfield": { 117 | "title": "Durationfield", 118 | "type": "number", 119 | "format": "time-delta", 120 | }, 121 | "emailfield": {"title": "Emailfield", "format": "email", "type": "string"}, 122 | "filefield": {"title": "Filefield", "type": "string"}, 123 | "filepathfield": {"title": "Filepathfield", "type": "string"}, 124 | "floatfield": {"title": "Floatfield", "type": "number"}, 125 | "genericipaddressfield": { 126 | "title": "Genericipaddressfield", 127 | "type": "string", 128 | "format": "ipvanyaddress", 129 | }, 130 | "ipaddressfield": { 131 | "title": "Ipaddressfield", 132 | "type": "string", 133 | "format": "ipvanyaddress", 134 | }, 135 | "imagefield": {"title": "Imagefield", "type": "string"}, 136 | "integerfield": {"title": "Integerfield", "type": "integer"}, 137 | "nullbooleanfield": {"title": "Nullbooleanfield", "type": "boolean"}, 138 | "positiveintegerfield": { 139 | "title": "Positiveintegerfield", 140 | "type": "integer", 141 | }, 142 | "positivesmallintegerfield": { 143 | "title": "Positivesmallintegerfield", 144 | "type": "integer", 145 | }, 146 | "slugfield": {"title": "Slugfield", "type": "string"}, 147 | "smallintegerfield": {"title": "Smallintegerfield", "type": "integer"}, 148 | "textfield": {"title": "Textfield", "type": "string"}, 149 | "timefield": {"title": "Timefield", "type": "string", "format": "time"}, 150 | "urlfield": { 151 | "title": "Urlfield", 152 | "type": "string", 153 | "format": "uri", 154 | "maxLength": 65536, 155 | "minLength": 1, 156 | }, 157 | "uuidfield": {"title": "Uuidfield", "type": "string", "format": "uuid"}, 158 | }, 159 | "required": [ 160 | "bigintegerfield", 161 | "binaryfield", 162 | "booleanfield", 163 | "charfield", 164 | "commaseparatedintegerfield", 165 | "datefield", 166 | "datetimefield", 167 | "decimalfield", 168 | "durationfield", 169 | "emailfield", 170 | "filefield", 171 | "filepathfield", 172 | "floatfield", 173 | "genericipaddressfield", 174 | "ipaddressfield", 175 | "imagefield", 176 | "integerfield", 177 | "nullbooleanfield", 178 | "positiveintegerfield", 179 | "positivesmallintegerfield", 180 | "slugfield", 181 | "smallintegerfield", 182 | "textfield", 183 | "timefield", 184 | "urlfield", 185 | "uuidfield", 186 | ], 187 | } 188 | 189 | 190 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 191 | def test_bigautofield(): 192 | # primary key are optional fields when include = __all__ 193 | class ModelBigAuto(models.Model): 194 | bigautofiled = models.BigAutoField(primary_key=True) 195 | 196 | class Meta: 197 | app_label = "tests" 198 | 199 | class ModelBigAutoSchema(ModelSchema): 200 | class Config: 201 | model = ModelBigAuto 202 | 203 | print(ModelBigAutoSchema.schema()) 204 | assert ModelBigAutoSchema.schema() == { 205 | "title": "ModelBigAutoSchema", 206 | "type": "object", 207 | "properties": { 208 | "bigautofiled": {"title": "Bigautofiled", "type": "integer", "extra": {}} 209 | }, 210 | } 211 | 212 | 213 | @pytest.mark.skipif( 214 | django.VERSION < (3, 1), reason="json field introduced in django 3.1" 215 | ) 216 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 217 | def test_django_31_fields(): 218 | class ModelNewFields(models.Model): 219 | jsonfield = models.JSONField() 220 | positivebigintegerfield = models.PositiveBigIntegerField() 221 | 222 | class Meta: 223 | app_label = "tests" 224 | 225 | class ModelNewFieldsSchema(ModelSchema): 226 | class Config: 227 | model = ModelNewFields 228 | 229 | print(ModelNewFieldsSchema.schema()) 230 | assert ModelNewFieldsSchema.schema() == { 231 | "title": "ModelNewFieldsSchema", 232 | "type": "object", 233 | "properties": { 234 | "id": {"title": "Id", "type": "integer", "extra": {}}, 235 | "jsonfield": { 236 | "title": "Jsonfield", 237 | "format": "json-string", 238 | "type": "string", 239 | }, 240 | "positivebigintegerfield": { 241 | "title": "Positivebigintegerfield", 242 | "type": "integer", 243 | }, 244 | }, 245 | "required": ["jsonfield", "positivebigintegerfield"], 246 | } 247 | with pytest.raises(ValidationError): 248 | ModelNewFieldsSchema(id=1, jsonfield={"any": "data"}, positivebigintegerfield=1) 249 | 250 | obj = ModelNewFieldsSchema( 251 | id=1, jsonfield=json.dumps({"any": "data"}), positivebigintegerfield=1 252 | ) 253 | assert obj.dict() == { 254 | "id": 1, 255 | "jsonfield": {"any": "data"}, 256 | "positivebigintegerfield": 1, 257 | } 258 | 259 | 260 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 261 | def test_relational(): 262 | class Related(models.Model): 263 | charfield = models.CharField() 264 | 265 | class Meta: 266 | app_label = "tests" 267 | 268 | class TestModel(models.Model): 269 | manytomanyfield = models.ManyToManyField(Related) 270 | onetoonefield = models.OneToOneField(Related, on_delete=models.CASCADE) 271 | foreignkey = models.ForeignKey(Related, on_delete=models.SET_NULL, null=True) 272 | 273 | class Meta: 274 | app_label = "tests" 275 | 276 | class TestSchema(ModelSchema): 277 | class Config: 278 | model = TestModel 279 | 280 | print(TestSchema.schema()) 281 | assert TestSchema.schema() == { 282 | "title": "TestSchema", 283 | "type": "object", 284 | "properties": { 285 | "id": {"extra": {}, "title": "Id", "type": "integer"}, 286 | "onetoonefield_id": {"title": "Onetoonefield", "type": "integer"}, 287 | "foreignkey_id": {"title": "Foreignkey", "type": "integer"}, 288 | "manytomanyfield": { 289 | "title": "Manytomanyfield", 290 | "type": "array", 291 | "items": {"type": "integer"}, 292 | }, 293 | }, 294 | "required": ["onetoonefield_id", "manytomanyfield"], 295 | } 296 | 297 | 298 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 299 | def test_default(): 300 | class MyModel(models.Model): 301 | default_static = models.CharField(default="hello") 302 | default_dynamic = models.CharField(default=lambda: "world") 303 | 304 | class Meta: 305 | app_label = "tests" 306 | 307 | class MyModelSchema(ModelSchema): 308 | class Config: 309 | model = MyModel 310 | 311 | assert MyModelSchema.schema() == { 312 | "title": "MyModelSchema", 313 | "type": "object", 314 | "properties": { 315 | "id": {"title": "Id", "extra": {}, "type": "integer"}, 316 | "default_static": { 317 | "title": "Default Static", 318 | "default": "hello", 319 | "type": "string", 320 | }, 321 | "default_dynamic": {"title": "Default Dynamic", "type": "string"}, 322 | }, 323 | } 324 | 325 | 326 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 327 | def test_manytomany(): 328 | class Foo(models.Model): 329 | f = models.CharField() 330 | 331 | class Meta: 332 | app_label = "tests" 333 | 334 | class Bar(models.Model): 335 | m2m = models.ManyToManyField(Foo, blank=True) 336 | 337 | class Meta: 338 | app_label = "tests" 339 | 340 | class BarSchema(ModelSchema): 341 | class Config: 342 | model = Bar 343 | 344 | # mocking database data: 345 | 346 | foo = Mock() 347 | foo.pk = 1 348 | foo.f = "test" 349 | 350 | m2m = Mock(spec=Manager) 351 | m2m.all = lambda: [foo] 352 | 353 | bar = Mock() 354 | bar.id = 1 355 | bar.m2m = m2m 356 | 357 | data = BarSchema.from_orm(bar).dict() 358 | 359 | assert data == {"id": 1, "m2m": [1]} 360 | 361 | 362 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 363 | def test_manytomany_validation(): 364 | bar = Mock() 365 | bar.pk = "555555s" 366 | 367 | foo = Mock() 368 | foo.pk = 1 369 | 370 | class WeekSchema(ModelSchema): 371 | class Config: 372 | model = Week 373 | 374 | with pytest.raises(Exception, match="Invalid type"): 375 | WeekSchema(name="FirstWeek", days=["1", "2"]) 376 | 377 | with pytest.raises(Exception, match="Invalid type"): 378 | WeekSchema(name="FirstWeek", days=[bar, bar]) 379 | 380 | schema = WeekSchema(name="FirstWeek", days=[foo, foo]) 381 | assert schema.dict() == {"id": None, "name": "FirstWeek", "days": [1, 1]} 382 | -------------------------------------------------------------------------------- /tests/test_v1_pydantic/test_custom_fields.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from pydantic import ValidationError 5 | 6 | from ninja_schema import ModelSchema 7 | from ninja_schema.pydanticutils import IS_PYDANTIC_V1 8 | from tests.models import Student, StudentEmail 9 | 10 | 11 | class TestCustomFields: 12 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 13 | def test_enum_field(self): 14 | class StudentSchema(ModelSchema): 15 | class Config: 16 | model = Student 17 | include = "__all__" 18 | 19 | print(json.dumps(StudentSchema.schema(), sort_keys=False, indent=4)) 20 | assert StudentSchema.schema() == { 21 | "title": "StudentSchema", 22 | "type": "object", 23 | "properties": { 24 | "id": {"title": "Id", "extra": {}, "type": "integer"}, 25 | "semester": { 26 | "title": "Semester", 27 | "default": "1", 28 | "allOf": [{"$ref": "#/definitions/SemesterEnum"}], 29 | }, 30 | }, 31 | "definitions": { 32 | "SemesterEnum": { 33 | "title": "SemesterEnum", 34 | "description": "An enumeration.", 35 | "enum": ["1", "2", "3"], 36 | "type": "string" 37 | } 38 | }, 39 | } 40 | schema_instance = StudentSchema(semester="1") 41 | assert str(schema_instance.json()) == '{"id": null, "semester": "1"}' 42 | with pytest.raises(ValidationError): 43 | StudentSchema(semester="something") 44 | 45 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 46 | def test_email_field(self): 47 | class StudentEmailSchema(ModelSchema): 48 | class Config: 49 | model = StudentEmail 50 | include = "__all__" 51 | 52 | print(json.dumps(StudentEmailSchema.schema(), sort_keys=False, indent=4)) 53 | assert StudentEmailSchema.schema() == { 54 | "title": "StudentEmailSchema", 55 | "type": "object", 56 | "properties": { 57 | "id": {"title": "Id", "extra": {}, "type": "integer"}, 58 | "email": {"title": "Email", "type": "string", "format": "email"}, 59 | }, 60 | "required": ["email"], 61 | } 62 | assert ( 63 | str(StudentEmailSchema(email="email@example.com").json()) 64 | == '{"id": null, "email": "email@example.com"}' 65 | ) 66 | with pytest.raises(ValidationError): 67 | StudentEmailSchema(email="emailexample.com") 68 | -------------------------------------------------------------------------------- /tests/test_v1_pydantic/test_model_schema.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | from ninja_schema import ModelSchema, SchemaFactory, model_validator 6 | from ninja_schema.errors import ConfigError 7 | from ninja_schema.pydanticutils import IS_PYDANTIC_V1 8 | from tests.models import Event 9 | 10 | 11 | class TestModelSchema: 12 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 13 | def test_schema_include_fields(self): 14 | class EventSchema(ModelSchema): 15 | class Config: 16 | model = Event 17 | include = "__all__" 18 | 19 | assert EventSchema.schema() == { 20 | "title": "EventSchema", 21 | "type": "object", 22 | "properties": { 23 | "id": {"title": "Id", "extra": {}, "type": "integer"}, 24 | "title": {"title": "Title", "maxLength": 100, "type": "string"}, 25 | "category_id": {"title": "Category", "type": "integer"}, 26 | "start_date": { 27 | "title": "Start Date", 28 | "type": "string", 29 | "format": "date", 30 | }, 31 | "end_date": {"title": "End Date", "type": "string", "format": "date"}, 32 | }, 33 | "required": ["title", "start_date", "end_date"], 34 | } 35 | 36 | class Event2Schema(ModelSchema): 37 | class Config: 38 | model = Event 39 | include = ["title", "start_date", "end_date"] 40 | 41 | assert Event2Schema.schema() == { 42 | "title": "Event2Schema", 43 | "type": "object", 44 | "properties": { 45 | "title": {"title": "Title", "maxLength": 100, "type": "string"}, 46 | "start_date": { 47 | "title": "Start Date", 48 | "type": "string", 49 | "format": "date", 50 | }, 51 | "end_date": {"title": "End Date", "type": "string", "format": "date"}, 52 | }, 53 | "required": ["title", "start_date", "end_date"], 54 | } 55 | 56 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 57 | def test_schema_depth(self): 58 | class EventDepthSchema(ModelSchema): 59 | class Config: 60 | model = Event 61 | include = "__all__" 62 | depth = 1 63 | 64 | assert EventDepthSchema.schema() == { 65 | "title": "EventDepthSchema", 66 | "type": "object", 67 | "properties": { 68 | "id": {"title": "Id", "extra": {}, "type": "integer"}, 69 | "title": {"title": "Title", "maxLength": 100, "type": "string"}, 70 | "category": { 71 | "title": "Category", 72 | "allOf": [{"$ref": "#/definitions/Category"}], 73 | }, 74 | "start_date": { 75 | "title": "Start Date", 76 | "type": "string", 77 | "format": "date", 78 | }, 79 | "end_date": {"title": "End Date", "type": "string", "format": "date"}, 80 | }, 81 | "required": ["title", "start_date", "end_date"], 82 | "definitions": { 83 | "Category": { 84 | "title": "Category", 85 | "type": "object", 86 | "properties": { 87 | "id": {"title": "Id", "extra": {}, "type": "integer"}, 88 | "name": {"title": "Name", "maxLength": 100, "type": "string"}, 89 | "start_date": { 90 | "title": "Start Date", 91 | "type": "string", 92 | "format": "date", 93 | }, 94 | "end_date": { 95 | "title": "End Date", 96 | "type": "string", 97 | "format": "date", 98 | }, 99 | }, 100 | "required": ["name", "start_date", "end_date"], 101 | } 102 | }, 103 | } 104 | 105 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 106 | def test_schema_exclude_fields(self): 107 | class Event3Schema(ModelSchema): 108 | class Config: 109 | model = Event 110 | exclude = ["id", "category"] 111 | 112 | assert Event3Schema.schema() == { 113 | "title": "Event3Schema", 114 | "type": "object", 115 | "properties": { 116 | "title": {"title": "Title", "maxLength": 100, "type": "string"}, 117 | "start_date": { 118 | "title": "Start Date", 119 | "type": "string", 120 | "format": "date", 121 | }, 122 | "end_date": {"title": "End Date", "type": "string", "format": "date"}, 123 | }, 124 | "required": ["title", "start_date", "end_date"], 125 | } 126 | 127 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 128 | def test_schema_optional_fields(self): 129 | class Event4Schema(ModelSchema): 130 | class Config: 131 | model = Event 132 | include = "__all__" 133 | optional = "__all__" 134 | 135 | assert Event4Schema.schema() == { 136 | "title": "Event4Schema", 137 | "type": "object", 138 | "properties": { 139 | "id": {"title": "Id", "extra": {}, "type": "integer"}, 140 | "title": { 141 | "title": "Title", 142 | "extra": {}, 143 | "maxLength": 100, 144 | "type": "string", 145 | }, 146 | "category_id": {"title": "Category", "extra": {}, "type": "integer"}, 147 | "start_date": { 148 | "title": "Start Date", 149 | "extra": {}, 150 | "type": "string", 151 | "format": "date", 152 | }, 153 | "end_date": { 154 | "title": "End Date", 155 | "extra": {}, 156 | "type": "string", 157 | "format": "date", 158 | }, 159 | }, 160 | } 161 | 162 | class Event5Schema(ModelSchema): 163 | class Config: 164 | model = Event 165 | include = ["id", "title", "start_date"] 166 | optional = [ 167 | "start_date", 168 | ] 169 | 170 | assert Event5Schema.schema() == { 171 | "title": "Event5Schema", 172 | "type": "object", 173 | "properties": { 174 | "id": {"title": "Id", "type": "integer"}, 175 | "title": {"title": "Title", "maxLength": 100, "type": "string"}, 176 | "start_date": { 177 | "title": "Start Date", 178 | "extra": {}, 179 | "type": "string", 180 | "format": "date", 181 | }, 182 | }, 183 | "required": [ 184 | "id", 185 | "title", 186 | ], 187 | } 188 | 189 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 190 | def test_schema_custom_fields(self): 191 | class Event6Schema(ModelSchema): 192 | custom_field1: str 193 | custom_field2: int = 1 194 | custom_field3 = "" 195 | _custom_field4 = [] # ignored by pydantic 196 | 197 | class Config: 198 | model = Event 199 | exclude = ["id", "category"] 200 | 201 | assert Event6Schema.schema() == { 202 | "title": "Event6Schema", 203 | "type": "object", 204 | "properties": { 205 | "title": {"title": "Title", "maxLength": 100, "type": "string"}, 206 | "start_date": { 207 | "title": "Start Date", 208 | "type": "string", 209 | "format": "date", 210 | }, 211 | "end_date": {"title": "End Date", "type": "string", "format": "date"}, 212 | "custom_field1": {"title": "Custom Field1", "type": "string"}, 213 | "custom_field3": { 214 | "title": "Custom Field3", 215 | "default": "", 216 | "type": "string", 217 | }, 218 | "custom_field2": { 219 | "title": "Custom Field2", 220 | "default": 1, 221 | "type": "integer", 222 | }, 223 | }, 224 | "required": ["title", "start_date", "end_date", "custom_field1"], 225 | } 226 | 227 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 228 | def test_model_validator(self): 229 | class EventSchema(ModelSchema): 230 | class Config: 231 | model = Event 232 | include = [ 233 | "title", 234 | "start_date", 235 | ] 236 | 237 | @model_validator("title") 238 | def validate_title(cls, value): 239 | return f"{value} - value cleaned" 240 | 241 | event = EventSchema(start_date="2021-06-12", title="PyConf 2021") 242 | assert "value cleaned" in event.title 243 | 244 | class Event2Schema(ModelSchema): 245 | custom_field: str 246 | 247 | class Config: 248 | model = Event 249 | include = [ 250 | "title", 251 | "start_date", 252 | ] 253 | 254 | @model_validator("title", "custom_field") 255 | def validate_title(cls, value): 256 | return f"{value} - value cleaned" 257 | 258 | event2 = Event2Schema( 259 | start_date="2021-06-12", 260 | title="PyConf 2021", 261 | custom_field="some custom name", 262 | ) 263 | assert "value cleaned" in event2.title 264 | assert "value cleaned" in event2.custom_field 265 | 266 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 267 | def test_invalid_fields_inputs(self): 268 | with pytest.raises(ConfigError): 269 | 270 | class Event1Schema(ModelSchema): 271 | class Config: 272 | model = Event 273 | include = ["xy", "yz"] 274 | 275 | with pytest.raises(ConfigError): 276 | 277 | class Event2Schema(ModelSchema): 278 | class Config: 279 | model = Event 280 | exclude = ["xy", "yz"] 281 | 282 | with pytest.raises(ConfigError): 283 | 284 | class Event3Schema(ModelSchema): 285 | class Config: 286 | model = Event 287 | optional = ["xy", "yz"] 288 | 289 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 290 | def test_model_validator_not_used(self): 291 | with pytest.raises(ConfigError): 292 | 293 | class Event1Schema(ModelSchema): 294 | class Config: 295 | model = Event 296 | exclude = [ 297 | "title", 298 | ] 299 | 300 | @model_validator("title") 301 | def validate_title(cls, value): 302 | return f"{value} - value cleaned" 303 | 304 | with pytest.raises(ConfigError): 305 | 306 | class Event2Schema(ModelSchema): 307 | class Config: 308 | model = Event 309 | include = [ 310 | "title", 311 | ] 312 | 313 | @model_validator("title", "invalid_field") 314 | def validate_title(cls, value): 315 | return f"{value} - value cleaned" 316 | 317 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 318 | def test_factory_functions(self): 319 | event_schema = SchemaFactory.create_schema(model=Event, name="EventSchema") 320 | print(json.dumps(event_schema.schema(), sort_keys=False, indent=4)) 321 | assert event_schema.schema() == { 322 | "title": "EventSchema", 323 | "type": "object", 324 | "properties": { 325 | "id": {"title": "Id", "extra": {}, "type": "integer"}, 326 | "title": {"title": "Title", "maxLength": 100, "type": "string"}, 327 | "category_id": {"title": "Category", "type": "integer"}, 328 | "start_date": { 329 | "title": "Start Date", 330 | "type": "string", 331 | "format": "date", 332 | }, 333 | "end_date": {"title": "End Date", "type": "string", "format": "date"}, 334 | }, 335 | "required": ["title", "start_date", "end_date"], 336 | } 337 | 338 | def get_new_event(self, title): 339 | event = Event(title=title) 340 | event.save() 341 | return event 342 | 343 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 344 | @pytest.mark.django_db 345 | def test_getter_functions(self): 346 | class EventSchema(ModelSchema): 347 | class Config: 348 | model = Event 349 | include = ["title", "category", "id"] 350 | 351 | event = self.get_new_event(title="PyConf") 352 | json_event = EventSchema.from_orm(event) 353 | 354 | assert json_event.dict() == {"id": 1, "title": "PyConf", "category": None} 355 | json_event.title = "PyConf Updated" 356 | 357 | json_event.apply_to_model(event) 358 | assert event.title == "PyConf Updated" 359 | 360 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 361 | def test_abstract_model_schema_does_not_raise_exception_for_incomplete_configuration( 362 | self, 363 | ): 364 | with pytest.raises( 365 | Exception, match="Invalid Configuration. 'model' is required" 366 | ): 367 | 368 | class AbstractModel(ModelSchema): 369 | class Config: 370 | orm_mode = True 371 | 372 | class AbstractBaseModelSchema(ModelSchema): 373 | class Config: 374 | ninja_schema_abstract = True 375 | -------------------------------------------------------------------------------- /tests/test_v1_pydantic/test_schema.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | from django.db.models import Manager, QuerySet 6 | from django.db.models.fields.files import ImageFieldFile 7 | from pydantic import Field 8 | 9 | from ninja_schema import Schema 10 | from ninja_schema.pydanticutils import IS_PYDANTIC_V1 11 | 12 | 13 | class FakeManager(Manager): 14 | def __init__(self, items): 15 | self._items = items 16 | 17 | def all(self): 18 | return self._items 19 | 20 | def __str__(self): 21 | return "FakeManager" 22 | 23 | 24 | class FakeQS(QuerySet): 25 | def __init__(self, items): 26 | self._result_cache = items 27 | self._prefetch_related_lookups = False 28 | 29 | def __str__(self): 30 | return "FakeQS" 31 | 32 | 33 | class Tag: 34 | def __init__(self, id, title): 35 | self.id = id 36 | self.title = title 37 | 38 | 39 | # mocking some user: 40 | class User: 41 | name = "John" 42 | group_set = FakeManager([1, 2, 3]) 43 | avatar = ImageFieldFile(None, Mock(), name=None) 44 | 45 | @property 46 | def tags(self): 47 | return FakeQS([Tag(1, "foo"), Tag(2, "bar")]) 48 | 49 | 50 | class TagSchema(Schema): 51 | id: str 52 | title: str 53 | 54 | 55 | class UserSchema(Schema): 56 | name: str 57 | groups: List[int] = Field(..., alias="group_set") 58 | tags: List[TagSchema] 59 | avatar: str = None 60 | 61 | 62 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 63 | def test_schema(): 64 | user = User() 65 | schema = UserSchema.from_orm(user) 66 | assert schema.dict() == { 67 | "name": "John", 68 | "groups": [1, 2, 3], 69 | "tags": [{"id": "1", "title": "foo"}, {"id": "2", "title": "bar"}], 70 | "avatar": None, 71 | } 72 | 73 | 74 | @pytest.mark.skipif(not IS_PYDANTIC_V1, reason="requires pydantic == 1.6.x") 75 | def test_schema_with_image(): 76 | user = User() 77 | field = Mock() 78 | field.storage.url = Mock(return_value="/smile.jpg") 79 | user.avatar = ImageFieldFile(None, field, name="smile.jpg") 80 | schema = UserSchema.from_orm(user) 81 | assert schema.dict() == { 82 | "name": "John", 83 | "groups": [1, 2, 3], 84 | "tags": [{"id": "1", "title": "foo"}, {"id": "2", "title": "bar"}], 85 | "avatar": "/smile.jpg", 86 | } 87 | -------------------------------------------------------------------------------- /tests/test_v2_pydantic/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eadwinCode/ninja-schema/99edde1b3c3d39377770497ce940eba7662535e0/tests/test_v2_pydantic/__init__.py -------------------------------------------------------------------------------- /tests/test_v2_pydantic/test_converters.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import Mock 3 | 4 | import django 5 | import pytest 6 | from django.db import models 7 | from django.db.models import Manager 8 | from pydantic import ValidationError 9 | 10 | from ninja_schema import ModelSchema 11 | from ninja_schema.pydanticutils import IS_PYDANTIC_V1 12 | from tests.models import Week 13 | 14 | 15 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 16 | def test_inheritance(): 17 | class ParentModel(models.Model): 18 | parent_field = models.CharField() 19 | 20 | class Meta: 21 | app_label = "tests" 22 | 23 | class ChildModel(ParentModel): 24 | child_field = models.CharField() 25 | 26 | class Meta: 27 | app_label = "tests" 28 | 29 | class ChildSchema(ModelSchema): 30 | class Config: 31 | model = ChildModel 32 | 33 | print(ChildSchema.schema()) 34 | 35 | assert ChildSchema.schema() == { 36 | "properties": { 37 | "id": {"description": "", "title": "Id", "type": "integer"}, 38 | "parent_field": { 39 | "description": "", 40 | "title": "Parent Field", 41 | "type": "string", 42 | }, 43 | "parentmodel_ptr": { 44 | "anyOf": [{"type": "integer"}, {"type": "null"}], 45 | "default": None, 46 | "description": "", 47 | "title": "Parentmodel Ptr", 48 | }, 49 | "child_field": { 50 | "description": "", 51 | "title": "Child Field", 52 | "type": "string", 53 | }, 54 | }, 55 | "required": ["id", "parent_field", "child_field"], 56 | "title": "ChildSchema", 57 | "type": "object", 58 | } 59 | 60 | 61 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 62 | def test_all_fields(): 63 | # test all except relational field 64 | 65 | class AllFields(models.Model): 66 | bigintegerfield = models.BigIntegerField() 67 | binaryfield = models.BinaryField() 68 | booleanfield = models.BooleanField() 69 | charfield = models.CharField() 70 | commaseparatedintegerfield = models.CommaSeparatedIntegerField() 71 | datefield = models.DateField() 72 | datetimefield = models.DateTimeField() 73 | decimalfield = models.DecimalField() 74 | durationfield = models.DurationField() 75 | emailfield = models.EmailField() 76 | filefield = models.FileField() 77 | filepathfield = models.FilePathField() 78 | floatfield = models.FloatField() 79 | genericipaddressfield = models.GenericIPAddressField() 80 | ipaddressfield = models.IPAddressField() 81 | imagefield = models.ImageField() 82 | integerfield = models.IntegerField() 83 | nullbooleanfield = models.NullBooleanField() 84 | positiveintegerfield = models.PositiveIntegerField() 85 | positivesmallintegerfield = models.PositiveSmallIntegerField() 86 | slugfield = models.SlugField() 87 | smallintegerfield = models.SmallIntegerField() 88 | textfield = models.TextField() 89 | timefield = models.TimeField() 90 | urlfield = models.URLField() 91 | uuidfield = models.UUIDField() 92 | 93 | class Meta: 94 | app_label = "tests" 95 | 96 | class AllFieldsSchema(ModelSchema): 97 | class Config: 98 | model = AllFields 99 | 100 | # print(SchemaCls.schema()) 101 | assert AllFieldsSchema.schema() == { 102 | "properties": { 103 | "id": { 104 | "anyOf": [{"type": "integer"}, {"type": "null"}], 105 | "default": None, 106 | "description": "", 107 | "title": "Id", 108 | }, 109 | "bigintegerfield": { 110 | "description": "", 111 | "title": "Bigintegerfield", 112 | "type": "integer", 113 | }, 114 | "binaryfield": { 115 | "description": "", 116 | "format": "binary", 117 | "title": "Binaryfield", 118 | "type": "string", 119 | }, 120 | "booleanfield": { 121 | "description": "", 122 | "title": "Booleanfield", 123 | "type": "boolean", 124 | }, 125 | "charfield": {"description": "", "title": "Charfield", "type": "string"}, 126 | "commaseparatedintegerfield": { 127 | "description": "", 128 | "title": "Commaseparatedintegerfield", 129 | "type": "string", 130 | }, 131 | "datefield": { 132 | "description": "", 133 | "format": "date", 134 | "title": "Datefield", 135 | "type": "string", 136 | }, 137 | "datetimefield": { 138 | "description": "", 139 | "format": "date-time", 140 | "title": "Datetimefield", 141 | "type": "string", 142 | }, 143 | "decimalfield": { 144 | "anyOf": [{"type": "number"}, {"type": "string"}], 145 | "description": "", 146 | "title": "Decimalfield", 147 | }, 148 | "durationfield": { 149 | "description": "", 150 | "format": "duration", 151 | "title": "Durationfield", 152 | "type": "string", 153 | }, 154 | "emailfield": { 155 | "description": "", 156 | "format": "email", 157 | "title": "Emailfield", 158 | "type": "string", 159 | }, 160 | "filefield": {"description": "", "title": "Filefield", "type": "string"}, 161 | "filepathfield": { 162 | "description": "", 163 | "title": "Filepathfield", 164 | "type": "string", 165 | }, 166 | "floatfield": {"description": "", "title": "Floatfield", "type": "number"}, 167 | "genericipaddressfield": { 168 | "description": "", 169 | "format": "ipvanyaddress", 170 | "title": "Genericipaddressfield", 171 | "type": "string", 172 | }, 173 | "ipaddressfield": { 174 | "description": "", 175 | "format": "ipvanyaddress", 176 | "title": "Ipaddressfield", 177 | "type": "string", 178 | }, 179 | "imagefield": {"description": "", "title": "Imagefield", "type": "string"}, 180 | "integerfield": { 181 | "description": "", 182 | "title": "Integerfield", 183 | "type": "integer", 184 | }, 185 | "nullbooleanfield": { 186 | "description": "", 187 | "title": "Nullbooleanfield", 188 | "type": "boolean", 189 | }, 190 | "positiveintegerfield": { 191 | "description": "", 192 | "title": "Positiveintegerfield", 193 | "type": "integer", 194 | }, 195 | "positivesmallintegerfield": { 196 | "description": "", 197 | "title": "Positivesmallintegerfield", 198 | "type": "integer", 199 | }, 200 | "slugfield": {"description": "", "title": "Slugfield", "type": "string"}, 201 | "smallintegerfield": { 202 | "description": "", 203 | "title": "Smallintegerfield", 204 | "type": "integer", 205 | }, 206 | "textfield": {"description": "", "title": "Textfield", "type": "string"}, 207 | "timefield": { 208 | "description": "", 209 | "format": "time", 210 | "title": "Timefield", 211 | "type": "string", 212 | }, 213 | "urlfield": { 214 | "description": "", 215 | "format": "uri", 216 | "minLength": 1, 217 | "title": "Urlfield", 218 | "type": "string", 219 | }, 220 | "uuidfield": { 221 | "description": "", 222 | "format": "uuid", 223 | "title": "Uuidfield", 224 | "type": "string", 225 | }, 226 | }, 227 | "required": [ 228 | "bigintegerfield", 229 | "binaryfield", 230 | "booleanfield", 231 | "charfield", 232 | "commaseparatedintegerfield", 233 | "datefield", 234 | "datetimefield", 235 | "decimalfield", 236 | "durationfield", 237 | "emailfield", 238 | "filefield", 239 | "filepathfield", 240 | "floatfield", 241 | "genericipaddressfield", 242 | "ipaddressfield", 243 | "imagefield", 244 | "integerfield", 245 | "nullbooleanfield", 246 | "positiveintegerfield", 247 | "positivesmallintegerfield", 248 | "slugfield", 249 | "smallintegerfield", 250 | "textfield", 251 | "timefield", 252 | "urlfield", 253 | "uuidfield", 254 | ], 255 | "title": "AllFieldsSchema", 256 | "type": "object", 257 | } 258 | 259 | 260 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 261 | def test_bigautofield(): 262 | # primary key are optional fields when include = __all__ 263 | class ModelBigAuto(models.Model): 264 | bigautofiled = models.BigAutoField(primary_key=True) 265 | 266 | class Meta: 267 | app_label = "tests" 268 | 269 | class ModelBigAutoSchema(ModelSchema): 270 | class Config: 271 | model = ModelBigAuto 272 | 273 | print(ModelBigAutoSchema.schema()) 274 | assert ModelBigAutoSchema.schema() == { 275 | "properties": { 276 | "bigautofiled": { 277 | "anyOf": [{"type": "integer"}, {"type": "null"}], 278 | "default": None, 279 | "description": "", 280 | "title": "Bigautofiled", 281 | } 282 | }, 283 | "title": "ModelBigAutoSchema", 284 | "type": "object", 285 | } 286 | 287 | 288 | @pytest.mark.skipif( 289 | django.VERSION < (3, 1), reason="json field introduced in django 3.1" 290 | ) 291 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 292 | def test_django_31_fields(): 293 | class ModelNewFields(models.Model): 294 | jsonfield = models.JSONField() 295 | positivebigintegerfield = models.PositiveBigIntegerField() 296 | 297 | class Meta: 298 | app_label = "tests" 299 | 300 | class ModelNewFieldsSchema(ModelSchema): 301 | class Config: 302 | model = ModelNewFields 303 | 304 | print(ModelNewFieldsSchema.schema()) 305 | assert ModelNewFieldsSchema.schema() == { 306 | "properties": { 307 | "id": { 308 | "anyOf": [{"type": "integer"}, {"type": "null"}], 309 | "default": None, 310 | "description": "", 311 | "title": "Id", 312 | }, 313 | "jsonfield": { 314 | "contentMediaType": "application/json", 315 | "contentSchema": {}, 316 | "description": "", 317 | "title": "Jsonfield", 318 | "type": "string", 319 | }, 320 | "positivebigintegerfield": { 321 | "description": "", 322 | "title": "Positivebigintegerfield", 323 | "type": "integer", 324 | }, 325 | }, 326 | "required": ["jsonfield", "positivebigintegerfield"], 327 | "title": "ModelNewFieldsSchema", 328 | "type": "object", 329 | } 330 | 331 | with pytest.raises(ValidationError): 332 | ModelNewFieldsSchema(id=1, jsonfield={"any": "data"}, positivebigintegerfield=1) 333 | 334 | obj = ModelNewFieldsSchema( 335 | id=1, jsonfield=json.dumps({"any": "data"}), positivebigintegerfield=1 336 | ) 337 | assert obj.dict() == { 338 | "id": 1, 339 | "jsonfield": {"any": "data"}, 340 | "positivebigintegerfield": 1, 341 | } 342 | 343 | 344 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 345 | def test_relational(): 346 | class Related(models.Model): 347 | charfield = models.CharField() 348 | 349 | class Meta: 350 | app_label = "tests" 351 | 352 | class TestModel(models.Model): 353 | manytomanyfield = models.ManyToManyField(Related) 354 | onetoonefield = models.OneToOneField(Related, on_delete=models.CASCADE) 355 | foreignkey = models.ForeignKey(Related, on_delete=models.SET_NULL, null=True) 356 | 357 | class Meta: 358 | app_label = "tests" 359 | 360 | class TestSchema(ModelSchema): 361 | class Config: 362 | model = TestModel 363 | 364 | print(json.dumps(TestSchema.schema(), sort_keys=False, indent=4)) 365 | assert TestSchema.schema() == { 366 | "properties": { 367 | "id": { 368 | "anyOf": [{"type": "integer"}, {"type": "null"}], 369 | "default": None, 370 | "description": "", 371 | "title": "Id", 372 | }, 373 | "onetoonefield_id": { 374 | "description": "", 375 | "title": "Onetoonefield", 376 | "type": "integer", 377 | }, 378 | "foreignkey_id": { 379 | "anyOf": [{"type": "integer"}, {"type": "null"}], 380 | "default": None, 381 | "description": "", 382 | "title": "Foreignkey", 383 | }, 384 | "manytomanyfield": { 385 | "description": "", 386 | "items": {"type": "integer"}, 387 | "title": "Manytomanyfield", 388 | "type": "array", 389 | }, 390 | }, 391 | "required": ["onetoonefield_id", "manytomanyfield"], 392 | "title": "TestSchema", 393 | "type": "object", 394 | } 395 | 396 | 397 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 398 | def test_default(): 399 | class MyModel(models.Model): 400 | default_static = models.CharField(default="hello") 401 | default_dynamic = models.CharField(default=lambda: "world") 402 | 403 | class Meta: 404 | app_label = "tests" 405 | 406 | class MyModelSchema(ModelSchema): 407 | class Config: 408 | model = MyModel 409 | 410 | print(json.dumps(MyModelSchema.schema(), sort_keys=False, indent=4)) 411 | assert MyModelSchema.schema() == { 412 | "properties": { 413 | "id": { 414 | "anyOf": [{"type": "integer"}, {"type": "null"}], 415 | "default": None, 416 | "description": "", 417 | "title": "Id", 418 | }, 419 | "default_static": { 420 | "default": "hello", 421 | "description": "", 422 | "title": "Default Static", 423 | "type": "string", 424 | }, 425 | "default_dynamic": { 426 | "description": "", 427 | "title": "Default Dynamic", 428 | "type": "string", 429 | }, 430 | }, 431 | "title": "MyModelSchema", 432 | "type": "object", 433 | } 434 | 435 | 436 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 437 | def test_manytomany(): 438 | class Foo(models.Model): 439 | f = models.CharField() 440 | 441 | class Meta: 442 | app_label = "tests" 443 | 444 | class Bar(models.Model): 445 | m2m = models.ManyToManyField(Foo, blank=True) 446 | 447 | class Meta: 448 | app_label = "tests" 449 | 450 | class BarSchema(ModelSchema): 451 | class Config: 452 | model = Bar 453 | 454 | # mocking database data: 455 | 456 | foo = Mock() 457 | foo.pk = 1 458 | foo.f = "test" 459 | 460 | m2m = Mock(spec=Manager) 461 | m2m.all = lambda: [foo] 462 | 463 | bar = Mock() 464 | bar.id = 1 465 | bar.m2m = m2m 466 | 467 | data = BarSchema.from_orm(bar).dict() 468 | 469 | assert data == {"id": 1, "m2m": [1]} 470 | 471 | 472 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 473 | def test_manytomany_validation(): 474 | bar = Mock() 475 | bar.pk = "555555s" 476 | 477 | foo = Mock() 478 | foo.pk = 1 479 | 480 | class WeekSchema(ModelSchema): 481 | class Config: 482 | model = Week 483 | 484 | with pytest.raises(Exception, match="Invalid type"): 485 | WeekSchema(name="FirstWeek", days=["1", "2"]) 486 | 487 | with pytest.raises(Exception, match="Invalid type"): 488 | WeekSchema(name="FirstWeek", days=[bar, bar]) 489 | 490 | schema = WeekSchema(name="FirstWeek", days=[foo, foo]) 491 | assert schema.dict() == {"id": None, "name": "FirstWeek", "days": [1, 1]} 492 | -------------------------------------------------------------------------------- /tests/test_v2_pydantic/test_custom_fields.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from pydantic import ValidationError 5 | 6 | from ninja_schema import ModelSchema 7 | from ninja_schema.pydanticutils import IS_PYDANTIC_V1 8 | from tests.models import Student, StudentEmail 9 | 10 | 11 | class TestCustomFields: 12 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 13 | def test_enum_field(self): 14 | class StudentSchema(ModelSchema): 15 | model_config = {"model": Student, "include": "__all__"} 16 | 17 | print(json.dumps(StudentSchema.schema(), sort_keys=False, indent=4)) 18 | assert StudentSchema.schema() == { 19 | "$defs": { 20 | "SemesterEnum": { 21 | "enum": ["1", "2", "3"], 22 | "title": "SemesterEnum", 23 | "type": "string", 24 | } 25 | }, 26 | "properties": { 27 | "id": { 28 | "anyOf": [{"type": "integer"}, {"type": "null"}], 29 | "default": None, 30 | "description": "", 31 | "title": "Id", 32 | }, 33 | "semester": { 34 | "$ref": "#/$defs/SemesterEnum", 35 | "default": "1", 36 | "description": "", 37 | "title": "Semester", 38 | }, 39 | }, 40 | "title": "StudentSchema", 41 | "type": "object", 42 | } 43 | schema_instance = StudentSchema(semester="1") 44 | assert str(schema_instance.json()) == '{"id":null,"semester":"1"}' 45 | with pytest.raises(ValidationError): 46 | StudentSchema(semester="something") 47 | 48 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 49 | def test_enum_field_or_greater(self): 50 | class StudentSchema(ModelSchema): 51 | model_config = {"model": Student, "include": "__all__"} 52 | 53 | print(json.dumps(StudentSchema.schema(), sort_keys=False, indent=4)) 54 | assert StudentSchema.schema() == { 55 | "$defs": { 56 | "SemesterEnum": { 57 | "enum": ["1", "2", "3"], 58 | "title": "SemesterEnum", 59 | "type": "string", 60 | } 61 | }, 62 | "properties": { 63 | "id": { 64 | "anyOf": [{"type": "integer"}, {"type": "null"}], 65 | "default": None, 66 | "description": "", 67 | "title": "Id", 68 | }, 69 | "semester": { 70 | "$ref": "#/$defs/SemesterEnum", 71 | "default": "1", 72 | "description": "", 73 | "title": "Semester", 74 | }, 75 | }, 76 | "title": "StudentSchema", 77 | "type": "object", 78 | } 79 | schema_instance = StudentSchema(semester="1") 80 | assert str(schema_instance.json()) == '{"id":null,"semester":"1"}' 81 | with pytest.raises(ValidationError): 82 | StudentSchema(semester="something") 83 | 84 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 85 | def test_email_field(self): 86 | class StudentEmailSchema(ModelSchema): 87 | class Config: 88 | model = StudentEmail 89 | include = "__all__" 90 | 91 | print(json.dumps(StudentEmailSchema.schema(), sort_keys=False, indent=4)) 92 | assert StudentEmailSchema.schema() == { 93 | "properties": { 94 | "id": { 95 | "anyOf": [{"type": "integer"}, {"type": "null"}], 96 | "default": None, 97 | "description": "", 98 | "title": "Id", 99 | }, 100 | "email": { 101 | "description": "", 102 | "format": "email", 103 | "title": "Email", 104 | "type": "string", 105 | }, 106 | }, 107 | "required": ["email"], 108 | "title": "StudentEmailSchema", 109 | "type": "object", 110 | } 111 | assert ( 112 | str(StudentEmailSchema(email="email@example.com").json()) 113 | == '{"id":null,"email":"email@example.com"}' 114 | ) 115 | with pytest.raises(ValidationError): 116 | StudentEmailSchema(email="emailexample.com") 117 | -------------------------------------------------------------------------------- /tests/test_v2_pydantic/test_factory.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ninja_schema.orm.factory import SchemaFactory 4 | from ninja_schema.pydanticutils import IS_PYDANTIC_V1 5 | from tests.models import Event 6 | 7 | 8 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 9 | def test_create_schema_with_model_config_options(): 10 | schema = SchemaFactory.create_schema( 11 | Event, 12 | skip_registry=True, 13 | from_attributes=True, # model_config_option 14 | title="Custom Title", # model_config_option 15 | ) 16 | 17 | assert schema.model_config["from_attributes"] is True 18 | assert schema.model_config["title"] == "Custom Title" 19 | -------------------------------------------------------------------------------- /tests/test_v2_pydantic/test_model_schema.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pydantic 4 | import pytest 5 | 6 | from ninja_schema import ModelSchema, SchemaFactory, model_validator 7 | from ninja_schema.errors import ConfigError 8 | from ninja_schema.pydanticutils import IS_PYDANTIC_V1 9 | from tests.models import Event 10 | 11 | 12 | class TestModelSchema: 13 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 14 | def test_schema_include_fields(self): 15 | class EventSchema(ModelSchema): 16 | class Config: 17 | model = Event 18 | include = "__all__" 19 | 20 | print(EventSchema.schema()) 21 | assert EventSchema.schema() == { 22 | "properties": { 23 | "id": { 24 | "anyOf": [{"type": "integer"}, {"type": "null"}], 25 | "default": None, 26 | "description": "", 27 | "title": "Id", 28 | }, 29 | "title": { 30 | "description": "", 31 | "maxLength": 100, 32 | "title": "Title", 33 | "type": "string", 34 | }, 35 | "category_id": { 36 | "anyOf": [{"type": "integer"}, {"type": "null"}], 37 | "default": None, 38 | "description": "", 39 | "title": "Category", 40 | }, 41 | "start_date": { 42 | "description": "", 43 | "format": "date", 44 | "title": "Start Date", 45 | "type": "string", 46 | }, 47 | "end_date": { 48 | "description": "", 49 | "format": "date", 50 | "title": "End Date", 51 | "type": "string", 52 | }, 53 | }, 54 | "required": ["title", "start_date", "end_date"], 55 | "title": "EventSchema", 56 | "type": "object", 57 | } 58 | 59 | class Event2Schema(ModelSchema): 60 | class Config: 61 | model = Event 62 | include = ["title", "start_date", "end_date"] 63 | 64 | print(json.dumps(Event2Schema.schema(), sort_keys=False, indent=4)) 65 | assert Event2Schema.schema() == { 66 | "properties": { 67 | "title": { 68 | "description": "", 69 | "maxLength": 100, 70 | "title": "Title", 71 | "type": "string", 72 | }, 73 | "start_date": { 74 | "description": "", 75 | "format": "date", 76 | "title": "Start Date", 77 | "type": "string", 78 | }, 79 | "end_date": { 80 | "description": "", 81 | "format": "date", 82 | "title": "End Date", 83 | "type": "string", 84 | }, 85 | }, 86 | "required": ["title", "start_date", "end_date"], 87 | "title": "Event2Schema", 88 | "type": "object", 89 | } 90 | 91 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 92 | def test_schema_depth(self): 93 | class EventDepthSchema(ModelSchema): 94 | class Config: 95 | model = Event 96 | include = "__all__" 97 | depth = 1 98 | 99 | print(json.dumps(EventDepthSchema.schema(), sort_keys=False, indent=4)) 100 | assert EventDepthSchema.schema() == { 101 | "$defs": { 102 | "Category": { 103 | "properties": { 104 | "id": { 105 | "anyOf": [{"type": "integer"}, {"type": "null"}], 106 | "default": None, 107 | "description": "", 108 | "title": "Id", 109 | }, 110 | "name": { 111 | "description": "", 112 | "maxLength": 100, 113 | "title": "Name", 114 | "type": "string", 115 | }, 116 | "start_date": { 117 | "description": "", 118 | "format": "date", 119 | "title": "Start Date", 120 | "type": "string", 121 | }, 122 | "end_date": { 123 | "description": "", 124 | "format": "date", 125 | "title": "End Date", 126 | "type": "string", 127 | }, 128 | }, 129 | "required": ["name", "start_date", "end_date"], 130 | "title": "Category", 131 | "type": "object", 132 | } 133 | }, 134 | "properties": { 135 | "id": { 136 | "anyOf": [{"type": "integer"}, {"type": "null"}], 137 | "default": None, 138 | "description": "", 139 | "title": "Id", 140 | }, 141 | "title": { 142 | "description": "", 143 | "maxLength": 100, 144 | "title": "Title", 145 | "type": "string", 146 | }, 147 | "category": { 148 | "anyOf": [{"$ref": "#/$defs/Category"}, {"type": "null"}], 149 | "default": None, 150 | "description": "", 151 | "title": "Category", 152 | }, 153 | "start_date": { 154 | "description": "", 155 | "format": "date", 156 | "title": "Start Date", 157 | "type": "string", 158 | }, 159 | "end_date": { 160 | "description": "", 161 | "format": "date", 162 | "title": "End Date", 163 | "type": "string", 164 | }, 165 | }, 166 | "required": ["title", "start_date", "end_date"], 167 | "title": "EventDepthSchema", 168 | "type": "object", 169 | } 170 | 171 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 172 | def test_schema_exclude_fields(self): 173 | class Event3Schema(ModelSchema): 174 | class Config: 175 | model = Event 176 | exclude = ["id", "category"] 177 | 178 | assert Event3Schema.schema() == { 179 | "properties": { 180 | "title": { 181 | "description": "", 182 | "maxLength": 100, 183 | "title": "Title", 184 | "type": "string", 185 | }, 186 | "start_date": { 187 | "description": "", 188 | "format": "date", 189 | "title": "Start Date", 190 | "type": "string", 191 | }, 192 | "end_date": { 193 | "description": "", 194 | "format": "date", 195 | "title": "End Date", 196 | "type": "string", 197 | }, 198 | }, 199 | "required": ["title", "start_date", "end_date"], 200 | "title": "Event3Schema", 201 | "type": "object", 202 | } 203 | 204 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 205 | def test_schema_optional_fields(self): 206 | class Event4Schema(ModelSchema): 207 | class Config: 208 | model = Event 209 | include = "__all__" 210 | optional = "__all__" 211 | 212 | assert Event4Schema.schema() == { 213 | "properties": { 214 | "id": { 215 | "anyOf": [{"type": "integer"}, {"type": "null"}], 216 | "default": None, 217 | "description": "", 218 | "title": "Id", 219 | }, 220 | "title": { 221 | "anyOf": [{"type": "string"}, {"type": "null"}], 222 | "default": None, 223 | "description": "", 224 | "title": "Title", 225 | }, 226 | "category": { 227 | "anyOf": [{"type": "integer"}, {"type": "null"}], 228 | "default": None, 229 | "description": "", 230 | "title": "Category", 231 | }, 232 | "start_date": { 233 | "anyOf": [{"format": "date", "type": "string"}, {"type": "null"}], 234 | "default": None, 235 | "description": "", 236 | "title": "Start Date", 237 | }, 238 | "end_date": { 239 | "anyOf": [{"format": "date", "type": "string"}, {"type": "null"}], 240 | "default": None, 241 | "description": "", 242 | "title": "End Date", 243 | }, 244 | }, 245 | "title": "Event4Schema", 246 | "type": "object", 247 | } 248 | 249 | class Event5Schema(ModelSchema): 250 | class Config: 251 | model = Event 252 | include = ["id", "title", "start_date"] 253 | optional = [ 254 | "start_date", 255 | ] 256 | 257 | print(Event5Schema.schema()) 258 | assert Event5Schema.schema() == { 259 | "properties": { 260 | "id": {"description": "", "title": "Id", "type": "integer"}, 261 | "title": { 262 | "description": "", 263 | "maxLength": 100, 264 | "title": "Title", 265 | "type": "string", 266 | }, 267 | "start_date": { 268 | "anyOf": [{"format": "date", "type": "string"}, {"type": "null"}], 269 | "default": None, 270 | "description": "", 271 | "title": "Start Date", 272 | }, 273 | }, 274 | "required": ["id", "title"], 275 | "title": "Event5Schema", 276 | "type": "object", 277 | } 278 | 279 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 280 | def test_schema_custom_fields(self): 281 | class Event6Schema(ModelSchema): 282 | custom_field1: str 283 | custom_field2: int = 1 284 | custom_field3: str = "" 285 | __custom_field4 = [] # ignored by pydantic 286 | 287 | class Config: 288 | model = Event 289 | exclude = ["id", "category"] 290 | 291 | assert Event6Schema.schema() == { 292 | "properties": { 293 | "title": { 294 | "description": "", 295 | "maxLength": 100, 296 | "title": "Title", 297 | "type": "string", 298 | }, 299 | "start_date": { 300 | "description": "", 301 | "format": "date", 302 | "title": "Start Date", 303 | "type": "string", 304 | }, 305 | "end_date": { 306 | "description": "", 307 | "format": "date", 308 | "title": "End Date", 309 | "type": "string", 310 | }, 311 | "custom_field1": {"title": "Custom Field1", "type": "string"}, 312 | "custom_field2": { 313 | "default": 1, 314 | "title": "Custom Field2", 315 | "type": "integer", 316 | }, 317 | "custom_field3": { 318 | "default": "", 319 | "title": "Custom Field3", 320 | "type": "string", 321 | }, 322 | }, 323 | "required": ["title", "start_date", "end_date", "custom_field1"], 324 | "title": "Event6Schema", 325 | "type": "object", 326 | } 327 | 328 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 329 | def test_model_validator(self): 330 | class EventSchema(ModelSchema): 331 | class Config: 332 | model = Event 333 | include = [ 334 | "title", 335 | "start_date", 336 | ] 337 | 338 | @model_validator("title") 339 | def validate_title(cls, value): 340 | return f"{value} - value cleaned" 341 | 342 | event = EventSchema(start_date="2021-06-12", title="PyConf 2021") 343 | assert "value cleaned" in event.title 344 | 345 | class Event2Schema(ModelSchema): 346 | custom_field: str 347 | 348 | class Config: 349 | model = Event 350 | include = [ 351 | "title", 352 | "start_date", 353 | ] 354 | 355 | @model_validator("title", "custom_field") 356 | def validate_title(cls, value): 357 | return f"{value} - value cleaned" 358 | 359 | event2 = Event2Schema( 360 | start_date="2021-06-12", 361 | title="PyConf 2021", 362 | custom_field="some custom name", 363 | ) 364 | assert "value cleaned" in event2.title 365 | assert "value cleaned" in event2.custom_field 366 | 367 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 368 | def test_invalid_fields_inputs(self): 369 | with pytest.raises(ConfigError): 370 | 371 | class Event1Schema(ModelSchema): 372 | class Config: 373 | model = Event 374 | include = ["xy", "yz"] 375 | 376 | with pytest.raises(ConfigError): 377 | 378 | class Event2Schema(ModelSchema): 379 | class Config: 380 | model = Event 381 | exclude = ["xy", "yz"] 382 | 383 | with pytest.raises(ConfigError): 384 | 385 | class Event3Schema(ModelSchema): 386 | class Config: 387 | model = Event 388 | optional = ["xy", "yz"] 389 | 390 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 391 | def test_model_validator_not_used(self): 392 | with pytest.raises(pydantic.errors.PydanticUserError): 393 | 394 | class Event1Schema(ModelSchema): 395 | class Config: 396 | model = Event 397 | exclude = [ 398 | "title", 399 | ] 400 | 401 | @model_validator("title") 402 | def validate_title(cls, value): 403 | return f"{value} - value cleaned" 404 | 405 | with pytest.raises(pydantic.errors.PydanticUserError): 406 | 407 | class Event2Schema(ModelSchema): 408 | class Config: 409 | model = Event 410 | include = [ 411 | "title", 412 | ] 413 | 414 | @model_validator("title", "invalid_field") 415 | def validate_title(cls, value): 416 | return f"{value} - value cleaned" 417 | 418 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 419 | def test_factory_functions(self): 420 | event_schema = SchemaFactory.create_schema(model=Event, name="EventSchema") 421 | print(json.dumps(event_schema.schema(), sort_keys=False, indent=4)) 422 | assert event_schema.schema() == { 423 | "properties": { 424 | "id": { 425 | "anyOf": [{"type": "integer"}, {"type": "null"}], 426 | "default": None, 427 | "description": "", 428 | "title": "Id", 429 | }, 430 | "title": { 431 | "description": "", 432 | "maxLength": 100, 433 | "title": "Title", 434 | "type": "string", 435 | }, 436 | "category_id": { 437 | "anyOf": [{"type": "integer"}, {"type": "null"}], 438 | "default": None, 439 | "description": "", 440 | "title": "Category", 441 | }, 442 | "start_date": { 443 | "description": "", 444 | "format": "date", 445 | "title": "Start Date", 446 | "type": "string", 447 | }, 448 | "end_date": { 449 | "description": "", 450 | "format": "date", 451 | "title": "End Date", 452 | "type": "string", 453 | }, 454 | }, 455 | "required": ["title", "start_date", "end_date"], 456 | "title": "EventSchema", 457 | "type": "object", 458 | } 459 | 460 | def get_new_event(self, title): 461 | event = Event(title=title) 462 | event.save() 463 | return event 464 | 465 | @pytest.mark.django_db 466 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 467 | def test_getter_functions(self): 468 | class EventSchema(ModelSchema): 469 | class Config: 470 | model = Event 471 | include = ["title", "category", "id"] 472 | 473 | event = self.get_new_event(title="PyConf") 474 | json_event = EventSchema.from_orm(event) 475 | 476 | assert json_event.dict() == {"id": 1, "title": "PyConf", "category": None} 477 | json_event.title = "PyConf Updated" 478 | 479 | json_event.apply_to_model(event) 480 | assert event.title == "PyConf Updated" 481 | 482 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 483 | def test_abstract_model_schema_does_not_raise_exception_for_incomplete_configuration( 484 | self, 485 | ): 486 | with pytest.raises( 487 | Exception, match="Invalid Configuration. 'model' is required" 488 | ): 489 | 490 | class AbstractModel(ModelSchema): 491 | class Config: 492 | orm_mode = True 493 | 494 | class AbstractBaseModelSchema(ModelSchema): 495 | class Config: 496 | ninja_schema_abstract = True 497 | 498 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 499 | def test_model_validator_with_new_model_config(self): 500 | from pydantic import ConfigDict 501 | 502 | class EventWithNewModelConfig(ModelSchema): 503 | model_config = ConfigDict( 504 | model=Event, 505 | include=[ 506 | "title", 507 | "start_date", 508 | ], 509 | ) 510 | 511 | @model_validator("title") 512 | def validate_title(cls, value): 513 | return f"{value} - value cleaned" 514 | 515 | event = EventWithNewModelConfig(start_date="2021-06-12", title="PyConf 2021") 516 | assert "value cleaned" in event.title 517 | -------------------------------------------------------------------------------- /tests/test_v2_pydantic/test_schema.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | from django.db.models import Manager, QuerySet 6 | from django.db.models.fields.files import ImageFieldFile 7 | from pydantic import Field 8 | 9 | from ninja_schema import Schema 10 | from ninja_schema.pydanticutils import IS_PYDANTIC_V1 11 | 12 | 13 | class FakeManager(Manager): 14 | def __init__(self, items): 15 | self._items = items 16 | 17 | def all(self): 18 | return self._items 19 | 20 | def __str__(self): 21 | return "FakeManager" 22 | 23 | 24 | class FakeQS(QuerySet): 25 | def __init__(self, items): 26 | self._result_cache = items 27 | self._prefetch_related_lookups = False 28 | 29 | def __str__(self): 30 | return "FakeQS" 31 | 32 | 33 | class Tag: 34 | def __init__(self, id, title): 35 | self.id = id 36 | self.title = title 37 | 38 | 39 | # mocking some user: 40 | class User: 41 | name = "John" 42 | group_set = FakeManager([1, 2, 3]) 43 | avatar = ImageFieldFile(None, Mock(), name=None) 44 | 45 | @property 46 | def tags(self): 47 | return FakeQS([Tag(1, "foo"), Tag(2, "bar")]) 48 | 49 | 50 | class TagSchema(Schema): 51 | id: int 52 | title: str 53 | 54 | 55 | class UserSchema(Schema): 56 | name: str 57 | groups: List[int] = Field(..., alias="group_set") 58 | tags: List[TagSchema] 59 | avatar: Optional[str] = None 60 | 61 | 62 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 63 | def test_schema(): 64 | user = User() 65 | schema = UserSchema.from_orm(user) 66 | assert schema.dict() == { 67 | "name": "John", 68 | "groups": [1, 2, 3], 69 | "tags": [{"id": 1, "title": "foo"}, {"id": 2, "title": "bar"}], 70 | "avatar": None, 71 | } 72 | 73 | 74 | @pytest.mark.skipif(IS_PYDANTIC_V1, reason="requires pydantic == 2.1.x") 75 | def test_schema_with_image(): 76 | user = User() 77 | field = Mock() 78 | field.storage.url = Mock(return_value="/smile.jpg") 79 | user.avatar = ImageFieldFile(None, field, name="smile.jpg") 80 | schema = UserSchema.from_orm(user) 81 | assert schema.dict() == { 82 | "name": "John", 83 | "groups": [1, 2, 3], 84 | "tags": [{"id": 1, "title": "foo"}, {"id": 2, "title": "bar"}], 85 | "avatar": "/smile.jpg", 86 | } 87 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [] 2 | --------------------------------------------------------------------------------