From b47a03ad0f046ac17a2c352e66e67f0ae7d5d846 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 30 Jun 2026 17:27:39 -0400 Subject: [PATCH 1/3] [ty] Infer exact generic class pattern specializations --- .../resources/mdtest/narrow/match.md | 78 ++++++++++++++-- crates/ty_python_semantic/src/types/narrow.rs | 93 ++++++++++++++++++- 2 files changed, 159 insertions(+), 12 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index 6c14087e70cdb..1833a244c28c0 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -866,17 +866,20 @@ def test_incompatible_declared_class_capture(value: PatternBox[int]) -> None: ## Generic subclass captures -We do not yet infer a generic subclass's type arguments from its base class. Attributes declared -only on the subclass therefore use `Unknown` for those arguments, but still retain types such as -`list[Unknown]`. Attributes inherited from a generic base can use type arguments from the subject. -When the subject does not provide type arguments, members declared by the pattern class use -`Unknown`; a type parameter default does not restrict which instances match at runtime. +When a generic pattern class inherits from the subject's class through an invariant base, the +subject specialization determines the pattern class's type arguments. This applies to annotated +attributes and properties. Every pattern-class type parameter must have an exact solution; variant +bases and unconstrained parameters retain the existing conservative fallback. When the subject does +not provide type arguments, members declared by the pattern class use `Unknown`; a type parameter +default does not restrict which instances match at runtime. ```py from typing import final, Generic from typing_extensions import TypeVar GenericPatternT = TypeVar("GenericPatternT") +ExtraGenericPatternT = TypeVar("ExtraGenericPatternT") +CovariantGenericPatternT = TypeVar("CovariantGenericPatternT", covariant=True) DefaultGenericPatternT = TypeVar("DefaultGenericPatternT", default=str) class GenericPatternBase(Generic[GenericPatternT]): ... @@ -885,6 +888,17 @@ class GenericPatternChild(GenericPatternBase[GenericPatternT]): item: GenericPatternT items: list[GenericPatternT] +class PartiallySpecializedGenericPatternChild( + GenericPatternBase[GenericPatternT], + Generic[GenericPatternT, ExtraGenericPatternT], +): + item: GenericPatternT + +class CovariantGenericPatternBase(Generic[CovariantGenericPatternT]): ... + +class CovariantGenericPatternChild(CovariantGenericPatternBase[CovariantGenericPatternT]): + item: CovariantGenericPatternT + class GenericMemberBase(Generic[GenericPatternT]): item: GenericPatternT @@ -898,20 +912,66 @@ class FinalGenericPatternBox(Generic[GenericPatternT]): class DefaultGenericPatternBox(Generic[DefaultGenericPatternT]): value: DefaultGenericPatternT +ResultValueT = TypeVar("ResultValueT") +ResultErrorT = TypeVar("ResultErrorT") + +class MatchResult(Generic[ResultValueT, ResultErrorT]): ... + +class MatchOk(MatchResult[ResultValueT, ResultErrorT]): + __match_args__ = ("value",) + + @property + def value(self) -> ResultValueT: + raise NotImplementedError + +class MatchErr(MatchResult[ResultValueT, ResultErrorT]): + __match_args__ = ("error",) + + @property + def error(self) -> ResultErrorT: + raise NotImplementedError + +def test_match_generic_subclass_property_capture( + result: MatchResult[int, str], +) -> int: + match result: + case MatchOk(value): + reveal_type(value) # revealed: int + return value + case MatchErr(error): + reveal_type(error) # revealed: str + raise ValueError(error) + raise AssertionError + def test_match_generic_subclass_capture(value: GenericPatternBase[int]) -> None: match value: case GenericPatternChild(item=item): - # TODO: This should be `int` once generic subclass specialization is supported. - reveal_type(item) # revealed: Unknown + reveal_type(item) # revealed: int def test_match_nested_generic_subclass_capture(value: GenericPatternBase[int]) -> list[int]: match value: case GenericPatternChild(items=items): - # TODO: This should be `list[int]` once generic subclass specialization is supported. - reveal_type(items) # revealed: list[Unknown] + reveal_type(items) # revealed: list[int] return items return [] +def test_match_partially_specialized_generic_subclass( + value: GenericPatternBase[int], +) -> None: + match value: + case PartiallySpecializedGenericPatternChild(item=item): + # `ExtraGenericPatternT` is not constrained by the subject, so the pattern class does + # not have one exact specialization. + reveal_type(item) # revealed: Unknown + +def test_match_covariant_generic_subclass( + value: CovariantGenericPatternBase[int], +) -> None: + match value: + case CovariantGenericPatternChild(item=item): + # The subject constrains only one end of the possible pattern-class specializations. + reveal_type(item) # revealed: Unknown + def test_match_inherited_generic_subclass_capture( value: GenericMemberBase[GenericPatternT], ) -> GenericPatternT: diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 63a020faab8c1..9c358cbe53e97 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -38,10 +38,12 @@ use ruff_python_stdlib::identifiers::is_identifier; use super::UnionType; use super::call::CallArguments; +use super::constraints::{ConstraintSetBuilder, PathBounds, Solutions}; use super::equality::{ equality_exclusion_constraint, equality_truthiness, evaluate_type_equality, evaluate_type_inequality, }; +use super::variance::TypeVarVariance; use itertools::Itertools; use ruff_python_ast as ast; use ruff_python_ast::{BoolOp, ExprBoolOp}; @@ -1476,6 +1478,18 @@ impl<'db> PatternSuccessAnalyzer<'db> { let subject_is_final = subject_ty .nominal_class(self.db) .is_some_and(|class| class.is_final(self.db)); + let specialized_pattern_class = + if context.positional_sources.is_empty() && kind.keywords.is_empty() { + None + } else { + context.class.and_then(|pattern_class| { + original_subject_ty + .nominal_class(self.db) + .and_then(|subject_class| { + self.specialize_pattern_class_for_subject(pattern_class, subject_class) + }) + }) + }; let member_type = |name: &Name| { let original_member_ty = original_subject_ty .member(self.db, name.as_str()) @@ -1503,7 +1517,12 @@ impl<'db> PatternSuccessAnalyzer<'db> { } } - if let Some(pattern_class) = context.class + if let Some(specialized_pattern_class) = specialized_pattern_class { + member_ty = Type::instance(self.db, specialized_pattern_class) + .member(self.db, name.as_str()) + .place + .ignore_possibly_undefined(); + } else if let Some(pattern_class) = context.class && pattern_class .generic_context(self.db) .and_then(|generic_context| { @@ -1524,8 +1543,9 @@ impl<'db> PatternSuccessAnalyzer<'db> { .place .ignore_possibly_undefined(); // For example, `Child[int]` and `Base[T]` share a generic hierarchy, so a `Base` - // pattern can reuse `int` from the subject. This does not infer `Child[int]` from - // a `Base[int]` subject. + // pattern can reuse `int` from the subject. This is also the conservative fallback + // when the subject does not determine one exact specialization of the pattern + // subclass. if original_subject_ty .nominal_class(self.db) .is_some_and(|original_class| { @@ -1586,6 +1606,73 @@ impl<'db> PatternSuccessAnalyzer<'db> { .collect() } + /// Infer an exact specialization of a generic pattern subclass from a specialized base-class + /// subject. + /// + /// This intentionally handles only the case where every pattern-class type variable has one + /// exact solution. Variant base classes and pattern classes with unconstrained parameters keep + /// the existing conservative member type. + fn specialize_pattern_class_for_subject( + &self, + pattern_class: ClassLiteral<'db>, + subject_class: ClassType<'db>, + ) -> Option> { + let generic_context = pattern_class.generic_context(self.db)?; + let pattern_base = pattern_class + .identity_specialization(self.db) + .iter_mro(self.db) + .filter_map(ClassBase::into_class) + .find(|base| base.class_literal(self.db) == subject_class.class_literal(self.db))?; + + let constraints = ConstraintSetBuilder::new(); + let solutions = Type::instance(self.db, pattern_base) + .assignable_solutions_with_inferable( + self.db, + Type::instance(self.db, subject_class), + generic_context.inferable_typevars(self.db), + ) + .solve_with(|variance, path_bound| { + let Some(lower) = path_bound.lower else { + return Ok(None); + }; + if variance != TypeVarVariance::Invariant + || path_bound.upper.materialize_exact(self.db) != lower + { + return Ok(None); + } + PathBounds::default_solve(self.db, &constraints, path_bound) + }); + let Solutions::Constrained(solutions) = solutions else { + return None; + }; + let [solution] = solutions.as_slice() else { + return None; + }; + + let typevars = generic_context.variables(self.db).collect::>(); + let types = typevars + .iter() + .copied() + .map(|typevar| { + solution + .iter() + .find(|binding| binding.bound_typevar == typevar) + .map(|binding| binding.solution) + }) + .collect::>>()?; + if types.iter().any(|ty| { + typevars.iter().any(|typevar| { + ty.references_typevar(self.db, typevar.typevar(self.db).identity(self.db)) + }) + }) { + return None; + } + Some( + pattern_class + .apply_specialization(self.db, |_| generic_context.specialize(self.db, types)), + ) + } + fn class_pattern_contexts( &self, kind: &ClassPatternPredicateKind<'db>, From 90fc041adab88af2d05c3ca8e561cac7f416a9c5 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 30 Jun 2026 17:30:45 -0400 Subject: [PATCH 2/3] [ty] Specialize generic patterns per TypeVar bound arm --- .../resources/mdtest/narrow/match.md | 23 +++++++++++++++++++ crates/ty_python_semantic/src/types/narrow.rs | 4 +++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index 1833a244c28c0..894eff8648719 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -884,6 +884,15 @@ DefaultGenericPatternT = TypeVar("DefaultGenericPatternT", default=str) class GenericPatternBase(Generic[GenericPatternT]): ... +OptionalGenericPatternT = TypeVar( + "OptionalGenericPatternT", + bound=GenericPatternBase[int] | None, +) +UnionBoundGenericPatternT = TypeVar( + "UnionBoundGenericPatternT", + bound=GenericPatternBase[int] | GenericPatternBase[str], +) + class GenericPatternChild(GenericPatternBase[GenericPatternT]): item: GenericPatternT items: list[GenericPatternT] @@ -948,6 +957,20 @@ def test_match_generic_subclass_capture(value: GenericPatternBase[int]) -> None: case GenericPatternChild(item=item): reveal_type(item) # revealed: int +def test_match_generic_subclass_capture_from_optional_typevar_bound( + value: OptionalGenericPatternT, +) -> None: + match value: + case GenericPatternChild(item=item): + reveal_type(item) # revealed: int + +def test_match_generic_subclass_capture_from_union_typevar_bound( + value: UnionBoundGenericPatternT, +) -> None: + match value: + case GenericPatternChild(item=item): + reveal_type(item) # revealed: int | str + def test_match_nested_generic_subclass_capture(value: GenericPatternBase[int]) -> list[int]: match value: case GenericPatternChild(items=items): diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 9c358cbe53e97..d0ccc63d166be 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1473,6 +1473,7 @@ impl<'db> PatternSuccessAnalyzer<'db> { kind: &ClassPatternPredicateKind<'db>, context: &ClassPatternContext<'db>, original_subject_ty: Type<'db>, + filtering_subject_ty: Type<'db>, subject_ty: Type<'db>, ) -> Option>> { let subject_is_final = subject_ty @@ -1483,7 +1484,7 @@ impl<'db> PatternSuccessAnalyzer<'db> { None } else { context.class.and_then(|pattern_class| { - original_subject_ty + filtering_subject_ty .nominal_class(self.db) .and_then(|subject_class| { self.specialize_pattern_class_for_subject(pattern_class, subject_class) @@ -1719,6 +1720,7 @@ impl<'db> PatternSuccessAnalyzer<'db> { kind, context, original_subject_ty, + subject_ty, narrowed_subject_ty, )?; Some((narrowed_subject_ty, arguments)) From 378df2ebb7c6ec8f21a30052f95fdfb0dbacdd86 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 30 Jun 2026 17:52:55 -0400 Subject: [PATCH 3/3] [ty] Document and simplify generic pattern specialization --- crates/ty_python_semantic/src/types/narrow.rs | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index d0ccc63d166be..3fa31d63bbcf9 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1483,13 +1483,12 @@ impl<'db> PatternSuccessAnalyzer<'db> { if context.positional_sources.is_empty() && kind.keywords.is_empty() { None } else { - context.class.and_then(|pattern_class| { - filtering_subject_ty - .nominal_class(self.db) - .and_then(|subject_class| { - self.specialize_pattern_class_for_subject(pattern_class, subject_class) - }) - }) + context + .class + .zip(filtering_subject_ty.nominal_class(self.db)) + .and_then(|(pattern_class, subject_class)| { + self.specialize_pattern_class_for_subject(pattern_class, subject_class) + }) }; let member_type = |name: &Name| { let original_member_ty = original_subject_ty @@ -1613,6 +1612,18 @@ impl<'db> PatternSuccessAnalyzer<'db> { /// This intentionally handles only the case where every pattern-class type variable has one /// exact solution. Variant base classes and pattern classes with unconstrained parameters keep /// the existing conservative member type. + /// + /// ```python + /// class Base[T]: ... + /// + /// class Child[T](Base[T]): + /// item: T + /// + /// def f(value: Base[int]) -> None: + /// match value: + /// case Child(item=item): + /// reveal_type(item) # int + /// ``` fn specialize_pattern_class_for_subject( &self, pattern_class: ClassLiteral<'db>, @@ -1650,10 +1661,9 @@ impl<'db> PatternSuccessAnalyzer<'db> { return None; }; - let typevars = generic_context.variables(self.db).collect::>(); + let typevars = generic_context.variables(self.db); let types = typevars - .iter() - .copied() + .clone() .map(|typevar| { solution .iter() @@ -1662,7 +1672,7 @@ impl<'db> PatternSuccessAnalyzer<'db> { }) .collect::>>()?; if types.iter().any(|ty| { - typevars.iter().any(|typevar| { + typevars.clone().any(|typevar| { ty.references_typevar(self.db, typevar.typevar(self.db).identity(self.db)) }) }) {