Coverage for src/paperap/signals.py: 89%

134 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-12 23:40 -0400

1""" 

2 

3 

4 

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

6 

7METADATA: 

8 

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 

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

309 

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

311 """ 

312 Get a signal by name. 

313 

314 Args: 

315 name: The signal name. 

316 

317 Returns: 

318 The signal instance, or None if not found. 

319 

320 """ 

321 return self._signals.get(name) 

322 

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

324 """ 

325 List all registered signal names. 

326 

327 Returns: 

328 A list of signal names. 

329 

330 """ 

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

332 

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

334 """ 

335 Create and register a new signal. 

336 

337 Args: 

338 name: Signal name 

339 description: Optional description for new signals 

340 return_type: Optional return type for new signals 

341 

342 Returns: 

343 The new signal instance. 

344 

345 """ 

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

347 self.register(signal) 

348 return signal 

349 

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

360 

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

371 

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

382 

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. 

394 

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

396 

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 

403 

404 Returns: 

405 The transformed first argument after all handlers have processed it 

406 

407 """ 

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

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

410 

411 arg_tuple = (args,) 

412 kwargs = kwargs or {} 

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

414 

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. 

418 

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 

423 

424 """ 

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

426 signal.connect(handler, priority) 

427 else: 

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

429 

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. 

433 

434 Args: 

435 name: The signal name. 

436 handler: The handler function to disconnect. 

437 

438 """ 

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

440 signal.disconnect(handler) 

441 else: 

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

443 

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. 

447 

448 Args: 

449 name: The signal name. 

450 handler: The handler function to disable 

451 

452 """ 

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

454 signal.disable(handler) 

455 else: 

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

457 

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. 

461 

462 Args: 

463 name: The signal name. 

464 handler: The handler function to enable. 

465 

466 """ 

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

468 signal.enable(handler) 

469 else: 

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

471 

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. 

475 

476 Args: 

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

478 name: The signal name. 

479 handler: The handler function to check. 

480 

481 Returns: 

482 True if the handler is queued, False otherwise. 

483 

484 """ 

485 return handler in self._queue[action].get(name, set()) 

486 

487 

488registry = SignalRegistry.get_instance()