Skip to content

Refactor Sentinel to conform to PEP 661 #617

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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, ``"<name>"`` 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::

Expand All @@ -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`
Expand Down
49 changes: 36 additions & 13 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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), '<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):
Expand All @@ -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__':
Expand Down
104 changes: 94 additions & 10 deletions src/typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import contextlib
import enum
import functools
import importlib
import inspect
import io
import keyword
Expand Down Expand Up @@ -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, "<name>" 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):
Expand All @@ -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.
Expand Down
Loading