[red-knot] Add CallableTypeFromFunction special form (#16683)

## Summary

This PR adds a new `CallableTypeFromFunction` special form to allow
extracting the abstract signature of a function literal i.e., convert a
`Type::Function` into a `Type::Callable` (`CallableType::General`).

This is done to support testing the `is_gradual_equivalent_to` type
relation specifically the case we want to make sure that a function that
has parameters with no annotations and does not have a return type
annotation is gradual equivalent to `Callable[[Any, Any, ...], Any]`
where the number of parameters should match between the function literal
and callable type.

Refer
https://github.com/astral-sh/ruff/pull/16634#discussion_r1989976692

### Bikeshedding

The name `CallableTypeFromFunction` is a bit too verbose. A possibly
alternative from Carl is `CallableTypeOf` but that would be similar to
`TypeOf` albeit with a limitation that the former only accepts function
literal types and errors on other types.

Some other alternatives:
* `FunctionSignature`
* `SignatureOf` (similar issues as `TypeOf`?)
* ...

## Test Plan

Update `type_api.md` with a new section that tests this special form,
both invalid and valid forms.
This commit is contained in:
Dhruv Manilawala 2025-03-13 07:49:34 +05:30 committed by GitHub
parent dd2313ab0f
commit 08fa9b4a90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 110 additions and 2 deletions

View file

@ -393,3 +393,42 @@ def type_of_annotation() -> None:
# error: "Special form `knot_extensions.TypeOf` expected exactly one type parameter"
t: TypeOf[int, str, bytes]
```
## `CallableTypeFromFunction`
The `CallableTypeFromFunction` special form can be used to extract the type of a function literal as
a callable type. This can be used to get the externally-visibly signature of the function, which can
then be used to test various type properties.
It accepts a single type parameter which is expected to be a function literal.
```py
from knot_extensions import CallableTypeFromFunction
def f1():
return
def f2() -> int:
return 1
def f3(x: int, y: str) -> None:
return
# error: [invalid-type-form] "Special form `knot_extensions.CallableTypeFromFunction` expected exactly one type parameter"
c1: CallableTypeFromFunction[f1, f2]
# error: [invalid-type-form] "Expected the first argument to `knot_extensions.CallableTypeFromFunction` to be a function literal, but got `Literal[int]`"
c2: CallableTypeFromFunction[int]
```
Using it in annotation to reveal the signature of the function:
```py
def _(
c1: CallableTypeFromFunction[f1],
c2: CallableTypeFromFunction[f2],
c3: CallableTypeFromFunction[f3],
) -> None:
reveal_type(c1) # revealed: () -> Unknown
reveal_type(c2) # revealed: () -> int
reveal_type(c3) # revealed: (x: int, y: str) -> None
```

View file

@ -4326,6 +4326,17 @@ impl<'db> FunctionType<'db> {
})
}
/// Convert the `FunctionType` into a [`Type::Callable`].
///
/// Returns `None` if the function is overloaded. This powers the `CallableTypeFromFunction`
/// special form from the `knot_extensions` module.
pub(crate) fn into_callable_type(self, db: &'db dyn Db) -> Option<Type<'db>> {
// TODO: Add support for overloaded callables; return `Type`, not `Option<Type>`.
Some(Type::Callable(CallableType::General(
GeneralCallableType::new(db, self.signature(db).as_single()?.clone()),
)))
}
/// Typed externally-visible signature for this function.
///
/// This is the signature as seen by external callers, possibly modified by decorators and/or

View file

@ -1434,6 +1434,8 @@ pub enum KnownInstanceType<'db> {
Intersection,
/// The symbol `knot_extensions.TypeOf`
TypeOf,
/// The symbol `knot_extensions.CallableTypeFromFunction`
CallableTypeFromFunction,
// Various special forms, special aliases and type qualifiers that we don't yet understand
// (all currently inferred as TODO in most contexts):
@ -1495,7 +1497,8 @@ impl<'db> KnownInstanceType<'db> {
| Self::AlwaysFalsy
| Self::Not
| Self::Intersection
| Self::TypeOf => Truthiness::AlwaysTrue,
| Self::TypeOf
| Self::CallableTypeFromFunction => Truthiness::AlwaysTrue,
}
}
@ -1542,6 +1545,7 @@ impl<'db> KnownInstanceType<'db> {
Self::Not => "knot_extensions.Not",
Self::Intersection => "knot_extensions.Intersection",
Self::TypeOf => "knot_extensions.TypeOf",
Self::CallableTypeFromFunction => "knot_extensions.CallableTypeFromFunction",
}
}
@ -1585,6 +1589,7 @@ impl<'db> KnownInstanceType<'db> {
Self::TypeOf => KnownClass::SpecialForm,
Self::Not => KnownClass::SpecialForm,
Self::Intersection => KnownClass::SpecialForm,
Self::CallableTypeFromFunction => KnownClass::SpecialForm,
Self::Unknown => KnownClass::Object,
Self::AlwaysTruthy => KnownClass::Object,
Self::AlwaysFalsy => KnownClass::Object,
@ -1649,6 +1654,7 @@ impl<'db> KnownInstanceType<'db> {
"Not" => Self::Not,
"Intersection" => Self::Intersection,
"TypeOf" => Self::TypeOf,
"CallableTypeFromFunction" => Self::CallableTypeFromFunction,
_ => return None,
};
@ -1704,7 +1710,8 @@ impl<'db> KnownInstanceType<'db> {
| Self::AlwaysFalsy
| Self::Not
| Self::Intersection
| Self::TypeOf => module.is_knot_extensions(),
| Self::TypeOf
| Self::CallableTypeFromFunction => module.is_knot_extensions(),
}
}
}

View file

@ -103,6 +103,7 @@ impl<'db> ClassBase<'db> {
| KnownInstanceType::Not
| KnownInstanceType::Intersection
| KnownInstanceType::TypeOf
| KnownInstanceType::CallableTypeFromFunction
| KnownInstanceType::AlwaysTruthy
| KnownInstanceType::AlwaysFalsy => None,
KnownInstanceType::Unknown => Some(Self::unknown()),

View file

@ -6360,6 +6360,44 @@ impl<'db> TypeInferenceBuilder<'db> {
argument_type
}
},
KnownInstanceType::CallableTypeFromFunction => match arguments_slice {
ast::Expr::Tuple(_) => {
self.context.report_lint(
&INVALID_TYPE_FORM,
subscript,
format_args!(
"Special form `{}` expected exactly one type parameter",
known_instance.repr(self.db())
),
);
Type::unknown()
}
_ => {
let argument_type = self.infer_expression(arguments_slice);
let Some(function_type) = argument_type.into_function_literal() else {
self.context.report_lint(
&INVALID_TYPE_FORM,
arguments_slice,
format_args!(
"Expected the first argument to `{}` to be a function literal, but got `{}`",
known_instance.repr(self.db()),
argument_type.display(self.db())
),
);
return Type::unknown();
};
function_type
.into_callable_type(self.db())
.unwrap_or_else(|| {
self.context.report_lint(
&INVALID_TYPE_FORM,
arguments_slice,
format_args!("Overloaded function literal is not yet supported"),
);
Type::unknown()
})
}
},
// TODO: Generics
KnownInstanceType::ChainMap => {

View file

@ -41,6 +41,14 @@ impl<'db> CallableSignature<'db> {
CallableSignature::Overloaded(overloads.into())
}
/// Returns the [`Signature`] if this is a non-overloaded callable, [None] otherwise.
pub(crate) fn as_single(&self) -> Option<&Signature<'db>> {
match self {
CallableSignature::Single(signature) => Some(signature),
CallableSignature::Overloaded(_) => None,
}
}
pub(crate) fn iter(&self) -> std::slice::Iter<Signature<'db>> {
match self {
CallableSignature::Single(signature) => std::slice::from_ref(signature).iter(),

View file

@ -220,6 +220,9 @@ pub(super) fn union_elements_ordering<'db>(left: &Type<'db>, right: &Type<'db>)
(KnownInstanceType::TypeOf, _) => Ordering::Less,
(_, KnownInstanceType::TypeOf) => Ordering::Greater,
(KnownInstanceType::CallableTypeFromFunction, _) => Ordering::Less,
(_, KnownInstanceType::CallableTypeFromFunction) => Ordering::Greater,
(KnownInstanceType::Unpack, _) => Ordering::Less,
(_, KnownInstanceType::Unpack) => Ordering::Greater,

View file

@ -12,6 +12,7 @@ AlwaysFalsy = object()
Not: _SpecialForm
Intersection: _SpecialForm
TypeOf: _SpecialForm
CallableTypeFromFunction: _SpecialForm
# Predicates on types
#