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.
- {open_edison-0.1.19.dist-info → open_edison-0.1.29.dist-info}/METADATA +66 -45
- open_edison-0.1.29.dist-info/RECORD +17 -0
- src/cli.py +2 -1
- src/config.py +71 -71
- src/events.py +153 -0
- src/middleware/data_access_tracker.py +164 -434
- src/middleware/session_tracking.py +133 -37
- src/oauth_manager.py +281 -0
- src/permissions.py +281 -0
- src/server.py +491 -134
- src/single_user_mcp.py +230 -158
- src/telemetry.py +4 -40
- open_edison-0.1.19.dist-info/RECORD +0 -14
- {open_edison-0.1.19.dist-info → open_edison-0.1.29.dist-info}/WHEEL +0 -0
- {open_edison-0.1.19.dist-info → open_edison-0.1.29.dist-info}/entry_points.txt +0 -0
- {open_edison-0.1.19.dist-info → open_edison-0.1.29.dist-info}/licenses/LICENSE +0 -0
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)
|