Skip to content

Commit a4b7794

Browse files
authoredOct 7, 2022
GH-91052: Add C API for watching dictionaries (GH-31787)
1 parent 683ab85 commit a4b7794

File tree

10 files changed

+487
-17
lines changed

10 files changed

+487
-17
lines changed
 

‎Doc/c-api/dict.rst

+51
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,54 @@ Dictionary Objects
238238
for key, value in seq2:
239239
if override or key not in a:
240240
a[key] = value
241+
242+
.. c:function:: int PyDict_AddWatcher(PyDict_WatchCallback callback)
243+
244+
Register *callback* as a dictionary watcher. Return a non-negative integer
245+
id which must be passed to future calls to :c:func:`PyDict_Watch`. In case
246+
of error (e.g. no more watcher IDs available), return ``-1`` and set an
247+
exception.
248+
249+
.. c:function:: int PyDict_ClearWatcher(int watcher_id)
250+
251+
Clear watcher identified by *watcher_id* previously returned from
252+
:c:func:`PyDict_AddWatcher`. Return ``0`` on success, ``-1`` on error (e.g.
253+
if the given *watcher_id* was never registered.)
254+
255+
.. c:function:: int PyDict_Watch(int watcher_id, PyObject *dict)
256+
257+
Mark dictionary *dict* as watched. The callback granted *watcher_id* by
258+
:c:func:`PyDict_AddWatcher` will be called when *dict* is modified or
259+
deallocated.
260+
261+
.. c:type:: PyDict_WatchEvent
262+
263+
Enumeration of possible dictionary watcher events: ``PyDict_EVENT_ADDED``,
264+
``PyDict_EVENT_MODIFIED``, ``PyDict_EVENT_DELETED``, ``PyDict_EVENT_CLONED``,
265+
``PyDict_EVENT_CLEARED``, or ``PyDict_EVENT_DEALLOCATED``.
266+
267+
.. c:type:: int (*PyDict_WatchCallback)(PyDict_WatchEvent event, PyObject *dict, PyObject *key, PyObject *new_value)
268+
269+
Type of a dict watcher callback function.
270+
271+
If *event* is ``PyDict_EVENT_CLEARED`` or ``PyDict_EVENT_DEALLOCATED``, both
272+
*key* and *new_value* will be ``NULL``. If *event* is ``PyDict_EVENT_ADDED``
273+
or ``PyDict_EVENT_MODIFIED``, *new_value* will be the new value for *key*.
274+
If *event* is ``PyDict_EVENT_DELETED``, *key* is being deleted from the
275+
dictionary and *new_value* will be ``NULL``.
276+
277+
``PyDict_EVENT_CLONED`` occurs when *dict* was previously empty and another
278+
dict is merged into it. To maintain efficiency of this operation, per-key
279+
``PyDict_EVENT_ADDED`` events are not issued in this case; instead a
280+
single ``PyDict_EVENT_CLONED`` is issued, and *key* will be the source
281+
dictionary.
282+
283+
The callback may inspect but must not modify *dict*; doing so could have
284+
unpredictable effects, including infinite recursion.
285+
286+
Callbacks occur before the notified modification to *dict* takes place, so
287+
the prior state of *dict* can be inspected.
288+
289+
If the callback returns with an exception set, it must return ``-1``; this
290+
exception will be printed as an unraisable exception using
291+
:c:func:`PyErr_WriteUnraisable`. Otherwise it should return ``0``.

‎Include/cpython/dictobject.h

+23
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,26 @@ typedef struct {
8383

8484
PyAPI_FUNC(PyObject *) _PyDictView_New(PyObject *, PyTypeObject *);
8585
PyAPI_FUNC(PyObject *) _PyDictView_Intersect(PyObject* self, PyObject *other);
86+
87+
/* Dictionary watchers */
88+
89+
typedef enum {
90+
PyDict_EVENT_ADDED,
91+
PyDict_EVENT_MODIFIED,
92+
PyDict_EVENT_DELETED,
93+
PyDict_EVENT_CLONED,
94+
PyDict_EVENT_CLEARED,
95+
PyDict_EVENT_DEALLOCATED,
96+
} PyDict_WatchEvent;
97+
98+
// Callback to be invoked when a watched dict is cleared, dealloced, or modified.
99+
// In clear/dealloc case, key and new_value will be NULL. Otherwise, new_value will be the
100+
// new value for key, NULL if key is being deleted.
101+
typedef int(*PyDict_WatchCallback)(PyDict_WatchEvent event, PyObject* dict, PyObject* key, PyObject* new_value);
102+
103+
// Register/unregister a dict-watcher callback
104+
PyAPI_FUNC(int) PyDict_AddWatcher(PyDict_WatchCallback callback);
105+
PyAPI_FUNC(int) PyDict_ClearWatcher(int watcher_id);
106+
107+
// Mark given dictionary as "watched" (callback will be called if it is modified)
108+
PyAPI_FUNC(int) PyDict_Watch(int watcher_id, PyObject* dict);

‎Include/internal/pycore_dict.h

+26-1
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,32 @@ struct _dictvalues {
154154

155155
extern uint64_t _pydict_global_version;
156156

157-
#define DICT_NEXT_VERSION() (++_pydict_global_version)
157+
#define DICT_MAX_WATCHERS 8
158+
#define DICT_VERSION_INCREMENT (1 << DICT_MAX_WATCHERS)
159+
#define DICT_VERSION_MASK (DICT_VERSION_INCREMENT - 1)
160+
161+
#define DICT_NEXT_VERSION() (_pydict_global_version += DICT_VERSION_INCREMENT)
162+
163+
void
164+
_PyDict_SendEvent(int watcher_bits,
165+
PyDict_WatchEvent event,
166+
PyDictObject *mp,
167+
PyObject *key,
168+
PyObject *value);
169+
170+
static inline uint64_t
171+
_PyDict_NotifyEvent(PyDict_WatchEvent event,
172+
PyDictObject *mp,
173+
PyObject *key,
174+
PyObject *value)
175+
{
176+
int watcher_bits = mp->ma_version_tag & DICT_VERSION_MASK;
177+
if (watcher_bits) {
178+
_PyDict_SendEvent(watcher_bits, event, mp, key, value);
179+
return DICT_NEXT_VERSION() | watcher_bits;
180+
}
181+
return DICT_NEXT_VERSION();
182+
}
158183

159184
extern PyObject *_PyObject_MakeDictFromInstanceAttributes(PyObject *obj, PyDictValues *values);
160185
extern PyObject *_PyDict_FromItems(

‎Include/internal/pycore_interp.h

+2
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ struct _is {
144144
// Initialized to _PyEval_EvalFrameDefault().
145145
_PyFrameEvalFunction eval_frame;
146146

147+
PyDict_WatchCallback dict_watchers[DICT_MAX_WATCHERS];
148+
147149
Py_ssize_t co_extra_user_count;
148150
freefunc co_extra_freefuncs[MAX_CO_EXTRA_USERS];
149151

‎Lib/test/test_capi.py

+132
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# these are all functions _testcapi exports whose name begins with 'test_'.
33

44
from collections import OrderedDict
5+
from contextlib import contextmanager
56
import _thread
67
import importlib.machinery
78
import importlib.util
@@ -1393,5 +1394,136 @@ def func2(x=None):
13931394
self.do_test(func2)
13941395

13951396

1397+
class TestDictWatchers(unittest.TestCase):
1398+
# types of watchers testcapimodule can add:
1399+
EVENTS = 0 # appends dict events as strings to global event list
1400+
ERROR = 1 # unconditionally sets and signals a RuntimeException
1401+
SECOND = 2 # always appends "second" to global event list
1402+
1403+
def add_watcher(self, kind=EVENTS):
1404+
return _testcapi.add_dict_watcher(kind)
1405+
1406+
def clear_watcher(self, watcher_id):
1407+
_testcapi.clear_dict_watcher(watcher_id)
1408+
1409+
@contextmanager
1410+
def watcher(self, kind=EVENTS):
1411+
wid = self.add_watcher(kind)
1412+
try:
1413+
yield wid
1414+
finally:
1415+
self.clear_watcher(wid)
1416+
1417+
def assert_events(self, expected):
1418+
actual = _testcapi.get_dict_watcher_events()
1419+
self.assertEqual(actual, expected)
1420+
1421+
def watch(self, wid, d):
1422+
_testcapi.watch_dict(wid, d)
1423+
1424+
def test_set_new_item(self):
1425+
d = {}
1426+
with self.watcher() as wid:
1427+
self.watch(wid, d)
1428+
d["foo"] = "bar"
1429+
self.assert_events(["new:foo:bar"])
1430+
1431+
def test_set_existing_item(self):
1432+
d = {"foo": "bar"}
1433+
with self.watcher() as wid:
1434+
self.watch(wid, d)
1435+
d["foo"] = "baz"
1436+
self.assert_events(["mod:foo:baz"])
1437+
1438+
def test_clone(self):
1439+
d = {}
1440+
d2 = {"foo": "bar"}
1441+
with self.watcher() as wid:
1442+
self.watch(wid, d)
1443+
d.update(d2)
1444+
self.assert_events(["clone"])
1445+
1446+
def test_no_event_if_not_watched(self):
1447+
d = {}
1448+
with self.watcher() as wid:
1449+
d["foo"] = "bar"
1450+
self.assert_events([])
1451+
1452+
def test_del(self):
1453+
d = {"foo": "bar"}
1454+
with self.watcher() as wid:
1455+
self.watch(wid, d)
1456+
del d["foo"]
1457+
self.assert_events(["del:foo"])
1458+
1459+
def test_pop(self):
1460+
d = {"foo": "bar"}
1461+
with self.watcher() as wid:
1462+
self.watch(wid, d)
1463+
d.pop("foo")
1464+
self.assert_events(["del:foo"])
1465+
1466+
def test_clear(self):
1467+
d = {"foo": "bar"}
1468+
with self.watcher() as wid:
1469+
self.watch(wid, d)
1470+
d.clear()
1471+
self.assert_events(["clear"])
1472+
1473+
def test_dealloc(self):
1474+
d = {"foo": "bar"}
1475+
with self.watcher() as wid:
1476+
self.watch(wid, d)
1477+
del d
1478+
self.assert_events(["dealloc"])
1479+
1480+
def test_error(self):
1481+
d = {}
1482+
unraisables = []
1483+
def unraisable_hook(unraisable):
1484+
unraisables.append(unraisable)
1485+
with self.watcher(kind=self.ERROR) as wid:
1486+
self.watch(wid, d)
1487+
orig_unraisable_hook = sys.unraisablehook
1488+
sys.unraisablehook = unraisable_hook
1489+
try:
1490+
d["foo"] = "bar"
1491+
finally:
1492+
sys.unraisablehook = orig_unraisable_hook
1493+
self.assert_events([])
1494+
self.assertEqual(len(unraisables), 1)
1495+
unraisable = unraisables[0]
1496+
self.assertIs(unraisable.object, d)
1497+
self.assertEqual(str(unraisable.exc_value), "boom!")
1498+
1499+
def test_two_watchers(self):
1500+
d1 = {}
1501+
d2 = {}
1502+
with self.watcher() as wid1:
1503+
with self.watcher(kind=self.SECOND) as wid2:
1504+
self.watch(wid1, d1)
1505+
self.watch(wid2, d2)
1506+
d1["foo"] = "bar"
1507+
d2["hmm"] = "baz"
1508+
self.assert_events(["new:foo:bar", "second"])
1509+
1510+
def test_watch_non_dict(self):
1511+
with self.watcher() as wid:
1512+
with self.assertRaisesRegex(ValueError, r"Cannot watch non-dictionary"):
1513+
self.watch(wid, 1)
1514+
1515+
def test_watch_out_of_range_watcher_id(self):
1516+
d = {}
1517+
with self.assertRaisesRegex(ValueError, r"Invalid dict watcher ID -1"):
1518+
self.watch(-1, d)
1519+
with self.assertRaisesRegex(ValueError, r"Invalid dict watcher ID 8"):
1520+
self.watch(8, d) # DICT_MAX_WATCHERS = 8
1521+
1522+
def test_unassigned_watcher_id(self):
1523+
d = {}
1524+
with self.assertRaisesRegex(ValueError, r"No dict watcher set for ID 1"):
1525+
self.watch(1, d)
1526+
1527+
13961528
if __name__ == "__main__":
13971529
unittest.main()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add API for subscribing to modification events on selected dictionaries.

0 commit comments

Comments
 (0)
Please sign in to comment.