Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validation Decorator #1179

Merged
merged 17 commits into from
Feb 5, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ jobs:

- stage: build
name: 'Docs Build and Upload'
python: 3.7
python: 3.8
script: make docs
env:
- secure: "vpTd8bkwPBP0CV3EJBAwSMNMnNK3m/71dvTvBd1T4YGuefyJvYhtA7wauA5xRL9jpK2mu5QR5eo0owTUJhKi4DjpafMMd1bc4PnXlrdZFzkn3VsGmlKt74D/aJgiuiNyhd/Qvq4OxMHrMhf4f6lKWoMM1vh6yT0yp3+51SexSh2Me0Q+npxbjXwoxX5XUHRcoSLtFk4GbYI88a2I+08XWI6v+Awo/giQ5QurUJhjAklbosrrQVr1FCOkU0em5jeyZvEbZSLmaMtbX1JlRdKoJm6WMU+y9I7zj35w6ue/vgfcLz7b/HDZrBx7/L9g1LxRo80briueX/IbHvN7DOVFKvaXVmnEa6lIDdCeOLOyESpIbmjqmDKi8JeexdPNxKq4Tvo2VEA9dL2w2aw+aALNtU2OF5iEMfPTUQyosu/CNu2PKtiuZkSOdvpYbSy1WUNHJRvomdR4Olzg8ZIScNsxU3IIPdrlG/LUA8auXcE9juFeZfD6D2hQZATqWeEe/C2J7amNSD+mLLaTf6nMQw8oNtKYOvYK17M7xyvi7HXDy711Bi18U3x6Ye0xGx8CDbFwl0ICNzIk9rrSAh9hEHTvfdUUkk35pxifvO0Hrh4SArCA20ozcH/hHWBhyqGdxoIQ6KoDgNbIFIGQ6/vugxL/pt8z1sJwPfJnq8tRDAyWZvE="
Expand Down
1 change: 1 addition & 0 deletions changes/1179-samuelcolvin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `validate_arguments` function decorator which checks the arguments to a function matches type annotations.
49 changes: 33 additions & 16 deletions docs/build/exec_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import textwrap
import traceback
from pathlib import Path
from typing import Any
from typing import Any, List
from unittest.mock import patch

from ansi2html import Ansi2HTMLConverter
Expand Down Expand Up @@ -59,13 +59,34 @@ def __call__(self, *args, file=None, flush=None):
return
s = ' '.join(map(to_string, args))

lines = []
for line in s.split('\n'):
if len(line) > MAX_LINE_LENGTH - 3:
lines += textwrap.wrap(line, width=MAX_LINE_LENGTH - 3)
else:
lines.append(line)
self.statements.append((frame.f_lineno, lines))
self.statements.append((frame.f_lineno, s))


def build_print_lines(s: str, max_len_reduction: int = 0):
print_lines = []
max_len = MAX_LINE_LENGTH - 3 - max_len_reduction
for line in s.split('\n'):
if len(line) > max_len:
print_lines += textwrap.wrap(line, width=max_len)
else:
print_lines.append(line)
return print_lines


def build_print_statement(line_no: int, s: str, lines: List[str]) -> None:
indent = ''
for back in range(1, 100):
m = re.search(r'^( *)print\(', lines[line_no - back])
if m:
indent = m.group(1)
break
print_lines = build_print_lines(s, len(indent))

if len(print_lines) > 2:
text = textwrap.indent('"""\n{}\n"""'.format('\n'.join(print_lines)), indent)
else:
text = '\n'.join(f'{indent}#> {line}' for line in print_lines)
lines.insert(line_no, text)


def all_md_contents() -> str:
Expand Down Expand Up @@ -154,15 +175,11 @@ def error(desc: str):
lines = [line for line in lines if line != to_json_line]
if len(mp.statements) != 1:
error('should have exactly one print statement')
new_files[file.stem + '.json'] = '\n'.join(mp.statements[0][1]) + '\n'

print_lines = build_print_lines(mp.statements[0][1])
new_files[file.stem + '.json'] = '\n'.join(print_lines) + '\n'
else:
for line_no, print_lines in reversed(mp.statements):
if len(print_lines) > 2:
text = '"""\n{}\n"""'.format('\n'.join(print_lines))
else:
text = '\n'.join('#> ' + l for l in print_lines)
lines.insert(line_no, text)
for line_no, print_string in reversed(mp.statements):
build_print_statement(line_no, print_string, lines)

try:
ignore_above = lines.index('# ignore-above')
Expand Down
27 changes: 27 additions & 0 deletions docs/examples/validation_decorator_async.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
class Connection:
async def execute(self, sql, *args):
return 'testing@example.com'

conn = Connection()
# ignore-above
import asyncio
from pydantic import PositiveInt, ValidationError, validate_arguments

@validate_arguments
async def get_user_email(user_id: PositiveInt):
# `conn` is some fictional connection to a database
email = await conn.execute('select email from users where id=$1', user_id)
if email is None:
raise RuntimeError('user not found')
else:
return email

async def main():
email = await get_user_email(123)
print(email)
try:
await get_user_email(-4)
except ValidationError as exc:
print(exc.errors())

asyncio.run(main())
17 changes: 17 additions & 0 deletions docs/examples/validation_decorator_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from pydantic import validate_arguments, ValidationError

@validate_arguments
def repeat(s: str, count: int, *, separator: bytes = b'') -> bytes:
b = s.encode()
return separator.join(b for _ in range(count))

a = repeat('hello', 3)
print(a)

b = repeat('x', '4', separator=' ')
print(b)

try:
c = repeat('hello', 'wrong')
except ValidationError as exc:
print(exc)
55 changes: 55 additions & 0 deletions docs/examples/validation_decorator_parameter_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from pydantic import validate_arguments

@validate_arguments
def pos_or_kw(a: int, b: int = 2) -> str:
return f'a={a} b={b}'

print(pos_or_kw(1))
print(pos_or_kw(a=1))
print(pos_or_kw(1, 3))
print(pos_or_kw(a=1, b=3))

@validate_arguments
def kw_only(*, a: int, b: int = 2) -> str:
return f'a={a} b={b}'

print(kw_only(a=1))
print(kw_only(a=1, b=3))

@validate_arguments
def pos_only(a: int, b: int = 2, /) -> str: # python 3.8 only
return f'a={a} b={b}'

print(pos_only(1))
print(pos_only(1, 2))

@validate_arguments
def var_args(*args: int) -> str:
return str(args)

print(var_args(1))
print(var_args(1, 2))
print(var_args(1, 2, 3))

@validate_arguments
def var_kwargs(**kwargs: int) -> str:
return str(kwargs)

print(var_kwargs(a=1))
print(var_kwargs(a=1, b=2))

@validate_arguments
def armageddon(
a: int,
/, # python 3.8 only
b: int,
c: int = None,
*d: int,
e: int,
f: int = None,
**g: int
) -> str:
return f'a={a} b={b} c={c} d={d} e={e} f={f} g={g}'

print(armageddon(1, 2, e=3))
print(armageddon(1, 2, 3, 4, 5, 6, c=7, e=8, f=9, g=10, spam=11))
12 changes: 12 additions & 0 deletions docs/examples/validation_decorator_raw_function.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from pydantic import validate_arguments

@validate_arguments
def repeat(s: str, count: int, *, separator: bytes = b'') -> bytes:
b = s.encode()
return separator.join(b for _ in range(count))

a = repeat('hello', 3)
print(a)

b = repeat.raw_function('good bye', 2, separator=b', ')
print(b)
15 changes: 15 additions & 0 deletions docs/examples/validation_decorator_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from pathlib import Path
from typing import Pattern, Optional

from pydantic import validate_arguments, DirectoryPath

@validate_arguments
def find_file(path: DirectoryPath, regex: Pattern, max=None) -> Optional[Path]:
for i, f in enumerate(path.glob('**/*')):
if max and i > max:
return
if f.is_file() and regex.fullmatch(str(f.relative_to(path))):
return f

print(find_file('/etc/', '^sys.*'))
print(find_file('/etc/', '^foobar.*', max=3))
13 changes: 9 additions & 4 deletions docs/usage/mypy.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Pydantic works with [mypy](http://mypy-lang.org/) provided you use the annotation-only version of
Pydantic models work with [mypy](http://mypy-lang.org/) provided you use the annotation-only version of
required fields:

```py
Expand All @@ -20,7 +20,6 @@ If you call mypy on the example code above, you should see mypy detect the attri
13: error: "Model" has no attribute "middle_name"
```


## Strict Optional

For your code to pass with `--strict-optional`, you need to to use `Optional[]` or an alias of `Optional[]`
Expand All @@ -39,5 +38,11 @@ If these aren't sufficient you can of course define your own.

Pydantic ships with a mypy plugin that adds a number of important pydantic-specific
features to mypy that improve its ability to type-check your code.

See the [pydantic mypy plugin docs](../mypy_plugin.md) for more details.

See the [pydantic mypy plugin docs](../mypy_plugin.md) for more details.


## Other pydantic interfaces

Pydantic [dataclasses](dataclasses.md) and the [`validate_assignment` decorator](validation_decorator.md)
should also work well with mypy.
144 changes: 144 additions & 0 deletions docs/usage/validation_decorator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
The `validate_assignment` decorator allows the arguments passed to a function to be parsed and validated using
the function's annotations before the function is called. While under the hood this uses the same approach of model
creation and initialisation; it provides an extremely easy way to apply validation to your code with minimal
boilerplate.

!!! info "In Beta"
The `validate_assignment` decorator is in **beta**, it has been added to *pydantic* in **v1.5** on a
**provisional basis**. It may change significantly in future releases and its interface will not be concrete
until **v2**. Feedback from the community while it's still provisional would be extremely useful; either comment
on [#1205](https://github.com/samuelcolvin/pydantic/issues/1205) or create a new issue.

Example of usage:

```py
{!.tmp_examples/validation_decorator_main.py!}
```
_(This script is complete, it should run "as is")_

## Argument Types

Argument types are inferred from type annotations on the function, arguments without a type decorator are considered
as `Any`. Since `validate_assignment` internally uses a standard `BaseModel`, all types listed in
[types](types.md) can be validated, including *pydantic* models and [custom types](types.md#custom-data-types).
As with the rest of *pydantic*, types can be coerced by the decorator before they're passed to the actual function:

```py
{!.tmp_examples/validation_decorator_types.py!}
```
_(This script is complete, it should run "as is")_

A few notes:
* through they're passed as strings `path` and `regex` are converted to a `Path` object and regex respectively,
by the decorator
* `max` has no type annotation, so will be considered as `Any` by the decorator

Type coercion like this can be extremely helpful but also confusing or not desired,
see [below](#coercion-and-stictness) for a discussion of `validate_assignment`'s limitations in this regard.

## Function Signatures

The decorator is designed to work with functions using all possible parameter configurations and all possible
combinations of these:

* positional or keyword arguments with or without defaults
* variable positional arguments defined via `*` (often `*args`)
* variable keyword arguments defined via `**` (often `**kwargs`)
* keyword only arguments - arguments after `*,`
* positional only arguments - arguments before `, /` (new in python 3.8)

To demonstrate all the above parameter types:

```py
{!.tmp_examples/validation_decorator_parameter_types.py!}
```
_(This script is complete, it should run "as is")_

## Usage with mypy

The `validate_assignment` decorator should work "out of the box" with [mypy](http://mypy-lang.org/) since it's
defined to return a function with the same signature as the function it decorates. The only limitation is that
since we trick mypy into thinking the function returned by the decorator is the same as the function being
decorated; access to the [raw function](#raw-function) or other attributes will require `type: ignore`.

## Raw function

The raw function which was decorated is accessible, this is useful if in some scenarios you trust your input
arguments and want to call the function in the most performant way (see [notes on performance](#performance) below):

```py
{!.tmp_examples/validation_decorator_raw_function.py!}
```
_(This script is complete, it should run "as is")_

## Async Functions

`validate_assignment` can also be used on async functions:

```py
{!.tmp_examples/validation_decorator_async.py!}
```
_(This script is complete, it should run "as is")_


## Limitations

`validate_assignment` has been released on a provisional basis without all the bells and whistles, which may
be added later, see [#1205](https://github.com/samuelcolvin/pydantic/issues/1205) for some more discussion of this.

In particular:

### Validation Exception

Currently upon validation failure, a standard *pydantic* `ValidationError` is raised,
see [model error handling](models.md#error-handling).

This is helpful since it's `str()` method provides useful details of the error which occurred and methods like
`.errors()` and `.json()` can be useful when exposing the errors to end users, however `ValidationError` inherits
from `ValueError` **not** `TypeError` which may be unexpected since python would raise a `TypeError` upon invalid
or missing arguments. This may be addressed in future by either allow a custom error or raising a different
exception by default, or both.

### Coercion and Stictness

*pydantic* currently leans on the side of trying to coerce types rather than raise an error if a type is wrong,
see [model data conversion](models.md#data-conversion) and `validate_assignment` is no different.

See [#1098](https://github.com/samuelcolvin/pydantic/issues/1098) and other issues with the "strictness" label
for a discussion of this. If *pydantic* get's a "strict" mode in future, `validate_assignment` will have an option
to use this, it may even become the default for the decorator.

### Performance

We've made a big effort to make *pydantic* as performant as possible (see [the benchmarks](../benchmarks.md))
and argument inspect and model creation is only performed once when the function is defined, however
there will still be a performance impact to using the `validate_assignment` decorator compared to
calling the raw function.

In many situations this will have little or no noticeable effect, however be aware that
`validate_assignment` is not an equivalent or alternative to function definitions in strongly typed languages,
it never will be.

### Return Value

The return value of the function is not validated against its return type annotation, this may be added as an option
in future.

### Config and Validators

Custom [`Config`](model_config.md) and [validators](validators.md) are not yet supported.

### Model fields and reserved arguments

The following names may not be used by arguments since they can be used internally to store information about
the function's signature:

* `v__args`
* `v__kwargs`
* `v__positional_only`

These names (together with `"args"` and `"kwargs"`) may or may not (depending on the function's signature) appear as
fields on the internal *pydantic* model accessible via `.model` thus this model isn't especially useful
(e.g. for generating a schema) at the moment.

This should be fixable in future as the way error are raised is changed.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ nav:
- usage/schema.md
- usage/exporting_models.md
- usage/dataclasses.md
- usage/validation_decorator.md
- 'Settings management': usage/settings.md
- usage/postponed_annotations.md
- 'Usage with mypy': usage/mypy.md
Expand Down