An easy-to-use, robust, thread-safe, and type-safe event system for Python with comprehensive async support.
Project description
Python Event System (PyESys)
A thread-safe, type-safe Python Event SYStem with async support.
PyESys provides a lightweight, dependency-free event/pub-sub framework. It supports:
- Synchronous and asynchronous handlers
- Thread-safe subscribe/unsubscribe/emit
- Weak-reference clean-up of bound-method handlers
- Runtime signature checking (handler arity must match an example signature)
- Custom error handling per handler
- Duplicate-subscription control
- Introspection (
handler_count,handlers) - Class-level and module-level events with decorator syntax
Table of Contents
Installation
Requires Python 3.12+.
Install from PyPI:
pip install pyesys
Or install in editable (development) mode:
git clone https://github.com/fisothemes/pyesys.git
cd pyesys
pip install -e .[dev]
The
[dev]extra installs development-only dependencies (e.g.,pytest,pytest-asyncio,black,sphinx).
Quick Start
Creating an Event with create_event
Each event is defined by an “example” function whose signature specifies the required arguments. Use create_event(...) (or Event.new(...)) to create a new event:
from pyesys.event import create_event
# Example function signature: (int, str) -> None
event_obj, listener = create_event(example=lambda x, y: None)
event_objis the dispatcher (an instance ofEvent).listeneris anEvent.Listenerinterface—uselistener += handlerto subscribe andlistener -= handlerto unsubscribe.
If you prefer a named function:
def example_sig(a: int, b: str) -> None:
pass
event_obj, listener = create_event(example=example_sig)
Creating an Event Directly with Event.new or Event
You may also instantiate an Event directly, which offers the same functionality. For instance:
from pyesys.event import Event
def example_sig(a: int, b: str) -> None:
pass
# Using Event.new() (alias for create_event):
event_obj, listener = Event.new(example=example_sig)
# Equivalent direct instantiation:
msg_event = Event(example=example_sig)
# Subscribe a handler:
def on_message(a: int, b: str) -> None:
print(f"Got {a} and {b}")
msg_event.Listener += on_message
# Emit:
msg_event.emit(1, "hello")
Here, msg_event.Listener is used to manage subscriptions, and msg_event.emit(...) dispatches to all subscribed handlers.
Subscribing & Emitting (Sync)
Handlers must accept the same parameters as the example and return None:
def on_data(x: int, name: str) -> None:
print(f"Received: {x}, {name}")
# Subscribe with +=
listener += on_data
# Emit synchronously (blocks until all handlers finish)
event_obj.emit(42, "hello")
# prints: Received: 42, hello
# Unsubscribe with -=
listener -= on_data
Duplicate-subscription control
By default, allow_duplicates=True, so adding the same handler twice invokes it twice on each emit. To prevent duplicates:
event_obj, listener = create_event(example=lambda x: None, allow_duplicates=False)
Subscribing & Emitting (Async)
Call emit_async(...) to dispatch all handlers concurrently:
async defhandlers are awaited directly.- Sync handlers run in a thread pool.
import asyncio
from pyesys.event import create_event
# Example signature: (int,) -> None
event_obj, listener = create_event(example=lambda x: None)
async def async_handler(v: int) -> None:
await asyncio.sleep(0.1)
print(f"Async got: {v}")
def sync_handler(v: int) -> None:
print(f"Sync got: {v}")
listener += async_handler
listener += sync_handler
async def main():
await event_obj.emit_async(10)
# prints in some order:
# Sync got: 10
# Async got: 10
asyncio.run(main())
If there are no handlers, emit_async(...) returns immediately without error.
Class-Level & Module-Level Events with @event
PyESys includes an @event decorator (in pyesys.prop) for defining events on classes or at module level. Each instance or module gets its own event, managed automatically:
Class-level events
from pyesys.prop import event
# Class-level event (per-instance)
class Button:
@event
def on_click(self):
"""Event signature definition (no parameters besides self)."""
pass
@on_click.emitter
def click(self):
"""This method is an emitter—after running, it fires `on_click`."""
print(f"Button {id(self)} was clicked!")
class Counter:
def __init__(self):
self.count = 0
def increment(self):
"""Signature matches `on_click` (no extra args)."""
self.count += 1
print(f"Count = {self.count}")
# Usage
counter = Counter()
button = Button()
# Subscribe the counter’s `increment` method to the button’s `on_click` event
button.on_click += counter.increment
# Trigger the emitter
button.click()
# prints: Button 140280689482800 was clicked!
# prints: Count = 1
Module-level events
# Module-level event (global)
from pyesys.prop import event
@event
def on_global_event(message: str):
"""Global event signature: handlers must accept a single str."""
pass
@on_global_event.emitter
def trigger_global(message: str):
"""This function triggers the module-level event after running."""
print(f"Global: {message}")
# Usage
def global_handler(msg: str) -> None:
print(f"Handled globally: {msg}")
on_global_event += global_handler
trigger_global("Hello, world!")
# prints: Global: Hello, world!
# prints: Handled globally: Hello, world!
Key features of @event:
- Automatic signature detection: Event signature is derived from the decorated function/method.
- Per-instance vs global: Class methods create per-instance events; module functions create a single global event.
- Emitter decorator: Use
@event_name.emitterto define methods/functions that automatically fire the event after their body. - Mixed sync/async support: Both synchronous and asynchronous handlers are supported seamlessly.
- Thread safety: Async handlers are dispatched in a background thread when emitted from a sync context.
API Overview
EventHandler
Located in pyesys.handler. Wraps a callable handler to:
- Detect bound methods vs. free functions
- Store a weak reference to the instance (for bound methods)
- Provide
is_alive()to check if the instance is still alive - Implement
__eq__/__hash__so duplicate detection works correctly - Expose
get_callback()to reconstruct a live callable
Most users will not instantiate EventHandler directly; it’s used internally by Event.
Event
Located in pyesys.event. Core class that manages subscriptions and dispatch:
class Event:
def __init__(
self,
*,
allow_duplicates: bool = True,
error_handler: Optional[ErrorHandler] = None
):
...
-
Constructor arguments:
allow_duplicates: IfFalse, the same handler won’t be added twice.error_handler: A callable(exception, handler)used when any handler raises.
Core methods & properties
-
Listener(anEvent.Listener):- Subscribe with
Listener += handler - Unsubscribe with
Listener -= handler - Query with
Listener.handler_count()
- Subscribe with
-
emit(*args, **kwargs)- Synchronously invoke all live handlers under a lock.
- Exceptions in each handler are routed to
error_handlerwithout stopping other handlers. - Async handlers (coroutines) returned in sync emit are silently closed (ignored).
-
async emit_async(*args, **kwargs)-
Dispatch all handlers concurrently:
async defhandlers are awaited.- Sync handlers run via
loop.run_in_executor.
-
All exceptions are caught and passed to
error_handler.
-
-
clear()- Remove all handlers (dropping subscriptions).
-
handler_count()(method)- Returns the number of currently alive handlers, pruning any dead bound methods.
-
handlers(property)- Returns a list of live callables (reconstructed via
EventHandler.get_callback()). - Mutating this returned list does not affect the internal state.
- Returns a list of live callables (reconstructed via
-
__bool__()/__len__()- Boolean truth is
Trueif there is at least one live handler. len(event_obj)returnshandler_count().
- Boolean truth is
-
new(example, *, allow_duplicates, error_handler) → (Event, Event.Listener)-
Factory method (alias:
create_event). -
Example usage:
event_obj, listener = Event.new( example = lambda x, y: None, allow_duplicates = False, error_handler = custom_handler )
-
create_event
A convenience alias for Event.new(...). Import from:
from pyesys.event import create_event
event_obj, listener = create_event(
example = lambda a, b: None,
allow_duplicates = True
)
@event Decorator
Located in pyesys.prop. Creates class-level or module-level events using decorator syntax:
from pyesys.prop import event
# Class-level event (per-instance)
class MyClass:
@event
def on_something(self, value: int):
"""Event signature: handlers must accept a single int."""
pass
@on_something.emitter
def do_something(self, value: int):
"""This method triggers the on_something event after running."""
print(f"Doing something with {value}")
# Module-level event (global)
@event
def on_global_event(message: str):
"""Global event signature: handlers must accept a single str."""
pass
@on_global_event.emitter
def trigger_global(message: str):
"""This function triggers the module-level event after running."""
print(f"Global: {message}")
- Automatic signature detection: Event signature is derived from the decorated function/method.
- Per-instance vs global: Class methods create per-instance events; module functions create a single global event.
- Emitter decorator: Use
@event_name.emitterto define methods/functions that automatically fire the event after their body. - Mixed sync/async support: Both synchronous and asynchronous handlers are supported seamlessly.
- Thread safety: Async handlers are dispatched in a background thread when emitted from a sync context.
Testing
PyESys uses pytest and pytest-asyncio. To install dev dependencies and run the test suite:
pip install -e .[dev]
pytest -q
Test files live under tests/:
tests/unit/test_handler.py– tests forEventHandlertests/unit/test_event.py– tests forEvent(sync & async)tests/unit/test_prop.py– tests for the@eventdecoratortests/integration/test_pyesys_end_to_end.py– end-to-end integration tests
All tests must pass before merging any changes.
Contributing
See CONTRIBUTING.md for details on:
- Setting up a development environment
- Branching and workflow conventions
- Coding style & formatting (PEP 8, Black, type hints)
- Writing tests and running them
- Submitting pull requests
Licence
This project is licenced under the MIT Licence. See LICENSE for details.
© 2025 Goodwill Mzumala
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyesys-0.1.0.tar.gz.
File metadata
- Download URL: pyesys-0.1.0.tar.gz
- Upload date:
- Size: 29.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: python-requests/2.32.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
21e119088ac531ca59fb79dd8101c10454ffbf7512476a4e63fd14f52fe5d19c
|
|
| MD5 |
0b47bb8950637099dfb6a22480d518e4
|
|
| BLAKE2b-256 |
eaa36fb28401acc44f2bfd26ffbaee71f961cbf7006dafbf343b4bc388875d6b
|
File details
Details for the file pyesys-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pyesys-0.1.0-py3-none-any.whl
- Upload date:
- Size: 16.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: python-requests/2.32.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1b9b369e7d252288057dc1d50eff7523f4802ec628bdfe1df1a24238148dd7b5
|
|
| MD5 |
f6cf90ffc084cae61a069a6cc0125cd2
|
|
| BLAKE2b-256 |
fac3d10fe7047a11f2b995643be510f1b5663a9a43e9c532039f038aab0b160d
|