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

mypy plugin support for dataclasses #966

Merged
1 change: 1 addition & 0 deletions changes/966-koxudaxi.md
@@ -0,0 +1 @@
Mypy plugin support for dataclasses
6 changes: 5 additions & 1 deletion docs/mypy_plugin.md
Expand Up @@ -53,7 +53,11 @@ There are other benefits too! See below for more details.
#### 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)


#### Generate a signature for `dataclasses`
* classes decorated with [`@pydantic.dataclasess.dataclass`](usage/dataclasses.md) are type checked the same as standard python dataclasses
* The `@pydantic.dataclasess.dataclass` decorator accepts a `config` keyword argument which has the same meaning as [the `Config` sub-class](usage/model_config.md).

### 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
Expand Down
7 changes: 2 additions & 5 deletions docs/usage/dataclasses.md
Expand Up @@ -22,11 +22,8 @@ created by the standard library `dataclass` decorator.
`pydantic.dataclasses.dataclass`'s arguments are the same as the standard decorator, except one extra
keyword argument `config` which has the same meaning as [Config](model_config.md).

!!! note
As a side effect of getting pydantic dataclasses to play nicely with mypy, the `config` argument will show
as invalid in IDEs and mypy. Use `@dataclass(..., config=Config) # type: ignore` as a workaround.

See [python/mypy#6239](https://github.com/python/mypy/issues/6239) for an explanation of this issue.
!!! warning
After v1.2, [The Mypy plugin](mypy.md) must be installed to type check pydantic dataclasses.
koxudaxi marked this conversation as resolved.
Show resolved Hide resolved

For more information about combining validators with dataclasses, see
[dataclass validators](validators.md#dataclass-validators).
Expand Down
62 changes: 30 additions & 32 deletions pydantic/dataclasses.py
Expand Up @@ -9,7 +9,7 @@
from .typing import AnyType

if TYPE_CHECKING:
from .main import BaseConfig, BaseModel # noqa: F401
from .main import BaseModel # noqa: F401

class DataclassType:
__pydantic_model__: Type[BaseModel]
Expand All @@ -22,6 +22,9 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
def __validate__(cls, v: Any) -> 'DataclassType':
pass

def __call__(self, *args: Any, **kwargs: Any) -> 'DataclassType':
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this use a TypeVar?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you expect this?

    def __call__(self: T, *args: Any, **kwargs: Any) -> T:
        pass

Also, Did you want to replace other -> 'DataclassType' like __init__ ?

Copy link
Contributor

@dmontagu dmontagu Nov 14, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's what I meant, I think it would be better (assuming that is the right signature, which I think it is?).

I don't think the __init__ signature needs to change, but maybe the _validate_dataclass one should.

(Any other places where 'DataclassType' is currently used probably should; I believe this is what is done for factory classmethods for BaseModel -- I don't see why it shouldn't be that way for the dataclasses.)

Also, I would recommend using something like DataclassT as the name of the TypeVar, to make it slightly more obvious what's happening, and give it bound=DataclassType. (The TypeVar for BaseModel in pydantic.main is called Model; I think that's nice.)

pass


def _validate_dataclass(cls: Type['DataclassType'], v: Any) -> 'DataclassType':
if isinstance(v, cls):
Expand Down Expand Up @@ -59,7 +62,7 @@ def _process_class(
order: bool,
unsafe_hash: bool,
frozen: bool,
config: Type['BaseConfig'],
config: Optional[Type[Any]],
) -> 'DataclassType':
post_init_original = getattr(_cls, '__post_init__', None)
if post_init_original and post_init_original.__name__ == '_pydantic_post_init':
Expand Down Expand Up @@ -105,33 +108,28 @@ def _pydantic_post_init(self: 'DataclassType', *initvars: Any) -> None:
return cls


if TYPE_CHECKING:
# see https://github.com/python/mypy/issues/6239 for explanation of why we do this
from dataclasses import dataclass as dataclass
else:

def dataclass(
_cls: Optional[AnyType] = None,
*,
init: bool = True,
repr: bool = True,
eq: bool = True,
order: bool = False,
unsafe_hash: bool = False,
frozen: bool = False,
config: Type['BaseConfig'] = None,
) -> Union[Callable[[AnyType], 'DataclassType'], 'DataclassType']:
"""
Like the python standard lib dataclasses but with type validation.

Arguments are the same as for standard dataclasses, except for validate_assignment which has the same meaning
as Config.validate_assignment.
"""

def wrap(cls: AnyType) -> 'DataclassType':
return _process_class(cls, init, repr, eq, order, unsafe_hash, frozen, config)

if _cls is None:
return wrap

return wrap(_cls)
def dataclass(
_cls: Optional[AnyType] = None,
*,
init: bool = True,
repr: bool = True,
eq: bool = True,
order: bool = False,
unsafe_hash: bool = False,
frozen: bool = False,
config: Type[Any] = None,
) -> Union[Callable[[AnyType], 'DataclassType'], 'DataclassType']:
"""
Like the python standard lib dataclasses but with type validation.

Arguments are the same as for standard dataclasses, except for validate_assignment which has the same meaning
as Config.validate_assignment.
"""

def wrap(cls: AnyType) -> 'DataclassType':
return _process_class(cls, init, repr, eq, order, unsafe_hash, frozen, config)

if _cls is None:
return wrap

return wrap(_cls)
7 changes: 7 additions & 0 deletions pydantic/mypy.py
Expand Up @@ -33,6 +33,7 @@
)
from mypy.options import Options
from mypy.plugin import CheckerPluginInterface, ClassDefContext, MethodContext, Plugin, SemanticAnalyzerPluginInterface
from mypy.plugins import dataclasses
from mypy.semanal import set_callable_name # type: ignore
from mypy.server.trigger import make_wildcard_trigger
from mypy.types import (
Expand All @@ -55,6 +56,7 @@
BASEMODEL_FULLNAME = 'pydantic.main.BaseModel'
BASESETTINGS_FULLNAME = 'pydantic.env_settings.BaseSettings'
FIELD_FULLNAME = 'pydantic.fields.Field'
DATACLASS_FULLNAME = 'pydantic.dataclasses.dataclass'


def plugin(version: str) -> 'TypingType[Plugin]':
Expand Down Expand Up @@ -85,6 +87,11 @@ def get_method_hook(self, fullname: str) -> Optional[Callable[[MethodContext], T
return from_orm_callback
return None

def get_class_decorator_hook(self, fullname: str) -> Optional[Callable[[ClassDefContext], None]]:
if fullname == DATACLASS_FULLNAME:
return dataclasses.dataclass_class_maker_callback
samuelcolvin marked this conversation as resolved.
Show resolved Hide resolved
return None

def _pydantic_model_class_maker_callback(self, ctx: ClassDefContext) -> None:
transformer = PydanticModelTransformer(ctx, self.plugin_config)
transformer.transform()
Expand Down
11 changes: 11 additions & 0 deletions tests/mypy/modules/plugin_fail.py
@@ -1,6 +1,7 @@
from typing import Any, Generic, Optional, Set, TypeVar, Union

from pydantic import BaseModel, BaseSettings, Extra, Field
from pydantic.dataclasses import dataclass
from pydantic.generics import GenericModel


Expand Down Expand Up @@ -191,3 +192,13 @@ def from_orm(self) -> None:


CoverageTester().from_orm()


@dataclass(config={})
class AddProject:
name: str
slug: Optional[str]
description: Optional[str]


p = AddProject(name='x', slug='y', description='z')
15 changes: 15 additions & 0 deletions tests/mypy/modules/plugin_success.py
@@ -1,6 +1,7 @@
from typing import ClassVar, Optional

from pydantic import BaseModel, Field
from pydantic.dataclasses import dataclass


class Model(BaseModel):
Expand Down Expand Up @@ -104,3 +105,17 @@ class ClassVarModel(BaseModel):


ClassVarModel(x=1)


class Config:
validate_assignment = True


@dataclass(config=Config)
class AddProject:
samuelcolvin marked this conversation as resolved.
Show resolved Hide resolved
name: str
slug: Optional[str]
description: Optional[str]


p = AddProject(name='x', slug='y', description='z')
11 changes: 0 additions & 11 deletions tests/mypy/modules/success.py
Expand Up @@ -9,7 +9,6 @@
from typing import Generic, List, Optional, TypeVar

from pydantic import BaseModel, NoneStr, StrictBool
from pydantic.dataclasses import dataclass
from pydantic.fields import Field
from pydantic.generics import GenericModel

Expand Down Expand Up @@ -81,16 +80,6 @@ def day_of_week(dt: datetime) -> int:
assert m_copy.list_of_ints == m_from_obj.list_of_ints


@dataclass
class AddProject:
name: str
slug: Optional[str]
description: Optional[str]


p = AddProject(name='x', slug='y', description='z')


if sys.version_info >= (3, 7):
T = TypeVar('T')

Expand Down
3 changes: 3 additions & 0 deletions tests/mypy/outputs/fail4.txt
@@ -0,0 +1,3 @@
121: error: Unexpected keyword argument "name" for "AddProject" [call-arg]
121: error: Unexpected keyword argument "slug" for "AddProject" [call-arg]
121: error: Unexpected keyword argument "description" for "AddProject" [call-arg]
69 changes: 35 additions & 34 deletions tests/mypy/outputs/plugin-fail-strict.txt
@@ -1,34 +1,35 @@
23: error: Unexpected keyword argument "z" for "Model" [call-arg]
samuelcolvin marked this conversation as resolved.
Show resolved Hide resolved
24: error: Missing named argument "y" for "Model" [call-arg]
25: error: Property "y" defined in "Model" is read-only [misc]
26: error: "Model" does not have orm_mode=True [pydantic-orm]
35: error: Unexpected keyword argument "x" for "ForbidExtraModel" [call-arg]
46: error: Unexpected keyword argument "x" for "ForbidExtraModel2" [call-arg]
52: error: Invalid value for "Config.extra" [pydantic-config]
57: error: Invalid value for "Config.orm_mode" [pydantic-config]
62: error: Invalid value for "Config.orm_mode" [pydantic-config]
73: error: Incompatible types in assignment (expression has type "ellipsis", variable has type "int") [assignment]
76: error: Untyped fields disallowed [pydantic-field]
83: error: Untyped fields disallowed [pydantic-field]
86: error: Missing named argument "a" for "DefaultTestingModel" [call-arg]
86: error: Missing named argument "b" for "DefaultTestingModel" [call-arg]
86: error: Missing named argument "c" for "DefaultTestingModel" [call-arg]
86: error: Missing named argument "d" for "DefaultTestingModel" [call-arg]
86: error: Missing named argument "e" for "DefaultTestingModel" [call-arg]
90: error: Name 'Undefined' is not defined [name-defined]
93: error: Missing named argument "undefined" for "UndefinedAnnotationModel" [call-arg]
100: error: Missing named argument "y" for "construct" of "Model" [call-arg]
102: error: Argument "x" to "construct" of "Model" has incompatible type "str"; expected "int" [arg-type]
107: error: Argument "x" to "InheritingModel" has incompatible type "str"; expected "int" [arg-type]
108: error: Argument "x" to "Settings" has incompatible type "str"; expected "int" [arg-type]
109: error: Argument "x" to "Model" has incompatible type "str"; expected "int" [arg-type]
126: error: Argument "data" to "Response" has incompatible type "int"; expected "Model" [arg-type]
134: error: Argument "y" to "AliasModel" has incompatible type "int"; expected "str" [arg-type]
140: error: Required dynamic aliases disallowed [pydantic-alias]
144: error: Argument "z" to "DynamicAliasModel" has incompatible type "str"; expected "int" [arg-type]
155: error: Unexpected keyword argument "y" for "DynamicAliasModel2" [call-arg]
162: error: Required dynamic aliases disallowed [pydantic-alias]
180: error: Untyped fields disallowed [pydantic-field]
184: error: Unexpected keyword argument "x" for "AliasGeneratorModel2" [call-arg]
185: error: Unexpected keyword argument "z" for "AliasGeneratorModel2" [call-arg]
188: error: Name 'Missing' is not defined [name-defined]
24: error: Unexpected keyword argument "z" for "Model" [call-arg]
25: error: Missing named argument "y" for "Model" [call-arg]
26: error: Property "y" defined in "Model" is read-only [misc]
27: error: "Model" does not have orm_mode=True [pydantic-orm]
36: error: Unexpected keyword argument "x" for "ForbidExtraModel" [call-arg]
47: error: Unexpected keyword argument "x" for "ForbidExtraModel2" [call-arg]
53: error: Invalid value for "Config.extra" [pydantic-config]
58: error: Invalid value for "Config.orm_mode" [pydantic-config]
63: error: Invalid value for "Config.orm_mode" [pydantic-config]
74: error: Incompatible types in assignment (expression has type "ellipsis", variable has type "int") [assignment]
77: error: Untyped fields disallowed [pydantic-field]
84: error: Untyped fields disallowed [pydantic-field]
87: error: Missing named argument "a" for "DefaultTestingModel" [call-arg]
87: error: Missing named argument "b" for "DefaultTestingModel" [call-arg]
87: error: Missing named argument "c" for "DefaultTestingModel" [call-arg]
87: error: Missing named argument "d" for "DefaultTestingModel" [call-arg]
87: error: Missing named argument "e" for "DefaultTestingModel" [call-arg]
91: error: Name 'Undefined' is not defined [name-defined]
94: error: Missing named argument "undefined" for "UndefinedAnnotationModel" [call-arg]
101: error: Missing named argument "y" for "construct" of "Model" [call-arg]
103: error: Argument "x" to "construct" of "Model" has incompatible type "str"; expected "int" [arg-type]
108: error: Argument "x" to "InheritingModel" has incompatible type "str"; expected "int" [arg-type]
109: error: Argument "x" to "Settings" has incompatible type "str"; expected "int" [arg-type]
110: error: Argument "x" to "Model" has incompatible type "str"; expected "int" [arg-type]
127: error: Argument "data" to "Response" has incompatible type "int"; expected "Model" [arg-type]
135: error: Argument "y" to "AliasModel" has incompatible type "int"; expected "str" [arg-type]
141: error: Required dynamic aliases disallowed [pydantic-alias]
145: error: Argument "z" to "DynamicAliasModel" has incompatible type "str"; expected "int" [arg-type]
156: error: Unexpected keyword argument "y" for "DynamicAliasModel2" [call-arg]
163: error: Required dynamic aliases disallowed [pydantic-alias]
181: error: Untyped fields disallowed [pydantic-field]
185: error: Unexpected keyword argument "x" for "AliasGeneratorModel2" [call-arg]
186: error: Unexpected keyword argument "z" for "AliasGeneratorModel2" [call-arg]
189: error: Name 'Missing' is not defined [name-defined]
197: error: Argument "config" to "dataclass" has incompatible type "Dict[<nothing>, <nothing>]"; expected "Optional[Type[Any]]" [arg-type]
47 changes: 24 additions & 23 deletions tests/mypy/outputs/plugin-fail.txt
@@ -1,23 +1,24 @@
23: error: Unexpected keyword argument "z" for "Model" [call-arg]
24: error: Missing named argument "y" for "Model" [call-arg]
25: error: Property "y" defined in "Model" is read-only [misc]
26: error: "Model" does not have orm_mode=True [pydantic-orm]
35: error: Unexpected keyword argument "x" for "ForbidExtraModel" [call-arg]
46: error: Unexpected keyword argument "x" for "ForbidExtraModel2" [call-arg]
52: error: Invalid value for "Config.extra" [pydantic-config]
57: error: Invalid value for "Config.orm_mode" [pydantic-config]
62: error: Invalid value for "Config.orm_mode" [pydantic-config]
73: error: Incompatible types in assignment (expression has type "ellipsis", variable has type "int") [assignment]
86: error: Missing named argument "a" for "DefaultTestingModel" [call-arg]
86: error: Missing named argument "b" for "DefaultTestingModel" [call-arg]
86: error: Missing named argument "c" for "DefaultTestingModel" [call-arg]
86: error: Missing named argument "d" for "DefaultTestingModel" [call-arg]
86: error: Missing named argument "e" for "DefaultTestingModel" [call-arg]
90: error: Name 'Undefined' is not defined [name-defined]
93: error: Missing named argument "undefined" for "UndefinedAnnotationModel" [call-arg]
100: error: Missing named argument "y" for "construct" of "Model" [call-arg]
102: error: Argument "x" to "construct" of "Model" has incompatible type "str"; expected "int" [arg-type]
155: error: Missing named argument "x" for "DynamicAliasModel2" [call-arg]
174: error: unused 'type: ignore' comment
181: error: unused 'type: ignore' comment
188: error: Name 'Missing' is not defined [name-defined]
24: error: Unexpected keyword argument "z" for "Model" [call-arg]
25: error: Missing named argument "y" for "Model" [call-arg]
26: error: Property "y" defined in "Model" is read-only [misc]
27: error: "Model" does not have orm_mode=True [pydantic-orm]
36: error: Unexpected keyword argument "x" for "ForbidExtraModel" [call-arg]
47: error: Unexpected keyword argument "x" for "ForbidExtraModel2" [call-arg]
53: error: Invalid value for "Config.extra" [pydantic-config]
58: error: Invalid value for "Config.orm_mode" [pydantic-config]
63: error: Invalid value for "Config.orm_mode" [pydantic-config]
74: error: Incompatible types in assignment (expression has type "ellipsis", variable has type "int") [assignment]
87: error: Missing named argument "a" for "DefaultTestingModel" [call-arg]
87: error: Missing named argument "b" for "DefaultTestingModel" [call-arg]
87: error: Missing named argument "c" for "DefaultTestingModel" [call-arg]
87: error: Missing named argument "d" for "DefaultTestingModel" [call-arg]
87: error: Missing named argument "e" for "DefaultTestingModel" [call-arg]
91: error: Name 'Undefined' is not defined [name-defined]
94: error: Missing named argument "undefined" for "UndefinedAnnotationModel" [call-arg]
101: error: Missing named argument "y" for "construct" of "Model" [call-arg]
103: error: Argument "x" to "construct" of "Model" has incompatible type "str"; expected "int" [arg-type]
156: error: Missing named argument "x" for "DynamicAliasModel2" [call-arg]
175: error: unused 'type: ignore' comment
182: error: unused 'type: ignore' comment
189: error: Name 'Missing' is not defined [name-defined]
197: error: Argument "config" to "dataclass" has incompatible type "Dict[<nothing>, <nothing>]"; expected "Optional[Type[Any]]" [arg-type]
6 changes: 3 additions & 3 deletions tests/mypy/outputs/plugin-success-strict.txt
@@ -1,3 +1,3 @@
28: error: Unexpected keyword argument "z" for "Model" [call-arg]
63: error: Untyped fields disallowed [pydantic-field]
78: error: Argument "x" to "OverrideModel" has incompatible type "float"; expected "int" [arg-type]
29: error: Unexpected keyword argument "z" for "Model" [call-arg]
64: error: Untyped fields disallowed [pydantic-field]
79: error: Argument "x" to "OverrideModel" has incompatible type "float"; expected "int" [arg-type]
6 changes: 4 additions & 2 deletions tests/mypy/test_mypy.py
@@ -1,5 +1,6 @@
import importlib
import os
import re
from pathlib import Path

import pytest
Expand All @@ -25,7 +26,7 @@
('mypy-default.ini', 'fail1.py', 'fail1.txt'),
('mypy-default.ini', 'fail2.py', 'fail2.txt'),
('mypy-default.ini', 'fail3.py', 'fail3.txt'),
('mypy-default.ini', 'plugin_success.py', None),
('mypy-default.ini', 'plugin_success.py', 'fail4.txt'),
]
executable_modules = list({fname[:-3] for _, fname, out_fname in cases if out_fname is None})

Expand All @@ -52,7 +53,8 @@ def test_mypy_results(config_filename, python_filename, output_filename):
)
actual_out, actual_err, actual_returncode = actual_result
# Need to strip filenames due to differences in formatting by OS
actual_out = '\n'.join(['.py:'.join(line.split('.py:')[1:]) for line in actual_out.split('\n')]).strip()
actual_out = '\n'.join(['.py:'.join(line.split('.py:')[1:]) for line in actual_out.split('\n') if line]).strip()
actual_out = re.sub(r'\n\s*\n', r'\n', actual_out)

if GENERATE and output_filename is not None:
with open(full_output_filename, 'w') as f:
Expand Down