domubus 0.1.0__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.
- domubus/__init__.py +67 -0
- domubus/bus.py +469 -0
- domubus/events.py +131 -0
- domubus/handlers.py +161 -0
- domubus/persistence.py +156 -0
- domubus/py.typed +1 -0
- domubus/types.py +70 -0
- domubus/watcher.py +131 -0
- domubus-0.1.0.dist-info/METADATA +177 -0
- domubus-0.1.0.dist-info/RECORD +12 -0
- domubus-0.1.0.dist-info/WHEEL +4 -0
- domubus-0.1.0.dist-info/licenses/LICENSE +22 -0
domubus/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""domubus - Async/sync event bus with optional Pydantic integration.
|
|
2
|
+
|
|
3
|
+
A type-safe, async/sync event bus with optional Pydantic integration,
|
|
4
|
+
persistence, and priority handlers. Zero required dependencies.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
from domubus import EventBus, BaseEvent
|
|
8
|
+
|
|
9
|
+
class DeviceStateChanged(BaseEvent):
|
|
10
|
+
event_type = "device.state.changed"
|
|
11
|
+
device_id: str
|
|
12
|
+
new_state: str
|
|
13
|
+
|
|
14
|
+
bus = EventBus()
|
|
15
|
+
|
|
16
|
+
@bus.on("device.state.changed")
|
|
17
|
+
async def handle_state_change(event: DeviceStateChanged):
|
|
18
|
+
print(f"Device {event.device_id} -> {event.new_state}")
|
|
19
|
+
|
|
20
|
+
await bus.emit_async(DeviceStateChanged(device_id="light1", new_state="on"))
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from domubus.bus import EventBus
|
|
24
|
+
from domubus.events import PYDANTIC_AVAILABLE, BaseEvent, StringEvent
|
|
25
|
+
from domubus.handlers import HandlerEntry, HandlerRegistry
|
|
26
|
+
from domubus.persistence import JSONLPersistence
|
|
27
|
+
from domubus.types import (
|
|
28
|
+
AsyncHandler,
|
|
29
|
+
BaseEventProtocol,
|
|
30
|
+
ErrorCallback,
|
|
31
|
+
EventFilter,
|
|
32
|
+
EventT,
|
|
33
|
+
EventT_co,
|
|
34
|
+
Handler,
|
|
35
|
+
SyncHandler,
|
|
36
|
+
)
|
|
37
|
+
from domubus.watcher import FileWatcher
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"PYDANTIC_AVAILABLE",
|
|
43
|
+
"AsyncHandler",
|
|
44
|
+
# Events
|
|
45
|
+
"BaseEvent",
|
|
46
|
+
"BaseEventProtocol",
|
|
47
|
+
"ErrorCallback",
|
|
48
|
+
# Core
|
|
49
|
+
"EventBus",
|
|
50
|
+
"EventFilter",
|
|
51
|
+
"EventT",
|
|
52
|
+
"EventT_co",
|
|
53
|
+
# Watcher
|
|
54
|
+
"FileWatcher",
|
|
55
|
+
# Types
|
|
56
|
+
"Handler",
|
|
57
|
+
# Handlers
|
|
58
|
+
"HandlerEntry",
|
|
59
|
+
"HandlerRegistry",
|
|
60
|
+
# Persistence
|
|
61
|
+
"JSONLPersistence",
|
|
62
|
+
"StringEvent",
|
|
63
|
+
"SyncHandler",
|
|
64
|
+
# Metadata
|
|
65
|
+
"__version__",
|
|
66
|
+
]
|
|
67
|
+
|
domubus/bus.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""Core EventBus implementation for domubus.
|
|
2
|
+
|
|
3
|
+
This module provides the main EventBus class that combines handlers, events,
|
|
4
|
+
and optional persistence into a unified async/sync event bus.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import os
|
|
11
|
+
import threading
|
|
12
|
+
from collections import deque
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Union
|
|
16
|
+
|
|
17
|
+
from domubus.events import BaseEvent, StringEvent
|
|
18
|
+
from domubus.handlers import HandlerRegistry
|
|
19
|
+
from domubus.persistence import JSONLPersistence
|
|
20
|
+
from domubus.watcher import FileWatcher
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from domubus.types import ErrorCallback, EventFilter, Handler
|
|
24
|
+
|
|
25
|
+
# Type alias for events that can be either BaseEvent or StringEvent
|
|
26
|
+
AnyEvent = Union[BaseEvent, StringEvent]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EventBus:
|
|
30
|
+
"""Async/sync event bus with optional persistence.
|
|
31
|
+
|
|
32
|
+
Supports both sync and async handlers, priority ordering, wildcard
|
|
33
|
+
subscriptions, one-time handlers, and optional JSONL persistence.
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
async with EventBus(persistence_path="events.jsonl") as bus:
|
|
37
|
+
@bus.on("device.light.on")
|
|
38
|
+
async def handle_light_on(event):
|
|
39
|
+
print(f"Light turned on: {event}")
|
|
40
|
+
|
|
41
|
+
await bus.emit_async("device.light.on", {"brightness": 100})
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# Event type registry for deserialization
|
|
45
|
+
_event_registry: dict[str, type[BaseEvent]] = {}
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def register_event_type(cls, event_class: type[BaseEvent]) -> None:
|
|
49
|
+
"""Register an event class for deserialization.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
event_class: A BaseEvent subclass with an event_type class variable.
|
|
53
|
+
"""
|
|
54
|
+
event_type = getattr(event_class, "event_type", None)
|
|
55
|
+
if event_type:
|
|
56
|
+
cls._event_registry[event_type] = event_class
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def register_event_types(cls, *event_classes: type[BaseEvent]) -> None:
|
|
60
|
+
"""Register multiple event classes for deserialization."""
|
|
61
|
+
for event_class in event_classes:
|
|
62
|
+
cls.register_event_type(event_class)
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
history_limit: int = 1000,
|
|
68
|
+
persistence_path: str | Path | None = None,
|
|
69
|
+
error_callback: ErrorCallback | None = None,
|
|
70
|
+
process_id: str | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Initialize EventBus.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
history_limit: Max events to keep in memory history.
|
|
76
|
+
persistence_path: Optional path to JSONL file for persistence.
|
|
77
|
+
error_callback: Optional callback for handler exceptions.
|
|
78
|
+
process_id: Unique ID for this process (for cross-process sync).
|
|
79
|
+
"""
|
|
80
|
+
self._registry = HandlerRegistry()
|
|
81
|
+
self._history: deque[dict[str, Any]] = deque(maxlen=history_limit)
|
|
82
|
+
self._error_callback = error_callback
|
|
83
|
+
self._lock = threading.RLock()
|
|
84
|
+
self._process_id = process_id or f"proc-{os.getpid()}"
|
|
85
|
+
self._persistence_path = Path(persistence_path) if persistence_path else None
|
|
86
|
+
|
|
87
|
+
# Optional persistence
|
|
88
|
+
self._persistence: JSONLPersistence | None = None
|
|
89
|
+
if persistence_path:
|
|
90
|
+
self._persistence = JSONLPersistence(persistence_path)
|
|
91
|
+
|
|
92
|
+
# File watcher for cross-process events
|
|
93
|
+
self._watcher: FileWatcher | None = None
|
|
94
|
+
|
|
95
|
+
# Context manager support (async)
|
|
96
|
+
async def __aenter__(self) -> EventBus:
|
|
97
|
+
"""Async context manager entry - loads history from persistence."""
|
|
98
|
+
if self._persistence:
|
|
99
|
+
self._persistence.open()
|
|
100
|
+
for event_dict in self._persistence.load():
|
|
101
|
+
self._history.append(event_dict)
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
105
|
+
"""Async context manager exit - closes persistence."""
|
|
106
|
+
if self._persistence:
|
|
107
|
+
self._persistence.close()
|
|
108
|
+
|
|
109
|
+
# Context manager support (sync)
|
|
110
|
+
def __enter__(self) -> EventBus:
|
|
111
|
+
"""Sync context manager entry - loads history from persistence."""
|
|
112
|
+
if self._persistence:
|
|
113
|
+
self._persistence.open()
|
|
114
|
+
for event_dict in self._persistence.load():
|
|
115
|
+
self._history.append(event_dict)
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def __exit__(self, *args: Any) -> None:
|
|
119
|
+
"""Sync context manager exit - closes persistence."""
|
|
120
|
+
if self._persistence:
|
|
121
|
+
self._persistence.close()
|
|
122
|
+
|
|
123
|
+
# Subscription methods
|
|
124
|
+
def subscribe(
|
|
125
|
+
self,
|
|
126
|
+
event_type: str,
|
|
127
|
+
handler: Handler,
|
|
128
|
+
*,
|
|
129
|
+
priority: int = 0,
|
|
130
|
+
once: bool = False,
|
|
131
|
+
filter_fn: EventFilter | None = None,
|
|
132
|
+
) -> str:
|
|
133
|
+
"""Subscribe a handler to an event type.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
event_type: Event type to subscribe to ("*" for all events).
|
|
137
|
+
handler: Handler function (sync or async).
|
|
138
|
+
priority: Execution priority (higher = earlier, default 0).
|
|
139
|
+
once: If True, handler is removed after first call.
|
|
140
|
+
filter_fn: Optional filter function to conditionally execute.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Handler ID for later unsubscription.
|
|
144
|
+
"""
|
|
145
|
+
with self._lock:
|
|
146
|
+
return self._registry.subscribe(event_type, handler, priority, once, filter_fn)
|
|
147
|
+
|
|
148
|
+
def unsubscribe(self, handler_id: str) -> bool:
|
|
149
|
+
"""Unsubscribe a handler by ID.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
handler_id: The ID returned from subscribe().
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
True if handler was found and removed.
|
|
156
|
+
"""
|
|
157
|
+
with self._lock:
|
|
158
|
+
return self._registry.unsubscribe(handler_id)
|
|
159
|
+
|
|
160
|
+
def on(
|
|
161
|
+
self,
|
|
162
|
+
event_type: str,
|
|
163
|
+
*,
|
|
164
|
+
priority: int = 0,
|
|
165
|
+
once: bool = False,
|
|
166
|
+
filter_fn: EventFilter | None = None,
|
|
167
|
+
) -> Callable[[Handler], Handler]:
|
|
168
|
+
"""Decorator to subscribe a handler.
|
|
169
|
+
|
|
170
|
+
Example:
|
|
171
|
+
@bus.on("device.light.on")
|
|
172
|
+
async def handle_light_on(event):
|
|
173
|
+
print(event)
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def decorator(handler: Handler) -> Handler:
|
|
177
|
+
self.subscribe(event_type, handler, priority=priority, once=once, filter_fn=filter_fn)
|
|
178
|
+
return handler
|
|
179
|
+
|
|
180
|
+
return decorator
|
|
181
|
+
|
|
182
|
+
def once(
|
|
183
|
+
self,
|
|
184
|
+
event_type: str,
|
|
185
|
+
*,
|
|
186
|
+
priority: int = 0,
|
|
187
|
+
filter_fn: EventFilter | None = None,
|
|
188
|
+
) -> Callable[[Handler], Handler]:
|
|
189
|
+
"""Decorator to subscribe a one-time handler.
|
|
190
|
+
|
|
191
|
+
The handler will be automatically unsubscribed after first execution.
|
|
192
|
+
"""
|
|
193
|
+
return self.on(event_type, priority=priority, once=True, filter_fn=filter_fn)
|
|
194
|
+
|
|
195
|
+
# Emit methods
|
|
196
|
+
async def emit_async(
|
|
197
|
+
self,
|
|
198
|
+
event: BaseEvent | StringEvent | str,
|
|
199
|
+
data: dict[str, Any] | None = None,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Emit an event asynchronously.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
event: Event object or string event type.
|
|
205
|
+
data: Optional data dict (only used when event is a string).
|
|
206
|
+
"""
|
|
207
|
+
# Normalize to event object
|
|
208
|
+
if isinstance(event, str):
|
|
209
|
+
event = StringEvent(event_type=event, data=data or {})
|
|
210
|
+
|
|
211
|
+
event_type = event.event_type
|
|
212
|
+
event_dict = event.to_dict()
|
|
213
|
+
|
|
214
|
+
# Add source process ID for cross-process filtering
|
|
215
|
+
event_dict["_source_process"] = self._process_id
|
|
216
|
+
|
|
217
|
+
# Add to history and persist
|
|
218
|
+
with self._lock:
|
|
219
|
+
self._history.append(event_dict)
|
|
220
|
+
if self._persistence:
|
|
221
|
+
self._persistence.append(event_dict)
|
|
222
|
+
|
|
223
|
+
# Get handlers and execute
|
|
224
|
+
handlers = self._registry.get_handlers(event_type)
|
|
225
|
+
handlers_to_remove: list[str] = []
|
|
226
|
+
|
|
227
|
+
for handler in handlers:
|
|
228
|
+
# Check filter
|
|
229
|
+
if handler.filter_fn and not handler.filter_fn(event):
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
if handler.is_async:
|
|
234
|
+
coro = handler.callback(event)
|
|
235
|
+
if coro is not None:
|
|
236
|
+
await coro
|
|
237
|
+
else:
|
|
238
|
+
handler.callback(event)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
if self._error_callback:
|
|
241
|
+
self._error_callback(e, event, handler.callback)
|
|
242
|
+
|
|
243
|
+
if handler.once:
|
|
244
|
+
handlers_to_remove.append(handler.id)
|
|
245
|
+
|
|
246
|
+
# Remove once handlers
|
|
247
|
+
for handler_id in handlers_to_remove:
|
|
248
|
+
self._registry.unsubscribe(handler_id)
|
|
249
|
+
|
|
250
|
+
def emit(
|
|
251
|
+
self,
|
|
252
|
+
event: BaseEvent | StringEvent | str,
|
|
253
|
+
data: dict[str, Any] | None = None,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Emit an event (sync wrapper for async emit).
|
|
256
|
+
|
|
257
|
+
If called from an async context, schedules as a task.
|
|
258
|
+
If called from sync context, runs in a new event loop.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
event: Event object or string event type.
|
|
262
|
+
data: Optional data dict (only used when event is a string).
|
|
263
|
+
"""
|
|
264
|
+
try:
|
|
265
|
+
loop = asyncio.get_running_loop()
|
|
266
|
+
# Already in async context - schedule as task
|
|
267
|
+
loop.create_task(self.emit_async(event, data))
|
|
268
|
+
except RuntimeError:
|
|
269
|
+
# No running loop - create one
|
|
270
|
+
asyncio.run(self.emit_async(event, data))
|
|
271
|
+
|
|
272
|
+
def emit_sync(
|
|
273
|
+
self,
|
|
274
|
+
event: BaseEvent | StringEvent | str,
|
|
275
|
+
data: dict[str, Any] | None = None,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Emit an event synchronously (only runs sync handlers).
|
|
278
|
+
|
|
279
|
+
This method only executes sync handlers and skips async handlers.
|
|
280
|
+
Use this when you need guaranteed synchronous execution.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
event: Event object or string event type.
|
|
284
|
+
data: Optional data dict (only used when event is a string).
|
|
285
|
+
"""
|
|
286
|
+
# Normalize to event object
|
|
287
|
+
if isinstance(event, str):
|
|
288
|
+
event = StringEvent(event_type=event, data=data or {})
|
|
289
|
+
|
|
290
|
+
event_type = event.event_type
|
|
291
|
+
event_dict = event.to_dict()
|
|
292
|
+
|
|
293
|
+
# Add source process ID for cross-process filtering
|
|
294
|
+
event_dict["_source_process"] = self._process_id
|
|
295
|
+
|
|
296
|
+
# Add to history and persist
|
|
297
|
+
with self._lock:
|
|
298
|
+
self._history.append(event_dict)
|
|
299
|
+
if self._persistence:
|
|
300
|
+
self._persistence.append(event_dict)
|
|
301
|
+
|
|
302
|
+
# Get handlers and execute (sync only)
|
|
303
|
+
handlers = self._registry.get_handlers(event_type)
|
|
304
|
+
handlers_to_remove: list[str] = []
|
|
305
|
+
|
|
306
|
+
for handler in handlers:
|
|
307
|
+
# Skip async handlers
|
|
308
|
+
if handler.is_async:
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
# Check filter
|
|
312
|
+
if handler.filter_fn and not handler.filter_fn(event):
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
handler.callback(event)
|
|
317
|
+
except Exception as e:
|
|
318
|
+
if self._error_callback:
|
|
319
|
+
self._error_callback(e, event, handler.callback)
|
|
320
|
+
|
|
321
|
+
if handler.once:
|
|
322
|
+
handlers_to_remove.append(handler.id)
|
|
323
|
+
|
|
324
|
+
# Remove once handlers
|
|
325
|
+
for handler_id in handlers_to_remove:
|
|
326
|
+
self._registry.unsubscribe(handler_id)
|
|
327
|
+
|
|
328
|
+
# History access
|
|
329
|
+
def get_history(
|
|
330
|
+
self,
|
|
331
|
+
event_type: str | None = None,
|
|
332
|
+
limit: int | None = None,
|
|
333
|
+
) -> list[dict[str, Any]]:
|
|
334
|
+
"""Get event history, optionally filtered by type.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
event_type: Filter by event type (None for all).
|
|
338
|
+
limit: Max events to return (None for all).
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
List of event dictionaries (newest last).
|
|
342
|
+
"""
|
|
343
|
+
with self._lock:
|
|
344
|
+
events = list(self._history)
|
|
345
|
+
|
|
346
|
+
if event_type:
|
|
347
|
+
events = [e for e in events if e.get("event_type") == event_type]
|
|
348
|
+
|
|
349
|
+
if limit:
|
|
350
|
+
events = events[-limit:]
|
|
351
|
+
|
|
352
|
+
return events
|
|
353
|
+
|
|
354
|
+
def clear_history(self) -> None:
|
|
355
|
+
"""Clear in-memory event history."""
|
|
356
|
+
with self._lock:
|
|
357
|
+
self._history.clear()
|
|
358
|
+
|
|
359
|
+
def clear_handlers(self) -> None:
|
|
360
|
+
"""Remove all handlers."""
|
|
361
|
+
with self._lock:
|
|
362
|
+
self._registry.clear()
|
|
363
|
+
|
|
364
|
+
def handler_count(self, event_type: str | None = None) -> int:
|
|
365
|
+
"""Count handlers, optionally filtered by event type."""
|
|
366
|
+
return self._registry.handler_count(event_type)
|
|
367
|
+
|
|
368
|
+
# Cross-process synchronization
|
|
369
|
+
async def start_sync(self, poll_interval: float = 0.1) -> None:
|
|
370
|
+
"""Start watching for events from other processes.
|
|
371
|
+
|
|
372
|
+
When enabled, events written to the persistence file by other processes
|
|
373
|
+
are automatically dispatched to local handlers.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
poll_interval: Seconds between file checks (default 0.1s).
|
|
377
|
+
"""
|
|
378
|
+
if not self._persistence_path:
|
|
379
|
+
raise RuntimeError("Cannot sync without persistence_path")
|
|
380
|
+
|
|
381
|
+
if self._watcher and self._watcher.is_running:
|
|
382
|
+
return # Already running
|
|
383
|
+
|
|
384
|
+
self._watcher = FileWatcher(
|
|
385
|
+
self._persistence_path,
|
|
386
|
+
on_event=self._handle_external_event,
|
|
387
|
+
process_id=self._process_id,
|
|
388
|
+
poll_interval=poll_interval,
|
|
389
|
+
)
|
|
390
|
+
await self._watcher.start()
|
|
391
|
+
|
|
392
|
+
async def stop_sync(self) -> None:
|
|
393
|
+
"""Stop watching for events from other processes."""
|
|
394
|
+
if self._watcher:
|
|
395
|
+
await self._watcher.stop()
|
|
396
|
+
self._watcher = None
|
|
397
|
+
|
|
398
|
+
def _handle_external_event(self, event_dict: dict[str, Any]) -> None:
|
|
399
|
+
"""Handle an event from another process.
|
|
400
|
+
|
|
401
|
+
Dispatches the event to local handlers without re-persisting.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
event_dict: The event dictionary from the file.
|
|
405
|
+
"""
|
|
406
|
+
event_type = event_dict.get("event_type", "")
|
|
407
|
+
if not event_type:
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
# Try to deserialize to registered event type
|
|
411
|
+
event_class = self._event_registry.get(event_type)
|
|
412
|
+
event: AnyEvent
|
|
413
|
+
if event_class:
|
|
414
|
+
try:
|
|
415
|
+
# Remove internal fields before deserializing
|
|
416
|
+
clean_dict = {k: v for k, v in event_dict.items() if not k.startswith("_")}
|
|
417
|
+
event = event_class(**clean_dict)
|
|
418
|
+
except Exception:
|
|
419
|
+
# Fall back to StringEvent if deserialization fails
|
|
420
|
+
event = StringEvent(
|
|
421
|
+
event_type=event_type,
|
|
422
|
+
data=event_dict.get("data", event_dict),
|
|
423
|
+
)
|
|
424
|
+
else:
|
|
425
|
+
# No registered class, use StringEvent
|
|
426
|
+
event = StringEvent(
|
|
427
|
+
event_type=event_type,
|
|
428
|
+
data=event_dict.get("data", event_dict),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Add to local history (but don't re-persist)
|
|
432
|
+
with self._lock:
|
|
433
|
+
self._history.append(event_dict)
|
|
434
|
+
|
|
435
|
+
# Dispatch to handlers (sync only for now)
|
|
436
|
+
handlers = self._registry.get_handlers(event_type)
|
|
437
|
+
handlers_to_remove: list[str] = []
|
|
438
|
+
|
|
439
|
+
for handler in handlers:
|
|
440
|
+
if handler.filter_fn and not handler.filter_fn(event):
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
if handler.is_async:
|
|
445
|
+
# Schedule async handlers
|
|
446
|
+
try:
|
|
447
|
+
loop = asyncio.get_running_loop()
|
|
448
|
+
result = handler.callback(event)
|
|
449
|
+
if result is not None and asyncio.iscoroutine(result):
|
|
450
|
+
loop.create_task(result)
|
|
451
|
+
except RuntimeError:
|
|
452
|
+
pass # No loop, skip async handler
|
|
453
|
+
else:
|
|
454
|
+
handler.callback(event)
|
|
455
|
+
except Exception as e:
|
|
456
|
+
if self._error_callback:
|
|
457
|
+
self._error_callback(e, event, handler.callback)
|
|
458
|
+
|
|
459
|
+
if handler.once:
|
|
460
|
+
handlers_to_remove.append(handler.id)
|
|
461
|
+
|
|
462
|
+
for handler_id in handlers_to_remove:
|
|
463
|
+
self._registry.unsubscribe(handler_id)
|
|
464
|
+
|
|
465
|
+
@property
|
|
466
|
+
def is_syncing(self) -> bool:
|
|
467
|
+
"""Check if cross-process sync is active."""
|
|
468
|
+
return self._watcher is not None and self._watcher.is_running
|
|
469
|
+
|
domubus/events.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Event definitions for domubus.
|
|
2
|
+
|
|
3
|
+
This module provides BaseEvent (Pydantic or dataclass fallback) and StringEvent
|
|
4
|
+
for simple string-based events.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from typing import Any, ClassVar
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
# Try Pydantic v2, fall back to dataclass
|
|
15
|
+
try:
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
17
|
+
|
|
18
|
+
PYDANTIC_AVAILABLE = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
PYDANTIC_AVAILABLE = False
|
|
21
|
+
BaseModel = None # type: ignore[misc, assignment]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _generate_id() -> str:
|
|
25
|
+
"""Generate a unique event ID using UUID4."""
|
|
26
|
+
return str(uuid4())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _generate_timestamp() -> float:
|
|
30
|
+
"""Generate a Unix timestamp for the current time."""
|
|
31
|
+
return time.time()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if PYDANTIC_AVAILABLE and BaseModel is not None:
|
|
35
|
+
|
|
36
|
+
class BaseEvent(BaseModel): # type: ignore[no-redef, unused-ignore]
|
|
37
|
+
"""Base event class using Pydantic v2.
|
|
38
|
+
|
|
39
|
+
Subclass this to create custom events with type-safe payloads.
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
class DeviceStateChanged(BaseEvent):
|
|
43
|
+
event_type: ClassVar[str] = "device.state.changed"
|
|
44
|
+
device_id: str
|
|
45
|
+
new_state: dict[str, Any]
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(extra="allow")
|
|
49
|
+
|
|
50
|
+
event_type: ClassVar[str] = "base"
|
|
51
|
+
id: str = Field(default_factory=_generate_id)
|
|
52
|
+
timestamp: float = Field(default_factory=_generate_timestamp)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
"""Serialize the event to a dictionary."""
|
|
56
|
+
return {"event_type": self.__class__.event_type, **self.model_dump()}
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_dict(cls, data: dict[str, Any]) -> BaseEvent:
|
|
60
|
+
"""Deserialize an event from a dictionary."""
|
|
61
|
+
data = data.copy()
|
|
62
|
+
data.pop("event_type", None)
|
|
63
|
+
return cls.model_validate(data)
|
|
64
|
+
|
|
65
|
+
else:
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class BaseEvent: # type: ignore[no-redef]
|
|
69
|
+
"""Base event class using dataclass (Pydantic fallback).
|
|
70
|
+
|
|
71
|
+
Subclass this to create custom events when Pydantic is not available.
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
@dataclass
|
|
75
|
+
class DeviceStateChanged(BaseEvent):
|
|
76
|
+
event_type: ClassVar[str] = "device.state.changed"
|
|
77
|
+
device_id: str = ""
|
|
78
|
+
new_state: dict = field(default_factory=dict)
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
event_type: ClassVar[str] = "base"
|
|
82
|
+
id: str = field(default_factory=_generate_id)
|
|
83
|
+
timestamp: float = field(default_factory=_generate_timestamp)
|
|
84
|
+
|
|
85
|
+
def to_dict(self) -> dict[str, Any]:
|
|
86
|
+
"""Serialize the event to a dictionary."""
|
|
87
|
+
return {"event_type": self.__class__.event_type, **asdict(self)}
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_dict(cls, data: dict[str, Any]) -> BaseEvent:
|
|
91
|
+
"""Deserialize an event from a dictionary."""
|
|
92
|
+
data = data.copy()
|
|
93
|
+
data.pop("event_type", None)
|
|
94
|
+
return cls(**data)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class StringEvent:
|
|
99
|
+
"""Simple string-based event for basic use cases.
|
|
100
|
+
|
|
101
|
+
Use this when you don't need a custom event class and just want
|
|
102
|
+
to emit events by name with arbitrary data.
|
|
103
|
+
|
|
104
|
+
Example:
|
|
105
|
+
event = StringEvent(event_type="device.light.on", data={"brightness": 100})
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
event_type: str
|
|
109
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
110
|
+
id: str = field(default_factory=_generate_id)
|
|
111
|
+
timestamp: float = field(default_factory=_generate_timestamp)
|
|
112
|
+
|
|
113
|
+
def to_dict(self) -> dict[str, Any]:
|
|
114
|
+
"""Serialize the event to a dictionary."""
|
|
115
|
+
return {
|
|
116
|
+
"event_type": self.event_type,
|
|
117
|
+
"data": self.data,
|
|
118
|
+
"id": self.id,
|
|
119
|
+
"timestamp": self.timestamp,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_dict(cls, data: dict[str, Any]) -> StringEvent:
|
|
124
|
+
"""Deserialize a StringEvent from a dictionary."""
|
|
125
|
+
return cls(
|
|
126
|
+
event_type=data["event_type"],
|
|
127
|
+
data=data.get("data", {}),
|
|
128
|
+
id=data.get("id", _generate_id()),
|
|
129
|
+
timestamp=data.get("timestamp", _generate_timestamp()),
|
|
130
|
+
)
|
|
131
|
+
|