diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index 6c14087e70cdb..894eff8648719 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -866,25 +866,48 @@ 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]): ... +OptionalGenericPatternT = TypeVar( + "OptionalGenericPatternT", + bound=GenericPatternBase[int] | None, +) +UnionBoundGenericPatternT = TypeVar( + "UnionBoundGenericPatternT", + bound=GenericPatternBase[int] | GenericPatternBase[str], +) + 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 +921,80 @@ 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_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): - # 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..3fa31d63bbcf9 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}; @@ -1471,11 +1473,23 @@ 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 .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 + .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 .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,84 @@ 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. + /// + /// ```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>, + 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); + let types = typevars + .clone() + .map(|typevar| { + solution + .iter() + .find(|binding| binding.bound_typevar == typevar) + .map(|binding| binding.solution) + }) + .collect::>>()?; + if types.iter().any(|ty| { + typevars.clone().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>, @@ -1632,6 +1730,7 @@ impl<'db> PatternSuccessAnalyzer<'db> { kind, context, original_subject_ty, + subject_ty, narrowed_subject_ty, )?; Some((narrowed_subject_ty, arguments))