Skip to content

Commit b0d51f6

Browse files
authored
Migrate from Pydantic V1 compat layer to native Pydantic V2 API (#198)
- Replace all pydantic.v1 imports with direct pydantic imports - Migrate V1 APIs: .dict() -> .model_dump(), .__fields__ -> .model_fields, .from_orm() -> .model_validate(), .schema() -> .model_json_schema() - Convert class Config to model_config = ConfigDict(...) (orm_mode -> from_attributes, allow_population_by_field_name -> populate_by_name) - Update OpenAPI schema generation for V2 output ($defs, anyOf for Optional) - Update test assertions to match V2 error messages and schema format - Update README with Pydantic V2 docs links and examples - Bump version to 0.2.0a1 Made-with: Cursor
1 parent 43c1d43 commit b0d51f6

File tree

11 files changed

+110
-83
lines changed

11 files changed

+110
-83
lines changed

README.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -390,15 +390,28 @@ class ListModelMixin(BaseListModelMixin):
390390

391391
### PydanticSerializer
392392

393-
Для использования сериализатор на основе [pydantic](https://pydantic-docs.helpmanual.io/) необходимо наследовать
393+
Для использования сериализатор на основе [pydantic](https://docs.pydantic.dev/) (V2) необходимо наследовать
394394
сериализатор от `PydanticSerializer`, указать в `Meta` `pydantic_model` и `pydantic_use_aliases` (при необходимости).
395395

396-
Параметр `pydantic_use_aliases` позволяет использовать [алиасы pydantic моделей](https://pydantic-docs.helpmanual.io/usage/model_config/#alias-precedence) для сериализации.
396+
Параметр `pydantic_use_aliases` позволяет использовать [алиасы pydantic моделей](https://docs.pydantic.dev/latest/concepts/fields/#field-aliases) для сериализации.
397+
398+
Для работы с Django-моделями используйте `model_config = ConfigDict(from_attributes=True)` в pydantic-модели.
399+
397400
```python
401+
from pydantic import BaseModel, ConfigDict
402+
from restdoctor.rest_framework.serializers import PydanticSerializer
403+
404+
405+
class MyPydanticModel(BaseModel):
406+
model_config = ConfigDict(from_attributes=True)
407+
408+
name: str
409+
value: int
410+
398411

399-
class PydanticSerializer(PydanticSerializer):
412+
class MyPydanticSerializer(PydanticSerializer):
400413
class Meta:
401-
pydantic_model = PydanticModel
414+
pydantic_model = MyPydanticModel
402415
pydantic_use_aliases = True
403416
```
404417

restdoctor/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from __future__ import annotations
22

3-
__version__ = '0.1.0'
3+
__version__ = '0.2.0a1'
44

55
default_app_config = 'restdoctor.apps.AppConfig'

restdoctor/rest_framework/schema/serializers.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
from django.conf import settings
77
from django.utils.encoding import force_str
8-
from pydantic.v1.schema import schema as pydantic_schema
98
from rest_framework.fields import HiddenField
109
from rest_framework.serializers import BaseSerializer
1110

@@ -69,7 +68,7 @@ def get_serializer_schema(
6968
required: bool = True,
7069
) -> OpenAPISchema:
7170
if isinstance(serializer, PydanticSerializer):
72-
return fix_pydantic_title(serializer.pydantic_model_class.schema())
71+
return fix_pydantic_title(serializer.pydantic_model_class.model_json_schema())
7372

7473
properties, required_list = self.map_serializer_fields(
7574
serializer, include_write_only=write_only, include_read_only=read_only
@@ -122,18 +121,24 @@ def get_pydantic_ref_name(self, serializer_class_name: str) -> str:
122121

123122
def map_pydantic_serializer(self, serializer: PydanticSerializer) -> OpenAPISchema:
124123
if not self.view_schema.generator:
125-
return fix_pydantic_title(serializer.pydantic_model_class.schema())
124+
return fix_pydantic_title(serializer.pydantic_model_class.model_json_schema())
126125

127-
schema = {}
128-
ref_repo = pydantic_schema([serializer.pydantic_model_class], ref_prefix=OPENAPI_REF_PREFIX)
129-
for class_name, definition in ref_repo['definitions'].items():
126+
full_schema = serializer.pydantic_model_class.model_json_schema(
127+
ref_template=OPENAPI_REF_PREFIX + '{model}'
128+
)
129+
130+
defs = full_schema.pop('$defs', {})
131+
for class_name, definition in defs.items():
130132
definition = fix_pydantic_title(definition)
131133
ref = self.get_pydantic_ref_name(class_name)
132134
self.view_schema.generator.local_refs_registry.put_local_ref(ref, definition)
133-
if class_name == serializer.pydantic_model_class.__name__:
134-
schema = {'$ref': ref}
135135

136-
return schema
136+
root_schema = fix_pydantic_title(full_schema)
137+
root_class_name = serializer.pydantic_model_class.__name__
138+
ref = self.get_pydantic_ref_name(root_class_name)
139+
self.view_schema.generator.local_refs_registry.put_local_ref(ref, root_schema)
140+
141+
return {'$ref': ref}
137142

138143
def map_serializer(
139144
self,
@@ -184,7 +189,9 @@ def map_pydantic_query_serializer(
184189
) -> typing.List[OpenAPISchema]:
185190
props = []
186191
schema_dict = fix_pydantic_title(
187-
serializer.pydantic_model_class.schema(ref_template=OPENAPI_REF_PREFIX + '{model}')
192+
serializer.pydantic_model_class.model_json_schema(
193+
ref_template=OPENAPI_REF_PREFIX + '{model}'
194+
)
188195
)
189196
required_fields = schema_dict.get('required', [])
190197
for field_name, field_schema in schema_dict['properties'].items():

restdoctor/rest_framework/serializers.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111

1212
from django.core.exceptions import ImproperlyConfigured
1313
from django.db.models import Model as DjangoModel
14-
from pydantic.v1 import BaseModel
15-
from pydantic.v1 import ValidationError as PydanticValidationError
14+
from pydantic import BaseModel
15+
from pydantic import ValidationError as PydanticValidationError
1616
from rest_framework.exceptions import ValidationError
1717
from rest_framework.fields import empty
1818
from rest_framework.serializers import BaseSerializer as BaseDRFSerializer
@@ -200,7 +200,7 @@ def _validate_django_model(cls) -> None:
200200

201201
@classmethod
202202
def _validate_django_model_fields(cls, model: DjangoModel) -> None:
203-
pydantic_fields_set = set(cls._get_pydantic_model().__fields__.keys())
203+
pydantic_fields_set = set(cls._get_pydantic_model().model_fields.keys())
204204
model_info = model_meta.get_field_info(model)
205205
model_fields = list(model_info.fields.keys())
206206
model_fields.extend(model_info.relations.keys())
@@ -217,23 +217,28 @@ def _validate_meta_fields_not_exist(cls) -> None:
217217

218218
@classmethod
219219
def _validate_orm_mode_enabled(cls) -> None:
220-
if not getattr(cls._get_pydantic_model().Config, 'orm_mode', False):
220+
if not cls._get_pydantic_model().model_config.get('from_attributes', False):
221221
raise ImproperlyConfigured(
222-
'pydantic_model.Config.orm_mode must be True for this serializer'
222+
'pydantic_model.model_config "from_attributes" must be True for this serializer'
223223
)
224224

225225
def query_dict_to_dict(self, data: QueryDict) -> dict:
226226
model_class = self.pydantic_model_class
227-
model_fields = model_class.__fields__
227+
pydantic_model_fields = model_class.model_fields
228228
to_dict: dict[str, typing.Any] = {}
229229
for key, orig_value in data.items():
230230
field_key = next(
231-
(name for (name, field_obj) in model_fields.items() if field_obj.alias == key), key
231+
(
232+
name
233+
for (name, field_obj) in pydantic_model_fields.items()
234+
if field_obj.alias == key
235+
),
236+
key,
232237
)
233-
if field_key not in model_fields:
238+
if field_key not in pydantic_model_fields:
234239
to_dict[key] = orig_value
235240
continue
236-
field_type = model_fields[field_key].annotation
241+
field_type = pydantic_model_fields[field_key].annotation
237242
if orig_value in ('', b'') and not self._is_string_like_field(field_type=field_type):
238243
continue
239244
if self._is_sequence_field(field_type=field_type):
@@ -244,7 +249,7 @@ def query_dict_to_dict(self, data: QueryDict) -> dict:
244249
return to_dict
245250

246251
def get_fields(self) -> dict[str, None]:
247-
return {field_name: None for field_name in self.pydantic_model_class.__fields__.keys()}
252+
return {field_name: None for field_name in self.pydantic_model_class.model_fields.keys()}
248253

249254
def to_internal_value(self, data: dict[str, typing.Any]) -> TPydanticModel:
250255
try:
@@ -260,12 +265,12 @@ def to_representation( # noqa: CAC001
260265
# Типы аргумента instance были выяснены экспериментальным путем
261266
# в ходе тестирования
262267
if isinstance(instance, dict):
263-
value = self.to_internal_value(instance).dict(by_alias=self.pydantic_use_aliases)
268+
value = self.to_internal_value(instance).model_dump(by_alias=self.pydantic_use_aliases)
264269
elif isinstance(instance, BaseModel):
265-
value = instance.dict(by_alias=self.pydantic_use_aliases)
270+
value = instance.model_dump(by_alias=self.pydantic_use_aliases)
266271
elif isinstance(instance, PydanticSerializer):
267272
instance.is_valid(raise_exception=True)
268-
value = instance._pydantic_instance.dict(by_alias=self.pydantic_use_aliases) # type: ignore
273+
value = instance._pydantic_instance.model_dump(by_alias=self.pydantic_use_aliases) # type: ignore
269274
elif isinstance(instance, DjangoModel):
270275
value = self._django_model_to_representation(instance)
271276
else:
@@ -282,7 +287,7 @@ def is_valid(self, raise_exception: bool = False) -> bool:
282287
try:
283288
pydantic_instance = self.pydantic_model_class(**self.initial_data)
284289
self._pydantic_instance = pydantic_instance
285-
self._validated_data = pydantic_instance.dict(by_alias=self.pydantic_use_aliases)
290+
self._validated_data = pydantic_instance.model_dump(by_alias=self.pydantic_use_aliases)
286291
except PydanticValidationError as exc:
287292
self._validated_data = {}
288293
self._errors = convert_pydantic_errors_to_drf_errors(exc.errors())
@@ -296,7 +301,7 @@ def is_valid(self, raise_exception: bool = False) -> bool:
296301

297302
def _django_model_to_representation(self, instance: DjangoModel) -> GenericRepresentation:
298303
try:
299-
value = self.pydantic_model_class.from_orm(instance).dict(
304+
value = self.pydantic_model_class.model_validate(instance).model_dump(
300305
by_alias=self.pydantic_use_aliases
301306
)
302307
except PydanticValidationError as exc:

tests/test_unit/test_schema/test_pydantic_schema.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from uuid import UUID
77

88
import pytest
9-
from pydantic.v1 import BaseModel, Field, StrictInt, StrictStr
9+
from pydantic import BaseModel, Field, StrictInt, StrictStr
1010

1111
from restdoctor.rest_framework.schema.generators import RefsSchemaGenerator
1212
from restdoctor.rest_framework.schema.openapi import RestDoctorSchema
@@ -23,10 +23,10 @@ class PydanticTestModel(BaseModel):
2323

2424

2525
class PydanticTestQueryModel(BaseModel):
26-
boolean_param: typing.Optional[bool] = Field(description='Boolean filter param')
26+
boolean_param: typing.Optional[bool] = Field(default=None, description='Boolean filter param')
2727
string_param: str
28-
integer_param: typing.Optional[int]
29-
uuid_list_param: typing.Optional[typing.List[UUID]]
28+
integer_param: typing.Optional[int] = None
29+
uuid_list_param: typing.Optional[typing.List[UUID]] = None
3030
text_choices_param: TestTextChoices
3131

3232

@@ -79,8 +79,8 @@ def test_nested_model_schema_without_definitions():
7979
@pytest.fixture()
8080
def test_nested_model_schema(test_model_schema, test_nested_model_schema_without_definitions):
8181
schema = copy.deepcopy(test_nested_model_schema_without_definitions)
82-
schema['properties']['nested_field'] = {'$ref': '#/definitions/PydanticTestModel'}
83-
schema['definitions'] = {'PydanticTestModel': test_model_schema}
82+
schema['properties']['nested_field'] = {'$ref': '#/$defs/PydanticTestModel'}
83+
schema['$defs'] = {'PydanticTestModel': test_model_schema}
8484
return schema
8585

8686

@@ -142,7 +142,11 @@ def test__serializer_schema():
142142
'in': 'query',
143143
'name': 'boolean_param',
144144
'required': False,
145-
'schema': {'description': 'Boolean filter param', 'type': 'boolean'},
145+
'schema': {
146+
'anyOf': [{'type': 'boolean'}, {'type': 'null'}],
147+
'default': None,
148+
'description': 'Boolean filter param',
149+
},
146150
},
147151
{
148152
'in': 'query',
@@ -154,16 +158,23 @@ def test__serializer_schema():
154158
'in': 'query',
155159
'name': 'integer_param',
156160
'required': False,
157-
'schema': {'description': 'Integer Param', 'type': 'integer'},
161+
'schema': {
162+
'anyOf': [{'type': 'integer'}, {'type': 'null'}],
163+
'default': None,
164+
'description': 'Integer Param',
165+
},
158166
},
159167
{
160168
'in': 'query',
161169
'name': 'uuid_list_param',
162170
'required': False,
163171
'schema': {
172+
'anyOf': [
173+
{'items': {'format': 'uuid', 'type': 'string'}, 'type': 'array'},
174+
{'type': 'null'},
175+
],
176+
'default': None,
164177
'description': 'Uuid List Param',
165-
'items': {'format': 'uuid', 'type': 'string'},
166-
'type': 'array',
167178
},
168179
},
169180
{

tests/test_unit/test_serializers/test_pydantic_serializer/conftest.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@
66

77
import pytest
88
from django.db import models
9-
from pydantic.v1 import BaseModel, Field, Json, StrictInt, StrictStr
9+
from pydantic import BaseModel, ConfigDict, Field, Json, StrictInt, StrictStr
1010

1111
from restdoctor.rest_framework.serializers import PydanticSerializer
1212

1313

1414
class PydanticTestModel(BaseModel):
15-
class Config:
16-
orm_mode = True
15+
model_config = ConfigDict(from_attributes=True)
1716

1817
created_at: datetime.datetime = Field(default_factory=datetime.datetime.utcnow)
1918
field_a: StrictStr
@@ -23,7 +22,7 @@ class Config:
2322
class PydanticWithQueryParamsTestModel(BaseModel):
2423
any_int: int = Field(alias='my_int')
2524
any_str: str = Field(alias='any_str')
26-
any_json: Json
25+
any_json: Json[typing.Any]
2726
any_list: list
2827
any_str_list: typing.List[str]
2928
any_bool: bool
@@ -35,15 +34,13 @@ class PydanticWithQueryParamsShortTestModel(BaseModel):
3534

3635

3736
class PydanticObjectTestModel(BaseModel):
38-
class Config:
39-
allow_population_by_field_name = True
37+
model_config = ConfigDict(populate_by_name=True)
4038

4139
object_id: int = Field(alias='id')
4240

4341

4442
class PydanticTestModelWithAliases(PydanticTestModel):
45-
class Config:
46-
allow_population_by_field_name = True
43+
model_config = ConfigDict(populate_by_name=True, protected_namespaces=())
4744

4845
object_type: str = Field(alias='type')
4946
model_object: PydanticObjectTestModel = Field(alias='object')
@@ -211,21 +208,21 @@ def pydantic_test_model_with_aliases_data(pydantic_test_model_data) -> dict[str,
211208
def serialized_pydantic_test_model_data(
212209
pydantic_test_model_data, pydantic_test_model
213210
) -> dict[str, str | datetime.datetime]:
214-
return pydantic_test_model(**pydantic_test_model_data).dict()
211+
return pydantic_test_model(**pydantic_test_model_data).model_dump()
215212

216213

217214
@pytest.fixture()
218215
def serialized_pydantic_test_with_query(
219216
pydantic_test_query_data, pydantic_test_with_query_model
220217
) -> dict[str, str | datetime.datetime]:
221-
return pydantic_test_with_query_model(**pydantic_test_query_data).dict()
218+
return pydantic_test_with_query_model(**pydantic_test_query_data).model_dump()
222219

223220

224221
@pytest.fixture()
225222
def serialized_pydantic_test_model_with_aliases_data(
226223
pydantic_test_model_with_aliases_data, pydantic_test_model_with_aliases
227224
) -> dict[str, str | datetime.datetime]:
228-
return pydantic_test_model_with_aliases(**pydantic_test_model_with_aliases_data).dict(
225+
return pydantic_test_model_with_aliases(**pydantic_test_model_with_aliases_data).model_dump(
229226
by_alias=True
230227
)
231228

tests/test_unit/test_serializers/test_pydantic_serializer/parameters.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import typing
44

5-
from pydantic.v1.types import Json
5+
from pydantic import Json
66

77
PARAMETRIZE_TYPES = [
88
(str, False),

tests/test_unit/test_serializers/test_pydantic_serializer/parameters_38.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import typing
44

5-
from pydantic.v1.types import Json
5+
from pydantic import Json
66

77
PARAMETRIZE_TYPES = [
88
(str, False),

0 commit comments

Comments
 (0)