[ty] Constraint sets compare generic callables correctly (#21392)
Constraint sets can now track subtyping/assignability/etc of generic
callables correctly. For instance:
```py
def identity[T](t: T) -> T:
return t
constraints = ConstraintSet.always()
static_assert(constraints.implies_subtype_of(TypeOf[identity], Callable[[int], int]))
static_assert(constraints.implies_subtype_of(TypeOf[identity], Callable[[str], str]))
```
A generic callable can be considered an intersection of all of its
possible specializations, and an assignability check with an
intersection as the lhs side succeeds of _any_ of the intersected types
satisfies the check. Put another way, if someone expects to receive any
function with a signature of `(int) -> int`, we can give them
`identity`.
Note that the corresponding check using `is_subtype_of` directly does
not yet work, since #20093 has not yet hooked up the core typing
relationship logic to use constraint sets:
```py
# These currently fail
static_assert(is_subtype_of(TypeOf[identity], Callable[[int], int]))
static_assert(is_subtype_of(TypeOf[identity], Callable[[str], str]))
```
To do this, we add a new _existential quantification_ operation on
constraint sets. This takes in a list of typevars and _removes_ those
typevars from the constraint set. Conceptually, we return a new
constraint set that evaluates to `true` when there was _any_ assignment
of the removed typevars that caused the old constraint set to evaluate
to `true`.
When comparing a generic constraint set, we add its typevars to the
`inferable` set, and figure out whatever constraints would allow any
specialization to satisfy the check. We then use the new existential
quantification operator to remove those new typevars, since the caller
doesn't (and shouldn't) know anything about them.
---------
Co-authored-by: David Peter <sharkdp@users.noreply.github.com>
This commit is contained in:
@@ -2099,18 +2099,14 @@ static_assert(is_equivalent_to(LegacyFunctionScoped, NewStyleFunctionScoped)) #
|
||||
|
||||
static_assert(is_assignable_to(NominalNewStyle, NewStyleFunctionScoped))
|
||||
static_assert(is_assignable_to(NominalNewStyle, LegacyFunctionScoped))
|
||||
# TODO: should pass
|
||||
static_assert(is_subtype_of(NominalNewStyle, NewStyleFunctionScoped)) # error: [static-assert-error]
|
||||
# TODO: should pass
|
||||
static_assert(is_subtype_of(NominalNewStyle, LegacyFunctionScoped)) # error: [static-assert-error]
|
||||
static_assert(is_subtype_of(NominalNewStyle, NewStyleFunctionScoped))
|
||||
static_assert(is_subtype_of(NominalNewStyle, LegacyFunctionScoped))
|
||||
static_assert(not is_assignable_to(NominalNewStyle, UsesSelf))
|
||||
|
||||
static_assert(is_assignable_to(NominalLegacy, NewStyleFunctionScoped))
|
||||
static_assert(is_assignable_to(NominalLegacy, LegacyFunctionScoped))
|
||||
# TODO: should pass
|
||||
static_assert(is_subtype_of(NominalLegacy, NewStyleFunctionScoped)) # error: [static-assert-error]
|
||||
# TODO: should pass
|
||||
static_assert(is_subtype_of(NominalLegacy, LegacyFunctionScoped)) # error: [static-assert-error]
|
||||
static_assert(is_subtype_of(NominalLegacy, NewStyleFunctionScoped))
|
||||
static_assert(is_subtype_of(NominalLegacy, LegacyFunctionScoped))
|
||||
static_assert(not is_assignable_to(NominalLegacy, UsesSelf))
|
||||
|
||||
static_assert(not is_assignable_to(NominalWithSelf, NewStyleFunctionScoped))
|
||||
|
||||
@@ -349,4 +349,101 @@ def mutually_constrained[T, U]():
|
||||
static_assert(not given_int.implies_subtype_of(Invariant[str], Invariant[T]))
|
||||
```
|
||||
|
||||
## Generic callables
|
||||
|
||||
A generic callable can be considered equivalent to an intersection of all of its possible
|
||||
specializations. That means that a generic callable is a subtype of any particular specialization.
|
||||
(If someone expects a function that works with a particular specialization, it's fine to hand them
|
||||
the generic callable.)
|
||||
|
||||
```py
|
||||
from typing import Callable
|
||||
from ty_extensions import CallableTypeOf, ConstraintSet, TypeOf, is_subtype_of, static_assert
|
||||
|
||||
def identity[T](t: T) -> T:
|
||||
return t
|
||||
|
||||
type GenericIdentity[T] = Callable[[T], T]
|
||||
|
||||
constraints = ConstraintSet.always()
|
||||
|
||||
static_assert(constraints.implies_subtype_of(TypeOf[identity], Callable[[int], int]))
|
||||
static_assert(constraints.implies_subtype_of(TypeOf[identity], Callable[[str], str]))
|
||||
static_assert(not constraints.implies_subtype_of(TypeOf[identity], Callable[[str], int]))
|
||||
|
||||
static_assert(constraints.implies_subtype_of(CallableTypeOf[identity], Callable[[int], int]))
|
||||
static_assert(constraints.implies_subtype_of(CallableTypeOf[identity], Callable[[str], str]))
|
||||
static_assert(not constraints.implies_subtype_of(CallableTypeOf[identity], Callable[[str], int]))
|
||||
|
||||
static_assert(constraints.implies_subtype_of(TypeOf[identity], GenericIdentity[int]))
|
||||
static_assert(constraints.implies_subtype_of(TypeOf[identity], GenericIdentity[str]))
|
||||
# This gives us the default specialization, GenericIdentity[Unknown], which does
|
||||
# not participate in subtyping.
|
||||
static_assert(not constraints.implies_subtype_of(TypeOf[identity], GenericIdentity))
|
||||
```
|
||||
|
||||
The reverse is not true — if someone expects a generic function that can be called with any
|
||||
specialization, we cannot hand them a function that only works with one specialization.
|
||||
|
||||
```py
|
||||
static_assert(not constraints.implies_subtype_of(Callable[[int], int], TypeOf[identity]))
|
||||
static_assert(not constraints.implies_subtype_of(Callable[[str], str], TypeOf[identity]))
|
||||
static_assert(not constraints.implies_subtype_of(Callable[[str], int], TypeOf[identity]))
|
||||
|
||||
static_assert(not constraints.implies_subtype_of(Callable[[int], int], CallableTypeOf[identity]))
|
||||
static_assert(not constraints.implies_subtype_of(Callable[[str], str], CallableTypeOf[identity]))
|
||||
static_assert(not constraints.implies_subtype_of(Callable[[str], int], CallableTypeOf[identity]))
|
||||
|
||||
static_assert(not constraints.implies_subtype_of(GenericIdentity[int], TypeOf[identity]))
|
||||
static_assert(not constraints.implies_subtype_of(GenericIdentity[str], TypeOf[identity]))
|
||||
# This gives us the default specialization, GenericIdentity[Unknown], which does
|
||||
# not participate in subtyping.
|
||||
static_assert(not constraints.implies_subtype_of(GenericIdentity, TypeOf[identity]))
|
||||
```
|
||||
|
||||
Unrelated typevars in the constraint set do not affect whether the subtyping check succeeds or
|
||||
fails.
|
||||
|
||||
```py
|
||||
def unrelated[T]():
|
||||
# Note that even though this typevar is also named T, it is not the same typevar as T@identity!
|
||||
constraints = ConstraintSet.range(bool, T, int)
|
||||
|
||||
static_assert(constraints.implies_subtype_of(TypeOf[identity], Callable[[int], int]))
|
||||
static_assert(constraints.implies_subtype_of(TypeOf[identity], Callable[[str], str]))
|
||||
static_assert(not constraints.implies_subtype_of(TypeOf[identity], Callable[[str], int]))
|
||||
static_assert(constraints.implies_subtype_of(TypeOf[identity], GenericIdentity[int]))
|
||||
static_assert(constraints.implies_subtype_of(TypeOf[identity], GenericIdentity[str]))
|
||||
|
||||
static_assert(not constraints.implies_subtype_of(Callable[[int], int], TypeOf[identity]))
|
||||
static_assert(not constraints.implies_subtype_of(Callable[[str], str], TypeOf[identity]))
|
||||
static_assert(not constraints.implies_subtype_of(Callable[[str], int], TypeOf[identity]))
|
||||
static_assert(not constraints.implies_subtype_of(GenericIdentity[int], TypeOf[identity]))
|
||||
static_assert(not constraints.implies_subtype_of(GenericIdentity[str], TypeOf[identity]))
|
||||
```
|
||||
|
||||
The generic callable's typevar _also_ does not affect whether the subtyping check succeeds or fails!
|
||||
|
||||
```py
|
||||
def identity2[T](t: T) -> T:
|
||||
# This constraint set refers to the same typevar as the generic function types below!
|
||||
constraints = ConstraintSet.range(bool, T, int)
|
||||
|
||||
static_assert(constraints.implies_subtype_of(TypeOf[identity2], Callable[[int], int]))
|
||||
static_assert(constraints.implies_subtype_of(TypeOf[identity2], Callable[[str], str]))
|
||||
# TODO: no error
|
||||
# error: [static-assert-error]
|
||||
static_assert(not constraints.implies_subtype_of(TypeOf[identity2], Callable[[str], int]))
|
||||
static_assert(constraints.implies_subtype_of(TypeOf[identity2], GenericIdentity[int]))
|
||||
static_assert(constraints.implies_subtype_of(TypeOf[identity2], GenericIdentity[str]))
|
||||
|
||||
static_assert(not constraints.implies_subtype_of(Callable[[int], int], TypeOf[identity2]))
|
||||
static_assert(not constraints.implies_subtype_of(Callable[[str], str], TypeOf[identity2]))
|
||||
static_assert(not constraints.implies_subtype_of(Callable[[str], int], TypeOf[identity2]))
|
||||
static_assert(not constraints.implies_subtype_of(GenericIdentity[int], TypeOf[identity2]))
|
||||
static_assert(not constraints.implies_subtype_of(GenericIdentity[str], TypeOf[identity2]))
|
||||
|
||||
return t
|
||||
```
|
||||
|
||||
[subtyping]: https://typing.python.org/en/latest/spec/concepts.html#subtype-supertype-and-type-equivalence
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Assignable-to relation
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.12"
|
||||
```
|
||||
|
||||
The `is_assignable_to(S, T)` relation below checks if type `S` is assignable to type `T` (target).
|
||||
This allows us to check if a type `S` can be used in a context where a type `T` is expected
|
||||
(function arguments, variable assignments). See the [typing documentation] for a precise definition
|
||||
@@ -1227,6 +1232,46 @@ from ty_extensions import static_assert, is_assignable_to
|
||||
static_assert(is_assignable_to(type, Callable[..., Any]))
|
||||
```
|
||||
|
||||
### Generic callables
|
||||
|
||||
A generic callable can be considered equivalent to an intersection of all of its possible
|
||||
specializations. That means that a generic callable is assignable to any particular specialization.
|
||||
(If someone expects a function that works with a particular specialization, it's fine to hand them
|
||||
the generic callable.)
|
||||
|
||||
```py
|
||||
from typing import Callable
|
||||
from ty_extensions import CallableTypeOf, TypeOf, is_assignable_to, static_assert
|
||||
|
||||
def identity[T](t: T) -> T:
|
||||
return t
|
||||
|
||||
static_assert(is_assignable_to(TypeOf[identity], Callable[[int], int]))
|
||||
static_assert(is_assignable_to(TypeOf[identity], Callable[[str], str]))
|
||||
# TODO: no error
|
||||
# error: [static-assert-error]
|
||||
static_assert(not is_assignable_to(TypeOf[identity], Callable[[str], int]))
|
||||
|
||||
static_assert(is_assignable_to(CallableTypeOf[identity], Callable[[int], int]))
|
||||
static_assert(is_assignable_to(CallableTypeOf[identity], Callable[[str], str]))
|
||||
# TODO: no error
|
||||
# error: [static-assert-error]
|
||||
static_assert(not is_assignable_to(CallableTypeOf[identity], Callable[[str], int]))
|
||||
```
|
||||
|
||||
The reverse is not true — if someone expects a generic function that can be called with any
|
||||
specialization, we cannot hand them a function that only works with one specialization.
|
||||
|
||||
```py
|
||||
static_assert(not is_assignable_to(Callable[[int], int], TypeOf[identity]))
|
||||
static_assert(not is_assignable_to(Callable[[str], str], TypeOf[identity]))
|
||||
static_assert(not is_assignable_to(Callable[[str], int], TypeOf[identity]))
|
||||
|
||||
static_assert(not is_assignable_to(Callable[[int], int], CallableTypeOf[identity]))
|
||||
static_assert(not is_assignable_to(Callable[[str], str], CallableTypeOf[identity]))
|
||||
static_assert(not is_assignable_to(Callable[[str], int], CallableTypeOf[identity]))
|
||||
```
|
||||
|
||||
## Generics
|
||||
|
||||
### Assignability of generic types parameterized by gradual types
|
||||
|
||||
@@ -2207,6 +2207,54 @@ static_assert(is_subtype_of(CallableTypeOf[overload_ab], CallableTypeOf[overload
|
||||
static_assert(is_subtype_of(CallableTypeOf[overload_ba], CallableTypeOf[overload_ab]))
|
||||
```
|
||||
|
||||
### Generic callables
|
||||
|
||||
A generic callable can be considered equivalent to an intersection of all of its possible
|
||||
specializations. That means that a generic callable is a subtype of any particular specialization.
|
||||
(If someone expects a function that works with a particular specialization, it's fine to hand them
|
||||
the generic callable.)
|
||||
|
||||
```py
|
||||
from typing import Callable
|
||||
from ty_extensions import CallableTypeOf, TypeOf, is_subtype_of, static_assert
|
||||
|
||||
def identity[T](t: T) -> T:
|
||||
return t
|
||||
|
||||
# TODO: Confusingly, these are not the same results as the corresponding checks in
|
||||
# is_assignable_to.md, even though all of these types are fully static. We have some heuristics that
|
||||
# currently conflict with each other, that we are in the process of removing with the constraint set
|
||||
# work.
|
||||
# TODO: no error
|
||||
# error: [static-assert-error]
|
||||
static_assert(is_subtype_of(TypeOf[identity], Callable[[int], int]))
|
||||
# TODO: no error
|
||||
# error: [static-assert-error]
|
||||
static_assert(is_subtype_of(TypeOf[identity], Callable[[str], str]))
|
||||
static_assert(not is_subtype_of(TypeOf[identity], Callable[[str], int]))
|
||||
|
||||
# TODO: no error
|
||||
# error: [static-assert-error]
|
||||
static_assert(is_subtype_of(CallableTypeOf[identity], Callable[[int], int]))
|
||||
# TODO: no error
|
||||
# error: [static-assert-error]
|
||||
static_assert(is_subtype_of(CallableTypeOf[identity], Callable[[str], str]))
|
||||
static_assert(not is_subtype_of(CallableTypeOf[identity], Callable[[str], int]))
|
||||
```
|
||||
|
||||
The reverse is not true — if someone expects a generic function that can be called with any
|
||||
specialization, we cannot hand them a function that only works with one specialization.
|
||||
|
||||
```py
|
||||
static_assert(not is_subtype_of(Callable[[int], int], TypeOf[identity]))
|
||||
static_assert(not is_subtype_of(Callable[[str], str], TypeOf[identity]))
|
||||
static_assert(not is_subtype_of(Callable[[str], int], TypeOf[identity]))
|
||||
|
||||
static_assert(not is_subtype_of(Callable[[int], int], CallableTypeOf[identity]))
|
||||
static_assert(not is_subtype_of(Callable[[str], str], CallableTypeOf[identity]))
|
||||
static_assert(not is_subtype_of(Callable[[str], int], CallableTypeOf[identity]))
|
||||
```
|
||||
|
||||
[gradual form]: https://typing.python.org/en/latest/spec/glossary.html#term-gradual-form
|
||||
[gradual tuple]: https://typing.python.org/en/latest/spec/tuples.html#tuple-type-form
|
||||
[special case for float and complex]: https://typing.python.org/en/latest/spec/special-types.html#special-cases-for-float-and-complex
|
||||
|
||||
Reference in New Issue
Block a user