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