Skip to content

Commit

Permalink
Pydantic V2
Browse files Browse the repository at this point in the history
  • Loading branch information
vitalik committed Jun 30, 2023
1 parent 933cd3b commit d8e7595
Show file tree
Hide file tree
Showing 46 changed files with 765 additions and 508 deletions.
36 changes: 18 additions & 18 deletions docs/docs/guides/input/filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ from typing import Optional


class BookFilterSchema(FilterSchema):
name: Optional[str]
author: Optional[str]
created_after: Optional[datetime]
name: Optional[str] = None
author: Optional[str] = None
created_after: Optional[datetime] = None
```


Expand Down Expand Up @@ -68,19 +68,19 @@ By default, the filters will behave the following way:
By default, `FilterSet` will use the field names to generate Q expressions:
```python
class BookFilterSchema(FilterSchema):
name: Optional[str]
name: Optional[str] = None
```
The `name` field will be converted into `Q(name=...)` expression.

When your database lookups are more complicated than that, you can explicitly specify them in the field definition using a `"q"` kwarg:
```python hl_lines="2"
class BookFilterSchema(FilterSchema):
name: Optional[str] = Field(q='name__icontains')
name: Optional[str] = Field(None, q='name__icontains')
```
You can even specify multiple lookup keyword argument names as a list:
```python hl_lines="2 3 4"
class BookFilterSchema(FilterSchema):
search: Optional[str] = Field(q=['name__icontains',
search: Optional[str] = Field(None, q=['name__icontains',
'author__name__icontains',
'publisher__name__icontains'])
```
Expand All @@ -96,8 +96,8 @@ By default,
So, with the following `FilterSchema`...
```python
class BookFilterSchema(FilterSchema):
search: Optional[str] = Field(q=['name__icontains', 'author__name__icontains'])
popular: Optional[bool]
search: Optional[str] = Field(None, q=['name__icontains', 'author__name__icontains'])
popular: Optional[bool] = None
```
...and the following query parameters from the user
```
Expand All @@ -109,9 +109,9 @@ the `FilterSchema` instance will look for popular books that have `harry` in the
You can customize this behavior using an `expression_connector` argument in field-level and class-level definition:
```python hl_lines="3 7"
class BookFilterSchema(FilterSchema):
active: Optional[bool] = Field(q=['is_active', 'publisher__is_active'],
active: Optional[bool] = Field(None, q=['is_active', 'publisher__is_active'],
expression_connector='AND')
name: Optional[str] = Field(q='name__icontains')
name: Optional[str] = Field(None, q='name__icontains')

class Config:
expression_connector = 'OR'
Expand All @@ -132,17 +132,17 @@ You can make the `FilterSchema` treat `None` as a valid value that should be fil
This can be done on a field level with a `ignore_none` kwarg:
```python hl_lines="3"
class BookFilterSchema(FilterSchema):
name: Optional[str] = Field(q='name__icontains')
tag: Optional[str] = Field(q='tag', ignore_none=False)
name: Optional[str] = Field(None, q='name__icontains')
tag: Optional[str] = Field(None, q='tag', ignore_none=False)
```

This way when no other value for `"tag"` is provided by the user, the filtering will always include a condition `tag=None`.

You can also specify this settings for all fields at the same time in the Config:
```python hl_lines="6"
class BookFilterSchema(FilterSchema):
name: Optional[str] = Field(q='name__icontains')
tag: Optional[str] = Field(q='tag', ignore_none=False)
name: Optional[str] = Field(None, q='name__icontains')
tag: Optional[str] = Field(None, q='tag', ignore_none=False)

class Config:
ignore_none = False
Expand All @@ -155,8 +155,8 @@ For such cases you can implement your field filtering logic as a custom method.

```python hl_lines="5"
class BookFilterSchema(FilterSchema):
tag: Optional[str]
popular: Optional[bool]
tag: Optional[str] = None
popular: Optional[bool] = None

def filter_popular(self, value: bool) -> Q:
return Q(view_count__gt=1000) | Q(download_count__gt=100) if value else Q()
Expand All @@ -167,8 +167,8 @@ If that is not enough, you can implement your own custom filtering logic for the

```python hl_lines="5"
class BookFilterSchema(FilterSchema):
name: Optional[str]
popular: Optional[bool]
name: Optional[str] = None
popular: Optional[bool] = None

def custom_expression(self) -> Q:
q = Q()
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/guides/response/config-pydantic.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ class CamelModelSchema(Schema):
!!! 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 cammel case) - you need to set as well `allow_population_by_field_name` and `by_alias`
Keep in mind that when you want modify output for field names (like cammel case) - you need to set as well `populate_by_name` and `by_alias`

```python hl_lines="6 9"
class UserSchema(ModelSchema):
class Config:
model = User
model_fields = ["id", "email"]
alias_generator = to_camel
allow_population_by_field_name = True # !!!!!! <--------
populate_by_name = True # !!!!!! <--------


@api.get("/users", response=list[UserSchema], by_alias=True) # !!!!!! <-------- by_alias
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/guides/response/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ class TaskSchema(Schema):
id: int
title: str
is_completed: bool
owner: Optional[str]
owner: Optional[str] = None
lower_title: str

@staticmethod
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/pydantic-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Config:

- orm_mode -> from_attributes
- allow_population_by_field_name -> populate_by_name
30 changes: 23 additions & 7 deletions docs/src/tutorial/form/code03.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,37 @@
from ninja import Form, Schema
from pydantic.fields import ModelField
from typing import Generic, TypeVar
from pydantic import FieldValidationInfo
from pydantic.fields import FieldInfo
from pydantic_core import core_schema
from typing import Any, Generic, TypeVar

PydanticField = TypeVar("PydanticField")


class EmptyStrToDefault(Generic[PydanticField]):
@classmethod
def __get_validators__(cls):
yield cls.validate
def __get_pydantic_core_schema__(cls, source, handler):
return core_schema.field_plain_validator_function(cls.validate)

# @classmethod
# def __get_pydantic_json_schema__(cls, schema, handler):
# return {"type": "object"}

@classmethod
def validate(cls, value: PydanticField, field: ModelField) -> PydanticField:
def validate(cls, value: Any, info: FieldValidationInfo) -> Any:
if value == "":
return field.default
return info.default
return value

# @classmethod
# def __get_validators__(cls):
# yield cls.validate

# @classmethod
# def validate(cls, value: PydanticField, field: FieldInfo) -> PydanticField:
# if value == "":
# return field.default
# return value


class Item(Schema):
name: str
Expand All @@ -26,5 +42,5 @@ class Item(Schema):


@api.post("/items-blank-default")
def update(request, item: Item=Form(...)):
def update(request, item: Item = Form(...)):
return item.dict()
3 changes: 1 addition & 2 deletions docs/src/tutorial/query/code02.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,4 @@
@api.get("/weapons/search")
def search_weapons(request, q: str, offset: int = 0):
results = [w for w in weapons if q in w.lower()]
print(q, results)
return results[offset: offset + 10]
return results[offset : offset + 10]
2 changes: 1 addition & 1 deletion ninja/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Django Ninja - Fast Django REST framework"""

__version__ = "0.22.1"
__version__ = "1.0a1"

from pydantic import Field

Expand Down
4 changes: 2 additions & 2 deletions ninja/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class Settings(BaseModel):
DOCS_VIEW: str = Field("swagger", alias="NINJA_DOCS_VIEW")

class Config:
orm_mode = True
from_attributes = True


settings = Settings.from_orm(django_settings)
settings = Settings.model_validate(django_settings)
26 changes: 14 additions & 12 deletions ninja/files.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
from typing import Any, Callable, Dict, Iterable, Optional, Type
from typing import Any, Callable

from django.core.files.uploadedfile import UploadedFile as DjangoUploadedFile
from pydantic.fields import ModelField
from pydantic.json_schema import JsonSchemaValue
from pydantic_core import core_schema

__all__ = ["UploadedFile"]


class UploadedFile(DjangoUploadedFile):
@classmethod
def __get_validators__(cls: Type["UploadedFile"]) -> Iterable[Callable[..., Any]]:
yield cls._validate
def __get_pydantic_json_schema__(cls, core_schema, handler):
# calling handler(core_schema) here raises an exception
json_schema = {}
json_schema.update(type="string", format="binary")
return json_schema

@classmethod
def _validate(cls: Type["UploadedFile"], v: Any) -> Any:
if not isinstance(v, DjangoUploadedFile):
raise ValueError(f"Expected UploadFile, received: {type(v)}")
return v
def _validate(cls, __input_value: Any, _):
if not isinstance(__input_value, DjangoUploadedFile):
raise ValueError(f"Expected UploadFile, received: {type(__input_value)}")
return __input_value

@classmethod
def __modify_schema__(
cls, field_schema: Dict[str, Any], field: Optional[ModelField] = None
) -> None:
field_schema.update(type="string", format="binary")
def __get_pydantic_core_schema__(cls, source, handler):
return core_schema.general_plain_validator_function(cls._validate)
41 changes: 25 additions & 16 deletions ninja/filter_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Q, QuerySet
from pydantic import BaseConfig
from pydantic.fields import ModelField
from pydantic.fields import FieldInfo
from typing_extensions import Literal

from .schema import Schema
Expand All @@ -16,18 +16,24 @@
ExpressionConnector = Literal["AND", "OR", "XOR"]


class FilterConfig(BaseConfig):
ignore_none: bool = DEFAULT_IGNORE_NONE
expression_connector: ExpressionConnector = cast(
ExpressionConnector, DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR
)
# class FilterConfig(BaseConfig):
# ignore_none: bool = DEFAULT_IGNORE_NONE
# expression_connector: ExpressionConnector = cast(
# ExpressionConnector, DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR
# )


class FilterSchema(Schema):
if TYPE_CHECKING:
__config__: ClassVar[Type[FilterConfig]] = FilterConfig # pragma: no cover
# if TYPE_CHECKING:
# __config__: ClassVar[Type[FilterConfig]] = FilterConfig # pragma: no cover

Config = FilterConfig
# Config = FilterConfig

class Config(Schema.Config):
ignore_none: bool = DEFAULT_IGNORE_NONE
expression_connector: ExpressionConnector = cast(
ExpressionConnector, DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR
)

def custom_expression(self) -> Q:
"""
Expand All @@ -48,19 +54,21 @@ def filter(self, queryset: QuerySet) -> QuerySet:
return queryset.filter(self.get_filter_expression())

def _resolve_field_expression(
self, field_name: str, field_value: Any, field: ModelField
self, field_name: str, field_value: Any, field: FieldInfo
) -> Q:
func = getattr(self, f"filter_{field_name}", None)
if callable(func):
return func(field_value) # type: ignore[no-any-return]

q_expression = field.field_info.extra.get("q", None)
field_extra = field.json_schema_extra or {}

q_expression = field_extra.get("q", None)
if not q_expression:
return Q(**{field_name: field_value})
elif isinstance(q_expression, str):
return Q(**{q_expression: field_value})
elif isinstance(q_expression, list):
expression_connector = field.field_info.extra.get(
expression_connector = field_extra.get(
"expression_connector", DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR
)
q = Q()
Expand All @@ -79,17 +87,18 @@ def _resolve_field_expression(

def _connect_fields(self) -> Q:
q = Q()
for field_name, field in self.__fields__.items():
for field_name, field in self.model_fields.items():
filter_value = getattr(self, field_name)
ignore_none = field.field_info.extra.get(
"ignore_none", self.__config__.ignore_none
field_extra = field.json_schema_extra or {}
ignore_none = field_extra.get(
"ignore_none", self.model_config["ignore_none"]
)

# Resolve q for a field even if we skip it due to None value
# So that improperly configured fields are easier to detect
field_q = self._resolve_field_expression(field_name, filter_value, field)
if filter_value is None and ignore_none:
continue
q = q._combine(field_q, self.__config__.expression_connector) # type: ignore[attr-defined]
q = q._combine(field_q, self.model_config["expression_connector"]) # type: ignore[attr-defined]

return q
Loading

0 comments on commit d8e7595

Please sign in to comment.