Skip to content

Commit

Permalink
Mypy plugin (#722)
Browse files Browse the repository at this point in the history
* Add mypy plugin

* Make all arguments optional for BaseSettings

* Get test coverage up

* Add changes

* Add type-checking for BaseModel.construct, and checking for from_orm

* Fix formatting and linting

* Fix the build

* Heavy refactor of plugin and mypy tests

* Make linting pass

* Handle dynamic aliases

* Better organize plugin code

* Add docs

* Add support for error codes

* Fix minor docs typo

* Rename config settings, add docstrings, and incorporate other feedback

* Incorporate feedback

* Update docs, remove dataclasses for cython

* fix mypy example
  • Loading branch information
dmontagu authored and samuelcolvin committed Oct 31, 2019
1 parent 347be1c commit 0c18619
Show file tree
Hide file tree
Showing 24 changed files with 1,322 additions and 96 deletions.
1 change: 1 addition & 0 deletions changes/722-dmontagu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a mypy plugin for type checking `BaseModel.__init__` and more
66 changes: 37 additions & 29 deletions docs/build/exec_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,37 +119,45 @@ def error(desc: str):
error('no trailing new line')
if re.search('^ *# *>', file_text, flags=re.M):
error('contains comments with print output, please remove')
no_print_intercept_re = re.compile(r'^# no-print-intercept\n', flags=re.M)
no_print_intercept = bool(no_print_intercept_re.search(file_text))
if no_print_intercept:
file_text = no_print_intercept_re.sub('', file_text)

mp = MockPrint(file)
with patch('builtins.print') as mock_print:
if not no_print_intercept:
mock_print.side_effect = mp
try:
importlib.import_module(file.stem)
except Exception:
tb = traceback.format_exception(*sys.exc_info())
error(''.join(e for e in tb if '/pydantic/docs/examples/' in e or not e.startswith(' File ')))

lines = file_text.split('\n')

to_json_line = '# output-json'
if to_json_line in lines:
lines = [line for line in lines if line != to_json_line]
if len(mp.statements) != 1:
error('should only have one print statement')
new_files[file.stem + '.json'] = '\n'.join(mp.statements[0][1]) + '\n'

dont_execute_re = re.compile(r'^# dont-execute\n', flags=re.M)
if dont_execute_re.search(file_text):
lines = dont_execute_re.sub('', file_text).split('\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)
no_print_intercept_re = re.compile(r'^# no-print-intercept\n', flags=re.M)
no_print_intercept = bool(no_print_intercept_re.search(file_text))
if no_print_intercept:
file_text = no_print_intercept_re.sub('', file_text)

mp = MockPrint(file)
with patch('builtins.print') as mock_print:
if not no_print_intercept:
mock_print.side_effect = mp
try:
mod = importlib.import_module(file.stem)
except Exception:
tb = traceback.format_exception(*sys.exc_info())
error(''.join(e for e in tb if '/pydantic/docs/examples/' in e or not e.startswith(' File ')))

if not mod.__file__.startswith(str(EXAMPLES_DIR)):
error(f'module path not inside "{EXAMPLES_DIR}", name may shadow another module?')

lines = file_text.split('\n')

to_json_line = '# output-json'
if to_json_line in lines:
lines = [line for line in lines if line != to_json_line]
if len(mp.statements) != 1:
error('should only have one print statement')
new_files[file.stem + '.json'] = '\n'.join(mp.statements[0][1]) + '\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)

try:
ignore_above = lines.index('# ignore-above')
Expand Down
6 changes: 3 additions & 3 deletions docs/examples/mypy.py → docs/examples/mypy_example.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# dont-execute
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, NoneStr
Expand All @@ -10,6 +11,5 @@ class Model(BaseModel):
list_of_ints: List[int]

m = Model(age=42, list_of_ints=[1, '2', b'3'])
print(m.age)
Model()
# will raise a validation error for age and list_of_ints
print(m.middle_name) # not a model field!
Model() # will raise a validation error for age and list_of_ints
136 changes: 136 additions & 0 deletions docs/mypy_plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
Pydantic works well with [mypy](http://mypy-lang.org/) right [out of the box](usage/mypy.md).

However, Pydantic also 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.

For example, consider the following script:
```py
{!.tmp_examples/mypy_example.py!}
```

Without any special configuration, mypy catches one of the errors (see [here](usage/mypy.md) for usage instructions):
```
13: error: "Model" has no attribute "middle_name"
```

But [with the plugin enabled](#enabling-the-plugin), it catches both:
```
13: error: "Model" has no attribute "middle_name"
16: error: Missing named argument "age" for "Model"
16: error: Missing named argument "list_of_ints" for "Model"
```

With the pydantic mypy plugin, you can fearlessly refactor your models knowing mypy will catch any mistakes
if your field names or types change.

There are other benefits too! See below for more details.

### Plugin Capabilities

#### Generate a signature for `Model.__init__`
* Any required fields that don't have dynamically-determined aliases will be included as required
keyword arguments.
* If `Config.allow_population_by_field_name=True`, the generated signature will use the field names,
rather than aliases.
* For subclasses of [`BaseSettings`](usage/settings.md), all fields are treated as optional since they may be
read from the environment.
* If `Config.extra="forbid"` and you don't make use of dynamically-determined aliases, the generated signature
will not allow unexpected inputs.
* **Optional:** If the [`init_forbid_extra` **plugin setting**](#plugin-settings) is set to `True`, unexpected inputs to
`__init__` will raise errors even if `Config.extra` is not `"forbid"`.
* **Optional:** If the [`init_typed` **plugin setting**](#plugin-settings) is set to `True`, the generated signature
will use the types of the model fields (otherwise they will be annotated as `Any` to allow parsing).

#### Generate a typed signature for `Model.construct`
* The [`construct`](usage/models.md#creating-models-without-validation) method is a faster alternative to `__init__`
when input data is known to be valid and does not need to be parsed. But because this method performs no runtime
validation, static checking is important to detect errors.

#### Respect `Config.allow_mutation`
* If `Config.allow_mutation` is `False`, you'll get a mypy error if you try to change
the value of a model field; cf. [faux immutability](usage/models.md#faux-immutability).

#### Respect `Config.orm_mode`
* If `Config.orm_mode` is `False`, you'll get a mypy error if you try to call `.from_orm()`;
cf. [ORM mode](usage/models.md#orm-mode-aka-arbitrary-class-instances)

### Optional Capabilites:
#### Prevent the use of required dynamic aliases
* If the [`warn_required_dynamic_aliases` **plugin setting**](#plugin-settings) is set to `True`, you'll get a mypy
error any time you use a dynamically-determined alias or alias generator on a model with
`Config.allow_population_by_field_name=False`.
* This is important because if such aliases are present, mypy cannot properly type check calls to `__init__`.
In this case, it will default to treating all arguments as optional.

#### Prevent the use of untyped fields
* If the [`warn_untyped_fields` **plugin setting**](#plugin-settings) is set to `True`, you'll get a mypy error
any time you create a field on a model without annotating its type.
* This is important because non-annotated fields may result in
[**validators being applied in a surprising order**](usage/models.md#field-ordering).
* In addition, mypy may not be able to correctly infer the type of the field, and may miss
checks or raise spurious errors.

### Enabling the Plugin

To enable the plugin, just add `pydantic.mypy` to the list of plugins in your
[mypy config file](https://mypy.readthedocs.io/en/latest/config_file.html)
(this could be `mypy.ini` or `setup.cfg`).

To get started, all you need to do is create a `mypy.ini` file with following contents:
```ini
[mypy]
plugins = pydantic.mypy
```

See the [mypy usage](usage/mypy.md) and [plugin configuration](#configuring-the-plugin) docs for more details.

### Plugin Settings

The plugin offers a few optional strictness flags if you want even stronger checks:

* `init_forbid_extra`

If enabled, disallow extra arguments to the `__init__` call even when `Config.extra` is not `"forbid"`.

* `init_typed`

If enabled, include the field types as type hints in the generated signature for the `__init__` method.
This means that you'll get mypy errors if you pass an argument that is not already the right type to
`__init__`, even if parsing could safely convert the type.

* `warn_required_dynamic_aliases`

If enabled, raise a mypy error whenever a model is created for which
calls to its `__init__` or `construct` methods require the use of aliases that cannot be statically determined.
This is the case, for example, if `allow_population_by_field_name=False` and the model uses an alias generator.

* `warn_untyped_fields`

If enabled, raise a mypy error whenever a field is declared on a model without explicitly specifying its type.


#### Configuring the Plugin
To change the values of the plugin settings, create a section in your mypy config file called `[pydantic-mypy]`,
and add any key-value pairs for settings you want to override.

A `mypy.ini` file with all plugin strictness flags enabled (and some other mypy strictness flags, too) might look like:
```ini
[mypy]
plugins = pydantic.mypy

follow_imports = silent
strict_optional = True
warn_redundant_casts = True
warn_unused_ignores = True
disallow_any_generics = True
check_untyped_defs = True

# for strict mypy: (this is the tricky one :-))
disallow_untyped_defs = True

[pydantic-mypy]
init_forbid_extra = True
init_typed = True
warn_required_dynamic_aliases = True
warn_untyped_fields = True
```
18 changes: 15 additions & 3 deletions docs/usage/mypy.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ Pydantic works with [mypy](http://mypy-lang.org/) provided you use the annotatio
required fields:

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

You can also run it through mypy with:
You can run your code through mypy with:

```bash
mypy \
Expand All @@ -16,6 +15,12 @@ mypy \
pydantic_mypy_test.py
```

If you call mypy on the example code above, you should see mypy detect the attribute access error:
```
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 @@ -29,3 +34,10 @@ Pydantic provides a few useful optional or union types:
* `NoneStrBytes` aka. `Optional[StrBytes]`

If these aren't sufficient you can of course define your own.

## Mypy Plugin

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.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ nav:
- 'Usage with devtools': usage/devtools.md
- Contributing to pydantic: contributing.md
- benchmarks.md
- 'Mypy plugin': mypy_plugin.md
- 'PyCharm plugin': pycharm_plugin.md
- changelog.md

Expand Down

0 comments on commit 0c18619

Please sign in to comment.