From ee1e2c4406d45dcb78c96247dfb2c5525e90a8ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Mon, 24 Aug 2026 12:34:48 -0600 Subject: [PATCH 1/2] Fix sentinel identity loss through generic substitution and inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- mypy/erasetype.py | 4 ++++ mypy/expandtype.py | 4 ++++ test-data/unit/check-sentinels.test | 21 +++++++++++++++++++-- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/mypy/erasetype.py b/mypy/erasetype.py index cb8d66f292dd3..9e8dc42fb9244 100644 --- a/mypy/erasetype.py +++ b/mypy/erasetype.py @@ -246,6 +246,10 @@ class LastKnownValueEraser(TypeTranslator): def visit_instance(self, t: Instance) -> Type: if not t.last_known_value and not t.args: return t + if t.last_known_value is not None and t.last_known_value.is_sentinel_literal(): + # Sentinel values (PEP 661) have no other way to identify themselves than + # via their literal, unlike e.g. enum members, so it must be preserved. + return t return t.copy_modified(args=[a.accept(self) for a in t.args], last_known_value=None) def visit_type_alias_type(self, t: TypeAliasType) -> Type: diff --git a/mypy/expandtype.py b/mypy/expandtype.py index fd507216a6be9..d98ab582cc83e 100644 --- a/mypy/expandtype.py +++ b/mypy/expandtype.py @@ -244,6 +244,10 @@ def visit_type_var(self, t: TypeVarType) -> Type: t = t.copy_modified(upper_bound=t.upper_bound.accept(self)) repl = self.variables.get(t.id, t) if isinstance(repl, ProperType) and isinstance(repl, Instance): + if repl.last_known_value is not None and repl.last_known_value.is_sentinel_literal(): + # Sentinel values (PEP 661) have no other way to identify themselves than + # via their literal, unlike e.g. enum members, so it must survive expansion. + return repl # TODO: do we really need to do this? # If I try to remove this special-casing ~40 tests fail on reveal_type(). return repl.copy_modified(last_known_value=None) diff --git a/test-data/unit/check-sentinels.test b/test-data/unit/check-sentinels.test index 6d39c11375bb5..bbaf9666a7e9c 100644 --- a/test-data/unit/check-sentinels.test +++ b/test-data/unit/check-sentinels.test @@ -181,11 +181,28 @@ from typing_extensions import sentinel, assert_type MISSING = sentinel("MISSING") ALIAS = MISSING -assert_type(ALIAS, sentinel) +# The value still identifies as the same sentinel... +assert_type(ALIAS, MISSING) def func(x: int | MISSING = MISSING) -> None: pass func(MISSING) -func(ALIAS) # E: Argument 1 to "func" has incompatible type "Sentinel"; expected "int | MISSING" +func(ALIAS) + +# ...but the reassignment does not make ALIAS usable as a type alias. +def uses_alias_as_type(x: ALIAS) -> None: # E: Variable "__main__.ALIAS" is not valid as a type \ + # N: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases + pass [builtins fixtures/tuple.pyi] + +[case testSentinelPreservedThroughGenericSubstitution] +from typing import assert_type +from typing_extensions import sentinel + +Unknown = sentinel("Unknown") + +def func(d: dict[str, str]) -> None: + var = d.get("key", Unknown) + assert_type(var, str | Unknown) +[builtins fixtures/dict-full.pyi] From ff4d71b3abb85717fb61bef37fae7bd4b2af0da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Mon, 24 Aug 2026 23:22:55 -0600 Subject: [PATCH 2/2] fix: Preserve sentinel identity in error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- mypy/messages.py | 12 ++++++++++++ test-data/unit/check-sentinels.test | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/mypy/messages.py b/mypy/messages.py index b58c9e7ac4b6c..6103ecfac4c69 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -2712,6 +2712,12 @@ def format_literal_value(typ: LiteralType) -> str: if isinstance(typ, Instance): itype = typ + if itype.last_known_value is not None and itype.last_known_value.is_sentinel_literal(): + # Sentinel values (PEP 661) have no other way to identify themselves + # than via their literal, so use it instead of the shared fallback + # class name (unlike other literals, sentinels are always formatted + # this way, e.g. "MISSING" rather than "Literal[MISSING]"). + return format_literal_value(itype.last_known_value) # Get the short name of the type. if itype.type.fullname == "types.ModuleType": # Make some common error messages simpler and tidier. @@ -3525,6 +3531,12 @@ def ignore_last_known_values(t: UnionType) -> Type: seen_instances = set() for item in t.items: if isinstance(item, ProperType) and isinstance(item, Instance): + if item.last_known_value is not None and item.last_known_value.is_sentinel_literal(): + # Sentinel values (PEP 661) have no other way to identify themselves + # than via their literal, unlike e.g. enum members, so it must be + # preserved (see mypy/erasetype.py for the same exemption). + union_items.append(item) + continue erased = item.copy_modified(last_known_value=None) if erased in seen_instances: continue diff --git a/test-data/unit/check-sentinels.test b/test-data/unit/check-sentinels.test index bbaf9666a7e9c..4e29ac3e09fa6 100644 --- a/test-data/unit/check-sentinels.test +++ b/test-data/unit/check-sentinels.test @@ -206,3 +206,13 @@ def func(d: dict[str, str]) -> None: var = d.get("key", Unknown) assert_type(var, str | Unknown) [builtins fixtures/dict-full.pyi] + +[case testSentinelPreservedInErrorMessages] +from typing_extensions import sentinel + +Unknown = sentinel("Unknown") + +def func(d: dict[str, str]) -> None: + var = d.get("key", Unknown) + x: int = var # E: Incompatible types in assignment (expression has type "str | Unknown", variable has type "int") +[builtins fixtures/dict-full.pyi]