diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e5b4b28..63ba69cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,15 @@ and this project adheres to [Semantic Versioning][semver]. ### Added - Testing against Python 3.9 ([#186]) +- Websocket server implementation `start_websocket` for LSP ([#129]) ### Changed ### Fixed +[#186]: https://github.com/openlawlibrary/pygls/pull/186 +[#129]: https://github.com/openlawlibrary/pygls/pull/129 + ## [0.10.3] - 05/05/2021 ### Added diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 2d7a00e8..f78a6f3c 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -7,6 +7,7 @@ - [DeathAxe](https://github.com/deathaxe) - [Denis Loginov](https://github.com/dinvlad) - [Jérome Perrin](https://github.com/perrinjerome) +- [Matej Kašťák](https://github.com/MatejKastak) - [Max O'Cull](https://github.com/Maxattax97) - [Samuel Roeca](https://github.com/pappasam) - [Tomoya Tanjo](https://github.com/tom-tan) diff --git a/docs/source/pages/advanced_usage.rst b/docs/source/pages/advanced_usage.rst index 00a4e230..17c58ca5 100644 --- a/docs/source/pages/advanced_usage.rst +++ b/docs/source/pages/advanced_usage.rst @@ -50,6 +50,22 @@ The code snippet below shows how to start the server in *STDIO* mode. server.start_io() +WEBSOCKET +^^^^^^^^^ + +WEBSOCKET connections are used when you want to expose language server to +browser based editors. + +The code snippet below shows how to start the server in *WEBSOCKET* mode. + +.. code:: python + + from pygls.server import LanguageServer + + server = LanguageServer() + + server.start_websocket('0.0.0.0', 1234) + Logging ~~~~~~~ diff --git a/examples/json-extension/.vscode/launch.json b/examples/json-extension/.vscode/launch.json index 1cb5440e..7ac28d73 100644 --- a/examples/json-extension/.vscode/launch.json +++ b/examples/json-extension/.vscode/launch.json @@ -29,6 +29,19 @@ "env": { "PYTHONPATH": "${workspaceFolder}" } + }, + { + "name": "Launch Server [WebSockets]", + "type": "python", + "request": "launch", + "module": "server", + "args": ["--ws"], + "justMyCode": false, + "python": "${command:python.interpreterPath}", + "cwd": "${workspaceFolder}", + "env": { + "PYTHONPATH": "${workspaceFolder}" + } } ], "compounds": [ diff --git a/examples/json-extension/server/__main__.py b/examples/json-extension/server/__main__.py index 08d40845..69a1e2ab 100644 --- a/examples/json-extension/server/__main__.py +++ b/examples/json-extension/server/__main__.py @@ -27,7 +27,11 @@ def add_arguments(parser): parser.add_argument( "--tcp", action="store_true", - help="Use TCP server instead of stdio" + help="Use TCP server" + ) + parser.add_argument( + "--ws", action="store_true", + help="Use WebSocket server" ) parser.add_argument( "--host", default="127.0.0.1", @@ -46,6 +50,8 @@ def main(): if args.tcp: json_server.start_tcp(args.host, args.port) + elif args.ws: + json_server.start_ws(args.host, args.port) else: json_server.start_io() diff --git a/pygls/protocol.py b/pygls/protocol.py index 64313e1a..f757ef4b 100644 --- a/pygls/protocol.py +++ b/pygls/protocol.py @@ -188,6 +188,8 @@ def __init__(self, server): self.transport = None self._message_buf = [] + self._send_only_body = False + def __call__(self): return self @@ -370,16 +372,18 @@ def _send_data(self, data): try: body = data.json(by_alias=True, exclude_unset=True) - logger.info('Sending data: %s', body) body = body.encode(self.CHARSET) - header = ( - f'Content-Length: {len(body)}\r\n' - f'Content-Type: {self.CONTENT_TYPE}; charset={self.CHARSET}\r\n\r\n' - ).encode(self.CHARSET) + if not self._send_only_body: + header = ( + f'Content-Length: {len(body)}\r\n' + f'Content-Type: {self.CONTENT_TYPE}; charset={self.CHARSET}\r\n\r\n' + ).encode(self.CHARSET) - self.transport.write(header + body) + self.transport.write(header + body) + else: + self.transport.write(body.decode('utf-8')) except Exception: logger.error(traceback.format_exc()) diff --git a/pygls/server.py b/pygls/server.py index 9fa3247f..8999c4f9 100644 --- a/pygls/server.py +++ b/pygls/server.py @@ -15,6 +15,7 @@ # limitations under the License. # ############################################################################ import asyncio +import json import logging import re import sys @@ -30,7 +31,7 @@ WorkspaceEdit) from pygls.lsp.types.window import ShowDocumentCallbackType, ShowDocumentParams from pygls.progress import Progress -from pygls.protocol import LanguageServerProtocol +from pygls.protocol import LanguageServerProtocol, deserialize_message from pygls.workspace import Workspace logger = logging.getLogger(__name__) @@ -97,6 +98,25 @@ def write(self, data): self.wfile.flush() +class WebSocketTransportAdapter: + """Protocol adapter which calls write method. + + Write method sends data via the WebSocket interface. + """ + + def __init__(self, ws, loop): + self._ws = ws + self._loop = loop + + def close(self) -> None: + """Stop the WebSocket server.""" + self._ws.close() + + def write(self, data: Any) -> None: + """Create a task to write specified data into a WebSocket.""" + asyncio.ensure_future(self._ws.send(data)) + + class Server: """Class that represents async server. It can be started using TCP or IO. @@ -196,7 +216,7 @@ def start_io(self, stdin=None, stdout=None): def start_tcp(self, host, port): """Starts TCP server.""" - logger.info('Starting server on %s:%s', host, port) + logger.info('Starting TCP server on %s:%s', host, port) self._stop_event = Event() self._server = self.loop.run_until_complete( @@ -209,6 +229,39 @@ def start_tcp(self, host, port): finally: self.shutdown() + def start_ws(self, host, port): + """Starts WebSocket server.""" + try: + import websockets + except ImportError: + logger.error('Run `pip install pygls[ws]` to install `websockets`.') + sys.exit(1) + + logger.info('Starting WebSocket server on {}:{}'.format(host, port)) + + self._stop_event = Event() + self.lsp._send_only_body = True # Don't send headers within the payload + + async def connection_made(websocket, _): + """Handle new connection wrapped in the WebSocket.""" + self.lsp.transport = WebSocketTransportAdapter(websocket, self.loop) + async for message in websocket: + self.lsp._procedure_handler( + json.loads(message, object_hook=deserialize_message) + ) + + start_server = websockets.serve(connection_made, host, port) + self._server = start_server.ws_server + self.loop.run_until_complete(start_server) + + try: + self.loop.run_forever() + except (KeyboardInterrupt, SystemExit): + pass + finally: + self._stop_event.set() + self.shutdown() + @property def thread_pool(self) -> ThreadPool: """Returns thread pool instance (lazy initialization).""" diff --git a/setup.cfg b/setup.cfg index 2d720ca5..8b86fb8c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,6 +38,8 @@ exclude = tests.* [options.extras_require] +ws = + websockets==9.* dev = bandit==1.6.0 flake8==3.7.7 diff --git a/tox.ini b/tox.ini index b368b6ca..b1531016 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,7 @@ envlist = py{36,37,38,39} [testenv] extras = + ws test dev commands =