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
145 changes: 145 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/sentinels.md
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few other things that we could test (from reading your PEP, please feel free to leave some of these out if you think they don't make sense):

  • That we recognize sentinel objects as being truthy, i.e. that bool(MISSING) is of type Literal[True].
  • That accessing .__name__ and .__module__ on a sentinel does not lead to an error, and that the type of those attributes is reasonable / "not obviously wrong".
  • That sentinel can not be subclassed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, adding some tests.

That sentinel can not be subclassed

We currently have only typing_extensions.Sentinel, which we didn't make @final. builtins.sentinel will be, though testing that specifically in ty doesn't feel that useful, as it should just listen to the @final in the stub.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh and __name__ does error right now because the attribute isn't there in typeshed for typing_extensions.Sentinel. We'll add it soon. This also seems not critical to test since the behavior is specified by the stub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# 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>")
WITH_REPR_KEYWORD = Sentinel("WITH_REPR_KEYWORD", repr="<with repr keyword>")

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
```

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.__module__) # revealed: str

class MissingSubclass(MISSING): # error: [invalid-base]
pass
```

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]
```

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]
```
9 changes: 8 additions & 1 deletion crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
}
Expand Down
13 changes: 13 additions & 0 deletions crates/ty_python_semantic/src/types/class/known.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ pub enum KnownClass {
Mapping,
// typing_extensions
ExtensionsTypeVar, // must be distinct from typing.TypeVar, backports new features
Sentinel,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like typing_extensions.Sentinel is an alias for builtins.sentinel? Should we add explicit tests to make sure that we also understand the latter, i.e. if someone uses

MISSING = sentinel("MISSING")

def next_value(default: int | MISSING = MISSING):
    ...

Copy link
Copy Markdown
Member

@AlexWaygood AlexWaygood May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we won't recognise the builtin symbol as existing until we upgrade our vendored version of typeshed in a couple of days

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I'm merging this for now, but it would be great to add a few more tests once that lands.

// Collections
ChainMap,
Counter,
Expand Down Expand Up @@ -180,6 +181,7 @@ impl KnownClass {
| Self::ParamSpecArgs
| Self::ParamSpecKwargs
| Self::TypeVarTuple
| Self::Sentinel
| Self::Super
| Self::WrapperDescriptorType
| Self::UnionType
Expand Down Expand Up @@ -325,6 +327,7 @@ impl KnownClass {
| KnownClass::ParamSpecArgs
| KnownClass::ParamSpecKwargs
| KnownClass::TypeVarTuple
| KnownClass::Sentinel
| KnownClass::TypeAliasType
| KnownClass::NoDefaultType
| KnownClass::NewType
Expand Down Expand Up @@ -419,6 +422,7 @@ impl KnownClass {
| KnownClass::ParamSpecArgs
| KnownClass::ParamSpecKwargs
| KnownClass::TypeVarTuple
| KnownClass::Sentinel
| KnownClass::TypeAliasType
| KnownClass::NoDefaultType
| KnownClass::NewType
Expand Down Expand Up @@ -513,6 +517,7 @@ impl KnownClass {
| KnownClass::ParamSpecArgs
| KnownClass::ParamSpecKwargs
| KnownClass::TypeVarTuple
| KnownClass::Sentinel
| KnownClass::TypeAliasType
| KnownClass::NoDefaultType
| KnownClass::NewType
Expand Down Expand Up @@ -610,6 +615,7 @@ impl KnownClass {
| Self::ParamSpecArgs
| Self::ParamSpecKwargs
| Self::TypeVarTuple
| Self::Sentinel
| Self::TypeAliasType
| Self::NoDefaultType
| Self::NewType
Expand Down Expand Up @@ -720,6 +726,7 @@ impl KnownClass {
| KnownClass::ParamSpecKwargs
| KnownClass::ProtocolMeta
| KnownClass::TypeVarTuple
| KnownClass::Sentinel
| KnownClass::TypeAliasType
| KnownClass::NoDefaultType
| KnownClass::NewType
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1193,6 +1201,7 @@ impl KnownClass {
Self::TypeAliasType
| Self::ExtensionsTypeVar
| Self::TypeVarTuple
| Self::Sentinel
| Self::ExtensionsParamSpec
| Self::ParamSpecArgs
| Self::ParamSpecKwargs
Expand Down Expand Up @@ -1315,6 +1324,7 @@ impl KnownClass {
| Self::ParamSpecArgs
| Self::ParamSpecKwargs
| Self::TypeVarTuple
| Self::Sentinel
| Self::Enum
| Self::EnumType
| Self::Auto
Expand Down Expand Up @@ -1413,6 +1423,7 @@ impl KnownClass {
| Self::ParamSpecArgs
| Self::ParamSpecKwargs
| Self::TypeVarTuple
| Self::Sentinel
| Self::Enum
| Self::EnumType
| Self::Auto
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -1632,6 +1644,7 @@ impl KnownClass {
| Self::ExtensionsTypeVar
| Self::ParamSpec
| Self::ExtensionsParamSpec
| Self::Sentinel
| Self::NamedTupleLike
| Self::ConstraintSet
| Self::GenericContext
Expand Down
1 change: 1 addition & 0 deletions crates/ty_python_semantic/src/types/class_base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions crates/ty_python_semantic/src/types/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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[")?;
Expand Down
Loading
Loading