From 3ff0a28d066e9c11da72b056da53bf1bc86b2bc7 Mon Sep 17 00:00:00 2001 From: Alex Carney <alcarneyme@gmail.com> Date: Thu, 30 Nov 2023 19:32:15 +0000 Subject: [PATCH] new JsonRPCClient class --- pygls/{client.py => client/__init__.py} | 0 pygls/client/_native.py | 102 ++++++++++++++++++++++++ tests/conftest.py | 27 +++---- tests/servers/rpc.py | 21 +++++ tests/test_server.py | 40 ++++++++++ 5 files changed, 174 insertions(+), 16 deletions(-) rename pygls/{client.py => client/__init__.py} (100%) create mode 100644 pygls/client/_native.py create mode 100644 tests/servers/rpc.py create mode 100644 tests/test_server.py diff --git a/pygls/client.py b/pygls/client/__init__.py similarity index 100% rename from pygls/client.py rename to pygls/client/__init__.py diff --git a/pygls/client/_native.py b/pygls/client/_native.py new file mode 100644 index 00000000..7fc0974e --- /dev/null +++ b/pygls/client/_native.py @@ -0,0 +1,102 @@ +############################################################################ +# Copyright(c) Open Law Library. All rights reserved. # +# See ThirdPartyNotices.txt in the project root for additional notices. # +# # +# Licensed under the Apache License, Version 2.0 (the "License") # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http: // www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################ +import asyncio +from typing import Optional +from typing import Type + +from pygls.handler._native import JsonRPCHandler +from pygls.handler._native import aio_main +from pygls.protocol.next import JsonRPCProtocol + +_CLIENT_SERVER_CONNECTION = "<<client-server-connection>>" +_EXIT_NOTIFICATION = "<<exit-notification>>" + + +class JsonRPCClient(JsonRPCHandler): + """Base JSON-RPC client for "native" runtimes""" + + def __init__( + self, *args, protocol_cls: Type[JsonRPCProtocol] = JsonRPCProtocol, **kwargs + ): + super().__init__(*args, protocol=protocol_cls(), **kwargs) + + self._server: Optional[asyncio.subprocess.Process] = None + + @property + def stopped(self) -> bool: + """Return ``True`` if the client has been stopped.""" + return self._stop_event.is_set() + + async def start_io(self, cmd: str, *args, **kwargs): + """Start the given server and communicate with it over stdio.""" + + self.logger.debug("Starting server process: %s", " ".join([cmd, *args])) + server = await asyncio.create_subprocess_exec( + cmd, + *args, + stdout=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + **kwargs, + ) + + assert server.stdout is not None, "Missing server stdout" + assert server.stdin is not None, "Missing server stdin" + + self._writer = server.stdin + self._create_task( + aio_main( + reader=server.stdout, + stop_event=self._stop_event, + message_handler=self, + ), + task_id=_CLIENT_SERVER_CONNECTION, + ) + self._create_task(self._server_exit(), task_id=_EXIT_NOTIFICATION) + self._server = server + + async def _server_exit(self): + if self._server is not None: + await self._server.wait() + self.logger.debug( + "Server process %s exited with return code: %s", + self._server.pid, + self._server.returncode, + ) + await self.server_exit(self._server) + self._stop_event.set() + + async def server_exit(self, server: asyncio.subprocess.Process): + """Called when the server process exits.""" + + async def stop(self): + self._stop_event.set() + + # Cancel pending tasks + for task_id, task in self._tasks.items(): + if task_id not in {_EXIT_NOTIFICATION, _CLIENT_SERVER_CONNECTION}: + task.cancel("Client is stopping") + + # Kill the server process + if self._server is not None and self._server.returncode is None: + self.logger.debug("Terminating server process: %s", self._server.pid) + self._server.terminate() + + # Wait for the remaining tasks + if len(self._tasks) > 0: + self.logger.debug(self._tasks.keys()) + await asyncio.gather(*self._tasks.values()) diff --git a/tests/conftest.py b/tests/conftest.py index e5eeecd2..4c305859 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,19 +21,19 @@ import sys import pytest -from lsprotocol import types, converters +from lsprotocol import converters +from lsprotocol import types -from pygls import uris, IS_PYODIDE, IS_WIN +from pygls import IS_PYODIDE +from pygls import IS_WIN +from pygls import uris from pygls.feature_manager import FeatureManager from pygls.workspace import Workspace -from .ls_setup import ( - NativeClientServer, - PyodideClientServer, - setup_ls_features, -) - from .client import create_client_for_server +from .ls_setup import NativeClientServer +from .ls_setup import PyodideClientServer +from .ls_setup import setup_ls_features DOC = """document for @@ -79,22 +79,17 @@ def fn(*args): return fn -@pytest.fixture() +@pytest.fixture(scope="session") def event_loop(): """Redefine `pytest-asyncio's default event_loop fixture to match the scope of our client fixture.""" - # Only required for Python 3.7 on Windows. - if sys.version_info.minor == 7 and IS_WIN: - policy = asyncio.WindowsProactorEventLoopPolicy() - else: - policy = asyncio.get_event_loop_policy() - + policy = asyncio.get_event_loop_policy() loop = policy.new_event_loop() yield loop try: - # Not implemented on pyodide + # Not implemented for pyodide's event loop loop.close() except NotImplementedError: pass diff --git a/tests/servers/rpc.py b/tests/servers/rpc.py new file mode 100644 index 00000000..d1235f3b --- /dev/null +++ b/tests/servers/rpc.py @@ -0,0 +1,21 @@ +"""A generic JSON-RPC server""" +import asyncio +import logging +from typing import Dict + +from pygls.server._native import JsonRPCServer + +server = JsonRPCServer() + + +@server.feature("math/add") +def add(params: Dict[str, float]): + a = params["a"] + b = params["b"] + + return dict(sum=a + b) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG, filename="server.log", filemode="w") + asyncio.run(server.start_io()) diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 00000000..99068ec8 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,40 @@ +import asyncio +import pathlib +import sys +from unittest import mock + +import pytest + +from pygls.client._native import JsonRPCClient + +SERVERS = pathlib.Path(__file__).parent / "servers" + + +@pytest.fixture(scope="module") +async def client(): + client_ = JsonRPCClient() + await client_.start_io(sys.executable, str(SERVERS / "rpc.py")) + + yield client_ + + await client_.stop() + + +async def test_send_request_sync(client: JsonRPCClient): + """Ensure that we can send a request and handle the result using a callback.""" + + callback = mock.Mock() + + # TODO: How to wait, without requiring await + await asyncio.wait_for( + client.send_request("math/add", dict(a=2, b=2), callback=callback), timeout=10 + ) + + callback.assert_called_with(dict(sum=4)) + + +async def test_send_request_async(client: JsonRPCClient): + """Ensure that we can send a request and handle the result using async-await syntax.""" + + result = await client.send_request("math/add", dict(a=1, b=4)) + assert result["sum"] == 5