Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 92 additions & 9 deletions crates/ty_python_semantic/resources/mdtest/narrow/match.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down
105 changes: 102 additions & 3 deletions crates/ty_python_semantic/src/types/narrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<Vec<ClassPatternArgument<'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
.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())
Expand Down Expand Up @@ -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| {
Expand All @@ -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| {
Expand Down Expand Up @@ -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<ClassType<'db>> {
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::<Option<Vec<_>>>()?;
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>,
Expand Down Expand Up @@ -1632,6 +1730,7 @@ impl<'db> PatternSuccessAnalyzer<'db> {
kind,
context,
original_subject_ty,
subject_ty,
narrowed_subject_ty,
)?;
Some((narrowed_subject_ty, arguments))
Expand Down
Loading