[ty] Treat narrowing bounds as gradual outside set simplification
diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index 15a18c5..8591192 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md
@@ -755,8 +755,9 @@ In `relaxed` mode, narrowing to a generic class using `isinstance()` intersects with its top materialization, using specially tagged `object*`/`Never*` bounds for gradual type arguments. These -bounds behave like `object`/`Never` while the intersection is simplified, retain their narrowing -provenance afterwards, and behave like `Unknown` for assignability. For example, in the case below, +bounds behave like `object`/`Never` while unions and intersections are simplified. Everywhere else, +including attribute access, calls, subscripting, iteration, operators, and type relations, they +behave like `Unknown` while retaining their narrowing provenance. For example, in the case below, `object & Covariant[object*]` simplifies to `Covariant[object*]`: ```py @@ -836,12 +837,13 @@ x.push("foo") ``` -Both tagged bounds remain gradual only for assignability. In particular, `object*` is assignable to -and from `int`, but it is not a subtype of `int`: +Both tagged bounds behave like `Unknown` for type relations outside union and intersection +simplification. In particular, `object*` is assignable to and from `int`, but it is not a subtype of +`int`, `int` is not a subtype of it, and it is not disjoint from `int`: ```py from ty_extensions import static_assert -from ty_extensions._internal import TypeOf, is_assignable_to, is_subtype_of +from ty_extensions._internal import TypeOf, is_assignable_to, is_disjoint_from, is_subtype_of def tagged_bounds_are_gradual(value: object) -> None: if isinstance(value, Covariant): @@ -851,6 +853,46 @@ static_assert(is_assignable_to(TypeOf[item], int)) static_assert(is_assignable_to(int, TypeOf[item])) static_assert(not is_subtype_of(TypeOf[item], int)) + static_assert(not is_subtype_of(int, TypeOf[item])) + static_assert(not is_subtype_of(TypeOf[item], TypeOf[item])) + static_assert(not is_disjoint_from(TypeOf[item], int)) +``` + +Tagged bounds also support all operations supported by `Unknown`. Results retain the tag so that +subsequent operations remain gradual as well: + +```py +def tagged_bounds_support_unknown_operations(value: object, assigned: int) -> None: + if isinstance(value, Covariant): + item = value.get() + reveal_type(item) # revealed: object* + + reveal_type(item.missing) # revealed: object* + reveal_type(item.missing(1, keyword="value")) # revealed: object* + reveal_type(item()) # revealed: object* + reveal_type(item[0]) # revealed: object* + reveal_type(item["key"].nested()) # revealed: object* + + reveal_type(item + 1) # revealed: object* + reveal_type(1 + item) # revealed: object* + reveal_type(-item) # revealed: object* + + for element in item: + reveal_type(element) # revealed: object* + reveal_type(element.missing) # revealed: object* + + assigned = item + +async def tagged_bounds_are_awaitable(value: object) -> None: + if isinstance(value, Covariant): + item = value.get() + reveal_type(await item) # revealed: object* + +def accepts_integer_bound[T: int](value: T) -> None: ... +def tagged_bounds_satisfy_generic_bounds(value: object) -> None: + if isinstance(value, Covariant): + item = value.get() + accepts_integer_bound(item) ``` The behavior of `issubclass()` is similar. @@ -1014,8 +1056,9 @@ With `analysis.strict-generic-narrowing` disabled, the positive branch is simplified using tagged `object*`/`Never*` bounds, which retain their narrowing provenance afterwards and behave like -`Unknown` for assignability. The negative branch still excludes the ordinary top materialization -because a negative `isinstance` result excludes every specialization of the class: +`Unknown` for all operations other than union and intersection simplification. The negative branch +still excludes the ordinary top materialization because a negative `isinstance` result excludes +every specialization of the class: ```toml [analysis] @@ -1062,7 +1105,7 @@ if isinstance(xs, Sequence): reveal_type(xs) # revealed: (OpenItem & Sequence[object*]) | Sequence[OpenItem] for x in xs: - reveal_type(x) # revealed: object + reveal_type(x) # revealed: object* else: reveal_type(xs) # revealed: OpenItem & ~Sequence[object] ``` @@ -1078,6 +1121,8 @@ reveal_type(xs.append) # revealed: bound method Top[list[Unknown]].append(object: Never*, /) -> None for x in xs: reveal_type(x) # revealed: object* + reveal_type(x.missing) # revealed: object* + reveal_type(x[0]) # revealed: object* xs.append(1) xs.append("foo") @@ -1124,7 +1169,7 @@ if isinstance(xs, list): reveal_type(xs) # revealed: (OpenItem & Top[list[Unknown]]) | list[OpenItem] for x in xs: - reveal_type(x) # revealed: object + reveal_type(x) # revealed: object* else: reveal_type(xs) # revealed: OpenItem & ~Top[list[Unknown]] ```
diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 275f143..c1d0d0c 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs
@@ -1179,8 +1179,20 @@ } } - fn narrowing_bound_fallback(self) -> Option<Type<'db>> { - let materialization_kind = self.as_nominal_instance()?.narrowing_bound_kind()?; + pub(crate) const fn narrowing_bound_kind(self) -> Option<MaterializationKind> { + match self { + Type::Dynamic(DynamicType::NarrowingBound(materialization_kind)) => { + Some(materialization_kind) + } + _ => None, + } + } + + /// Return the tagged bound's top or bottom meaning during set-theoretic simplification. + /// + /// Outside union and intersection simplification, tagged bounds behave like `Unknown`. + pub(crate) fn narrowing_bound_fallback(self) -> Option<Type<'db>> { + let materialization_kind = self.narrowing_bound_kind()?; Some(match materialization_kind { MaterializationKind::Top => Type::object(), MaterializationKind::Bottom => Type::Never, @@ -1215,20 +1227,20 @@ DynamicType::Unknown | DynamicType::UnknownGeneric(_) | DynamicType::AmbiguousOverload + | DynamicType::NarrowingBound(_) ) ) } pub(crate) const fn is_never(&self) -> bool { - match self { + matches!( + self, Type::Never - | Type::Divergent(DivergentType { - materialization: Some(MaterializationKind::Bottom), - .. - }) => true, - Type::NominalInstance(instance) => instance.is_narrowing_never(), - _ => false, - } + | Type::Divergent(DivergentType { + materialization: Some(MaterializationKind::Bottom), + .. + }) + ) } /// Returns `true` if this type contains a `Self` type variable. @@ -1356,7 +1368,8 @@ | DynamicType::InvalidConcatenateUnknown | DynamicType::UnknownGeneric(_) | DynamicType::UnspecializedTypeVar - | DynamicType::AmbiguousOverload => false, + | DynamicType::AmbiguousOverload + | DynamicType::NarrowingBound(_) => false, DynamicType::Todo(_) => true, }) } @@ -1934,10 +1947,7 @@ #[must_use] pub(crate) fn negate(&self, db: &'db dyn Db) -> Type<'db> { - if let Some(materialization_kind) = self - .as_nominal_instance() - .and_then(NominalInstanceType::narrowing_bound_kind) - { + if let Some(materialization_kind) = self.narrowing_bound_kind() { return Type::narrowing_bound(materialization_kind.flip()); } @@ -2109,7 +2119,8 @@ | DynamicType::UnspecializedTypeVar | DynamicType::Todo(_) | DynamicType::InvalidConcatenateUnknown - | DynamicType::AmbiguousOverload => false, + | DynamicType::AmbiguousOverload + | DynamicType::NarrowingBound(_) => false, }, } } @@ -6808,6 +6819,7 @@ TypeMapping::Promote(PromotionMode::On, PromotionKind::Regular) => self.promote_impl(db), } + Type::Dynamic(DynamicType::NarrowingBound(_)) => self, Type::Dynamic(_) => match type_mapping { TypeMapping::ApplySpecialization(_) | TypeMapping::ApplySpecializationWithMaterialization { .. } | @@ -7356,7 +7368,8 @@ Self::Dynamic( DynamicType::Unknown | DynamicType::UnknownGeneric(_) - | DynamicType::AmbiguousOverload, + | DynamicType::AmbiguousOverload + | DynamicType::NarrowingBound(_), ) => Type::SpecialForm(SpecialFormType::Unknown).definition(db), Self::Divergent(_) => Type::SpecialForm(SpecialFormType::Divergent).definition(db), Self::Dynamic(DynamicType::Todo(_)) => { @@ -8053,6 +8066,11 @@ Any, /// An unannotated value, or a dynamic type resulting from an error Unknown, + /// A gradual `isinstance` bound that is top or bottom only while simplifying type sets. + /// + /// All other type operations treat this exactly like `Unknown`, while retaining its display + /// provenance as `object*` or `Never*`. + NarrowingBound(MaterializationKind), /// Similar to `Unknown`, this represents a dynamic type that has been explicitly specialized /// with legacy typevars, e.g. `UnknownClass[T]`, where `T` is a legacy typevar. We keep track /// of the type variables in the generic context in case this type is later specialized again. @@ -8105,6 +8123,8 @@ | DynamicType::UnknownGeneric(_) | DynamicType::InvalidConcatenateUnknown | DynamicType::AmbiguousOverload => f.write_str("Unknown"), + DynamicType::NarrowingBound(MaterializationKind::Top) => f.write_str("object*"), + DynamicType::NarrowingBound(MaterializationKind::Bottom) => f.write_str("Never*"), DynamicType::UnspecializedTypeVar => f.write_str("UnspecializedTypeVar"), // `DynamicType::Todo`'s display should be explicit that is not a valid display of // any other type
diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs index d93cedf..c86da92 100644 --- a/crates/ty_python_semantic/src/types/class_base.rs +++ b/crates/ty_python_semantic/src/types/class_base.rs
@@ -67,7 +67,8 @@ DynamicType::Unknown | DynamicType::UnknownGeneric(_) | DynamicType::InvalidConcatenateUnknown - | DynamicType::AmbiguousOverload, + | DynamicType::AmbiguousOverload + | DynamicType::NarrowingBound(_), ) => "Unknown", ClassBase::Dynamic(DynamicType::UnspecializedTypeVar) => "UnspecializedTypeVar", ClassBase::Dynamic(DynamicType::Todo(_)) => "@Todo",
diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index a214a56..4aa6054 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs
@@ -956,16 +956,6 @@ } Type::Divergent(_) => f.with_type(self.ty).write_str("Divergent"), Type::Never => f.with_type(self.ty).write_str("Never"), - Type::NominalInstance(instance) - if instance.narrowing_bound_kind() == Some(MaterializationKind::Top) => - { - f.with_type(self.ty).write_str("object*") - } - Type::NominalInstance(instance) - if instance.narrowing_bound_kind() == Some(MaterializationKind::Bottom) => - { - f.with_type(self.ty).write_str("Never*") - } Type::NominalInstance(instance) => { let class = instance.class(self.db);
diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index c342981..fd0d946 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs
@@ -2396,11 +2396,7 @@ .peekable(); let revealed_type = if let Some(first) = argument_types.next() { // A singleton tagged bound must not be canonicalized to ordinary `object`. - if argument_types.peek().is_none() - && first - .as_nominal_instance() - .is_some_and(|instance| instance.narrowing_bound_kind().is_some()) - { + if argument_types.peek().is_none() && first.narrowing_bound_kind().is_some() { first } else { argument_types
diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 79d01bf..bbce969 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs
@@ -1496,6 +1496,7 @@ let vartype = if for_narrowing && materialization_kind == MaterializationKind::Top && vartype.is_unknown() + && vartype.narrowing_bound_kind().is_none() && let Some(TypeVarBoundOrConstraints::UpperBound(bound)) = bound_typevar.typevar(db).bound_or_constraints(db) {
diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 1b13ea1..111fe2a 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs
@@ -1700,7 +1700,10 @@ // "declared types is `Unknown` (e.g. due to a bad annotation, missing // import, etc.)". Ideally we would still prefer `Unknown` declared type, // but use inferred type if there is no declared type. - && !matches!(declared_type, Type::Dynamic(DynamicType::Unknown)) + && !matches!( + declared_type, + Type::Dynamic(DynamicType::Unknown | DynamicType::NarrowingBound(_)) + ) && declared_type.is_assignable_to(self.db(), inferred_ty) { (declared_ty, declared_type) @@ -3679,7 +3682,7 @@ } // `Unknown` is likely to be the result of an unresolved import or a typo, which will // already get a diagnostic, so don't pile on an extra diagnostic here. - Type::Dynamic(DynamicType::Unknown) => return, + Type::Dynamic(DynamicType::Unknown | DynamicType::NarrowingBound(_)) => return, _ => {} } if let Some(builder) = self @@ -4474,7 +4477,10 @@ && !matches!(name_expr.id.as_str(), "_ignore_" | "_value_" | "_name_") // Not bare Final (bare Final is allowed on enum members) && !(declared.qualifiers.contains(TypeQualifiers::FINAL) - && matches!(declared.inner_type(), Type::Dynamic(DynamicType::Unknown))) + && matches!( + declared.inner_type(), + Type::Dynamic(DynamicType::Unknown | DynamicType::NarrowingBound(_)) + )) // Value type would be an enum member at runtime (exclude callables, // which are never members) && !inferred_ty.is_subtype_of( @@ -8119,7 +8125,9 @@ // return type as type context. let return_tcx = if let Some(signature) = callable_tcx { match signature.return_ty { - Type::Dynamic(DynamicType::Unknown) => TypeContext::new(None), + Type::Dynamic(DynamicType::Unknown | DynamicType::NarrowingBound(_)) => { + TypeContext::new(None) + } _ => TypeContext::new(Some(signature.return_ty)), } } else {
diff --git a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs index abb6c7d..8641879 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs
@@ -387,8 +387,16 @@ (any @ Type::Dynamic(DynamicType::Any), _, _) | (_, any @ Type::Dynamic(DynamicType::Any), _) => Some(any), - (unknown @ Type::Dynamic(DynamicType::Unknown), _, _) - | (_, unknown @ Type::Dynamic(DynamicType::Unknown), _) => Some(unknown), + ( + unknown @ Type::Dynamic(DynamicType::Unknown | DynamicType::NarrowingBound(_)), + _, + _, + ) + | ( + _, + unknown @ Type::Dynamic(DynamicType::Unknown | DynamicType::NarrowingBound(_)), + _, + ) => Some(unknown), (unknown @ Type::Dynamic(DynamicType::InvalidConcatenateUnknown), _, _) | (_, unknown @ Type::Dynamic(DynamicType::InvalidConcatenateUnknown), _) => {
diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index dc2bf2d..9fbb75a 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs
@@ -9,7 +9,7 @@ use super::protocol_class::{ProtocolInterface, ProtocolInterfaceView}; use super::{ - BoundTypeVarIdentity, BoundTypeVarInstance, ClassType, DivergentType, KnownClass, + BoundTypeVarIdentity, BoundTypeVarInstance, ClassType, DivergentType, DynamicType, KnownClass, MaterializationKind, SubclassOfType, Type, TypeAliasType, TypeVarVariance, }; use crate::place::PlaceAndQualifiers; @@ -44,21 +44,17 @@ } pub(crate) const fn narrowing_bound(materialization_kind: MaterializationKind) -> Self { - Type::NominalInstance(NominalInstanceType(NominalInstanceInner::NarrowingBound( - materialization_kind, - ))) + Type::Dynamic(DynamicType::NarrowingBound(materialization_kind)) } pub(crate) const fn is_object(&self) -> bool { matches!( self, - Type::NominalInstance(NominalInstanceType( - NominalInstanceInner::Object - | NominalInstanceInner::NarrowingBound(MaterializationKind::Top) - )) | Type::Divergent(DivergentType { - materialization: Some(MaterializationKind::Top), - .. - }) + Type::NominalInstance(NominalInstanceType(NominalInstanceInner::Object)) + | Type::Divergent(DivergentType { + materialization: Some(MaterializationKind::Top), + .. + }) ) } @@ -196,7 +192,7 @@ NominalInstanceInner::ExactTuple(tuple) => { walk_tuple_type(db, tuple, visitor); } - NominalInstanceInner::Object | NominalInstanceInner::NarrowingBound(_) => {} + NominalInstanceInner::Object => {} NominalInstanceInner::NonTuple(class) => visitor.visit_type(db, class.class(db).into()), NominalInstanceInner::SysVersionInfo => {} } @@ -250,9 +246,7 @@ NominalInstanceInner::SysVersionInfo => { sys_version_info_class(db).unwrap_or_else(|| ClassType::object(db)) } - NominalInstanceInner::Object | NominalInstanceInner::NarrowingBound(_) => { - ClassType::object(db) - } + NominalInstanceInner::Object => ClassType::object(db), } } @@ -268,9 +262,7 @@ NominalInstanceInner::ExactTuple(_) => Some(KnownClass::Tuple), NominalInstanceInner::NonTuple(class) => class.class(db).known(db), NominalInstanceInner::SysVersionInfo => Some(KnownClass::VersionInfo), - NominalInstanceInner::Object | NominalInstanceInner::NarrowingBound(_) => { - Some(KnownClass::Object) - } + NominalInstanceInner::Object => Some(KnownClass::Object), } } @@ -293,7 +285,7 @@ NominalInstanceInner::SysVersionInfo => { Some(Cow::Owned(TupleSpec::version_info_spec(db))) } - NominalInstanceInner::Object | NominalInstanceInner::NarrowingBound(_) => None, + NominalInstanceInner::Object => None, NominalInstanceInner::NonTuple(class) => { let class = class.class(db); // Avoid an expensive MRO traversal for common stdlib classes. @@ -325,35 +317,13 @@ /// Return `true` if this type represents instances of the class `builtins.object`. pub(super) const fn is_object(self) -> bool { - matches!( - self.0, - NominalInstanceInner::Object - | NominalInstanceInner::NarrowingBound(MaterializationKind::Top) - ) - } - - pub(crate) const fn narrowing_bound_kind(self) -> Option<MaterializationKind> { - match self.0 { - NominalInstanceInner::NarrowingBound(materialization_kind) => { - Some(materialization_kind) - } - _ => None, - } - } - - pub(crate) const fn is_narrowing_never(self) -> bool { - matches!( - self.0, - NominalInstanceInner::NarrowingBound(MaterializationKind::Bottom) - ) + matches!(self.0, NominalInstanceInner::Object) } pub(super) fn is_definition_generic(self, db: &'db dyn Db) -> bool { match self.0 { NominalInstanceInner::ExactTuple(_) => true, - NominalInstanceInner::SysVersionInfo - | NominalInstanceInner::Object - | NominalInstanceInner::NarrowingBound(_) => false, + NominalInstanceInner::SysVersionInfo | NominalInstanceInner::Object => false, NominalInstanceInner::NonTuple(class) => class.class(db).is_generic(), } } @@ -373,8 +343,7 @@ NominalInstanceInner::ExactTuple(tuple) => Some(Cow::Borrowed(tuple.tuple(db))), NominalInstanceInner::NonTuple(_) | NominalInstanceInner::SysVersionInfo - | NominalInstanceInner::Object - | NominalInstanceInner::NarrowingBound(_) => None, + | NominalInstanceInner::Object => None, } } @@ -388,8 +357,7 @@ NominalInstanceInner::NonTuple(class) => class.class(db), NominalInstanceInner::ExactTuple(_) | NominalInstanceInner::SysVersionInfo - | NominalInstanceInner::Object - | NominalInstanceInner::NarrowingBound(_) => return None, + | NominalInstanceInner::Object => return None, }; let (class_literal, specialization) = class.static_class_literal(db)?; let specialization = specialization?; @@ -436,9 +404,6 @@ Some(Self(NominalInstanceInner::SysVersionInfo)) } NominalInstanceInner::Object => Some(Self(NominalInstanceInner::Object)), - NominalInstanceInner::NarrowingBound(materialization_kind) => Some(Self( - NominalInstanceInner::NarrowingBound(materialization_kind), - )), NominalInstanceInner::NonTuple(class) => { let transformed = class .class(db) @@ -457,9 +422,7 @@ // should not be relied on for type narrowing, so we do not treat it as one. // See: // https://docs.python.org/3/reference/expressions.html#parenthesized-forms - NominalInstanceInner::ExactTuple(_) - | NominalInstanceInner::Object - | NominalInstanceInner::NarrowingBound(_) => false, + NominalInstanceInner::ExactTuple(_) | NominalInstanceInner::Object => false, NominalInstanceInner::SysVersionInfo => true, NominalInstanceInner::NonTuple(class) => class .class(db) @@ -486,7 +449,6 @@ } NominalInstanceInner::SysVersionInfo => Type::NominalInstance(self), NominalInstanceInner::Object => Type::object(), - NominalInstanceInner::NarrowingBound(_) => Type::NominalInstance(self), NominalInstanceInner::NonTuple(class) => { let transformed = class @@ -510,9 +472,7 @@ NominalInstanceInner::ExactTuple(tuple) => { tuple.find_legacy_typevars_impl(db, binding_context, typevars, visitor); } - NominalInstanceInner::SysVersionInfo - | NominalInstanceInner::Object - | NominalInstanceInner::NarrowingBound(_) => {} + NominalInstanceInner::SysVersionInfo | NominalInstanceInner::Object => {} NominalInstanceInner::NonTuple(class) => { class .class(db) @@ -985,8 +945,6 @@ /// prevalent and foundational, and it's useful to be able to instantiate this without having /// to load the definition of `object` from the typeshed. Object, - /// A tagged `object` or `Never` that preserves generic-narrowing provenance. - NarrowingBound(MaterializationKind), /// A tuple type, e.g. `tuple[int, str]`. /// /// Note that the type `tuple[int, str]` includes subtypes of `tuple[int, str]`,
diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index ae899b1..013e951 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs
@@ -1132,16 +1132,11 @@ Some(self.check_type_pair(db, Type::TypeVar(source), target)) } - /// Tagged narrowing bounds retain their top/bottom meaning for subtype checks and - /// simplification, but remain gradual when checking assignability. + /// Tagged bounds have top/bottom semantics only for pragmatic set simplification. fn narrowing_bound_fallback(&self, ty: Type<'db>) -> Option<Type<'db>> { - ty.narrowing_bound_fallback().map(|fallback| { - if self.relation.is_assignability() { - Type::unknown() - } else { - fallback - } - }) + matches!(self.relation, TypeRelation::Redundancy { pure: false }) + .then(|| ty.narrowing_bound_fallback()) + .flatten() } /// Return a constraint set indicating the conditions under which `self.relation` holds between `source` and `target`. @@ -2733,14 +2728,6 @@ } } - if let Some(left) = left.narrowing_bound_fallback() { - return self.check_type_pair(db, left, right); - } - - if let Some(right) = right.narrowing_bound_fallback() { - return self.check_type_pair(db, left, right); - } - if let Some(left) = left.materialized_divergent_fallback() { return self.check_type_pair(db, left, right); }
diff --git a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs index 5916365..60a4560 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs
@@ -40,14 +40,43 @@ use crate::types::enums::EnumComplement; use crate::types::set_theoretic::expand_intersection_typevars_and_newtypes; use crate::types::{ - BytesLiteralType, ClassLiteral, EnumLiteralType, IntersectionType, KnownClass, - KnownInstanceType, LiteralValueType, LiteralValueTypeKind, NegativeIntersectionElements, - StringLiteralType, SubclassOfType, Type, TypeVarBoundOrConstraints, TypeVarVariance, UnionType, + BytesLiteralType, ClassLiteral, DynamicType, EnumLiteralType, IntersectionType, KnownClass, + KnownInstanceType, LiteralValueType, LiteralValueTypeKind, MaterializationKind, + NegativeIntersectionElements, StringLiteralType, SubclassOfType, Type, + TypeVarBoundOrConstraints, TypeVarVariance, UnionType, }; use crate::{Db, FxOrderMap, FxOrderSet}; use rustc_hash::FxHashSet; use smallvec::SmallVec; +/// Tagged narrowing bounds retain their top/bottom meaning only while simplifying type sets. +/// Everywhere else they are ordinary gradual types. +fn materialize_narrowing_bound_for_set_simplification(ty: Type<'_>) -> Type<'_> { + ty.narrowing_bound_fallback().unwrap_or(ty) +} + +fn is_bottom_for_set_simplification(ty: &Type<'_>) -> bool { + ty.is_never() || ty.narrowing_bound_kind() == Some(MaterializationKind::Bottom) +} + +fn is_top_for_set_simplification(ty: Type<'_>) -> bool { + ty.is_object() || ty.narrowing_bound_kind() == Some(MaterializationKind::Top) +} + +fn is_subtype_for_set_simplification(db: &dyn Db, left: Type<'_>, right: Type<'_>) -> bool { + materialize_narrowing_bound_for_set_simplification(left).is_subtype_of( + db, + materialize_narrowing_bound_for_set_simplification(right), + ) +} + +fn are_disjoint_for_set_simplification(db: &dyn Db, left: Type<'_>, right: Type<'_>) -> bool { + materialize_narrowing_bound_for_set_simplification(left).is_disjoint_from( + db, + materialize_narrowing_bound_for_set_simplification(right), + ) +} + /// Extract `(core, guard)` from truthiness-guarded intersections. /// /// e.g. @@ -138,6 +167,7 @@ continue; } if general_type.is_non_divergent_dynamic() + && general_type.narrowing_bound_kind().is_none() && typevar.variance(db) == TypeVarVariance::Invariant { has_dynamic_replacement = true; @@ -707,8 +737,14 @@ } } } - // Adding `Never` to a union is a no-op. Type::Never => {} + Type::Dynamic(DynamicType::NarrowingBound(MaterializationKind::Bottom)) => { + // A tagged bottom is redundant once another element exists, but must retain its + // gradual provenance when it is the only element. + if self.elements.is_empty() { + self.elements.push(UnionElement::Type(ty)); + } + } Type::TypeAlias(alias) if self.unpack_aliases => { if seen_aliases.contains(&ty) { // Union contains itself recursively via a type alias. This is an error, just @@ -756,7 +792,11 @@ to_remove = Some(index); continue; } - if ty_negated().is_subtype_of(self.db, *existing) { + if is_subtype_for_set_simplification( + self.db, + ty_negated(), + *existing, + ) { // The type that includes both this new element, and its negation // (or a supertype of its negation), must be simply `object`. self.collapse_to_object(); @@ -809,7 +849,11 @@ to_remove = Some(index); continue; } - if ty_negated().is_subtype_of(self.db, *existing) { + if is_subtype_for_set_simplification( + self.db, + ty_negated(), + *existing, + ) { // The type that includes both this new element, and its negation // (or a supertype of its negation), must be simply `object`. self.collapse_to_object(); @@ -864,7 +908,11 @@ to_remove = Some(index); continue; } - if ty_negated().is_subtype_of(self.db, *existing) { + if is_subtype_for_set_simplification( + self.db, + ty_negated(), + *existing, + ) { // The type that includes both this new element, and its negation // (or a supertype of its negation), must be simply `object`. self.collapse_to_object(); @@ -940,7 +988,11 @@ to_remove = Some(index); continue; } - if ty_negated().is_subtype_of(self.db, *existing) { + if is_subtype_for_set_simplification( + self.db, + ty_negated(), + *existing, + ) { // The type that includes both this new element, and its negation // (or a supertype of its negation), must be simply `object`. self.collapse_to_object(); @@ -983,8 +1035,20 @@ _ => self.push_type(ty, seen_aliases), } } - // Adding `object` to a union results in `object`. - ty if ty.is_object() && !cycle_recovery => self.collapse_to_object(), + // Adding a top type subsumes every existing union element. Preserve narrowing + // provenance unless an ordinary `object` was already present. + ty if is_top_for_set_simplification(ty) && !cycle_recovery => { + if ty.narrowing_bound_kind().is_some() + && !self.elements.iter().any(|element| { + matches!(element, UnionElement::Type(existing) if existing.is_object()) + }) + { + self.elements.clear(); + self.elements.push(UnionElement::Type(ty)); + } else { + self.collapse_to_object(); + } + } _ => self.push_type(ty, seen_aliases), } } @@ -1385,7 +1449,7 @@ impl<'db> InnerIntersectionBuilder<'db> { fn contains_never(&self) -> bool { - self.positive.iter().any(Type::is_never) + self.positive.iter().any(is_bottom_for_set_simplification) } /// Return `true` when an intersection excludes every member of an enum class. @@ -1451,12 +1515,12 @@ /// Adds a positive type to this intersection. fn add_positive(&mut self, db: &'db dyn Db, mut new_positive: Type<'db>) { // `Never & T` -> `Never` - if self.positive.iter().any(Type::is_never) { + if self.positive.iter().any(is_bottom_for_set_simplification) { return; } // `T & Never` -> `Never` - if new_positive.is_never() { + if is_bottom_for_set_simplification(&new_positive) { *self = Self::default(); self.positive.insert(new_positive); return; @@ -1549,9 +1613,7 @@ _ => { let positive_as_instance = new_positive.as_nominal_instance(); - if let Some(instance) = positive_as_instance - && instance.is_object() - { + if is_top_for_set_simplification(new_positive) { // `object & T` -> `T`; it is always redundant to add `object` to an intersection return; } @@ -1639,7 +1701,7 @@ to_remove.push(index); } // A & B = Never if A and B are disjoint - if new_positive.is_disjoint_from(db, *existing_positive) { + if are_disjoint_for_set_simplification(db, new_positive, *existing_positive) { *self = Self::default(); self.positive.insert(Type::Never); return; @@ -1652,13 +1714,13 @@ let mut to_remove = SmallVec::<[usize; 1]>::new(); for (index, existing_negative) in self.negative.iter().enumerate() { // S & ~T = Never if S <: T - if new_positive.is_subtype_of(db, *existing_negative) { + if is_subtype_for_set_simplification(db, new_positive, *existing_negative) { *self = Self::default(); self.positive.insert(Type::Never); return; } // A & ~B = A if A and B are disjoint - if existing_negative.is_disjoint_from(db, new_positive) { + if are_disjoint_for_set_simplification(db, *existing_negative, new_positive) { to_remove.push(index); } } @@ -1674,7 +1736,7 @@ /// Adds a negative type to this intersection. fn add_negative(&mut self, db: &'db dyn Db, new_negative: Type<'db>) { // `Never & ~T` -> `Never`. - if self.positive.iter().any(Type::is_never) { + if self.positive.iter().any(is_bottom_for_set_simplification) { return; } @@ -1707,10 +1769,11 @@ self.add_positive(db, *neg); } } - Type::Never => { + Type::Never + | Type::Dynamic(DynamicType::NarrowingBound(MaterializationKind::Bottom)) => { // Adding ~Never to an intersection is a no-op. } - Type::NominalInstance(instance) if instance.is_object() => { + ty if is_top_for_set_simplification(ty) => { // Adding ~object to an intersection results in Never. *self = Self::default(); self.positive.insert(Type::Never); @@ -1767,7 +1830,7 @@ to_remove.push(index); } // same rule, reverse order - if new_negative.is_subtype_of(db, *existing_negative) { + if is_subtype_for_set_simplification(db, new_negative, *existing_negative) { return; } } @@ -1798,13 +1861,13 @@ } // S & ~T = Never if S <: T - if existing_positive.is_subtype_of(db, new_negative) { + if is_subtype_for_set_simplification(db, *existing_positive, new_negative) { *self = Self::default(); self.positive.insert(Type::Never); return; } // A & ~B = A if A and B are disjoint - if existing_positive.is_disjoint_from(db, new_negative) { + if are_disjoint_for_set_simplification(db, *existing_positive, new_negative) { return; } } @@ -1847,7 +1910,7 @@ let matching_constraints = constraints .iter() .enumerate() - .filter(|(_, c)| c.is_subtype_of(db, *negative)); + .filter(|(_, c)| is_subtype_for_set_simplification(db, **c, *negative)); for (constraint_index, _) in matching_constraints { remaining_constraints[constraint_index] = None; } @@ -1931,7 +1994,7 @@ use crate::place::{global_symbol, known_module_symbol}; use crate::types::enums::enum_member_literals; use crate::types::type_alias::TypeAliasType; - use crate::types::{KnownClass, KnownInstanceType, Truthiness}; + use crate::types::{KnownClass, KnownInstanceType, MaterializationKind, Truthiness}; use ruff_db::system::DbWithWritableSystem as _; use ty_module_resolver::KnownModule; @@ -1954,6 +2017,61 @@ } #[test] + fn build_union_preserves_single_narrowing_bounds() { + let db = setup_db(); + + for materialization_kind in [MaterializationKind::Top, MaterializationKind::Bottom] { + let bound = Type::narrowing_bound(materialization_kind); + assert_eq!(UnionBuilder::new(&db).add(bound).build(), bound); + } + } + + #[test] + fn build_union_simplifies_narrowing_bounds() { + let db = setup_db(); + let bound = Type::narrowing_bound(MaterializationKind::Top); + let bottom = Type::narrowing_bound(MaterializationKind::Bottom); + let int = KnownClass::Int.to_instance(&db); + + assert_eq!(UnionBuilder::new(&db).add(bound).add(int).build(), bound); + assert_eq!(UnionBuilder::new(&db).add(int).add(bound).build(), bound); + assert_eq!(UnionBuilder::new(&db).add(bottom).add(int).build(), int); + assert_eq!(UnionBuilder::new(&db).add(int).add(bottom).build(), int); + } + + #[test] + fn narrowing_bounds_are_gradual_outside_set_simplification() { + let db = setup_db(); + let int = KnownClass::Int.to_instance(&db); + + for materialization_kind in [MaterializationKind::Top, MaterializationKind::Bottom] { + let bound = Type::narrowing_bound(materialization_kind); + + assert!(!bound.is_never()); + assert!(bound.is_assignable_to(&db, int)); + assert!(int.is_assignable_to(&db, bound)); + assert!(!bound.is_subtype_of(&db, int)); + assert!(!int.is_subtype_of(&db, bound)); + assert!(!bound.is_disjoint_from(&db, int)); + assert_eq!(bound.member(&db, "missing").place.expect_type(), bound); + } + } + + #[test] + fn build_intersection_preserves_narrowing_bottom_under_negation() { + let db = setup_db(); + let bottom = Type::narrowing_bound(MaterializationKind::Bottom); + let int = KnownClass::Int.to_instance(&db); + + let intersection = IntersectionBuilder::new(&db) + .add_positive(bottom) + .add_negative(int) + .build(); + + assert_eq!(intersection, bottom); + } + + #[test] fn build_union_two_elements() { let db = setup_db();
diff --git a/crates/ty_python_semantic/src/types/subclass_of.rs b/crates/ty_python_semantic/src/types/subclass_of.rs index 462cfd2..95112c7 100644 --- a/crates/ty_python_semantic/src/types/subclass_of.rs +++ b/crates/ty_python_semantic/src/types/subclass_of.rs
@@ -515,8 +515,10 @@ ), }, Type::TypeVar(bound_typevar) => SubclassOfInner::TypeVar(bound_typevar), - Type::Dynamic(DynamicType::Any) => SubclassOfInner::Dynamic(DynamicType::Any), - Type::Dynamic(DynamicType::Unknown) => SubclassOfInner::Dynamic(DynamicType::Unknown), + Type::Dynamic( + dynamic + @ (DynamicType::Any | DynamicType::Unknown | DynamicType::NarrowingBound(_)), + ) => SubclassOfInner::Dynamic(dynamic), _ => return None, }) }
diff --git a/crates/ty_python_semantic/src/types/typevar.rs b/crates/ty_python_semantic/src/types/typevar.rs index 537d919..2fe7c7d 100644 --- a/crates/ty_python_semantic/src/types/typevar.rs +++ b/crates/ty_python_semantic/src/types/typevar.rs
@@ -605,7 +605,8 @@ | DynamicType::UnknownGeneric(_) | DynamicType::UnspecializedTypeVar | DynamicType::InvalidConcatenateUnknown - | DynamicType::AmbiguousOverload => Parameters::unknown(), + | DynamicType::AmbiguousOverload + | DynamicType::NarrowingBound(_) => Parameters::unknown(), }, Type::Divergent(_) => Parameters::unknown(), Type::TypeVar(typevar) if typevar.is_paramspec(db) => {