From 10a56546f51610c9a2bfa15b10d1e8f8cbb4aef5 Mon Sep 17 00:00:00 2001 From: "kyle.sauder" Date: Mon, 21 Apr 2025 14:01:29 -0400 Subject: [PATCH 01/21] modelschema metaclass and schema factory rework - passing tests --- ninja/orm/factory.py | 104 ++++++++++++------ ninja/orm/fields.py | 8 +- ninja/orm/metaclass.py | 204 ++++++++++++++++++++++-------------- tests/test_orm_metaclass.py | 49 ++++++++- 4 files changed, 251 insertions(+), 114 deletions(-) diff --git a/ninja/orm/factory.py b/ninja/orm/factory.py index 3416df6ec..7e458a0d6 100644 --- a/ninja/orm/factory.py +++ b/ninja/orm/factory.py @@ -1,5 +1,17 @@ import itertools -from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Type, Union, cast +from typing import ( + Any, + Dict, + Iterator, + List, + Literal, + Optional, + Set, + Tuple, + Type, + Union, + cast, +) from django.db.models import Field as DjangoField from django.db.models import ManyToManyRel, ManyToOneRel, Model @@ -38,36 +50,33 @@ def create_schema( *, name: str = "", depth: int = 0, - fields: Optional[List[str]] = None, + fields: Optional[ + Union[List[str], Literal["__all__"]] + ] = "__all__", # should Meta mirror this? exclude: Optional[List[str]] = None, optional_fields: Optional[List[str]] = None, custom_fields: Optional[List[Tuple[str, Any, Any]]] = None, base_class: Type[Schema] = Schema, + primary_key_optional: bool = True, ) -> Type[Schema]: name = name or model.__name__ - if fields and exclude: - raise ConfigError("Only one of 'fields' or 'exclude' should be set.") - key = self.get_key( model, name, depth, fields, exclude, optional_fields, custom_fields ) - if key in self.schemas: - return self.schemas[key] - model_fields_list = list(self._selected_model_fields(model, fields, exclude)) - if optional_fields: - if optional_fields == "__all__": - optional_fields = [f.name for f in model_fields_list] + schema = self.get_schema(key) + if schema is not None: + return schema - definitions = {} - for fld in model_fields_list: - python_type, field_info = get_schema_field( - fld, - depth=depth, - optional=optional_fields and (fld.name in optional_fields), - ) - definitions[fld.name] = (python_type, field_info) + definitions = self.convert_django_fields( + model, + depth=depth, + fields=fields, + exclude=exclude, + optional_fields=optional_fields, + primary_key_optional=primary_key_optional, + ) if custom_fields: for fld_name, python_type, field_info in custom_fields: @@ -78,7 +87,7 @@ def create_schema( if name in self.schema_names: name = self._get_unique_name(name) - schema: Type[Schema] = create_pydantic_model( + schema = create_pydantic_model( name, __config__=None, __base__=base_class, @@ -86,24 +95,55 @@ def create_schema( __validators__={}, **definitions, ) # type: ignore - # __model_name: str, - # *, - # __config__: ConfigDict | None = None, - # __base__: None = None, - # __module__: str = __name__, - # __validators__: dict[str, AnyClassMethod] | None = None, - # __cls_kwargs__: dict[str, Any] | None = None, - # **field_definitions: Any, + self.schemas[key] = schema self.schema_names.add(name) return schema + def get_schema(self, key: SchemaKey) -> Union[Type[Schema], None]: + if key in self.schemas: + return self.schemas[key] + return None + + def convert_django_fields( + self, + model: Type[Model], + *, + depth: int = 0, + fields: Optional[Union[List[str], Literal["__all__"]]] = None, + exclude: Optional[List[str]] = None, + optional_fields: Optional[List[str]] = None, + primary_key_optional: bool = True, + ) -> Dict[str, Tuple[Any, Any]]: + from devtools import debug + + debug(fields, exclude) + if (fields and fields != "__all__") and exclude: + raise ConfigError("Only one of 'fields' or 'exclude' should be set.") + + model_fields_list = list(self._selected_model_fields(model, fields, exclude)) + if optional_fields: + if optional_fields == "__all__": + optional_fields = [f.name for f in model_fields_list] + + definitions = {} + for fld in model_fields_list: + python_type, field_info = get_schema_field( + fld, + depth=depth, + optional=optional_fields and (fld.name in optional_fields), + primary_key_optional=primary_key_optional, + ) + definitions[fld.name] = (python_type, field_info) + + return definitions + def get_key( self, model: Type[Model], name: str, depth: int, - fields: Union[str, List[str], None], + fields: Optional[Union[List[str], Literal["__all__"]]], exclude: Optional[List[str]], optional_fields: Optional[Union[List[str], str]], custom_fields: Optional[List[Tuple[str, str, Any]]], @@ -131,15 +171,19 @@ def _get_unique_name(self, name: str) -> str: def _selected_model_fields( self, model: Type[Model], - fields: Optional[List[str]] = None, + fields: Optional[Union[List[str], Literal["__all__"]]] = None, exclude: Optional[List[str]] = None, ) -> Iterator[DjangoField]: "Returns iterator for model fields based on `exclude` or `fields` arguments" all_fields = {f.name: f for f in self._model_fields(model)} + if fields == "__all__": + fields = None + if not fields and not exclude: for f in all_fields.values(): yield f + return invalid_fields = (set(fields or []) | set(exclude or [])) - all_fields.keys() if invalid_fields: diff --git a/ninja/orm/fields.py b/ninja/orm/fields.py index d67814c8c..4ea9c4f7c 100644 --- a/ninja/orm/fields.py +++ b/ninja/orm/fields.py @@ -115,7 +115,11 @@ def _validate(cls, v: Any, _): @no_type_check def get_schema_field( - field: DjangoField, *, depth: int = 0, optional: bool = False + field: DjangoField, + *, + depth: int = 0, + optional: bool = False, + primary_key_optional: bool = True, ) -> Tuple: "Returns pydantic field from django's model field" alias = None @@ -163,7 +167,7 @@ def get_schema_field( ] raise ConfigError("\n".join(msg)) from e - if field.primary_key or blank or null or optional: + if (field.primary_key and primary_key_optional) or blank or null or optional: default = None nullable = True diff --git a/ninja/orm/metaclass.py b/ninja/orm/metaclass.py index 84605aa69..5b19c7c28 100644 --- a/ninja/orm/metaclass.py +++ b/ninja/orm/metaclass.py @@ -1,68 +1,114 @@ import warnings -from typing import Any, List, Optional, Union, no_type_check +from dataclasses import asdict +from typing import Any, List, Literal, Optional, Type, Union, no_type_check from django.db.models import Model as DjangoModel from pydantic.dataclasses import dataclass from ninja.errors import ConfigError -from ninja.orm.factory import create_schema +from ninja.orm.factory import factory from ninja.schema import ResolverMetaclass, Schema -_is_modelschema_class_defined = False - @dataclass class MetaConf: - model: Any - fields: Optional[List[str]] = None + """ + Mirros the relevant arguments for create_schema + + model: Django model being used to create the Schema + fields: List of field names in the model to use. Defaults to '__all__' which includes all fields + exclude: List of field names to exclude + optional_fields: List of field names which will be optional, can also take '__all__' + depth: If > 0 schema will also be created for the nested ForeignKeys and Many2Many (with the provided depth of lookup) + primary_key_optional: Defaults to True, controls if django's primary_key=True field in the provided model is required + + fields_optional: same as optional_fields, deprecated in order to match `create_schema()` API + """ + + model: Optional[Type[DjangoModel]] = None + fields: Union[List[str], Literal["__all__"], Literal["__UNSET__"], None] = ( + "__UNSET__" + ) exclude: Union[List[str], str, None] = None - fields_optional: Union[List[str], str, None] = None - - @staticmethod - def from_schema_class(name: str, namespace: dict) -> "MetaConf": + optional_fields: Union[List[str], Literal["__all__"], None] = None + depth: int = 0 + primary_key_optional: bool = True + # deprecated + fields_optional: Union[ + List[str], Literal["__all__"], None, Literal["__UNSET__"] + ] = "__UNSET__" + + @classmethod + def from_class_namepace(cls, name: str, namespace: dict) -> Union["MetaConf", None]: + """Check namespace for Meta or Config and create MetaConf from those classes or return None""" + conf = None if "Meta" in namespace: - meta = namespace["Meta"] - model = meta.model - fields = getattr(meta, "fields", None) - exclude = getattr(meta, "exclude", None) - optional_fields = getattr(meta, "fields_optional", None) - + conf = cls.from_meta(namespace["Meta"]) elif "Config" in namespace: - config = namespace["Config"] - model = config.model - fields = getattr(config, "model_fields", None) - exclude = getattr(config, "model_exclude", None) - optional_fields = getattr(config, "model_fields_optional", None) - + conf = cls.from_config(namespace["Config"]) + if not conf: + # No model so this isn't a "ModelSchema" config + return None warnings.warn( "The use of `Config` class is deprecated for ModelSchema, use 'Meta' instead", DeprecationWarning, stacklevel=2, ) - else: - raise ConfigError( - f"ModelSchema class '{name}' requires a 'Meta' (or a 'Config') subclass" - ) + if conf is None: + return None - assert issubclass(model, DjangoModel) + if conf.model: + if not conf.exclude and conf.fields == "__UNSET__": + raise ConfigError("Specify either `exclude` or `fields`") + elif conf.exclude and conf.fields == "__UNSET__": + conf.fields = None - if not fields and not exclude: - raise ConfigError( - "Creating a ModelSchema without either the 'fields' attribute" - " or the 'exclude' attribute is prohibited" + if conf.fields_optional != "__UNSET__": + if conf.optional_fields is not None: + raise ConfigError( + "Specify either `fields_optional` or `optional_fields`. `fields_optional` is deprecated." + ) + warnings.warn( + "The use of `fields_optional` is deprecated. Use `optional_fields` instead to match `create_schema()` API", + DeprecationWarning, + stacklevel=2, ) + conf.optional_fields = conf.fields_optional - if fields == "__all__": - fields = None - # ^ when None is passed to create_schema - all fields are selected + return conf - return MetaConf( - model=model, - fields=fields, - exclude=exclude, - fields_optional=optional_fields, - ) + @staticmethod + def from_config(config: Any) -> Union["MetaConf", None]: + # FIXME: deprecate usage of Config to pass ORM options? + confdict = { + "model": getattr(config, "model", None), + "fields": getattr(config, "model_fields", None), + "exclude": getattr(config, "exclude", None), + "optional_fields": getattr(config, "optional_fields", None), + "depth": getattr(config, "depth", None), + "primary_key_optional": getattr(config, "primary_key_optional", None), + "fields_optional": getattr(config, "fields_optional", None), + } + if not confdict.get("model"): + # this isn't a "ModelSchema" config class + return None + + return MetaConf(**{k: v for k, v in confdict.items() if v is not None}) + + @staticmethod + def from_meta(meta: Any) -> Union["MetaConf", None]: + confdict = { + "model": getattr(meta, "model", None), + "fields": getattr(meta, "fields", None), + "exclude": getattr(meta, "exclude", None), + "optional_fields": getattr(meta, "optional_fields", None), + "depth": getattr(meta, "depth", None), + "primary_key_optional": getattr(meta, "primary_key_optional", None), + "fields_optional": getattr(meta, "fields_optional", None), + } + + return MetaConf(**{k: v for k, v in confdict.items() if v is not None}) class ModelSchemaMetaclass(ResolverMetaclass): @@ -74,6 +120,39 @@ def __new__( namespace: dict, **kwargs, ): + namespace[ + "__ninja_meta__" + ] = {} # there might be a better place than __ninja_meta__? + meta_conf = MetaConf.from_class_namepace(name, namespace) + + if meta_conf: + meta_conf = asdict(meta_conf) + # fields_optional is deprecated + del meta_conf["fields_optional"] + + # update meta_conf with bases + combined = {} + for base in reversed(bases): + combined.update(getattr(base, "__ninja_meta__", {})) + combined.update(**meta_conf) + namespace["__ninja_meta__"] = combined + if namespace["__ninja_meta__"]["model"]: + fields = factory.convert_django_fields(**namespace["__ninja_meta__"]) + for field, val in fields.items(): + # if the field exists on the Schema, we don't overwrite it + if not namespace.get("__annotations__", {}).get(field): + # set type + namespace.setdefault("__annotations__", {})[field] = val[0] + # and default value + namespace[field] = val[1] + + del namespace["Meta"] # clean up the space, might not be needed + + elif name != "ModelSchema": + raise ConfigError( + f"ModelSchema class '{name}' requires a 'Meta' (or a 'Config') subclass" + ) + cls = super().__new__( mcs, name, @@ -81,45 +160,14 @@ def __new__( namespace, **kwargs, ) - for base in reversed(bases): - if ( - _is_modelschema_class_defined - and issubclass(base, ModelSchema) - and base == ModelSchema - ): - meta_conf = MetaConf.from_schema_class(name, namespace) - - custom_fields = [] - annotations = namespace.get("__annotations__", {}) - for attr_name, type in annotations.items(): - if attr_name.startswith("_"): - continue - default = namespace.get(attr_name, ...) - custom_fields.append((attr_name, type, default)) - - # # cls.__doc__ = namespace.get("__doc__", config.model.__doc__) - # cls.__fields__ = {} # forcing pydantic recreate - # # assert False, "!! cls.model_fields" - - # print(config.model, name, fields, exclude, "!!") - - model_schema = create_schema( - meta_conf.model, - name=name, - fields=meta_conf.fields, - exclude=meta_conf.exclude, - optional_fields=meta_conf.fields_optional, - custom_fields=custom_fields, - base_class=cls, - ) - model_schema.__doc__ = cls.__doc__ - return model_schema - return cls class ModelSchema(Schema, metaclass=ModelSchemaMetaclass): - pass - - -_is_modelschema_class_defined = True + @no_type_check + def __new__(cls, *args, **kwargs): + if not cls.__ninja_meta__.get("model"): + raise ConfigError( + f"No model set for class '{cls.__name__}' in the Meta hierarchy" + ) + return super().__new__(cls) diff --git a/tests/test_orm_metaclass.py b/tests/test_orm_metaclass.py index 58838a15e..efa4fa189 100644 --- a/tests/test_orm_metaclass.py +++ b/tests/test_orm_metaclass.py @@ -1,7 +1,8 @@ import pytest from django.db import models +from pydantic import ValidationError -from ninja import ModelSchema +from ninja import ModelSchema, Schema from ninja.errors import ConfigError @@ -14,9 +15,9 @@ class Meta: app_label = "tests" class SampleSchema(ModelSchema): - class Config: + class Meta: model = User - model_fields = ["firstname", "lastname"] + fields = ["firstname", "lastname"] def hello(self): return f"Hello({self.firstname})" @@ -90,7 +91,7 @@ class Category(models.Model): class Meta: app_label = "tests" - with pytest.raises(ConfigError): + with pytest.raises(ConfigError, match="Specify either `exclude` or `fields`"): class CategorySchema(ModelSchema): class Meta: @@ -183,3 +184,43 @@ def test_model_schema_without_config(): class NoConfigSchema(ModelSchema): x: int + + +def test_nondjango_model_error(): + class NonDjangoModel: + field1 = models.CharField() + field2 = models.CharField(blank=True, null=True) + + with pytest.raises( + ValidationError, + match=r"Input should be a subclass of Model \[type=is_subclass_of, input_value=.NonDjangoModel'>, input_type=type\]", + ): + + class SomeSchema(ModelSchema): + class Meta: + model = NonDjangoModel + fields = "__all__" + + +def test_better_inheritance(): + class SomeModel(models.Model): + field1 = models.CharField() + field2 = models.CharField(blank=True, null=True) + + class Meta: + app_label = "tests" + + class ProjectBaseSchema(Schema): + # pydantic defaults and stuff + pass + + class ProjectBaseModelSchema(ModelSchema, ProjectBaseSchema): + # more pydantic modelschema defaults + class Meta: + primary_key_optional = False + + with pytest.raises( + ConfigError, + match="No model set for class 'ProjectBaseModelSchema' in the Meta hierarchy", + ): + ProjectBaseModelSchema() From 37fddd86a6102442a42b04cb590fa451831759c4 Mon Sep 17 00:00:00 2001 From: "kyle.sauder" Date: Mon, 21 Apr 2025 14:01:29 -0400 Subject: [PATCH 02/21] test small inheritance fixes --- ninja/orm/factory.py | 7 +-- ninja/orm/metaclass.py | 20 ++++--- tests/test_orm_metaclass.py | 112 ++++++++++++++++++++++++++++++++++-- 3 files changed, 122 insertions(+), 17 deletions(-) diff --git a/ninja/orm/factory.py b/ninja/orm/factory.py index 7e458a0d6..f7a927ebe 100644 --- a/ninja/orm/factory.py +++ b/ninja/orm/factory.py @@ -50,9 +50,7 @@ def create_schema( *, name: str = "", depth: int = 0, - fields: Optional[ - Union[List[str], Literal["__all__"]] - ] = "__all__", # should Meta mirror this? + fields: Optional[Union[List[str], Literal["__all__"]]] = "__all__", exclude: Optional[List[str]] = None, optional_fields: Optional[List[str]] = None, custom_fields: Optional[List[Tuple[str, Any, Any]]] = None, @@ -115,9 +113,6 @@ def convert_django_fields( optional_fields: Optional[List[str]] = None, primary_key_optional: bool = True, ) -> Dict[str, Tuple[Any, Any]]: - from devtools import debug - - debug(fields, exclude) if (fields and fields != "__all__") and exclude: raise ConfigError("Only one of 'fields' or 'exclude' should be set.") diff --git a/ninja/orm/metaclass.py b/ninja/orm/metaclass.py index 5b19c7c28..12f649615 100644 --- a/ninja/orm/metaclass.py +++ b/ninja/orm/metaclass.py @@ -32,7 +32,7 @@ class MetaConf: exclude: Union[List[str], str, None] = None optional_fields: Union[List[str], Literal["__all__"], None] = None depth: int = 0 - primary_key_optional: bool = True + primary_key_optional: Optional[bool] = None # deprecated fields_optional: Union[ List[str], Literal["__all__"], None, Literal["__UNSET__"] @@ -120,9 +120,8 @@ def __new__( namespace: dict, **kwargs, ): - namespace[ - "__ninja_meta__" - ] = {} # there might be a better place than __ninja_meta__? + # there might be a better place than __ninja_meta__? + namespace["__ninja_meta__"] = {} meta_conf = MetaConf.from_class_namepace(name, namespace) if meta_conf: @@ -133,10 +132,17 @@ def __new__( # update meta_conf with bases combined = {} for base in reversed(bases): - combined.update(getattr(base, "__ninja_meta__", {})) - combined.update(**meta_conf) + combined.update( + **{ + k: v + for k, v in getattr(base, "__ninja_meta__", {}).items() + if v is not None + } + ) + combined.update(**{k: v for k, v in meta_conf.items() if v is not None}) namespace["__ninja_meta__"] = combined - if namespace["__ninja_meta__"]["model"]: + + if namespace["__ninja_meta__"].get("model"): fields = factory.convert_django_fields(**namespace["__ninja_meta__"]) for field, val in fields.items(): # if the field exists on the Schema, we don't overwrite it diff --git a/tests/test_orm_metaclass.py b/tests/test_orm_metaclass.py index efa4fa189..b6965b90a 100644 --- a/tests/test_orm_metaclass.py +++ b/tests/test_orm_metaclass.py @@ -1,6 +1,11 @@ +from typing import Annotated, Optional, TypeVar + import pytest +from devtools import debug from django.db import models -from pydantic import ValidationError +from pydantic import GetJsonSchemaHandler, ValidationError, model_serializer +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import core_schema from ninja import ModelSchema, Schema from ninja.errors import ConfigError @@ -202,6 +207,46 @@ class Meta: fields = "__all__" +class OmissibleClass: + """ + Class for the custom Omissible type to modify the JsonSchemaValue for the field. + """ + + @classmethod + def __get_pydantic_json_schema__( + cls, source: core_schema.CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + output = handler(source) + # FIXME: access by static index + t_type = output["anyOf"][0] + del output["anyOf"] + + assert any(i in t_type.keys() for i in ["type", "$ref"]) + + for key in ["type", "$ref"]: + val = t_type.get(key) + if val is not None: + output[key] = val + break + return output + + +T = TypeVar("T") +Omissible = Annotated[Optional[T], OmissibleClass] + + +def _omissible_serialize(self, handler): + """Delete the key from the dump if the key is Omissible and None""" + dump = handler(self) + for key, field_info in self.model_fields.items(): + metadata = field_info.metadata + for c in metadata: + if dump.get(key) is None and OmissibleClass == c: + del dump[key] + + return dump + + def test_better_inheritance(): class SomeModel(models.Model): field1 = models.CharField() @@ -211,11 +256,10 @@ class Meta: app_label = "tests" class ProjectBaseSchema(Schema): - # pydantic defaults and stuff - pass + _omissible_serialize = model_serializer(mode="wrap")(_omissible_serialize) class ProjectBaseModelSchema(ModelSchema, ProjectBaseSchema): - # more pydantic modelschema defaults + # more pydantic modelschema options class Meta: primary_key_optional = False @@ -224,3 +268,63 @@ class Meta: match="No model set for class 'ProjectBaseModelSchema' in the Meta hierarchy", ): ProjectBaseModelSchema() + + class Intermediate(ProjectBaseModelSchema): + class Meta: + depth = 0 + + with pytest.raises( + ConfigError, + match="No model set for class 'Intermediate' in the Meta hierarchy", + ): + Intermediate() + + class SomeModelSchema(ProjectBaseModelSchema): + field2: Omissible[str] = None + extra: Omissible[str] = None + + class Meta: + model = SomeModel + fields = "__all__" + + assert SomeModelSchema._omissible_serialize + assert not getattr(SomeModelSchema, "Meta", None) + assert SomeModelSchema.__ninja_meta__["model"] == SomeModel + assert SomeModelSchema.__ninja_meta__["fields"] == "__all__" + assert not SomeModelSchema.__ninja_meta__["primary_key_optional"] + + assert len(SomeModelSchema.__annotations__.keys()) == 4 + assert SomeModelSchema.__annotations__["id"] == int + assert SomeModelSchema.__annotations__["field2"] == Omissible[str] + assert SomeModelSchema.__annotations__["extra"] == Omissible[str] + assert SomeModelSchema.__annotations__["field1"] == str + + sms = SomeModelSchema(id=1, field1="char", field2="opt") + debug(sms) + assert sms.json() == '{"field2":"opt","id":1,"field1":"char"}' + assert sms.json_schema() == { + "properties": { + "field2": { + "title": "Field2", + "type": "string", + }, + "extra": { + "title": "Extra", + "type": "string", + }, + "id": { + "title": "ID", + "type": "integer", + }, + "field1": { + "title": "Field1", + "type": "string", + }, + }, + "required": [ + "id", + "field1", + ], + "title": "SomeModelSchema", + "type": "object", + } From ee041ced1c8d9d1f798d6e0cea1a99d213bd729d Mon Sep 17 00:00:00 2001 From: "kyle.sauder" Date: Mon, 21 Apr 2025 14:01:29 -0400 Subject: [PATCH 03/21] remove devtools.debug --- tests/test_orm_metaclass.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_orm_metaclass.py b/tests/test_orm_metaclass.py index b6965b90a..0c2eae5ad 100644 --- a/tests/test_orm_metaclass.py +++ b/tests/test_orm_metaclass.py @@ -1,7 +1,6 @@ from typing import Annotated, Optional, TypeVar import pytest -from devtools import debug from django.db import models from pydantic import GetJsonSchemaHandler, ValidationError, model_serializer from pydantic.json_schema import JsonSchemaValue @@ -300,7 +299,6 @@ class Meta: assert SomeModelSchema.__annotations__["field1"] == str sms = SomeModelSchema(id=1, field1="char", field2="opt") - debug(sms) assert sms.json() == '{"field2":"opt","id":1,"field1":"char"}' assert sms.json_schema() == { "properties": { From dbb25d6c30c60eed755f52af3e213daee7e9efdb Mon Sep 17 00:00:00 2001 From: "kyle.sauder" Date: Mon, 21 Apr 2025 14:01:29 -0400 Subject: [PATCH 04/21] fix Literal import and typos --- ninja/orm/metaclass.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ninja/orm/metaclass.py b/ninja/orm/metaclass.py index 12f649615..6bd919cd9 100644 --- a/ninja/orm/metaclass.py +++ b/ninja/orm/metaclass.py @@ -1,9 +1,10 @@ import warnings from dataclasses import asdict -from typing import Any, List, Literal, Optional, Type, Union, no_type_check +from typing import Any, List, Optional, Type, Union, no_type_check from django.db.models import Model as DjangoModel from pydantic.dataclasses import dataclass +from typing_extensions import Literal from ninja.errors import ConfigError from ninja.orm.factory import factory @@ -13,7 +14,7 @@ @dataclass class MetaConf: """ - Mirros the relevant arguments for create_schema + Mirrors the relevant arguments for create_schema model: Django model being used to create the Schema fields: List of field names in the model to use. Defaults to '__all__' which includes all fields @@ -80,7 +81,6 @@ def from_class_namepace(cls, name: str, namespace: dict) -> Union["MetaConf", No @staticmethod def from_config(config: Any) -> Union["MetaConf", None]: - # FIXME: deprecate usage of Config to pass ORM options? confdict = { "model": getattr(config, "model", None), "fields": getattr(config, "model_fields", None), From a816282c1f94dcd9888953877b5b2d5e6b54ae06 Mon Sep 17 00:00:00 2001 From: "kyle.sauder" Date: Mon, 21 Apr 2025 14:01:29 -0400 Subject: [PATCH 05/21] simplify and clean up metaclass --- ninja/orm/metaclass.py | 170 ++++++++++++++--------------------------- 1 file changed, 57 insertions(+), 113 deletions(-) diff --git a/ninja/orm/metaclass.py b/ninja/orm/metaclass.py index 6bd919cd9..9bfb0d662 100644 --- a/ninja/orm/metaclass.py +++ b/ninja/orm/metaclass.py @@ -1,18 +1,17 @@ import warnings -from dataclasses import asdict -from typing import Any, List, Optional, Type, Union, no_type_check +from inspect import getmembers +from typing import List, Optional, Type, Union, no_type_check from django.db.models import Model as DjangoModel -from pydantic.dataclasses import dataclass -from typing_extensions import Literal +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator +from typing_extensions import Literal, Self from ninja.errors import ConfigError from ninja.orm.factory import factory from ninja.schema import ResolverMetaclass, Schema -@dataclass -class MetaConf: +class MetaConf(BaseModel): """ Mirrors the relevant arguments for create_schema @@ -26,89 +25,40 @@ class MetaConf: fields_optional: same as optional_fields, deprecated in order to match `create_schema()` API """ - model: Optional[Type[DjangoModel]] = None - fields: Union[List[str], Literal["__all__"], Literal["__UNSET__"], None] = ( - "__UNSET__" + model: Optional[Type[DjangoModel]] + # aliased for Config + fields: Union[List[str], Literal["__all__"], None] = Field( + None, validation_alias=AliasChoices("fields", "model_fields") ) - exclude: Union[List[str], str, None] = None + exclude: Optional[List[str]] = None optional_fields: Union[List[str], Literal["__all__"], None] = None depth: int = 0 primary_key_optional: Optional[bool] = None # deprecated - fields_optional: Union[ - List[str], Literal["__all__"], None, Literal["__UNSET__"] - ] = "__UNSET__" - - @classmethod - def from_class_namepace(cls, name: str, namespace: dict) -> Union["MetaConf", None]: - """Check namespace for Meta or Config and create MetaConf from those classes or return None""" - conf = None - if "Meta" in namespace: - conf = cls.from_meta(namespace["Meta"]) - elif "Config" in namespace: - conf = cls.from_config(namespace["Config"]) - if not conf: - # No model so this isn't a "ModelSchema" config - return None - warnings.warn( - "The use of `Config` class is deprecated for ModelSchema, use 'Meta' instead", - DeprecationWarning, - stacklevel=2, - ) + fields_optional: Union[List[str], Literal["__all__"], None] = Field( + default=None, exclude=True + ) - if conf is None: - return None + model_config = ConfigDict(extra="forbid") - if conf.model: - if not conf.exclude and conf.fields == "__UNSET__": + @model_validator(mode="after") + def check_fields(self) -> Self: + if self.model: + if not self.exclude and not self.fields: raise ConfigError("Specify either `exclude` or `fields`") - elif conf.exclude and conf.fields == "__UNSET__": - conf.fields = None - if conf.fields_optional != "__UNSET__": - if conf.optional_fields is not None: + if self.fields_optional: + if self.optional_fields is not None: raise ConfigError( - "Specify either `fields_optional` or `optional_fields`. `fields_optional` is deprecated." + "Use only `optional_fields`, `fields_optional` is deprecated." ) warnings.warn( "The use of `fields_optional` is deprecated. Use `optional_fields` instead to match `create_schema()` API", DeprecationWarning, stacklevel=2, ) - conf.optional_fields = conf.fields_optional - - return conf - - @staticmethod - def from_config(config: Any) -> Union["MetaConf", None]: - confdict = { - "model": getattr(config, "model", None), - "fields": getattr(config, "model_fields", None), - "exclude": getattr(config, "exclude", None), - "optional_fields": getattr(config, "optional_fields", None), - "depth": getattr(config, "depth", None), - "primary_key_optional": getattr(config, "primary_key_optional", None), - "fields_optional": getattr(config, "fields_optional", None), - } - if not confdict.get("model"): - # this isn't a "ModelSchema" config class - return None - - return MetaConf(**{k: v for k, v in confdict.items() if v is not None}) - - @staticmethod - def from_meta(meta: Any) -> Union["MetaConf", None]: - confdict = { - "model": getattr(meta, "model", None), - "fields": getattr(meta, "fields", None), - "exclude": getattr(meta, "exclude", None), - "optional_fields": getattr(meta, "optional_fields", None), - "depth": getattr(meta, "depth", None), - "primary_key_optional": getattr(meta, "primary_key_optional", None), - "fields_optional": getattr(meta, "fields_optional", None), - } - - return MetaConf(**{k: v for k, v in confdict.items() if v is not None}) + self.optional_fields = self.fields_optional + return self class ModelSchemaMetaclass(ResolverMetaclass): @@ -120,45 +70,41 @@ def __new__( namespace: dict, **kwargs, ): - # there might be a better place than __ninja_meta__? - namespace["__ninja_meta__"] = {} - meta_conf = MetaConf.from_class_namepace(name, namespace) + conf_class = None + meta_conf = None - if meta_conf: - meta_conf = asdict(meta_conf) - # fields_optional is deprecated - del meta_conf["fields_optional"] - - # update meta_conf with bases - combined = {} - for base in reversed(bases): - combined.update( - **{ - k: v - for k, v in getattr(base, "__ninja_meta__", {}).items() - if v is not None - } - ) - combined.update(**{k: v for k, v in meta_conf.items() if v is not None}) - namespace["__ninja_meta__"] = combined - - if namespace["__ninja_meta__"].get("model"): - fields = factory.convert_django_fields(**namespace["__ninja_meta__"]) - for field, val in fields.items(): - # if the field exists on the Schema, we don't overwrite it - if not namespace.get("__annotations__", {}).get(field): - # set type - namespace.setdefault("__annotations__", {})[field] = val[0] - # and default value - namespace[field] = val[1] - - del namespace["Meta"] # clean up the space, might not be needed - - elif name != "ModelSchema": - raise ConfigError( - f"ModelSchema class '{name}' requires a 'Meta' (or a 'Config') subclass" + if "Meta" in namespace: + conf_class = namespace["Meta"] + elif "Config" in namespace: + conf_class = namespace["Config"] + warnings.warn( + "The use of `Config` class is deprecated for ModelSchema, use 'Meta' instead", + DeprecationWarning, + stacklevel=2, ) + if conf_class: + conf_dict = { + k: v for k, v in getmembers(conf_class) if not k.startswith("__") + } + meta_conf = MetaConf.model_validate(conf_dict) + + if meta_conf: + meta_conf = meta_conf.model_dump(exclude_none=True) + + fields = factory.convert_django_fields(**meta_conf) + for field, val in fields.items(): + # do not allow field to be defined both in the class and + # explicitly through `Meta.fields` or implicitly through `Meta.excluded` + if namespace.get("__annotations__", {}).get(field): + raise ConfigError( + f"{name}: '{field}' is defined in class body and in Meta.fields or implicitly in Meta.excluded" + ) + # set type + namespace.setdefault("__annotations__", {})[field] = val[0] + # and default value + namespace[field] = val[1] + cls = super().__new__( mcs, name, @@ -172,8 +118,6 @@ def __new__( class ModelSchema(Schema, metaclass=ModelSchemaMetaclass): @no_type_check def __new__(cls, *args, **kwargs): - if not cls.__ninja_meta__.get("model"): - raise ConfigError( - f"No model set for class '{cls.__name__}' in the Meta hierarchy" - ) + if not getattr(getattr(cls, "Meta", {}), "model", None): + raise ConfigError(f"No model set for class '{cls.__name__}'") return super().__new__(cls) From 20314ddffbad5412381286e2c147add5218a453f Mon Sep 17 00:00:00 2001 From: "kyle.sauder" Date: Mon, 21 Apr 2025 14:01:29 -0400 Subject: [PATCH 06/21] test cleanup --- tests/test_orm_metaclass.py | 367 +++++++++++++++++++++++++++--------- 1 file changed, 273 insertions(+), 94 deletions(-) diff --git a/tests/test_orm_metaclass.py b/tests/test_orm_metaclass.py index 0c2eae5ad..aba06ab0c 100644 --- a/tests/test_orm_metaclass.py +++ b/tests/test_orm_metaclass.py @@ -1,12 +1,10 @@ -from typing import Annotated, Optional, TypeVar +from typing import Optional import pytest from django.db import models -from pydantic import GetJsonSchemaHandler, ValidationError, model_serializer -from pydantic.json_schema import JsonSchemaValue -from pydantic_core import core_schema +from pydantic import ValidationError -from ninja import ModelSchema, Schema +from ninja import ModelSchema from ninja.errors import ConfigError @@ -183,12 +181,15 @@ class Meta: def test_model_schema_without_config(): with pytest.raises( ConfigError, - match=r"ModelSchema class 'NoConfigSchema' requires a 'Meta' \(or a 'Config'\) subclass", + match=r"No model set for class 'NoConfigSchema'", ): - + # do not raise on creation of class class NoConfigSchema(ModelSchema): x: int + # instead raise in instantiation + NoConfigSchema(x=1) + def test_nondjango_model_error(): class NonDjangoModel: @@ -206,123 +207,301 @@ class Meta: fields = "__all__" -class OmissibleClass: - """ - Class for the custom Omissible type to modify the JsonSchemaValue for the field. - """ +def test_desired_inheritance(): + class Item(models.Model): + id = models.PositiveIntegerField + slug = models.CharField() - @classmethod - def __get_pydantic_json_schema__( - cls, source: core_schema.CoreSchema, handler: GetJsonSchemaHandler - ) -> JsonSchemaValue: - output = handler(source) - # FIXME: access by static index - t_type = output["anyOf"][0] - del output["anyOf"] + class Meta: + app_label = "tests" - assert any(i in t_type.keys() for i in ["type", "$ref"]) + class ProjectModelSchema(ModelSchema): + _pydantic_config = "config" - for key in ["type", "$ref"]: - val = t_type.get(key) - if val is not None: - output[key] = val - break - return output + class ResourceModelSchema(ProjectModelSchema): + field1: str + class Meta: + model = Item + fields = ["id"] -T = TypeVar("T") -Omissible = Annotated[Optional[T], OmissibleClass] + class ItemModelSchema(ResourceModelSchema): + field2: str + class Meta(ResourceModelSchema.Meta): + model = Item + fields = ["id", "slug"] -def _omissible_serialize(self, handler): - """Delete the key from the dump if the key is Omissible and None""" - dump = handler(self) - for key, field_info in self.model_fields.items(): - metadata = field_info.metadata - for c in metadata: - if dump.get(key) is None and OmissibleClass == c: - del dump[key] + i = ItemModelSchema(id=1, slug="slug", field1="1", field2="2") + assert i._pydantic_config == "config" + assert i.model_json_schema() == { + "properties": { + "field1": { + "title": "Field1", + "type": "string", + }, + "id": { + "anyOf": [ + { + "type": "integer", + }, + { + "type": "null", + }, + ], + "default": None, + "title": "ID", + }, + "field2": { + "title": "Field2", + "type": "string", + }, + "slug": { + "title": "Slug", + "type": "string", + }, + }, + "required": [ + "field1", + "field2", + "slug", + ], + "title": "ItemModelSchema", + "type": "object", + } - return dump +def test_specific_inheritance(): + """https://github.com/vitalik/django-ninja/issues/347""" -def test_better_inheritance(): - class SomeModel(models.Model): - field1 = models.CharField() - field2 = models.CharField(blank=True, null=True) + class Item(models.Model): + id = models.PositiveIntegerField + slug = models.CharField() + name = models.CharField() + image_path = models.CharField() + length_in_mn = models.PositiveIntegerField() + special_field_for_meal = models.CharField() class Meta: app_label = "tests" - class ProjectBaseSchema(Schema): - _omissible_serialize = model_serializer(mode="wrap")(_omissible_serialize) - - class ProjectBaseModelSchema(ModelSchema, ProjectBaseSchema): - # more pydantic modelschema options - class Meta: - primary_key_optional = False - - with pytest.raises( - ConfigError, - match="No model set for class 'ProjectBaseModelSchema' in the Meta hierarchy", - ): - ProjectBaseModelSchema() - - class Intermediate(ProjectBaseModelSchema): - class Meta: - depth = 0 - - with pytest.raises( - ConfigError, - match="No model set for class 'Intermediate' in the Meta hierarchy", - ): - Intermediate() - - class SomeModelSchema(ProjectBaseModelSchema): - field2: Omissible[str] = None - extra: Omissible[str] = None + class ItemBaseModelSchema(ModelSchema): + is_favorite: Optional[bool] = None class Meta: - model = SomeModel - fields = "__all__" + model = Item + fields = [ + "id", + "slug", + "name", + "image_path", + ] + + class ItemInBasesSchema(ItemBaseModelSchema): + class Meta(ItemBaseModelSchema.Meta): + model = Item + fields = ItemBaseModelSchema.Meta.fields + ["length_in_mn"] + + class ItemInMealsSchema(ItemBaseModelSchema): + class Meta(ItemBaseModelSchema.Meta): + model = Item + fields = ItemBaseModelSchema.Meta.fields + [ + "length_in_mn", + "special_field_for_meal", + ] + + ibase = ItemBaseModelSchema( + id=1, + slug="slug", + name="item", + image_path="/images/image.png", + is_favorite=False, + ) + item_inbases = ItemInBasesSchema( + id=2, + slug="slug", + name="item", + image_path="/images/image.png", + is_favorite=False, + length_in_mn=2, + ) + item_inmeals = ItemInMealsSchema( + id=3, + slug="slug", + name="item", + image_path="/images/image.png", + is_favorite=False, + length_in_mn=2, + special_field_for_meal="char", + ) + + assert ( + ibase.model_dump_json() + == '{"is_favorite":false,"id":1,"slug":"slug","name":"item","image_path":"/images/image.png"}' + ) + assert ibase.model_json_schema() == { + "properties": { + "is_favorite": { + "anyOf": [ + { + "type": "boolean", + }, + { + "type": "null", + }, + ], + "default": None, + "title": "Is Favorite", + }, + "id": { + "anyOf": [ + { + "type": "integer", + }, + { + "type": "null", + }, + ], + "default": None, + "title": "ID", + }, + "slug": { + "title": "Slug", + "type": "string", + }, + "name": { + "title": "Name", + "type": "string", + }, + "image_path": { + "title": "Image Path", + "type": "string", + }, + }, + "required": [ + "slug", + "name", + "image_path", + ], + "title": "ItemBaseModelSchema", + "type": "object", + } - assert SomeModelSchema._omissible_serialize - assert not getattr(SomeModelSchema, "Meta", None) - assert SomeModelSchema.__ninja_meta__["model"] == SomeModel - assert SomeModelSchema.__ninja_meta__["fields"] == "__all__" - assert not SomeModelSchema.__ninja_meta__["primary_key_optional"] - - assert len(SomeModelSchema.__annotations__.keys()) == 4 - assert SomeModelSchema.__annotations__["id"] == int - assert SomeModelSchema.__annotations__["field2"] == Omissible[str] - assert SomeModelSchema.__annotations__["extra"] == Omissible[str] - assert SomeModelSchema.__annotations__["field1"] == str - - sms = SomeModelSchema(id=1, field1="char", field2="opt") - assert sms.json() == '{"field2":"opt","id":1,"field1":"char"}' - assert sms.json_schema() == { + assert ( + item_inbases.model_dump_json() + == '{"is_favorite":false,"id":2,"slug":"slug","name":"item","image_path":"/images/image.png","length_in_mn":2}' + ) + assert item_inbases.model_json_schema() == { "properties": { - "field2": { - "title": "Field2", + "is_favorite": { + "anyOf": [ + { + "type": "boolean", + }, + { + "type": "null", + }, + ], + "default": None, + "title": "Is Favorite", + }, + "id": { + "anyOf": [ + { + "type": "integer", + }, + { + "type": "null", + }, + ], + "default": None, + "title": "ID", + }, + "slug": { + "title": "Slug", "type": "string", }, - "extra": { - "title": "Extra", + "name": { + "title": "Name", "type": "string", }, + "image_path": { + "title": "Image Path", + "type": "string", + }, + "length_in_mn": { + "title": "Length In Mn", + "type": "integer", + }, + }, + "required": [ + "slug", + "name", + "image_path", + "length_in_mn", + ], + "title": "ItemInBasesSchema", + "type": "object", + } + + assert ( + item_inmeals.model_dump_json() + == '{"is_favorite":false,"id":3,"slug":"slug","name":"item","image_path":"/images/image.png","length_in_mn":2,"special_field_for_meal":"char"}' + ) + assert item_inmeals.model_json_schema() == { + "properties": { + "is_favorite": { + "anyOf": [ + { + "type": "boolean", + }, + { + "type": "null", + }, + ], + "default": None, + "title": "Is Favorite", + }, "id": { + "anyOf": [ + { + "type": "integer", + }, + { + "type": "null", + }, + ], + "default": None, "title": "ID", + }, + "slug": { + "title": "Slug", + "type": "string", + }, + "name": { + "title": "Name", + "type": "string", + }, + "image_path": { + "title": "Image Path", + "type": "string", + }, + "length_in_mn": { + "title": "Length In Mn", "type": "integer", }, - "field1": { - "title": "Field1", + "special_field_for_meal": { + "title": "Special Field For Meal", "type": "string", }, }, "required": [ - "id", - "field1", + "slug", + "name", + "image_path", + "length_in_mn", + "special_field_for_meal", ], - "title": "SomeModelSchema", + "title": "ItemInMealsSchema", "type": "object", } From 50ecd69872d8f7707b31eb1e693855f80d7c1ae1 Mon Sep 17 00:00:00 2001 From: "kyle.sauder" Date: Mon, 21 Apr 2025 14:01:29 -0400 Subject: [PATCH 07/21] fix Literal import --- ninja/orm/factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ninja/orm/factory.py b/ninja/orm/factory.py index f7a927ebe..52f636526 100644 --- a/ninja/orm/factory.py +++ b/ninja/orm/factory.py @@ -4,7 +4,6 @@ Dict, Iterator, List, - Literal, Optional, Set, Tuple, @@ -16,6 +15,7 @@ from django.db.models import Field as DjangoField from django.db.models import ManyToManyRel, ManyToOneRel, Model from pydantic import create_model as create_pydantic_model +from typing_extensions import Literal from ninja.errors import ConfigError from ninja.orm.fields import get_schema_field From 306c1453b4fee5ddc5b60b8e6bf5e376ff7c2416 Mon Sep 17 00:00:00 2001 From: "kyle.sauder" Date: Mon, 21 Apr 2025 14:01:29 -0400 Subject: [PATCH 08/21] cleanup error branches --- ninja/orm/metaclass.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/ninja/orm/metaclass.py b/ninja/orm/metaclass.py index 9bfb0d662..375842889 100644 --- a/ninja/orm/metaclass.py +++ b/ninja/orm/metaclass.py @@ -43,13 +43,14 @@ class MetaConf(BaseModel): @model_validator(mode="after") def check_fields(self) -> Self: - if self.model: - if not self.exclude and not self.fields: - raise ConfigError("Specify either `exclude` or `fields`") + if self.model and ( + (not self.exclude and not self.fields) or (self.exclude and self.fields) + ): + raise ValueError("Specify either `exclude` or `fields`") if self.fields_optional: if self.optional_fields is not None: - raise ConfigError( + raise ValueError( "Use only `optional_fields`, `fields_optional` is deprecated." ) warnings.warn( @@ -98,7 +99,7 @@ def __new__( # explicitly through `Meta.fields` or implicitly through `Meta.excluded` if namespace.get("__annotations__", {}).get(field): raise ConfigError( - f"{name}: '{field}' is defined in class body and in Meta.fields or implicitly in Meta.excluded" + f"'{field}' is defined in class body and in Meta.fields or implicitly in Meta.excluded" ) # set type namespace.setdefault("__annotations__", {})[field] = val[0] From 174df960c8bc3aba47485d44640bf023c379db17 Mon Sep 17 00:00:00 2001 From: "kyle.sauder" Date: Mon, 21 Apr 2025 14:01:29 -0400 Subject: [PATCH 09/21] test cov 100 --- tests/test_orm_metaclass.py | 50 +++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/tests/test_orm_metaclass.py b/tests/test_orm_metaclass.py index aba06ab0c..610901d2d 100644 --- a/tests/test_orm_metaclass.py +++ b/tests/test_orm_metaclass.py @@ -93,12 +93,49 @@ class Category(models.Model): class Meta: app_label = "tests" - with pytest.raises(ConfigError, match="Specify either `exclude` or `fields`"): + with pytest.raises(ValidationError, match="Specify either `exclude` or `fields`"): - class CategorySchema(ModelSchema): + class CategorySchema1(ModelSchema): class Meta: model = Category + with pytest.raises(ValidationError, match="Specify either `exclude` or `fields`"): + + class CategorySchema2(ModelSchema): + class Meta: + model = Category + exclude = ["title"] + fields = ["title"] + + with pytest.raises( + ValidationError, + match="Use only `optional_fields`, `fields_optional` is deprecated.", + ): + + class CategorySchema3(ModelSchema): + class Meta: + model = Category + fields = "__all__" + fields_optional = ["title"] + optional_fields = ["title"] + + with pytest.raises( + ConfigError, + match="'title' is defined in class body and in Meta.fields or implicitly in Meta.excluded", + ): + + class CategorySchema4(ModelSchema): + title: str + + class Meta: + model = Category + fields = "__all__" + + class CategorySchema5(ModelSchema): + class Config: + model = Category + fields = "__all__" + def test_optional(): class OptModel(models.Model): @@ -179,15 +216,14 @@ class Meta: def test_model_schema_without_config(): + # do not raise on creation of class + class NoConfigSchema(ModelSchema): + x: int + with pytest.raises( ConfigError, match=r"No model set for class 'NoConfigSchema'", ): - # do not raise on creation of class - class NoConfigSchema(ModelSchema): - x: int - - # instead raise in instantiation NoConfigSchema(x=1) From d4eb5c9d8dc871f9609b1eace1366cf14f3cf1c1 Mon Sep 17 00:00:00 2001 From: "kyle.sauder" Date: Mon, 21 Apr 2025 14:01:29 -0400 Subject: [PATCH 10/21] fixed model optional and conf.model check --- ninja/orm/metaclass.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ninja/orm/metaclass.py b/ninja/orm/metaclass.py index 375842889..af1677bc5 100644 --- a/ninja/orm/metaclass.py +++ b/ninja/orm/metaclass.py @@ -25,7 +25,7 @@ class MetaConf(BaseModel): fields_optional: same as optional_fields, deprecated in order to match `create_schema()` API """ - model: Optional[Type[DjangoModel]] + model: Optional[Type[DjangoModel]] = None # aliased for Config fields: Union[List[str], Literal["__all__"], None] = Field( None, validation_alias=AliasChoices("fields", "model_fields") @@ -90,7 +90,7 @@ def __new__( } meta_conf = MetaConf.model_validate(conf_dict) - if meta_conf: + if meta_conf and meta_conf.model: meta_conf = meta_conf.model_dump(exclude_none=True) fields = factory.convert_django_fields(**meta_conf) From 76ea432de862570446b248cb20d71435b292fd0e Mon Sep 17 00:00:00 2001 From: "kyle.sauder" Date: Mon, 21 Apr 2025 14:01:29 -0400 Subject: [PATCH 11/21] add to inheritance usage test --- tests/test_orm_metaclass.py | 40 ++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/tests/test_orm_metaclass.py b/tests/test_orm_metaclass.py index 610901d2d..f50cc8039 100644 --- a/tests/test_orm_metaclass.py +++ b/tests/test_orm_metaclass.py @@ -2,9 +2,9 @@ import pytest from django.db import models -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError -from ninja import ModelSchema +from ninja import ModelSchema, Schema from ninja.errors import ConfigError @@ -245,19 +245,28 @@ class Meta: def test_desired_inheritance(): class Item(models.Model): - id = models.PositiveIntegerField + id = models.PositiveIntegerField(primary_key=True) slug = models.CharField() class Meta: app_label = "tests" - class ProjectModelSchema(ModelSchema): + class ProjectBaseSchema(Schema): + # add any project wide Schema/pydantic configs + _omissible_serialize = ( + "serializer_func" # model_serializer(mode="wrap")(_omissible_serialize) + ) + + class ProjectBaseModelSchema(ModelSchema, ProjectBaseSchema): _pydantic_config = "config" - class ResourceModelSchema(ProjectModelSchema): + class Meta: + primary_key_optional = False + + class ResourceModelSchema(ProjectBaseModelSchema): field1: str - class Meta: + class Meta(ProjectBaseModelSchema.Meta): model = Item fields = ["id"] @@ -268,8 +277,14 @@ class Meta(ResourceModelSchema.Meta): model = Item fields = ["id", "slug"] + assert issubclass(ItemModelSchema, BaseModel) + assert ItemModelSchema.Meta.primary_key_optional is False + i = ItemModelSchema(id=1, slug="slug", field1="1", field2="2") + assert i._pydantic_config == "config" + assert i._omissible_serialize == "serializer_func" + assert i.model_dump_json() == '{"field1":"1","id":1,"field2":"2","slug":"slug"}' assert i.model_json_schema() == { "properties": { "field1": { @@ -277,16 +292,8 @@ class Meta(ResourceModelSchema.Meta): "type": "string", }, "id": { - "anyOf": [ - { - "type": "integer", - }, - { - "type": "null", - }, - ], - "default": None, - "title": "ID", + "type": "integer", + "title": "Id", }, "field2": { "title": "Field2", @@ -299,6 +306,7 @@ class Meta(ResourceModelSchema.Meta): }, "required": [ "field1", + "id", "field2", "slug", ], From 6260e98dfa8d19dcf191819ecb89d050b89eaaac Mon Sep 17 00:00:00 2001 From: "kyle.sauder" Date: Mon, 21 Apr 2025 14:01:29 -0400 Subject: [PATCH 12/21] fix an unnecessary nested if --- ninja/orm/factory.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ninja/orm/factory.py b/ninja/orm/factory.py index 52f636526..44498147f 100644 --- a/ninja/orm/factory.py +++ b/ninja/orm/factory.py @@ -117,9 +117,8 @@ def convert_django_fields( raise ConfigError("Only one of 'fields' or 'exclude' should be set.") model_fields_list = list(self._selected_model_fields(model, fields, exclude)) - if optional_fields: - if optional_fields == "__all__": - optional_fields = [f.name for f in model_fields_list] + if optional_fields and optional_fields == "__all__": + optional_fields = [f.name for f in model_fields_list] definitions = {} for fld in model_fields_list: From 1464b7858e80b5c061e028c75c7a0ac6742575a7 Mon Sep 17 00:00:00 2001 From: Ksauder Date: Wed, 23 Apr 2025 20:32:28 -0400 Subject: [PATCH 13/21] remove Config from ModelSchema --- ninja/orm/metaclass.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/ninja/orm/metaclass.py b/ninja/orm/metaclass.py index af1677bc5..5e028872f 100644 --- a/ninja/orm/metaclass.py +++ b/ninja/orm/metaclass.py @@ -76,13 +76,6 @@ def __new__( if "Meta" in namespace: conf_class = namespace["Meta"] - elif "Config" in namespace: - conf_class = namespace["Config"] - warnings.warn( - "The use of `Config` class is deprecated for ModelSchema, use 'Meta' instead", - DeprecationWarning, - stacklevel=2, - ) if conf_class: conf_dict = { From 8cbbc258af49641e3f2a8a42225588ee8f968ffa Mon Sep 17 00:00:00 2001 From: Ksauder Date: Wed, 23 Apr 2025 20:37:27 -0400 Subject: [PATCH 14/21] update tests --- tests/test_orm_metaclass.py | 57 +++++++++++++++++++++++++++++++++---- tests/test_schema.py | 23 +++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/tests/test_orm_metaclass.py b/tests/test_orm_metaclass.py index f50cc8039..375e7c67a 100644 --- a/tests/test_orm_metaclass.py +++ b/tests/test_orm_metaclass.py @@ -330,6 +330,7 @@ class Meta: app_label = "tests" class ItemBaseModelSchema(ModelSchema): + model_config = {"title": "Item Title"} is_favorite: Optional[bool] = None class Meta: @@ -342,14 +343,19 @@ class Meta: ] class ItemInBasesSchema(ItemBaseModelSchema): + class Config: + allow_inf_nan = True + class Meta(ItemBaseModelSchema.Meta): model = Item fields = ItemBaseModelSchema.Meta.fields + ["length_in_mn"] - class ItemInMealsSchema(ItemBaseModelSchema): - class Meta(ItemBaseModelSchema.Meta): + class ItemInMealsSchema(ItemInBasesSchema): + model_config = {"validate_default": True} + + class Meta: model = Item - fields = ItemBaseModelSchema.Meta.fields + [ + fields = ItemInBasesSchema.Meta.fields + [ "length_in_mn", "special_field_for_meal", ] @@ -379,6 +385,7 @@ class Meta(ItemBaseModelSchema.Meta): special_field_for_meal="char", ) + assert ibase.model_config["title"] == "Item Title" assert ( ibase.model_dump_json() == '{"is_favorite":false,"id":1,"slug":"slug","name":"item","image_path":"/images/image.png"}' @@ -427,10 +434,11 @@ class Meta(ItemBaseModelSchema.Meta): "name", "image_path", ], - "title": "ItemBaseModelSchema", + "title": "Item Title", "type": "object", } + assert item_inbases.model_config["allow_inf_nan"] == True # noqa: E712 assert ( item_inbases.model_dump_json() == '{"is_favorite":false,"id":2,"slug":"slug","name":"item","image_path":"/images/image.png","length_in_mn":2}' @@ -484,10 +492,12 @@ class Meta(ItemBaseModelSchema.Meta): "image_path", "length_in_mn", ], - "title": "ItemInBasesSchema", + "title": "Item Title", "type": "object", } + assert item_inmeals.model_config["allow_inf_nan"] == True # noqa: E712 + assert item_inmeals.model_config["validate_default"] == True # noqa: E712 assert ( item_inmeals.model_dump_json() == '{"is_favorite":false,"id":3,"slug":"slug","name":"item","image_path":"/images/image.png","length_in_mn":2,"special_field_for_meal":"char"}' @@ -546,6 +556,41 @@ class Meta(ItemBaseModelSchema.Meta): "length_in_mn", "special_field_for_meal", ], - "title": "ItemInMealsSchema", + "title": "Item Title", "type": "object", } + + +def test_pydantic_config_inheritance(): + class User(models.Model): + firstname = models.CharField() + lastname = models.CharField(blank=True, null=True) + + class Meta: + app_label = "tests" + + class Grandparent(ModelSchema): + grandparent: str + + class Config: + grandparent = "gpa" + + class Parent(Grandparent): + parent: str + + class Config: + parent = "parent" + + class Child(Parent): + model_config = {"child": True} + child: str + + class Meta: + model = User + fields = "__all__" + + c = Child(firstname="user", lastname="name", grandparent="1", parent="2", child="3") + + assert c.model_config["child"] + assert c.model_config["parent"] + assert c.model_config["grandparent"] diff --git a/tests/test_schema.py b/tests/test_schema.py index 28b2ad09a..60ce72645 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -227,3 +227,26 @@ class ValidateAssignmentSchema(Schema): assert schema_inst.str_var == 5 except ValidationError as ve: raise AssertionError() from ve + + +def test_schema_config_inheritance(): + class Grandparent(Schema): + model_config = {"grandparent": "gpa"} + grandparent: str + + class Parent(Grandparent): + parent: str + + class Config: + parent = "parent" + + class Child(Parent): + model_config = {"child": True} + child: str + + c = Child(grandparent="1", parent="2", child="3") + + assert Grandparent.model_config["from_attributes"] + assert c.model_config["child"] == True # noqa: E712 + assert c.model_config["parent"] == "parent" + assert c.model_config["grandparent"] == "gpa" From 0cf6747d417a219bb0b4b6ce5c98a021867f7993 Mon Sep 17 00:00:00 2001 From: Ksauder Date: Thu, 24 Apr 2025 09:50:03 -0400 Subject: [PATCH 15/21] update some docs --- docs/docs/guides/response/config-pydantic.md | 11 +++-- docs/docs/guides/response/django-pydantic.md | 45 ++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/docs/docs/guides/response/config-pydantic.md b/docs/docs/guides/response/config-pydantic.md index eabe8f984..1d5ec524e 100644 --- a/docs/docs/guides/response/config-pydantic.md +++ b/docs/docs/guides/response/config-pydantic.md @@ -38,11 +38,14 @@ Keep in mind that when you want modify output for field names (like camel case) ```python hl_lines="6 9" class UserSchema(ModelSchema): class Config: - model = User - model_fields = ["id", "email", "is_staff"] + """Pydantic config""" alias_generator = to_camel populate_by_name = True # !!!!!! <-------- - + + class Meta: + """ModelSchema config""" + model = User + model_fields = ["id", "email", "is_staff"] @api.get("/users", response=list[UserSchema], by_alias=True) # !!!!!! <-------- by_alias def get_users(request): @@ -84,6 +87,6 @@ BaseUserSchema = create_schema(User) class UserSchema(BaseUserSchema): - class Config(BaseUserSchema.Config): + class Config: ... ``` diff --git a/docs/docs/guides/response/django-pydantic.md b/docs/docs/guides/response/django-pydantic.md index 6ab7e7ced..a7ab1dad4 100644 --- a/docs/docs/guides/response/django-pydantic.md +++ b/docs/docs/guides/response/django-pydantic.md @@ -28,6 +28,25 @@ class UserSchema(ModelSchema): # last_name: str ``` +The `Meta` class is only used for configuring the interaction between the django model and the underlying +pydantic model. To configure the pydantic model (or `Schema` as it's been abstracted in `django-ninja`), +define a `class Config` or `model_config` in your `ModelSchema` class. + +```Python +class UserSlimGetSchema(ModelSchema): + # pydantic config + # -- + model_config = {"validate_default": True} + # OR + class Config: + validate_default = True + # -- + + class Meta: + model = User + fields = ["id", "name"] +``` + ### Using ALL model fields To use all fields from a model - you can pass `__all__` to `fields`: @@ -174,3 +193,29 @@ def modify_data(request, pk: int, payload: PatchDict[GroupSchema]): ``` in this example the `payload` argument will be a type of `dict` only fields that were passed in request and validated using `GroupSchema` + + +### Inheritance + +ModelSchemas can utilize inheritance. The `Meta` class is not inherited implicitly and must have an explicit parent if desired. + +```Python +class ProjectBaseSchema(Schema): + # global pydantic config, hooks, etc + model_config = {} + +class ProjectBaseModelSchema(ModelSchema, ProjectBaseSchema): + + class Meta: + primary_key_optional = False + +class UserSlimGetSchema(ProjectBaseModelSchema): + class Meta(ProjectBaseModelSchema.Meta): + model = User + fields = ["id", "username"] + +class UserFullGetSchema(UserSlimGetSchema): + class Meta(UserSlimGetSchema.Meta): + model = Item + fields = ["id", "slug"] +``` \ No newline at end of file From 3aa8aaa5a7ba7a0e7cb83527f17c206640dfc519 Mon Sep 17 00:00:00 2001 From: Ksauder Date: Fri, 25 Apr 2025 00:13:40 -0400 Subject: [PATCH 16/21] tighten up field overwriting --- ninja/orm/metaclass.py | 20 ++++++++++++++++++++ tests/test_orm_metaclass.py | 14 ++++---------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/ninja/orm/metaclass.py b/ninja/orm/metaclass.py index 5e028872f..fdc8aadef 100644 --- a/ninja/orm/metaclass.py +++ b/ninja/orm/metaclass.py @@ -84,6 +84,12 @@ def __new__( meta_conf = MetaConf.model_validate(conf_dict) if meta_conf and meta_conf.model: + existing_annotations_keys = set() + for base in bases: + existing_annotations_keys |= set( + getattr(base, "__annotations__", {}).keys() + ) + meta_conf = meta_conf.model_dump(exclude_none=True) fields = factory.convert_django_fields(**meta_conf) @@ -94,6 +100,20 @@ def __new__( raise ConfigError( f"'{field}' is defined in class body and in Meta.fields or implicitly in Meta.excluded" ) + # NOTE: the check below disables the ability to explicitly declare all fields on Model child schemas + # class ItemSlimSchema(ModelSchema): + # class Meta: + # model = Item + # fields = ["id", "name"] + # + # class ItemSchema(ItemSlimSchema): + # class Meta(ItemSlimSchema.Meta): + # fields = ["type", "desc"] <-- will work with inheritting from the parent.Meta, other fields already exist + # fields = ["id", "name", "type", "desc"] <-- won't work, inheriting from parent.Meta or not + if field in existing_annotations_keys: + raise ConfigError( + f"Field {field} from model {meta_conf['model']} already exists in the Schema" + ) # set type namespace.setdefault("__annotations__", {})[field] = val[0] # and default value diff --git a/tests/test_orm_metaclass.py b/tests/test_orm_metaclass.py index 375e7c67a..e4c6da6f7 100644 --- a/tests/test_orm_metaclass.py +++ b/tests/test_orm_metaclass.py @@ -274,8 +274,7 @@ class ItemModelSchema(ResourceModelSchema): field2: str class Meta(ResourceModelSchema.Meta): - model = Item - fields = ["id", "slug"] + fields = ["slug"] assert issubclass(ItemModelSchema, BaseModel) assert ItemModelSchema.Meta.primary_key_optional is False @@ -347,18 +346,13 @@ class Config: allow_inf_nan = True class Meta(ItemBaseModelSchema.Meta): - model = Item - fields = ItemBaseModelSchema.Meta.fields + ["length_in_mn"] + fields = ["length_in_mn"] class ItemInMealsSchema(ItemInBasesSchema): model_config = {"validate_default": True} - class Meta: - model = Item - fields = ItemInBasesSchema.Meta.fields + [ - "length_in_mn", - "special_field_for_meal", - ] + class Meta(ItemInBasesSchema.Meta): + fields = ["special_field_for_meal"] ibase = ItemBaseModelSchema( id=1, From 8f1a2d843f47d6c8a942556f1aefc285a601a904 Mon Sep 17 00:00:00 2001 From: Ksauder Date: Fri, 25 Apr 2025 21:39:27 -0400 Subject: [PATCH 17/21] MetaConf ValidationError -> ConfigError --- ninja/orm/metaclass.py | 18 ++++++++++++++---- tests/test_orm_metaclass.py | 10 +++++----- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/ninja/orm/metaclass.py b/ninja/orm/metaclass.py index fdc8aadef..9ff547a03 100644 --- a/ninja/orm/metaclass.py +++ b/ninja/orm/metaclass.py @@ -3,7 +3,14 @@ from typing import List, Optional, Type, Union, no_type_check from django.db.models import Model as DjangoModel -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + ValidationError, + model_validator, +) from typing_extensions import Literal, Self from ninja.errors import ConfigError @@ -46,11 +53,11 @@ def check_fields(self) -> Self: if self.model and ( (not self.exclude and not self.fields) or (self.exclude and self.fields) ): - raise ValueError("Specify either `exclude` or `fields`") + raise ConfigError("Specify either `exclude` or `fields`") if self.fields_optional: if self.optional_fields is not None: - raise ValueError( + raise ConfigError( "Use only `optional_fields`, `fields_optional` is deprecated." ) warnings.warn( @@ -81,7 +88,10 @@ def __new__( conf_dict = { k: v for k, v in getmembers(conf_class) if not k.startswith("__") } - meta_conf = MetaConf.model_validate(conf_dict) + try: + meta_conf = MetaConf.model_validate(conf_dict) + except ValidationError as ve: + raise ConfigError(str(ve)) from ve if meta_conf and meta_conf.model: existing_annotations_keys = set() diff --git a/tests/test_orm_metaclass.py b/tests/test_orm_metaclass.py index e4c6da6f7..565cf701b 100644 --- a/tests/test_orm_metaclass.py +++ b/tests/test_orm_metaclass.py @@ -2,7 +2,7 @@ import pytest from django.db import models -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel from ninja import ModelSchema, Schema from ninja.errors import ConfigError @@ -93,13 +93,13 @@ class Category(models.Model): class Meta: app_label = "tests" - with pytest.raises(ValidationError, match="Specify either `exclude` or `fields`"): + with pytest.raises(ConfigError, match="Specify either `exclude` or `fields`"): class CategorySchema1(ModelSchema): class Meta: model = Category - with pytest.raises(ValidationError, match="Specify either `exclude` or `fields`"): + with pytest.raises(ConfigError, match="Specify either `exclude` or `fields`"): class CategorySchema2(ModelSchema): class Meta: @@ -108,7 +108,7 @@ class Meta: fields = ["title"] with pytest.raises( - ValidationError, + ConfigError, match="Use only `optional_fields`, `fields_optional` is deprecated.", ): @@ -233,7 +233,7 @@ class NonDjangoModel: field2 = models.CharField(blank=True, null=True) with pytest.raises( - ValidationError, + ConfigError, match=r"Input should be a subclass of Model \[type=is_subclass_of, input_value=.NonDjangoModel'>, input_type=type\]", ): From a730d4b6bba9b6823482135d4b58b0958637c448 Mon Sep 17 00:00:00 2001 From: Ksauder Date: Fri, 25 Apr 2025 22:47:02 -0400 Subject: [PATCH 18/21] add doc example test --- tests/test_doc_examples.py | 156 +++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/test_doc_examples.py diff --git a/tests/test_doc_examples.py b/tests/test_doc_examples.py new file mode 100644 index 000000000..a7359e647 --- /dev/null +++ b/tests/test_doc_examples.py @@ -0,0 +1,156 @@ +def test_inheritance_example(): + """django-pydantic.md""" + from django.db import models + from pydantic import model_serializer + + from ninja import ModelSchema, Schema + + # + def _my_magic_serializer(self, handler): + dump = handler(self) + dump["magic"] = "shazam" + return dump + + class ProjSchema(Schema): + # pydantic configuration + _my_magic_serilizer = model_serializer(mode="wrap")(_my_magic_serializer) + model_config = {"arbitrary_types_allowed": True} + + class ProjModelSchema(ProjSchema, ModelSchema): + # ModelSchema specific configuration + pass + + class ProjMeta: + # ModelSchema Meta defaults + primary_key_optional = False + + # + + # + class Item(models.Model): + name = models.CharField(max_length=64) + type = models.CharField(max_length=64) + desc = models.CharField(max_length=255, blank=True, null=True) + + class Meta: + app_label = "test" + + class Event(models.Model): + name = models.CharField(max_length=64) + action = models.CharField(max_length=64) + + class Meta: + app_label = "test" + + # + + # + # All schemas will be using the configuration defined in parent Schemas + class ItemSlimGetSchema(ProjModelSchema): + class Meta(ProjMeta): + model = Item + fields = ["id", "name"] + + class ItemGetSchema(ItemSlimGetSchema): + class Meta(ItemSlimGetSchema.Meta): + # inherits model, and the parents fields are already set in __annotations__ + fields = ["type", "desc"] + + class EventGetSchema(ProjModelSchema): + class Meta(ProjMeta): + model = Event + fields = ["id", "name"] + + class ItemSummarySchema(ProjSchema): + model_config = { + # extra pydantic config + "title": "Item Summary" + } + name: str + event: EventGetSchema + item: ItemGetSchema + + # + item = Item(id=1, name="test", type="amazing", desc=None) + event = Event(id=1, name="event", action="testing") + summary = ItemSummarySchema(name="summary", event=event, item=item) + + assert summary.json_schema() == { + "$defs": { + "EventGetSchema": { + "properties": { + "id": { + "title": "ID", + "type": "integer", + }, + "name": { + "maxLength": 64, + "title": "Name", + "type": "string", + }, + }, + "required": [ + "id", + "name", + ], + "title": "EventGetSchema", + "type": "object", + }, + "ItemGetSchema": { + "properties": { + "id": { + "title": "ID", + "type": "integer", + }, + "name": { + "maxLength": 64, + "title": "Name", + "type": "string", + }, + "type": { + "maxLength": 64, + "title": "Type", + "type": "string", + }, + "desc": { + "anyOf": [ + { + "maxLength": 255, + "type": "string", + }, + { + "type": "null", + }, + ], + "title": "Desc", + }, + }, + "required": [ + "id", + "name", + "type", + ], + "title": "ItemGetSchema", + "type": "object", + }, + }, + "properties": { + "name": { + "title": "Name", + "type": "string", + }, + "event": { + "$ref": "#/$defs/EventGetSchema", + }, + "item": { + "$ref": "#/$defs/ItemGetSchema", + }, + }, + "required": [ + "name", + "event", + "item", + ], + "title": "Item Summary", + "type": "object", + } From 60a02de59b09e8ca96b68f2999376199f062475d Mon Sep 17 00:00:00 2001 From: Ksauder Date: Fri, 25 Apr 2025 23:39:14 -0400 Subject: [PATCH 19/21] cleanup --- ninja/orm/metaclass.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ninja/orm/metaclass.py b/ninja/orm/metaclass.py index 9ff547a03..a12248535 100644 --- a/ninja/orm/metaclass.py +++ b/ninja/orm/metaclass.py @@ -78,13 +78,10 @@ def __new__( namespace: dict, **kwargs, ): - conf_class = None meta_conf = None if "Meta" in namespace: conf_class = namespace["Meta"] - - if conf_class: conf_dict = { k: v for k, v in getmembers(conf_class) if not k.startswith("__") } @@ -110,7 +107,7 @@ def __new__( raise ConfigError( f"'{field}' is defined in class body and in Meta.fields or implicitly in Meta.excluded" ) - # NOTE: the check below disables the ability to explicitly declare all fields on Model child schemas + # NOTE: the check below disables the ability to declare any already existing fields on ModelSchema children # class ItemSlimSchema(ModelSchema): # class Meta: # model = Item @@ -119,6 +116,7 @@ def __new__( # class ItemSchema(ItemSlimSchema): # class Meta(ItemSlimSchema.Meta): # fields = ["type", "desc"] <-- will work with inheritting from the parent.Meta, other fields already exist + # on the underlying pydantic model # fields = ["id", "name", "type", "desc"] <-- won't work, inheriting from parent.Meta or not if field in existing_annotations_keys: raise ConfigError( From d7b0cb08715975da4718b98d8eecb675ee52ee8f Mon Sep 17 00:00:00 2001 From: Ksauder Date: Fri, 25 Apr 2025 23:47:07 -0400 Subject: [PATCH 20/21] doc updates --- docs/docs/guides/response/config-pydantic.md | 24 ++-- .../response/django-pydantic-create-schema.md | 5 +- docs/docs/guides/response/django-pydantic.md | 123 ++++++++++++++++-- 3 files changed, 128 insertions(+), 24 deletions(-) diff --git a/docs/docs/guides/response/config-pydantic.md b/docs/docs/guides/response/config-pydantic.md index 1d5ec524e..c79f5aff1 100644 --- a/docs/docs/guides/response/config-pydantic.md +++ b/docs/docs/guides/response/config-pydantic.md @@ -1,7 +1,10 @@ # Overriding Pydantic Config There are many customizations available for a **Django Ninja `Schema`**, via the schema's -[Pydantic `Config` class](https://pydantic-docs.helpmanual.io/usage/model_config/). +[Pydantic `Config` class/`model_config` dict attr](https://pydantic-docs.helpmanual.io/usage/model_config/). + +!!! Warning + Using a `Config` class is [deprecated in pydantic v2](https://docs.pydantic.dev/latest/concepts/config/). !!! info Under the hood **Django Ninja** uses [Pydantic Models](https://pydantic-docs.helpmanual.io/usage/models/) @@ -22,17 +25,14 @@ def to_camel(string: str) -> str: words = string.split('_') return words[0].lower() + ''.join(word.capitalize() for word in words[1:]) -class CamelModelSchema(Schema): +class CamelSchema(Schema): str_field_name: str float_field_name: float - class Config(Schema.Config): + class Config: alias_generator = to_camel ``` -!!! note - When overriding the schema's `Config`, it is necessary to inherit from the base `Config` class. - Keep in mind that when you want modify output for field names (like camel case) - you need to set as well `populate_by_name` and `by_alias` ```python hl_lines="6 9" @@ -75,9 +75,11 @@ results: ## Custom Config from Django Model When using [`create_schema`](django-pydantic-create-schema.md#create_schema), the resulting -schema can be used to build another class with a custom config like: +schema can be used to build another class with a custom config and/or more keys. However, +the model and fields being used with the model cannot be modified as this would require +an explicit `Meta` config class to be defined and inherited from. -```python hl_lines="10" +```python hl_lines="9" from django.contrib.auth.models import User from ninja.orm import create_schema @@ -86,7 +88,7 @@ BaseUserSchema = create_schema(User) class UserSchema(BaseUserSchema): - - class Config: - ... + model_config = ConfigDict() # any necessary pydantic config + related_item: RelatedItemModelSchema + stringkey: str ``` diff --git a/docs/docs/guides/response/django-pydantic-create-schema.md b/docs/docs/guides/response/django-pydantic-create-schema.md index f33c7bac6..08eef2ee2 100644 --- a/docs/docs/guides/response/django-pydantic-create-schema.md +++ b/docs/docs/guides/response/django-pydantic-create-schema.md @@ -1,7 +1,7 @@ # Using create_schema -Under the hood, [`ModelSchema`](django-pydantic.md#modelschema) uses the `create_schema` function. -This is a more advanced (and less safe) method - please use it carefully. +This is a more advanced (and less safe) method - please use it carefully. It is usually better +to create `ModelSchema` classes with `Meta` configs explicitly. ## `create_schema` @@ -19,7 +19,6 @@ def create_schema( ) ``` - Take this example: ```python hl_lines="2 4" diff --git a/docs/docs/guides/response/django-pydantic.md b/docs/docs/guides/response/django-pydantic.md index a7ab1dad4..a02ea49f9 100644 --- a/docs/docs/guides/response/django-pydantic.md +++ b/docs/docs/guides/response/django-pydantic.md @@ -5,10 +5,25 @@ Schemas are very useful to define your validation rules and responses, but somet ## ModelSchema -`ModelSchema` is a special base class that can automatically generate schemas from your models. +`ModelSchema` is a special base class that can automatically generate schemas from your models. Under the hood it converts your models Django fields into +pydantic type annotations. `ModelSchema` inherits from `Schema`, and is just a `Schema` with a Django field -> pydantic field conversion step. All other `Schema` +related configuration and inheritance is the same. -All you need is to set `model` and `fields` attributes on your schema `Meta`: +### Configuration + +To configure a `ModelSchema` you define a `Meta` class attribute just like in Django. This `Meta` class will be validated by `ninja.orm.metaclass.MetaConf`. + +```Python +class MetaConf: # summary + model: Django model being used to create the Schema + fields: List of field names in the model to use. Defaults to '__all__' which includes all fields + exclude: List of field names to exclude + optional_fields: List of field names which will be optional, can also take '__all__' + depth: If > 0 schema will also be created for the nested ForeignKeys and Many2Many (with the provided depth of lookup) + primary_key_optional: Defaults to True, controls if django's primary_key=True field in the provided model is required +``` +All you need is to set `model` and `fields` attributes on your schema `Meta`: ```python hl_lines="2 5 6 7" from django.contrib.auth.models import User @@ -28,9 +43,11 @@ class UserSchema(ModelSchema): # last_name: str ``` +### Non-Django Model Configuration + The `Meta` class is only used for configuring the interaction between the django model and the underlying -pydantic model. To configure the pydantic model (or `Schema` as it's been abstracted in `django-ninja`), -define a `class Config` or `model_config` in your `ModelSchema` class. +`Schema`. To configure the pydantic model underlying the `Schema` define, `model_config` in your +`ModelSchema` class, or [use the deprecated by pydantic `class Config`](https://docs.pydantic.dev/latest/concepts/config/). ```Python class UserSlimGetSchema(ModelSchema): @@ -47,6 +64,96 @@ class UserSlimGetSchema(ModelSchema): fields = ["id", "name"] ``` +### Inheritance + +Because a `ModelSchema` is just a child of `Schema`, which is in turn just a child of pydantic `BaseModel`, you +can do some convenient inheritance to handle more advanced configuration scenarios. + +!!! Warning + Beware that pydantic v2 does not always respect MRO: https://github.com/pydantic/pydantic/issues/9992 + +```python + from ninja import Schema, ModelSchema + from pydantic import model_serializer + from django.db import models + + # + def _my_magic_serializer(self, handler): + dump = handler(self) + dump["magic"] = "shazam" + return dump + + + class ProjSchema(Schema): + # pydantic configuration + _my_magic_serilizer = model_serializer(mode="wrap")(_my_magic_serializer) + model_config = {"arbitrary_types_allowed": True} + + + class ProjModelSchema(ProjSchema, ModelSchema): + # ModelSchema specific configuration + pass + + + class ProjMeta: + # ModelSchema Meta defaults + primary_key_optional = False + + # + + + # + class Item(models.Model): + name = models.CharField(max_length=64) + type = models.CharField(max_length=64) + desc = models.CharField(max_length=255, blank=True, null=True) + + class Meta: + app_label = "test" + + + class Event(models.Model): + name = models.CharField(max_length=64) + action = models.CharField(max_length=64) + + class Meta: + app_label = "test" + + # + + + # + # All schemas will be using the configuration defined in parent Schemas + class ItemSlimGetSchema(ProjModelSchema): + class Meta(ProjMeta): + model = Item + fields = ["id", "name"] + + + class ItemGetSchema(ItemSlimGetSchema): + class Meta(ItemSlimGetSchema.Meta): + # inherits model, and the parents fields are already set in __annotations__ + fields = ["type", "desc"] + + + class EventGetSchema(ProjModelSchema): + class Meta(ProjMeta): + model = Event + fields = ["id", "name"] + + + class ItemSummarySchema(ProjSchema): + model_config = { + "title": "Item Summary" + } + name: str + event: EventGetSchema + item: ItemGetSchema + + # +``` + + ### Using ALL model fields To use all fields from a model - you can pass `__all__` to `fields`: @@ -89,7 +196,8 @@ class UserSchema(ModelSchema): ### Overriding fields -To change default annotation for some field, or to add a new field, just use annotated attributes as usual. +To change default annotation for some field, or to add a new field, just use annotated attributes as usual since a `ModelSchema` is +in the end just a `Schema`. ```python hl_lines="1 2 3 4 8" class GroupSchema(ModelSchema): @@ -141,11 +249,8 @@ def patch(request, pk: int, payload: PatchGroupSchema): setattr(obj, attr, value) obj.save() - - ``` - ### Custom fields types For each Django field it encounters, `ModelSchema` uses the default `Field.get_internal_type` method @@ -163,7 +268,6 @@ class MyModel(models.Modle): from ninja.orm import register_field register_field('VectorField', list[float]) - ``` #### PatchDict @@ -189,7 +293,6 @@ def modify_data(request, pk: int, payload: PatchDict[GroupSchema]): setattr(obj, attr, value) obj.save() - ``` in this example the `payload` argument will be a type of `dict` only fields that were passed in request and validated using `GroupSchema` From 87b2481c567fe796876cbdbc5e39e28de594e81d Mon Sep 17 00:00:00 2001 From: Ksauder Date: Sat, 26 Apr 2025 00:13:03 -0400 Subject: [PATCH 21/21] fix test-cov and add extra config check --- ninja/orm/metaclass.py | 7 +++++++ tests/test_orm_metaclass.py | 21 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/ninja/orm/metaclass.py b/ninja/orm/metaclass.py index a12248535..1e7c00250 100644 --- a/ninja/orm/metaclass.py +++ b/ninja/orm/metaclass.py @@ -80,6 +80,13 @@ def __new__( ): meta_conf = None + if "Config" in namespace: + config_keys = {k for k, _ in getmembers(namespace["Config"])} + if any(k in config_keys for k in MetaConf.model_fields.keys()): + raise ConfigError( + "class `Config` cannot be used to configure ModelSchema. Use `Meta` instead" + ) + if "Meta" in namespace: conf_class = namespace["Meta"] conf_dict = { diff --git a/tests/test_orm_metaclass.py b/tests/test_orm_metaclass.py index 565cf701b..8534fb13f 100644 --- a/tests/test_orm_metaclass.py +++ b/tests/test_orm_metaclass.py @@ -132,10 +132,29 @@ class Meta: fields = "__all__" class CategorySchema5(ModelSchema): - class Config: + class Meta: model = Category fields = "__all__" + with pytest.raises( + ConfigError, + match=f"Field title from model {Category} already exists in the Schema", + ): + + class CategorySchema6(CategorySchema5): + class Meta(CategorySchema5.Meta): + fields = ["title"] + + with pytest.raises( + ConfigError, + match="class `Config` cannot be used to configure ModelSchema. Use `Meta` instead", + ): + + class CategorySchema7(ModelSchema): + class Config: + model = Category + fields = "__all__" + def test_optional(): class OptModel(models.Model):