Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c641321
Add support for PEP 661 (sentinels)
JelleZijlstra May 10, 2026
4d00721
simplify
JelleZijlstra May 10, 2026
271a07c
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 10, 2026
190b7c5
test isinstance
JelleZijlstra May 10, 2026
cfa09f4
Fix sentinel cache restore
JelleZijlstra May 11, 2026
835bb3b
maybe fix
JelleZijlstra May 11, 2026
a2caece
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 11, 2026
afa698c
refactor: Make `write_literal`/`read_literal` sentinel-aware
edgarrmondragon Jun 25, 2026
aa22da4
chore: Bump cache version to 11
edgarrmondragon Jun 25, 2026
106e194
Merge branch 'master' into sentinels-reloaded
edgarrmondragon Jun 25, 2026
52dc2d9
ci: Use local lib-rt in "interpreted" job
edgarrmondragon Jun 26, 2026
acfbf46
Merge branch 'master' into sentinels-reloaded
edgarrmondragon Jun 30, 2026
48c1234
Merge branch 'master' into sentinels-reloaded
edgarrmondragon Jul 21, 2026
f6e5a5c
Fix missing dependency trigger for sentinel literals
edgarrmondragon Jul 21, 2026
41f9383
Do not treat sentinel reassignment as an alias
edgarrmondragon Jul 25, 2026
d85759f
Merge branch 'master' into sentinels-reloaded
edgarrmondragon Aug 7, 2026
6ec6a83
Handle dataclasses.MISSING and dataclasses.KW_ONLY as sentinels
edgarrmondragon Aug 7, 2026
6a7dcfc
Revert now unnecessary workflow change
edgarrmondragon Aug 7, 2026
057504d
Merge branch 'master' into sentinels-reloaded
edgarrmondragon Aug 7, 2026
e75b7cd
Reject sentinels as types when defined within a class nested in a fun…
edgarrmondragon Aug 7, 2026
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
24 changes: 19 additions & 5 deletions mypy/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@
from __future__ import annotations

from collections.abc import Sequence
from typing import Any, Final, TypeAlias as _TypeAlias
from typing import TYPE_CHECKING, Any, Final, TypeAlias as _TypeAlias

if TYPE_CHECKING:
from mypy.types import SentinelValue

from librt.internal import (
ReadBuffer as ReadBuffer,
Expand All @@ -69,7 +72,7 @@
from mypy_extensions import u8

# High-level cache layout format
CACHE_VERSION: Final = 10
CACHE_VERSION: Final = 11

# Type used internally to represent errors:
# (path, line, column, end_line, end_column, severity, message, code)
Expand Down Expand Up @@ -308,6 +311,7 @@ def read(cls, data: ReadBuffer) -> CacheMetaEx | None:
LITERAL_BYTES: Final[Tag] = 5
LITERAL_FLOAT: Final[Tag] = 6
LITERAL_COMPLEX: Final[Tag] = 7
LITERAL_SENTINEL: Final[Tag] = 8

# Collections.
LIST_GEN: Final[Tag] = 20
Expand All @@ -328,7 +332,7 @@ def read(cls, data: ReadBuffer) -> CacheMetaEx | None:
END_TAG: Final[Tag] = 255


def read_literal(data: ReadBuffer, tag: Tag) -> int | str | bool | float:
def read_literal(data: ReadBuffer, tag: Tag) -> int | str | bool | float | SentinelValue:
if tag == LITERAL_INT:
return read_int_bare(data)
elif tag == LITERAL_STR:
Expand All @@ -339,12 +343,18 @@ def read_literal(data: ReadBuffer, tag: Tag) -> int | str | bool | float:
return True
elif tag == LITERAL_FLOAT:
return read_float_bare(data)
elif tag == LITERAL_SENTINEL:
from mypy.types import SentinelValue as _SentinelValue

return _SentinelValue(read_str_bare(data), read_str_bare(data))
assert False, f"Unknown literal tag {tag}"


# There is an intentional asymmetry between read and write for literals because
# None and/or complex values are only allowed in some contexts but not in others.
def write_literal(data: WriteBuffer, value: int | str | bool | float | complex | None) -> None:
def write_literal(
data: WriteBuffer, value: int | str | bool | float | complex | SentinelValue | None
) -> None:
if isinstance(value, bool):
write_bool(data, value)
elif isinstance(value, int):
Expand All @@ -360,8 +370,12 @@ def write_literal(data: WriteBuffer, value: int | str | bool | float | complex |
write_tag(data, LITERAL_COMPLEX)
write_float_bare(data, value.real)
write_float_bare(data, value.imag)
else:
elif value is None:
write_tag(data, LITERAL_NONE)
else:
write_tag(data, LITERAL_SENTINEL)
write_str_bare(data, value.fullname)
write_str_bare(data, value.name)


def read_int(data: ReadBuffer) -> int:
Expand Down
5 changes: 5 additions & 0 deletions mypy/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -2783,13 +2783,18 @@ def format_literal_value(typ: LiteralType) -> str:
modifier += "="
items.append(f"{item_name!r}{modifier}: {format(item_type)}")
return f"TypedDict({{{', '.join(items)}}})"
elif isinstance(typ, LiteralType) and typ.is_sentinel_literal():
return format_literal_value(typ)
elif isinstance(typ, LiteralType):
return f"Literal[{format_literal_value(typ)}]"
elif isinstance(typ, UnionType):
typ = get_proper_type(ignore_last_known_values(typ))
if not isinstance(typ, UnionType):
return format(typ)
literal_items, union_items = separate_union_literals(typ)
sentinel_items = [item for item in literal_items if item.is_sentinel_literal()]
literal_items = [item for item in literal_items if not item.is_sentinel_literal()]
union_items = [*sentinel_items, *union_items]

# Coalesce multiple Literal[] members. This also changes output order.
# If there's just one Literal item, retain the original ordering.
Expand Down
12 changes: 10 additions & 2 deletions mypy/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,7 @@ def is_dynamic(self) -> bool:
"from_module_getattr",
"has_explicit_value",
"allow_incompatible_override",
"is_sentinel",
]


Expand Down Expand Up @@ -1454,6 +1455,7 @@ class Var(SymbolNode):
"allow_incompatible_override",
"invalid_partial_type",
"is_argument",
"is_sentinel",
)

__match_args__ = ("name", "type", "final_value")
Expand Down Expand Up @@ -1516,6 +1518,8 @@ def __init__(self, name: str, type: mypy.types.Type | None = None) -> None:
self.invalid_partial_type = False
# Is it a variable symbol for a function argument?
self.is_argument = False
# Was this variable created by PEP 661 sentinel()/Sentinel() syntax?
self.is_sentinel = False

@property
def name(self) -> str:
Expand Down Expand Up @@ -1598,6 +1602,7 @@ def write(self, data: WriteBuffer) -> None:
self.from_module_getattr,
self.has_explicit_value,
self.allow_incompatible_override,
self.is_sentinel,
],
)
write_literal(data, self.final_value)
Expand Down Expand Up @@ -1635,12 +1640,15 @@ def read(cls, data: ReadBuffer) -> Var:
v.from_module_getattr,
v.has_explicit_value,
v.allow_incompatible_override,
) = read_flags(data, num_flags=19)
v.is_sentinel,
) = read_flags(data, num_flags=20)
tag = read_tag(data)
if tag == LITERAL_COMPLEX:
v.final_value = complex(read_float_bare(data), read_float_bare(data))
elif tag != LITERAL_NONE:
v.final_value = read_literal(data, tag)
val = read_literal(data, tag)
assert not isinstance(val, mypy.types.SentinelValue)
v.final_value = val
assert read_tag(data) == END_TAG
return v

Expand Down
4 changes: 4 additions & 0 deletions mypy/plugins/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
LiteralType,
NoneType,
ProperType,
SentinelValue,
TupleType,
Type,
TypeOfAny,
Expand Down Expand Up @@ -799,6 +800,9 @@ def _is_kw_only_type(self, node: Type | None) -> bool:
if node is None:
return False
node_type = get_proper_type(node)
if isinstance(node_type, LiteralType) and isinstance(node_type.value, SentinelValue):
# PEP 661 sentinel: `KW_ONLY = sentinel("KW_ONLY")` (Python 3.15+).
return node_type.value.fullname == "dataclasses.KW_ONLY"
if not isinstance(node_type, Instance):
return False
return node_type.type.fullname == "dataclasses.KW_ONLY"
Expand Down
54 changes: 54 additions & 0 deletions mypy/semanal.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@
OVERRIDE_DECORATOR_NAMES,
PROTOCOL_NAMES,
REVEAL_TYPE_NAMES,
SENTINEL_TYPE_NAMES,
TPDICT_NAMES,
TYPE_ALIAS_NAMES,
TYPE_CHECK_ONLY_NAMES,
Expand All @@ -289,6 +290,7 @@
ParamSpecType,
PlaceholderType,
ProperType,
SentinelValue,
TrivialSyntheticTypeTranslator,
TupleType,
Type,
Expand Down Expand Up @@ -3377,9 +3379,15 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
# may be set to True while there were still placeholders due to forward refs.
s.is_alias_def = False

sentinel_definition = self.is_sentinel_declaration(s)

# OK, this is a regular assignment, perform the necessary analysis steps.
s.is_final_def = self.unwrap_final(s)
if sentinel_definition:
s.is_final_def = True
self.analyze_lvalues(s)
if sentinel_definition:
self.setup_sentinel_var(s)
self.check_final_implicit_def(s)
self.store_final_status(s)
self.check_classvar(s)
Expand All @@ -3392,6 +3400,51 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
self.process__deletable__(s)
self.process__slots__(s)

def is_sentinel_declaration(self, s: AssignmentStmt) -> bool:
"""Does this assignment define a PEP 661 sentinel singleton?"""
if self.is_nested_within_func_scope() or s.unanalyzed_type is not None:
return False
if len(s.lvalues) != 1 or not isinstance(s.lvalues[0], NameExpr):
return False
if not isinstance(s.rvalue, CallExpr):
return False
call = s.rvalue
if not isinstance(call.callee, RefExpr):
return False
if call.callee.fullname not in SENTINEL_TYPE_NAMES:
return False
if not call.args or call.arg_kinds[0] != ARG_POS or not isinstance(call.args[0], StrExpr):
return False
return True

def setup_sentinel_var(self, s: AssignmentStmt) -> None:
lvalue = s.lvalues[0]
assert isinstance(lvalue, NameExpr)
if not isinstance(lvalue.node, Var):
return
var = lvalue.node
var.is_sentinel = True
typ = self.sentinel_type_for_var(var, s.rvalue)
if typ is not None:
s.type = typ

def sentinel_type_for_var(self, var: Var, rvalue: Expression) -> Instance | None:
assert isinstance(rvalue, CallExpr)
callee = rvalue.callee
assert isinstance(callee, RefExpr)
typ = self.named_type_or_none(callee.fullname)
if typ is None:
return None
name = f"{self.type.name}.{var.name}" if self.type is not None else var.name
return typ.copy_modified(
last_known_value=LiteralType(
SentinelValue(var.fullname, name),
fallback=typ,
line=rvalue.line,
column=rvalue.column,
)
)

def analyze_identity_global_assignment(self, s: AssignmentStmt) -> bool:
"""Special case 'X = X' in global scope.

Expand Down Expand Up @@ -4763,6 +4816,7 @@ def store_declared_types(self, lvalue: Lvalue, typ: Type) -> None:
var.is_final
and isinstance(typ, Instance)
and typ.last_known_value
and not isinstance(typ.last_known_value.value, SentinelValue)
and (not self.type or not self.type.is_enum)
):
var.final_value = typ.last_known_value.value
Expand Down
6 changes: 5 additions & 1 deletion mypy/server/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ class 'mod.Cls'. This can also refer to an attribute inherited from a
ParamSpecType,
PartialType,
ProperType,
SentinelValue,
TupleType,
Type,
TypeAliasType,
Expand Down Expand Up @@ -1096,7 +1097,10 @@ def visit_typeddict_type(self, typ: TypedDictType) -> list[str]:
return triggers

def visit_literal_type(self, typ: LiteralType) -> list[str]:
return self.get_type_triggers(typ.fallback)
triggers = self.get_type_triggers(typ.fallback)
if isinstance(typ.value, SentinelValue):
triggers.append(make_trigger(typ.value.fullname))
return triggers

def visit_unbound_type(self, typ: UnboundType) -> list[str]:
return []
Expand Down
24 changes: 24 additions & 0 deletions mypy/test/testtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import re
from unittest import TestCase, skipUnless

from librt.internal import ReadBuffer, WriteBuffer

from mypy.cache import read_tag
from mypy.erasetype import erase_type, remove_instance_last_known_values
from mypy.indirection import TypeIndirectionVisitor
from mypy.join import join_types
Expand All @@ -30,13 +33,15 @@
from mypy.test.typefixture import InterfaceTypeFixture, TypeFixture
from mypy.typeops import false_only, make_simplified_union, true_only
from mypy.types import (
LITERAL_TYPE,
AnyType,
CallableType,
Instance,
LiteralType,
NoneType,
Overloaded,
ProperType,
SentinelValue,
TupleType,
Type,
TypedDictType,
Expand Down Expand Up @@ -66,6 +71,25 @@ def setUp(self) -> None:
def test_any(self) -> None:
assert_equal(str(AnyType(TypeOfAny.special_form)), "Any")

def test_sentinel_literal_json_roundtrip(self) -> None:
literal = LiteralType(SentinelValue("__main__.MISSING", "MISSING"), self.fx.a)
assert_equal(str(literal), "MISSING")
data = literal.serialize()
assert isinstance(data, dict)
roundtrip = LiteralType.deserialize(data)
self.assertEqual(roundtrip.value, literal.value)
self.assertEqual(roundtrip.fallback.type_ref, self.fx.a.type.fullname)

def test_sentinel_literal_ff_roundtrip(self) -> None:
literal = LiteralType(SentinelValue("__main__.MISSING", "MISSING"), self.fx.a)
data = WriteBuffer()
literal.write(data)
buffer = ReadBuffer(data.getvalue())
assert read_tag(buffer) == LITERAL_TYPE
roundtrip = LiteralType.read(buffer)
self.assertEqual(roundtrip.value, literal.value)
self.assertEqual(roundtrip.fallback.type_ref, self.fx.a.type.fullname)

def test_simple_unbound_type(self) -> None:
u = UnboundType("Foo")
assert_equal(str(u), "Foo?")
Expand Down
10 changes: 10 additions & 0 deletions mypy/typeanal.py
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,16 @@ def analyze_unbound_type_without_type_info(
column=t.column,
)

if isinstance(sym.node, Var) and sym.node.is_sentinel:
typ = get_proper_type(sym.node.type)
if isinstance(typ, Instance) and typ.last_known_value is not None:
return LiteralType(
value=typ.last_known_value.value,
fallback=typ.last_known_value.fallback,
line=t.line,
column=t.column,
)

# None of the above options worked. We parse the args (if there are any)
# to make sure there are no remaining semanal-only types, then give up.
t = t.copy_modified(args=self.anal_array(t.args))
Expand Down
2 changes: 1 addition & 1 deletion mypy/typeops.py
Original file line number Diff line number Diff line change
Expand Up @@ -1058,7 +1058,7 @@ def is_singleton_identity_type(typ: ProperType) -> bool:
or (typ.type.fullname in NOT_IMPLEMENTED_TYPE_NAMES)
)
if isinstance(typ, LiteralType):
return typ.is_enum_literal() or isinstance(typ.value, bool)
return typ.is_enum_literal() or typ.is_sentinel_literal() or isinstance(typ.value, bool)
if isinstance(typ, TypeType) and isinstance(typ.item, Instance) and typ.item.type.is_final:
return True
if isinstance(typ, FunctionLike) and typ.is_type_obj() and typ.type_object().is_final:
Expand Down
Loading
Loading