[red-knot] Check whether two callable types are equivalent (#16698)

## Summary

This PR checks whether two callable types are equivalent or not.

This is required because for an equivalence relationship, the default
value does not necessarily need to be the same but if the parameter in
one of the callable has a default value then the corresponding parameter
in the other callable should also have a default value. This is the main
reason a manual implementation is required.

And, as per https://typing.python.org/en/latest/spec/callables.html#id4,
the default _type_ doesn't participate in a subtype relationship, only
the optionality (required or not) participates. This means that the
following two callable types are equivalent:

```py
def f1(a: int = 1) -> None: ...
def f2(a: int = 2) -> None: ...
```

Additionally, the name of positional-only, variadic and keyword-variadic
are not required to be the same for an equivalence relation.

A potential solution to avoid the manual implementation would be to only
store whether a parameter has a default value or not but the type is
currently required to check for assignability.

## Test plan

Add tests for callable types in `is_equivalent_to.md`
This commit is contained in:
Dhruv Manilawala 2025-03-21 08:49:07 +05:30 committed by GitHub
parent 63e78b41cd
commit 193c38199e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 216 additions and 3 deletions

View File

@ -118,4 +118,126 @@ class R: ...
static_assert(is_equivalent_to(Intersection[tuple[P | Q], R], Intersection[tuple[Q | P], R])) static_assert(is_equivalent_to(Intersection[tuple[P | Q], R], Intersection[tuple[Q | P], R]))
``` ```
## Callable
### Equivalent
For an equivalence relationship, the default value does not necessarily need to be the same but if
the parameter in one of the callable has a default value then the corresponding parameter in the
other callable should also have a default value.
```py
from knot_extensions import CallableTypeFromFunction, is_equivalent_to, static_assert
from typing import Callable
def f1(a: int = 1) -> None: ...
def f2(a: int = 2) -> None: ...
static_assert(is_equivalent_to(CallableTypeFromFunction[f1], CallableTypeFromFunction[f2]))
```
The names of the positional-only, variadic and keyword-variadic parameters does not need to be the
same.
```py
def f3(a1: int, /, *args1: int, **kwargs2: int) -> None: ...
def f4(a2: int, /, *args2: int, **kwargs1: int) -> None: ...
static_assert(is_equivalent_to(CallableTypeFromFunction[f3], CallableTypeFromFunction[f4]))
```
Putting it all together, the following two callables are equivalent:
```py
def f5(a1: int, /, b: float, c: bool = False, *args1: int, d: int = 1, e: str, **kwargs1: float) -> None: ...
def f6(a2: int, /, b: float, c: bool = True, *args2: int, d: int = 2, e: str, **kwargs2: float) -> None: ...
static_assert(is_equivalent_to(CallableTypeFromFunction[f5], CallableTypeFromFunction[f6]))
```
### Not equivalent
There are multiple cases when two callable types are not equivalent which are enumerated below.
```py
from knot_extensions import CallableTypeFromFunction, is_equivalent_to, static_assert
from typing import Callable
```
When the number of parameters is different:
```py
def f1(a: int) -> None: ...
def f2(a: int, b: int) -> None: ...
static_assert(not is_equivalent_to(CallableTypeFromFunction[f1], CallableTypeFromFunction[f2]))
```
When either of the callable types uses a gradual form for the parameters:
```py
static_assert(not is_equivalent_to(Callable[..., None], Callable[[int], None]))
static_assert(not is_equivalent_to(Callable[[int], None], Callable[..., None]))
```
When the return types are not equivalent or absent in one or both of the callable types:
```py
def f3(): ...
def f4() -> None: ...
static_assert(not is_equivalent_to(Callable[[], int], Callable[[], None]))
static_assert(not is_equivalent_to(CallableTypeFromFunction[f3], CallableTypeFromFunction[f3]))
static_assert(not is_equivalent_to(CallableTypeFromFunction[f3], CallableTypeFromFunction[f4]))
static_assert(not is_equivalent_to(CallableTypeFromFunction[f4], CallableTypeFromFunction[f3]))
```
When the parameter names are different:
```py
def f5(a: int) -> None: ...
def f6(b: int) -> None: ...
static_assert(not is_equivalent_to(CallableTypeFromFunction[f5], CallableTypeFromFunction[f6]))
```
When only one of the callable types has parameter names:
```py
static_assert(not is_equivalent_to(CallableTypeFromFunction[f5], Callable[[int], None]))
```
When the parameter kinds are different:
```py
def f7(a: int, /) -> None: ...
def f8(a: int) -> None: ...
static_assert(not is_equivalent_to(CallableTypeFromFunction[f7], CallableTypeFromFunction[f8]))
```
When the annotated types of the parameters are not equivalent or absent in one or both of the
callable types:
```py
def f9(a: int) -> None: ...
def f10(a: str) -> None: ...
def f11(a) -> None: ...
static_assert(not is_equivalent_to(CallableTypeFromFunction[f9], CallableTypeFromFunction[f10]))
static_assert(not is_equivalent_to(CallableTypeFromFunction[f10], CallableTypeFromFunction[f11]))
static_assert(not is_equivalent_to(CallableTypeFromFunction[f11], CallableTypeFromFunction[f10]))
static_assert(not is_equivalent_to(CallableTypeFromFunction[f11], CallableTypeFromFunction[f11]))
```
When the default value for a parameter is present only in one of the callable type:
```py
def f12(a: int) -> None: ...
def f13(a: int = 2) -> None: ...
static_assert(not is_equivalent_to(CallableTypeFromFunction[f12], CallableTypeFromFunction[f13]))
static_assert(not is_equivalent_to(CallableTypeFromFunction[f13], CallableTypeFromFunction[f12]))
```
[the equivalence relation]: https://typing.readthedocs.io/en/latest/spec/glossary.html#term-equivalent [the equivalence relation]: https://typing.readthedocs.io/en/latest/spec/glossary.html#term-equivalent

View File

@ -898,6 +898,10 @@ impl<'db> Type<'db> {
left.is_equivalent_to(db, right) left.is_equivalent_to(db, right)
} }
(Type::Tuple(left), Type::Tuple(right)) => left.is_equivalent_to(db, right), (Type::Tuple(left), Type::Tuple(right)) => left.is_equivalent_to(db, right),
(
Type::Callable(CallableType::General(left)),
Type::Callable(CallableType::General(right)),
) => left.is_equivalent_to(db, right),
_ => self == other && self.is_fully_static(db) && other.is_fully_static(db), _ => self == other && self.is_fully_static(db) && other.is_fully_static(db),
} }
} }
@ -4362,10 +4366,8 @@ impl<'db> FunctionType<'db> {
/// Convert the `FunctionType` into a [`Type::Callable`]. /// Convert the `FunctionType` into a [`Type::Callable`].
/// ///
/// Returns `None` if the function is overloaded. This powers the `CallableTypeFromFunction` /// This powers the `CallableTypeFromFunction` special form from the `knot_extensions` module.
/// special form from the `knot_extensions` module.
pub(crate) fn into_callable_type(self, db: &'db dyn Db) -> Type<'db> { pub(crate) fn into_callable_type(self, db: &'db dyn Db) -> Type<'db> {
// TODO: Add support for overloaded callables
Type::Callable(CallableType::General(GeneralCallableType::new( Type::Callable(CallableType::General(GeneralCallableType::new(
db, db,
self.signature(db).clone(), self.signature(db).clone(),
@ -4611,6 +4613,90 @@ impl<'db> GeneralCallableType<'db> {
.is_some_and(|return_type| return_type.is_fully_static(db)) .is_some_and(|return_type| return_type.is_fully_static(db))
} }
/// Return `true` if `self` represents the exact same set of possible runtime objects as `other`.
pub(crate) fn is_equivalent_to(self, db: &'db dyn Db, other: Self) -> bool {
let self_signature = self.signature(db);
let other_signature = other.signature(db);
let self_parameters = self_signature.parameters();
let other_parameters = other_signature.parameters();
if self_parameters.len() != other_parameters.len() {
return false;
}
if self_parameters.is_gradual() || other_parameters.is_gradual() {
return false;
}
// Check equivalence relationship between two optional types. If either of them is `None`,
// then it is not a fully static type which means it's not equivalent either.
let is_equivalent = |self_type: Option<Type<'db>>, other_type: Option<Type<'db>>| match (
self_type, other_type,
) {
(Some(self_type), Some(other_type)) => self_type.is_equivalent_to(db, other_type),
_ => false,
};
if !is_equivalent(self_signature.return_ty, other_signature.return_ty) {
return false;
}
for (self_parameter, other_parameter) in self_parameters.iter().zip(other_parameters) {
match (self_parameter.kind(), other_parameter.kind()) {
(
ParameterKind::PositionalOnly {
default_ty: self_default,
..
},
ParameterKind::PositionalOnly {
default_ty: other_default,
..
},
) if self_default.is_some() == other_default.is_some() => {}
(
ParameterKind::PositionalOrKeyword {
name: self_name,
default_ty: self_default,
},
ParameterKind::PositionalOrKeyword {
name: other_name,
default_ty: other_default,
},
) if self_default.is_some() == other_default.is_some()
&& self_name == other_name => {}
(ParameterKind::Variadic { .. }, ParameterKind::Variadic { .. }) => {}
(
ParameterKind::KeywordOnly {
name: self_name,
default_ty: self_default,
},
ParameterKind::KeywordOnly {
name: other_name,
default_ty: other_default,
},
) if self_default.is_some() == other_default.is_some()
&& self_name == other_name => {}
(ParameterKind::KeywordVariadic { .. }, ParameterKind::KeywordVariadic { .. }) => {}
_ => return false,
}
if !is_equivalent(
self_parameter.annotated_type(),
other_parameter.annotated_type(),
) {
return false;
}
}
true
}
/// Return `true` if `self` has exactly the same set of possible static materializations as /// Return `true` if `self` has exactly the same set of possible static materializations as
/// `other` (if `self` represents the same set of possible sets of possible runtime objects as /// `other` (if `self` represents the same set of possible sets of possible runtime objects as
/// `other`). /// `other`).

View File

@ -601,6 +601,11 @@ impl<'db> Parameter<'db> {
self.annotated_ty self.annotated_ty
} }
/// Kind of the parameter.
pub(crate) fn kind(&self) -> &ParameterKind<'db> {
&self.kind
}
/// Name of the parameter (if it has one). /// Name of the parameter (if it has one).
pub(crate) fn name(&self) -> Option<&ast::name::Name> { pub(crate) fn name(&self) -> Option<&ast::name::Name> {
match &self.kind { match &self.kind {