rpc-router 0.3.0__tar.gz

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.
@@ -0,0 +1,3 @@
1
+ Metadata-Version: 2.4
2
+ Name: rpc-router
3
+ Version: 0.3.0
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "rpc-router"
3
+ dependencies = []
4
+ dynamic = ["version"]
5
+
6
+ [dependency-groups]
7
+ fastapi = ["fastapi"]
8
+ websockets = ["websockets"]
9
+
10
+ [tool.setuptools.packages.find]
11
+ where = ["src"]
12
+
13
+ [tool.uv]
14
+ package = true
15
+
16
+ [build-system]
17
+ build-backend = "setuptools.build_meta"
18
+ requires = [
19
+ "setuptools>=45",
20
+ "git-semantic-version"
21
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
File without changes
@@ -0,0 +1 @@
1
+ from .transport import FastAPIServer as FastAPIServer
@@ -0,0 +1,69 @@
1
+ from fastapi import WebSocket, WebSocketDisconnect
2
+
3
+ from ...exceptions import ConnectionBrokenException
4
+ from ...lifecycle import AbstractServer
5
+ from ...protocol import AbstractDuplexConnection, ConnectionManager, DuplexRouter
6
+
7
+
8
+ class FastAPIDuplexConnection(AbstractDuplexConnection):
9
+ """
10
+ Concrete transport mapping abstract string hooks directly to
11
+ FastAPI's native WebSocket object structure.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ websocket: WebSocket,
17
+ *,
18
+ router: DuplexRouter,
19
+ client_id: str | None = None,
20
+ manager: ConnectionManager | None = None,
21
+ ):
22
+ super().__init__(router, client_id=client_id, manager=manager)
23
+ self.ws = websocket
24
+
25
+ async def _raw_send(self, payload_str: str) -> None:
26
+ """Pushes raw text data straight through the FastAPI client pipe."""
27
+ try:
28
+ await self.ws.send_text(payload_str)
29
+ except WebSocketDisconnect:
30
+ raise ConnectionBrokenException
31
+
32
+ async def _raw_recv(self) -> str | None:
33
+ """Reads raw text out of FastAPI's incoming stream, catching disconnects safely."""
34
+ try:
35
+ return await self.ws.receive_text()
36
+ except WebSocketDisconnect:
37
+ return None
38
+
39
+ async def _raw_close(self) -> None:
40
+ """FastAPI automatically closes dropped socket tasks cleanly via context."""
41
+
42
+
43
+ class FastAPIServer(AbstractServer):
44
+ """
45
+ Concrete FastAPI Server Wrapper.
46
+ Inherits the full abstract lifecycle context and handles connecting pipes cleanly.
47
+ """
48
+
49
+ async def _raw_start(self) -> None:
50
+ """Agnostic server hook. FastAPI starts via its own ASGI server launcher (e.g., uvicorn)."""
51
+ print(
52
+ "🚀 FastAPIServer context initialized. Ready to route WebSocket pipelines."
53
+ )
54
+
55
+ async def _raw_stop(self) -> None:
56
+ """Agnostic cleanup hook. Runs automatically when the server context exits."""
57
+ print("🛑 FastAPIServer context torn down cleanly.")
58
+
59
+ async def handle_websocket(self, websocket: WebSocket) -> None:
60
+ """
61
+ Public execution gateway route method.
62
+ Accepts raw incoming websockets and couples them directly to the abstract engine.
63
+ """
64
+ conn = FastAPIDuplexConnection(
65
+ websocket, router=self.router, manager=self.manager
66
+ )
67
+
68
+ async with conn:
69
+ await conn.wait_forever()
@@ -0,0 +1 @@
1
+ from .transport import WebsocketClient as WebsocketClient, WebsocketServer as WebsocketServer
@@ -0,0 +1,84 @@
1
+ import logging
2
+
3
+ import websockets
4
+ from websockets.protocol import State
5
+
6
+ from ...exceptions import ConnectionBrokenException, ConnectionRefusedException
7
+ from ...lifecycle import AbstractClient, AbstractServer
8
+ from ...protocol import AbstractDuplexConnection, ConnectionManager, DuplexRouter
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class WebsocketsDuplexConnection(AbstractDuplexConnection):
14
+ """Framework specific pipe translation layer handling raw string frames."""
15
+
16
+ def __init__(
17
+ self,
18
+ websocket,
19
+ router: DuplexRouter,
20
+ client_id: str | None = None,
21
+ manager: ConnectionManager | None = None,
22
+ ):
23
+ super().__init__(router, client_id=client_id, manager=manager)
24
+ self.ws = websocket
25
+
26
+ async def _raw_send(self, payload_str: str) -> None:
27
+ try:
28
+ await self.ws.send(payload_str)
29
+ except websockets.exceptions.ConnectionClosed as e:
30
+ raise ConnectionBrokenException from e
31
+
32
+ async def _raw_recv(self) -> str | None:
33
+ try:
34
+ return await self.ws.recv()
35
+ except websockets.exceptions.ConnectionClosed:
36
+ return None
37
+
38
+ async def _raw_close(self) -> None:
39
+ await self.ws.close()
40
+
41
+
42
+ class WebsocketServer(AbstractServer):
43
+ """Concrete WebSocket Server mapping lifecycle hooks directly to the websockets library."""
44
+
45
+ def __init__(self, router: DuplexRouter, host: str, port: int):
46
+ super().__init__(router)
47
+ self.host = host
48
+ self.port = port
49
+ self._server = None
50
+
51
+ async def _raw_start(self) -> None:
52
+ """Concrete implementation bootstrapping the websockets loop server handle."""
53
+
54
+ async def handler(websocket):
55
+ # Context entry handles manager tracking and listener loop tasks automatically
56
+ async with WebsocketsDuplexConnection(
57
+ websocket, self.router, manager=self.manager
58
+ ) as conn:
59
+ # Abstract helper blocks the function handle until the client disconnects
60
+ await conn.wait_forever()
61
+
62
+ self._server = await websockets.serve(handler, self.host, self.port)
63
+ logger.info(f"WebSocket Server active on ws://{self.host}:{self.port}")
64
+
65
+ async def _raw_stop(self) -> None:
66
+ """Concrete implementation shutting down the web socket network listener descriptor."""
67
+ if self._server:
68
+ self._server.close()
69
+ await self._server.wait_closed()
70
+ logger.info("WebSocket Server shut down cleanly")
71
+
72
+
73
+ class WebsocketClient(AbstractClient):
74
+ """Concrete WebSocket Client mapping connection handshakes directly to the websockets library."""
75
+
76
+ def __init__(self, router: DuplexRouter, target_url: str):
77
+ super().__init__(router)
78
+ self.target_url = target_url
79
+
80
+ async def _create_connection(self) -> WebsocketsDuplexConnection:
81
+ websocket = await websockets.connect(self.target_url)
82
+ if not websocket.state == State.OPEN:
83
+ raise ConnectionRefusedException
84
+ return WebsocketsDuplexConnection(websocket, self.router)
@@ -0,0 +1,10 @@
1
+ class BaseException(Exception):
2
+ pass
3
+
4
+
5
+ class ConnectionBrokenException(BaseException):
6
+ pass
7
+
8
+
9
+ class ConnectionRefusedException(BaseException):
10
+ pass
@@ -0,0 +1,69 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from .protocol import AbstractDuplexConnection, ConnectionManager, DuplexRouter
4
+
5
+
6
+ class AbstractEndpoint(ABC):
7
+ def __init__(self, router: DuplexRouter):
8
+ self.router = router
9
+ self.on_connect = router.on_connect
10
+ self.before_receive = router.before_receive
11
+ self.before_send = router.before_send
12
+
13
+
14
+ class AbstractServer(AbstractEndpoint):
15
+ """
16
+ Abstract Lifecycle Context Manager for Servers.
17
+ Manages global registries. Subclasses only implement raw boot hooks.
18
+ """
19
+
20
+ def __init__(self, router: DuplexRouter):
21
+ super().__init__(router=router)
22
+ self.manager = ConnectionManager()
23
+
24
+ @abstractmethod
25
+ async def _raw_start(self) -> None:
26
+ """Low-level server startup primitive (e.g., websockets.serve, fastapi boot)."""
27
+
28
+ @abstractmethod
29
+ async def _raw_stop(self) -> None:
30
+ """Low-level server shutdown primitive (e.g., closing server listeners)."""
31
+
32
+ async def __aenter__(self):
33
+ """Entering the server context automatically triggers the low-level infrastructure boot."""
34
+ await self._raw_start()
35
+ return self.manager
36
+
37
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
38
+ """Exiting the server context guarantees infrastructure teardown and client purging."""
39
+ # 1. Cleanly disconnect all remaining active connections tracked by the server
40
+ for client_id, conn in list(self.manager.active_connections.items()):
41
+ if conn.is_alive:
42
+ await conn.__aexit__(None, None, None)
43
+
44
+ # 2. Trigger transport shutdown
45
+ await self._raw_stop()
46
+
47
+
48
+ class AbstractClient(AbstractEndpoint):
49
+ """
50
+ Abstract Lifecycle Context Manager for Clients.
51
+ Subclasses only implement raw socket creation hooks.
52
+ """
53
+
54
+ def __init__(self, router: DuplexRouter):
55
+ super().__init__(router=router)
56
+ self._conn: AbstractDuplexConnection | None = None
57
+
58
+ @abstractmethod
59
+ async def _create_connection(self) -> AbstractDuplexConnection:
60
+ """Subclasses override this to open a raw network stream pipe."""
61
+
62
+ async def __aenter__(self) -> AbstractDuplexConnection:
63
+ self._conn = await self._create_connection()
64
+ # Simply enter the connection's context. The connection ABC starts the background loop natively!
65
+ return await self._conn.__aenter__()
66
+
67
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
68
+ if self._conn:
69
+ return await self._conn.__aexit__(exc_type, exc_val, exc_tb)
@@ -0,0 +1,256 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import uuid
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Callable
7
+ from typing import Any, Optional, Protocol
8
+
9
+ from .exceptions import ConnectionBrokenException
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class SendReceiveHandler(Protocol):
15
+ def __call__(self, ctx: AbstractDuplexConnection, frame: any) -> any:
16
+ pass
17
+
18
+
19
+ class DuplexRouter:
20
+ """Symmetrical routing registry using an intuitive event syntax."""
21
+
22
+ def __init__(self):
23
+ self.routes: dict[str, Callable] = {}
24
+ self.connect_handler: Callable | None = None
25
+ self.receive_handlers: list[SendReceiveHandler] = []
26
+ self.send_handlers: list[SendReceiveHandler] = []
27
+
28
+ def on(self, name_or_func: str | Callable | None = None):
29
+ if callable(name_or_func):
30
+ self.routes[name_or_func.__name__] = name_or_func
31
+ return name_or_func
32
+
33
+ def decorator(func):
34
+ method_name = (
35
+ name_or_func if isinstance(name_or_func, str) else func.__name__
36
+ )
37
+ self.routes[method_name] = func
38
+ return func
39
+
40
+ return decorator
41
+
42
+ def on_connect(self, func: Callable):
43
+ self.connect_handler = func
44
+ return func
45
+
46
+ def before_receive(self, func: SendReceiveHandler):
47
+ self.receive_handlers.append(func)
48
+ return func
49
+
50
+ def before_send(self, func: SendReceiveHandler):
51
+ self.send_handlers.append(func)
52
+ return func
53
+
54
+
55
+ class ConnectionManager:
56
+ """Manages active connection lifetimes across an application topology."""
57
+
58
+ def __init__(self):
59
+ self.active_connections: dict[str, AbstractDuplexConnection] = {}
60
+
61
+ def register(self, client_id: str, connection: "AbstractDuplexConnection") -> None:
62
+ self.active_connections[client_id] = connection
63
+ logger.debug(f"Registered client: {client_id}")
64
+
65
+ def unregister(self, client_id: str) -> None:
66
+ if self.active_connections.pop(client_id, None):
67
+ logger.debug(f"Unregistered client: {client_id}")
68
+
69
+ def get(self, client_id: str) -> Optional["AbstractDuplexConnection"]:
70
+ conn = self.active_connections.get(client_id)
71
+ return conn if (conn and conn.is_alive) else None
72
+
73
+ async def broadcast(
74
+ self,
75
+ method: str,
76
+ payload: Any = None,
77
+ exclude: list[str] | None = None,
78
+ ):
79
+ if exclude is None:
80
+ exclude = []
81
+ logger.debug(f"Broadcasting {method}: {payload}")
82
+ count = 0
83
+ for client_id in self.active_connections:
84
+ if client_id in exclude:
85
+ continue
86
+ count += 1
87
+ await self.send_to(client_id=client_id, method=method, payload=payload)
88
+ logger.debug(f"Broadcasted {method}: {payload} to {count} clients")
89
+ return count
90
+
91
+ async def send_to(self, client_id: str, method: str, payload: Any = None):
92
+ logger.debug(f"Sending {method}: {payload} to {client_id}")
93
+ conn = self.active_connections.get(client_id)
94
+ if not conn:
95
+ raise RuntimeError("No client with id %s", client_id)
96
+ await conn.send(method=method, payload=payload)
97
+
98
+
99
+ class AbstractDuplexConnection(ABC):
100
+ """
101
+ Symmetrical Protocol Engine & Self-Driving Context Manager.
102
+ Completely encapsulates background task loops and lifecycles.
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ router: DuplexRouter,
108
+ client_id: str | None = None,
109
+ manager: ConnectionManager | None = None,
110
+ ):
111
+ self.router = router
112
+ self.client_id = client_id or f"rpc-{uuid.uuid4().hex[:8]}"
113
+ self.manager = manager
114
+ self.pending_calls: dict[str, asyncio.Future] = {}
115
+ self.is_alive = True
116
+ self._listener_task: asyncio.Task | None = None
117
+
118
+ @abstractmethod
119
+ async def _raw_send(self, payload_str: str) -> None:
120
+ pass
121
+
122
+ @abstractmethod
123
+ async def _raw_recv(self) -> str | None:
124
+ pass
125
+
126
+ @abstractmethod
127
+ async def _raw_close(self) -> None:
128
+ pass
129
+
130
+ async def send(self, method: str, payload: Any = None) -> None:
131
+ if not self.is_alive:
132
+ raise ConnectionBrokenException
133
+ frame = {"id": None, "type": "signal", "method": method, "payload": payload}
134
+ await self._dispatch_frame(frame)
135
+
136
+ async def _dispatch_frame(self, frame):
137
+ logger.debug("Sending %s", frame)
138
+ for handler in self.router.send_handlers:
139
+ handler(self, frame=frame)
140
+ await self._raw_send(json.dumps(frame))
141
+
142
+ async def call(self, method: str, payload: Any = None, timeout: float = 5.0) -> Any:
143
+ if not self.is_alive:
144
+ raise ConnectionBrokenException
145
+ call_id = f"rpc-{uuid.uuid4()}"
146
+ future = asyncio.get_running_loop().create_future()
147
+ self.pending_calls[call_id] = future
148
+ frame = {"id": call_id, "type": "request", "method": method, "payload": payload}
149
+ await self._dispatch_frame(frame)
150
+ try:
151
+ return await asyncio.wait_for(future, timeout=timeout)
152
+ except asyncio.TimeoutError:
153
+ self.pending_calls.pop(call_id, None)
154
+ raise TimeoutError(f"Call to '{method}' timed out")
155
+
156
+ async def wait_forever(self) -> None:
157
+ """
158
+ Abstract helper method to passively anchor any server-side or client-side context.
159
+ Blocks execution natively until the underlying network stream finishes or drops.
160
+ """
161
+ if self._listener_task:
162
+ try:
163
+ await self._listener_task
164
+ except (asyncio.CancelledError, Exception):
165
+ pass
166
+
167
+ async def __aenter__(self):
168
+ self.is_alive = True
169
+ if self.router.connect_handler:
170
+ try:
171
+ res = await self.router.connect_handler(self)
172
+ logger.debug(f"Connect handler returned {res}")
173
+ except Exception as e:
174
+ logger.error(f"Exception {e} occured during connection")
175
+ self.is_alive = False
176
+ await self._raw_close()
177
+ if self.is_alive:
178
+ if self.manager:
179
+ self.manager.register(self.client_id, self)
180
+ self._listener_task = asyncio.create_task(self._listen_loop())
181
+ return self
182
+
183
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
184
+ self.is_alive = False
185
+ if self.manager:
186
+ self.manager.unregister(self.client_id)
187
+
188
+ if self._listener_task and not self._listener_task.done():
189
+ self._listener_task.cancel()
190
+ try:
191
+ await self._listener_task
192
+ except (asyncio.CancelledError, Exception):
193
+ pass
194
+
195
+ for fut in list(self.pending_calls.values()):
196
+ if not fut.done():
197
+ fut.cancel()
198
+ self.pending_calls.clear()
199
+ await self._raw_close()
200
+
201
+ async def _listen_loop(self) -> None:
202
+ try:
203
+ while self.is_alive:
204
+ message = await self._raw_recv()
205
+ if message is None:
206
+ logger.debug("Connection terminated")
207
+ break
208
+ if self.is_alive:
209
+ asyncio.create_task(self._handle_frame(message))
210
+ finally:
211
+ self.is_alive = False
212
+
213
+ async def _handle_frame(self, raw_message: str) -> None:
214
+ try:
215
+ frame = json.loads(raw_message)
216
+ logger.debug("Handling %s", frame)
217
+ for handler in self.router.receive_handlers:
218
+ handler(self, frame=frame)
219
+ t, fid, m, p = (
220
+ frame.get("type"),
221
+ frame.get("id"),
222
+ frame.get("method"),
223
+ frame.get("payload"),
224
+ )
225
+ if t == "response" and fid in self.pending_calls:
226
+ future = self.pending_calls.pop(fid, None)
227
+ if future and not future.done():
228
+ (
229
+ future.set_exception(RuntimeError(frame["error"]))
230
+ if frame.get("error")
231
+ else future.set_result(p)
232
+ )
233
+ elif t in ("request", "signal") and (handler := self.router.routes.get(m)):
234
+ if t == "signal":
235
+ await handler(self, p)
236
+ elif t == "request" and fid:
237
+ try:
238
+ res = await handler(self, p)
239
+ frame = {
240
+ "id": fid,
241
+ "type": "response",
242
+ "method": m,
243
+ "payload": res,
244
+ }
245
+ await self._dispatch_frame(frame)
246
+ except Exception as e:
247
+ frame = {
248
+ "id": fid,
249
+ "type": "response",
250
+ "method": m,
251
+ "payload": None,
252
+ "error": str(e),
253
+ }
254
+ await self._dispatch_frame(frame)
255
+ except Exception:
256
+ pass
@@ -0,0 +1,3 @@
1
+ Metadata-Version: 2.4
2
+ Name: rpc-router
3
+ Version: 0.3.0
@@ -0,0 +1,14 @@
1
+ pyproject.toml
2
+ src/rpc_router/__init__.py
3
+ src/rpc_router/exceptions.py
4
+ src/rpc_router/lifecycle.py
5
+ src/rpc_router/protocol.py
6
+ src/rpc_router.egg-info/PKG-INFO
7
+ src/rpc_router.egg-info/SOURCES.txt
8
+ src/rpc_router.egg-info/dependency_links.txt
9
+ src/rpc_router.egg-info/top_level.txt
10
+ src/rpc_router/backends/__init__.py
11
+ src/rpc_router/backends/fastapi/__init__.py
12
+ src/rpc_router/backends/fastapi/transport.py
13
+ src/rpc_router/backends/websockets/__init__.py
14
+ src/rpc_router/backends/websockets/transport.py
@@ -0,0 +1 @@
1
+ rpc_router