open-edison 0.1.19__py3-none-any.whl → 0.1.29__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.
src/events.py ADDED
@@ -0,0 +1,153 @@
1
+ """
2
+ Lightweight in-process event broadcasting for Open Edison (SSE-friendly).
3
+
4
+ Provides a simple publisher/subscriber model to stream JSON events to
5
+ connected dashboard clients over Server-Sent Events (SSE).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ from collections.abc import AsyncIterator, Callable
13
+ from functools import wraps
14
+ from typing import Any
15
+
16
+ from loguru import logger as log
17
+
18
+ _subscribers: set[asyncio.Queue[str]] = set()
19
+ _lock = asyncio.Lock()
20
+
21
+ # One-time approvals for (session_id, kind, name)
22
+ _approvals: dict[str, asyncio.Event] = {}
23
+ _approvals_lock = asyncio.Lock()
24
+
25
+
26
+ def _approval_key(session_id: str, kind: str, name: str) -> str:
27
+ return f"{session_id}::{kind}::{name}"
28
+
29
+
30
+ def requires_loop(func: Callable[..., Any]) -> Callable[..., None | Any]: # noqa: ANN401
31
+ """Decorator to ensure the function is called when there is an asyncio event loop.
32
+ This is for sync(!) functions that return None / can do so on error"""
33
+
34
+ @wraps(func)
35
+ def wrapper(*args: Any, **kwargs: Any) -> None | Any:
36
+ if asyncio.get_event_loop_policy()._local._loop is None: # type: ignore[attr-defined]
37
+ log.warning("fire_and_forget called in non-async context")
38
+ return None
39
+ return func(*args, **kwargs)
40
+
41
+ return wrapper
42
+
43
+
44
+ async def subscribe() -> asyncio.Queue[str]:
45
+ """Register a new subscriber and return its queue of SSE strings."""
46
+ queue: asyncio.Queue[str] = asyncio.Queue(maxsize=100)
47
+ async with _lock:
48
+ _subscribers.add(queue)
49
+ log.debug(f"SSE subscriber added (total={len(_subscribers)})")
50
+ return queue
51
+
52
+
53
+ async def unsubscribe(queue: asyncio.Queue[str]) -> None:
54
+ """Remove a subscriber and drain its queue."""
55
+ async with _lock:
56
+ _subscribers.discard(queue)
57
+ log.debug(f"SSE subscriber removed (total={len(_subscribers)})")
58
+ try:
59
+ while not queue.empty():
60
+ _ = queue.get_nowait()
61
+ except Exception:
62
+ pass
63
+
64
+
65
+ async def publish(event: dict[str, Any]) -> None:
66
+ """Publish a JSON event to all subscribers.
67
+
68
+ The event is serialized and wrapped as an SSE data frame.
69
+ """
70
+ try:
71
+ data = json.dumps(event, ensure_ascii=False)
72
+ except Exception as e: # noqa: BLE001
73
+ log.error(f"Failed to serialize event for SSE: {e}")
74
+ return
75
+
76
+ frame = f"data: {data}\n\n"
77
+ async with _lock:
78
+ dead: list[asyncio.Queue[str]] = []
79
+ for q in _subscribers:
80
+ try:
81
+ # Best-effort non-blocking put; drop if full to avoid backpressure
82
+ if q.full():
83
+ _ = q.get_nowait()
84
+ q.put_nowait(frame)
85
+ except Exception:
86
+ dead.append(q)
87
+ for q in dead:
88
+ _subscribers.discard(q)
89
+
90
+
91
+ @requires_loop
92
+ def fire_and_forget(event: dict[str, Any]) -> None:
93
+ """Schedule publish(event) and log any exception when the task completes."""
94
+ task = asyncio.create_task(publish(event))
95
+
96
+ def _log_exc(t: asyncio.Task[None]) -> None:
97
+ try:
98
+ _ = t.exception()
99
+ if _ is not None:
100
+ log.error(f"SSE publish failed: {_}")
101
+ except Exception as e: # noqa: BLE001
102
+ log.error(f"SSE publish done-callback error: {e}")
103
+
104
+ task.add_done_callback(_log_exc)
105
+
106
+
107
+ async def approve_once(session_id: str, kind: str, name: str) -> None:
108
+ """Approve a single pending operation for this session/kind/name.
109
+
110
+ This unblocks exactly one waiter if present (and future waiters will create a new Event).
111
+ """
112
+ key = _approval_key(session_id, kind, name)
113
+ async with _approvals_lock:
114
+ ev = _approvals.get(key)
115
+ if ev is None:
116
+ ev = asyncio.Event()
117
+ _approvals[key] = ev
118
+ ev.set()
119
+
120
+
121
+ async def wait_for_approval(session_id: str, kind: str, name: str, timeout_s: float = 30.0) -> bool:
122
+ """Wait up to timeout for approval. Consumes the approval if granted."""
123
+ key = _approval_key(session_id, kind, name)
124
+ async with _approvals_lock:
125
+ ev = _approvals.get(key)
126
+ if ev is None:
127
+ ev = asyncio.Event()
128
+ _approvals[key] = ev
129
+ try:
130
+ await asyncio.wait_for(ev.wait(), timeout=timeout_s)
131
+ return True
132
+ except TimeoutError:
133
+ return False
134
+ finally:
135
+ # Consume the event so it does not auto-approve future waits
136
+ async with _approvals_lock:
137
+ _approvals.pop(key, None)
138
+
139
+
140
+ async def sse_stream(queue: asyncio.Queue[str]) -> AsyncIterator[bytes]:
141
+ """Yield SSE frames from the given queue with periodic heartbeats."""
142
+ try:
143
+ # Initial comment to open the stream
144
+ yield b": connected\n\n"
145
+ while True:
146
+ try:
147
+ frame = await asyncio.wait_for(queue.get(), timeout=15.0)
148
+ yield frame.encode("utf-8")
149
+ except TimeoutError:
150
+ # Heartbeat to keep the connection alive
151
+ yield b": ping\n\n"
152
+ finally:
153
+ await unsubscribe(queue)