From 6dbaf9c847f8b07390e45725338676843554f63d Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Mon, 6 Mar 2023 23:47:07 -0500 Subject: [PATCH 01/32] Add signature for dataclasses.replace --- mypy/plugins/dataclasses.py | 60 ++++++++++++++++++++++++- mypy/plugins/default.py | 4 +- test-data/unit/check-dataclasses.test | 20 +++++++++ test-data/unit/lib-stub/dataclasses.pyi | 2 + 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 7694134ac09e..2918f38754cf 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -7,6 +7,7 @@ from mypy import errorcodes, message_registry from mypy.expandtype import expand_type +from mypy.messages import format_type_bare from mypy.nodes import ( ARG_NAMED, ARG_NAMED_OPT, @@ -35,7 +36,7 @@ TypeVarExpr, Var, ) -from mypy.plugin import ClassDefContext, SemanticAnalyzerPluginInterface +from mypy.plugin import ClassDefContext, FunctionSigContext, SemanticAnalyzerPluginInterface from mypy.plugins.common import ( _get_decorator_bool_argument, add_attribute_to_class, @@ -769,3 +770,60 @@ def _is_dataclasses_decorator(node: Node) -> bool: if isinstance(node, RefExpr): return node.fullname in dataclass_makers return False + + +def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: + """ + Generates a signature for the 'dataclasses.replace' function that's specific to the call site + and dependent on the type of the first argument. + """ + if len(ctx.args) != 2: + # Ideally the name and context should be callee's, but we don't have it in FunctionSigContext. + ctx.api.fail(f'"{ctx.default_signature.name}" has unexpected type annotation', ctx.context) + return ctx.default_signature + + if len(ctx.args[0]) != 1: + return ctx.default_signature # leave it to the type checker to complain + + inst_arg = ctx.args[0][0] + + # + from mypy.checker import TypeChecker + + assert isinstance(ctx.api, TypeChecker) + obj_type = ctx.api.expr_checker.accept(inst_arg) + # + + obj_type = get_proper_type(obj_type) + if not isinstance(obj_type, Instance): + return ctx.default_signature + inst_type_str = format_type_bare(obj_type) + + metadata = obj_type.type.metadata + dataclass = metadata.get("dataclass") + if not dataclass: + ctx.api.fail( + f'Argument 1 to "replace" has incompatible type "{inst_type_str}"; expected a dataclass', + ctx.context, + ) + return ctx.default_signature + + arg_names = [None] + arg_kinds = [ARG_POS] + arg_types = [obj_type] + for attr in dataclass["attributes"]: + if not attr["is_in_init"]: + continue + arg_names.append(attr["name"]) + arg_kinds.append( + ARG_NAMED if not attr["has_default"] and attr["is_init_var"] else ARG_NAMED_OPT + ) + arg_types.append(ctx.api.named_type(attr["type"])) + + return ctx.default_signature.copy_modified( + arg_names=arg_names, + arg_kinds=arg_kinds, + arg_types=arg_types, + ret_type=obj_type, + name=f"{ctx.default_signature.name} of {inst_type_str}", + ) diff --git a/mypy/plugins/default.py b/mypy/plugins/default.py index 3dc32a67b84c..3f3986731760 100644 --- a/mypy/plugins/default.py +++ b/mypy/plugins/default.py @@ -50,10 +50,12 @@ def get_function_hook(self, fullname: str) -> Callable[[FunctionContext], Type] def get_function_signature_hook( self, fullname: str ) -> Callable[[FunctionSigContext], FunctionLike] | None: - from mypy.plugins import attrs + from mypy.plugins import attrs, dataclasses if fullname in ("attr.evolve", "attrs.evolve", "attr.assoc", "attrs.assoc"): return attrs.evolve_function_sig_callback + elif fullname == "dataclasses.replace": + return dataclasses.replace_function_sig_callback return None def get_method_signature_hook( diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index 4d85be391186..ab310a15f2be 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -2001,3 +2001,23 @@ class Bar(Foo): ... e: Element[Bar] reveal_type(e.elements) # N: Revealed type is "typing.Sequence[__main__.Element[__main__.Bar]]" [builtins fixtures/dataclasses.pyi] + +[case testReplace] +from dataclasses import dataclass, replace, InitVar + +@dataclass +class A: + x: int + q: InitVar[int] + q2: InitVar[int] = 0 + + +a = A(x=42, q=7) +a2 = replace(a) # E: Missing named argument "q" for "replace" of "A" +a2 = replace(a, q=42) +a2 = replace(a, x=42, q=42) +a2 = replace(a, x='42', q=42) # E: Argument "x" to "replace" of "A" has incompatible type "str"; expected "int" +a2 = replace(a, q='42') # E: Argument "q" to "replace" of "A" has incompatible type "str"; expected "int" +reveal_type(a2) # N: Revealed type is "__main__.A" + +[builtins fixtures/dataclasses.pyi] diff --git a/test-data/unit/lib-stub/dataclasses.pyi b/test-data/unit/lib-stub/dataclasses.pyi index bd33b459266c..7ca2dc8c6a23 100644 --- a/test-data/unit/lib-stub/dataclasses.pyi +++ b/test-data/unit/lib-stub/dataclasses.pyi @@ -32,3 +32,5 @@ def field(*, class Field(Generic[_T]): pass + +def replace(obj: _T, **changes: Any) -> _T: ... From 4dcbe441706c44a47e46208c5014232ba0da0f1c Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Mon, 6 Mar 2023 23:53:39 -0500 Subject: [PATCH 02/32] add ClassVar --- test-data/unit/check-dataclasses.test | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index ab310a15f2be..a93ed247ca0f 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -2004,12 +2004,14 @@ reveal_type(e.elements) # N: Revealed type is "typing.Sequence[__main__.Element [case testReplace] from dataclasses import dataclass, replace, InitVar +from typing import ClassVar @dataclass class A: x: int q: InitVar[int] q2: InitVar[int] = 0 + c: ClassVar[int] a = A(x=42, q=7) From 7ed374177d59ffb3c4d4d42198ac01a521900103 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Mon, 6 Mar 2023 23:57:34 -0500 Subject: [PATCH 03/32] prevent misleading note --- mypy/plugins/dataclasses.py | 3 +++ test-data/unit/check-dataclasses.test | 1 + 2 files changed, 4 insertions(+) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 2918f38754cf..01e0407f889a 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -826,4 +826,7 @@ def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: arg_types=arg_types, ret_type=obj_type, name=f"{ctx.default_signature.name} of {inst_type_str}", + # prevent 'dataclasses.pyi:...: note: "replace" of "A" defined here' notes + # since they are misleading: the definition is dynamic, not from a definition + definition=None, ) diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index a93ed247ca0f..65d8fee8c500 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -2018,6 +2018,7 @@ a = A(x=42, q=7) a2 = replace(a) # E: Missing named argument "q" for "replace" of "A" a2 = replace(a, q=42) a2 = replace(a, x=42, q=42) +a2 = replace(a, x=42, q=42, c=7) # E: Unexpected keyword argument "c" for "replace" of "A" a2 = replace(a, x='42', q=42) # E: Argument "x" to "replace" of "A" has incompatible type "str"; expected "int" a2 = replace(a, q='42') # E: Argument "q" to "replace" of "A" has incompatible type "str"; expected "int" reveal_type(a2) # N: Revealed type is "__main__.A" From 89257b5d333d58668365a81d9db1bb9a8c6a3133 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Tue, 7 Mar 2023 23:40:13 -0500 Subject: [PATCH 04/32] stash in a secret class-private --- mypy/plugins/dataclasses.py | 59 +++++++++++++++------------ test-data/unit/check-dataclasses.test | 11 +++++ 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 01e0407f889a..a33c17d0e586 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -23,6 +23,7 @@ Context, DataclassTransformSpec, Expression, + FuncDef, JsonDict, NameExpr, Node, @@ -73,6 +74,7 @@ frozen_default=False, field_specifiers=("dataclasses.Field", "dataclasses.field"), ) +_INTERNAL_REPLACE_METHOD = "__mypy_replace" class DataclassAttribute: @@ -325,6 +327,7 @@ def transform(self) -> bool: add_attribute_to_class(self._api, self._cls, "__match_args__", match_args_type) self._add_dataclass_fields_magic_attribute() + self._add_internal_replace_method(attributes) info.metadata["dataclass"] = { "attributes": [attr.serialize() for attr in attributes], @@ -333,6 +336,30 @@ def transform(self) -> bool: return True + def _add_internal_replace_method(self, attributes: list[DataclassAttribute]) -> None: + arg_types = [Instance(self._cls.info, [])] + arg_kinds = [ARG_POS] + arg_names = [None] + for attr in attributes: + arg_types.append(attr.type) + arg_names.append(attr.name) + arg_kinds.append( + ARG_NAMED_OPT if attr.has_default or not attr.is_init_var else ARG_NAMED + ) + + signature = CallableType( + arg_types=arg_types, + arg_kinds=arg_kinds, + arg_names=arg_names, + ret_type=Instance(self._cls.info, []), + fallback=self._api.named_type("builtins.function"), + name=f"replace of {self._cls.info.name}", + ) + + self._cls.info.names[_INTERNAL_REPLACE_METHOD] = SymbolTableNode( + kind=MDEF, node=FuncDef(typ=signature), plugin_generated=True + ) + def add_slots( self, info: TypeInfo, attributes: list[DataclassAttribute], *, correct_version: bool ) -> None: @@ -797,36 +824,16 @@ def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: obj_type = get_proper_type(obj_type) if not isinstance(obj_type, Instance): return ctx.default_signature - inst_type_str = format_type_bare(obj_type) - metadata = obj_type.type.metadata - dataclass = metadata.get("dataclass") - if not dataclass: + repl = obj_type.type.get_method(_INTERNAL_REPLACE_METHOD) + if repl is None: + inst_type_str = format_type_bare(obj_type) ctx.api.fail( f'Argument 1 to "replace" has incompatible type "{inst_type_str}"; expected a dataclass', ctx.context, ) return ctx.default_signature - arg_names = [None] - arg_kinds = [ARG_POS] - arg_types = [obj_type] - for attr in dataclass["attributes"]: - if not attr["is_in_init"]: - continue - arg_names.append(attr["name"]) - arg_kinds.append( - ARG_NAMED if not attr["has_default"] and attr["is_init_var"] else ARG_NAMED_OPT - ) - arg_types.append(ctx.api.named_type(attr["type"])) - - return ctx.default_signature.copy_modified( - arg_names=arg_names, - arg_kinds=arg_kinds, - arg_types=arg_types, - ret_type=obj_type, - name=f"{ctx.default_signature.name} of {inst_type_str}", - # prevent 'dataclasses.pyi:...: note: "replace" of "A" defined here' notes - # since they are misleading: the definition is dynamic, not from a definition - definition=None, - ) + repl_type = repl.type + assert isinstance(repl_type, CallableType) + return repl_type diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index 65d8fee8c500..25cb9c2e70db 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -2024,3 +2024,14 @@ a2 = replace(a, q='42') # E: Argument "q" to "replace" of "A" has incompatible reveal_type(a2) # N: Revealed type is "__main__.A" [builtins fixtures/dataclasses.pyi] +[case testReplaceNotDataclass] +from dataclasses import replace + +replace(5) # E: Argument 1 to "replace" has incompatible type "int"; expected a dataclass + +class C: + pass + +replace(C()) # E: Argument 1 to "replace" has incompatible type "C"; expected a dataclass + +[builtins fixtures/dataclasses.pyi] From 32b1d4709a8fba1e16651eefad4c0a5c674202e6 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Tue, 7 Mar 2023 23:51:23 -0500 Subject: [PATCH 05/32] fix typing --- mypy/plugins/dataclasses.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index a33c17d0e586..a8bd66a24d96 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -337,10 +337,11 @@ def transform(self) -> bool: return True def _add_internal_replace_method(self, attributes: list[DataclassAttribute]) -> None: - arg_types = [Instance(self._cls.info, [])] + arg_types: list[Type] = [Instance(self._cls.info, [])] arg_kinds = [ARG_POS] - arg_names = [None] + arg_names: list[str | None] = [None] for attr in attributes: + assert attr.type is not None arg_types.append(attr.type) arg_names.append(attr.name) arg_kinds.append( @@ -834,6 +835,6 @@ def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: ) return ctx.default_signature - repl_type = repl.type + repl_type = get_proper_type(repl.type) assert isinstance(repl_type, CallableType) return repl_type From 1f08816e63d52072be228688016c21299734c052 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Wed, 8 Mar 2023 00:07:19 -0500 Subject: [PATCH 06/32] docs and naming --- mypy/plugins/dataclasses.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index a8bd66a24d96..8c5cd1297feb 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -74,7 +74,7 @@ frozen_default=False, field_specifiers=("dataclasses.Field", "dataclasses.field"), ) -_INTERNAL_REPLACE_METHOD = "__mypy_replace" +_INTERNAL_REPLACE_SYM_NAME = "__mypy_replace" class DataclassAttribute: @@ -337,6 +337,10 @@ def transform(self) -> bool: return True def _add_internal_replace_method(self, attributes: list[DataclassAttribute]) -> None: + """ + Stashes the signature of 'dataclasses.replace(...)' for this specific dataclass + to be used later if someone calls 'dataclasses.replace' on this dataclass. + """ arg_types: list[Type] = [Instance(self._cls.info, [])] arg_kinds = [ARG_POS] arg_names: list[str | None] = [None] @@ -357,7 +361,7 @@ def _add_internal_replace_method(self, attributes: list[DataclassAttribute]) -> name=f"replace of {self._cls.info.name}", ) - self._cls.info.names[_INTERNAL_REPLACE_METHOD] = SymbolTableNode( + self._cls.info.names[_INTERNAL_REPLACE_SYM_NAME] = SymbolTableNode( kind=MDEF, node=FuncDef(typ=signature), plugin_generated=True ) @@ -802,8 +806,8 @@ def _is_dataclasses_decorator(node: Node) -> bool: def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: """ - Generates a signature for the 'dataclasses.replace' function that's specific to the call site - and dependent on the type of the first argument. + Returns a signature for the 'dataclasses.replace' function that's dependent on the type + of the first positional argument. """ if len(ctx.args) != 2: # Ideally the name and context should be callee's, but we don't have it in FunctionSigContext. @@ -826,8 +830,8 @@ def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: if not isinstance(obj_type, Instance): return ctx.default_signature - repl = obj_type.type.get_method(_INTERNAL_REPLACE_METHOD) - if repl is None: + obj_replace = obj_type.type.get_method(_INTERNAL_REPLACE_SYM_NAME) + if obj_replace is None: inst_type_str = format_type_bare(obj_type) ctx.api.fail( f'Argument 1 to "replace" has incompatible type "{inst_type_str}"; expected a dataclass', @@ -835,6 +839,6 @@ def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: ) return ctx.default_signature - repl_type = get_proper_type(repl.type) - assert isinstance(repl_type, CallableType) - return repl_type + obj_replace_sig = get_proper_type(obj_replace.type) + assert isinstance(obj_replace_sig, CallableType) + return obj_replace_sig From c456a5f80f14be924cdc2f953e6ab796767f0901 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Wed, 8 Mar 2023 11:00:24 -0500 Subject: [PATCH 07/32] inst -> obj --- mypy/plugins/dataclasses.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 8c5cd1297feb..26a62eb0c687 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -817,28 +817,28 @@ def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: if len(ctx.args[0]) != 1: return ctx.default_signature # leave it to the type checker to complain - inst_arg = ctx.args[0][0] + obj_arg = ctx.args[0][0] # from mypy.checker import TypeChecker assert isinstance(ctx.api, TypeChecker) - obj_type = ctx.api.expr_checker.accept(inst_arg) + obj_type = ctx.api.expr_checker.accept(obj_arg) # obj_type = get_proper_type(obj_type) if not isinstance(obj_type, Instance): return ctx.default_signature - obj_replace = obj_type.type.get_method(_INTERNAL_REPLACE_SYM_NAME) - if obj_replace is None: - inst_type_str = format_type_bare(obj_type) + replace_func = obj_type.type.get_method(_INTERNAL_REPLACE_SYM_NAME) + if replace_func is None: + obj_type_str = format_type_bare(obj_type) ctx.api.fail( - f'Argument 1 to "replace" has incompatible type "{inst_type_str}"; expected a dataclass', + f'Argument 1 to "replace" has incompatible type "{obj_type_str}"; expected a dataclass', ctx.context, ) return ctx.default_signature - obj_replace_sig = get_proper_type(obj_replace.type) - assert isinstance(obj_replace_sig, CallableType) - return obj_replace_sig + signature = get_proper_type(replace_func.type) + assert isinstance(signature, CallableType) + return signature From 8118d29c3b2807c81b5e1db8ac5b4b207a3c7349 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Wed, 8 Mar 2023 11:09:36 -0500 Subject: [PATCH 08/32] add the secret symbol to deps.test --- test-data/unit/deps.test | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test-data/unit/deps.test b/test-data/unit/deps.test index 28d51f1a4c30..42c8dbdd5ab2 100644 --- a/test-data/unit/deps.test +++ b/test-data/unit/deps.test @@ -1388,6 +1388,7 @@ class B(A): -> , m -> -> , m.B.__init__ + -> -> -> -> @@ -1419,6 +1420,7 @@ class B(A): -> -> , m.B.__init__ -> + -> -> -> -> From 789cb2ba7f90901acd319478ec7927a051279770 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Wed, 8 Mar 2023 13:34:40 -0500 Subject: [PATCH 09/32] language --- mypy/plugins/dataclasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 26a62eb0c687..b54cb75864b1 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -339,7 +339,7 @@ def transform(self) -> bool: def _add_internal_replace_method(self, attributes: list[DataclassAttribute]) -> None: """ Stashes the signature of 'dataclasses.replace(...)' for this specific dataclass - to be used later if someone calls 'dataclasses.replace' on this dataclass. + to be used later whenever 'dataclasses.replace' is called for this dataclass. """ arg_types: list[Type] = [Instance(self._cls.info, [])] arg_kinds = [ARG_POS] From 9f0974cce4c964f922e6b73a3c794ee0f5608810 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Wed, 8 Mar 2023 13:35:13 -0500 Subject: [PATCH 10/32] nit --- mypy/plugins/dataclasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index b54cb75864b1..96855dd4acfb 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -347,10 +347,10 @@ def _add_internal_replace_method(self, attributes: list[DataclassAttribute]) -> for attr in attributes: assert attr.type is not None arg_types.append(attr.type) - arg_names.append(attr.name) arg_kinds.append( ARG_NAMED_OPT if attr.has_default or not attr.is_init_var else ARG_NAMED ) + arg_names.append(attr.name) signature = CallableType( arg_types=arg_types, From a37e4063d77af7af77cad39b2ce05aadbecd2dc9 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Wed, 8 Mar 2023 13:37:31 -0500 Subject: [PATCH 11/32] nit --- mypy/plugins/dataclasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 96855dd4acfb..1d262801b160 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -348,7 +348,7 @@ def _add_internal_replace_method(self, attributes: list[DataclassAttribute]) -> assert attr.type is not None arg_types.append(attr.type) arg_kinds.append( - ARG_NAMED_OPT if attr.has_default or not attr.is_init_var else ARG_NAMED + ARG_NAMED if attr.is_init_var and not attr.has_default else ARG_NAMED_OPT ) arg_names.append(attr.name) From 0e84c4f79810dc272553b1727e04a02542eeff69 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Tue, 14 Mar 2023 10:29:50 -0400 Subject: [PATCH 12/32] make obj positional-only --- test-data/unit/lib-stub/dataclasses.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-data/unit/lib-stub/dataclasses.pyi b/test-data/unit/lib-stub/dataclasses.pyi index 7ca2dc8c6a23..948ef1c9bb72 100644 --- a/test-data/unit/lib-stub/dataclasses.pyi +++ b/test-data/unit/lib-stub/dataclasses.pyi @@ -33,4 +33,4 @@ def field(*, class Field(Generic[_T]): pass -def replace(obj: _T, **changes: Any) -> _T: ... +def replace(obj: _T, /, **changes: Any) -> _T: ... From 7b907cf50fbadf873007d455d9fd301119a09872 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Tue, 14 Mar 2023 10:40:18 -0400 Subject: [PATCH 13/32] add pythoneval test --- test-data/unit/pythoneval.test | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test-data/unit/pythoneval.test b/test-data/unit/pythoneval.test index d89009c2c56f..39eedd57c677 100644 --- a/test-data/unit/pythoneval.test +++ b/test-data/unit/pythoneval.test @@ -1984,3 +1984,23 @@ def good9(foo1: Foo[Concatenate[int, P]], foo2: Foo[[int, str, bytes]], *args: P [out] _testStrictEqualitywithParamSpec.py:11: error: Non-overlapping equality check (left operand type: "Foo[[int]]", right operand type: "Bar[[int]]") + +[case testDataclassReplace] +from dataclasses import dataclass, replace + +@dataclass +class A: + x: int + + +a = A(x=42) +a2 = replace(a, x=42) +reveal_type(a2) +a2 = replace() +a2 = replace(a, x='spam') +a2 = replace(a, x=42, q=42) +[out] +_testDataclassReplace.py:10: note: Revealed type is "_testDataclassReplace.A" +_testDataclassReplace.py:11: error: Too few arguments for "replace" +_testDataclassReplace.py:12: error: Argument "x" to "replace" of "A" has incompatible type "str"; expected "int" +_testDataclassReplace.py:13: error: Unexpected keyword argument "q" for "replace" of "A" From 985db60989eb6a36e2cc518841b21e3c1838d82e Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Tue, 14 Mar 2023 11:47:55 -0400 Subject: [PATCH 14/32] use py3.7 syntax Co-authored-by: Alex Waygood --- test-data/unit/lib-stub/dataclasses.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-data/unit/lib-stub/dataclasses.pyi b/test-data/unit/lib-stub/dataclasses.pyi index 948ef1c9bb72..b2b48c2ae486 100644 --- a/test-data/unit/lib-stub/dataclasses.pyi +++ b/test-data/unit/lib-stub/dataclasses.pyi @@ -33,4 +33,4 @@ def field(*, class Field(Generic[_T]): pass -def replace(obj: _T, /, **changes: Any) -> _T: ... +def replace(__obj: _T, **changes: Any) -> _T: ... From 3227fdeb6f040c27438588a6b1bb93bdbe535bcd Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 17 Mar 2023 14:23:19 -0400 Subject: [PATCH 15/32] Fix lint --- mypy/plugins/dataclasses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 6ad6207d9574..d35cd590a21d 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -845,6 +845,7 @@ def _has_direct_dataclass_transform_metaclass(info: TypeInfo) -> bool: and info.declared_metaclass.type.dataclass_transform_spec is not None ) + def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: """ Returns a signature for the 'dataclasses.replace' function that's dependent on the type From 2dbf24946ca2eb1936d3ff891be6d9d57c52b935 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Thu, 30 Mar 2023 15:35:13 -0400 Subject: [PATCH 16/32] use syntactically invalid name for symbol --- mypy/plugins/dataclasses.py | 2 +- test-data/unit/deps.test | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 363c0130a995..9014a85561fe 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -76,7 +76,7 @@ frozen_default=False, field_specifiers=("dataclasses.Field", "dataclasses.field"), ) -_INTERNAL_REPLACE_SYM_NAME = "__mypy_replace" +_INTERNAL_REPLACE_SYM_NAME = "mypy-replace" class DataclassAttribute: diff --git a/test-data/unit/deps.test b/test-data/unit/deps.test index 42c8dbdd5ab2..f5ee7539ebb5 100644 --- a/test-data/unit/deps.test +++ b/test-data/unit/deps.test @@ -1388,8 +1388,8 @@ class B(A): -> , m -> -> , m.B.__init__ - -> - -> + -> + -> -> -> -> m, m.A, m.B @@ -1420,7 +1420,7 @@ class B(A): -> -> , m.B.__init__ -> - -> + -> -> -> -> From 40315b74669633a170591eb46b008092500726ad Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Thu, 30 Mar 2023 15:44:10 -0400 Subject: [PATCH 17/32] mypy-replace must use name mangling prefix --- mypy/plugins/dataclasses.py | 2 +- test-data/unit/deps.test | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 389eac4ea261..405452c6a417 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -76,7 +76,7 @@ frozen_default=False, field_specifiers=("dataclasses.Field", "dataclasses.field"), ) -_INTERNAL_REPLACE_SYM_NAME = "mypy-replace" +_INTERNAL_REPLACE_SYM_NAME = "__mypy-replace" class DataclassAttribute: diff --git a/test-data/unit/deps.test b/test-data/unit/deps.test index f5ee7539ebb5..fe5107b1529d 100644 --- a/test-data/unit/deps.test +++ b/test-data/unit/deps.test @@ -1388,8 +1388,8 @@ class B(A): -> , m -> -> , m.B.__init__ - -> - -> + -> + -> -> -> -> m, m.A, m.B @@ -1420,7 +1420,7 @@ class B(A): -> -> , m.B.__init__ -> - -> + -> -> -> -> From c0058950753ef275edb6abd6775660a4b0dc971e Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Thu, 30 Mar 2023 18:04:59 -0400 Subject: [PATCH 18/32] Generic dataclass support --- mypy/plugins/dataclasses.py | 25 ++++++++++++++++++------- test-data/unit/check-dataclasses.test | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 405452c6a417..da1d4d6d766e 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -6,7 +6,7 @@ from typing_extensions import Final from mypy import errorcodes, message_registry -from mypy.expandtype import expand_type +from mypy.expandtype import expand_type, expand_type_by_instance from mypy.messages import format_type_bare from mypy.nodes import ( ARG_NAMED, @@ -350,12 +350,15 @@ def _add_internal_replace_method(self, attributes: list[DataclassAttribute]) -> Stashes the signature of 'dataclasses.replace(...)' for this specific dataclass to be used later whenever 'dataclasses.replace' is called for this dataclass. """ - arg_types: list[Type] = [Instance(self._cls.info, [])] - arg_kinds = [ARG_POS] - arg_names: list[str | None] = [None] + arg_types: list[Type] = [] + arg_kinds = [] + arg_names: list[str | None] = [] + + info = self._cls.info for attr in attributes: - assert attr.type is not None - arg_types.append(attr.type) + attr_type = attr.expand_type(info) + assert attr_type is not None + arg_types.append(attr_type) arg_kinds.append( ARG_NAMED if attr.is_init_var and not attr.has_default else ARG_NAMED_OPT ) @@ -365,7 +368,7 @@ def _add_internal_replace_method(self, attributes: list[DataclassAttribute]) -> arg_types=arg_types, arg_kinds=arg_kinds, arg_names=arg_names, - ret_type=Instance(self._cls.info, []), + ret_type=NoneType(), fallback=self._api.named_type("builtins.function"), name=f"replace of {self._cls.info.name}", ) @@ -883,4 +886,12 @@ def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: signature = get_proper_type(replace_func.type) assert isinstance(signature, CallableType) + signature = expand_type_by_instance(signature, obj_type) + # re-add the instance type + signature = signature.copy_modified( + arg_types=[obj_type, *signature.arg_types], + arg_kinds=[ARG_POS, *signature.arg_kinds], + arg_names=[None, *signature.arg_names], + ret_type=obj_type, + ) return signature diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index abb376c89b86..f0498d37bf65 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -2070,3 +2070,21 @@ class C: pass replace(C()) # E: Argument 1 to "replace" has incompatible type "C"; expected a dataclass + +[case testReplaceGeneric] +from dataclasses import dataclass, replace, InitVar +from typing import ClassVar, Generic, TypeVar + +T = TypeVar('T') + +@dataclass +class A(Generic[T]): + x: T + + +a = A(x=42) +reveal_type(a) # N: Revealed type is "__main__.A[builtins.int]" +a2 = replace(a, x=42) +reveal_type(a2) # N: Revealed type is "__main__.A[builtins.int]" +a2 = replace(a, x='42') # E: Argument "x" to "replace" of "A" has incompatible type "str"; expected "int" +reveal_type(a2) # N: Revealed type is "__main__.A[builtins.int]" From d71bc21f15a98ad356461d53494a8b9c0ab0f17e Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Thu, 30 Mar 2023 18:12:44 -0400 Subject: [PATCH 19/32] add fine-grained test --- test-data/unit/fine-grained-dataclass.test | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 test-data/unit/fine-grained-dataclass.test diff --git a/test-data/unit/fine-grained-dataclass.test b/test-data/unit/fine-grained-dataclass.test new file mode 100644 index 000000000000..a8c7da8c9c9e --- /dev/null +++ b/test-data/unit/fine-grained-dataclass.test @@ -0,0 +1,25 @@ +[case replace] +[file model.py] +from dataclasses import dataclass + +@dataclass +class Model: + x: int = 0 +[file replace.py] +from dataclasses import replace +from model import Model + +m = Model() +replace(m, x=42) + +[file model.py.2] +from dataclasses import dataclass + +@dataclass +class Model: + x: str = 'hello' + +[builtins fixtures/dataclasses.pyi] +[out] +== +replace.py:5: error: Argument "x" to "replace" of "Model" has incompatible type "int"; expected "str" From d914b9472dfd187ebc5fe98e812b83435fe26e01 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Thu, 30 Mar 2023 20:26:05 -0400 Subject: [PATCH 20/32] disable for arbitrary transforms --- mypy/plugins/dataclasses.py | 4 +++- test-data/unit/check-dataclass-transform.test | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index da1d4d6d766e..4834b80fd369 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -336,7 +336,9 @@ def transform(self) -> bool: add_attribute_to_class(self._api, self._cls, "__match_args__", match_args_type) self._add_dataclass_fields_magic_attribute() - self._add_internal_replace_method(attributes) + + if self._spec is _TRANSFORM_SPEC_FOR_DATACLASSES: + self._add_internal_replace_method(attributes) info.metadata["dataclass"] = { "attributes": [attr.serialize() for attr in attributes], diff --git a/test-data/unit/check-dataclass-transform.test b/test-data/unit/check-dataclass-transform.test index 8d8e38997582..8d5c09843bd2 100644 --- a/test-data/unit/check-dataclass-transform.test +++ b/test-data/unit/check-dataclass-transform.test @@ -807,3 +807,21 @@ reveal_type(bar.base) # N: Revealed type is "builtins.int" [typing fixtures/typing-full.pyi] [builtins fixtures/dataclasses.pyi] + +[case testDataclassTransformReplace] +from dataclasses import replace +from typing import dataclass_transform, Type + +@dataclass_transform() +def my_dataclass(cls: Type) -> Type: + return cls + +@my_dataclass +class Person: + name: str + +p = Person('John') +y = replace(p, name='Jonh') # E: Argument 1 to "replace" has incompatible type "Person"; expected a dataclass + +[typing fixtures/typing-full.pyi] +[builtins fixtures/dataclasses.pyi] From 5735726bf28947a96b1de313798e2ebce1a14ecc Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Thu, 6 Apr 2023 22:18:11 -0400 Subject: [PATCH 21/32] fix testDataclassTransformReplace --- test-data/unit/check-dataclass-transform.test | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test-data/unit/check-dataclass-transform.test b/test-data/unit/check-dataclass-transform.test index e15d9a642d02..53a6eea95bfa 100644 --- a/test-data/unit/check-dataclass-transform.test +++ b/test-data/unit/check-dataclass-transform.test @@ -853,7 +853,10 @@ class Person: name: str p = Person('John') -y = replace(p, name='Jonh') # E: Argument 1 to "replace" has incompatible type "Person"; expected a dataclass +y = replace(p, name='Bob') # E: Argument 1 to "replace" has incompatible type "Person"; expected a dataclass + +[typing fixtures/typing-full.pyi] +[builtins fixtures/dataclasses.pyi] [case testDataclassTransformSimpleDescriptor] # flags: --python-version 3.11 From d38897e8924027b43a5b4aed9526c9e4c2d1097c Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Thu, 6 Apr 2023 22:36:02 -0400 Subject: [PATCH 22/32] better error message for generics --- mypy/plugins/dataclasses.py | 5 +++-- test-data/unit/check-dataclasses.test | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 195870cff4b6..590de571951d 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -949,6 +949,7 @@ def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: obj_type = get_proper_type(obj_type) if not isinstance(obj_type, Instance): return ctx.default_signature + inst_type_str = format_type_bare(obj_type) replace_func = obj_type.type.get_method(_INTERNAL_REPLACE_SYM_NAME) if replace_func is None: @@ -963,10 +964,10 @@ def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: assert isinstance(signature, CallableType) signature = expand_type_by_instance(signature, obj_type) # re-add the instance type - signature = signature.copy_modified( + return signature.copy_modified( arg_types=[obj_type, *signature.arg_types], arg_kinds=[ARG_POS, *signature.arg_kinds], arg_names=[None, *signature.arg_names], ret_type=obj_type, + name=f"{ctx.default_signature.name} of {inst_type_str}", ) - return signature diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index f0498d37bf65..98f312e827a5 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -2086,5 +2086,5 @@ a = A(x=42) reveal_type(a) # N: Revealed type is "__main__.A[builtins.int]" a2 = replace(a, x=42) reveal_type(a2) # N: Revealed type is "__main__.A[builtins.int]" -a2 = replace(a, x='42') # E: Argument "x" to "replace" of "A" has incompatible type "str"; expected "int" +a2 = replace(a, x='42') # E: Argument "x" to "replace" of "A[int]" has incompatible type "str"; expected "int" reveal_type(a2) # N: Revealed type is "__main__.A[builtins.int]" From 04f0ee370241313e0a321657403a730f86f6d389 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 7 Apr 2023 23:34:28 -0400 Subject: [PATCH 23/32] add support for typevars --- mypy/plugins/dataclasses.py | 27 +++++++++++++------ test-data/unit/check-dataclasses.test | 37 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 590de571951d..86be39cbd508 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -947,27 +947,38 @@ def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: # obj_type = get_proper_type(obj_type) - if not isinstance(obj_type, Instance): - return ctx.default_signature - inst_type_str = format_type_bare(obj_type) + obj_type_str = format_type_bare(obj_type) + if isinstance(obj_type, AnyType): + return ctx.default_signature # replace(Any, ...) -> Any + + dataclass_type = obj_type.upper_bound if isinstance(obj_type, TypeVarType) else obj_type - replace_func = obj_type.type.get_method(_INTERNAL_REPLACE_SYM_NAME) + if not isinstance(dataclass_type, Instance): + ctx.api.fail( + f'Argument 1 to "replace" has variable type "{obj_type_str}" with unexpected bounds' + if isinstance(obj_type, TypeVarType) + else f'Argument 1 to "replace" has unexpected type "{obj_type_str}"', + ctx.context, + ) + return ctx.default_signature + replace_func = dataclass_type.type.get_method(_INTERNAL_REPLACE_SYM_NAME) if replace_func is None: - obj_type_str = format_type_bare(obj_type) ctx.api.fail( - f'Argument 1 to "replace" has incompatible type "{obj_type_str}"; expected a dataclass', + f'Argument 1 to "replace" has variable type "{obj_type_str}" not bound to a dataclass' + if isinstance(obj_type, TypeVarType) + else f'Argument 1 to "replace" has incompatible type "{obj_type_str}"; expected a dataclass', ctx.context, ) return ctx.default_signature signature = get_proper_type(replace_func.type) assert isinstance(signature, CallableType) - signature = expand_type_by_instance(signature, obj_type) + signature = expand_type_by_instance(signature, dataclass_type) # re-add the instance type return signature.copy_modified( arg_types=[obj_type, *signature.arg_types], arg_kinds=[ARG_POS, *signature.arg_kinds], arg_names=[None, *signature.arg_names], ret_type=obj_type, - name=f"{ctx.default_signature.name} of {inst_type_str}", + name=f"{ctx.default_signature.name} of {obj_type_str}", ) diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index 98f312e827a5..d71e9c334265 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -2061,6 +2061,43 @@ reveal_type(a2) # N: Revealed type is "__main__.A" [builtins fixtures/dataclasses.pyi] +[case testReplaceTypeVar] +from dataclasses import dataclass, replace +from typing import TypeVar + +@dataclass +class A: + x: int + +TA = TypeVar('TA', bound=A) +TInt = TypeVar('TInt', bound=int) +TAny = TypeVar('TAny') + + +def f(t: TA) -> TA: + _ = replace(t, x='spam') # E: Argument "x" to "replace" of "TA" has incompatible type "str"; expected "int" + return replace(t, x=42) + + +def g(t: TInt) -> None: + _ = replace(t, x=42) # E: Argument 1 to "replace" has variable type "TInt" not bound to a dataclass + + +def h(t: TAny) -> TAny: + return replace(t, x='spam') # E: Argument 1 to "replace" has variable type "TAny" not bound to a dataclass + +[builtins fixtures/dataclasses.pyi] + +[case testReplaceAny] +from dataclasses import replace +from typing import Any + +a: Any +a2 = replace(a) +reveal_type(a2) # N: Revealed type is "Any" + +[builtins fixtures/dataclasses.pyi] + [case testReplaceNotDataclass] from dataclasses import replace From f402b8624b1ca73cb1eec124d01d3e95943321ce Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 7 Apr 2023 23:46:28 -0400 Subject: [PATCH 24/32] streamline --- mypy/plugins/dataclasses.py | 13 +++---------- test-data/unit/check-dataclasses.test | 7 +++++++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 86be39cbd508..e94a5619c82d 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -952,16 +952,9 @@ def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: return ctx.default_signature # replace(Any, ...) -> Any dataclass_type = obj_type.upper_bound if isinstance(obj_type, TypeVarType) else obj_type - - if not isinstance(dataclass_type, Instance): - ctx.api.fail( - f'Argument 1 to "replace" has variable type "{obj_type_str}" with unexpected bounds' - if isinstance(obj_type, TypeVarType) - else f'Argument 1 to "replace" has unexpected type "{obj_type_str}"', - ctx.context, - ) - return ctx.default_signature - replace_func = dataclass_type.type.get_method(_INTERNAL_REPLACE_SYM_NAME) + replace_func = None + if isinstance(dataclass_type, Instance): + replace_func = dataclass_type.type.get_method(_INTERNAL_REPLACE_SYM_NAME) if replace_func is None: ctx.api.fail( f'Argument 1 to "replace" has variable type "{obj_type_str}" not bound to a dataclass' diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index d71e9c334265..d85f8942016f 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -2072,6 +2072,7 @@ class A: TA = TypeVar('TA', bound=A) TInt = TypeVar('TInt', bound=int) TAny = TypeVar('TAny') +TNone = TypeVar('TNone', bound=None) def f(t: TA) -> TA: @@ -2086,6 +2087,10 @@ def g(t: TInt) -> None: def h(t: TAny) -> TAny: return replace(t, x='spam') # E: Argument 1 to "replace" has variable type "TAny" not bound to a dataclass + +def q(t: TNone) -> TNone: + return replace(t, x='spam') # E: Argument 1 to "replace" has variable type "TNone" not bound to a dataclass + [builtins fixtures/dataclasses.pyi] [case testReplaceAny] @@ -2108,6 +2113,8 @@ class C: replace(C()) # E: Argument 1 to "replace" has incompatible type "C"; expected a dataclass +replace(None) # E: Argument 1 to "replace" has incompatible type "None"; expected a dataclass + [case testReplaceGeneric] from dataclasses import dataclass, replace, InitVar from typing import ClassVar, Generic, TypeVar From 306c3f373db184ed6bfb3b373db77a0549f8baef Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 7 Apr 2023 23:59:31 -0400 Subject: [PATCH 25/32] self-check --- mypy/plugins/dataclasses.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index e94a5619c82d..27d936825344 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -951,7 +951,9 @@ def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: if isinstance(obj_type, AnyType): return ctx.default_signature # replace(Any, ...) -> Any - dataclass_type = obj_type.upper_bound if isinstance(obj_type, TypeVarType) else obj_type + dataclass_type = get_proper_type( + obj_type.upper_bound if isinstance(obj_type, TypeVarType) else obj_type + ) replace_func = None if isinstance(dataclass_type, Instance): replace_func = dataclass_type.type.get_method(_INTERNAL_REPLACE_SYM_NAME) @@ -963,6 +965,7 @@ def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: ctx.context, ) return ctx.default_signature + assert isinstance(dataclass_type, Instance) signature = get_proper_type(replace_func.type) assert isinstance(signature, CallableType) From 283fe3d5b6e4522501f114157210f081dbc828a4 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 21 Apr 2023 13:16:05 -0400 Subject: [PATCH 26/32] Add improved union support from #15050 --- mypy/plugins/dataclasses.py | 128 ++++++++++++++++++++------ test-data/unit/check-dataclasses.test | 89 +++++++++++++++--- 2 files changed, 174 insertions(+), 43 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 5707e2a81d4a..ced711fed6aa 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -7,6 +7,7 @@ from mypy import errorcodes, message_registry from mypy.expandtype import expand_type, expand_type_by_instance +from mypy.meet import meet_types from mypy.messages import format_type_bare from mypy.nodes import ( ARG_NAMED, @@ -57,10 +58,13 @@ Instance, LiteralType, NoneType, + ProperType, TupleType, Type, TypeOfAny, TypeVarType, + UninhabitedType, + UnionType, get_proper_type, ) from mypy.typevars import fill_typevars @@ -372,7 +376,6 @@ def _add_internal_replace_method(self, attributes: list[DataclassAttribute]) -> arg_names=arg_names, ret_type=NoneType(), fallback=self._api.named_type("builtins.function"), - name=f"replace of {self._cls.info.name}", ) self._cls.info.names[_INTERNAL_REPLACE_SYM_NAME] = SymbolTableNode( @@ -923,6 +926,91 @@ def _has_direct_dataclass_transform_metaclass(info: TypeInfo) -> bool: ) +def _fail_not_dataclass(ctx: FunctionSigContext, t: Type, parent_t: Type) -> None: + t_name = format_type_bare(t, ctx.api.options) + if parent_t is t: + msg = ( + f'Argument 1 to "replace" has a variable type "{t_name}" not bound to a dataclass' + if isinstance(t, TypeVarType) + else f'Argument 1 to "replace" has incompatible type "{t_name}"; expected a dataclass' + ) + else: + pt_name = format_type_bare(parent_t, ctx.api.options) + msg = ( + f'Argument 1 to "replace" has type "{pt_name}" whose item "{t_name}" is not bound to a dataclass' + if isinstance(t, TypeVarType) + else f'Argument 1 to "replace" has incompatible type "{pt_name}" whose item "{t_name}" is not a dataclass' + ) + + ctx.api.fail(msg, ctx.context) + + +def _get_expanded_dataclasses_fields( + ctx: FunctionSigContext, typ: ProperType, display_typ: ProperType, parent_typ: ProperType +) -> list[CallableType] | None: + """ + For a given type, determine what dataclasses it can be: for each class, return the field types. + For generic classes, the field types are expanded. + If the type contains Any or a non-dataclass, returns None; in the latter case, also reports an error. + """ + if isinstance(typ, AnyType): + return None + elif isinstance(typ, UnionType): + ret: list[CallableType] | None = [] + for item in typ.relevant_items(): + item = get_proper_type(item) + item_types = _get_expanded_dataclasses_fields(ctx, item, item, parent_typ) + if ret is not None and item_types is not None: + ret += item_types + else: + ret = None # but keep iterating to emit all errors + return ret + elif isinstance(typ, TypeVarType): + return _get_expanded_dataclasses_fields( + ctx, get_proper_type(typ.upper_bound), display_typ, parent_typ + ) + elif isinstance(typ, Instance): + replace_sym = typ.type.get_method(_INTERNAL_REPLACE_SYM_NAME) + if replace_sym is None: + _fail_not_dataclass(ctx, display_typ, parent_typ) + return None + replace_sig = get_proper_type(replace_sym.type) + assert isinstance(replace_sig, CallableType) + return [expand_type_by_instance(replace_sig, typ)] + else: + _fail_not_dataclass(ctx, display_typ, parent_typ) + return None + + +def _meet_replace_sigs(sigs: list[CallableType]) -> CallableType: + """ + Produces the lowest bound of the 'replace' signatures of multiple dataclasses. + """ + args = { + name: (typ, kind) + for name, typ, kind in zip(sigs[0].arg_names, sigs[0].arg_types, sigs[0].arg_kinds) + } + + for sig in sigs[1:]: + sig_args = { + name: (typ, kind) + for name, typ, kind in zip(sig.arg_names, sig.arg_types, sig.arg_kinds) + } + for name in (*args.keys(), *sig_args.keys()): + sig_typ, sig_kind = args.get(name, (UninhabitedType(), ARG_NAMED_OPT)) + sig2_typ, sig2_kind = sig_args.get(name, (UninhabitedType(), ARG_NAMED_OPT)) + args[name] = ( + meet_types(sig_typ, sig2_typ), + ARG_NAMED_OPT if sig_kind == sig2_kind == ARG_NAMED_OPT else ARG_NAMED, + ) + + return sigs[0].copy_modified( + arg_names=list(args.keys()), + arg_types=[typ for typ, _ in args.values()], + arg_kinds=[kind for _, kind in args.values()], + ) + + def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: """ Returns a signature for the 'dataclasses.replace' function that's dependent on the type @@ -946,34 +1034,18 @@ def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: # obj_type = get_proper_type(obj_type) - obj_type_str = format_type_bare(obj_type) - if isinstance(obj_type, AnyType): - return ctx.default_signature # replace(Any, ...) -> Any + inst_type_str = format_type_bare(obj_type, ctx.api.options) - dataclass_type = get_proper_type( - obj_type.upper_bound if isinstance(obj_type, TypeVarType) else obj_type - ) - replace_func = None - if isinstance(dataclass_type, Instance): - replace_func = dataclass_type.type.get_method(_INTERNAL_REPLACE_SYM_NAME) - if replace_func is None: - ctx.api.fail( - f'Argument 1 to "replace" has variable type "{obj_type_str}" not bound to a dataclass' - if isinstance(obj_type, TypeVarType) - else f'Argument 1 to "replace" has incompatible type "{obj_type_str}"; expected a dataclass', - ctx.context, - ) + replace_sigs = _get_expanded_dataclasses_fields(ctx, obj_type, obj_type, obj_type) + if replace_sigs is None: return ctx.default_signature - assert isinstance(dataclass_type, Instance) - - signature = get_proper_type(replace_func.type) - assert isinstance(signature, CallableType) - signature = expand_type_by_instance(signature, dataclass_type) - # re-add the instance type - return signature.copy_modified( - arg_types=[obj_type, *signature.arg_types], - arg_kinds=[ARG_POS, *signature.arg_kinds], - arg_names=[None, *signature.arg_names], + replace_sig = _meet_replace_sigs(replace_sigs) + + return replace_sig.copy_modified( + arg_names=[None, *replace_sig.arg_names], + arg_kinds=[ARG_POS, *replace_sig.arg_kinds], + arg_types=[obj_type, *replace_sig.arg_types], ret_type=obj_type, - name=f"{ctx.default_signature.name} of {obj_type_str}", + fallback=ctx.default_signature.fallback, + name=f"{ctx.default_signature.name} of {inst_type_str}", ) diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index d85f8942016f..cbe95e7b06ea 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -2059,39 +2059,101 @@ a2 = replace(a, x='42', q=42) # E: Argument "x" to "replace" of "A" has incompa a2 = replace(a, q='42') # E: Argument "q" to "replace" of "A" has incompatible type "str"; expected "int" reveal_type(a2) # N: Revealed type is "__main__.A" +[case testReplaceUnion] +# flags: --strict-optional +from typing import Generic, Union, TypeVar +from dataclasses import dataclass, replace, InitVar + +T = TypeVar('T') + +@dataclass +class A(Generic[T]): + x: T # exercises meet(T=int, int) = int + y: bool # exercises meet(bool, int) = bool + z: str # exercises meet(str, bytes) = + w: dict # exercises meet(dict, ) = + a: InitVar[int] # exercises (non-optional, optional) = non-optional + +@dataclass +class B: + x: int + y: bool + z: bytes + a: int + + +a_or_b: Union[A[int], B] +_ = replace(a_or_b, x=42, y=True, a=42) +_ = replace(a_or_b, x=42, y=True) # E: Missing named argument "a" for "replace" of "Union[A[int], B]" +_ = replace(a_or_b, x=42, y=True, z='42', a=42) # E: Argument "z" to "replace" of "Union[A[int], B]" has incompatible type "str"; expected +_ = replace(a_or_b, x=42, y=True, w={}, a=42) # E: Argument "w" to "replace" of "Union[A[int], B]" has incompatible type "Dict[, ]"; expected + [builtins fixtures/dataclasses.pyi] -[case testReplaceTypeVar] +[case testReplaceUnionOfTypeVar] +# flags: --strict-optional +from typing import Generic, Union, TypeVar from dataclasses import dataclass, replace -from typing import TypeVar @dataclass class A: x: int + y: int + z: str + w: dict + +class B: + pass TA = TypeVar('TA', bound=A) +TB = TypeVar('TB', bound=B) + +def f(b_or_t: Union[TA, TB, int]) -> None: + a2 = replace(b_or_t) # E: Argument 1 to "replace" has type "Union[TA, TB, int]" whose item "TB" is not bound to a dataclass # E: Argument 1 to "replace" has incompatible type "Union[TA, TB, int]" whose item "int" is not a dataclass + +[case testReplaceTypeVarBoundNotDataclass] +from dataclasses import dataclass, replace +from typing import Union, TypeVar + TInt = TypeVar('TInt', bound=int) TAny = TypeVar('TAny') TNone = TypeVar('TNone', bound=None) +TUnion = TypeVar('TUnion', bound=Union[str, int]) +def f1(t: TInt) -> None: + _ = replace(t, x=42) # E: Argument 1 to "replace" has a variable type "TInt" not bound to a dataclass -def f(t: TA) -> TA: - _ = replace(t, x='spam') # E: Argument "x" to "replace" of "TA" has incompatible type "str"; expected "int" - return replace(t, x=42) +def f2(t: TAny) -> TAny: + return replace(t, x='spam') # E: Argument 1 to "replace" has a variable type "TAny" not bound to a dataclass +def f3(t: TNone) -> TNone: + return replace(t, x='spam') # E: Argument 1 to "replace" has a variable type "TNone" not bound to a dataclass -def g(t: TInt) -> None: - _ = replace(t, x=42) # E: Argument 1 to "replace" has variable type "TInt" not bound to a dataclass +def f4(t: TUnion) -> TUnion: + return replace(t, x='spam') # E: Argument 1 to "replace" has incompatible type "TUnion" whose item "str" is not a dataclass # E: Argument 1 to "replace" has incompatible type "TUnion" whose item "int" is not a dataclass +[case testReplaceTypeVarBound] +from dataclasses import dataclass, replace +from typing import TypeVar -def h(t: TAny) -> TAny: - return replace(t, x='spam') # E: Argument 1 to "replace" has variable type "TAny" not bound to a dataclass +@dataclass +class A: + x: int +@dataclass +class B(A): + pass -def q(t: TNone) -> TNone: - return replace(t, x='spam') # E: Argument 1 to "replace" has variable type "TNone" not bound to a dataclass +TA = TypeVar('TA', bound=A) -[builtins fixtures/dataclasses.pyi] +def f(t: TA) -> TA: + t2 = replace(t, x=42) + reveal_type(t2) # N: Revealed type is "TA`-1" + _ = replace(t, x='42') # E: Argument "x" to "replace" of "TA" has incompatible type "str"; expected "int" + return t2 + +f(A(x=42)) +f(B(x=42)) [case testReplaceAny] from dataclasses import replace @@ -2101,8 +2163,6 @@ a: Any a2 = replace(a) reveal_type(a2) # N: Revealed type is "Any" -[builtins fixtures/dataclasses.pyi] - [case testReplaceNotDataclass] from dataclasses import replace @@ -2125,7 +2185,6 @@ T = TypeVar('T') class A(Generic[T]): x: T - a = A(x=42) reveal_type(a) # N: Revealed type is "__main__.A[builtins.int]" a2 = replace(a, x=42) From 8fe75a7163b966e64a006e142a68ee061833cc27 Mon Sep 17 00:00:00 2001 From: Ilya Priven Date: Sun, 21 May 2023 22:00:50 -0400 Subject: [PATCH 27/32] add to testReplaceUnion --- test-data/unit/check-dataclasses.test | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index caf0f6bc8328..24b46a3e3cb4 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -2072,21 +2072,22 @@ class A(Generic[T]): y: bool # exercises meet(bool, int) = bool z: str # exercises meet(str, bytes) = w: dict # exercises meet(dict, ) = - a: InitVar[int] # exercises (non-optional, optional) = non-optional + init_var: InitVar[int] # exercises (non-optional, optional) = non-optional @dataclass class B: x: int y: bool z: bytes - a: int + init_var: int a_or_b: Union[A[int], B] -_ = replace(a_or_b, x=42, y=True, a=42) -_ = replace(a_or_b, x=42, y=True) # E: Missing named argument "a" for "replace" of "Union[A[int], B]" -_ = replace(a_or_b, x=42, y=True, z='42', a=42) # E: Argument "z" to "replace" of "Union[A[int], B]" has incompatible type "str"; expected -_ = replace(a_or_b, x=42, y=True, w={}, a=42) # E: Argument "w" to "replace" of "Union[A[int], B]" has incompatible type "Dict[, ]"; expected +_ = replace(a_or_b, x=42, y=True, init_var=42) +_ = replace(a_or_b, x=42, y=True) # E: Missing named argument "init_var" for "replace" of "Union[A[int], B]" +_ = replace(a_or_b, x=42, y=True, z='42', init_var=42) # E: Argument "z" to "replace" of "Union[A[int], B]" has incompatible type "str"; expected +_ = replace(a_or_b, x=42, y=True, w={}, init_var=42) # E: Argument "w" to "replace" of "Union[A[int], B]" has incompatible type "Dict[, ]"; expected +_ = replace(a_or_b, y=42, init_var=42) # E: Argument "y" to "replace" of "Union[A[int], B]" has incompatible type "int"; expected "bool" [builtins fixtures/dataclasses.pyi] From bea50e82f40567b28fb11b95abf3741fb2d3c549 Mon Sep 17 00:00:00 2001 From: Ilya Priven Date: Sun, 21 May 2023 22:01:37 -0400 Subject: [PATCH 28/32] testReplaceUnion: fix B.y to be int --- test-data/unit/check-dataclasses.test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index 24b46a3e3cb4..3e340289c900 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -2077,7 +2077,7 @@ class A(Generic[T]): @dataclass class B: x: int - y: bool + y: int z: bytes init_var: int From cd35951b603b10cd4a7597b8d8f52d3137000e36 Mon Sep 17 00:00:00 2001 From: Ilya Priven Date: Sun, 21 May 2023 22:02:52 -0400 Subject: [PATCH 29/32] rename testcase replace to testReplace --- test-data/unit/fine-grained-dataclass.test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-data/unit/fine-grained-dataclass.test b/test-data/unit/fine-grained-dataclass.test index a8c7da8c9c9e..036d858ddf69 100644 --- a/test-data/unit/fine-grained-dataclass.test +++ b/test-data/unit/fine-grained-dataclass.test @@ -1,4 +1,4 @@ -[case replace] +[case testReplace] [file model.py] from dataclasses import dataclass From 4c4fc944492dbc9672a11f2a9fdd4b35c3ccc992 Mon Sep 17 00:00:00 2001 From: Ilya Priven Date: Mon, 12 Jun 2023 08:24:22 -0400 Subject: [PATCH 30/32] remove get_expression_type hack --- mypy/plugins/dataclasses.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 9bd079930e57..abcb4687b9f5 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -1034,15 +1034,7 @@ def replace_function_sig_callback(ctx: FunctionSigContext) -> CallableType: return ctx.default_signature # leave it to the type checker to complain obj_arg = ctx.args[0][0] - - # - from mypy.checker import TypeChecker - - assert isinstance(ctx.api, TypeChecker) - obj_type = ctx.api.expr_checker.accept(obj_arg) - # - - obj_type = get_proper_type(obj_type) + obj_type = get_proper_type(ctx.api.get_expression_type(obj_arg)) inst_type_str = format_type_bare(obj_type, ctx.api.options) replace_sigs = _get_expanded_dataclasses_fields(ctx, obj_type, obj_type, obj_type) From be4a29044d0bd0f74377aba2fc84ea76c4e14f82 Mon Sep 17 00:00:00 2001 From: Ilya Priven Date: Fri, 16 Jun 2023 23:32:29 -0400 Subject: [PATCH 31/32] assert isinstance(replace_sig, ProperType) --- mypy/plugins/dataclasses.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index abcb4687b9f5..1abd9c892f21 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -983,7 +983,8 @@ def _get_expanded_dataclasses_fields( if replace_sym is None: _fail_not_dataclass(ctx, display_typ, parent_typ) return None - replace_sig = get_proper_type(replace_sym.type) + replace_sig = replace_sym.type + assert isinstance(replace_sig, ProperType) assert isinstance(replace_sig, CallableType) return [expand_type_by_instance(replace_sig, typ)] else: From ee0ae21c072bdf0da7f6025ba7da178511144a59 Mon Sep 17 00:00:00 2001 From: Ilya Priven Date: Fri, 16 Jun 2023 23:33:48 -0400 Subject: [PATCH 32/32] add a TODO for _meet_replace_sigs --- mypy/plugins/dataclasses.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 1abd9c892f21..913b1ef23312 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -992,6 +992,9 @@ def _get_expanded_dataclasses_fields( return None +# TODO: we can potentially get the function signature hook to allow returning a union +# and leave this to the regular machinery of resolving a union of callables +# (https://github.com/python/mypy/issues/15457) def _meet_replace_sigs(sigs: list[CallableType]) -> CallableType: """ Produces the lowest bound of the 'replace' signatures of multiple dataclasses.