From 151e9c247287ab2d59961eb874530c618db281a1 Mon Sep 17 00:00:00 2001 From: David Peter Date: Thu, 25 Jun 2026 15:05:53 +0200 Subject: [PATCH 01/15] [ty] Intersection simplifications with gradual generic specializations --- .../mdtest/generics/set_theoretic.md | 55 +++++- .../ty_python_semantic/src/types/generics.rs | 2 +- .../src/types/set_theoretic/builder.rs | 171 ++++++++++++++---- 3 files changed, 190 insertions(+), 38 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md b/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md index 94ab8223f4555..fafe9258b3b43 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md @@ -9,7 +9,7 @@ python-version = "3.14" ```pyi from typing import Any -from ty_extensions import static_assert, is_equivalent_to, is_subtype_of +from ty_extensions import Bottom, static_assert, is_equivalent_to, is_subtype_of ``` Throughout the document, we use the following classes as canonical examples for covariant, @@ -192,12 +192,19 @@ Contra[P] & Contra[Any] = Contra[P | Any] (5b) We can encode all of these in ty assertions: ```pyi -# TODO: all of these should pass +# TODO: both should pass static_assert(is_equivalent_to(Co[P] | Co[Any], Co[P | Any])) # error: [static-assert-error] -static_assert(is_equivalent_to(Co[P] & Co[Any], Co[P & Any])) # error: [static-assert-error] +static_assert(is_equivalent_to(Co[Any] | Co[P], Co[P | Any])) # error: [static-assert-error] +static_assert(is_equivalent_to(Co[P] & Co[Any], Co[P & Any])) +static_assert(is_equivalent_to(Co[Any] & Co[P], Co[P & Any])) + +# TODO: both should pass static_assert(is_equivalent_to(Contra[P] | Contra[Any], Contra[P & Any])) # error: [static-assert-error] -static_assert(is_equivalent_to(Contra[P] & Contra[Any], Contra[P | Any])) # error: [static-assert-error] +static_assert(is_equivalent_to(Contra[Any] | Contra[P], Contra[P & Any])) # error: [static-assert-error] + +static_assert(is_equivalent_to(Contra[P] & Contra[Any], Contra[P | Any])) +static_assert(is_equivalent_to(Contra[Any] & Contra[P], Contra[P | Any])) ``` What about invariance? We can naively write `Invariant[Any]` in its interval representation: @@ -285,3 +292,43 @@ static_assert(is_equivalent_to(Invariant[P] & Invariant[Any], Invariant[P])) static_assert(not is_equivalent_to(Invariant[P] | Invariant[Any], Invariant[P])) ``` + +Finally, we consider intersections with the negation of an `Any`-specialized generic class. For any +of the generic classes `C` above, we can negate the canonical interval representation of `C[Any]`: + +```ignore +~C[Any] + = ~(Bottom[C[Any]] | Top[C[Any]] & Any) + = ~Bottom[C[Any]] & (~Top[C[Any]] | Any) + = ~Top[C[Any]] | ~Bottom[C[Any]] & Any +``` + +Every fully-static specialization `C[P]` is a subtype of `Top[C[Any]]`, therefore: + +```ignore +C[P] & ~C[Any] + = C[P] & (~Top[C[Any]] | ~Bottom[C[Any]] & Any) + = C[P] & ~Bottom[C[Any]] & Any +``` + +For covariant and contravariant classes, we can simplify the bottom materializations as in (3a) and +(3b): + +```ignore +Co[P] & ~Co[Any] = Co[P] & ~Co[Never] & Any +Contra[P] & ~Contra[Any] = Contra[P] & ~Contra[object] & Any +Invariant[P] & ~Invariant[Any] = Invariant[P] & ~Bottom[Invariant[Any]] & Any +``` + +We can verify all three relations in ty: + +```pyi +static_assert(is_equivalent_to(Co[P] & ~Co[Any], Co[P] & ~Bottom[Co[Any]] & Any)) +static_assert(is_equivalent_to(Contra[P] & ~Contra[Any], Contra[P] & ~Bottom[Contra[Any]] & Any)) +static_assert(is_equivalent_to(Invariant[P] & ~Invariant[Any], Invariant[P] & ~Bottom[Invariant[Any]] & Any)) + +# The simplification is independent of the order in which the intersection elements are added. +static_assert(is_equivalent_to(~Co[Any] & Co[P], Co[P] & ~Bottom[Co[Any]] & Any)) +static_assert(is_equivalent_to(~Contra[Any] & Contra[P], Contra[P] & ~Bottom[Contra[Any]] & Any)) +static_assert(is_equivalent_to(~Invariant[Any] & Invariant[P], Invariant[P] & ~Bottom[Invariant[Any]] & Any)) +``` diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 63f0dd268fdf8..eaeaee68840d4 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -1723,7 +1723,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } } -fn specialization_variance<'db>( +pub(super) fn specialization_variance<'db>( db: &'db dyn Db, bound_typevar: BoundTypeVarInstance<'db>, ) -> TypeVarVariance { 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 970cb69460461..4153040f68493 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs @@ -38,11 +38,13 @@ use super::RecursivelyDefined; use crate::types::enums::EnumComplement; +use crate::types::generics::{Specialization, specialization_variance}; 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, + StaticClassLiteral, StringLiteralType, SubclassOfType, Type, TypeVarBoundOrConstraints, + TypeVarVariance, UnionType, }; use crate::{Db, FxOrderMap, FxOrderSet}; use rustc_hash::FxHashSet; @@ -90,21 +92,66 @@ fn split_truthiness_guarded_intersection<'db>( Some((core.build(), guard)) } -/// Return `true` if `general` and `specific` are specializations of the same generic class and -/// `general` only differs by using dynamic types for invariant type variables. For example, -/// `list[Any]` is an invariant-dynamic generalization of `list[int]`. -fn is_invariant_dynamic_generalization_of<'db>( +struct DynamicGeneralization<'db> { + class: StaticClassLiteral<'db>, + general: Specialization<'db>, + specific: Specialization<'db>, + specific_type: Type<'db>, + has_variant_replacement: bool, +} + +impl<'db> DynamicGeneralization<'db> { + fn intersection(self, db: &'db dyn Db) -> Option> { + let generic_context = self.general.generic_context(db); + if !self.has_variant_replacement { + return Some(self.specific_type); + } + + // Tuple specializations carry additional element information, and variance-based merges + // require the specific specialization to be fully static. + if self.class.known(db) == Some(KnownClass::Tuple) || self.specific_type.has_dynamic(db) { + return None; + } + + let types: Vec<_> = generic_context + .variables(db) + .zip(self.general.types(db)) + .zip(self.specific.types(db)) + .map(|((typevar, general), specific)| { + if general == specific { + return *specific; + } + match specialization_variance(db, typevar) { + TypeVarVariance::Covariant => { + IntersectionType::from_two_elements(db, *specific, *general) + } + TypeVarVariance::Contravariant => { + UnionType::from_two_elements(db, *specific, *general) + } + TypeVarVariance::Invariant | TypeVarVariance::Bivariant => *specific, + } + }) + .collect(); + let specialization = generic_context.specialize(db, types); + + Some(Type::instance( + db, + self.class + .apply_optional_specialization(db, Some(specialization)), + )) + } +} + +/// Return the relationship between two specializations of the same generic class if `general` +/// only differs from `specific` by using dynamic types. +fn dynamic_generalization_of<'db>( db: &'db dyn Db, general: Type<'db>, specific: Type<'db>, -) -> bool { +) -> Option> { // Fast path to avoid performance regressions. - if !general.has_dynamic(db) { - return false; - } - - if matches!(general, Type::TypeVar(_) | Type::NewTypeInstance(_)) { - return false; + if !general.has_dynamic(db) || matches!(general, Type::TypeVar(_) | Type::NewTypeInstance(_)) { + return None; } let ( @@ -115,7 +162,7 @@ fn is_invariant_dynamic_generalization_of<'db>( specific.class_specialization(db), ) else { - return false; + return None; }; // Top and bottom materializations are not gradual types. @@ -123,10 +170,11 @@ fn is_invariant_dynamic_generalization_of<'db>( || general_specialization.materialization_kind(db).is_some() || specific_specialization.materialization_kind(db).is_some() { - return false; + return None; } let mut has_dynamic_replacement = false; + let mut has_variant_replacement = false; for ((typevar, general_type), specific_type) in general_specialization .generic_context(db) .variables(db) @@ -136,15 +184,44 @@ fn is_invariant_dynamic_generalization_of<'db>( if general_type == specific_type { continue; } - if general_type.is_non_divergent_dynamic() - && typevar.variance(db) == TypeVarVariance::Invariant - { + if general_type.is_non_divergent_dynamic() { has_dynamic_replacement = true; + has_variant_replacement |= matches!( + specialization_variance(db, typevar), + TypeVarVariance::Covariant | TypeVarVariance::Contravariant + ); continue; } - return false; + return None; } - has_dynamic_replacement + has_dynamic_replacement.then_some(DynamicGeneralization { + class: general_class, + general: general_specialization, + specific: specific_specialization, + specific_type: specific, + has_variant_replacement, + }) +} + +fn dynamic_intersection<'db>( + db: &'db dyn Db, + left: Type<'db>, + right: Type<'db>, +) -> Option> { + dynamic_generalization_of(db, left, right) + .or_else(|| dynamic_generalization_of(db, right, left)) + .and_then(|generalization| generalization.intersection(db)) +} + +/// If `general` is a dynamic generalization of the fully-static `specific`, return the bottom +/// materialization that should replace `general` in `specific & ~general`. +fn negated_generalization_bottom<'db>( + db: &'db dyn Db, + general: Type<'db>, + specific: Type<'db>, +) -> Option> { + dynamic_generalization_of(db, general, specific)?; + (!specific.has_dynamic(db)).then(|| general.bottom_materialization(db)) } /// Try to merge a complementary guarded pair into an unguarded core. @@ -1630,25 +1707,22 @@ impl<'db> InnerIntersectionBuilder<'db> { } let mut to_remove = SmallVec::<[usize; 1]>::new(); + let mut dynamic_merge = None; for (index, existing_positive) in self.positive.iter().enumerate() { - // S & T = S if S <: T or T is an invariant-dynamic generalization of S. - if existing_positive.is_redundant_with(db, new_positive) - || is_invariant_dynamic_generalization_of( - db, - new_positive, - *existing_positive, - ) + if let Some(merged) = dynamic_intersection(db, new_positive, *existing_positive) { + if merged == *existing_positive { + return; + } + dynamic_merge = Some((index, merged)); + break; + } + // S & T = S if S <: T. + if existing_positive.is_redundant_with(db, new_positive) { return; } // same rule, reverse order - if new_positive.is_redundant_with(db, *existing_positive) - || is_invariant_dynamic_generalization_of( - db, - *existing_positive, - new_positive, - ) - { + if new_positive.is_redundant_with(db, *existing_positive) { to_remove.push(index); } // A & B = Never if A and B are disjoint @@ -1658,12 +1732,27 @@ impl<'db> InnerIntersectionBuilder<'db> { return; } } + if let Some((index, merged)) = dynamic_merge { + self.positive.swap_remove_index(index); + self.add_positive(db, merged); + return; + } for index in to_remove.into_iter().rev() { self.positive.swap_remove_index(index); } let mut to_remove = SmallVec::<[usize; 1]>::new(); + let mut replacement_negatives = SmallVec::<[Type<'db>; 1]>::new(); for (index, existing_negative) in self.negative.iter().enumerate() { + // S & ~G = S & ~Bottom[G] & Any if G is a dynamic generalization of the + // fully-static specialization S. This follows because S <: Top[G]. + if let Some(bottom) = + negated_generalization_bottom(db, *existing_negative, new_positive) + { + to_remove.push(index); + replacement_negatives.push(bottom); + continue; + } // S & ~T = Never if S <: T if new_positive.is_subtype_of(db, *existing_negative) { *self = Self::default(); @@ -1678,6 +1767,14 @@ impl<'db> InnerIntersectionBuilder<'db> { for index in to_remove.into_iter().rev() { self.negative.swap_remove_index(index); } + if !replacement_negatives.is_empty() { + for bottom in replacement_negatives { + self.add_negative(db, bottom); + } + self.add_positive(db, Type::any()); + self.add_positive(db, new_positive); + return; + } self.positive.insert(new_positive); } @@ -1759,6 +1856,14 @@ impl<'db> InnerIntersectionBuilder<'db> { self.add_negative(db, Type::string_literal(db, "")); } _ => { + if let Some(bottom) = self.positive.iter().find_map(|existing_positive| { + negated_generalization_bottom(db, new_negative, *existing_positive) + }) { + self.add_negative(db, bottom); + self.add_positive(db, Type::any()); + return; + } + let new_negative_enum = new_negative.as_enum_literal(); let mut to_remove = SmallVec::<[usize; 1]>::new(); for (index, existing_negative) in self.negative.iter().enumerate() { From 2b986837cacb458a739986a255975af153f751d6 Mon Sep 17 00:00:00 2001 From: David Peter Date: Thu, 25 Jun 2026 18:51:01 +0200 Subject: [PATCH 02/15] tuples --- .../mdtest/generics/set_theoretic.md | 67 ++++++++++++- .../src/types/set_theoretic/builder.rs | 98 ++++++++++++++----- 2 files changed, 136 insertions(+), 29 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md b/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md index fafe9258b3b43..d18413d523af7 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md @@ -2,6 +2,8 @@ This test suite explores the interplay between generics and set theoretic gradual types. +## General relationships + ```toml [environment] python-version = "3.14" @@ -324,11 +326,68 @@ We can verify all three relations in ty: ```pyi static_assert(is_equivalent_to(Co[P] & ~Co[Any], Co[P] & ~Bottom[Co[Any]] & Any)) -static_assert(is_equivalent_to(Contra[P] & ~Contra[Any], Contra[P] & ~Bottom[Contra[Any]] & Any)) -static_assert(is_equivalent_to(Invariant[P] & ~Invariant[Any], Invariant[P] & ~Bottom[Invariant[Any]] & Any)) - -# The simplification is independent of the order in which the intersection elements are added. static_assert(is_equivalent_to(~Co[Any] & Co[P], Co[P] & ~Bottom[Co[Any]] & Any)) + +static_assert(is_equivalent_to(Contra[P] & ~Contra[Any], Contra[P] & ~Bottom[Contra[Any]] & Any)) static_assert(is_equivalent_to(~Contra[Any] & Contra[P], Contra[P] & ~Bottom[Contra[Any]] & Any)) + +static_assert(is_equivalent_to(Invariant[P] & ~Invariant[Any], Invariant[P] & ~Bottom[Invariant[Any]] & Any)) static_assert(is_equivalent_to(~Invariant[Any] & Invariant[P], Invariant[P] & ~Bottom[Invariant[Any]] & Any)) ``` + +## Edge cases + +The simplification must preserve the identities of type variables and `NewType` instances: + +```toml +[environment] +python-version = "3.14" +``` + +```pyi +from typing import Any, NewType +from ty_extensions import is_equivalent_to, static_assert + +class P: ... +class Q: ... + +class Co[T]: + def get(self) -> T: + raise NotImplementedError + +CoId = NewType("CoId", Co[P]) + +def preserve_typevar[T: Co[P]](value: T & Co[Any]) -> T: + return value + +def preserve_newtype(value: CoId & Co[Any]) -> CoId: + return value +``` + +Tuple specializations carry information about their length and individual element types in addition +to the aggregate type argument. Tuple shapes therefore need to match for the generalization to +apply: + +```pyi +static_assert(is_equivalent_to(tuple[int, str] & ~tuple[Any], tuple[int, str])) +``` + +For matching fixed-length tuples, intersections are simplified element-wise: + +```pyi +static_assert(is_equivalent_to(tuple[P, Q] & tuple[Any, Q], tuple[P & Any, Q])) +static_assert(is_equivalent_to(tuple[Any, Q] & tuple[P, Q], tuple[P & Any, Q])) + +static_assert(is_equivalent_to(tuple[P, Q] & tuple[P, Any], tuple[P, Q & Any])) +static_assert(is_equivalent_to(tuple[P, Any] & tuple[P, Q], tuple[P, Q & Any])) +``` + +The same element-wise relationship applies when the gradual tuple is negated: + +```pyi +static_assert(is_equivalent_to(tuple[P, Q] & ~tuple[Any, Q], tuple[P, Q] & Any)) +static_assert(is_equivalent_to(~tuple[Any, Q] & tuple[P, Q], tuple[P, Q] & Any)) + +static_assert(is_equivalent_to(tuple[P, Q] & ~tuple[P, Any], tuple[P, Q] & Any)) +static_assert(is_equivalent_to(~tuple[P, Any] & tuple[P, Q], tuple[P, Q] & Any)) +``` 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 4153040f68493..6cb3b03312fff 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs @@ -102,17 +102,39 @@ struct DynamicGeneralization<'db> { impl<'db> DynamicGeneralization<'db> { fn intersection(self, db: &'db dyn Db) -> Option> { - let generic_context = self.general.generic_context(db); if !self.has_variant_replacement { return Some(self.specific_type); } - // Tuple specializations carry additional element information, and variance-based merges - // require the specific specialization to be fully static. - if self.class.known(db) == Some(KnownClass::Tuple) || self.specific_type.has_dynamic(db) { + // Rebuilding the generic specialization would lose type-variable correlation or NewType + // identity. + if matches!( + self.specific_type, + Type::TypeVar(_) | Type::NewTypeInstance(_) + ) { + return None; + } + + // Variance-based merges require the specific specialization to be fully static. + if self.specific_type.has_dynamic(db) { return None; } + if self.class.known(db) == Some(KnownClass::Tuple) { + let general = self.general.tuple(db)?.as_fixed_length()?; + let specific = self.specific.tuple(db)?.as_fixed_length()?; + return Some(Type::heterogeneous_tuple( + db, + specific + .iter_all_elements() + .zip(general.iter_all_elements()) + .map(|(specific, general)| { + IntersectionType::from_two_elements(db, specific, general) + }), + )); + } + + let generic_context = self.general.generic_context(db); let types: Vec<_> = generic_context .variables(db) .zip(self.general.types(db)) @@ -173,27 +195,53 @@ fn dynamic_generalization_of<'db>( return None; } - let mut has_dynamic_replacement = false; - let mut has_variant_replacement = false; - for ((typevar, general_type), specific_type) in general_specialization - .generic_context(db) - .variables(db) - .zip(general_specialization.types(db)) - .zip(specific_specialization.types(db)) - { - if general_type == specific_type { - continue; - } - if general_type.is_non_divergent_dynamic() { - has_dynamic_replacement = true; - has_variant_replacement |= matches!( - specialization_variance(db, typevar), - TypeVarVariance::Covariant | TypeVarVariance::Contravariant - ); - continue; - } - return None; - } + let (has_dynamic_replacement, has_variant_replacement) = + if general_class.known(db) == Some(KnownClass::Tuple) { + let general_tuple = general_specialization.tuple(db)?.as_fixed_length()?; + let specific_tuple = specific_specialization.tuple(db)?.as_fixed_length()?; + if general_tuple.len() != specific_tuple.len() { + return None; + } + + let mut has_dynamic_replacement = false; + for (general_type, specific_type) in general_tuple + .iter_all_elements() + .zip(specific_tuple.iter_all_elements()) + { + if general_type == specific_type { + continue; + } + if general_type.is_non_divergent_dynamic() { + has_dynamic_replacement = true; + continue; + } + return None; + } + (has_dynamic_replacement, has_dynamic_replacement) + } else { + let mut has_dynamic_replacement = false; + let mut has_variant_replacement = false; + for ((typevar, general_type), specific_type) in general_specialization + .generic_context(db) + .variables(db) + .zip(general_specialization.types(db)) + .zip(specific_specialization.types(db)) + { + if general_type == specific_type { + continue; + } + if general_type.is_non_divergent_dynamic() { + has_dynamic_replacement = true; + has_variant_replacement |= matches!( + specialization_variance(db, typevar), + TypeVarVariance::Covariant | TypeVarVariance::Contravariant + ); + continue; + } + return None; + } + (has_dynamic_replacement, has_variant_replacement) + }; has_dynamic_replacement.then_some(DynamicGeneralization { class: general_class, general: general_specialization, From b501acf2dea0182528bb94ee7f4b20ea91835264 Mon Sep 17 00:00:00 2001 From: David Peter Date: Fri, 26 Jun 2026 11:02:20 +0200 Subject: [PATCH 03/15] Improve tests --- .../mdtest/generics/set_theoretic.md | 89 ++++++++++++------- 1 file changed, 57 insertions(+), 32 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md b/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md index d18413d523af7..d025ff8200c5d 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md @@ -1,14 +1,14 @@ # Generics and set theoretic types -This test suite explores the interplay between generics and set theoretic gradual types. - -## General relationships - ```toml [environment] python-version = "3.14" ``` +This test suite explores the interplay between generics and set theoretic gradual types. + +## General relationships + ```pyi from typing import Any from ty_extensions import Bottom, static_assert, is_equivalent_to, is_subtype_of @@ -337,57 +337,82 @@ static_assert(is_equivalent_to(~Invariant[Any] & Invariant[P], Invariant[P] & ~B ## Edge cases -The simplification must preserve the identities of type variables and `NewType` instances: +### Multi-parameter and mixed-variance generics -```toml -[environment] -python-version = "3.14" -``` +The results above naturally extend to multi-parameter generics and mixed variances: ```pyi -from typing import Any, NewType -from ty_extensions import is_equivalent_to, static_assert +from typing import Any, Generic, TypeVar +from ty_extensions import Bottom, static_assert, is_equivalent_to, is_subtype_of class P: ... class Q: ... +class R: ... -class Co[T]: - def get(self) -> T: - raise NotImplementedError +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) +T_invariant = TypeVar("T_invariant") -CoId = NewType("CoId", Co[P]) - -def preserve_typevar[T: Co[P]](value: T & Co[Any]) -> T: - return value +class Mixed(Generic[T_co, T_contra, T_invariant]): ... -def preserve_newtype(value: CoId & Co[Any]) -> CoId: - return value +static_assert(is_equivalent_to(Mixed[P, Q, R] & Mixed[Any, Any, Any], Mixed[P & Any, Q | Any, R])) ``` -Tuple specializations carry information about their length and individual element types in addition -to the aggregate type argument. Tuple shapes therefore need to match for the generalization to -apply: +### Tuples + +Tuple types are covariant in every type parameter, so the results derived for `Co[T]` above apply to +`tuple` at every position: ```pyi -static_assert(is_equivalent_to(tuple[int, str] & ~tuple[Any], tuple[int, str])) -``` +from typing import Any, Never +from ty_extensions import static_assert, is_equivalent_to, is_subtype_of -For matching fixed-length tuples, intersections are simplified element-wise: +class P: ... +class Q: ... + +static_assert(is_equivalent_to(tuple[P] & tuple[Any], tuple[P & Any])) +static_assert(is_equivalent_to(tuple[Any] & tuple[P], tuple[P & Any])) + +static_assert(is_equivalent_to(tuple[P] & ~tuple[Any], tuple[P] & ~tuple[Never] & Any)) +static_assert(is_equivalent_to(~tuple[Any] & tuple[P], tuple[P] & ~tuple[Never] & Any)) -```pyi static_assert(is_equivalent_to(tuple[P, Q] & tuple[Any, Q], tuple[P & Any, Q])) static_assert(is_equivalent_to(tuple[Any, Q] & tuple[P, Q], tuple[P & Any, Q])) static_assert(is_equivalent_to(tuple[P, Q] & tuple[P, Any], tuple[P, Q & Any])) static_assert(is_equivalent_to(tuple[P, Any] & tuple[P, Q], tuple[P, Q & Any])) + +static_assert(is_equivalent_to(tuple[P, Q] & tuple[Any, Any], tuple[P & Any, Q & Any])) +static_assert(is_equivalent_to(tuple[Any, Any] & tuple[P, Q], tuple[P & Any, Q & Any])) +``` + +Intersections with tuples of different length are not affected: + +```pyi +static_assert(is_equivalent_to(tuple[int, str] & ~tuple[Any], tuple[int, str])) +static_assert(is_equivalent_to(tuple[int, str] & ~tuple[int, str, Any], tuple[int, str])) ``` -The same element-wise relationship applies when the gradual tuple is negated: +### Type var bounds and `NewTypes` + +The simplification preserve the identities of type variables and `NewType` instances: ```pyi -static_assert(is_equivalent_to(tuple[P, Q] & ~tuple[Any, Q], tuple[P, Q] & Any)) -static_assert(is_equivalent_to(~tuple[Any, Q] & tuple[P, Q], tuple[P, Q] & Any)) +from typing import Any, NewType +from ty_extensions import is_equivalent_to, static_assert -static_assert(is_equivalent_to(tuple[P, Q] & ~tuple[P, Any], tuple[P, Q] & Any)) -static_assert(is_equivalent_to(~tuple[P, Any] & tuple[P, Q], tuple[P, Q] & Any)) +class P: ... +class Q: ... + +class Co[T]: + def get(self) -> T: + raise NotImplementedError + +CoId = NewType("CoId", Co[P]) + +def preserve_typevar[T: Co[P]](value: T & Co[Any]) -> T: + return value + +def preserve_newtype(value: CoId & Co[Any]) -> CoId: + return value ``` From 509b64afeb9722a9ce0e46e6480d44de5e366097 Mon Sep 17 00:00:00 2001 From: David Peter Date: Fri, 26 Jun 2026 11:14:38 +0200 Subject: [PATCH 04/15] More tests --- .../mdtest/generics/set_theoretic.md | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md b/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md index d025ff8200c5d..a492fdf1fb9b1 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md @@ -1,13 +1,15 @@ # Generics and set theoretic types +This test suite explores the interplay between generics and set theoretic gradual types. + ```toml [environment] python-version = "3.14" ``` -This test suite explores the interplay between generics and set theoretic gradual types. +## Derivations and general results -## General relationships +This section concentrates on deriving the main results while the next section covers some more edge cases. ```pyi from typing import Any @@ -295,7 +297,7 @@ static_assert(is_equivalent_to(Invariant[P] & Invariant[Any], Invariant[P])) static_assert(not is_equivalent_to(Invariant[P] | Invariant[Any], Invariant[P])) ``` -Finally, we consider intersections with the negation of an `Any`-specialized generic class. For any +We can also consider intersections with the negation of an `Any`-specialized generic class. For all of the generic classes `C` above, we can negate the canonical interval representation of `C[Any]`: ```ignore @@ -343,7 +345,7 @@ The results above naturally extend to multi-parameter generics and mixed varianc ```pyi from typing import Any, Generic, TypeVar -from ty_extensions import Bottom, static_assert, is_equivalent_to, is_subtype_of +from ty_extensions import Bottom, static_assert, is_equivalent_to class P: ... class Q: ... @@ -365,7 +367,7 @@ Tuple types are covariant in every type parameter, so the results derived for `C ```pyi from typing import Any, Never -from ty_extensions import static_assert, is_equivalent_to, is_subtype_of +from ty_extensions import static_assert, is_equivalent_to class P: ... class Q: ... @@ -393,6 +395,22 @@ static_assert(is_equivalent_to(tuple[int, str] & ~tuple[Any], tuple[int, str])) static_assert(is_equivalent_to(tuple[int, str] & ~tuple[int, str, Any], tuple[int, str])) ``` +### `type[...]` + +`type[..]` can also be a covariant type constructor, so these results also hold here: + +```pyi +from typing import Any +from ty_extensions import static_assert, is_equivalent_to + +class P: ... +class Q: ... + +# TODO: these should pass +static_assert(is_equivalent_to(type[P] & type[Any], type[P & Any])) # error: [static-assert-error] +static_assert(is_equivalent_to(type[Any] & type[P], type[P & Any])) # error: [static-assert-error] +``` + ### Type var bounds and `NewTypes` The simplification preserve the identities of type variables and `NewType` instances: From b50cb71a9084dc2bfe616e32748a24f30b076f09 Mon Sep 17 00:00:00 2001 From: David Peter Date: Fri, 26 Jun 2026 11:37:06 +0200 Subject: [PATCH 05/15] type[..] --- .../resources/mdtest/generics/set_theoretic.md | 15 +++++++++------ .../resources/mdtest/named_tuple.md | 2 +- .../src/types/infer/builder/type_expression.rs | 4 +++- .../src/types/set_theoretic/builder.rs | 7 +++++++ .../src/types/subclass_of.rs | 18 ++++++++++++++---- 5 files changed, 34 insertions(+), 12 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md b/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md index a492fdf1fb9b1..5774d3bedd3c7 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md @@ -9,7 +9,8 @@ python-version = "3.14" ## Derivations and general results -This section concentrates on deriving the main results while the next section covers some more edge cases. +This section concentrates on deriving the main results while the next section covers some more edge +cases. ```pyi from typing import Any @@ -397,18 +398,20 @@ static_assert(is_equivalent_to(tuple[int, str] & ~tuple[int, str, Any], tuple[in ### `type[...]` -`type[..]` can also be a covariant type constructor, so these results also hold here: +`type[..]` can also be seen as a covariant type constructor, so the results also hold here: ```pyi -from typing import Any +from typing import Any, Never from ty_extensions import static_assert, is_equivalent_to class P: ... class Q: ... -# TODO: these should pass -static_assert(is_equivalent_to(type[P] & type[Any], type[P & Any])) # error: [static-assert-error] -static_assert(is_equivalent_to(type[Any] & type[P], type[P & Any])) # error: [static-assert-error] +static_assert(is_equivalent_to(type[P] & type[Any], type[P & Any])) +static_assert(is_equivalent_to(type[Any] & type[P], type[P & Any])) + +static_assert(is_equivalent_to(type[P] & ~type[Any], type[P] & ~type[Never] & Any)) +static_assert(is_equivalent_to(~type[Any] & type[P], type[P] & ~type[Never] & Any)) ``` ### Type var bounds and `NewTypes` diff --git a/crates/ty_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md index d110a0b75b344..5cc612f39aa64 100644 --- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md @@ -1455,7 +1455,7 @@ def expects_named_tuple(x: typing.NamedTuple): reveal_type(x.__iter__) def _(y: type[typing.NamedTuple]): - reveal_type(y) # revealed: @Todo(unsupported type[X] special form) + reveal_type(y) # revealed: type[tuple[object, ...]] & type[NamedTupleLike] # error: [invalid-type-form] "Special form `typing.NamedTuple` expected no type parameter" def _(z: typing.NamedTuple[int]): ... diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs index acf1007d650c7..48ae4ce955a7b 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs @@ -1244,7 +1244,9 @@ impl<'db> TypeInferenceBuilder<'db, '_> { ast::Expr::Name(_) | ast::Expr::Attribute(_) | ast::Expr::StringLiteral(_) => { infer_type_argument(self, slice) } - ast::Expr::BinOp(binary) if binary.op == ast::Operator::BitOr => { + ast::Expr::BinOp(binary) + if matches!(binary.op, ast::Operator::BitOr | ast::Operator::BitAnd) => + { infer_type_argument(self, slice) } ast::Expr::Tuple(_) => { 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 6cb3b03312fff..d0166fba28416 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs @@ -268,6 +268,13 @@ fn negated_generalization_bottom<'db>( general: Type<'db>, specific: Type<'db>, ) -> Option> { + if let (Type::SubclassOf(general_subclass), Type::SubclassOf(_)) = (general, specific) + && general_subclass.is_dynamic() + && !specific.has_dynamic(db) + { + return Some(general.bottom_materialization(db)); + } + dynamic_generalization_of(db, general, specific)?; (!specific.has_dynamic(db)).then(|| general.bottom_materialization(db)) } diff --git a/crates/ty_python_semantic/src/types/subclass_of.rs b/crates/ty_python_semantic/src/types/subclass_of.rs index 2ba2dcaef3b28..17805689c356b 100644 --- a/crates/ty_python_semantic/src/types/subclass_of.rs +++ b/crates/ty_python_semantic/src/types/subclass_of.rs @@ -6,9 +6,9 @@ use crate::types::relation::{DisjointnessChecker, TypeRelationChecker}; use crate::types::variance::VarianceInferable; use crate::types::{ ApplyTypeMappingVisitor, BoundTypeVarInstance, ClassLiteral, ClassType, DynamicType, - FindLegacyTypeVarsVisitor, KnownClass, MaterializationKind, MemberLookupPolicy, - SpecialFormType, Type, TypeContext, TypeMapping, TypeVarBoundOrConstraints, TypeVarVariance, - TypedDictType, UnionType, todo_type, + FindLegacyTypeVarsVisitor, IntersectionType, KnownClass, MaterializationKind, + MemberLookupPolicy, SpecialFormType, Type, TypeContext, TypeMapping, TypeVarBoundOrConstraints, + TypeVarVariance, TypedDictType, UnionType, todo_type, }; use crate::{Db, FxOrderSet}; use ty_python_core::definition::Definition; @@ -78,8 +78,9 @@ impl<'db> SubclassOfType<'db> { /// Given an instance of the class or type variable `T`, returns a [`Type`] instance representing `type[T]`. pub(crate) fn try_from_instance(db: &'db dyn Db, ty: Type<'db>) -> Option> { - // Handle unions by distributing `type[]` over each element: + // Handle unions and intersections by distributing `type[]` over each element: // `type[A | B]` -> `type[A] | type[B]` + // `type[A & B]` -> `type[A] & type[B]` match ty { Type::Union(union) => UnionType::try_from_elements( db, @@ -88,6 +89,15 @@ impl<'db> SubclassOfType<'db> { .iter() .map(|element| Self::try_from_instance(db, *element)), ), + Type::Intersection(intersection) if intersection.iter_negative(db).next().is_none() => { + Some(IntersectionType::from_elements( + db, + intersection + .iter_positive(db) + .map(|element| Self::try_from_instance(db, element)) + .collect::>>()?, + )) + } Type::ProtocolInstance(protocol) => Some(protocol.to_meta_type(db)), _ => SubclassOfInner::try_from_instance(db, ty) .map(|subclass_of| Self::from(db, subclass_of)), From b5117022bb9f59cf4892c7795b593c98d490c3a7 Mon Sep 17 00:00:00 2001 From: David Peter Date: Mon, 29 Jun 2026 15:12:01 +0200 Subject: [PATCH 06/15] [ty] Refine gradual generic intersection handling --- .../src/types/set_theoretic.rs | 1 + .../src/types/set_theoretic/builder.rs | 197 +--------------- .../generic_gradual_intersections.rs | 219 ++++++++++++++++++ .../src/types/subclass_of.rs | 15 +- 4 files changed, 233 insertions(+), 199 deletions(-) create mode 100644 crates/ty_python_semantic/src/types/set_theoretic/generic_gradual_intersections.rs diff --git a/crates/ty_python_semantic/src/types/set_theoretic.rs b/crates/ty_python_semantic/src/types/set_theoretic.rs index 10bcd594a0f9a..701c859263f63 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic.rs @@ -12,6 +12,7 @@ use crate::types::{TypeVarBoundOrConstraints, visitor}; use crate::{Db, FxOrderSet}; pub(crate) mod builder; +mod generic_gradual_intersections; pub(crate) use builder::{IntersectionBuilder, UnionBuilder}; 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 d0166fba28416..3e553ef669775 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs @@ -37,14 +37,15 @@ //! (unless exactly the same literal type), we can avoid many unnecessary redundancy checks. use super::RecursivelyDefined; +use super::generic_gradual_intersections::{ + generic_gradual_intersection, negated_generalization_bottom, +}; use crate::types::enums::EnumComplement; -use crate::types::generics::{Specialization, specialization_variance}; use crate::types::set_theoretic::expand_intersection_typevars_and_newtypes; use crate::types::{ BytesLiteralType, ClassLiteral, EnumLiteralType, IntersectionType, KnownClass, KnownInstanceType, LiteralValueType, LiteralValueTypeKind, NegativeIntersectionElements, - StaticClassLiteral, StringLiteralType, SubclassOfType, Type, TypeVarBoundOrConstraints, - TypeVarVariance, UnionType, + StringLiteralType, SubclassOfType, Type, TypeVarBoundOrConstraints, UnionType, }; use crate::{Db, FxOrderMap, FxOrderSet}; use rustc_hash::FxHashSet; @@ -92,193 +93,6 @@ fn split_truthiness_guarded_intersection<'db>( Some((core.build(), guard)) } -struct DynamicGeneralization<'db> { - class: StaticClassLiteral<'db>, - general: Specialization<'db>, - specific: Specialization<'db>, - specific_type: Type<'db>, - has_variant_replacement: bool, -} - -impl<'db> DynamicGeneralization<'db> { - fn intersection(self, db: &'db dyn Db) -> Option> { - if !self.has_variant_replacement { - return Some(self.specific_type); - } - - // Rebuilding the generic specialization would lose type-variable correlation or NewType - // identity. - if matches!( - self.specific_type, - Type::TypeVar(_) | Type::NewTypeInstance(_) - ) { - return None; - } - - // Variance-based merges require the specific specialization to be fully static. - if self.specific_type.has_dynamic(db) { - return None; - } - - if self.class.known(db) == Some(KnownClass::Tuple) { - let general = self.general.tuple(db)?.as_fixed_length()?; - let specific = self.specific.tuple(db)?.as_fixed_length()?; - return Some(Type::heterogeneous_tuple( - db, - specific - .iter_all_elements() - .zip(general.iter_all_elements()) - .map(|(specific, general)| { - IntersectionType::from_two_elements(db, specific, general) - }), - )); - } - - let generic_context = self.general.generic_context(db); - let types: Vec<_> = generic_context - .variables(db) - .zip(self.general.types(db)) - .zip(self.specific.types(db)) - .map(|((typevar, general), specific)| { - if general == specific { - return *specific; - } - match specialization_variance(db, typevar) { - TypeVarVariance::Covariant => { - IntersectionType::from_two_elements(db, *specific, *general) - } - TypeVarVariance::Contravariant => { - UnionType::from_two_elements(db, *specific, *general) - } - TypeVarVariance::Invariant | TypeVarVariance::Bivariant => *specific, - } - }) - .collect(); - let specialization = generic_context.specialize(db, types); - - Some(Type::instance( - db, - self.class - .apply_optional_specialization(db, Some(specialization)), - )) - } -} - -/// Return the relationship between two specializations of the same generic class if `general` -/// only differs from `specific` by using dynamic types. -fn dynamic_generalization_of<'db>( - db: &'db dyn Db, - general: Type<'db>, - specific: Type<'db>, -) -> Option> { - // Fast path to avoid performance regressions. - if !general.has_dynamic(db) || matches!(general, Type::TypeVar(_) | Type::NewTypeInstance(_)) { - return None; - } - - let ( - Some((general_class, general_specialization)), - Some((specific_class, specific_specialization)), - ) = ( - general.class_specialization(db), - specific.class_specialization(db), - ) - else { - return None; - }; - - // Top and bottom materializations are not gradual types. - if general_class != specific_class - || general_specialization.materialization_kind(db).is_some() - || specific_specialization.materialization_kind(db).is_some() - { - return None; - } - - let (has_dynamic_replacement, has_variant_replacement) = - if general_class.known(db) == Some(KnownClass::Tuple) { - let general_tuple = general_specialization.tuple(db)?.as_fixed_length()?; - let specific_tuple = specific_specialization.tuple(db)?.as_fixed_length()?; - if general_tuple.len() != specific_tuple.len() { - return None; - } - - let mut has_dynamic_replacement = false; - for (general_type, specific_type) in general_tuple - .iter_all_elements() - .zip(specific_tuple.iter_all_elements()) - { - if general_type == specific_type { - continue; - } - if general_type.is_non_divergent_dynamic() { - has_dynamic_replacement = true; - continue; - } - return None; - } - (has_dynamic_replacement, has_dynamic_replacement) - } else { - let mut has_dynamic_replacement = false; - let mut has_variant_replacement = false; - for ((typevar, general_type), specific_type) in general_specialization - .generic_context(db) - .variables(db) - .zip(general_specialization.types(db)) - .zip(specific_specialization.types(db)) - { - if general_type == specific_type { - continue; - } - if general_type.is_non_divergent_dynamic() { - has_dynamic_replacement = true; - has_variant_replacement |= matches!( - specialization_variance(db, typevar), - TypeVarVariance::Covariant | TypeVarVariance::Contravariant - ); - continue; - } - return None; - } - (has_dynamic_replacement, has_variant_replacement) - }; - has_dynamic_replacement.then_some(DynamicGeneralization { - class: general_class, - general: general_specialization, - specific: specific_specialization, - specific_type: specific, - has_variant_replacement, - }) -} - -fn dynamic_intersection<'db>( - db: &'db dyn Db, - left: Type<'db>, - right: Type<'db>, -) -> Option> { - dynamic_generalization_of(db, left, right) - .or_else(|| dynamic_generalization_of(db, right, left)) - .and_then(|generalization| generalization.intersection(db)) -} - -/// If `general` is a dynamic generalization of the fully-static `specific`, return the bottom -/// materialization that should replace `general` in `specific & ~general`. -fn negated_generalization_bottom<'db>( - db: &'db dyn Db, - general: Type<'db>, - specific: Type<'db>, -) -> Option> { - if let (Type::SubclassOf(general_subclass), Type::SubclassOf(_)) = (general, specific) - && general_subclass.is_dynamic() - && !specific.has_dynamic(db) - { - return Some(general.bottom_materialization(db)); - } - - dynamic_generalization_of(db, general, specific)?; - (!specific.has_dynamic(db)).then(|| general.bottom_materialization(db)) -} - /// Try to merge a complementary guarded pair into an unguarded core. /// /// e.g. @@ -1764,7 +1578,8 @@ impl<'db> InnerIntersectionBuilder<'db> { let mut to_remove = SmallVec::<[usize; 1]>::new(); let mut dynamic_merge = None; for (index, existing_positive) in self.positive.iter().enumerate() { - if let Some(merged) = dynamic_intersection(db, new_positive, *existing_positive) + if let Some(merged) = + generic_gradual_intersection(db, new_positive, *existing_positive) { if merged == *existing_positive { return; diff --git a/crates/ty_python_semantic/src/types/set_theoretic/generic_gradual_intersections.rs b/crates/ty_python_semantic/src/types/set_theoretic/generic_gradual_intersections.rs new file mode 100644 index 0000000000000..e63bedad0714c --- /dev/null +++ b/crates/ty_python_semantic/src/types/set_theoretic/generic_gradual_intersections.rs @@ -0,0 +1,219 @@ +use crate::Db; +use crate::types::generics::{Specialization, specialization_variance}; +use crate::types::tuple::FixedLengthTuple; +use crate::types::{ + IntersectionType, KnownClass, StaticClassLiteral, Type, TypeVarVariance, UnionType, +}; + +/// Simplify the intersection of two generic specializations when one is a gradual +/// generalization of the other. +/// +/// For example, `list[int] & list[Any]` simplifies to `list[int]`, while +/// `Sequence[int] & Sequence[Any]` simplifies to `Sequence[int & Any]`. +pub(super) fn generic_gradual_intersection<'db>( + db: &'db dyn Db, + left: Type<'db>, + right: Type<'db>, +) -> Option> { + dynamic_generalization_of(db, left, right) + .or_else(|| dynamic_generalization_of(db, right, left)) + .map(|generalization| generalization.intersection(db)) +} + +/// If `general` is a dynamic generalization of the fully-static `specific`, return the bottom +/// materialization that should replace `general` in `specific & ~general`. +/// +/// For example, `Co[P] & ~Co[Any] = Co[P] & ~Bottom[Co[Any]] & Any`; this function returns +/// `Bottom[Co[Any]]`. Similarly, `type[P] & ~type[Any] = type[P] & ~type[Never] & Any`; this +/// function returns `type[Never]`. +pub(super) fn negated_generalization_bottom<'db>( + db: &'db dyn Db, + general: Type<'db>, + specific: Type<'db>, +) -> Option> { + if let (Type::SubclassOf(general_subclass), Type::SubclassOf(_)) = (general, specific) + && general_subclass.is_dynamic() + && !specific.has_dynamic(db) + { + return Some(general.bottom_materialization(db)); + } + + dynamic_generalization_of(db, general, specific)?; + if specific.has_dynamic(db) { + None + } else { + Some(general.bottom_materialization(db)) + } +} + +/// Describes how intersecting a dynamic generic specialization such as `list[Any]` with a more +/// specific specialization such as `list[int]` should be simplified. +enum DynamicGeneralization<'db> { + /// All dynamic replacements occur in invariant or bivariant positions, so the intersection + /// simplifies to the original specific type. + /// + /// For example, `list[int] & list[Any] = list[int]`. + UseSpecific(Type<'db>), + /// At least one dynamic replacement occurs in a covariant or contravariant position, so the + /// intersection requires rebuilding the specialization according to each parameter's variance. + /// + /// For example, `Sequence[int] & Sequence[Any] = Sequence[int & Any]`. + RebuildSpecialization { + class: StaticClassLiteral<'db>, + general: Specialization<'db>, + specific: Specialization<'db>, + }, + /// Tuple specializations require rebuilding each element independently. + RebuildTuple { + general: &'db FixedLengthTuple>, + specific: &'db FixedLengthTuple>, + }, +} + +impl<'db> DynamicGeneralization<'db> { + /// Simplify the intersection represented by this relationship. + /// + /// Returns the original specific type when no reconstruction is needed, or rebuilds the + /// specialization according to its parameter variances. + fn intersection(self, db: &'db dyn Db) -> Type<'db> { + match self { + Self::UseSpecific(specific) => specific, + Self::RebuildTuple { general, specific } => Type::heterogeneous_tuple( + db, + specific + .iter_all_elements() + .zip(general.iter_all_elements()) + .map(|(specific, general)| { + IntersectionType::from_two_elements(db, specific, general) + }), + ), + Self::RebuildSpecialization { + class, + general, + specific, + } => { + let generic_context = general.generic_context(db); + let types: Vec<_> = generic_context + .variables(db) + .zip(general.types(db)) + .zip(specific.types(db)) + .map(|((typevar, general), specific)| { + if general == specific { + return *specific; + } + match specialization_variance(db, typevar) { + TypeVarVariance::Covariant => { + IntersectionType::from_two_elements(db, *specific, *general) + } + TypeVarVariance::Contravariant => { + UnionType::from_two_elements(db, *specific, *general) + } + TypeVarVariance::Invariant | TypeVarVariance::Bivariant => *specific, + } + }) + .collect(); + let specialization = generic_context.specialize(db, types); + + Type::instance( + db, + class.apply_optional_specialization(db, Some(specialization)), + ) + } + } + } +} + +/// Return the relationship between two specializations of the same generic class if `general` +/// only differs from `specific` by using dynamic types. +/// +/// For example, `list[Any]` dynamically generalizes `list[int]`, while `list[str]` does not. +fn dynamic_generalization_of<'db>( + db: &'db dyn Db, + general: Type<'db>, + specific: Type<'db>, +) -> Option> { + // Fast path to avoid performance regressions. + if !general.has_dynamic(db) + || matches!(general, Type::TypeVar(_) | Type::NewTypeInstance(_)) + || matches!(specific, Type::TypeVar(_) | Type::NewTypeInstance(_)) + { + return None; + } + + let ( + Some((general_class, general_specialization)), + Some((specific_class, specific_specialization)), + ) = ( + general.class_specialization(db), + specific.class_specialization(db), + ) + else { + return None; + }; + + // Top and bottom materializations are not gradual types. + if general_class != specific_class + || general_specialization == specific_specialization + || general_specialization.materialization_kind(db).is_some() + || specific_specialization.materialization_kind(db).is_some() + { + return None; + } + + if general_class.known(db) == Some(KnownClass::Tuple) { + let general_tuple = general_specialization.tuple(db)?.as_fixed_length()?; + let specific_tuple = specific_specialization.tuple(db)?.as_fixed_length()?; + if general_tuple.len() != specific_tuple.len() + || general_tuple + .iter_all_elements() + .zip(specific_tuple.iter_all_elements()) + .any(|(general, specific)| { + general != specific && !general.is_non_divergent_dynamic() + }) + { + return None; + } + if specific.has_dynamic(db) { + return None; + } + + return Some(DynamicGeneralization::RebuildTuple { + general: general_tuple, + specific: specific_tuple, + }); + } + + let generic_context = general_specialization.generic_context(db); + if generic_context + .variables(db) + .zip(general_specialization.types(db)) + .zip(specific_specialization.types(db)) + .any(|((_, general), specific)| general != specific && !general.is_non_divergent_dynamic()) + { + return None; + } + + if generic_context + .variables(db) + .zip(general_specialization.types(db)) + .zip(specific_specialization.types(db)) + .any(|((typevar, general), specific)| { + general != specific + && matches!( + specialization_variance(db, typevar), + TypeVarVariance::Covariant | TypeVarVariance::Contravariant + ) + }) + { + if specific.has_dynamic(db) { + return None; + } + Some(DynamicGeneralization::RebuildSpecialization { + class: general_class, + general: general_specialization, + specific: specific_specialization, + }) + } else { + Some(DynamicGeneralization::UseSpecific(specific)) + } +} diff --git a/crates/ty_python_semantic/src/types/subclass_of.rs b/crates/ty_python_semantic/src/types/subclass_of.rs index 17805689c356b..eaec0aa90ef21 100644 --- a/crates/ty_python_semantic/src/types/subclass_of.rs +++ b/crates/ty_python_semantic/src/types/subclass_of.rs @@ -6,7 +6,7 @@ use crate::types::relation::{DisjointnessChecker, TypeRelationChecker}; use crate::types::variance::VarianceInferable; use crate::types::{ ApplyTypeMappingVisitor, BoundTypeVarInstance, ClassLiteral, ClassType, DynamicType, - FindLegacyTypeVarsVisitor, IntersectionType, KnownClass, MaterializationKind, + FindLegacyTypeVarsVisitor, IntersectionBuilder, KnownClass, MaterializationKind, MemberLookupPolicy, SpecialFormType, Type, TypeContext, TypeMapping, TypeVarBoundOrConstraints, TypeVarVariance, TypedDictType, UnionType, todo_type, }; @@ -90,13 +90,12 @@ impl<'db> SubclassOfType<'db> { .map(|element| Self::try_from_instance(db, *element)), ), Type::Intersection(intersection) if intersection.iter_negative(db).next().is_none() => { - Some(IntersectionType::from_elements( - db, - intersection - .iter_positive(db) - .map(|element| Self::try_from_instance(db, element)) - .collect::>>()?, - )) + intersection + .iter_positive(db) + .try_fold(IntersectionBuilder::new(db), |builder, element| { + Some(builder.add_positive(Self::try_from_instance(db, element)?)) + }) + .map(IntersectionBuilder::build) } Type::ProtocolInstance(protocol) => Some(protocol.to_meta_type(db)), _ => SubclassOfInner::try_from_instance(db, ty) From 2744a107624b9c6fdec4bce29e789b41b54f89ab Mon Sep 17 00:00:00 2001 From: David Peter Date: Mon, 29 Jun 2026 15:33:52 +0200 Subject: [PATCH 07/15] [ty] Clarify gradual intersection replacements --- .../src/types/set_theoretic/builder.rs | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) 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 3e553ef669775..d98801678cbab 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs @@ -1576,7 +1576,11 @@ impl<'db> InnerIntersectionBuilder<'db> { } let mut to_remove = SmallVec::<[usize; 1]>::new(); - let mut dynamic_merge = None; + struct Replacement<'db> { + index: usize, + value: Type<'db>, + } + let mut replacement = None; for (index, existing_positive) in self.positive.iter().enumerate() { if let Some(merged) = generic_gradual_intersection(db, new_positive, *existing_positive) @@ -1584,7 +1588,10 @@ impl<'db> InnerIntersectionBuilder<'db> { if merged == *existing_positive { return; } - dynamic_merge = Some((index, merged)); + replacement = Some(Replacement { + index, + value: merged, + }); break; } // S & T = S if S <: T. @@ -1602,9 +1609,9 @@ impl<'db> InnerIntersectionBuilder<'db> { return; } } - if let Some((index, merged)) = dynamic_merge { + if let Some(Replacement { index, value }) = replacement { self.positive.swap_remove_index(index); - self.add_positive(db, merged); + self.add_positive(db, value); return; } for index in to_remove.into_iter().rev() { @@ -1615,7 +1622,7 @@ impl<'db> InnerIntersectionBuilder<'db> { let mut replacement_negatives = SmallVec::<[Type<'db>; 1]>::new(); for (index, existing_negative) in self.negative.iter().enumerate() { // S & ~G = S & ~Bottom[G] & Any if G is a dynamic generalization of the - // fully-static specialization S. This follows because S <: Top[G]. + // fully-static specialization S. if let Some(bottom) = negated_generalization_bottom(db, *existing_negative, new_positive) { @@ -1637,6 +1644,9 @@ impl<'db> InnerIntersectionBuilder<'db> { for index in to_remove.into_iter().rev() { self.negative.swap_remove_index(index); } + // For example, if we add `Co[P]` to an intersection containing `~Co[Any]`, + // replace that negative with `~Bottom[Co[Any]]` and add `Any`, producing + // `Co[P] & ~Bottom[Co[Any]] & Any`. See `generics/set_theoretic.md` for details. if !replacement_negatives.is_empty() { for bottom in replacement_negatives { self.add_negative(db, bottom); @@ -1726,6 +1736,9 @@ impl<'db> InnerIntersectionBuilder<'db> { self.add_negative(db, Type::string_literal(db, "")); } _ => { + // For example, if we add `~Co[Any]` to an intersection containing `Co[P]`, + // replace that negative with `~Bottom[Co[Any]]` and add `Any`, producing + // `Co[P] & ~Bottom[Co[Any]] & Any`. See `generics/set_theoretic.md` for details. if let Some(bottom) = self.positive.iter().find_map(|existing_positive| { negated_generalization_bottom(db, new_negative, *existing_positive) }) { From a9ca717c7267137fe82839db6249461d1737efaa Mon Sep 17 00:00:00 2001 From: David Peter Date: Mon, 29 Jun 2026 15:39:27 +0200 Subject: [PATCH 08/15] [ty] Fix Clippy warning in intersection builder --- .../src/types/set_theoretic/builder.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 d98801678cbab..21655f470442e 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs @@ -1336,6 +1336,11 @@ impl<'db> IntersectionBuilder<'db> { } } +struct Replacement<'db> { + index: usize, + value: Type<'db>, +} + #[derive(Debug, Clone, Default)] struct InnerIntersectionBuilder<'db> { positive: FxOrderSet>, @@ -1576,10 +1581,6 @@ impl<'db> InnerIntersectionBuilder<'db> { } let mut to_remove = SmallVec::<[usize; 1]>::new(); - struct Replacement<'db> { - index: usize, - value: Type<'db>, - } let mut replacement = None; for (index, existing_positive) in self.positive.iter().enumerate() { if let Some(merged) = From 0399f6f8afddb7d8e1ce826c856a2c6a20a2a640 Mon Sep 17 00:00:00 2001 From: David Peter Date: Thu, 25 Jun 2026 12:41:45 +0200 Subject: [PATCH 09/15] [ty] A configuration setting for `isinstance` narrowing --- crates/ty/docs/configuration.md | 37 ++++ crates/ty/tests/cli/config_option.rs | 2 +- crates/ty_project/src/db.rs | 12 +- crates/ty_project/src/metadata/options.rs | 68 +++++- crates/ty_project/src/metadata/settings.rs | 7 +- .../resources/mdtest/narrow/isinstance.md | 209 ++++++++++++++++++ crates/ty_python_semantic/src/db.rs | 10 +- crates/ty_python_semantic/src/lib.rs | 18 ++ crates/ty_python_semantic/src/types/narrow.rs | 69 ++++-- crates/ty_python_semantic/tests/corpus.rs | 10 +- crates/ty_test/src/config.rs | 10 +- crates/ty_test/src/db.rs | 24 +- crates/ty_test/src/lib.rs | 1 + ty.schema.json | 35 +++ 14 files changed, 480 insertions(+), 32 deletions(-) diff --git a/crates/ty/docs/configuration.md b/crates/ty/docs/configuration.md index d1a12f159c09f..5a4232b6fb2e9 100644 --- a/crates/ty/docs/configuration.md +++ b/crates/ty/docs/configuration.md @@ -654,6 +654,43 @@ Defaults to `true`. --- +## `semantics` + +### `isinstance-narrowing` + +Controls how ty narrows to unspecialized generic classes in `isinstance()` checks. + +With `strict`, ty narrows to the top materialization of the class. For example, +`isinstance(value, list)` narrows an `object` value to `Top[list[Unknown]]`, representing +a list with any possible specialization. + +With `relaxed`, ty narrows to the class's default specialization instead. The same check +narrows an `object` value to `list[Unknown]`. + +Defaults to `strict`. + +**Default value**: `strict` + +**Type**: `strict | relaxed` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.ty.semantics] + isinstance-narrowing = "relaxed" + ``` + +=== "ty.toml" + + ```toml + [semantics] + isinstance-narrowing = "relaxed" + ``` + +--- + ## `src` ### `exclude` diff --git a/crates/ty/tests/cli/config_option.rs b/crates/ty/tests/cli/config_option.rs index 89e4c7266d755..782da770ae148 100644 --- a/crates/ty/tests/cli/config_option.rs +++ b/crates/ty/tests/cli/config_option.rs @@ -128,7 +128,7 @@ fn cli_config_args_invalid_option() -> anyhow::Result<()> { | 1 | bad-option=true | ^^^^^^^^^^ - unknown field `bad-option`, expected one of `environment`, `src`, `rules`, `terminal`, `analysis`, `overrides` + unknown field `bad-option`, expected one of `environment`, `src`, `rules`, `terminal`, `analysis`, `semantics`, `overrides` Usage: ty diff --git a/crates/ty_project/src/db.rs b/crates/ty_project/src/db.rs index b8d2fb6fb9ec3..e4c44d3e54dd6 100644 --- a/crates/ty_project/src/db.rs +++ b/crates/ty_project/src/db.rs @@ -19,7 +19,7 @@ use ty_python_core::program::{ FallibleStrategy, MisconfigurationStrategy, Program, UseDefaultStrategy, }; use ty_python_semantic::lint::{LintRegistry, RuleSelection}; -use ty_python_semantic::{AnalysisSettings, Db as SemanticDb}; +use ty_python_semantic::{AnalysisSettings, Db as SemanticDb, SemanticSettings}; mod changes; mod ignore; @@ -549,6 +549,10 @@ impl SemanticDb for ProjectDatabase { settings.analysis(self) } + fn semantic_settings(&self, _file: File) -> &SemanticSettings { + self.project().settings(self).semantics() + } + fn verbose(&self) -> bool { self.project().verbose(self) } @@ -629,7 +633,7 @@ pub(crate) mod testing { use ty_python_core::platform::PythonPlatform; use ty_python_core::program::{FallibleStrategy, Program, ProgramSettings}; use ty_python_semantic::lint::{LintRegistry, RuleSelection}; - use ty_python_semantic::{AnalysisSettings, PythonVersionWithSource}; + use ty_python_semantic::{AnalysisSettings, PythonVersionWithSource, SemanticSettings}; use crate::db::Db; use crate::{Project, ProjectMetadata}; @@ -778,6 +782,10 @@ pub(crate) mod testing { self.project().settings(self).analysis() } + fn semantic_settings(&self, _file: ruff_db::files::File) -> &SemanticSettings { + self.project().settings(self).semantics() + } + fn verbose(&self) -> bool { false } diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 63e5c0589573d..628592e92c1ff 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -37,9 +37,9 @@ use ty_python_core::platform::PythonPlatform; use ty_python_core::program::{MisconfigurationStrategy, ProgramSettings}; use ty_python_semantic::lint::{Level, LintSource, RuleSelection}; use ty_python_semantic::{ - AnalysisSettings, PythonEnvironment, PythonVersionFileSource, PythonVersionSource, - PythonVersionWithSource, SitePackagesPaths, SysPrefixPathOrigin, - inferred_python_version_source_annotation, + AnalysisSettings, IsInstanceNarrowing, PythonEnvironment, PythonVersionFileSource, + PythonVersionSource, PythonVersionWithSource, SemanticSettings, SitePackagesPaths, + SysPrefixPathOrigin, inferred_python_version_source_annotation, }; use ty_static::EnvVars; @@ -100,6 +100,11 @@ pub struct Options { #[option_group] pub analysis: Option, + /// Configures semantic type inference behavior. + #[serde(skip_serializing_if = "Option::is_none")] + #[option_group] + pub semantics: Option, + /// Override configurations for specific file patterns. /// /// Each override specifies include/exclude patterns and rule configurations @@ -490,6 +495,8 @@ impl Options { }; let analysis = strategy.fallback(analysis_result, |_| AnalysisSettings::default())?; + let semantics = self.semantics.or_default().to_settings(); + let overrides = self .to_overrides_settings(db, project_root, &mut diagnostics) .map_err(|err| ToSettingsError { @@ -504,6 +511,7 @@ impl Options { terminal, src, analysis, + semantics, overrides, }; @@ -1583,6 +1591,60 @@ impl AnalysisOptions { } } +#[derive( + Debug, + Default, + Clone, + Eq, + PartialEq, + Serialize, + Deserialize, + OptionsMetadata, + get_size2::GetSize, +)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct SemanticsOptions { + /// Controls how ty narrows to unspecialized generic classes in `isinstance()` checks. + /// + /// With `strict`, ty narrows to the top materialization of the class. For example, + /// `isinstance(value, list)` narrows an `object` value to `Top[list[Unknown]]`, representing + /// a list with any possible specialization. + /// + /// With `relaxed`, ty narrows to the class's default specialization instead. The same check + /// narrows an `object` value to `list[Unknown]`. + /// + /// Defaults to `strict`. + #[option( + default = "strict", + value_type = "strict | relaxed", + example = r#" + isinstance-narrowing = "relaxed" + "# + )] + pub isinstance_narrowing: Option>, +} + +impl SemanticsOptions { + fn to_settings(&self) -> SemanticSettings { + SemanticSettings { + isinstance_narrowing: self + .isinstance_narrowing + .as_deref() + .copied() + .unwrap_or_default(), + } + } +} + +impl Combine for SemanticsOptions { + fn combine_with(&mut self, other: Self) { + if self.isinstance_narrowing.is_none() { + self.isinstance_narrowing = other.isinstance_narrowing; + } + } +} + fn build_module_glob_set( db: &dyn Db, patterns: &[RangedValue], diff --git a/crates/ty_project/src/metadata/settings.rs b/crates/ty_project/src/metadata/settings.rs index eaf9966f400fc..46c71a095ddea 100644 --- a/crates/ty_project/src/metadata/settings.rs +++ b/crates/ty_project/src/metadata/settings.rs @@ -2,8 +2,8 @@ use std::sync::Arc; use ruff_db::files::File; use ty_combine::Combine; -use ty_python_semantic::AnalysisSettings; use ty_python_semantic::lint::RuleSelection; +use ty_python_semantic::{AnalysisSettings, SemanticSettings}; use crate::metadata::options::{InnerOverrideOptions, OutputFormat}; use crate::{Db, glob::IncludeExcludeFilter}; @@ -27,6 +27,7 @@ pub struct Settings { pub(super) terminal: TerminalSettings, pub(super) src: SrcSettings, pub(super) analysis: AnalysisSettings, + pub(super) semantics: SemanticSettings, /// Settings for configuration overrides that apply to specific file patterns. /// @@ -60,6 +61,10 @@ impl Settings { pub fn analysis(&self) -> &AnalysisSettings { &self.analysis } + + pub fn semantics(&self) -> &SemanticSettings { + &self.semantics + } } #[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index 8cc098df0714a..d8e23dd583af1 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -728,6 +728,215 @@ def _(x: type[object], y: type[object], z: type[object]): reveal_type(z) # revealed: type[Top[Invariant[Unknown]]] ``` +## Use cases: `isinstance` narrowing and generics + +### Strict mode + +```toml +[semantics] +isinstance-narrowing = "strict" +``` + +#### Covariance + +Narrowing from `object` via `isinstance(.., Sequence)`: + +```py +from typing import Sequence, final + +def _(xs: object): + if isinstance(xs, Sequence): + reveal_type(xs) # revealed: Sequence[object] + for x in xs: + reveal_type(x) # revealed: object + else: + reveal_type(xs) # revealed: ~Sequence[object] +``` + +Narrowing from `Item | Sequence[Item]` via `isinstance(.., Sequence)`: + +```py +@final +class Item: ... + +def _(xs: Item | Sequence[Item]): + if isinstance(xs, Sequence): + reveal_type(xs) # revealed: Sequence[Item] + for x in xs: + reveal_type(x) # revealed: Item + else: + reveal_type(xs) # revealed: Item +``` + +Narrowing from (non-final) `OpenItem | Sequence[OpenItem]` via `isinstance(.., Sequence)`: + +```py +class OpenItem: ... + +def _(xs: OpenItem | Sequence[OpenItem]): + if isinstance(xs, Sequence): + reveal_type(xs) # revealed: (OpenItem & Sequence[object]) | Sequence[OpenItem] + for x in xs: + reveal_type(x) # revealed: object + else: + reveal_type(xs) # revealed: OpenItem & ~Sequence[object] +``` + +#### Invariance + +Narrowing from `object` via `isinstance(.., list)`: + +```py +def _(xs: object): + if isinstance(xs, list): + reveal_type(xs) # revealed: Top[list[Unknown]] + for x in xs: + reveal_type(x) # revealed: object + + # This is an error in strict mode: + # error: [invalid-argument-type] "Expected `Never`, found `Literal[1]`" + xs.append(1) + + else: + reveal_type(xs) # revealed: ~Top[list[Unknown]] +``` + +Narrowing from `Item | list[Item]` via `isinstance(.., list)`: + +```py +from typing import final + +@final +class Item: ... + +def _(xs: Item | list[Item]): + if isinstance(xs, list): + reveal_type(xs) # revealed: list[Item] + for x in xs: + reveal_type(x) # revealed: Item + else: + reveal_type(xs) # revealed: Item +``` + +Narrowing from (non-final) `OpenItem | list[OpenItem]` via `isinstance(.., list)`: + +```py +class OpenItem: ... + +def _(xs: OpenItem | list[OpenItem]): + if isinstance(xs, list): + reveal_type(xs) # revealed: (OpenItem & Top[list[Unknown]]) | list[OpenItem] + for x in xs: + reveal_type(x) # revealed: object + else: + reveal_type(xs) # revealed: OpenItem & ~Top[list[Unknown]] +``` + +### Relaxed mode + +The `semantics.isinstance-narrowing` option can be set to `relaxed` to narrow to the default +specialization of a generic class without top-materializing it: + +```toml +[semantics] +isinstance-narrowing = "relaxed" +``` + +#### Covariance + +Narrowing from `object` via `isinstance(.., Sequence)`: + +```py +from typing import Sequence, final + +def _(xs: object): + if isinstance(xs, Sequence): + reveal_type(xs) # revealed: Sequence[Unknown] + for x in xs: + reveal_type(x) # revealed: Unknown + else: + reveal_type(xs) # revealed: ~Sequence[Unknown] +``` + +Narrowing from `Item | Sequence[Item]` via `isinstance(.., Sequence)`: + +```py +@final +class Item: ... + +def _(xs: Item | Sequence[Item]): + if isinstance(xs, Sequence): + # TODO: we might want to simplify this to `Sequence[Item & Unknown]` + reveal_type(xs) # revealed: Sequence[Item] & Sequence[Unknown] + for x in xs: + reveal_type(x) # revealed: Item & Unknown + else: + reveal_type(xs) # revealed: Item | (Sequence[Item] & ~Sequence[Unknown]) +``` + +Narrowing from (non-final) `OpenItem | Sequence[OpenItem]` via `isinstance(.., Sequence)`: + +```py +class OpenItem: ... + +def _(xs: OpenItem | Sequence[OpenItem]): + if isinstance(xs, Sequence): + reveal_type(xs) # revealed: (OpenItem & Sequence[Unknown]) | (Sequence[OpenItem] & Sequence[Unknown]) + for x in xs: + reveal_type(x) # revealed: Unknown + else: + reveal_type(xs) # revealed: (OpenItem & ~Sequence[Unknown]) | (Sequence[OpenItem] & ~Sequence[Unknown]) +``` + +#### Invariance + +Narrowing from `object` via `isinstance(.., list)`: + +```py +def _(xs: object): + if isinstance(xs, list): + reveal_type(xs) # revealed: list[Unknown] + for x in xs: + reveal_type(x) # revealed: Unknown + + # In relaxed mode, this is fine + xs.append(1) + + else: + reveal_type(xs) # revealed: ~list[Unknown] +``` + +Narrowing from `Item | list[Item]` via `isinstance(.., list)`: + +```py +from typing import final + +@final +class Item: ... + +def _(xs: Item | list[Item]): + if isinstance(xs, list): + reveal_type(xs) # revealed: list[Item] + for x in xs: + reveal_type(x) # revealed: Item + else: + reveal_type(xs) # revealed: Item | (list[Item] & ~list[Unknown]) +``` + +Narrowing from (non-final) `OpenItem | list[OpenItem]` via `isinstance(.., list)`: + +```py +class OpenItem: ... + +def _(xs: OpenItem | list[OpenItem]): + if isinstance(xs, list): + reveal_type(xs) # revealed: (OpenItem & list[Unknown]) | list[OpenItem] + for x in xs: + reveal_type(x) # revealed: Unknown | OpenItem + else: + reveal_type(xs) # revealed: (OpenItem & ~list[Unknown]) | (list[OpenItem] & ~list[Unknown]) +``` + ## Narrowing generic defaults in Python 3.13 When a type parameter has a bare `Any` default, narrowing still materializes the substituted diff --git a/crates/ty_python_semantic/src/db.rs b/crates/ty_python_semantic/src/db.rs index e33ff5f2e90c1..df084810714c8 100644 --- a/crates/ty_python_semantic/src/db.rs +++ b/crates/ty_python_semantic/src/db.rs @@ -1,5 +1,5 @@ -use crate::AnalysisSettings; use crate::lint::{LintRegistry, RuleSelection}; +use crate::{AnalysisSettings, SemanticSettings}; use ruff_db::diagnostic::Diagnostic; use ruff_db::files::File; use ty_python_core::Db as PythonCoreDb; @@ -16,6 +16,8 @@ pub trait Db: PythonCoreDb { fn analysis_settings(&self, file: File) -> &AnalysisSettings; + fn semantic_settings(&self, file: File) -> &SemanticSettings; + /// Whether ty is running with logging verbosity INFO or higher (`-v` or more). fn verbose(&self) -> bool; @@ -55,6 +57,7 @@ pub(crate) mod tests { events: Events, rule_selection: Arc, analysis_settings: Arc, + semantic_settings: Arc, } impl TestDb { @@ -75,6 +78,7 @@ pub(crate) mod tests { files: Files::default(), rule_selection: Arc::new(RuleSelection::from_registry(default_lint_registry())), analysis_settings: AnalysisSettings::default().into(), + semantic_settings: SemanticSettings::default().into(), } } @@ -152,6 +156,10 @@ pub(crate) mod tests { &self.analysis_settings } + fn semantic_settings(&self, _file: File) -> &SemanticSettings { + &self.semantic_settings + } + fn verbose(&self) -> bool { false } diff --git a/crates/ty_python_semantic/src/lib.rs b/crates/ty_python_semantic/src/lib.rs index 1a7b6262e049e..4508a8dace0ef 100644 --- a/crates/ty_python_semantic/src/lib.rs +++ b/crates/ty_python_semantic/src/lib.rs @@ -103,6 +103,24 @@ pub struct AnalysisSettings { pub replace_imports_with_any: ModuleGlobSet, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, get_size2::GetSize)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(rename_all = "kebab-case") +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum IsInstanceNarrowing { + #[default] + Strict, + Relaxed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, get_size2::GetSize)] +pub struct SemanticSettings { + pub isinstance_narrowing: IsInstanceNarrowing, +} + impl Default for AnalysisSettings { fn default() -> Self { Self { diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 63a020faab8c1..ef61a7e741b7b 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1,7 +1,6 @@ use std::borrow::Cow; use std::collections::{BTreeMap, btree_map::Entry as BTreeEntry, hash_map::Entry}; -use crate::Db; use crate::reachability::{narrow_type_by_constraint, type_narrowed_by_previous_patterns}; use crate::subscript::PyIndex; use crate::types::function::KnownFunction; @@ -21,6 +20,7 @@ use crate::types::{ pattern_binding_fallthrough_type, sequence_pattern_type_builder, singleton_pattern_type, starred_sequence_pattern_type, typed_dict_matches_class_pattern, }; +use crate::{Db, IsInstanceNarrowing}; use ty_python_core::expression::Expression; use ty_python_core::frozen::FrozenMap; use ty_python_core::place::{PlaceExpr, PlaceTable, ScopedPlaceId}; @@ -443,20 +443,28 @@ impl ClassInfoConstraintFunction { db: &'db dyn Db, classinfo: Type<'db>, is_positive: bool, + isinstance_narrowing: IsInstanceNarrowing, ) -> Option> { let constraint_from_class_literal = |class: ClassLiteral<'db>| match self { - ClassInfoConstraintFunction::IsInstance => { - Type::instance(db, class.top_materialization(db)) - } + ClassInfoConstraintFunction::IsInstance => Type::instance( + db, + match isinstance_narrowing { + IsInstanceNarrowing::Strict => class.top_materialization(db), + IsInstanceNarrowing::Relaxed => class.default_specialization(db), + }, + ), ClassInfoConstraintFunction::IsSubclass => { SubclassOfType::from(db, class.top_materialization(db)) } }; match classinfo { - Type::TypeAlias(alias) => { - self.generate_constraint(db, alias.value_type(db), is_positive) - } + Type::TypeAlias(alias) => self.generate_constraint( + db, + alias.value_type(db), + is_positive, + isinstance_narrowing, + ), Type::ClassLiteral(class_literal) => Some(constraint_from_class_literal(class_literal)), Type::SubclassOf(subclass_of_ty) => { // We can't narrow negatively from a `SubclassOf` type. `if !isinstance(x, y)` @@ -491,6 +499,7 @@ impl ClassInfoConstraintFunction { db, *element, is_positive, + isinstance_narrowing, )?); } Some(builder.build()) @@ -500,16 +509,20 @@ impl ClassInfoConstraintFunction { } } Type::Union(union) => union.try_map(db, |element| { - self.generate_constraint(db, *element, is_positive) + self.generate_constraint(db, *element, is_positive, isinstance_narrowing) }), Type::TypeVar(bound_typevar) => { match bound_typevar.typevar(db).bound_or_constraints(db)? { TypeVarBoundOrConstraints::UpperBound(bound) => { - self.generate_constraint(db, bound, is_positive) - } - TypeVarBoundOrConstraints::Constraints(constraints) => { - self.generate_constraint(db, constraints.as_type(db), is_positive) + self.generate_constraint(db, bound, is_positive, isinstance_narrowing) } + TypeVarBoundOrConstraints::Constraints(constraints) => self + .generate_constraint( + db, + constraints.as_type(db), + is_positive, + isinstance_narrowing, + ), } } @@ -520,9 +533,9 @@ impl ClassInfoConstraintFunction { Type::NominalInstance(nominal) => nominal.tuple_spec(db).and_then(|tuple| { UnionType::try_from_elements( db, - tuple - .iter_all_elements() - .map(|element| self.generate_constraint(db, element, is_positive)), + tuple.iter_all_elements().map(|element| { + self.generate_constraint(db, element, is_positive, isinstance_narrowing) + }), ) }), @@ -539,9 +552,10 @@ impl ClassInfoConstraintFunction { db, KnownClass::NoneType.to_class_literal(db), is_positive, + isinstance_narrowing, ) } else { - self.generate_constraint(db, element, is_positive) + self.generate_constraint(db, element, is_positive, isinstance_narrowing) } }), ) @@ -552,15 +566,20 @@ impl ClassInfoConstraintFunction { db, alias.aliased_class().to_class_literal(db), is_positive, + isinstance_narrowing, ), SpecialFormType::Tuple => self.generate_constraint( db, KnownClass::Tuple.to_class_literal(db), is_positive, + isinstance_narrowing, + ), + SpecialFormType::Type => self.generate_constraint( + db, + KnownClass::Type.to_class_literal(db), + is_positive, + isinstance_narrowing, ), - SpecialFormType::Type => { - self.generate_constraint(db, KnownClass::Type.to_class_literal(db), is_positive) - } // We don't have a good meta-type for `Callable`s right now, // so only apply `isinstance()` narrowing, not `issubclass()` @@ -925,6 +944,7 @@ fn positive_class_pattern_type<'db>( db, class_expression_ty, true, + IsInstanceNarrowing::Strict, ) } _ => None, @@ -3134,8 +3154,16 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { let class_info_ty = inference.expression_type(second_arg); + let isinstance_narrowing = if function == ClassInfoConstraintFunction::IsInstance { + self.db + .semantic_settings(self.scope().file(self.db)) + .isinstance_narrowing + } else { + IsInstanceNarrowing::Strict + }; + function - .generate_constraint(self.db, class_info_ty, is_positive) + .generate_constraint(self.db, class_info_ty, is_positive, isinstance_narrowing) .map(|constraint| { NarrowingConstraints::from_iter([( place, @@ -3252,6 +3280,7 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { self.db, KnownClass::Mapping.to_class_literal(self.db), true, + IsInstanceNarrowing::Strict, )?; Some(NarrowingConstraints::from_iter([( diff --git a/crates/ty_python_semantic/tests/corpus.rs b/crates/ty_python_semantic/tests/corpus.rs index b2bfcff38b7b8..004868a9bc52a 100644 --- a/crates/ty_python_semantic/tests/corpus.rs +++ b/crates/ty_python_semantic/tests/corpus.rs @@ -12,7 +12,9 @@ use ty_python_core::platform::PythonPlatform; use ty_python_core::program::{FallibleStrategy, Program, ProgramSettings}; use ty_python_semantic::lint::{LintRegistry, RuleSelection}; use ty_python_semantic::pull_types::pull_types; -use ty_python_semantic::{AnalysisSettings, check_file_unwrap, default_lint_registry}; +use ty_python_semantic::{ + AnalysisSettings, SemanticSettings, check_file_unwrap, default_lint_registry, +}; use ty_site_packages::{PythonVersionSource, PythonVersionWithSource}; use ruff_db::diagnostic::Diagnostic; @@ -184,6 +186,7 @@ pub struct CorpusDb { system: TestSystem, vendored: VendoredFileSystem, analysis_settings: Arc, + semantic_settings: Arc, } impl CorpusDb { @@ -196,6 +199,7 @@ impl CorpusDb { rule_selection: RuleSelection::from_registry(default_lint_registry()), files: Files::default(), analysis_settings: Arc::new(AnalysisSettings::default()), + semantic_settings: Arc::new(SemanticSettings::default()), }; Program::from_settings( @@ -285,6 +289,10 @@ impl ty_python_semantic::Db for CorpusDb { &self.analysis_settings } + fn semantic_settings(&self, _file: File) -> &SemanticSettings { + &self.semantic_settings + } + fn dyn_clone(&self) -> Box { Box::new(self.clone()) } diff --git a/crates/ty_test/src/config.rs b/crates/ty_test/src/config.rs index 554d6eb7fdb4f..486918ca6655f 100644 --- a/crates/ty_test/src/config.rs +++ b/crates/ty_test/src/config.rs @@ -21,7 +21,7 @@ use ruff_db::system::{SystemPath, SystemPathBuf}; use ruff_python_ast::PythonVersion; use serde::{Deserialize, Serialize}; use ty_python_core::platform::PythonPlatform; -use ty_python_semantic::lint::Level; +use ty_python_semantic::{IsInstanceNarrowing, lint::Level}; #[derive(Deserialize, Debug, Default, Clone)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] @@ -34,6 +34,8 @@ pub(crate) struct MarkdownTestConfig { pub(crate) analysis: Option, + pub(crate) semantics: Option, + /// The [`ruff_db::system::System`] to use for tests. /// /// Defaults to the case-sensitive [`ruff_db::system::InMemorySystem`]. @@ -129,6 +131,12 @@ pub(crate) struct Analysis { pub(crate) replace_imports_with_any: Option>, } +#[derive(Deserialize, Default, Debug, Clone)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub(crate) struct Semantics { + pub(crate) isinstance_narrowing: Option, +} + #[derive(Deserialize, Debug, Clone)] #[serde(untagged)] pub(crate) enum Log { diff --git a/crates/ty_test/src/db.rs b/crates/ty_test/src/db.rs index 56e9151ef0ad2..bc05de27b1b49 100644 --- a/crates/ty_test/src/db.rs +++ b/crates/ty_test/src/db.rs @@ -1,4 +1,4 @@ -use crate::config::{Analysis, Rules}; +use crate::config::{Analysis, Rules, Semantics}; use camino::{Utf8Component, Utf8PathBuf}; use ruff_db::Db as SourceDb; use ruff_db::diagnostic::{Diagnostic, Severity}; @@ -18,7 +18,7 @@ use ty_python_core::Db as _; use ty_python_core::program::Program; use ty_python_semantic::lint::{LintRegistry, RuleSelection}; use ty_python_semantic::{ - AnalysisSettings, Db as SemanticDb, check_file_unwrap, default_lint_registry, + AnalysisSettings, Db as SemanticDb, SemanticSettings, check_file_unwrap, default_lint_registry, }; #[salsa::db] @@ -110,6 +110,19 @@ impl Db { } } + pub(crate) fn update_semantics_options(&mut self, options: Option<&Semantics>) { + let semantics = SemanticSettings { + isinstance_narrowing: options + .and_then(|options| options.isinstance_narrowing) + .unwrap_or_default(), + }; + + let settings = self.settings(); + if settings.semantics(self) != &semantics { + settings.set_semantics(self).to(semantics); + } + } + pub(crate) fn update_mdtest_rule_selection( &mut self, rules: Option<&Rules>, @@ -199,6 +212,10 @@ impl SemanticDb for Db { self.settings().analysis(self) } + fn semantic_settings(&self, _file: File) -> &SemanticSettings { + self.settings().semantics(self) + } + fn dyn_clone(&self) -> Box { Box::new(self.clone()) } @@ -220,6 +237,9 @@ struct Settings { #[returns(ref)] analysis: AnalysisSettings, #[default] + #[returns(ref)] + semantics: SemanticSettings, + #[default] #[returns(deref)] rule_selection: MdtestRuleSelection, #[default] diff --git a/crates/ty_test/src/lib.rs b/crates/ty_test/src/lib.rs index a5a60304fa0af..76a93bddbc813 100644 --- a/crates/ty_test/src/lib.rs +++ b/crates/ty_test/src/lib.rs @@ -318,6 +318,7 @@ fn run_test( Program::init_or_update(db, settings); db.update_analysis_options(configuration.analysis.as_ref()); + db.update_semantics_options(configuration.semantics.as_ref()); db.update_mdtest_rule_selection(configuration.rules.as_ref(), options.default_error_rule); db.set_verbosity(test.configuration().verbose()); diff --git a/ty.schema.json b/ty.schema.json index b093b6ae38cb6..35b8f8052218a 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -46,6 +46,17 @@ } ] }, + "semantics": { + "description": "Configures semantic type inference behavior.", + "anyOf": [ + { + "$ref": "#/definitions/SemanticsOptions" + }, + { + "type": "null" + } + ] + }, "src": { "anyOf": [ { @@ -178,6 +189,13 @@ }, "additionalProperties": false }, + "IsInstanceNarrowing": { + "type": "string", + "enum": [ + "strict", + "relaxed" + ] + }, "Level": { "oneOf": [ { @@ -1545,6 +1563,23 @@ "$ref": "#/definitions/Level" } }, + "SemanticsOptions": { + "type": "object", + "properties": { + "isinstance-narrowing": { + "description": "Controls how ty narrows to unspecialized generic classes in `isinstance()` checks.\n\nWith `strict`, ty narrows to the top materialization of the class. For example,\n`isinstance(value, list)` narrows an `object` value to `Top[list[Unknown]]`, representing\na list with any possible specialization.\n\nWith `relaxed`, ty narrows to the class's default specialization instead. The same check\nnarrows an `object` value to `list[Unknown]`.\n\nDefaults to `strict`.", + "anyOf": [ + { + "$ref": "#/definitions/IsInstanceNarrowing" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, "SrcOptions": { "type": "object", "properties": { From e430dbf48ba7dc2e9c91fcbe5549bc80dd327eff Mon Sep 17 00:00:00 2001 From: David Peter Date: Thu, 25 Jun 2026 13:12:25 +0200 Subject: [PATCH 10/15] [ty] Fix CI for isinstance narrowing setting --- .../e2e/snapshots/e2e__commands__debug_command.snap | 3 +++ fuzz/fuzz_targets/ty_check_invalid_syntax.rs | 10 ++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap index fe4a338748601..f0fdc1e27c45d 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap @@ -171,6 +171,9 @@ Settings: Settings { globs: [], }, }, + semantics: SemanticSettings { + isinstance_narrowing: Strict, + }, overrides: [], } diff --git a/fuzz/fuzz_targets/ty_check_invalid_syntax.rs b/fuzz/fuzz_targets/ty_check_invalid_syntax.rs index 274c8851d087a..fec2b667f506d 100644 --- a/fuzz/fuzz_targets/ty_check_invalid_syntax.rs +++ b/fuzz/fuzz_targets/ty_check_invalid_syntax.rs @@ -23,8 +23,8 @@ use ty_python_core::program::{FallibleStrategy, Program, ProgramSettings}; use ty_python_semantic::lint::LintRegistry; use ty_python_semantic::types::check_types; use ty_python_semantic::{ - AnalysisSettings, Db as SemanticDb, PythonVersionWithSource, default_lint_registry, - lint::RuleSelection, + AnalysisSettings, Db as SemanticDb, PythonVersionWithSource, SemanticSettings, + default_lint_registry, lint::RuleSelection, }; /// Database that can be used for testing. @@ -39,6 +39,7 @@ struct TestDb { vendored: VendoredFileSystem, rule_selection: Arc, analysis_settings: Arc, + semantic_settings: Arc, } impl TestDb { @@ -54,6 +55,7 @@ impl TestDb { files: Files::default(), rule_selection: RuleSelection::from_registry(default_lint_registry()).into(), analysis_settings: AnalysisSettings::default().into(), + semantic_settings: SemanticSettings::default().into(), } } } @@ -119,6 +121,10 @@ impl SemanticDb for TestDb { &self.analysis_settings } + fn semantic_settings(&self, _file: File) -> &SemanticSettings { + &self.semantic_settings + } + fn lint_registry(&self) -> &LintRegistry { default_lint_registry() } From 1aa857b46e424a013d7e5b47eb53ef9297fd7da6 Mon Sep 17 00:00:00 2001 From: David Peter Date: Thu, 25 Jun 2026 17:39:28 +0200 Subject: [PATCH 11/15] Add test for exhaustiveness checking --- .../resources/mdtest/narrow/isinstance.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index d8e23dd583af1..c48517e690dba 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -937,6 +937,20 @@ def _(xs: OpenItem | list[OpenItem]): reveal_type(xs) # revealed: (OpenItem & ~list[Unknown]) | (list[OpenItem] & ~list[Unknown]) ``` +#### Exhaustiveness checking + +In relaxed mode, exhaustiveness checking is harder to achieve: + +```py +# TODO +# error: [invalid-return-type] "Function can implicitly return `None`, which is not assignable to return type `str`" +def _(xs: list[str] | set[str]) -> str: + if isinstance(xs, list): + return "it's a list!" + elif isinstance(xs, set): + return "it's a set!" +``` + ## Narrowing generic defaults in Python 3.13 When a type parameter has a bare `Any` default, narrowing still materializes the substituted From b4f74478e21042e3473701dfaebc1f0d93809b50 Mon Sep 17 00:00:00 2001 From: David Peter Date: Thu, 25 Jun 2026 12:52:28 +0200 Subject: [PATCH 12/15] [ty] Default to relaxed isinstance narrowing --- crates/ty/docs/configuration.md | 8 ++++---- crates/ty_project/src/metadata/options.rs | 6 +++--- .../resources/mdtest/exhaustiveness_checking.md | 3 +++ .../resources/mdtest/generics/pep695/variables.md | 3 +++ .../resources/mdtest/loops/for.md | 8 ++++++++ .../resources/mdtest/narrow/isinstance.md | 14 ++++++++++++++ .../resources/mdtest/narrow/match.md | 5 +++++ crates/ty_python_semantic/src/lib.rs | 2 +- .../snapshots/e2e__commands__debug_command.snap | 2 +- ty.schema.json | 2 +- 10 files changed, 43 insertions(+), 10 deletions(-) diff --git a/crates/ty/docs/configuration.md b/crates/ty/docs/configuration.md index 5a4232b6fb2e9..da4459c1843aa 100644 --- a/crates/ty/docs/configuration.md +++ b/crates/ty/docs/configuration.md @@ -667,9 +667,9 @@ a list with any possible specialization. With `relaxed`, ty narrows to the class's default specialization instead. The same check narrows an `object` value to `list[Unknown]`. -Defaults to `strict`. +Defaults to `relaxed`. -**Default value**: `strict` +**Default value**: `relaxed` **Type**: `strict | relaxed` @@ -679,14 +679,14 @@ Defaults to `strict`. ```toml [tool.ty.semantics] - isinstance-narrowing = "relaxed" + isinstance-narrowing = "strict" ``` === "ty.toml" ```toml [semantics] - isinstance-narrowing = "relaxed" + isinstance-narrowing = "strict" ``` --- diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 628592e92c1ff..cfb32dfa62772 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -1614,12 +1614,12 @@ pub struct SemanticsOptions { /// With `relaxed`, ty narrows to the class's default specialization instead. The same check /// narrows an `object` value to `list[Unknown]`. /// - /// Defaults to `strict`. + /// Defaults to `relaxed`. #[option( - default = "strict", + default = "relaxed", value_type = "strict | relaxed", example = r#" - isinstance-narrowing = "relaxed" + isinstance-narrowing = "strict" "# )] pub isinstance_narrowing: Option>, diff --git a/crates/ty_python_semantic/resources/mdtest/exhaustiveness_checking.md b/crates/ty_python_semantic/resources/mdtest/exhaustiveness_checking.md index c6076e4e72291..034aa0d61a3ac 100644 --- a/crates/ty_python_semantic/resources/mdtest/exhaustiveness_checking.md +++ b/crates/ty_python_semantic/resources/mdtest/exhaustiveness_checking.md @@ -440,6 +440,9 @@ def match_exhaustive_generic[T](obj: GenericClass[T]) -> GenericClass[T]: ```toml [environment] python-version = "3.12" + +[semantics] +isinstance-narrowing = "strict" ``` ```py diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md index 0435f7e12c405..fde45a5f3e9db 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md @@ -3,6 +3,9 @@ ```toml [environment] python-version = "3.13" + +[semantics] +isinstance-narrowing = "strict" ``` [PEP 695] and Python 3.12 introduced new, more ergonomic syntax for type variables. diff --git a/crates/ty_python_semantic/resources/mdtest/loops/for.md b/crates/ty_python_semantic/resources/mdtest/loops/for.md index e8bcae1b6987b..34e2832418578 100644 --- a/crates/ty_python_semantic/resources/mdtest/loops/for.md +++ b/crates/ty_python_semantic/resources/mdtest/loops/for.md @@ -697,6 +697,11 @@ for x in Test(): When we have an intersection type via `isinstance` narrowing, we should be able to infer the iterable element type precisely: +```toml +[semantics] +isinstance-narrowing = "strict" +``` + ```py from typing import Sequence @@ -1571,6 +1576,9 @@ iteration. ```toml [environment] python-version = "3.12" + +[semantics] +isinstance-narrowing = "strict" ``` ### TypeVar bound with non-iterable elements diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index c48517e690dba..46b04104a0bd5 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -301,6 +301,11 @@ def _(x: int | str | None): Certain special forms in `typing.py` are aliases to classes elsewhere in the standard library; these can be used in `isinstance()` and `issubclass()` checks. We support narrowing using them: +```toml +[semantics] +isinstance-narrowing = "strict" +``` + ```py import typing as t @@ -596,6 +601,9 @@ def i[T: Intersection[type[Bar], type[Baz | Spam]], U: (type[Eggs], type[Ham])]( ```toml [environment] python-version = "3.12" + +[semantics] +isinstance-narrowing = "strict" ``` Narrowing to a generic class using `isinstance()` uses the top materialization of the generic. With @@ -960,6 +968,9 @@ instead), so the default value is irrelevant here: ```toml [environment] python-version = "3.13" + +[semantics] +isinstance-narrowing = "strict" ``` ```py @@ -997,6 +1008,9 @@ arm that later causes `call-top-callable` false positives. ```toml [environment] python-version = "3.13" + +[semantics] +isinstance-narrowing = "strict" ``` ```py diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index 6c14087e70cdb..9842a432aba2b 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -140,6 +140,11 @@ def f(x: Covariant[int]): ## Mapping patterns +```toml +[semantics] +isinstance-narrowing = "strict" +``` + ```py from collections.abc import Mapping from typing import Any diff --git a/crates/ty_python_semantic/src/lib.rs b/crates/ty_python_semantic/src/lib.rs index 4508a8dace0ef..602f1f1ab30a4 100644 --- a/crates/ty_python_semantic/src/lib.rs +++ b/crates/ty_python_semantic/src/lib.rs @@ -111,8 +111,8 @@ pub struct AnalysisSettings { )] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub enum IsInstanceNarrowing { - #[default] Strict, + #[default] Relaxed, } diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap index f0fdc1e27c45d..0d2e45e411160 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap @@ -172,7 +172,7 @@ Settings: Settings { }, }, semantics: SemanticSettings { - isinstance_narrowing: Strict, + isinstance_narrowing: Relaxed, }, overrides: [], } diff --git a/ty.schema.json b/ty.schema.json index 35b8f8052218a..e945f77032d3d 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -1567,7 +1567,7 @@ "type": "object", "properties": { "isinstance-narrowing": { - "description": "Controls how ty narrows to unspecialized generic classes in `isinstance()` checks.\n\nWith `strict`, ty narrows to the top materialization of the class. For example,\n`isinstance(value, list)` narrows an `object` value to `Top[list[Unknown]]`, representing\na list with any possible specialization.\n\nWith `relaxed`, ty narrows to the class's default specialization instead. The same check\nnarrows an `object` value to `list[Unknown]`.\n\nDefaults to `strict`.", + "description": "Controls how ty narrows to unspecialized generic classes in `isinstance()` checks.\n\nWith `strict`, ty narrows to the top materialization of the class. For example,\n`isinstance(value, list)` narrows an `object` value to `Top[list[Unknown]]`, representing\na list with any possible specialization.\n\nWith `relaxed`, ty narrows to the class's default specialization instead. The same check\nnarrows an `object` value to `list[Unknown]`.\n\nDefaults to `relaxed`.", "anyOf": [ { "$ref": "#/definitions/IsInstanceNarrowing" From 9c9c789415c1e8a12fcca75cf2c840a0a49f8ba3 Mon Sep 17 00:00:00 2001 From: David Peter Date: Mon, 29 Jun 2026 19:57:01 +0200 Subject: [PATCH 13/15] Update assertions --- crates/ruff_benchmark/benches/ty_walltime.rs | 2 +- .../resources/mdtest/narrow/isinstance.md | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/ruff_benchmark/benches/ty_walltime.rs b/crates/ruff_benchmark/benches/ty_walltime.rs index 61c1fd58e431b..26ad160907725 100644 --- a/crates/ruff_benchmark/benches/ty_walltime.rs +++ b/crates/ruff_benchmark/benches/ty_walltime.rs @@ -211,7 +211,7 @@ static TANJUN: Benchmark = Benchmark::new( max_dep_date: TY_ECOSYSTEM_PIN, python_version: SupportedPythonVersion::Py311, }, - 110, + 120, ); static STATIC_FRAME: Benchmark = Benchmark::new( diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index 46b04104a0bd5..202875274f918 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -874,12 +874,11 @@ class Item: ... def _(xs: Item | Sequence[Item]): if isinstance(xs, Sequence): - # TODO: we might want to simplify this to `Sequence[Item & Unknown]` - reveal_type(xs) # revealed: Sequence[Item] & Sequence[Unknown] + reveal_type(xs) # revealed: Sequence[Item & Unknown] for x in xs: reveal_type(x) # revealed: Item & Unknown else: - reveal_type(xs) # revealed: Item | (Sequence[Item] & ~Sequence[Unknown]) + reveal_type(xs) # revealed: Item | (Sequence[Item] & Any & ~Sequence[Never]) ``` Narrowing from (non-final) `OpenItem | Sequence[OpenItem]` via `isinstance(.., Sequence)`: @@ -889,11 +888,11 @@ class OpenItem: ... def _(xs: OpenItem | Sequence[OpenItem]): if isinstance(xs, Sequence): - reveal_type(xs) # revealed: (OpenItem & Sequence[Unknown]) | (Sequence[OpenItem] & Sequence[Unknown]) + reveal_type(xs) # revealed: (OpenItem & Sequence[Unknown]) | Sequence[OpenItem & Unknown] for x in xs: reveal_type(x) # revealed: Unknown else: - reveal_type(xs) # revealed: (OpenItem & ~Sequence[Unknown]) | (Sequence[OpenItem] & ~Sequence[Unknown]) + reveal_type(xs) # revealed: (OpenItem & ~Sequence[Unknown]) | (Sequence[OpenItem] & Any & ~Sequence[Never]) ``` #### Invariance @@ -928,7 +927,7 @@ def _(xs: Item | list[Item]): for x in xs: reveal_type(x) # revealed: Item else: - reveal_type(xs) # revealed: Item | (list[Item] & ~list[Unknown]) + reveal_type(xs) # revealed: Item | (list[Item] & Any & ~Bottom[list[Unknown]]) ``` Narrowing from (non-final) `OpenItem | list[OpenItem]` via `isinstance(.., list)`: @@ -942,7 +941,7 @@ def _(xs: OpenItem | list[OpenItem]): for x in xs: reveal_type(x) # revealed: Unknown | OpenItem else: - reveal_type(xs) # revealed: (OpenItem & ~list[Unknown]) | (list[OpenItem] & ~list[Unknown]) + reveal_type(xs) # revealed: (OpenItem & ~list[Unknown]) | (list[OpenItem] & Any & ~Bottom[list[Unknown]]) ``` #### Exhaustiveness checking From 3b12452348932a6110333b477486bd14725b43db Mon Sep 17 00:00:00 2001 From: David Peter Date: Mon, 29 Jun 2026 20:12:16 +0200 Subject: [PATCH 14/15] [ty] Update relaxed narrowing CI expectations --- crates/ruff_benchmark/benches/ty_walltime.rs | 2 +- crates/ty_ide/src/inlay_hints.rs | 38 +++++++------------- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/crates/ruff_benchmark/benches/ty_walltime.rs b/crates/ruff_benchmark/benches/ty_walltime.rs index 26ad160907725..4a0b6fa53f7f5 100644 --- a/crates/ruff_benchmark/benches/ty_walltime.rs +++ b/crates/ruff_benchmark/benches/ty_walltime.rs @@ -110,7 +110,7 @@ static ALTAIR: Benchmark = Benchmark::new( max_dep_date: TY_ECOSYSTEM_PIN, python_version: SupportedPythonVersion::Py311, }, - 3, + 7, ); static COLOUR_SCIENCE: Benchmark = Benchmark::new( diff --git a/crates/ty_ide/src/inlay_hints.rs b/crates/ty_ide/src/inlay_hints.rs index 1a9e9b2aca6a8..c15554696b1c1 100644 --- a/crates/ty_ide/src/inlay_hints.rs +++ b/crates/ty_ide/src/inlay_hints.rs @@ -7133,22 +7133,9 @@ Source with applied edits: def f(xyxy: object): if isinstance(xyxy, list): - x[: Top[list[Unknown]]] = xyxy + x[: list[Unknown]] = xyxy --------------------------------------------- - info[inlay-hint-location]: Inlay Hint Target - --> stdlib/ty_extensions.pyi:LL:1 - | - LL | Top: _SpecialForm - | ^^^ - | - info: Source - --> main2.py:LL:13 - | - LL | x[: Top[list[Unknown]]] = xyxy - | ^^^ - | - info[inlay-hint-location]: Inlay Hint Target --> stdlib/builtins.pyi:LL:7 | @@ -7156,10 +7143,10 @@ Source with applied edits: | ^^^^ | info: Source - --> main2.py:LL:17 + --> main2.py:LL:13 | - LL | x[: Top[list[Unknown]]] = xyxy - | ^^^^ + LL | x[: list[Unknown]] = xyxy + | ^^^^ | info[inlay-hint-location]: Inlay Hint Target @@ -7169,23 +7156,22 @@ Source with applied edits: | ^^^^^^^ | info: Source - --> main2.py:LL:22 + --> main2.py:LL:18 | - LL | x[: Top[list[Unknown]]] = xyxy - | ^^^^^^^ + LL | x[: list[Unknown]] = xyxy + | ^^^^^^^ | --------------------------------------------- info[inlay-hint-edit]: Inlay hint edits --> main.py:1:1 | - 1 + from ty_extensions import Top - 2 + from ty_extensions import Unknown - 3 | - 4 | def f(xyxy: object): - 5 | if isinstance(xyxy, list): + 1 + from ty_extensions import Unknown + 2 | + 3 | def f(xyxy: object): + 4 | if isinstance(xyxy, list): - x = xyxy - 6 + x: Top[list[Unknown]] = xyxy + 5 + x: list[Unknown] = xyxy | "); } From 447259c713330d7028b67a5940e2ecf257bef92e Mon Sep 17 00:00:00 2001 From: David Peter Date: Tue, 30 Jun 2026 14:50:12 +0200 Subject: [PATCH 15/15] [ty] Simplify gradual ParamSpec intersections --- .../mdtest/generics/set_theoretic.md | 30 ++++++++++++++++ .../ty_python_semantic/src/types/callable.rs | 30 +++++++++++++++- .../ty_python_semantic/src/types/relation.rs | 20 +---------- .../generic_gradual_intersections.rs | 36 +++++++++++++++++-- 4 files changed, 93 insertions(+), 23 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md b/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md index 5774d3bedd3c7..b5a2fa6e7e037 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/set_theoretic.md @@ -338,6 +338,36 @@ static_assert(is_equivalent_to(Invariant[P] & ~Invariant[Any], Invariant[P] & ~B static_assert(is_equivalent_to(~Invariant[Any] & Invariant[P], Invariant[P] & ~Bottom[Invariant[Any]] & Any)) ``` +The same identity applies when a bare `...` gradually generalizes a concrete `ParamSpec`: + +```pyi +from typing import Any, Awaitable, Callable, cast +from ty_extensions import Bottom, static_assert, is_equivalent_to + +type GradualClassmethod = classmethod[Any, ..., Any] +type ConcreteClassmethod = classmethod[object, [], Awaitable[None]] + +static_assert( + is_equivalent_to( + ConcreteClassmethod & ~GradualClassmethod, + ConcreteClassmethod & ~Bottom[GradualClassmethod] & Any, + ) +) + +class Task: + def __init__( + self, + fn: Callable[[], Awaitable[None]] | ConcreteClassmethod, + ) -> None: + if isinstance(fn, classmethod): + fn = cast(Callable[[], Awaitable[None]], fn.__func__) + assert callable(fn) + self.fn = fn + +async def run() -> None: + await Task(run).fn() +``` + ## Edge cases ### Multi-parameter and mixed-variance generics diff --git a/crates/ty_python_semantic/src/types/callable.rs b/crates/ty_python_semantic/src/types/callable.rs index 369b4a4dd955f..eac4c89daa9ef 100644 --- a/crates/ty_python_semantic/src/types/callable.rs +++ b/crates/ty_python_semantic/src/types/callable.rs @@ -13,7 +13,7 @@ use crate::{ constraints::{ConstraintSet, IteratorConstraintsExtension}, known_instance::FunctoolsPartialInstance, relation::{TypeRelation, TypeRelationChecker}, - signatures::{CallableSignature, PartialSignatureApplication}, + signatures::{CallableSignature, ParametersKind, PartialSignatureApplication}, visitor, walk_signature, }, }; @@ -490,6 +490,34 @@ impl<'db> CallableType<'db> { matches!(self.kind(db), CallableTypeKind::StaticMethodLike) } + /// Return `true` if this callable is the gradual `...` value for a `ParamSpec`. + /// + /// This intentionally does not match arbitrary gradual callables or prefixed gradual forms + /// such as `Callable[Concatenate[int, ...], object]`. + pub(crate) fn is_gradual_paramspec_value(self, db: &'db dyn Db) -> bool { + self.kind(db) == CallableTypeKind::ParamSpecValue + && self + .signatures(db) + .iter() + .all(|signature| signature.parameters().kind() == ParametersKind::Gradual) + } + + /// Return `true` if this callable represents a fully-static concrete `ParamSpec` value. + /// + /// The synthetic return type of a `ParamSpecValue` is `Unknown`, but it is not part of the + /// represented parameter list and should not make a concrete specialization gradual. + pub(crate) fn is_fully_static_paramspec_value(self, db: &'db dyn Db) -> bool { + self.kind(db) == CallableTypeKind::ParamSpecValue + && self.signatures(db).iter().all(|signature| { + signature.parameters().kind() == ParametersKind::Standard + && signature + .parameters() + .as_slice() + .iter() + .all(|parameter| !parameter.annotated_type().has_dynamic(db)) + }) + } + /// Returns `true` if this callable represents a function used as a class member. pub fn is_method_like(self, db: &'db dyn Db) -> bool { matches!( diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index bfdd7ae53a754..b0f52770b53d2 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -1403,7 +1403,7 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { if self.is_eager_assignability() && !bound_typevar.is_inferable(db, self.inferable) && bound_typevar.is_paramspec(db) - && Self::is_gradual_paramspec_value(db, other) => + && other.is_gradual_paramspec_value(db) => { self.always() } @@ -2290,24 +2290,6 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { } } - /// Return `true` if `callable` is the gradual `...` value for a `ParamSpec`. - /// - /// For example, in `Command[Any, ..., Any]`, the middle type argument is represented as a - /// callable-shaped `ParamSpec` value with gradual parameters. That value is assignability- - /// consistent with a concrete `ParamSpec` specialization such as the middle type argument in - /// `Command[int, [str], object]`. - /// - /// This intentionally does not match arbitrary gradual callables like `Callable[..., object]` - /// or prefixed gradual forms like `Callable[Concatenate[int, ...], object]`; it only matches - /// the internal value used to represent a bare `...` `ParamSpec` specialization. - fn is_gradual_paramspec_value(db: &'db dyn Db, callable: CallableType<'db>) -> bool { - callable.kind(db) == CallableTypeKind::ParamSpecValue - && callable - .signatures(db) - .iter() - .all(|signature| signature.parameters().kind() == ParametersKind::Gradual) - } - /// Returns `true` if `callable` is the top materialization of a `ParamSpec` value. fn is_top_paramspec_value(db: &'db dyn Db, callable: CallableType<'db>) -> bool { callable.kind(db) == CallableTypeKind::ParamSpecValue diff --git a/crates/ty_python_semantic/src/types/set_theoretic/generic_gradual_intersections.rs b/crates/ty_python_semantic/src/types/set_theoretic/generic_gradual_intersections.rs index e63bedad0714c..77d8b7adc1108 100644 --- a/crates/ty_python_semantic/src/types/set_theoretic/generic_gradual_intersections.rs +++ b/crates/ty_python_semantic/src/types/set_theoretic/generic_gradual_intersections.rs @@ -39,13 +39,36 @@ pub(super) fn negated_generalization_bottom<'db>( } dynamic_generalization_of(db, general, specific)?; - if specific.has_dynamic(db) { + if has_semantic_dynamic(db, specific) { None } else { Some(general.bottom_materialization(db)) } } +/// Return whether a generic specialization contains dynamic information that is semantically +/// relevant to its type arguments. +/// +/// A concrete `ParamSpecValue` has a synthetic `Unknown` return type which is only an internal +/// representation detail. Its parameter list can still be fully static. +fn has_semantic_dynamic(db: &dyn Db, ty: Type) -> bool { + if !ty.has_dynamic(db) { + return false; + } + + let Some((_, specialization)) = ty.class_specialization(db) else { + return true; + }; + + specialization.types(db).iter().any(|argument| { + argument.has_dynamic(db) + && !matches!( + argument, + Type::Callable(callable) if callable.is_fully_static_paramspec_value(db) + ) + }) +} + /// Describes how intersecting a dynamic generic specialization such as `list[Any]` with a more /// specific specialization such as `list[int]` should be simplified. enum DynamicGeneralization<'db> { @@ -188,7 +211,14 @@ fn dynamic_generalization_of<'db>( .variables(db) .zip(general_specialization.types(db)) .zip(specific_specialization.types(db)) - .any(|((_, general), specific)| general != specific && !general.is_non_divergent_dynamic()) + .any(|((_, general), specific)| { + general != specific + && !general.is_non_divergent_dynamic() + && !matches!( + general, + Type::Callable(callable) if callable.is_gradual_paramspec_value(db) + ) + }) { return None; } @@ -205,7 +235,7 @@ fn dynamic_generalization_of<'db>( ) }) { - if specific.has_dynamic(db) { + if has_semantic_dynamic(db, specific) { return None; } Some(DynamicGeneralization::RebuildSpecialization {