From 43382e5383514240d625798d70ac032edb7a8fe7 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Tue, 2 Dec 2025 13:47:35 -0800 Subject: [PATCH 1/7] Support for pickling sentinel objects as singletons The `repr` parameters position was replaced by `module_name` to conform to PEP 661. Added copy and pickle tests. Updated documentation for Sentinel. `_marker` was defined before `caller` which causes minor issues, resolved by setting its module name manually. --- doc/index.rst | 12 +++++++++++- src/test_typing_extensions.py | 29 ++++++++++++++++++++++------- src/typing_extensions.py | 15 ++++++++++++--- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 66577ef0..061cdd03 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1071,11 +1071,14 @@ 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. + *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, ``""`` will be used. @@ -1091,6 +1094,13 @@ Sentinel objects ... >>> func(MISSING) + Sentinels defined inside a class scope should use a :term:`qualified name`. + + Example:: + + >>> class MyClass: + ... MISSING = Sentinel('MyClass.MISSING') + .. versionadded:: 4.14.0 See :pep:`661` diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index f07e1eb0..70b04283 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -9541,6 +9541,8 @@ def test_invalid_special_forms(self): class TestSentinels(BaseTestCase): + SENTINEL = Sentinel("TestSentinels.SENTINEL") + def test_sentinel_no_repr(self): sentinel_no_repr = Sentinel('sentinel_no_repr') @@ -9570,13 +9572,26 @@ def test_sentinel_not_callable(self): ): sentinel() - def test_sentinel_not_picklable(self): - sentinel = Sentinel('sentinel') - with self.assertRaisesRegex( - TypeError, - "Cannot pickle 'Sentinel' object" - ): - pickle.dumps(sentinel) + def test_sentinel_copy_identity(self): + self.assertIs(self.SENTINEL, copy.copy(self.SENTINEL)) + self.assertIs(self.SENTINEL, copy.deepcopy(self.SENTINEL)) + + anonymous_sentinel = Sentinel("anonymous_sentinel") + self.assertIs(anonymous_sentinel, copy.copy(anonymous_sentinel)) + self.assertIs(anonymous_sentinel, copy.deepcopy(anonymous_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 load_tests(loader, tests, pattern): import doctest diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 20c331ee..56ea472b 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -164,6 +164,9 @@ class Sentinel: *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, "" will be used. """ @@ -171,11 +174,16 @@ class Sentinel: def __init__( self, 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}>' + # For pickling as a singleton: + self.__module__ = module_name if module_name is not None else _caller() + def __repr__(self): return self._repr @@ -193,11 +201,12 @@ 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 taken from the __module__ attribute -_marker = Sentinel("sentinel") +_marker = Sentinel("sentinel", __name__) # The functions below are modified copies of typing internal helpers. # They are needed by _ProtocolMeta and they provide support for PEP 646. From c4ec0b34c56e3afee47c7f9c2d4a2401a816bfbb Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 11 Jan 2026 11:50:10 -0800 Subject: [PATCH 2/7] Change module_name into a keyword-only parameter --- doc/index.rst | 2 +- src/typing_extensions.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 061cdd03..77e11734 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1071,7 +1071,7 @@ Capsule objects Sentinel objects ~~~~~~~~~~~~~~~~ -.. class:: Sentinel(name, module_name=None, *, 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. diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 56ea472b..7e42cf77 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -174,8 +174,8 @@ class Sentinel: def __init__( self, name: str, - module_name: typing.Optional[str] = None, *, + module_name: typing.Optional[str] = None, repr: typing.Optional[str] = None, ): self._name = name @@ -206,7 +206,7 @@ def __reduce__(self) -> str: return self._name # Module is taken from the __module__ attribute -_marker = Sentinel("sentinel", __name__) +_marker = Sentinel("sentinel", module_name=__name__) # The functions below are modified copies of typing internal helpers. # They are needed by _ProtocolMeta and they provide support for PEP 646. From bf59ce387d00c992dd04b56ed12cea570fcf919f Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 24 Apr 2026 13:51:19 -0700 Subject: [PATCH 3/7] Move overload and deprecated to the top These need to come first to support PEP 702 within the module --- src/typing_extensions.py | 429 ++++++++++++++++++++------------------- 1 file changed, 215 insertions(+), 214 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 7e42cf77..3acdba92 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -159,6 +159,221 @@ # Added with bpo-45166 to 3.10.1+ and some 3.9 versions _FORWARD_REF_HAS_CLASS = "__forward_is_class__" in typing.ForwardRef.__slots__ + +_overload_dummy = typing._overload_dummy + +if hasattr(typing, "get_overloads"): # 3.11+ + overload = typing.overload + get_overloads = typing.get_overloads + clear_overloads = typing.clear_overloads +else: + # {module: {qualname: {firstlineno: func}}} + _overload_registry = collections.defaultdict( + functools.partial(collections.defaultdict, dict) + ) + + def overload(func): + """Decorator for overloaded functions/methods. + + In a stub file, place two or more stub definitions for the same + function in a row, each decorated with @overload. For example: + + @overload + def utf8(value: None) -> None: ... + @overload + def utf8(value: bytes) -> bytes: ... + @overload + def utf8(value: str) -> bytes: ... + + In a non-stub file (i.e. a regular .py file), do the same but + follow it with an implementation. The implementation should *not* + be decorated with @overload. For example: + + @overload + def utf8(value: None) -> None: ... + @overload + def utf8(value: bytes) -> bytes: ... + @overload + def utf8(value: str) -> bytes: ... + def utf8(value): + # implementation goes here + + The overloads for a function can be retrieved at runtime using the + get_overloads() function. + """ + # classmethod and staticmethod + f = getattr(func, "__func__", func) + try: + _overload_registry[f.__module__][f.__qualname__][ + f.__code__.co_firstlineno + ] = func + except AttributeError: + # Not a normal function; ignore. + pass + return _overload_dummy + + def get_overloads(func): + """Return all defined overloads for *func* as a sequence.""" + # classmethod and staticmethod + f = getattr(func, "__func__", func) + if f.__module__ not in _overload_registry: + return [] + mod_dict = _overload_registry[f.__module__] + if f.__qualname__ not in mod_dict: + return [] + return list(mod_dict[f.__qualname__].values()) + + def clear_overloads(): + """Clear all overloads in the registry.""" + _overload_registry.clear() + + +# Python 3.13.8+ and 3.14.1+ contain a fix for the wrapped __init_subclass__ +# Breakpoint: https://github.com/python/cpython/pull/138210 +if ((3, 13, 8) <= sys.version_info < (3, 14)) or sys.version_info >= (3, 14, 1): + deprecated = warnings.deprecated +else: + _T = typing.TypeVar("_T") + + class deprecated: + """Indicate that a class, function or overload is deprecated. + + When this decorator is applied to an object, the type checker + will generate a diagnostic on usage of the deprecated object. + + Usage: + + @deprecated("Use B instead") + class A: + pass + + @deprecated("Use g instead") + def f(): + pass + + @overload + @deprecated("int support is deprecated") + def g(x: int) -> int: ... + @overload + def g(x: str) -> int: ... + + The warning specified by *category* will be emitted at runtime + on use of deprecated objects. For functions, that happens on calls; + for classes, on instantiation and on creation of subclasses. + If the *category* is ``None``, no warning is emitted at runtime. + The *stacklevel* determines where the + warning is emitted. If it is ``1`` (the default), the warning + is emitted at the direct caller of the deprecated object; if it + is higher, it is emitted further up the stack. + Static type checker behavior is not affected by the *category* + and *stacklevel* arguments. + + The deprecation message passed to the decorator is saved in the + ``__deprecated__`` attribute on the decorated object. + If applied to an overload, the decorator + must be after the ``@overload`` decorator for the attribute to + exist on the overload as returned by ``get_overloads()``. + + See PEP 702 for details. + + """ + def __init__( + self, + message: str, + /, + *, + category: typing.Optional[typing.Type[Warning]] = DeprecationWarning, + stacklevel: int = 1, + ) -> None: + if not isinstance(message, str): + raise TypeError( + "Expected an object of type str for 'message', not " + f"{type(message).__name__!r}" + ) + self.message = message + self.category = category + self.stacklevel = stacklevel + + def __call__(self, arg: _T, /) -> _T: + # Make sure the inner functions created below don't + # retain a reference to self. + msg = self.message + category = self.category + stacklevel = self.stacklevel + if category is None: + arg.__deprecated__ = msg + return arg + elif isinstance(arg, type): + import functools + from types import MethodType + + original_new = arg.__new__ + + @functools.wraps(original_new) + def __new__(cls, /, *args, **kwargs): + if cls is arg: + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + if original_new is not object.__new__: + return original_new(cls, *args, **kwargs) + # Mirrors a similar check in object.__new__. + elif cls.__init__ is object.__init__ and (args or kwargs): + raise TypeError(f"{cls.__name__}() takes no arguments") + else: + return original_new(cls) + + arg.__new__ = staticmethod(__new__) + + if "__init_subclass__" in arg.__dict__: + # __init_subclass__ is directly present on the decorated class. + # Synthesize a wrapper that calls this method directly. + original_init_subclass = arg.__init_subclass__ + # We need slightly different behavior if __init_subclass__ + # is a bound method (likely if it was implemented in Python). + # Otherwise, it likely means it's a builtin such as + # object's implementation of __init_subclass__. + if isinstance(original_init_subclass, MethodType): + original_init_subclass = original_init_subclass.__func__ + + @functools.wraps(original_init_subclass) + def __init_subclass__(*args, **kwargs): + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + return original_init_subclass(*args, **kwargs) + else: + def __init_subclass__(cls, *args, **kwargs): + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + return super(arg, cls).__init_subclass__(*args, **kwargs) + + arg.__init_subclass__ = classmethod(__init_subclass__) + + arg.__deprecated__ = __new__.__deprecated__ = msg + __init_subclass__.__deprecated__ = msg + return arg + elif callable(arg): + import asyncio.coroutines + import functools + import inspect + + @functools.wraps(arg) + def wrapper(*args, **kwargs): + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + return arg(*args, **kwargs) + + if asyncio.coroutines.iscoroutinefunction(arg): + # Breakpoint: https://github.com/python/cpython/pull/99247 + if sys.version_info >= (3, 12): + wrapper = inspect.markcoroutinefunction(wrapper) + else: + wrapper._is_coroutine = asyncio.coroutines._is_coroutine + + arg.__deprecated__ = wrapper.__deprecated__ = msg + return wrapper + else: + raise TypeError( + "@deprecated decorator with non-None category must be applied to " + f"a class or callable, not {arg!r}" + ) + + class Sentinel: """Create a unique sentinel object. @@ -467,75 +682,6 @@ def __getitem__(self, parameters): instead of a type.""") -_overload_dummy = typing._overload_dummy - - -if hasattr(typing, "get_overloads"): # 3.11+ - overload = typing.overload - get_overloads = typing.get_overloads - clear_overloads = typing.clear_overloads -else: - # {module: {qualname: {firstlineno: func}}} - _overload_registry = collections.defaultdict( - functools.partial(collections.defaultdict, dict) - ) - - def overload(func): - """Decorator for overloaded functions/methods. - - In a stub file, place two or more stub definitions for the same - function in a row, each decorated with @overload. For example: - - @overload - def utf8(value: None) -> None: ... - @overload - def utf8(value: bytes) -> bytes: ... - @overload - def utf8(value: str) -> bytes: ... - - In a non-stub file (i.e. a regular .py file), do the same but - follow it with an implementation. The implementation should *not* - be decorated with @overload. For example: - - @overload - def utf8(value: None) -> None: ... - @overload - def utf8(value: bytes) -> bytes: ... - @overload - def utf8(value: str) -> bytes: ... - def utf8(value): - # implementation goes here - - The overloads for a function can be retrieved at runtime using the - get_overloads() function. - """ - # classmethod and staticmethod - f = getattr(func, "__func__", func) - try: - _overload_registry[f.__module__][f.__qualname__][ - f.__code__.co_firstlineno - ] = func - except AttributeError: - # Not a normal function; ignore. - pass - return _overload_dummy - - def get_overloads(func): - """Return all defined overloads for *func* as a sequence.""" - # classmethod and staticmethod - f = getattr(func, "__func__", func) - if f.__module__ not in _overload_registry: - return [] - mod_dict = _overload_registry[f.__module__] - if f.__qualname__ not in mod_dict: - return [] - return list(mod_dict[f.__qualname__].values()) - - def clear_overloads(): - """Clear all overloads in the registry.""" - _overload_registry.clear() - - # This is not a real generic class. Don't use outside annotations. Type = typing.Type @@ -2916,151 +3062,6 @@ def method(self) -> None: return arg -# Python 3.13.8+ and 3.14.1+ contain a fix for the wrapped __init_subclass__ -# Breakpoint: https://github.com/python/cpython/pull/138210 -if ((3, 13, 8) <= sys.version_info < (3, 14)) or sys.version_info >= (3, 14, 1): - deprecated = warnings.deprecated -else: - _T = typing.TypeVar("_T") - - class deprecated: - """Indicate that a class, function or overload is deprecated. - - When this decorator is applied to an object, the type checker - will generate a diagnostic on usage of the deprecated object. - - Usage: - - @deprecated("Use B instead") - class A: - pass - - @deprecated("Use g instead") - def f(): - pass - - @overload - @deprecated("int support is deprecated") - def g(x: int) -> int: ... - @overload - def g(x: str) -> int: ... - - The warning specified by *category* will be emitted at runtime - on use of deprecated objects. For functions, that happens on calls; - for classes, on instantiation and on creation of subclasses. - If the *category* is ``None``, no warning is emitted at runtime. - The *stacklevel* determines where the - warning is emitted. If it is ``1`` (the default), the warning - is emitted at the direct caller of the deprecated object; if it - is higher, it is emitted further up the stack. - Static type checker behavior is not affected by the *category* - and *stacklevel* arguments. - - The deprecation message passed to the decorator is saved in the - ``__deprecated__`` attribute on the decorated object. - If applied to an overload, the decorator - must be after the ``@overload`` decorator for the attribute to - exist on the overload as returned by ``get_overloads()``. - - See PEP 702 for details. - - """ - def __init__( - self, - message: str, - /, - *, - category: typing.Optional[typing.Type[Warning]] = DeprecationWarning, - stacklevel: int = 1, - ) -> None: - if not isinstance(message, str): - raise TypeError( - "Expected an object of type str for 'message', not " - f"{type(message).__name__!r}" - ) - self.message = message - self.category = category - self.stacklevel = stacklevel - - def __call__(self, arg: _T, /) -> _T: - # Make sure the inner functions created below don't - # retain a reference to self. - msg = self.message - category = self.category - stacklevel = self.stacklevel - if category is None: - arg.__deprecated__ = msg - return arg - elif isinstance(arg, type): - import functools - from types import MethodType - - original_new = arg.__new__ - - @functools.wraps(original_new) - def __new__(cls, /, *args, **kwargs): - if cls is arg: - warnings.warn(msg, category=category, stacklevel=stacklevel + 1) - if original_new is not object.__new__: - return original_new(cls, *args, **kwargs) - # Mirrors a similar check in object.__new__. - elif cls.__init__ is object.__init__ and (args or kwargs): - raise TypeError(f"{cls.__name__}() takes no arguments") - else: - return original_new(cls) - - arg.__new__ = staticmethod(__new__) - - if "__init_subclass__" in arg.__dict__: - # __init_subclass__ is directly present on the decorated class. - # Synthesize a wrapper that calls this method directly. - original_init_subclass = arg.__init_subclass__ - # We need slightly different behavior if __init_subclass__ - # is a bound method (likely if it was implemented in Python). - # Otherwise, it likely means it's a builtin such as - # object's implementation of __init_subclass__. - if isinstance(original_init_subclass, MethodType): - original_init_subclass = original_init_subclass.__func__ - - @functools.wraps(original_init_subclass) - def __init_subclass__(*args, **kwargs): - warnings.warn(msg, category=category, stacklevel=stacklevel + 1) - return original_init_subclass(*args, **kwargs) - else: - def __init_subclass__(cls, *args, **kwargs): - warnings.warn(msg, category=category, stacklevel=stacklevel + 1) - return super(arg, cls).__init_subclass__(*args, **kwargs) - - arg.__init_subclass__ = classmethod(__init_subclass__) - - arg.__deprecated__ = __new__.__deprecated__ = msg - __init_subclass__.__deprecated__ = msg - return arg - elif callable(arg): - import asyncio.coroutines - import functools - import inspect - - @functools.wraps(arg) - def wrapper(*args, **kwargs): - warnings.warn(msg, category=category, stacklevel=stacklevel + 1) - return arg(*args, **kwargs) - - if asyncio.coroutines.iscoroutinefunction(arg): - # Breakpoint: https://github.com/python/cpython/pull/99247 - if sys.version_info >= (3, 12): - wrapper = inspect.markcoroutinefunction(wrapper) - else: - wrapper._is_coroutine = asyncio.coroutines._is_coroutine - - arg.__deprecated__ = wrapper.__deprecated__ = msg - return wrapper - else: - raise TypeError( - "@deprecated decorator with non-None category must be applied to " - f"a class or callable, not {arg!r}" - ) - # Breakpoint: https://github.com/python/cpython/pull/23702 if sys.version_info < (3, 10): def _is_param_expr(arg): From 9ba60f02ecd746cc50776404363c19c19ae3e7ee Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 24 Apr 2026 14:54:38 -0700 Subject: [PATCH 4/7] Move _caller above sentinel Sentinels _marker requires _caller in order to remove its module_name --- src/typing_extensions.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 3acdba92..94e28526 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -374,6 +374,18 @@ def wrapper(*args, **kwargs): ) +def _caller(depth=1, default='__main__'): + try: + return sys._getframemodulename(depth + 1) or default + except AttributeError: # For platforms without _getframemodulename() + pass + try: + return sys._getframe(depth + 1).f_globals.get('__name__', default) + except (AttributeError, ValueError): # For platforms without _getframe() + pass + return None + + class Sentinel: """Create a unique sentinel object. @@ -793,18 +805,6 @@ def _get_protocol_attrs(cls): return attrs -def _caller(depth=1, default='__main__'): - try: - return sys._getframemodulename(depth + 1) or default - except AttributeError: # For platforms without _getframemodulename() - pass - try: - return sys._getframe(depth + 1).f_globals.get('__name__', default) - except (AttributeError, ValueError): # For platforms without _getframe() - pass - return None - - # `__match_args__` attribute was removed from protocol members in 3.13, # we want to backport this change to older Python versions. # Breakpoint: https://github.com/python/cpython/pull/110683 From f68007e5462a8a62f49e1f363ff4c267cb339120 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 24 Apr 2026 15:14:46 -0700 Subject: [PATCH 5/7] Update sentinel for PEP 661 changes Rename Sentinel to sentinel, deprecated old name Remove module_name parameter Deprecate subclassing sentinel Enforce correct sentinel parameters using deprecated overloads Rename _name attribute to __name__ Rename sentinel in tests, tests passed before making this change Also add tests for sentinel deprecations --- doc/index.rst | 14 +++++------ src/test_typing_extensions.py | 42 ++++++++++++++++++++------------ src/typing_extensions.py | 45 ++++++++++++++++++++++++++--------- 3 files changed, 68 insertions(+), 33 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 77e11734..7f437ca3 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1071,21 +1071,21 @@ Capsule objects Sentinel objects ~~~~~~~~~~~~~~~~ -.. class:: Sentinel(name, *, module_name=None, repr=None) +.. 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. - *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, ``""`` will be used. + A sentinel is bound to the module it is created within, + sentinels are not equal to similar named sentinels from other modules. + Example:: - >>> from typing_extensions import Sentinel, assert_type - >>> MISSING = Sentinel('MISSING') + >>> 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) @@ -1099,7 +1099,7 @@ Sentinel objects Example:: >>> class MyClass: - ... MISSING = Sentinel('MyClass.MISSING') + ... MISSING = sentinel('MyClass.MISSING') .. versionadded:: 4.14.0 diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 70b04283..cf50fae2 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -102,6 +102,7 @@ reveal_type, runtime, runtime_checkable, + sentinel, type_repr, ) @@ -9541,42 +9542,42 @@ def test_invalid_special_forms(self): class TestSentinels(BaseTestCase): - SENTINEL = Sentinel("TestSentinels.SENTINEL") + SENTINEL = sentinel("TestSentinels.SENTINEL") def test_sentinel_no_repr(self): - sentinel_no_repr = Sentinel('sentinel_no_repr') + sentinel_no_repr = sentinel('sentinel_no_repr') - self.assertEqual(sentinel_no_repr._name, 'sentinel_no_repr') - self.assertEqual(repr(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') + 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') + sentinel_type = sentinel('sentinel') - def func1(a: int | sentinel = sentinel): pass - def func2(a: sentinel | int = sentinel): pass + def func1(a: int | sentinel_type = sentinel_type): pass + def func2(a: sentinel_type | int = sentinel_type): pass - self.assertEqual(func1.__annotations__['a'], Union[int, sentinel]) - self.assertEqual(func2.__annotations__['a'], Union[sentinel, int]) + self.assertEqual(func1.__annotations__['a'], Union[int, sentinel_type]) + self.assertEqual(func2.__annotations__['a'], Union[sentinel_type, int]) def test_sentinel_not_callable(self): - sentinel = Sentinel('sentinel') + sentinel_ = sentinel('sentinel') with self.assertRaisesRegex( TypeError, - "'Sentinel' object is not callable" + "'sentinel' object is not callable" ): - sentinel() + sentinel_() def test_sentinel_copy_identity(self): self.assertIs(self.SENTINEL, copy.copy(self.SENTINEL)) self.assertIs(self.SENTINEL, copy.deepcopy(self.SENTINEL)) - anonymous_sentinel = Sentinel("anonymous_sentinel") + anonymous_sentinel = sentinel("anonymous_sentinel") self.assertIs(anonymous_sentinel, copy.copy(anonymous_sentinel)) self.assertIs(anonymous_sentinel, copy.deepcopy(anonymous_sentinel)) @@ -9585,7 +9586,7 @@ def test_sentinel_picklable_qualified(self): 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 + anonymous_sentinel = sentinel("anonymous_sentinel") # Anonymous sentinel can not be pickled for proto in range(pickle.HIGHEST_PROTOCOL + 1): with self.assertRaisesRegex( pickle.PicklingError, @@ -9593,6 +9594,17 @@ def test_sentinel_picklable_anonymous(self): ): self.assertIs(anonymous_sentinel, pickle.loads(pickle.dumps(anonymous_sentinel, protocol=proto))) + def test_sentinel_deprecated(self): + with self.assertWarnsRegex(DeprecationWarning, r"Subclassing sentinel is forbidden by PEP 661"): + class SentinelSubclass(Sentinel): + pass + + with self.assertWarnsRegex(DeprecationWarning, r"Sentinel was renamed to typing_extensions.sentinel"): + my_sentinel = Sentinel(name="my_sentinel") + with self.assertWarnsRegex(DeprecationWarning, r"Setting attributes on sentinel is deprecated"): + my_sentinel.foo = "bar" + + def load_tests(loader, tests, pattern): import doctest tests.addTests(doctest.DocTestSuite(typing_extensions)) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 94e28526..66c403bb 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -91,6 +91,7 @@ 'overload', 'override', 'Protocol', + 'sentinel', 'Sentinel', 'reveal_type', 'runtime', @@ -386,30 +387,45 @@ def _caller(depth=1, default='__main__'): return None -class Sentinel: +class sentinel: """Create a unique sentinel object. *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, "" will be used. """ + @overload + def __init__(self, name: str, /, *, repr: typing.Optional[str] = None): ... + + @overload + @deprecated("'name' must be positional-only, 'repr' must be keyword-only.") + def __init__(self, name: str, repr: typing.Optional[str] = None): ... + def __init__( self, 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}>' + self.__name__ = name + self._repr = repr if repr is not None else name # For pickling as a singleton: - self.__module__ = module_name if module_name is not None else _caller() + self.__module__ = _caller() + + @deprecated("Subclassing sentinel is forbidden by PEP 661") + def __init_subclass__(cls): + super().__init_subclass__() + + def __setattr__(self, attr: str, value: object) -> None: + if attr not in {"__name__", "_repr", "__module__"}: + warnings.warn( + "Setting attributes on sentinel is deprecated", + DeprecationWarning, + stacklevel=2, + ) + super().__setattr__(attr, value) def __repr__(self): return self._repr @@ -430,10 +446,17 @@ def __ror__(self, other): def __reduce__(self) -> str: """Reduce this sentinel to a singleton.""" - return self._name # Module is taken from the __module__ attribute + return self.__name__ # Module is taken from the __module__ attribute + +with warnings.catch_warnings(): # Allow sentinel subclass for backwards compatibility + warnings.simplefilter("ignore") + + @deprecated("""Sentinel was renamed to typing_extensions.sentinel""") + class Sentinel(sentinel): + pass +_marker = sentinel("sentinel") -_marker = Sentinel("sentinel", module_name=__name__) # The functions below are modified copies of typing internal helpers. # They are needed by _ProtocolMeta and they provide support for PEP 646. From 0f23901b25188746dd6812dff08aba25060cce63 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 24 Apr 2026 15:51:53 -0700 Subject: [PATCH 6/7] Deprecate sentinel repr parameter `repr` is not in PEP 661 --- doc/index.rst | 7 +++---- src/test_typing_extensions.py | 2 +- src/typing_extensions.py | 8 +++----- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 7f437ca3..eb619eac 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1071,17 +1071,16 @@ Capsule objects Sentinel objects ~~~~~~~~~~~~~~~~ -.. class:: sentinel(name, /, *, repr=None) +.. class:: sentinel(name, /) 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, ``""`` will be used. - A sentinel is bound to the module it is created within, sentinels are not equal to similar named sentinels from other modules. + Assigning attributes to a sentinel including `__weakref__` is forbidden. + Example:: >>> from typing_extensions import sentinel, assert_type diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index cf50fae2..71ff2bba 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -9550,7 +9550,7 @@ def test_sentinel_no_repr(self): self.assertEqual(sentinel_no_repr.__name__, 'sentinel_no_repr') self.assertEqual(repr(sentinel_no_repr), 'sentinel_no_repr') - def test_sentinel_explicit_repr(self): + def test_sentinel_deprecated_explicit_repr(self): sentinel_explicit_repr = sentinel('sentinel_explicit_repr', repr='explicit_repr') self.assertEqual(repr(sentinel_explicit_repr), 'explicit_repr') diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 66c403bb..3ce26025 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -391,16 +391,14 @@ 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, "" will be used. """ @overload - def __init__(self, name: str, /, *, repr: typing.Optional[str] = None): ... + def __init__(self, name: str, /): ... @overload - @deprecated("'name' must be positional-only, 'repr' must be keyword-only.") + @deprecated("'name' must be positional-only, \ +'repr' is deprecated and must be removed.") def __init__(self, name: str, repr: typing.Optional[str] = None): ... def __init__( From 423be5c0247c4017013accbba0498be597ba7614 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 24 Apr 2026 17:02:59 -0700 Subject: [PATCH 7/7] Skip deprecation of Sentinel class --- src/test_typing_extensions.py | 3 +-- src/typing_extensions.py | 7 +------ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 71ff2bba..bf25976a 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -9599,8 +9599,7 @@ def test_sentinel_deprecated(self): class SentinelSubclass(Sentinel): pass - with self.assertWarnsRegex(DeprecationWarning, r"Sentinel was renamed to typing_extensions.sentinel"): - my_sentinel = Sentinel(name="my_sentinel") + my_sentinel = Sentinel(name="my_sentinel") with self.assertWarnsRegex(DeprecationWarning, r"Setting attributes on sentinel is deprecated"): my_sentinel.foo = "bar" diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 3ce26025..b8bae93a 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -446,12 +446,7 @@ def __reduce__(self) -> str: """Reduce this sentinel to a singleton.""" return self.__name__ # Module is taken from the __module__ attribute -with warnings.catch_warnings(): # Allow sentinel subclass for backwards compatibility - warnings.simplefilter("ignore") - - @deprecated("""Sentinel was renamed to typing_extensions.sentinel""") - class Sentinel(sentinel): - pass +Sentinel = sentinel _marker = sentinel("sentinel")