Skip to content

Commit

Permalink
Merge pull request #194 from swyddfa/develop
Browse files Browse the repository at this point in the history
New Release
  • Loading branch information
alcarney authored Nov 23, 2024
2 parents 6294939 + df1ffe3 commit eb3dd21
Show file tree
Hide file tree
Showing 43 changed files with 310 additions and 311 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/lsp-devtools-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.9", "3.10", "3.11", "3.12"]
os: [ubuntu-latest]

steps:
Expand Down
10 changes: 5 additions & 5 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ exclude: '.bumpversion.cfg$'
repos:

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
rev: v5.0.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
exclude: 'lib/pytest-lsp/pytest_lsp/clients/.*\.json'
- id: trailing-whitespace

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.2
rev: v0.8.0
hooks:
- id: ruff
args: [--fix]
Expand All @@ -20,15 +20,15 @@ repos:
files: 'lib/.*\.py'

- repo: https://github.com/pre-commit/mirrors-mypy
rev: 'v1.10.1'
rev: 'v1.13.0'
hooks:
- id: mypy
name: mypy (pytest-lsp)
args: [--explicit-package-bases,--check-untyped-defs]
additional_dependencies:
- importlib-resources
- platformdirs
- pygls
- 'pygls>=2.0a2'
- pytest
- pytest-asyncio
- websockets
Expand All @@ -42,7 +42,7 @@ repos:
- attrs
- importlib-resources
- platformdirs
- pygls
- 'pygls>=2.0a2'
- stamina
- textual
- websockets
Expand Down
3 changes: 2 additions & 1 deletion docs/pytest-lsp/howto/testing-json-rpc-servers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ As an example we'll reuse some of the `pygls`_ internals to write a simple JSON-

- 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``
- client to server notification ``server/exit`` that instructs the server to exit
- server to client notification ``log/message``, allows the server to send debug messages to the client.

.. note::
Expand Down Expand Up @@ -40,7 +41,7 @@ Once you have your factory function defined you can pass it to the :class:`~pyte
.. literalinclude:: ../../../lib/pytest-lsp/tests/examples/generic-rpc/t_server.py
:language: python
:start-at: @pytest_lsp.fixture(
:end-at: # Teardown code
:end-at: rpc_client.protocol.notify

Writing Test Cases
------------------
Expand Down
1 change: 1 addition & 0 deletions lib/lsp-devtools/changes/190.misc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Drop Python 3.8 support
1 change: 1 addition & 0 deletions lib/lsp-devtools/changes/191.fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The `lsp-devtools agent` should now suppress `asyncio.CancelledError` exceptions allowing the agent to process to terminate gracefully
1 change: 1 addition & 0 deletions lib/lsp-devtools/changes/192.misc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Migrate to pygls `v2.0a2`
5 changes: 4 additions & 1 deletion lib/lsp-devtools/hatch.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ packages = ["lsp_devtools"]
[envs.hatch-test]
extra-dependencies = ["pytest-asyncio"]

[envs.hatch-test.env-vars]
UV_PRERELEASE="allow"

[envs.hatch-static-analysis]
config-path = "ruff_defaults.toml"
dependencies = ["ruff==0.5.2"]
dependencies = ["ruff==0.8.0"]
12 changes: 8 additions & 4 deletions lib/lsp-devtools/lsp_devtools/agent/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations

import argparse
import asyncio
import subprocess
import sys
from typing import List

from .agent import Agent
from .agent import RPCMessage
Expand Down Expand Up @@ -31,7 +32,7 @@ async def forward_stderr(server: asyncio.subprocess.Process):
sys.stderr.buffer.write(line)


async def main(args, extra: List[str]):
async def main(args, extra: list[str]):
if extra is None:
print("Missing server start command", file=sys.stderr)
return 1
Expand All @@ -54,8 +55,11 @@ async def main(args, extra: List[str]):
)


def run_agent(args, extra: List[str]):
asyncio.run(main(args, extra))
def run_agent(args, extra: list[str]):
try:
asyncio.run(main(args, extra))
except asyncio.CancelledError:
pass


def cli(commands: argparse._SubParsersAction):
Expand Down
33 changes: 14 additions & 19 deletions lib/lsp-devtools/lsp_devtools/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,14 @@
import attrs

if typing.TYPE_CHECKING:
from collections.abc import Coroutine
from typing import Any
from typing import BinaryIO
from typing import Callable
from typing import Coroutine
from typing import Dict
from typing import Optional
from typing import Set
from typing import Tuple
from typing import Union

from pygls.io_ import AsyncReader

MessageHandler = Callable[[bytes], Union[None, Coroutine[Any, Any, None]]]

UTC = timezone.utc
Expand All @@ -35,9 +33,9 @@
class RPCMessage:
"""A Json-RPC message."""

headers: Dict[str, str]
headers: dict[str, str]

body: Dict[str, Any]
body: dict[str, Any]

def __getitem__(self, key: str):
return self.headers[key]
Expand All @@ -46,8 +44,8 @@ def __getitem__(self, key: str):
def parse_rpc_message(data: bytes) -> RPCMessage:
"""Parse a JSON-RPC message from the given set of bytes."""

headers: Dict[str, str] = {}
body: Optional[Dict[str, Any]] = None
headers: dict[str, str] = {}
body: dict[str, Any] | None = None
headers_complete = False

for line in data.split(b"\r\n"):
Expand Down Expand Up @@ -78,7 +76,7 @@ def parse_rpc_message(data: bytes) -> RPCMessage:
return RPCMessage(headers, body)


async def aio_readline(reader: asyncio.StreamReader, message_handler: MessageHandler):
async def aio_readline(reader: AsyncReader, message_handler: MessageHandler):
CONTENT_LENGTH_PATTERN = re.compile(rb"^Content-Length: (\d+)\r\n$")

# Initialize message buffer
Expand Down Expand Up @@ -118,7 +116,7 @@ async def aio_readline(reader: asyncio.StreamReader, message_handler: MessageHan

async def get_streams(
stdin, stdout
) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]:
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
"""Convert blocking stdin/stdout streams into async streams."""
loop = asyncio.get_running_loop()

Expand Down Expand Up @@ -150,9 +148,9 @@ def __init__(
self.handler = handler
self.session_id = str(uuid4())

self._tasks: Set[asyncio.Task] = set()
self.reader: Optional[asyncio.StreamReader] = None
self.writer: Optional[asyncio.StreamWriter] = None
self._tasks: set[asyncio.Task] = set()
self.reader: asyncio.StreamReader | None = None
self.writer: asyncio.StreamWriter | None = None

async def start(self):
# Get async versions of stdin/stdout
Expand Down Expand Up @@ -226,13 +224,10 @@ async def stop(self):
except TimeoutError:
self.server.kill()

args = {}
if sys.version_info >= (3, 9):
args["msg"] = "lsp-devtools agent is stopping."

# Cancel the tasks connecting client to server
for task in self._tasks:
task.cancel(**args)
logger.debug("cancelling: %s", task)
task.cancel(msg="lsp-devtools agent is stopping.")

if self.writer:
self.writer.close()
102 changes: 18 additions & 84 deletions lib/lsp-devtools/lsp_devtools/agent/client.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,17 @@
from __future__ import annotations

import asyncio
import inspect
import typing

import stamina
from pygls.client import JsonRPCClient
from pygls.client import aio_readline
from pygls.protocol import default_converter

from lsp_devtools.agent.protocol import AgentProtocol

if typing.TYPE_CHECKING:
from typing import Any
from typing import List
from typing import Optional

# from websockets.client import WebSocketClientProtocol


# class WebSocketClientTransportAdapter:
# """Protocol adapter for the WebSocket client interface."""

# def __init__(self, ws: WebSocketClientProtocol, loop: asyncio.AbstractEventLoop):
# self._ws = ws
# self._loop = loop

# def close(self) -> None:
# """Stop the WebSocket server."""
# print("-- CLOSING --")
# self._loop.create_task(self._ws.close())

# def write(self, data: Any) -> None:
# """Create a task to write specified data into a WebSocket."""
# asyncio.ensure_future(self._ws.send(data))


class AgentClient(JsonRPCClient):
Expand All @@ -45,17 +24,17 @@ def __init__(self):
protocol_cls=AgentProtocol, converter_factory=default_converter
)
self.connected = False
self._buffer: List[bytes] = []
self._buffer: list[bytes] = []
self._tasks: set[asyncio.Task[Any]] = set()

def _report_server_error(self, error, source):
# Bail on error
# TODO: Report the actual error somehow
self._stop_event.set()

def feature(self, feature_name: str, options: Optional[Any] = None):
def feature(self, feature_name: str, options: Any | None = None):
return self.protocol.fm.feature(feature_name, options)

# TODO: Upstream this... or at least something equivalent.
async def start_tcp(self, host: str, port: int):
# The user might not have started the server app immediately and since the
# agent will live as long as the wrapper language server we may as well
Expand All @@ -69,71 +48,26 @@ async def start_tcp(self, host: str, port: int):
)
async for attempt in retries:
with attempt:
reader, writer = await asyncio.open_connection(host, port)

self.protocol.connection_made(writer) # type: ignore[arg-type]
connection = asyncio.create_task(
aio_readline(self._stop_event, reader, self.protocol.data_received)
)
self.connected = True
self._async_tasks.append(connection)
await super().start_tcp(host, port)
self.connected = True

def forward_message(self, message: bytes):
"""Forward the given message to the server instance."""

if not self.connected:
if not self.connected or self.protocol.writer is None:
self._buffer.append(message)
return

if self.protocol.transport is None:
return

# Send any buffered messages
while len(self._buffer) > 0:
self.protocol.transport.write(self._buffer.pop(0))

self.protocol.transport.write(message)

# TODO: Upstream this... or at least something equivalent.
# def start_ws(self, host: str, port: int):
# self.protocol._send_only_body = True # Don't send headers within the payload

# async def client_connection(host: str, port: int):
# """Create and run a client connection."""

# self._client = await websockets.connect( # type: ignore
# f"ws://{host}:{port}"
# )
# loop = asyncio.get_running_loop()
# self.protocol.transport = WebSocketClientTransportAdapter(
# self._client, loop
# )
# message = None

# try:
# while not self._stop_event.is_set():
# try:
# message = await asyncio.wait_for(
# self._client.recv(), timeout=0.5
# )
# self.protocol._procedure_handler(
# json.loads(
# message,
# object_hook=self.protocol._deserialize_message
# )
# )
# except JSONDecodeError:
# print(message or "-- message not found --")
# raise
# except TimeoutError:
# pass
# except Exception:
# raise

# finally:
# await self._client.close()

# try:
# asyncio.run(client_connection(host, port))
# except KeyboardInterrupt:
# pass
res = self.protocol.writer.write(self._buffer.pop(0))
if inspect.isawaitable(res):
task = asyncio.ensure_future(res)
task.add_done_callback(self._tasks.discard)
self._tasks.add(task)

res = self.protocol.writer.write(message)
if inspect.isawaitable(res):
task = asyncio.ensure_future(res)
task.add_done_callback(self._tasks.discard)
self._tasks.add(task)
Loading

0 comments on commit eb3dd21

Please sign in to comment.