Skip to content

Commit c124c70

Browse files
committed
Add tests
1 parent 1ab6791 commit c124c70

File tree

8 files changed

+900
-6
lines changed

8 files changed

+900
-6
lines changed

.pylintrc

-4
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,3 @@ disable =
2020
[REPORTS]
2121

2222
reports = no
23-
24-
[TYPECHECK]
25-
26-
generated-members = pyls_*

jsonrpc/dispatchers.py

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Copyright 2018 Palantir Technologies, Inc.
2+
import functools
3+
import re
4+
5+
_RE_FIRST_CAP = re.compile('(.)([A-Z][a-z]+)')
6+
_RE_ALL_CAP = re.compile('([a-z0-9])([A-Z])')
7+
8+
9+
class MethodDispatcher(object):
10+
"""JSON RPC dispatcher that calls methods on itself.
11+
12+
Method names are computed by converting camel case to snake case, slashes with double underscores, and removing
13+
dollar signs.
14+
"""
15+
16+
def __getitem__(self, item):
17+
method_name = 'm_{}'.format(_method_to_string(item))
18+
if hasattr(self, method_name):
19+
method = getattr(self, method_name)
20+
21+
@functools.wraps(method)
22+
def handler(params):
23+
return method(**(params or {}))
24+
25+
return handler
26+
raise KeyError()
27+
28+
29+
def _method_to_string(method):
30+
return _camel_to_underscore(method.replace("/", "__").replace("$", ""))
31+
32+
33+
def _camel_to_underscore(string):
34+
s1 = _RE_FIRST_CAP.sub(r'\1_\2', string)
35+
return _RE_ALL_CAP.sub(r'\1_\2', s1).lower()

jsonrpc/endpoint.py

+237
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
# Copyright 2018 Palantir Technologies, Inc.
2+
import logging
3+
import uuid
4+
import sys
5+
6+
from concurrent import futures
7+
from .exceptions import JsonRpcException, JsonRpcRequestCancelled, JsonRpcInternalError, JsonRpcMethodNotFound
8+
9+
log = logging.getLogger(__name__)
10+
JSONRPC_VERSION = '2.0'
11+
CANCEL_METHOD = '$/cancelRequest'
12+
13+
14+
class Endpoint(object):
15+
16+
def __init__(self, dispatcher, consumer, id_generator=lambda: str(uuid.uuid4()), max_workers=5):
17+
"""A JSON RPC endpoint for managing messages sent to/from the client.
18+
19+
Args:
20+
dispatcher (dict): A dictionary of method name to handler function.
21+
The handler functions should return either the result or a callable that will be used to asynchronously
22+
compute the result.
23+
consumer (fn): A function that consumes JSON RPC message dicts and sends them to the client.
24+
id_generator (fn, optional): A function used to generate request IDs.
25+
Defaults to the string value of :func:`uuid.uuid4`.
26+
max_workers (int, optional): The number of workers in the asynchronous executor pool.
27+
"""
28+
self._dispatcher = dispatcher
29+
self._consumer = consumer
30+
self._id_generator = id_generator
31+
32+
self._client_request_futures = {}
33+
self._server_request_futures = {}
34+
self._executor_service = futures.ThreadPoolExecutor(max_workers=max_workers)
35+
36+
def shutdown(self):
37+
self._executor_service.shutdown()
38+
39+
def notify(self, method, params=None):
40+
"""Send a JSON RPC notification to the client.
41+
42+
Args:
43+
method (str): The method name of the notification to send
44+
params (any): The payload of the notification
45+
"""
46+
log.debug('Sending notification: %s %s', method, params)
47+
48+
message = {
49+
'jsonrpc': JSONRPC_VERSION,
50+
'method': method,
51+
}
52+
if params is not None:
53+
message['params'] = params
54+
55+
self._consumer(message)
56+
57+
def request(self, method, params=None):
58+
"""Send a JSON RPC request to the client.
59+
60+
Args:
61+
method (str): The method name of the message to send
62+
params (any): The payload of the message
63+
64+
Returns:
65+
Future that will resolve once a response has been received
66+
"""
67+
msg_id = self._id_generator()
68+
log.debug('Sending request with id %s: %s %s', msg_id, method, params)
69+
70+
message = {
71+
'jsonrpc': JSONRPC_VERSION,
72+
'id': msg_id,
73+
'method': method,
74+
}
75+
if params is not None:
76+
message['params'] = params
77+
78+
request_future = futures.Future()
79+
request_future.add_done_callback(self._cancel_callback(msg_id))
80+
81+
self._server_request_futures[msg_id] = request_future
82+
self._consumer(message)
83+
84+
return request_future
85+
86+
def _cancel_callback(self, request_id):
87+
"""Construct a cancellation callback for the given request ID."""
88+
def callback(future):
89+
if future.cancelled():
90+
self.notify(CANCEL_METHOD, {'id': request_id})
91+
future.set_exception(JsonRpcRequestCancelled())
92+
return callback
93+
94+
def consume(self, message):
95+
"""Consume a JSON RPC message from the client.
96+
97+
Args:
98+
message (dict): The JSON RPC message sent by the client
99+
"""
100+
if 'jsonrpc' not in message or message['jsonrpc'] != JSONRPC_VERSION:
101+
log.warn("Unknown message type %s", message)
102+
return
103+
104+
if 'id' not in message:
105+
log.debug("Handling notification from client %s", message)
106+
self._handle_notification(message['method'], message.get('params'))
107+
elif 'method' not in message:
108+
log.debug("Handling response from client %s", message)
109+
self._handle_response(message['id'], message.get('result'), message.get('error'))
110+
else:
111+
try:
112+
log.debug("Handling request from client %s", message)
113+
self._handle_request(message['id'], message['method'], message.get('params'))
114+
except JsonRpcException as e:
115+
log.exception("Failed to handle request %s", message['id'])
116+
self._consumer({
117+
'jsonrpc': JSONRPC_VERSION,
118+
'id': message['id'],
119+
'error': e.to_dict()
120+
})
121+
except Exception: # pylint: disable=broad-except
122+
log.exception("Failed to handle request %s", message['id'])
123+
self._consumer({
124+
'jsonrpc': JSONRPC_VERSION,
125+
'id': message['id'],
126+
'error': JsonRpcInternalError.of(sys.exc_info()).to_dict()
127+
})
128+
129+
def _handle_notification(self, method, params):
130+
"""Handle a notification from the client."""
131+
if method == CANCEL_METHOD:
132+
self._handle_cancel_notification(params['id'])
133+
return
134+
135+
try:
136+
handler = self._dispatcher[method]
137+
except KeyError:
138+
log.warn("Ignoring notification for unknown method %s", method)
139+
return
140+
141+
try:
142+
handler_result = handler(params)
143+
except Exception: # pylint: disable=broad-except
144+
log.exception("Failed to handle notification %s: %s", method, params)
145+
return
146+
147+
if callable(handler_result):
148+
log.debug("Executing async notification handler %s", handler_result)
149+
notification_future = self._executor_service.submit(handler_result)
150+
notification_future.add_done_callback(self._notification_callback(method, params))
151+
152+
@staticmethod
153+
def _notification_callback(method, params):
154+
"""Construct a notification callback for the given request ID."""
155+
def callback(future):
156+
try:
157+
future.result()
158+
log.debug("Successfully handled async notification %s %s", method, params)
159+
except Exception: # pylint: disable=broad-except
160+
log.exception("Failed to handle async notification %s %s", method, params)
161+
return callback
162+
163+
def _handle_cancel_notification(self, msg_id):
164+
"""Handle a cancel notification from the client."""
165+
request_future = self._client_request_futures.pop(msg_id, None)
166+
167+
if not request_future:
168+
log.warn("Received cancel notification for unknown message id %s", msg_id)
169+
return
170+
171+
# Will only work if the request hasn't started executing
172+
if request_future.cancel():
173+
log.debug("Cancelled request with id %s", msg_id)
174+
175+
def _handle_request(self, msg_id, method, params):
176+
"""Handle a request from the client."""
177+
try:
178+
handler = self._dispatcher[method]
179+
except KeyError:
180+
raise JsonRpcMethodNotFound.of(method)
181+
182+
handler_result = handler(params)
183+
184+
if callable(handler_result):
185+
log.debug("Executing async request handler %s", handler_result)
186+
request_future = self._executor_service.submit(handler_result)
187+
self._client_request_futures[msg_id] = request_future
188+
request_future.add_done_callback(self._request_callback(msg_id))
189+
else:
190+
log.debug("Got result from synchronous request handler: %s", handler_result)
191+
self._consumer({
192+
'jsonrpc': JSONRPC_VERSION,
193+
'id': msg_id,
194+
'result': handler_result
195+
})
196+
197+
def _request_callback(self, request_id):
198+
"""Construct a request callback for the given request ID."""
199+
def callback(future):
200+
# Remove the future from the client requests map
201+
self._client_request_futures.pop(request_id, None)
202+
203+
if future.cancelled():
204+
future.set_exception(JsonRpcRequestCancelled())
205+
206+
message = {
207+
'jsonrpc': JSONRPC_VERSION,
208+
'id': request_id,
209+
}
210+
211+
try:
212+
message['result'] = future.result()
213+
except JsonRpcException as e:
214+
log.exception("Failed to handle request %s", request_id)
215+
message['error'] = e.to_dict()
216+
except Exception: # pylint: disable=broad-except
217+
log.exception("Failed to handle request %s", request_id)
218+
message['error'] = JsonRpcInternalError.of(sys.exc_info()).to_dict()
219+
220+
self._consumer(message)
221+
222+
return callback
223+
224+
def _handle_response(self, msg_id, result=None, error=None):
225+
"""Handle a response from the client."""
226+
request_future = self._server_request_futures.pop(msg_id, None)
227+
228+
if not request_future:
229+
log.warn("Received response to unknown message id %s", msg_id)
230+
return
231+
232+
if error is not None:
233+
log.debug("Received error response to message %s: %s", msg_id, error)
234+
request_future.set_exception(JsonRpcException.from_dict(error))
235+
236+
log.debug("Received result for message %s: %s", msg_id, result)
237+
request_future.set_result(result)

jsonrpc/exceptions.py

+110
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Copyright 2018 Palantir Technologies, Inc.
2+
import traceback
3+
4+
5+
class JsonRpcException(Exception):
6+
7+
def __init__(self, message=None, code=None, data=None):
8+
super(JsonRpcException, self).__init__()
9+
self.message = message or getattr(self.__class__, 'MESSAGE')
10+
self.code = code or getattr(self.__class__, 'CODE')
11+
self.data = data
12+
13+
def to_dict(self):
14+
exception_dict = {
15+
'code': self.code,
16+
'message': self.message,
17+
}
18+
if self.data is not None:
19+
exception_dict['data'] = self.data
20+
return exception_dict
21+
22+
def __eq__(self, other):
23+
return (
24+
isinstance(other, self.__class__) and
25+
self.code == other.code and
26+
self.message == other.message
27+
)
28+
29+
def __hash__(self):
30+
return hash((self.code, self.message))
31+
32+
@staticmethod
33+
def from_dict(error):
34+
for exc_class in _EXCEPTIONS:
35+
if exc_class.supports_code(error['code']):
36+
return exc_class(**error)
37+
return JsonRpcException(**error)
38+
39+
@classmethod
40+
def supports_code(cls, code):
41+
# Defaults to UnknownErrorCode
42+
return getattr(cls, 'CODE', -32001) == code
43+
44+
45+
class JsonRpcParseError(JsonRpcException):
46+
CODE = -32700
47+
MESSAGE = 'Parse Error'
48+
49+
50+
class JsonRpcInvalidRequest(JsonRpcException):
51+
CODE = -32600
52+
MESSAGE = 'Invalid Request'
53+
54+
55+
class JsonRpcMethodNotFound(JsonRpcException):
56+
CODE = -32601
57+
MESSAGE = 'Method Not Found'
58+
59+
@classmethod
60+
def of(cls, method):
61+
return cls(message=cls.MESSAGE + ': ' + method)
62+
63+
64+
class JsonRpcInvalidParams(JsonRpcException):
65+
CODE = -32602
66+
MESSAGE = 'Invalid Params'
67+
68+
69+
class JsonRpcInternalError(JsonRpcException):
70+
CODE = -32602
71+
MESSAGE = 'Internal Error'
72+
73+
@classmethod
74+
def of(cls, exc_info):
75+
exc_type, exc_value, exc_tb = exc_info
76+
return cls(
77+
message=''.join(traceback.format_exception_only(exc_type, exc_value)).strip(),
78+
data={'traceback': traceback.format_tb(exc_tb)}
79+
)
80+
81+
82+
class JsonRpcRequestCancelled(JsonRpcException):
83+
CODE = -32800
84+
MESSAGE = 'Request Cancelled'
85+
86+
87+
class JsonRpcServerError(JsonRpcException):
88+
89+
def __init__(self, message, code, data=None):
90+
assert _is_server_error_code(code)
91+
super(JsonRpcServerError, self).__init__(message=message, code=code, data=data)
92+
93+
@classmethod
94+
def supports_code(cls, code):
95+
return _is_server_error_code(code)
96+
97+
98+
def _is_server_error_code(code):
99+
return -32099 <= code <= -32000
100+
101+
102+
_EXCEPTIONS = (
103+
JsonRpcParseError,
104+
JsonRpcInvalidRequest,
105+
JsonRpcMethodNotFound,
106+
JsonRpcInvalidParams,
107+
JsonRpcInternalError,
108+
JsonRpcRequestCancelled,
109+
JsonRpcServerError,
110+
)

0 commit comments

Comments
 (0)