Coverage for src / typed_event.py: 100%
88 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-20 21:27 +0100
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-20 21:27 +0100
1"""
2``@event`` decorator for methods, to make them into subscribable events.
4.. default-role:: py:obj
5"""
7__all__ = [
8 "event",
9 "Event",
10 "CancelEvent",
11]
13import logging
14import traceback
15import inspect
16from functools import update_wrapper
17from typing import (
18 Callable,
19 Literal,
20 ParamSpec,
21 Generic,
22 Self,
23 TypeAlias,
24 TypeVar,
25 overload,
26 get_args,
27)
29from weakref import WeakValueDictionary
31# For keeping bound copies. I also tried to make listeners weak-referenced.
32# Turns out that weak-referencing listeners is not that great after all, because
33# it breaks using lambda or inner functions as handlers. They are immediately
34# lost when the defining scope exits.
36T = TypeVar("T")
37P = ParamSpec("P")
38R = TypeVar("R") # return type
40ExceptionPolicy: TypeAlias = Literal["log", "print", "raise", "group"]
43class CancelEvent(Exception):
44 """Raise this in an event handler to inhibit all further processing."""
47class Event(Generic[P, R]):
48 """Notifies a number of "listeners" (functions) when called.
50 The principle is well-known under many names:
52 * Define the event as member of a class that wants to tell the world
53 about changes.
54 * Arbitrary listeners can subscribe the event.
55 * In the class's implementation, the event is called when the trigger
56 condition occurs. Listeners will be called in the order they subscribed.
58 **Defining events**
60 In contrast to other adhoc event systems, this one enforces well-defined
61 signatures and documentation. An event is created by decorating a method
62 (the so-called "prototype") with ``@event``.
64 The ``event`` will take over the method's signature, annotations and
65 docstring. IDE tools and Sphinx documentation should (mostly) "see" the
66 Event like any other method.
68 The prototype method is executed every time the event is triggered. Usually
69 it does not need any code except for a docstring or ``pass`` statement.
71 Restrictions apply:
73 * In ``strict`` mode, only positional-only and/or keyword-only
74 args are allowed. This is to make clear to the user how the arguments
75 will be given (by position or by name).
77 * Yes: ``prototype(a: int, b: str, /)``
78 * Yes: ``prototype(*, a: int, b: str)``
79 * No: ``prototype(a:int, b:str)`` (but allowed in non-strict mode)
81 ``strict`` mode is disabled for backwards compatibility, but will become
82 the default in the future.
84 * Arguments with default values are forbidden, since their meaning would be
85 ambiguous for the user of the class.
86 * The prototype does not get an automatic ``self`` argument. I.e. it works
87 like a ``staticmethod``. You *can* define a ``self`` argument, but it must
88 be given explicitly upon calling.
90 **Listeners**
92 A listener is a Callable whose signature fits the event specification.
93 Event listeners can be subscribed/unsubscribed using the ``+=`` and ``-=``
94 operators. Listener signature is *not* checked at the time of subscription.
96 There is some freedom in listener signature. E.g. you can have extra
97 parameters with default values, or you can catch the event data via
98 ``*args`` / ``**kwargs``.
100 **Triggering the event**
102 The event is triggered by calling the ``event`` instance. Usually this
103 happens within the class containing the Event.
105 First, the wrapped protoype is executed, in order to verify correct arguments.
106 Note that adherence to annotated types is *not* checked, in line with
107 standard Python behavior.
109 Any handler can raise `CancelEvent` to gracefully abort the processing of
110 further listeners.
112 **Return values**
114 At most one listener is expected to return a value. If multiple listeners
115 return a value, an exception is raised.
117 The return type must always be Optional.
119 **Exceptions**
121 Listeners may raise exceptions that are unexpected for the event's origin
122 site. `Event` has the "exceptions" parameter to control how they are
123 handled:
125 * ``"log"`` (default) emits a ``logging.error`` message with the traceback.
126 * ``"print"`` prints the exception (using ``traceback.print_exception``).
127 * ``"raise"`` raises any exception immediately. No subsequent listeners are
128 called.
129 * ``"group"`` calls all listeners, then raises an ``ExceptionGroup`` if any
130 failed. The error is always an ``ExceptionGroup``, even in case of a single
131 error.
133 When using ``raise``, code that triggers an event must be prepared for any
134 exception being thrown at it.
136 `CancelEvent` is obviously exempt from this exception handling.
138 **Unbound/Bound distinction**
140 Analogous to unbound methods, the class will contain the event as "unbound"
141 event. You can in principle subscribe to it, and trigger it using
142 ``Class.event()``. There is only one, global list of subscribers.
144 A class *instance* will have a "bound" copy of the ``Event``, meaning that
145 it has its own list of subscribers independent from all other instances. It
146 does *not* inherit listeners from the unbound event. Typically, the *bound*
147 event is the one you want to subscribe to.
149 Lastly, you can also apply ``@event`` to a module-level function. There will
150 be only one, global list of subscribers, same as for an unbound event.
152 **Example**::
154 # Class definition
155 class MyCounter:
156 @event
157 def counter_changed_to(self, new_value:int):
158 '''Event: counter changed to given value'''
160 def my_timer_function(self):
161 # ...
162 self.counter_changed(123)
163 # ...
165 # User code
166 class MyGUI:
167 def __init__(self, counter_instance:MyCounter):
168 self.counter_instance = counter_instance
169 self.counter_instance.counter_changed_to += self.on_counter_changed
171 def on_counter_changed(self, new_value):
172 self.update_display(new_value)
173 """
175 def __init__(
176 self,
177 prototype: Callable[P, R],
178 strict: bool | None = None,
179 exceptions: ExceptionPolicy = "log",
180 ):
181 self._prototype = prototype
182 self._listeners: list[Callable[P, R | None]] = []
183 # None as default, so that we can discern from excplicit opt-in.
184 # allows to add a warning in the future.
185 self._strict: bool = strict or False
186 if exceptions not in (policies := get_args(ExceptionPolicy)):
187 raise ValueError(f"exceptions must be one of {policies}")
188 self._exceptions = exceptions
190 sig: inspect.Signature = inspect.signature(self._prototype)
191 iP = inspect.Parameter
192 if any(p.default is not iP.empty for p in sig.parameters.values()):
193 raise TypeError("Default values are forbidden for events")
194 if any(
195 p.kind in (iP.VAR_POSITIONAL, iP.VAR_KEYWORD)
196 for p in sig.parameters.values()
197 ):
198 raise TypeError("*args and **kwargs are forbidden for events")
199 if self._strict and any(
200 p.kind == iP.POSITIONAL_OR_KEYWORD for p in sig.parameters.values()
201 ):
202 raise TypeError(
203 "Event arguments must be marked positional-only or keyword-only!"
204 )
205 self._self_arg = "self" in sig.parameters
206 self._argnames = [p.name for p in sig.parameters.values() if p.name != "self"]
207 update_wrapper(self, self._prototype)
209 self._is_bound = False
210 self._bound_copies = WeakValueDictionary()
212 def __get__(self, instance, owner) -> "Event[P, R]":
213 # Copy the event for each instance, so that that each instance
214 # has its private list of listeners.
215 if instance is None:
216 return self
217 key = id(instance)
218 try:
219 return self._bound_copies[key]
220 except KeyError:
221 ev = Event(self._prototype, strict=self._strict)
222 ev._is_bound = True
223 self._bound_copies[key] = ev
224 return ev
226 def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R | None:
227 epolicy = self._exceptions
228 results = []
229 # Call to verify arguments
230 r = self._prototype(*args, **kwargs)
231 if r is not None:
232 raise RuntimeError("Event prototype should not return a value")
233 # === Call each listener ===
234 excs = []
235 for listener in self._listeners:
236 try:
237 r = listener(*args, **kwargs)
238 except CancelEvent:
239 break
240 except Exception as exc:
241 if epolicy == "log":
242 logging.exception(exc)
243 elif epolicy == "print":
244 traceback.print_exception(exc)
245 elif epolicy == "group":
246 excs.append(exc)
247 else:
248 raise
249 if r is not None:
250 results.append(r)
251 if excs:
252 raise ExceptionGroup("One or more listeners raised an error.", excs)
253 if len(results) > 1:
254 raise RuntimeError("Multple return values from event handler")
255 return None if not results else results[0]
257 def __iadd__(self, listener: Callable[P, R | None]) -> Self:
258 # Old handlers are most likely to vanish when new ones are added :-)
259 self._listeners.append(listener)
260 return self
262 def __isub__(self, listener: Callable[P, R | None]) -> Self:
263 self._listeners = [
264 r_listener for r_listener in self._listeners if r_listener is not listener
265 ]
266 return self
268 def __str__(self):
269 names = ", ".join(self._argnames)
270 prefix = "Bound" if self._is_bound else "Unbound"
271 return f"<{prefix} Event {self._prototype.__qualname__}({names})>"
273 __repr__ = __str__
276# Decorator: Allow both @event and @event(params=...) syntax.
279# Used as @event without parens
280@overload
281def event(
282 prototype: Callable[P, R],
283 *,
284 strict: bool | None = None,
285 exceptions: ExceptionPolicy = "log",
286) -> Event[P, R]: ...
289# Used as @event(...)
290@overload
291def event(
292 prototype: None = None,
293 *,
294 strict: bool | None = None,
295 exceptions: ExceptionPolicy = "log",
296) -> Callable[[Callable[P, R]], Event[P, R]]: ...
299def event(
300 prototype: Callable[P, R] | None = None,
301 *,
302 strict: bool | None = None,
303 exceptions: ExceptionPolicy = "log",
304) -> Event[P, R] | Callable[[Callable[P, R]], Event[P, R]]:
305 """Turn the decorated method into an Event.
307 See `Event`. The `@event` decorator allows to pass arguments:
309 @event(strict=False, exeptions="print")
310 def some_event(arg1: bool, /): ...
311 """
312 if prototype is None:
314 def wrap(prototype):
315 return Event(prototype, strict=strict, exceptions=exceptions)
317 return wrap
318 else:
319 return Event(prototype, strict=strict, exceptions=exceptions)