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

1""" 

2 

3 

4 

5---------------------------------------------------------------------------- 

6 

7METADATA: 

8 

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 

16 

17---------------------------------------------------------------------------- 

18 

19LAST MODIFIED: 

20 

212025-03-09 By Jess Mann 

22 

23""" 

24 

25from __future__ import annotations 

26 

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) 

43 

44logger = logging.getLogger(__name__) 

45 

46 

47class QueueType(TypedDict): 

48 """ 

49 A type used by SignalRegistry for storing queued signal actions. 

50 """ 

51 

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]] 

56 

57 

58ActionType = Literal["connect", "disconnect", "disable", "enable"] 

59 

60 

61class SignalPriority: 

62 """ 

63 Priority levels for signal handlers. 

64 

65 Any int can be provided, but these are the recommended values. 

66 """ 

67 

68 FIRST = 0 

69 HIGH = 25 

70 NORMAL = 50 

71 LOW = 75 

72 LAST = 100 

73 

74 

75class SignalParams(TypedDict): 

76 """ 

77 A type used by SignalRegistry for storing signal parameters. 

78 """ 

79 

80 name: str 

81 description: str 

82 

83 

84class Signal[_ReturnType]: 

85 """ 

86 A signal that can be connected to and emitted. 

87 

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 """ 

92 

93 name: str 

94 description: str 

95 _handlers: dict[int, list[Callable[..., _ReturnType]]] 

96 _disabled_handlers: Set[Callable[..., _ReturnType]] 

97 

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__() 

104 

105 def connect(self, handler: Callable[..., _ReturnType], priority: int = SignalPriority.NORMAL) -> None: 

106 """ 

107 Connect a handler to this signal. 

108 

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). 

112 

113 """ 

114 self._handlers[priority].append(handler) 

115 

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) 

119 

120 def disconnect(self, handler: Callable[..., _ReturnType]) -> None: 

121 """ 

122 Disconnect a handler from this signal. 

123 

124 Args: 

125 handler: The handler to disconnect. 

126 

127 """ 

128 for priority in self._handlers: 

129 if handler in self._handlers[priority]: 

130 self._handlers[priority].remove(handler) 

131 

132 @overload 

133 def emit(self, value: _ReturnType | None, *args: Any, **kwargs: Any) -> _ReturnType | None: ... 

134 

135 @overload 

136 def emit(self, **kwargs: Any) -> _ReturnType | None: ... 

137 

138 def emit(self, *args: Any, **kwargs: Any) -> _ReturnType | None: 

139 """ 

140 Emit the signal, calling all connected handlers in priority order. 

141 

142 Each handler receives the output of the previous handler as its first argument. 

143 Other arguments are passed unchanged. 

144 

145 Args: 

146 *args: Positional arguments to pass to handlers. 

147 **kwargs: Keyword arguments to pass to handlers. 

148 

149 Returns: 

150 The final result after all handlers have processed the data. 

151 

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:] 

159 

160 # Get all priorities in ascending order (lower numbers execute first) 

161 priorities = sorted(self._handlers.keys()) 

162 

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) 

169 

170 return current_value 

171 

172 def disable(self, handler: Callable[..., _ReturnType]) -> None: 

173 """ 

174 Temporarily disable a handler without disconnecting it. 

175 

176 Args: 

177 handler: The handler to disable. 

178 

179 """ 

180 self._disabled_handlers.add(handler) 

181 

182 def enable(self, handler: Callable[..., _ReturnType]) -> None: 

183 """ 

184 Re-enable a temporarily disabled handler. 

185 

186 Args: 

187 handler: The handler to enable. 

188 

189 """ 

190 if handler in self._disabled_handlers: 

191 self._disabled_handlers.remove(handler) 

192 

193 

194class SignalRegistry: 

195 """ 

196 Registry of all signals in the application. 

197 

198 Signals can be created, connected to, and emitted through the registry. 

199 

200 Examples: 

201 >>> SignalRegistry.emit( 

202 ... "document.save:success", 

203 ... "Fired when a document has been saved successfully", 

204 ... kwargs = {"document": document} 

205 ... ) 

206 

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 ... ) 

213 

214 >>> SignalRegistry.connect("document.save:success", my_handler) 

215 

216 """ 

217 

218 _instance: Self 

219 _signals: dict[str, Signal] 

220 _queue: QueueType 

221 

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__() 

231 

232 def __new__(cls) -> Self: 

233 """ 

234 Ensure that only one instance of the class is created. 

235 

236 Returns: 

237 The singleton instance of this class. 

238 

239 """ 

240 if not hasattr(cls, "_instance"): 

241 cls._instance = super().__new__(cls) 

242 return cls._instance 

243 

244 @classmethod 

245 def get_instance(cls) -> Self: 

246 """ 

247 Get the singleton instance of this class. 

248 

249 Returns: 

250 The singleton instance of this class. 

251 

252 """ 

253 if not hasattr(cls, "_instance"): 

254 cls._instance = cls() 

255 return cls._instance 

256 

257 def register(self, signal: Signal) -> None: 

258 """ 

259 Register a signal and process queued actions. 

260 

261 Args: 

262 signal: The signal to register. 

263 

264 """ 

265 self._signals[signal.name] = signal 

266 

267 # Process queued connections 

268 for handler, priority in self._queue["connect"].pop(signal.name, set()): 

269 signal.connect(handler, priority) 

270 

271 # Process queued disconnections 

272 for handler in self._queue["disconnect"].pop(signal.name, set()): 

273 signal.disconnect(handler) 

274 

275 # Process queued disables 

276 for handler in self._queue["disable"].pop(signal.name, set()): 

277 signal.disable(handler) 

278 

279 # Process queued enables 

280 for handler in self._queue["enable"].pop(signal.name, set()): 

281 signal.enable(handler) 

282 

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. 

286 

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). 

292 

293 Raises: 

294 ValueError: If the action is invalid. 

295 

296 """ 

297 if action not in self._queue: 

298 raise ValueError(f"Invalid queue action: {action}") 

299 

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) 

307 

308 def get(self, name: str) -> Signal | None: 

309 """ 

310 Get a signal by name. 

311 

312 Args: 

313 name: The signal name. 

314 

315 Returns: 

316 The signal instance, or None if not found. 

317 

318 """ 

319 return self._signals.get(name) 

320 

321 def list_signals(self) -> list[str]: 

322 """ 

323 List all registered signal names. 

324 

325 Returns: 

326 A list of signal names. 

327 

328 """ 

329 return list(self._signals.keys()) 

330 

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. 

334 

335 Args: 

336 name: Signal name 

337 description: Optional description for new signals 

338 return_type: Optional return type for new signals 

339 

340 Returns: 

341 The new signal instance. 

342 

343 """ 

344 signal = Signal[R](name, description) 

345 self.register(signal) 

346 return signal 

347 

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: ... 

358 

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: ... 

369 

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: ... 

380 

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. 

392 

393 Each handler transforms the first argument and passes it to the next handler. 

394 

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 

401 

402 Returns: 

403 The transformed first argument after all handlers have processed it 

404 

405 """ 

406 if not (signal := self.get(name)): 

407 signal = self.create(name, description, return_type) 

408 

409 arg_tuple = (args,) 

410 kwargs = kwargs or {} 

411 return signal.emit(*arg_tuple, **kwargs) 

412 

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. 

416 

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 

421 

422 """ 

423 if signal := self.get(name): 

424 signal.connect(handler, priority) 

425 else: 

426 self.queue_action("connect", name, handler, priority) 

427 

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. 

431 

432 Args: 

433 name: The signal name. 

434 handler: The handler function to disconnect. 

435 

436 """ 

437 if signal := self.get(name): 

438 signal.disconnect(handler) 

439 else: 

440 self.queue_action("disconnect", name, handler) 

441 

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. 

445 

446 Args: 

447 name: The signal name. 

448 handler: The handler function to disable 

449 

450 """ 

451 if signal := self.get(name): 

452 signal.disable(handler) 

453 else: 

454 self.queue_action("disable", name, handler) 

455 

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. 

459 

460 Args: 

461 name: The signal name. 

462 handler: The handler function to enable. 

463 

464 """ 

465 if signal := self.get(name): 

466 signal.enable(handler) 

467 else: 

468 self.queue_action("enable", name, handler) 

469 

470 def is_queued(self, action: ActionType, name: str, handler: Callable) -> bool: 

471 """ 

472 Check if a handler is queued for a signal action. 

473 

474 Args: 

475 action: The action to check (connect, disconnect, disable, enable). 

476 name: The signal name. 

477 handler: The handler function to check. 

478 

479 Returns: 

480 True if the handler is queued, False otherwise. 

481 

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 

491 

492 

493registry = SignalRegistry.get_instance()