Skip to content
Merged
181 changes: 106 additions & 75 deletions crates/ty/docs/rules.md

Large diffs are not rendered by default.

95 changes: 95 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/narrow/callable.md
Original file line number Diff line number Diff line change
@@ -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)
```
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
78 changes: 77 additions & 1 deletion crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1903,13 +1903,15 @@ impl<'db> Type<'db> {
db,
CallableSignature::from_overloads(method.signatures(db)),
CallableTypeKind::Regular,
false,
))),

Type::WrapperDescriptor(wrapper_descriptor) => {
Some(CallableTypes::one(CallableType::new(
db,
CallableSignature::from_overloads(wrapper_descriptor.signatures(db)),
CallableTypeKind::Regular,
false,
)))
}

Expand Down Expand Up @@ -12310,6 +12312,7 @@ impl<'db> BoundMethodType<'db> {
.map(|signature| signature.bind_self(db, Some(self_instance))),
),
CallableTypeKind::FunctionLike,
false,
)
}

Expand Down Expand Up @@ -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>(
Expand Down Expand Up @@ -12473,6 +12482,7 @@ impl<'db> CallableType<'db> {
db,
CallableSignature::single(signature),
CallableTypeKind::Regular,
false,
)
}

Expand All @@ -12481,6 +12491,7 @@ impl<'db> CallableType<'db> {
db,
CallableSignature::single(signature),
CallableTypeKind::FunctionLike,
false,
)
}

Expand All @@ -12492,6 +12503,7 @@ impl<'db> CallableType<'db> {
db,
CallableSignature::single(Signature::new(parameters, None)),
CallableTypeKind::ParamSpecValue,
false,
)
}

Expand Down Expand Up @@ -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),
)
}

Expand All @@ -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),
)
}

Expand All @@ -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.
Expand All @@ -12548,6 +12567,7 @@ impl<'db> CallableType<'db> {
db,
self.signatures(db).normalized_impl(db, visitor),
self.kind(db),
self.is_top_materialization(db),
)
}

Expand All @@ -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),
))
}

Expand All @@ -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),
)
}

Expand Down Expand Up @@ -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),
Expand All @@ -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)
Expand Down
Loading
Loading