## Summary See discussion at https://github.com/astral-sh/ruff/pull/19478/files#r2223870292 Fixes https://github.com/astral-sh/ty/issues/865 ## Test Plan Added one mdtest for invalid Callable annotation; removed `pull-types: skip` from that test file. Co-authored-by: lipefree <willy.ngo.2000@gmail.com>
12 KiB
Callable
References:
Note that typing.Callable is deprecated at runtime, in favor of collections.abc.Callable (see:
https://docs.python.org/3/library/typing.html#deprecated-aliases). However, removal of
typing.Callable is not currently planned, and the canonical location of the stub for the symbol in
typeshed is still typing.pyi.
Invalid forms
The Callable special form requires exactly two arguments where the first argument is either a
parameter type list, parameter specification, typing.Concatenate, or ... and the second argument
is the return type. Here, we explore various invalid forms.
Empty
A bare Callable without any type arguments:
from typing import Callable
def _(c: Callable):
reveal_type(c) # revealed: (...) -> Unknown
Invalid parameter type argument
When it's not a list:
from typing import Callable
# error: [invalid-type-form] "The first argument to `Callable` must be either a list of types, ParamSpec, Concatenate, or `...`"
def _(c: Callable[int, str]):
reveal_type(c) # revealed: (...) -> Unknown
Or, when it's a literal type:
# error: [invalid-type-form] "The first argument to `Callable` must be either a list of types, ParamSpec, Concatenate, or `...`"
def _(c: Callable[42, str]):
reveal_type(c) # revealed: (...) -> Unknown
Or, when one of the parameter type is invalid in the list:
# error: [invalid-type-form] "Int literals are not allowed in this context in a type expression"
# error: [invalid-type-form] "Boolean literals are not allowed in this context in a type expression"
def _(c: Callable[[int, 42, str, False], None]):
# revealed: (int, Unknown, str, Unknown, /) -> None
reveal_type(c)
Missing return type
Using a parameter list:
from typing import Callable
# error: [invalid-type-form] "Special form `typing.Callable` expected exactly two arguments (parameter types and return type)"
def _(c: Callable[[int, str]]):
reveal_type(c) # revealed: (...) -> Unknown
Or, an ellipsis:
# error: [invalid-type-form] "Special form `typing.Callable` expected exactly two arguments (parameter types and return type)"
def _(c: Callable[...]):
reveal_type(c) # revealed: (...) -> Unknown
Or something else that's invalid in a type expression generally:
# fmt: off
def _(c: Callable[ # error: [invalid-type-form] "Special form `typing.Callable` expected exactly two arguments (parameter types and return type)"
{1, 2} # error: [invalid-type-form] "The first argument to `Callable` must be either a list of types, ParamSpec, Concatenate, or `...`"
]
):
reveal_type(c) # revealed: (...) -> Unknown
Invalid parameters and return type
from typing import Callable
# fmt: off
def _(c: Callable[
# error: [invalid-type-form] "Int literals are not allowed in this context in a type expression"
{1, 2}, 2 # error: [invalid-type-form] "The first argument to `Callable` must be either a list of types, ParamSpec, Concatenate, or `...`"
]
):
reveal_type(c) # revealed: (...) -> Unknown
More than two arguments
We can't reliably infer the callable type if there are more then 2 arguments because we don't know which argument corresponds to either the parameters or the return type.
from typing import Callable
# error: [invalid-type-form] "Special form `typing.Callable` expected exactly two arguments (parameter types and return type)"
def _(c: Callable[[int], str, str]):
reveal_type(c) # revealed: (...) -> Unknown
List as the second argument
from typing import Callable
# fmt: off
def _(c: Callable[
int, # error: [invalid-type-form] "The first argument to `Callable` must be either a list of types, ParamSpec, Concatenate, or `...`"
[str] # error: [invalid-type-form] "List literals are not allowed in this context in a type expression"
]
):
reveal_type(c) # revealed: (...) -> Unknown
Tuple as the second argument
from typing import Callable
# fmt: off
def _(c: Callable[
int, # error: [invalid-type-form] "The first argument to `Callable` must be either a list of types, ParamSpec, Concatenate, or `...`"
(str, ) # error: [invalid-type-form] "Tuple literals are not allowed in this context in a type expression"
]
):
reveal_type(c) # revealed: (...) -> Unknown
List as both arguments
from typing import Callable
# error: [invalid-type-form] "List literals are not allowed in this context in a type expression"
def _(c: Callable[[int], [str]]):
reveal_type(c) # revealed: (int, /) -> Unknown
Three list arguments
from typing import Callable
# fmt: off
def _(c: Callable[ # error: [invalid-type-form] "Special form `typing.Callable` expected exactly two arguments (parameter types and return type)"
[int],
[str], # error: [invalid-type-form] "List literals are not allowed in this context in a type expression"
[bytes] # error: [invalid-type-form] "List literals are not allowed in this context in a type expression"
]
):
reveal_type(c) # revealed: (...) -> Unknown
Simple
A simple Callable with multiple parameters and a return type:
from typing import Callable
def _(c: Callable[[int, str], int]):
reveal_type(c) # revealed: (int, str, /) -> int
Union
from typing import Callable, Union
def _(
c: Callable[[Union[int, str]], int] | None,
d: None | Callable[[Union[int, str]], int],
e: None | Callable[[Union[int, str]], int] | int,
):
reveal_type(c) # revealed: ((int | str, /) -> int) | None
reveal_type(d) # revealed: None | ((int | str, /) -> int)
reveal_type(e) # revealed: None | ((int | str, /) -> int) | int
Intersection
from typing import Callable, Union
from ty_extensions import Intersection, Not
class Foo: ...
def _(
c: Intersection[Callable[[Union[int, str]], int], int],
d: Intersection[int, Callable[[Union[int, str]], int]],
e: Intersection[int, Callable[[Union[int, str]], int], Foo],
f: Intersection[Not[Callable[[int, str], Intersection[int, Foo]]]],
):
reveal_type(c) # revealed: ((int | str, /) -> int) & int
reveal_type(d) # revealed: int & ((int | str, /) -> int)
reveal_type(e) # revealed: int & ((int | str, /) -> int) & Foo
reveal_type(f) # revealed: ~((int, str, /) -> int & Foo)
Nested
A nested Callable as one of the parameter types:
from typing import Callable
def _(c: Callable[[Callable[[int], str]], int]):
reveal_type(c) # revealed: ((int, /) -> str, /) -> int
And, as the return type:
def _(c: Callable[[int, str], Callable[[int], int]]):
reveal_type(c) # revealed: (int, str, /) -> (int, /) -> int
Gradual form
The Callable special form supports the use of ... in place of the list of parameter types. This
is a gradual form indicating that the type is consistent with any input signature:
from typing import Callable
def gradual_form(c: Callable[..., str]):
reveal_type(c) # revealed: (...) -> str
Using typing.Concatenate
Using Concatenate as the first argument to Callable:
from typing_extensions import Callable, Concatenate
def _(c: Callable[Concatenate[int, str, ...], int]):
# TODO: Should reveal the correct signature
reveal_type(c) # revealed: (...) -> int
And, as one of the parameter types:
def _(c: Callable[[Concatenate[int, str, ...], int], int]):
# TODO: Should reveal the correct signature
reveal_type(c) # revealed: (...) -> int
Other type expressions can be nested inside Concatenate:
def _(c: Callable[[Concatenate[int | str, type[str], ...], int], int]):
# TODO: Should reveal the correct signature
reveal_type(c) # revealed: (...) -> int
But providing fewer than 2 arguments to Concatenate is an error:
# fmt: off
def _(
c: Callable[Concatenate[int], int], # error: [invalid-type-form] "Special form `typing.Concatenate` expected at least 2 parameters but got 1"
d: Callable[Concatenate[(int,)], int], # error: [invalid-type-form] "Special form `typing.Concatenate` expected at least 2 parameters but got 1"
e: Callable[Concatenate[()], int] # error: [invalid-type-form] "Special form `typing.Concatenate` expected at least 2 parameters but got 0"
):
reveal_type(c) # revealed: (...) -> int
reveal_type(d) # revealed: (...) -> int
reveal_type(e) # revealed: (...) -> int
# fmt: on
Using typing.ParamSpec
[environment]
python-version = "3.12"
Using a ParamSpec in a Callable annotation:
from typing_extensions import Callable
def _[**P1](c: Callable[P1, int]):
reveal_type(P1.args) # revealed: @Todo(ParamSpec)
reveal_type(P1.kwargs) # revealed: @Todo(ParamSpec)
# TODO: Signature should be (**P1) -> int
reveal_type(c) # revealed: (...) -> int
And, using the legacy syntax:
from typing_extensions import ParamSpec
P2 = ParamSpec("P2")
# TODO: argument list should not be `...` (requires `ParamSpec` support)
def _(c: Callable[P2, int]):
reveal_type(c) # revealed: (...) -> int
Using typing.Unpack
Using the unpack operator (*):
from typing_extensions import Callable, TypeVarTuple
Ts = TypeVarTuple("Ts")
def _(c: Callable[[int, *Ts], int]):
# TODO: Should reveal the correct signature
reveal_type(c) # revealed: (...) -> int
And, using the legacy syntax using Unpack:
from typing_extensions import Unpack
def _(c: Callable[[int, Unpack[Ts]], int]):
# TODO: Should reveal the correct signature
reveal_type(c) # revealed: (...) -> int
Member lookup
from typing import Callable
def _(c: Callable[[int], int]):
reveal_type(c.__init__) # revealed: bound method object.__init__() -> None
reveal_type(c.__class__) # revealed: type
reveal_type(c.__call__) # revealed: (int, /) -> int
Unlike other type checkers, we do not allow attributes to be accessed that would only be available on function-like callables:
def f_wrong(c: Callable[[], None]):
# error: [unresolved-attribute] "Type `() -> None` has no attribute `__qualname__`"
c.__qualname__
# error: [unresolved-attribute] "Unresolved attribute `__qualname__` on type `() -> None`."
c.__qualname__ = "my_callable"
We do this, because at runtime, calls to f_wrong with a non-function callable would raise an
AttributeError:
class MyCallable:
def __call__(self) -> None:
pass
f_wrong(MyCallable()) # raises `AttributeError` at runtime
If users want to read/write to attributes such as __qualname__, they need to check the existence
of the attribute first:
from inspect import getattr_static
def f_okay(c: Callable[[], None]):
if hasattr(c, "__qualname__"):
c.__qualname__ # okay
# `hasattr` only guarantees that an attribute is readable.
# error: [invalid-assignment] "Object of type `Literal["my_callable"]` is not assignable to attribute `__qualname__` on type `(() -> None) & <Protocol with members '__qualname__'>`"
c.__qualname__ = "my_callable"
result = getattr_static(c, "__qualname__")
reveal_type(result) # revealed: Never
if isinstance(result, property) and result.fset:
c.__qualname__ = "my_callable" # okay