Skip to content

Commit

Permalink
mypy plugin support for dataclasses (#966)
Browse files Browse the repository at this point in the history
* mypy plugin support for dataclassesv

* fix styles and types

* - change type-hint for `Config`
- change name of an expected file
- update documents

* fix broken a reference of a document.

* - update unittest
- update documents

* fix a document link

* Update docs/mypy_plugin.md

Co-Authored-By: Samuel Colvin <samcolvin@gmail.com>

* Update docs/mypy_plugin.md

Co-Authored-By: Samuel Colvin <samcolvin@gmail.com>

* Update docs/mypy_plugin.md

Co-Authored-By: Samuel Colvin <samcolvin@gmail.com>

* remove extra whitespaces on mypy test results

* fix output file name of mypy test

* Update docs/usage/dataclasses.md

Co-Authored-By: Samuel Colvin <samcolvin@gmail.com>

* use TypeVar for DataclassType
  • Loading branch information
koxudaxi authored and samuelcolvin committed Nov 14, 2019
1 parent 0e8f886 commit 33b3dc1
Show file tree
Hide file tree
Showing 13 changed files with 146 additions and 115 deletions.
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 @@ -30,11 +30,8 @@ _(This script is complete, it should run "as is")_
`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_plugin.md) must be installed to type check pydantic dataclasses.

For more information about combining validators with dataclasses, see
[dataclass validators](validators.md#dataclass-validators).
Expand Down
72 changes: 36 additions & 36 deletions pydantic/dataclasses.py
@@ -1,5 +1,5 @@
import dataclasses
from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, Optional, Type, Union
from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, Optional, Type, TypeVar, Union

from .class_validators import gather_all_validators
from .error_wrappers import ValidationError
Expand All @@ -9,7 +9,9 @@
from .typing import AnyType

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

DataclassT = TypeVar('DataclassT', bound='DataclassType')

class DataclassType:
__pydantic_model__: Type[BaseModel]
Expand All @@ -19,11 +21,14 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
pass

@classmethod
def __validate__(cls, v: Any) -> 'DataclassType':
def __validate__(cls: Type['DataclassT'], v: Any) -> 'DataclassT':
pass

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


def _validate_dataclass(cls: Type['DataclassType'], v: Any) -> 'DataclassType':
def _validate_dataclass(cls: Type['DataclassT'], v: Any) -> 'DataclassT':
if isinstance(v, cls):
return v
elif isinstance(v, (list, tuple)):
Expand All @@ -34,7 +39,7 @@ def _validate_dataclass(cls: Type['DataclassType'], v: Any) -> 'DataclassType':
raise DataclassTypeError(class_name=cls.__name__)


def _get_validators(cls: Type['DataclassType']) -> Generator[Any, None, None]:
def _get_validators(cls: Type['DataclassT']) -> Generator[Any, None, None]:
yield cls.__validate__


Expand All @@ -59,7 +64,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 @@ -113,33 +118,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
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:
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 Any, Dict, Generic, List, Optional, TypeVar

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

Expand Down Expand Up @@ -94,16 +93,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]
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]

0 comments on commit 33b3dc1

Please sign in to comment.