Skip to content

Commit d1c7eeb

Browse files
Refactor PEP 661 sentinel handling to use a synthetic nominal class
Signed-off-by: Edgar Ramírez Mondragón <edgarrm358@gmail.com>
1 parent d32c4d3 commit d1c7eeb

20 files changed

Lines changed: 172 additions & 118 deletions

mypy/cache.py

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,7 @@
4848
from __future__ import annotations
4949

5050
from collections.abc import Sequence
51-
from typing import TYPE_CHECKING, Any, Final, TypeAlias as _TypeAlias
52-
53-
if TYPE_CHECKING:
54-
from mypy.types import SentinelValue
51+
from typing import Any, Final, TypeAlias as _TypeAlias
5552

5653
from librt.internal import (
5754
ReadBuffer as ReadBuffer,
@@ -72,7 +69,7 @@
7269
from mypy_extensions import u8
7370

7471
# High-level cache layout format
75-
CACHE_VERSION: Final = 11
72+
CACHE_VERSION: Final = 12
7673

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

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

334330

335-
def read_literal(data: ReadBuffer, tag: Tag) -> int | str | bool | float | SentinelValue:
331+
def read_literal(data: ReadBuffer, tag: Tag) -> int | str | bool | float:
336332
if tag == LITERAL_INT:
337333
return read_int_bare(data)
338334
elif tag == LITERAL_STR:
@@ -343,18 +339,12 @@ def read_literal(data: ReadBuffer, tag: Tag) -> int | str | bool | float | Senti
343339
return True
344340
elif tag == LITERAL_FLOAT:
345341
return read_float_bare(data)
346-
elif tag == LITERAL_SENTINEL:
347-
from mypy.types import SentinelValue as _SentinelValue
348-
349-
return _SentinelValue(read_str_bare(data), read_str_bare(data))
350342
assert False, f"Unknown literal tag {tag}"
351343

352344

353345
# There is an intentional asymmetry between read and write for literals because
354346
# None and/or complex values are only allowed in some contexts but not in others.
355-
def write_literal(
356-
data: WriteBuffer, value: int | str | bool | float | complex | SentinelValue | None
357-
) -> None:
347+
def write_literal(data: WriteBuffer, value: int | str | bool | float | complex | None) -> None:
358348
if isinstance(value, bool):
359349
write_bool(data, value)
360350
elif isinstance(value, int):
@@ -370,12 +360,8 @@ def write_literal(
370360
write_tag(data, LITERAL_COMPLEX)
371361
write_float_bare(data, value.real)
372362
write_float_bare(data, value.imag)
373-
elif value is None:
374-
write_tag(data, LITERAL_NONE)
375363
else:
376-
write_tag(data, LITERAL_SENTINEL)
377-
write_str_bare(data, value.fullname)
378-
write_str_bare(data, value.name)
364+
write_tag(data, LITERAL_NONE)
379365

380366

381367
def read_int(data: ReadBuffer) -> int:

mypy/checkexpr.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
PromoteExpr,
8686
RefExpr,
8787
RevealExpr,
88+
SentinelExpr,
8889
SetComprehension,
8990
SetExpr,
9091
SliceExpr,
@@ -6456,6 +6457,9 @@ def visit_type_var_tuple_expr(self, e: TypeVarTupleExpr) -> Type:
64566457
def visit_newtype_expr(self, e: NewTypeExpr) -> Type:
64576458
return AnyType(TypeOfAny.special_form)
64586459

6460+
def visit_sentinel_expr(self, e: SentinelExpr) -> Type:
6461+
return Instance(e.info, [], line=e.line, column=e.column)
6462+
64596463
def visit_namedtuple_expr(self, e: NamedTupleExpr) -> Type:
64606464
tuple_type = e.info.tuple_type
64616465
if tuple_type:

mypy/evalexpr.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ def visit_typeddict_expr(self, o: mypy.nodes.TypedDictExpr) -> object:
186186
def visit_newtype_expr(self, o: mypy.nodes.NewTypeExpr) -> object:
187187
return UNKNOWN
188188

189+
def visit_sentinel_expr(self, o: mypy.nodes.SentinelExpr) -> object:
190+
return UNKNOWN
191+
189192
def visit__promote_expr(self, o: mypy.nodes.PromoteExpr) -> object:
190193
return UNKNOWN
191194

mypy/literals.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
ParamSpecExpr,
3737
PromoteExpr,
3838
RevealExpr,
39+
SentinelExpr,
3940
SetComprehension,
4041
SetExpr,
4142
SliceExpr,
@@ -311,6 +312,9 @@ def visit_typeddict_expr(self, e: TypedDictExpr) -> None:
311312
def visit_newtype_expr(self, e: NewTypeExpr) -> None:
312313
return None
313314

315+
def visit_sentinel_expr(self, e: SentinelExpr) -> None:
316+
return None
317+
314318
def visit__promote_expr(self, e: PromoteExpr) -> None:
315319
return None
316320

mypy/messages.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
flatten_nested_unions,
104104
get_proper_type,
105105
get_proper_types,
106+
sentinel_display_name,
106107
)
107108
from mypy.typetraverser import TypeTraverserVisitor
108109
from mypy.util import plural_s, unmangle
@@ -2722,7 +2723,9 @@ def format_literal_value(typ: LiteralType) -> str:
27222723
if itype.type.fullname == "typing._SpecialForm":
27232724
# This is not a real type but used for some typing-related constructs.
27242725
return "<typing special form>"
2725-
if verbosity >= 2 or (fullnames and itype.type.fullname in fullnames):
2726+
if itype.type.is_sentinel:
2727+
base_str = sentinel_display_name(itype.type)
2728+
elif verbosity >= 2 or (fullnames and itype.type.fullname in fullnames):
27262729
base_str = itype.type.fullname
27272730
else:
27282731
base_str = itype.type.name
@@ -2783,18 +2786,13 @@ def format_literal_value(typ: LiteralType) -> str:
27832786
modifier += "="
27842787
items.append(f"{item_name!r}{modifier}: {format(item_type)}")
27852788
return f"TypedDict({{{', '.join(items)}}})"
2786-
elif isinstance(typ, LiteralType) and typ.is_sentinel_literal():
2787-
return format_literal_value(typ)
27882789
elif isinstance(typ, LiteralType):
27892790
return f"Literal[{format_literal_value(typ)}]"
27902791
elif isinstance(typ, UnionType):
27912792
typ = get_proper_type(ignore_last_known_values(typ))
27922793
if not isinstance(typ, UnionType):
27932794
return format(typ)
27942795
literal_items, union_items = separate_union_literals(typ)
2795-
sentinel_items = [item for item in literal_items if item.is_sentinel_literal()]
2796-
literal_items = [item for item in literal_items if not item.is_sentinel_literal()]
2797-
union_items = [*sentinel_items, *union_items]
27982796

27992797
# Coalesce multiple Literal[] members. This also changes output order.
28002798
# If there's just one Literal item, retain the original ordering.

mypy/mixedtraverser.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
NamedTupleExpr,
1111
NewTypeExpr,
1212
PromoteExpr,
13+
SentinelExpr,
1314
TypeAlias,
1415
TypeAliasExpr,
1516
TypeAliasStmt,
@@ -95,6 +96,10 @@ def visit_newtype_expr(self, o: NewTypeExpr, /) -> None:
9596
self.process_type_info(o.info)
9697
self.visit_optional_type(o.old_type)
9798

99+
def visit_sentinel_expr(self, o: SentinelExpr, /) -> None:
100+
super().visit_sentinel_expr(o)
101+
self.process_type_info(o.info)
102+
98103
# Statements
99104

100105
def visit_assignment_stmt(self, o: AssignmentStmt, /) -> None:

mypy/nodes.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import os
6+
import sys
67
from abc import abstractmethod
78
from collections import defaultdict
89
from collections.abc import Callable, Iterator, Sequence
@@ -79,6 +80,11 @@
7980
from mypy.util import is_sunder, is_typeshed_file, short_type
8081
from mypy.visitor import ExpressionVisitor, NodeVisitor, StatementVisitor
8182

83+
if sys.version_info >= (3, 12):
84+
from typing import override
85+
else:
86+
from typing_extensions import override
87+
8288
if TYPE_CHECKING:
8389
from mypy.patterns import Pattern
8490

@@ -1646,9 +1652,7 @@ def read(cls, data: ReadBuffer) -> Var:
16461652
if tag == LITERAL_COMPLEX:
16471653
v.final_value = complex(read_float_bare(data), read_float_bare(data))
16481654
elif tag != LITERAL_NONE:
1649-
val = read_literal(data, tag)
1650-
assert not isinstance(val, mypy.types.SentinelValue)
1651-
v.final_value = val
1655+
v.final_value = read_literal(data, tag)
16521656
assert read_tag(data) == END_TAG
16531657
return v
16541658

@@ -3544,6 +3548,30 @@ def accept(self, visitor: ExpressionVisitor[T]) -> T:
35443548
return visitor.visit_newtype_expr(self)
35453549

35463550

3551+
class SentinelExpr(Expression):
3552+
"""PEP 661 sentinel()/Sentinel() call expression.
3553+
3554+
Marks the rvalue of a sentinel declaration (`X = sentinel("X")`) so that its type
3555+
is the synthetic per-declaration class in `info`, rather than whatever ordinary
3556+
call-checking against sentinel's/Sentinel's __init__ signature would produce.
3557+
"""
3558+
3559+
__slots__ = ("info",)
3560+
3561+
__match_args__ = ("info",)
3562+
3563+
# The synthesized class representing this specific sentinel.
3564+
info: TypeInfo
3565+
3566+
def __init__(self, info: TypeInfo, line: int, column: int) -> None:
3567+
super().__init__(line=line, column=column)
3568+
self.info = info
3569+
3570+
@override
3571+
def accept(self, visitor: ExpressionVisitor[T]) -> T:
3572+
return visitor.visit_sentinel_expr(self)
3573+
3574+
35473575
class AwaitExpr(Expression):
35483576
"""Await expression (await ...)."""
35493577

@@ -3666,6 +3694,7 @@ class is generic then it will be a type constructor of higher kind.
36663694
"is_named_tuple",
36673695
"typeddict_type",
36683696
"is_newtype",
3697+
"is_sentinel",
36693698
"is_intersection",
36703699
"metadata",
36713700
"alt_promote",
@@ -3806,6 +3835,9 @@ class is generic then it will be a type constructor of higher kind.
38063835
# Is this a newtype type?
38073836
is_newtype: bool
38083837

3838+
# Is this a synthetic type generated for a PEP 661 sentinel()/Sentinel() declaration?
3839+
is_sentinel: bool
3840+
38093841
# Is this a synthesized intersection type?
38103842
is_intersection: bool
38113843

@@ -3860,6 +3892,7 @@ class is generic then it will be a type constructor of higher kind.
38603892
"meta_fallback_to_any",
38613893
"is_named_tuple",
38623894
"is_newtype",
3895+
"is_sentinel",
38633896
"is_protocol",
38643897
"runtime_protocol",
38653898
"is_final",
@@ -3907,6 +3940,7 @@ def __init__(self, names: SymbolTable, defn: ClassDef, module_name: str) -> None
39073940
self.is_named_tuple = False
39083941
self.typeddict_type = None
39093942
self.is_newtype = False
3943+
self.is_sentinel = False
39103944
self.is_intersection = False
39113945
self.metadata = {}
39123946
self.self_type = None
@@ -4350,6 +4384,7 @@ def write(self, data: WriteBuffer) -> None:
43504384
self.meta_fallback_to_any,
43514385
self.is_named_tuple,
43524386
self.is_newtype,
4387+
self.is_sentinel,
43534388
self.is_protocol,
43544389
self.runtime_protocol,
43554390
self.is_final,
@@ -4423,12 +4458,13 @@ def read(cls, data: ReadBuffer) -> TypeInfo:
44234458
ti.meta_fallback_to_any,
44244459
ti.is_named_tuple,
44254460
ti.is_newtype,
4461+
ti.is_sentinel,
44264462
ti.is_protocol,
44274463
ti.runtime_protocol,
44284464
ti.is_final,
44294465
ti.is_disjoint_base,
44304466
ti.is_intersection,
4431-
) = read_flags(data, num_flags=11)
4467+
) = read_flags(data, num_flags=12)
44324468
ti.metadata = read_json(data)
44334469
tag = read_tag(data)
44344470
if tag != LITERAL_NONE:

mypy/plugins/dataclasses.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@
6363
LiteralType,
6464
NoneType,
6565
ProperType,
66-
SentinelValue,
6766
TupleType,
6867
Type,
6968
TypeOfAny,
@@ -800,11 +799,11 @@ def _is_kw_only_type(self, node: Type | None) -> bool:
800799
if node is None:
801800
return False
802801
node_type = get_proper_type(node)
803-
if isinstance(node_type, LiteralType) and isinstance(node_type.value, SentinelValue):
804-
# PEP 661 sentinel: `KW_ONLY = sentinel("KW_ONLY")` (Python 3.15+).
805-
return node_type.value.fullname == "dataclasses.KW_ONLY"
806802
if not isinstance(node_type, Instance):
807803
return False
804+
if node_type.type.is_sentinel:
805+
# Clean up synthetic class's mangled fullname
806+
return node_type.type.fullname.removesuffix("'") == "dataclasses.KW_ONLY"
808807
return node_type.type.fullname == "dataclasses.KW_ONLY"
809808

810809
def _add_dataclass_fields_magic_attribute(self) -> None:

mypy/semanal.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@
156156
RefExpr,
157157
ReturnStmt,
158158
RevealExpr,
159+
SentinelExpr,
159160
SetComprehension,
160161
SetExpr,
161162
SliceExpr,
@@ -290,7 +291,6 @@
290291
ParamSpecType,
291292
PlaceholderType,
292293
ProperType,
293-
SentinelValue,
294294
TrivialSyntheticTypeTranslator,
295295
TupleType,
296296
Type,
@@ -3435,16 +3435,32 @@ def sentinel_type_for_var(self, var: Var, rvalue: Expression) -> Instance | None
34353435
typ = self.named_type_or_none(callee.fullname)
34363436
if typ is None:
34373437
return None
3438-
name = f"{self.type.name}.{var.name}" if self.type is not None else var.name
3439-
return typ.copy_modified(
3440-
last_known_value=LiteralType(
3441-
SentinelValue(var.fullname, name),
3442-
fallback=typ,
3443-
line=rvalue.line,
3444-
column=rvalue.column,
3445-
)
3438+
3439+
# Give this sentinel its own synthetic nominal type (like NewType), rather than
3440+
# tagging the shared sentinel/Sentinel class with a Literal[...] value: a sentinel
3441+
# has no way to identify itself other than this type, unlike e.g. an enum member.
3442+
# The mangled name avoids colliding with the Var of the same name in this scope.
3443+
mangled_name = f"{var.name}'"
3444+
info = self.basic_new_typeinfo(mangled_name, typ, rvalue.line)
3445+
info.is_sentinel = True
3446+
3447+
# Insert directly rather than via add_symbol(): redefining the sentinel Var (e.g.
3448+
# reassigning MISSING = sentinel(...) again) is already reported once for the Var
3449+
# itself, and would otherwise also be (redundantly) reported for this mangled name.
3450+
symbol_table = self.type.names if self.type is not None else self.globals
3451+
symbol_table[mangled_name] = SymbolTableNode(
3452+
kind=MDEF if self.type is not None else GDEF,
3453+
node=info,
3454+
module_public=False,
3455+
module_hidden=True,
34463456
)
34473457

3458+
# Redirect type-checking of this call expression to visit_sentinel_expr, so its
3459+
# type is this synthetic class rather than whatever ordinary call-checking
3460+
# against sentinel's/Sentinel's __init__ signature would otherwise produce.
3461+
rvalue.analyzed = SentinelExpr(info, line=rvalue.line, column=rvalue.column)
3462+
return Instance(info, [], line=rvalue.line, column=rvalue.column)
3463+
34483464
def analyze_identity_global_assignment(self, s: AssignmentStmt) -> bool:
34493465
"""Special case 'X = X' in global scope.
34503466
@@ -4816,7 +4832,6 @@ def store_declared_types(self, lvalue: Lvalue, typ: Type) -> None:
48164832
var.is_final
48174833
and isinstance(typ, Instance)
48184834
and typ.last_known_value
4819-
and not isinstance(typ.last_known_value.value, SentinelValue)
48204835
and (not self.type or not self.type.is_enum)
48214836
):
48224837
var.final_value = typ.last_known_value.value

0 commit comments

Comments
 (0)