From a273993aa99dfd0a24ec504874bb0f9d2136342d Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Sat, 9 May 2026 16:35:47 -0700 Subject: [PATCH 1/6] codex --- crates/ty_python_semantic/src/types.rs | 9 +- .../src/types/class/known.rs | 13 +++ .../src/types/class_base.rs | 1 + .../ty_python_semantic/src/types/display.rs | 3 + .../src/types/infer/builder.rs | 90 +++++++++++++++++-- .../types/infer/builder/type_expression.rs | 12 +++ .../src/types/known_instance.rs | 33 ++++++- crates/ty_python_semantic/src/types/narrow.rs | 44 ++++++++- .../ty_python_semantic/src/types/relation.rs | 16 ++++ 9 files changed, 212 insertions(+), 9 deletions(-) diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index f0c78f413e30c..e49af8eb49915 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -68,7 +68,9 @@ use crate::types::generics::{ ApplySpecialization, InferableTypeVars, Specialization, bind_typevar, }; use crate::types::infer::InferenceFlags; -use crate::types::known_instance::{InternedConstraintSet, InternedType, UnionTypeInstance}; +use crate::types::known_instance::{ + InternedConstraintSet, InternedType, SentinelInstance, UnionTypeInstance, +}; pub use crate::types::method::{BoundMethodType, KnownBoundMethodType, WrapperDescriptorKind}; use crate::types::mro::{MroIterator, StaticMroError}; pub(crate) use crate::types::narrow::{NarrowingConstraint, infer_narrowing_constraint}; @@ -2237,6 +2239,7 @@ impl<'db> Type<'db> { !(special_form.check_module(KnownModule::Typing) && special_form.check_module(KnownModule::TypingExtensions)) } + Type::KnownInstance(KnownInstanceType::Sentinel(_)) => true, Type::KnownInstance(_) => false, Type::Callable(_) => { // A callable type is never a singleton because for any given signature, @@ -5402,6 +5405,9 @@ impl<'db> Type<'db> { } KnownInstanceType::Callable(callable) => Ok(Type::Callable(*callable)), KnownInstanceType::LiteralStringAlias(ty) => Ok(ty.inner(db)), + KnownInstanceType::Sentinel(sentinel) => { + Ok(Type::KnownInstance(KnownInstanceType::Sentinel(*sentinel))) + } KnownInstanceType::FunctoolsPartial(_) => Err(InvalidTypeExpressionError { invalid_expressions: smallvec_inline![InvalidTypeExpression::InvalidType( *self, scope_id @@ -6133,6 +6139,7 @@ impl<'db> Type<'db> { | KnownInstanceType::LiteralStringAlias(_) | KnownInstanceType::NamedTupleSpec(_) | KnownInstanceType::NewType(_) + | KnownInstanceType::Sentinel(_) | KnownInstanceType::FunctoolsPartial(_) => { // TODO: For some of these, we may need to try to find legacy typevars in inner types. } diff --git a/crates/ty_python_semantic/src/types/class/known.rs b/crates/ty_python_semantic/src/types/class/known.rs index 8ceb0991f5da2..4e76876406f56 100644 --- a/crates/ty_python_semantic/src/types/class/known.rs +++ b/crates/ty_python_semantic/src/types/class/known.rs @@ -117,6 +117,7 @@ pub enum KnownClass { Mapping, // typing_extensions ExtensionsTypeVar, // must be distinct from typing.TypeVar, backports new features + Sentinel, // Collections ChainMap, Counter, @@ -180,6 +181,7 @@ impl KnownClass { | Self::ParamSpecArgs | Self::ParamSpecKwargs | Self::TypeVarTuple + | Self::Sentinel | Self::Super | Self::WrapperDescriptorType | Self::UnionType @@ -325,6 +327,7 @@ impl KnownClass { | KnownClass::ParamSpecArgs | KnownClass::ParamSpecKwargs | KnownClass::TypeVarTuple + | KnownClass::Sentinel | KnownClass::TypeAliasType | KnownClass::NoDefaultType | KnownClass::NewType @@ -419,6 +422,7 @@ impl KnownClass { | KnownClass::ParamSpecArgs | KnownClass::ParamSpecKwargs | KnownClass::TypeVarTuple + | KnownClass::Sentinel | KnownClass::TypeAliasType | KnownClass::NoDefaultType | KnownClass::NewType @@ -513,6 +517,7 @@ impl KnownClass { | KnownClass::ParamSpecArgs | KnownClass::ParamSpecKwargs | KnownClass::TypeVarTuple + | KnownClass::Sentinel | KnownClass::TypeAliasType | KnownClass::NoDefaultType | KnownClass::NewType @@ -610,6 +615,7 @@ impl KnownClass { | Self::ParamSpecArgs | Self::ParamSpecKwargs | Self::TypeVarTuple + | Self::Sentinel | Self::TypeAliasType | Self::NoDefaultType | Self::NewType @@ -720,6 +726,7 @@ impl KnownClass { | KnownClass::ParamSpecKwargs | KnownClass::ProtocolMeta | KnownClass::TypeVarTuple + | KnownClass::Sentinel | KnownClass::TypeAliasType | KnownClass::NoDefaultType | KnownClass::NewType @@ -797,6 +804,7 @@ impl KnownClass { Self::ParamSpecArgs => "ParamSpecArgs", Self::ParamSpecKwargs => "ParamSpecKwargs", Self::TypeVarTuple => "TypeVarTuple", + Self::Sentinel => "Sentinel", Self::TypeAliasType => "TypeAliasType", Self::NoDefaultType => "_NoDefaultType", Self::NewType => "NewType", @@ -1193,6 +1201,7 @@ impl KnownClass { Self::TypeAliasType | Self::ExtensionsTypeVar | Self::TypeVarTuple + | Self::Sentinel | Self::ExtensionsParamSpec | Self::ParamSpecArgs | Self::ParamSpecKwargs @@ -1315,6 +1324,7 @@ impl KnownClass { | Self::ParamSpecArgs | Self::ParamSpecKwargs | Self::TypeVarTuple + | Self::Sentinel | Self::Enum | Self::EnumType | Self::Auto @@ -1413,6 +1423,7 @@ impl KnownClass { | Self::ParamSpecArgs | Self::ParamSpecKwargs | Self::TypeVarTuple + | Self::Sentinel | Self::Enum | Self::EnumType | Self::Auto @@ -1506,6 +1517,7 @@ impl KnownClass { "ParamSpecArgs" => &[Self::ParamSpecArgs], "ParamSpecKwargs" => &[Self::ParamSpecKwargs], "TypeVarTuple" => &[Self::TypeVarTuple], + "Sentinel" => &[Self::Sentinel], "ChainMap" => &[Self::ChainMap], "Counter" => &[Self::Counter], "defaultdict" => &[Self::DefaultDict], @@ -1632,6 +1644,7 @@ impl KnownClass { | Self::ExtensionsTypeVar | Self::ParamSpec | Self::ExtensionsParamSpec + | Self::Sentinel | Self::NamedTupleLike | Self::ConstraintSet | Self::GenericContext diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs index 0c8242c2c8f12..5cf3c1b5a2bd7 100644 --- a/crates/ty_python_semantic/src/types/class_base.rs +++ b/crates/ty_python_semantic/src/types/class_base.rs @@ -191,6 +191,7 @@ impl<'db> ClassBase<'db> { | KnownInstanceType::Literal(_) | KnownInstanceType::LiteralStringAlias(_) | KnownInstanceType::NamedTupleSpec(_) + | KnownInstanceType::Sentinel(_) // A class inheriting from a newtype would make intuitive sense, but newtype // wrappers are just identity callables at runtime, so this sort of inheritance // doesn't work and isn't allowed. diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index 2c1baa76fffbc..c85b684f332cc 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -3109,6 +3109,9 @@ impl<'db> FmtDetailed<'db> for DisplayKnownInstanceRepr<'db> { f.with_type(ty).write_str(declaration.name(self.db))?; f.write_str("'>") } + KnownInstanceType::Sentinel(sentinel) => { + f.with_type(ty).write_str(sentinel.name(self.db).as_str()) + } KnownInstanceType::NamedTupleSpec(_) => f.write_str("NamedTupleSpec"), KnownInstanceType::FunctoolsPartial(partial) => { f.write_str("partial[")?; diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 1ca6f3c454f3f..e604b7043c9be 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -92,11 +92,11 @@ use crate::types::{ CallDunderError, CallableBinding, CallableType, CallableTypes, ClassType, DynamicType, InferenceFlags, InternedConstraintSet, InternedType, IntersectionBuilder, IntersectionType, KnownClass, KnownInstanceType, KnownUnion, LiteralValueTypeKind, MemberLookupPolicy, - ParamSpecAttrKind, Parameter, ParameterForm, Parameters, Signature, SpecialFormType, - SubclassOfType, Type, TypeAliasType, TypeAndQualifiers, TypeContext, TypeQualifiers, - TypeVarBoundOrConstraints, TypeVarKind, TypeVarVariance, TypedDictType, UnionAccumulator, - UnionBuilder, UnionType, binding_type, infer_complete_scope_types, infer_scope_types, - todo_type, + ParamSpecAttrKind, Parameter, ParameterForm, Parameters, SentinelInstance, Signature, + SpecialFormType, SubclassOfType, Type, TypeAliasType, TypeAndQualifiers, TypeContext, + TypeQualifiers, TypeVarBoundOrConstraints, TypeVarKind, TypeVarVariance, TypedDictType, + UnionAccumulator, UnionBuilder, UnionType, binding_type, infer_complete_scope_types, + infer_scope_types, todo_type, }; use crate::{AnalysisSettings, Db, FxIndexSet, Program}; use ty_python_core::ast_ids::ScopedUseId; @@ -3395,6 +3395,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Some(KnownClass::TypeAliasType) => { self.infer_typealiastype_call(target, call_expr, definition) } + Some(KnownClass::Sentinel) => self + .infer_sentinel_expression(target, call_expr, definition) + .unwrap_or_else(|| { + self.infer_call_expression_impl(call_expr, callable_type, tcx) + }), Some(_) | None => { self.infer_call_expression_impl(call_expr, callable_type, tcx) } @@ -3527,6 +3532,81 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ))) } + fn infer_sentinel_expression( + &mut self, + target: &ast::Expr, + call_expr: &ast::ExprCall, + definition: Definition<'db>, + ) -> Option> { + if !self.sentinel_definition_scope_is_supported() { + return None; + } + + let ast::Expr::Name(ast::ExprName { + id: target_name, .. + }) = target + else { + return None; + }; + + let ast::Arguments { + args, + keywords, + range: _, + node_index: _, + } = &call_expr.arguments; + + if !keywords.is_empty() || args.iter().any(|arg| arg.is_starred_expr()) { + return None; + } + + let name_arg = match &**args { + [name_arg] | [name_arg, _] => name_arg, + _ => return None, + }; + + let name_ty = self.infer_expression(name_arg, TypeContext::default()); + let sentinel_name = name_ty.as_string_literal()?; + + let Some(repr_arg) = args.get(1) else { + return Some(Type::KnownInstance(KnownInstanceType::Sentinel( + SentinelInstance::new(self.db(), target_name.clone(), sentinel_name, definition), + ))); + }; + + let repr_ty = self.infer_expression(repr_arg, TypeContext::default()); + if repr_ty.as_string_literal().is_none() && !repr_arg.is_none_literal_expr() { + return None; + } + + Some(Type::KnownInstance(KnownInstanceType::Sentinel( + SentinelInstance::new(self.db(), target_name.clone(), sentinel_name, definition), + ))) + } + + fn sentinel_definition_scope_is_supported(&self) -> bool { + let db = self.db(); + let mut scope_id = self.scope.file_scope_id(db); + + loop { + let scope = self.index.scope(scope_id); + match scope.node().scope_kind() { + ScopeKind::Module => return true, + ScopeKind::Class => {} + ScopeKind::Function + | ScopeKind::Lambda + | ScopeKind::Comprehension + | ScopeKind::TypeAlias + | ScopeKind::TypeParams => return false, + } + + let Some(parent) = scope.parent() else { + return false; + }; + scope_id = parent; + } + } + fn infer_assignment_deferred(&mut self, target: &ast::Expr, value: &'ast ast::Expr) { // Infer deferred bounds/constraints/defaults of a legacy TypeVar / ParamSpec / NewType, // and field types for functional TypedDict. diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs index 43e88b15cda2a..db95cde13e415 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs @@ -1607,6 +1607,18 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } Type::unknown() } + KnownInstanceType::Sentinel(sentinel) => { + if !self.in_string_annotation() { + self.infer_expression(&subscript.slice, TypeContext::default()); + } + if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, subscript) { + builder.into_diagnostic(format_args!( + "`{}` is a sentinel and cannot be specialized", + sentinel.name(self.db()) + )); + } + Type::unknown() + } KnownInstanceType::NamedTupleSpec(_) => { if !self.in_string_annotation() { self.infer_expression(&subscript.slice, TypeContext::default()); diff --git a/crates/ty_python_semantic/src/types/known_instance.rs b/crates/ty_python_semantic/src/types/known_instance.rs index a3183af1b6775..bd3aeedc151f7 100644 --- a/crates/ty_python_semantic/src/types/known_instance.rs +++ b/crates/ty_python_semantic/src/types/known_instance.rs @@ -1,4 +1,5 @@ use itertools::Either; +use ruff_python_ast::name::Name; use crate::{ Db, DisplaySettings, @@ -112,6 +113,9 @@ pub enum KnownInstanceType<'db> { /// subtype of `base` in type expressions. See the `struct NewType` payload for an example. NewType(NewType<'db>), + /// A single sentinel object created with `typing_extensions.Sentinel`. + Sentinel(SentinelInstance<'db>), + /// The inferred spec for a functional `NamedTuple` class. NamedTupleSpec(NamedTupleSpec<'db>), @@ -168,6 +172,9 @@ pub(super) fn walk_known_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Size KnownInstanceType::NewType(newtype) => { visitor.visit_type(db, newtype.concrete_base_type(db)); } + KnownInstanceType::Sentinel(_) => { + // Nothing to visit + } KnownInstanceType::NamedTupleSpec(spec) => { for field in spec.fields(db) { visitor.visit_type(db, field.ty); @@ -231,6 +238,7 @@ impl<'db> KnownInstanceType<'db> { class_type.recursive_type_normalized_impl(db, div, true) }) .map(Self::NewType), + Self::Sentinel(sentinel) => Some(Self::Sentinel(sentinel)), Self::GenericContext(generic) => Some(Self::GenericContext(generic)), Self::Specialization(specialization) => specialization .recursive_type_normalized_impl(db, div, true) @@ -267,6 +275,7 @@ impl<'db> KnownInstanceType<'db> { | Self::Callable(_) => KnownClass::GenericAlias, Self::LiteralStringAlias(_) => KnownClass::Str, Self::NewType(_) => KnownClass::NewType, + Self::Sentinel(_) => KnownClass::Sentinel, Self::NamedTupleSpec(_) => KnownClass::Sequence, Self::FunctoolsPartial(_) => KnownClass::FunctoolsPartial, } @@ -358,7 +367,8 @@ impl<'db> KnownInstanceType<'db> { | KnownInstanceType::Literal(_) | KnownInstanceType::LiteralStringAlias(_) | KnownInstanceType::NamedTupleSpec(_) - | KnownInstanceType::NewType(_) => { + | KnownInstanceType::NewType(_) + | KnownInstanceType::Sentinel(_) => { // TODO: For some of these, we may need to apply the type mapping to inner types. Type::KnownInstance(self) } @@ -366,6 +376,27 @@ impl<'db> KnownInstanceType<'db> { } } +/// Contains information about a sentinel object created with `typing_extensions.Sentinel`. +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +pub struct SentinelInstance<'db> { + pub name: Name, + pub sentinel_name: StringLiteralType<'db>, + pub definition: Definition<'db>, +} + +impl get_size2::GetSize for SentinelInstance<'_> {} + +impl<'db> SentinelInstance<'db> { + pub(crate) fn is_same_sentinel(self, db: &'db dyn Db, other: Self) -> bool { + let self_definition = self.definition(db); + let other_definition = other.definition(db); + + self_definition.file(db) == other_definition.file(db) + && self_definition.file_scope(db) == other_definition.file_scope(db) + && self_definition.place(db) == other_definition.place(db) + } +} + /// Data regarding a `warnings.deprecated` or `typing_extensions.deprecated` decorator. #[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, salsa::Update, get_size2::GetSize)] pub struct DeprecatedInstance<'db> { diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 805da7c2b536f..ae6f8aa02a76f 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1250,6 +1250,24 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { } } + fn identity_narrowed_union<'db>( + db: &'db dyn Db, + subject_ty: Type<'db>, + singleton_ty: Type<'db>, + is_positive_check: bool, + ) -> Option> { + let union = subject_ty.as_union_like(db)?; + + let filtered = union.filter(db, |elem| { + if is_positive_check { + !elem.is_disjoint_from(db, singleton_ty) + } else { + !elem.is_subtype_of(db, singleton_ty) + } + }); + (filtered != Type::Union(union)).then_some(filtered) + } + let ast::ExprCompare { range: _, node_index: _, @@ -1534,7 +1552,18 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { && let Some(ty) = self.evaluate_expr_compare_op(lhs_ty, rhs_ty, *op, is_positive) { let place = self.expect_place(&narrowable); - let constraint = NarrowingConstraint::intersection(ty); + let constraint = if matches!(*op, ast::CmpOp::Is | ast::CmpOp::IsNot) + && rhs_ty.is_singleton(self.db) + && let Some(filtered) = identity_narrowed_union( + self.db, + lhs_ty, + rhs_ty, + is_positive == (*op == ast::CmpOp::Is), + ) { + NarrowingConstraint::replacement(filtered) + } else { + NarrowingConstraint::intersection(ty) + }; constraints .entry(place) .and_modify(|existing| { @@ -1556,7 +1585,18 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { && let Some(ty) = self.evaluate_expr_compare_op(rhs_ty, lhs_ty, *op, is_positive) { let place = self.expect_place(&narrowable); - let constraint = NarrowingConstraint::intersection(ty); + let constraint = if matches!(*op, ast::CmpOp::Is | ast::CmpOp::IsNot) + && lhs_ty.is_singleton(self.db) + && let Some(filtered) = identity_narrowed_union( + self.db, + rhs_ty, + lhs_ty, + is_positive == (*op == ast::CmpOp::Is), + ) { + NarrowingConstraint::replacement(filtered) + } else { + NarrowingConstraint::intersection(ty) + }; constraints .entry(place) .and_modify(|existing| { diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index f2ad50a704296..4bf819450f6ec 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -1031,6 +1031,14 @@ impl<'a, 'c, 'db> TypeRelationChecker<'a, 'c, 'db> { self.check_callable_pair(db, source_partial.partial(db), target_partial.partial(db)) }), + ( + Type::KnownInstance(KnownInstanceType::Sentinel(source_sentinel)), + Type::KnownInstance(KnownInstanceType::Sentinel(target_sentinel)), + ) => ConstraintSet::from_bool( + self.constraints, + source_sentinel.is_same_sentinel(db, target_sentinel), + ), + // When checking `FunctoolsPartial <: functools.partial[T]`, we need to specialize // the nominal instance with the partial's return type so the check is precise. ( @@ -2376,6 +2384,14 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { Type::KnownBoundMethod(KnownBoundMethodType::PropertyDunderDelete(right)), ) => self.check_property_instance_pair(db, left, right), + ( + Type::KnownInstance(KnownInstanceType::Sentinel(left_sentinel)), + Type::KnownInstance(KnownInstanceType::Sentinel(right_sentinel)), + ) => ConstraintSet::from_bool( + self.constraints, + !left_sentinel.is_same_sentinel(db, right_sentinel), + ), + // any single-valued type is disjoint from another single-valued type // iff the two types are nonequal ( From dc68a38c1b0e957dacf29011c327e7f0acc936ed Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Sat, 9 May 2026 17:57:42 -0700 Subject: [PATCH 2/6] amend --- .../src/types/infer/builder.rs | 21 +++++++-- crates/ty_python_semantic/src/types/narrow.rs | 44 +------------------ 2 files changed, 19 insertions(+), 46 deletions(-) diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index e604b7043c9be..67a5cdf9107e2 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -3556,19 +3556,32 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { node_index: _, } = &call_expr.arguments; - if !keywords.is_empty() || args.iter().any(|arg| arg.is_starred_expr()) { + if args.iter().any(|arg| arg.is_starred_expr()) { return None; } - let name_arg = match &**args { - [name_arg] | [name_arg, _] => name_arg, + let (name_arg, mut repr_arg) = match &**args { + [name_arg] => (name_arg, None), + [name_arg, repr_arg] => (name_arg, Some(repr_arg)), _ => return None, }; + for keyword in keywords { + let Some(keyword_name) = &keyword.arg else { + return None; + }; + + if keyword_name.as_str() != "repr" || repr_arg.is_some() { + return None; + } + + repr_arg = Some(&keyword.value); + } + let name_ty = self.infer_expression(name_arg, TypeContext::default()); let sentinel_name = name_ty.as_string_literal()?; - let Some(repr_arg) = args.get(1) else { + let Some(repr_arg) = repr_arg else { return Some(Type::KnownInstance(KnownInstanceType::Sentinel( SentinelInstance::new(self.db(), target_name.clone(), sentinel_name, definition), ))); diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index ae6f8aa02a76f..805da7c2b536f 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1250,24 +1250,6 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { } } - fn identity_narrowed_union<'db>( - db: &'db dyn Db, - subject_ty: Type<'db>, - singleton_ty: Type<'db>, - is_positive_check: bool, - ) -> Option> { - let union = subject_ty.as_union_like(db)?; - - let filtered = union.filter(db, |elem| { - if is_positive_check { - !elem.is_disjoint_from(db, singleton_ty) - } else { - !elem.is_subtype_of(db, singleton_ty) - } - }); - (filtered != Type::Union(union)).then_some(filtered) - } - let ast::ExprCompare { range: _, node_index: _, @@ -1552,18 +1534,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { && let Some(ty) = self.evaluate_expr_compare_op(lhs_ty, rhs_ty, *op, is_positive) { let place = self.expect_place(&narrowable); - let constraint = if matches!(*op, ast::CmpOp::Is | ast::CmpOp::IsNot) - && rhs_ty.is_singleton(self.db) - && let Some(filtered) = identity_narrowed_union( - self.db, - lhs_ty, - rhs_ty, - is_positive == (*op == ast::CmpOp::Is), - ) { - NarrowingConstraint::replacement(filtered) - } else { - NarrowingConstraint::intersection(ty) - }; + let constraint = NarrowingConstraint::intersection(ty); constraints .entry(place) .and_modify(|existing| { @@ -1585,18 +1556,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { && let Some(ty) = self.evaluate_expr_compare_op(rhs_ty, lhs_ty, *op, is_positive) { let place = self.expect_place(&narrowable); - let constraint = if matches!(*op, ast::CmpOp::Is | ast::CmpOp::IsNot) - && lhs_ty.is_singleton(self.db) - && let Some(filtered) = identity_narrowed_union( - self.db, - rhs_ty, - lhs_ty, - is_positive == (*op == ast::CmpOp::Is), - ) { - NarrowingConstraint::replacement(filtered) - } else { - NarrowingConstraint::intersection(ty) - }; + let constraint = NarrowingConstraint::intersection(ty); constraints .entry(place) .and_modify(|existing| { From 3378274da8aaa4e9897ae5f21ac79b6d64c68ab7 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Sat, 9 May 2026 17:58:58 -0700 Subject: [PATCH 3/6] test --- .../resources/mdtest/sentinels.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 crates/ty_python_semantic/resources/mdtest/sentinels.md diff --git a/crates/ty_python_semantic/resources/mdtest/sentinels.md b/crates/ty_python_semantic/resources/mdtest/sentinels.md new file mode 100644 index 0000000000000..a295fb26d0f2e --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/sentinels.md @@ -0,0 +1,117 @@ +# Sentinels + +## `typing_extensions.Sentinel` + +Sentinels constructed with `typing_extensions.Sentinel` can be used directly in +type expressions: + +```py +from typing_extensions import Sentinel, assert_type + +MISSING = Sentinel("MISSING") +OTHER = Sentinel("OTHER") +WITH_REPR = Sentinel("WITH_REPR", "") +WITH_REPR_KEYWORD = Sentinel("WITH_REPR_KEYWORD", repr="") + +reveal_type(MISSING) # revealed: MISSING +reveal_type(OTHER) # revealed: OTHER +reveal_type(WITH_REPR) # revealed: WITH_REPR +reveal_type(WITH_REPR_KEYWORD) # revealed: WITH_REPR_KEYWORD + +def accepts_missing(x: MISSING) -> None: ... +def accepts_other(x: OTHER) -> None: ... + +accepts_missing(MISSING) +accepts_missing(OTHER) # error: [invalid-argument-type] +accepts_other(OTHER) +accepts_other(MISSING) # error: [invalid-argument-type] + +def bad_default(x: int = MISSING) -> None: # error: [invalid-parameter-default] + pass + +def good_default(x: int | MISSING | OTHER = MISSING) -> None: + if x is MISSING: + assert_type(x, MISSING) + reveal_type(x) # revealed: MISSING + else: + assert_type(x, int | OTHER) + reveal_type(x) # revealed: int | OTHER + +good_default(1) +good_default(MISSING) +good_default(OTHER) + +def reverse_check(x: int | MISSING | OTHER) -> None: + if MISSING is x: + assert_type(x, MISSING) + reveal_type(x) # revealed: MISSING + else: + assert_type(x, int | OTHER) + reveal_type(x) # revealed: int | OTHER + +def negative_check(x: int | MISSING | OTHER) -> None: + if x is not MISSING: + assert_type(x, int | OTHER) + reveal_type(x) # revealed: int | OTHER + else: + assert_type(x, MISSING) + reveal_type(x) # revealed: MISSING + +def reverse_negative_check(x: int | MISSING | OTHER) -> None: + if MISSING is not x: + assert_type(x, int | OTHER) + reveal_type(x) # revealed: int | OTHER + else: + assert_type(x, MISSING) + reveal_type(x) # revealed: MISSING +``` + +Sentinels declared in class scope can also be used in type expressions: + +```py +from typing_extensions import Sentinel, assert_type + +class C: + MARKER = Sentinel("C.MARKER") + +def accepts_marker(x: C.MARKER) -> None: ... + +accepts_marker(C.MARKER) + +def class_default(x: int | C.MARKER = C.MARKER) -> None: + if x is C.MARKER: + assert_type(x, C.MARKER) + reveal_type(x) # revealed: MARKER + else: + assert_type(x, int) + reveal_type(x) # revealed: int + +def class_reverse_negative(x: int | C.MARKER) -> None: + if C.MARKER is not x: + assert_type(x, int) + reveal_type(x) # revealed: int + else: + assert_type(x, C.MARKER) + reveal_type(x) # revealed: MARKER +``` + +Sentinel declarations are recognized only in module and class scope: + +```py +from typing_extensions import Sentinel + +def outer(): + LOCAL = Sentinel("LOCAL") + + def inner(x: LOCAL) -> None: ... # error: [invalid-type-form] +``` + +Sentinels are not generic: + +```py +from typing_extensions import Sentinel + +MISSING = Sentinel("MISSING") + +def f(x: MISSING[int]) -> None: ... # error: [invalid-type-form] +``` From 04024d56be1cc544bd5b485135d9f5fbaeceee6e Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Sat, 9 May 2026 18:09:39 -0700 Subject: [PATCH 4/6] handle invalid calls --- .../resources/mdtest/sentinels.md | 17 +++++++++++++++-- .../src/types/infer/builder.rs | 12 ++++++------ .../src/types/known_instance.rs | 1 - 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/sentinels.md b/crates/ty_python_semantic/resources/mdtest/sentinels.md index a295fb26d0f2e..98b2822466c3b 100644 --- a/crates/ty_python_semantic/resources/mdtest/sentinels.md +++ b/crates/ty_python_semantic/resources/mdtest/sentinels.md @@ -2,8 +2,7 @@ ## `typing_extensions.Sentinel` -Sentinels constructed with `typing_extensions.Sentinel` can be used directly in -type expressions: +Sentinels constructed with `typing_extensions.Sentinel` can be used directly in type expressions: ```py from typing_extensions import Sentinel, assert_type @@ -115,3 +114,17 @@ MISSING = Sentinel("MISSING") def f(x: MISSING[int]) -> None: ... # error: [invalid-type-form] ``` + +Invalid sentinel constructor calls fall back to the normal call path: + +```py +from typing_extensions import Sentinel + +NAME = "NAME" + +NON_LITERAL_NAME = Sentinel(NAME) +UNKNOWN_NAME = Sentinel(UNKNOWN) # error: [unresolved-reference] +NON_LITERAL_REPR = Sentinel("NON_LITERAL_REPR", repr=NAME) +UNKNOWN_REPR = Sentinel("UNKNOWN_REPR", repr=UNKNOWN) # error: [unresolved-reference] +UNKNOWN_KEYWORD = Sentinel("UNKNOWN_KEYWORD", unknown=NAME) # error: [unknown-argument] +``` diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 67a5cdf9107e2..2600db27496d1 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -3578,22 +3578,22 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { repr_arg = Some(&keyword.value); } - let name_ty = self.infer_expression(name_arg, TypeContext::default()); - let sentinel_name = name_ty.as_string_literal()?; + if !matches!(name_arg, ast::Expr::StringLiteral(_)) { + return None; + } let Some(repr_arg) = repr_arg else { return Some(Type::KnownInstance(KnownInstanceType::Sentinel( - SentinelInstance::new(self.db(), target_name.clone(), sentinel_name, definition), + SentinelInstance::new(self.db(), target_name.clone(), definition), ))); }; - let repr_ty = self.infer_expression(repr_arg, TypeContext::default()); - if repr_ty.as_string_literal().is_none() && !repr_arg.is_none_literal_expr() { + if !matches!(repr_arg, ast::Expr::StringLiteral(_)) && !repr_arg.is_none_literal_expr() { return None; } Some(Type::KnownInstance(KnownInstanceType::Sentinel( - SentinelInstance::new(self.db(), target_name.clone(), sentinel_name, definition), + SentinelInstance::new(self.db(), target_name.clone(), definition), ))) } diff --git a/crates/ty_python_semantic/src/types/known_instance.rs b/crates/ty_python_semantic/src/types/known_instance.rs index bd3aeedc151f7..1df165d7884d1 100644 --- a/crates/ty_python_semantic/src/types/known_instance.rs +++ b/crates/ty_python_semantic/src/types/known_instance.rs @@ -380,7 +380,6 @@ impl<'db> KnownInstanceType<'db> { #[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] pub struct SentinelInstance<'db> { pub name: Name, - pub sentinel_name: StringLiteralType<'db>, pub definition: Definition<'db>, } From bc460ea97dcf669610421312430b7b0284d0ee3b Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 12 May 2026 21:11:23 -0700 Subject: [PATCH 5/6] more tests --- .../resources/mdtest/sentinels.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/ty_python_semantic/resources/mdtest/sentinels.md b/crates/ty_python_semantic/resources/mdtest/sentinels.md index 98b2822466c3b..c387b39697676 100644 --- a/crates/ty_python_semantic/resources/mdtest/sentinels.md +++ b/crates/ty_python_semantic/resources/mdtest/sentinels.md @@ -65,6 +65,22 @@ def reverse_negative_check(x: int | MISSING | OTHER) -> None: reveal_type(x) # revealed: MISSING ``` +Sentinel objects are always truthy, expose the standard sentinel metadata attributes, and are +rejected as class bases: + +```py +from typing_extensions import Sentinel + +MISSING = Sentinel("MISSING") + +reveal_type(bool(MISSING)) # revealed: Literal[True] +reveal_type(MISSING.__name__) # revealed: str +reveal_type(MISSING.__module__) # revealed: str + +class MissingSubclass(MISSING): # error: [invalid-base] + pass +``` + Sentinels declared in class scope can also be used in type expressions: ```py From ba9db8b825cf8e26c9d364146a773b4113dedd8a Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 12 May 2026 21:25:43 -0700 Subject: [PATCH 6/6] no __name__ --- crates/ty_python_semantic/resources/mdtest/sentinels.md | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ty_python_semantic/resources/mdtest/sentinels.md b/crates/ty_python_semantic/resources/mdtest/sentinels.md index c387b39697676..3c1f3ab4e5f80 100644 --- a/crates/ty_python_semantic/resources/mdtest/sentinels.md +++ b/crates/ty_python_semantic/resources/mdtest/sentinels.md @@ -74,7 +74,6 @@ from typing_extensions import Sentinel MISSING = Sentinel("MISSING") reveal_type(bool(MISSING)) # revealed: Literal[True] -reveal_type(MISSING.__name__) # revealed: str reveal_type(MISSING.__module__) # revealed: str class MissingSubclass(MISSING): # error: [invalid-base]