Skip to content

Commit 37a1ec8

Browse files
AlexWaygoodcarljm
andauthored
[ty] Fix assignability of intersections with bounded typevars (#24502)
Co-authored-by: Carl Meyer <carl@astral.sh>
1 parent f518cc9 commit 37a1ec8

3 files changed

Lines changed: 108 additions & 31 deletions

File tree

crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,75 @@ def intersection_is_assignable[T](t: T) -> None:
850850
static_assert(is_subtype_of(Intersection[T, Not[None]], T))
851851
```
852852

853+
## Bounded typevars remain assignable to their upper bound after narrowing
854+
855+
Narrowing can leave a bounded typevar represented as an intersection, but it should still be
856+
assignable to its upper bound.
857+
858+
```py
859+
from typing import Callable
860+
from ty_extensions import Intersection, Not
861+
862+
class A: ...
863+
864+
class SomeClass[T: int | str]:
865+
field: T
866+
867+
def narrowed1(self) -> None:
868+
narrowed: int | str
869+
assert not isinstance(self.field, int)
870+
reveal_type(self.field) # revealed: T@SomeClass & ~int
871+
narrowed = self.field
872+
873+
def narrowed2(self) -> None:
874+
narrowed: int | str
875+
assert not isinstance(self.field, A)
876+
reveal_type(self.field) # revealed: T@SomeClass & ~A
877+
narrowed = self.field
878+
879+
def lenient_issubclass[T: type | tuple[type, ...]](class_or_tuple: T) -> T:
880+
if not isinstance(class_or_tuple, tuple):
881+
reveal_type(class_or_tuple) # revealed: T@lenient_issubclass & ~tuple[object, ...]
882+
# `T@lenient_issubclass & ~tuple[object, ...]` is assignable to `type`,
883+
# because `(type | tuple[type, ...]) & ~tuple[object, ...]` simplifies to `type`
884+
return check(class_or_tuple)
885+
return class_or_tuple
886+
887+
def check(check_type: type): ...
888+
889+
# In this scenario, we do not expand the intersection,
890+
# because it only has inferrable type variables in it.
891+
# This ensures that we continue to infer a precise type on the last line here:
892+
def higher[U](f: Callable[[U], type]) -> U:
893+
raise NotImplementedError
894+
895+
def source[T: type | tuple[type, ...]](x: T) -> Intersection[T, Not[tuple[object, ...]]]:
896+
raise NotImplementedError
897+
898+
reveal_type(higher(source)) # revealed: type
899+
```
900+
901+
## Constrained typevars remain assignable to the union of their constraints after narrowing
902+
903+
```py
904+
class A: ...
905+
906+
class SomeClass[T: (int, str)]:
907+
field: T
908+
909+
def narrowed1(self) -> None:
910+
narrowed: int | str
911+
assert not isinstance(self.field, int)
912+
reveal_type(self.field) # revealed: T@SomeClass & str
913+
narrowed = self.field
914+
915+
def narrowed2(self) -> None:
916+
narrowed: int | str
917+
assert not isinstance(self.field, A)
918+
reveal_type(self.field) # revealed: T@SomeClass & ~A
919+
narrowed = self.field
920+
```
921+
853922
## Narrowing
854923

855924
We can use narrowing expressions to eliminate some of the possibilities of a constrained typevar:

crates/ty_python_semantic/src/types.rs

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1343,13 +1343,6 @@ impl<'db> Type<'db> {
13431343
}
13441344
}
13451345

1346-
pub(crate) const fn as_new_type(self) -> Option<NewType<'db>> {
1347-
match self {
1348-
Type::NewTypeInstance(new_type) => Some(new_type),
1349-
_ => None,
1350-
}
1351-
}
1352-
13531346
/// If this type is a `Type::TypeAlias`, recursively resolves it to its
13541347
/// underlying value type. Otherwise, returns `self` unchanged.
13551348
pub(crate) fn resolve_type_alias(self, db: &'db dyn Db) -> Type<'db> {

crates/ty_python_semantic/src/types/relation.rs

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use crate::types::enums::is_single_member_enum;
1111
use crate::types::function::FunctionDecorators;
1212
use crate::types::set_theoretic::RecursivelyDefined;
1313
use crate::types::{
14-
ApplyTypeMappingVisitor, CallableType, ClassBase, ClassType, CycleDetector,
14+
ApplyTypeMappingVisitor, CallableType, ClassBase, ClassType, CycleDetector, IntersectionType,
1515
KnownBoundMethodType, KnownClass, KnownInstanceType, LiteralValueTypeKind, MemberLookupPolicy,
1616
PropertyInstanceType, ProtocolInstanceType, SubclassOfInner, TypeVarBoundOrConstraints,
1717
UnionType, UpcastPolicy,
@@ -710,6 +710,17 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> {
710710
}
711711
}
712712

713+
let should_expand_intersection = |intersection: IntersectionType<'db>| {
714+
intersection
715+
.positive(db)
716+
.iter()
717+
.any(|element| match element {
718+
Type::TypeVar(tvar) => !tvar.is_inferable(db, self.inferable),
719+
Type::NewTypeInstance(newtype) => newtype.concrete_base_type(db).is_union(),
720+
_ => false,
721+
})
722+
};
723+
713724
match (source, target) {
714725
// Everything is a subtype of `object`.
715726
(_, Type::NominalInstance(target)) if target.is_object() => self.always(),
@@ -996,25 +1007,18 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> {
9961007
.or(db, self.constraints, || {
9971008
// Normally non-unions cannot directly contain unions in our model due to the fact that we
9981009
// enforce a DNF structure on our set-theoretic types. However, it *is* possible for there
999-
// to be a newtype of a union, or for an intersection to contain a newtype of a union; this
1000-
// requires special handling.
1010+
// to be a newtype of a union, for an intersection to contain a newtype of a union, or for
1011+
// a non-inferable typevar (possibly inside an intersection) to widen to a bound or set of
1012+
// constraints that exposes a union; this requires special handling.
10011013
match source {
1002-
Type::Intersection(intersection) => {
1003-
if intersection.positive(db).iter().any(|&element| {
1004-
element.as_new_type().is_some_and(|newtype| {
1005-
newtype.concrete_base_type(db).is_union()
1006-
})
1007-
}) {
1008-
let mapped = intersection.map_positive(db, |&t| match t {
1009-
Type::NewTypeInstance(newtype) => {
1010-
newtype.concrete_base_type(db)
1011-
}
1012-
_ => t,
1013-
});
1014-
self.check_type_pair(db, mapped, target)
1015-
} else {
1016-
self.never()
1017-
}
1014+
Type::Intersection(intersection)
1015+
if should_expand_intersection(intersection) =>
1016+
{
1017+
self.check_type_pair(
1018+
db,
1019+
intersection.with_expanded_typevars_and_newtypes(db),
1020+
target,
1021+
)
10181022
}
10191023
Type::NewTypeInstance(newtype) => {
10201024
let concrete_base = newtype.concrete_base_type(db);
@@ -1082,11 +1086,22 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> {
10821086
// positive elements is a subtype of that type. If there are no positive elements,
10831087
// we treat `object` as the implicit positive element (e.g., `~str` is semantically
10841088
// `object & ~str`).
1085-
intersection.positive_elements_or_object(db).when_any(
1086-
db,
1087-
self.constraints,
1088-
|elem_ty| self.check_type_pair(db, elem_ty, target),
1089-
)
1089+
intersection
1090+
.positive_elements_or_object(db)
1091+
.when_any(db, self.constraints, |elem_ty| {
1092+
self.check_type_pair(db, elem_ty, target)
1093+
})
1094+
.or(db, self.constraints, || {
1095+
if should_expand_intersection(intersection) {
1096+
self.check_type_pair(
1097+
db,
1098+
intersection.with_expanded_typevars_and_newtypes(db),
1099+
target,
1100+
)
1101+
} else {
1102+
self.never()
1103+
}
1104+
})
10901105
}
10911106

10921107
// `Never` is the bottom type, the empty set.

0 commit comments

Comments
 (0)