8000 Implement support for returning TypedDict for dataclasses.asdict by syastrov · Pull Request #8583 · python/mypy · GitHub
[go: up one dir, main page]

Skip to content

Implement support for returning TypedDict for dataclasses.asdict #8583

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

Open
wants to merge 46 commits into
base: master
Choose a base branch
from
Open
Show file tree
8000
Hide file tree
Changes from 1 commit
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
bb9f051
Implement support for returning TypedDict for dataclasses.asdict
syastrov Mar 26, 2020
e2f9f06
Remove redundant test. Fix comment typo.
syastrov Mar 26, 2020
2f6ec2d
Test for cases where dataclasses.asdict is called on non-dataclass in…
syastrov Mar 26, 2020
a9779e2
Clean up tests, and test more edge-cases.
syastrov Mar 26, 2020
c5d0a15
Remove no-longer-needed type: ignore on CheckerPluginInterface.module…
syastrov Mar 26, 2020
454431d
Make typeddicts non-total.
syastrov Mar 26, 2020
d809e8b
Address some of review comments (formatting, docs, code nitpicks, rem…
syastrov Mar 30, 2020
4d195cc
Simplify: Remove _transform_type_args and remove unneeded None check …
syastrov Mar 30, 2020
b33798c
Fix unused import
syastrov Mar 30, 2020
fe19bc9
Add fine-grained test for dataclasses.asdict.
syastrov Mar 30, 2020
6328a7c
Oops, add forgotten fine-grained dataclasses test. And remove redunda…
syastrov Mar 30, 2020
4694299
Only import the module containing TypedDict fallback if dataclasses i…
syastrov Apr 1, 2020
9c29081
Only enable TypedDict for Python >= 3.8.
syastrov Apr 2, 2020
d7df77a
Refactor asdict implementation to use TypeTranslator instead of recur…
syastrov Apr 2, 2020
e9a56ba
Made TypedDicts returned by asdict total again.
syastrov Apr 2, 2020
2e5240e
Fixed test after total change.
syastrov Apr 2, 2020
52a1c27
Make code a bit more readable, and a bit more robust.
syastrov Apr 2, 2020
43f174c
Fix typo
syastrov Apr 2, 2020
227ba90
After refactoring to use TypeTranslator, ensure Callable and Type[..]…
syastrov Apr 2, 2020
d12c665
Address second review comments.
syastrov Apr 8, 2020
45e72d7
Fix return type
syastrov Apr 8, 2020
a03f033
Try to address more review comments and fix flake8
syastrov Jun 3, 2020
b4d7e15
Add fine grained deps test to help debug asdict dependencies.
syastrov Jun 3, 2020
d96d977
Fix some asdict tests missing tuple dependency
syastrov Jun 3, 2020
441b665
Revert "Fix some asdict tests missing tuple dependency"
syastrov Jun 4, 2020
344ca6a
Don't need dep on typing_extensions
syastrov Jun 4, 2020
c8858fa
Checker lookup_fully_qualified_or_none: Don't raise KeyError, return …
syastrov Jun 9, 2020
20d7716
Add dependencies for asdict on the referenced dataclasses and its att…
syastrov Jun 9, 2020
5fec41b
Fix fine-grained no-cache test by adding correct dep on dataclass attrs.
syastrov Aug 18, 2020
5862c16
remove unused imports
syastrov Aug 18, 2020
26b7393
Merge branch 'master' into dataclasses-asdict
syastrov Feb 17, 2021
d7e0310
Remove error when passing a "non-dataclass" to asdict to reduce false…
syastrov Feb 17, 2021
080c00c
Fix flake8
syastrov Feb 17, 2021
9e45f8f
Fix asdict tests (require using python version 3.7 minimum).
syastrov Feb 17, 2021
38b466a
Merge branch 'master' into dataclasses-asdict
syastrov Aug 19, 2021
74ebc6f
Fix tests for quoting changes
syastrov Aug 19, 2021
aef274a
Merge branch 'master' into dataclasses-asdict
97littleleaf11 Nov 17, 2021
79f25db
Merge
97littleleaf11 Jan 18, 2022
f54e503
Fix
97littleleaf11 Jan 18, 2022
9820cfc
Add fixture for tests
97littleleaf11 Jan 18, 2022
9f49cac
Add fixture for tests
97littleleaf11 Jan 18, 2022
fcd1ff5
Add fixture for tests
97littleleaf11 Jan 18, 2022
9fa6a6b
Merge from master
97littleleaf11 Jan 18, 2022
6e1585d
Fix
97littleleaf11 Jan 18, 2022
d6f9170
Merge branch 'master' of https://github.com/python/mypy into HEAD
97littleleaf11 Jan 19, 2022
e226562
Test for a workaround
97littleleaf11 Jan 19, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Address second review comments.
- Remove limitation that Python must be 3.8+.
- Move fine-grained test of asdict to main fine-grained test file.
- Correct fine-grained test of asdict so it actually tests it properly.
  This doesn't pass currently...
- Try to lookup anonymous TypedDict fallback using named_type_or_none,
- Add additional dependency only on typing_extensions.
-
copied from SemanticAnalyzer to TypeChecker.
  • Loading branch information
syastrov committed Aug 18, 2020
commit d12c665197a8e880ab25ed5619b37294552e3a52
6 changes: 3 additions & 3 deletions docs/source/additional_features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ Caveats/Known Issues
Some functions in the :py:mod:`dataclasses` module, such as :py:func:`~dataclasses.replace`,
have imprecise (too permissive) types. This will be fixed in future releases.

In Python version 3.8 and above, calls to :py:func:`~dataclasses.asdict` will return a ``TypedDict`` based on the
original dataclass definition, transforming it recursively. There are, however, some limitations. In particular, a
precise return type cannot be inferred for recursive dataclasses, and for calls where ``dict_factory`` is set.
Calls to :py:func:`~dataclasses.asdict` will return a ``TypedDict`` based on the original dataclass
definition, transforming it recursively. There are, however, some limitations. In particular, a precise return type
cannot be inferred for recursive dataclasses, and for calls where ``dict_factory`` is set.

Mypy does not yet recognize aliases of :py:func:`dataclasses.dataclass <dataclasses.dataclass>`, and will
probably never recognize dynamically computed decorators. The following examples
Expand Down
19 changes: 17 additions & 2 deletions mypy/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
Import, ImportFrom, ImportAll, ImportBase, TypeAlias,
ARG_POS, ARG_STAR, LITERAL_TYPE, MDEF, GDEF,
CONTRAVARIANT, COVARIANT, INVARIANT, TypeVarExpr, AssignmentExpr,
is_final_node,
ARG_NAMED)
is_final_node, ARG_NAMED, PlaceholderNode
)
from mypy import nodes
from mypy.literals import literal, literal_hash, Key
from mypy.typeanal import has_any_from_unimported_type, check_for_explicit_any
Expand Down Expand Up @@ -4593,6 +4593,21 @@ def named_type(self, name: str) -> Instance:
any_type = AnyType(TypeOfAny.from_omitted_generics)
return Instance(node, [any_type] * len(node.defn.type_vars))

def named_type_or_none(self, qualified_name: str,
args: Optional[List[Type]] = None) -> Optional[Instance]:
sym = self.lookup_qualified(qualified_name)
Copy link
Member

Choose a reason for hiding this comment

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

This one no better than it was. You need to copy/duplicate the important logic. In particular, my whole idea was to not use local lookup functions, but a global lookup like lookup_fully_qualified_or_none() in semantic analyzer.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I guess I misunderstood your comment. Not really knowing how the checker or semantic analyzer work internally, I don't really think I am qualified to make this change. However, I will try.

I have copied the lookup_fully_qualified_or_none function to checker (and only modified it to raise KeyError if the result is None) and used it in named_type_or_none.
I am not sure if this makes sense and whether the docstring/TODO comments should be altered/removed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops, late-night coding mistake :) I removed the part about raising KeyError, and it returns None now instead if it can't find the name of course.

if not sym or isinstance(sym.node, PlaceholderNode):
return None
node = sym.node
if isinstance(node, TypeAlias):
assert isinstance(node.target, Instance) # type: ignore
node = node.target.type
assert isinstance(node, TypeInfo), node
if args is not None:
# TODO: assert len(args) == len(node.defn.type_vars)
return Instance(node, args)
return Instance(node, [AnyType(TypeOfAny.unannotated)] * len(node.defn.type_vars))

def named_generic_type(self, name: str, args: List[Type]) -> Instance:
"""Return an instance with the given name and type arguments.

Expand Down
4 changes: 4 additions & 0 deletions mypy/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ def named_generic_type(self, name: str, args: List[Type]) -> Instance:
"""Construct an instance of a builtin type with given type arguments."""
raise NotImplementedError

def named_type_or_none(self, qualified_name: str,
args: Optional[List[Type]] = None) -> Optional[Instance]:
raise NotImplementedError


@trait
class SemanticAnalyzerPluginInterface:
Expand Down
10 changes: 1 addition & 9 deletions mypy/plugins/common.py
A3E2
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
)
from mypy.plugin import ClassDefContext, SemanticAnalyzerPluginInterface, CheckerPluginInterface
from mypy.semanal import set_callable_name
from mypy.semanal_typeddict import get_anonymous_typeddict_type
from mypy.types import (
CallableType, Overloaded, Type, TypeVarDef, deserialize_type, get_proper_type,
TypedDictType, Instance, TPDICT_FB_NAMES
Expand Down Expand Up @@ -165,15 +166,6 @@ def deserialize_and_fixup_type(
return typ


def get_anonymous_typeddict_type(api: CheckerPluginInterface) -> Instance:
for type_fullname in TPDICT_FB_NAMES:
try:
return api.named_generic_type(type_fullname, [])
except KeyError:
continue
raise RuntimeError("No TypedDict fallback type found")


def make_anonymous_typeddict(api: CheckerPluginInterface, fields: 'OrderedDict[str, Type]',
required_keys: Set[str]) -> TypedDictType:
return TypedDictType(fields, required_keys=required_keys,
Expand Down
6 changes: 3 additions & 3 deletions mypy/plugins/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,15 +381,15 @@ def _collect_field_args(expr: Expression) -> Tuple[bool, Dict[str, Expression]]:
return False, {}


def asdict_callback(ctx: FunctionContext, return_typeddicts: bool = False) -> Type:
"""Check that calls to asdict pass in a dataclass. Optionally, return TypedDicts."""
def asdict_callback(ctx: FunctionContext) -> Type:
"""Check that calls to asdict pass in a dataclass. If possible, return TypedDicts."""
positional_arg_types = ctx.arg_types[0]

if positional_arg_types:
dataclass_instance = get_proper_type(positional_arg_types[0])
if isinstance(dataclass_instance, Instance):
if is_type_dataclass(dataclass_instance.type):
if len(ctx.arg_types) == 1 and return_typeddicts:
if len(ctx.arg_types) == 1:
return _asdictify(ctx.api, dataclass_instance)
else:
# We can't infer a more precise type for calls where dict_factory is set.
Expand Down
12 changes: 5 additions & 7 deletions mypy/plugins/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
from typing import Callable, Optional, List, Tuple

from mypy import message_registry
from mypy.nodes import Expression, StrExpr, IntExpr, DictExpr, UnaryExpr, MypyFile
from mypy.nodes import (
Expression, StrExpr, IntExpr, DictExpr, UnaryExpr, MypyFile, ImportFrom, Import, ImportAll
)
from mypy.plugin import (
Plugin, FunctionContext, MethodContext, MethodSigContext, AttributeContext, ClassDefContext,
CheckerPluginInterface,
Expand All @@ -21,10 +23,7 @@ class DefaultPlugin(Plugin):
"""Type checker plugin that is enabled by default."""

def get_additional_deps(self, file: MypyFile) -> List[Tuple[int, str, int]]:
if self.python_version >= (3, 8):
# Add module needed for anonymous TypedDict (used to support dataclasses.asdict)
return [(10, "typing", -1)]
return []
return [(10, "typing_extensions", -1)]

def get_function_hook(self, fullname: str
) -> Optional[Callable[[FunctionContext], Type]]:
Expand All @@ -38,8 +37,7 @@ def get_function_hook(self, fullname: str
elif fullname == 'ctypes.Array':
return ctypes.array_constructor_callback
elif fullname == 'dataclasses.asdict':
return partial(dataclasses.asdict_callback,
return_typeddicts=self.python_version >= (3, 8))
return dataclasses.asdict_callback
return None

def get_method_signature_hook(self, fullname: str
Expand Down
18 changes: 12 additions & 6 deletions mypy/semanal_typeddict.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Semantic analysis of TypedDict definitions."""

from mypy.ordered_dict import OrderedDict
from typing import Optional, List, Set, Tuple
from collections import OrderedDict
from typing import Optional, List, Set, Tuple, Union
from typing_extensions import Final

from mypy.types import Type, AnyType, TypeOfAny, TypedDictType, TPDICT_NAMES
from mypy.plugin import CheckerPluginInterface
from mypy.types import Type, AnyType, TypeOfAny, TypedDictType, TPDICT_NAMES, Instance
from mypy.nodes import (
CallExpr, TypedDictExpr, Expression, NameExpr, Context, StrExpr, BytesExpr, UnicodeExpr,
ClassDef, RefExpr, TypeInfo, AssignmentStmt, PassStmt, ExpressionStmt, EllipsisExpr, TempNode,
Expand Down Expand Up @@ -304,10 +306,7 @@ def fail_typeddict_arg(self, message: str,
def build_typeddict_typeinfo(self, name: str, items: List[str],
types: List[Type],
required_keys: Set[str]) -> TypeInfo:
# Prefer typing then typing_extensions if available.
fallback = (self.api.named_type_or_none('typing._TypedDict', []) or
self.api.named_type_or_none('typing_extensions._TypedDict', []) or
self.api.named_type_or_none('mypy_extensions._TypedDict', []))
fallback = get_anonymous_typeddict_type(self.api)
assert fallback is not None
info = self.api.basic_new_typeinfo(name, fallback)
info.typeddict_type = TypedDictType(OrderedDict(zip(items, types)), required_keys,
Expand All @@ -322,3 +321,10 @@ def is_typeddict(self, expr: Expression) -> bool:

def fail(self, msg: str, ctx: Context) -> None:
self.api.fail(msg, ctx)

def get_anonymous_typeddict_type(
api: Union[SemanticAnalyzerInterface, CheckerPluginInterface]) -> Instance:
# Prefer typing then typing_extensions if available.
return (api.named_type_or_none('typing._TypedDict', []) or
api.named_type_or_none('typing_extensions._TypedDict', []) or
api.named_type_or_none('mypy_extensions._TypedDict', []))
1 change: 0 additions & 1 deletion mypy/test/testfinegrained.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ class FineGrainedSuite(DataSuite):
'fine-grained-modules.test',
'fine-grained-follow-imports.test',
'fine-grained-suggest.test',
'fine-grained-dataclasses.test',
]

# Whether to use the fine-grained cache in the testing. This is overridden
Expand Down
32 changes: 18 additions & 14 deletions test-data/unit/check-dataclasses.test
Original file line number Diff line number Diff line change
Expand Up @@ -992,7 +992,6 @@ reveal_type(B) # N: Revealed type is 'def (foo: builtins.int) -> __main__.B' 10BC0 ;
[builtins fixtures/property.pyi]

[case testDataclassesAsdict]
# flags: --python-version 3.8
from dataclasses import dataclass, asdict
from typing import List, Tuple, Dict, Any

Expand Down Expand Up @@ -1028,9 +1027,11 @@ from dataclasses import dataclass, asdict
class Foo:
bar: str

reveal_type(asdict(Foo('bar'))) # N: Revealed type is 'Any'
reveal_type(asdict(Foo('bar'))) # N: Revealed type is 'TypedDict({'bar': builtins.str})'

[typing fixtures/typing-full.pyi]
[builtins fixtures/tuple.pyi]


[case testDataclassesAsdictPython37]
# flags: --python-version 3.7
Expand All @@ -1040,13 +1041,25 @@ from dataclasses import dataclass, asdict
class Foo:
bar: str

reveal_type(asdict(Foo('bar'))) # N: Revealed type is 'Any'
reveal_type(asdict(Foo('bar'))) # N: Revealed type is 'TypedDict({'bar': builtins.str})'

[typing fixtures/typing-full.pyi]
[builtins fixtures/tuple.pyi]

[case testDataclassesAsdictRecursion]
[case testDataclassesAsdictPython38]
# flags: --python-version 3.8
from dataclasses import dataclass, asdict

@dataclass
class Foo:
bar: str

reveal_type(asdict(Foo('bar'))) # N: Revealed type is 'TypedDict({'bar': builtins.str})'

[typing fixtures/typing-full.pyi]

[case testDataclassesAsdictRecursion]
from dataclasses import dataclass, asdict
from typing import Optional

@dataclass
Expand All @@ -1066,10 +1079,10 @@ result = asdict(A(B(C(A()))))
reveal_type(result) # N: Revealed type is 'TypedDict({'b': Union[TypedDict({'c': TypedDict({'a': builtins.dict[builtins.str, Any]})}), None]})'

[typing fixtures/typing-full.pyi]
[builtins fixtures/tuple.pyi]
[builtins fixtures/dict.pyi]

[case testDataclassesAsdictUnions]
# flags: --python-version 3.8
from dataclasses import dataclass, asdict
from typing import Union

Expand All @@ -1086,7 +1099,6 @@ reveal_type(asdict(Customer("foo"))) # N: Revealed type is 'TypedDict({'card':
[typing fixtures/typing-full.pyi]

[case testDataclassesAsdictType]
# flags: --python-version 3.8
from dataclasses import dataclass, asdict
from typing import Type

Expand All @@ -1105,7 +1117,6 @@ reveal_type(asdict(Customer(Card))) # N: Revealed type is 'TypedDict({'card': T


[case testDataclassesAsdictCallable]
# flags: --python-version 3.8
from dataclasses import dataclass, asdict
from typing import Callable

Expand All @@ -1126,7 +1137,6 @@ reveal_type(asdict(Customer(func))) # N: Revealed type is 'TypedDict({'card': d


[case testDataclassesAsdictList]
# flags: --python-version 3.8
from dataclasses import dataclass, asdict
from typing import List, Any

Expand Down Expand Up @@ -1157,7 +1167,6 @@ reveal_type(result['list_no_generic']) # N: Revealed type is 'builtins.list[Any]


[case testDataclassesAsdictListSubclass]
# flags: --python-version 3.8
from dataclasses import dataclass, asdict
from typing import List, Any, TypeVar, Generic

Expand Down Expand Up @@ -1206,7 +1215,6 @@ reveal_type(result['list_subclass_with_constraint']) # N: Revealed type is 'buil


[case testDataclassesAsdictDict]
# flags: --python-version 3.8
from dataclasses import dataclass, asdict
from typing import Dict

Expand All @@ -1227,7 +1235,6 @@ reveal_type(result['participants_by_name']) # N: Revealed type is 'builtins.dict
[builtins fixtures/dict.pyi]

[case testDataclassesAsdictDictSubclass]
# flags: --python-version 3.8
from dataclasses import dataclass, asdict
from typing import Dict, Generic, TypeVar

Expand Down Expand Up @@ -1255,7 +1262,6 @@ reveal_type(result['participants_by_name']) # N: Revealed type is 'builtins.dict


[case testDataclassesAsdictTuple]
# flags: --python-version 3.8
from dataclasses import dataclass, asdict
from typing import Tuple

Expand All @@ -1279,7 +1285,6 @@ reveal_type(result['partners']) # N: Revealed type is 'Tuple[TypedDict({'name':


[case testDataclassesAsdictTupleSubclass]
# flags: --python-version 3.8
from dataclasses import dataclass, asdict
from typing import Tuple, Generic, TypeVar

Expand Down Expand Up @@ -1308,7 +1313,6 @@ reveal_type(result['tuple_subclass']) # N: Revealed type is 'Tuple[TypedDict({'n
[builtins fixtures/tuple.pyi]

[case testDataclassesAsdictNamedTuple]
# flags: --python-version 3.8
from dataclasses import dataclass, asdict
from typing import NamedTuple

Expand Down
39 changes: 0 additions & 39 deletions test-data/unit/fine-grained-dataclasses.test

This file was deleted.

29 changes: 29 additions & 0 deletions test-data/unit/fine-grained.test
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,35 @@ class A:
main:3: error: Too few arguments for "C"
==

[case testDataclassesAsdictFineGrained]
# flags: --python-version 3.8
[file a.py]
from dataclasses import dataclass
from b import AttributeInOtherModule

@dataclass
class MyDataclass:
attr: AttributeInOtherModule

my_var: MyDataclass

[file b.py]
AttributeInOtherModule = str
[file c.py]
from dataclasses import asdict
from a import my_var
asdict(my_var)['attr'] + "foo"

[file b.py.2]
AttributeInOtherModule = int

[out]
==
c.py:3 error: Unsupported left operand type for + ("int")

[typing fixtures/typing-typeddict.pyi]
[builtins fixtures/fine_grained.pyi]

[case testAttrsUpdate1]
[file a.py]
import attr
Expand Down
0