diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index e2b08952a7..ee58180199 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -8,7 +8,7 @@ Default level: warn · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -80,7 +80,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -98,13 +98,44 @@ Calling a non-callable object will raise a `TypeError` at runtime. 4() # TypeError: 'int' object is not callable ``` +## `call-top-callable` + + +Default level: error · +Added in 0.0.7 · +Related issues · +View source + + + +**What it does** + +Checks for calls to objects typed as `Top[Callable[..., T]]` (the infinite union of all +callable types with return type `T`). + +**Why is this bad?** + +When an object is narrowed to `Top[Callable[..., object]]` (e.g., via `callable(x)` or +`isinstance(x, Callable)`), we know the object is callable, but we don't know its +precise signature. This type represents the set of all possible callable types +(including, e.g., functions that take no arguments and functions that require arguments), +so no specific set of arguments can be guaranteed to be valid. + +**Examples** + +```python +def f(x: object): + if callable(x): + x() # error: We know `x` is callable, but not what arguments it accepts +``` + ## `conflicting-argument-forms` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -136,7 +167,7 @@ f(int) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -167,7 +198,7 @@ a = 1 Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -199,7 +230,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -231,7 +262,7 @@ class B(A): ... Default level: error · Preview (since 1.0.0) · Related issues · -View source +View source @@ -259,7 +290,7 @@ type B = A Default level: warn · Added in 0.0.1-alpha.16 · Related issues · -View source +View source @@ -286,7 +317,7 @@ old_func() # emits [deprecated] diagnostic Default level: ignore · Preview (since 0.0.1-alpha.1) · Related issues · -View source +View source @@ -315,7 +346,7 @@ false positives it can produce. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -342,7 +373,7 @@ class B(A, A): ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -498,7 +529,7 @@ def test(): -> "Literal[5]": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -528,7 +559,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -554,7 +585,7 @@ t[3] # IndexError: tuple index out of range Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -643,7 +674,7 @@ an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -670,7 +701,7 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -698,7 +729,7 @@ a: int = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -732,7 +763,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -768,7 +799,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -792,7 +823,7 @@ class A(42): ... # error: [invalid-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -819,7 +850,7 @@ with 1: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -848,7 +879,7 @@ a: str Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -892,7 +923,7 @@ except ZeroDivisionError: Default level: error · Added in 0.0.1-alpha.28 · Related issues · -View source +View source @@ -934,7 +965,7 @@ class D(A): Default level: error · Added in 0.0.1-alpha.35 · Related issues · -View source +View source @@ -978,7 +1009,7 @@ class NonFrozenChild(FrozenBase): # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1046,7 +1077,7 @@ a = 20 / 0 # type: ignore Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -1085,7 +1116,7 @@ carol = Person(name="Carol", age=25) # typo! Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1120,7 +1151,7 @@ def f(t: TypeVar("U")): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1154,7 +1185,7 @@ class B(metaclass=f): ... Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1261,7 +1292,7 @@ Correct use of `@override` is enforced by ty's `invalid-explicit-override` rule. Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -1315,7 +1346,7 @@ AttributeError: Cannot overwrite NamedTuple attribute _asdict Default level: error · Preview (since 1.0.0) · Related issues · -View source +View source @@ -1345,7 +1376,7 @@ Baz = NewType("Baz", int | str) # error: invalid base for `typing.NewType` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1395,7 +1426,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1421,7 +1452,7 @@ def f(a: int = ''): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1452,7 +1483,7 @@ P2 = ParamSpec("S2") # error: ParamSpec name must match the variable it's assig Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1486,7 +1517,7 @@ TypeError: Protocols can only inherit from other protocols, got Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1535,7 +1566,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1560,7 +1591,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1656,7 +1687,7 @@ class C: ... Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -1683,7 +1714,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -1730,7 +1761,7 @@ Bar[int] # error: too few arguments Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1760,7 +1791,7 @@ TYPE_CHECKING = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1790,7 +1821,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1824,7 +1855,7 @@ f(10) # Error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1858,7 +1889,7 @@ class C: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1893,7 +1924,7 @@ T = TypeVar('T', bound=str) # valid bound TypeVar Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1918,7 +1949,7 @@ func() # TypeError: func() missing 1 required positional argument: 'x' Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1951,7 +1982,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1980,7 +2011,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2004,7 +2035,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2030,7 +2061,7 @@ for i in 34: # TypeError: 'int' object is not iterable Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -2063,7 +2094,7 @@ class B(A): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2090,7 +2121,7 @@ f(1, x=2) # Error raised here Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2117,7 +2148,7 @@ f(x=1) # Error raised here Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2145,7 +2176,7 @@ A.c # AttributeError: type object 'A' has no attribute 'c' Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2177,7 +2208,7 @@ A()[0] # TypeError: 'A' object is not subscriptable Default level: ignore · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2214,7 +2245,7 @@ from module import a # ImportError: cannot import name 'a' from 'module' Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2278,7 +2309,7 @@ def test(): -> "int": Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2305,7 +2336,7 @@ cast(int, f()) # Redundant Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2335,7 +2366,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2364,7 +2395,7 @@ class B(A): ... # Error raised here Default level: error · Preview (since 0.0.1-alpha.30) · Related issues · -View source +View source @@ -2398,7 +2429,7 @@ class F(NamedTuple): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2425,7 +2456,7 @@ f("foo") # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2453,7 +2484,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2499,7 +2530,7 @@ class A: Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2523,7 +2554,7 @@ reveal_type(1) # NameError: name 'reveal_type' is not defined Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2550,7 +2581,7 @@ f(x=1, y=2) # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2578,7 +2609,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo' Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -2636,7 +2667,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2661,7 +2692,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2686,7 +2717,7 @@ print(x) # NameError: name 'x' is not defined Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -2725,7 +2756,7 @@ class D(C): ... # error: [unsupported-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2762,7 +2793,7 @@ b1 < b2 < b1 # exception raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2821,7 +2852,7 @@ a = 20 / 2 Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2884,7 +2915,7 @@ def foo(x: int | str) -> int | str: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/callable.md b/crates/ty_python_semantic/resources/mdtest/narrow/callable.md new file mode 100644 index 0000000000..9762dc6e6e --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/narrow/callable.md @@ -0,0 +1,95 @@ +# Narrowing for `callable()` + +## Basic narrowing + +The `callable()` builtin returns `TypeIs[Callable[..., object]]`, which narrows the type to the +intersection with `Top[Callable[..., object]]`. The `Top[...]` wrapper indicates this is a fully +static type representing the top materialization of a gradual callable. + +Since all callable types are subtypes of `Top[Callable[..., object]]`, intersections with `Top[...]` +simplify to just the original callable type. + +```py +from typing import Any, Callable + +def f(x: Callable[..., Any] | None): + if callable(x): + # The intersection simplifies because `(...) -> Any` is a subtype of + # `Top[(...) -> object]` - all callables are subtypes of the top materialization. + reveal_type(x) # revealed: (...) -> Any + else: + # Since `(...) -> Any` is a subtype of `Top[(...) -> object]`, the intersection + # with the negation is empty (Never), leaving just None. + reveal_type(x) # revealed: None +``` + +## Narrowing with other callable types + +```py +from typing import Any, Callable + +def g(x: Callable[[int], str] | None): + if callable(x): + # All callables are subtypes of `Top[(...) -> object]`, so the intersection simplifies. + reveal_type(x) # revealed: (int, /) -> str + else: + reveal_type(x) # revealed: None + +def h(x: Callable[..., int] | None): + if callable(x): + reveal_type(x) # revealed: (...) -> int + else: + reveal_type(x) # revealed: None +``` + +## Narrowing from object + +```py +from typing import Callable + +def f(x: object): + if callable(x): + reveal_type(x) # revealed: Top[(...) -> object] + else: + reveal_type(x) # revealed: ~Top[(...) -> object] +``` + +## Calling narrowed callables + +The narrowed type `Top[Callable[..., object]]` represents the set of all possible callable types +(including, e.g., functions that take no arguments and functions that require arguments). While such +objects *are* callable (they pass `callable()`), no specific set of arguments can be guaranteed to +be valid. + +```py +import typing as t + +def call_with_args(y: object, a: int, b: str) -> object: + if isinstance(y, t.Callable): + # error: [call-top-callable] + return y(a, b) + return None +``` + +## Assignability of narrowed callables + +A narrowed callable `Top[Callable[..., object]]` should be assignable to `Callable[..., Any]`. This +is important for decorators and other patterns where we need to pass the narrowed callable to +functions expecting gradual callables. + +```py +from typing import Any, Callable, TypeVar +from ty_extensions import static_assert, Top, is_assignable_to + +static_assert(is_assignable_to(Top[Callable[..., bool]], Callable[..., int])) + +F = TypeVar("F", bound=Callable[..., Any]) + +def wrap(f: F) -> F: + return f + +def f(x: object): + if callable(x): + # x has type `Top[(...) -> object]`, which should be assignable to `Callable[..., Any]` + wrap(x) +``` diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index 4a78fd8982..eca82c11ff 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -213,8 +213,7 @@ def f(x: dict[str, int] | list[str], y: object): reveal_type(x) # revealed: list[str] if isinstance(y, t.Callable): - # TODO: a better top-materialization for `Callable`s (https://github.com/astral-sh/ty/issues/1426) - reveal_type(y) # revealed: () -> object + reveal_type(y) # revealed: Top[(...) -> object] ``` ## Class types diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/materialization.md b/crates/ty_python_semantic/resources/mdtest/type_properties/materialization.md index 01f3402c74..a87fda36f2 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/materialization.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/materialization.md @@ -178,6 +178,48 @@ def _(top: Top[C3], bottom: Bottom[C3]) -> None: reveal_type(bottom) ``` +## Callable with gradual parameters + +For callables with gradual parameters (the `...` form), the top materialization preserves the +gradual form since we cannot know what parameters are required. The bottom materialization +simplifies to the bottom callable `(*args: object, **kwargs: object) -> Never` since this is the +most specific type that is a subtype of all possible callable materializations. + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Any, Callable, Never, Protocol +from ty_extensions import Bottom, Top, is_equivalent_to, is_subtype_of, static_assert + +type GradualCallable = Callable[..., Any] + +def _(top: Top[GradualCallable], bottom: Bottom[GradualCallable]) -> None: + # The top materialization keeps the gradual parameters wrapped + reveal_type(top) # revealed: Top[(...) -> object] + + # The bottom materialization simplifies to the fully static bottom callable + reveal_type(bottom) # revealed: (*args: object, **kwargs: object) -> Never + +# The bottom materialization of a gradual callable is a subtype of (and supertype of) +# a protocol with `__call__(self, *args: object, **kwargs: object) -> Never` +class EquivalentToBottom(Protocol): + def __call__(self, *args: object, **kwargs: object) -> Never: ... + +static_assert(is_subtype_of(EquivalentToBottom, Bottom[Callable[..., Never]])) +static_assert(is_subtype_of(Bottom[Callable[..., Never]], EquivalentToBottom)) + +# TODO: is_equivalent_to only considers types of the same kind equivalent (Callable vs ProtocolInstance), +# so this fails even though mutual subtyping proves semantic equivalence. +static_assert(is_equivalent_to(Bottom[Callable[..., Never]], EquivalentToBottom)) # error: [static-assert-error] + +# Top-materialized callables are not equivalent to non-top-materialized callables, even if their +# signatures would otherwise be equivalent after materialization. +static_assert(not is_equivalent_to(Top[Callable[..., object]], Callable[..., object])) +``` + ## Tuple All positions in a tuple are covariant. diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 8ecf8f550c..268a9af272 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1903,6 +1903,7 @@ impl<'db> Type<'db> { db, CallableSignature::from_overloads(method.signatures(db)), CallableTypeKind::Regular, + false, ))), Type::WrapperDescriptor(wrapper_descriptor) => { @@ -1910,6 +1911,7 @@ impl<'db> Type<'db> { db, CallableSignature::from_overloads(wrapper_descriptor.signatures(db)), CallableTypeKind::Regular, + false, ))) } @@ -12310,6 +12312,7 @@ impl<'db> BoundMethodType<'db> { .map(|signature| signature.bind_self(db, Some(self_instance))), ), CallableTypeKind::FunctionLike, + false, ) } @@ -12429,6 +12432,12 @@ pub struct CallableType<'db> { pub(crate) signatures: CallableSignature<'db>, kind: CallableTypeKind, + + /// Whether this callable is a top materialization (e.g., `Top[Callable[..., object]]`). + /// + /// Bottom materializations of gradual callables are simplified to the bottom callable + /// `(*args: object, **kwargs: object) -> Never`, so this is always false for them. + is_top_materialization: bool, } pub(super) fn walk_callable_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( @@ -12473,6 +12482,7 @@ impl<'db> CallableType<'db> { db, CallableSignature::single(signature), CallableTypeKind::Regular, + false, ) } @@ -12481,6 +12491,7 @@ impl<'db> CallableType<'db> { db, CallableSignature::single(signature), CallableTypeKind::FunctionLike, + false, ) } @@ -12492,6 +12503,7 @@ impl<'db> CallableType<'db> { db, CallableSignature::single(Signature::new(parameters, None)), CallableTypeKind::ParamSpecValue, + false, ) } @@ -12521,6 +12533,7 @@ impl<'db> CallableType<'db> { db, self.signatures(db).bind_self(db, self_type), self.kind(db), + self.is_top_materialization(db), ) } @@ -12529,6 +12542,7 @@ impl<'db> CallableType<'db> { db, self.signatures(db).apply_self(db, self_type), self.kind(db), + self.is_top_materialization(db), ) } @@ -12537,7 +12551,12 @@ impl<'db> CallableType<'db> { /// Specifically, this represents a callable type with a single signature: /// `(*args: object, **kwargs: object) -> Never`. pub(crate) fn bottom(db: &'db dyn Db) -> CallableType<'db> { - Self::new(db, CallableSignature::bottom(), CallableTypeKind::Regular) + Self::new( + db, + CallableSignature::bottom(), + CallableTypeKind::Regular, + false, + ) } /// Return a "normalized" version of this `Callable` type. @@ -12548,6 +12567,7 @@ impl<'db> CallableType<'db> { db, self.signatures(db).normalized_impl(db, visitor), self.kind(db), + self.is_top_materialization(db), ) } @@ -12562,6 +12582,7 @@ impl<'db> CallableType<'db> { self.signatures(db) .recursive_type_normalized_impl(db, div, nested)?, self.kind(db), + self.is_top_materialization(db), )) } @@ -12572,11 +12593,43 @@ impl<'db> CallableType<'db> { tcx: TypeContext<'db>, visitor: &ApplyTypeMappingVisitor<'db>, ) -> Self { + if let TypeMapping::Materialize(materialization_kind) = type_mapping { + // Top materializations are fully static types already, + // so materializing them further does nothing. + if self.is_top_materialization(db) { + return self; + } + + // If we're materializing a callable with gradual parameters: + // - For Top materialization: wrap in `Top[...]` to preserve the gradual nature + // - For Bottom materialization: simplify to the bottom callable since + // `Bottom[Callable[..., R]]` is equivalent to `(*args: object, **kwargs: object) -> Bottom[R]` + if self.signatures(db).has_gradual_parameters() { + match materialization_kind { + MaterializationKind::Top => { + return CallableType::new( + db, + self.signatures(db) + .materialize_return_types(db, *materialization_kind), + self.kind(db), + true, + ); + } + MaterializationKind::Bottom => { + // Bottom materialization of a gradual callable simplifies to the + // bottom callable: (*args: object, **kwargs: object) -> Never + return CallableType::bottom(db); + } + } + } + } + CallableType::new( db, self.signatures(db) .apply_type_mapping_impl(db, type_mapping, tcx, visitor), self.kind(db), + self.is_top_materialization(db), ) } @@ -12606,6 +12659,24 @@ impl<'db> CallableType<'db> { if other.is_function_like(db) && !self.is_function_like(db) { return ConstraintSet::from(false); } + + // Handle top materialization: + // - `Top[Callable[..., R]]` is a supertype of all callables with return type subtype of R. + // + // For Top, we only need to compare return types because Top parameters are a supertype + // of all possible parameters. Bottom materializations are simplified to the bottom + // callable directly, so they use normal signature comparison. + if other.is_top_materialization(db) { + return self.signatures(db).return_types_have_relation_to( + db, + other.signatures(db), + inferable, + relation, + relation_visitor, + disjointness_visitor, + ); + } + self.signatures(db).has_relation_to_impl( db, other.signatures(db), @@ -12630,6 +12701,11 @@ impl<'db> CallableType<'db> { return ConstraintSet::from(true); } + // Callables with different top materialization status are not equivalent + if self.is_top_materialization(db) != other.is_top_materialization(db) { + return ConstraintSet::from(false); + } + ConstraintSet::from(self.is_function_like(db) == other.is_function_like(db)).and(db, || { self.signatures(db) .is_equivalent_to_impl(db, other.signatures(db), inferable, visitor) diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index b456b912e4..236a9e11be 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -27,9 +27,9 @@ use crate::place::{Definedness, Place, known_module_symbol}; use crate::types::call::arguments::{Expansion, is_expandable_type}; use crate::types::constraints::ConstraintSet; use crate::types::diagnostic::{ - CALL_NON_CALLABLE, CONFLICTING_ARGUMENT_FORMS, INVALID_ARGUMENT_TYPE, MISSING_ARGUMENT, - NO_MATCHING_OVERLOAD, PARAMETER_ALREADY_ASSIGNED, POSITIONAL_ONLY_PARAMETER_AS_KWARG, - TOO_MANY_POSITIONAL_ARGUMENTS, UNKNOWN_ARGUMENT, + CALL_NON_CALLABLE, CALL_TOP_CALLABLE, CONFLICTING_ARGUMENT_FORMS, INVALID_ARGUMENT_TYPE, + MISSING_ARGUMENT, NO_MATCHING_OVERLOAD, PARAMETER_ALREADY_ASSIGNED, + POSITIONAL_ONLY_PARAMETER_AS_KWARG, TOO_MANY_POSITIONAL_ARGUMENTS, UNKNOWN_ARGUMENT, }; use crate::types::enums::is_enum_class; use crate::types::function::{ @@ -1622,6 +1622,21 @@ impl<'db> CallableBinding<'db> { ) .entered(); + // If the callable is a top materialization (e.g., `Top[Callable[..., object]]`), any call + // should fail because we don't know the actual signature. The type IS callable (it passes + // `callable()`), but it represents an infinite union of all possible callable types, so + // there's no valid set of arguments. + if let Type::Callable(callable) = self.signature_type + && callable.is_top_materialization(db) + { + for overload in &mut self.overloads { + overload + .errors + .push(BindingError::CalledTopCallable(self.signature_type)); + } + return None; + } + tracing::trace!( target: "ty_python_semantic::types::call::bind", matching_overload_index = ?self.matching_overload_index(), @@ -4124,6 +4139,10 @@ pub(crate) enum BindingError<'db> { /// This overload binding of the callable does not match the arguments. // TODO: We could expand this with an enum to specify why the overload is unmatched. UnmatchedOverload, + /// The callable type is a top materialization (e.g., `Top[Callable[..., object]]`), which + /// represents the infinite union of all callables. While such types *are* callable (they pass + /// `callable()`), any specific call should fail because we don't know the actual signature. + CalledTopCallable(Type<'db>), } impl<'db> BindingError<'db> { @@ -4551,6 +4570,24 @@ impl<'db> BindingError<'db> { } Self::UnmatchedOverload => {} + + Self::CalledTopCallable(callable_ty) => { + let node = Self::get_node(node, None); + if let Some(builder) = context.report_lint(&CALL_TOP_CALLABLE, node) { + let callable_ty_display = callable_ty.display(context.db()); + let mut diag = builder.into_diagnostic(format_args!( + "Object of type `{callable_ty_display}` is not safe to call; \ + its signature is not known" + )); + diag.info( + "This type includes all possible callables, so it cannot safely be called \ + because there is no valid set of arguments for it", + ); + if let Some(union_diag) = union_diag { + union_diag.add_union_context(context.db(), &mut diag); + } + } + } } } @@ -4709,5 +4746,6 @@ fn asynccontextmanager_return_type<'db>(db: &'db dyn Db, func_ty: Type<'db>) -> db, CallableSignature::single(new_signature), CallableTypeKind::FunctionLike, + false, ))) } diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 70ba8b3b25..16858e0bcc 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1051,6 +1051,7 @@ impl<'db> ClassType<'db> { db, getitem_signature, CallableTypeKind::FunctionLike, + false, )); Member::definitely_declared(getitem_type) }) @@ -1218,6 +1219,7 @@ impl<'db> ClassType<'db> { db, dunder_new_signature.bind_self(db, Some(instance_ty)), CallableTypeKind::FunctionLike, + false, ); if returns_non_subclass { @@ -1288,6 +1290,7 @@ impl<'db> ClassType<'db> { db, synthesized_dunder_init_signature, CallableTypeKind::FunctionLike, + false, )) } else { None @@ -2120,6 +2123,7 @@ impl<'db> ClassLiteral<'db> { db, callable_ty.signatures(db), CallableTypeKind::FunctionLike, + callable_ty.is_top_materialization(db), )), Type::Union(union) => { union.map(db, |element| into_function_like_callable(db, *element)) @@ -2764,6 +2768,7 @@ impl<'db> ClassLiteral<'db> { Some(Type::none(db)), )), CallableTypeKind::FunctionLike, + false, ))); } @@ -2790,6 +2795,7 @@ impl<'db> ClassLiteral<'db> { db, CallableSignature::from_overloads(overloads), CallableTypeKind::FunctionLike, + false, ))) } (CodeGeneratorKind::TypedDict, "__getitem__") => { @@ -2817,6 +2823,7 @@ impl<'db> ClassLiteral<'db> { db, CallableSignature::from_overloads(overloads), CallableTypeKind::FunctionLike, + false, ))) } (CodeGeneratorKind::TypedDict, "__delitem__") => { @@ -2848,6 +2855,7 @@ impl<'db> ClassLiteral<'db> { Some(Type::none(db)), )), CallableTypeKind::FunctionLike, + false, ))); } @@ -2873,6 +2881,7 @@ impl<'db> ClassLiteral<'db> { db, CallableSignature::from_overloads(overloads), CallableTypeKind::FunctionLike, + false, ))) } (CodeGeneratorKind::TypedDict, "get") => { @@ -2981,6 +2990,7 @@ impl<'db> ClassLiteral<'db> { db, CallableSignature::from_overloads(overloads), CallableTypeKind::FunctionLike, + false, ))) } (CodeGeneratorKind::TypedDict, "pop") => { @@ -3041,6 +3051,7 @@ impl<'db> ClassLiteral<'db> { db, CallableSignature::from_overloads(overloads), CallableTypeKind::FunctionLike, + false, ))) } (CodeGeneratorKind::TypedDict, "setdefault") => { @@ -3069,6 +3080,7 @@ impl<'db> ClassLiteral<'db> { db, CallableSignature::from_overloads(overloads), CallableTypeKind::FunctionLike, + false, ))) } (CodeGeneratorKind::TypedDict, "update") => { diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index a7a4d8d3f1..6df76743c9 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -50,6 +50,7 @@ use ty_module_resolver::{Module, ModuleName}; pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&AMBIGUOUS_PROTOCOL_MEMBER); registry.register_lint(&CALL_NON_CALLABLE); + registry.register_lint(&CALL_TOP_CALLABLE); registry.register_lint(&POSSIBLY_MISSING_IMPLICIT_CALL); registry.register_lint(&CONFLICTING_ARGUMENT_FORMS); registry.register_lint(&CONFLICTING_DECLARATIONS); @@ -151,6 +152,31 @@ declare_lint! { } } +declare_lint! { + /// ## What it does + /// Checks for calls to objects typed as `Top[Callable[..., T]]` (the infinite union of all + /// callable types with return type `T`). + /// + /// ## Why is this bad? + /// When an object is narrowed to `Top[Callable[..., object]]` (e.g., via `callable(x)` or + /// `isinstance(x, Callable)`), we know the object is callable, but we don't know its + /// precise signature. This type represents the set of all possible callable types + /// (including, e.g., functions that take no arguments and functions that require arguments), + /// so no specific set of arguments can be guaranteed to be valid. + /// + /// ## Examples + /// ```python + /// def f(x: object): + /// if callable(x): + /// x() # error: We know `x` is callable, but not what arguments it accepts + /// ``` + pub(crate) static CALL_TOP_CALLABLE = { + summary: "detects calls to the top callable type", + status: LintStatus::stable("0.0.7"), + default_level: Level::Error, + } +} + declare_lint! { /// ## What it does /// Checks for implicit calls to possibly missing methods. diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index 197ed5dca2..5693df9d1b 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -1576,6 +1576,7 @@ impl<'db> CallableType<'db> { DisplayCallableType { signatures: self.signatures(db), kind: self.kind(db), + is_top_materialization: self.is_top_materialization(db), db, settings, } @@ -1585,23 +1586,31 @@ impl<'db> CallableType<'db> { pub(crate) struct DisplayCallableType<'a, 'db> { signatures: &'a CallableSignature<'db>, kind: CallableTypeKind, + is_top_materialization: bool, db: &'db dyn Db, settings: DisplaySettings<'db>, } impl<'db> FmtDetailed<'db> for DisplayCallableType<'_, 'db> { fn fmt_detailed(&self, f: &mut TypeWriter<'_, '_, 'db>) -> fmt::Result { + // If this callable is a top materialization, wrap it in Top[...] + if self.is_top_materialization { + f.with_type(Type::SpecialForm(SpecialFormType::Top)) + .write_str("Top")?; + f.write_char('[')?; + } + match self.signatures.overloads.as_slice() { [signature] => { if matches!(self.kind, CallableTypeKind::ParamSpecValue) { signature .parameters() .display_with(self.db, self.settings.clone()) - .fmt_detailed(f) + .fmt_detailed(f)?; } else { signature .display_with(self.db, self.settings.clone()) - .fmt_detailed(f) + .fmt_detailed(f)?; } } signatures => { @@ -1621,9 +1630,13 @@ impl<'db> FmtDetailed<'db> for DisplayCallableType<'_, 'db> { if !self.settings.multiline { f.write_char(']')?; } - Ok(()) } } + + if self.is_top_materialization { + f.write_char(']')?; + } + Ok(()) } } @@ -2231,8 +2244,12 @@ impl<'db> FmtDetailed<'db> for DisplayMaybeParenthesizedType<'db> { f.write_char(')') }; match self.ty { - Type::Callable(_) - | Type::KnownBoundMethod(_) + // Callable types with a top materialization are displayed as `Top[(...) -> T]`, + // which is already unambiguous and doesn't need additional parentheses. + Type::Callable(callable) if !callable.is_top_materialization(self.db) => { + write_parentheses(f) + } + Type::KnownBoundMethod(_) | Type::FunctionLiteral(_) | Type::BoundMethod(_) | Type::Union(_) => write_parentheses(f), diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 40e4e953ef..540e8b373f 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -1094,7 +1094,7 @@ impl<'db> FunctionType<'db> { } else { CallableTypeKind::FunctionLike }; - CallableType::new(db, self.signature(db), kind) + CallableType::new(db, self.signature(db), kind, false) } /// Convert the `FunctionType` into a [`BoundMethodType`]. diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 3b1ceed9d6..0c54a1410e 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -2427,6 +2427,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { db, callable.signatures(db), kind, + callable.is_top_materialization(db), ))), Type::Union(union) => union .try_map(db, |element| propagate_callable_kind(db, *element, kind)), diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 3c02a3aed8..8d45b5a42a 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -990,5 +990,6 @@ fn protocol_bind_self<'db>( db, callable.signatures(db).bind_self(db, self_type), CallableTypeKind::Regular, + callable.is_top_materialization(db), ) } diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index 33266f8dde..efb9914bf5 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -428,6 +428,7 @@ impl<'db> CallableSignature<'db> { |signature| Signature::new(signature.parameters().clone(), None), )), CallableTypeKind::ParamSpecValue, + false, )); let param_spec_matches = ConstraintSet::constrain_typevar( db, @@ -461,6 +462,7 @@ impl<'db> CallableSignature<'db> { |signature| Signature::new(signature.parameters().clone(), None), )), CallableTypeKind::ParamSpecValue, + false, )); let param_spec_matches = ConstraintSet::constrain_typevar( db, @@ -569,6 +571,56 @@ impl<'db> CallableSignature<'db> { } } } + + /// Returns `true` if any signature in this callable has gradual parameters (`...`). + pub(crate) fn has_gradual_parameters(&self) -> bool { + self.overloads.iter().any(|sig| sig.parameters.is_gradual()) + } + + /// Materialize only the return types of all signatures, preserving parameters as-is. + /// + /// This is used when wrapping gradual callables in `Top[...]`. We want to preserve the gradual + /// parameters but materialize the return types (which are in covariant position). + pub(crate) fn materialize_return_types( + &self, + db: &'db dyn Db, + materialization_kind: MaterializationKind, + ) -> Self { + Self::from_overloads( + self.overloads + .iter() + .map(|sig| sig.materialize_return_type(db, materialization_kind)), + ) + } + + /// Check whether the return types of this callable have the given relation to the return + /// types of another callable. + pub(crate) fn return_types_have_relation_to( + &self, + db: &'db dyn Db, + other: &Self, + inferable: InferableTypeVars<'_, 'db>, + relation: TypeRelation<'db>, + relation_visitor: &HasRelationToVisitor<'db>, + disjointness_visitor: &IsDisjointVisitor<'db>, + ) -> ConstraintSet<'db> { + // For each overload in self, the return type must have the relation to + // the return type of some overload in other. + self.overloads.iter().when_all(db, |self_sig| { + let self_return_ty = self_sig.return_ty.unwrap_or(Type::unknown()); + other.overloads.iter().when_any(db, |other_sig| { + let other_return_ty = other_sig.return_ty.unwrap_or(Type::unknown()); + self_return_ty.has_relation_to_impl( + db, + other_return_ty, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + }) + }) + } } impl<'a, 'db> IntoIterator for &'a CallableSignature<'db> { @@ -731,6 +783,26 @@ impl<'db> Signature<'db> { Self::new(Parameters::object(), Some(Type::Never)) } + /// Materialize only the return type, preserving parameters as-is. + pub(crate) fn materialize_return_type( + &self, + db: &'db dyn Db, + materialization_kind: MaterializationKind, + ) -> Self { + Self { + generic_context: self.generic_context, + definition: self.definition, + parameters: self.parameters.clone(), + return_ty: self.return_ty.map(|ty| { + ty.materialize( + db, + materialization_kind, + &ApplyTypeMappingVisitor::default(), + ) + }), + } + } + pub(crate) fn with_inherited_generic_context( mut self, db: &'db dyn Db, @@ -1115,6 +1187,7 @@ impl<'db> Signature<'db> { .map(|signature| Signature::new(signature.parameters().clone(), None)), ), CallableTypeKind::ParamSpecValue, + false, )); let param_spec_matches = ConstraintSet::constrain_typevar(db, self_bound_typevar, Type::Never, upper); @@ -1366,6 +1439,7 @@ impl<'db> Signature<'db> { db, CallableSignature::single(Signature::new(other.parameters.clone(), None)), CallableTypeKind::ParamSpecValue, + false, )); let param_spec_matches = ConstraintSet::constrain_typevar( db, @@ -1382,6 +1456,7 @@ impl<'db> Signature<'db> { db, CallableSignature::single(Signature::new(self.parameters.clone(), None)), CallableTypeKind::ParamSpecValue, + false, )); let param_spec_matches = ConstraintSet::constrain_typevar( db, @@ -2051,27 +2126,13 @@ impl<'db> Parameters<'db> { tcx: TypeContext<'db>, visitor: &ApplyTypeMappingVisitor<'db>, ) -> Self { - match type_mapping { - // Note that we've already flipped the materialization in Signature.apply_type_mapping_impl(), - // so the "top" materialization here is the bottom materialization of the whole Signature. - // It might make sense to flip the materialization here instead. - TypeMapping::Materialize(MaterializationKind::Top) if self.is_gradual() => { - Parameters::object() - } - // TODO: This is wrong, the empty Parameters is not a subtype of all materializations. - // The bottom materialization is not currently representable and implementing it - // properly requires extending the Parameters struct. - TypeMapping::Materialize(MaterializationKind::Bottom) if self.is_gradual() => { - Parameters::empty() - } - _ => Self { - value: self - .value - .iter() - .map(|param| param.apply_type_mapping_impl(db, type_mapping, tcx, visitor)) - .collect(), - kind: self.kind, - }, + Self { + value: self + .value + .iter() + .map(|param| param.apply_type_mapping_impl(db, type_mapping, tcx, visitor)) + .collect(), + kind: self.kind, } } diff --git a/ty.schema.json b/ty.schema.json index 4d49c27b60..e93fd1a6b9 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -356,6 +356,16 @@ } ] }, + "call-top-callable": { + "title": "detects calls to the top callable type", + "description": "## What it does\nChecks for calls to objects typed as `Top[Callable[..., T]]` (the infinite union of all\ncallable types with return type `T`).\n\n## Why is this bad?\nWhen an object is narrowed to `Top[Callable[..., object]]` (e.g., via `callable(x)` or\n`isinstance(x, Callable)`), we know the object is callable, but we don't know its\nprecise signature. This type represents the set of all possible callable types\n(including, e.g., functions that take no arguments and functions that require arguments),\nso no specific set of arguments can be guaranteed to be valid.\n\n## Examples\n```python\ndef f(x: object):\n if callable(x):\n x() # error: We know `x` is callable, but not what arguments it accepts\n```", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "conflicting-argument-forms": { "title": "detects when an argument is used as both a value and a type form in a call", "description": "## What it does\nChecks whether an argument is used as both a value and a type form in a call.\n\n## Why is this bad?\nSuch calls have confusing semantics and often indicate a logic error.\n\n## Examples\n```python\nfrom typing import reveal_type\nfrom ty_extensions import is_singleton\n\nif flag:\n f = repr # Expects a value\nelse:\n f = is_singleton # Expects a type form\n\nf(int) # error\n```",