Skip to content

Commit 94293de

Browse files
committed
docs: 📚 Add a proper README
1 parent dd994b2 commit 94293de

File tree

4 files changed

+211
-2
lines changed

4 files changed

+211
-2
lines changed

‎README.md

Lines changed: 187 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,189 @@
11
# `pydantic-async-validation`
22

3-
TODO
3+
Add async validation to your pydantic models 🥳. This allows you to add validation that actually checks the database
4+
or makes an API call or just use any code you did write async.
5+
6+
Note that validation cannot happen during model creation, so you have to call `await model.model_async_validate()`
7+
yourself. This is due to the fact that `__init__()` will always be a sync method and you cannot sanely call async
8+
methods from sync methods.
9+
10+
## Example usage
11+
12+
```python
13+
import pydantic
14+
from pydantic_async_validation import async_field_validator, AsyncValidationModelMixin
15+
16+
17+
class SomethingModel(AsyncValidationModelMixin, pydantic.BaseModel):
18+
name: str
19+
20+
@async_field_validator('name')
21+
async def validate_name(self, value: str) -> None:
22+
if value == "invalid":
23+
raise ValueError("Invalid name")
24+
25+
26+
valid_instance = SomethingModel(name="valid")
27+
await valid_instance.model_async_validate()
28+
29+
invalid_instance = SomethingModel(name="invalid")
30+
await invalid_instance.model_async_validate() # will raise normal pydantic ValidationError
31+
```
32+
33+
## Field validators
34+
35+
You can use `async_field_validator` to add async validators to your model. The first argument is the name of the field
36+
to validate. You may also pass additional field names, the validator will then be called for all fields. As validation
37+
is happening after the instance was created, you can access all fields of the model and the validator should just be a
38+
normal instance method (accepting `self` as its first parameter).
39+
40+
Field validators may use any combination of the following arguments:
41+
* `value`: The value of the field to validate (same as `getattr(self, field)`)
42+
* `field`: The name of the field being validated, can be useful if you use the same validator for multiple fields
43+
* `config`: The config of the validator, see `ValidationInfo` for details
44+
45+
You may also pass additional keyword arguments to `async_field_validator`, they will be passed to the validator config
46+
(`ValidationInfo` instance) and be available in the validator config as `config.extra`.
47+
48+
Example:
49+
50+
```python
51+
import pydantic
52+
from pydantic_async_validation import async_field_validator, AsyncValidationModelMixin, ValidationInfo
53+
54+
55+
class SomethingModel(AsyncValidationModelMixin, pydantic.BaseModel):
56+
name: str
57+
other_name: str
58+
59+
@async_field_validator('name', 'other_name', some_extra='value')
60+
async def validate_name(self, value: str, field: str, config: ValidationInfo) -> None:
61+
if value == "invalid":
62+
# Using ValueError
63+
raise ValueError(f"Invalid {field} with extra {config.extra['some_extra']}")
64+
```
65+
66+
## Model validators
67+
68+
You can use `async_model_validator` to add async validators to your model. The validator will be called after all field
69+
validators have been called. The validator should be a normal instance method (accepting `self` as its first parameter).
70+
71+
Model validators may use any combination of the following arguments:
72+
* `config`: The config of the validator, see `ValidationInfo` for details
73+
74+
75+
Example:
76+
77+
```python
78+
import pydantic
79+
from pydantic_async_validation import async_model_validator, AsyncValidationModelMixin, ValidationInfo
80+
81+
82+
class SomethingModel(AsyncValidationModelMixin, pydantic.BaseModel):
83+
name: str
84+
other_name: str
85+
86+
@async_model_validator(some_extra='value')
87+
async def validate_names(self, config: ValidationInfo) -> None:
88+
# Using assertion
89+
assert self.name != self.other_name, f"Names are equal with extra {config.extra['some_extra']}"
90+
```
91+
92+
## When to use field vs. model validators
93+
94+
As validation happens after the model instance was created, you can access all fields just using `self` anyways. So
95+
field vs. model validation is kind of the same thing. However field validators allow you to get the `value` of the
96+
field as its parameter, so this is perfect when you reuse validators or want to validate multiple fields with the same
97+
validator. Also field validators will tie the `ValidationError` to the field, so it will contain the detail about which
98+
field failed to validate. In general you should use field validators when you want to validate a single field. I also
99+
suggest using the `value` parameter to have a clean and consistent interface for your validators.
100+
101+
Model validators on the other hand should be used when you need to validate multiple fields at once. This is especially
102+
useful when you want to validate that multiple fields are consistent with each other. For example you might want to
103+
validate that a start date is before an end date. In this case you would use a model validator and access both fields
104+
using `self`. Note that model validators will be tied to `"__root__"` in the `ValidationError` as there is no specific
105+
field to tie it to.
106+
107+
## Handling validation errors
108+
109+
Like with normal pydantic validation, you can catch `ValidationError` and access the `errors()` method to get a list of
110+
all errors. Like pydantic errors will be collected and be raised as one `ValidationError` at the end of validation,
111+
including all errors that occurred.
112+
113+
`model_async_validate()` will also try to validate child model instances, that are also using the
114+
`AsyncValidationModelMixin`. This means the following example code will work as expected:
115+
116+
```python
117+
import pydantic
118+
from pydantic_async_validation import async_field_validator, AsyncValidationModelMixin, ValidationInfo
119+
120+
121+
class SomethingModel(AsyncValidationModelMixin, pydantic.BaseModel):
122+
name: str
123+
124+
@async_field_validator('name')
125+
async def validate_name(self, value: str, field: str, config: ValidationInfo) -> None:
126+
if value == "invalid":
127+
raise ValueError(f"Value may not be 'invalid'")
128+
129+
130+
class ParentModel(AsyncValidationModelMixin, pydantic.BaseModel):
131+
child: SomethingModel
132+
133+
134+
invalid_instance = ParentModel(child=SomethingModel(name="invalid"))
135+
await invalid_instance.model_async_validate() # will raise normal pydantic ValidationError
136+
```
137+
138+
Note the `ValidationError` will not have the location of the error set to `"child.name"`.
139+
140+
Recursive validation will happen in those cases:
141+
* Child models as direct instance variables (see example above)
142+
* Child models in list items
143+
* Child models in dict values
144+
145+
## FastAPI support
146+
147+
When using FastAPI you also can use the `AsyncValidationModelMixin`, note however that FastAPI will see any
148+
`ValidationError` risen in endpoint methods as unhandled exceptions and thus will return a HTTP 500 error. FastAPI
149+
will only handle the validation errors happening during handling the endpoint parameters in as special way and
150+
convert those to `RequestValidationError` - which will then be handled by the default exception handler for
151+
`RequestValidationError` FastAPI provides. This will then result in a HTTP 422 return code.
152+
153+
When using `pydantic_async_validation` this would be a major drawback, as using `model_async_validate` for
154+
validating input (/request) data is a totally fine use case. To solve this issue you can use the
155+
`ensure_request_validation_errors` context manager provided in `pydantic_async_validation.fastapi`. This will
156+
ensure that any `ValidationError` risen during the context manager will be converted to a `RequestValidationError`.
157+
Those `RequestValidationError`s will then be handled by the default exception handler for `RequestValidationError`
158+
FastAPI provides. This will then again result in a HTTP 422 return code.
159+
160+
Example for usage with FastAPI:
161+
162+
```python
163+
import fastapi
164+
import pydantic
165+
from pydantic_async_validation import AsyncValidationModelMixin
166+
from pydantic_async_validation.fastapi import ensure_request_validation_errors
167+
168+
169+
class SomethingModel(AsyncValidationModelMixin, pydantic.BaseModel): ...
170+
171+
172+
app = fastapi.FastAPI()
173+
174+
@app.get("/return-http-422-on-async-validation-error")
175+
async def return_http_422_on_async_validation_error():
176+
instance = SomethingModel(...)
177+
with ensure_request_validation_errors():
178+
await instance.model_async_validate()
179+
```
180+
181+
You may also use `ensure_request_validation_errors` to do additional validation on the request data using normal
182+
pydantic validation and converting those `ValidationError`s to `RequestValidationError`s. 😉
183+
184+
# Contributing
185+
186+
If you want to contribute to this project, feel free to just fork the project,
187+
create a dev branch in your fork and then create a pull request (PR). If you
188+
are unsure about whether your changes really suit the project please create an
189+
issue first, to talk about this.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
from pydantic_async_validation.mixins import AsyncValidationModelMixin
2-
from pydantic_async_validation.validators import async_field_validator, async_model_validator
2+
from pydantic_async_validation.validators import ValidationInfo, async_field_validator, async_model_validator

‎tests/test_field_validators.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ async def validate_name(self, value: str) -> None:
1919
if value == "invalid":
2020
raise ValueError("Invalid name")
2121

22+
@async_field_validator('age')
23+
async def validate_age(self, value: int) -> None:
24+
assert value == self.age
25+
assert value > 0
26+
2227

2328
@pytest.mark.asyncio
2429
async def test_async_validation_raises_no_issues():
@@ -33,6 +38,13 @@ async def test_async_validation_raises_when_validation_fails():
3338
await instance.model_async_validate()
3439

3540

41+
@pytest.mark.asyncio
42+
async def test_async_validation_raises_when_validation_fails_by_assertion():
43+
instance = SomethingModel(name="valid", age=0)
44+
with pytest.raises(pydantic.ValidationError):
45+
await instance.model_async_validate()
46+
47+
3648
@pytest.mark.asyncio
3749
async def test_all_field_validator_combinations_are_valid():
3850
class OtherModel(AsyncValidationModelMixin, pydantic.BaseModel):

‎tests/test_model_validators.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ async def validate_name(self) -> None:
1717
if self.name == "invalid":
1818
raise ValueError("Invalid name")
1919

20+
@async_model_validator()
21+
async def validate_age(self) -> None:
22+
assert self.age > 0
23+
2024

2125
@pytest.mark.asyncio
2226
async def test_async_validation_raises_no_issues():
@@ -31,6 +35,13 @@ async def test_async_validation_raises_when_validation_fails():
3135
await instance.model_async_validate()
3236

3337

38+
@pytest.mark.asyncio
39+
async def test_async_validation_raises_when_validation_fails():
40+
instance = SomethingModel(name="invalid", age=1)
41+
with pytest.raises(pydantic.ValidationError):
42+
await instance.model_async_validate()
43+
44+
3445
@pytest.mark.asyncio
3546
async def test_all_field_validator_combinations_are_valid():
3647
class OtherModel(AsyncValidationModelMixin, pydantic.BaseModel):

0 commit comments

Comments
 (0)