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

1""" 

2 

3 

4 

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

6 

7METADATA: 

8 

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 

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 

44_ReturnType = TypeVar("_ReturnType") # pylint: disable=invalid-name 

45 

46logger = logging.getLogger(__name__) 

47 

48 

49class QueueType(TypedDict): 

50 """ 

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

52 """ 

53 

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

58 

59 

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

61 

62 

63class SignalPriority: 

64 """ 

65 Priority levels for signal handlers. 

66 

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

68 """ 

69 

70 FIRST = 0 

71 HIGH = 25 

72 NORMAL = 50 

73 LOW = 75 

74 LAST = 100 

75 

76 

77class SignalParams(TypedDict): 

78 """ 

79 A type used by SignalRegistry for storing signal parameters. 

80 """ 

81 

82 name: str 

83 description: str 

84 

85 

86class Signal(Generic[_ReturnType]): 

87 """ 

88 A signal that can be connected to and emitted. 

89 

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

94 

95 name: str 

96 description: str 

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

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

99 

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

106 

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

108 """ 

109 Connect a handler to this signal. 

110 

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

114 

115 """ 

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

117 

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) 

121 

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

123 """ 

124 Disconnect a handler from this signal. 

125 

126 Args: 

127 handler: The handler to disconnect. 

128 

129 """ 

130 for priority in self._handlers: 

131 if handler in self._handlers[priority]: 

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

133 

134 @overload 

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

136 

137 @overload 

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

139 

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

141 """ 

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

143 

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

145 Other arguments are passed unchanged. 

146 

147 Args: 

148 *args: Positional arguments to pass to handlers. 

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

150 

151 Returns: 

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

153 

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

161 

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

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

164 

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) 

171 

172 return current_value 

173 

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

175 """ 

176 Temporarily disable a handler without disconnecting it. 

177 

178 Args: 

179 handler: The handler to disable. 

180 

181 """ 

182 self._disabled_handlers.add(handler) 

183 

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

185 """ 

186 Re-enable a temporarily disabled handler. 

187 

188 Args: 

189 handler: The handler to enable. 

190 

191 """ 

192 if handler in self._disabled_handlers: 

193 self._disabled_handlers.remove(handler) 

194 

195 

196class SignalRegistry: 

197 """ 

198 Registry of all signals in the application. 

199 

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

201 

202 Examples: 

203 >>> SignalRegistry.emit( 

204 ... "document.save:success", 

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

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

207 ... ) 

208 

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

215 

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

217 

218 """ 

219 

220 _instance: Self 

221 _signals: dict[str, Signal] 

222 _queue: QueueType 

223 

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

233 

234 def __new__(cls) -> Self: 

235 """ 

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

237 

238 Returns: 

239 The singleton instance of this class. 

240 

241 """ 

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

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

244 return cls._instance 

245 

246 @classmethod 

247 def get_instance(cls) -> Self: 

248 """ 

249 Get the singleton instance of this class. 

250 

251 Returns: 

252 The singleton instance of this class. 

253 

254 """ 

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

256 cls._instance = cls() 

257 return cls._instance 

258 

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

260 """ 

261 Register a signal and process queued actions. 

262 

263 Args: 

264 signal: The signal to register. 

265 

266 """ 

267 self._signals[signal.name] = signal 

268 

269 # Process queued connections 

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

271 signal.connect(handler, priority) 

272 

273 # Process queued disconnections 

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

275 signal.disconnect(handler) 

276 

277 # Process queued disables 

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

279 signal.disable(handler) 

280 

281 # Process queued enables 

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

283 signal.enable(handler) 

284 

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. 

290 

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

296 

297 Raises: 

298 ValueError: If the action is invalid. 

299 

300 """ 

301 if action not in self._queue: 

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

303 

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) 

311 

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

313 """ 

314 Get a signal by name. 

315 

316 Args: 

317 name: The signal name. 

318 

319 Returns: 

320 The signal instance, or None if not found. 

321 

322 """ 

323 return self._signals.get(name) 

324 

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

326 """ 

327 List all registered signal names. 

328 

329 Returns: 

330 A list of signal names. 

331 

332 """ 

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

334 

335 def create(self, name: str, description: str = "", return_type: type[_ReturnType] | None = None) -> Signal: 

336 """ 

337 Create and register a new signal. 

338 

339 Args: 

340 name: Signal name 

341 description: Optional description for new signals 

342 return_type: Optional return type for new signals 

343 

344 Returns: 

345 The new signal instance. 

346 

347 """ 

348 signal = Signal[_ReturnType](name, description) 

349 self.register(signal) 

350 return signal 

351 

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

362 

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

373 

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

384 

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. 

396 

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

398 

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 

405 

406 Returns: 

407 The transformed first argument after all handlers have processed it 

408 

409 """ 

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

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

412 

413 arg_tuple = (args,) 

414 kwargs = kwargs or {} 

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

416 

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. 

420 

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 

425 

426 """ 

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

428 signal.connect(handler, priority) 

429 else: 

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

431 

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. 

435 

436 Args: 

437 name: The signal name. 

438 handler: The handler function to disconnect. 

439 

440 """ 

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

442 signal.disconnect(handler) 

443 else: 

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

445 

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. 

449 

450 Args: 

451 name: The signal name. 

452 handler: The handler function to disable 

453 

454 """ 

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

456 signal.disable(handler) 

457 else: 

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

459 

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. 

463 

464 Args: 

465 name: The signal name. 

466 handler: The handler function to enable. 

467 

468 """ 

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

470 signal.enable(handler) 

471 else: 

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

473 

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. 

477 

478 Args: 

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

480 name: The signal name. 

481 handler: The handler function to check. 

482 

483 Returns: 

484 True if the handler is queued, False otherwise. 

485 

486 """ 

487 for queued_handler in self._queue[action].get(name, set()): 

488 if queued_handler == handler: 

489 return True 

490 return False 

491 

492 

493registry = SignalRegistry.get_instance()