Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do you need to update code few lines below to handle LITERAL_SENTINEL as well?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Actually yeah, as @cdce8p suggested above, I guess updating write_literal and read_literal may be better. IIUC sentinels are more like int/str in terms of having full "literalness", rather than float/complex that have some special handling because they have limited "literalness".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I (well Codex) put something together but I need to look at this a bit more to make sure it's right, going to put this into draft for now.

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