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

Enable e2e testing of generic JSON-RPC servers #79

Merged
merged 3 commits into from
Sep 8, 2023
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
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
autodoc_typehints = "description"

intersphinx_mapping = {
"pygls": ("https://pygls.readthedocs.io/en/latest/", None),
"python": ("https://docs.python.org/3/", None),
"pytest": ("https://docs.pytest.org/en/stable/", None),
}
Expand Down
1 change: 1 addition & 0 deletions docs/pytest-lsp/guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ User Guide
guide/client-capabilities
guide/fixtures
guide/troubleshooting
guide/testing-json-rpc-servers
60 changes: 60 additions & 0 deletions docs/pytest-lsp/guide/testing-json-rpc-servers.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
Testing JSON-RPC Servers
========================

While ``pytest-lsp`` is primarily focused on writing tests for LSP servers it is possible to reuse some of the machinery to test other JSON-RPC servers.

A Simple JSON-RPC Server
------------------------

As an example we'll reuse some of the `pygls`_ internals to write a simple JSON-RPC server that implements the following protocol.

- client to server request ``math/add``, returns the sum of two numbers ``a`` and ``b``
- client to server request ``math/sub``, returns the difference of two numbers ``a`` and ``b``
- server to client notification ``log/message``, allows the server to send debug messages to the client.

.. note::

The details of the implementation below don't really matter as we just need *something* to help us illustrate how to use ``pytest-lsp`` in this way.

Remember you can write your servers in whatever language/framework you prefer!

.. literalinclude:: ../../../lib/pytest-lsp/tests/examples/generic-rpc/server.py
:language: python

Constructing a Client
---------------------

While ``pytest-lsp`` can manage the connection between client and server, it needs to be given a client that understands the protocol that the server implements.
This is done with a factory function

.. literalinclude:: ../../../lib/pytest-lsp/tests/examples/generic-rpc/t_server.py
:language: python
:start-at: def client_factory():
:end-at: return client

The Client Fixture
------------------

Once you have your factory function defined you can pass it to the :class:`~pytest_lsp.ClientServerConfig` when defining your client fixture

.. literalinclude:: ../../../lib/pytest-lsp/tests/examples/generic-rpc/t_server.py
:language: python
:start-at: @pytest_lsp.fixture(
:end-at: # Teardown code

Writing Test Cases
------------------

With the client fixuture defined, test cases are written almost identically as they would be for your LSP servers.
The only difference is that the generic :meth:`~pygls:pygls.protocol.JsonRPCProtocol.send_request_async` and :meth:`~pygls:pygls.protocol.JsonRPCProtocol.notify` methods are used to communicate with the server.

.. literalinclude:: ../../../lib/pytest-lsp/tests/examples/generic-rpc/t_server.py
:language: python
:start-at: @pytest.mark.asyncio

However, it is also possible to extend the base :class:`~pygls:pygls.client.JsonRPCClient` to provide a higher level interface to your server.
See the `SubprocessSphinxClient`_ from the `esbonio`_ project for such an example.

.. _esbonio: https://github.com/swyddfa/esbonio
.. _pygls: https://github.com/openlawlibrary/pygls
.. _SubprocessSphinxClient: https://github.com/swyddfa/esbonio/blob/develop/lib/esbonio/esbonio/server/features/sphinx_manager/client_subprocess.py
8 changes: 2 additions & 6 deletions docs/pytest-lsp/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ LanguageClient

.. autoclass:: LanguageClient
:members:
:inherited-members:
:show-inheritance:


Test Setup
Expand All @@ -21,12 +21,8 @@ Test Setup
.. autoclass:: ClientServerConfig
:members:

.. autofunction:: make_client_server
.. autofunction:: make_test_lsp_client

.. autofunction:: make_test_client

.. autoclass:: ClientServer
:members:

Checks
------
Expand Down
2 changes: 2 additions & 0 deletions lib/pytest-lsp/changes/73.enhancement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
It is now possible to test any JSON-RPC based server with ``pytest-lsp``.
Note however, this support will only ever extend to managing the client-server connection.
1 change: 1 addition & 0 deletions lib/pytest-lsp/changes/73.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``make_test_client`` has been renamed to ``make_test_lsp_client``
10 changes: 2 additions & 8 deletions lib/pytest-lsp/pytest_lsp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,20 @@
from .client import LanguageClient
from .client import __version__
from .client import client_capabilities
from .client import make_test_client
from .plugin import ClientServer
from .client import make_test_lsp_client
from .plugin import ClientServerConfig
from .plugin import fixture
from .plugin import make_client_server
from .plugin import pytest_runtest_makereport
from .plugin import pytest_runtest_setup
from .protocol import LanguageClientProtocol

__all__ = [
"__version__",
"ClientServer",
"ClientServerConfig",
"LanguageClient",
"LanguageClientProtocol",
"LspSpecificationWarning",
"client_capabilities",
"fixture",
"make_client_server",
"make_test_client",
"make_test_lsp_client",
"pytest_runtest_makereport",
"pytest_runtest_setup",
]
4 changes: 2 additions & 2 deletions lib/pytest-lsp/pytest_lsp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from lsprotocol.types import ShowDocumentParams
from lsprotocol.types import ShowDocumentResult
from lsprotocol.types import ShowMessageParams
from pygls.lsp.client import LanguageClient as BaseLanguageClient
from pygls.lsp.client import BaseLanguageClient
from pygls.protocol import default_converter

from .protocol import LanguageClientProtocol
Expand Down Expand Up @@ -178,7 +178,7 @@ def cancel_all_tasks(message: str):
task.cancel(message)


def make_test_client() -> LanguageClient:
def make_test_lsp_client() -> LanguageClient:
"""Construct a new test client instance with the handlers needed to capture
additional responses from the server."""

Expand Down
93 changes: 26 additions & 67 deletions lib/pytest-lsp/pytest_lsp/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,82 +4,42 @@
import textwrap
import typing
from typing import Callable
from typing import Dict
from typing import List
from typing import Optional

import attrs
import pytest
import pytest_asyncio
from pygls.client import JsonRPCClient

from pytest_lsp.client import LanguageClient
from pytest_lsp.client import make_test_client
from pytest_lsp.client import make_test_lsp_client

logger = logging.getLogger("client")


class ClientServer:
"""A client server pair used to drive test cases."""

def __init__(self, *, client: LanguageClient, server_command: List[str]):
self.server_command = server_command
"""The command to use when starting the server."""

self.client = client
"""The client used to drive the test."""

async def start(self):
await self.client.start_io(*self.server_command)

async def stop(self):
await self.client.stop()


@attrs.define
class ClientServerConfig:
"""Configuration for a LSP Client-Server pair."""

def __init__(
self,
server_command: List[str],
*,
client_factory: Callable[[], LanguageClient] = make_test_client,
) -> None:
"""
Parameters
----------
server_command
The command to use to start the language server.

client_factory
Factory function to use when constructing the language client instance.
Defaults to :func:`pytest_lsp.make_test_client`
"""

self.server_command = server_command
self.client_factory = client_factory


def make_client_server(config: ClientServerConfig) -> ClientServer:
"""Construct a new ``ClientServer`` instance."""

return ClientServer(
server_command=config.server_command,
client=config.client_factory(),
)
"""Configuration for a Client-Server connection."""

server_command: List[str]
"""The command to use to start the language server."""

@pytest.hookimpl(trylast=True)
def pytest_runtest_setup(item: pytest.Item):
"""Ensure that that client has not errored before running a test."""
client_factory: Callable[[], JsonRPCClient] = attrs.field(
default=make_test_lsp_client,
)
"""Factory function to use when constructing the test client instance."""

client: Optional[LanguageClient] = None
for arg in item.funcargs.values(): # type: ignore[attr-defined]
if isinstance(arg, LanguageClient):
client = arg
break
server_env: Optional[Dict[str, str]] = attrs.field(default=None)
"""Environment variables to set when starting the server."""

if not client or client.error is None:
return
async def start(self) -> JsonRPCClient:
"""Return the client instance to use for the test."""
client = self.client_factory()

raise client.error
await client.start_io(*self.server_command, env=self.server_env)
return client


def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo):
Expand Down Expand Up @@ -123,7 +83,7 @@ async def anext(it):

def get_fixture_arguments(
fn: Callable,
client: LanguageClient,
client: JsonRPCClient,
request: pytest.FixtureRequest,
) -> dict:
"""Return the arguments to pass to the user's fixture function.
Expand All @@ -134,7 +94,7 @@ def get_fixture_arguments(
The user's fixture function

client
The language client instance to inject
The test client instance to inject

request
pytest's request fixture
Expand All @@ -154,7 +114,7 @@ def get_fixture_arguments(

# Inject the language client
for name, cls in typing.get_type_hints(fn).items():
if issubclass(cls, LanguageClient):
if issubclass(cls, JsonRPCClient):
kwargs[name] = client
required_parameters.remove(name)

Expand Down Expand Up @@ -183,26 +143,25 @@ def fixture(
def wrapper(fn):
@pytest_asyncio.fixture(**kwargs)
async def the_fixture(request):
client_server = make_client_server(config)
await client_server.start()
client = await config.start()

kwargs = get_fixture_arguments(fn, client_server.client, request)
kwargs = get_fixture_arguments(fn, client, request)
result = fn(**kwargs)
if inspect.isasyncgen(result):
try:
await anext(result)
except StopAsyncIteration:
pass

yield client_server.client
yield client

if inspect.isasyncgen(result):
try:
await anext(result)
except StopAsyncIteration:
pass

await client_server.stop()
await client.stop()

return the_fixture

Expand Down
30 changes: 30 additions & 0 deletions lib/pytest-lsp/tests/examples/generic-rpc/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from pygls.protocol import JsonRPCProtocol, default_converter
from pygls.server import Server

server = Server(protocol_cls=JsonRPCProtocol, converter_factory=default_converter)


@server.lsp.fm.feature("math/add")
def addition(ls: Server, params):
a = params.a
b = params.b

ls.lsp.notify("log/message", dict(message=f"{a=}"))
ls.lsp.notify("log/message", dict(message=f"{b=}"))

return dict(total=a + b)


@server.lsp.fm.feature("math/sub")
def subtraction(ls: Server, params):
a = params.a
b = params.b

ls.lsp.notify("log/message", dict(message=f"{a=}"))
ls.lsp.notify("log/message", dict(message=f"{b=}"))

return dict(total=b - a)


if __name__ == "__main__":
server.start_io()
50 changes: 50 additions & 0 deletions lib/pytest-lsp/tests/examples/generic-rpc/t_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import logging
import sys

import pytest
import pytest_lsp
from pygls.client import JsonRPCClient
from pytest_lsp import ClientServerConfig


def client_factory():
client = JsonRPCClient()

@client.feature("log/message")
def _on_message(params):
logging.info("LOG: %s", params.message)

return client


@pytest_lsp.fixture(
config=ClientServerConfig(
client_factory=client_factory, server_command=[sys.executable, "server.py"]
),
)
async def client(rpc_client: JsonRPCClient):
# Setup code here (if any)

yield

# Teardown code here (if any)


@pytest.mark.asyncio
async def test_add(client: JsonRPCClient):
"""Ensure that the server implements addition correctly."""

result = await client.protocol.send_request_async(
"math/add", params={"a": 1, "b": 2}
)
assert result.total == 3


@pytest.mark.asyncio
async def test_sub(client: JsonRPCClient):
"""Ensure that the server implements addition correctly."""

result = await client.protocol.send_request_async(
"math/sub", params={"a": 1, "b": 2}
)
assert result.total == -1
Loading