diff --git a/docs/plugin-api-additions/fd.md b/docs/plugin-api-additions/fd.md new file mode 100644 index 00000000..0d9657fb --- /dev/null +++ b/docs/plugin-api-additions/fd.md @@ -0,0 +1,19 @@ +# Python descriptor hooks + +`hook_fd(fd, callback, userdata=None, flags=FD_READ)` returns an ordinary hook +handle accepted by `unhook()`. Callback arguments are `(fd, ready_flags, userdata)`; +return True to retain the watch and False/None to remove it. Exceptions print a +traceback and remove the watch. Unloading the script removes its watches. +The callback runs in the context captured during registration, and that context +is restored afterwards. A closed context removes the watch. + +Use nonblocking IO and bounded reads/writes. The descriptor belongs to the script; +unhook before closing it. On Windows socket descriptors use the default flags; +CRT descriptors need FD_NOTSOCKET. The existing C API uses int descriptors: values +outside its range are rejected rather than truncated. This does not add threads. + +Example: +```python +watch = zoitechat.hook_fd(sock.fileno(), on_readable, flags=zoitechat.FD_READ) +# def on_readable(fd, flags, userdata): ...; return True +``` diff --git a/plugins/python/_zoitechat_fd.py b/plugins/python/_zoitechat_fd.py new file mode 100644 index 00000000..e3f0d447 --- /dev/null +++ b/plugins/python/_zoitechat_fd.py @@ -0,0 +1,39 @@ +"""File descriptor readiness hooks; callbacks execute on the main thread.""" +import operator +import _zoitechat as api +from _zoitechat_embedded import ffi, lib + +__all__ = ['FD_READ', 'FD_WRITE', 'FD_EXCEPTION', 'FD_NOTSOCKET', 'hook_fd'] +FD_READ, FD_WRITE, FD_EXCEPTION, FD_NOTSOCKET = 1, 2, 4, 8 + + +def hook_fd(fd, callback, userdata=None, flags=FD_READ): + """callback(fd, ready_flags, userdata): True keeps the watch, False removes it.""" + fd = operator.index(fd) + flags = operator.index(flags) + if fd < 0 or fd > 2147483647: + raise ValueError('fd must fit the C API nonnegative int descriptor') + if flags & ~15 or not flags & 7: + raise ValueError('flags must select READ, WRITE or EXCEPTION') + if not callable(callback): + raise TypeError('callback must be callable') + plugin = api.__get_current_plugin() + context = api.get_context() + + def dispatch(descriptor, ready, data): + previous = api.get_context() + if not context.set(): + return False + try: + return callback(descriptor, ready, data) + finally: + previous.set() + + hook = plugin.add_hook(dispatch, userdata) + handle = lib.zoitechat_hook_fd(lib.ph, fd, flags, lib._on_fd_hook, hook.handle) + if handle == ffi.NULL: + hook.is_unload = True + plugin.remove_hook(id(hook)) + raise RuntimeError('unable to watch descriptor') + hook.zoitechat_hook = handle + return id(hook) diff --git a/plugins/python/generate_plugin.py b/plugins/python/generate_plugin.py index 0eb1ac2d..de029a45 100755 --- a/plugins/python/generate_plugin.py +++ b/plugins/python/generate_plugin.py @@ -38,6 +38,7 @@ extern "Python" int _on_print_attrs_hook(char **, zoitechat_event_attrs *, void extern "Python" int _on_server_hook(char **, char **, void *); extern "Python" int _on_server_attrs_hook(char **, char **, zoitechat_event_attrs *, void *); extern "Python" int _on_timer_hook(void *); +extern "Python" int _on_fd_hook(int, int, void *); extern "Python" int _on_plugin_init(char **, char **, char **, char *, char *); extern "Python" int _on_plugin_deinit(void); diff --git a/plugins/python/python.py b/plugins/python/python.py index 4b55ca98..74ab814e 100644 --- a/plugins/python/python.py +++ b/plugins/python/python.py @@ -304,6 +304,28 @@ def _on_timer_hook(userdata): return 0 +@ffi.def_extern() +def _on_fd_hook(fd, flags, userdata): + hook = ffi.from_handle(userdata) + keep = False + try: + keep = bool(hook.callback(fd, flags, hook.userdata)) + except Exception: + traceback.print_exc() + if not keep: + # The C watch removes itself on return; do not unhook it a second time. + try: + hook.is_unload = True + # from_handle() yields a weak proxy: id(proxy) is not id(Hook). + for existing in hook.plugin.hooks: + if existing == hook: + hook.plugin.remove_hook(id(existing)) + break + except ReferenceError: + pass # the callback already unhooked itself + return int(keep) + + @ffi.def_extern() def _on_say_command(word, word_eol, userdata): """Handle input in the special >>python<< tab.