diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index c6448eb947218..6e9b50e8fb37f 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -518,6 +518,29 @@ def mapping_or_singleton_binding(value: StringMapping | None) -> None: reveal_type(item) # revealed: str | None ``` +The first two alternatives bind an `int`. A list that does not contain exactly one element reaches +the final capture instead. If that list is later changed to contain one element, the same sequence +pattern must be able to match it: + +```py +@final +class MutableOrBox: + value: int = 0 + +def failed_sequence_alternative_does_not_narrow_later_capture( + value: list[int] | MutableOrBox, +) -> None: + match value: + case [item] | MutableOrBox(value=item) | item: + reveal_type(item) # revealed: int | list[int] + if isinstance(item, list): + item.clear() + item.append(1) + match item: + case [only]: + reveal_type(item) # revealed: list[int] +``` + ## Declared pattern captures A capture still has to satisfy an earlier declaration for the same name. This uses the same @@ -659,6 +682,8 @@ A class pattern can use a variable whose type is `type[Class]`. Both the subject use the instance type described by that annotation. ```py +from typing import Literal + class IndirectPattern: ... def test_match_indirect_class_pattern( @@ -669,6 +694,22 @@ def test_match_indirect_class_pattern( case PatternClass() as item: reveal_type(item) # revealed: IndirectPattern reveal_type(value) # revealed: IndirectPattern + +class IndirectIntPattern: + tag: Literal["int"] + payload: int + +class IndirectStrPattern: + tag: Literal["str"] + payload: str + +def test_union_class_pattern_uses_members_from_matching_class( + value: object, + PatternClass: type[IndirectIntPattern] | type[IndirectStrPattern], +) -> None: + match value: + case PatternClass(tag="int", payload=item): + reveal_type(item) # revealed: int ``` ## Class pattern aliases @@ -825,15 +866,18 @@ def test_incompatible_declared_class_capture(value: PatternBox[int]) -> None: ## Generic subclass captures -We do not yet infer a generic subclass's specialization from its base class. In the first two -examples, ty therefore cannot infer `GenericPatternChild[int]` from `GenericPatternBase[int]`, so -attributes declared only on the subclass are `Unknown`. Attributes inherited from the generic base -class can still use the subject's specialization, as shown in the final example. +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. ```py -from typing import Generic, TypeVar +from typing import final, Generic +from typing_extensions import TypeVar GenericPatternT = TypeVar("GenericPatternT") +DefaultGenericPatternT = TypeVar("DefaultGenericPatternT", default=str) class GenericPatternBase(Generic[GenericPatternT]): ... @@ -845,6 +889,14 @@ class GenericMemberBase(Generic[GenericPatternT]): item: GenericPatternT class GenericMemberChild(GenericMemberBase[GenericPatternT]): ... +class IntGenericMemberChild(GenericMemberBase[int]): ... + +@final +class FinalGenericPatternBox(Generic[GenericPatternT]): + value: list[GenericPatternT] + +class DefaultGenericPatternBox(Generic[DefaultGenericPatternT]): + value: DefaultGenericPatternT def test_match_generic_subclass_capture(value: GenericPatternBase[int]) -> None: match value: @@ -856,7 +908,7 @@ def test_match_nested_generic_subclass_capture(value: GenericPatternBase[int]) - match value: case GenericPatternChild(items=items): # TODO: This should be `list[int]` once generic subclass specialization is supported. - reveal_type(items) # revealed: Unknown + reveal_type(items) # revealed: list[Unknown] return items return [] @@ -870,6 +922,23 @@ def test_match_inherited_generic_subclass_capture( return item case _: raise ValueError + +def test_match_generic_base_capture_preserves_subject_specialization( + value: IntGenericMemberChild, +) -> None: + match value: + case GenericMemberBase(item=item): + reveal_type(item) # revealed: int + +def test_match_direct_generic_pattern_preserves_declared_member(value: object) -> None: + match value: + case FinalGenericPatternBox(value=int() as item): + reveal_type(item) # revealed: Never + +def test_match_generic_pattern_ignores_typevar_default(value: object) -> None: + match value: + case DefaultGenericPatternBox(value=int() as item): + reveal_type(item) # revealed: Unknown & int ``` ## Positional class patterns @@ -1073,9 +1142,15 @@ def builtin_positional_pattern_refines_subject_alias(value: bool) -> Literal[Tru ## Overlapping class patterns Two unrelated non-final classes can have a common subclass through multiple inheritance. The -successful pattern therefore preserves both class types: +successful pattern therefore preserves both class types. Attributes from both bases remain possible, +even when one annotation is broader than the other. For a generic pattern class whose type arguments +are not known from the subject, its attributes use `Unknown`. ```py +from typing import Generic, TypeVar + +OverlapT = TypeVar("OverlapT") + class OverlapCaptureA: ... class OverlapCaptureB: @@ -1095,12 +1170,56 @@ class OverlapMemberA: class OverlapMemberB: member: str +class CompatibleOverlapMemberA: + member: object = "x" + +class CompatibleOverlapMemberB: + member: int = 1 + def test_match_class_capture_combines_overlapping_member_types( value: OverlapMemberA, ) -> None: match value: case OverlapMemberB(member=item): reveal_type(item) # revealed: int | str + +def test_match_class_capture_preserves_compatible_overlapping_member_types( + value: CompatibleOverlapMemberA, +) -> None: + match value: + case CompatibleOverlapMemberB(member=str() as item): + reveal_type(item) # revealed: str + +class GenericOverlapA: + member: int + +class GenericOverlapB(Generic[OverlapT]): + member: OverlapT + +class GenericOverlapC(GenericOverlapB[str], GenericOverlapA): + member: str + +class GenericListOverlapA: ... + +class GenericListOverlapB(Generic[OverlapT]): + values: list[OverlapT] + +class GenericListOverlapC(GenericListOverlapA, GenericListOverlapB[int]): ... + +def test_match_generic_class_capture_preserves_possible_multiple_inheritance( + value: GenericOverlapA, +) -> None: + match value: + case GenericOverlapB(member=str() as item): + reveal_type(item) # revealed: str + +def test_match_generic_container_member_keeps_loop_reachable( + value: GenericListOverlapA, +) -> None: + match value: + case GenericListOverlapB(values=items): + for item in items: + reveal_type(item) # revealed: Unknown ``` ## Class pattern captures from `Any` and `Unknown` @@ -1130,8 +1249,9 @@ def test_match_gradual_class_captures(any_value: Any, unknown_value: Unknown) -> Python reads an explicit mapping entry by calling `get` with a sentinel. A custom `get` method can therefore produce a broader type than `__getitem__`; the sentinel's type is treated as `object` when calling a custom override. The key type of an ordinary `Mapping` does not prove that another key is -absent because a custom `get` method may accept a broader set of keys. `**rest` is always a new -`dict` containing the unmatched items. +absent because a custom `get` method may accept a broader set of keys. When the subject is only +known as `object`, a successful mapping pattern gives its entries the type `object`, not `Unknown`. +`**rest` is always a new `dict` containing the unmatched items. ```py from collections.abc import Iterator, Mapping @@ -1155,6 +1275,11 @@ def test_match_dict_alias_preserves_concrete_type(value: dict[str, int]) -> None case {"item": item, **rest} as whole: reveal_type(whole) # revealed: dict[str, int] +def test_match_object_mapping_entry_type(value: object) -> None: + match value: + case {"item": item}: + reveal_type(item) # revealed: object + class CustomGet(Mapping[str, int | str]): def __getitem__(self, key: str) -> int: return 1 @@ -2168,8 +2293,9 @@ def match_named_expression_subject_capture(value: tuple[int]) -> None: ## Cycles in pattern binding types Pattern captures can affect the type of a later match subject, including through a loop or a -function defined before the capture. Direct, sequence, class, and match-self captures resolve to a -concrete type. A mapping capture conservatively retains `Unknown` from cycle recovery. +function defined before the capture. Direct, sequence, class, and built-in positional captures +resolve to a concrete type. For a mapping capture, the recursive subject is known only to be a +mapping, so its entry type is `object`. ```py def match_loop_carried_capture(flag: bool, x: int) -> None: @@ -2200,7 +2326,7 @@ def match_loop_carried_mapping_capture(flag: bool) -> None: while flag: match x: case {"value": x}: - reveal_type(x) # revealed: int | Unknown + reveal_type(x) # revealed: object def match_loop_carried_match_self_capture(flag: bool, x: int) -> None: while flag: diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index cee621e56305a..f4ab2d72d371f 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -38,8 +38,8 @@ pub(crate) use self::match_pattern::{ ClassPatternPositionalSource, callable_pattern_type, class_pattern_positional_sources, definite_match_pattern_type, definite_match_pattern_type_for_subject, exact_sequence_pattern_type, mapping_pattern_type, pattern_binding_fallthrough_type, - pattern_fallthrough_type, sequence_pattern_type_builder, singleton_pattern_type, - starred_sequence_pattern_type, typed_dict_matches_class_pattern, + sequence_pattern_type_builder, singleton_pattern_type, starred_sequence_pattern_type, + typed_dict_matches_class_pattern, }; pub(crate) use self::relation_error::{ErrorContext, ErrorContextTree, ParameterDescription}; use self::set_theoretic::KnownUnion; diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 3e74d60ce1014..63a020faab8c1 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -18,8 +18,8 @@ use crate::types::{ Type, TypeContext, TypeVarBoundOrConstraints, UnionBuilder, callable_pattern_type, class_pattern_positional_sources, definite_match_pattern_type_for_subject, exact_sequence_pattern_type, infer_expression_types, mapping_pattern_type, - pattern_binding_fallthrough_type, pattern_fallthrough_type, sequence_pattern_type_builder, - singleton_pattern_type, starred_sequence_pattern_type, typed_dict_matches_class_pattern, + pattern_binding_fallthrough_type, sequence_pattern_type_builder, singleton_pattern_type, + starred_sequence_pattern_type, typed_dict_matches_class_pattern, }; use ty_python_core::expression::Expression; use ty_python_core::frozen::FrozenMap; @@ -1394,7 +1394,7 @@ impl<'db> PatternSuccessAnalyzer<'db> { for pattern in patterns { remaining_subject_ty = - pattern_fallthrough_type(self.db, previous_pattern, remaining_subject_ty); + pattern_binding_fallthrough_type(self.db, previous_pattern, remaining_subject_ty); let alternative = self.analyze_successful_pattern(pattern, remaining_subject_ty); matched_subject_types.add_in_place(alternative.matched_subject_ty); binding_subject_types.add_in_place(alternative.binding_subject_ty); @@ -1483,7 +1483,7 @@ impl<'db> PatternSuccessAnalyzer<'db> { .ignore_possibly_undefined(); let place = subject_ty.member(self.db, name.as_str()).place; let mut member_ty = place.ignore_possibly_undefined(); - if member_ty.is_some_and(|ty| ty.is_never()) + if original_subject_ty.nominal_class(self.db).is_some() && let Type::Intersection(intersection) = subject_ty { let overlapping_member_ty = UnionType::from_elements( @@ -1503,8 +1503,8 @@ impl<'db> PatternSuccessAnalyzer<'db> { } } - if context.class.is_some_and(|pattern_class| { - pattern_class + if let Some(pattern_class) = context.class + && pattern_class .generic_context(self.db) .and_then(|generic_context| { pattern_class @@ -1517,11 +1517,43 @@ impl<'db> PatternSuccessAnalyzer<'db> { .ignore_possibly_undefined() }) .is_some_and(|ty| ty.has_typevar(self.db)) - }) { - // The pattern subclass's default specialization loses the type arguments from the - // subject's generic base. Prefer a member type already known from the subject; - // otherwise, do not treat the subclass's fallback as a declared type. - member_ty = Some(original_member_ty.unwrap_or_else(Type::unknown)); + { + let unknown_pattern_class = pattern_class.unknown_specialization(self.db); + let unknown_pattern_member_ty = Type::instance(self.db, unknown_pattern_class) + .member(self.db, name.as_str()) + .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. + if original_subject_ty + .nominal_class(self.db) + .is_some_and(|original_class| { + unknown_pattern_class.is_subtype_of_class_literal( + self.db, + original_class.class_literal(self.db), + ) || original_class.is_subtype_of_class_literal( + self.db, + unknown_pattern_class.class_literal(self.db), + ) + }) + { + // The pattern class's unknown specialization loses type arguments known + // through the related subject type. Prefer the subject's member type when it + // exists, but retain a member declared only by the pattern class. + member_ty = Some( + original_member_ty + .or(unknown_pattern_member_ty) + .unwrap_or_else(Type::unknown), + ); + } else if let Some(pattern_member_ty) = unknown_pattern_member_ty { + // Unrelated classes can overlap through multiple inheritance, so retain the + // generic pattern class's member as a possible runtime value. + member_ty = Some(UnionType::from_elements( + self.db, + member_ty.into_iter().chain([pattern_member_ty]), + )); + } } member_ty.or_else(|| (!subject_is_final).then_some(Type::unknown())) }; @@ -1554,43 +1586,91 @@ impl<'db> PatternSuccessAnalyzer<'db> { .collect() } + fn class_pattern_contexts( + &self, + kind: &ClassPatternPredicateKind<'db>, + ) -> SmallVec<[ClassPatternContext<'db>; 2]> { + let class_expr_ty = + infer_same_file_expression_type(self.db, kind.class, TypeContext::default()) + .resolve_type_alias(self.db); + let context = |class_expr_ty: Type<'db>| { + let class = class_expr_ty.as_class_literal(); + ClassPatternContext { + class, + class_ty: positive_class_pattern_type(self.db, class_expr_ty) + .unwrap_or_else(Type::object), + positional_sources: class.map_or_else( + || vec![ClassPatternPositionalSource::Unknown; kind.positional.len()], + |class| class_pattern_positional_sources(self.db, class, kind.positional.len()), + ), + } + }; + match class_expr_ty { + Type::Union(union) => union + .elements(self.db) + .iter() + .copied() + .map(context) + .collect(), + _ => smallvec![context(class_expr_ty)], + } + } + + fn class_pattern_arm( + &self, + kind: &ClassPatternPredicateKind<'db>, + context: &ClassPatternContext<'db>, + original_subject_ty: Type<'db>, + subject_ty: Type<'db>, + ) -> Option<(Type<'db>, Vec>)> { + let narrowed_subject_ty = + self.filter_class_pattern_subject_type(context.class, context.class_ty, subject_ty); + if narrowed_subject_ty.is_never() { + return None; + } + let arguments = self.class_pattern_arguments_for_arm( + kind, + context, + original_subject_ty, + narrowed_subject_ty, + )?; + Some((narrowed_subject_ty, arguments)) + } + fn analyze_successful_class_pattern( &self, kind: &ClassPatternPredicateKind<'db>, subject_ty: Type<'db>, ) -> PatternSuccessResult<'db> { - let class_expr_ty = - infer_same_file_expression_type(self.db, kind.class, TypeContext::default()); - let class = class_expr_ty.as_class_literal(); - let class_ty = - positive_class_pattern_type(self.db, class_expr_ty).unwrap_or_else(Type::object); - let context = ClassPatternContext { - class, - class_ty, - positional_sources: class.map_or_else( - || vec![ClassPatternPositionalSource::Unknown; kind.positional.len()], - |class| class_pattern_positional_sources(self.db, class, kind.positional.len()), - ), - }; + let mut matched_subject_types = UnionBuilder::new(self.db); + let mut binding_subject_types = UnionBuilder::new(self.db); + let mut bindings = BTreeMap::new(); + for context in self.class_pattern_contexts(kind) { + let result = + self.analyze_successful_class_pattern_for_context(kind, &context, subject_ty); + matched_subject_types.add_in_place(result.matched_subject_ty); + binding_subject_types.add_in_place(result.binding_subject_ty); + Self::merge_bindings(&mut bindings, result.bindings); + } + PatternSuccessResult { + matched_subject_ty: matched_subject_types.build(), + binding_subject_ty: binding_subject_types.build(), + bindings, + } + } + + fn analyze_successful_class_pattern_for_context( + &self, + kind: &ClassPatternPredicateKind<'db>, + context: &ClassPatternContext<'db>, + subject_ty: Type<'db>, + ) -> PatternSuccessResult<'db> { self.analyze_pattern_subject_arms( subject_ty, OriginalSubjectPreservation::EquivalentTypes, |analyzer, original_subject_ty, subject_ty| { - let narrowed_subject_ty = analyzer.filter_class_pattern_subject_type( - context.class, - context.class_ty, - subject_ty, - ); - if narrowed_subject_ty.is_never() { - return None; - } - - let arguments = analyzer.class_pattern_arguments_for_arm( - kind, - &context, - original_subject_ty, - narrowed_subject_ty, - )?; + let (narrowed_subject_ty, arguments) = + analyzer.class_pattern_arm(kind, context, original_subject_ty, subject_ty)?; let mut matched_subject_ty = narrowed_subject_ty; let mut binding_subject_ty = narrowed_subject_ty; let mut bindings = BTreeMap::new(); @@ -1702,37 +1782,43 @@ impl<'db> PatternSuccessAnalyzer<'db> { false } + fn mapping_pattern_key_types(&self, kind: &MappingPatternPredicateKind<'db>) -> Vec> { + kind.entries + .iter() + .map(|entry| { + infer_same_file_expression_type(self.db, entry.key, TypeContext::default()) + }) + .collect() + } + + fn mapping_pattern_arm( + &self, + subject_ty: Type<'db>, + key_types: &[Type<'db>], + ) -> Option<(Type<'db>, Vec>)> { + let narrowed_subject_ty = self.intersect_types(subject_ty, mapping_pattern_type(self.db)); + if narrowed_subject_ty.is_never() { + return None; + } + let value_types = key_types + .iter() + .map(|key_ty| self.mapping_pattern_value_type_for_arm(narrowed_subject_ty, *key_ty)) + .collect::>>()?; + Some((narrowed_subject_ty, value_types)) + } + fn analyze_successful_mapping_pattern( &self, kind: &MappingPatternPredicateKind<'db>, subject_ty: Type<'db>, ) -> PatternSuccessResult<'db> { - let key_types: Vec<_> = kind - .entries - .iter() - .map(|entry| { - infer_same_file_expression_type(self.db, entry.key, TypeContext::default()) - }) - .collect(); + let key_types = self.mapping_pattern_key_types(kind); self.analyze_pattern_subject_arms( subject_ty, OriginalSubjectPreservation::EquivalentTypes, |analyzer, _, subject_ty| { - let narrowed_subject_ty = - analyzer.intersect_types(subject_ty, mapping_pattern_type(analyzer.db)); - if narrowed_subject_ty.is_never() { - return None; - } - - let value_types: Option> = kind - .entries - .iter() - .zip(&key_types) - .map(|(_, key_ty)| { - analyzer.mapping_pattern_value_type_for_arm(subject_ty, *key_ty) - }) - .collect(); - let value_types = value_types?; + let (narrowed_subject_ty, value_types) = + analyzer.mapping_pattern_arm(subject_ty, &key_types)?; let mut bindings = BTreeMap::new(); for (entry, value_ty) in kind.entries.iter().zip(value_types) { let mut child = analyzer.analyze_successful_pattern(&entry.pattern, value_ty);