gh-91052: Add PyDict_Unwatch for unwatching a dictionary by carljm · Pull Request #98055 · python/cpython · GitHub
Skip to content
Merged
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
21 changes: 20 additions & 1 deletion Doc/c-api/dict.rst
5 changes: 5 additions & 0 deletions Doc/whatsnew/3.12.rst
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,11 @@ New Features
which sets the vectorcall field of a given :c:type:`PyFunctionObject`.
(Contributed by Andrew Frost in :gh:`92257`.)

* The C API now permits registering callbacks via :c:func:`PyDict_AddWatcher`,
:c:func:`PyDict_AddWatch` and related APIs to be called whenever a dictionary
is modified. This is intended for use by optimizing interpreters, JIT
compilers, or debuggers.

Porting to Python 3.12
----------------------

Expand Down
1 change: 1 addition & 0 deletions Include/cpython/dictobject.h
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,4 @@ PyAPI_FUNC(int) PyDict_ClearWatcher(int watcher_id);

// Mark given dictionary as "watched" (callback will be called if it is modified)
PyAPI_FUNC(int) PyDict_Watch(int watcher_id, PyObject* dict);
PyAPI_FUNC(int) PyDict_Unwatch(int watcher_id, PyObject* dict);
60 changes: 44 additions & 16 deletions Lib/test/test_capi.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import weakref
from test import support
from test.support import MISSING_C_DOCSTRINGS
from test.support import catch_unraisable_exception
from test.support import import_helper
from test.support import threading_helper
from test.support import warnings_helper
Expand Down Expand Up @@ -1421,6 +1422,9 @@ def assert_events(self, expected):
def watch(self, wid, d):
_testcapi.watch_dict(wid, d)

def unwatch(self, wid, d):
_testcapi.unwatch_dict(wid, d)

def test_set_new_item(self):
d = {}
with self.watcher() as wid:
Expand Down Expand Up @@ -1477,27 +1481,24 @@ def test_dealloc(self):
del d
self.assert_events(["dealloc"])

def test_unwatch(self):
d = {}
with self.watcher() as wid:
self.watch(wid, d)
d["foo"] = "bar"
self.unwatch(wid, d)
d["hmm"] = "baz"
self.assert_events(["new:foo:bar"])

def test_error(self):
d = {}
unraisables = []
def unraisable_hook(unraisable):
unraisables.append(unraisable)
with self.watcher(kind=self.ERROR) as wid:
self.watch(wid, d)
orig_unraisable_hook = sys.unraisablehook
sys.unraisablehook = unraisable_hook
try:
with catch_unraisable_exception() as cm:
d["foo"] = "bar"
finally:
sys.unraisablehook = orig_unraisable_hook
self.assertIs(cm.unraisable.object, d)
self.assertEqual(str(cm.unraisable.exc_value), "boom!")
self.assert_events([])
self.assertEqual(len(unraisables), 1)
unraisable = unraisables[0]
self.assertIs(unraisable.object, d)
self.assertEqual(str(unraisable.exc_value), "boom!")
# avoid leaking reference cycles
del unraisable
del unraisables

def test_two_watchers(self):
d1 = {}
Expand All @@ -1522,11 +1523,38 @@ def test_watch_out_of_range_watcher_id(self):
with self.assertRaisesRegex(ValueError, r"Invalid dict watcher ID 8"):
self.watch(8, d) # DICT_MAX_WATCHERS = 8

def test_unassigned_watcher_id(self):
def test_watch_unassigned_watcher_id(self):
d = {}
with self.assertRaisesRegex(ValueError, r"No dict watcher set for ID 1"):
self.watch(1, d)

def test_unwatch_non_dict(self):
with self.watcher() as wid:
with self.assertRaisesRegex(ValueError, r"Cannot watch non-dictionary"):
self.unwatch(wid, 1)

def test_unwatch_out_of_range_watcher_id(self):
d = {}
with self.assertRaisesRegex(ValueError, r"Invalid dict watcher ID -1"):
self.unwatch(-1, d)
with self.assertRaisesRegex(ValueError, r"Invalid dict watcher ID 8"):
self.unwatch(8, d) # DICT_MAX_WATCHERS = 8

def test_unwatch_unassigned_watcher_id(self):
d = {}
with self.assertRaisesRegex(ValueError, r"No dict watcher set for ID 1"):
self.unwatch(1, d)

def test_clear_out_of_range_watcher_id(self):
with self.assertRaisesRegex(ValueError, r"Invalid dict watcher ID -1"):
self.clear_watcher(-1)
with self.assertRaisesRegex(ValueError, r"Invalid dict watcher ID 8"):
self.clear_watcher(8) # DICT_MAX_WATCHERS = 8

def test_clear_unassigned_watcher_id(self):
with self.assertRaisesRegex(ValueError, r"No dict watcher set for ID 1"):
self.clear_watcher(1)


if __name__ == "__main__":
unittest.main()
15 changes: 15 additions & 0 deletions Modules/_testcapimodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -5296,6 +5296,20 @@ watch_dict(PyObject *self, PyObject *args)
Py_RETURN_NONE;
}

static PyObject *
unwatch_dict(PyObject *self, PyObject *args)
{
PyObject *dict;
int watcher_id;
if (!PyArg_ParseTuple(args, "iO", &watcher_id, &dict)) {
return NULL;
}
if (PyDict_Unwatch(watcher_id, dict)) {
return NULL;
}
Py_RETURN_NONE;
}

static PyObject *
get_dict_watcher_events(PyObject *self, PyObject *Py_UNUSED(args))
{
Expand Down Expand Up @@ -5904,6 +5918,7 @@ static PyMethodDef TestMethods[] = {
{"add_dict_watcher", add_dict_watcher, METH_O, NULL},
{"clear_dict_watcher", clear_dict_watcher, METH_O, NULL},
{"watch_dict", watch_dict, METH_VARARGS, NULL},
{"unwatch_dict", unwatch_dict, METH_VARARGS, NULL},
{"get_dict_watcher_events", get_dict_watcher_events, METH_NOARGS, NULL},
{NULL, NULL} /* sentinel */
};
Expand Down
41 changes: 30 additions & 11 deletions Objects/dictobject.c