Repository: python/typing_extensions
Pull request #594: Add support for sentinels (PEP 661)
Author: login: Viicos
Created: 2025-05-08T15:29:52Z | Merged: 2025-05-13T15:18:45Z

Fixes https://github.com/python/typing_extensions/issues/591.

Regarding the pickling behavior, no matter if the sentinel is defined at the module level or in a nested scope, this seems tricky to handle. Ideally, I'd like to have the following working:

```python
sentinel = Sentinel('sentinel')

class A:
    def __init__(self):
        self.s = sentinel

a = A()
a.s is sentinel  # True

import pickle

a_r = pickle.loads(pickle.dumps(a))

a_r.s is sentinel  # False
```

Considering unplicking data can be done across interpreter runs, this isn't really achievable, but still will be surprising.


Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba1a6d78..92a19a3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,8 @@ New features:
   Patch by [Victorien Plot](https://github.com/Viicos).
 - Add `typing_extensions.Reader` and `typing_extensions.Writer`. Patch by
   Sebastian Rittau.
+- Add support for sentinels ([PEP 661](https://peps.python.org/pep-0661/)). Patch by
+  [Victorien Plot](https://github.com/Viicos).
 
 # Release 4.13.2 (April 10, 2025)
 
diff --git a/doc/index.rst b/doc/index.rst
index 68402faf..21d6fa60 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -1027,6 +1027,34 @@ Capsule objects
    .. versionadded:: 4.12.0
 
 
+Sentinel objects
+~~~~~~~~~~~~~~~~
+
+.. class:: Sentinel(name, repr=None)
+
+   A type used to define sentinel values. The *name* argument should be the
+   name of the variable to which the return value shall be assigned.
+
+   If *repr* is provided, it will be used for the :meth:`~object.__repr__`
+   of the sentinel object. If not provided, ``"<name>"`` will be used.
+
+   Example::
+
+      >>> from typing_extensions import Sentinel, assert_type
+      >>> MISSING = Sentinel('MISSING')
+      >>> def func(arg: int | MISSING = MISSING) -> None:
+      ...     if arg is MISSING:
+      ...         assert_type(arg, MISSING)
+      ...     else:
+      ...         assert_type(arg, int)
+      ...
+      >>> func(MISSING)
+
+   .. versionadded:: 4.14.0
+
+      See :pep:`661`
+
+
 Pure aliases
 ~~~~~~~~~~~~
 
diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py
index a7953dc5..c23e94b7 100644
--- a/src/test_typing_extensions.py
+++ b/src/test_typing_extensions.py
@@ -65,6 +65,7 @@
     ReadOnly,
     Required,
     Self,
+    Sentinel,
     Set,
     Tuple,
     Type,
@@ -9096,5 +9097,44 @@ def test_invalid_special_forms(self):
             self.assertIs(evaluate_forward_ref(typing.ForwardRef("ClassVar", is_argument=False), globals=vars(typing)), ClassVar)
 
 
+class TestSentinels(BaseTestCase):
+    def test_sentinel_no_repr(self):
+        sentinel_no_repr = Sentinel('sentinel_no_repr')
+
+        self.assertEqual(sentinel_no_repr._name, 'sentinel_no_repr')
+        self.assertEqual(repr(sentinel_no_repr), '<sentinel_no_repr>')
+
+    def test_sentinel_explicit_repr(self):
+        sentinel_explicit_repr = Sentinel('sentinel_explicit_repr', repr='explicit_repr')
+
+        self.assertEqual(repr(sentinel_explicit_repr), 'explicit_repr')
+
+    @skipIf(sys.version_info < (3, 10), reason='New unions not available in 3.9')
+    def test_sentinel_type_expression_union(self):
+        sentinel = Sentinel('sentinel')
+
+        def func1(a: int | sentinel = sentinel): pass
+        def func2(a: sentinel | int = sentinel): pass
+
+        self.assertEqual(func1.__annotations__['a'], Union[int, sentinel])
+        self.assertEqual(func2.__annotations__['a'], Union[sentinel, int])
+
+    def test_sentinel_not_callable(self):
+        sentinel = Sentinel('sentinel')
+        with self.assertRaisesRegex(
+            TypeError,
+            "'Sentinel' object is not callable"
+        ):
+            sentinel()
+
+    def test_sentinel_not_picklable(self):
+        sentinel = Sentinel('sentinel')
+        with self.assertRaisesRegex(
+            TypeError,
+            "Cannot pickle 'Sentinel' object"
+        ):
+            pickle.dumps(sentinel)
+
+
 if __name__ == '__main__':
     main()
diff --git a/src/typing_extensions.py b/src/typing_extensions.py
index 1ab6220d..d4e92a4c 100644
--- a/src/typing_extensions.py
+++ b/src/typing_extensions.py
@@ -89,6 +89,7 @@
     'overload',
     'override',
     'Protocol',
+    'Sentinel',
     'reveal_type',
     'runtime',
     'runtime_checkable',
@@ -4210,6 +4211,42 @@ def evaluate_forward_ref(
         )
 
 
+class Sentinel:
+    """Create a unique sentinel object.
+
+    *name* should be the name of the variable to which the return value shall be assigned.
+
+    *repr*, if supplied, will be used for the repr of the sentinel object.
+    If not provided, "<name>" will be used.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        repr: typing.Optional[str] = None,
+    ):
+        self._name = name
+        self._repr = repr if repr is not None else f'<{name}>'
+
+    def __repr__(self):
+        return self._repr
+
+    if sys.version_info < (3, 11):
+        # The presence of this method convinces typing._type_check
+        # that Sentinels are types.
+        def __call__(self, *args, **kwargs):
+            raise TypeError(f"{type(self).__name__!r} object is not callable")
+
+    def __or__(self, other):
+        return typing.Union[self, other]
+
+    def __ror__(self, other):
+        return typing.Union[other, self]
+
+    def __getstate__(self):
+        raise TypeError(f"Cannot pickle {type(self).__name__!r} object")
+
+
 # Aliases for items that are in typing in all supported versions.
 # We use hasattr() checks so this library will continue to import on
 # future versions of Python that may remove these names.


Reviews:
user: login: JelleZijlstra
body: Thanks! Could you also add this to the documentation?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T15:25:02Z
user: login: Viicos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T16:17:33Z
user: login: Viicos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T16:35:29Z
user: login: JelleZijlstra
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-09T16:39:42Z
user: login: TeamSpen210
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-10T21:05:07Z
user: login: Viicos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T07:27:30Z
user: login: JelleZijlstra
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-13T15:18:33Z

Review comments:
diff_hunk: @@ -1,3 +1,4 @@
+# pyright: ignore
path: src/typing_extensions.py
user: login: JelleZijlstra
body: ```suggestion
```
created_at: 2025-05-09T15:23:22Z
author_association: MEMBER
diff_hunk: @@ -4222,6 +4224,44 @@ def evaluate_forward_ref(
         )
 
 
+class Sentinel:
+    """Create a unique sentinel object.
+
+    *name* should be the fully-qualified name of the variable to which the
path: src/typing_extensions.py
user: login: JelleZijlstra
body: Fully-qualified? That doesn't match the PEP which does `MISSING = Sentinel("MISSING")`.
created_at: 2025-05-09T15:24:29Z
author_association: MEMBER
diff_hunk: @@ -4222,6 +4224,44 @@ def evaluate_forward_ref(
         )
 
 
+class Sentinel:
+    """Create a unique sentinel object.
+
+    *name* should be the fully-qualified name of the variable to which the
path: src/typing_extensions.py
user: login: Viicos
body: This was copied verbatim from https://github.com/taleinat/python-stdlib-sentinels/, updated.
created_at: 2025-05-09T16:17:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4222,6 +4224,44 @@ def evaluate_forward_ref(
         )
 
 
+class Sentinel:
+    """Create a unique sentinel object.
+
+    *name* should be the fully-qualified name of the variable to which the
path: src/typing_extensions.py
user: login: Viicos
body: Actually the repr construction by splitting dots don't make sense anymore, so I also updated the logic in the last logic.
created_at: 2025-05-09T16:35:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4222,6 +4224,44 @@ def evaluate_forward_ref(
         )
 
 
+class Sentinel:
+    """Create a unique sentinel object.
+
+    *name* should be the fully-qualified name of the variable to which the
path: src/typing_extensions.py
user: login: TeamSpen210
body: It'd make sense to be fully qualified inside the module, so `Sentinel("SomeClass.CONST")`. Pickle expects that if we decide to use it. It's recommended in the PEP under "Additional Notes", but not required.
created_at: 2025-05-10T21:05:07Z
author_association: NONE
diff_hunk: @@ -4222,6 +4224,44 @@ def evaluate_forward_ref(
         )
 
 
+class Sentinel:
+    """Create a unique sentinel object.
+
+    *name* should be the fully-qualified name of the variable to which the
path: src/typing_extensions.py
user: login: Viicos
body: > It'd make sense to be fully qualified inside the module, so `Sentinel("SomeClass.CONST")`. Pickle expects that if we decide to use it. It's recommended in the PEP under "Additional Notes", but not required.

This will have to be decided in the PEP discussion.
created_at: 2025-05-13T07:27:30Z
author_association: CONTRIBUTOR

PR comments:
user: login: TeamSpen210
created_at: 2025-05-08T23:26:43Z
body: For pickling, there's some builtin behaviour which might be perfect for this. If `__reduce__` returns the sentinel name, and `__module__` is defined (via `sys._getframemodulename()`), `pickle` will import the module then lookup attributes to find the value. It'll even check and error on pickling if the sentinel isn't present there. This'll make your example work, by looking up `__main__.sentinel`. If that global wasn't present, `self.s = Sentinel("a.s")` would also work.
author_association: NONE
user: login: JelleZijlstra
created_at: 2025-05-09T00:16:45Z
body: I'm worried the pickling behavior the PEP wants may change over time. One option would be to make them not pickleable for now. That way, we can always add pickling as a feature later, but if we add pickling now and need to change the behavior later, that may cause issues for users.
author_association: MEMBER
user: login: Viicos
created_at: 2025-05-09T07:33:26Z
body: Yes anyway this will be better discussed in the PEP thread. I removed the `__reduce__()` implementation, let me know if you actually want to raise an error.
author_association: CONTRIBUTOR
user: login: JelleZijlstra
created_at: 2025-05-09T13:44:38Z
body: Thanks! Can you make it raise an error? Otherwise I think it will pickle by value by default which is probably not what we'd want.
author_association: MEMBER
user: login: Viicos
created_at: 2025-05-09T15:12:06Z
body: Yes, makes sense, added a type error following prior art: https://github.com/search?q=repo%3Apython%2Fcpython+%22Cannot+pickle%22&type=code.
author_association: CONTRIBUTOR
user: login: JelleZijlstra
created_at: 2025-05-13T03:48:23Z
body: Would you mind fixing the lint? I think we need to make `Union` unconditionally defined.
author_association: MEMBER
user: login: HexDecimal
created_at: 2025-06-02T16:32:11Z
body: This implementation of Sentinel is impractical in its current state.

Sentinel's are supposed to be unique to the module it's defined in, allowing Sentinel's with the same name from separate modules to be equal is unexpected and broken behavior.

```py
import bar, baz  # Both with 'MISSING = Sentinel("MISSING")'

# Correct implementation would pass this assert
assert bar.MISSING != baz.MISSING  # These modules mean "missing" in different ways!
```

`__copy__` has been ignored, so this allows copies which break the identity of the Sentinel.
```py
import copy
sentinel = Sentinel('sentinel')

# Correct implementation would pass this assert
assert sentinel is copy.copy(sentinel)  # Any copy must have the same identity!
```

An important feature of Sentinel's is that their identity is preserved even during serialization, but Pickle support seems to have been rejected.

At the same time this implemented a `repr` attribute which will cause its own issues and was [specifically rejected by PEP 661 as being too complex to add](https://peps.python.org/pep-0661/#allowing-customization-of-repr).

```py
>>> Sentinel("sentinel", repr="foo") == Sentinel("sentinel", repr="bar")
False  # 'repr' makes these unique?
```
author_association: CONTRIBUTOR
user: login: JelleZijlstra
created_at: 2025-06-03T01:26:42Z
body: > allowing Sentinel's with the same name from separate modules to be equal is unexpected and broken behavior

The implementation doesn't do that. Since `__eq__` is not defined, equality is by identity.

> `__copy__` has been ignored, so this allows copies which break the identity of the Sentinel.

In fact `copy.copy` throws an error because it calls `__getstate__`.

> An important feature of Sentinel's is that their identity is preserved even during serialization, but Pickle support seems to have been rejected.

We'll likely add Pickle support later but I do not believe it is essential. We're waiting for the PEP author to decide what it will look like.

> At the same time this implemented a repr attribute which will cause its own issues

Good point, I thought our implementation came from a proposed reference implementation. I'm not sure what the issues are that you're alluding to, but if the PEP doesn't end up supporting this argument we can deprecate it from typing-extensions.
author_association: MEMBER
user: login: HexDecimal
created_at: 2025-06-03T02:20:28Z
body: Sorry. I've used dataclasses so often that I forgot the default behavior of Python classes. Here I am making several wrong assumptions about how equality works.

> We'll likely add Pickle support later but I do not believe it is essential.

I've used sentinels in my own Python projects and pickling them is essential. I currently use [sentinel-value](https://pypi.org/project/sentinel-value/) but I'd wish to use something more standard. I don't really need to be in a hurry though.

> We're waiting for the PEP author to decide what it will look like.

Does this count? https://github.com/python/typing_extensions/issues/591#issuecomment-2863310895
I think attribute lookup appears to be very forward compatible, one could seamlessly replace a `typing_extensions.Sentinel` definition with a `typing.Sentinel` and the replacement class will be loaded instead of the class originally pickled.
author_association: CONTRIBUTOR
user: login: Viicos
created_at: 2025-06-03T08:49:55Z
body: @HexDecimal all this discussion should live in the [PEP thread](https://discuss.python.org/t/9126/) instead, where you can provide feedback on this initial implementation. Regarding pickling, I already started giving thoughts in the last comment of the thread.
author_association: CONTRIBUTOR