Skip to content

Commit 0447232

Browse files
committed
gh-91052: Add C API for watching dictionaries
1 parent b0f89cb commit 0447232

File tree

8 files changed

+382
-17
lines changed

8 files changed

+382
-17
lines changed

Doc/c-api/dict.rst

+41
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,44 @@ 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_Watch(int watcher_id, PyObject *dict)
250+
251+
Mark dictionary *dict* as watched. The callback granted *watcher_id* by
252+
:c:func:`PyDict_AddWatcher` will be called when *dict* is modified or
253+
deallocated.
254+
255+
.. c:type:: PyDict_WatchEvent
256+
257+
Enumeration of possible dictionary watcher events: ``PyDict_EVENT_ADDED``,
258+
``PyDict_EVENT_MODIFIED``, ``PyDict_EVENT_DELETED``, ``PyDict_EVENT_CLONED``,
259+
``PyDict_EVENT_CLEARED``, or ``PyDict_EVENT_DEALLOCED``.
260+
261+
.. c:type:: void (*PyDict_WatchCallback)(PyDict_WatchEvent event, PyObject *dict, PyObject *key, PyObject *new_value)
262+
263+
Type of a dict watcher callback function.
264+
265+
If *event* is ``PyDict_EVENT_CLEARED`` or ``PyDict_EVENT_DEALLOCED``, both
266+
*key* and *new_value* will be ``NULL``. If *event* is
267+
``PyDict_EVENT_ADDED`` or ``PyDict_EVENT_MODIFIED``, *new_value* will be the
268+
new value for *key*. If *event* is ``PyDict_EVENT_DELETED``, *key* is being
269+
deleted from the dictionary and *new_value* will be ``NULL``.
270+
271+
``PyDict_EVENT_CLONED`` occurs when *dict* was previously empty and another
272+
dict is merged into it. To maintain efficiency of this operation, per-key
273+
``PyDict_EVENT_ADDED`` events are not issued in this case; instead a
274+
single ``PyDict_EVENT_CLONED`` is issued, and *key* will be the source
275+
dictionary.
276+
277+
The callback may inspect but should not modify *dict*; doing so could have
278+
unpredictable effects, including infinite recursion.
279+
280+
Callbacks occur before the notified modification to *dict* takes place, so
281+
the prior state of *dict* can be inspected.

Include/cpython/dictobject.h

+22
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,25 @@ 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_DEALLOCED,
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 void(*PyDict_WatchCallback)(PyDict_WatchEvent event, PyObject* dict, PyObject* key, PyObject* new_value);
102+
103+
// Register a dict-watcher callback
104+
PyAPI_FUNC(int) PyDict_AddWatcher(PyDict_WatchCallback callback);
105+
106+
// Mark given dictionary as "watched" (callback will be called if it is modified)
107+
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_MASK 255
159+
#define DICT_VERSION_INCREMENT 256
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+
void *dict_watchers[8];
148+
147149
Py_ssize_t co_extra_user_count;
148150
freefunc co_extra_freefuncs[MAX_CO_EXTRA_USERS];
149151

Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add API for subscribing to modification events on selected dictionaries.

Modules/_testcapimodule.c

+201
Original file line numberDiff line numberDiff line change
@@ -5169,6 +5169,206 @@ test_tstate_capi(PyObject *self, PyObject *Py_UNUSED(args))
51695169
}
51705170

51715171

5172+
// Test dict watching
5173+
static PyObject *g_dict_watch_events;
5174+
5175+
static void
5176+
dict_watch_callback(PyDict_WatchEvent event,
5177+
PyObject *dict,
5178+
PyObject *key,
5179+
PyObject *new_value)
5180+
{
5181+
PyObject *msg;
5182+
switch(event) {
5183+
case PyDict_EVENT_CLEARED:
5184+
msg = PyUnicode_FromString("clear");
5185+
break;
5186+
case PyDict_EVENT_DEALLOCED:
5187+
msg = PyUnicode_FromString("dealloc");
5188+
break;
5189+
case PyDict_EVENT_CLONED:
5190+
msg = PyUnicode_FromString("clone");
5191+
break;
5192+
case PyDict_EVENT_ADDED:
5193+
msg = PyUnicode_FromFormat("new:%S:%S", key, new_value);
5194+
break;
5195+
case PyDict_EVENT_MODIFIED:
5196+
msg = PyUnicode_FromFormat("mod:%S:%S", key, new_value);
5197+
break;
5198+
case PyDict_EVENT_DELETED:
5199+
msg = PyUnicode_FromFormat("del:%S", key);
5200+
break;
5201+
default:
5202+
msg = PyUnicode_FromString("unknown");
5203+
}
5204+
assert(PyList_Check(g_dict_watch_events));
5205+
PyList_Append(g_dict_watch_events, msg);
5206+
}
5207+
5208+
static int
5209+
dict_watch_assert(Py_ssize_t expected_num_events,
5210+
const char *expected_last_msg)
5211+
{
5212+
char buf[512];
5213+
Py_ssize_t actual_num_events = PyList_Size(g_dict_watch_events);
5214+
if (expected_num_events != actual_num_events) {
5215+
snprintf(buf,
5216+
512,
5217+
"got %d dict watch events, expected %d",
5218+
(int)actual_num_events,
5219+
(int)expected_num_events);
5220+
raiseTestError("test_watch_dict", (const char *)&buf);
5221+
return -1;
5222+
}
5223+
PyObject *last_msg = PyList_GetItem(g_dict_watch_events,
5224+
PyList_Size(g_dict_watch_events)-1);
5225+
if (PyUnicode_CompareWithASCIIString(last_msg, expected_last_msg)) {
5226+
snprintf(buf,
5227+
512,
5228+
"last event is '%s', expected '%s'",
5229+
PyUnicode_AsUTF8(last_msg),
5230+
expected_last_msg);
5231+
raiseTestError("test_watch_dict", (const char *)&buf);
5232+
return -1;
5233+
}
5234+
return 0;
5235+
}
5236+
5237+
static int
5238+
try_watch(int watcher_id, PyObject *obj) {
5239+
if (PyDict_Watch(watcher_id, obj)) {
5240+
raiseTestError("test_watch_dict", "PyDict_Watch() failed on dict");
5241+
return -1;
5242+
}
5243+
return 0;
5244+
}
5245+
5246+
static int
5247+
dict_watch_assert_error(int watcher_id, PyObject *obj, const char *fail_msg)
5248+
{
5249+
if (!PyDict_Watch(watcher_id, obj)) {
5250+
raiseTestError("test_watch_dict", fail_msg);
5251+
return -1;
5252+
} else if (!PyErr_Occurred()) {
5253+
raiseTestError("test_watch_dict", "PyDict_Watch() returned error code without exception set");
5254+
return -1;
5255+
} else {
5256+
PyErr_Clear();
5257+
}
5258+
return 0;
5259+
}
5260+
5261+
static PyObject *
5262+
test_watch_dict(PyObject *self, PyObject *Py_UNUSED(args))
5263+
{
5264+
PyObject *watched = PyDict_New();
5265+
PyObject *unwatched = PyDict_New();
5266+
PyObject *one = PyLong_FromLong(1);
5267+
PyObject *two = PyLong_FromLong(2);
5268+
PyObject *key1 = PyUnicode_FromString("key1");
5269+
PyObject *key2 = PyUnicode_FromString("key2");
5270+
5271+
g_dict_watch_events = PyList_New(0);
5272+
5273+
int wid = PyDict_AddWatcher(dict_watch_callback);
5274+
if (try_watch(wid, watched)) {
5275+
return NULL;
5276+
}
5277+
5278+
PyDict_SetItem(unwatched, key1, two);
5279+
PyDict_Merge(watched, unwatched, 1);
5280+
5281+
if (dict_watch_assert(1, "clone")) {
5282+
return NULL;
5283+
}
5284+
5285+
PyDict_SetItem(watched, key1, one);
5286+
PyDict_SetItem(unwatched, key1, one);
5287+
5288+
if (dict_watch_assert(2, "mod:key1:1")) {
5289+
return NULL;
5290+
}
5291+
5292+
PyDict_SetItemString(watched, "key1", two);
5293+
PyDict_SetItemString(unwatched, "key1", two);
5294+
5295+
if (dict_watch_assert(3, "mod:key1:2")) {
5296+
return NULL;
5297+
}
5298+
5299+
PyDict_SetItem(watched, key2, one);
5300+
PyDict_SetItem(unwatched, key2, one);
5301+
5302+
if (dict_watch_assert(4, "new:key2:1")) {
5303+
return NULL;
5304+
}
5305+
5306+
_PyDict_Pop(watched, key2, Py_None);
5307+
_PyDict_Pop(unwatched, key2, Py_None);
5308+
5309+
if (dict_watch_assert(5, "del:key2")) {
5310+
return NULL;
5311+
}
5312+
5313+
PyDict_DelItemString(watched, "key1");
5314+
PyDict_DelItemString(unwatched, "key1");
5315+
5316+
if (dict_watch_assert(6, "del:key1")) {
5317+
return NULL;
5318+
}
5319+
5320+
PyDict_SetDefault(watched, key1, one);
5321+
PyDict_SetDefault(unwatched, key1, one);
5322+
5323+
if (dict_watch_assert(7, "new:key1:1")) {
5324+
return NULL;
5325+
}
5326+
5327+
PyDict_Clear(watched);
5328+
PyDict_Clear(unwatched);
5329+
5330+
if (dict_watch_assert(8, "clear")) {
5331+
return NULL;
5332+
}
5333+
5334+
PyObject *copy = PyDict_Copy(watched);
5335+
// copied dict is not watched, so this does not add an event
5336+
Py_CLEAR(copy);
5337+
5338+
Py_CLEAR(watched);
5339+
5340+
if (dict_watch_assert(9, "dealloc")) {
5341+
return NULL;
5342+
}
5343+
5344+
// it is an error to try to watch a non-dict
5345+
if (dict_watch_assert_error(wid, one, "PyDict_Watch() succeeded on non-dict")) {
5346+
return NULL;
5347+
}
5348+
5349+
// It is an error to pass an out-of-range watcher ID
5350+
if (dict_watch_assert_error(-1, unwatched, "PyDict_Watch() succeeded on negative watcher ID")) {
5351+
return NULL;
5352+
}
5353+
if (dict_watch_assert_error(8, unwatched, "PyDict_Watch() succeeded on too-large watcher ID")) {
5354+
return NULL;
5355+
}
5356+
5357+
// It is an error to pass a never-registered watcher ID
5358+
if (dict_watch_assert_error(7, unwatched, "PyDict_Watch() succeeded on unused watcher ID")) {
5359+
return NULL;
5360+
}
5361+
5362+
Py_CLEAR(unwatched);
5363+
Py_CLEAR(g_dict_watch_events);
5364+
Py_DECREF(one);
5365+
Py_DECREF(two);
5366+
Py_DECREF(key1);
5367+
Py_DECREF(key2);
5368+
Py_RETURN_NONE;
5369+
}
5370+
5371+
51725372
// Test PyFloat_Pack2(), PyFloat_Pack4() and PyFloat_Pack8()
51735373
static PyObject *
51745374
test_float_pack(PyObject *self, PyObject *args)
@@ -5762,6 +5962,7 @@ static PyMethodDef TestMethods[] = {
57625962
{"settrace_to_record", settrace_to_record, METH_O, NULL},
57635963
{"test_macros", test_macros, METH_NOARGS, NULL},
57645964
{"clear_managed_dict", clear_managed_dict, METH_O, NULL},
5965+
{"test_watch_dict", test_watch_dict, METH_NOARGS, NULL},
57655966
{NULL, NULL} /* sentinel */
57665967
};
57675968

0 commit comments

Comments
 (0)