From 50b797dd6614baf7b529ae9abb247d5478eeb11c Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 4 Jun 2025 00:11:06 -0700 Subject: [PATCH 01/10] Refactor Sentinel to conform to PEP 661 `repr` parameter removed, explicit repr tests removed `__repr__` modified to match PEP implementation (removed angle brackets) Added `module_name` parameter following PEP implementation and tweaking to use `_caller` helper function. `name` required support for qualified names to follow PEP implementation. Added `__reduce__` to track Sentinel by name and module_name. Added a Sentinel registry to preserve Sentinel identity across multiple calls to the class. Added tests for this. Added an import step to allow forward compatibility with other sentinel libraries. Import step is tested. This was not required by the PEP but it is required for typing_extensions to have a forward compatible type. Added copy and pickle tests. Updated documentation for Sentinel. --- doc/index.rst | 34 +++++++++++++--- src/test_typing_extensions.py | 39 ++++++++++-------- src/typing_extensions.py | 74 +++++++++++++++++++++++++++++------ 3 files changed, 113 insertions(+), 34 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 21d6fa60..f819802f 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1030,13 +1030,19 @@ Capsule objects Sentinel objects ~~~~~~~~~~~~~~~~ -.. class:: Sentinel(name, repr=None) +.. class:: Sentinel(name, module_name=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. - If *repr* is provided, it will be used for the :meth:`~object.__repr__` - of the sentinel object. If not provided, ``""`` will be used. + *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__``. + + All sentinels with the same *name* and *module_name* have the same identity. + Sentinel objects are tested using :py:ref:`is`. + Sentinel identity is preserved across :py:mod:`copy` and :py:mod:`pickle`. Example:: @@ -1050,6 +1056,24 @@ Sentinel objects ... >>> func(MISSING) + Sentinels defined in a class scope must use fully qualified names. + + Example:: + + >>> class MyClass: + ... MISSING = Sentinel('MyClass.MISSING') + + Calling the Sentinel class follows these rules for the return value: + + 1. If *name* and *module_name* were used in a previous call then return the same + object as that previous call. + This preserves the identity of the sentinel. + 2. Otherwise if *module_name.name* already exists then return that object + even if that object is not a :class:`typing_extensions.Sentinel` type. + This enables forward compatibility with sentinel types from other libraries + (the inverse may not be true.) + 3. Otherwise a new :class:`typing_extensions.Sentinel` is returned. + .. versionadded:: 4.14.0 See :pep:`661` diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 5de161f9..7f391377 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -9269,16 +9269,11 @@ 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), '') - - def test_sentinel_explicit_repr(self): - sentinel_explicit_repr = Sentinel('sentinel_explicit_repr', repr='explicit_repr') - - self.assertEqual(repr(sentinel_explicit_repr), 'explicit_repr') + def test_sentinel_repr(self): + self.assertEqual(repr(TestSentinels.SENTINEL), "TestSentinels.SENTINEL") + self.assertEqual(repr(Sentinel("sentinel")), "sentinel") @skipIf(sys.version_info < (3, 10), reason='New unions not available in 3.9') def test_sentinel_type_expression_union(self): @@ -9298,13 +9293,25 @@ 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_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_import(self): + self.assertIs(Sentinel._import_sentinel("TestSentinels", __name__), TestSentinels) + self.assertIs(Sentinel._import_sentinel("TestSentinels.SENTINEL", __name__), TestSentinels.SENTINEL) + self.assertIs(Sentinel._import_sentinel("nonexistent", __name__), None) + self.assertIs(Sentinel._import_sentinel("TestSentinels.nonexistent", __name__), None) + + def test_sentinel_picklable(self): + for proto in range(pickle.HIGHEST_PROTOCOL + 1): + self.assertIs(self.SENTINEL, pickle.loads(pickle.dumps(self.SENTINEL, protocol=proto))) + if __name__ == '__main__': diff --git a/src/typing_extensions.py b/src/typing_extensions.py index efa09d55..276fdded 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -5,6 +5,7 @@ import contextlib import enum import functools +import importlib import inspect import io import keyword @@ -4155,25 +4156,65 @@ def evaluate_forward_ref( ) +_sentinel_registry = {} + + class Sentinel: - """Create a unique sentinel object. + """A sentinel object. - *name* should be the name of the variable to which the return value shall be assigned. + *name* should be the qualified 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. + *module_name* is the module where the sentinel is defined. + Defaults to the current modules ``__name__``. + + 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, - repr: typing.Optional[str] = None, + module_name: 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 None: + module_name = _caller(default="") + + registry_key = f"{module_name}-{name}" + + # Check registered sentinels + sentinel = _sentinel_registry.get(registry_key, None) + if sentinel is not None: + return sentinel + + # Import sentinel at module_name.name + sentinel = cls._import_sentinel(name, module_name) + if sentinel is not None: + return _sentinel_registry.setdefault(registry_key, sentinel) + + # Create initial or anonymous sentinel + sentinel = super().__new__(cls) + sentinel._name = name + sentinel._module_name = module_name + return _sentinel_registry.setdefault(registry_key, sentinel) + + @staticmethod + def _import_sentinel(name: str, module_name: str): + """Return object `name` imported from `module_name`, otherwise return None.""" + if not module_name: + return None + try: + module = importlib.import_module(module_name) + return operator.attrgetter(name)(module) + except ImportError: + return None + except AttributeError: + return None - def __repr__(self): - return self._repr + def __repr__(self) -> str: + return self._name if sys.version_info < (3, 11): # The presence of this method convinces typing._type_check @@ -4188,8 +4229,15 @@ 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): + """Record where this sentinel is defined.""" + return ( + Sentinel, # Ensure pickle data does not get locked to a subclass + ( # Only the location of the sentinel needs to be stored + self._name, + self._module_name, + ), + ) # Aliases for items that are in typing in all supported versions. From 5868d6ee842e42677c996d1b91097345c84589d6 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 4 Jun 2025 14:59:22 -0700 Subject: [PATCH 02/10] Pickle Sentinel indirectly --- src/typing_extensions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 276fdded..0a627b13 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -4229,10 +4229,16 @@ def __or__(self, other): def __ror__(self, other): return typing.Union[other, self] + @classmethod + def _unpickle_fetch_sentinel(cls, name: str, module_name: str): + """Unpickle using the sentinels location.""" + return cls(name, module_name) + def __reduce__(self): """Record where this sentinel is defined.""" + # Avoid self.__class__ to ensure pickle data does not get locked to a subclass return ( - Sentinel, # Ensure pickle data does not get locked to a subclass + Sentinel._unpickle_fetch_sentinel, ( # Only the location of the sentinel needs to be stored self._name, self._module_name, From f4057933aa5787606732277d7b136d2e5b8467e1 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 4 Jun 2025 15:29:20 -0700 Subject: [PATCH 03/10] Improve test_sentinel_import test coverage --- src/test_typing_extensions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 7f391377..158dcd8d 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -9303,10 +9303,12 @@ def test_sentinel_copy(self): self.assertIs(self.SENTINEL, copy.deepcopy(self.SENTINEL)) def test_sentinel_import(self): - self.assertIs(Sentinel._import_sentinel("TestSentinels", __name__), TestSentinels) + self.assertIs(Sentinel("TestSentinels"), TestSentinels) self.assertIs(Sentinel._import_sentinel("TestSentinels.SENTINEL", __name__), TestSentinels.SENTINEL) self.assertIs(Sentinel._import_sentinel("nonexistent", __name__), None) self.assertIs(Sentinel._import_sentinel("TestSentinels.nonexistent", __name__), None) + self.assertIs(Sentinel._import_sentinel("nonexistent", ""), None) + self.assertIs(Sentinel._import_sentinel("nonexistent", "nonexistent.nonexistent.nonexistent"), None) def test_sentinel_picklable(self): for proto in range(pickle.HIGHEST_PROTOCOL + 1): From add7fdbeab77e943daab990e5b0a50946322a8b6 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 5 Jun 2025 09:45:16 -0700 Subject: [PATCH 04/10] Add Sentinel repr keyword and ensure backwards compatibility Adds tests for both the keyword and old positional repr parameters --- doc/index.rst | 6 +++++- src/test_typing_extensions.py | 14 ++++++++++++++ src/typing_extensions.py | 29 +++++++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index f819802f..b67268b7 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1030,7 +1030,7 @@ Capsule objects Sentinel objects ~~~~~~~~~~~~~~~~ -.. class:: Sentinel(name, module_name=None) +.. class:: Sentinel(name, module_name=None, *, repr=None) A type used to define custom sentinel values. @@ -1040,6 +1040,10 @@ Sentinel objects *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. + Only the initial definition of the sentinel can configure *repr*. + All sentinels with the same *name* and *module_name* have the same identity. Sentinel objects are tested using :py:ref:`is`. Sentinel identity is preserved across :py:mod:`copy` and :py:mod:`pickle`. diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 158dcd8d..2aee14bf 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -9275,6 +9275,20 @@ 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") + self.assertEqual(repr(sentinel_explicit_repr), "explicit_repr") + self.assertEqual(repr(Sentinel("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") + 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): sentinel = Sentinel('sentinel') diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 0a627b13..06b5987b 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -4168,6 +4168,10 @@ class Sentinel: *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. + Only the initial definition of the sentinel can configure *repr*. + 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. @@ -4177,8 +4181,27 @@ def __new__( cls, name: str, module_name: typing.Optional[str] = None, + *, + repr: typing.Optional[str] = None, ): """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, + ) + if module_name is None: module_name = _caller(default="") @@ -4198,6 +4221,7 @@ def __new__( sentinel = super().__new__(cls) sentinel._name = name sentinel._module_name = module_name + sentinel._repr = repr if repr is not None else name return _sentinel_registry.setdefault(registry_key, sentinel) @staticmethod @@ -4214,7 +4238,7 @@ def _import_sentinel(name: str, module_name: str): return None def __repr__(self) -> str: - return self._name + return self._repr if sys.version_info < (3, 11): # The presence of this method convinces typing._type_check @@ -4232,7 +4256,8 @@ def __ror__(self, other): @classmethod def _unpickle_fetch_sentinel(cls, name: str, module_name: str): """Unpickle using the sentinels location.""" - return cls(name, module_name) + # Explicit repr=name because a saved module_name is known to be valid + return cls(name, module_name, repr=name) def __reduce__(self): """Record where this sentinel is defined.""" From cda6fbb8cfa76386822bf181a9651f53867b017e Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 5 Jun 2025 11:27:12 -0700 Subject: [PATCH 05/10] Move Sentinel reduce callable to the module top-level Changes in Sentinel such as swapping it with a theoretical typing.Sentinel will affect private methods, so the callable used with `__reduce__` must be at the top-level to be stable. --- src/typing_extensions.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 06b5987b..6758156a 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -4158,6 +4158,10 @@ def evaluate_forward_ref( _sentinel_registry = {} +def _unpickle_fetch_sentinel(name: str, module_name: str): + """Stable Sentinel unpickling function, fetch Sentinel at 'module_name.name'.""" + # Explicit repr=name because a saved module_name is known to be valid + return Sentinel(name, module_name, repr=name) class Sentinel: """A sentinel object. @@ -4253,17 +4257,11 @@ def __or__(self, other): def __ror__(self, other): return typing.Union[other, self] - @classmethod - def _unpickle_fetch_sentinel(cls, name: str, module_name: str): - """Unpickle using the sentinels location.""" - # Explicit repr=name because a saved module_name is known to be valid - return cls(name, module_name, repr=name) - def __reduce__(self): """Record where this sentinel is defined.""" - # Avoid self.__class__ to ensure pickle data does not get locked to a subclass + # Reduce callable must be at the top-level to be stable whenever Sentinel changes return ( - Sentinel._unpickle_fetch_sentinel, + _unpickle_fetch_sentinel, ( # Only the location of the sentinel needs to be stored self._name, self._module_name, From 5c0e8a79317478ebfda949c56b011be47a618eb0 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Tue, 10 Jun 2025 11:09:06 -0700 Subject: [PATCH 06/10] Store Sentinel parameters in reduce method Additional data stored as a dict to ensure backwards and forwards compatibility --- src/typing_extensions.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 6758156a..afecd2a9 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -4158,10 +4158,15 @@ def evaluate_forward_ref( _sentinel_registry = {} -def _unpickle_fetch_sentinel(name: str, module_name: str): - """Stable Sentinel unpickling function, fetch Sentinel at 'module_name.name'.""" +def _unpickle_sentinel( + name: str, + module_name: str, + config: typing.Dict[str, typing.Any], + /, +): + """Stable Sentinel unpickling function, get Sentinel at 'module_name.name'.""" # Explicit repr=name because a saved module_name is known to be valid - return Sentinel(name, module_name, repr=name) + return Sentinel(name, module_name, repr=config.get("repr", name)) class Sentinel: """A sentinel object. @@ -4258,13 +4263,15 @@ def __ror__(self, other): return typing.Union[other, self] def __reduce__(self): - """Record where this sentinel is defined.""" + """Record where this sentinel is defined and its current parameters.""" + config = {"repr": self._repr} # Reduce callable must be at the top-level to be stable whenever Sentinel changes return ( - _unpickle_fetch_sentinel, - ( # Only the location of the sentinel needs to be stored + _unpickle_sentinel, + ( self._name, self._module_name, + config, ), ) From e29210acc191dae0dd4ec49ca2fcaee2fbff50d8 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Tue, 1 Jul 2025 01:37:09 -0700 Subject: [PATCH 07/10] Reduce Sentinel as singletons Use pickles method of handing singleton objects. This is simpler and more predictable than a custom unpickle function. Anonymous sentinels can no longer be pickled and will raise PicklingError instead of TypeError. --- src/test_typing_extensions.py | 11 ++++++++++- src/typing_extensions.py | 27 ++++----------------------- 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 2aee14bf..e7422326 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -9324,10 +9324,19 @@ def test_sentinel_import(self): self.assertIs(Sentinel._import_sentinel("nonexistent", ""), None) self.assertIs(Sentinel._import_sentinel("nonexistent", "nonexistent.nonexistent.nonexistent"), None) - def test_sentinel_picklable(self): + 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))) + if __name__ == '__main__': diff --git a/src/typing_extensions.py b/src/typing_extensions.py index afecd2a9..b00039c1 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -4158,16 +4158,6 @@ def evaluate_forward_ref( _sentinel_registry = {} -def _unpickle_sentinel( - name: str, - module_name: str, - config: typing.Dict[str, typing.Any], - /, -): - """Stable Sentinel unpickling function, get Sentinel at 'module_name.name'.""" - # Explicit repr=name because a saved module_name is known to be valid - return Sentinel(name, module_name, repr=config.get("repr", name)) - class Sentinel: """A sentinel object. @@ -4229,7 +4219,7 @@ def __new__( # Create initial or anonymous sentinel sentinel = super().__new__(cls) sentinel._name = name - sentinel._module_name = module_name + sentinel.__module__ = module_name # Assign which module defined this instance sentinel._repr = repr if repr is not None else name return _sentinel_registry.setdefault(registry_key, sentinel) @@ -4262,18 +4252,9 @@ def __or__(self, other): def __ror__(self, other): return typing.Union[other, self] - def __reduce__(self): - """Record where this sentinel is defined and its current parameters.""" - config = {"repr": self._repr} - # Reduce callable must be at the top-level to be stable whenever Sentinel changes - return ( - _unpickle_sentinel, - ( - self._name, - self._module_name, - config, - ), - ) + def __reduce__(self) -> str: + """Reduce this sentinel to a singleton.""" + return self._name # Module is set from __module__ attribute # Aliases for items that are in typing in all supported versions. From 72d4379923b5d1b94a918190acaf32e53cb3fd2d Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 3 Jul 2025 20:07:55 -0700 Subject: [PATCH 08/10] Remove Sentinel import code No longer needed due to a correctly implemented reduce method. Simplifies code and makes syntax more predictable. --- doc/index.rst | 11 ----------- src/test_typing_extensions.py | 8 -------- src/typing_extensions.py | 18 ------------------ 3 files changed, 37 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index b67268b7..d2f2fbb5 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1067,17 +1067,6 @@ Sentinel objects >>> class MyClass: ... MISSING = Sentinel('MyClass.MISSING') - Calling the Sentinel class follows these rules for the return value: - - 1. If *name* and *module_name* were used in a previous call then return the same - object as that previous call. - This preserves the identity of the sentinel. - 2. Otherwise if *module_name.name* already exists then return that object - even if that object is not a :class:`typing_extensions.Sentinel` type. - This enables forward compatibility with sentinel types from other libraries - (the inverse may not be true.) - 3. Otherwise a new :class:`typing_extensions.Sentinel` is returned. - .. versionadded:: 4.14.0 See :pep:`661` diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index e7422326..c3e66f16 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -9316,14 +9316,6 @@ def test_sentinel_copy(self): self.assertIs(self.SENTINEL, copy.copy(self.SENTINEL)) self.assertIs(self.SENTINEL, copy.deepcopy(self.SENTINEL)) - def test_sentinel_import(self): - self.assertIs(Sentinel("TestSentinels"), TestSentinels) - self.assertIs(Sentinel._import_sentinel("TestSentinels.SENTINEL", __name__), TestSentinels.SENTINEL) - self.assertIs(Sentinel._import_sentinel("nonexistent", __name__), None) - self.assertIs(Sentinel._import_sentinel("TestSentinels.nonexistent", __name__), None) - self.assertIs(Sentinel._import_sentinel("nonexistent", ""), None) - self.assertIs(Sentinel._import_sentinel("nonexistent", "nonexistent.nonexistent.nonexistent"), None) - 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))) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index b00039c1..e071e9fa 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -4211,11 +4211,6 @@ def __new__( if sentinel is not None: return sentinel - # Import sentinel at module_name.name - sentinel = cls._import_sentinel(name, module_name) - if sentinel is not None: - return _sentinel_registry.setdefault(registry_key, sentinel) - # Create initial or anonymous sentinel sentinel = super().__new__(cls) sentinel._name = name @@ -4223,19 +4218,6 @@ def __new__( sentinel._repr = repr if repr is not None else name return _sentinel_registry.setdefault(registry_key, sentinel) - @staticmethod - def _import_sentinel(name: str, module_name: str): - """Return object `name` imported from `module_name`, otherwise return None.""" - if not module_name: - return None - try: - module = importlib.import_module(module_name) - return operator.attrgetter(name)(module) - except ImportError: - return None - except AttributeError: - return None - def __repr__(self) -> str: return self._repr From 232a38f1251916a0ce51e4434a09a7c17810c589 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 6 Jul 2025 14:01:02 -0700 Subject: [PATCH 09/10] Warn if a Sentinel repr does not match its initial definition Remove documentation implying that multiple definitions of Sentinel objects are a regular occurrence --- doc/index.rst | 1 - src/test_typing_extensions.py | 12 ++++++++++-- src/typing_extensions.py | 13 +++++++++++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index d2f2fbb5..b1dffba3 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1042,7 +1042,6 @@ Sentinel objects If *repr* is provided, it will be used for the :meth:`~object.__repr__` of the sentinel object. If not provided, *name* will be used. - Only the initial definition of the sentinel can configure *repr*. All sentinels with the same *name* and *module_name* have the same identity. Sentinel objects are tested using :py:ref:`is`. diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index c3e66f16..b929be5b 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -9278,7 +9278,11 @@ def test_sentinel_repr(self): def test_sentinel_explicit_repr(self): sentinel_explicit_repr = Sentinel("sentinel_explicit_repr", repr="explicit_repr") self.assertEqual(repr(sentinel_explicit_repr), "explicit_repr") - self.assertEqual(repr(Sentinel("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") def test_sentinel_explicit_repr_deprecated(self): with self.assertWarnsRegex( @@ -9287,7 +9291,11 @@ def test_sentinel_explicit_repr_deprecated(self): ): deprecated_repr = Sentinel("deprecated_repr", "explicit_repr") self.assertEqual(repr(deprecated_repr), "explicit_repr") - self.assertEqual(repr(Sentinel("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): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index e071e9fa..d0d8d892 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -4169,7 +4169,6 @@ class Sentinel: *repr*, if supplied, will be used for the repr of the sentinel object. If not provided, *name* will be used. - Only the initial definition of the sentinel can configure *repr*. 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. @@ -4206,16 +4205,26 @@ def __new__( 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 if repr is not None else name + sentinel._repr = repr return _sentinel_registry.setdefault(registry_key, sentinel) def __repr__(self) -> str: From 93d7b6594a76c075881bc693a3a78fdd3e9c0964 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 24 Jul 2025 10:38:46 -0700 Subject: [PATCH 10/10] Forbid Sentinel conversion to bool Going with a strict implementation as early as possible. This restriction can always be relaxed at a later time. A truth test on a sentinel implies that the sentinel is mixed in with other types during the test. In nearly all cases this is incorrect and could result in unexpected behavior. --- doc/index.rst | 5 ++++- src/test_typing_extensions.py | 5 +++++ src/typing_extensions.py | 4 ++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/doc/index.rst b/doc/index.rst index b1dffba3..d3d96315 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1044,9 +1044,12 @@ Sentinel objects 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 objects are tested using :py:ref:`is`. 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:: >>> from typing_extensions import Sentinel, assert_type diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index b929be5b..34add814 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -9337,6 +9337,11 @@ def test_sentinel_picklable_anonymous(self): ): self.assertIs(anonymous_sentinel, pickle.loads(pickle.dumps(anonymous_sentinel, protocol=proto))) + def test_sentinel_bool(self): + with self.assertRaisesRegex( + TypeError, rf"{self.SENTINEL!r} is not convertable to bool", + ): + bool(self.SENTINEL) if __name__ == '__main__': diff --git a/src/typing_extensions.py b/src/typing_extensions.py index d0d8d892..74d5ffcd 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -4247,6 +4247,10 @@ 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. # We use hasattr() checks so this library will continue to import on