Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-91051: allow setting a callback hook on PyType_Modified #97875

Merged
merged 21 commits into from
Oct 21, 2022
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
49 changes: 49 additions & 0 deletions Doc/c-api/type.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,55 @@ Type Objects
modification of the attributes or base classes of the type.


.. c:function:: int PyType_AddWatcher(PyType_WatchCallback callback)
carljm marked this conversation as resolved.
Show resolved Hide resolved

Register *callback* as a type watcher. Return a non-negative integer ID
which must be passed to future calls to :c:func:`PyType_Watch`. In case of
error (e.g. no more watcher IDs available), return ``-1`` and set an
exception.

.. versionadded:: 3.12


.. c:function:: int PyType_ClearWatcher(int watcher_id)

Clear watcher identified by *watcher_id* (previously returned from
:c:func:`PyType_AddWatcher`). Return ``0`` on success, ``-1`` on error (e.g.
if *watcher_id* was never registered.)

An extension should never call ``PyType_ClearWatcher`` with a *watcher_id*
that was not returned to it by a previous call to
:c:func:`PyType_AddWatcher`.

.. versionadded:: 3.12


.. c:function:: int PyType_Watch(int watcher_id, PyObject *type)

Mark *type* as watched. The callback granted *watcher_id* by
:c:func:`PyType_AddWatcher` will be called whenever
:c:func:`PyType_Modified` reports a change to *type*. (The callback may be
called only once for a series of consecutive modifications to *type*, if
:c:func:`PyType_Lookup` is not called on *type* between the modifications;
this is an implementation detail and subject to change.)

An extension should never call ``PyType_Watch`` with a *watcher_id* that was
not returned to it by a previous call to :c:func:`PyType_AddWatcher`.

.. versionadded:: 3.12


.. c:type:: int (*PyType_WatchCallback)(PyObject *type)

Type of a type-watcher callback function.

The callback must not modify *type* or cause :c:func:`PyType_Modified` to be
called on *type* or any type in its MRO; violating this rule could cause
infinite recursion.

.. versionadded:: 3.12


.. c:function:: int PyType_HasFeature(PyTypeObject *o, int feature)

Return non-zero if the type object *o* sets the feature *feature*.
Expand Down
6 changes: 6 additions & 0 deletions Doc/whatsnew/3.12.rst
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,12 @@ New Features
: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.
(Contributed by Carl Meyer in :gh:`91052`.)

* Added :c:func:`PyType_AddWatcher` and :c:func:`PyType_Watch` API to register
carljm marked this conversation as resolved.
Show resolved Hide resolved
callbacks to receive notification on changes to a type.
(Contributed by Carl Meyer in :gh:`91051`.)


Porting to Python 3.12
----------------------
Expand Down
11 changes: 11 additions & 0 deletions Include/cpython/object.h
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ struct _typeobject {

destructor tp_finalize;
vectorcallfunc tp_vectorcall;

/* bitset of which type-watchers care about this type */
char tp_watched;
};

/* This struct is used by the specializer
Expand Down Expand Up @@ -510,3 +513,11 @@ Py_DEPRECATED(3.11) typedef int UsingDeprecatedTrashcanMacro;

PyAPI_FUNC(int) _PyObject_VisitManagedDict(PyObject *obj, visitproc visit, void *arg);
PyAPI_FUNC(void) _PyObject_ClearManagedDict(PyObject *obj);

#define TYPE_MAX_WATCHERS 8

typedef int(*PyType_WatchCallback)(PyTypeObject *);
PyAPI_FUNC(int) PyType_AddWatcher(PyType_WatchCallback callback);
PyAPI_FUNC(int) PyType_ClearWatcher(int watcher_id);
PyAPI_FUNC(int) PyType_Watch(int watcher_id, PyObject *type);
PyAPI_FUNC(int) PyType_Unwatch(int watcher_id, PyObject *type);
1 change: 1 addition & 0 deletions Include/internal/pycore_interp.h
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ struct _is {
struct atexit_state atexit;

PyObject *audit_hooks;
PyType_WatchCallback type_watchers[TYPE_MAX_WATCHERS];

struct _Py_unicode_state unicode;
struct _Py_float_state float_state;
Expand Down
169 changes: 168 additions & 1 deletion Lib/test/test_capi.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# these are all functions _testcapi exports whose name begins with 'test_'.

from collections import OrderedDict
from contextlib import contextmanager
from contextlib import contextmanager, ExitStack
import _thread
import importlib.machinery
import importlib.util
Expand Down Expand Up @@ -1606,5 +1606,172 @@ def test_clear_unassigned_watcher_id(self):
self.clear_watcher(1)


class TestTypeWatchers(unittest.TestCase):
# types of watchers testcapimodule can add:
TYPES = 0 # appends modified types to global event list
ERROR = 1 # unconditionally sets and signals a RuntimeException
WRAP = 2 # appends modified type wrapped in list to global event list

# duplicating the C constant
TYPE_MAX_WATCHERS = 8

def add_watcher(self, kind=TYPES):
return _testcapi.add_type_watcher(kind)

def clear_watcher(self, watcher_id):
_testcapi.clear_type_watcher(watcher_id)

@contextmanager
def watcher(self, kind=TYPES):
wid = self.add_watcher(kind)
try:
yield wid
finally:
self.clear_watcher(wid)

def assert_events(self, expected):
actual = _testcapi.get_type_modified_events()
self.assertEqual(actual, expected)

def watch(self, wid, t):
_testcapi.watch_type(wid, t)

def unwatch(self, wid, t):
_testcapi.unwatch_type(wid, t)

def test_watch_type(self):
class C: pass
with self.watcher() as wid:
self.watch(wid, C)
C.foo = "bar"
self.assert_events([C])

def test_event_aggregation(self):
class C: pass
with self.watcher() as wid:
self.watch(wid, C)
C.foo = "bar"
C.bar = "baz"
# only one event registered for both modifications
self.assert_events([C])

def test_lookup_resets_aggregation(self):
class C: pass
with self.watcher() as wid:
self.watch(wid, C)
C.foo = "bar"
# lookup resets type version tag
self.assertEqual(C.foo, "bar")
C.bar = "baz"
# both events registered
self.assert_events([C, C])

def test_unwatch_type(self):
carljm marked this conversation as resolved.
Show resolved Hide resolved
class C: pass
with self.watcher() as wid:
self.watch(wid, C)
C.foo = "bar"
self.assertEqual(C.foo, "bar")
self.assert_events([C])
self.unwatch(wid, C)
C.bar = "baz"
self.assert_events([C])

def test_clear_watcher(self):
class C: pass
# outer watcher is unused, it's just to keep events list alive
with self.watcher() as _:
with self.watcher() as wid:
self.watch(wid, C)
C.foo = "bar"
self.assertEqual(C.foo, "bar")
self.assert_events([C])
C.bar = "baz"
# Watcher on C has been cleared, no new event
self.assert_events([C])

def test_watch_type_subclass(self):
class C: pass
class D(C): pass
with self.watcher() as wid:
self.watch(wid, D)
C.foo = "bar"
self.assert_events([D])

def test_error(self):
class C: pass
with self.watcher(kind=self.ERROR) as wid:
self.watch(wid, C)
with catch_unraisable_exception() as cm:
C.foo = "bar"
self.assertIs(cm.unraisable.object, C)
self.assertEqual(str(cm.unraisable.exc_value), "boom!")
self.assert_events([])

def test_two_watchers(self):
class C1: pass
class C2: pass
with self.watcher() as wid1:
with self.watcher(kind=self.WRAP) as wid2:
self.assertNotEqual(wid1, wid2)
self.watch(wid1, C1)
carljm marked this conversation as resolved.
Show resolved Hide resolved
self.watch(wid2, C2)
C1.foo = "bar"
C2.hmm = "baz"
self.assert_events([C1, [C2]])

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

def test_watch_out_of_range_watcher_id(self):
class C: pass
with self.assertRaisesRegex(ValueError, r"Invalid type watcher ID -1"):
self.watch(-1, C)
with self.assertRaisesRegex(ValueError, r"Invalid type watcher ID 8"):
self.watch(self.TYPE_MAX_WATCHERS, C)

def test_watch_unassigned_watcher_id(self):
class C: pass
with self.assertRaisesRegex(ValueError, r"No type watcher set for ID 1"):
self.watch(1, C)

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

def test_unwatch_out_of_range_watcher_id(self):
class C: pass
with self.assertRaisesRegex(ValueError, r"Invalid type watcher ID -1"):
self.unwatch(-1, C)
with self.assertRaisesRegex(ValueError, r"Invalid type watcher ID 8"):
self.unwatch(self.TYPE_MAX_WATCHERS, C)

def test_unwatch_unassigned_watcher_id(self):
class C: pass
with self.assertRaisesRegex(ValueError, r"No type watcher set for ID 1"):
self.unwatch(1, C)

def test_clear_out_of_range_watcher_id(self):
with self.assertRaisesRegex(ValueError, r"Invalid type watcher ID -1"):
self.clear_watcher(-1)
with self.assertRaisesRegex(ValueError, r"Invalid type watcher ID 8"):
self.clear_watcher(self.TYPE_MAX_WATCHERS)

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

def test_no_more_ids_available(self):
contexts = [self.watcher() for i in range(self.TYPE_MAX_WATCHERS)]
with ExitStack() as stack:
for ctx in contexts:
stack.enter_context(ctx)
with self.assertRaisesRegex(RuntimeError, r"no more type watcher IDs"):
self.add_watcher()


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion Lib/test/test_sys.py
Original file line number Diff line number Diff line change
Expand Up @@ -1521,7 +1521,7 @@ def delx(self): del self.__x
check((1,2,3), vsize('') + 3*self.P)
# type
# static type: PyTypeObject
fmt = 'P2nPI13Pl4Pn9Pn12PIP'
fmt = 'P2nPI13Pl4Pn9Pn12PIPc'
s = vsize('2P' + fmt)
check(int, s)
# class
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add :c:func:`PyType_Watch` and related APIs to allow callbacks on
:c:func:`PyType_Modified`.
Loading