Skip to content
Open
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
3 changes: 2 additions & 1 deletion mypy/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
from mypy_extensions import u8

# High-level cache layout format
CACHE_VERSION: Final = 8
CACHE_VERSION: Final = 9

# Type used internally to represent errors:
# (path, line, column, end_line, end_column, severity, message, code)
Expand Down Expand Up @@ -308,6 +308,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 Down
5 changes: 5 additions & 0 deletions mypy/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -2777,13 +2777,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
8 changes: 7 additions & 1 deletion mypy/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1414,6 +1414,7 @@ def is_dynamic(self) -> bool:
"from_module_getattr",
"has_explicit_value",
"allow_incompatible_override",
"is_sentinel",
]


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

__match_args__ = ("name", "type", "final_value")
Expand Down Expand Up @@ -1514,6 +1516,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 @@ -1596,6 +1600,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 @@ -1633,7 +1638,8 @@ 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))
Expand Down
67 changes: 67 additions & 0 deletions mypy/semanal.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@
OVERRIDE_DECORATOR_NAMES,
PROTOCOL_NAMES,
REVEAL_TYPE_NAMES,
SENTINEL_TYPE_NAMES,
TPDICT_NAMES,
TYPE_ALIAS_NAMES,
TYPE_CHECK_ONLY_NAMES,
Expand All @@ -287,6 +288,7 @@
ParamSpecType,
PlaceholderType,
ProperType,
SentinelValue,
TrivialSyntheticTypeTranslator,
TupleType,
Type,
Expand Down Expand Up @@ -3357,9 +3359,17 @@ 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)
if sentinel_definition and self.is_existing_final_lvalue(s):
sentinel_definition = False

# 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 @@ -3372,6 +3382,60 @@ 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_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 is_existing_final_lvalue(self, s: AssignmentStmt) -> bool:
if len(s.lvalues) != 1 or not isinstance(s.lvalues[0], NameExpr):
return False
name = s.lvalues[0].name
existing = self.current_symbol_table().get(name)
if existing is not None and is_final_node(existing.node):
return True
return self.is_alias_for_final_name(name)

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 @@ -3536,6 +3600,8 @@ def is_type_ref(self, rv: Expression, bare: bool = False) -> bool:
# Assignment color = Color['RED'] defines a variable, not an alias.
return not rv.node.is_enum
if isinstance(rv.node, Var):
if rv.node.is_sentinel:
return True
return rv.node.fullname in NEVER_NAMES

if isinstance(rv, NameExpr):
Expand Down Expand Up @@ -4717,6 +4783,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
10 changes: 10 additions & 0 deletions mypy/test/testtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
NoneType,
Overloaded,
ProperType,
SentinelValue,
TupleType,
Type,
TypeOfAny,
Expand Down Expand Up @@ -65,6 +66,15 @@ 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_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 @@ -1000,6 +1000,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 @@ -1025,7 +1025,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
56 changes: 47 additions & 9 deletions mypy/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Any,
ClassVar,
Final,
NamedTuple,
NewType,
TypeAlias as _TypeAlias,
TypeGuard,
Expand All @@ -33,6 +34,7 @@
EXTRA_ATTRS,
LIST_GEN,
LITERAL_NONE,
LITERAL_SENTINEL,
ReadBuffer,
Tag,
WriteBuffer,
Expand Down Expand Up @@ -64,6 +66,7 @@

JsonDict: _TypeAlias = dict[str, Any]


# The set of all valid expressions that can currently be contained
# inside of a Literal[...].
#
Expand Down Expand Up @@ -94,7 +97,12 @@
#
# Note: Float values are only used internally. They are not accepted within
# Literal[...].
LiteralValue: _TypeAlias = int | str | bool | float
class SentinelValue(NamedTuple):
fullname: str
name: str


LiteralValue: _TypeAlias = int | str | bool | float | SentinelValue


TUPLE_NAMES: Final = ("builtins.tuple", "typing.Tuple")
Expand All @@ -109,6 +117,12 @@
"typing_extensions.TypeVarTuple",
)

SENTINEL_TYPE_NAMES: Final = (
"builtins.sentinel",
"typing_extensions.sentinel",
"typing_extensions.Sentinel",
)

TYPED_NAMEDTUPLE_NAMES: Final = ("typing.NamedTuple", "typing_extensions.NamedTuple")

# Supported names of TypedDict type constructors.
Expand Down Expand Up @@ -3226,11 +3240,15 @@ def __init__(
# almost no test cases where we would redundantly compute
# `can_be_false`/`can_be_true`.
def can_be_false_default(self) -> bool:
if isinstance(self.value, SentinelValue):
return False
if self.fallback.type.is_enum:
return self.fallback.can_be_false
return not self.value

def can_be_true_default(self) -> bool:
if isinstance(self.value, SentinelValue):
return True
if self.fallback.type.is_enum:
return self.fallback.can_be_true
return bool(self.value)
Expand All @@ -3251,13 +3269,19 @@ def __eq__(self, other: object) -> bool:
def is_enum_literal(self) -> bool:
return self.fallback.type.is_enum

def is_sentinel_literal(self) -> bool:
return isinstance(self.value, SentinelValue)

def value_repr(self) -> str:
"""Returns the string representation of the underlying type.

This function is almost equivalent to running `repr(self.value)`,
except it includes some additional logic to correctly handle cases
where the value is a string, byte string, a unicode string, or an enum.
"""
if isinstance(self.value, SentinelValue):
return self.value.name

raw = repr(self.value)
fallback_name = self.fallback.type.fullname

Expand All @@ -3276,29 +3300,41 @@ def value_repr(self) -> str:
return raw

def serialize(self) -> JsonDict | str:
return {
".class": "LiteralType",
"value": self.value,
"fallback": self.fallback.serialize(),
}
value: LiteralValue | JsonDict = self.value
if isinstance(value, SentinelValue):
value = {".class": "SentinelValue", "fullname": value.fullname, "name": value.name}
return {".class": "LiteralType", "value": value, "fallback": self.fallback.serialize()}

@classmethod
def deserialize(cls, data: JsonDict) -> LiteralType:
assert data[".class"] == "LiteralType"
return LiteralType(value=data["value"], fallback=Instance.deserialize(data["fallback"]))
value = data["value"]
if isinstance(value, dict):
assert value[".class"] == "SentinelValue"
value = SentinelValue(value["fullname"], value["name"])
return LiteralType(value=value, fallback=Instance.deserialize(data["fallback"]))

def write(self, data: WriteBuffer) -> None:
write_tag(data, LITERAL_TYPE)
self.fallback.write(data)
write_literal(data, self.value)
if isinstance(self.value, SentinelValue):
write_tag(data, LITERAL_SENTINEL)
write_str(data, self.value.fullname)
write_str(data, self.value.name)
else:
write_literal(data, self.value)
write_tag(data, END_TAG)

@classmethod
def read(cls, data: ReadBuffer) -> LiteralType:
assert read_tag(data) == INSTANCE
fallback = Instance.read(data)
tag = read_tag(data)
ret = LiteralType(read_literal(data, tag), fallback)
if tag == LITERAL_SENTINEL:
value = SentinelValue(read_str(data), read_str(data))
else:
value = read_literal(data, tag)
ret = LiteralType(value, fallback)
assert read_tag(data) == END_TAG
return ret

Expand Down Expand Up @@ -3958,6 +3994,8 @@ def visit_raw_expression_type(self, t: RawExpressionType, /) -> str:
return repr(t.literal_value)

def visit_literal_type(self, t: LiteralType, /) -> str:
if isinstance(t.value, SentinelValue):
return t.value_repr()
return f"Literal[{t.value_repr()}]"

def visit_union_type(self, t: UnionType, /) -> str:
Expand Down
Loading
Loading