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

pytest-lsp: Forward a server's stderr output #147

Merged
merged 1 commit into from
Feb 7, 2024
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 lib/pytest-lsp/changes/143.enhancement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
When a test fails `pytest-lsp` will now show the server's `stderr` output (if any)
32 changes: 26 additions & 6 deletions lib/pytest-lsp/pytest_lsp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,22 +82,33 @@ def __init__(self, *args, configuration: Optional[Dict[str, Any]] = None, **kwar
self._last_log_index = 0
"""Used to keep track of which log messages correspond with which test case."""

self._stderr_forwarder: Optional[asyncio.Task] = None
"""A task that forwards the server's stderr to the test process."""

async def start_io(self, cmd: str, *args, **kwargs):
await super().start_io(cmd, *args, **kwargs)

# Forward the server's stderr to this process' stderr
if self._server and self._server.stderr:
self._stderr_forwarder = asyncio.create_task(forward_stderr(self._server))

async def stop(self):
if self._stderr_forwarder:
self._stderr_forwarder.cancel()

return await super().stop()

async def server_exit(self, server: asyncio.subprocess.Process):
"""Called when the server process exits."""
logger.debug("Server process exited with code: %s", server.returncode)

if self._stop_event.is_set():
return

stderr = ""
if server.stderr is not None:
stderr_bytes = await server.stderr.read()
stderr = stderr_bytes.decode("utf8")

loop = asyncio.get_running_loop()
loop.call_soon(
cancel_all_tasks,
f"Server process exited with return code: {server.returncode}\n{stderr}",
f"Server process exited with return code: {server.returncode}",
)

def report_server_error(
Expand Down Expand Up @@ -259,6 +270,15 @@ async def wait_for_notification(self, method: str):
return await self.protocol.wait_for_notification_async(method)


async def forward_stderr(server: asyncio.subprocess.Process):
if server.stderr is None:
return

# EOF is signalled with an empty bytestring
while (line := await server.stderr.readline()) != b"":
sys.stderr.buffer.write(line)


def cancel_all_tasks(message: str):
"""Called to cancel all awaited tasks."""

Expand Down
21 changes: 21 additions & 0 deletions lib/pytest-lsp/tests/examples/server-stderr/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import sys

from lsprotocol import types
from pygls.server import LanguageServer

server = LanguageServer("server-stderr", "v1")


@server.feature(types.TEXT_DOCUMENT_COMPLETION)
def completion(params: types.CompletionParams):
items = []

for i in range(10):
print(f"Suggesting item {i}", file=sys.stderr, flush=True)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL stderr was only changed to be line-buffered by default in Python 3.9, so to work in 3.8 we need to set flush=True here

items.append(types.CompletionItem(label=f"item-{i}"))

return items


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

from lsprotocol.types import ClientCapabilities
from lsprotocol.types import CompletionList
from lsprotocol.types import CompletionParams
from lsprotocol.types import InitializeParams
from lsprotocol.types import Position
from lsprotocol.types import TextDocumentIdentifier

import pytest_lsp
from pytest_lsp import ClientServerConfig
from pytest_lsp import LanguageClient


@pytest_lsp.fixture(
config=ClientServerConfig(server_command=[sys.executable, "server.py"]),
)
async def client(lsp_client: LanguageClient):
# Setup
params = InitializeParams(capabilities=ClientCapabilities())
await lsp_client.initialize_session(params)

yield

# Teardown
await lsp_client.shutdown_session()


async def test_completions(client: LanguageClient):
results = await client.text_document_completion_async(
params=CompletionParams(
position=Position(line=1, character=0),
text_document=TextDocumentIdentifier(uri="file:///path/to/file.txt"),
)
)

assert results is not None

if isinstance(results, CompletionList):
items = results.items
else:
items = results

labels = [item.label for item in items]
assert labels == [f"item-{i}" for i in range(10)]
assert False # Force the test case to fail.
21 changes: 21 additions & 0 deletions lib/pytest-lsp/tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,27 @@ def test_generic_rpc(pytester: pytest.Pytester):
results.stdout.fnmatch_lines(" *LOG: b=2")


def test_server_stderr_fail(pytester: pytest.Pytester):
"""Ensure that the server's stderr stream is presented on failure."""

setup_test(pytester, "server-stderr")

results = pytester.runpytest()
results.assert_outcomes(failed=1)

results.stdout.fnmatch_lines("-* Captured stderr call -*")
results.stdout.fnmatch_lines("Suggesting item 0")
results.stdout.fnmatch_lines("Suggesting item 1")
results.stdout.fnmatch_lines("Suggesting item 2")
results.stdout.fnmatch_lines("Suggesting item 3")
results.stdout.fnmatch_lines("Suggesting item 4")
results.stdout.fnmatch_lines("Suggesting item 5")
results.stdout.fnmatch_lines("Suggesting item 6")
results.stdout.fnmatch_lines("Suggesting item 7")
results.stdout.fnmatch_lines("Suggesting item 8")
results.stdout.fnmatch_lines("Suggesting item 9")


def test_window_log_message_fail(pytester: pytest.Pytester):
"""Ensure that the initial getting started example fails as expected."""

Expand Down
2 changes: 1 addition & 1 deletion lib/pytest-lsp/tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ async def test_capabilities(client):
else:
message = [
"E*asyncio.exceptions.CancelledError: Server process exited with return code: 1", # noqa: E501
"E*ZeroDivisionError: division by zero",
"ZeroDivisionError: division by zero",
]

results.stdout.fnmatch_lines(message)
Expand Down
Loading