Coverage for src/paperap/signals.py: 89%
134 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-11 21:37 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-11 21:37 -0400
1"""
5----------------------------------------------------------------------------
7METADATA:
9File: signals.py
10 Project: paperap
11Created: 2025-03-09
12 Version: 0.0.5
13Author: Jess Mann
14Email: jess@jmann.me
15 Copyright (c) 2025 Jess Mann
17----------------------------------------------------------------------------
19LAST MODIFIED:
212025-03-09 By Jess Mann
23"""
25from __future__ import annotations
27import logging
28from collections import defaultdict
29from typing import (
30 Any,
31 Callable,
32 Generic,
33 Iterable,
34 Literal,
35 Optional,
36 Self,
37 Set,
38 TypeAlias,
39 TypedDict,
40 TypeVar,
41 overload,
42)
44_ReturnType = TypeVar("_ReturnType") # pylint: disable=invalid-name
46logger = logging.getLogger(__name__)
49class QueueType(TypedDict):
50 """
51 A type used by SignalRegistry for storing queued signal actions.
52 """
54 connect: dict[str, set[tuple[Callable, int]]]
55 disconnect: dict[str, set[Callable]]
56 disable: dict[str, set[Callable]]
57 enable: dict[str, set[Callable]]
60ActionType = Literal["connect", "disconnect", "disable", "enable"]
63class SignalPriority:
64 """
65 Priority levels for signal handlers.
67 Any int can be provided, but these are the recommended values.
68 """
70 FIRST = 0
71 HIGH = 25
72 NORMAL = 50
73 LOW = 75
74 LAST = 100
77class SignalParams(TypedDict):
78 """
79 A type used by SignalRegistry for storing signal parameters.
80 """
82 name: str
83 description: str
86class Signal(Generic[_ReturnType]):
87 """
88 A signal that can be connected to and emitted.
90 Handlers can be registered with a priority to control execution order.
91 Each handler receives the output of the previous handler as its first argument,
92 enabling a filter/transformation chain.
93 """
95 name: str
96 description: str
97 _handlers: dict[int, list[Callable[..., _ReturnType]]]
98 _disabled_handlers: Set[Callable[..., _ReturnType]]
100 def __init__(self, name: str, description: str = "") -> None:
101 self.name = name
102 self.description = description
103 self._handlers = defaultdict(list)
104 self._disabled_handlers = set()
105 super().__init__()
107 def connect(self, handler: Callable[..., _ReturnType], priority: int = SignalPriority.NORMAL) -> None:
108 """
109 Connect a handler to this signal.
111 Args:
112 handler: The handler function to be called when the signal is emitted.
113 priority: The priority level for this handler (lower numbers execute first).
115 """
116 self._handlers[priority].append(handler)
118 # Check if the handler was temporarily disabled in the registry
119 if SignalRegistry.get_instance().is_queued("disable", self.name, handler):
120 self._disabled_handlers.add(handler)
122 def disconnect(self, handler: Callable[..., _ReturnType]) -> None:
123 """
124 Disconnect a handler from this signal.
126 Args:
127 handler: The handler to disconnect.
129 """
130 for priority in self._handlers:
131 if handler in self._handlers[priority]:
132 self._handlers[priority].remove(handler)
134 @overload
135 def emit(self, value: _ReturnType | None, *args: Any, **kwargs: Any) -> _ReturnType | None: ...
137 @overload
138 def emit(self, **kwargs: Any) -> _ReturnType | None: ...
140 def emit(self, *args: Any, **kwargs: Any) -> _ReturnType | None:
141 """
142 Emit the signal, calling all connected handlers in priority order.
144 Each handler receives the output of the previous handler as its first argument.
145 Other arguments are passed unchanged.
147 Args:
148 *args: Positional arguments to pass to handlers.
149 **kwargs: Keyword arguments to pass to handlers.
151 Returns:
152 The final result after all handlers have processed the data.
154 """
155 current_value: _ReturnType | None = None
156 remaining_args = args
157 if args:
158 # Start with the first argument as the initial value
159 current_value = args[0]
160 remaining_args = args[1:]
162 # Get all priorities in ascending order (lower numbers execute first)
163 priorities = sorted(self._handlers.keys())
165 # Process handlers in priority order
166 for priority in priorities:
167 for handler in self._handlers[priority]:
168 if handler not in self._disabled_handlers:
169 # Pass the current value as the first argument, along with any other args
170 current_value = handler(current_value, *remaining_args, **kwargs)
172 return current_value
174 def disable(self, handler: Callable[..., _ReturnType]) -> None:
175 """
176 Temporarily disable a handler without disconnecting it.
178 Args:
179 handler: The handler to disable.
181 """
182 self._disabled_handlers.add(handler)
184 def enable(self, handler: Callable[..., _ReturnType]) -> None:
185 """
186 Re-enable a temporarily disabled handler.
188 Args:
189 handler: The handler to enable.
191 """
192 if handler in self._disabled_handlers:
193 self._disabled_handlers.remove(handler)
196class SignalRegistry:
197 """
198 Registry of all signals in the application.
200 Signals can be created, connected to, and emitted through the registry.
202 Examples:
203 >>> SignalRegistry.emit(
204 ... "document.save:success",
205 ... "Fired when a document has been saved successfully",
206 ... kwargs = {"document": document}
207 ... )
209 >>> filtered_data = SignalRegistry.emit(
210 ... "document.save:before",
211 ... "Fired before a document is saved. Optionally filters the data that will be saved.",
212 ... args = (data,),
213 ... kwargs = {"document": document}
214 ... )
216 >>> SignalRegistry.connect("document.save:success", my_handler)
218 """
220 _instance: Self
221 _signals: dict[str, Signal]
222 _queue: QueueType
224 def __init__(self) -> None:
225 self._signals = {}
226 self._queue = {
227 "connect": {}, # {signal_name: {(handler, priority), ...}}
228 "disconnect": {}, # {signal_name: {handler, ...}}
229 "disable": {}, # {signal_name: {handler, ...}}
230 "enable": {}, # {signal_name: {handler, ...}}
231 }
232 super().__init__()
234 def __new__(cls) -> Self:
235 """
236 Ensure that only one instance of the class is created.
238 Returns:
239 The singleton instance of this class.
241 """
242 if not hasattr(cls, "_instance"):
243 cls._instance = super().__new__(cls)
244 return cls._instance
246 @classmethod
247 def get_instance(cls) -> Self:
248 """
249 Get the singleton instance of this class.
251 Returns:
252 The singleton instance of this class.
254 """
255 if not hasattr(cls, "_instance"):
256 cls._instance = cls()
257 return cls._instance
259 def register(self, signal: Signal) -> None:
260 """
261 Register a signal and process queued actions.
263 Args:
264 signal: The signal to register.
266 """
267 self._signals[signal.name] = signal
269 # Process queued connections
270 for handler, priority in self._queue["connect"].pop(signal.name, set()):
271 signal.connect(handler, priority)
273 # Process queued disconnections
274 for handler in self._queue["disconnect"].pop(signal.name, set()):
275 signal.disconnect(handler)
277 # Process queued disables
278 for handler in self._queue["disable"].pop(signal.name, set()):
279 signal.disable(handler)
281 # Process queued enables
282 for handler in self._queue["enable"].pop(signal.name, set()):
283 signal.enable(handler)
285 def queue_action(
286 self, action: ActionType, name: str, handler: Callable[..., _ReturnType], priority: int | None = None
287 ) -> None:
288 """
289 Queue any signal-related action to be processed when the signal is registered.
291 Args:
292 action: The action to queue (connect, disconnect, disable, enable).
293 name: The signal name.
294 handler: The handler function to queue.
295 priority: The priority level for this handler (only for connect action).
297 Raises:
298 ValueError: If the action is invalid.
300 """
301 if action not in self._queue:
302 raise ValueError(f"Invalid queue action: {action}")
304 if action == "connect":
305 priority = priority if priority is not None else SignalPriority.NORMAL
306 self._queue[action].setdefault(name, set()).add((handler, priority))
307 else:
308 self._queue[action].setdefault(name, set()).add(handler)
310 def get(self, name: str) -> Signal | None:
311 """
312 Get a signal by name.
314 Args:
315 name: The signal name.
317 Returns:
318 The signal instance, or None if not found.
320 """
321 return self._signals.get(name)
323 def list_signals(self) -> list[str]:
324 """
325 List all registered signal names.
327 Returns:
328 A list of signal names.
330 """
331 return list(self._signals.keys())
333 def create(self, name: str, description: str = "", return_type: type[_ReturnType] | None = None) -> Signal:
334 """
335 Create and register a new signal.
337 Args:
338 name: Signal name
339 description: Optional description for new signals
340 return_type: Optional return type for new signals
342 Returns:
343 The new signal instance.
345 """
346 signal = Signal[_ReturnType](name, description)
347 self.register(signal)
348 return signal
350 @overload
351 def emit(
352 self,
353 name: str,
354 description: str = "",
355 *,
356 return_type: type[_ReturnType],
357 args: _ReturnType | tuple[_ReturnType, *tuple[Any, ...]] | None = None,
358 kwargs: dict[str, Any] | None = None,
359 ) -> _ReturnType: ...
361 @overload
362 def emit(
363 self,
364 name: str,
365 description: str = "",
366 *,
367 return_type: None = None,
368 args: _ReturnType | tuple[_ReturnType, *tuple[Any, ...]],
369 kwargs: dict[str, Any] | None = None,
370 ) -> _ReturnType: ...
372 @overload
373 def emit(
374 self,
375 name: str,
376 description: str = "",
377 *,
378 return_type: None = None,
379 args: None = None,
380 kwargs: dict[str, Any] | None = None,
381 ) -> None: ...
383 def emit(
384 self,
385 name: str,
386 description: str = "",
387 *,
388 return_type: type[_ReturnType] | None = None,
389 args: _ReturnType | tuple[_ReturnType, *tuple[Any, ...]] | None = None,
390 kwargs: dict[str, Any] | None = None,
391 ) -> _ReturnType | None:
392 """
393 Emit a signal, calling handlers in priority order.
395 Each handler transforms the first argument and passes it to the next handler.
397 Args:
398 name: Signal name
399 description: Optional description for new signals
400 return_type: Optional return type for new signals
401 args: List of positional arguments (first one is transformed through the chain)
402 kwargs: Keyword arguments passed to all handlers
404 Returns:
405 The transformed first argument after all handlers have processed it
407 """
408 if not (signal := self.get(name)):
409 signal = self.create(name, description, return_type)
411 arg_tuple = (args,)
412 kwargs = kwargs or {}
413 return signal.emit(*arg_tuple, **kwargs)
415 def connect(self, name: str, handler: Callable[..., _ReturnType], priority: int = SignalPriority.NORMAL) -> None:
416 """
417 Connect a handler to a signal, or queue it if the signal is not yet registered.
419 Args:
420 name: The signal name.
421 handler: The handler function to connect.
422 priority: The priority level for this handler (lower numbers execute first
424 """
425 if signal := self.get(name):
426 signal.connect(handler, priority)
427 else:
428 self.queue_action("connect", name, handler, priority)
430 def disconnect(self, name: str, handler: Callable[..., _ReturnType]) -> None:
431 """
432 Disconnect a handler from a signal, or queue it if the signal is not yet registered.
434 Args:
435 name: The signal name.
436 handler: The handler function to disconnect.
438 """
439 if signal := self.get(name):
440 signal.disconnect(handler)
441 else:
442 self.queue_action("disconnect", name, handler)
444 def disable(self, name: str, handler: Callable[..., _ReturnType]) -> None:
445 """
446 Temporarily disable a handler for a signal, or queue it if the signal is not yet registered.
448 Args:
449 name: The signal name.
450 handler: The handler function to disable
452 """
453 if signal := self.get(name):
454 signal.disable(handler)
455 else:
456 self.queue_action("disable", name, handler)
458 def enable(self, name: str, handler: Callable[..., _ReturnType]) -> None:
459 """
460 Enable a previously disabled handler, or queue it if the signal is not yet registered.
462 Args:
463 name: The signal name.
464 handler: The handler function to enable.
466 """
467 if signal := self.get(name):
468 signal.enable(handler)
469 else:
470 self.queue_action("enable", name, handler)
472 def is_queued(self, action: ActionType, name: str, handler: Callable[..., _ReturnType]) -> bool:
473 """
474 Check if a handler is queued for a signal action.
476 Args:
477 action: The action to check (connect, disconnect, disable, enable).
478 name: The signal name.
479 handler: The handler function to check.
481 Returns:
482 True if the handler is queued, False otherwise.
484 """
485 return handler in self._queue[action].get(name, set())
487registry = SignalRegistry.get_instance()