Skip to content
27 changes: 23 additions & 4 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1030,13 +1030,25 @@ Capsule objects
Sentinel objects
~~~~~~~~~~~~~~~~

.. class:: Sentinel(name, repr=None)
.. class:: Sentinel(name, module_name=None, *, 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.
A type used to define custom sentinel values.

*name* should be the qualified name of the variable to which
the return value shall be assigned.

*module_name* is the module where the sentinel is defined.
Defaults to the current modules ``__name__``.

If *repr* is provided, it will be used for the :meth:`~object.__repr__`
of the sentinel object. If not provided, ``"<name>"`` will be used.
of the sentinel object. If not provided, *name* will be used.

All sentinels with the same *name* and *module_name* have the same identity.
Sentinel identity is preserved across :py:mod:`copy` and :py:mod:`pickle`.

Sentinel objects are tested using :py:ref:`is`.
Sentinels have no truthiness and attempting to convert a sentinel to
:py:class:`bool` will raise :py:exc:`TypeError`.

Example::

Expand All @@ -1050,6 +1062,13 @@ Sentinel objects
...
>>> func(MISSING)

Sentinels defined in a class scope must use fully qualified names.

Example::

>>> class MyClass:
... MISSING = Sentinel('MyClass.MISSING')

.. versionadded:: 4.14.0

See :pep:`661`
Expand Down
59 changes: 48 additions & 11 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9269,16 +9269,33 @@ def test_invalid_special_forms(self):


class TestSentinels(BaseTestCase):
def test_sentinel_no_repr(self):
sentinel_no_repr = Sentinel('sentinel_no_repr')
SENTINEL = Sentinel("TestSentinels.SENTINEL")

self.assertEqual(sentinel_no_repr._name, 'sentinel_no_repr')
self.assertEqual(repr(sentinel_no_repr), '<sentinel_no_repr>')
def test_sentinel_repr(self):
self.assertEqual(repr(TestSentinels.SENTINEL), "TestSentinels.SENTINEL")
self.assertEqual(repr(Sentinel("sentinel")), "sentinel")

def test_sentinel_explicit_repr(self):
sentinel_explicit_repr = Sentinel('sentinel_explicit_repr', repr='explicit_repr')
sentinel_explicit_repr = Sentinel("sentinel_explicit_repr", repr="explicit_repr")
self.assertEqual(repr(sentinel_explicit_repr), "explicit_repr")
with self.assertWarnsRegex(
DeprecationWarning,
r"repr='sentinel_explicit_repr' conflicts with initial definition of repr='explicit_repr'"
):
self.assertEqual(repr(Sentinel("sentinel_explicit_repr")), "explicit_repr")

self.assertEqual(repr(sentinel_explicit_repr), 'explicit_repr')
def test_sentinel_explicit_repr_deprecated(self):
with self.assertWarnsRegex(
DeprecationWarning,
r"Use keyword parameter repr='explicit_repr' instead"
):
deprecated_repr = Sentinel("deprecated_repr", "explicit_repr")
self.assertEqual(repr(deprecated_repr), "explicit_repr")
with self.assertWarnsRegex(
DeprecationWarning,
r"repr='deprecated_repr' conflicts with initial definition of repr='explicit_repr'"
):
self.assertEqual(repr(Sentinel("deprecated_repr")), "explicit_repr")

@skipIf(sys.version_info < (3, 10), reason='New unions not available in 3.9')
def test_sentinel_type_expression_union(self):
Expand All @@ -9298,13 +9315,33 @@ def test_sentinel_not_callable(self):
):
sentinel()

def test_sentinel_not_picklable(self):
sentinel = Sentinel('sentinel')
def test_sentinel_identity(self):
self.assertIs(TestSentinels.SENTINEL, Sentinel("TestSentinels.SENTINEL"))
self.assertIs(Sentinel("SENTINEL"), Sentinel("SENTINEL", __name__))
self.assertIsNot(TestSentinels.SENTINEL, Sentinel("SENTINEL"))

def test_sentinel_copy(self):
self.assertIs(self.SENTINEL, copy.copy(self.SENTINEL))
self.assertIs(self.SENTINEL, copy.deepcopy(self.SENTINEL))

def test_sentinel_picklable_qualified(self):
for proto in range(pickle.HIGHEST_PROTOCOL + 1):
self.assertIs(self.SENTINEL, pickle.loads(pickle.dumps(self.SENTINEL, protocol=proto)))

def test_sentinel_picklable_anonymous(self):
anonymous_sentinel = Sentinel("anonymous_sentinel") # Anonymous sentinel can not be pickled
for proto in range(pickle.HIGHEST_PROTOCOL + 1):
with self.assertRaisesRegex(
pickle.PicklingError,
r"attribute lookup anonymous_sentinel on \w+ failed|not found as \w+.anonymous_sentinel"
):
self.assertIs(anonymous_sentinel, pickle.loads(pickle.dumps(anonymous_sentinel, protocol=proto)))

def test_sentinel_bool(self):
with self.assertRaisesRegex(
TypeError,
"Cannot pickle 'Sentinel' object"
TypeError, rf"{self.SENTINEL!r} is not convertable to bool",
):
pickle.dumps(sentinel)
bool(self.SENTINEL)


if __name__ == '__main__':
Expand Down
80 changes: 70 additions & 10 deletions src/typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import contextlib
import enum
import functools
import importlib
import inspect
import io
import keyword
Expand Down Expand Up @@ -4155,24 +4156,78 @@ def evaluate_forward_ref(
)


_sentinel_registry = {}

class Sentinel:
"""Create a unique sentinel object.
"""A sentinel object.

*name* should be the qualified name of the variable to which
the return value shall be assigned.

*name* should be the name of the variable to which the return value shall be assigned.
*module_name* is the module where the sentinel is defined.
Defaults to the current modules ``__name__``.

*repr*, if supplied, will be used for the repr of the sentinel object.
If not provided, "<name>" will be used.
If not provided, *name* will be used.

All sentinels with the same *name* and *module_name* have the same identity.
The ``is`` operator is used to test if an object is a sentinel.
Sentinel identity is preserved across copy and pickle.
"""

def __init__(
self,
def __new__(
cls,
name: str,
module_name: typing.Optional[str] = None,
*,
repr: typing.Optional[str] = None,
):
self._name = name
self._repr = repr if repr is not None else f'<{name}>'
"""Return an object with a consistent identity."""
if module_name is not None and repr is None:
# 'repr' used to be the 2nd positional argument but is now 'module_name'
# Test if 'module_name' is a module or is the old 'repr' argument
# Use 'repr=name' to suppress this check
try:
importlib.import_module(module_name)
except Exception:
repr = module_name
module_name = None
warnings.warn(
"'repr' as a positional argument could be mistaken for the sentinels"
" 'module_name'."
f" Use keyword parameter repr={repr!r} instead.",
category=DeprecationWarning,
stacklevel=2,
)

def __repr__(self):
if module_name is None:
module_name = _caller(default="")

registry_key = f"{module_name}-{name}"

repr = repr if repr is not None else name

# Check registered sentinels
sentinel = _sentinel_registry.get(registry_key, None)
if sentinel is not None:
if sentinel._repr != repr:
warnings.warn(
f"repr={repr!r} conflicts with initial definition of "
f"repr={sentinel._repr!r} and will be ignored"
"\nUsage of repr should be consistent across definitions",
DeprecationWarning,
stacklevel=2,
)
return sentinel

# Create initial or anonymous sentinel
sentinel = super().__new__(cls)
sentinel._name = name
sentinel.__module__ = module_name # Assign which module defined this instance
sentinel._repr = repr
return _sentinel_registry.setdefault(registry_key, sentinel)

def __repr__(self) -> str:
return self._repr

if sys.version_info < (3, 11):
Expand All @@ -4188,8 +4243,13 @@ def __or__(self, other):
def __ror__(self, other):
return typing.Union[other, self]

def __getstate__(self):
raise TypeError(f"Cannot pickle {type(self).__name__!r} object")
def __reduce__(self) -> str:
"""Reduce this sentinel to a singleton."""
return self._name # Module is set from __module__ attribute

def __bool__(self) -> Never:
"""Raise TypeError."""
raise TypeError(f"Sentinel {self!r} is not convertable to bool.")


# Aliases for items that are in typing in all supported versions.
Expand Down
Loading