Skip to content

Commit 44c0d9f

Browse files
Fix sentinel identity loss through generic substitution and inference (#21888)
A sentinel's only way to identify itself is via its attached literal value, unlike e.g. enum members whose class already carries identity. That literal was being unconditionally stripped both when a `TypeVar` was substituted with a sentinel instance (e.g. `dict.get`'s overloaded default parameter) and when inferring the type of a plain variable assignment, collapsing every sentinel down to the same uninformative `sentinel` type. Preserve it in both cases. Fixes #21866 Follow-up from #21647 Signed-off-by: Edgar Ramírez Mondragón <edgarrm358@gmail.com>
1 parent 1dab3c5 commit 44c0d9f

4 files changed

Lines changed: 62 additions & 2 deletions

File tree

mypy/erasetype.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,10 @@ class LastKnownValueEraser(TypeTranslator):
246246
def visit_instance(self, t: Instance) -> Type:
247247
if not t.last_known_value and not t.args:
248248
return t
249+
if t.last_known_value is not None and t.last_known_value.is_sentinel_literal():
250+
# Sentinel values (PEP 661) have no other way to identify themselves than
251+
# via their literal, unlike e.g. enum members, so it must be preserved.
252+
return t
249253
return t.copy_modified(args=[a.accept(self) for a in t.args], last_known_value=None)
250254

251255
def visit_type_alias_type(self, t: TypeAliasType) -> Type:

mypy/expandtype.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,10 @@ def visit_type_var(self, t: TypeVarType) -> Type:
244244
t = t.copy_modified(upper_bound=t.upper_bound.accept(self))
245245
repl = self.variables.get(t.id, t)
246246
if isinstance(repl, ProperType) and isinstance(repl, Instance):
247+
if repl.last_known_value is not None and repl.last_known_value.is_sentinel_literal():
248+
# Sentinel values (PEP 661) have no other way to identify themselves than
249+
# via their literal, unlike e.g. enum members, so it must survive expansion.
250+
return repl
247251
# TODO: do we really need to do this?
248252
# If I try to remove this special-casing ~40 tests fail on reveal_type().
249253
return repl.copy_modified(last_known_value=None)

mypy/messages.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2712,6 +2712,12 @@ def format_literal_value(typ: LiteralType) -> str:
27122712

27132713
if isinstance(typ, Instance):
27142714
itype = typ
2715+
if itype.last_known_value is not None and itype.last_known_value.is_sentinel_literal():
2716+
# Sentinel values (PEP 661) have no other way to identify themselves
2717+
# than via their literal, so use it instead of the shared fallback
2718+
# class name (unlike other literals, sentinels are always formatted
2719+
# this way, e.g. "MISSING" rather than "Literal[MISSING]").
2720+
return format_literal_value(itype.last_known_value)
27152721
# Get the short name of the type.
27162722
if itype.type.fullname == "types.ModuleType":
27172723
# Make some common error messages simpler and tidier.
@@ -3525,6 +3531,12 @@ def ignore_last_known_values(t: UnionType) -> Type:
35253531
seen_instances = set()
35263532
for item in t.items:
35273533
if isinstance(item, ProperType) and isinstance(item, Instance):
3534+
if item.last_known_value is not None and item.last_known_value.is_sentinel_literal():
3535+
# Sentinel values (PEP 661) have no other way to identify themselves
3536+
# than via their literal, unlike e.g. enum members, so it must be
3537+
# preserved (see mypy/erasetype.py for the same exemption).
3538+
union_items.append(item)
3539+
continue
35283540
erased = item.copy_modified(last_known_value=None)
35293541
if erased in seen_instances:
35303542
continue

test-data/unit/check-sentinels.test

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,51 @@ from typing_extensions import sentinel, assert_type
181181
MISSING = sentinel("MISSING")
182182
ALIAS = MISSING
183183

184-
assert_type(ALIAS, sentinel)
184+
# The value still identifies as the same sentinel...
185+
assert_type(ALIAS, MISSING)
185186

186187
def func(x: int | MISSING = MISSING) -> None:
187188
pass
188189

189190
func(MISSING)
190-
func(ALIAS) # E: Argument 1 to "func" has incompatible type "Sentinel"; expected "int | MISSING"
191+
func(ALIAS)
192+
193+
# ...but the reassignment does not make ALIAS usable as a type alias.
194+
def uses_alias_as_type(x: ALIAS) -> None: # E: Variable "__main__.ALIAS" is not valid as a type \
195+
# N: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases
196+
pass
197+
[builtins fixtures/tuple.pyi]
198+
199+
[case testSentinelPreservedThroughGenericSubstitution]
200+
from typing import assert_type
201+
from typing_extensions import sentinel
202+
203+
Unknown = sentinel("Unknown")
204+
205+
def func(d: dict[str, str]) -> None:
206+
var = d.get("key", Unknown)
207+
assert_type(var, str | Unknown)
208+
[builtins fixtures/dict-full.pyi]
209+
210+
[case testSentinelPreservedInErrorMessages]
211+
from typing_extensions import sentinel
212+
213+
Unknown = sentinel("Unknown")
214+
215+
def func(d: dict[str, str]) -> None:
216+
var = d.get("key", Unknown)
217+
x: int = var # E: Incompatible types in assignment (expression has type "str | Unknown", variable has type "int")
218+
[builtins fixtures/dict-full.pyi]
219+
220+
[case testAssignSentinel]
221+
from typing_extensions import sentinel
222+
223+
X = sentinel("X")
224+
225+
def inspect_sentinel(arg: sentinel) -> None: ...
226+
227+
reveal_type(X) # N: Revealed type is "X?"
228+
reveal_type(inspect_sentinel) # N: Revealed type is "def (arg: typing_extensions.Sentinel)"
229+
inspect_sentinel(arg=X)
230+
191231
[builtins fixtures/tuple.pyi]

0 commit comments

Comments
 (0)