Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add WebSocket server #129

Merged
merged 8 commits into from
Jun 16, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions docs/source/pages/advanced_usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~

Expand Down
13 changes: 13 additions & 0 deletions examples/json-extension/.vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
8 changes: 7 additions & 1 deletion examples/json-extension/server/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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()

Expand Down
16 changes: 10 additions & 6 deletions pygls/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ def __init__(self, server):
self.transport = None
self._message_buf = []

self._send_only_body = False

def __call__(self):
return self

Expand Down Expand Up @@ -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())

Expand Down
57 changes: 55 additions & 2 deletions pygls/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# limitations under the License. #
############################################################################
import asyncio
import json
import logging
import re
import sys
Expand All @@ -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__)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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(
Expand All @@ -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)."""
Expand Down
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ exclude =
tests.*

[options.extras_require]
ws =
websockets==9.*
dev =
bandit==1.6.0
flake8==3.7.7
Expand Down
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ envlist = py{36,37,38,39}

[testenv]
extras =
ws
test
dev
commands =
Expand Down