Skip to content
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
1 change: 1 addition & 0 deletions Doc/c-api/concrete.rst
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ Other Objects
picklebuffer.rst
weakref.rst
capsule.rst
sentinel.rst
frame.rst
gen.rst
coro.rst
Expand Down
35 changes: 35 additions & 0 deletions Doc/c-api/sentinel.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
.. highlight:: c

.. _sentinelobjects:

Sentinel objects
----------------

.. c:var:: PyTypeObject PySentinel_Type

This instance of :c:type:`PyTypeObject` represents the Python
:class:`sentinel` type. This is the same object as :class:`sentinel`.

.. versionadded:: next

.. c:function:: int PySentinel_Check(PyObject *o)

Return true if *o* is a :class:`sentinel` object. The :class:`sentinel` type
does not allow subclasses, so this check is exact.

.. versionadded:: next

.. c:function:: PyObject* PySentinel_New(const char *name, const char *module_name)

Return a new :class:`sentinel` object with :attr:`~sentinel.__name__` set to
*name* and :attr:`~sentinel.__module__` set to *module_name*.
*name* must not be ``NULL``. If *module_name* is ``NULL``, :attr:`~sentinel.__module__`
is set to ``None``.
Return ``NULL`` with an exception set on failure.

For pickling to work, *module_name* must be the name of an importable
module, and the sentinel must be accessible from that module under a
path matching *name*. Pickle treats *name* as a global variable name
in *module_name* (see :meth:`object.__reduce__`).

.. versionadded:: next
4 changes: 4 additions & 0 deletions Doc/data/refcounts.dat
Original file line number Diff line number Diff line change
Expand Up @@ -2037,6 +2037,10 @@ PySeqIter_Check:PyObject *:op:0:
PySeqIter_New:PyObject*::+1:
PySeqIter_New:PyObject*:seq:0:

PySentinel_New:PyObject*::+1:
PySentinel_New:const char*:name::
PySentinel_New:const char*:module_name::

PySequence_Check:int:::
PySequence_Check:PyObject*:o:0:

Expand Down
69 changes: 62 additions & 7 deletions Doc/library/functions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ are always available. They are listed here in alphabetical order.
| | :func:`ascii` | | :func:`filter` | | :func:`map` | | **S** |
| | | | :func:`float` | | :func:`max` | | |func-set|_ |
| | **B** | | :func:`format` | | |func-memoryview|_ | | :func:`setattr` |
| | :func:`bin` | | |func-frozenset|_ | | :func:`min` | | :func:`slice` |
| | :func:`bool` | | | | | | :func:`sorted` |
| | :func:`breakpoint` | | **G** | | **N** | | :func:`staticmethod` |
| | |func-bytearray|_ | | :func:`getattr` | | :func:`next` | | |func-str|_ |
| | |func-bytes|_ | | :func:`globals` | | | | :func:`sum` |
| | | | | | **O** | | :func:`super` |
| | **C** | | **H** | | :func:`object` | | |
| | :func:`bin` | | |func-frozenset|_ | | :func:`min` | | :func:`sentinel` |
| | :func:`bool` | | | | | | :func:`slice` |
| | :func:`breakpoint` | | **G** | | **N** | | :func:`sorted` |
| | |func-bytearray|_ | | :func:`getattr` | | :func:`next` | | :func:`staticmethod` |
| | |func-bytes|_ | | :func:`globals` | | | | |func-str|_ |
| | | | | | **O** | | :func:`sum` |
| | **C** | | **H** | | :func:`object` | | :func:`super` |
| | :func:`callable` | | :func:`hasattr` | | :func:`oct` | | **T** |
| | :func:`chr` | | :func:`hash` | | :func:`open` | | |func-tuple|_ |
| | :func:`classmethod` | | :func:`help` | | :func:`ord` | | :func:`type` |
Expand Down Expand Up @@ -1827,6 +1827,61 @@ are always available. They are listed here in alphabetical order.
:func:`setattr`.


.. class:: sentinel(name, /)

Return a new unique sentinel object. *name* must be a :class:`str`, and is
used as the returned object's representation::

>>> MISSING = sentinel("MISSING")
>>> MISSING
MISSING

Sentinel objects are truthy and compare equal only to themselves. They are
intended to be compared with the :keyword:`is` operator.

Shallow and deep copies of a sentinel object return the object itself.

Sentinels are conventionally assigned to a variable with a matching name.
Sentinels defined in this way can be used in :term:`type hints <type hint>`::

MISSING = sentinel("MISSING")

def next_value(default: int | MISSING = MISSING):
...

Sentinel objects support the :ref:`| <bitwise>` operator for use in type expressions.

:mod:`Pickling <pickle>` is supported for sentinel objects that are
placed in the global scope of a module under a name matching the sentinel's
name, and for sentinels placed in class scopes with a name matching the
:term:`qualified name` of the sentinel. Other sentinels, such as those
defined in a function scope, are not picklable. The identity of the sentinel is preserved
after pickling::

import pickle

PICKLABLE = sentinel("PICKLABLE")

assert pickle.loads(pickle.dumps(PICKLABLE)) is PICKLABLE

class Cls:
PICKLABLE = sentinel("Cls.PICKLABLE")

assert pickle.loads(pickle.dumps(Cls.PICKLABLE)) is Cls.PICKLABLE

Sentinel objects have the following attributes:

.. attribute:: __name__

The sentinel's name.

.. attribute:: __module__

The name of the module where the sentinel was created.

.. versionadded:: next


.. class:: slice(stop, /)
slice(start, stop, step=None, /)

Expand Down
16 changes: 16 additions & 0 deletions Doc/whatsnew/3.15.rst
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ Summary -- Release highlights
<whatsnew315-lazy-imports>`
* :pep:`814`: :ref:`Add frozendict built-in type
<whatsnew315-frozendict>`
* :pep:`661`: :ref:`Add sentinel built-in type
<whatsnew315-sentinel>`
* :pep:`799`: :ref:`A dedicated profiling package for organizing Python
profiling tools <whatsnew315-profiling-package>`
* :pep:`799`: :ref:`Tachyon: High frequency statistical sampling profiler
Expand Down Expand Up @@ -235,6 +237,20 @@ to accept also other mapping types such as :class:`~types.MappingProxyType`.
(Contributed by Victor Stinner and Donghee Na in :gh:`141510`.)


.. _whatsnew315-sentinel:

:pep:`661`: Add sentinel built-in type
--------------------------------------

A new :class:`sentinel` type is added to the :mod:`builtins` module for
creating unique sentinel values with a concise representation. Sentinel
objects preserve identity when copied, support use in type expressions with
the ``|`` operator, and can be pickled when they are importable by module and
name.

(PEP by Tal Einat; contributed by Jelle Zijlstra in :gh:`148829`.)


.. _whatsnew315-profiling-package:

:pep:`799`: A dedicated profiling package
Expand Down
1 change: 1 addition & 0 deletions Include/Python.h
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ __pragma(warning(disable: 4201))
#include "cpython/genobject.h"
#include "descrobject.h"
#include "genericaliasobject.h"
#include "sentinelobject.h"
#include "warnings.h"
#include "weakrefobject.h"
#include "structseq.h"
Expand Down
22 changes: 22 additions & 0 deletions Include/sentinelobject.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/* Sentinel object interface */

#ifndef Py_SENTINELOBJECT_H
#define Py_SENTINELOBJECT_H
#ifdef __cplusplus
extern "C" {
#endif

#ifndef Py_LIMITED_API
PyAPI_DATA(PyTypeObject) PySentinel_Type;

#define PySentinel_Check(op) Py_IS_TYPE((op), &PySentinel_Type)

PyAPI_FUNC(PyObject *) PySentinel_New(
const char *name,
const char *module_name);
#endif
Comment thread
picnixz marked this conversation as resolved.

#ifdef __cplusplus
}
#endif
#endif /* !Py_SENTINELOBJECT_H */
1 change: 1 addition & 0 deletions Lib/test/pickletester.py
Original file line number Diff line number Diff line change
Expand Up @@ -3244,6 +3244,7 @@ def test_builtin_types(self):
'BuiltinImporter': (3, 3),
'str': (3, 4), # not interoperable with Python < 3.4
'frozendict': (3, 15),
'sentinel': (3, 15),
}
for t in builtins.__dict__.values():
if isinstance(t, type) and not issubclass(t, BaseException):
Expand Down
98 changes: 98 additions & 0 deletions Lib/test/test_builtin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import builtins
import collections
import contextlib
import copy
import decimal
import fractions
import gc
Expand All @@ -21,6 +22,7 @@
import typing
import unittest
import warnings
import weakref
from contextlib import ExitStack
from functools import partial
from inspect import CO_COROUTINE
Expand Down Expand Up @@ -52,6 +54,10 @@

# used as proof of globals being used
A_GLOBAL_VALUE = 123
A_SENTINEL = sentinel("A_SENTINEL")

class SentinelContainer:
CLASS_SENTINEL = sentinel("SentinelContainer.CLASS_SENTINEL")

class Squares:

Expand Down Expand Up @@ -1903,6 +1909,98 @@ class C:
__repr__ = None
self.assertRaises(TypeError, repr, C())

def test_sentinel(self):
missing = sentinel("MISSING")
other = sentinel("MISSING")

self.assertIsInstance(missing, sentinel)
self.assertIs(type(missing), sentinel)
self.assertEqual(missing.__name__, "MISSING")
self.assertEqual(missing.__module__, __name__)
self.assertIsNot(missing, other)
self.assertEqual(repr(missing), "MISSING")
self.assertTrue(missing)
self.assertIs(copy.copy(missing), missing)
self.assertIs(copy.deepcopy(missing), missing)
self.assertEqual(missing, missing)
self.assertNotEqual(missing, other)
self.assertRaises(TypeError, sentinel)
self.assertRaises(TypeError, sentinel, "MISSING", "EXTRA")
self.assertRaises(TypeError, sentinel, name="MISSING")
with self.assertRaisesRegex(TypeError, "must be str"):
sentinel(1)
self.assertTrue(sentinel.__flags__ & support._TPFLAGS_IMMUTABLETYPE)
self.assertTrue(sentinel.__flags__ & support._TPFLAGS_HAVE_GC)
self.assertFalse(sentinel.__flags__ & support._TPFLAGS_BASETYPE)
with self.assertRaises(TypeError):
class SubSentinel(sentinel):
pass
with self.assertRaises(TypeError):
sentinel.attribute = "value"
with self.assertRaises(AttributeError):
missing.__name__ = "CHANGED"
with self.assertRaises(AttributeError):
missing.__module__ = "changed"
Comment on lines +1942 to +1943
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add tests to check that we can't delete those attributes either

with self.assertRaises(AttributeError):
del missing.__name__
with self.assertRaises(AttributeError):
del missing.__module__

def test_sentinel_pickle(self):
for proto in range(pickle.HIGHEST_PROTOCOL + 1):
with self.subTest(protocol=proto):
self.assertIs(
pickle.loads(pickle.dumps(A_SENTINEL, protocol=proto)),
A_SENTINEL)
self.assertIs(
pickle.loads(pickle.dumps(
SentinelContainer.CLASS_SENTINEL, protocol=proto)),
SentinelContainer.CLASS_SENTINEL)

missing = sentinel("MISSING")
for proto in range(pickle.HIGHEST_PROTOCOL + 1):
with self.subTest(protocol=proto):
with self.assertRaises(pickle.PicklingError):
pickle.dumps(missing, protocol=proto)

def test_sentinel_str_subclass_name_cycle(self):
class Name(str):
pass

name = Name("MISSING")
missing = sentinel(name)
self.assertIs(missing.__name__, name)
self.assertTrue(gc.is_tracked(missing))

name.missing = missing
ref = weakref.ref(name)
del name, missing
support.gc_collect()
self.assertIsNone(ref())

def test_sentinel_union(self):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a test where the RHS/LHS is not a type?

missing = sentinel("MISSING")

self.assertIsInstance(missing | int, typing.Union)
self.assertEqual((missing | int).__args__, (missing, int))
self.assertIsInstance(int | missing, typing.Union)
self.assertEqual((int | missing).__args__, (int, missing))
self.assertIs(missing | missing, missing)
self.assertEqual(repr(int | missing), "int | MISSING")
self.assertIsInstance(missing | None, typing.Union)
self.assertEqual((missing | None).__args__, (missing, type(None)))
self.assertIsInstance(None | missing, typing.Union)
self.assertEqual((None | missing).__args__, (type(None), missing))
self.assertIsInstance(missing | list[int], typing.Union)
self.assertEqual((missing | list[int]).__args__, (missing, list[int]))
self.assertIsInstance(missing | (int | str), typing.Union)
self.assertEqual((missing | (int | str)).__args__, (missing, int, str))

with self.assertRaises(TypeError):
missing | 1
with self.assertRaises(TypeError):
1 | missing

def test_round(self):
self.assertEqual(round(0.0), 0.0)
self.assertEqual(type(round(0.0)), int)
Expand Down
22 changes: 22 additions & 0 deletions Lib/test/test_capi/test_object.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import enum
import os
import pickle
import sys
import textwrap
import unittest
Expand Down Expand Up @@ -63,6 +64,27 @@ def test_get_constant_borrowed(self):
self.check_get_constant(_testlimitedcapi.get_constant_borrowed)


class SentinelTest(unittest.TestCase):

def test_pysentinel_new(self):
marker = _testcapi.pysentinel_new("CAPI_SENTINEL", __name__)
self.assertIs(type(marker), sentinel)
self.assertTrue(_testcapi.pysentinel_check(marker))
self.assertFalse(_testcapi.pysentinel_check(object()))
self.assertEqual(marker.__name__, "CAPI_SENTINEL")
self.assertEqual(marker.__module__, __name__)
self.assertEqual(repr(marker), "CAPI_SENTINEL")

no_module = _testcapi.pysentinel_new("NO_MODULE")
self.assertIs(type(no_module), sentinel)
self.assertEqual(no_module.__name__, "NO_MODULE")
self.assertIs(no_module.__module__, None)

globals()["CAPI_SENTINEL"] = marker
self.addCleanup(globals().pop, "CAPI_SENTINEL", None)
self.assertIs(pickle.loads(pickle.dumps(marker)), marker)


class PrintTest(unittest.TestCase):
def testPyObjectPrintObject(self):

Expand Down
Loading
Loading