Skip to content

Commit c70a83b

Browse files
authored
Merge pull request #45 from mts-ai/feature/fix-validators
fix schemas validators passthrough
2 parents 51cdac2 + 1e0f9c8 commit c70a83b

File tree

11 files changed

+905
-488
lines changed

11 files changed

+905
-488
lines changed

.github/workflows/documentation.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ jobs:
1919
- uses: actions/setup-python@v3
2020
- name: Install dependencies
2121
run: |
22-
pip install sphinx sphinx_rtd_theme
2322
pip install -r docs/requirements.txt
2423
- name: Sphinx build
2524
run: |

docs/changelog.rst

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@ Changelog
22
#########
33

44

5+
**2.3.1**
6+
*********
7+
8+
Pydantic validators inheritance fix
9+
====================================
10+
11+
* fix schema validators passthrough `#45 <https://github.com/mts-ai/FastAPI-JSONAPI/pull/45>`_
12+
* fix doc build
13+
14+
Authors
15+
"""""""
16+
17+
* `@CosmoV`_
18+
* `@mahenzon`_
19+
20+
521
**2.3.0**
622
*********
723

@@ -15,9 +31,9 @@ Current Atomic Operation context var
1531
Authors
1632
"""""""
1733

18-
1934
* `@mahenzon`_
2035

36+
2137
**2.2.2**
2238
*********
2339

docs/requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
fastapi<0.100.0
22
pydantic<2
33
simplejson>=3.17.6
4+
sphinx
5+
sphinx_rtd_theme
46
sqlalchemy<2
57
tortoise-orm>=0.19.3

fastapi_jsonapi/schema_builder.py

Lines changed: 11 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,16 @@
77
Iterable,
88
List,
99
Optional,
10-
Set,
1110
Tuple,
1211
Type,
1312
TypeVar,
1413
Union,
1514
)
1615

1716
import pydantic
18-
from pydantic import BaseConfig, root_validator, validator
17+
from pydantic import BaseConfig
1918
from pydantic import BaseModel as PydanticBaseModel
20-
from pydantic.class_validators import ROOT_VALIDATOR_CONFIG_KEY, VALIDATOR_CONFIG_KEY
21-
from pydantic.fields import FieldInfo, ModelField, Validator
19+
from pydantic.fields import FieldInfo, ModelField
2220

2321
from fastapi_jsonapi.data_typing import TypeSchema
2422
from fastapi_jsonapi.schema import (
@@ -35,6 +33,10 @@
3533
)
3634
from fastapi_jsonapi.schema_base import BaseModel, Field, RelationshipInfo, registry
3735
from fastapi_jsonapi.splitter import SPLIT_REL
36+
from fastapi_jsonapi.validation_utils import (
37+
extract_field_validators,
38+
extract_validators,
39+
)
3840

3941
JSON_API_RESPONSE_TYPE = Dict[Union[int, str], Dict[str, Any]]
4042

@@ -291,7 +293,10 @@ def _get_info_from_schema_for_building(
291293
# works both for to-one and to-many
292294
included_schemas.append((name, field.type_, relationship.resource_type))
293295
elif name == "id":
294-
id_validators = self._extract_field_validators(schema, target_field_name="id")
296+
id_validators = extract_field_validators(
297+
schema,
298+
include_for_field_names={"id"},
299+
)
295300
resource_id_field = (*(resource_id_field[:-1]), id_validators)
296301

297302
if not field.field_info.extra.get("client_can_set_id"):
@@ -310,7 +315,7 @@ class ConfigOrmMode(BaseConfig):
310315
f"{base_name}AttributesJSONAPI",
311316
**attributes_schema_fields,
312317
__config__=ConfigOrmMode,
313-
__validators__=self._extract_validators(schema, exclude_for_field_names={"id"}),
318+
__validators__=extract_validators(schema, exclude_for_field_names={"id"}),
314319
)
315320

316321
relationships_schema = pydantic.create_model(
@@ -378,111 +383,6 @@ def create_relationship_data_schema(
378383
self.relationship_schema_cache[cache_key] = relationship_data_schema
379384
return relationship_data_schema
380385

381-
def _is_target_validator(self, attr_name: str, value: Any, validator_config_key: str) -> bool:
382-
"""
383-
True if passed object is validator of type identified by "validator_config_key" arg
384-
385-
:param attr_name:
386-
:param value:
387-
:param validator_config_key: Choice field, available options are pydantic consts
388-
VALIDATOR_CONFIG_KEY, ROOT_VALIDATOR_CONFIG_KEY
389-
"""
390-
return (
391-
# also with private items
392-
not attr_name.startswith("__")
393-
and getattr(value, validator_config_key, None)
394-
)
395-
396-
def _unpack_validators(self, model: Type[BaseModel], validator_config_key: str) -> Dict[str, Validator]:
397-
"""
398-
Selects all validators from model attrs and unpack them from class methods
399-
400-
:param model: Type[BaseModel]
401-
:param validator_config_key: Choice field, available options are pydantic consts
402-
VALIDATOR_CONFIG_KEY, ROOT_VALIDATOR_CONFIG_KEY
403-
"""
404-
root_validator_class_methods = {
405-
# validators only
406-
attr_name: value
407-
for attr_name, value in model.__dict__.items()
408-
if self._is_target_validator(attr_name, value, validator_config_key)
409-
}
410-
411-
return {
412-
validator_name: getattr(validator_method, validator_config_key)
413-
for validator_name, validator_method in root_validator_class_methods.items()
414-
}
415-
416-
def _extract_root_validators(self, model: Type[BaseModel]) -> Dict[str, Callable]:
417-
validators = {}
418-
419-
unpacked_validators = self._unpack_validators(model, ROOT_VALIDATOR_CONFIG_KEY)
420-
for validator_name, validator_instance in unpacked_validators.items():
421-
validators[validator_name] = root_validator(
422-
pre=validator_instance.pre,
423-
skip_on_failure=validator_instance.skip_on_failure,
424-
allow_reuse=True,
425-
)(validator_instance.func)
426-
427-
return validators
428-
429-
def _extract_field_validators(
430-
self,
431-
model: Type[BaseModel],
432-
target_field_name: str = None,
433-
exclude_for_field_names: Set[str] = None,
434-
) -> Dict[str, Callable]:
435-
"""
436-
:param model: Type[BaseModel]
437-
:param target_field_name: Name of field for which validators will be returned.
438-
If not set the function will return validators for all fields.
439-
"""
440-
validators = {}
441-
validator_origin_param_keys = ("pre", "each_item", "always", "check_fields")
442-
443-
unpacked_validators = self._unpack_validators(model, VALIDATOR_CONFIG_KEY)
444-
for validator_name, (field_names, validator_instance) in unpacked_validators.items():
445-
if target_field_name and target_field_name not in field_names:
446-
continue
447-
elif target_field_name:
448-
field_names = [target_field_name] # noqa: PLW2901
449-
450-
if exclude_for_field_names:
451-
field_names = [ # noqa: PLW2901
452-
# filter names
453-
field_name
454-
for field_name in field_names
455-
if field_name not in exclude_for_field_names
456-
]
457-
458-
if not field_names:
459-
continue
460-
461-
validators[validator_name] = validator(
462-
*field_names,
463-
allow_reuse=True,
464-
**{
465-
# copy origin params
466-
param_name: getattr(validator_instance, param_name)
467-
for param_name in validator_origin_param_keys
468-
},
469-
)(validator_instance.func)
470-
471-
return validators
472-
473-
def _extract_validators(
474-
self,
475-
model: Type[BaseModel],
476-
exclude_for_field_names: Set[str] = None,
477-
) -> Dict[str, Callable]:
478-
return {
479-
**self._extract_field_validators(
480-
model,
481-
exclude_for_field_names=exclude_for_field_names,
482-
),
483-
**self._extract_root_validators(model),
484-
}
485-
486386
def _build_jsonapi_object(
487387
self,
488388
base_name: str,
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from copy import deepcopy
2+
from typing import (
3+
Callable,
4+
Dict,
5+
Set,
6+
Type,
7+
)
8+
9+
from pydantic import (
10+
class_validators,
11+
root_validator,
12+
validator,
13+
)
14+
from pydantic.fields import Validator
15+
from pydantic.utils import unique_list
16+
17+
from fastapi_jsonapi.schema_base import BaseModel
18+
19+
20+
def extract_root_validators(model: Type[BaseModel]) -> Dict[str, Callable]:
21+
pre_rv_new, post_rv_new = class_validators.extract_root_validators(model.__dict__)
22+
pre_root_validators = unique_list(
23+
model.__pre_root_validators__ + pre_rv_new,
24+
name_factory=lambda v: v.__name__,
25+
)
26+
post_root_validators = unique_list(
27+
model.__post_root_validators__ + post_rv_new,
28+
name_factory=lambda skip_on_failure_and_v: skip_on_failure_and_v[1].__name__,
29+
)
30+
31+
result_validators = {}
32+
33+
for validator_func in pre_root_validators:
34+
result_validators[validator_func.__name__] = root_validator(
35+
pre=True,
36+
allow_reuse=True,
37+
)(validator_func)
38+
39+
for skip_on_failure, validator_func in post_root_validators:
40+
result_validators[validator_func.__name__] = root_validator(
41+
allow_reuse=True,
42+
skip_on_failure=skip_on_failure,
43+
)(validator_func)
44+
45+
return result_validators
46+
47+
48+
def _deduplicate_field_validators(validators: Dict) -> Dict:
49+
result_validators = {}
50+
51+
for field_name, field_validators in validators.items():
52+
result_validators[field_name] = list(
53+
{
54+
# override in definition order
55+
field_validator.func.__name__: field_validator
56+
for field_validator in field_validators
57+
}.values(),
58+
)
59+
60+
return result_validators
61+
62+
63+
def extract_field_validators(
64+
model: Type[BaseModel],
65+
*,
66+
include_for_field_names: Set[str] = None,
67+
exclude_for_field_names: Set[str] = None,
68+
):
69+
validators = class_validators.inherit_validators(
70+
class_validators.extract_validators(model.__dict__),
71+
deepcopy(model.__validators__),
72+
)
73+
validators = _deduplicate_field_validators(validators)
74+
validator_origin_param_keys = (
75+
"pre",
76+
"each_item",
77+
"always",
78+
"check_fields",
79+
)
80+
81+
exclude_for_field_names = exclude_for_field_names or set()
82+
83+
if include_for_field_names and exclude_for_field_names:
84+
include_for_field_names = include_for_field_names.difference(
85+
exclude_for_field_names,
86+
)
87+
88+
result_validators = {}
89+
for field_name, field_validators in validators.items():
90+
if field_name in exclude_for_field_names:
91+
continue
92+
93+
if include_for_field_names and field_name not in include_for_field_names:
94+
continue
95+
96+
field_validator: Validator
97+
for field_validator in field_validators:
98+
validator_name = f"{field_name}_{field_validator.func.__name__}_validator"
99+
validator_params = {
100+
# copy validator params
101+
param_key: getattr(field_validator, param_key)
102+
for param_key in validator_origin_param_keys
103+
}
104+
result_validators[validator_name] = validator(
105+
field_name,
106+
**validator_params,
107+
allow_reuse=True,
108+
)(field_validator.func)
109+
110+
return result_validators
111+
112+
113+
def extract_validators(
114+
model: Type[BaseModel],
115+
exclude_for_field_names: Set[str] = None,
116+
) -> Dict[str, Callable]:
117+
return {
118+
**extract_field_validators(
119+
model,
120+
exclude_for_field_names=exclude_for_field_names,
121+
),
122+
**extract_root_validators(model),
123+
}

tests/fixtures/app.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
ParentToChildAssociation,
2020
Post,
2121
PostComment,
22+
Task,
2223
User,
2324
UserBio,
2425
)
@@ -36,6 +37,9 @@
3637
PostInSchema,
3738
PostPatchSchema,
3839
PostSchema,
40+
TaskInSchema,
41+
TaskPatchSchema,
42+
TaskSchema,
3943
UserBioSchema,
4044
UserInSchema,
4145
UserPatchSchema,
@@ -161,6 +165,19 @@ def add_routers(app_plain: FastAPI):
161165
schema_in_post=ComputerInSchema,
162166
)
163167

168+
RoutersJSONAPI(
169+
router=router,
170+
path="/tasks",
171+
tags=["Task"],
172+
class_detail=DetailViewBaseGeneric,
173+
class_list=ListViewBaseGeneric,
174+
model=Task,
175+
schema=TaskSchema,
176+
resource_type="task",
177+
schema_in_patch=TaskPatchSchema,
178+
schema_in_post=TaskInSchema,
179+
)
180+
164181
atomic = AtomicOperations()
165182

166183
app_plain.include_router(router, prefix="")

tests/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,15 @@ def __repr__(self):
222222
return f"{self.__class__.__name__}(id={self.id}, name={self.name!r}, user_id={self.user_id})"
223223

224224

225+
class Task(Base):
226+
__tablename__ = "tasks"
227+
id = Column(Integer, primary_key=True)
228+
task_ids = Column(JSON, nullable=True, unique=False)
229+
230+
231+
# uuid below
232+
233+
225234
class CustomUUIDType(TypeDecorator):
226235
cache_ok = True
227236

0 commit comments

Comments
 (0)