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 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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, ), )