Skip to content

Commit e4367cb

Browse files
committed
gh-127124: Change context watcher callback to a callable object
This enables developers to associate state with the callback without relying on globals. Also, refactor the tests for improved readability and extensibility, and to cover the new state object.
1 parent f7bb658 commit e4367cb

File tree

10 files changed

+199
-221
lines changed

10 files changed

+199
-221
lines changed

Doc/c-api/contextvars.rst

Lines changed: 65 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -101,21 +101,76 @@ Context object management functions:
101101
current context for the current thread. Returns ``0`` on success,
102102
and ``-1`` on error.
103103
104-
.. c:function:: int PyContext_AddWatcher(PyContext_WatchCallback callback)
105-
106-
Register *callback* as a context object watcher for the current interpreter.
107-
Return an ID which may be passed to :c:func:`PyContext_ClearWatcher`.
108-
In case of error (e.g. no more watcher IDs available),
109-
return ``-1`` and set an exception.
104+
.. c:function:: int PyContext_AddWatcher(PyObject *callback)
105+
106+
Registers the callable object *callback* as a context object watcher for the
107+
current interpreter. When a context event occurs, *callback* is called with
108+
two arguments:
109+
110+
#. An event type ID from :c:type:`PyContextEvent`.
111+
#. An object containing event-specific supplemental data; see
112+
:c:type:`PyContextEvent` for details.
113+
114+
Any exception raised by *callback* will be printed as an unraisable
115+
exception as if by a call to :c:func:`PyErr_FormatUnraisable`, then
116+
discarded.
117+
118+
On success, this function returns a non-negative ID which may be passed to
119+
:c:func:`PyContext_ClearWatcher` to unregister the callback and remove the
120+
reference this function adds to *callback*. Sets an exception and returns
121+
``-1`` on error (e.g., no more watcher IDs available).
122+
123+
Example using a C function as the callback::
124+
125+
static PyObject *
126+
my_callback(PyObject *self, PyObject *const *args, Py_ssize_t nargs)
127+
{
128+
if (PyVectorcall_NARGS(nargs) != 2) {
129+
PyErr_Format(PyExc_TypeError, "want 2 args, got %zd", nargs);
130+
return NULL;
131+
}
132+
int event = PyLong_AsInt(args[0]);
133+
if (event == -1 && PyErr_Occurred()) {
134+
return NULL;
135+
}
136+
if (event != Py_CONTEXT_SWITCHED) {
137+
Py_RETURN_NONE;
138+
}
139+
PyObject *ctx = args[1];
140+
141+
// Do something interesting with self and ctx here.
142+
143+
Py_RETURN_NONE;
144+
}
145+
146+
PyMethodDef my_callback_md = {
147+
.ml_name = "my_callback",
148+
.ml_meth = (PyCFunction)(void *)&my_callback,
149+
.ml_flags = METH_FASTCALL,
150+
.ml_doc = NULL,
151+
};
152+
153+
int
154+
register_my_callback(PyObject *callback_state)
155+
{
156+
PyObject *cb = PyCFunction_New(&my_callback_md, callback_state);
157+
if (cb == NULL) {
158+
return -1;
159+
}
160+
int id = PyContext_AddWatcher(cb);
161+
Py_CLEAR(cb);
162+
return id;
163+
}
110164
111165
.. versionadded:: 3.14
112166
113167
.. c:function:: int PyContext_ClearWatcher(int watcher_id)
114168
115-
Clear watcher identified by *watcher_id* previously returned from
116-
:c:func:`PyContext_AddWatcher` for the current interpreter.
117-
Return ``0`` on success, or ``-1`` and set an exception on error
118-
(e.g. if the given *watcher_id* was never registered.)
169+
Clears the watcher identified by *watcher_id* previously returned from
170+
:c:func:`PyContext_AddWatcher` for the current interpreter, and removes the
171+
reference created for the registered callback object. Returns ``0`` on
172+
success, or sets an exception and returns ``-1`` on error (e.g., if the
173+
given *watcher_id* was never registered).
119174
120175
.. versionadded:: 3.14
121176
@@ -130,23 +185,6 @@ Context object management functions:
130185
131186
.. versionadded:: 3.14
132187
133-
.. c:type:: int (*PyContext_WatchCallback)(PyContextEvent event, PyObject *obj)
134-
135-
Context object watcher callback function. The object passed to the callback
136-
is event-specific; see :c:type:`PyContextEvent` for details.
137-
138-
If the callback returns with an exception set, it must return ``-1``; this
139-
exception will be printed as an unraisable exception using
140-
:c:func:`PyErr_FormatUnraisable`. Otherwise it should return ``0``.
141-
142-
There may already be a pending exception set on entry to the callback. In
143-
this case, the callback should return ``0`` with the same exception still
144-
set. This means the callback may not call any other API that can set an
145-
exception unless it saves and clears the exception state first, and restores
146-
it before returning.
147-
148-
.. versionadded:: 3.14
149-
150188
151189
Context variable functions:
152190

Include/cpython/context.h

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -36,29 +36,7 @@ typedef enum {
3636
Py_CONTEXT_SWITCHED = 1,
3737
} PyContextEvent;
3838

39-
/*
40-
* Context object watcher callback function. The object passed to the callback
41-
* is event-specific; see PyContextEvent for details.
42-
*
43-
* if the callback returns with an exception set, it must return -1. Otherwise
44-
* it should return 0
45-
*/
46-
typedef int (*PyContext_WatchCallback)(PyContextEvent, PyObject *);
47-
48-
/*
49-
* Register a per-interpreter callback that will be invoked for context object
50-
* enter/exit events.
51-
*
52-
* Returns a handle that may be passed to PyContext_ClearWatcher on success,
53-
* or -1 and sets and error if no more handles are available.
54-
*/
55-
PyAPI_FUNC(int) PyContext_AddWatcher(PyContext_WatchCallback callback);
56-
57-
/*
58-
* Clear the watcher associated with the watcher_id handle.
59-
*
60-
* Returns 0 on success or -1 if no watcher exists for the provided id.
61-
*/
39+
PyAPI_FUNC(int) PyContext_AddWatcher(PyObject *callback);
6240
PyAPI_FUNC(int) PyContext_ClearWatcher(int watcher_id);
6341

6442
/* Create a new context variable.

Include/internal/pycore_interp.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ struct _is {
242242
PyObject *audit_hooks;
243243
PyType_WatchCallback type_watchers[TYPE_MAX_WATCHERS];
244244
PyCode_WatchCallback code_watchers[CODE_MAX_WATCHERS];
245-
PyContext_WatchCallback context_watchers[CONTEXT_MAX_WATCHERS];
245+
PyObject *context_watchers[CONTEXT_MAX_WATCHERS];
246246
// One bit is set for each non-NULL entry in code_watchers
247247
uint8_t active_code_watchers;
248248
uint8_t active_context_watchers;

Lib/test/test_capi/test_watchers.py

Lines changed: 33 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import contextlib
12
import unittest
23
import contextvars
34

@@ -589,60 +590,49 @@ def test_allocate_too_many_watchers(self):
589590

590591
class TestContextObjectWatchers(unittest.TestCase):
591592
@contextmanager
592-
def context_watcher(self, which_watcher):
593-
wid = _testcapi.add_context_watcher(which_watcher)
593+
def context_watcher(self, cb=None):
594+
log = None
595+
if cb is None:
596+
log = []
597+
def cb(event, ctx):
598+
self.assertEqual(event, _testcapi.Py_CONTEXT_SWITCHED)
599+
log.append(ctx)
600+
wid = _testcapi.add_context_watcher(cb)
594601
try:
595-
switches = _testcapi.get_context_switches(which_watcher)
596-
except ValueError:
597-
switches = None
598-
try:
599-
yield switches
602+
yield log
600603
finally:
601604
_testcapi.clear_context_watcher(wid)
602605

603-
def assert_event_counts(self, want_0, want_1):
604-
self.assertEqual(len(_testcapi.get_context_switches(0)), want_0)
605-
self.assertEqual(len(_testcapi.get_context_switches(1)), want_1)
606-
607606
def test_context_object_events_dispatched(self):
608-
# verify that all counts are zero before any watchers are registered
609-
self.assert_event_counts(0, 0)
610-
611-
# verify that all counts remain zero when a context object is
612-
# entered and exited with no watchers registered
613607
ctx = contextvars.copy_context()
614-
ctx.run(self.assert_event_counts, 0, 0)
615-
self.assert_event_counts(0, 0)
616-
617-
# verify counts are as expected when first watcher is registered
618-
with self.context_watcher(0):
619-
self.assert_event_counts(0, 0)
620-
ctx.run(self.assert_event_counts, 1, 0)
621-
self.assert_event_counts(2, 0)
622-
623-
# again with second watcher registered
624-
with self.context_watcher(1):
625-
self.assert_event_counts(2, 0)
626-
ctx.run(self.assert_event_counts, 3, 1)
627-
self.assert_event_counts(4, 2)
628-
629-
# verify counts are reset and don't change after both watchers are cleared
630-
ctx.run(self.assert_event_counts, 0, 0)
631-
self.assert_event_counts(0, 0)
608+
with self.context_watcher() as switches_0:
609+
self.assertEqual(len(switches_0), 0)
610+
ctx.run(lambda: self.assertEqual(len(switches_0), 1))
611+
self.assertEqual(len(switches_0), 2)
612+
with self.context_watcher() as switches_1:
613+
self.assertEqual((len(switches_0), len(switches_1)), (2, 0))
614+
ctx.run(lambda: self.assertEqual(
615+
(len(switches_0), len(switches_1)), (3, 1)))
616+
self.assertEqual((len(switches_0), len(switches_1)), (4, 2))
632617

633618
def test_callback_error(self):
634619
ctx_outer = contextvars.copy_context()
635620
ctx_inner = contextvars.copy_context()
636621
unraisables = []
637622

623+
def _cb(event, ctx):
624+
raise RuntimeError('boom!')
625+
638626
def _in_outer():
639-
with self.context_watcher(2):
627+
with self.context_watcher(_cb):
640628
with catch_unraisable_exception() as cm:
641629
ctx_inner.run(lambda: unraisables.append(cm.unraisable))
642630
unraisables.append(cm.unraisable)
643631

644632
try:
645633
ctx_outer.run(_in_outer)
634+
self.assertEqual([x is not None for x in unraisables],
635+
[True, True])
646636
self.assertEqual([x.err_msg for x in unraisables],
647637
["Exception ignored in Py_CONTEXT_SWITCHED "
648638
f"watcher callback for {ctx!r}"
@@ -656,21 +646,24 @@ def _in_outer():
656646
def test_clear_out_of_range_watcher_id(self):
657647
with self.assertRaisesRegex(ValueError, r"Invalid context watcher ID -1"):
658648
_testcapi.clear_context_watcher(-1)
659-
with self.assertRaisesRegex(ValueError, r"Invalid context watcher ID 8"):
660-
_testcapi.clear_context_watcher(8) # CONTEXT_MAX_WATCHERS = 8
649+
with self.assertRaisesRegex(ValueError, f"Invalid context watcher ID {_testcapi.CONTEXT_MAX_WATCHERS}"):
650+
_testcapi.clear_context_watcher(_testcapi.CONTEXT_MAX_WATCHERS)
661651

662652
def test_clear_unassigned_watcher_id(self):
663653
with self.assertRaisesRegex(ValueError, r"No context watcher set for ID 1"):
664654
_testcapi.clear_context_watcher(1)
665655

666656
def test_allocate_too_many_watchers(self):
667-
with self.assertRaisesRegex(RuntimeError, r"no more context watcher IDs available"):
668-
_testcapi.allocate_too_many_context_watchers()
657+
with contextlib.ExitStack() as stack:
658+
for i in range(_testcapi.CONTEXT_MAX_WATCHERS):
659+
stack.enter_context(self.context_watcher())
660+
with self.assertRaisesRegex(RuntimeError, r"no more context watcher IDs available"):
661+
stack.enter_context(self.context_watcher())
669662

670663
def test_exit_base_context(self):
671664
ctx = contextvars.Context()
672665
_testcapi.clear_context_stack()
673-
with self.context_watcher(0) as switches:
666+
with self.context_watcher() as switches:
674667
ctx.run(lambda: None)
675668
self.assertEqual(switches, [ctx, None])
676669

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Changed :c:func:`PyContext_AddWatcher` to take a callable object instead of a C
2+
function pointer so that the callback can have non-global state.

Modules/_testcapi/clinic/watchers.c.h

Lines changed: 36 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)