Skip to content

Commit dc9c304

Browse files
ilevkivskyiwyfo
andauthored
Fix callable instance variable support (take 2) (#13400)
Fixes #708 Fixes #5485 This builds on the original proposal, but handles three important issues/edge cases: * This PR fixes serialization of `is_inferred` so that the distinction works correctly in incremental mode (I added a test) * Dunder operator methods are always considered class variables (this is a relatively common pattern and matches Python semantics; there is an existing tests that previously needed `ClassVar[...]`) * If we detect a `Too few arguments` error for a variable with callable type we give a note suggesting to try `ClassVar[...]` I also add a short doc paragraph on this. Co-authored-by: wyfo <[email protected]>
1 parent a0e2a2d commit dc9c304

12 files changed

+147
-86
lines changed

docs/source/class_basics.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,22 @@ a :py:data:`~typing.ClassVar` annotation, but this might not do what you'd expec
147147
In this case the type of the attribute will be implicitly ``Any``.
148148
This behavior will change in the future, since it's surprising.
149149

150+
An explicit :py:data:`~typing.ClassVar` may be particularly handy to distinguish
151+
between class and instance variables with callable types. For example:
152+
153+
.. code-block:: python
154+
155+
from typing import Callable, ClassVar
156+
157+
class A:
158+
foo: Callable[[int], None]
159+
bar: ClassVar[Callable[[A, int], None]]
160+
bad: Callable[[A], None]
161+
162+
A().foo(42) # OK
163+
A().bar(42) # OK
164+
A().bad() # Error: Too few arguments
165+
150166
.. note::
151167
A :py:data:`~typing.ClassVar` type parameter cannot include type variables:
152168
``ClassVar[T]`` and ``ClassVar[list[T]]``

mypy/checkexpr.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1318,7 +1318,14 @@ def check_callable_call(
13181318
arg_types = self.infer_arg_types_in_context(callee, args, arg_kinds, formal_to_actual)
13191319

13201320
self.check_argument_count(
1321-
callee, arg_types, arg_kinds, arg_names, formal_to_actual, context
1321+
callee,
1322+
arg_types,
1323+
arg_kinds,
1324+
arg_names,
1325+
formal_to_actual,
1326+
context,
1327+
object_type,
1328+
callable_name,
13221329
)
13231330

13241331
self.check_argument_types(
@@ -1723,6 +1730,8 @@ def check_argument_count(
17231730
actual_names: Optional[Sequence[Optional[str]]],
17241731
formal_to_actual: List[List[int]],
17251732
context: Optional[Context],
1733+
object_type: Optional[Type] = None,
1734+
callable_name: Optional[str] = None,
17261735
) -> bool:
17271736
"""Check that there is a value for all required arguments to a function.
17281737
@@ -1753,6 +1762,8 @@ def check_argument_count(
17531762
# No actual for a mandatory formal
17541763
if kind.is_positional():
17551764
self.msg.too_few_arguments(callee, context, actual_names)
1765+
if object_type and callable_name and "." in callable_name:
1766+
self.missing_classvar_callable_note(object_type, callable_name, context)
17561767
else:
17571768
argname = callee.arg_names[i] or "?"
17581769
self.msg.missing_named_argument(callee, context, argname)
@@ -1836,6 +1847,20 @@ def check_for_extra_actual_arguments(
18361847

18371848
return ok, is_unexpected_arg_error
18381849

1850+
def missing_classvar_callable_note(
1851+
self, object_type: Type, callable_name: str, context: Context
1852+
) -> None:
1853+
if isinstance(object_type, ProperType) and isinstance(object_type, Instance):
1854+
_, var_name = callable_name.rsplit(".", maxsplit=1)
1855+
node = object_type.type.get(var_name)
1856+
if node is not None and isinstance(node.node, Var):
1857+
if not node.node.is_inferred and not node.node.is_classvar:
1858+
self.msg.note(
1859+
f'"{var_name}" is considered instance variable,'
1860+
" to make it class variable use ClassVar[...]",
1861+
context,
1862+
)
1863+
18391864
def check_argument_types(
18401865
self,
18411866
arg_types: List[Type],

mypy/checkmember.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,18 @@ def instance_alias_type(alias: TypeAlias, named_type: Callable[[str], Instance])
659659
return expand_type_by_instance(tp, target)
660660

661661

662+
def is_instance_var(var: Var, info: TypeInfo) -> bool:
663+
"""Return if var is an instance variable according to PEP 526."""
664+
return (
665+
# check the type_info node is the var (not a decorated function, etc.)
666+
var.name in info.names
667+
and info.names[var.name].node is var
668+
and not var.is_classvar
669+
# variables without annotations are treated as classvar
670+
and not var.is_inferred
671+
)
672+
673+
662674
def analyze_var(
663675
name: str,
664676
var: Var,
@@ -690,7 +702,12 @@ def analyze_var(
690702
t = get_proper_type(expand_type_by_instance(typ, itype))
691703
result: Type = t
692704
typ = get_proper_type(typ)
693-
if var.is_initialized_in_class and isinstance(typ, FunctionLike) and not typ.is_type_obj():
705+
if (
706+
var.is_initialized_in_class
707+
and (not is_instance_var(var, info) or mx.is_operator)
708+
and isinstance(typ, FunctionLike)
709+
and not typ.is_type_obj()
710+
):
694711
if mx.is_lvalue:
695712
if var.is_property:
696713
if not var.is_settable_property:

mypy/nodes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -939,6 +939,7 @@ def deserialize(cls, data: JsonDict) -> "Decorator":
939939
"final_set_in_init",
940940
"explicit_self_type",
941941
"is_ready",
942+
"is_inferred",
942943
"from_module_getattr",
943944
"has_explicit_value",
944945
"allow_incompatible_override",

mypy/semanal.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3851,10 +3851,14 @@ def check_classvar(self, s: AssignmentStmt) -> None:
38513851
if isinstance(node, Var):
38523852
node.is_classvar = True
38533853
analyzed = self.anal_type(s.type)
3854-
if analyzed is not None and get_type_vars(analyzed):
3854+
assert self.type is not None
3855+
if analyzed is not None and set(get_type_vars(analyzed)) & set(
3856+
self.type.defn.type_vars
3857+
):
38553858
# This means that we have a type var defined inside of a ClassVar.
38563859
# This is not allowed by PEP526.
38573860
# See https://github.com/python/mypy/issues/11538
3861+
38583862
self.fail(message_registry.CLASS_VAR_WITH_TYPEVARS, s)
38593863
elif not isinstance(lvalue, MemberExpr) or self.is_self_member_ref(lvalue):
38603864
# In case of member access, report error only when assigning to self

test-data/unit/check-classvar.test

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,3 +325,12 @@ class Good(A[int, str]):
325325
x = 42
326326
reveal_type(Good.x) # N: Revealed type is "builtins.int"
327327
[builtins fixtures/classmethod.pyi]
328+
329+
[case testSuggestClassVarOnTooFewArgumentsMethod]
330+
from typing import Callable
331+
332+
class C:
333+
foo: Callable[[C], int]
334+
c:C
335+
c.foo() # E: Too few arguments \
336+
# N: "foo" is considered instance variable, to make it class variable use ClassVar[...]

test-data/unit/check-dataclasses.test

Lines changed: 18 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,80 +1304,41 @@ reveal_type(A.__dataclass_fields__) # N: Revealed type is "builtins.dict[builti
13041304

13051305
[builtins fixtures/dict.pyi]
13061306

1307-
[case testDataclassCallableProperty]
1307+
[case testDataclassCallableFieldAccess]
13081308
# flags: --python-version 3.7
13091309
from dataclasses import dataclass
13101310
from typing import Callable
13111311

13121312
@dataclass
13131313
class A:
1314-
foo: Callable[[int], int]
1314+
x: Callable[[int], int]
1315+
y: Callable[[int], int] = lambda i: i
13151316

1316-
def my_foo(x: int) -> int:
1317-
return x
1318-
1319-
a = A(foo=my_foo)
1320-
a.foo(1)
1321-
reveal_type(a.foo) # N: Revealed type is "def (builtins.int) -> builtins.int"
1322-
reveal_type(A.foo) # N: Revealed type is "def (builtins.int) -> builtins.int"
1323-
[typing fixtures/typing-medium.pyi]
1324-
[builtins fixtures/dataclasses.pyi]
1325-
1326-
[case testDataclassCallableAssignment]
1327-
# flags: --python-version 3.7
1328-
from dataclasses import dataclass
1329-
from typing import Callable
1330-
1331-
@dataclass
1332-
class A:
1333-
foo: Callable[[int], int]
1334-
1335-
def my_foo(x: int) -> int:
1336-
return x
1337-
1338-
a = A(foo=my_foo)
1339-
1340-
def another_foo(x: int) -> int:
1341-
return x
1342-
1343-
a.foo = another_foo
1317+
a = A(lambda i:i)
1318+
x: int = a.x(0)
1319+
y: str = a.y(0) # E: Incompatible types in assignment (expression has type "int", variable has type "str")
1320+
reveal_type(a.x) # N: Revealed type is "def (builtins.int) -> builtins.int"
1321+
reveal_type(a.y) # N: Revealed type is "def (builtins.int) -> builtins.int"
1322+
reveal_type(A.y) # N: Revealed type is "def (builtins.int) -> builtins.int"
13441323
[builtins fixtures/dataclasses.pyi]
13451324

1346-
[case testDataclassCallablePropertyWrongType]
1325+
[case testDataclassCallableFieldAssignment]
13471326
# flags: --python-version 3.7
13481327
from dataclasses import dataclass
13491328
from typing import Callable
13501329

13511330
@dataclass
13521331
class A:
1353-
foo: Callable[[int], int]
1332+
x: Callable[[int], int]
13541333

1355-
def my_foo(x: int) -> str:
1356-
return "foo"
1334+
def x(i: int) -> int:
1335+
return i
1336+
def x2(s: str) -> str:
1337+
return s
13571338

1358-
a = A(foo=my_foo) # E: Argument "foo" to "A" has incompatible type "Callable[[int], str]"; expected "Callable[[int], int]"
1359-
[typing fixtures/typing-medium.pyi]
1360-
[builtins fixtures/dataclasses.pyi]
1361-
1362-
[case testDataclassCallablePropertyWrongTypeAssignment]
1363-
# flags: --python-version 3.7
1364-
from dataclasses import dataclass
1365-
from typing import Callable
1366-
1367-
@dataclass
1368-
class A:
1369-
foo: Callable[[int], int]
1370-
1371-
def my_foo(x: int) -> int:
1372-
return x
1373-
1374-
a = A(foo=my_foo)
1375-
1376-
def another_foo(x: int) -> str:
1377-
return "foo"
1378-
1379-
a.foo = another_foo # E: Incompatible types in assignment (expression has type "Callable[[int], str]", variable has type "Callable[[int], int]")
1380-
[typing fixtures/typing-medium.pyi]
1339+
a = A(lambda i:i)
1340+
a.x = x
1341+
a.x = x2 # E: Incompatible types in assignment (expression has type "Callable[[str], str]", variable has type "Callable[[int], int]")
13811342
[builtins fixtures/dataclasses.pyi]
13821343

13831344
[case testDataclassFieldDoesNotFailOnKwargsUnpacking]

test-data/unit/check-functions.test

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -571,39 +571,51 @@ A().f('') # E: Argument 1 to "f" of "A" has incompatible type "str"; expected "i
571571

572572

573573
[case testMethodAsDataAttribute]
574-
from typing import Any, Callable
574+
from typing import Any, Callable, ClassVar
575575
class B: pass
576576
x = None # type: Any
577577
class A:
578-
f = x # type: Callable[[A], None]
579-
g = x # type: Callable[[A, B], None]
578+
f = x # type: ClassVar[Callable[[A], None]]
579+
g = x # type: ClassVar[Callable[[A, B], None]]
580580
a = None # type: A
581581
a.f()
582582
a.g(B())
583583
a.f(a) # E: Too many arguments
584584
a.g() # E: Too few arguments
585585

586586
[case testMethodWithInvalidMethodAsDataAttribute]
587-
from typing import Any, Callable
587+
from typing import Any, Callable, ClassVar
588588
class B: pass
589589
x = None # type: Any
590590
class A:
591-
f = x # type: Callable[[], None]
592-
g = x # type: Callable[[B], None]
591+
f = x # type: ClassVar[Callable[[], None]]
592+
g = x # type: ClassVar[Callable[[B], None]]
593593
a = None # type: A
594594
a.f() # E: Attribute function "f" with type "Callable[[], None]" does not accept self argument
595595
a.g() # E: Invalid self argument "A" to attribute function "g" with type "Callable[[B], None]"
596596

597597
[case testMethodWithDynamicallyTypedMethodAsDataAttribute]
598-
from typing import Any, Callable
598+
from typing import Any, Callable, ClassVar
599599
class B: pass
600600
x = None # type: Any
601601
class A:
602-
f = x # type: Callable[[Any], Any]
602+
f = x # type: ClassVar[Callable[[Any], Any]]
603603
a = None # type: A
604604
a.f()
605605
a.f(a) # E: Too many arguments
606606

607+
[case testMethodWithInferredMethodAsDataAttribute]
608+
from typing import Any
609+
def m(self: "A") -> int: ...
610+
611+
class A:
612+
n = m
613+
614+
a = A()
615+
reveal_type(a.n()) # N: Revealed type is "builtins.int"
616+
reveal_type(A.n(a)) # N: Revealed type is "builtins.int"
617+
A.n() # E: Too few arguments
618+
607619
[case testOverloadedMethodAsDataAttribute]
608620
from foo import *
609621
[file foo.pyi]
@@ -645,35 +657,35 @@ a.g(B())
645657
a.g(a) # E: Argument 1 has incompatible type "A[B]"; expected "B"
646658

647659
[case testInvalidMethodAsDataAttributeInGenericClass]
648-
from typing import Any, TypeVar, Generic, Callable
660+
from typing import Any, TypeVar, Generic, Callable, ClassVar
649661
t = TypeVar('t')
650662
class B: pass
651663
class C: pass
652664
x = None # type: Any
653665
class A(Generic[t]):
654-
f = x # type: Callable[[A[B]], None]
666+
f = x # type: ClassVar[Callable[[A[B]], None]]
655667
ab = None # type: A[B]
656668
ac = None # type: A[C]
657669
ab.f()
658670
ac.f() # E: Invalid self argument "A[C]" to attribute function "f" with type "Callable[[A[B]], None]"
659671

660672
[case testPartiallyTypedSelfInMethodDataAttribute]
661-
from typing import Any, TypeVar, Generic, Callable
673+
from typing import Any, TypeVar, Generic, Callable, ClassVar
662674
t = TypeVar('t')
663675
class B: pass
664676
class C: pass
665677
x = None # type: Any
666678
class A(Generic[t]):
667-
f = x # type: Callable[[A], None]
679+
f = x # type: ClassVar[Callable[[A], None]]
668680
ab = None # type: A[B]
669681
ac = None # type: A[C]
670682
ab.f()
671683
ac.f()
672684

673685
[case testCallableDataAttribute]
674-
from typing import Callable
686+
from typing import Callable, ClassVar
675687
class A:
676-
g = None # type: Callable[[A], None]
688+
g = None # type: ClassVar[Callable[[A], None]]
677689
def __init__(self, f: Callable[[], None]) -> None:
678690
self.f = f
679691
a = A(None)

test-data/unit/check-functools.test

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Ord() >= 1 # E: Unsupported operand types for >= ("Ord" and "int")
2525

2626
[case testTotalOrderingLambda]
2727
from functools import total_ordering
28-
from typing import Any, Callable
28+
from typing import Any, Callable, ClassVar
2929

3030
@total_ordering
3131
class Ord:

test-data/unit/check-incremental.test

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5659,6 +5659,23 @@ class D(C):
56595659
[out2]
56605660
tmp/a.py:9: error: Trying to assign name "z" that is not in "__slots__" of type "a.D"
56615661

5662+
[case testMethodAliasIncremental]
5663+
import b
5664+
[file a.py]
5665+
class A:
5666+
def f(self) -> None: pass
5667+
g = f
5668+
5669+
[file b.py]
5670+
from a import A
5671+
A().g()
5672+
[file b.py.2]
5673+
# trivial change
5674+
from a import A
5675+
A().g()
5676+
[out]
5677+
[out2]
5678+
56625679
[case testIncrementalWithDifferentKindsOfNestedTypesWithinMethod]
56635680
# flags: --python-version 3.7
56645681

0 commit comments

Comments
 (0)