diff --git a/crates/pyrefly_config/src/error_kind.rs b/crates/pyrefly_config/src/error_kind.rs index 77ba3fc0df..52c864a3a7 100644 --- a/crates/pyrefly_config/src/error_kind.rs +++ b/crates/pyrefly_config/src/error_kind.rs @@ -225,6 +225,8 @@ pub enum ErrorKind { /// A use of `typing.Self` in a context where Pyrefly does not recognize it as /// mapping to a valid class type. InvalidSelfType, + /// An error caused by incorrect usage or definition of a Sentinel. + InvalidSentinel, /// Attempting to call `super()` in a way that is not allowed. /// e.g. calling `super(Y, x)` on an object `x` that does not match the class `Y`. InvalidSuperCall, diff --git a/crates/pyrefly_types/src/display.rs b/crates/pyrefly_types/src/display.rs index 508e5de926..ce239fe960 100644 --- a/crates/pyrefly_types/src/display.rs +++ b/crates/pyrefly_types/src/display.rs @@ -585,6 +585,7 @@ impl<'a> TypeDisplayContext<'a> { output.write_qname(t.qname())?; output.write_str("]") } + Type::Sentinel(t) => output.write_qname(t.qname()), Type::TypeVarTuple(t) => { let type_var_tuple_qname = self.stdlib.map(|s| s.type_var_tuple().qname()); output.write_builtin("TypeVarTuple", type_var_tuple_qname)?; diff --git a/crates/pyrefly_types/src/equality.rs b/crates/pyrefly_types/src/equality.rs index c4030cf1ad..52a71413c5 100644 --- a/crates/pyrefly_types/src/equality.rs +++ b/crates/pyrefly_types/src/equality.rs @@ -28,6 +28,7 @@ use vec1::Vec1; use crate::param_spec::ParamSpec; use crate::quantified::QuantifiedIdentity; +use crate::sentinel::Sentinel; use crate::type_var::TypeVar; use crate::type_var_tuple::TypeVarTuple; @@ -47,6 +48,7 @@ pub struct TypeEqCtx { param_spec: SmallMap, type_var: SmallMap, type_var_tuple: SmallMap, + sentinel: SmallMap, /// Alpha-equivalence mapping for Quantified binders: LHS identity → RHS identity. /// First pairing wins; subsequent occurrences must match. quantified_identity: SmallMap, @@ -144,6 +146,18 @@ impl TypeEq for TypeVarTuple { } } +impl TypeEq for Sentinel { + fn type_eq(&self, other: &Self, ctx: &mut TypeEqCtx) -> bool { + type_eq_identity( + self, + other, + ctx, + |ctx| &mut ctx.sentinel, + |ctx| self.type_eq_inner(other, ctx), + ) + } +} + pub trait TypeEq: Eq { fn type_eq(&self, other: &Self, ctx: &mut TypeEqCtx) -> bool { let _ = ctx; diff --git a/crates/pyrefly_types/src/heap.rs b/crates/pyrefly_types/src/heap.rs index 135075c582..71c3ed3ba9 100644 --- a/crates/pyrefly_types/src/heap.rs +++ b/crates/pyrefly_types/src/heap.rs @@ -37,6 +37,7 @@ use crate::literal::Literal; use crate::module::ModuleType; use crate::param_spec::ParamSpec; use crate::quantified::Quantified; +use crate::sentinel::Sentinel; use crate::shaped_array::ShapedArrayType; use crate::special_form::SpecialForm; use crate::tuple::Tuple; @@ -137,6 +138,11 @@ impl TypeHeap { Type::None } + /// Create a `Type::Sentinel`. + pub fn mk_sentinel(&self, sentinel: Sentinel) -> Type { + Type::Sentinel(sentinel) + } + /// Create a `Type::Union` from members. pub fn mk_union(&self, members: Vec) -> Type { Type::Union(Box::new(Union { diff --git a/crates/pyrefly_types/src/lib.rs b/crates/pyrefly_types/src/lib.rs index 030c829edc..56955e243a 100644 --- a/crates/pyrefly_types/src/lib.rs +++ b/crates/pyrefly_types/src/lib.rs @@ -38,6 +38,7 @@ pub mod module; pub mod param_spec; pub mod quantified; pub mod read_only; +pub mod sentinel; pub mod shaped_array; pub mod simplify; pub mod special_form; diff --git a/crates/pyrefly_types/src/sentinel.rs b/crates/pyrefly_types/src/sentinel.rs new file mode 100644 index 0000000000..3fdd3c3055 --- /dev/null +++ b/crates/pyrefly_types/src/sentinel.rs @@ -0,0 +1,71 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +use std::fmt; +use std::fmt::Display; +use std::hash::Hash; + +use dupe::Dupe; +use pyrefly_derive::TypeEq; +use pyrefly_python::module::Module; +use pyrefly_python::nesting_context::NestingContext; +use pyrefly_python::qname::QName; +use pyrefly_util::arc_id::ArcId; +use pyrefly_util::visit::Visit; +use pyrefly_util::visit::VisitMut; +use ruff_python_ast::Identifier; + +use crate::equality::TypeEq; +use crate::equality::TypeEqCtx; +use crate::heap::TypeHeap; +use crate::types::Type; + +/// Used to represent Sentinel calls. Each Sentinel is unique, so use the ArcId to separate them. +#[derive(Clone, Dupe, Debug, PartialEq, Eq, Hash, Ord, PartialOrd)] +pub struct Sentinel(ArcId); + +// This is a lie, we do have types in the bound position +impl Visit for Sentinel { + const RECURSE_CONTAINS: bool = false; + fn recurse<'a>(&'a self, _: &mut dyn FnMut(&'a Type)) {} +} + +impl VisitMut for Sentinel { + const RECURSE_CONTAINS: bool = false; + fn recurse_mut(&mut self, _: &mut dyn FnMut(&mut Type)) {} +} + +impl Display for Sentinel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0.qname.id()) + } +} + +#[derive(Debug, PartialEq, TypeEq, Eq, Ord, PartialOrd)] +struct SentinelInner { + qname: QName, +} + +impl Sentinel { + pub fn new(name: Identifier, nesting_context: NestingContext, module: Module) -> Self { + Self(ArcId::new(SentinelInner { + qname: QName::new(name, nesting_context, module), + })) + } + + pub fn qname(&self) -> &QName { + &self.0.qname + } + + pub fn to_type(&self, heap: &TypeHeap) -> Type { + heap.mk_sentinel(self.dupe()) + } + + pub fn type_eq_inner(&self, other: &Self, ctx: &mut TypeEqCtx) -> bool { + self.0.type_eq(&other.0, ctx) + } +} diff --git a/crates/pyrefly_types/src/stdlib.rs b/crates/pyrefly_types/src/stdlib.rs index b44209c09a..1c1ae8a3b0 100644 --- a/crates/pyrefly_types/src/stdlib.rs +++ b/crates/pyrefly_types/src/stdlib.rs @@ -105,6 +105,9 @@ pub struct Stdlib { /// After 3.14, `typing_extensions` reexports from `typing`. /// For 3.12 and 3.13 defined separately in both locations. type_alias_type: StdlibResult, + /// Defined in `typing_extensions` as Sentinel. + /// Defined in `builtins` as `sentinel` since 3.15. + sentinel: StdlibResult, traceback_type: StdlibResult, builtins_type: StdlibResult, /// Introduced in Python 3.10. @@ -257,6 +260,10 @@ impl Stdlib { param_spec_kwargs: lookup_concrete(standardised(3, 10), "ParamSpecKwargs"), type_var_tuple: lookup_concrete(standardised(3, 11), "TypeVarTuple"), type_alias_type: lookup_concrete(standardised(3, 12), "TypeAliasType"), + sentinel: version + .at_least(3, 15) + .then(|| lookup_concrete(builtins, "sentinel")) + .unwrap_or_else(|| lookup_concrete(typing_extensions, "Sentinel")), traceback_type: lookup_concrete(types, "TracebackType"), function_type: lookup_concrete(types, "FunctionType"), method_type: lookup_concrete(types, "MethodType"), @@ -581,6 +588,10 @@ impl Stdlib { Self::primitive(&self.type_alias_type) } + pub fn sentinel(&self) -> &ClassType { + Self::primitive(&self.sentinel) + } + pub fn traceback_type(&self) -> &ClassType { Self::primitive(&self.traceback_type) } diff --git a/crates/pyrefly_types/src/types.rs b/crates/pyrefly_types/src/types.rs index 54d64576fd..e3cc558bcc 100644 --- a/crates/pyrefly_types/src/types.rs +++ b/crates/pyrefly_types/src/types.rs @@ -59,6 +59,7 @@ use crate::literal::Literal; use crate::module::ModuleType; use crate::param_spec::ParamSpec; use crate::quantified::Quantified; +use crate::sentinel::Sentinel; use crate::shaped_array::ShapedArrayType; use crate::simplify::unions; use crate::special_form::SpecialForm; @@ -858,6 +859,9 @@ pub enum Type { /// be immediately looked up for untyping (see `TypeAliasData::TypeAliasRef`), `UntypedAlias` /// stores a reference that is untyped once we actually look up the value. UntypedAlias(Box), + // Sentinel types, documented here: https://docs.python.org/3.15/library/functions.html#sentinel + // First introduced in PEP 661: https://peps.python.org/pep-0661/ + Sentinel(Sentinel), /// Represents the result of a super() call. The first ClassType is the point in the MRO that attribute lookup /// on the super instance should start at (*not* the class passed to the super() call), and the second /// ClassType is the second argument (implicit or explicit) to the super() call. For example, in: @@ -919,6 +923,7 @@ impl Visit for Type { Type::Annotated(x, _metadata) => x.visit(f), Type::Unpack(x) => x.visit(f), Type::TypeVar(x) => x.visit(f), + Type::Sentinel(x) => x.visit(f), Type::ParamSpec(x) => x.visit(f), Type::TypeVarTuple(x) => x.visit(f), Type::SpecialForm(x) => x.visit(f), @@ -975,6 +980,7 @@ impl VisitMut for Type { Type::Annotated(x, _metadata) => x.visit_mut(f), Type::Unpack(x) => x.visit_mut(f), Type::TypeVar(x) => x.visit_mut(f), + Type::Sentinel(x) => x.visit_mut(f), Type::ParamSpec(x) => x.visit_mut(f), Type::TypeVarTuple(x) => x.visit_mut(f), Type::SpecialForm(x) => x.visit_mut(f), @@ -1919,6 +1925,7 @@ impl Type { Type::ParamSpec(t) => Some(t.qname()), Type::SelfType(cls) => Some(cls.qname()), Type::Literal(lit) if let Lit::Enum(e) = &lit.value => Some(e.class.qname()), + Type::Sentinel(s) => Some(s.qname()), _ => None, } } @@ -1932,6 +1939,7 @@ impl Type { Type::Literal(lit) if let Lit::Str(x) = &lit.value => Some(!x.is_empty()), Type::Type(_) => Some(true), Type::None => Some(false), + Type::Sentinel(_) => Some(true), Type::Tuple(Tuple::Concrete(elements)) => Some(!elements.is_empty()), Type::Union(u) => { let mut answer = None; diff --git a/pyrefly/lib/alt/attr.rs b/pyrefly/lib/alt/attr.rs index 406051aafb..2c0eeec0a2 100644 --- a/pyrefly/lib/alt/attr.rs +++ b/pyrefly/lib/alt/attr.rs @@ -2336,6 +2336,9 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { Type::TypeVar(_) => acc.push(AttributeBase1::ClassInstance( self.stdlib.type_var().clone(), )), + Type::Sentinel(_) => acc.push(AttributeBase1::ClassInstance( + self.stdlib.sentinel().clone(), + )), Type::ParamSpec(_) => acc.push(AttributeBase1::ClassInstance( self.stdlib.param_spec().clone(), )), diff --git a/pyrefly/lib/alt/expr.rs b/pyrefly/lib/alt/expr.rs index 1a16a14bc3..d31cebd79a 100644 --- a/pyrefly/lib/alt/expr.rs +++ b/pyrefly/lib/alt/expr.rs @@ -16,6 +16,7 @@ use itertools::Itertools; use pyrefly_python::ast::Ast; use pyrefly_python::dunder; use pyrefly_python::module_name::ModuleName; +use pyrefly_python::nesting_context::NestingContext; use pyrefly_python::short_identifier::ShortIdentifier; use pyrefly_types::callable::FunctionKind; use pyrefly_types::dimension::SizeExpr; @@ -95,6 +96,7 @@ use crate::types::literal::Lit; use crate::types::param_spec::ParamSpec; use crate::types::quantified::Quantified; use crate::types::quantified::QuantifiedKind; +use crate::types::sentinel::Sentinel; use crate::types::special_form::SpecialForm; use crate::types::tuple::Tuple; use crate::types::type_info::TypeInfo; @@ -1788,6 +1790,89 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } } + pub fn sentinel_from_call( + &self, + assignment_name: Identifier, + nesting_context: NestingContext, + x: &ExprCall, + errors: &ErrorCollector, + ) -> Sentinel { + let mut sentinel_name = assignment_name; + let mut iargs = x.arguments.args.iter(); + if let Some(arg) = iargs.next() { + if let Expr::StringLiteral(lit) = arg { + sentinel_name = Identifier::new(lit.value.to_str(), lit.range()); + } else { + self.error( + errors, + arg.range(), + ErrorKind::InvalidSentinel, + "Expected first argument of sentinel to be a string literal".to_owned(), + ); + } + } else { + self.error( + errors, + x.range, + ErrorKind::InvalidSentinel, + "Sentinel requires a name as the first argument".to_owned(), + ); + } + if let Some(arg) = iargs.next() { + let args_range_end = x.arguments.args.last().map(|arg| arg.range().end()); + let range = TextRange::new( + arg.range().start(), + // args_range_end should never be None as it should only be None if there are + // no args, but no reason not to have a default here anyway. + args_range_end.unwrap_or_else(|| arg.range().end()), + ); + self.error( + errors, + range, + ErrorKind::InvalidSentinel, + "Sentinel only takes one positional argument".to_owned(), + ); + } + + for kw in &x.arguments.keywords { + match &kw.arg { + Some(id) => match id.id.as_str() { + "repr" => { + let got = self.expr_infer(&kw.value, errors); + if !self + .is_subset_eq(&got, &self.heap.mk_class_type(self.stdlib.str().clone())) + { + self.error( + errors, + kw.range, + ErrorKind::InvalidSentinel, + format!("Invalid type for sentinel `repr` {got}"), + ); + } + } + _ => { + self.error( + errors, + kw.range, + ErrorKind::InvalidSentinel, + format!("Unexpected keyword argument `{}` to sentinel", id.id), + ); + } + }, + _ => { + self.error( + errors, + kw.range, + ErrorKind::InvalidSentinel, + "Cannot pass unpacked keyword arguments to sentinel".to_owned(), + ); + } + } + } + + Sentinel::new(sentinel_name, nesting_context, self.module().dupe()) + } + pub fn typevar_from_call( &self, name: Identifier, diff --git a/pyrefly/lib/alt/narrow.rs b/pyrefly/lib/alt/narrow.rs index b3e380b396..b5a7c89217 100644 --- a/pyrefly/lib/alt/narrow.rs +++ b/pyrefly/lib/alt/narrow.rs @@ -294,6 +294,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { { self.heap.mk_never() } + (Type::Sentinel(s1), Type::Sentinel(s2)) if s1 == s2 => self.heap.mk_never(), (Type::ClassType(cls), Type::Literal(lit)) if cls.is_builtin("bool") && let Lit::Bool(b) = &lit.value => @@ -850,7 +851,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { range, ); match right { - Type::None | Type::Ellipsis | Type::Literal(_) => { + Type::None | Type::Ellipsis | Type::Literal(_) | Type::Sentinel(_) => { if self.is_subset_eq(&right, &facet_ty) { t.clone() } else { @@ -872,8 +873,8 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { ); match (&facet_ty, &right) { ( - Type::None | Type::Ellipsis | Type::Literal(_), - Type::None | Type::Ellipsis | Type::Literal(_), + Type::None | Type::Ellipsis | Type::Literal(_) | Type::Sentinel(_), + Type::None | Type::Ellipsis | Type::Literal(_) | Type::Sentinel(_), ) if self.literal_equal(&right, &facet_ty) => self.heap.mk_never(), _ => t.clone(), } @@ -1410,7 +1411,10 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } AtomicNarrowOp::Eq(v) => { let right = self.expr_infer(v, errors); - if matches!(right, Type::Literal(_) | Type::None | Type::Ellipsis) { + if matches!( + right, + Type::Literal(_) | Type::None | Type::Ellipsis | Type::Sentinel(_) + ) { self.intersect(ty, &right) } else { ty.clone() @@ -1418,7 +1422,10 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } AtomicNarrowOp::NotEq(v) => { let right = self.expr_infer(v, errors); - if matches!(right, Type::Literal(_) | Type::None | Type::Ellipsis) { + if matches!( + right, + Type::Literal(_) | Type::None | Type::Ellipsis | Type::Sentinel(_) + ) { self.distribute_over_union(ty, |t| match (t, &right) { (_, _) if self.literal_equal(t, &right) => self.heap.mk_never(), (Type::ClassType(cls), Type::Literal(lit)) @@ -2163,6 +2170,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { match (left, right) { (Type::None, Type::None) => true, (Type::Ellipsis, Type::Ellipsis) => true, + (Type::Sentinel(s1), Type::Sentinel(s2)) => s1 == s2, (Type::Literal(left), Type::Literal(right)) => left.value == right.value, _ => false, } diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index ff96726dfc..f84d658f10 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -5343,6 +5343,28 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { self.heap.mk_none() } Binding::Delete(x) => self.check_del_statement(x, errors), + Binding::Sentinel(x) => { + let (ann, name, nesting_context, call) = x.as_ref(); + let ty = self + .sentinel_from_call(name.clone(), nesting_context.dupe(), call, errors) + .to_type(self.heap); + if let Some(k) = ann + && let AnnotationWithTarget { + target, + annotation: + Annotation { + ty: Some(want), + qualifiers: _, + }, + } = &*self.get_idx(*k) + { + // Validate the annotation already on assigned name + self.check_type(&ty, want, call.range, errors, &|| { + TypeCheckContext::of_kind(TypeCheckKind::from_annotation_target(target)) + }); + } + ty + } } } @@ -5697,6 +5719,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } Some(*t) } + Type::Sentinel(sentinel) => Some(self.heap.mk_sentinel(sentinel)), Type::None => Some(self.heap.mk_none()), // Both a value and a type Type::Ellipsis => Some(self.heap.mk_ellipsis()), // A bit weird because of tuples, so just promote it Type::Any(style) => Some(style.propagate()), diff --git a/pyrefly/lib/binding/binding.rs b/pyrefly/lib/binding/binding.rs index 8df403e666..e3dd73bf00 100644 --- a/pyrefly/lib/binding/binding.rs +++ b/pyrefly/lib/binding/binding.rs @@ -2354,6 +2354,15 @@ pub enum Binding { /// A match statement or if/elif chain that may be type-exhaustive. /// Resolves to Never if ANY narrow entry narrows to Never, None otherwise. Exhaustive(Box), + /// Last boolean represents if the sentinel is from builtins. If false, then it is from typing_extensions. + Sentinel( + Box<( + Option>, + Identifier, + NestingContext, + Box, + )>, + ), } impl DisplayWith for Binding { @@ -2396,6 +2405,10 @@ impl DisplayWith for Binding { let (a, name, call) = x.as_ref(); write!(f, "TypeVarTuple({}, {name}, {})", ann(a), m.display(call)) } + Self::Sentinel(x) => { + let (a, name, _, call) = x.as_ref(); + write!(f, "sentinel({}, {name}, {})", ann(a), m.display(call)) + } Self::ReturnExplicit(x) => { write!(f, "ReturnExplicit({}, ", ann(&x.annot))?; match &x.expr { @@ -2693,6 +2706,7 @@ impl Binding { Binding::NameAssign(x) if x.name.as_str() == x.name.to_uppercase() => { Some(SymbolKind::Constant) } + Binding::Sentinel(_) => Some(SymbolKind::Constant), Binding::NameAssign(x) => { if x.name .as_str() diff --git a/pyrefly/lib/binding/stmt.rs b/pyrefly/lib/binding/stmt.rs index 26ab6ea594..7f612a0c73 100644 --- a/pyrefly/lib/binding/stmt.rs +++ b/pyrefly/lib/binding/stmt.rs @@ -281,6 +281,32 @@ impl<'a> BindingsBuilder<'a> { }) } + fn assign_sentinel(&mut self, name: &ExprName, call: &mut ExprCall) { + // Type var declarations are static types only; skip them for first-usage type inference. + let static_type_usage = &mut Usage::StaticTypeInformation { + is_annotation: false, + }; + self.ensure_expr(&mut call.func, static_type_usage); + if let Some(expr) = call.arguments.args.iter_mut().next() { + self.ensure_expr(expr, static_type_usage); + } + for kw in call.arguments.keywords.iter_mut() { + self.ensure_expr(&mut kw.value, static_type_usage); + } + let nesting_context = self.scopes.nesting_context(); + // Like legacy type var, Sentinel can only be created with a single Sentinel binding to a + // single variable (https://peps.python.org/pep-0661/#typing). Thus we bind it in the same + // way legacy type vars are bound. + self.bind_legacy_type_var_or_typing_alias(name, |ann| { + Binding::Sentinel(Box::new(( + ann, + Ast::expr_name_identifier(name.clone()), + nesting_context, + Box::new(call.clone()), + ))) + }) + } + fn ensure_type_alias_type_args( &mut self, call: &mut ExprCall, @@ -598,6 +624,14 @@ impl<'a> BindingsBuilder<'a> { self.assign_type_var_tuple(name, call); return; } + SpecialExport::Sentinel => { + self.assign_sentinel(name, call); + return; + } + SpecialExport::BuiltinsSentinel => { + self.assign_sentinel(name, call); + return; + } SpecialExport::Enum | SpecialExport::IntEnum | SpecialExport::StrEnum => { diff --git a/pyrefly/lib/export/special.rs b/pyrefly/lib/export/special.rs index 037b3a79c6..39284d7cc1 100644 --- a/pyrefly/lib/export/special.rs +++ b/pyrefly/lib/export/special.rs @@ -74,6 +74,8 @@ pub enum SpecialExport { UsesShapeDsl, ShapeDslFunction, ShapedArray, + Sentinel, + BuiltinsSentinel, } impl SpecialExport { @@ -139,6 +141,8 @@ impl SpecialExport { "uses_shape_dsl" => Some(Self::UsesShapeDsl), "shape_dsl_function" => Some(Self::ShapeDslFunction), "shaped_array" => Some(Self::ShapedArray), + "Sentinel" => Some(Self::Sentinel), + "sentinel" => Some(Self::BuiltinsSentinel), _ => None, } } @@ -213,6 +217,8 @@ impl SpecialExport { Self::UsesShapeDsl => matches!(m.as_str(), "shape_extensions"), Self::ShapeDslFunction => matches!(m.as_str(), "shape_extensions.dsl"), Self::ShapedArray => matches!(m.as_str(), "shape_extensions"), + Self::Sentinel => matches!(m.as_str(), "typing_extensions"), + Self::BuiltinsSentinel => matches!(m.as_str(), "builtins"), } } diff --git a/pyrefly/lib/query.rs b/pyrefly/lib/query.rs index 3225a06c01..03e890ce9d 100644 --- a/pyrefly/lib/query.rs +++ b/pyrefly/lib/query.rs @@ -425,6 +425,9 @@ fn type_shape_kind(context: &TypeShapeContext, ty: &Type) -> TypeShapeKind { "typing.Literal", vec![named_leaf(literal.value.to_string())], ), + Type::Sentinel(sentinel) => { + named_type_shape_kind("sentinel", vec![named_leaf(format!("{}", sentinel))]) + } Type::LiteralString(_) => { named_type_shape_kind("typing_extensions.LiteralString", Vec::new()) } diff --git a/pyrefly/lib/report/cinderx/convert.rs b/pyrefly/lib/report/cinderx/convert.rs index 91cd8c3449..1b04e58d74 100644 --- a/pyrefly/lib/report/cinderx/convert.rs +++ b/pyrefly/lib/report/cinderx/convert.rs @@ -432,6 +432,7 @@ pub(crate) fn type_to_structured( | Type::ParamSpec(_) | Type::TypeVarTuple(_) | Type::TypeForm(_) + | Type::Sentinel(_) | Type::ElementOfTypeVarTuple(_) | Type::ShapedArray(_) | Type::NNModule(_) diff --git a/pyrefly/lib/test/mod.rs b/pyrefly/lib/test/mod.rs index 7f9cc219bc..07bb498e0c 100644 --- a/pyrefly/lib/test/mod.rs +++ b/pyrefly/lib/test/mod.rs @@ -67,6 +67,7 @@ mod redundant_cast; mod returns; mod scope; mod semantic_syntax_errors; +mod sentinel; mod shape_dsl; mod simple; mod slots; diff --git a/pyrefly/lib/test/narrow.rs b/pyrefly/lib/test/narrow.rs index 02fba29413..32bfecf527 100644 --- a/pyrefly/lib/test/narrow.rs +++ b/pyrefly/lib/test/narrow.rs @@ -3230,3 +3230,75 @@ def b(): assert_type(val, int) "#, ); + +testcase!( + test_sentinel_type_narrow_to_boolean, + r#" +from typing_extensions import Sentinel +from typing import Literal, assert_type + +MISSING = Sentinel("MISSING") +def f(x: bool | MISSING): + if x is MISSING: + assert_type(x, MISSING) + else: + assert_type(x, bool) + if x is not MISSING: + assert_type(x, bool) + else: + assert_type(x, MISSING) + "#, +); + +testcase!( + test_sentinel_type_narrow_to_boolean_eq, + r#" +from typing_extensions import Sentinel +from typing import Literal, assert_type + +MISSING = Sentinel("MISSING") +def f(x: bool | MISSING): + if x == MISSING: + assert_type(x, MISSING) + else: + assert_type(x, bool) + if x != MISSING: + assert_type(x, bool) + else: + assert_type(x, MISSING) + "#, +); + +testcase!( + sentinel_narrow_from_union, + r#" +from typing_extensions import Sentinel +from typing import Literal, assert_type + +MISSING = Sentinel("MISSING") +def f(x: int | bool | MISSING): + if x is MISSING: + assert_type(x, MISSING) + else: + assert_type(x, int | bool) + if x is not MISSING: + assert_type(x, int | bool) + else: + assert_type(x, MISSING) + "#, +); + +testcase!( + sentinel_narrow_with_type_param, + r#" +from typing_extensions import Sentinel + +MIS = Sentinel("MIS", repr="MISSING") + +def a[T](x: T | MIS) -> T: + if x is not MIS: + return x + else: + raise ValueError("a") + "#, +); diff --git a/pyrefly/lib/test/sentinel.rs b/pyrefly/lib/test/sentinel.rs new file mode 100644 index 0000000000..98c800bd98 --- /dev/null +++ b/pyrefly/lib/test/sentinel.rs @@ -0,0 +1,253 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +use pyrefly_python::sys_info::PythonVersion; + +use crate::test::util::TestEnv; +use crate::testcase; + +testcase!( + test_sentinel_construction_success, + r#" +from typing_extensions import Sentinel + +A = Sentinel("A") + "#, +); + +testcase!( + test_sentinel_construction_second_positional_arg, + r#" +from typing_extensions import Sentinel + +A = Sentinel("A", 123) # E: Sentinel only takes one positional argument + "#, +); + +testcase!( + test_sentinel_construction_with_repr_success, + r#" +from typing_extensions import Sentinel + +A = Sentinel("A", repr="some text") + "#, +); + +testcase!( + test_sentinel_construction_with_repr_str_success, + r#" +from typing_extensions import Sentinel + +text: str = "some other text" +A = Sentinel("A", repr=text) + "#, +); + +testcase!( + test_sentinel_construction_with_repr_int_error, + r#" +from typing_extensions import Sentinel + +text = 5 +A = Sentinel("A", repr=text) # E: Invalid type for sentinel `repr` Literal[5] + "#, +); + +testcase!( + test_sentinel_construction_name_kwarg_error, + r#" +from typing_extensions import Sentinel + +A = Sentinel(name="A") # E: Sentinel requires a name as the first argument # E: Unexpected keyword argument `name` to sentinel + "#, +); + +testcase!( + test_sentinel_construction_different_names_allowed, + r#" +from typing_extensions import Sentinel + +A = Sentinel("") + "#, +); + +testcase!( + test_sentinel_uses_sentinel_string_literal_name_in_error_messages, + r#" +from typing_extensions import Sentinel + +A = Sentinel("") + +def foo(a: A): + b: int = a # E: `` is not assignable to `int` + "#, +); + +testcase!( + test_sentinel_defaults_to_assignment_name_if_not_constructed_with_name, + r#" +from typing_extensions import Sentinel + +A = Sentinel() # E: Sentinel requires a name as the first argument + +def foo(a: A): + b: int = a # E: `A` is not assignable to `int` + "#, +); + +testcase!( + test_sentinel_typehint_success, + r#" +from typing_extensions import Sentinel + +A: A = Sentinel("A") + "#, +); + +testcase!( + test_sentinel_typehint_different_sentinel, + r#" +from typing_extensions import Sentinel + +A = Sentinel("A") +B: A = Sentinel("B") # E: `Sentinel` is not assignable to `A` + "#, +); + +testcase!( + test_sentinel_typehint_any, + r#" +from typing import Any, assert_type +from typing_extensions import Sentinel + +A: Any = Sentinel("A") +assert_type(A, Any) + "#, +); + +testcase!( + test_sentinel_violates_annotation, + r#" +from typing_extensions import Sentinel + +MISSING: int = 0 +MISSING = Sentinel('MISSING') # E: `MISSING` is not assignable to variable `MISSING` with type `int` + "#, +); + +testcase!( + test_sentinel_no_args_error, + r#" +from typing_extensions import Sentinel + +MISSING = Sentinel() # E: Sentinel requires a name as the first argument + "#, +); + +testcase!( + test_sentinel_typing_extensions_3_15, + TestEnv::new().with_version(PythonVersion::new(3, 15, 0)), + r#" +from typing import Literal, assert_type +from typing_extensions import Sentinel + +A = Sentinel("A") +def foo(a: A | Literal[False]): + if a: + assert_type(a, A) + else: + assert_type(a, Literal[False]) + "#, +); + +testcase!( + test_sentinel_lowercase_3_15, + TestEnv::new().with_version(PythonVersion::new(3, 15, 0)), + r#" +from typing import Literal, assert_type + +A = sentinel("A") +def foo(a: A | Literal[False]): + if a: + assert_type(a, A) + else: + assert_type(a, Literal[False]) + "#, +); + +testcase!( + test_sentinel_lowercase_3_14_error, + TestEnv::new().with_version(PythonVersion::new(3, 14, 0)), + r#" +A = sentinel("A") # E: Could not find name `sentinel` + "#, +); + +testcase!( + test_sentinel_in_class_body, + r#" +from typing import assert_type +from typing_extensions import Sentinel + +class Cls: + IN_CLASS = Sentinel("Cls.IN_CLASS") + +def func3(x: int | Cls.IN_CLASS = Cls.IN_CLASS) -> None: + if x is Cls.IN_CLASS: + assert_type(x, Cls.IN_CLASS) + else: + assert_type(x, int) + "#, +); + +testcase!( + test_builtin_sentinel_3_15_has_dunder_name, + TestEnv::new().with_version(PythonVersion::new(3, 15, 0)), + r#" +MISSING = sentinel("MISSING") +MISSING.__name__ + "#, +); + +testcase!( + test_typeshed_sentinel_3_14_no_dunder_name, + TestEnv::new().with_version(PythonVersion::new(3, 14, 0)), + r#" +from typing_extensions import Sentinel + +MISSING = Sentinel("MISSING") +MISSING.__name__ # E: Object of class `Sentinel` has no attribute `__name__` + "#, +); + +testcase!( + sentinel_is_truthy, + r#" +from typing_extensions import Sentinel +from typing import Literal, assert_type + +MISSING = Sentinel("MISSING") +x: MISSING | None = MISSING +if x: + assert_type(x, MISSING) +else: + assert_type(x, None) + "#, +); + +testcase!( + test_sentinel_in_class_body_reveal_qualified_name, + r#" +from typing import reveal_type +from typing_extensions import Sentinel + +class Cls: + IN_CLASS = Sentinel("IN_CLASS") + +reveal_type(Cls.IN_CLASS) # E: Cls.IN_CLASS + "#, +); diff --git a/pyrefly/lib/tsp/type_conversion.rs b/pyrefly/lib/tsp/type_conversion.rs index 8b261bc7ea..17a1791608 100644 --- a/pyrefly/lib/tsp/type_conversion.rs +++ b/pyrefly/lib/tsp/type_conversion.rs @@ -360,6 +360,9 @@ impl TypeConverter<'_> { // --- Materialization is a solver artifact --- PyreflyType::Materialization => builtin("Unknown"), + + // --- Sentinel type --- + PyreflyType::Sentinel(_) => builtin("sentinel"), } } diff --git a/website/docs/error-kinds.mdx b/website/docs/error-kinds.mdx index 8930748c3f..5fff46207c 100644 --- a/website/docs/error-kinds.mdx +++ b/website/docs/error-kinds.mdx @@ -885,6 +885,21 @@ class TD(TypedDict): x: Option[Self] ``` +## invalid-sentinel + +An error caused by incorrect definition of a Sentinel. A few examples: + +```python +from typing_extensions import Sentinel + +# First argument passed to sentinel constructor isn't a string literal +my_str: str = "MISSING" +A = Sentinel(my_str) + +# Invalid arguments passed to sentinel constructor +MISSING = Sentinel("MISSING", non_existent="") +``` + ## invalid-super-call `super()` has [a few restrictions](https://docs.python.org/3/library/functions.html#super) on how it is called.