diff --git a/pyls/__main__.py b/pyls/__main__.py index a5ea1d89..5ab8af66 100644 --- a/pyls/__main__.py +++ b/pyls/__main__.py @@ -4,16 +4,13 @@ import logging import logging.config import sys - -from . import language_server -from .python_ls import PythonLanguageServer +from .python_ls import start_io_lang_server, start_tcp_lang_server, PythonLanguageServer LOG_FORMAT = "%(asctime)s UTC - %(levelname)s - %(name)s - %(message)s" def add_arguments(parser): parser.description = "Python Language Server" - parser.add_argument( "--tcp", action="store_true", help="Use TCP server instead of stdio" @@ -51,10 +48,10 @@ def main(): _configure_logger(args.verbose, args.log_config, args.log_file) if args.tcp: - language_server.start_tcp_lang_server(args.host, args.port, PythonLanguageServer) + start_tcp_lang_server(args.host, args.port, PythonLanguageServer) else: stdin, stdout = _binary_stdio() - language_server.start_io_lang_server(stdin, stdout, PythonLanguageServer) + start_io_lang_server(stdin, stdout, PythonLanguageServer) def _binary_stdio(): diff --git a/pyls/dispatcher.py b/pyls/dispatcher.py deleted file mode 100644 index b6a1c376..00000000 --- a/pyls/dispatcher.py +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright 2017 Palantir Technologies, Inc. -import re - -_RE_FIRST_CAP = re.compile('(.)([A-Z][a-z]+)') -_RE_ALL_CAP = re.compile('([a-z0-9])([A-Z])') - - -class JSONRPCMethodDispatcher(object): - """JSON RPC method dispatcher that calls methods on itself with params.""" - - def __getitem__(self, item): - """The jsonrpc dispatcher uses getitem to retrieve the RPC method implementation.""" - method_name = "m_" + _method_to_string(item) - if not hasattr(self, method_name): - raise KeyError("Cannot find method %s" % method_name) - func = getattr(self, method_name) - - def wrapped(*args, **kwargs): - return func(*args, **kwargs) - - return wrapped - - -def _method_to_string(method): - return _camel_to_underscore(method.replace("/", "__").replace("$", "")) - - -def _camel_to_underscore(string): - s1 = _RE_FIRST_CAP.sub(r'\1_\2', string) - return _RE_ALL_CAP.sub(r'\1_\2', s1).lower() diff --git a/pyls/json_rpc_server.py b/pyls/json_rpc_server.py new file mode 100644 index 00000000..ac27edb8 --- /dev/null +++ b/pyls/json_rpc_server.py @@ -0,0 +1,115 @@ +# Copyright 2017 Palantir Technologies, Inc. +import json +import logging +import threading + +from jsonrpc.jsonrpc2 import JSONRPC20Response +from jsonrpc.jsonrpc import JSONRPCRequest +from jsonrpc.exceptions import ( + JSONRPCInvalidRequestException, +) + + +log = logging.getLogger(__name__) + + +class JSONRPCServer(object): + """ Read/Write JSON RPC messages """ + + def __init__(self, rfile, wfile): + self.batch_messages = {} + self.rfile = rfile + self.wfile = wfile + self.write_lock = threading.Lock() + + def close(self): + with self.write_lock: + self.wfile.close() + self.rfile.close() + + def get_messages(self): + """Generator that produces well structured JSON RPC message. + + Returns: + message: received message + + Note: + This method is not thread safe and should only invoked from a single thread + """ + while not self.rfile.closed: + request_str = self._read_message() + + if request_str is None: + break + if isinstance(request_str, bytes): + request_str = request_str.decode("utf-8") + + try: + try: + message_blob = json.loads(request_str) + message = JSONRPCRequest.from_data(message_blob) + except JSONRPCInvalidRequestException: + # work around where JSONRPC20Reponse expects _id key + message_blob['_id'] = message_blob['id'] + message = JSONRPC20Response(**message_blob) + except (KeyError, ValueError): + log.error("Could not parse message %s", request_str) + continue + + yield message + + def write_message(self, message): + """ Write message to out file descriptor + + Args: + message (dict): body of the message to send + """ + with self.write_lock: + if self.wfile.closed: + return + log.debug("Sending %s", message) + body = json.dumps(message, separators=(",", ":")) + content_length = len(body) + response = ( + "Content-Length: {}\r\n" + "Content-Type: application/vscode-jsonrpc; charset=utf8\r\n\r\n" + "{}".format(content_length, body) + ) + self.wfile.write(response.encode('utf-8')) + self.wfile.flush() + + def _read_message(self): + """Reads the contents of a message + + Returns: + body of message if parsable else None + """ + line = self.rfile.readline() + + if not line: + return None + + content_length = _content_length(line) + + # Blindly consume all header lines + while line and line.strip(): + line = self.rfile.readline() + + if not line: + return None + + # Grab the body + return self.rfile.read(content_length) + + +def _content_length(line): + """Extract the content length from an input line.""" + if line.startswith(b'Content-Length: '): + _, value = line.split(b'Content-Length: ') + value = value.strip() + try: + return int(value) + except ValueError: + raise ValueError("Invalid Content-Length header: {}".format(value)) + + return None diff --git a/pyls/language_server.py b/pyls/language_server.py deleted file mode 100644 index f3101820..00000000 --- a/pyls/language_server.py +++ /dev/null @@ -1,95 +0,0 @@ -# Copyright 2017 Palantir Technologies, Inc. -import logging -import socketserver -from . import dispatcher, uris -from .server import JSONRPCServer - -log = logging.getLogger(__name__) - - -class _StreamHandlerWrapper(socketserver.StreamRequestHandler, object): - """A wrapper class that is used to construct a custom handler class.""" - - delegate = None - - def setup(self): - super(_StreamHandlerWrapper, self).setup() - # pylint: disable=no-member - self.delegate = self.DELEGATE_CLASS(self.rfile, self.wfile) - - def handle(self): - self.delegate.handle() - - -def start_tcp_lang_server(bind_addr, port, handler_class): - if not issubclass(handler_class, JSONRPCServer): - raise ValueError("Handler class must be a subclass of JSONRPCServer") - - # Construct a custom wrapper class around the user's handler_class - wrapper_class = type( - handler_class.__name__ + "Handler", - (_StreamHandlerWrapper,), - {'DELEGATE_CLASS': handler_class} - ) - - server = socketserver.ThreadingTCPServer((bind_addr, port), wrapper_class) - try: - log.info("Serving %s on (%s, %s)", handler_class.__name__, bind_addr, port) - server.serve_forever() - finally: - log.info("Shutting down") - server.server_close() - - -def start_io_lang_server(rfile, wfile, handler_class): - if not issubclass(handler_class, JSONRPCServer): - raise ValueError("Handler class must be a subclass of JSONRPCServer") - log.info("Starting %s IO language server", handler_class.__name__) - server = handler_class(rfile, wfile) - server.handle() - - -class LanguageServer(dispatcher.JSONRPCMethodDispatcher, JSONRPCServer): - """ Implementation of the Microsoft VSCode Language Server Protocol - https://github.com/Microsoft/language-server-protocol/blob/master/versions/protocol-1-x.md - """ - - process_id = None - root_uri = None - init_opts = None - - def capabilities(self): # pylint: disable=no-self-use - return {} - - def initialize(self, root_uri, init_opts, process_id): - pass - - def m_initialize(self, **kwargs): - log.debug("Language server initialized with %s", kwargs) - if 'rootUri' in kwargs: - self.root_uri = kwargs['rootUri'] - elif 'rootPath' in kwargs: - root_path = kwargs['rootPath'] - self.root_uri = uris.from_fs_path(root_path) - else: - self.root_uri = '' - self.init_opts = kwargs.get('initializationOptions') - self.process_id = kwargs.get('processId') - - self.initialize(self.root_uri, self.init_opts, self.process_id) - - # Get our capabilities - return {'capabilities': self.capabilities()} - - def m___cancel_request(self, **kwargs): - # TODO: We could I suppose launch tasks in their own threads and kill - # them on cancel, but is it really worth the effort given most methods - # are reasonably quick? - # This tends to happen when cancelling a hover request - pass - - def m_shutdown(self, **_kwargs): - self.shutdown() - - def m_exit(self, **_kwargs): - self.exit() diff --git a/pyls/python_ls.py b/pyls/python_ls.py index a966156c..80d1ffd8 100644 --- a/pyls/python_ls.py +++ b/pyls/python_ls.py @@ -1,44 +1,110 @@ # Copyright 2017 Palantir Technologies, Inc. import logging -from . import lsp, _utils +import socketserver +import re + +from . import lsp, _utils, uris from .config import config -from .language_server import LanguageServer +from .json_rpc_server import JSONRPCServer +from .rpc_manager import JSONRPCManager from .workspace import Workspace log = logging.getLogger(__name__) +_RE_FIRST_CAP = re.compile('(.)([A-Z][a-z]+)') +_RE_ALL_CAP = re.compile('([a-z0-9])([A-Z])') + LINT_DEBOUNCE_S = 0.5 # 500 ms -class PythonLanguageServer(LanguageServer): +class _StreamHandlerWrapper(socketserver.StreamRequestHandler, object): + """A wrapper class that is used to construct a custom handler class.""" + + delegate = None + + def setup(self): + super(_StreamHandlerWrapper, self).setup() + # pylint: disable=no-member + self.delegate = self.DELEGATE_CLASS(self.rfile, self.wfile) + + def handle(self): + self.delegate.handle() + + +def start_tcp_lang_server(bind_addr, port, handler_class): + if not isinstance(handler_class, PythonLanguageServer): + raise ValueError('Handler class must be an instance of PythonLanguageServer') + + # Construct a custom wrapper class around the user's handler_class + wrapper_class = type( + handler_class.__name__ + 'Handler', + (_StreamHandlerWrapper,), + {'DELEGATE_CLASS': handler_class} + ) + + server = socketserver.TCPServer((bind_addr, port), wrapper_class) + try: + log.info('Serving %s on (%s, %s)', handler_class.__name__, bind_addr, port) + server.serve_forever() + finally: + log.info('Shutting down') + server.server_close() + + +def start_io_lang_server(rfile, wfile, handler_class): + if not issubclass(handler_class, PythonLanguageServer): + raise ValueError('Handler class must be an instance of PythonLanguageServer') + log.info('Starting %s IO language server', handler_class.__name__) + server = handler_class(rfile, wfile) + server.start() + + +class PythonLanguageServer(object): + """ Implementation of the Microsoft VSCode Language Server Protocol + https://github.com/Microsoft/language-server-protocol/blob/master/versions/protocol-1-x.md + """ + # pylint: disable=too-many-public-methods,redefined-builtin - workspace = None - config = None + def __init__(self, rx, tx): + self.rpc_manager = JSONRPCManager(JSONRPCServer(rx, tx), self.handle_request) + self.workspace = None + self.config = None + self._dispatchers = [] - # Set of method dispatchers to query - _dispatchers = [] + def start(self): + """Entry point for the server""" + self.rpc_manager.start() - def __getitem__(self, item): - """Override the method dispatcher to farm out any unknown messages to our plugins.""" - try: - return super(PythonLanguageServer, self).__getitem__(item) - except KeyError: - log.debug("Checking dispatchers for %s: %s", item, self._dispatchers) + def handle_request(self, method, params): + """Provides callables to handle requests or responses to those reqeuests + + Args: + method (str): name of the message + params (dict): body of the message + + Returns: + Callable if method is to be handled + + Raises: + KeyError: Handler for method is not found + """ + + method_call = 'm_{}'.format(_method_to_string(method)) + if hasattr(self, method_call): + return getattr(self, method_call)(**params) + elif self._dispatchers: for dispatcher in self._dispatchers: - try: - return dispatcher.__getitem__(item) - except KeyError: - pass - raise KeyError("Unknown item %s" % item) + if method_call in dispatcher: + return dispatcher[method_call](**params) - def _hook_caller(self, hook_name): - return self.config.plugin_manager.subset_hook_caller(hook_name, self.config.disabled_plugins) + raise KeyError('Handler for method {} not found'.format(method)) def _hook(self, hook_name, doc_uri=None, **kwargs): + """Calls hook_name and returns a list of results from all registered handlers""" doc = self.workspace.get_document(doc_uri) if doc_uri else None - hook = self.config.plugin_manager.subset_hook_caller(hook_name, self.config.disabled_plugins) - return hook(config=self.config, workspace=self.workspace, document=doc, **kwargs) + hook_handlers = self.config.plugin_manager.subset_hook_caller(hook_name, self.config.disabled_plugins) + return hook_handlers(config=self.config, workspace=self.workspace, document=doc, **kwargs) def capabilities(self): server_capabilities = { @@ -66,15 +132,34 @@ def capabilities(self): 'textDocumentSync': lsp.TextDocumentSyncKind.INCREMENTAL, 'experimental': merge(self._hook('pyls_experimental_capabilities')) } - log.info("Server capabilities: %s", server_capabilities) + log.info('Server capabilities: %s', server_capabilities) return server_capabilities - def initialize(self, root_uri, init_opts, _process_id): - self.workspace = Workspace(root_uri, lang_server=self) - self.config = config.Config(root_uri, init_opts) + def m_initialize(self, processId=None, rootUri=None, rootPath=None, initializationOptions={}): # pylint: disable=dangerous-default-value + log.debug('Language server initialized with %s %s %s %s', processId, rootUri, rootPath, initializationOptions) + if rootUri is None: + rootUri = uris.from_fs_path(rootPath) if rootPath is not None else '' + + self.workspace = Workspace(rootUri, rpc_manager=self.rpc_manager) + self.config = config.Config(rootUri, initializationOptions) self._dispatchers = self._hook('pyls_dispatchers') self._hook('pyls_initialize') + # Get our capabilities + return {'capabilities': self.capabilities()} + + def m__cancel_request(self, **kwargs): + def handler(): + self.rpc_manager.cancel(kwargs['id']) + return handler + + def m_shutdown(self, **_kwargs): + self.rpc_manager.shutdown() + return None + + def m_exit(self, **_kwargs): + self.rpc_manager.exit() + def code_actions(self, doc_uri, range, context): return flatten(self._hook('pyls_code_actions', doc_uri, range=range, context=context)) @@ -194,6 +279,15 @@ def m_workspace__execute_command(self, command=None, arguments=None): return self.execute_command(command, arguments) +def _method_to_string(method): + return _camel_to_underscore(method.replace("/", "__").replace("$", "")) + + +def _camel_to_underscore(string): + s1 = _RE_FIRST_CAP.sub(r'\1_\2', string) + return _RE_ALL_CAP.sub(r'\1_\2', s1).lower() + + def flatten(list_of_lists): return [item for lst in list_of_lists for item in lst] diff --git a/pyls/rpc_manager.py b/pyls/rpc_manager.py new file mode 100644 index 00000000..0adecfec --- /dev/null +++ b/pyls/rpc_manager.py @@ -0,0 +1,166 @@ +# Copyright 2017 Palantir Technologies, Inc. +import logging +from uuid import uuid1 + +from concurrent.futures import ThreadPoolExecutor, Future +from jsonrpc.base import JSONRPCBaseResponse +from jsonrpc.jsonrpc1 import JSONRPC10Response +from jsonrpc.jsonrpc2 import JSONRPC20Response, JSONRPC20Request +from jsonrpc.exceptions import JSONRPCMethodNotFound, JSONRPCInternalError + +log = logging.getLogger(__name__) + +RESPONSE_CLASS_MAP = { + "1.0": JSONRPC10Response, + "2.0": JSONRPC20Response +} + + +class JSONRPCManager(object): + + def __init__(self, message_manager, message_handler): + self._message_manager = message_manager + self._message_handler = message_handler + self._shutdown = False + self._sent_requests = {} + self._received_requests = {} + self._executor_service = ThreadPoolExecutor() + + def start(self): + """Start reading JSONRPC messages off of rx""" + self.consume_requests() + + def shutdown(self): + """Set flag to ignore all non exit messages""" + self._shutdown = True + + def exit(self): + """Stop listening for new message""" + self._executor_service.shutdown() + self._message_manager.close() + + def call(self, method, params=None): + """Send a JSONRPC message with an expected response. + + Args: + method (str): The method name of the message to send + params (dict): The payload of the message + + Returns: + Future that will resolve once a response has been recieved + + """ + log.debug('Calling %s %s', method, params) + request = JSONRPC20Request(_id=uuid1().int, method=method, params=params) + request_future = Future() + self._sent_requests[request._id] = request_future + self._message_manager.write_message(request.data) + return request_future + + def notify(self, method, params=None): + """Send a JSONRPC notification. + + Args: + method (str): The method name of the notification to send + params (dict): The payload of the notification + """ + log.debug('Notify %s %s', method, params) + notification = JSONRPC20Request(method=method, params=params) + self._message_manager.write_message(notification.data) + + def cancel(self, request_id): + """Cancel pending request handler. + + Args: + request_id (string | number): The id of the original request + + Note: + Request will only be cancelled if it has not begun execution. + """ + log.debug('Cancel request %d', request_id) + try: + self._received_requests[request_id].cancel() + except KeyError: + log.error('Received cancel for finished/nonexistent request %d', request_id) + + def consume_requests(self): + """ Infinite loop watching for messages from the client.""" + for message in self._message_manager.get_messages(): + if isinstance(message, JSONRPCBaseResponse): + self._handle_response(message) + else: + self._handle_request(message) + + def _handle_request(self, request): + """Execute corresponding handler for the recieved request + + Args: + request (JSONRPCBaseRequest): request to act upon + + Note: + requests are handled asynchronously if the handler returns a callable, otherwise they are handle + synchronously by the main thread + """ + if self._shutdown and request.method != 'exit': + return + + params = request.params if request.params is not None else {} + try: + maybe_handler = self._message_handler(request.method, params) + except KeyError: + log.debug("No handler found for %s", request.method) + self._message_manager.write_message( + JSONRPC20Response(_id=request._id, error=JSONRPCMethodNotFound()._data).data) + return + + if request._id in self._received_requests: + log.error('Received request %s with duplicate id', request.data) + elif callable(maybe_handler): + self._handle_async_request(request, maybe_handler) + elif not request.is_notification: + log.debug('Sync request %s', request._id) + response = _make_response(request, result=maybe_handler) + self._message_manager.write_message(response.data) + + def _handle_async_request(self, request, handler): + log.debug('Async request %s', request._id) + future = self._executor_service.submit(handler) + + if request.is_notification: + return + + def did_finish_callback(completed_future): + del self._received_requests[request._id] + if completed_future.cancelled(): + log.debug('Cleared cancelled request %d', request._id) + return + + error, trace = completed_future.exception_info() + if error is not None: + log.error('Failed to handle request %s with error %s %s', request._id, error, trace) + # TODO(forozco): add more descriptive error + response = _make_response(request, error=JSONRPCInternalError()._data) + else: + response = _make_response(request, result=completed_future.result()) + self._message_manager.write_message(response.data) + self._received_requests[request._id] = future + future.add_done_callback(did_finish_callback) + + def _handle_response(self, response): + try: + request = self._sent_requests[response._id] + log.debug("Received response %s", response.data) + + def cleanup(_): + del self._sent_requests[response._id] + request.add_done_callback(cleanup) + request.set_result(response.data) + + except KeyError: + log.error('Received unexpected response %s', response.data) + + +def _make_response(request, **kwargs): + response = RESPONSE_CLASS_MAP[request.JSONRPC_VERSION](_id=request._id, **kwargs) + response.request = request + return response diff --git a/pyls/server.py b/pyls/server.py deleted file mode 100644 index fb7069c5..00000000 --- a/pyls/server.py +++ /dev/null @@ -1,127 +0,0 @@ -# Copyright 2017 Palantir Technologies, Inc. -import json -import logging -import uuid - -from jsonrpc import jsonrpc2, JSONRPCResponseManager - -log = logging.getLogger(__name__) - - -class JSONRPCServer(object): - """ Read/Write JSON RPC messages """ - - def __init__(self, rfile, wfile): - self.rfile = rfile - self.wfile = wfile - - self._callbacks = {} - self._shutdown = False - - def exit(self): - # Exit causes a complete exit of the server - self.rfile.close() - self.wfile.close() - - def shutdown(self): - # Shutdown signals the server to stop, but not exit - self._shutdown = True - log.debug("Server shut down, awaiting exit notification") - - def handle(self): - while True: - try: - data = self._read_message() - log.debug("Got message: %s", data) - - if self._shutdown: - # Handle only the exit notification when we're shut down - JSONRPCResponseManager.handle(data, {'exit': self.exit}) - break - - if isinstance(data, bytes): - data = data.decode("utf-8") - - msg = json.loads(data) - if 'method' in msg: - # It's a notification or request - # Dispatch to the thread pool for handling - response = JSONRPCResponseManager.handle(data, self) - if response is not None: - self._write_message(response.data) - else: - # Otherwise, it's a response message - on_result, on_error = self._callbacks.pop(msg['id']) - if 'result' in msg and on_result: - on_result(msg['result']) - elif 'error' in msg and on_error: - on_error(msg['error']) - except: # pylint: disable=bare-except - log.exception("Language server exiting due to uncaught exception") - break - - def call(self, method, params=None, on_result=None, on_error=None): - """Call a method on the client.""" - msg_id = str(uuid.uuid4()) - log.debug("Sending request %s: %s: %s", msg_id, method, params) - req = jsonrpc2.JSONRPC20Request(method=method, params=params) - req._id = msg_id - - def _default_on_error(error): - log.error("Call to %s failed with %s", method, error) - - if not on_error: - on_error = _default_on_error - - self._callbacks[msg_id] = (on_result, on_error) - self._write_message(req.data) - - def notify(self, method, params=None): - """ Send a notification to the client, expects no response. """ - log.debug("Sending notification %s: %s", method, params) - req = jsonrpc2.JSONRPC20Request( - method=method, params=params, is_notification=True - ) - self._write_message(req.data) - - def _read_message(self): - line = self.rfile.readline() - - if not line: - raise EOFError() - - content_length = _content_length(line) - - # Blindly consume all header lines - while line and line.strip(): - line = self.rfile.readline() - - if not line: - raise EOFError() - - # Grab the body - return self.rfile.read(content_length) - - def _write_message(self, msg): - body = json.dumps(msg, separators=(",", ":")) - content_length = len(body) - response = ( - "Content-Length: {}\r\n" - "Content-Type: application/vscode-jsonrpc; charset=utf8\r\n\r\n" - "{}".format(content_length, body) - ) - self.wfile.write(response.encode('utf-8')) - self.wfile.flush() - - -def _content_length(line): - """Extract the content length from an input line.""" - if line.startswith(b'Content-Length: '): - _, value = line.split(b'Content-Length: ') - value = value.strip() - try: - return int(value) - except ValueError: - raise ValueError("Invalid Content-Length header: {}".format(value)) - - return None diff --git a/pyls/workspace.py b/pyls/workspace.py index fd9374da..4f2a893b 100644 --- a/pyls/workspace.py +++ b/pyls/workspace.py @@ -74,12 +74,12 @@ class Workspace(object): M_SHOW_MESSAGE = 'window/showMessage' PRELOADED_MODULES = get_preferred_submodules() - def __init__(self, root_uri, lang_server=None): + def __init__(self, root_uri, rpc_manager=None): self._root_uri = root_uri + self._rpc_manager = rpc_manager self._root_uri_scheme = uris.urlparse(self._root_uri)[0] self._root_path = uris.to_fs_path(self._root_uri) self._docs = {} - self._lang_server = lang_server # Whilst incubating, keep private self.__rope = Project(self._root_path) @@ -124,18 +124,16 @@ def update_document(self, doc_uri, change, version=None): self._docs[doc_uri].version = version def apply_edit(self, edit, on_result=None, on_error=None): - return self._lang_server.call( + return self._rpc_manager.call( self.M_APPLY_EDIT, {'edit': edit}, on_result=on_result, on_error=on_error ) def publish_diagnostics(self, doc_uri, diagnostics): - params = {'uri': doc_uri, 'diagnostics': diagnostics} - self._lang_server.notify(self.M_PUBLISH_DIAGNOSTICS, params) + self._rpc_manager.notify(self.M_PUBLISH_DIAGNOSTICS, params={'uri': doc_uri, 'diagnostics': diagnostics}) def show_message(self, message, msg_type=lsp.MessageType.Info): - params = {'type': msg_type, 'message': message} - self._lang_server.notify(self.M_SHOW_MESSAGE, params) + self._rpc_manager.notify(self.M_SHOW_MESSAGE, params={'type': msg_type, 'message': message}) def source_roots(self, document_path): """Return the source roots for the given document.""" diff --git a/setup.py b/setup.py index dd2170af..e3860de6 100755 --- a/setup.py +++ b/setup.py @@ -34,6 +34,7 @@ install_requires=[ 'configparser', 'future>=0.14.0', + 'futures; python_version == "2.7"', 'jedi>=0.10', 'json-rpc', 'mccabe', @@ -50,7 +51,7 @@ # for example: # $ pip install -e .[test] extras_require={ - 'test': ['tox', 'versioneer', 'pytest', 'pytest-cov', 'coverage'], + 'test': ['tox', 'versioneer', 'pytest', 'mock', 'pytest-cov', 'coverage'], }, # To provide executable scripts, use entry points in preference to the diff --git a/test/fixtures.py b/test/fixtures.py index d25f191f..a51c2ca4 100644 --- a/test/fixtures.py +++ b/test/fixtures.py @@ -1,10 +1,11 @@ # Copyright 2017 Palantir Technologies, Inc. import pytest +import os from pyls import uris from pyls.config.config import Config from pyls.python_ls import PythonLanguageServer from pyls.workspace import Workspace -from io import StringIO +from StringIO import StringIO @pytest.fixture diff --git a/test/test_dispatcher.py b/test/test_dispatcher.py deleted file mode 100644 index 12bd1d73..00000000 --- a/test/test_dispatcher.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright 2017 Palantir Technologies, Inc. -import pytest -from pyls import dispatcher - - -class TestDispatcher(dispatcher.JSONRPCMethodDispatcher): - - def m_test__method(self, **params): - return params - - -def test_method_dispatcher(): - td = TestDispatcher() - params = {'hello': 'world'} - assert td['test/method'](**params) == params - - -def test_method_dispatcher_missing_method(): - td = TestDispatcher() - with pytest.raises(KeyError): - td['test/noMethod']('hello') diff --git a/test/test_json_rpc_server.py b/test/test_json_rpc_server.py new file mode 100644 index 00000000..234a7132 --- /dev/null +++ b/test/test_json_rpc_server.py @@ -0,0 +1,60 @@ +# Copyright 2018 Palantir Technologies, Inc. +import pytest +import os +from jsonrpc.jsonrpc2 import JSONRPC20Request, JSONRPC20Response +from pyls.json_rpc_server import JSONRPCServer + +@pytest.fixture +def json_rpc_server(tmpdir): + manager_rx, tester_tx = os.pipe() + tester_rx, manager_tx = os.pipe() + + client = JSONRPCServer(os.fdopen(manager_rx, 'rb'), os.fdopen(manager_tx, 'wb')) + server = JSONRPCServer(os.fdopen(tester_rx, 'rb'), os.fdopen(tester_tx, 'wb')) + + yield client, server + + client.close() + server.close() + + +def test_receive_request(json_rpc_server): + client, server = json_rpc_server + request = {'jsonrpc': '2.0', 'id': 0, 'method': 'initialize', 'params': {}} + client.write_message(request) + message = server.get_messages().next() + assert isinstance(message, JSONRPC20Request) + assert request == message.data + assert not message.is_notification + + +def test_receive_notification(json_rpc_server): + client, server = json_rpc_server + notification = {'jsonrpc': '2.0', 'method': 'notify', 'params': {}} + client.write_message(notification) + message = server.get_messages().next() + assert isinstance(message, JSONRPC20Request) + assert notification == message.data + assert message.is_notification + + +def test_receive_response(json_rpc_server): + client, server = json_rpc_server + response = {'jsonrpc': '2.0', 'id': 0, 'result': {}} + client.write_message(response) + message = server.get_messages().next() + assert isinstance(message, JSONRPC20Response ) + assert response == message.data + + +def test_drop_bad_message(json_rpc_server): + client, server = json_rpc_server + response = {'jsonrpc': '2.0', 'id': 0, 'result': {}} + client.write_message(response) + server.close() + try: + server.get_messages().next() + except StopIteration: + pass + else: + assert False diff --git a/test/test_language_server.py b/test/test_language_server.py index e1641dff..ed559065 100644 --- a/test/test_language_server.py +++ b/test/test_language_server.py @@ -1,22 +1,17 @@ # Copyright 2017 Palantir Technologies, Inc. -import json import os from threading import Thread import jsonrpc +from jsonrpc.exceptions import JSONRPCMethodNotFound import pytest -from pyls.server import JSONRPCServer -from pyls.language_server import start_io_lang_server -from pyls.python_ls import PythonLanguageServer +from pyls.python_ls import start_io_lang_server ,PythonLanguageServer +CALL_TIMEOUT = 2 -class JSONRPCClient(JSONRPCServer): - """ This is a weird way of testing.. but we're going to have two JSONRPCServers - talking to each other. One pretending to be a 'VSCode'-like client, the other is - our language server """ - pass - +def start_client(client): + client.start() @pytest.fixture def client_server(): @@ -27,71 +22,34 @@ def client_server(): # Server to client pipe scr, scw = os.pipe() - server = Thread(target=start_io_lang_server, args=( + server_thread = Thread(target=start_io_lang_server, args=( os.fdopen(csr, 'rb'), os.fdopen(scw, 'wb'), PythonLanguageServer )) - server.daemon = True - server.start() + server_thread.daemon = True + server_thread.start() - client = JSONRPCClient(os.fdopen(scr, 'rb'), os.fdopen(csw, 'wb')) + client = PythonLanguageServer(os.fdopen(scr, 'rb'), os.fdopen(csw, 'wb')) + client_thread = Thread(target=start_client, args=[client]) + client_thread.daemon = True + client_thread.start() - yield client, server + yield client - client.call('shutdown') - response = _get_response(client) - assert response['result'] is None - client.notify('exit') + shutdown_response = client.rpc_manager.call('shutdown').result(timeout=CALL_TIMEOUT) + assert shutdown_response['result'] is None + client.rpc_manager.notify('exit') def test_initialize(client_server): - client, server = client_server - - client.call('initialize', { + response = client_server.rpc_manager.call('initialize', { 'processId': 1234, 'rootPath': os.path.dirname(__file__), 'initializationOptions': {} - }) - response = _get_response(client) - + }).result(timeout=CALL_TIMEOUT) assert 'capabilities' in response['result'] def test_missing_message(client_server): - client, server = client_server - - client.call('unknown_method') - response = _get_response(client) - assert response['error']['code'] == -32601 # Method not implemented error - - -def test_linting(client_server): - client, server = client_server - - # Initialize - client.call('initialize', { - 'processId': 1234, - 'rootPath': os.path.dirname(__file__), - 'initializationOptions': {} - }) - response = _get_response(client) - - assert 'capabilities' in response['result'] - - # didOpen - client.notify('textDocument/didOpen', { - 'textDocument': {'uri': 'file:///test', 'text': 'import sys'} - }) - response = _get_notification(client) - - assert response['method'] == 'textDocument/publishDiagnostics' - assert len(response['params']['diagnostics']) > 0 - - -def _get_notification(client): - request = jsonrpc.jsonrpc.JSONRPCRequest.from_json(client._read_message().decode('utf-8')) - assert request.is_notification - return request.data - + response = client_server.rpc_manager.call('unknown_method').result(timeout=CALL_TIMEOUT) + assert response['error']['code'] == JSONRPCMethodNotFound.CODE -def _get_response(client): - return json.loads(client._read_message().decode('utf-8')) diff --git a/test/test_rpc_manager.py b/test/test_rpc_manager.py new file mode 100644 index 00000000..e3da3203 --- /dev/null +++ b/test/test_rpc_manager.py @@ -0,0 +1,125 @@ +# Copyright 2018 Palantir Technologies, Inc. +import pytest +import time +from StringIO import StringIO +from jsonrpc.jsonrpc2 import JSONRPC20Request, JSONRPC20Response +from pyls.json_rpc_server import JSONRPCServer +from pyls.rpc_manager import JSONRPCManager +from mock import Mock + + +BASE_HANDLED_RESPONSE_CONTENT = 'handled' +BASE_HANDLED_RESPONSE = JSONRPC20Response(_id=1, result=BASE_HANDLED_RESPONSE_CONTENT) + +@pytest.fixture +def rpc_management(): + message_manager = JSONRPCServer(StringIO(), StringIO()) + message_manager.get_messages = Mock(return_value=[JSONRPC20Request(_id=1, method='test', params={})]) + message_manager.write_message = Mock() + message_handler = Mock(return_value=BASE_HANDLED_RESPONSE_CONTENT) + rpc_manager = JSONRPCManager(message_manager, message_handler) + + yield rpc_manager, message_manager, message_handler, + + rpc_manager.exit() + + +def test_handle_request_sync(rpc_management): + rpc_manager, message_manager, message_handler = rpc_management + + rpc_manager.start() + message_manager.get_messages.assert_any_call() + message_manager.write_message.assert_called_once_with(BASE_HANDLED_RESPONSE.data) + message_handler.assert_called_once_with('test', {}) + + +def test_handle_request_async(rpc_management): + rpc_manager, message_manager, message_handler = rpc_management + response = JSONRPC20Response(_id=1, result="async") + + def wrapper(): + return 'async' + message_handler.configure_mock(return_value=wrapper) + + rpc_manager.start() + message_manager.get_messages.assert_any_call() + message_handler.assert_called_once_with('test', {}) + + if rpc_manager._sent_requests: + rpc_manager._sent_requests.values()[0].result(timeout=1) + message_manager.write_message.assert_called_once_with(response.data) + + +def test_handle_notification_sync(rpc_management): + rpc_manager, message_manager, message_handler = rpc_management + notification = JSONRPC20Request(method='notification', params={}, is_notification=True) + message_manager.get_messages.configure_mock(return_value=[notification]) + + rpc_manager.start() + message_manager.get_messages.assert_any_call() + message_handler.assert_called_once_with('notification', {}) + message_manager.write_message.assert_not_called() + + +def test_handle_notification_sync_empty(rpc_management): + rpc_manager, message_manager, message_handler = rpc_management + notification = JSONRPC20Request(method='notification', params=None, is_notification=True) + message_manager.get_messages.configure_mock(return_value=[notification]) + + rpc_manager.start() + message_manager.get_messages.assert_any_call() + message_handler.assert_called_once_with('notification', {}) + message_manager.write_message.assert_not_called() + + +def test_handle_notification_async(rpc_management): + rpc_manager, message_manager, message_handler = rpc_management + notification = JSONRPC20Request(method='notification', params={}, is_notification=True) + def wrapper(): + pass + message_handler.configure_mock(return_value=wrapper) + message_manager.get_messages.configure_mock(return_value=[notification]) + + rpc_manager.start() + message_manager.get_messages.assert_any_call() + message_handler.assert_called_once_with('notification', {}) + message_manager.write_message.assert_not_called() + + +def test_handle_notification_async_empty(rpc_management): + rpc_manager, message_manager, message_handler = rpc_management + notification = JSONRPC20Request(method='notification', params=None, is_notification=True) + def wrapper(): + pass + message_handler.configure_mock(return_value=wrapper) + message_manager.get_messages.configure_mock(return_value=[notification]) + + rpc_manager.start() + message_manager.get_messages.assert_any_call() + message_handler.assert_called_once_with('notification', {}) + message_manager.write_message.assert_not_called() + + +def test_send_request(rpc_management): + rpc_manager, message_manager, message_handler = rpc_management + + response_future = rpc_manager.call('request', {}) + message_manager.write_message.assert_called_once() + assert len(rpc_manager._sent_requests) == 1 + request_id = rpc_manager._sent_requests.keys()[0] + + response = JSONRPC20Response(_id=request_id, result={}) + message_manager.get_messages.configure_mock(return_value=[response]) + + rpc_manager.start() + message_manager.get_messages.assert_any_call() + assert not rpc_manager._sent_requests + assert response_future.result() == response.data + + +def test_send_notification(rpc_management): + rpc_manager, message_manager, message_handler = rpc_management + + rpc_manager.notify('notify', {}) + message_manager.write_message.assert_called_once_with(JSONRPC20Request(method='notify', params={}).data) + diff --git a/tox.ini b/tox.ini index 28a64c69..3fcd443b 100644 --- a/tox.ini +++ b/tox.ini @@ -22,6 +22,7 @@ commands = deps = pytest coverage + mock pytest-cov pylint