diff --git a/README.md b/README.md index cad0c1dc..d2dcd5fc 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,21 @@ As an example, to change the list of errors that pycodestyle will ignore, assumi 3. Same as 1, but add to `setup.cfg` file in the root of the project. +Python LSP Server can communicate over WebSockets when configured as follows: + +``` +pylsp --ws --port [port] +``` + +The following libraries are required for Web Sockets support: +- [websockets](https://websockets.readthedocs.io/en/stable/) for Python LSP Server Web sockets using websockets library. refer [Websockets installation](https://websockets.readthedocs.io/en/stable/intro/index.html#installation) for more details + +You can install this dependency with command below: + +``` +pip install 'python-lsp-server[websockets]' +``` + ## LSP Server Features * Auto Completion diff --git a/pylsp/__main__.py b/pylsp/__main__.py index 4698d5c9..50950a30 100644 --- a/pylsp/__main__.py +++ b/pylsp/__main__.py @@ -13,7 +13,7 @@ import json from .python_lsp import (PythonLSPServer, start_io_lang_server, - start_tcp_lang_server) + start_tcp_lang_server, start_ws_lang_server) from ._version import __version__ LOG_FORMAT = "%(asctime)s {0} - %(levelname)s - %(name)s - %(message)s".format( @@ -27,6 +27,10 @@ def add_arguments(parser): "--tcp", action="store_true", help="Use TCP server instead of stdio" ) + parser.add_argument( + "--ws", action="store_true", + help="Use Web Sockets server instead of stdio" + ) parser.add_argument( "--host", default="127.0.0.1", help="Bind to this address" @@ -72,6 +76,9 @@ def main(): if args.tcp: start_tcp_lang_server(args.host, args.port, args.check_parent_process, PythonLSPServer) + elif args.ws: + start_ws_lang_server(args.port, args.check_parent_process, + PythonLSPServer) else: stdin, stdout = _binary_stdio() start_io_lang_server(stdin, stdout, args.check_parent_process, diff --git a/pylsp/python_lsp.py b/pylsp/python_lsp.py index 81e93bdc..8cac63d5 100644 --- a/pylsp/python_lsp.py +++ b/pylsp/python_lsp.py @@ -6,6 +6,7 @@ import os import socketserver import threading +import ujson as json from pylsp_jsonrpc.dispatchers import MethodDispatcher from pylsp_jsonrpc.endpoint import Endpoint @@ -91,6 +92,57 @@ def start_io_lang_server(rfile, wfile, check_parent_process, handler_class): server.start() +def start_ws_lang_server(port, check_parent_process, handler_class): + if not issubclass(handler_class, PythonLSPServer): + raise ValueError('Handler class must be an instance of PythonLSPServer') + + # pylint: disable=import-outside-toplevel + + # imports needed only for websockets based server + try: + import asyncio + from concurrent.futures import ThreadPoolExecutor + import websockets + except ImportError as e: + raise ImportError("websocket modules missing. Please run pip install 'python-lsp-server[websockets]") from e + + with ThreadPoolExecutor(max_workers=10) as tpool: + async def pylsp_ws(websocket): + log.debug("Creating LSP object") + + # creating a partial function and suppling the websocket connection + response_handler = partial(send_message, websocket=websocket) + + # Not using default stream reader and writer. + # Instead using a consumer based approach to handle processed requests + pylsp_handler = handler_class(rx=None, tx=None, consumer=response_handler, + check_parent_process=check_parent_process) + + async for message in websocket: + try: + log.debug("consuming payload and feeding it to LSP handler") + request = json.loads(message) + loop = asyncio.get_running_loop() + await loop.run_in_executor(tpool, pylsp_handler.consume, request) + except Exception as e: # pylint: disable=broad-except + log.exception("Failed to process request %s, %s", message, str(e)) + + def send_message(message, websocket): + """Handler to send responses of processed requests to respective web socket clients""" + try: + payload = json.dumps(message, ensure_ascii=False) + asyncio.run(websocket.send(payload)) + except Exception as e: # pylint: disable=broad-except + log.exception("Failed to write message %s, %s", message, str(e)) + + async def run_server(): + async with websockets.serve(pylsp_ws, port=port): + # runs forever + await asyncio.Future() + + asyncio.run(run_server()) + + class PythonLSPServer(MethodDispatcher): """ Implementation of the Microsoft VSCode Language Server Protocol https://github.com/Microsoft/language-server-protocol/blob/master/versions/protocol-1-x.md @@ -98,7 +150,7 @@ class PythonLSPServer(MethodDispatcher): # pylint: disable=too-many-public-methods,redefined-builtin - def __init__(self, rx, tx, check_parent_process=False): + def __init__(self, rx, tx, check_parent_process=False, consumer=None): self.workspace = None self.config = None self.root_uri = None @@ -106,10 +158,24 @@ def __init__(self, rx, tx, check_parent_process=False): self.workspaces = {} self.uri_workspace_mapper = {} - self._jsonrpc_stream_reader = JsonRpcStreamReader(rx) - self._jsonrpc_stream_writer = JsonRpcStreamWriter(tx) self._check_parent_process = check_parent_process - self._endpoint = Endpoint(self, self._jsonrpc_stream_writer.write, max_workers=MAX_WORKERS) + + if rx is not None: + self._jsonrpc_stream_reader = JsonRpcStreamReader(rx) + else: + self._jsonrpc_stream_reader = None + + if tx is not None: + self._jsonrpc_stream_writer = JsonRpcStreamWriter(tx) + else: + self._jsonrpc_stream_writer = None + + # if consumer is None, it is assumed that the default streams-based approach is being used + if consumer is None: + self._endpoint = Endpoint(self, self._jsonrpc_stream_writer.write, max_workers=MAX_WORKERS) + else: + self._endpoint = Endpoint(self, consumer, max_workers=MAX_WORKERS) + self._dispatchers = [] self._shutdown = False @@ -117,6 +183,11 @@ def start(self): """Entry point for the server.""" self._jsonrpc_stream_reader.listen(self._endpoint.consume) + def consume(self, message): + """Entry point for consumer based server. Alternative to stream listeners.""" + # assuming message will be JSON + self._endpoint.consume(message) + def __getitem__(self, item): """Override getitem to fallback through multiple dispatchers.""" if self._shutdown and item != 'exit': @@ -141,8 +212,10 @@ def m_shutdown(self, **_kwargs): def m_exit(self, **_kwargs): self._endpoint.shutdown() - self._jsonrpc_stream_reader.close() - self._jsonrpc_stream_writer.close() + if self._jsonrpc_stream_reader is not None: + self._jsonrpc_stream_reader.close() + if self._jsonrpc_stream_writer is not None: + self._jsonrpc_stream_writer.close() def _match_uri_to_workspace(self, uri): workspace_uri = _utils.match_uri_to_workspace(uri, self.workspaces) diff --git a/pyproject.toml b/pyproject.toml index 3923ecf4..708df34b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ pyflakes = ["pyflakes>=2.4.0,<2.5.0"] pylint = ["pylint>=2.5.0"] rope = ["rope>0.10.5"] yapf = ["yapf"] +websockets = ["websockets>=10.3"] test = [ "pylint>=2.5.0", "pytest",