Skip to content

[ty] Allow compatible function reassignment - #27329

Draft
charliermarsh wants to merge 2 commits into
mainfrom
charlie/allow-compatible-function-reassignment
Draft

[ty] Allow compatible function reassignment#27329
charliermarsh wants to merge 2 commits into
mainfrom
charlie/allow-compatible-function-reassignment

Conversation

@charliermarsh

@charliermarsh charliermarsh commented Jul 30, 2026

Copy link
Copy Markdown
Member

Summary

When a function is defined, we currently treat its name as being declared with the type of that exact function object. This makes an ordinary reassignment fail even when the replacement accepts the same arguments and returns the same type:

def original(value: int) -> int:
    return value

def replacement(value: int) -> int:
    return value

original = replacement

We should allow this assignment: original does not need to keep pointing to the same function forever. But checking only that the replacement is callable would be too permissive.

Why callable signatures are not enough

Functions are descriptors. When a function is placed on a class, accessing it through an instance automatically supplies that instance as the first argument. Ordinary callable objects and class objects do not generally behave this way.

class Callback:
    def __call__(self, instance: object, value: int) -> int:
        return value

def callback(instance: object, value: int) -> int:
    return value

callback = Callback()

class Container:
    method = callback

Container().method(1)  # Fails at runtime: `value` was not provided.

Even though callback was originally defined at module scope, it can later become a class attribute. Replacing it with an arbitrary callable would let us incorrectly assume that the replacement still binds an instance.

Resulting model

We separate the type of the current value from the contract used to check reassignment:

  1. A newly defined function keeps its precise function-literal type and identity.
  2. Reassignment is checked against a function-like callable with the same signature and descriptor behavior.
  3. After a valid reassignment, the name refers to the actual replacement, not the original function.
  4. Public lookups and imports use the current live binding, preserving exact identity for unconditional replacements and both possibilities for conditional replacements.
# provider.py
before = original
original = replacement

# consumer.py
from provider import before, original

reveal_type(original is before)  # Literal[False], not Literal[True].

Therefore compatible functions are accepted, while callable objects, class objects, incompatible signatures, and mismatched keyword parameter names remain rejected. This is the same descriptor-preserving assignment model used for compatible method replacements in #26158.

Type-checking-only declarations

A function defined only under TYPE_CHECKING never creates a function object at runtime, so it does not promise descriptor behavior:

from functools import partial
from typing import TYPE_CHECKING

def implementation(prefix: str, value: int) -> int:
    return value

if TYPE_CHECKING:
    def callback(value: int) -> int: ...

callback = partial(implementation, "prefix")

Here the definition is just a convenient way to declare a callable signature. A compatible callable object remains valid, and imports see the actual callable object instead of incorrectly treating it as a function descriptor.

@astral-sh-bot astral-sh-bot Bot added the ty Multi-file analysis & type inference label Jul 30, 2026
@astral-sh-bot

astral-sh-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

Typing conformance results

No changes detected ✅

Current numbers
The percentage of diagnostics emitted that were expected errors held steady at 96.84%. The percentage of expected errors that received a diagnostic held steady at 92.11%. The number of fully passing files held steady at 99/133.

@astral-sh-bot

astral-sh-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

Memory usage report

Memory usage unchanged ✅

@astral-sh-bot

astral-sh-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-assignment 2 19 0
invalid-method-override 7 0 0
possibly-missing-attribute 6 0 0
missing-argument 1 0 0
unresolved-attribute 0 1 0
Total 16 20 0

Flaky changes detected. This PR summary excludes flaky changes; see the HTML report for details.

Raw diff (36 changes)
colour (https://github.com/colour-science/colour)
- colour/difference/cam16_ucs.py:165:18 error[invalid-assignment] Object of type `(...) -> Unknown` is not assignable to `Overload[(Jpapbp_1: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, Jpapbp_2: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, coefficients: Coefficients_UCS_Luo2006, *, additional_data: Literal[False] = False) -> ndarray[tuple[Any, ...], dtype[floating[_16Bit] | floating[_32Bit] | float64]], (Jpapbp_1: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, Jpapbp_2: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, coefficients: Coefficients_UCS_Luo2006, *, additional_data: Literal[True]) -> DeltaE_Specification_Luo2006]`
- colour/difference/cam16_ucs.py:168:20 error[invalid-assignment] Object of type `(...) -> Unknown` is not assignable to `Overload[(Jpapbp_1: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, Jpapbp_2: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, *, additional_data: Literal[False] = False) -> ndarray[tuple[Any, ...], dtype[floating[_16Bit] | floating[_32Bit] | float64]], (Jpapbp_1: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, Jpapbp_2: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, *, additional_data: Literal[True]) -> DeltaE_Specification_Luo2006]`
- colour/difference/cam16_ucs.py:173:20 error[invalid-assignment] Object of type `(...) -> Unknown` is not assignable to `Overload[(Jpapbp_1: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, Jpapbp_2: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, *, additional_data: Literal[False] = False) -> ndarray[tuple[Any, ...], dtype[floating[_16Bit] | floating[_32Bit] | float64]], (Jpapbp_1: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, Jpapbp_2: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, *, additional_data: Literal[True]) -> DeltaE_Specification_Luo2006]`
- colour/difference/cam16_ucs.py:178:20 error[invalid-assignment] Object of type `(...) -> Unknown` is not assignable to `Overload[(Jpapbp_1: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, Jpapbp_2: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, *, additional_data: Literal[False] = False) -> ndarray[tuple[Any, ...], dtype[floating[_16Bit] | floating[_32Bit] | float64]], (Jpapbp_1: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, Jpapbp_2: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | ... omitted 5 union elements, *, additional_data: Literal[True]) -> DeltaE_Specification_Luo2006]`

dd-trace-py (https://github.com/DataDog/dd-trace-py)
+ tests/testing/internal/pytest/test_plugin.py:76:18 warning[possibly-missing-attribute] Attribute `pytest_runtest_protocol_wrapper` may be missing on object of type `TestOptPlugin`
+ tests/testing/internal/pytest/test_plugin.py:110:18 warning[possibly-missing-attribute] Attribute `pytest_runtest_protocol_wrapper` may be missing on object of type `TestOptPlugin`
+ tests/testing/internal/pytest/test_plugin.py:154:18 warning[possibly-missing-attribute] Attribute `pytest_runtest_protocol_wrapper` may be missing on object of type `TestOptPlugin`
+ tests/testing/internal/pytest/test_plugin.py:188:18 warning[possibly-missing-attribute] Attribute `pytest_runtest_protocol_wrapper` may be missing on object of type `TestOptPlugin`
+ tests/testing/internal/pytest/test_plugin.py:583:18 warning[possibly-missing-attribute] Attribute `pytest_runtest_protocol_wrapper` may be missing on object of type `TestOptPlugin`
+ tests/testing/internal/pytest/test_plugin.py:610:18 warning[possibly-missing-attribute] Attribute `pytest_runtest_protocol_wrapper` may be missing on object of type `TestOptPlugin`

ibis (https://github.com/ibis-project/ibis)
+ ibis/examples/gen_registry.py:342:21 error[invalid-assignment] Object of type `partial[(path: Path, *, board: Unknown = ..., metadata: dict[str, dict[str, str] | None] = ..., bar: Unknown = ...) -> None]` is not assignable to `def write_pin(path: Path, *, board: Unknown, metadata: dict[str, dict[str, str] | None], bar: Unknown) -> None`
+ ibis/examples/gen_registry.py:351:17 error[missing-argument] No arguments provided for required parameters `board`, `metadata`, `bar` of bound method `Executor.submit`

pip (https://github.com/pypa/pip)
- src/pip/_vendor/urllib3/util/wait.py:107:27 error[invalid-assignment] Object of type `def poll_wait_for_socket(sock: socket, read: bool = False, write: bool = False, timeout: int | float | None = None) -> bool` is not assignable to `def wait_for_socket(sock: socket, read: bool = False, write: bool = False, timeout: int | float | None = None) -> bool`
- src/pip/_vendor/urllib3/util/wait.py:109:27 error[invalid-assignment] Object of type `def select_wait_for_socket(sock: socket, read: bool = False, write: bool = False, timeout: int | float | None = None) -> bool` is not assignable to `def wait_for_socket(sock: socket, read: bool = False, write: bool = False, timeout: int | float | None = None) -> bool`

pwndbg (https://github.com/pwndbg/pwndbg)
- pwndbg/gdblib/tui/context.py:220:24 error[invalid-assignment] Object of type `(...) -> str` is not assignable to `def _ansi_substr(self, line: str, start_char: int, end_char: int) -> str`

pytest (https://github.com/pytest-dev/pytest)
- src/_pytest/junitxml.py:370:23 error[invalid-assignment] Object of type `bound method LogXML.add_global_property(name: str, value: object) -> None` is not assignable to `def record_func(name: str, value: object) -> None`

scipy (https://github.com/scipy/scipy)
- benchmarks/benchmarks/fft_basic.py:121:9 error[unresolved-attribute] Function `next_fast_len` has no attribute `__wrapped__`

sympy (https://github.com/sympy/sympy)
+ sympy/multipledispatch/tests/test_core.py:12:12 error[invalid-assignment] Object of type `partial[(*types, *, namespace=..., on_ambiguity=...) -> Unknown]` is not assignable to `def dispatch(*types, *, namespace=..., on_ambiguity=...) -> Unknown`
- sympy/sets/tests/test_sets.py:59:9 error[invalid-assignment] Object of type `(x) -> cos` is not assignable to `def f(x) -> Unknown`

tornado (https://github.com/tornadoweb/tornado)
- tornado/util.py:430:27 error[invalid-assignment] Object of type `def _websocket_mask_python(mask: bytes, data: bytes) -> bytes` is not assignable to `def websocket_mask(mask: bytes, data: bytes) -> bytes`

trio (https://github.com/python-trio/trio)
- src/trio/_socket.py:1123:12 error[invalid-assignment] Object of type `(_SocketType, bufsize: int, flags: int = 0, /) -> Awaitable[bytes]` is not assignable to `def recv(self, buflen: int, flags: int = 0, /) -> Awaitable[bytes]`
- src/trio/_socket.py:1142:17 error[invalid-assignment] Object of type `(_SocketType, /, buffer: Buffer, nbytes: int = 0, flags: int = 0) -> Awaitable[int]` is not assignable to `def recv_into(self, /, buffer: Buffer, nbytes: int = 0, flags: int = 0) -> Awaitable[int]`
- src/trio/_socket.py:1160:16 error[invalid-assignment] Object of type `(_SocketType, bufsize: int, flags: int = 0, /) -> Awaitable[tuple[bytes, Any]]` is not assignable to `def recvfrom(self, bufsize: int, flags: int = 0, /) -> Awaitable[tuple[bytes, Any]]`
- src/trio/_socket.py:1179:21 error[invalid-assignment] Object of type `(_SocketType, /, buffer: Buffer, nbytes: int = 0, flags: int = 0) -> Awaitable[tuple[int, Any]]` is not assignable to `def recvfrom_into(self, /, buffer: Buffer, nbytes: int = 0, flags: int = 0) -> Awaitable[tuple[int, Any]]`
- src/trio/_socket.py:1201:19 error[invalid-assignment] Object of type `(_SocketType, bufsize: int, ancbufsize: int = 0, flags: int = 0, /) -> Awaitable[tuple[bytes, list[tuple[int, int, bytes]], int, Any]]` is not assignable to `def recvmsg(self, bufsize: int, ancbufsize: int = 0, flags: int = 0, /) -> Awaitable[tuple[bytes, list[tuple[int, int, bytes]], int, object]]`
- src/trio/_socket.py:1224:24 error[invalid-assignment] Object of type `(_SocketType, buffers: Iterable[Buffer], ancbufsize: int = 0, flags: int = 0, /) -> Awaitable[tuple[int, list[tuple[int, int, bytes]], int, Any]]` is not assignable to `def recvmsg_into(self, buffers: Iterable[Buffer], ancbufsize: int = 0, flags: int = 0, /) -> Awaitable[tuple[int, list[tuple[int, int, bytes]], int, object]]`
- src/trio/_socket.py:1238:12 error[invalid-assignment] Object of type `(_SocketType, data: Buffer, flags: int = 0, /) -> Awaitable[int]` is not assignable to `def send(self, bytes: Buffer, flags: int = 0, /) -> Awaitable[int]`
+ src/trio/_socket.py:1121:13 error[invalid-method-override] Invalid override of method `recv`: Definition is incompatible with `SocketType.recv`
+ src/trio/_socket.py:1134:13 error[invalid-method-override] Invalid override of method `recv_into`: Definition is incompatible with `SocketType.recv_into`
+ src/trio/_socket.py:1153:13 error[invalid-method-override] Invalid override of method `recvfrom`: Definition is incompatible with `SocketType.recvfrom`
+ src/trio/_socket.py:1171:13 error[invalid-method-override] Invalid override of method `recvfrom_into`: Definition is incompatible with `SocketType.recvfrom_into`
+ src/trio/_socket.py:1193:17 error[invalid-method-override] Invalid override of method `recvmsg`: Definition is incompatible with `SocketType.recvmsg`
+ src/trio/_socket.py:1216:17 error[invalid-method-override] Invalid override of method `recvmsg_into`: Definition is incompatible with `SocketType.recvmsg_into`
+ src/trio/_socket.py:1236:13 error[invalid-method-override] Invalid override of method `send`: Definition is incompatible with `SocketType.send`

urllib3 (https://github.com/urllib3/urllib3)
- src/urllib3/util/wait.py:107:27 error[invalid-assignment] Object of type `def poll_wait_for_socket(sock: socket, read: bool = False, write: bool = False, timeout: int | float | None = None) -> bool` is not assignable to `def wait_for_socket(sock: socket, read: bool = False, write: bool = False, timeout: int | float | None = None) -> bool`
- src/urllib3/util/wait.py:109:27 error[invalid-assignment] Object of type `def select_wait_for_socket(sock: socket, read: bool = False, write: bool = False, timeout: int | float | None = None) -> bool` is not assignable to `def wait_for_socket(sock: socket, read: bool = False, write: bool = False, timeout: int | float | None = None) -> bool`

Full report with detailed diff (timing results)

@charliermarsh
charliermarsh force-pushed the charlie/allow-compatible-function-reassignment branch from 5bd6729 to 83e8a7b Compare July 30, 2026 17:32
@charliermarsh charliermarsh changed the title [ty] Allow reassigning functions with compatible callables [ty] Allow compatible function reassignment Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant