mirror of https://github.com/astral-sh/ruff
[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:
parent
63e78b41cd
commit
193c38199e
|
|
@ -118,4 +118,126 @@ class 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
|
||||
|
|
|
|||
|
|
@ -898,6 +898,10 @@ impl<'db> Type<'db> {
|
|||
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),
|
||||
}
|
||||
}
|
||||
|
|
@ -4362,10 +4366,8 @@ 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.
|
||||
/// This powers the `CallableTypeFromFunction` special form from the `knot_extensions` module.
|
||||
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(
|
||||
db,
|
||||
self.signature(db).clone(),
|
||||
|
|
@ -4611,6 +4613,90 @@ impl<'db> GeneralCallableType<'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
|
||||
/// `other` (if `self` represents the same set of possible sets of possible runtime objects as
|
||||
/// `other`).
|
||||
|
|
|
|||
|
|
@ -601,6 +601,11 @@ impl<'db> Parameter<'db> {
|
|||
self.annotated_ty
|
||||
}
|
||||
|
||||
/// Kind of the parameter.
|
||||
pub(crate) fn kind(&self) -> &ParameterKind<'db> {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
/// Name of the parameter (if it has one).
|
||||
pub(crate) fn name(&self) -> Option<&ast::name::Name> {
|
||||
match &self.kind {
|
||||
|
|
|
|||
Loading…
Reference in New Issue