From 0c2cf7586903040436237b03aebc5bc9f0c62735 Mon Sep 17 00:00:00 2001 From: David Peter Date: Fri, 31 Oct 2025 16:00:30 +0100 Subject: [PATCH] [ty] Do not promote literals in contravariant position (#21164) ## Summary closes https://github.com/astral-sh/ty/issues/1463 ## Test Plan Regression tests --- .../resources/mdtest/literal_promotion.md | 32 ++++++++++ crates/ty_python_semantic/src/types.rs | 60 ++++++++++++++----- .../src/types/signatures.rs | 15 ++--- 3 files changed, 84 insertions(+), 23 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/literal_promotion.md diff --git a/crates/ty_python_semantic/resources/mdtest/literal_promotion.md b/crates/ty_python_semantic/resources/mdtest/literal_promotion.md new file mode 100644 index 0000000000..726ca59d20 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/literal_promotion.md @@ -0,0 +1,32 @@ +# Literal promotion + +There are certain places where we promote literals to their common supertype: + +```py +reveal_type([1, 2, 3]) # revealed: list[Unknown | int] +reveal_type({"a", "b", "c"}) # revealed: set[Unknown | str] +``` + +This promotion should not take place if the literal type appears in contravariant position: + +```py +from typing import Callable, Literal + +def in_negated_position(non_zero_number: int): + if non_zero_number == 0: + raise ValueError() + + reveal_type(non_zero_number) # revealed: int & ~Literal[0] + + reveal_type([non_zero_number]) # revealed: list[Unknown | (int & ~Literal[0])] + +def in_parameter_position(callback: Callable[[Literal[1]], None]): + reveal_type(callback) # revealed: (Literal[1], /) -> None + + reveal_type([callback]) # revealed: list[Unknown | ((Literal[1], /) -> None)] + +def double_negation(callback: Callable[[Callable[[Literal[1]], None]], None]): + reveal_type(callback) # revealed: ((Literal[1], /) -> None, /) -> None + + reveal_type([callback]) # revealed: list[Unknown | (((int, /) -> None, /) -> None)] +``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index a4eb563e6a..be2fb264d8 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1270,7 +1270,11 @@ impl<'db> Type<'db> { /// /// It also avoids literal promotion if a literal type annotation was provided as type context. pub(crate) fn promote_literals(self, db: &'db dyn Db, tcx: TypeContext<'db>) -> Type<'db> { - self.apply_type_mapping(db, &TypeMapping::PromoteLiterals, tcx) + self.apply_type_mapping( + db, + &TypeMapping::PromoteLiterals(PromoteLiteralsMode::On), + tcx, + ) } /// Like [`Type::promote_literals`], but does not recurse into nested types. @@ -6765,7 +6769,7 @@ impl<'db> Type<'db> { self } } - TypeMapping::PromoteLiterals + TypeMapping::PromoteLiterals(_) | TypeMapping::ReplaceParameterDefaults | TypeMapping::BindLegacyTypevars(_) => self, TypeMapping::Materialize(materialization_kind) => { @@ -6779,7 +6783,7 @@ impl<'db> Type<'db> { } TypeMapping::Specialization(_) | TypeMapping::PartialSpecialization(_) | - TypeMapping::PromoteLiterals | + TypeMapping::PromoteLiterals(_) | TypeMapping::BindSelf(_) | TypeMapping::ReplaceSelf { .. } | TypeMapping::Materialize(_) | @@ -6790,7 +6794,7 @@ impl<'db> Type<'db> { let function = Type::FunctionLiteral(function.apply_type_mapping_impl(db, type_mapping, tcx, visitor)); match type_mapping { - TypeMapping::PromoteLiterals => function.promote_literals_impl(db, tcx), + TypeMapping::PromoteLiterals(PromoteLiteralsMode::On) => function.promote_literals_impl(db, tcx), _ => function } } @@ -6867,13 +6871,9 @@ impl<'db> Type<'db> { builder = builder.add_positive(positive.apply_type_mapping_impl(db, type_mapping, tcx, visitor)); } - let flipped_mapping = match type_mapping { - TypeMapping::Materialize(materialization_kind) => &TypeMapping::Materialize(materialization_kind.flip()), - _ => type_mapping, - }; for negative in intersection.negative(db) { builder = - builder.add_negative(negative.apply_type_mapping_impl(db, flipped_mapping, tcx, visitor)); + builder.add_negative(negative.apply_type_mapping_impl(db, &type_mapping.flip(), tcx, visitor)); } builder.build() } @@ -6902,8 +6902,9 @@ impl<'db> Type<'db> { TypeMapping::BindSelf(_) | TypeMapping::ReplaceSelf { .. } | TypeMapping::Materialize(_) | - TypeMapping::ReplaceParameterDefaults => self, - TypeMapping::PromoteLiterals => self.promote_literals_impl(db, tcx) + TypeMapping::ReplaceParameterDefaults | + TypeMapping::PromoteLiterals(PromoteLiteralsMode::Off) => self, + TypeMapping::PromoteLiterals(PromoteLiteralsMode::On) => self.promote_literals_impl(db, tcx) } Type::Dynamic(_) => match type_mapping { @@ -6912,7 +6913,7 @@ impl<'db> Type<'db> { TypeMapping::BindLegacyTypevars(_) | TypeMapping::BindSelf(_) | TypeMapping::ReplaceSelf { .. } | - TypeMapping::PromoteLiterals | + TypeMapping::PromoteLiterals(_) | TypeMapping::ReplaceParameterDefaults => self, TypeMapping::Materialize(materialization_kind) => match materialization_kind { MaterializationKind::Top => Type::object(), @@ -7456,6 +7457,21 @@ fn apply_specialization_cycle_initial<'db>( Type::Never } +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize)] +pub enum PromoteLiteralsMode { + On, + Off, +} + +impl PromoteLiteralsMode { + const fn flip(self) -> Self { + match self { + PromoteLiteralsMode::On => PromoteLiteralsMode::Off, + PromoteLiteralsMode::Off => PromoteLiteralsMode::On, + } + } +} + /// A mapping that can be applied to a type, producing another type. This is applied inductively to /// the components of complex types. /// @@ -7470,7 +7486,7 @@ pub enum TypeMapping<'a, 'db> { PartialSpecialization(PartialSpecialization<'a, 'db>), /// Replaces any literal types with their corresponding promoted type form (e.g. `Literal["string"]` /// to `str`, or `def _() -> int` to `Callable[[], int]`). - PromoteLiterals, + PromoteLiterals(PromoteLiteralsMode), /// Binds a legacy typevar with the generic context (class, function, type alias) that it is /// being used in. BindLegacyTypevars(BindingContext<'db>), @@ -7495,7 +7511,7 @@ impl<'db> TypeMapping<'_, 'db> { match self { TypeMapping::Specialization(_) | TypeMapping::PartialSpecialization(_) - | TypeMapping::PromoteLiterals + | TypeMapping::PromoteLiterals(_) | TypeMapping::BindLegacyTypevars(_) | TypeMapping::Materialize(_) | TypeMapping::ReplaceParameterDefaults => context, @@ -7521,6 +7537,22 @@ impl<'db> TypeMapping<'_, 'db> { ), } } + + /// Returns a new `TypeMapping` that should be applied in contravariant positions. + pub(crate) fn flip(&self) -> Self { + match self { + TypeMapping::Materialize(materialization_kind) => { + TypeMapping::Materialize(materialization_kind.flip()) + } + TypeMapping::PromoteLiterals(mode) => TypeMapping::PromoteLiterals(mode.flip()), + TypeMapping::Specialization(_) + | TypeMapping::PartialSpecialization(_) + | TypeMapping::BindLegacyTypevars(_) + | TypeMapping::BindSelf(_) + | TypeMapping::ReplaceSelf { .. } + | TypeMapping::ReplaceParameterDefaults => self.clone(), + } + } } /// A Salsa-tracked constraint set. This is only needed to have something appropriately small to diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index b0ff205e48..11979100bb 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -509,20 +509,17 @@ impl<'db> Signature<'db> { tcx: TypeContext<'db>, visitor: &ApplyTypeMappingVisitor<'db>, ) -> Self { - let flipped_mapping = match type_mapping { - TypeMapping::Materialize(materialization_kind) => { - &TypeMapping::Materialize(materialization_kind.flip()) - } - _ => type_mapping, - }; Self { generic_context: self .generic_context .map(|context| type_mapping.update_signature_generic_context(db, context)), definition: self.definition, - parameters: self - .parameters - .apply_type_mapping_impl(db, flipped_mapping, tcx, visitor), + parameters: self.parameters.apply_type_mapping_impl( + db, + &type_mapping.flip(), + tcx, + visitor, + ), return_ty: self .return_ty .map(|ty| ty.apply_type_mapping_impl(db, type_mapping, tcx, visitor)),