Skip to content

Commit 50b797d

Browse files
committed
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.
1 parent 40e22eb commit 50b797d

File tree

3 files changed

+113
-34
lines changed

3 files changed

+113
-34
lines changed

doc/index.rst

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,13 +1030,19 @@ Capsule objects
10301030
Sentinel objects
10311031
~~~~~~~~~~~~~~~~
10321032

1033-
.. class:: Sentinel(name, repr=None)
1033+
.. class:: Sentinel(name, module_name=None)
10341034

1035-
A type used to define sentinel values. The *name* argument should be the
1036-
name of the variable to which the return value shall be assigned.
1035+
A type used to define custom sentinel values.
10371036

1038-
If *repr* is provided, it will be used for the :meth:`~object.__repr__`
1039-
of the sentinel object. If not provided, ``"<name>"`` will be used.
1037+
*name* should be the qualified name of the variable to which
1038+
the return value shall be assigned.
1039+
1040+
*module_name* is the module where the sentinel is defined.
1041+
Defaults to the current modules ``__name__``.
1042+
1043+
All sentinels with the same *name* and *module_name* have the same identity.
1044+
Sentinel objects are tested using :py:ref:`is`.
1045+
Sentinel identity is preserved across :py:mod:`copy` and :py:mod:`pickle`.
10401046

10411047
Example::
10421048

@@ -1050,6 +1056,24 @@ Sentinel objects
10501056
...
10511057
>>> func(MISSING)
10521058

1059+
Sentinels defined in a class scope must use fully qualified names.
1060+
1061+
Example::
1062+
1063+
>>> class MyClass:
1064+
... MISSING = Sentinel('MyClass.MISSING')
1065+
1066+
Calling the Sentinel class follows these rules for the return value:
1067+
1068+
1. If *name* and *module_name* were used in a previous call then return the same
1069+
object as that previous call.
1070+
This preserves the identity of the sentinel.
1071+
2. Otherwise if *module_name.name* already exists then return that object
1072+
even if that object is not a :class:`typing_extensions.Sentinel` type.
1073+
This enables forward compatibility with sentinel types from other libraries
1074+
(the inverse may not be true.)
1075+
3. Otherwise a new :class:`typing_extensions.Sentinel` is returned.
1076+
10531077
.. versionadded:: 4.14.0
10541078

10551079
See :pep:`661`

src/test_typing_extensions.py

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9269,16 +9269,11 @@ def test_invalid_special_forms(self):
92699269

92709270

92719271
class TestSentinels(BaseTestCase):
9272-
def test_sentinel_no_repr(self):
9273-
sentinel_no_repr = Sentinel('sentinel_no_repr')
9272+
SENTINEL = Sentinel("TestSentinels.SENTINEL")
92749273

9275-
self.assertEqual(sentinel_no_repr._name, 'sentinel_no_repr')
9276-
self.assertEqual(repr(sentinel_no_repr), '<sentinel_no_repr>')
9277-
9278-
def test_sentinel_explicit_repr(self):
9279-
sentinel_explicit_repr = Sentinel('sentinel_explicit_repr', repr='explicit_repr')
9280-
9281-
self.assertEqual(repr(sentinel_explicit_repr), 'explicit_repr')
9274+
def test_sentinel_repr(self):
9275+
self.assertEqual(repr(TestSentinels.SENTINEL), "TestSentinels.SENTINEL")
9276+
self.assertEqual(repr(Sentinel("sentinel")), "sentinel")
92829277

92839278
@skipIf(sys.version_info < (3, 10), reason='New unions not available in 3.9')
92849279
def test_sentinel_type_expression_union(self):
@@ -9298,13 +9293,25 @@ def test_sentinel_not_callable(self):
92989293
):
92999294
sentinel()
93009295

9301-
def test_sentinel_not_picklable(self):
9302-
sentinel = Sentinel('sentinel')
9303-
with self.assertRaisesRegex(
9304-
TypeError,
9305-
"Cannot pickle 'Sentinel' object"
9306-
):
9307-
pickle.dumps(sentinel)
9296+
def test_sentinel_identity(self):
9297+
self.assertIs(TestSentinels.SENTINEL, Sentinel("TestSentinels.SENTINEL"))
9298+
self.assertIs(Sentinel("SENTINEL"), Sentinel("SENTINEL", __name__))
9299+
self.assertIsNot(TestSentinels.SENTINEL, Sentinel("SENTINEL"))
9300+
9301+
def test_sentinel_copy(self):
9302+
self.assertIs(self.SENTINEL, copy.copy(self.SENTINEL))
9303+
self.assertIs(self.SENTINEL, copy.deepcopy(self.SENTINEL))
9304+
9305+
def test_sentinel_import(self):
9306+
self.assertIs(Sentinel._import_sentinel("TestSentinels", __name__), TestSentinels)
9307+
self.assertIs(Sentinel._import_sentinel("TestSentinels.SENTINEL", __name__), TestSentinels.SENTINEL)
9308+
self.assertIs(Sentinel._import_sentinel("nonexistent", __name__), None)
9309+
self.assertIs(Sentinel._import_sentinel("TestSentinels.nonexistent", __name__), None)
9310+
9311+
def test_sentinel_picklable(self):
9312+
for proto in range(pickle.HIGHEST_PROTOCOL + 1):
9313+
self.assertIs(self.SENTINEL, pickle.loads(pickle.dumps(self.SENTINEL, protocol=proto)))
9314+
93089315

93099316

93109317
if __name__ == '__main__':

src/typing_extensions.py

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import contextlib
66
import enum
77
import functools
8+
import importlib
89
import inspect
910
import io
1011
import keyword
@@ -4155,25 +4156,65 @@ def evaluate_forward_ref(
41554156
)
41564157

41574158

4159+
_sentinel_registry = {}
4160+
4161+
41584162
class Sentinel:
4159-
"""Create a unique sentinel object.
4163+
"""A sentinel object.
41604164
4161-
*name* should be the name of the variable to which the return value shall be assigned.
4165+
*name* should be the qualified name of the variable to which
4166+
the return value shall be assigned.
41624167
4163-
*repr*, if supplied, will be used for the repr of the sentinel object.
4164-
If not provided, "<name>" will be used.
4168+
*module_name* is the module where the sentinel is defined.
4169+
Defaults to the current modules ``__name__``.
4170+
4171+
All sentinels with the same *name* and *module_name* have the same identity.
4172+
The ``is`` operator is used to test if an object is a sentinel.
4173+
Sentinel identity is preserved across copy and pickle.
41654174
"""
41664175

4167-
def __init__(
4168-
self,
4176+
def __new__(
4177+
cls,
41694178
name: str,
4170-
repr: typing.Optional[str] = None,
4179+
module_name: typing.Optional[str] = None,
41714180
):
4172-
self._name = name
4173-
self._repr = repr if repr is not None else f'<{name}>'
4181+
"""Return an object with a consistent identity."""
4182+
if module_name is None:
4183+
module_name = _caller(default="")
4184+
4185+
registry_key = f"{module_name}-{name}"
4186+
4187+
# Check registered sentinels
4188+
sentinel = _sentinel_registry.get(registry_key, None)
4189+
if sentinel is not None:
4190+
return sentinel
4191+
4192+
# Import sentinel at module_name.name
4193+
sentinel = cls._import_sentinel(name, module_name)
4194+
if sentinel is not None:
4195+
return _sentinel_registry.setdefault(registry_key, sentinel)
4196+
4197+
# Create initial or anonymous sentinel
4198+
sentinel = super().__new__(cls)
4199+
sentinel._name = name
4200+
sentinel._module_name = module_name
4201+
return _sentinel_registry.setdefault(registry_key, sentinel)
4202+
4203+
@staticmethod
4204+
def _import_sentinel(name: str, module_name: str):
4205+
"""Return object `name` imported from `module_name`, otherwise return None."""
4206+
if not module_name:
4207+
return None
4208+
try:
4209+
module = importlib.import_module(module_name)
4210+
return operator.attrgetter(name)(module)
4211+
except ImportError:
4212+
return None
4213+
except AttributeError:
4214+
return None
41744215

4175-
def __repr__(self):
4176-
return self._repr
4216+
def __repr__(self) -> str:
4217+
return self._name
41774218

41784219
if sys.version_info < (3, 11):
41794220
# The presence of this method convinces typing._type_check
@@ -4188,8 +4229,15 @@ def __or__(self, other):
41884229
def __ror__(self, other):
41894230
return typing.Union[other, self]
41904231

4191-
def __getstate__(self):
4192-
raise TypeError(f"Cannot pickle {type(self).__name__!r} object")
4232+
def __reduce__(self):
4233+
"""Record where this sentinel is defined."""
4234+
return (
4235+
Sentinel, # Ensure pickle data does not get locked to a subclass
4236+
( # Only the location of the sentinel needs to be stored
4237+
self._name,
4238+
self._module_name,
4239+
),
4240+
)
41934241

41944242

41954243
# Aliases for items that are in typing in all supported versions.

0 commit comments

Comments
 (0)