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.
- rpc_router-0.3.0/PKG-INFO +3 -0
- rpc_router-0.3.0/pyproject.toml +21 -0
- rpc_router-0.3.0/setup.cfg +4 -0
- rpc_router-0.3.0/src/rpc_router/__init__.py +0 -0
- rpc_router-0.3.0/src/rpc_router/backends/__init__.py +0 -0
- rpc_router-0.3.0/src/rpc_router/backends/fastapi/__init__.py +1 -0
- rpc_router-0.3.0/src/rpc_router/backends/fastapi/transport.py +69 -0
- rpc_router-0.3.0/src/rpc_router/backends/websockets/__init__.py +1 -0
- rpc_router-0.3.0/src/rpc_router/backends/websockets/transport.py +84 -0
- rpc_router-0.3.0/src/rpc_router/exceptions.py +10 -0
- rpc_router-0.3.0/src/rpc_router/lifecycle.py +69 -0
- rpc_router-0.3.0/src/rpc_router/protocol.py +256 -0
- rpc_router-0.3.0/src/rpc_router.egg-info/PKG-INFO +3 -0
- rpc_router-0.3.0/src/rpc_router.egg-info/SOURCES.txt +14 -0
- rpc_router-0.3.0/src/rpc_router.egg-info/dependency_links.txt +1 -0
- rpc_router-0.3.0/src/rpc_router.egg-info/top_level.txt +1 -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
|
+
]
|
|
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,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,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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rpc_router
|