Coverage for src/paperap/signals.py: 99%
137 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-18 12:26 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-18 12:26 -0400
1"""
5----------------------------------------------------------------------------
7METADATA:
9File: signals.py
10 Project: paperap
11Created: 2025-03-09
12 Version: 0.0.7
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 # If it's in the disconnect queue, remove it
306 priority = priority if priority is not None else SignalPriority.NORMAL
307 self._queue[action].setdefault(name, set()).add((handler, priority))
308 else:
309 # For non-connect actions, just add the handler without priority
310 self._queue[action].setdefault(name, set()).add(handler)
312 def get(self, name: str) -> Signal | None:
313 """
314 Get a signal by name.
316 Args:
317 name: The signal name.
319 Returns:
320 The signal instance, or None if not found.
322 """
323 return self._signals.get(name)
325 def list_signals(self) -> list[str]:
326 """
327 List all registered signal names.
329 Returns:
330 A list of signal names.
332 """
333 return list(self._signals.keys())
335 def create(self, name: str, description: str = "", return_type: type[_ReturnType] | None = None) -> Signal:
336 """
337 Create and register a new signal.
339 Args:
340 name: Signal name
341 description: Optional description for new signals
342 return_type: Optional return type for new signals
344 Returns:
345 The new signal instance.
347 """
348 signal = Signal[_ReturnType](name, description)
349 self.register(signal)
350 return signal
352 @overload
353 def emit(
354 self,
355 name: str,
356 description: str = "",
357 *,
358 return_type: type[_ReturnType],
359 args: _ReturnType | tuple[_ReturnType, *tuple[Any, ...]] | None = None,
360 kwargs: dict[str, Any] | None = None,
361 ) -> _ReturnType: ...
363 @overload
364 def emit(
365 self,
366 name: str,
367 description: str = "",
368 *,
369 return_type: None = None,
370 args: _ReturnType | tuple[_ReturnType, *tuple[Any, ...]],
371 kwargs: dict[str, Any] | None = None,
372 ) -> _ReturnType: ...
374 @overload
375 def emit(
376 self,
377 name: str,
378 description: str = "",
379 *,
380 return_type: None = None,
381 args: None = None,
382 kwargs: dict[str, Any] | None = None,
383 ) -> None: ...
385 def emit(
386 self,
387 name: str,
388 description: str = "",
389 *,
390 return_type: type[_ReturnType] | None = None,
391 args: _ReturnType | tuple[_ReturnType, *tuple[Any, ...]] | None = None,
392 kwargs: dict[str, Any] | None = None,
393 ) -> _ReturnType | None:
394 """
395 Emit a signal, calling handlers in priority order.
397 Each handler transforms the first argument and passes it to the next handler.
399 Args:
400 name: Signal name
401 description: Optional description for new signals
402 return_type: Optional return type for new signals
403 args: List of positional arguments (first one is transformed through the chain)
404 kwargs: Keyword arguments passed to all handlers
406 Returns:
407 The transformed first argument after all handlers have processed it
409 """
410 if not (signal := self.get(name)):
411 signal = self.create(name, description, return_type)
413 arg_tuple = (args,)
414 kwargs = kwargs or {}
415 return signal.emit(*arg_tuple, **kwargs)
417 def connect(self, name: str, handler: Callable[..., _ReturnType], priority: int = SignalPriority.NORMAL) -> None:
418 """
419 Connect a handler to a signal, or queue it if the signal is not yet registered.
421 Args:
422 name: The signal name.
423 handler: The handler function to connect.
424 priority: The priority level for this handler (lower numbers execute first
426 """
427 if signal := self.get(name):
428 signal.connect(handler, priority)
429 else:
430 self.queue_action("connect", name, handler, priority)
432 def disconnect(self, name: str, handler: Callable[..., _ReturnType]) -> None:
433 """
434 Disconnect a handler from a signal, or queue it if the signal is not yet registered.
436 Args:
437 name: The signal name.
438 handler: The handler function to disconnect.
440 """
441 if signal := self.get(name):
442 signal.disconnect(handler)
443 else:
444 self.queue_action("disconnect", name, handler)
446 def disable(self, name: str, handler: Callable[..., _ReturnType]) -> None:
447 """
448 Temporarily disable a handler for a signal, or queue it if the signal is not yet registered.
450 Args:
451 name: The signal name.
452 handler: The handler function to disable
454 """
455 if signal := self.get(name):
456 signal.disable(handler)
457 else:
458 self.queue_action("disable", name, handler)
460 def enable(self, name: str, handler: Callable[..., _ReturnType]) -> None:
461 """
462 Enable a previously disabled handler, or queue it if the signal is not yet registered.
464 Args:
465 name: The signal name.
466 handler: The handler function to enable.
468 """
469 if signal := self.get(name):
470 signal.enable(handler)
471 else:
472 self.queue_action("enable", name, handler)
474 def is_queued(self, action: ActionType, name: str, handler: Callable[..., _ReturnType]) -> bool:
475 """
476 Check if a handler is queued for a signal action.
478 Args:
479 action: The action to check (connect, disconnect, disable, enable).
480 name: The signal name.
481 handler: The handler function to check.
483 Returns:
484 True if the handler is queued, False otherwise.
486 """
487 for queued_handler in self._queue[action].get(name, set()):
488 if queued_handler == handler:
489 return True
490 return False
493registry = SignalRegistry.get_instance()