From cd832ccebda80ea71bacc9ad22ce3ed6775d750e Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Wed, 25 Jan 2023 23:07:09 -0500 Subject: [PATCH 01/17] wip --- mypy/plugins/attrs.py | 19 ++++++++++++ mypy/plugins/default.py | 10 +++++++ test-data/unit/check-attr.test | 36 ++++++++++++++++++++++- test-data/unit/lib-stub/attr/__init__.pyi | 3 ++ 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index 16e8891e5f57..381024fcc8e2 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -883,3 +883,22 @@ def add_method( """ self_type = self_type if self_type is not None else self.self_type add_method(self.ctx, method_name, args, ret_type, self_type, tvd) + + +def evolve_callback(ctx: mypy.plugin.FunctionSigContext) -> FunctionLike: + """Callback to provide an accurate signature for attrs.evolve.""" + if len(ctx.args[0]) < 1: + return ctx.default_signature + + metadata = ctx.args[0][0].node.type.type.metadata + + args = { + md_attribute['name']: ctx.api.named_generic_type(md_attribute['init_type'], args=[]) + for md_attribute in metadata.get('attrs', {}).get('attributes', []) + } + + return ctx.default_signature.copy_modified( + arg_kinds=ctx.default_signature.arg_kinds[:1] + [ARG_NAMED_OPT] * len(args), + arg_names=ctx.default_signature.arg_names[:1] + list(args.keys()), + arg_types=ctx.default_signature.arg_types[:1] + list(args.values()), + ) diff --git a/mypy/plugins/default.py b/mypy/plugins/default.py index 04971868e8f4..d9e96ee083c5 100644 --- a/mypy/plugins/default.py +++ b/mypy/plugins/default.py @@ -9,6 +9,7 @@ AttributeContext, ClassDefContext, FunctionContext, + FunctionSigContext, MethodContext, MethodSigContext, Plugin, @@ -45,6 +46,15 @@ def get_function_hook(self, fullname: str) -> Callable[[FunctionContext], Type] return singledispatch.create_singledispatch_function_callback return None + def get_function_signature_hook( + self, fullname: str + ) -> Callable[[FunctionSigContext], FunctionLike] | None: + from mypy.plugins import attrs + + if fullname in ("attr.evolve", "attr.assoc"): + return attrs.evolve_callback + return None + def get_method_signature_hook( self, fullname: str ) -> Callable[[MethodSigContext], FunctionLike] | None: diff --git a/test-data/unit/check-attr.test b/test-data/unit/check-attr.test index f555f2ea7011..9b90ec48935c 100644 --- a/test-data/unit/check-attr.test +++ b/test-data/unit/check-attr.test @@ -1866,4 +1866,38 @@ reveal_type(D) # N: Revealed type is "def (a: builtins.int, b: builtins.str) -> D(1, "").a = 2 # E: Cannot assign to final attribute "a" D(1, "").b = "2" # E: Cannot assign to final attribute "b" -[builtins fixtures/property.pyi] \ No newline at end of file +[builtins fixtures/property.pyi] + +[case testEvolve] +import attr + +@attr.s(auto_attribs=True) +class C: + name: str + +c = C(name='foo') +attr.evolve() # E: Missing positional argument "inst" in call to "evolve" +attr.evolve(c) +attr.evolve(c, name='bar') +attr.evolve( + c, + name=42, # E: Argument "name" to "evolve" has incompatible type "int"; expected "str" +) +attr.evolve( + c, + age=42, # type: ignore[call-arg] +) + +# 'assoc' is the deprecated one: + +attr.assoc( + c, + name=42, # E: Argument "name" to "assoc" has incompatible type "int"; expected "str" +) +attr.assoc( + c, + age=42, # type: ignore[call-arg] +) + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-medium.pyi] diff --git a/test-data/unit/lib-stub/attr/__init__.pyi b/test-data/unit/lib-stub/attr/__init__.pyi index 795e5d3f4f69..1a3838aa3ab1 100644 --- a/test-data/unit/lib-stub/attr/__init__.pyi +++ b/test-data/unit/lib-stub/attr/__init__.pyi @@ -244,3 +244,6 @@ def field( order: Optional[bool] = ..., on_setattr: Optional[object] = ..., ) -> Any: ... + +def evolve(inst: _T, **changes: Any) -> _T: ... +def assoc(inst: _T, **changes: Any) -> _T: ... From 64a9e58e10512952019db9507f1c0999d658df47 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Thu, 26 Jan 2023 11:06:58 -0500 Subject: [PATCH 02/17] more resilient --- mypy/plugins/attrs.py | 10 +++++++--- test-data/unit/check-attr.test | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index 381024fcc8e2..1dec3a094629 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -890,11 +890,15 @@ def evolve_callback(ctx: mypy.plugin.FunctionSigContext) -> FunctionLike: if len(ctx.args[0]) < 1: return ctx.default_signature - metadata = ctx.args[0][0].node.type.type.metadata + node = ctx.args[0][0].node + if node is None: + return ctx.default_signature + + metadata = node.type.type.metadata args = { - md_attribute['name']: ctx.api.named_generic_type(md_attribute['init_type'], args=[]) - for md_attribute in metadata.get('attrs', {}).get('attributes', []) + md_attribute["name"]: ctx.api.named_generic_type(md_attribute["init_type"], args=[]) + for md_attribute in metadata.get("attrs", {}).get("attributes", []) } return ctx.default_signature.copy_modified( diff --git a/test-data/unit/check-attr.test b/test-data/unit/check-attr.test index 9b90ec48935c..0219adcff6d8 100644 --- a/test-data/unit/check-attr.test +++ b/test-data/unit/check-attr.test @@ -1888,7 +1888,7 @@ attr.evolve( age=42, # type: ignore[call-arg] ) -# 'assoc' is the deprecated one: +# 'assoc' is deprecated equivalent of 'evolve' attr.assoc( c, From c1455145f31ec3272481a802d2086707dcb5e35a Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Thu, 26 Jan 2023 11:28:28 -0500 Subject: [PATCH 03/17] hmpf --- mypy/plugins/attrs.py | 7 ++++++- test-data/unit/check-attr.test | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index 1dec3a094629..a38e7e4c6a3e 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -890,7 +890,12 @@ def evolve_callback(ctx: mypy.plugin.FunctionSigContext) -> FunctionLike: if len(ctx.args[0]) < 1: return ctx.default_signature - node = ctx.args[0][0].node + expr = ctx.args[0][0] + if not isinstance(expr, RefExpr): + # TODO: can't rely on expressions having a type! + return ctx.default_signature + + node = expr.node if node is None: return ctx.default_signature diff --git a/test-data/unit/check-attr.test b/test-data/unit/check-attr.test index 0219adcff6d8..d91cdc6abdea 100644 --- a/test-data/unit/check-attr.test +++ b/test-data/unit/check-attr.test @@ -1875,12 +1875,15 @@ import attr class C: name: str +def q() -> C: + return C(name='foo') + c = C(name='foo') attr.evolve() # E: Missing positional argument "inst" in call to "evolve" attr.evolve(c) attr.evolve(c, name='bar') attr.evolve( - c, + q(), name=42, # E: Argument "name" to "evolve" has incompatible type "int"; expected "str" ) attr.evolve( From 3477d2db2e78d18b5a2f2e8f21a1604fb57f0516 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 27 Jan 2023 00:43:25 -0500 Subject: [PATCH 04/17] wip --- mypy/plugins/attrs.py | 71 ++++++++++++++++++++++------------ mypy/plugins/default.py | 12 +----- test-data/unit/check-attr.test | 45 +++++++++------------ 3 files changed, 66 insertions(+), 62 deletions(-) diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index a38e7e4c6a3e..2826e6a2ec13 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -6,7 +6,10 @@ from typing_extensions import Final, Literal import mypy.plugin # To avoid circular imports. +from mypy import subtypes +from mypy.errorcodes import ARG_TYPE, CALL_ARG from mypy.exprtotype import TypeTranslationError, expr_to_unanalyzed_type +from mypy.messages import format_type_distinctly from mypy.nodes import ( ARG_NAMED, ARG_NAMED_OPT, @@ -885,29 +888,47 @@ def add_method( add_method(self.ctx, method_name, args, ret_type, self_type, tvd) -def evolve_callback(ctx: mypy.plugin.FunctionSigContext) -> FunctionLike: +def evolve_callback(ctx: mypy.plugin.FunctionContext) -> Type: """Callback to provide an accurate signature for attrs.evolve.""" - if len(ctx.args[0]) < 1: - return ctx.default_signature - - expr = ctx.args[0][0] - if not isinstance(expr, RefExpr): - # TODO: can't rely on expressions having a type! - return ctx.default_signature - - node = expr.node - if node is None: - return ctx.default_signature - - metadata = node.type.type.metadata - - args = { - md_attribute["name"]: ctx.api.named_generic_type(md_attribute["init_type"], args=[]) - for md_attribute in metadata.get("attrs", {}).get("attributes", []) - } - - return ctx.default_signature.copy_modified( - arg_kinds=ctx.default_signature.arg_kinds[:1] + [ARG_NAMED_OPT] * len(args), - arg_names=ctx.default_signature.arg_names[:1] + list(args.keys()), - arg_types=ctx.default_signature.arg_types[:1] + list(args.values()), - ) + inst_type: TypeInfo | None = None + changes_types = {} + for arg_kinds, arg_names, arg_types in zip(ctx.arg_kinds, ctx.arg_names, ctx.arg_types): + for arg_kind, arg_name, arg_type in zip(arg_kinds, arg_names, arg_types): + if arg_kind == ARG_POS: + if isinstance(arg_type, Instance): + inst_type = arg_type.type + elif arg_kind == ARG_NAMED: + changes_types[arg_name] = arg_type + + if not inst_type: + return ctx.default_return_type + + init_method = inst_type.get_method("__init__") or inst_type.get_method("__attrs_init__") + if not init_method: + return ctx.default_return_type + init_method_t = init_method.type + assert isinstance(init_method_t, CallableType) + + init_arg_types = {} + for arg_kind, arg_name, arg_type in zip( + init_method_t.arg_kinds, init_method_t.arg_names, init_method_t.arg_types + ): + if not (arg_kind == ARG_POS and arg_name == "self"): + init_arg_types[arg_name] = arg_type + + # TODO: we're reinventing the wheel here -- perhaps we can: + # - use ExpressionChecker.check_callable_call against a synthetic CallableType? + # - add an @override to `attr.evolve` for specific class and specific args? + for name, typ in changes_types.items(): + expected_type = init_arg_types.get(name) + if not expected_type: + ctx.api.fail(f'Unexpected argument "{name}"', ctx.context, code=ARG_TYPE) + elif not subtypes.is_subtype(typ, expected_type): + actual, expected = format_type_distinctly(typ, expected_type) + ctx.api.fail( + f'Argument "{name}" to "evolve" has incompatible type {actual}; expected {expected}', + ctx.context, + code=CALL_ARG, + ) + + return ctx.default_return_type diff --git a/mypy/plugins/default.py b/mypy/plugins/default.py index d9e96ee083c5..0e391ca09fc8 100644 --- a/mypy/plugins/default.py +++ b/mypy/plugins/default.py @@ -9,7 +9,6 @@ AttributeContext, ClassDefContext, FunctionContext, - FunctionSigContext, MethodContext, MethodSigContext, Plugin, @@ -38,20 +37,13 @@ class DefaultPlugin(Plugin): """Type checker plugin that is enabled by default.""" def get_function_hook(self, fullname: str) -> Callable[[FunctionContext], Type] | None: - from mypy.plugins import ctypes, singledispatch + from mypy.plugins import attrs, ctypes, singledispatch if fullname == "ctypes.Array": return ctypes.array_constructor_callback elif fullname == "functools.singledispatch": return singledispatch.create_singledispatch_function_callback - return None - - def get_function_signature_hook( - self, fullname: str - ) -> Callable[[FunctionSigContext], FunctionLike] | None: - from mypy.plugins import attrs - - if fullname in ("attr.evolve", "attr.assoc"): + elif fullname in ("attr.evolve", "attr.assoc"): return attrs.evolve_callback return None diff --git a/test-data/unit/check-attr.test b/test-data/unit/check-attr.test index d91cdc6abdea..e6c26d708f5e 100644 --- a/test-data/unit/check-attr.test +++ b/test-data/unit/check-attr.test @@ -1871,36 +1871,27 @@ D(1, "").b = "2" # E: Cannot assign to final attribute "b" [case testEvolve] import attr +class Base: + pass + +class Derived(Base): + pass + +class Other: + pass + @attr.s(auto_attribs=True) class C: name: str - -def q() -> C: - return C(name='foo') - -c = C(name='foo') -attr.evolve() # E: Missing positional argument "inst" in call to "evolve" -attr.evolve(c) -attr.evolve(c, name='bar') -attr.evolve( - q(), - name=42, # E: Argument "name" to "evolve" has incompatible type "int"; expected "str" -) -attr.evolve( - c, - age=42, # type: ignore[call-arg] -) - -# 'assoc' is deprecated equivalent of 'evolve' - -attr.assoc( - c, - name=42, # E: Argument "name" to "assoc" has incompatible type "int"; expected "str" -) -attr.assoc( - c, - age=42, # type: ignore[call-arg] -) + b: Base + +c = C(name='foo', b=Derived()) +attr.evolve(c, b=Derived()) +attr.evolve(c, b=Base()) +attr.evolve(c, b=Other()) # E: Argument "b" to "evolve" has incompatible type "Other"; expected "Base" +attr.evolve(c, name='foo') +attr.evolve(c, name=42) # E: Argument "name" to "evolve" has incompatible type "int"; expected "str" +attr.evolve(c, foobar=42) # E: Unexpected argument "foobar" [builtins fixtures/dict.pyi] [typing fixtures/typing-medium.pyi] From 056ac6611c1bd8472b49c29afa64813a7259e2a0 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 27 Jan 2023 11:00:25 -0500 Subject: [PATCH 05/17] rely on ExpressionChecker.accept :| --- mypy/plugins/attrs.py | 95 +++++++++++----------- mypy/plugins/default.py | 14 +++- test-data/unit/check-attr.test | 13 +-- test-data/unit/lib-stub/attrs/__init__.pyi | 3 + 4 files changed, 69 insertions(+), 56 deletions(-) diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index 2826e6a2ec13..886d4a09ec00 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -6,10 +6,8 @@ from typing_extensions import Final, Literal import mypy.plugin # To avoid circular imports. -from mypy import subtypes -from mypy.errorcodes import ARG_TYPE, CALL_ARG +from mypy.checker import TypeChecker from mypy.exprtotype import TypeTranslationError, expr_to_unanalyzed_type -from mypy.messages import format_type_distinctly from mypy.nodes import ( ARG_NAMED, ARG_NAMED_OPT, @@ -888,47 +886,50 @@ def add_method( add_method(self.ctx, method_name, args, ret_type, self_type, tvd) -def evolve_callback(ctx: mypy.plugin.FunctionContext) -> Type: - """Callback to provide an accurate signature for attrs.evolve.""" - inst_type: TypeInfo | None = None - changes_types = {} - for arg_kinds, arg_names, arg_types in zip(ctx.arg_kinds, ctx.arg_names, ctx.arg_types): - for arg_kind, arg_name, arg_type in zip(arg_kinds, arg_names, arg_types): - if arg_kind == ARG_POS: - if isinstance(arg_type, Instance): - inst_type = arg_type.type - elif arg_kind == ARG_NAMED: - changes_types[arg_name] = arg_type - - if not inst_type: - return ctx.default_return_type - - init_method = inst_type.get_method("__init__") or inst_type.get_method("__attrs_init__") - if not init_method: - return ctx.default_return_type - init_method_t = init_method.type - assert isinstance(init_method_t, CallableType) - - init_arg_types = {} - for arg_kind, arg_name, arg_type in zip( - init_method_t.arg_kinds, init_method_t.arg_names, init_method_t.arg_types - ): - if not (arg_kind == ARG_POS and arg_name == "self"): - init_arg_types[arg_name] = arg_type - - # TODO: we're reinventing the wheel here -- perhaps we can: - # - use ExpressionChecker.check_callable_call against a synthetic CallableType? - # - add an @override to `attr.evolve` for specific class and specific args? - for name, typ in changes_types.items(): - expected_type = init_arg_types.get(name) - if not expected_type: - ctx.api.fail(f'Unexpected argument "{name}"', ctx.context, code=ARG_TYPE) - elif not subtypes.is_subtype(typ, expected_type): - actual, expected = format_type_distinctly(typ, expected_type) - ctx.api.fail( - f'Argument "{name}" to "evolve" has incompatible type {actual}; expected {expected}', - ctx.context, - code=CALL_ARG, - ) - - return ctx.default_return_type +def _get_attrs_init_type(typ: Type) -> CallableType | None: + """ + If `typ` refers to an attrs class, gets the type of its initializer method. + """ + if not isinstance(typ, Instance): + return None + magic_attr = typ.type.get(MAGIC_ATTR_NAME) + if magic_attr is None or not magic_attr.plugin_generated: + return None + init_method = typ.type.get_method("__init__") or typ.type.get_method("__attrs_init__") + if not isinstance(init_method, FuncDef): + return None + return init_method.type + + +def evolve_function_sig_callback(ctx: mypy.plugin.FunctionSigContext) -> CallableType: + """ + Generates a signature for the 'attr.evolve' function that's specific to the call site + and dependent on the type of the first argument. + """ + if len(ctx.args) != 2: + ctx.api.fail('Unexpected type annotation for "evolve"', ctx.context) + return ctx.default_signature + + if len(ctx.args[0]) != 1: + return ctx.default_signature # type checker would already complain + + inst_arg = ctx.args[0][0] + + # + assert isinstance(ctx.api, TypeChecker) + inst_type = ctx.api.expr_checker.accept(inst_arg) + # + + # In practice, we're taking the initializer generated by _add_init and tweaking it + # so that (a) its arguments are kw-only & optional, and (b) its return type is the instance's. + attrs_init_type = _get_attrs_init_type(inst_type) + if not attrs_init_type: + ctx.api.fail('First argument to "evolve" must be an attrs instance', ctx.context) + return ctx.default_signature + + return attrs_init_type.copy_modified( + arg_names=["inst"] + attrs_init_type.arg_names[1:], + arg_kinds=[ARG_POS] + [ARG_NAMED_OPT] * (len(attrs_init_type.arg_kinds) - 1), + ret_type=inst_type, + name=ctx.default_signature.name, + ) diff --git a/mypy/plugins/default.py b/mypy/plugins/default.py index 0e391ca09fc8..a0f89ef11180 100644 --- a/mypy/plugins/default.py +++ b/mypy/plugins/default.py @@ -9,6 +9,7 @@ AttributeContext, ClassDefContext, FunctionContext, + FunctionSigContext, MethodContext, MethodSigContext, Plugin, @@ -37,14 +38,21 @@ class DefaultPlugin(Plugin): """Type checker plugin that is enabled by default.""" def get_function_hook(self, fullname: str) -> Callable[[FunctionContext], Type] | None: - from mypy.plugins import attrs, ctypes, singledispatch + from mypy.plugins import ctypes, singledispatch if fullname == "ctypes.Array": return ctypes.array_constructor_callback elif fullname == "functools.singledispatch": return singledispatch.create_singledispatch_function_callback - elif fullname in ("attr.evolve", "attr.assoc"): - return attrs.evolve_callback + return None + + def get_function_signature_hook( + self, fullname: str + ) -> Callable[[FunctionSigContext], FunctionLike] | None: + from mypy.plugins import attrs + + if fullname in ("attr.evolve", "attrs.evolve", "attr.assoc", "attrs.assoc"): + return attrs.evolve_function_sig_callback return None def get_method_signature_hook( diff --git a/test-data/unit/check-attr.test b/test-data/unit/check-attr.test index e6c26d708f5e..8379a623785a 100644 --- a/test-data/unit/check-attr.test +++ b/test-data/unit/check-attr.test @@ -1886,12 +1886,13 @@ class C: b: Base c = C(name='foo', b=Derived()) -attr.evolve(c, b=Derived()) -attr.evolve(c, b=Base()) -attr.evolve(c, b=Other()) # E: Argument "b" to "evolve" has incompatible type "Other"; expected "Base" -attr.evolve(c, name='foo') -attr.evolve(c, name=42) # E: Argument "name" to "evolve" has incompatible type "int"; expected "str" -attr.evolve(c, foobar=42) # E: Unexpected argument "foobar" +c = attr.evolve(c) +c = attr.evolve(c, b=Derived()) +c = attr.evolve(c, b=Base()) +c = attr.evolve(c, b=Other()) # E: Argument "b" to "evolve" has incompatible type "Other"; expected "Base" +c = attr.evolve(c, name='foo') +c = attr.evolve(c, name=42) # E: Argument "name" to "evolve" has incompatible type "int"; expected "str" +c = attr.evolve(c, foobar=42) # E: Unexpected keyword argument "foobar" for "evolve" [builtins fixtures/dict.pyi] [typing fixtures/typing-medium.pyi] diff --git a/test-data/unit/lib-stub/attrs/__init__.pyi b/test-data/unit/lib-stub/attrs/__init__.pyi index d25774045132..8e9aa1fdced5 100644 --- a/test-data/unit/lib-stub/attrs/__init__.pyi +++ b/test-data/unit/lib-stub/attrs/__init__.pyi @@ -126,3 +126,6 @@ def field( order: Optional[bool] = ..., on_setattr: Optional[object] = ..., ) -> Any: ... + +def evolve(inst: _T, **changes: Any) -> _T: ... +def assoc(inst: _T, **changes: Any) -> _T: ... From d976202efecaf4c49e3a01b52a070c5766b3ea70 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 27 Jan 2023 11:23:40 -0500 Subject: [PATCH 06/17] handle Any, more tests --- mypy/plugins/attrs.py | 8 +++++++- test-data/unit/check-attr.test | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index 886d4a09ec00..126663d06374 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -920,11 +920,17 @@ def evolve_function_sig_callback(ctx: mypy.plugin.FunctionSigContext) -> Callabl inst_type = ctx.api.expr_checker.accept(inst_arg) # + if isinstance(inst_type, AnyType): + return ctx.default_signature + # In practice, we're taking the initializer generated by _add_init and tweaking it # so that (a) its arguments are kw-only & optional, and (b) its return type is the instance's. attrs_init_type = _get_attrs_init_type(inst_type) if not attrs_init_type: - ctx.api.fail('First argument to "evolve" must be an attrs instance', ctx.context) + ctx.api.fail( + f'Argument 1 to "evolve" has incompatible type "{inst_type}"; expected an attrs class', + ctx.context, + ) return ctx.default_signature return attrs_init_type.copy_modified( diff --git a/test-data/unit/check-attr.test b/test-data/unit/check-attr.test index 8379a623785a..428ddf71c43c 100644 --- a/test-data/unit/check-attr.test +++ b/test-data/unit/check-attr.test @@ -1869,6 +1869,7 @@ D(1, "").b = "2" # E: Cannot assign to final attribute "b" [builtins fixtures/property.pyi] [case testEvolve] +from typing import Any import attr class Base: @@ -1894,5 +1895,21 @@ c = attr.evolve(c, name='foo') c = attr.evolve(c, name=42) # E: Argument "name" to "evolve" has incompatible type "int"; expected "str" c = attr.evolve(c, foobar=42) # E: Unexpected keyword argument "foobar" for "evolve" +def f() -> C: + return c + + +# Determining type of first argument's expression +c = attr.evolve(f(), name='foo') + +# First argument type check +attr.evolve(42, name='foo') # E: Argument 1 to "evolve" has incompatible type "Literal[42]?"; expected an attrs class +attr.evolve(None, name='foo') # E: Argument 1 to "evolve" has incompatible type "None"; expected an attrs class + +# All bets are off for 'Any' +any: Any +ret = attr.evolve(any, name='foo') +reveal_type(ret) # N: Revealed type is "Any" + [builtins fixtures/dict.pyi] [typing fixtures/typing-medium.pyi] From 1bdd315b1732e605c71fd815fe2ef13c374fe300 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 27 Jan 2023 11:52:14 -0500 Subject: [PATCH 07/17] make type-checker happy --- mypy/plugins/attrs.py | 8 ++++++-- test-data/unit/check-attr.test | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index 126663d06374..98ce91bc5ef2 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -890,13 +890,14 @@ def _get_attrs_init_type(typ: Type) -> CallableType | None: """ If `typ` refers to an attrs class, gets the type of its initializer method. """ + typ = get_proper_type(typ) if not isinstance(typ, Instance): return None magic_attr = typ.type.get(MAGIC_ATTR_NAME) if magic_attr is None or not magic_attr.plugin_generated: return None init_method = typ.type.get_method("__init__") or typ.type.get_method("__attrs_init__") - if not isinstance(init_method, FuncDef): + if not isinstance(init_method, FuncDef) or not isinstance(init_method.type, CallableType): return None return init_method.type @@ -920,6 +921,7 @@ def evolve_function_sig_callback(ctx: mypy.plugin.FunctionSigContext) -> Callabl inst_type = ctx.api.expr_checker.accept(inst_arg) # + inst_type = get_proper_type(inst_type) if isinstance(inst_type, AnyType): return ctx.default_signature @@ -933,8 +935,10 @@ def evolve_function_sig_callback(ctx: mypy.plugin.FunctionSigContext) -> Callabl ) return ctx.default_signature + arg_names = attrs_init_type.arg_names.copy() + arg_names[0] = "inst" return attrs_init_type.copy_modified( - arg_names=["inst"] + attrs_init_type.arg_names[1:], + arg_names=arg_names, arg_kinds=[ARG_POS] + [ARG_NAMED_OPT] * (len(attrs_init_type.arg_kinds) - 1), ret_type=inst_type, name=ctx.default_signature.name, diff --git a/test-data/unit/check-attr.test b/test-data/unit/check-attr.test index 428ddf71c43c..8c92f8f3a8c1 100644 --- a/test-data/unit/check-attr.test +++ b/test-data/unit/check-attr.test @@ -1888,10 +1888,11 @@ class C: c = C(name='foo', b=Derived()) c = attr.evolve(c) +c = attr.evolve(c, name='foo') +c = attr.evolve(c, 'foo') # E: Too many positional arguments for "evolve" c = attr.evolve(c, b=Derived()) c = attr.evolve(c, b=Base()) c = attr.evolve(c, b=Other()) # E: Argument "b" to "evolve" has incompatible type "Other"; expected "Base" -c = attr.evolve(c, name='foo') c = attr.evolve(c, name=42) # E: Argument "name" to "evolve" has incompatible type "int"; expected "str" c = attr.evolve(c, foobar=42) # E: Unexpected keyword argument "foobar" for "evolve" From 4c8138d7563020b2e0f96c86ece0c0e562091adf Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 27 Jan 2023 12:41:16 -0500 Subject: [PATCH 08/17] nits --- mypy/plugins/attrs.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index 98ce91bc5ef2..b88567a51a32 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -77,6 +77,7 @@ SELF_TVAR_NAME: Final = "_AT" MAGIC_ATTR_NAME: Final = "__attrs_attrs__" MAGIC_ATTR_CLS_NAME_TEMPLATE: Final = "__{}_AttrsAttributes__" # The tuple subclass pattern. +ATTRS_INIT_NAME: Final = "__attrs_init__" class Converter: @@ -326,7 +327,7 @@ def attr_class_maker_callback( adder = MethodAdder(ctx) # If __init__ is not being generated, attrs still generates it as __attrs_init__ instead. - _add_init(ctx, attributes, adder, "__init__" if init else "__attrs_init__") + _add_init(ctx, attributes, adder, "__init__" if init else ATTRS_INIT_NAME) if order: _add_order(ctx, adder) if frozen: @@ -896,7 +897,7 @@ def _get_attrs_init_type(typ: Type) -> CallableType | None: magic_attr = typ.type.get(MAGIC_ATTR_NAME) if magic_attr is None or not magic_attr.plugin_generated: return None - init_method = typ.type.get_method("__init__") or typ.type.get_method("__attrs_init__") + init_method = typ.type.get_method("__init__") or typ.type.get_method(ATTRS_INIT_NAME) if not isinstance(init_method, FuncDef) or not isinstance(init_method.type, CallableType): return None return init_method.type @@ -908,11 +909,12 @@ def evolve_function_sig_callback(ctx: mypy.plugin.FunctionSigContext) -> Callabl and dependent on the type of the first argument. """ if len(ctx.args) != 2: - ctx.api.fail('Unexpected type annotation for "evolve"', ctx.context) + # 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 # type checker would already complain + return ctx.default_signature # leave it to the type checker to complain inst_arg = ctx.args[0][0] From 46026fed1664080d5167f3545c38570a0f14af2c Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 27 Jan 2023 13:37:31 -0500 Subject: [PATCH 09/17] teach fixtures about dict for kwargs --- test-data/unit/fixtures/attr.pyi | 1 + test-data/unit/fixtures/bool.pyi | 1 + test-data/unit/fixtures/callable.pyi | 1 + test-data/unit/fixtures/classmethod.pyi | 1 + test-data/unit/fixtures/exception.pyi | 1 + test-data/unit/fixtures/list.pyi | 1 + test-data/unit/fixtures/tuple.pyi | 1 + 7 files changed, 7 insertions(+) diff --git a/test-data/unit/fixtures/attr.pyi b/test-data/unit/fixtures/attr.pyi index 3ac535c21108..27dc65b9c53a 100644 --- a/test-data/unit/fixtures/attr.pyi +++ b/test-data/unit/fixtures/attr.pyi @@ -25,3 +25,4 @@ class complex: class str: pass class ellipsis: pass class tuple: pass +class dict: pass diff --git a/test-data/unit/fixtures/bool.pyi b/test-data/unit/fixtures/bool.pyi index 0f6e1a174c7b..02410fd5d01c 100644 --- a/test-data/unit/fixtures/bool.pyi +++ b/test-data/unit/fixtures/bool.pyi @@ -15,5 +15,6 @@ class bool(int): pass class float: pass class str: pass class ellipsis: pass +class dict: pass class list(Generic[T]): pass class property: pass diff --git a/test-data/unit/fixtures/callable.pyi b/test-data/unit/fixtures/callable.pyi index 4ad72bee93ec..44abf0691ceb 100644 --- a/test-data/unit/fixtures/callable.pyi +++ b/test-data/unit/fixtures/callable.pyi @@ -28,3 +28,4 @@ class str: def __eq__(self, other: 'str') -> bool: pass class ellipsis: pass class list: ... +class dict: pass diff --git a/test-data/unit/fixtures/classmethod.pyi b/test-data/unit/fixtures/classmethod.pyi index 03ad803890a3..99e208e1525c 100644 --- a/test-data/unit/fixtures/classmethod.pyi +++ b/test-data/unit/fixtures/classmethod.pyi @@ -24,5 +24,6 @@ class str: pass class bytes: pass class bool: pass class ellipsis: pass +class dict: pass class tuple(typing.Generic[_T]): pass diff --git a/test-data/unit/fixtures/exception.pyi b/test-data/unit/fixtures/exception.pyi index 70e3b19c4149..73ea8e28f6a4 100644 --- a/test-data/unit/fixtures/exception.pyi +++ b/test-data/unit/fixtures/exception.pyi @@ -13,6 +13,7 @@ class int: pass class str: pass class bool: pass class ellipsis: pass +class dict: pass class BaseException: def __init__(self, *args: object) -> None: ... diff --git a/test-data/unit/fixtures/list.pyi b/test-data/unit/fixtures/list.pyi index 31dc333b3d4f..da89e5c638fe 100644 --- a/test-data/unit/fixtures/list.pyi +++ b/test-data/unit/fixtures/list.pyi @@ -34,5 +34,6 @@ class float: class str: def __len__(self) -> bool: pass class bool(int): pass +class dict: pass property = object() # Dummy definition. diff --git a/test-data/unit/fixtures/tuple.pyi b/test-data/unit/fixtures/tuple.pyi index 60e47dd02220..8e0cf68228ac 100644 --- a/test-data/unit/fixtures/tuple.pyi +++ b/test-data/unit/fixtures/tuple.pyi @@ -37,6 +37,7 @@ class bool(int): pass class str: pass # For convenience class bytes: pass class bytearray: pass +class dict: pass class list(Sequence[T], Generic[T]): @overload From 2316b06a44a9d5f7b248abdfd072bab273042d50 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 27 Jan 2023 14:39:41 -0500 Subject: [PATCH 10/17] fix random test failures --- test-data/unit/fine-grained.test | 4 +- test-data/unit/merge.test | 96 ++++++++++++++++---------------- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/test-data/unit/fine-grained.test b/test-data/unit/fine-grained.test index d47c21283c91..9f22dc9ab7ac 100644 --- a/test-data/unit/fine-grained.test +++ b/test-data/unit/fine-grained.test @@ -1809,8 +1809,8 @@ def f() -> Iterator[None]: [typing fixtures/typing-medium.pyi] [builtins fixtures/list.pyi] [triggered] -2: , __main__ -3: , __main__, a +2: , , __main__ +3: , , __main__, a [out] main:2: note: Revealed type is "contextlib.GeneratorContextManager[None]" == diff --git a/test-data/unit/merge.test b/test-data/unit/merge.test index a593a064cbb2..144a095440f2 100644 --- a/test-data/unit/merge.test +++ b/test-data/unit/merge.test @@ -669,18 +669,18 @@ TypeInfo<2>( Mro(target.N<2>, builtins.tuple<3>, typing.Sequence<4>, typing.Iterable<5>, builtins.object<1>) Names( _NT<6> - __annotations__<7> (builtins.object<1>) - __doc__<8> (builtins.str<9>) - __match_args__<10> (Tuple[Literal['x']]) - __new__<11> - _asdict<12> - _field_defaults<13> (builtins.object<1>) - _field_types<14> (builtins.object<1>) - _fields<15> (Tuple[builtins.str<9>]) - _make<16> - _replace<17> - _source<18> (builtins.str<9>) - x<19> (target.A<0>))) + __annotations__<7> (builtins.dict[builtins.str<8>, Any]<9>) + __doc__<10> (builtins.str<8>) + __match_args__<11> (Tuple[Literal['x']]) + __new__<12> + _asdict<13> + _field_defaults<14> (builtins.dict[builtins.str<8>, Any]<9>) + _field_types<15> (builtins.dict[builtins.str<8>, Any]<9>) + _fields<16> (Tuple[builtins.str<8>]) + _make<17> + _replace<18> + _source<19> (builtins.str<8>) + x<20> (target.A<0>))) ==> TypeInfo<0>( Name(target.A) @@ -693,19 +693,19 @@ TypeInfo<2>( Mro(target.N<2>, builtins.tuple<3>, typing.Sequence<4>, typing.Iterable<5>, builtins.object<1>) Names( _NT<6> - __annotations__<7> (builtins.object<1>) - __doc__<8> (builtins.str<9>) - __match_args__<10> (Tuple[Literal['x'], Literal['y']]) - __new__<11> - _asdict<12> - _field_defaults<13> (builtins.object<1>) - _field_types<14> (builtins.object<1>) - _fields<15> (Tuple[builtins.str<9>, builtins.str<9>]) - _make<16> - _replace<17> - _source<18> (builtins.str<9>) - x<19> (target.A<0>) - y<20> (target.A<0>))) + __annotations__<7> (builtins.dict[builtins.str<8>, Any]<9>) + __doc__<10> (builtins.str<8>) + __match_args__<11> (Tuple[Literal['x'], Literal['y']]) + __new__<12> + _asdict<13> + _field_defaults<14> (builtins.dict[builtins.str<8>, Any]<9>) + _field_types<15> (builtins.dict[builtins.str<8>, Any]<9>) + _fields<16> (Tuple[builtins.str<8>, builtins.str<8>]) + _make<17> + _replace<18> + _source<19> (builtins.str<8>) + x<20> (target.A<0>) + y<21> (target.A<0>))) [case testNamedTupleOldVersion_typeinfo] import target @@ -730,17 +730,17 @@ TypeInfo<2>( Mro(target.N<2>, builtins.tuple<3>, typing.Sequence<4>, typing.Iterable<5>, builtins.object<1>) Names( _NT<6> - __annotations__<7> (builtins.object<1>) - __doc__<8> (builtins.str<9>) - __new__<10> - _asdict<11> - _field_defaults<12> (builtins.object<1>) - _field_types<13> (builtins.object<1>) - _fields<14> (Tuple[builtins.str<9>]) - _make<15> - _replace<16> - _source<17> (builtins.str<9>) - x<18> (target.A<0>))) + __annotations__<7> (builtins.dict[builtins.str<8>, Any]<9>) + __doc__<10> (builtins.str<8>) + __new__<11> + _asdict<12> + _field_defaults<13> (builtins.dict[builtins.str<8>, Any]<9>) + _field_types<14> (builtins.dict[builtins.str<8>, Any]<9>) + _fields<15> (Tuple[builtins.str<8>]) + _make<16> + _replace<17> + _source<18> (builtins.str<8>) + x<19> (target.A<0>))) ==> TypeInfo<0>( Name(target.A) @@ -753,18 +753,18 @@ TypeInfo<2>( Mro(target.N<2>, builtins.tuple<3>, typing.Sequence<4>, typing.Iterable<5>, builtins.object<1>) Names( _NT<6> - __annotations__<7> (builtins.object<1>) - __doc__<8> (builtins.str<9>) - __new__<10> - _asdict<11> - _field_defaults<12> (builtins.object<1>) - _field_types<13> (builtins.object<1>) - _fields<14> (Tuple[builtins.str<9>, builtins.str<9>]) - _make<15> - _replace<16> - _source<17> (builtins.str<9>) - x<18> (target.A<0>) - y<19> (target.A<0>))) + __annotations__<7> (builtins.dict[builtins.str<8>, Any]<9>) + __doc__<10> (builtins.str<8>) + __new__<11> + _asdict<12> + _field_defaults<13> (builtins.dict[builtins.str<8>, Any]<9>) + _field_types<14> (builtins.dict[builtins.str<8>, Any]<9>) + _fields<15> (Tuple[builtins.str<8>, builtins.str<8>]) + _make<16> + _replace<17> + _source<18> (builtins.str<8>) + x<19> (target.A<0>) + y<20> (target.A<0>))) [case testUnionType_types] import target From 5148e5f16beeeaebdd91111cc8db68c3e5c388f7 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 27 Jan 2023 16:19:34 -0500 Subject: [PATCH 11/17] style --- mypy/plugins/attrs.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index b88567a51a32..ed266f660188 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -937,11 +937,9 @@ def evolve_function_sig_callback(ctx: mypy.plugin.FunctionSigContext) -> Callabl ) return ctx.default_signature - arg_names = attrs_init_type.arg_names.copy() - arg_names[0] = "inst" return attrs_init_type.copy_modified( - arg_names=arg_names, - arg_kinds=[ARG_POS] + [ARG_NAMED_OPT] * (len(attrs_init_type.arg_kinds) - 1), + arg_names=["inst"] + attrs_init_type.arg_names[1:], + arg_kinds=[ARG_POS] + [ARG_NAMED_OPT for _ in attrs_init_type.arg_kinds[1:]], ret_type=inst_type, name=ctx.default_signature.name, ) From cf756d130bdd780ab6139f793c23a028027cf10c Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 27 Jan 2023 16:31:17 -0500 Subject: [PATCH 12/17] improve tests --- test-data/unit/check-attr.test | 36 +++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/test-data/unit/check-attr.test b/test-data/unit/check-attr.test index 8c92f8f3a8c1..a0b933bf0bf2 100644 --- a/test-data/unit/check-attr.test +++ b/test-data/unit/check-attr.test @@ -1896,21 +1896,47 @@ c = attr.evolve(c, b=Other()) # E: Argument "b" to "evolve" has incompatible ty c = attr.evolve(c, name=42) # E: Argument "name" to "evolve" has incompatible type "int"; expected "str" c = attr.evolve(c, foobar=42) # E: Unexpected keyword argument "foobar" for "evolve" +# test passing instance as 'inst' kw +c = attr.evolve(inst=c, name='foo') + +# test determining type of first argument's expression from something that's not NameExpr def f() -> C: return c - -# Determining type of first argument's expression c = attr.evolve(f(), name='foo') -# First argument type check +# test 'inst' arg type check attr.evolve(42, name='foo') # E: Argument 1 to "evolve" has incompatible type "Literal[42]?"; expected an attrs class attr.evolve(None, name='foo') # E: Argument 1 to "evolve" has incompatible type "None"; expected an attrs class -# All bets are off for 'Any' +# test that all bets are off for 'Any' any: Any ret = attr.evolve(any, name='foo') reveal_type(ret) # N: Revealed type is "Any" -[builtins fixtures/dict.pyi] +[builtins fixtures/attr.pyi] +[typing fixtures/typing-medium.pyi] + +[case testEvolveVariants] +from typing import Any +import attr +import attrs + + +@attr.s(auto_attribs=True) +class C: + name: str + +c = C(name='foo') + +c = attr.assoc(c, name='test') +c = attr.assoc(c, name=42) # E: Argument "name" to "assoc" has incompatible type "int"; expected "str" + +c = attrs.evolve(c, name='test') +c = attrs.evolve(c, name=42) # E: Argument "name" to "evolve" has incompatible type "int"; expected "str" + +c = attrs.assoc(c, name='test') +c = attrs.assoc(c, name=42) # E: Argument "name" to "assoc" has incompatible type "int"; expected "str" + +[builtins fixtures/attr.pyi] [typing fixtures/typing-medium.pyi] From 34c8c94b87d05bc0aa4a09b9f00ebb44daa5be97 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 27 Jan 2023 16:34:00 -0500 Subject: [PATCH 13/17] another test --- test-data/unit/check-attr.test | 1 + 1 file changed, 1 insertion(+) diff --git a/test-data/unit/check-attr.test b/test-data/unit/check-attr.test index a0b933bf0bf2..094a8688cf1d 100644 --- a/test-data/unit/check-attr.test +++ b/test-data/unit/check-attr.test @@ -1898,6 +1898,7 @@ c = attr.evolve(c, foobar=42) # E: Unexpected keyword argument "foobar" for "ev # test passing instance as 'inst' kw c = attr.evolve(inst=c, name='foo') +c = attr.evolve(not_inst=c, name='foo') # E: Missing positional argument "inst" in call to "evolve" # test determining type of first argument's expression from something that's not NameExpr def f() -> C: From 87a4c0b0725c1fa1fca5a1a5b4234b033bd5b759 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 27 Jan 2023 16:54:52 -0500 Subject: [PATCH 14/17] clarify evolve of _what_ --- mypy/plugins/attrs.py | 6 ++++-- test-data/unit/check-attr.test | 16 ++++++++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index ed266f660188..3e9eb72330e6 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -8,6 +8,7 @@ import mypy.plugin # To avoid circular imports. from mypy.checker import TypeChecker from mypy.exprtotype import TypeTranslationError, expr_to_unanalyzed_type +from mypy.messages import format_type_bare from mypy.nodes import ( ARG_NAMED, ARG_NAMED_OPT, @@ -926,13 +927,14 @@ def evolve_function_sig_callback(ctx: mypy.plugin.FunctionSigContext) -> Callabl inst_type = get_proper_type(inst_type) if isinstance(inst_type, AnyType): return ctx.default_signature + inst_type_str = format_type_bare(inst_type) # In practice, we're taking the initializer generated by _add_init and tweaking it # so that (a) its arguments are kw-only & optional, and (b) its return type is the instance's. attrs_init_type = _get_attrs_init_type(inst_type) if not attrs_init_type: ctx.api.fail( - f'Argument 1 to "evolve" has incompatible type "{inst_type}"; expected an attrs class', + f'Argument 1 to "evolve" has incompatible type "{inst_type_str}"; expected an attrs class', ctx.context, ) return ctx.default_signature @@ -941,5 +943,5 @@ def evolve_function_sig_callback(ctx: mypy.plugin.FunctionSigContext) -> Callabl arg_names=["inst"] + attrs_init_type.arg_names[1:], arg_kinds=[ARG_POS] + [ARG_NAMED_OPT for _ in attrs_init_type.arg_kinds[1:]], ret_type=inst_type, - name=ctx.default_signature.name, + name=f"{ctx.default_signature.name} of {inst_type_str}", ) diff --git a/test-data/unit/check-attr.test b/test-data/unit/check-attr.test index 094a8688cf1d..c25b7fc38dbe 100644 --- a/test-data/unit/check-attr.test +++ b/test-data/unit/check-attr.test @@ -1889,12 +1889,12 @@ class C: c = C(name='foo', b=Derived()) c = attr.evolve(c) c = attr.evolve(c, name='foo') -c = attr.evolve(c, 'foo') # E: Too many positional arguments for "evolve" +c = attr.evolve(c, 'foo') # E: Too many positional arguments for "evolve" of "C" c = attr.evolve(c, b=Derived()) c = attr.evolve(c, b=Base()) -c = attr.evolve(c, b=Other()) # E: Argument "b" to "evolve" has incompatible type "Other"; expected "Base" -c = attr.evolve(c, name=42) # E: Argument "name" to "evolve" has incompatible type "int"; expected "str" -c = attr.evolve(c, foobar=42) # E: Unexpected keyword argument "foobar" for "evolve" +c = attr.evolve(c, b=Other()) # E: Argument "b" to "evolve" of "C" has incompatible type "Other"; expected "Base" +c = attr.evolve(c, name=42) # E: Argument "name" to "evolve" of "C" has incompatible type "int"; expected "str" +c = attr.evolve(c, foobar=42) # E: Unexpected keyword argument "foobar" for "evolve" of "C" # test passing instance as 'inst' kw c = attr.evolve(inst=c, name='foo') @@ -1907,7 +1907,7 @@ def f() -> C: c = attr.evolve(f(), name='foo') # test 'inst' arg type check -attr.evolve(42, name='foo') # E: Argument 1 to "evolve" has incompatible type "Literal[42]?"; expected an attrs class +attr.evolve(42, name='foo') # E: Argument 1 to "evolve" has incompatible type "int"; expected an attrs class attr.evolve(None, name='foo') # E: Argument 1 to "evolve" has incompatible type "None"; expected an attrs class # test that all bets are off for 'Any' @@ -1931,13 +1931,13 @@ class C: c = C(name='foo') c = attr.assoc(c, name='test') -c = attr.assoc(c, name=42) # E: Argument "name" to "assoc" has incompatible type "int"; expected "str" +c = attr.assoc(c, name=42) # E: Argument "name" to "assoc" of "C" has incompatible type "int"; expected "str" c = attrs.evolve(c, name='test') -c = attrs.evolve(c, name=42) # E: Argument "name" to "evolve" has incompatible type "int"; expected "str" +c = attrs.evolve(c, name=42) # E: Argument "name" to "evolve" of "C" has incompatible type "int"; expected "str" c = attrs.assoc(c, name='test') -c = attrs.assoc(c, name=42) # E: Argument "name" to "assoc" has incompatible type "int"; expected "str" +c = attrs.assoc(c, name=42) # E: Argument "name" to "assoc" of "C" has incompatible type "int"; expected "str" [builtins fixtures/attr.pyi] [typing fixtures/typing-medium.pyi] From 0976f8607bb0fad47c1b9dab25bd1bcf3f5a0b81 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 27 Jan 2023 18:01:11 -0500 Subject: [PATCH 15/17] CallableType.copy_modified: arg_name is sequence --- mypy/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/types.py b/mypy/types.py index 0244f57847c5..b9426fcfe400 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -1771,7 +1771,7 @@ def copy_modified( self: CT, arg_types: Bogus[Sequence[Type]] = _dummy, arg_kinds: Bogus[list[ArgKind]] = _dummy, - arg_names: Bogus[list[str | None]] = _dummy, + arg_names: Bogus[Sequence[str | None]] = _dummy, ret_type: Bogus[Type] = _dummy, fallback: Bogus[Instance] = _dummy, name: Bogus[str | None] = _dummy, From 5443d51d9b2d02f36e5a1eebb9d779d7f35ad138 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Mon, 30 Jan 2023 11:58:59 -0500 Subject: [PATCH 16/17] break up evolve tests --- test-data/unit/check-attr.test | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/test-data/unit/check-attr.test b/test-data/unit/check-attr.test index c25b7fc38dbe..3fbda83a9d6c 100644 --- a/test-data/unit/check-attr.test +++ b/test-data/unit/check-attr.test @@ -1869,7 +1869,6 @@ D(1, "").b = "2" # E: Cannot assign to final attribute "b" [builtins fixtures/property.pyi] [case testEvolve] -from typing import Any import attr class Base: @@ -1906,16 +1905,21 @@ def f() -> C: c = attr.evolve(f(), name='foo') -# test 'inst' arg type check +[builtins fixtures/attr.pyi] + +[case testEvolveFromNonAttrs] +import attr + attr.evolve(42, name='foo') # E: Argument 1 to "evolve" has incompatible type "int"; expected an attrs class attr.evolve(None, name='foo') # E: Argument 1 to "evolve" has incompatible type "None"; expected an attrs class +[case testEvolveFromAny] +from typing import Any +import attr -# test that all bets are off for 'Any' -any: Any +any: Any = 42 ret = attr.evolve(any, name='foo') reveal_type(ret) # N: Revealed type is "Any" -[builtins fixtures/attr.pyi] [typing fixtures/typing-medium.pyi] [case testEvolveVariants] From bfcb366b20c24b371b6b3ebddc5677073023abc5 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Fri, 3 Mar 2023 10:46:31 -0500 Subject: [PATCH 17/17] clarify signature --- mypy/plugins/attrs.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index bbb73b16c52f..c71d898e1c62 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -934,8 +934,6 @@ def evolve_function_sig_callback(ctx: mypy.plugin.FunctionSigContext) -> Callabl return ctx.default_signature inst_type_str = format_type_bare(inst_type) - # In practice, we're taking the initializer generated by _add_init and tweaking it - # so that (a) its arguments are kw-only & optional, and (b) its return type is the instance's. attrs_init_type = _get_attrs_init_type(inst_type) if not attrs_init_type: ctx.api.fail( @@ -944,6 +942,10 @@ def evolve_function_sig_callback(ctx: mypy.plugin.FunctionSigContext) -> Callabl ) return ctx.default_signature + # AttrClass.__init__ has the following signature (or similar, if having kw-only & defaults): + # def __init__(self, attr1: Type1, attr2: Type2) -> None: + # We want to generate a signature for evolve that looks like this: + # def evolve(inst: AttrClass, *, attr1: Type1 = ..., attr2: Type2 = ...) -> AttrClass: return attrs_init_type.copy_modified( arg_names=["inst"] + attrs_init_type.arg_names[1:], arg_kinds=[ARG_POS] + [ARG_NAMED_OPT for _ in attrs_init_type.arg_kinds[1:]],