diff --git a/doc/index.rst b/doc/index.rst index 21d6fa60..b67268b7 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1030,13 +1030,23 @@ 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, ``""`` will be used. + 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`. Example:: @@ -1050,6 +1060,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..2aee14bf 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -9269,16 +9269,25 @@ 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_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") + 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") + 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): @@ -9298,13 +9307,27 @@ 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("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): + 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..afecd2a9 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,24 +4156,97 @@ 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: - """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, "" will be used. + 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. """ - 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}" + + # 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 + 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 if sys.version_info < (3, 11): @@ -4188,8 +4262,18 @@ 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 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, + ), + ) # Aliases for items that are in typing in all supported versions.