Skip to content
Draft
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
24 changes: 5 additions & 19 deletions mypy/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,7 @@
from __future__ import annotations

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

if TYPE_CHECKING:
from mypy.types import SentinelValue
from typing import Any, Final, TypeAlias as _TypeAlias

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

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

# Type used internally to represent errors:
# (path, line, column, end_line, end_column, severity, message, code)
Expand Down Expand Up @@ -311,7 +308,6 @@ 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 @@ -332,7 +328,7 @@ def read(cls, data: ReadBuffer) -> CacheMetaEx | None:
END_TAG: Final[Tag] = 255


def read_literal(data: ReadBuffer, tag: Tag) -> int | str | bool | float | SentinelValue:
def read_literal(data: ReadBuffer, tag: Tag) -> int | str | bool | float:
if tag == LITERAL_INT:
return read_int_bare(data)
elif tag == LITERAL_STR:
Expand All @@ -343,18 +339,12 @@ def read_literal(data: ReadBuffer, tag: Tag) -> int | str | bool | float | Senti
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 | SentinelValue | None
) -> None:
def write_literal(data: WriteBuffer, value: int | str | bool | float | complex | None) -> None:
if isinstance(value, bool):
write_bool(data, value)
elif isinstance(value, int):
Expand All @@ -370,12 +360,8 @@ def write_literal(
write_tag(data, LITERAL_COMPLEX)
write_float_bare(data, value.real)
write_float_bare(data, value.imag)
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)
write_tag(data, LITERAL_NONE)


def read_int(data: ReadBuffer) -> int:
Expand Down
4 changes: 4 additions & 0 deletions mypy/checkexpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
PromoteExpr,
RefExpr,
RevealExpr,
SentinelExpr,
SetComprehension,
SetExpr,
SliceExpr,
Expand Down Expand Up @@ -6456,6 +6457,9 @@ def visit_type_var_tuple_expr(self, e: TypeVarTupleExpr) -> Type:
def visit_newtype_expr(self, e: NewTypeExpr) -> Type:
return AnyType(TypeOfAny.special_form)

def visit_sentinel_expr(self, e: SentinelExpr) -> Type:
return Instance(e.info, [], line=e.line, column=e.column)

def visit_namedtuple_expr(self, e: NamedTupleExpr) -> Type:
tuple_type = e.info.tuple_type
if tuple_type:
Expand Down
3 changes: 3 additions & 0 deletions mypy/evalexpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ def visit_typeddict_expr(self, o: mypy.nodes.TypedDictExpr) -> object:
def visit_newtype_expr(self, o: mypy.nodes.NewTypeExpr) -> object:
return UNKNOWN

def visit_sentinel_expr(self, o: mypy.nodes.SentinelExpr) -> object:
return UNKNOWN

def visit__promote_expr(self, o: mypy.nodes.PromoteExpr) -> object:
return UNKNOWN

Expand Down
4 changes: 4 additions & 0 deletions mypy/literals.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
ParamSpecExpr,
PromoteExpr,
RevealExpr,
SentinelExpr,
SetComprehension,
SetExpr,
SliceExpr,
Expand Down Expand Up @@ -311,6 +312,9 @@ def visit_typeddict_expr(self, e: TypedDictExpr) -> None:
def visit_newtype_expr(self, e: NewTypeExpr) -> None:
return None

def visit_sentinel_expr(self, e: SentinelExpr) -> None:
return None

def visit__promote_expr(self, e: PromoteExpr) -> None:
return None

Expand Down
10 changes: 4 additions & 6 deletions mypy/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
flatten_nested_unions,
get_proper_type,
get_proper_types,
sentinel_display_name,
)
from mypy.typetraverser import TypeTraverserVisitor
from mypy.util import plural_s, unmangle
Expand Down Expand Up @@ -2722,7 +2723,9 @@ def format_literal_value(typ: LiteralType) -> str:
if itype.type.fullname == "typing._SpecialForm":
# This is not a real type but used for some typing-related constructs.
return "<typing special form>"
if verbosity >= 2 or (fullnames and itype.type.fullname in fullnames):
if itype.type.is_sentinel:
base_str = sentinel_display_name(itype.type)
elif verbosity >= 2 or (fullnames and itype.type.fullname in fullnames):
base_str = itype.type.fullname
else:
base_str = itype.type.name
Expand Down Expand Up @@ -2783,18 +2786,13 @@ 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
5 changes: 5 additions & 0 deletions mypy/mixedtraverser.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
NamedTupleExpr,
NewTypeExpr,
PromoteExpr,
SentinelExpr,
TypeAlias,
TypeAliasExpr,
TypeAliasStmt,
Expand Down Expand Up @@ -95,6 +96,10 @@ def visit_newtype_expr(self, o: NewTypeExpr, /) -> None:
self.process_type_info(o.info)
self.visit_optional_type(o.old_type)

def visit_sentinel_expr(self, o: SentinelExpr, /) -> None:
super().visit_sentinel_expr(o)
self.process_type_info(o.info)

# Statements

def visit_assignment_stmt(self, o: AssignmentStmt, /) -> None:
Expand Down
44 changes: 40 additions & 4 deletions mypy/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import os
import sys
from abc import abstractmethod
from collections import defaultdict
from collections.abc import Callable, Iterator, Sequence
Expand Down Expand Up @@ -79,6 +80,11 @@
from mypy.util import is_sunder, is_typeshed_file, short_type
from mypy.visitor import ExpressionVisitor, NodeVisitor, StatementVisitor

if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override

if TYPE_CHECKING:
from mypy.patterns import Pattern

Expand Down Expand Up @@ -1646,9 +1652,7 @@ def read(cls, data: ReadBuffer) -> Var:
if tag == LITERAL_COMPLEX:
v.final_value = complex(read_float_bare(data), read_float_bare(data))
elif tag != LITERAL_NONE:
val = read_literal(data, tag)
assert not isinstance(val, mypy.types.SentinelValue)
v.final_value = val
v.final_value = read_literal(data, tag)
assert read_tag(data) == END_TAG
return v

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


class SentinelExpr(Expression):
"""PEP 661 sentinel()/Sentinel() call expression.

Marks the rvalue of a sentinel declaration (`X = sentinel("X")`) so that its type
is the synthetic per-declaration class in `info`, rather than whatever ordinary
call-checking against sentinel's/Sentinel's __init__ signature would produce.
"""

__slots__ = ("info",)

__match_args__ = ("info",)

# The synthesized class representing this specific sentinel.
info: TypeInfo

def __init__(self, info: TypeInfo, line: int, column: int) -> None:
super().__init__(line=line, column=column)
self.info = info

@override
def accept(self, visitor: ExpressionVisitor[T]) -> T:
return visitor.visit_sentinel_expr(self)


class AwaitExpr(Expression):
"""Await expression (await ...)."""

Expand Down Expand Up @@ -3666,6 +3694,7 @@ class is generic then it will be a type constructor of higher kind.
"is_named_tuple",
"typeddict_type",
"is_newtype",
"is_sentinel",
"is_intersection",
"metadata",
"alt_promote",
Expand Down Expand Up @@ -3806,6 +3835,9 @@ class is generic then it will be a type constructor of higher kind.
# Is this a newtype type?
is_newtype: bool

# Is this a synthetic type generated for a PEP 661 sentinel()/Sentinel() declaration?
is_sentinel: bool

# Is this a synthesized intersection type?
is_intersection: bool

Expand Down Expand Up @@ -3860,6 +3892,7 @@ class is generic then it will be a type constructor of higher kind.
"meta_fallback_to_any",
"is_named_tuple",
"is_newtype",
"is_sentinel",
"is_protocol",
"runtime_protocol",
"is_final",
Expand Down Expand Up @@ -3907,6 +3940,7 @@ def __init__(self, names: SymbolTable, defn: ClassDef, module_name: str) -> None
self.is_named_tuple = False
self.typeddict_type = None
self.is_newtype = False
self.is_sentinel = False
self.is_intersection = False
self.metadata = {}
self.self_type = None
Expand Down Expand Up @@ -4350,6 +4384,7 @@ def write(self, data: WriteBuffer) -> None:
self.meta_fallback_to_any,
self.is_named_tuple,
self.is_newtype,
self.is_sentinel,
self.is_protocol,
self.runtime_protocol,
self.is_final,
Expand Down Expand Up @@ -4423,12 +4458,13 @@ def read(cls, data: ReadBuffer) -> TypeInfo:
ti.meta_fallback_to_any,
ti.is_named_tuple,
ti.is_newtype,
ti.is_sentinel,
ti.is_protocol,
ti.runtime_protocol,
ti.is_final,
ti.is_disjoint_base,
ti.is_intersection,
) = read_flags(data, num_flags=11)
) = read_flags(data, num_flags=12)
ti.metadata = read_json(data)
tag = read_tag(data)
if tag != LITERAL_NONE:
Expand Down
7 changes: 3 additions & 4 deletions mypy/plugins/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@
LiteralType,
NoneType,
ProperType,
SentinelValue,
TupleType,
Type,
TypeOfAny,
Expand Down Expand Up @@ -800,11 +799,11 @@ 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
if node_type.type.is_sentinel:
# Clean up synthetic class's mangled fullname
return node_type.type.fullname.removesuffix("'") == "dataclasses.KW_ONLY"
return node_type.type.fullname == "dataclasses.KW_ONLY"

def _add_dataclass_fields_magic_attribute(self) -> None:
Expand Down
35 changes: 25 additions & 10 deletions mypy/semanal.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
RefExpr,
ReturnStmt,
RevealExpr,
SentinelExpr,
SetComprehension,
SetExpr,
SliceExpr,
Expand Down Expand Up @@ -290,7 +291,6 @@
ParamSpecType,
PlaceholderType,
ProperType,
SentinelValue,
TrivialSyntheticTypeTranslator,
TupleType,
Type,
Expand Down Expand Up @@ -3435,16 +3435,32 @@ def sentinel_type_for_var(self, var: Var, rvalue: Expression) -> Instance | None
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,
)

# Give this sentinel its own synthetic nominal type (like NewType), rather than
# tagging the shared sentinel/Sentinel class with a Literal[...] value: a sentinel
# has no way to identify itself other than this type, unlike e.g. an enum member.
# The mangled name avoids colliding with the Var of the same name in this scope.
mangled_name = f"{var.name}'"
info = self.basic_new_typeinfo(mangled_name, typ, rvalue.line)
info.is_sentinel = True

# Insert directly rather than via add_symbol(): redefining the sentinel Var (e.g.
# reassigning MISSING = sentinel(...) again) is already reported once for the Var
# itself, and would otherwise also be (redundantly) reported for this mangled name.
symbol_table = self.type.names if self.type is not None else self.globals
symbol_table[mangled_name] = SymbolTableNode(
kind=MDEF if self.type is not None else GDEF,
node=info,
module_public=False,
module_hidden=True,
)

# Redirect type-checking of this call expression to visit_sentinel_expr, so its
# type is this synthetic class rather than whatever ordinary call-checking
# against sentinel's/Sentinel's __init__ signature would otherwise produce.
rvalue.analyzed = SentinelExpr(info, line=rvalue.line, column=rvalue.column)
return Instance(info, [], 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 @@ -4816,7 +4832,6 @@ 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
Loading
Loading