Coverage for src/paperap/signals.py: 97%
139 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-20 13:17 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-20 13:17 -0400
1"""
5----------------------------------------------------------------------------
7METADATA:
9File: signals.py
10 Project: paperap
11Created: 2025-03-09
12 Version: 0.0.8
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)
44logger = logging.getLogger(__name__)
47class QueueType(TypedDict):
48 """
49 A type used by SignalRegistry for storing queued signal actions.
50 """
52 connect: dict[str, set[tuple[Callable, int]]]
53 disconnect: dict[str, set[Callable]]
54 disable: dict[str, set[Callable]]
55 enable: dict[str, set[Callable]]
58ActionType = Literal["connect", "disconnect", "disable", "enable"]
61class SignalPriority:
62 """
63 Priority levels for signal handlers.
65 Any int can be provided, but these are the recommended values.
66 """
68 FIRST = 0
69 HIGH = 25
70 NORMAL = 50
71 LOW = 75
72 LAST = 100
75class SignalParams(TypedDict):
76 """
77 A type used by SignalRegistry for storing signal parameters.
78 """
80 name: str
81 description: str
84class Signal[_ReturnType]:
85 """
86 A signal that can be connected to and emitted.
88 Handlers can be registered with a priority to control execution order.
89 Each handler receives the output of the previous handler as its first argument,
90 enabling a filter/transformation chain.
91 """
93 name: str
94 description: str
95 _handlers: dict[int, list[Callable[..., _ReturnType]]]
96 _disabled_handlers: Set[Callable[..., _ReturnType]]
98 def __init__(self, name: str, description: str = "") -> None:
99 self.name = name
100 self.description = description
101 self._handlers = defaultdict(list)
102 self._disabled_handlers = set()
103 super().__init__()
105 def connect(self, handler: Callable[..., _ReturnType], priority: int = SignalPriority.NORMAL) -> None:
106 """
107 Connect a handler to this signal.
109 Args:
110 handler: The handler function to be called when the signal is emitted.
111 priority: The priority level for this handler (lower numbers execute first).
113 """
114 self._handlers[priority].append(handler)
116 # Check if the handler was temporarily disabled in the registry
117 if SignalRegistry.get_instance().is_queued("disable", self.name, handler):
118 self._disabled_handlers.add(handler)
120 def disconnect(self, handler: Callable[..., _ReturnType]) -> None:
121 """
122 Disconnect a handler from this signal.
124 Args:
125 handler: The handler to disconnect.
127 """
128 for priority in self._handlers:
129 if handler in self._handlers[priority]:
130 self._handlers[priority].remove(handler)
132 @overload
133 def emit(self, value: _ReturnType | None, *args: Any, **kwargs: Any) -> _ReturnType | None: ...
135 @overload
136 def emit(self, **kwargs: Any) -> _ReturnType | None: ...
138 def emit(self, *args: Any, **kwargs: Any) -> _ReturnType | None:
139 """
140 Emit the signal, calling all connected handlers in priority order.
142 Each handler receives the output of the previous handler as its first argument.
143 Other arguments are passed unchanged.
145 Args:
146 *args: Positional arguments to pass to handlers.
147 **kwargs: Keyword arguments to pass to handlers.
149 Returns:
150 The final result after all handlers have processed the data.
152 """
153 current_value: _ReturnType | None = None
154 remaining_args = args
155 if args:
156 # Start with the first argument as the initial value
157 current_value = args[0]
158 remaining_args = args[1:]
160 # Get all priorities in ascending order (lower numbers execute first)
161 priorities = sorted(self._handlers.keys())
163 # Process handlers in priority order
164 for priority in priorities:
165 for handler in self._handlers[priority]:
166 if handler not in self._disabled_handlers:
167 # Pass the current value as the first argument, along with any other args
168 current_value = handler(current_value, *remaining_args, **kwargs)
170 return current_value
172 def disable(self, handler: Callable[..., _ReturnType]) -> None:
173 """
174 Temporarily disable a handler without disconnecting it.
176 Args:
177 handler: The handler to disable.
179 """
180 self._disabled_handlers.add(handler)
182 def enable(self, handler: Callable[..., _ReturnType]) -> None:
183 """
184 Re-enable a temporarily disabled handler.
186 Args:
187 handler: The handler to enable.
189 """
190 if handler in self._disabled_handlers:
191 self._disabled_handlers.remove(handler)
194class SignalRegistry:
195 """
196 Registry of all signals in the application.
198 Signals can be created, connected to, and emitted through the registry.
200 Examples:
201 >>> SignalRegistry.emit(
202 ... "document.save:success",
203 ... "Fired when a document has been saved successfully",
204 ... kwargs = {"document": document}
205 ... )
207 >>> filtered_data = SignalRegistry.emit(
208 ... "document.save:before",
209 ... "Fired before a document is saved. Optionally filters the data that will be saved.",
210 ... args = (data,),
211 ... kwargs = {"document": document}
212 ... )
214 >>> SignalRegistry.connect("document.save:success", my_handler)
216 """
218 _instance: Self
219 _signals: dict[str, Signal]
220 _queue: QueueType
222 def __init__(self) -> None:
223 self._signals = {}
224 self._queue = {
225 "connect": {}, # {signal_name: {(handler, priority), ...}}
226 "disconnect": {}, # {signal_name: {handler, ...}}
227 "disable": {}, # {signal_name: {handler, ...}}
228 "enable": {}, # {signal_name: {handler, ...}}
229 }
230 super().__init__()
232 def __new__(cls) -> Self:
233 """
234 Ensure that only one instance of the class is created.
236 Returns:
237 The singleton instance of this class.
239 """
240 if not hasattr(cls, "_instance"):
241 cls._instance = super().__new__(cls)
242 return cls._instance
244 @classmethod
245 def get_instance(cls) -> Self:
246 """
247 Get the singleton instance of this class.
249 Returns:
250 The singleton instance of this class.
252 """
253 if not hasattr(cls, "_instance"):
254 cls._instance = cls()
255 return cls._instance
257 def register(self, signal: Signal) -> None:
258 """
259 Register a signal and process queued actions.
261 Args:
262 signal: The signal to register.
264 """
265 self._signals[signal.name] = signal
267 # Process queued connections
268 for handler, priority in self._queue["connect"].pop(signal.name, set()):
269 signal.connect(handler, priority)
271 # Process queued disconnections
272 for handler in self._queue["disconnect"].pop(signal.name, set()):
273 signal.disconnect(handler)
275 # Process queued disables
276 for handler in self._queue["disable"].pop(signal.name, set()):
277 signal.disable(handler)
279 # Process queued enables
280 for handler in self._queue["enable"].pop(signal.name, set()):
281 signal.enable(handler)
283 def queue_action(self, action: ActionType, name: str, handler: Callable, priority: int | None = None) -> None:
284 """
285 Queue any signal-related action to be processed when the signal is registered.
287 Args:
288 action: The action to queue (connect, disconnect, disable, enable).
289 name: The signal name.
290 handler: The handler function to queue.
291 priority: The priority level for this handler (only for connect action).
293 Raises:
294 ValueError: If the action is invalid.
296 """
297 if action not in self._queue:
298 raise ValueError(f"Invalid queue action: {action}")
300 if action == "connect":
301 # If it's in the disconnect queue, remove it
302 priority = priority if priority is not None else SignalPriority.NORMAL
303 self._queue[action].setdefault(name, set()).add((handler, priority))
304 else:
305 # For non-connect actions, just add the handler without priority
306 self._queue[action].setdefault(name, set()).add(handler)
308 def get(self, name: str) -> Signal | None:
309 """
310 Get a signal by name.
312 Args:
313 name: The signal name.
315 Returns:
316 The signal instance, or None if not found.
318 """
319 return self._signals.get(name)
321 def list_signals(self) -> list[str]:
322 """
323 List all registered signal names.
325 Returns:
326 A list of signal names.
328 """
329 return list(self._signals.keys())
331 def create[R](self, name: str, description: str = "", return_type: type[R] | None = None) -> Signal[R]:
332 """
333 Create and register a new signal.
335 Args:
336 name: Signal name
337 description: Optional description for new signals
338 return_type: Optional return type for new signals
340 Returns:
341 The new signal instance.
343 """
344 signal = Signal[R](name, description)
345 self.register(signal)
346 return signal
348 @overload
349 def emit[_ReturnType](
350 self,
351 name: str,
352 description: str = "",
353 *,
354 return_type: type[_ReturnType],
355 args: _ReturnType | None = None,
356 kwargs: dict[str, Any] | None = None,
357 ) -> _ReturnType: ...
359 @overload
360 def emit[_ReturnType](
361 self,
362 name: str,
363 description: str = "",
364 *,
365 return_type: None = None,
366 args: _ReturnType,
367 kwargs: dict[str, Any] | None = None,
368 ) -> _ReturnType: ...
370 @overload
371 def emit(
372 self,
373 name: str,
374 description: str = "",
375 *,
376 return_type: None = None,
377 args: None = None,
378 kwargs: dict[str, Any] | None = None,
379 ) -> None: ...
381 def emit[_ReturnType](
382 self,
383 name: str,
384 description: str = "",
385 *,
386 return_type: type[_ReturnType] | None = None,
387 args: _ReturnType | None = None,
388 kwargs: dict[str, Any] | None = None,
389 ) -> _ReturnType | None:
390 """
391 Emit a signal, calling handlers in priority order.
393 Each handler transforms the first argument and passes it to the next handler.
395 Args:
396 name: Signal name
397 description: Optional description for new signals
398 return_type: Optional return type for new signals
399 args: List of positional arguments (first one is transformed through the chain)
400 kwargs: Keyword arguments passed to all handlers
402 Returns:
403 The transformed first argument after all handlers have processed it
405 """
406 if not (signal := self.get(name)):
407 signal = self.create(name, description, return_type)
409 arg_tuple = (args,)
410 kwargs = kwargs or {}
411 return signal.emit(*arg_tuple, **kwargs)
413 def connect(self, name: str, handler: Callable, priority: int = SignalPriority.NORMAL) -> None:
414 """
415 Connect a handler to a signal, or queue it if the signal is not yet registered.
417 Args:
418 name: The signal name.
419 handler: The handler function to connect.
420 priority: The priority level for this handler (lower numbers execute first
422 """
423 if signal := self.get(name):
424 signal.connect(handler, priority)
425 else:
426 self.queue_action("connect", name, handler, priority)
428 def disconnect(self, name: str, handler: Callable) -> None:
429 """
430 Disconnect a handler from a signal, or queue it if the signal is not yet registered.
432 Args:
433 name: The signal name.
434 handler: The handler function to disconnect.
436 """
437 if signal := self.get(name):
438 signal.disconnect(handler)
439 else:
440 self.queue_action("disconnect", name, handler)
442 def disable(self, name: str, handler: Callable) -> None:
443 """
444 Temporarily disable a handler for a signal, or queue it if the signal is not yet registered.
446 Args:
447 name: The signal name.
448 handler: The handler function to disable
450 """
451 if signal := self.get(name):
452 signal.disable(handler)
453 else:
454 self.queue_action("disable", name, handler)
456 def enable(self, name: str, handler: Callable) -> None:
457 """
458 Enable a previously disabled handler, or queue it if the signal is not yet registered.
460 Args:
461 name: The signal name.
462 handler: The handler function to enable.
464 """
465 if signal := self.get(name):
466 signal.enable(handler)
467 else:
468 self.queue_action("enable", name, handler)
470 def is_queued(self, action: ActionType, name: str, handler: Callable) -> bool:
471 """
472 Check if a handler is queued for a signal action.
474 Args:
475 action: The action to check (connect, disconnect, disable, enable).
476 name: The signal name.
477 handler: The handler function to check.
479 Returns:
480 True if the handler is queued, False otherwise.
482 """
483 for queued_handler in self._queue[action].get(name, set()):
484 # Handle "connect" case where queued_handler is a tuple (handler, priority)
485 if isinstance(queued_handler, tuple):
486 if queued_handler[0] == handler:
487 return True
488 elif queued_handler == handler:
489 return True
490 return False
493registry = SignalRegistry.get_instance()