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

1"""

2

3

4

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

6

7 METADATA:

8

9 File: signals.py

10 Project: paperap

11 Created: 2025-03-09

12 Version: 0.0.5

13 Author: Jess Mann

14 Email: jess@jmann.me

15 Copyright (c) 2025 Jess Mann

16

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

18

19 LAST MODIFIED:

20

21 2025-03-09 By Jess Mann

22

23"""

24

25 from __future__ import annotations

26

27 import logging

28 from collections import defaultdict

29 from 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

46 logger = logging.getLogger(__name__)

47

48

49 class 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

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

61

62

63 class 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

77 class SignalParams(TypedDict):

78 """

79 A type used by SignalRegistry for storing signal parameters.

80 """

81

82 name: str

83 description: str

84

85

86 class 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

196 class 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

488 registry = SignalRegistry.get_instance()