ebk 0.1.0__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ebk might be problematic. Click here for more details.

ebk/plugins/hooks.py ADDED
@@ -0,0 +1,444 @@
1
+ """
2
+ Hook system for EBK plugins.
3
+
4
+ This module provides a hook system that allows plugins and user code
5
+ to register callbacks for various events in the EBK lifecycle.
6
+ """
7
+
8
+ import asyncio
9
+ import inspect
10
+ import logging
11
+ from typing import Callable, Any, List, Dict, Optional, Union
12
+ from functools import wraps
13
+ from collections import defaultdict
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class HookRegistry:
19
+ """Registry for managing hook callbacks."""
20
+
21
+ def __init__(self):
22
+ self._hooks: Dict[str, List[Callable]] = defaultdict(list)
23
+ self._async_hooks: Dict[str, List[Callable]] = defaultdict(list)
24
+ self._hook_priorities: Dict[str, Dict[Callable, int]] = defaultdict(dict)
25
+ self._hook_descriptions: Dict[str, str] = {}
26
+
27
+ def register_hook(self,
28
+ event: str,
29
+ callback: Callable,
30
+ priority: int = 0,
31
+ description: Optional[str] = None) -> None:
32
+ """
33
+ Register a hook callback.
34
+
35
+ Args:
36
+ event: Event name to hook into
37
+ callback: Callback function
38
+ priority: Priority (higher runs first)
39
+ description: Optional description of what this hook does
40
+ """
41
+ if asyncio.iscoroutinefunction(callback):
42
+ self._async_hooks[event].append(callback)
43
+ else:
44
+ self._hooks[event].append(callback)
45
+
46
+ self._hook_priorities[event][callback] = priority
47
+
48
+ # Sort by priority
49
+ if event in self._hooks:
50
+ self._hooks[event].sort(
51
+ key=lambda cb: self._hook_priorities[event].get(cb, 0),
52
+ reverse=True
53
+ )
54
+ if event in self._async_hooks:
55
+ self._async_hooks[event].sort(
56
+ key=lambda cb: self._hook_priorities[event].get(cb, 0),
57
+ reverse=True
58
+ )
59
+
60
+ if description:
61
+ hook_id = f"{event}:{callback.__name__}"
62
+ self._hook_descriptions[hook_id] = description
63
+
64
+ logger.debug(f"Registered hook for {event}: {callback.__name__} (priority: {priority})")
65
+
66
+ def unregister_hook(self, event: str, callback: Callable) -> bool:
67
+ """
68
+ Unregister a hook callback.
69
+
70
+ Args:
71
+ event: Event name
72
+ callback: Callback to remove
73
+
74
+ Returns:
75
+ True if callback was removed
76
+ """
77
+ removed = False
78
+
79
+ if callback in self._hooks.get(event, []):
80
+ self._hooks[event].remove(callback)
81
+ removed = True
82
+
83
+ if callback in self._async_hooks.get(event, []):
84
+ self._async_hooks[event].remove(callback)
85
+ removed = True
86
+
87
+ if removed:
88
+ self._hook_priorities[event].pop(callback, None)
89
+ logger.debug(f"Unregistered hook for {event}: {callback.__name__}")
90
+
91
+ return removed
92
+
93
+ def trigger(self, event: str, *args, **kwargs) -> List[Any]:
94
+ """
95
+ Trigger all callbacks for an event (synchronous).
96
+
97
+ Args:
98
+ event: Event name
99
+ *args: Positional arguments for callbacks
100
+ **kwargs: Keyword arguments for callbacks
101
+
102
+ Returns:
103
+ List of results from callbacks
104
+ """
105
+ results = []
106
+
107
+ # Run synchronous hooks
108
+ for callback in self._hooks.get(event, []):
109
+ try:
110
+ result = callback(*args, **kwargs)
111
+ if result is not None:
112
+ results.append(result)
113
+ except Exception as e:
114
+ logger.error(f"Hook {callback.__name__} failed for event {event}: {e}")
115
+
116
+ # Handle async hooks in sync context
117
+ if event in self._async_hooks:
118
+ logger.warning(f"Async hooks registered for {event} but triggered synchronously")
119
+
120
+ return results
121
+
122
+ async def trigger_async(self, event: str, *args, **kwargs) -> List[Any]:
123
+ """
124
+ Trigger all callbacks for an event (asynchronous).
125
+
126
+ Args:
127
+ event: Event name
128
+ *args: Positional arguments for callbacks
129
+ **kwargs: Keyword arguments for callbacks
130
+
131
+ Returns:
132
+ List of results from callbacks
133
+ """
134
+ results = []
135
+
136
+ # Run synchronous hooks
137
+ for callback in self._hooks.get(event, []):
138
+ try:
139
+ result = callback(*args, **kwargs)
140
+ if result is not None:
141
+ results.append(result)
142
+ except Exception as e:
143
+ logger.error(f"Hook {callback.__name__} failed for event {event}: {e}")
144
+
145
+ # Run async hooks
146
+ tasks = []
147
+ for callback in self._async_hooks.get(event, []):
148
+ tasks.append(self._run_async_hook(callback, event, args, kwargs))
149
+
150
+ if tasks:
151
+ async_results = await asyncio.gather(*tasks, return_exceptions=True)
152
+ for result in async_results:
153
+ if isinstance(result, Exception):
154
+ logger.error(f"Async hook failed for event {event}: {result}")
155
+ elif result is not None:
156
+ results.append(result)
157
+
158
+ return results
159
+
160
+ async def _run_async_hook(self, callback: Callable, event: str, args, kwargs) -> Any:
161
+ """
162
+ Run a single async hook with error handling.
163
+
164
+ Args:
165
+ callback: Async callback function
166
+ event: Event name
167
+ args: Positional arguments
168
+ kwargs: Keyword arguments
169
+
170
+ Returns:
171
+ Result from callback or None
172
+ """
173
+ try:
174
+ return await callback(*args, **kwargs)
175
+ except Exception as e:
176
+ logger.error(f"Async hook {callback.__name__} failed for event {event}: {e}")
177
+ raise
178
+
179
+ def trigger_filter(self, event: str, value: Any, *args, **kwargs) -> Any:
180
+ """
181
+ Trigger filter hooks that can modify a value.
182
+
183
+ Each hook receives the current value and can return a modified version.
184
+ If a hook returns None, the value is unchanged.
185
+
186
+ Args:
187
+ event: Event name
188
+ value: Initial value to filter
189
+ *args: Additional positional arguments for callbacks
190
+ **kwargs: Additional keyword arguments for callbacks
191
+
192
+ Returns:
193
+ Final filtered value
194
+ """
195
+ current_value = value
196
+
197
+ for callback in self._hooks.get(event, []):
198
+ try:
199
+ result = callback(current_value, *args, **kwargs)
200
+ if result is not None:
201
+ current_value = result
202
+ except Exception as e:
203
+ logger.error(f"Filter hook {callback.__name__} failed for event {event}: {e}")
204
+
205
+ return current_value
206
+
207
+ async def trigger_filter_async(self, event: str, value: Any, *args, **kwargs) -> Any:
208
+ """
209
+ Trigger async filter hooks that can modify a value.
210
+
211
+ Args:
212
+ event: Event name
213
+ value: Initial value to filter
214
+ *args: Additional positional arguments for callbacks
215
+ **kwargs: Additional keyword arguments for callbacks
216
+
217
+ Returns:
218
+ Final filtered value
219
+ """
220
+ current_value = value
221
+
222
+ # Run synchronous hooks first
223
+ current_value = self.trigger_filter(event, current_value, *args, **kwargs)
224
+
225
+ # Run async hooks
226
+ for callback in self._async_hooks.get(event, []):
227
+ try:
228
+ result = await callback(current_value, *args, **kwargs)
229
+ if result is not None:
230
+ current_value = result
231
+ except Exception as e:
232
+ logger.error(f"Async filter hook {callback.__name__} failed for event {event}: {e}")
233
+
234
+ return current_value
235
+
236
+ def has_hooks(self, event: str) -> bool:
237
+ """
238
+ Check if an event has any hooks registered.
239
+
240
+ Args:
241
+ event: Event name
242
+
243
+ Returns:
244
+ True if hooks are registered
245
+ """
246
+ return bool(self._hooks.get(event) or self._async_hooks.get(event))
247
+
248
+ def list_hooks(self) -> Dict[str, List[str]]:
249
+ """
250
+ List all registered hooks.
251
+
252
+ Returns:
253
+ Dictionary mapping events to callback names
254
+ """
255
+ result = {}
256
+
257
+ for event, callbacks in self._hooks.items():
258
+ if event not in result:
259
+ result[event] = []
260
+ result[event].extend([cb.__name__ for cb in callbacks])
261
+
262
+ for event, callbacks in self._async_hooks.items():
263
+ if event not in result:
264
+ result[event] = []
265
+ result[event].extend([f"{cb.__name__} (async)" for cb in callbacks])
266
+
267
+ return result
268
+
269
+ def clear_hooks(self, event: Optional[str] = None) -> None:
270
+ """
271
+ Clear hooks for an event or all events.
272
+
273
+ Args:
274
+ event: Event name to clear, or None to clear all
275
+ """
276
+ if event:
277
+ self._hooks.pop(event, None)
278
+ self._async_hooks.pop(event, None)
279
+ self._hook_priorities.pop(event, None)
280
+ else:
281
+ self._hooks.clear()
282
+ self._async_hooks.clear()
283
+ self._hook_priorities.clear()
284
+ self._hook_descriptions.clear()
285
+
286
+
287
+ # Global hook registry
288
+ hooks = HookRegistry()
289
+
290
+
291
+ def hook(event: str, priority: int = 0, description: Optional[str] = None):
292
+ """
293
+ Decorator for registering hook callbacks.
294
+
295
+ Usage:
296
+ @hook("entry.added")
297
+ def on_entry_added(entry, library):
298
+ print(f"Entry added: {entry['title']}")
299
+
300
+ @hook("before_export", priority=10)
301
+ async def validate_before_export(entries, format):
302
+ # Async hook
303
+ await validate_entries(entries)
304
+
305
+ Args:
306
+ event: Event name to hook into
307
+ priority: Priority (higher runs first)
308
+ description: Optional description
309
+
310
+ Returns:
311
+ Decorator function
312
+ """
313
+ def decorator(func: Callable) -> Callable:
314
+ hooks.register_hook(event, func, priority, description)
315
+ return func
316
+ return decorator
317
+
318
+
319
+ def trigger_hook(event: str, *args, **kwargs) -> List[Any]:
320
+ """
321
+ Trigger all callbacks for an event.
322
+
323
+ Args:
324
+ event: Event name
325
+ *args: Positional arguments for callbacks
326
+ **kwargs: Keyword arguments for callbacks
327
+
328
+ Returns:
329
+ List of results from callbacks
330
+ """
331
+ return hooks.trigger(event, *args, **kwargs)
332
+
333
+
334
+ async def trigger_hook_async(event: str, *args, **kwargs) -> List[Any]:
335
+ """
336
+ Trigger all callbacks for an event (async).
337
+
338
+ Args:
339
+ event: Event name
340
+ *args: Positional arguments for callbacks
341
+ **kwargs: Keyword arguments for callbacks
342
+
343
+ Returns:
344
+ List of results from callbacks
345
+ """
346
+ return await hooks.trigger_async(event, *args, **kwargs)
347
+
348
+
349
+ def filter_value(event: str, value: Any, *args, **kwargs) -> Any:
350
+ """
351
+ Apply filter hooks to modify a value.
352
+
353
+ Args:
354
+ event: Event name
355
+ value: Initial value
356
+ *args: Additional arguments for callbacks
357
+ **kwargs: Additional keyword arguments for callbacks
358
+
359
+ Returns:
360
+ Filtered value
361
+ """
362
+ return hooks.trigger_filter(event, value, *args, **kwargs)
363
+
364
+
365
+ async def filter_value_async(event: str, value: Any, *args, **kwargs) -> Any:
366
+ """
367
+ Apply async filter hooks to modify a value.
368
+
369
+ Args:
370
+ event: Event name
371
+ value: Initial value
372
+ *args: Additional arguments for callbacks
373
+ **kwargs: Additional keyword arguments for callbacks
374
+
375
+ Returns:
376
+ Filtered value
377
+ """
378
+ return await hooks.trigger_filter_async(event, value, *args, **kwargs)
379
+
380
+
381
+ # Predefined events that EBK will trigger
382
+ EVENTS = {
383
+ # Library events
384
+ 'library.opened': 'Library has been opened',
385
+ 'library.closed': 'Library has been closed',
386
+ 'library.saved': 'Library has been saved',
387
+
388
+ # Entry events
389
+ 'entry.added': 'Entry added to library',
390
+ 'entry.updated': 'Entry updated',
391
+ 'entry.deleted': 'Entry deleted from library',
392
+ 'entry.before_add': 'Before entry is added (can cancel)',
393
+ 'entry.before_update': 'Before entry is updated (can cancel)',
394
+ 'entry.before_delete': 'Before entry is deleted (can cancel)',
395
+
396
+ # Metadata events
397
+ 'metadata.extracted': 'Metadata extracted from source',
398
+ 'metadata.enriched': 'Metadata enriched from external source',
399
+ 'metadata.validated': 'Metadata validated',
400
+
401
+ # Tag events
402
+ 'tags.suggested': 'Tags suggested for entry',
403
+ 'tags.added': 'Tags added to entry',
404
+ 'tags.removed': 'Tags removed from entry',
405
+
406
+ # Import/Export events
407
+ 'import.started': 'Import operation started',
408
+ 'import.progress': 'Import operation progress',
409
+ 'import.completed': 'Import operation completed',
410
+ 'import.failed': 'Import operation failed',
411
+ 'export.started': 'Export operation started',
412
+ 'export.progress': 'Export operation progress',
413
+ 'export.completed': 'Export operation completed',
414
+ 'export.failed': 'Export operation failed',
415
+
416
+ # Search events
417
+ 'search.started': 'Search operation started',
418
+ 'search.completed': 'Search operation completed',
419
+ 'search.results_filtered': 'Search results filtered',
420
+
421
+ # Plugin events
422
+ 'plugin.registered': 'Plugin registered',
423
+ 'plugin.unregistered': 'Plugin unregistered',
424
+ 'plugin.enabled': 'Plugin enabled',
425
+ 'plugin.disabled': 'Plugin disabled',
426
+ 'plugin.configured': 'Plugin configured',
427
+
428
+ # Filter events (value can be modified)
429
+ 'filter.entry_data': 'Filter entry data before save',
430
+ 'filter.search_query': 'Filter search query before execution',
431
+ 'filter.export_entries': 'Filter entries before export',
432
+ 'filter.import_entry': 'Filter entry during import',
433
+ 'filter.suggested_tags': 'Filter suggested tags',
434
+ }
435
+
436
+
437
+ def list_available_events() -> Dict[str, str]:
438
+ """
439
+ List all available events with descriptions.
440
+
441
+ Returns:
442
+ Dictionary mapping event names to descriptions
443
+ """
444
+ return EVENTS.copy()