Skip to content

Handle assignment of bound methods in class bodies #19233

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mypy/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -2449,7 +2449,7 @@ def erase_override(t: Type) -> Type:
if not is_subtype(original_arg_type, erase_override(override_arg_type)):
context: Context = node
if isinstance(node, FuncDef) and not node.is_property:
arg_node = node.arguments[i + len(override.bound_args)]
arg_node = node.arguments[i + override.bound()]
if arg_node.line != -1:
context = arg_node
self.msg.argument_incompatible_with_supertype(
Expand Down
2 changes: 1 addition & 1 deletion mypy/checkexpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -4975,7 +4975,7 @@ def apply_type_arguments_to_callable(
tp.fallback,
name="tuple",
definition=tp.definition,
bound_args=tp.bound_args,
is_bound=tp.is_bound,
)
self.msg.incompatible_type_application(
min_arg_count, len(type_vars), len(args), ctx
Expand Down
4 changes: 2 additions & 2 deletions mypy/checkmember.py
Original file line number Diff line number Diff line change
Expand Up @@ -921,7 +921,7 @@ def analyze_var(
bound_items = []
for ct in call_type.items if isinstance(call_type, UnionType) else [call_type]:
p_ct = get_proper_type(ct)
if isinstance(p_ct, FunctionLike) and not p_ct.is_type_obj():
if isinstance(p_ct, FunctionLike) and (not p_ct.bound() or var.is_property):
item = expand_and_bind_callable(p_ct, var, itype, name, mx, is_trivial_self)
else:
item = expand_without_binding(ct, var, itype, original_itype, mx)
Expand Down Expand Up @@ -1498,6 +1498,6 @@ def bind_self_fast(method: F, original_type: Type | None = None) -> F:
arg_types=func.arg_types[1:],
arg_kinds=func.arg_kinds[1:],
arg_names=func.arg_names[1:],
bound_args=[original_type],
is_bound=True,
)
return cast(F, res)
3 changes: 0 additions & 3 deletions mypy/fixup.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,9 +271,6 @@ def visit_callable_type(self, ct: CallableType) -> None:
ct.ret_type.accept(self)
for v in ct.variables:
v.accept(self)
for arg in ct.bound_args:
if arg:
arg.accept(self)
if ct.type_guard is not None:
ct.type_guard.accept(self)
if ct.type_is is not None:
Expand Down
4 changes: 2 additions & 2 deletions mypy/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,8 +644,8 @@ def incompatible_argument(
callee_name = callable_name(callee)
if callee_name is not None:
name = callee_name
if callee.bound_args and callee.bound_args[0] is not None:
base = format_type(callee.bound_args[0], self.options)
if object_type is not None:
base = format_type(object_type, self.options)
else:
base = extract_type(name)

Expand Down
1 change: 1 addition & 0 deletions mypy/server/astdiff.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,7 @@ def visit_callable_type(self, typ: CallableType) -> SnapshotItem:
typ.is_type_obj(),
typ.is_ellipsis_args,
snapshot_types(typ.variables),
typ.is_bound,
)

def normalize_callable_variables(self, typ: CallableType) -> CallableType:
Expand Down
3 changes: 2 additions & 1 deletion mypy/typeops.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ def type_object_type(info: TypeInfo, named_type: Callable[[str], Instance]) -> P
arg_kinds=[ARG_STAR, ARG_STAR2],
arg_names=["_args", "_kwds"],
ret_type=any_type,
is_bound=True,
fallback=named_type("builtins.function"),
)
return class_callable(sig, info, fallback, None, is_new=False)
Expand Down Expand Up @@ -479,7 +480,7 @@ class B(A): pass
arg_kinds=func.arg_kinds[1:],
arg_names=func.arg_names[1:],
variables=variables,
bound_args=[original_type],
is_bound=True,
)
return cast(F, res)

Expand Down
20 changes: 10 additions & 10 deletions mypy/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1605,6 +1605,9 @@ def with_name(self, name: str) -> FunctionLike:
def get_name(self) -> str | None:
pass

def bound(self) -> bool:
return bool(self.items) and self.items[0].is_bound


class FormalArgument(NamedTuple):
name: str | None
Expand Down Expand Up @@ -1834,8 +1837,7 @@ class CallableType(FunctionLike):
# 'dict' and 'partial' for a `functools.partial` evaluation)
"from_type_type", # Was this callable generated by analyzing Type[...]
# instantiation?
"bound_args", # Bound type args, mostly unused but may be useful for
# tools that consume mypy ASTs
"is_bound", # Is this a bound method?
"def_extras", # Information about original definition we want to serialize.
# This is used for more detailed error messages.
"type_guard", # T, if -> TypeGuard[T] (ret_type is bool in this case).
Expand Down Expand Up @@ -1863,7 +1865,7 @@ def __init__(
implicit: bool = False,
special_sig: str | None = None,
from_type_type: bool = False,
bound_args: Sequence[Type | None] = (),
is_bound: bool = False,
def_extras: dict[str, Any] | None = None,
type_guard: Type | None = None,
type_is: Type | None = None,
Expand Down Expand Up @@ -1896,9 +1898,7 @@ def __init__(
self.from_type_type = from_type_type
self.from_concatenate = from_concatenate
self.imprecise_arg_kinds = imprecise_arg_kinds
if not bound_args:
bound_args = ()
self.bound_args = bound_args
self.is_bound = is_bound
if def_extras:
self.def_extras = def_extras
elif isinstance(definition, FuncDef):
Expand Down Expand Up @@ -1935,7 +1935,7 @@ def copy_modified(
implicit: Bogus[bool] = _dummy,
special_sig: Bogus[str | None] = _dummy,
from_type_type: Bogus[bool] = _dummy,
bound_args: Bogus[list[Type | None]] = _dummy,
is_bound: Bogus[bool] = _dummy,
def_extras: Bogus[dict[str, Any]] = _dummy,
type_guard: Bogus[Type | None] = _dummy,
type_is: Bogus[Type | None] = _dummy,
Expand All @@ -1960,7 +1960,7 @@ def copy_modified(
implicit=implicit if implicit is not _dummy else self.implicit,
special_sig=special_sig if special_sig is not _dummy else self.special_sig,
from_type_type=from_type_type if from_type_type is not _dummy else self.from_type_type,
bound_args=bound_args if bound_args is not _dummy else self.bound_args,
is_bound=is_bound if is_bound is not _dummy else self.is_bound,
def_extras=def_extras if def_extras is not _dummy else dict(self.def_extras),
type_guard=type_guard if type_guard is not _dummy else self.type_guard,
type_is=type_is if type_is is not _dummy else self.type_is,
Expand Down Expand Up @@ -2285,7 +2285,7 @@ def serialize(self) -> JsonDict:
"variables": [v.serialize() for v in self.variables],
"is_ellipsis_args": self.is_ellipsis_args,
"implicit": self.implicit,
"bound_args": [(None if t is None else t.serialize()) for t in self.bound_args],
"is_bound": self.is_bound,
"def_extras": dict(self.def_extras),
"type_guard": self.type_guard.serialize() if self.type_guard is not None else None,
"type_is": (self.type_is.serialize() if self.type_is is not None else None),
Expand All @@ -2308,7 +2308,7 @@ def deserialize(cls, data: JsonDict) -> CallableType:
variables=[cast(TypeVarLikeType, deserialize_type(v)) for v in data["variables"]],
is_ellipsis_args=data["is_ellipsis_args"],
implicit=data["implicit"],
bound_args=[(None if t is None else deserialize_type(t)) for t in data["bound_args"]],
is_bound=data["is_bound"],
def_extras=data["def_extras"],
type_guard=(
deserialize_type(data["type_guard"]) if data["type_guard"] is not None else None
Expand Down
2 changes: 1 addition & 1 deletion test-data/unit/check-classes.test
Original file line number Diff line number Diff line change
Expand Up @@ -4292,7 +4292,7 @@ int.__eq__(3, 4)
[builtins fixtures/args.pyi]
[out]
main:33: error: Too few arguments for "__eq__" of "int"
main:33: error: Unsupported operand types for == ("int" and "type[int]")
main:33: error: Unsupported operand types for == ("type[int]" and "type[int]")

[case testDupBaseClasses]
class A:
Expand Down
42 changes: 42 additions & 0 deletions test-data/unit/check-functions.test
Original file line number Diff line number Diff line change
Expand Up @@ -3591,3 +3591,45 @@ class Bar(Foo):

def foo(self, value: Union[int, str]) -> Union[int, str]:
return super().foo(value) # E: Call to abstract method "foo" of "Foo" with trivial body via super() is unsafe

[case testBoundMethodsAssignedInClassBody]
from typing import Callable

class A:
def f(self, x: int) -> str:
pass
@classmethod
def g(cls, x: int) -> str:
pass
@staticmethod
def h(x: int) -> str:
pass
attr: Callable[[int], str]

class C:
x1 = A.f
x2 = A.g
x3 = A().f
x4 = A().g
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would it make sense to test @staticmethod as well?

Copy link
Collaborator

Choose a reason for hiding this comment

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

And maybe normal attribute with an explicit annotated Callable type?

Copy link
Member Author

Choose a reason for hiding this comment

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

Static methods are actually unrelated to this story, they are already handled using special-casing here https://github.com/python/mypy/blob/master/mypy/checker.py#L4420-L4422. But I still added the tests, because existing tests only cover static method alias within same class. (Also attributes with explicit callable annotations are considered instance attributes, but anyway added for completeness.)

x5 = A.h
x6 = A().h
x7 = A().attr

reveal_type(C.x1) # N: Revealed type is "def (self: __main__.A, x: builtins.int) -> builtins.str"
reveal_type(C.x2) # N: Revealed type is "def (x: builtins.int) -> builtins.str"
reveal_type(C.x3) # N: Revealed type is "def (x: builtins.int) -> builtins.str"
reveal_type(C.x4) # N: Revealed type is "def (x: builtins.int) -> builtins.str"
reveal_type(C.x5) # N: Revealed type is "def (x: builtins.int) -> builtins.str"
reveal_type(C.x6) # N: Revealed type is "def (x: builtins.int) -> builtins.str"
reveal_type(C.x7) # N: Revealed type is "def (builtins.int) -> builtins.str"

reveal_type(C().x1) # E: Invalid self argument "C" to attribute function "x1" with type "Callable[[A, int], str]" \
# N: Revealed type is "def (x: builtins.int) -> builtins.str"
reveal_type(C().x2) # N: Revealed type is "def (x: builtins.int) -> builtins.str"
reveal_type(C().x3) # N: Revealed type is "def (x: builtins.int) -> builtins.str"
reveal_type(C().x4) # N: Revealed type is "def (x: builtins.int) -> builtins.str"
reveal_type(C().x5) # N: Revealed type is "def (x: builtins.int) -> builtins.str"
reveal_type(C().x6) # N: Revealed type is "def (x: builtins.int) -> builtins.str"
reveal_type(C().x7) # E: Invalid self argument "C" to attribute function "x7" with type "Callable[[int], str]" \
# N: Revealed type is "def () -> builtins.str"
[builtins fixtures/classmethod.pyi]
24 changes: 24 additions & 0 deletions test-data/unit/check-incremental.test
Original file line number Diff line number Diff line change
Expand Up @@ -6862,3 +6862,27 @@ if int():
[out]
[out2]
main:6: error: Incompatible types in assignment (expression has type "str", variable has type "int")

[case testMethodMakeBoundIncremental]
from a import A
a = A()
a.f()
[file a.py]
class B:
def f(self, s: A) -> int: ...

def f(s: A) -> int: ...

class A:
f = f
[file a.py.2]
class B:
def f(self, s: A) -> int: ...

def f(s: A) -> int: ...

class A:
f = B().f
[out]
[out2]
main:3: error: Too few arguments
24 changes: 24 additions & 0 deletions test-data/unit/fine-grained.test
Original file line number Diff line number Diff line change
Expand Up @@ -11217,3 +11217,27 @@ class A:
[out]
==
main:3: error: Property "f" defined in "A" is read-only

[case testMethodMakeBoundFineGrained]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Add also normal incremental mode (serialization) tests case?

from a import A
a = A()
a.f()
[file a.py]
class B:
def f(self, s: A) -> int: ...

def f(s: A) -> int: ...

class A:
f = f
[file a.py.2]
class B:
def f(self, s: A) -> int: ...

def f(s: A) -> int: ...

class A:
f = B().f
[out]
==
main:3: error: Too few arguments