fastapi-ws-rpc 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.
- fastapi_ws_rpc/__init__.py +11 -0
- fastapi_ws_rpc/connection_manager.py +15 -0
- fastapi_ws_rpc/logger.py +113 -0
- fastapi_ws_rpc/rpc_channel.py +482 -0
- fastapi_ws_rpc/rpc_methods.py +98 -0
- fastapi_ws_rpc/schemas.py +44 -0
- fastapi_ws_rpc/simplewebsocket.py +65 -0
- fastapi_ws_rpc/utils.py +89 -0
- fastapi_ws_rpc/websocket_rpc_client.py +318 -0
- fastapi_ws_rpc/websocket_rpc_endpoint.py +141 -0
- fastapi_ws_rpc-0.1.0.dist-info/LICENSE +15 -0
- fastapi_ws_rpc-0.1.0.dist-info/METADATA +196 -0
- fastapi_ws_rpc-0.1.0.dist-info/RECORD +14 -0
- fastapi_ws_rpc-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from fastapi_ws_rpc.rpc_methods import RpcMethodsBase
|
|
2
|
+
from fastapi_ws_rpc.schemas import WebSocketFrameType
|
|
3
|
+
from fastapi_ws_rpc.websocket_rpc_client import WebSocketRpcClient
|
|
4
|
+
from fastapi_ws_rpc.websocket_rpc_endpoint import WebsocketRPCEndpoint
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"RpcMethodsBase",
|
|
8
|
+
"WebSocketRpcClient",
|
|
9
|
+
"WebsocketRPCEndpoint",
|
|
10
|
+
"WebSocketFrameType",
|
|
11
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from fastapi import WebSocket
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConnectionManager:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.active_connections: List[WebSocket] = []
|
|
9
|
+
|
|
10
|
+
async def connect(self, websocket: WebSocket):
|
|
11
|
+
await websocket.accept()
|
|
12
|
+
self.active_connections.append(websocket)
|
|
13
|
+
|
|
14
|
+
def disconnect(self, websocket: WebSocket):
|
|
15
|
+
self.active_connections.remove(websocket)
|
fastapi_ws_rpc/logger.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from logging.config import dictConfig
|
|
5
|
+
from typing import NewType
|
|
6
|
+
|
|
7
|
+
ENV_VAR = "WS_RPC_LOGGING"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LoggingModes(Enum):
|
|
11
|
+
# don't produce logs
|
|
12
|
+
NO_LOGS = 0
|
|
13
|
+
# Log alongside uvicorn
|
|
14
|
+
UVICORN = 1
|
|
15
|
+
# Simple log calls (no config)
|
|
16
|
+
SIMPLE = 2
|
|
17
|
+
# log via the loguru module
|
|
18
|
+
LOGURU = 3
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
LoggingMode = NewType("LoggingMode", LoggingModes)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LoggingConfig:
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._mode = None
|
|
27
|
+
|
|
28
|
+
config_template = {
|
|
29
|
+
"version": 1,
|
|
30
|
+
"disable_existing_loggers": True,
|
|
31
|
+
"formatters": {
|
|
32
|
+
"default": {
|
|
33
|
+
"()": "uvicorn.logging.DefaultFormatter",
|
|
34
|
+
"fmt": "%(levelprefix)s %(asctime)s %(message)s",
|
|
35
|
+
"datefmt": "%Y-%m-%d %H:%M:%S",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
"handlers": {
|
|
39
|
+
"default": {
|
|
40
|
+
"formatter": "default",
|
|
41
|
+
"class": "logging.StreamHandler",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
"loggers": {},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
UVICORN_LOGGERS = {
|
|
48
|
+
"uvicorn.error": {
|
|
49
|
+
"propagate": False,
|
|
50
|
+
"handlers": ["default"],
|
|
51
|
+
},
|
|
52
|
+
"fastapi_ws_rpc": {
|
|
53
|
+
"handlers": ["default"],
|
|
54
|
+
"propagate": False,
|
|
55
|
+
"level": logging.INFO,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
def get_mode(self):
|
|
60
|
+
# if no one set the mode - set default from ENV or hardcoded default
|
|
61
|
+
if self._mode is None:
|
|
62
|
+
mode = LoggingModes.__members__.get(
|
|
63
|
+
os.environ.get(ENV_VAR, "").upper(), LoggingModes.SIMPLE
|
|
64
|
+
)
|
|
65
|
+
self.set_mode(mode)
|
|
66
|
+
return self._mode
|
|
67
|
+
|
|
68
|
+
def set_mode(self, mode: LoggingMode = LoggingModes.UVICORN, level=logging.INFO):
|
|
69
|
+
"""
|
|
70
|
+
Configure logging. this method calls 'logging.config.dictConfig()' to enable
|
|
71
|
+
quick setup of logging. Call this method before starting the app.
|
|
72
|
+
For more advanced cases use 'logging.config' directly (loggers used by this
|
|
73
|
+
library are all nested under "fastapi_ws_rpc" logger name)
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
mode (LoggingMode, optional): The mode to set logging to. Defaults to
|
|
77
|
+
LoggingModes.UVICORN.
|
|
78
|
+
"""
|
|
79
|
+
self._mode = mode
|
|
80
|
+
logging_config = self.config_template.copy()
|
|
81
|
+
# add logs beside uvicorn
|
|
82
|
+
if mode == LoggingModes.UVICORN:
|
|
83
|
+
logging_config["loggers"] = self.UVICORN_LOGGERS.copy()
|
|
84
|
+
logging_config["loggers"]["fastapi_ws_rpc"]["level"] = level
|
|
85
|
+
dictConfig(logging_config)
|
|
86
|
+
elif mode == LoggingModes.SIMPLE:
|
|
87
|
+
pass
|
|
88
|
+
elif mode == LoggingModes.LOGURU:
|
|
89
|
+
pass
|
|
90
|
+
# no logs
|
|
91
|
+
else:
|
|
92
|
+
logging_config["loggers"] = {}
|
|
93
|
+
dictConfig(logging_config)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# Singelton for logging configuration
|
|
97
|
+
logging_config = LoggingConfig()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_logger(name):
|
|
101
|
+
"""
|
|
102
|
+
Get a logger object to log with.
|
|
103
|
+
Called by inner modules for logging.
|
|
104
|
+
"""
|
|
105
|
+
mode = logging_config.get_mode()
|
|
106
|
+
# logging through loguru
|
|
107
|
+
if mode == LoggingModes.LOGURU:
|
|
108
|
+
from loguru import logger
|
|
109
|
+
else:
|
|
110
|
+
# regular python logging
|
|
111
|
+
logger = logging.getLogger(f"fastapi_ws_rpc.{name}")
|
|
112
|
+
|
|
113
|
+
return logger
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Definition for an RPC channel protocol on top of a websocket -
|
|
3
|
+
enabling bi-directional request/response interactions
|
|
4
|
+
"""
|
|
5
|
+
import asyncio
|
|
6
|
+
from inspect import _empty, signature
|
|
7
|
+
from typing import Any, Dict, List
|
|
8
|
+
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from .logger import get_logger
|
|
12
|
+
from .rpc_methods import EXPOSED_BUILT_IN_METHODS, NoResponse, RpcMethodsBase
|
|
13
|
+
from .schemas import RpcMessage, RpcRequest, RpcResponse
|
|
14
|
+
from .utils import gen_uid, pydantic_parse
|
|
15
|
+
|
|
16
|
+
logger = get_logger("RPC_CHANNEL")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DEFAULT_TIMEOUT:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RemoteValueError(ValueError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RpcException(Exception):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RpcChannelClosedException(Exception):
|
|
32
|
+
"""
|
|
33
|
+
Raised when the channel is closed mid-operation
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UnknownMethodException(RpcException):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RpcPromise:
|
|
44
|
+
"""
|
|
45
|
+
Simple Event and id wrapper/proxy
|
|
46
|
+
Holds the state of a pending request
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, request: RpcRequest):
|
|
50
|
+
self._request = request
|
|
51
|
+
self._id = request.call_id
|
|
52
|
+
# event used to wait for the completion of the request
|
|
53
|
+
# (upon receiving its matching response)
|
|
54
|
+
self._event = asyncio.Event()
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def request(self):
|
|
58
|
+
return self._request
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def call_id(self):
|
|
62
|
+
return self._id
|
|
63
|
+
|
|
64
|
+
def set(self):
|
|
65
|
+
"""
|
|
66
|
+
Signal completion of request with received response
|
|
67
|
+
"""
|
|
68
|
+
self._event.set()
|
|
69
|
+
|
|
70
|
+
def wait(self):
|
|
71
|
+
"""
|
|
72
|
+
Wait on the internal event - triggered on response
|
|
73
|
+
"""
|
|
74
|
+
return self._event.wait()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class RpcProxy:
|
|
78
|
+
"""Provides a proxy to a remote method on the other side of the channel."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, channel, method_name: str) -> None:
|
|
81
|
+
self.channel = channel
|
|
82
|
+
self.method_name = method_name
|
|
83
|
+
|
|
84
|
+
def __call__(self, **kwargs: Any) -> Any:
|
|
85
|
+
"""Calls the remote method with the given keyword arguments.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
kwargs : dict
|
|
90
|
+
Keyword arguments to pass to the remote method.
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
Any
|
|
95
|
+
Return value of the remote method.
|
|
96
|
+
"""
|
|
97
|
+
logger.debug("Calling RPC method: %s", self.method_name)
|
|
98
|
+
return self.channel.call(self.method_name, args=kwargs)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RpcCaller:
|
|
102
|
+
"""Calls remote methods on the other side of the channel."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, channel: "RpcChannel", methods=None) -> None:
|
|
105
|
+
self._channel = channel
|
|
106
|
+
|
|
107
|
+
def __getattribute__(self, name: str):
|
|
108
|
+
"""Returns an :class:`~fastapi_ws_rpc.rpc_channel.RpcProxy` instance
|
|
109
|
+
for exposed RPC methods.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
name : str
|
|
114
|
+
Name of the requested attribute (or RPC method).
|
|
115
|
+
|
|
116
|
+
Returns
|
|
117
|
+
-------
|
|
118
|
+
Any
|
|
119
|
+
Attribute or RPC method.
|
|
120
|
+
"""
|
|
121
|
+
is_exposed = not name.startswith("_") or name in EXPOSED_BUILT_IN_METHODS
|
|
122
|
+
if is_exposed:
|
|
123
|
+
logger.debug("%s was detected to be a remote RPC method.", name)
|
|
124
|
+
return RpcProxy(self._channel, name)
|
|
125
|
+
return super().__getattribute__(name)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Callback signatures
|
|
129
|
+
async def OnConnectCallback(channel):
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def OnDisconnectCallback(channel):
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def OnErrorCallback(channel, err: Exception):
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class RpcChannel:
|
|
142
|
+
"""
|
|
143
|
+
A wire agnostic json-rpc channel protocol for both server and client.
|
|
144
|
+
Enable each side to send RPC-requests (calling exposed methods on other side)
|
|
145
|
+
and receive rpc-responses with the return value
|
|
146
|
+
|
|
147
|
+
provides a .other property for calling remote methods.
|
|
148
|
+
e.g. answer = channel.other.add(a=1,b=1) will (For example) ask the other
|
|
149
|
+
side to perform 1+1 and will return an RPC-response of 2
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
methods: RpcMethodsBase,
|
|
155
|
+
socket,
|
|
156
|
+
channel_id=None,
|
|
157
|
+
default_response_timeout=None,
|
|
158
|
+
sync_channel_id=False,
|
|
159
|
+
**kwargs,
|
|
160
|
+
):
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
methods (RpcMethodsBase): RPC methods to expose to other side
|
|
165
|
+
socket: socket object providing simple send/recv methods
|
|
166
|
+
channel_id (str, optional): uuid for channel. Defaults to None in
|
|
167
|
+
which case a random UUID is generated.
|
|
168
|
+
default_response_timeout(float, optional) default timeout for RPC
|
|
169
|
+
call responses. Defaults to None - i.e. no timeout
|
|
170
|
+
sync_channel_id(bool, optional) should get the other side of the
|
|
171
|
+
channel id, helps to identify connections, cost a bit networking time.
|
|
172
|
+
Defaults to False - i.e. not getting the other side channel id
|
|
173
|
+
"""
|
|
174
|
+
logger.debug("Initializing RPC channel...")
|
|
175
|
+
self.methods = methods._copy_()
|
|
176
|
+
# allow methods to access channel (for recursive calls - e.g. call me as
|
|
177
|
+
# a response for me calling you)
|
|
178
|
+
self.methods._set_channel_(self)
|
|
179
|
+
# Pending requests - id-mapped to async-event
|
|
180
|
+
self.requests: Dict[str, asyncio.Event] = {}
|
|
181
|
+
# Received responses
|
|
182
|
+
self.responses = {}
|
|
183
|
+
self.socket = socket
|
|
184
|
+
# timeout
|
|
185
|
+
self.default_response_timeout = default_response_timeout
|
|
186
|
+
# Unique channel id
|
|
187
|
+
self.id = channel_id if channel_id is not None else gen_uid()
|
|
188
|
+
# flag control should we retrieve the channel id of the other side
|
|
189
|
+
self._sync_channel_id = sync_channel_id
|
|
190
|
+
# The channel id of the other side (if sync_channel_id is True)
|
|
191
|
+
self._other_channel_id = None
|
|
192
|
+
# asyncio event to check if we got the channel id of the other side
|
|
193
|
+
self._channel_id_synced = asyncio.Event()
|
|
194
|
+
#
|
|
195
|
+
# convenience caller
|
|
196
|
+
# TODO - pass remote methods object to support validation before call
|
|
197
|
+
self.other = RpcCaller(self)
|
|
198
|
+
# core event callback registers
|
|
199
|
+
self._connect_handlers: List[OnConnectCallback] = []
|
|
200
|
+
self._disconnect_handlers: List[OnDisconnectCallback] = []
|
|
201
|
+
self._error_handlers = []
|
|
202
|
+
# internal event
|
|
203
|
+
self._closed = asyncio.Event()
|
|
204
|
+
|
|
205
|
+
# any other kwarg goes straight to channel context (Accessible to methods)
|
|
206
|
+
self._context = kwargs or {}
|
|
207
|
+
logger.debug("RPC channel initialized.")
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def context(self) -> Dict[str, Any]:
|
|
211
|
+
return self._context
|
|
212
|
+
|
|
213
|
+
async def get_other_channel_id(self) -> str:
|
|
214
|
+
"""
|
|
215
|
+
Method to get the channel id of the other side of the channel
|
|
216
|
+
The _channel_id_synced verify we have it
|
|
217
|
+
Timeout exception can be raised if the value isn't available
|
|
218
|
+
"""
|
|
219
|
+
await asyncio.wait_for(
|
|
220
|
+
self._channel_id_synced.wait(), self.default_response_timeout
|
|
221
|
+
)
|
|
222
|
+
return self._other_channel_id
|
|
223
|
+
|
|
224
|
+
def get_return_type(self, method):
|
|
225
|
+
method_signature = signature(method)
|
|
226
|
+
return (
|
|
227
|
+
method_signature.return_annotation
|
|
228
|
+
if method_signature.return_annotation is not _empty
|
|
229
|
+
else str
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
async def send(self, data):
|
|
233
|
+
"""
|
|
234
|
+
For internal use. wrap calls to underlying socket
|
|
235
|
+
"""
|
|
236
|
+
await self.socket.send(data)
|
|
237
|
+
|
|
238
|
+
async def receive(self):
|
|
239
|
+
"""
|
|
240
|
+
For internal use. wrap calls to underlying socket
|
|
241
|
+
"""
|
|
242
|
+
return await self.socket.recv()
|
|
243
|
+
|
|
244
|
+
async def close(self):
|
|
245
|
+
res = await self.socket.close()
|
|
246
|
+
# signal closer
|
|
247
|
+
self._closed.set()
|
|
248
|
+
return res
|
|
249
|
+
|
|
250
|
+
def isClosed(self):
|
|
251
|
+
return self._closed.is_set()
|
|
252
|
+
|
|
253
|
+
async def wait_until_closed(self):
|
|
254
|
+
"""
|
|
255
|
+
Waits until the close internal event happens.
|
|
256
|
+
"""
|
|
257
|
+
return await self._closed.wait()
|
|
258
|
+
|
|
259
|
+
async def on_message(self, data):
|
|
260
|
+
"""Handle an incoming RPC message."""
|
|
261
|
+
logger.debug(f"Processing received message: {data}")
|
|
262
|
+
try:
|
|
263
|
+
message = pydantic_parse(RpcMessage, data)
|
|
264
|
+
if message.request is not None:
|
|
265
|
+
await self.on_request(message.request)
|
|
266
|
+
if message.response is not None:
|
|
267
|
+
await self.on_response(message.response)
|
|
268
|
+
except ValidationError as e:
|
|
269
|
+
logger.error("Failed to parse message", {"message": data, "error": e})
|
|
270
|
+
await self.on_error(e)
|
|
271
|
+
except Exception as e:
|
|
272
|
+
await self.on_error(e)
|
|
273
|
+
raise
|
|
274
|
+
|
|
275
|
+
def register_connect_handler(self, coros: List[OnConnectCallback] = None):
|
|
276
|
+
"""
|
|
277
|
+
Register a connection handler callback that will be called (As an async
|
|
278
|
+
task)) with the channel
|
|
279
|
+
Args:
|
|
280
|
+
coros (List[Coroutine]): async callback
|
|
281
|
+
"""
|
|
282
|
+
if coros is not None:
|
|
283
|
+
self._connect_handlers.extend(coros)
|
|
284
|
+
|
|
285
|
+
def register_disconnect_handler(self, coros: List[OnDisconnectCallback] = None):
|
|
286
|
+
"""
|
|
287
|
+
Register a disconnect handler callback that will be called (As an async
|
|
288
|
+
task)) with the channel id
|
|
289
|
+
Args:
|
|
290
|
+
coros (List[Coroutine]): async callback
|
|
291
|
+
"""
|
|
292
|
+
if coros is not None:
|
|
293
|
+
self._disconnect_handlers.extend(coros)
|
|
294
|
+
|
|
295
|
+
def register_error_handler(self, coros: List[OnErrorCallback] = None):
|
|
296
|
+
"""
|
|
297
|
+
Register an error handler callback that will be called (As an async
|
|
298
|
+
task)) with the channel and triggered error.
|
|
299
|
+
Args:
|
|
300
|
+
coros (List[Coroutine]): async callback
|
|
301
|
+
"""
|
|
302
|
+
if coros is not None:
|
|
303
|
+
self._error_handlers.extend(coros)
|
|
304
|
+
|
|
305
|
+
async def on_handler_event(self, handlers, *args, **kwargs):
|
|
306
|
+
await asyncio.gather(*(callback(*args, **kwargs) for callback in handlers))
|
|
307
|
+
|
|
308
|
+
async def on_connect(self):
|
|
309
|
+
"""
|
|
310
|
+
Run get other channel id if sync_channel_id is True
|
|
311
|
+
Run all callbacks from self._connect_handlers
|
|
312
|
+
"""
|
|
313
|
+
if self._sync_channel_id:
|
|
314
|
+
logger.debug("Syncing channel ID...")
|
|
315
|
+
self._get_other_channel_id_task = asyncio.create_task(
|
|
316
|
+
self._get_other_channel_id()
|
|
317
|
+
)
|
|
318
|
+
await self.on_handler_event(self._connect_handlers, self)
|
|
319
|
+
|
|
320
|
+
async def _get_other_channel_id(self):
|
|
321
|
+
"""
|
|
322
|
+
Perform call to the other side of the channel to get its channel id
|
|
323
|
+
Each side is generating the channel id by itself so there is no way
|
|
324
|
+
to identify a connection without this sync
|
|
325
|
+
"""
|
|
326
|
+
if self._other_channel_id is None:
|
|
327
|
+
logger.debug("No cached channel ID found, calling _get_channel_id_()...")
|
|
328
|
+
other_channel_id = await self.other._get_channel_id_()
|
|
329
|
+
self._other_channel_id = (
|
|
330
|
+
other_channel_id.result
|
|
331
|
+
if other_channel_id and other_channel_id.result
|
|
332
|
+
else None
|
|
333
|
+
)
|
|
334
|
+
logger.debug("Got channel ID: %s", self._other_channel_id)
|
|
335
|
+
if self._other_channel_id is None:
|
|
336
|
+
raise RemoteValueError()
|
|
337
|
+
# update asyncio event that we received remote channel id
|
|
338
|
+
self._channel_id_synced.set()
|
|
339
|
+
return self._other_channel_id
|
|
340
|
+
return self._other_channel_id
|
|
341
|
+
|
|
342
|
+
async def on_disconnect(self):
|
|
343
|
+
# disconnect happened - mark the channel as closed
|
|
344
|
+
self._closed.set()
|
|
345
|
+
await self.on_handler_event(self._disconnect_handlers, self)
|
|
346
|
+
|
|
347
|
+
async def on_error(self, error: Exception):
|
|
348
|
+
await self.on_handler_event(self._error_handlers, self, error)
|
|
349
|
+
|
|
350
|
+
async def on_request(self, message: RpcRequest):
|
|
351
|
+
"""
|
|
352
|
+
Handle incoming RPC requests - calling relevant exposed method
|
|
353
|
+
Note: methods prefixed with "_" are protected and ignored.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
message (RpcRequest): the RPC request with the method to call
|
|
357
|
+
"""
|
|
358
|
+
# TODO add exception support (catch exceptions and pass to other side
|
|
359
|
+
# as response with errors)
|
|
360
|
+
logger.debug(f"Handling RPC request on channel {self.id}: {message}")
|
|
361
|
+
method_name = message.method
|
|
362
|
+
# Ignore "_" prefixed methods (except the built in "_ping_")
|
|
363
|
+
if isinstance(method_name, str) and (
|
|
364
|
+
not method_name.startswith("_") or method_name in EXPOSED_BUILT_IN_METHODS
|
|
365
|
+
):
|
|
366
|
+
method = getattr(self.methods, method_name)
|
|
367
|
+
if callable(method):
|
|
368
|
+
result = await method(**message.arguments)
|
|
369
|
+
if result is not NoResponse:
|
|
370
|
+
# get indicated type
|
|
371
|
+
result_type = self.get_return_type(method)
|
|
372
|
+
# if no type given - try to convert to string
|
|
373
|
+
if result_type is str and not isinstance(result, str):
|
|
374
|
+
result = str(result)
|
|
375
|
+
response = RpcMessage(
|
|
376
|
+
request=RpcRequest(
|
|
377
|
+
method=method_name,
|
|
378
|
+
arguments=message.arguments,
|
|
379
|
+
call_id=message.call_id,
|
|
380
|
+
),
|
|
381
|
+
response=RpcResponse[result_type](
|
|
382
|
+
call_id=message.call_id,
|
|
383
|
+
result=result,
|
|
384
|
+
result_type=getattr(
|
|
385
|
+
result_type,
|
|
386
|
+
"__name__",
|
|
387
|
+
getattr(result_type, "_name", "unknown-type"),
|
|
388
|
+
),
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
await self.send(response)
|
|
392
|
+
|
|
393
|
+
def get_saved_promise(self, call_id):
|
|
394
|
+
return self.requests[call_id]
|
|
395
|
+
|
|
396
|
+
def get_saved_response(self, call_id):
|
|
397
|
+
return self.responses[call_id]
|
|
398
|
+
|
|
399
|
+
def clear_saved_call(self, call_id):
|
|
400
|
+
del self.requests[call_id]
|
|
401
|
+
del self.responses[call_id]
|
|
402
|
+
|
|
403
|
+
async def on_response(self, response: RpcResponse):
|
|
404
|
+
"""
|
|
405
|
+
Handle an incoming response to a previous RPC call
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
response (RpcResponse): the received response
|
|
409
|
+
"""
|
|
410
|
+
logger.debug("Handling RPC response - %s", {"response": response})
|
|
411
|
+
if response.call_id is not None and response.call_id in self.requests:
|
|
412
|
+
self.responses[response.call_id] = response
|
|
413
|
+
promise = self.requests[response.call_id]
|
|
414
|
+
promise.set()
|
|
415
|
+
|
|
416
|
+
async def wait_for_response(self, promise, timeout=DEFAULT_TIMEOUT) -> RpcResponse:
|
|
417
|
+
"""
|
|
418
|
+
Wait on a previously made call
|
|
419
|
+
Args:
|
|
420
|
+
promise (RpcPromise): the awaitable-wrapper returned from the RPC
|
|
421
|
+
request call
|
|
422
|
+
timeout (float, None, or DEFAULT_TIMEOUT): the timeout to wait on
|
|
423
|
+
the response, defaults to DEFAULT_TIMEOUT.
|
|
424
|
+
- DEFAULT_TIMEOUT - use the value passed as
|
|
425
|
+
'default_response_timeout' in channel init
|
|
426
|
+
- None - no timeout
|
|
427
|
+
- a number - seconds to wait before timing out
|
|
428
|
+
Raises:
|
|
429
|
+
asyncio.exceptions.TimeoutError - on timeout
|
|
430
|
+
RpcChannelClosedException - if the channel fails before wait could
|
|
431
|
+
be completed
|
|
432
|
+
"""
|
|
433
|
+
if timeout is DEFAULT_TIMEOUT:
|
|
434
|
+
timeout = self.default_response_timeout
|
|
435
|
+
# wait for the promise or until the channel is terminated
|
|
436
|
+
_, pending = await asyncio.wait(
|
|
437
|
+
[
|
|
438
|
+
asyncio.ensure_future(promise.wait()),
|
|
439
|
+
asyncio.ensure_future(self._closed.wait()),
|
|
440
|
+
],
|
|
441
|
+
timeout=timeout,
|
|
442
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
443
|
+
)
|
|
444
|
+
# Cancel all pending futures and then detect if close was the first done
|
|
445
|
+
for fut in pending:
|
|
446
|
+
fut.cancel()
|
|
447
|
+
response = self.responses.get(promise.call_id, NoResponse)
|
|
448
|
+
# if the channel was closed before we could finish
|
|
449
|
+
if response is NoResponse:
|
|
450
|
+
raise RpcChannelClosedException(
|
|
451
|
+
f"Channel Closed before RPC response for {promise.call_id} could be received" # noqa: E501
|
|
452
|
+
)
|
|
453
|
+
self.clear_saved_call(promise.call_id)
|
|
454
|
+
return response
|
|
455
|
+
|
|
456
|
+
async def async_call(self, name, args={}, call_id=None) -> RpcPromise:
|
|
457
|
+
"""
|
|
458
|
+
Call a method and return the event and the sent message (including the
|
|
459
|
+
chosen call_id)
|
|
460
|
+
use self.wait_for_response on the event and call_id to get the return
|
|
461
|
+
value of the call
|
|
462
|
+
Args:
|
|
463
|
+
name (str): name of the method to call on the other side
|
|
464
|
+
args (dict): keyword args to pass tot he other side
|
|
465
|
+
call_id (string, optional): a UUID to use to track the call () -
|
|
466
|
+
override only with true UUIDs
|
|
467
|
+
"""
|
|
468
|
+
call_id = call_id or gen_uid()
|
|
469
|
+
message = RpcMessage(
|
|
470
|
+
request=RpcRequest(method=name, arguments=args, call_id=call_id)
|
|
471
|
+
)
|
|
472
|
+
logger.debug("Sending the following RPC message: %s", message)
|
|
473
|
+
await self.send(message)
|
|
474
|
+
promise = self.requests[message.request.call_id] = RpcPromise(message.request)
|
|
475
|
+
return promise
|
|
476
|
+
|
|
477
|
+
async def call(self, name, args={}, timeout=DEFAULT_TIMEOUT):
|
|
478
|
+
"""
|
|
479
|
+
Call a method and wait for a response to be received
|
|
480
|
+
"""
|
|
481
|
+
promise = await self.async_call(name, args)
|
|
482
|
+
return await self.wait_for_response(promise, timeout=timeout)
|