Skip to content

Commit

Permalink
new JsonRPCClient class
Browse files Browse the repository at this point in the history
  • Loading branch information
alcarney committed Dec 1, 2023
1 parent 747ed40 commit 3ff0a28
Show file tree
Hide file tree
Showing 5 changed files with 174 additions and 16 deletions.
File renamed without changes.
102 changes: 102 additions & 0 deletions pygls/client/_native.py
Original file line number Diff line number Diff line change
@@ -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())
27 changes: 11 additions & 16 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions tests/servers/rpc.py
Original file line number Diff line number Diff line change
@@ -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())
40 changes: 40 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 3ff0a28

Please sign in to comment.