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/handlers.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Handler management for domubus.
|
|
2
|
+
|
|
3
|
+
This module provides HandlerEntry (individual handler) and HandlerRegistry
|
|
4
|
+
(collection of handlers with priority ordering and wildcard support).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from domubus.types import EventFilter, Handler
|
|
16
|
+
|
|
17
|
+
WILDCARD = "*"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class HandlerEntry:
|
|
22
|
+
"""Represents a registered event handler.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
id: Unique identifier for this handler registration.
|
|
26
|
+
event_type: The event type this handler listens to ("*" for wildcard).
|
|
27
|
+
callback: The handler function (sync or async).
|
|
28
|
+
priority: Execution priority (higher = earlier execution).
|
|
29
|
+
once: If True, handler is removed after first execution.
|
|
30
|
+
filter_fn: Optional filter function to conditionally execute.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
id: str
|
|
34
|
+
event_type: str
|
|
35
|
+
callback: Handler
|
|
36
|
+
priority: int = 0
|
|
37
|
+
once: bool = False
|
|
38
|
+
filter_fn: EventFilter | None = None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def is_async(self) -> bool:
|
|
42
|
+
"""Check if the handler is an async function."""
|
|
43
|
+
return asyncio.iscoroutinefunction(self.callback)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def name(self) -> str:
|
|
47
|
+
"""Get the handler function name."""
|
|
48
|
+
return getattr(self.callback, "__name__", repr(self.callback))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class HandlerRegistry:
|
|
52
|
+
"""Manages event handlers with priority ordering and wildcard support.
|
|
53
|
+
|
|
54
|
+
Handlers are stored per event type and sorted by priority (highest first).
|
|
55
|
+
Wildcard handlers ("*") receive all events.
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
registry = HandlerRegistry()
|
|
59
|
+
handler_id = registry.subscribe("device.light.on", my_handler, priority=10)
|
|
60
|
+
handlers = registry.get_handlers("device.light.on")
|
|
61
|
+
registry.unsubscribe(handler_id)
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self) -> None:
|
|
65
|
+
"""Initialize an empty handler registry."""
|
|
66
|
+
self._handlers: dict[str, list[HandlerEntry]] = {}
|
|
67
|
+
self._wildcard_handlers: list[HandlerEntry] = []
|
|
68
|
+
|
|
69
|
+
def subscribe(
|
|
70
|
+
self,
|
|
71
|
+
event_type: str,
|
|
72
|
+
callback: Handler,
|
|
73
|
+
priority: int = 0,
|
|
74
|
+
once: bool = False,
|
|
75
|
+
filter_fn: EventFilter | None = None,
|
|
76
|
+
) -> str:
|
|
77
|
+
"""Register a handler for an event type.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
event_type: Event type to subscribe to ("*" for all events).
|
|
81
|
+
callback: Handler function (sync or async).
|
|
82
|
+
priority: Execution priority (higher = earlier, default 0).
|
|
83
|
+
once: If True, handler is removed after first call.
|
|
84
|
+
filter_fn: Optional filter function to conditionally execute.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Unique handler ID for later unsubscription.
|
|
88
|
+
"""
|
|
89
|
+
handler_id = str(uuid4())
|
|
90
|
+
entry = HandlerEntry(
|
|
91
|
+
id=handler_id,
|
|
92
|
+
event_type=event_type,
|
|
93
|
+
callback=callback,
|
|
94
|
+
priority=priority,
|
|
95
|
+
once=once,
|
|
96
|
+
filter_fn=filter_fn,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if event_type == WILDCARD:
|
|
100
|
+
self._wildcard_handlers.append(entry)
|
|
101
|
+
self._wildcard_handlers.sort(key=lambda h: -h.priority)
|
|
102
|
+
else:
|
|
103
|
+
if event_type not in self._handlers:
|
|
104
|
+
self._handlers[event_type] = []
|
|
105
|
+
self._handlers[event_type].append(entry)
|
|
106
|
+
self._handlers[event_type].sort(key=lambda h: -h.priority)
|
|
107
|
+
|
|
108
|
+
return handler_id
|
|
109
|
+
|
|
110
|
+
def unsubscribe(self, handler_id: str) -> bool:
|
|
111
|
+
"""Remove a handler by its ID.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
handler_id: The ID returned from subscribe().
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
True if handler was found and removed, False otherwise.
|
|
118
|
+
"""
|
|
119
|
+
# Check specific handlers
|
|
120
|
+
for handlers in self._handlers.values():
|
|
121
|
+
for i, h in enumerate(handlers):
|
|
122
|
+
if h.id == handler_id:
|
|
123
|
+
handlers.pop(i)
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
# Check wildcard handlers
|
|
127
|
+
for i, h in enumerate(self._wildcard_handlers):
|
|
128
|
+
if h.id == handler_id:
|
|
129
|
+
self._wildcard_handlers.pop(i)
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
def get_handlers(self, event_type: str) -> list[HandlerEntry]:
|
|
135
|
+
"""Get all handlers for an event type (including wildcards).
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
event_type: The event type to get handlers for.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
List of handlers sorted by priority (highest first).
|
|
142
|
+
"""
|
|
143
|
+
specific = self._handlers.get(event_type, [])
|
|
144
|
+
# Merge specific and wildcard, re-sort by priority
|
|
145
|
+
all_handlers = list(specific) + list(self._wildcard_handlers)
|
|
146
|
+
return sorted(all_handlers, key=lambda h: -h.priority)
|
|
147
|
+
|
|
148
|
+
def clear(self) -> None:
|
|
149
|
+
"""Remove all handlers."""
|
|
150
|
+
self._handlers.clear()
|
|
151
|
+
self._wildcard_handlers.clear()
|
|
152
|
+
|
|
153
|
+
def handler_count(self, event_type: str | None = None) -> int:
|
|
154
|
+
"""Count handlers, optionally filtered by event type."""
|
|
155
|
+
if event_type is None:
|
|
156
|
+
total = sum(len(h) for h in self._handlers.values())
|
|
157
|
+
return total + len(self._wildcard_handlers)
|
|
158
|
+
if event_type == WILDCARD:
|
|
159
|
+
return len(self._wildcard_handlers)
|
|
160
|
+
return len(self._handlers.get(event_type, []))
|
|
161
|
+
|
domubus/persistence.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Persistence layer for domubus.
|
|
2
|
+
|
|
3
|
+
This module provides WAL-style (Write-Ahead Logging) persistence to JSONL files.
|
|
4
|
+
Events are appended one per line as JSON, enabling efficient append and recovery.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import IO, Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JSONLPersistence:
|
|
16
|
+
"""WAL-style persistence to JSONL file.
|
|
17
|
+
|
|
18
|
+
Events are appended as JSON lines to a file. On load, corrupted lines are
|
|
19
|
+
skipped gracefully. The file can be compacted to remove old events.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
persistence = JSONLPersistence("~/.myapp/events.jsonl", max_events=10000)
|
|
23
|
+
persistence.open()
|
|
24
|
+
persistence.append({"event_type": "test", "data": {}})
|
|
25
|
+
events = persistence.load()
|
|
26
|
+
persistence.close()
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
file_path: str | Path,
|
|
32
|
+
*,
|
|
33
|
+
max_events: int = 10000,
|
|
34
|
+
fsync: bool = True,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Initialize persistence.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
file_path: Path to the JSONL file (will be created if not exists).
|
|
40
|
+
max_events: Maximum events to keep when loading/compacting.
|
|
41
|
+
fsync: If True, flush and fsync after each write for durability.
|
|
42
|
+
"""
|
|
43
|
+
self.file_path = Path(file_path).expanduser()
|
|
44
|
+
self.max_events = max_events
|
|
45
|
+
self.fsync = fsync
|
|
46
|
+
self._file: IO[str] | None = None
|
|
47
|
+
|
|
48
|
+
def open(self) -> None:
|
|
49
|
+
"""Open file for appending."""
|
|
50
|
+
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
self._file = open(self.file_path, "a", encoding="utf-8")
|
|
52
|
+
|
|
53
|
+
def close(self) -> None:
|
|
54
|
+
"""Close file handle."""
|
|
55
|
+
if self._file:
|
|
56
|
+
self._file.close()
|
|
57
|
+
self._file = None
|
|
58
|
+
|
|
59
|
+
def is_open(self) -> bool:
|
|
60
|
+
"""Check if the file is currently open."""
|
|
61
|
+
return self._file is not None
|
|
62
|
+
|
|
63
|
+
def append(self, event: dict[str, Any]) -> None:
|
|
64
|
+
"""Append an event to file (WAL-style).
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
event: Event dictionary to persist.
|
|
68
|
+
"""
|
|
69
|
+
if not self._file:
|
|
70
|
+
self.open()
|
|
71
|
+
|
|
72
|
+
assert self._file is not None # For type checker
|
|
73
|
+
line = json.dumps(event, separators=(",", ":"), default=str)
|
|
74
|
+
self._file.write(line + "\n")
|
|
75
|
+
|
|
76
|
+
if self.fsync:
|
|
77
|
+
self._file.flush()
|
|
78
|
+
os.fsync(self._file.fileno())
|
|
79
|
+
|
|
80
|
+
def load(self) -> list[dict[str, Any]]:
|
|
81
|
+
"""Load all events from file.
|
|
82
|
+
|
|
83
|
+
Corrupted lines are skipped. Only returns the last max_events.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
List of event dictionaries.
|
|
87
|
+
"""
|
|
88
|
+
if not self.file_path.exists():
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
events: list[dict[str, Any]] = []
|
|
92
|
+
with open(self.file_path, encoding="utf-8") as f:
|
|
93
|
+
for line in f:
|
|
94
|
+
line = line.strip()
|
|
95
|
+
if line:
|
|
96
|
+
try:
|
|
97
|
+
events.append(json.loads(line))
|
|
98
|
+
except json.JSONDecodeError:
|
|
99
|
+
continue # Skip corrupted lines
|
|
100
|
+
|
|
101
|
+
# Return only last max_events
|
|
102
|
+
return events[-self.max_events :]
|
|
103
|
+
|
|
104
|
+
def _load_all(self) -> list[dict[str, Any]]:
|
|
105
|
+
"""Load ALL events from file (ignores max_events limit)."""
|
|
106
|
+
if not self.file_path.exists():
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
events: list[dict[str, Any]] = []
|
|
110
|
+
with open(self.file_path, encoding="utf-8") as f:
|
|
111
|
+
for line in f:
|
|
112
|
+
line = line.strip()
|
|
113
|
+
if line:
|
|
114
|
+
try:
|
|
115
|
+
events.append(json.loads(line))
|
|
116
|
+
except json.JSONDecodeError:
|
|
117
|
+
continue
|
|
118
|
+
return events
|
|
119
|
+
|
|
120
|
+
def compact(self) -> int:
|
|
121
|
+
"""Compact file to only keep max_events.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Number of events removed.
|
|
125
|
+
"""
|
|
126
|
+
events = self._load_all()
|
|
127
|
+
if len(events) <= self.max_events:
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
removed = len(events) - self.max_events
|
|
131
|
+
kept = events[-self.max_events :]
|
|
132
|
+
|
|
133
|
+
# Rewrite file
|
|
134
|
+
self.close()
|
|
135
|
+
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
136
|
+
for event in kept:
|
|
137
|
+
line = json.dumps(event, separators=(",", ":"), default=str)
|
|
138
|
+
f.write(line + "\n")
|
|
139
|
+
|
|
140
|
+
self.open()
|
|
141
|
+
return removed
|
|
142
|
+
|
|
143
|
+
def clear(self) -> None:
|
|
144
|
+
"""Clear all events from the file."""
|
|
145
|
+
self.close()
|
|
146
|
+
if self.file_path.exists():
|
|
147
|
+
self.file_path.unlink()
|
|
148
|
+
self.open()
|
|
149
|
+
|
|
150
|
+
def event_count(self) -> int:
|
|
151
|
+
"""Count events in the file."""
|
|
152
|
+
if not self.file_path.exists():
|
|
153
|
+
return 0
|
|
154
|
+
with open(self.file_path, encoding="utf-8") as f:
|
|
155
|
+
return sum(1 for line in f if line.strip())
|
|
156
|
+
|
domubus/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
domubus/types.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Type definitions for domubus event bus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from typing import Any, Protocol, TypeVar, Union, runtime_checkable
|
|
7
|
+
|
|
8
|
+
# TypeVar for event types
|
|
9
|
+
EventT = TypeVar("EventT", bound="BaseEventProtocol")
|
|
10
|
+
EventT_co = TypeVar("EventT_co", bound="BaseEventProtocol", covariant=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class BaseEventProtocol(Protocol):
|
|
15
|
+
"""Protocol that all events must satisfy.
|
|
16
|
+
|
|
17
|
+
All events (Pydantic BaseEvent, StringEvent, or custom) must have these attributes.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def event_type(self) -> str:
|
|
22
|
+
"""The event type identifier (e.g., 'device.light.on')."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def timestamp(self) -> float:
|
|
27
|
+
"""Unix timestamp when the event was created."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def id(self) -> str:
|
|
32
|
+
"""Unique identifier for this event instance."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
"""Serialize the event to a dictionary."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Handler callback types
|
|
41
|
+
SyncHandler = Callable[[Any], None]
|
|
42
|
+
"""Synchronous handler that receives an event and returns nothing."""
|
|
43
|
+
|
|
44
|
+
AsyncHandler = Callable[[Any], Awaitable[None]]
|
|
45
|
+
"""Asynchronous handler that receives an event and returns nothing."""
|
|
46
|
+
|
|
47
|
+
Handler = Union[SyncHandler, AsyncHandler]
|
|
48
|
+
"""Union type for both sync and async handlers."""
|
|
49
|
+
|
|
50
|
+
# Error callback type
|
|
51
|
+
ErrorCallback = Callable[[Exception, Any, Handler], None]
|
|
52
|
+
"""Callback invoked when a handler raises an exception.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
exception: The exception that was raised.
|
|
56
|
+
event: The event that was being processed.
|
|
57
|
+
handler: The handler that raised the exception.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# Filter function type
|
|
61
|
+
EventFilter = Callable[[Any], bool]
|
|
62
|
+
"""Filter function to conditionally execute handlers.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
event: The event to check.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
True if the handler should be executed, False otherwise.
|
|
69
|
+
"""
|
|
70
|
+
|
domubus/watcher.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""File watcher for cross-process event synchronization.
|
|
2
|
+
|
|
3
|
+
This module provides file watching capability to enable multiple processes
|
|
4
|
+
to share events via the same JSONL persistence file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FileWatcher:
|
|
22
|
+
"""Watches a JSONL file for new events appended by other processes.
|
|
23
|
+
|
|
24
|
+
Uses file position tracking to efficiently detect new lines.
|
|
25
|
+
Only reads events appended after the watcher starts.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
watcher = FileWatcher(
|
|
29
|
+
"events.jsonl",
|
|
30
|
+
on_event=lambda e: print(f"New event: {e}"),
|
|
31
|
+
process_id="chat-123",
|
|
32
|
+
)
|
|
33
|
+
await watcher.start()
|
|
34
|
+
# ... events from other processes are now detected ...
|
|
35
|
+
await watcher.stop()
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
file_path: str | Path,
|
|
41
|
+
*,
|
|
42
|
+
on_event: Callable[[dict[str, Any]], None],
|
|
43
|
+
process_id: str | None = None,
|
|
44
|
+
poll_interval: float = 0.1,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Initialize the file watcher.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
file_path: Path to the JSONL file to watch.
|
|
50
|
+
on_event: Callback for each new event detected.
|
|
51
|
+
process_id: Unique ID for this process (to filter own events).
|
|
52
|
+
poll_interval: Seconds between file checks.
|
|
53
|
+
"""
|
|
54
|
+
self.file_path = Path(file_path).expanduser()
|
|
55
|
+
self._on_event = on_event
|
|
56
|
+
self._process_id = process_id or f"proc-{os.getpid()}"
|
|
57
|
+
self._poll_interval = poll_interval
|
|
58
|
+
|
|
59
|
+
self._position: int = 0
|
|
60
|
+
self._running = False
|
|
61
|
+
self._task: asyncio.Task[None] | None = None
|
|
62
|
+
|
|
63
|
+
async def start(self) -> None:
|
|
64
|
+
"""Start watching the file for new events."""
|
|
65
|
+
if self._running:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
# Start from end of file (only watch new events)
|
|
69
|
+
if self.file_path.exists():
|
|
70
|
+
self._position = self.file_path.stat().st_size
|
|
71
|
+
|
|
72
|
+
self._running = True
|
|
73
|
+
self._task = asyncio.create_task(self._watch_loop())
|
|
74
|
+
|
|
75
|
+
async def stop(self) -> None:
|
|
76
|
+
"""Stop watching the file."""
|
|
77
|
+
self._running = False
|
|
78
|
+
if self._task:
|
|
79
|
+
self._task.cancel()
|
|
80
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
81
|
+
await self._task
|
|
82
|
+
self._task = None
|
|
83
|
+
|
|
84
|
+
async def _watch_loop(self) -> None:
|
|
85
|
+
"""Main watch loop - polls file for new content."""
|
|
86
|
+
while self._running:
|
|
87
|
+
try:
|
|
88
|
+
await self._check_file()
|
|
89
|
+
except Exception:
|
|
90
|
+
pass # Ignore errors, keep watching
|
|
91
|
+
|
|
92
|
+
await asyncio.sleep(self._poll_interval)
|
|
93
|
+
|
|
94
|
+
async def _check_file(self) -> None:
|
|
95
|
+
"""Check file for new lines and process them."""
|
|
96
|
+
if not self.file_path.exists():
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
current_size = self.file_path.stat().st_size
|
|
100
|
+
if current_size <= self._position:
|
|
101
|
+
return # No new data
|
|
102
|
+
|
|
103
|
+
# Read new lines
|
|
104
|
+
with open(self.file_path, encoding="utf-8") as f:
|
|
105
|
+
f.seek(self._position)
|
|
106
|
+
new_content = f.read()
|
|
107
|
+
self._position = f.tell()
|
|
108
|
+
|
|
109
|
+
# Process each new line
|
|
110
|
+
for line in new_content.splitlines():
|
|
111
|
+
line = line.strip()
|
|
112
|
+
if not line:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
event = json.loads(line)
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
continue # Skip corrupted lines
|
|
119
|
+
|
|
120
|
+
# Skip events from this process (avoid echo)
|
|
121
|
+
if event.get("_source_process") == self._process_id:
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
# Dispatch to handler
|
|
125
|
+
self._on_event(event)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def is_running(self) -> bool:
|
|
129
|
+
"""Check if the watcher is currently running."""
|
|
130
|
+
return self._running
|
|
131
|
+
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: domubus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async/sync event bus with optional Pydantic integration, persistence, and priority handlers
|
|
5
|
+
Project-URL: Homepage, https://github.com/lensator/domubus
|
|
6
|
+
Project-URL: Documentation, https://github.com/lensator/domubus#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/lensator/domubus
|
|
8
|
+
Project-URL: Issues, https://github.com/lensator/domubus/issues
|
|
9
|
+
Author: voxDomus Team
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: async,event-bus,event-driven,home-automation,pubsub,pydantic
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Home Automation
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pydantic>=2.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
34
|
+
Provides-Extra: pydantic
|
|
35
|
+
Requires-Dist: pydantic>=2.0; extra == 'pydantic'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# domubus
|
|
39
|
+
|
|
40
|
+
A type-safe, async/sync event bus for Python with optional Pydantic integration, persistence, and priority handlers. **Zero required dependencies.**
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
|
|
44
|
+
- ✅ **Async & Sync** - Both async and sync handlers, emit from either context
|
|
45
|
+
- ✅ **Zero Dependencies** - Core package uses only Python stdlib
|
|
46
|
+
- ✅ **Optional Pydantic** - Type-safe events when Pydantic is installed
|
|
47
|
+
- ✅ **Priority Handlers** - Control execution order with priority values
|
|
48
|
+
- ✅ **Wildcard Subscriptions** - Subscribe to all events with `*`
|
|
49
|
+
- ✅ **One-time Handlers** - Auto-unsubscribe after first execution
|
|
50
|
+
- ✅ **Event Filters** - Conditionally execute handlers
|
|
51
|
+
- ✅ **JSONL Persistence** - WAL-style event persistence
|
|
52
|
+
- ✅ **Fully Typed** - PEP 561 compatible with `py.typed` marker
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install domubus
|
|
58
|
+
|
|
59
|
+
# With Pydantic support
|
|
60
|
+
pip install domubus[pydantic]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import asyncio
|
|
67
|
+
from domubus import EventBus
|
|
68
|
+
|
|
69
|
+
bus = EventBus()
|
|
70
|
+
|
|
71
|
+
@bus.on("device.light.on")
|
|
72
|
+
async def handle_light_on(event):
|
|
73
|
+
print(f"Light turned on: {event.data}")
|
|
74
|
+
|
|
75
|
+
@bus.on("device.light.on", priority=100)
|
|
76
|
+
def high_priority_handler(event):
|
|
77
|
+
print("This runs first (sync handler)")
|
|
78
|
+
|
|
79
|
+
async def main():
|
|
80
|
+
await bus.emit_async("device.light.on", {"brightness": 100})
|
|
81
|
+
|
|
82
|
+
asyncio.run(main())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Typed Events with Pydantic
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from typing import ClassVar
|
|
89
|
+
from domubus import EventBus, BaseEvent
|
|
90
|
+
|
|
91
|
+
class DeviceStateChanged(BaseEvent):
|
|
92
|
+
event_type: ClassVar[str] = "device.state.changed"
|
|
93
|
+
device_id: str
|
|
94
|
+
new_state: str
|
|
95
|
+
|
|
96
|
+
bus = EventBus()
|
|
97
|
+
|
|
98
|
+
@bus.on("device.state.changed")
|
|
99
|
+
async def handle_state_change(event: DeviceStateChanged):
|
|
100
|
+
print(f"Device {event.device_id} -> {event.new_state}")
|
|
101
|
+
|
|
102
|
+
await bus.emit_async(DeviceStateChanged(device_id="light1", new_state="on"))
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Persistence
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from domubus import EventBus
|
|
109
|
+
|
|
110
|
+
# Events are persisted to JSONL file
|
|
111
|
+
async with EventBus(persistence_path="~/.myapp/events.jsonl") as bus:
|
|
112
|
+
await bus.emit_async("user.login", {"user_id": "123"})
|
|
113
|
+
|
|
114
|
+
# History is automatically loaded on context enter
|
|
115
|
+
history = bus.get_history(event_type="user.login")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Wildcard Subscriptions
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
@bus.on("*")
|
|
122
|
+
def log_all_events(event):
|
|
123
|
+
print(f"Event: {event.event_type}")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## One-time Handlers
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
@bus.once("system.ready")
|
|
130
|
+
async def on_ready(event):
|
|
131
|
+
print("System ready! (only runs once)")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Event Filters
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
def only_important(event):
|
|
138
|
+
return event.data.get("priority") == "high"
|
|
139
|
+
|
|
140
|
+
@bus.on("notification", filter_fn=only_important)
|
|
141
|
+
def handle_important(event):
|
|
142
|
+
print(f"Important: {event.data}")
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Error Handling
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
def on_error(exception, event, handler):
|
|
149
|
+
print(f"Handler {handler.__name__} failed: {exception}")
|
|
150
|
+
|
|
151
|
+
bus = EventBus(error_callback=on_error)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## API Reference
|
|
155
|
+
|
|
156
|
+
### EventBus
|
|
157
|
+
|
|
158
|
+
- `subscribe(event_type, handler, priority=0, once=False, filter_fn=None)` - Subscribe handler
|
|
159
|
+
- `unsubscribe(handler_id)` - Unsubscribe by ID
|
|
160
|
+
- `on(event_type, ...)` - Decorator for subscribing
|
|
161
|
+
- `once(event_type, ...)` - Decorator for one-time handler
|
|
162
|
+
- `emit_async(event, data=None)` - Emit event asynchronously
|
|
163
|
+
- `emit(event, data=None)` - Emit (async from sync context)
|
|
164
|
+
- `emit_sync(event, data=None)` - Emit synchronously (sync handlers only)
|
|
165
|
+
- `get_history(event_type=None, limit=None)` - Get event history
|
|
166
|
+
- `clear_history()` - Clear in-memory history
|
|
167
|
+
- `clear_handlers()` - Remove all handlers
|
|
168
|
+
|
|
169
|
+
### Events
|
|
170
|
+
|
|
171
|
+
- `BaseEvent` - Pydantic-based event (or dataclass fallback)
|
|
172
|
+
- `StringEvent` - Simple string-based event with data dict
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
MIT
|
|
177
|
+
|