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