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-91053: Add an optional callback that is invoked whenever a function is modified #98175

Merged
merged 16 commits into from
Nov 22, 2022
Merged
Show file tree
Hide file tree
Changes from 7 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
31 changes: 17 additions & 14 deletions Doc/c-api/function.rst
Original file line number Diff line number Diff line change
Expand Up @@ -122,29 +122,32 @@ There are a few functions specific to Python functions.

.. c:function:: int PyFunction_AddWatcher(PyFunction_WatchCallback callback)

Register *callback* as a function watcher for the current interpreter. Returns
an id which may be passed to :c:func:`PyFunction_ClearWatcher`. In case
of error (e.g. no more watcher IDs available), return ``-1`` and set an
exception.
Register *callback* as a function watcher for the current interpreter.
Return an ID which may be passed to :c:func:`PyFunction_ClearWatcher`.
In case of error (e.g. no more watcher IDs available),
return ``-1`` and set an exception.

.. versionadded:: 3.12


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

Clear watcher identified by *watcher_id* previously returned from
:c:func:`PyFunction_AddWatcher` for the current interpreter. Return ``0`` on
success or ``-1`` on error (e.g. if the given *watcher_id* was never
registered.)
:c:func:`PyFunction_AddWatcher` for the current interpreter.
Return ``0`` on success, or ``-1`` and set an exception on error
(e.g. if the given *watcher_id* was never registered.)

.. versionadded:: 3.12


.. c:type:: PyFunction_WatchEvent

Enumeration of possible function watcher events: ``PyFunction_EVENT_CREATED``,
``PyFunction_EVENT_DESTROY``, ``PyFunction_EVENT_MODIFY_CODE``,
``PyFunction_EVENT_MODIFY_DEFAULTS``, or ``PyFunction_EVENT_MODIFY_KWDEFAULTS``.
Enumeration of possible function watcher events:
- ``PyFunction_EVENT_CREATE``
- ``PyFunction_EVENT_DESTROY``
- ``PyFunction_EVENT_MODIFY_CODE``
- ``PyFunction_EVENT_MODIFY_DEFAULTS``
- ``PyFunction_EVENT_MODIFY_KWDEFAULTS``

.. versionadded:: 3.12

Expand All @@ -153,15 +156,15 @@ There are a few functions specific to Python functions.

Type of a function watcher callback function.

If *event* is ``PyFunction_EVENT_CREATED`` or ``PyFunction_EVENT_DESTROY``
If *event* is ``PyFunction_EVENT_CREATE`` or ``PyFunction_EVENT_DESTROY``
then *new_value* will be ``NULL``. Otherwise, *new_value* will hold a
borrowed reference to the new value that is about to be stored in *func* for
the attribute that is being modified.
:term:`borrowed reference` to the new value that is about to be stored in
*func* for the attribute that is being modified.

The callback may inspect but must not modify *func*; doing so could have
unpredictable effects, including infinite recursion.

If *event* is ``PyFunction_EVENT_CREATED``, then the callback is invoked
If *event* is ``PyFunction_EVENT_CREATE``, then the callback is invoked
after `func` has been fully initialized. Otherwise, the callback is invoked
before the modification to *func* takes place, so the prior state of *func*
can be inspected.
Expand Down
5 changes: 4 additions & 1 deletion Include/cpython/funcobject.h
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ PyAPI_FUNC(PyObject *) PyClassMethod_New(PyObject *);
PyAPI_FUNC(PyObject *) PyStaticMethod_New(PyObject *);

#define FOREACH_FUNC_EVENT(V) \
V(CREATED) \
V(CREATE) \
V(DESTROY) \
V(MODIFY_CODE) \
V(MODIFY_DEFAULTS) \
Expand All @@ -155,6 +155,9 @@ typedef enum {
* callback will be invoked with the respective event and new_value will
* contain a borrowed reference to the new value that is about to be stored in
* the function. Otherwise the third argument is NULL.
*
* If the callback returns with an exception set, it must return -1. Otherwise
* it should return 0.
*/
typedef int (*PyFunction_WatchCallback)(
PyFunction_WatchEvent event,
Expand Down
106 changes: 106 additions & 0 deletions Lib/test/test_capi.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@
# Skip this test if the _testcapi module isn't available.
_testcapi = import_helper.import_module('_testcapi')

from _testcapi import (
PYFUNC_EVENT_CREATE,
PYFUNC_EVENT_DESTROY,
PYFUNC_EVENT_MODIFY_CODE,
PYFUNC_EVENT_MODIFY_DEFAULTS,
PYFUNC_EVENT_MODIFY_KWDEFAULTS,
_add_func_watcher,
_allocate_too_many_func_watchers,
_clear_func_watcher,
_set_func_defaults_via_capi,
_set_func_kwdefaults_via_capi,
)

import _testinternalcapi


Expand Down Expand Up @@ -1556,5 +1569,98 @@ def test_clear_unassigned_watcher_id(self):
self.clear_watcher(1)


class FuncEventsTest(unittest.TestCase):
@contextmanager
def add_watcher(self, func):
wid = _add_func_watcher(func)
try:
yield
finally:
_clear_func_watcher(wid)

def test_func_events_dispatched(self):
events = []
def watcher(*args):
events.append(args)

with self.add_watcher(watcher):
def myfunc():
pass
self.assertIn((PYFUNC_EVENT_CREATE, myfunc, None), events)
myfunc_id = id(myfunc)

new_code = self.test_func_events_dispatched.__code__
myfunc.__code__ = new_code
self.assertIn((PYFUNC_EVENT_MODIFY_CODE, myfunc, new_code), events)

new_defaults = (123,)
myfunc.__defaults__ = new_defaults
self.assertIn((PYFUNC_EVENT_MODIFY_DEFAULTS, myfunc, new_defaults), events)

new_defaults = (456,)
_set_func_defaults_via_capi(myfunc, new_defaults)
self.assertIn((PYFUNC_EVENT_MODIFY_DEFAULTS, myfunc, new_defaults), events)

new_kwdefaults = {"self": 123}
myfunc.__kwdefaults__ = new_kwdefaults
self.assertIn((PYFUNC_EVENT_MODIFY_KWDEFAULTS, myfunc, new_kwdefaults), events)

new_kwdefaults = {"self": 456}
_set_func_kwdefaults_via_capi(myfunc, new_kwdefaults)
self.assertIn((PYFUNC_EVENT_MODIFY_KWDEFAULTS, myfunc, new_kwdefaults), events)

# Clear events reference to func
events = []
del myfunc
self.assertIn((PYFUNC_EVENT_DESTROY, myfunc_id, None), events)

def test_multiple_watchers(self):
events0 = []
def first_watcher(*args):
events0.append(args)

events1 = []
def second_watcher(*args):
events1.append(args)

with self.add_watcher(first_watcher):
with self.add_watcher(second_watcher):
def myfunc():
pass

event = (PYFUNC_EVENT_CREATE, myfunc, None)
self.assertIn(event, events0)
self.assertIn(event, events1)

def test_watcher_raises_error(self):
class MyError(Exception):
pass

def watcher(*args):
raise MyError("testing 123")

with self.add_watcher(watcher):
with catch_unraisable_exception() as cm:
def myfunc():
pass

self.assertIs(cm.unraisable.object, myfunc)
self.assertIsInstance(cm.unraisable.exc_value, MyError)

def test_clear_out_of_range_watcher_id(self):
with self.assertRaisesRegex(ValueError, r"invalid func watcher ID -1"):
_clear_func_watcher(-1)
with self.assertRaisesRegex(ValueError, r"invalid func watcher ID 8"):
_clear_func_watcher(8) # FUNC_MAX_WATCHERS = 8

def test_clear_unassigned_watcher_id(self):
with self.assertRaisesRegex(ValueError, r"no func watcher set for ID 1"):
_clear_func_watcher(1)

def test_allocate_too_many_watchers(self):
with self.assertRaisesRegex(RuntimeError, r"no more func watcher IDs"):
_allocate_too_many_func_watchers()


if __name__ == "__main__":
unittest.main()
97 changes: 0 additions & 97 deletions Lib/test/test_func_events.py

This file was deleted.

Loading