From b67617c2845e4882925082bd18cab2138792574e Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Wed, 30 Oct 2019 00:00:08 -0400 Subject: [PATCH 1/3] add a server status endpoint --- py_src/jupyter_lsp/handlers.py | 31 +++++++++-- py_src/jupyter_lsp/schema/__init__.py | 12 ++++ py_src/jupyter_lsp/schema/servers.schema.json | 48 ++++++++++++++++ py_src/jupyter_lsp/serverextension.py | 15 +++-- py_src/jupyter_lsp/session.py | 33 ++++++++++- py_src/jupyter_lsp/tests/conftest.py | 23 ++++++-- py_src/jupyter_lsp/tests/test_session.py | 55 ++++++++++++++----- py_src/jupyter_lsp/types.py | 12 ++++ 8 files changed, 198 insertions(+), 31 deletions(-) create mode 100644 py_src/jupyter_lsp/schema/__init__.py create mode 100644 py_src/jupyter_lsp/schema/servers.schema.json diff --git a/py_src/jupyter_lsp/handlers.py b/py_src/jupyter_lsp/handlers.py index b65414d53..85887d462 100644 --- a/py_src/jupyter_lsp/handlers.py +++ b/py_src/jupyter_lsp/handlers.py @@ -1,20 +1,27 @@ """ tornado handler for managing and communicating with language servers """ +from typing import Optional, Text + from notebook.base.handlers import IPythonHandler from notebook.base.zmqhandlers import WebSocketHandler, WebSocketMixin from tornado.ioloop import IOLoop +from .manager import LanguageServerManager -class LanguageServerWebSocketHandler(WebSocketMixin, WebSocketHandler, IPythonHandler): - """ Setup tornado websocket to route to language server sessions - """ - language = None - manager = None +class BaseHandler(IPythonHandler): + manager: LanguageServerManager = None - def initialize(self, manager): + def initialize(self, manager: LanguageServerManager): self.manager = manager + +class LanguageServerWebSocketHandler(WebSocketMixin, WebSocketHandler, BaseHandler): + """ Setup tornado websocket to route to language server sessions + """ + + language: Optional[Text] = None + def open(self, language): self.language = language self.manager.subscribe(self) @@ -31,3 +38,15 @@ def send(message): def on_close(self): self.manager.unsubscribe(self) self.log.debug("[{0: >16}] Closed a handler".format(self.language)) + + +class LanguageServersHandler(BaseHandler): + def get(self): + self.finish( + { + "sessions": sorted( + [session.to_json() for session in self.manager.sessions.values()], + key=lambda session: session["languages"], + ) + } + ) diff --git a/py_src/jupyter_lsp/schema/__init__.py b/py_src/jupyter_lsp/schema/__init__.py new file mode 100644 index 000000000..ca4e3f121 --- /dev/null +++ b/py_src/jupyter_lsp/schema/__init__.py @@ -0,0 +1,12 @@ +import json +import pathlib + +import jsonschema + +HERE = pathlib.Path(__file__).parent + + +def servers_schema(): + return jsonschema.validators.Draft7Validator( + json.loads((HERE / "servers.schema.json").read_text()) + ) diff --git a/py_src/jupyter_lsp/schema/servers.schema.json b/py_src/jupyter_lsp/schema/servers.schema.json new file mode 100644 index 000000000..9d378e0af --- /dev/null +++ b/py_src/jupyter_lsp/schema/servers.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/servers-response", + "definitions": { + "servers-response": { + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { "$ref": "#/definitions/session" } + } + }, + "required": ["sessions"] + }, + "nullable-date-time": { + "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] + }, + "session": { + "additionalProperties": false, + "required": [ + "languages", + "handler_count", + "status", + "last_server_message_at", + "last_handler_message_at" + ], + "properties": { + "languages": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true, + "minItems": 1 + }, + "handler_count": { "type": "integer", "minValue": 0 }, + "status": { + "type": "string", + "enum": ["not_started", "starting", "started", "stopping", "stopped"] + }, + "last_server_message_at": { + "$ref": "#/definitions/nullable-date-time" + }, + "last_handler_message_at": { + "$ref": "#/definitions/nullable-date-time" + } + } + } + } +} diff --git a/py_src/jupyter_lsp/serverextension.py b/py_src/jupyter_lsp/serverextension.py index 634c6fb5b..eec66873b 100644 --- a/py_src/jupyter_lsp/serverextension.py +++ b/py_src/jupyter_lsp/serverextension.py @@ -5,7 +5,7 @@ import traitlets from notebook.utils import url_path_join as ujoin -from .handlers import LanguageServerWebSocketHandler +from .handlers import LanguageServersHandler, LanguageServerWebSocketHandler from .manager import LanguageServerManager @@ -20,13 +20,16 @@ def load_jupyter_server_extension(nbapp): json.dumps(manager.language_servers, indent=2, sort_keys=True) ) ) + + lsp_url = ujoin(nbapp.base_url, "lsp") + re_langs = "(?P.*)" + + opts = {"manager": nbapp.language_server_manager} + nbapp.web_app.add_handlers( ".*", [ - ( - ujoin(nbapp.base_url, "lsp", "(?P.*)"), - LanguageServerWebSocketHandler, - {"manager": nbapp.language_server_manager}, - ) + (lsp_url, LanguageServersHandler, opts), + (ujoin(lsp_url, re_langs), LanguageServerWebSocketHandler, opts), ], ) diff --git a/py_src/jupyter_lsp/session.py b/py_src/jupyter_lsp/session.py index a3cb72103..18dc76b18 100644 --- a/py_src/jupyter_lsp/session.py +++ b/py_src/jupyter_lsp/session.py @@ -3,13 +3,15 @@ import asyncio import atexit import subprocess +from datetime import datetime, timezone from tornado.queues import Queue from tornado.websocket import WebSocketHandler -from traitlets import Bunch, Instance, List, Set, Unicode, observe +from traitlets import Bunch, Instance, List, Set, Unicode, UseEnum, observe from traitlets.config import LoggingConfigurable from . import stdio +from .types import SessionStatus class LanguageServerSession(LoggingConfigurable): @@ -42,6 +44,9 @@ class LanguageServerSession(LoggingConfigurable): default_value=[], help="the currently subscribed websockets", ) + status = UseEnum(SessionStatus, default_value=SessionStatus.NOT_STARTED) + last_handler_message_at = Instance(datetime, allow_none=True) + last_server_message_at = Instance(datetime, allow_none=True) _tasks = None @@ -56,10 +61,24 @@ def __repr__(self): # pragma: no cover self.languages, self.argv ) + def to_json(self): + return dict( + languages=self.languages, + handler_count=len(self.handlers), + status=self.status.value, + last_server_message_at=self.last_server_message_at.isoformat() + if self.last_server_message_at + else None, + last_handler_message_at=self.last_handler_message_at.isoformat() + if self.last_handler_message_at + else None, + ) + def initialize(self): """ (re)initialize a language server session """ self.stop() + self.status = SessionStatus.STARTING self.init_queues() self.init_process() self.init_writer() @@ -71,9 +90,14 @@ def initialize(self): for coro in [self._read_lsp, self._write_lsp, self._broadcast_from_lsp] ] + self.status = SessionStatus.STARTED + def stop(self): """ clean up all of the state of the session """ + + self.status = SessionStatus.STOPPING + if self.process: self.process.terminate() self.process = None @@ -87,6 +111,8 @@ def stop(self): if self._tasks: [task.cancel() for task in self._tasks] + self.status = SessionStatus.STOPPED + @observe("handlers") def _on_handlers(self, change: Bunch): """ re-initialize if someone starts listening, or stop if nobody is @@ -99,8 +125,12 @@ def _on_handlers(self, change: Bunch): def write(self, message): """ wrapper around the write queue to keep it mostly internal """ + self.last_handler_message_at = self.now() self.to_lsp.put_nowait(message) + def now(self): + return datetime.now(timezone.utc) + def init_process(self): """ start the language server subprocess """ @@ -139,6 +169,7 @@ async def _broadcast_from_lsp(self): server """ async for msg in self.from_lsp: + self.last_server_message_at = self.now() for handler in self.handlers: handler.write_message(msg) self.from_lsp.task_done() diff --git a/py_src/jupyter_lsp/tests/conftest.py b/py_src/jupyter_lsp/tests/conftest.py index 6e3c4995b..fbf942b4b 100644 --- a/py_src/jupyter_lsp/tests/conftest.py +++ b/py_src/jupyter_lsp/tests/conftest.py @@ -9,7 +9,8 @@ # local imports from jupyter_lsp import LanguageServerManager -from jupyter_lsp.handlers import LanguageServerWebSocketHandler +from jupyter_lsp.handlers import LanguageServersHandler, LanguageServerWebSocketHandler +from jupyter_lsp.schema import servers_schema # these should always be available in a test environment () KNOWN_LANGUAGES = [ @@ -38,6 +39,8 @@ KNOWN_UNKNOWN_LANGUAGES = ["cobol"] +SERVERS_SCHEMA = servers_schema() + @fixture def manager() -> LanguageServerManager: @@ -55,10 +58,12 @@ def known_unknown_language(request): @fixture -def handler(manager): - handler = MockWebsocketHandler() +def handlers(manager): + ws_handler = MockWebsocketHandler() + ws_handler.initialize(manager) + handler = MockHandler() handler.initialize(manager) - return handler + return handler, ws_handler @fixture @@ -100,5 +105,15 @@ def write_message(self, message: Text) -> None: self._messages_wrote.put_nowait(message) +class MockHandler(LanguageServersHandler): + _payload = None + + def __init__(self): + pass + + def finish(self, payload): + self._payload = payload + + class MockNotebookApp(NotebookApp): pass diff --git a/py_src/jupyter_lsp/tests/test_session.py b/py_src/jupyter_lsp/tests/test_session.py index 1bc2ffe9f..f6d9f80ff 100644 --- a/py_src/jupyter_lsp/tests/test_session.py +++ b/py_src/jupyter_lsp/tests/test_session.py @@ -2,42 +2,69 @@ import pytest +from .conftest import SERVERS_SCHEMA + + +def assert_status_set(handler, expected_statuses, language=None): + handler.get() + payload = handler._payload + + SERVERS_SCHEMA.validate(payload) + + statuses = { + s["status"] + for s in payload["sessions"] + if language is None or language in s["languages"] + } + assert statuses == expected_statuses + @pytest.mark.asyncio -async def test_start_known(known_language, handler, jsonrpc_init_msg): +async def test_start_known(known_language, handlers, jsonrpc_init_msg): """ will a process start for a known language if a handler starts listening? """ + handler, ws_handler = handlers manager = handler.manager + manager.initialize() - handler.open(known_language) - sessions = list(manager.sessions_for_handler(handler)) + + assert_status_set(handler, {"not_started"}) + + ws_handler.open(known_language) + sessions = list(manager.sessions_for_handler(ws_handler)) session = sessions[0] assert session.process is not None - handler.on_message(jsonrpc_init_msg) + assert_status_set(handler, {"started"}, known_language) + + ws_handler.on_message(jsonrpc_init_msg) try: - await asyncio.wait_for(handler._messages_wrote.get(), 20) - handler._messages_wrote.task_done() + await asyncio.wait_for(ws_handler._messages_wrote.get(), 20) + ws_handler._messages_wrote.task_done() finally: - handler.on_close() + ws_handler.on_close() - assert not list(manager.sessions_for_handler(handler)) + assert not list(manager.sessions_for_handler(ws_handler)) assert not session.handlers assert not session.process + assert_status_set(handler, {"stopped"}, known_language) + assert_status_set(handler, {"stopped", "not_started"}) + @pytest.mark.asyncio -async def test_start_unknown(known_unknown_language, handler, jsonrpc_init_msg): +async def test_start_unknown(known_unknown_language, handlers, jsonrpc_init_msg): """ will a process not start for an unknown if a handler starts listening? """ + handler, ws_handler = handlers manager = handler.manager manager.initialize() - handler.open(known_unknown_language) - assert not list(manager.sessions_for_handler(handler)) + ws_handler.open(known_unknown_language) + assert not list(manager.sessions_for_handler(ws_handler)) - handler.on_message(jsonrpc_init_msg) + ws_handler.on_message(jsonrpc_init_msg) - handler.on_close() + ws_handler.on_close() - assert not list(manager.sessions_for_handler(handler)) + assert not list(manager.sessions_for_handler(ws_handler)) diff --git a/py_src/jupyter_lsp/types.py b/py_src/jupyter_lsp/types.py index e3c88ed9d..d6007d402 100644 --- a/py_src/jupyter_lsp/types.py +++ b/py_src/jupyter_lsp/types.py @@ -1,5 +1,6 @@ """ API used by spec finders and manager """ +import enum import pathlib import shutil import sys @@ -14,6 +15,17 @@ KeyedLanguageServerSpecs = Dict[Text, LanguageServerSpec] +class SessionStatus(enum.Enum): + """ States in which a language server session can be + """ + + NOT_STARTED = "not_started" + STARTING = "starting" + STARTED = "started" + STOPPING = "stopping" + STOPPED = "stopped" + + class LanguageServerManagerAPI(LoggingConfigurable): """ Public API that can be used for python-based spec finders """ From 6ec4d7aae106c73ab04a3937d8afff0f3dec2656 Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Wed, 30 Oct 2019 00:26:03 -0400 Subject: [PATCH 2/3] add server status API endpoint --- py_src/jupyter_lsp/handlers.py | 10 ++++++- py_src/jupyter_lsp/schema/__init__.py | 4 ++- py_src/jupyter_lsp/schema/servers.schema.json | 26 ++++++++++++++++--- py_src/jupyter_lsp/tests/test_session.py | 11 ++++++-- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/py_src/jupyter_lsp/handlers.py b/py_src/jupyter_lsp/handlers.py index 85887d462..e8802d5cd 100644 --- a/py_src/jupyter_lsp/handlers.py +++ b/py_src/jupyter_lsp/handlers.py @@ -41,12 +41,20 @@ def on_close(self): class LanguageServersHandler(BaseHandler): + """ Reports the status of all current servers + + Response should conform to schema in schema/servers.schema.json + """ + def get(self): + """ finish with the JSON representations of the sessions + """ self.finish( { + "version": 0, "sessions": sorted( [session.to_json() for session in self.manager.sessions.values()], key=lambda session: session["languages"], - ) + ), } ) diff --git a/py_src/jupyter_lsp/schema/__init__.py b/py_src/jupyter_lsp/schema/__init__.py index ca4e3f121..2db1d71cd 100644 --- a/py_src/jupyter_lsp/schema/__init__.py +++ b/py_src/jupyter_lsp/schema/__init__.py @@ -6,7 +6,9 @@ HERE = pathlib.Path(__file__).parent -def servers_schema(): +def servers_schema() -> jsonschema.validators.Draft7Validator: + """ return a JSON Schema Draft 7 validator for the server status API + """ return jsonschema.validators.Draft7Validator( json.loads((HERE / "servers.schema.json").read_text()) ) diff --git a/py_src/jupyter_lsp/schema/servers.schema.json b/py_src/jupyter_lsp/schema/servers.schema.json index 9d378e0af..d09ee17e4 100644 --- a/py_src/jupyter_lsp/schema/servers.schema.json +++ b/py_src/jupyter_lsp/schema/servers.schema.json @@ -1,21 +1,32 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "$ref": "#/definitions/servers-response", + "title": "jupyter_lsp server status response", + "description": "describes the current state of (potentially) running language servers", "definitions": { "servers-response": { "type": "object", "properties": { "sessions": { "type": "array", + "description": "a list of servers that are, could be, or were running", "items": { "$ref": "#/definitions/session" } + }, + "version": { + "type": "integer", + "description": "the version of the schema", + "enum": [0] } }, - "required": ["sessions"] + "required": ["sessions", "version"] }, "nullable-date-time": { + "description": "a date/time that might not have been recorded", "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] }, "session": { + "title": "Language Server Session", + "description": "a language server session", "additionalProperties": false, "required": [ "languages", @@ -26,20 +37,29 @@ ], "properties": { "languages": { + "description": "languages supported by this Language Server", "type": "array", - "items": {"type": "string"}, + "items": { "type": "string" }, "uniqueItems": true, "minItems": 1 }, - "handler_count": { "type": "integer", "minValue": 0 }, + "handler_count": { + "title": "handler count", + "description": "the count of currently-connected WebSocket handlers", + "type": "integer", + "minValue": 0 + }, "status": { + "description": "a string describing the current state of the server", "type": "string", "enum": ["not_started", "starting", "started", "stopping", "stopped"] }, "last_server_message_at": { + "description": "date-time of last seen message from the language server", "$ref": "#/definitions/nullable-date-time" }, "last_handler_message_at": { + "description": "date-time of last seen message from a WebSocket handler", "$ref": "#/definitions/nullable-date-time" } } diff --git a/py_src/jupyter_lsp/tests/test_session.py b/py_src/jupyter_lsp/tests/test_session.py index f6d9f80ff..3d7267be2 100644 --- a/py_src/jupyter_lsp/tests/test_session.py +++ b/py_src/jupyter_lsp/tests/test_session.py @@ -9,7 +9,8 @@ def assert_status_set(handler, expected_statuses, language=None): handler.get() payload = handler._payload - SERVERS_SCHEMA.validate(payload) + errors = list(SERVERS_SCHEMA.iter_errors(payload)) + assert not errors statuses = { s["status"] @@ -60,11 +61,17 @@ async def test_start_unknown(known_unknown_language, handlers, jsonrpc_init_msg) handler, ws_handler = handlers manager = handler.manager manager.initialize() + + assert_status_set(handler, {"not_started"}) + ws_handler.open(known_unknown_language) assert not list(manager.sessions_for_handler(ws_handler)) - ws_handler.on_message(jsonrpc_init_msg) + assert_status_set(handler, {"not_started"}) + ws_handler.on_message(jsonrpc_init_msg) + assert_status_set(handler, {"not_started"}) ws_handler.on_close() assert not list(manager.sessions_for_handler(ws_handler)) + assert_status_set(handler, {"not_started"}) From bf00df3036a2c82e95f0f087a255a5ccc112da9b Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Wed, 30 Oct 2019 00:42:16 -0400 Subject: [PATCH 3/3] bah, can't use class member type annotations --- py_src/jupyter_lsp/handlers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/py_src/jupyter_lsp/handlers.py b/py_src/jupyter_lsp/handlers.py index e8802d5cd..145cbb360 100644 --- a/py_src/jupyter_lsp/handlers.py +++ b/py_src/jupyter_lsp/handlers.py @@ -10,7 +10,7 @@ class BaseHandler(IPythonHandler): - manager: LanguageServerManager = None + manager = None # type: LanguageServerManager def initialize(self, manager: LanguageServerManager): self.manager = manager @@ -20,7 +20,7 @@ class LanguageServerWebSocketHandler(WebSocketMixin, WebSocketHandler, BaseHandl """ Setup tornado websocket to route to language server sessions """ - language: Optional[Text] = None + language = None # type: Optional[Text] def open(self, language): self.language = language