From 91bca1cab0abf859bab7a7f483c080f6be7a0aab Mon Sep 17 00:00:00 2001 From: nocturn9x Date: Sun, 26 Dec 2021 15:52:47 +0100 Subject: [PATCH] Made the behavior of oneshot handlers uniform across execution modes. Added more tests and notes to README --- README.md | 20 ++++++++++++++++---- asyncevents/__init__.py | 12 ++++++------ asyncevents/events.py | 18 ++++++------------ tests/multiple_handlers_one_event.py | 22 ++++++++++++++++++++++ tests/oneshot.py | 12 +++++++++++- 5 files changed, 61 insertions(+), 23 deletions(-) create mode 100644 tests/multiple_handlers_one_event.py diff --git a/README.md b/README.md index 10a16a9..5a48090 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,24 @@ asyncevents is a small library to help developers perform asynchronous event han - The public API is fully type hinted for those sweet, sweet editor suggestions - Public API is fully documented (and some private stuff too): you could write this from scratch in a couple of hours (like I did) - Very small (~200 CLOC), although it can't fit on a postcard +- Oneshot events (i.e. fired only once) -__Note__: Deterministic event handling can only occur in blocking mode, i.e. when a call to `emit()` blocks until -all event handlers have run. If the non-blocking mode is used, handlers are started according to their priority, -but there's no telling on how they will be further scheduled to run and that depends entirely on the underlying -asyncio event loop +## Must Read +- Deterministic event handling can only occur in blocking mode, i.e. when a call to `emit()` blocks until + all event handlers have run. If the non-blocking mode is used, handlers are started according to their priority, + but there's no telling on how they will be further scheduled to run and that depends entirely on the underlying + asyncio event loop +- When using `oneshot=True`, the handler is unscheduled _before_ it is first run: this ensures a consistent behavior + between blocking and non-blocking mode. +- An exception in one event handler does not cause the others to be cancelled when using non-blocking mode. In blocking + mode, if an error occurs in one handler, and it propagates, then the handlers after it are not started +- Exceptions in custom exception handlers are not caught +- When using non-blocking mode, exceptions are kind of a mess due to how asyncio works: They are only delivered once + you `wait()` for an event (same for log messages). This allows spawning many events and only having to worry about + exceptions in a single point in your code, but it also means you have less control over how the handlers run: if an + error occurs in one handler, it will be raised once you call `wait()` but any other error in other handlers will + be silently dropped ## Limitations diff --git a/asyncevents/__init__.py b/asyncevents/__init__.py index bed7e80..a426293 100644 --- a/asyncevents/__init__.py +++ b/asyncevents/__init__.py @@ -8,6 +8,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import functools import threading from asyncevents import events, errors from typing import Any, Callable, Coroutine, Optional @@ -19,7 +20,7 @@ local_storage: threading.local = threading.local() AsyncEventEmitter = events.AsyncEventEmitter -def get_current_emitter(): +def get_current_emitter() -> AsyncEventEmitter: """ Returns the currently active emitter in the current thread @@ -60,7 +61,7 @@ async def wait(event: Optional[str] = None): async def emit(event: str, block: bool = True): """ - Shorthand for get_current_emitter().emit(event) + Shorthand for get_current_emitter().emit(event, block) """ await get_current_emitter().emit(event, block) @@ -68,17 +69,16 @@ async def emit(event: str, block: bool = True): def on_event(event: str, priority: int = 0, emitter: AsyncEventEmitter = get_current_emitter(), oneshot: bool = False): """ - Decorator shorthand of emitter.register_event(event, f, priority) + Decorator shorthand of emitter.register_event(event, f, priority, oneshot) """ - def decorator(corofunc: Callable[["AsyncEventEmitter", str], Coroutine[Any, Any, Any]]): + def decorator(corofunc: Callable[[AsyncEventEmitter, str], Coroutine[Any, Any, Any]]): emitter.register_event(event, corofunc, priority, oneshot) + @functools.wraps async def wrapper(*args, **kwargs): return await corofunc(*args, **kwargs) - return wrapper - return decorator diff --git a/asyncevents/events.py b/asyncevents/events.py index f37d5ba..995d7ed 100644 --- a/asyncevents/events.py +++ b/asyncevents/events.py @@ -108,23 +108,17 @@ class AsyncEventEmitter: # and runs the handlers in the background await self._check_event(event) temp: List[Tuple[int, float, Callable[["AsyncEventEmitter", str], Coroutine[Any, Any, Any]], bool]] = [] + t: Tuple[int, float, Callable[["AsyncEventEmitter", str], Coroutine[Any, Any, Any]], bool] while self.handlers[event]: # We use heappop because we want the first # by priority and the heap queue only has # the guarantee we need for heap[0] temp.append(heappop(self.handlers[event])) - task = asyncio.create_task(temp[-1][-2](self, event)) - if temp[-1][-1]: - task.add_done_callback( - partial( - # The extra argument is the future asyncio passes us, - # which we don't care about - lambda s, ev, corofunc, _: s.unregister_handler(ev, corofunc), - self, - event, - temp[-1][-2], - ) - ) + t = temp[-1] + if t[-1]: + # It won't be re-scheduled + temp.pop() + task = asyncio.create_task(t[-2](self, event)) self._tasks.append((event, asyncio.create_task(self._handle_errors_in_awaitable(event, task)))) # We push back the elements for t in temp: diff --git a/tests/multiple_handlers_one_event.py b/tests/multiple_handlers_one_event.py new file mode 100644 index 0000000..c056236 --- /dev/null +++ b/tests/multiple_handlers_one_event.py @@ -0,0 +1,22 @@ +import asyncio +from asyncevents import on_event, emit + + +@on_event("test") +async def hello(_, event: str): + print(f"Hello {event!r}!") + + +@on_event("test") +async def hi(_, event: str): + print(f"Hi {event!r}!") + + +async def main(): + print("Firing blocking event 'test'") + await emit("test") + print("Handlers for event 'test' have exited") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/oneshot.py b/tests/oneshot.py index 45ef4b2..b218062 100644 --- a/tests/oneshot.py +++ b/tests/oneshot.py @@ -1,5 +1,5 @@ import asyncio -from asyncevents import on_event, emit +from asyncevents import on_event, emit, wait @on_event("hello", oneshot=True) # The handler is removed after it fires once @@ -7,11 +7,21 @@ async def hello(_, event: str): print(f"Hello {event!r}!") +@on_event("hi", oneshot=True) +async def hi(_, event: str): + print(f"Hi {event!r}!") + + async def main(): print("Firing blocking event 'hello'") await emit("hello") print("Handlers for event 'hello' have exited") await emit("hello") # Nothing happens + print("Firing non-blocking event 'hi'") + await emit("hi", block=False) + await wait("hi") + print("Handlers for event 'hi' have exited") + await emit("hi") # Nothing happens if __name__ == "__main__":