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