From 84be4702b2a531e61b8548aea940e98dbad7479c Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Wed, 21 Jun 2023 18:14:22 +0100 Subject: [PATCH 01/63] lsp-devtools: Swap relationship between client and server Originally, the assumption was that the lsp agent would host a server that commands like `lsp-devtools record` and `lsp-devtools tui` would connect to. However this posed a number of issues, such as commands like `record` missing the beginning of a session since the agent would not wait for a connection (#29) as well as the agent not stopping once an LSP session ended (#17) So this commit reverses that relationship, with commands like ``record`` spinning up the server and the lsp agent creating a client connection. Additionally this commit - Switches to using the base `Client` provided by a future version of `pygls` - Trials using async stdin/stdout streams for the agent - a potential candidate someday for upstreaming into `pygls` - Switches to using a TCP connection between client and server. (#37) --- lib/lsp-devtools/changes/17.fix.rst | 1 + lib/lsp-devtools/changes/29.fix.rst | 1 + lib/lsp-devtools/changes/37.misc.rst | 1 + .../lsp_devtools/agent/__init__.py | 70 ++++---- lib/lsp-devtools/lsp_devtools/agent/agent.py | 122 +++++++------ lib/lsp-devtools/lsp_devtools/agent/client.py | 166 +++++++----------- lib/lsp-devtools/lsp_devtools/agent/server.py | 60 ++++++- lib/lsp-devtools/pyproject.toml | 2 +- 8 files changed, 231 insertions(+), 192 deletions(-) create mode 100644 lib/lsp-devtools/changes/17.fix.rst create mode 100644 lib/lsp-devtools/changes/29.fix.rst create mode 100644 lib/lsp-devtools/changes/37.misc.rst diff --git a/lib/lsp-devtools/changes/17.fix.rst b/lib/lsp-devtools/changes/17.fix.rst new file mode 100644 index 0000000..af9cc8e --- /dev/null +++ b/lib/lsp-devtools/changes/17.fix.rst @@ -0,0 +1 @@ +The ``lsp-devtools agent`` command no longer fails to exit once an LSP session closes. diff --git a/lib/lsp-devtools/changes/29.fix.rst b/lib/lsp-devtools/changes/29.fix.rst new file mode 100644 index 0000000..bef6b1e --- /dev/null +++ b/lib/lsp-devtools/changes/29.fix.rst @@ -0,0 +1 @@ +As a consequence of the new architecture, commands like ``lsp-devtools record`` no longer miss the start of an LSP session diff --git a/lib/lsp-devtools/changes/37.misc.rst b/lib/lsp-devtools/changes/37.misc.rst new file mode 100644 index 0000000..a42dd18 --- /dev/null +++ b/lib/lsp-devtools/changes/37.misc.rst @@ -0,0 +1 @@ +The ``lsp-devtools agent`` now uses a TCP connection, which should make distribution easier diff --git a/lib/lsp-devtools/lsp_devtools/agent/__init__.py b/lib/lsp-devtools/lsp_devtools/agent/__init__.py index c1d4165..76a6fff 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/agent/__init__.py @@ -3,16 +3,15 @@ import logging import subprocess import sys -import threading from typing import List from .agent import Agent from .agent import logger from .client import AgentClient -from .client import parse_rpc_message from .protocol import MESSAGE_TEXT_NOTIFICATION from .protocol import MessageText from .server import AgentServer +from .server import parse_rpc_message __all__ = [ "Agent", @@ -25,47 +24,51 @@ ] -class WSHandler(logging.Handler): - """Logging handler that forwards captured LSP messages through to the web socket - client.""" +class MessageHandler(logging.Handler): + """Logging handler that forwards captured JSON-RPC messages through to the + ``AgentServer`` instance.""" - def __init__(self, server: AgentServer, *args, **kwargs): + def __init__(self, client: AgentClient, *args, **kwargs): super().__init__(*args, **kwargs) - self.server = server + self.client = client def emit(self, record: logging.LogRecord): message = MessageText( text=record.args[0], # type: ignore source=record.__dict__["source"], ) - self.server.lsp.message_text_notification(message) + self.client.protocol.message_text_notification(message) -def run_agent(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 - process = subprocess.Popen(extra, stdin=subprocess.PIPE, stdout=subprocess.PIPE) - agent = Agent(process, sys.stdin.buffer, sys.stdout.buffer) + command, *arguments = extra + + server = await asyncio.create_subprocess_exec( + command, + *arguments, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + agent = Agent(server, sys.stdin.buffer, sys.stdout.buffer) - server = AgentServer() - handler = WSHandler(server) + client = AgentClient() + handler = MessageHandler(client) handler.setLevel(logging.INFO) logger.setLevel(logging.INFO) logger.addHandler(handler) - agent_thread = threading.Thread( - target=asyncio.run, - args=(agent.start(),), - ) - agent_thread.start() + await client.start_tcp(args.host, args.port) + await agent.start() + - try: - server.start_ws(args.host, args.port) - finally: - agent.stop() +def run_agent(args, extra: List[str]): + asyncio.run(main(args, extra)) def cli(commands: argparse._SubParsersAction): @@ -74,31 +77,32 @@ def cli(commands: argparse._SubParsersAction): help="instrument an LSP session", formatter_class=argparse.RawDescriptionHelpFormatter, description="""\ -This command runs the given language server as a subprocess, wrapping it in a -websocket server allowing all traffic to be inspected by some client. +This command runs the given JSON-RPC server as a subprocess, wrapping it in a +an "AgentClient" which will capture all messages sent to/from the wrapped +server, forwarding them onto an "AgentServer" to be processed. To wrap a server, supply its start command after all other agent options and preceeded by a `--`, for example: lsp-devtools agent -p 1234 -- python -m esbonio -Wrapping a language server with this command is required to enable the +Wrapping a JSON-RPC server with this command is required to enable the majority of the lsp-devtools suite of tools. - ┌─ LSP Client ─┐ ┌─────── Agent ────────┐ ┌─ LSP Server ─┐ + ┌─ RPC Client ─┐ ┌──── Agent Client ────┐ ┌─ RPC Server ─┐ │ │ │ ┌──────────────┐ │ │ │ │ stdout│─────│───│ │───│────│stdin │ - │ │ │ │ Agent Server │ │ │ │ + │ │ │ │ Agent │ │ │ │ │ stdin│─────│───│ │───│────│stdout │ │ │ │ └──────────────┘ │ │ │ - │ │ │ │ │ │ │ - └──────────────┘ └──────────┴───────────┘ └──────────────┘ + │ │ │ │ │ │ + └──────────────┘ └──────────────────────┘ └──────────────┘ │ - │ web socket + │ tcp/websocket │ ┌──────────────┐ │ │ - │ Agent Client │ + │ Agent Server │ │ │ └──────────────┘ @@ -107,13 +111,13 @@ def cli(commands: argparse._SubParsersAction): cmd.add_argument( "--host", - help="the host to run the websocket server on.", + help="the host to connect to.", default="localhost", ) cmd.add_argument( "-p", "--port", - help="the port to run the websocket server on", + help="the port to connect to", default=8765, ) diff --git a/lib/lsp-devtools/lsp_devtools/agent/agent.py b/lib/lsp-devtools/lsp_devtools/agent/agent.py index 982f4b8..8cb1f90 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/agent.py +++ b/lib/lsp-devtools/lsp_devtools/agent/agent.py @@ -1,20 +1,18 @@ import asyncio +import inspect import logging -import subprocess +import re import threading -from concurrent.futures import ThreadPoolExecutor from functools import partial from typing import BinaryIO -from pygls.server import aio_readline - logger = logging.getLogger("lsp_devtools.agent") -def forward_message(source: str, dest: BinaryIO, message: bytes): +async def forward_message(source: str, dest: asyncio.StreamWriter, message: bytes): """Forward the given message to the destination channel""" dest.write(message) - dest.flush() + await dest.drain() # Log the full message logger.info( @@ -24,81 +22,103 @@ def forward_message(source: str, dest: BinaryIO, message: bytes): ) -async def check_server_process( - server_process: subprocess.Popen, stop_event: threading.Event -): - """Ensure that the server process is still alive.""" - - while not stop_event.is_set(): - retcode = server_process.poll() - print(".") - if retcode is not None: - # Cancel any pending tasks. - for task in asyncio.all_tasks(): - task.cancel(f"Server process exited with code: {retcode}") +# TODO: Upstream this? +async def aio_readline(stop_event, reader, message_handler): + CONTENT_LENGTH_PATTERN = re.compile(rb"^Content-Length: (\d+)\r\n$") - # Signal everything to stop. - stop_event.set() + # Initialize message buffer + message = [] + content_length = 0 - await asyncio.sleep(0.1) + while not stop_event.is_set(): + # Read a header line + header = await reader.readline() + if not header: + break + message.append(header) + + # Extract content length if possible + if not content_length: + match = CONTENT_LENGTH_PATTERN.fullmatch(header) + if match: + content_length = int(match.group(1)) + logger.debug("Content length: %s", content_length) + + # Check if all headers have been read (as indicated by an empty line \r\n) + if content_length and not header.strip(): + # Read body + body = await reader.readexactly(content_length) + if not body: + break + message.append(body) + + # Pass message to protocol, optionally async + result = message_handler(b"".join(message)) + if inspect.isawaitable(result): + await result + + # Reset the buffer + message = [] + content_length = 0 + + +async def get_streams(stdin, stdout): + """Convert blocking stdin/stdout streams into async streams.""" + loop = asyncio.get_running_loop() + + reader = asyncio.StreamReader() + read_protocol = asyncio.StreamReaderProtocol(reader) + await loop.connect_read_pipe(lambda: read_protocol, stdin) + + write_transport, write_protocol = await loop.connect_write_pipe( + asyncio.streams.FlowControlMixin, stdout + ) + writer = asyncio.StreamWriter(write_transport, write_protocol, reader, loop) + return reader, writer class Agent: """The Agent sits between a language server and its client, listening to messages enabling them to be recorded.""" - def __init__(self, server: subprocess.Popen, stdin: BinaryIO, stdout: BinaryIO): + def __init__( + self, server: asyncio.subprocess.Process, stdin: BinaryIO, stdout: BinaryIO + ): self.stdin = stdin self.stdout = stdout - self.server_process = server + self.server = server self.stop_event = threading.Event() - self.thread_pool_executor = ThreadPoolExecutor( - max_workers=4, - thread_name_prefix="LSP Traffic Worker ", - ) async def start(self): - event_loop = asyncio.get_running_loop() + # Get async versions of stdin/stdout + reader, writer = await get_streams(self.stdin, self.stdout) # Connect stdin to the subprocess' stdin client_to_server = aio_readline( - loop=event_loop, - executor=self.thread_pool_executor, - stop_event=self.stop_event, - rfile=self.stdin, - proxy=partial(forward_message, "client", self.server_process.stdin), + self.stop_event, + reader, + partial(forward_message, "client", self.server.stdin), ) # Connect the subprocess' stdout to stdout server_to_client = aio_readline( - loop=event_loop, - executor=self.thread_pool_executor, - stop_event=self.stop_event, - rfile=self.server_process.stdout, - proxy=partial(forward_message, "server", self.stdout), + self.stop_event, + self.server.stdout, + partial(forward_message, "server", writer), ) # Run both connections concurrently. return await asyncio.gather( client_to_server, server_to_client, - check_server_process(self.server_process, self.stop_event), ) - def stop(self): + async def stop(self): self.stop_event.set() - self.thread_pool_executor.shutdown(wait=False, cancel_futures=True) try: - self.server_process.terminate() - ret = self.server_process.wait(timeout=1) + self.server.terminate() + ret = await self.server.wait() print(f"Server process exited with code: {ret}") except TimeoutError: - self.server_process.kill() - - # Need to close these to prevent open file warnings - if self.server_process.stdin is not None: - self.server_process.stdin.close() - - if self.server_process.stdout is not None: - self.server_process.stdout.close() + self.server.kill() diff --git a/lib/lsp-devtools/lsp_devtools/agent/client.py b/lib/lsp-devtools/lsp_devtools/agent/client.py index 01816dc..68f94b3 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/client.py +++ b/lib/lsp-devtools/lsp_devtools/agent/client.py @@ -1,19 +1,13 @@ import asyncio -import json -import re -from json.decoder import JSONDecodeError -from threading import Event from typing import Any -from typing import Callable from typing import Optional -import websockets +from pygls.client import Client +from pygls.client import aio_readline from pygls.protocol import default_converter -from pygls.server import Server from websockets.client import WebSocketClientProtocol from lsp_devtools.agent.protocol import AgentProtocol -from lsp_devtools.agent.protocol import MessageText class WebSocketClientTransportAdapter: @@ -33,62 +27,15 @@ def write(self, data: Any) -> None: asyncio.ensure_future(self._ws.send(data)) -MESSAGE_PATTERN = re.compile( - r"^(?:[^\r\n]+\r\n)*" - + r"Content-Length: (?P\d+)\r\n" - + r"(?:[^\r\n]+\r\n)*\r\n" - + r"(?P{.*)", - re.DOTALL, -) +class AgentClient(Client): + """Client for connecting to an AgentServer instance.""" - -def parse_rpc_message( - ls: "AgentClient", message: MessageText, callback: Callable[[dict], None] -): - """Parse json-rpc messages coming from the agent. - - Originally adatped from the ``data_received`` method on pygls' ``JsonRPCProtocol`` - class. - """ - data = message.text - message_buf = ls._client_buf if message.source == "client" else ls._server_buf - - while len(data): - # Append the incoming chunk to the message buffer - message_buf.append(data) - - # Look for the body of the message - msg = "".join(message_buf) - found = MESSAGE_PATTERN.fullmatch(msg) - - body = found.group("body") if found else "" - length = int(found.group("length")) if found else 1 - - if len(body) < length: - # Message is incomplete; bail until more data arrives - return - - # Message is complete; - # extract the body and any remaining data, - # and reset the buffer for the next message - body, data = body[:length], body[length:] - message_buf.clear() - - callback(json.loads(body)) - - -class AgentClient(Server): - """Client for connecting to an LSPAgent instance.""" - - lsp: AgentProtocol + protocol: AgentProtocol def __init__(self): super().__init__( protocol_cls=AgentProtocol, converter_factory=default_converter ) - self._client_buf = [] - self._server_buf = [] - self._stop_event: Event = Event() def _report_server_error(self, error, source): # Bail on error @@ -96,50 +43,59 @@ def _report_server_error(self, error, source): self._stop_event.set() def feature(self, feature_name: str, options: Optional[Any] = None): - return self.lsp.fm.feature(feature_name, options) - - def start_ws_client(self, host: str, port: int): - """Similar to ``start_ws``, but where we create a client connection rather than - host a server.""" - - self.lsp._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}" - ) - self.lsp.transport = WebSocketClientTransportAdapter( - self._client, self.loop - ) - message = None - - try: - while not self._stop_event.is_set(): - try: - message = await asyncio.wait_for( - self._client.recv(), timeout=0.5 - ) - self.lsp._procedure_handler( - json.loads( - message, object_hook=self.lsp._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 - finally: - self.shutdown() + 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): + reader, writer = await asyncio.open_connection(host, port) + + # adapter = TCPTransportAdapter(writer) + self.protocol.connection_made(writer) + connection = asyncio.create_task( + aio_readline(self._stop_event, reader, self.protocol.data_received) + ) + self._async_tasks.append(connection) + + # 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 diff --git a/lib/lsp-devtools/lsp_devtools/agent/server.py b/lib/lsp-devtools/lsp_devtools/agent/server.py index b0456a5..8d4972a 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/server.py +++ b/lib/lsp-devtools/lsp_devtools/agent/server.py @@ -1,12 +1,19 @@ +import json +import re +from typing import Any +from typing import Callable +from typing import Optional + from pygls.protocol import default_converter from pygls.server import Server from lsp_devtools.agent.protocol import AgentProtocol +from lsp_devtools.agent.protocol import MessageText class AgentServer(Server): - """A pygls server that wraps an agent allowing other processes to interact with it - via websockets.""" + """A pygls server that accepts connections from agents allowing them to send their + collected messages.""" lsp: AgentProtocol @@ -18,3 +25,52 @@ def __init__(self, *args, **kwargs): kwargs["converter_factory"] = default_converter super().__init__(*args, **kwargs) + self._client_buffer = [] + self._server_buffer = [] + + def feature(self, feature_name: str, options: Optional[Any] = None): + return self.lsp.fm.feature(feature_name, options) + + +MESSAGE_PATTERN = re.compile( + r"^(?:[^\r\n]+\r\n)*" + + r"Content-Length: (?P\d+)\r\n" + + r"(?:[^\r\n]+\r\n)*\r\n" + + r"(?P{.*)", + re.DOTALL, +) + + +def parse_rpc_message( + ls: AgentServer, message: MessageText, callback: Callable[[dict], None] +): + """Parse json-rpc messages coming from the agent. + + Originally adatped from the ``data_received`` method on pygls' ``JsonRPCProtocol`` + class. + """ + data = message.text + message_buf = ls._client_buffer if message.source == "client" else ls._server_buffer + + while len(data): + # Append the incoming chunk to the message buffer + message_buf.append(data) + + # Look for the body of the message + msg = "".join(message_buf) + found = MESSAGE_PATTERN.fullmatch(msg) + + body = found.group("body") if found else "" + length = int(found.group("length")) if found else 1 + + if len(body) < length: + # Message is incomplete; bail until more data arrives + return + + # Message is complete; + # extract the body and any remaining data, + # and reset the buffer for the next message + body, data = body[:length], body[length:] + message_buf.clear() + + callback(json.loads(body)) diff --git a/lib/lsp-devtools/pyproject.toml b/lib/lsp-devtools/pyproject.toml index 19c7100..a44b00f 100644 --- a/lib/lsp-devtools/pyproject.toml +++ b/lib/lsp-devtools/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "appdirs", "aiosqlite", "importlib-resources; python_version<\"3.9\"", - "pygls[ws]", + "pygls", "textual>=0.14.0", "typing-extensions; python_version<\"3.8\"", ] From 0eb1a875d1f6c3f990862fc838d39e2deafae20e Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Wed, 21 Jun 2023 18:31:38 +0100 Subject: [PATCH 02/63] lsp-devtools: Align `lsp-devtools record` to new agent architecture --- lib/lsp-devtools/changes/28.fix.rst | 1 + lib/lsp-devtools/changes/38.fix.rst | 1 + .../lsp_devtools/record/__init__.py | 33 +++++++++++++------ .../lsp_devtools/record/filters.py | 1 + lib/lsp-devtools/tests/record/test_filters.py | 2 -- 5 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 lib/lsp-devtools/changes/28.fix.rst create mode 100644 lib/lsp-devtools/changes/38.fix.rst diff --git a/lib/lsp-devtools/changes/28.fix.rst b/lib/lsp-devtools/changes/28.fix.rst new file mode 100644 index 0000000..d4884d8 --- /dev/null +++ b/lib/lsp-devtools/changes/28.fix.rst @@ -0,0 +1 @@ +``lsp-devtools record`` no longer emits a ``ResourceWarning`` diff --git a/lib/lsp-devtools/changes/38.fix.rst b/lib/lsp-devtools/changes/38.fix.rst new file mode 100644 index 0000000..eb9bd1f --- /dev/null +++ b/lib/lsp-devtools/changes/38.fix.rst @@ -0,0 +1 @@ +``lsp-devtools agent`` no longer emits ``Unable to send data, no available transport!`` messages diff --git a/lib/lsp-devtools/lsp_devtools/record/__init__.py b/lib/lsp-devtools/lsp_devtools/record/__init__.py index 24cce15..5933391 100644 --- a/lib/lsp-devtools/lsp_devtools/record/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/record/__init__.py @@ -1,16 +1,19 @@ import argparse +import json import logging import pathlib +import sys +from functools import partial +from logging import LogRecord from typing import List from typing import Optional -from pygls.protocol import partial from rich.console import ConsoleRenderable from rich.logging import RichHandler from rich.traceback import Traceback from lsp_devtools.agent import MESSAGE_TEXT_NOTIFICATION -from lsp_devtools.agent import AgentClient +from lsp_devtools.agent import AgentServer from lsp_devtools.agent import MessageText from lsp_devtools.agent import parse_rpc_message from lsp_devtools.handlers.sql import SqlHandler @@ -50,13 +53,19 @@ def render( return res + def format(self, record: LogRecord) -> str: + # Pretty print json messages + if isinstance(record.args, dict): + record.args = (json.dumps(record.args, indent=2),) + return super().format(record) -def log_raw_message(ls: AgentClient, message: MessageText): + +def log_raw_message(ls: AgentServer, message: MessageText): """Push raw messages through the logging system.""" logger.info(message.text, extra={"source": message.source}) -def log_rpc_message(ls: AgentClient, message: MessageText): +def log_rpc_message(ls: AgentServer, message: MessageText): """Push parsed json-rpc messages through the logging system""" logfn = partial(logger.info, "%s", extra={"source": message.source}) @@ -114,10 +123,13 @@ def setup_sqlite_output(args): def start_recording(args, extra: List[str]): - client = AgentClient() + server = AgentServer() log_func = log_raw_message if args.capture_raw_output else log_rpc_message logger.setLevel(logging.INFO) - client.feature(MESSAGE_TEXT_NOTIFICATION)(log_func) + server.feature(MESSAGE_TEXT_NOTIFICATION)(log_func) + + host = args.host + port = args.port if args.to_file: setup_file_output(args) @@ -129,7 +141,8 @@ def start_recording(args, extra: List[str]): setup_stdout_output(args) try: - client.start_ws_client(args.host, args.port) + print(f"Waiting for connection on {host}:{port}...", end="\r", flush=True) + server.start_tcp(host, port) except Exception: # TODO: Error handling raise @@ -188,10 +201,10 @@ def setup_filter_args(cmd: argparse.ArgumentParser): def cli(commands: argparse._SubParsersAction): cmd: argparse.ArgumentParser = commands.add_parser( "record", - help="record an LSP session, requires the server be wrapped by an agent.", + help="record a JSON-RPC session.", description="""\ -This command connects to an LSP agent allowing for messages sent -between client and server to be logged. +This command starts a JSON-RPC server allowing for a client to connect (over TCP by +default) and push messages to it and have them be recorded. """, ) diff --git a/lib/lsp-devtools/lsp_devtools/record/filters.py b/lib/lsp-devtools/lsp_devtools/record/filters.py index 5e534e9..b6ec12c 100644 --- a/lib/lsp-devtools/lsp_devtools/record/filters.py +++ b/lib/lsp-devtools/lsp_devtools/record/filters.py @@ -78,6 +78,7 @@ def filter(self, record: logging.LogRecord) -> bool: if self.formatter.pattern: try: record.msg = self.formatter.format(message) + record.args = None except Exception: logger.debug( "Skipping message that failed to format: %s", message, exc_info=True diff --git a/lib/lsp-devtools/tests/record/test_filters.py b/lib/lsp-devtools/tests/record/test_filters.py index d75deac..6c01f0f 100644 --- a/lib/lsp-devtools/tests/record/test_filters.py +++ b/lib/lsp-devtools/tests/record/test_filters.py @@ -384,7 +384,5 @@ def test_filter_format_message(): record = logging.LogRecord("example", logging.INFO, "", 0, "%s", request, None) record.__dict__["source"] = "client" - lsp.filter(record) assert lsp.filter(record) is True - assert record.msg == "file:///path/to/file.txt" From d9e2f050d7af146d72953eb28eca299994a5d301 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sat, 10 Jun 2023 18:47:49 +0100 Subject: [PATCH 03/63] Remove auto-generated `LanguageClient` --- lib/pytest-lsp/pytest_lsp/gen.py | 1702 ------------------------------ scripts/gen_client.py | 203 ---- 2 files changed, 1905 deletions(-) delete mode 100644 lib/pytest-lsp/pytest_lsp/gen.py delete mode 100644 scripts/gen_client.py diff --git a/lib/pytest-lsp/pytest_lsp/gen.py b/lib/pytest-lsp/pytest_lsp/gen.py deleted file mode 100644 index 9d155dc..0000000 --- a/lib/pytest-lsp/pytest_lsp/gen.py +++ /dev/null @@ -1,1702 +0,0 @@ -# GENERATED FROM scripts/gen-client.py -- DO NOT EDIT -# Last Modified: 2023-04-06 19:06:16.290214 -# flake8: noqa -from concurrent.futures import Future -from lsprotocol.types import CallHierarchyIncomingCall -from lsprotocol.types import CallHierarchyIncomingCallsParams -from lsprotocol.types import CallHierarchyItem -from lsprotocol.types import CallHierarchyOutgoingCall -from lsprotocol.types import CallHierarchyOutgoingCallsParams -from lsprotocol.types import CallHierarchyPrepareParams -from lsprotocol.types import CancelParams -from lsprotocol.types import CodeAction -from lsprotocol.types import CodeActionParams -from lsprotocol.types import CodeLens -from lsprotocol.types import CodeLensParams -from lsprotocol.types import ColorInformation -from lsprotocol.types import ColorPresentation -from lsprotocol.types import ColorPresentationParams -from lsprotocol.types import Command -from lsprotocol.types import CompletionItem -from lsprotocol.types import CompletionList -from lsprotocol.types import CompletionParams -from lsprotocol.types import CreateFilesParams -from lsprotocol.types import DeclarationParams -from lsprotocol.types import DefinitionParams -from lsprotocol.types import DeleteFilesParams -from lsprotocol.types import DidChangeConfigurationParams -from lsprotocol.types import DidChangeNotebookDocumentParams -from lsprotocol.types import DidChangeTextDocumentParams -from lsprotocol.types import DidChangeWatchedFilesParams -from lsprotocol.types import DidChangeWorkspaceFoldersParams -from lsprotocol.types import DidCloseNotebookDocumentParams -from lsprotocol.types import DidCloseTextDocumentParams -from lsprotocol.types import DidOpenNotebookDocumentParams -from lsprotocol.types import DidOpenTextDocumentParams -from lsprotocol.types import DidSaveNotebookDocumentParams -from lsprotocol.types import DidSaveTextDocumentParams -from lsprotocol.types import DocumentColorParams -from lsprotocol.types import DocumentDiagnosticParams -from lsprotocol.types import DocumentFormattingParams -from lsprotocol.types import DocumentHighlight -from lsprotocol.types import DocumentHighlightParams -from lsprotocol.types import DocumentLink -from lsprotocol.types import DocumentLinkParams -from lsprotocol.types import DocumentOnTypeFormattingParams -from lsprotocol.types import DocumentRangeFormattingParams -from lsprotocol.types import DocumentSymbol -from lsprotocol.types import DocumentSymbolParams -from lsprotocol.types import ExecuteCommandParams -from lsprotocol.types import FoldingRange -from lsprotocol.types import FoldingRangeParams -from lsprotocol.types import Hover -from lsprotocol.types import HoverParams -from lsprotocol.types import ImplementationParams -from lsprotocol.types import InitializeParams -from lsprotocol.types import InitializeResult -from lsprotocol.types import InitializedParams -from lsprotocol.types import InlayHint -from lsprotocol.types import InlayHintParams -from lsprotocol.types import InlineValueEvaluatableExpression -from lsprotocol.types import InlineValueParams -from lsprotocol.types import InlineValueText -from lsprotocol.types import InlineValueVariableLookup -from lsprotocol.types import LinkedEditingRangeParams -from lsprotocol.types import LinkedEditingRanges -from lsprotocol.types import Location -from lsprotocol.types import LocationLink -from lsprotocol.types import Moniker -from lsprotocol.types import MonikerParams -from lsprotocol.types import PrepareRenameParams -from lsprotocol.types import PrepareRenameResult_Type1 -from lsprotocol.types import PrepareRenameResult_Type2 -from lsprotocol.types import ProgressParams -from lsprotocol.types import Range -from lsprotocol.types import ReferenceParams -from lsprotocol.types import RelatedFullDocumentDiagnosticReport -from lsprotocol.types import RelatedUnchangedDocumentDiagnosticReport -from lsprotocol.types import RenameFilesParams -from lsprotocol.types import RenameParams -from lsprotocol.types import SelectionRange -from lsprotocol.types import SelectionRangeParams -from lsprotocol.types import SemanticTokens -from lsprotocol.types import SemanticTokensDelta -from lsprotocol.types import SemanticTokensDeltaParams -from lsprotocol.types import SemanticTokensParams -from lsprotocol.types import SemanticTokensRangeParams -from lsprotocol.types import SetTraceParams -from lsprotocol.types import SignatureHelp -from lsprotocol.types import SignatureHelpParams -from lsprotocol.types import SymbolInformation -from lsprotocol.types import TextEdit -from lsprotocol.types import TypeDefinitionParams -from lsprotocol.types import TypeHierarchyItem -from lsprotocol.types import TypeHierarchyPrepareParams -from lsprotocol.types import TypeHierarchySubtypesParams -from lsprotocol.types import TypeHierarchySupertypesParams -from lsprotocol.types import WillSaveTextDocumentParams -from lsprotocol.types import WorkDoneProgressCancelParams -from lsprotocol.types import WorkspaceDiagnosticParams -from lsprotocol.types import WorkspaceDiagnosticReport -from lsprotocol.types import WorkspaceEdit -from lsprotocol.types import WorkspaceSymbol -from lsprotocol.types import WorkspaceSymbolParams -from pygls.protocol import LanguageServerProtocol -from pygls.protocol import default_converter -from pygls.server import Server -from typing import Any -from typing import Callable -from typing import List -from typing import Optional -from typing import Union - - -class Client(Server): - - def __init__( - self, - name: str, - version: str, - protocol_cls=LanguageServerProtocol, - converter_factory=default_converter, - **kwargs, - ): - self.name = name - self.version = version - super().__init__(protocol_cls, converter_factory, **kwargs) - - def call_hierarchy_incoming_calls( - self, - params: CallHierarchyIncomingCallsParams, - callback: Optional[Callable[[Optional[List[CallHierarchyIncomingCall]]], None]] = None, - ) -> Future: - """Make a ``callHierarchy/incomingCalls`` request. - - A request to resolve the incoming calls for a given `CallHierarchyItem`. - - @since 3.16.0 - """ - return self.lsp.send_request("callHierarchy/incomingCalls", params, callback) - - async def call_hierarchy_incoming_calls_async( - self, - params: CallHierarchyIncomingCallsParams, - ) -> Optional[List[CallHierarchyIncomingCall]]: - """Make a ``callHierarchy/incomingCalls`` request. - - A request to resolve the incoming calls for a given `CallHierarchyItem`. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("callHierarchy/incomingCalls", params) - - def call_hierarchy_outgoing_calls( - self, - params: CallHierarchyOutgoingCallsParams, - callback: Optional[Callable[[Optional[List[CallHierarchyOutgoingCall]]], None]] = None, - ) -> Future: - """Make a ``callHierarchy/outgoingCalls`` request. - - A request to resolve the outgoing calls for a given `CallHierarchyItem`. - - @since 3.16.0 - """ - return self.lsp.send_request("callHierarchy/outgoingCalls", params, callback) - - async def call_hierarchy_outgoing_calls_async( - self, - params: CallHierarchyOutgoingCallsParams, - ) -> Optional[List[CallHierarchyOutgoingCall]]: - """Make a ``callHierarchy/outgoingCalls`` request. - - A request to resolve the outgoing calls for a given `CallHierarchyItem`. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("callHierarchy/outgoingCalls", params) - - def code_action_resolve( - self, - params: CodeAction, - callback: Optional[Callable[[CodeAction], None]] = None, - ) -> Future: - """Make a ``codeAction/resolve`` request. - - Request to resolve additional information for a given code action.The - request's parameter is of type {@link CodeAction} the response is of type. - - {@link CodeAction} or a Thenable that resolves to such. - """ - return self.lsp.send_request("codeAction/resolve", params, callback) - - async def code_action_resolve_async( - self, - params: CodeAction, - ) -> CodeAction: - """Make a ``codeAction/resolve`` request. - - Request to resolve additional information for a given code action.The - request's parameter is of type {@link CodeAction} the response is of type. - - {@link CodeAction} or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("codeAction/resolve", params) - - def code_lens_resolve( - self, - params: CodeLens, - callback: Optional[Callable[[CodeLens], None]] = None, - ) -> Future: - """Make a ``codeLens/resolve`` request. - - A request to resolve a command for a given code lens. - """ - return self.lsp.send_request("codeLens/resolve", params, callback) - - async def code_lens_resolve_async( - self, - params: CodeLens, - ) -> CodeLens: - """Make a ``codeLens/resolve`` request. - - A request to resolve a command for a given code lens. - """ - return await self.lsp.send_request_async("codeLens/resolve", params) - - def completion_item_resolve( - self, - params: CompletionItem, - callback: Optional[Callable[[CompletionItem], None]] = None, - ) -> Future: - """Make a ``completionItem/resolve`` request. - - Request to resolve additional information for a given completion - item.The request's parameter is of type {@link CompletionItem} the response - is of type {@link CompletionItem} or a Thenable that resolves to such. - """ - return self.lsp.send_request("completionItem/resolve", params, callback) - - async def completion_item_resolve_async( - self, - params: CompletionItem, - ) -> CompletionItem: - """Make a ``completionItem/resolve`` request. - - Request to resolve additional information for a given completion - item.The request's parameter is of type {@link CompletionItem} the response - is of type {@link CompletionItem} or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("completionItem/resolve", params) - - def document_link_resolve( - self, - params: DocumentLink, - callback: Optional[Callable[[DocumentLink], None]] = None, - ) -> Future: - """Make a ``documentLink/resolve`` request. - - Request to resolve additional information for a given document link. - - The request's parameter is of type {@link DocumentLink} the response - is of type {@link DocumentLink} or a Thenable that resolves to such. - """ - return self.lsp.send_request("documentLink/resolve", params, callback) - - async def document_link_resolve_async( - self, - params: DocumentLink, - ) -> DocumentLink: - """Make a ``documentLink/resolve`` request. - - Request to resolve additional information for a given document link. - - The request's parameter is of type {@link DocumentLink} the response - is of type {@link DocumentLink} or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("documentLink/resolve", params) - - def initialize( - self, - params: InitializeParams, - callback: Optional[Callable[[InitializeResult], None]] = None, - ) -> Future: - """Make a ``initialize`` request. - - The initialize request is sent from the client to the server. - - It is sent once as the request after starting up the server. The - requests parameter is of type {@link InitializeParams} the response - if of type {@link InitializeResult} of a Thenable that resolves to - such. - """ - return self.lsp.send_request("initialize", params, callback) - - async def initialize_async( - self, - params: InitializeParams, - ) -> InitializeResult: - """Make a ``initialize`` request. - - The initialize request is sent from the client to the server. - - It is sent once as the request after starting up the server. The - requests parameter is of type {@link InitializeParams} the response - if of type {@link InitializeResult} of a Thenable that resolves to - such. - """ - return await self.lsp.send_request_async("initialize", params) - - def inlay_hint_resolve( - self, - params: InlayHint, - callback: Optional[Callable[[InlayHint], None]] = None, - ) -> Future: - """Make a ``inlayHint/resolve`` request. - - A request to resolve additional properties for an inlay hint. The - request's parameter is of type {@link InlayHint}, the response is of type. - - {@link InlayHint} or a Thenable that resolves to such. - - @since 3.17.0 - """ - return self.lsp.send_request("inlayHint/resolve", params, callback) - - async def inlay_hint_resolve_async( - self, - params: InlayHint, - ) -> InlayHint: - """Make a ``inlayHint/resolve`` request. - - A request to resolve additional properties for an inlay hint. The - request's parameter is of type {@link InlayHint}, the response is of type. - - {@link InlayHint} or a Thenable that resolves to such. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("inlayHint/resolve", params) - - def shutdown_request( - self, - params: None, - callback: Optional[Callable[[None], None]] = None, - ) -> Future: - """Make a ``shutdown`` request. - - A shutdown request is sent from the client to the server. - - It is sent once when the client decides to shutdown the server. The - only notification that is sent after a shutdown request is the exit - event. - """ - return self.lsp.send_request("shutdown", params, callback) - - async def shutdown_request_async( - self, - params: None, - ) -> None: - """Make a ``shutdown`` request. - - A shutdown request is sent from the client to the server. - - It is sent once when the client decides to shutdown the server. The - only notification that is sent after a shutdown request is the exit - event. - """ - return await self.lsp.send_request_async("shutdown", params) - - def text_document_code_action( - self, - params: CodeActionParams, - callback: Optional[Callable[[Optional[List[Union[Command, CodeAction]]]], None]] = None, - ) -> Future: - """Make a ``textDocument/codeAction`` request. - - A request to provide commands for the given text document and range. - """ - return self.lsp.send_request("textDocument/codeAction", params, callback) - - async def text_document_code_action_async( - self, - params: CodeActionParams, - ) -> Optional[List[Union[Command, CodeAction]]]: - """Make a ``textDocument/codeAction`` request. - - A request to provide commands for the given text document and range. - """ - return await self.lsp.send_request_async("textDocument/codeAction", params) - - def text_document_code_lens( - self, - params: CodeLensParams, - callback: Optional[Callable[[Optional[List[CodeLens]]], None]] = None, - ) -> Future: - """Make a ``textDocument/codeLens`` request. - - A request to provide code lens for the given text document. - """ - return self.lsp.send_request("textDocument/codeLens", params, callback) - - async def text_document_code_lens_async( - self, - params: CodeLensParams, - ) -> Optional[List[CodeLens]]: - """Make a ``textDocument/codeLens`` request. - - A request to provide code lens for the given text document. - """ - return await self.lsp.send_request_async("textDocument/codeLens", params) - - def text_document_color_presentation( - self, - params: ColorPresentationParams, - callback: Optional[Callable[[List[ColorPresentation]], None]] = None, - ) -> Future: - """Make a ``textDocument/colorPresentation`` request. - - A request to list all presentation for a color. - - The request's parameter is of type {@link ColorPresentationParams} - the response is of type {@link ColorInformation ColorInformation[]} - or a Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/colorPresentation", params, callback) - - async def text_document_color_presentation_async( - self, - params: ColorPresentationParams, - ) -> List[ColorPresentation]: - """Make a ``textDocument/colorPresentation`` request. - - A request to list all presentation for a color. - - The request's parameter is of type {@link ColorPresentationParams} - the response is of type {@link ColorInformation ColorInformation[]} - or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/colorPresentation", params) - - def text_document_completion( - self, - params: CompletionParams, - callback: Optional[Callable[[Union[List[CompletionItem], CompletionList, None]], None]] = None, - ) -> Future: - """Make a ``textDocument/completion`` request. - - Request to request completion at a given text document position. The - request's parameter is of type {@link TextDocumentPosition} the response is - of type {@link CompletionItem CompletionItem[]} or {@link CompletionList} - or a Thenable that resolves to such. - - The request can delay the computation of the {@link - CompletionItem.detail `detail`} and {@link - CompletionItem.documentation `documentation`} properties to the - `completionItem/resolve` request. However, properties that are - needed for the initial sorting and filtering, like `sortText`, - `filterText`, `insertText`, and `textEdit`, must not be changed - during resolve. - """ - return self.lsp.send_request("textDocument/completion", params, callback) - - async def text_document_completion_async( - self, - params: CompletionParams, - ) -> Union[List[CompletionItem], CompletionList, None]: - """Make a ``textDocument/completion`` request. - - Request to request completion at a given text document position. The - request's parameter is of type {@link TextDocumentPosition} the response is - of type {@link CompletionItem CompletionItem[]} or {@link CompletionList} - or a Thenable that resolves to such. - - The request can delay the computation of the {@link - CompletionItem.detail `detail`} and {@link - CompletionItem.documentation `documentation`} properties to the - `completionItem/resolve` request. However, properties that are - needed for the initial sorting and filtering, like `sortText`, - `filterText`, `insertText`, and `textEdit`, must not be changed - during resolve. - """ - return await self.lsp.send_request_async("textDocument/completion", params) - - def text_document_declaration( - self, - params: DeclarationParams, - callback: Optional[Callable[[Union[Location, List[Location], List[LocationLink], None]], None]] = None, - ) -> Future: - """Make a ``textDocument/declaration`` request. - - A request to resolve the type definition locations of a symbol at a - given text document position. - - The request's parameter is of type [TextDocumentPositionParams] - (#TextDocumentPositionParams) the response is of type {@link - Declaration} or a typed array of {@link DeclarationLink} or a - Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/declaration", params, callback) - - async def text_document_declaration_async( - self, - params: DeclarationParams, - ) -> Union[Location, List[Location], List[LocationLink], None]: - """Make a ``textDocument/declaration`` request. - - A request to resolve the type definition locations of a symbol at a - given text document position. - - The request's parameter is of type [TextDocumentPositionParams] - (#TextDocumentPositionParams) the response is of type {@link - Declaration} or a typed array of {@link DeclarationLink} or a - Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/declaration", params) - - def text_document_definition( - self, - params: DefinitionParams, - callback: Optional[Callable[[Union[Location, List[Location], List[LocationLink], None]], None]] = None, - ) -> Future: - """Make a ``textDocument/definition`` request. - - A request to resolve the definition location of a symbol at a given text - document position. - - The request's parameter is of type [TextDocumentPosition] - (#TextDocumentPosition) the response is of either type {@link - Definition} or a typed array of {@link DefinitionLink} or a Thenable - that resolves to such. - """ - return self.lsp.send_request("textDocument/definition", params, callback) - - async def text_document_definition_async( - self, - params: DefinitionParams, - ) -> Union[Location, List[Location], List[LocationLink], None]: - """Make a ``textDocument/definition`` request. - - A request to resolve the definition location of a symbol at a given text - document position. - - The request's parameter is of type [TextDocumentPosition] - (#TextDocumentPosition) the response is of either type {@link - Definition} or a typed array of {@link DefinitionLink} or a Thenable - that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/definition", params) - - def text_document_diagnostic( - self, - params: DocumentDiagnosticParams, - callback: Optional[Callable[[Union[RelatedFullDocumentDiagnosticReport, RelatedUnchangedDocumentDiagnosticReport]], None]] = None, - ) -> Future: - """Make a ``textDocument/diagnostic`` request. - - The document diagnostic request definition. - - @since 3.17.0 - """ - return self.lsp.send_request("textDocument/diagnostic", params, callback) - - async def text_document_diagnostic_async( - self, - params: DocumentDiagnosticParams, - ) -> Union[RelatedFullDocumentDiagnosticReport, RelatedUnchangedDocumentDiagnosticReport]: - """Make a ``textDocument/diagnostic`` request. - - The document diagnostic request definition. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("textDocument/diagnostic", params) - - def text_document_document_color( - self, - params: DocumentColorParams, - callback: Optional[Callable[[List[ColorInformation]], None]] = None, - ) -> Future: - """Make a ``textDocument/documentColor`` request. - - A request to list all color symbols found in a given text document. - - The request's parameter is of type {@link DocumentColorParams} the - response is of type {@link ColorInformation ColorInformation[]} or a - Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/documentColor", params, callback) - - async def text_document_document_color_async( - self, - params: DocumentColorParams, - ) -> List[ColorInformation]: - """Make a ``textDocument/documentColor`` request. - - A request to list all color symbols found in a given text document. - - The request's parameter is of type {@link DocumentColorParams} the - response is of type {@link ColorInformation ColorInformation[]} or a - Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/documentColor", params) - - def text_document_document_highlight( - self, - params: DocumentHighlightParams, - callback: Optional[Callable[[Optional[List[DocumentHighlight]]], None]] = None, - ) -> Future: - """Make a ``textDocument/documentHighlight`` request. - - Request to resolve a {@link DocumentHighlight} for a given text document - position. - - The request's parameter is of type [TextDocumentPosition] - (#TextDocumentPosition) the request response is of type - [DocumentHighlight[]] (#DocumentHighlight) or a Thenable that - resolves to such. - """ - return self.lsp.send_request("textDocument/documentHighlight", params, callback) - - async def text_document_document_highlight_async( - self, - params: DocumentHighlightParams, - ) -> Optional[List[DocumentHighlight]]: - """Make a ``textDocument/documentHighlight`` request. - - Request to resolve a {@link DocumentHighlight} for a given text document - position. - - The request's parameter is of type [TextDocumentPosition] - (#TextDocumentPosition) the request response is of type - [DocumentHighlight[]] (#DocumentHighlight) or a Thenable that - resolves to such. - """ - return await self.lsp.send_request_async("textDocument/documentHighlight", params) - - def text_document_document_link( - self, - params: DocumentLinkParams, - callback: Optional[Callable[[Optional[List[DocumentLink]]], None]] = None, - ) -> Future: - """Make a ``textDocument/documentLink`` request. - - A request to provide document links. - """ - return self.lsp.send_request("textDocument/documentLink", params, callback) - - async def text_document_document_link_async( - self, - params: DocumentLinkParams, - ) -> Optional[List[DocumentLink]]: - """Make a ``textDocument/documentLink`` request. - - A request to provide document links. - """ - return await self.lsp.send_request_async("textDocument/documentLink", params) - - def text_document_document_symbol( - self, - params: DocumentSymbolParams, - callback: Optional[Callable[[Union[List[SymbolInformation], List[DocumentSymbol], None]], None]] = None, - ) -> Future: - """Make a ``textDocument/documentSymbol`` request. - - A request to list all symbols found in a given text document. - - The request's parameter is of type {@link TextDocumentIdentifier} - the response is of type {@link SymbolInformation - SymbolInformation[]} or a Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/documentSymbol", params, callback) - - async def text_document_document_symbol_async( - self, - params: DocumentSymbolParams, - ) -> Union[List[SymbolInformation], List[DocumentSymbol], None]: - """Make a ``textDocument/documentSymbol`` request. - - A request to list all symbols found in a given text document. - - The request's parameter is of type {@link TextDocumentIdentifier} - the response is of type {@link SymbolInformation - SymbolInformation[]} or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/documentSymbol", params) - - def text_document_folding_range( - self, - params: FoldingRangeParams, - callback: Optional[Callable[[Optional[List[FoldingRange]]], None]] = None, - ) -> Future: - """Make a ``textDocument/foldingRange`` request. - - A request to provide folding ranges in a document. - - The request's parameter is of type {@link FoldingRangeParams}, the - response is of type {@link FoldingRangeList} or a Thenable that - resolves to such. - """ - return self.lsp.send_request("textDocument/foldingRange", params, callback) - - async def text_document_folding_range_async( - self, - params: FoldingRangeParams, - ) -> Optional[List[FoldingRange]]: - """Make a ``textDocument/foldingRange`` request. - - A request to provide folding ranges in a document. - - The request's parameter is of type {@link FoldingRangeParams}, the - response is of type {@link FoldingRangeList} or a Thenable that - resolves to such. - """ - return await self.lsp.send_request_async("textDocument/foldingRange", params) - - def text_document_formatting( - self, - params: DocumentFormattingParams, - callback: Optional[Callable[[Optional[List[TextEdit]]], None]] = None, - ) -> Future: - """Make a ``textDocument/formatting`` request. - - A request to to format a whole document. - """ - return self.lsp.send_request("textDocument/formatting", params, callback) - - async def text_document_formatting_async( - self, - params: DocumentFormattingParams, - ) -> Optional[List[TextEdit]]: - """Make a ``textDocument/formatting`` request. - - A request to to format a whole document. - """ - return await self.lsp.send_request_async("textDocument/formatting", params) - - def text_document_hover( - self, - params: HoverParams, - callback: Optional[Callable[[Optional[Hover]], None]] = None, - ) -> Future: - """Make a ``textDocument/hover`` request. - - Request to request hover information at a given text document position. - - The request's parameter is of type {@link TextDocumentPosition} the - response is of type {@link Hover} or a Thenable that resolves to - such. - """ - return self.lsp.send_request("textDocument/hover", params, callback) - - async def text_document_hover_async( - self, - params: HoverParams, - ) -> Optional[Hover]: - """Make a ``textDocument/hover`` request. - - Request to request hover information at a given text document position. - - The request's parameter is of type {@link TextDocumentPosition} the - response is of type {@link Hover} or a Thenable that resolves to - such. - """ - return await self.lsp.send_request_async("textDocument/hover", params) - - def text_document_implementation( - self, - params: ImplementationParams, - callback: Optional[Callable[[Union[Location, List[Location], List[LocationLink], None]], None]] = None, - ) -> Future: - """Make a ``textDocument/implementation`` request. - - A request to resolve the implementation locations of a symbol at a given - text document position. - - The request's parameter is of type [TextDocumentPositionParams] - (#TextDocumentPositionParams) the response is of type {@link - Definition} or a Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/implementation", params, callback) - - async def text_document_implementation_async( - self, - params: ImplementationParams, - ) -> Union[Location, List[Location], List[LocationLink], None]: - """Make a ``textDocument/implementation`` request. - - A request to resolve the implementation locations of a symbol at a given - text document position. - - The request's parameter is of type [TextDocumentPositionParams] - (#TextDocumentPositionParams) the response is of type {@link - Definition} or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/implementation", params) - - def text_document_inlay_hint( - self, - params: InlayHintParams, - callback: Optional[Callable[[Optional[List[InlayHint]]], None]] = None, - ) -> Future: - """Make a ``textDocument/inlayHint`` request. - - A request to provide inlay hints in a document. The request's parameter - is of type {@link InlayHintsParams}, the response is of type. - - {@link InlayHint InlayHint[]} or a Thenable that resolves to such. - - @since 3.17.0 - """ - return self.lsp.send_request("textDocument/inlayHint", params, callback) - - async def text_document_inlay_hint_async( - self, - params: InlayHintParams, - ) -> Optional[List[InlayHint]]: - """Make a ``textDocument/inlayHint`` request. - - A request to provide inlay hints in a document. The request's parameter - is of type {@link InlayHintsParams}, the response is of type. - - {@link InlayHint InlayHint[]} or a Thenable that resolves to such. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("textDocument/inlayHint", params) - - def text_document_inline_value( - self, - params: InlineValueParams, - callback: Optional[Callable[[Optional[List[Union[InlineValueText, InlineValueVariableLookup, InlineValueEvaluatableExpression]]]], None]] = None, - ) -> Future: - """Make a ``textDocument/inlineValue`` request. - - A request to provide inline values in a document. The request's - parameter is of type {@link InlineValueParams}, the response is of type. - - {@link InlineValue InlineValue[]} or a Thenable that resolves to such. - - @since 3.17.0 - """ - return self.lsp.send_request("textDocument/inlineValue", params, callback) - - async def text_document_inline_value_async( - self, - params: InlineValueParams, - ) -> Optional[List[Union[InlineValueText, InlineValueVariableLookup, InlineValueEvaluatableExpression]]]: - """Make a ``textDocument/inlineValue`` request. - - A request to provide inline values in a document. The request's - parameter is of type {@link InlineValueParams}, the response is of type. - - {@link InlineValue InlineValue[]} or a Thenable that resolves to such. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("textDocument/inlineValue", params) - - def text_document_linked_editing_range( - self, - params: LinkedEditingRangeParams, - callback: Optional[Callable[[Optional[LinkedEditingRanges]], None]] = None, - ) -> Future: - """Make a ``textDocument/linkedEditingRange`` request. - - A request to provide ranges that can be edited together. - - @since 3.16.0 - """ - return self.lsp.send_request("textDocument/linkedEditingRange", params, callback) - - async def text_document_linked_editing_range_async( - self, - params: LinkedEditingRangeParams, - ) -> Optional[LinkedEditingRanges]: - """Make a ``textDocument/linkedEditingRange`` request. - - A request to provide ranges that can be edited together. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("textDocument/linkedEditingRange", params) - - def text_document_moniker( - self, - params: MonikerParams, - callback: Optional[Callable[[Optional[List[Moniker]]], None]] = None, - ) -> Future: - """Make a ``textDocument/moniker`` request. - - A request to get the moniker of a symbol at a given text document - position. - - The request parameter is of type {@link TextDocumentPositionParams}. - The response is of type {@link Moniker Moniker[]} or `null`. - """ - return self.lsp.send_request("textDocument/moniker", params, callback) - - async def text_document_moniker_async( - self, - params: MonikerParams, - ) -> Optional[List[Moniker]]: - """Make a ``textDocument/moniker`` request. - - A request to get the moniker of a symbol at a given text document - position. - - The request parameter is of type {@link TextDocumentPositionParams}. - The response is of type {@link Moniker Moniker[]} or `null`. - """ - return await self.lsp.send_request_async("textDocument/moniker", params) - - def text_document_on_type_formatting( - self, - params: DocumentOnTypeFormattingParams, - callback: Optional[Callable[[Optional[List[TextEdit]]], None]] = None, - ) -> Future: - """Make a ``textDocument/onTypeFormatting`` request. - - A request to format a document on type. - """ - return self.lsp.send_request("textDocument/onTypeFormatting", params, callback) - - async def text_document_on_type_formatting_async( - self, - params: DocumentOnTypeFormattingParams, - ) -> Optional[List[TextEdit]]: - """Make a ``textDocument/onTypeFormatting`` request. - - A request to format a document on type. - """ - return await self.lsp.send_request_async("textDocument/onTypeFormatting", params) - - def text_document_prepare_call_hierarchy( - self, - params: CallHierarchyPrepareParams, - callback: Optional[Callable[[Optional[List[CallHierarchyItem]]], None]] = None, - ) -> Future: - """Make a ``textDocument/prepareCallHierarchy`` request. - - A request to result a `CallHierarchyItem` in a document at a given - position. Can be used as an input to an incoming or outgoing call - hierarchy. - - @since 3.16.0 - """ - return self.lsp.send_request("textDocument/prepareCallHierarchy", params, callback) - - async def text_document_prepare_call_hierarchy_async( - self, - params: CallHierarchyPrepareParams, - ) -> Optional[List[CallHierarchyItem]]: - """Make a ``textDocument/prepareCallHierarchy`` request. - - A request to result a `CallHierarchyItem` in a document at a given - position. Can be used as an input to an incoming or outgoing call - hierarchy. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("textDocument/prepareCallHierarchy", params) - - def text_document_prepare_rename( - self, - params: PrepareRenameParams, - callback: Optional[Callable[[Union[Range, PrepareRenameResult_Type1, PrepareRenameResult_Type2, None]], None]] = None, - ) -> Future: - """Make a ``textDocument/prepareRename`` request. - - A request to test and perform the setup necessary for a rename. - - @since 3.16 - support for default behavior - """ - return self.lsp.send_request("textDocument/prepareRename", params, callback) - - async def text_document_prepare_rename_async( - self, - params: PrepareRenameParams, - ) -> Union[Range, PrepareRenameResult_Type1, PrepareRenameResult_Type2, None]: - """Make a ``textDocument/prepareRename`` request. - - A request to test and perform the setup necessary for a rename. - - @since 3.16 - support for default behavior - """ - return await self.lsp.send_request_async("textDocument/prepareRename", params) - - def text_document_prepare_type_hierarchy( - self, - params: TypeHierarchyPrepareParams, - callback: Optional[Callable[[Optional[List[TypeHierarchyItem]]], None]] = None, - ) -> Future: - """Make a ``textDocument/prepareTypeHierarchy`` request. - - A request to result a `TypeHierarchyItem` in a document at a given - position. Can be used as an input to a subtypes or supertypes type - hierarchy. - - @since 3.17.0 - """ - return self.lsp.send_request("textDocument/prepareTypeHierarchy", params, callback) - - async def text_document_prepare_type_hierarchy_async( - self, - params: TypeHierarchyPrepareParams, - ) -> Optional[List[TypeHierarchyItem]]: - """Make a ``textDocument/prepareTypeHierarchy`` request. - - A request to result a `TypeHierarchyItem` in a document at a given - position. Can be used as an input to a subtypes or supertypes type - hierarchy. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("textDocument/prepareTypeHierarchy", params) - - def text_document_range_formatting( - self, - params: DocumentRangeFormattingParams, - callback: Optional[Callable[[Optional[List[TextEdit]]], None]] = None, - ) -> Future: - """Make a ``textDocument/rangeFormatting`` request. - - A request to to format a range in a document. - """ - return self.lsp.send_request("textDocument/rangeFormatting", params, callback) - - async def text_document_range_formatting_async( - self, - params: DocumentRangeFormattingParams, - ) -> Optional[List[TextEdit]]: - """Make a ``textDocument/rangeFormatting`` request. - - A request to to format a range in a document. - """ - return await self.lsp.send_request_async("textDocument/rangeFormatting", params) - - def text_document_references( - self, - params: ReferenceParams, - callback: Optional[Callable[[Optional[List[Location]]], None]] = None, - ) -> Future: - """Make a ``textDocument/references`` request. - - A request to resolve project-wide references for the symbol denoted by - the given text document position. The request's parameter is of type {@link - ReferenceParams} the response is of type. - - {@link Location Location[]} or a Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/references", params, callback) - - async def text_document_references_async( - self, - params: ReferenceParams, - ) -> Optional[List[Location]]: - """Make a ``textDocument/references`` request. - - A request to resolve project-wide references for the symbol denoted by - the given text document position. The request's parameter is of type {@link - ReferenceParams} the response is of type. - - {@link Location Location[]} or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/references", params) - - def text_document_rename( - self, - params: RenameParams, - callback: Optional[Callable[[Optional[WorkspaceEdit]], None]] = None, - ) -> Future: - """Make a ``textDocument/rename`` request. - - A request to rename a symbol. - """ - return self.lsp.send_request("textDocument/rename", params, callback) - - async def text_document_rename_async( - self, - params: RenameParams, - ) -> Optional[WorkspaceEdit]: - """Make a ``textDocument/rename`` request. - - A request to rename a symbol. - """ - return await self.lsp.send_request_async("textDocument/rename", params) - - def text_document_selection_range( - self, - params: SelectionRangeParams, - callback: Optional[Callable[[Optional[List[SelectionRange]]], None]] = None, - ) -> Future: - """Make a ``textDocument/selectionRange`` request. - - A request to provide selection ranges in a document. - - The request's parameter is of type {@link SelectionRangeParams}, the - response is of type {@link SelectionRange SelectionRange[]} or a - Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/selectionRange", params, callback) - - async def text_document_selection_range_async( - self, - params: SelectionRangeParams, - ) -> Optional[List[SelectionRange]]: - """Make a ``textDocument/selectionRange`` request. - - A request to provide selection ranges in a document. - - The request's parameter is of type {@link SelectionRangeParams}, the - response is of type {@link SelectionRange SelectionRange[]} or a - Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/selectionRange", params) - - def text_document_semantic_tokens_full( - self, - params: SemanticTokensParams, - callback: Optional[Callable[[Optional[SemanticTokens]], None]] = None, - ) -> Future: - """Make a ``textDocument/semanticTokens/full`` request. - - @since 3.16.0 - """ - return self.lsp.send_request("textDocument/semanticTokens/full", params, callback) - - async def text_document_semantic_tokens_full_async( - self, - params: SemanticTokensParams, - ) -> Optional[SemanticTokens]: - """Make a ``textDocument/semanticTokens/full`` request. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("textDocument/semanticTokens/full", params) - - def text_document_semantic_tokens_full_delta( - self, - params: SemanticTokensDeltaParams, - callback: Optional[Callable[[Union[SemanticTokens, SemanticTokensDelta, None]], None]] = None, - ) -> Future: - """Make a ``textDocument/semanticTokens/full/delta`` request. - - @since 3.16.0 - """ - return self.lsp.send_request("textDocument/semanticTokens/full/delta", params, callback) - - async def text_document_semantic_tokens_full_delta_async( - self, - params: SemanticTokensDeltaParams, - ) -> Union[SemanticTokens, SemanticTokensDelta, None]: - """Make a ``textDocument/semanticTokens/full/delta`` request. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("textDocument/semanticTokens/full/delta", params) - - def text_document_semantic_tokens_range( - self, - params: SemanticTokensRangeParams, - callback: Optional[Callable[[Optional[SemanticTokens]], None]] = None, - ) -> Future: - """Make a ``textDocument/semanticTokens/range`` request. - - @since 3.16.0 - """ - return self.lsp.send_request("textDocument/semanticTokens/range", params, callback) - - async def text_document_semantic_tokens_range_async( - self, - params: SemanticTokensRangeParams, - ) -> Optional[SemanticTokens]: - """Make a ``textDocument/semanticTokens/range`` request. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("textDocument/semanticTokens/range", params) - - def text_document_signature_help( - self, - params: SignatureHelpParams, - callback: Optional[Callable[[Optional[SignatureHelp]], None]] = None, - ) -> Future: - """Make a ``textDocument/signatureHelp`` request. - - - """ - return self.lsp.send_request("textDocument/signatureHelp", params, callback) - - async def text_document_signature_help_async( - self, - params: SignatureHelpParams, - ) -> Optional[SignatureHelp]: - """Make a ``textDocument/signatureHelp`` request. - - - """ - return await self.lsp.send_request_async("textDocument/signatureHelp", params) - - def text_document_type_definition( - self, - params: TypeDefinitionParams, - callback: Optional[Callable[[Union[Location, List[Location], List[LocationLink], None]], None]] = None, - ) -> Future: - """Make a ``textDocument/typeDefinition`` request. - - A request to resolve the type definition locations of a symbol at a - given text document position. - - The request's parameter is of type [TextDocumentPositionParams] - (#TextDocumentPositionParams) the response is of type {@link - Definition} or a Thenable that resolves to such. - """ - return self.lsp.send_request("textDocument/typeDefinition", params, callback) - - async def text_document_type_definition_async( - self, - params: TypeDefinitionParams, - ) -> Union[Location, List[Location], List[LocationLink], None]: - """Make a ``textDocument/typeDefinition`` request. - - A request to resolve the type definition locations of a symbol at a - given text document position. - - The request's parameter is of type [TextDocumentPositionParams] - (#TextDocumentPositionParams) the response is of type {@link - Definition} or a Thenable that resolves to such. - """ - return await self.lsp.send_request_async("textDocument/typeDefinition", params) - - def text_document_will_save_wait_until( - self, - params: WillSaveTextDocumentParams, - callback: Optional[Callable[[Optional[List[TextEdit]]], None]] = None, - ) -> Future: - """Make a ``textDocument/willSaveWaitUntil`` request. - - A document will save request is sent from the client to the server - before the document is actually saved. - - The request can return an array of TextEdits which will be applied - to the text document before it is saved. Please note that clients - might drop results if computing the text edits took too long or if a - server constantly fails on this request. This is done to keep the - save fast and reliable. - """ - return self.lsp.send_request("textDocument/willSaveWaitUntil", params, callback) - - async def text_document_will_save_wait_until_async( - self, - params: WillSaveTextDocumentParams, - ) -> Optional[List[TextEdit]]: - """Make a ``textDocument/willSaveWaitUntil`` request. - - A document will save request is sent from the client to the server - before the document is actually saved. - - The request can return an array of TextEdits which will be applied - to the text document before it is saved. Please note that clients - might drop results if computing the text edits took too long or if a - server constantly fails on this request. This is done to keep the - save fast and reliable. - """ - return await self.lsp.send_request_async("textDocument/willSaveWaitUntil", params) - - def type_hierarchy_subtypes( - self, - params: TypeHierarchySubtypesParams, - callback: Optional[Callable[[Optional[List[TypeHierarchyItem]]], None]] = None, - ) -> Future: - """Make a ``typeHierarchy/subtypes`` request. - - A request to resolve the subtypes for a given `TypeHierarchyItem`. - - @since 3.17.0 - """ - return self.lsp.send_request("typeHierarchy/subtypes", params, callback) - - async def type_hierarchy_subtypes_async( - self, - params: TypeHierarchySubtypesParams, - ) -> Optional[List[TypeHierarchyItem]]: - """Make a ``typeHierarchy/subtypes`` request. - - A request to resolve the subtypes for a given `TypeHierarchyItem`. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("typeHierarchy/subtypes", params) - - def type_hierarchy_supertypes( - self, - params: TypeHierarchySupertypesParams, - callback: Optional[Callable[[Optional[List[TypeHierarchyItem]]], None]] = None, - ) -> Future: - """Make a ``typeHierarchy/supertypes`` request. - - A request to resolve the supertypes for a given `TypeHierarchyItem`. - - @since 3.17.0 - """ - return self.lsp.send_request("typeHierarchy/supertypes", params, callback) - - async def type_hierarchy_supertypes_async( - self, - params: TypeHierarchySupertypesParams, - ) -> Optional[List[TypeHierarchyItem]]: - """Make a ``typeHierarchy/supertypes`` request. - - A request to resolve the supertypes for a given `TypeHierarchyItem`. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("typeHierarchy/supertypes", params) - - def workspace_diagnostic( - self, - params: WorkspaceDiagnosticParams, - callback: Optional[Callable[[WorkspaceDiagnosticReport], None]] = None, - ) -> Future: - """Make a ``workspace/diagnostic`` request. - - The workspace diagnostic request definition. - - @since 3.17.0 - """ - return self.lsp.send_request("workspace/diagnostic", params, callback) - - async def workspace_diagnostic_async( - self, - params: WorkspaceDiagnosticParams, - ) -> WorkspaceDiagnosticReport: - """Make a ``workspace/diagnostic`` request. - - The workspace diagnostic request definition. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("workspace/diagnostic", params) - - def workspace_execute_command( - self, - params: ExecuteCommandParams, - callback: Optional[Callable[[Optional[Any]], None]] = None, - ) -> Future: - """Make a ``workspace/executeCommand`` request. - - A request send from the client to the server to execute a command. - - The request might return a workspace edit which the client will - apply to the workspace. - """ - return self.lsp.send_request("workspace/executeCommand", params, callback) - - async def workspace_execute_command_async( - self, - params: ExecuteCommandParams, - ) -> Optional[Any]: - """Make a ``workspace/executeCommand`` request. - - A request send from the client to the server to execute a command. - - The request might return a workspace edit which the client will - apply to the workspace. - """ - return await self.lsp.send_request_async("workspace/executeCommand", params) - - def workspace_symbol( - self, - params: WorkspaceSymbolParams, - callback: Optional[Callable[[Union[List[SymbolInformation], List[WorkspaceSymbol], None]], None]] = None, - ) -> Future: - """Make a ``workspace/symbol`` request. - - A request to list project-wide symbols matching the query string given - by the {@link WorkspaceSymbolParams}. The response is of type {@link - SymbolInformation SymbolInformation[]} or a Thenable that resolves to such. - - @since 3.17.0 - support for WorkspaceSymbol in the returned data. Clients - need to advertise support for WorkspaceSymbols via the client capability - `workspace.symbol.resolveSupport`. - """ - return self.lsp.send_request("workspace/symbol", params, callback) - - async def workspace_symbol_async( - self, - params: WorkspaceSymbolParams, - ) -> Union[List[SymbolInformation], List[WorkspaceSymbol], None]: - """Make a ``workspace/symbol`` request. - - A request to list project-wide symbols matching the query string given - by the {@link WorkspaceSymbolParams}. The response is of type {@link - SymbolInformation SymbolInformation[]} or a Thenable that resolves to such. - - @since 3.17.0 - support for WorkspaceSymbol in the returned data. Clients - need to advertise support for WorkspaceSymbols via the client capability - `workspace.symbol.resolveSupport`. - """ - return await self.lsp.send_request_async("workspace/symbol", params) - - def workspace_symbol_resolve( - self, - params: WorkspaceSymbol, - callback: Optional[Callable[[WorkspaceSymbol], None]] = None, - ) -> Future: - """Make a ``workspaceSymbol/resolve`` request. - - A request to resolve the range inside the workspace symbol's location. - - @since 3.17.0 - """ - return self.lsp.send_request("workspaceSymbol/resolve", params, callback) - - async def workspace_symbol_resolve_async( - self, - params: WorkspaceSymbol, - ) -> WorkspaceSymbol: - """Make a ``workspaceSymbol/resolve`` request. - - A request to resolve the range inside the workspace symbol's location. - - @since 3.17.0 - """ - return await self.lsp.send_request_async("workspaceSymbol/resolve", params) - - def workspace_will_create_files( - self, - params: CreateFilesParams, - callback: Optional[Callable[[Optional[WorkspaceEdit]], None]] = None, - ) -> Future: - """Make a ``workspace/willCreateFiles`` request. - - The will create files request is sent from the client to the server - before files are actually created as long as the creation is triggered from - within the client. - - @since 3.16.0 - """ - return self.lsp.send_request("workspace/willCreateFiles", params, callback) - - async def workspace_will_create_files_async( - self, - params: CreateFilesParams, - ) -> Optional[WorkspaceEdit]: - """Make a ``workspace/willCreateFiles`` request. - - The will create files request is sent from the client to the server - before files are actually created as long as the creation is triggered from - within the client. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("workspace/willCreateFiles", params) - - def workspace_will_delete_files( - self, - params: DeleteFilesParams, - callback: Optional[Callable[[Optional[WorkspaceEdit]], None]] = None, - ) -> Future: - """Make a ``workspace/willDeleteFiles`` request. - - The did delete files notification is sent from the client to the server - when files were deleted from within the client. - - @since 3.16.0 - """ - return self.lsp.send_request("workspace/willDeleteFiles", params, callback) - - async def workspace_will_delete_files_async( - self, - params: DeleteFilesParams, - ) -> Optional[WorkspaceEdit]: - """Make a ``workspace/willDeleteFiles`` request. - - The did delete files notification is sent from the client to the server - when files were deleted from within the client. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("workspace/willDeleteFiles", params) - - def workspace_will_rename_files( - self, - params: RenameFilesParams, - callback: Optional[Callable[[Optional[WorkspaceEdit]], None]] = None, - ) -> Future: - """Make a ``workspace/willRenameFiles`` request. - - The will rename files request is sent from the client to the server - before files are actually renamed as long as the rename is triggered from - within the client. - - @since 3.16.0 - """ - return self.lsp.send_request("workspace/willRenameFiles", params, callback) - - async def workspace_will_rename_files_async( - self, - params: RenameFilesParams, - ) -> Optional[WorkspaceEdit]: - """Make a ``workspace/willRenameFiles`` request. - - The will rename files request is sent from the client to the server - before files are actually renamed as long as the rename is triggered from - within the client. - - @since 3.16.0 - """ - return await self.lsp.send_request_async("workspace/willRenameFiles", params) - - def cancel_request(self, params: CancelParams) -> None: - """Send a ``$/cancelRequest`` notification. - - - """ - self.lsp.notify("$/cancelRequest", params) - - def exit(self, params: None) -> None: - """Send a ``exit`` notification. - - The exit event is sent from the client to the server to ask the server - to exit its process. - """ - self.lsp.notify("exit", params) - - def initialized(self, params: InitializedParams) -> None: - """Send a ``initialized`` notification. - - The initialized notification is sent from the client to the server after - the client is fully initialized and the server is allowed to send requests - from the server to the client. - """ - self.lsp.notify("initialized", params) - - def notebook_document_did_change(self, params: DidChangeNotebookDocumentParams) -> None: - """Send a ``notebookDocument/didChange`` notification. - - - """ - self.lsp.notify("notebookDocument/didChange", params) - - def notebook_document_did_close(self, params: DidCloseNotebookDocumentParams) -> None: - """Send a ``notebookDocument/didClose`` notification. - - A notification sent when a notebook closes. - - @since 3.17.0 - """ - self.lsp.notify("notebookDocument/didClose", params) - - def notebook_document_did_open(self, params: DidOpenNotebookDocumentParams) -> None: - """Send a ``notebookDocument/didOpen`` notification. - - A notification sent when a notebook opens. - - @since 3.17.0 - """ - self.lsp.notify("notebookDocument/didOpen", params) - - def notebook_document_did_save(self, params: DidSaveNotebookDocumentParams) -> None: - """Send a ``notebookDocument/didSave`` notification. - - A notification sent when a notebook document is saved. - - @since 3.17.0 - """ - self.lsp.notify("notebookDocument/didSave", params) - - def progress(self, params: ProgressParams) -> None: - """Send a ``$/progress`` notification. - - - """ - self.lsp.notify("$/progress", params) - - def set_trace(self, params: SetTraceParams) -> None: - """Send a ``$/setTrace`` notification. - - - """ - self.lsp.notify("$/setTrace", params) - - def text_document_did_change(self, params: DidChangeTextDocumentParams) -> None: - """Send a ``textDocument/didChange`` notification. - - The document change notification is sent from the client to the server - to signal changes to a text document. - """ - self.lsp.notify("textDocument/didChange", params) - - def text_document_did_close(self, params: DidCloseTextDocumentParams) -> None: - """Send a ``textDocument/didClose`` notification. - - The document close notification is sent from the client to the server - when the document got closed in the client. - - The document's truth now exists where the document's uri points to - (e.g. if the document's uri is a file uri the truth now exists on - disk). As with the open notification the close notification is about - managing the document's content. Receiving a close notification - doesn't mean that the document was open in an editor before. A close - notification requires a previous open notification to be sent. - """ - self.lsp.notify("textDocument/didClose", params) - - def text_document_did_open(self, params: DidOpenTextDocumentParams) -> None: - """Send a ``textDocument/didOpen`` notification. - - The document open notification is sent from the client to the server to - signal newly opened text documents. - - The document's truth is now managed by the client and the server - must not try to read the document's truth using the document's uri. - Open in this sense means it is managed by the client. It doesn't - necessarily mean that its content is presented in an editor. An open - notification must not be sent more than once without a corresponding - close notification send before. This means open and close - notification must be balanced and the max open count is one. - """ - self.lsp.notify("textDocument/didOpen", params) - - def text_document_did_save(self, params: DidSaveTextDocumentParams) -> None: - """Send a ``textDocument/didSave`` notification. - - The document save notification is sent from the client to the server - when the document got saved in the client. - """ - self.lsp.notify("textDocument/didSave", params) - - def text_document_will_save(self, params: WillSaveTextDocumentParams) -> None: - """Send a ``textDocument/willSave`` notification. - - A document will save notification is sent from the client to the server - before the document is actually saved. - """ - self.lsp.notify("textDocument/willSave", params) - - def window_work_done_progress_cancel(self, params: WorkDoneProgressCancelParams) -> None: - """Send a ``window/workDoneProgress/cancel`` notification. - - The `window/workDoneProgress/cancel` notification is sent from the - client to the server to cancel a progress initiated on the server side. - """ - self.lsp.notify("window/workDoneProgress/cancel", params) - - def workspace_did_change_configuration(self, params: DidChangeConfigurationParams) -> None: - """Send a ``workspace/didChangeConfiguration`` notification. - - The configuration change notification is sent from the client to the - server when the client's configuration has changed. - - The notification contains the changed configuration as defined by - the language client. - """ - self.lsp.notify("workspace/didChangeConfiguration", params) - - def workspace_did_change_watched_files(self, params: DidChangeWatchedFilesParams) -> None: - """Send a ``workspace/didChangeWatchedFiles`` notification. - - The watched files notification is sent from the client to the server - when the client detects changes to file watched by the language client. - """ - self.lsp.notify("workspace/didChangeWatchedFiles", params) - - def workspace_did_change_workspace_folders(self, params: DidChangeWorkspaceFoldersParams) -> None: - """Send a ``workspace/didChangeWorkspaceFolders`` notification. - - The `workspace/didChangeWorkspaceFolders` notification is sent from the - client to the server when the workspace folder configuration changes. - """ - self.lsp.notify("workspace/didChangeWorkspaceFolders", params) - - def workspace_did_create_files(self, params: CreateFilesParams) -> None: - """Send a ``workspace/didCreateFiles`` notification. - - The did create files notification is sent from the client to the server - when files were created from within the client. - - @since 3.16.0 - """ - self.lsp.notify("workspace/didCreateFiles", params) - - def workspace_did_delete_files(self, params: DeleteFilesParams) -> None: - """Send a ``workspace/didDeleteFiles`` notification. - - The will delete files request is sent from the client to the server - before files are actually deleted as long as the deletion is triggered from - within the client. - - @since 3.16.0 - """ - self.lsp.notify("workspace/didDeleteFiles", params) - - def workspace_did_rename_files(self, params: RenameFilesParams) -> None: - """Send a ``workspace/didRenameFiles`` notification. - - The did rename files notification is sent from the client to the server - when files were renamed from within the client. - - @since 3.16.0 - """ - self.lsp.notify("workspace/didRenameFiles", params) diff --git a/scripts/gen_client.py b/scripts/gen_client.py deleted file mode 100644 index 60fb0b8..0000000 --- a/scripts/gen_client.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Script to automatically generate a lanaguge client from `lsprotocol` type definitons -""" -import argparse -import inspect -import pathlib -import re -import sys -import textwrap -from datetime import datetime -from typing import Optional -from typing import Set -from typing import Tuple -from typing import Type - -from lsprotocol._hooks import _resolve_forward_references -from lsprotocol.types import METHOD_TO_TYPES -from lsprotocol.types import message_direction - -cli = argparse.ArgumentParser( - description="generate language client from lsprotocol types." -) -cli.add_argument("-o", "--output", default=None) - - -def write_imports(imports: Set[Tuple[str, str]]) -> str: - lines = [] - - for import_ in sorted(list(imports), key=lambda i: (i[0], i[1])): - if isinstance(import_, tuple): - mod, name = import_ - lines.append(f"from {mod} import {name}") - continue - - lines.append(f"import {import_}") - - return "\n".join(lines) - - -def to_snake_case(string: str) -> str: - return "".join(f"_{c.lower()}" if c.isupper() else c for c in string) - - -def write_notification( - method: str, - request: Type, - params: Optional[Type], - imports: Set[Tuple[str, str]], -) -> str: - python_name = to_snake_case(method).replace("/", "_").replace("$_", "") - - if params is None: - param_name = "None" - else: - param_mod, param_name = params.__module__, params.__name__ - imports.add((param_mod, param_name)) - - return "\n".join( - [ - f"def {python_name}(self, params: {param_name}) -> None:", - f' """Send a ``{method}`` notification.', - "", - textwrap.indent(inspect.getdoc(request) or "", " "), - ' """', - f' self.lsp.notify("{method}", params)', - "", - ] - ) - - -def get_response_type(response: Type, imports: Set[Tuple[str, str]]) -> str: - # Find the response type. - result_field = [f for f in response.__attrs_attrs__ if f.name == "result"][0] - result = re.sub(r"", r"\1", str(result_field.type)) - result = re.sub(r"ForwardRef\('([\w.]+)'\)", r"lsprotocol.types.\1", result) - result = result.replace("NoneType", "None") - - # Replace any lsprotocol types with their short name. - for match in re.finditer(r"lsprotocol.types.([\w]+)", result): - imports.add(("lsprotocol.types", match.group(1))) - - # Replace any typing imports with their short name. - for match in re.finditer(r"typing.([\w]+)", result): - imports.add(("typing", match.group(1))) - - result = result.replace("lsprotocol.types.", "") - result = result.replace("typing.", "") - - return result - - -def write_method( - method: str, - request: Type, - params: Optional[Type], - response: Type, - imports: Set[Tuple[str, str]], -) -> str: - python_name = to_snake_case(method).replace("/", "_").replace("$_", "") - if python_name == "shutdown": - python_name = "shutdown_request" - - if params is None: - param_name = "None" - else: - param_mod, param_name = params.__module__, params.__name__ - imports.add((param_mod, param_name)) - - result_type = get_response_type(response, imports) - - return "\n".join( - [ - f"def {python_name}(", - " self,", - f" params: {param_name},", - f" callback: Optional[Callable[[{result_type}], None]] = None,", - ") -> Future:", - f' """Make a ``{method}`` request.', - "", - textwrap.indent(inspect.getdoc(request) or "", " "), - ' """', - f' return self.lsp.send_request("{method}", params, callback)', - "", - f"async def {python_name}_async(", - " self,", - f" params: {param_name},", - f") -> {result_type}:", - f' """Make a ``{method}`` request.', - "", - textwrap.indent(inspect.getdoc(request) or "", " "), - ' """', - f' return await self.lsp.send_request_async("{method}", params)', - "", - ] - ) - - -def generate_client() -> str: - methods = [] - imports = { - ("concurrent.futures", "Future"), - ("pygls.protocol", "LanguageServerProtocol"), - ("pygls.protocol", "default_converter"), - ("pygls.server", "Server"), - ("typing", "Callable"), - ("typing", "Optional"), - } - - for method_name, types in METHOD_TO_TYPES.items(): - # Skip any requests that come from the server. - if message_direction(method_name) == "serverToClient": - continue - - request, response, params, _ = types - - if response is None: - method = write_notification(method_name, request, params, imports) - else: - method = write_method(method_name, request, params, response, imports) - - methods.append(textwrap.indent(method, " ")) - - code = [ - "# GENERATED FROM scripts/gen-client.py -- DO NOT EDIT", - f"# Last Modified: {datetime.now()}", - "# flake8: noqa", - write_imports(imports), - "", - "", - "class Client(Server):", - "", - " def __init__(", - " self,", - " name: str,", - " version: str,", - " protocol_cls=LanguageServerProtocol,", - " converter_factory=default_converter,", - " **kwargs,", - " ):", - " self.name = name", - " self.version = version", - " super().__init__(protocol_cls, converter_factory, **kwargs)", - "", - *methods, - ] - return "\n".join(code) - - -def main(): - args = cli.parse_args() - - # Make sure all the type annotations in lsprotocol are resolved correctly. - _resolve_forward_references() - client = generate_client() - - if args.output is None: - sys.stdout.write(client) - else: - output = pathlib.Path(args.output) - output.write_text(client) - - -if __name__ == "__main__": - main() From e664f47004f3d423d3581aefd13a89fb988d366e Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sat, 10 Jun 2023 18:51:32 +0100 Subject: [PATCH 04/63] Align to pygls' LanguageClient --- lib/pytest-lsp/pytest_lsp/client.py | 51 ++++++++++------- lib/pytest-lsp/pytest_lsp/plugin.py | 86 +++-------------------------- 2 files changed, 39 insertions(+), 98 deletions(-) diff --git a/lib/pytest-lsp/pytest_lsp/client.py b/lib/pytest-lsp/pytest_lsp/client.py index a9e9601..38441e1 100644 --- a/lib/pytest-lsp/pytest_lsp/client.py +++ b/lib/pytest-lsp/pytest_lsp/client.py @@ -4,7 +4,6 @@ import os import sys import traceback -from typing import Any from typing import Dict from typing import List from typing import Optional @@ -25,9 +24,9 @@ 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.protocol import default_converter -from .gen import Client from .protocol import LanguageClientProtocol if sys.version_info.minor < 9: @@ -40,11 +39,18 @@ logger = logging.getLogger(__name__) -class LanguageClient(Client): +class LanguageClient(BaseLanguageClient): """Used to drive language servers under test.""" - def __init__(self, *args, **kwargs): - super().__init__("pytest-lsp-client", __version__, *args, **kwargs) + def __init__( + self, + protocol_cls: Type[LanguageClientProtocol] = LanguageClientProtocol, + *args, + **kwargs, + ): + super().__init__( + "pytest-lsp-client", __version__, protocol_cls=protocol_cls, *args, **kwargs + ) self.capabilities: Optional[ClientCapabilities] = None """The client's capabilities.""" @@ -71,20 +77,27 @@ def __init__(self, *args, **kwargs): self._last_log_index = 0 """Used to keep track of which log messages correspond with which test case.""" - def feature( - self, - feature_name: str, - options: Optional[Any] = None, - ): - return self.lsp.fm.feature(feature_name, options) + 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) - def _report_server_error(self, error: Exception, source: Type[Exception]): - # This may wind up being a mistake, but let's ignore broken pipe errors... - # If the server process has exited, the watchdog task will give us a better - # error message. - if isinstance(error, BrokenPipeError): + if self._stop_event.is_set(): return + stderr = "" + if server.stderr is not None: + stderr = await server.stderr.read() + stderr = stderr.decode("utf8") + + loop = asyncio.get_running_loop() + loop.call_soon( + cancel_all_tasks, + f"Server process exited with return code: {server.returncode}\n{stderr}", + ) + + def report_server_error(self, error: Exception, source: Type[Exception]): + """Called when the server does something unexpected, e.g. sending malformed + JSON.""" self.error = error tb = "".join(traceback.format_exc()) @@ -141,7 +154,7 @@ async def shutdown_session(self) -> None: if self.error is not None or self.capabilities is None: return - await self.shutdown_request_async(None) + await self.shutdown_async(None) self.exit(None) async def wait_for_notification(self, method: str): @@ -152,7 +165,7 @@ async def wait_for_notification(self, method: str): method The notification method to wait for, e.g. ``textDocument/publishDiagnostics`` """ - return await self.lsp.wait_for_notification_async(method) + return await self.protocol.wait_for_notification_async(method) def cancel_all_tasks(message: str): @@ -170,9 +183,7 @@ def make_test_client() -> LanguageClient: additional responses from the server.""" client = LanguageClient( - protocol_cls=LanguageClientProtocol, converter_factory=default_converter, - loop=asyncio.get_running_loop(), ) @client.feature(TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS) diff --git a/lib/pytest-lsp/pytest_lsp/plugin.py b/lib/pytest-lsp/pytest_lsp/plugin.py index 807d292..c529b7b 100644 --- a/lib/pytest-lsp/pytest_lsp/plugin.py +++ b/lib/pytest-lsp/pytest_lsp/plugin.py @@ -1,20 +1,14 @@ -import asyncio import inspect import logging -import subprocess import sys import textwrap -import threading import typing -from concurrent.futures import ThreadPoolExecutor from typing import Callable from typing import List from typing import Optional import pytest import pytest_asyncio -from pygls.server import StdOutTransportAdapter -from pygls.server import aio_readline from pytest_lsp.client import LanguageClient from pytest_lsp.client import make_test_client @@ -22,78 +16,21 @@ logger = logging.getLogger("client") -async def check_server_process( - server: subprocess.Popen, stop: threading.Event, client: LanguageClient -): - """Continously poll server process to see if it is still running.""" - while not stop.is_set(): - retcode = server.poll() - if retcode is not None: - stderr = "" - if server.stderr is not None: - stderr = server.stderr.read().decode("utf8") - - message = f"Server exited with return code: {retcode}\n{stderr}" - client._report_server_error(RuntimeError(message), RuntimeError) - - else: - await asyncio.sleep(0.1) - - class ClientServer: """A client server pair used to drive test cases.""" - def __init__(self, *, client: LanguageClient, server: subprocess.Popen): - self.server = server - """The process object running the server.""" + 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.""" - self._thread_pool_executor = ThreadPoolExecutor(max_workers=2) - self._stop_event = threading.Event() - - def start(self): - loop = asyncio.get_running_loop() - - self.client._stop_event = self._stop_event - transport = StdOutTransportAdapter(self.server.stdout, self.server.stdin) - self.client.lsp.connection_made(transport) - - # TODO: Remove once Python 3.7 is no longer supported - conn_name = {} - watch_name = {} - - if sys.version_info.minor > 7: - conn_name["name"] = "Client-Server Connection" - watch_name["name"] = "Server Watchdog" - - # Have the client listen to and respond to requests from the server. - self.conn = loop.create_task( - aio_readline( - loop, - self._thread_pool_executor, - self.client._stop_event, - self.server.stdout, - self.client.lsp.data_received, - ), - **conn_name, # type: ignore[arg-type] - ) - - # Watch the server process to see if it exits prematurely. - self.watch = loop.create_task( - check_server_process(self.server, self._stop_event, self.client), - **watch_name, # type: ignore[arg-type] - ) + async def start(self): + await self.client.start_io(*self.server_command) async def stop(self): - self.server.terminate() - - if self.client._stop_event: - self.client._stop_event.set() - - # Wait for background tasks to finish. - await asyncio.gather(self.conn, self.watch) + await self.client.stop() class ClientServerConfig: @@ -123,15 +60,8 @@ def __init__( def make_client_server(config: ClientServerConfig) -> ClientServer: """Construct a new ``ClientServer`` instance.""" - server = subprocess.Popen( - config.server_command, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - return ClientServer( - server=server, + server_command=config.server_command, client=config.client_factory(), ) @@ -225,7 +155,7 @@ def wrapper(fn): @pytest_asyncio.fixture(**kwargs) async def the_fixture(request): client_server = make_client_server(config) - client_server.start() + await client_server.start() kwargs = get_fixture_arguments(fn, client_server.client, request) result = fn(**kwargs) From 4a7b1f2aa2237fa9ec9e6cb4562e135be744c505 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sat, 10 Jun 2023 18:52:23 +0100 Subject: [PATCH 05/63] Update tests, docs and changelog --- .../guide/getting-started-fail-output.txt | 65 +++++++++---------- lib/pytest-lsp/changes/61.enhancement.rst | 2 + lib/pytest-lsp/tests/test_examples.py | 2 +- lib/pytest-lsp/tests/test_plugin.py | 9 +-- 4 files changed, 37 insertions(+), 41 deletions(-) create mode 100644 lib/pytest-lsp/changes/61.enhancement.rst diff --git a/docs/pytest-lsp/guide/getting-started-fail-output.txt b/docs/pytest-lsp/guide/getting-started-fail-output.txt index cd42432..ab4b727 100644 --- a/docs/pytest-lsp/guide/getting-started-fail-output.txt +++ b/docs/pytest-lsp/guide/getting-started-fail-output.txt @@ -1,17 +1,18 @@ $ pytest -================================================= test session starts ================================================== +================================================ test session starts ========================= +======================= platform linux -- Python 3.11.3, pytest-7.2.0, pluggy-1.0.0 -rootdir: /tmp/pytest-of-alex/pytest-2/test_getting_started_fail0, configfile: tox.ini -plugins: typeguard-2.13.3, asyncio-0.20.2, lsp-0.2.1 +rootdir: /tmp/pytest-of-alex/pytest-38/test_getting_started_fail0, configfile: tox.ini +plugins: asyncio-0.21.0, typeguard-3.0.2, lsp-0.3.0 asyncio: mode=Mode.AUTO collected 1 item -test_server.py E [100%] +test_server.py E [100%] -======================================================== ERRORS ======================================================== -__________________________________________ ERROR at setup of test_completions __________________________________________ +====================================================== ERRORS ======================================================= +________________________________________ ERROR at setup of test_completions _________________________________________ -lsp_client = +lsp_client = @pytest_lsp.fixture( config=ClientServerConfig(server_command=[sys.executable, "server.py"]), @@ -22,33 +23,25 @@ lsp_client = > await lsp_client.initialize_session(params) test_server.py:21: -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ -/var/home/alex/Projects/lsp-devtools/.env/lib64/python3.11/site-packages/pytest_lsp/client.py:171: in initialize_session +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +/var/home/alex/Projects/lsp-devtools/.env/lib64/python3.11/site-packages/pytest_lsp/client.py:137: in initialize_sess +ion response = await self.initialize_async(params) -_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ - -self = -params = InitializeParams(capabilities=ClientCapabilities(workspace=None, text_document=None, notebook_document=None, window=No..., root_path=None, root_uri=None, initialization_options=None, trace=None, work_done_token=None, workspace_folders=None) - - async def initialize_async( - self, - params: InitializeParams, - ) -> InitializeResult: - """Make a ``initialize`` request. - - The initialize request is sent from the client to the server. - - It is sent once as the request after starting up the server. The - requests parameter is of type {@link InitializeParams} the response - if of type {@link InitializeResult} of a Thenable that resolves to - such. - """ -> return await self.lsp.send_request_async("initialize", params) -E asyncio.exceptions.CancelledError: RuntimeError: Server exited with return code: 0 -E -E NoneType: None - -/var/home/alex/Projects/lsp-devtools/.env/lib64/python3.11/site-packages/pytest_lsp/gen.py:307: CancelledError -=============================================== short test summary info ================================================ -ERROR test_server.py::test_completions - asyncio.exceptions.CancelledError: RuntimeError: Server exited with return c... -=================================================== 1 error in 1.11s =================================================== +/var/home/alex/Projects/lsp-devtools/.env/lib64/python3.11/site-packages/pygls/lsp/client.py:349: in initialize_async + return await self.protocol.send_request_async("initialize", params) +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = , method = 'initialize' +params = InitializeParams(capabilities=ClientCapabilities(workspace=None, text_document=None, notebook_document=None, + window=No..., root_path=None, root_uri=None, initialization_options=None, trace=None, work_done_token=None, workspac +e_folders=None) + + async def send_request_async(self, method, params=None): +> result = await super().send_request_async(method, params) +E asyncio.exceptions.CancelledError: Server process exited with return code: 0 + +/var/home/alex/Projects/lsp-devtools/.env/lib64/python3.11/site-packages/pytest_lsp/protocol.py:42: CancelledError +============================================== short test summary info ============================================== +ERROR test_server.py::test_completions - asyncio.exceptions.CancelledError: Server process exited with return code: 0 +================================================= 1 error in 1.15s ================================================== + diff --git a/lib/pytest-lsp/changes/61.enhancement.rst b/lib/pytest-lsp/changes/61.enhancement.rst new file mode 100644 index 0000000..b3deb12 --- /dev/null +++ b/lib/pytest-lsp/changes/61.enhancement.rst @@ -0,0 +1,2 @@ +pytest-lsp's ``LanguageClient`` is now based on the one provided by ``pygls``. +The main benefit is that the server connection is now based on an ``asyncio.subprocess.Process`` removing the need for pytest-lsp to constantly check to see if the server is still running. diff --git a/lib/pytest-lsp/tests/test_examples.py b/lib/pytest-lsp/tests/test_examples.py index bd5389e..ae6fcb7 100644 --- a/lib/pytest-lsp/tests/test_examples.py +++ b/lib/pytest-lsp/tests/test_examples.py @@ -95,7 +95,7 @@ def test_getting_started_fail(pytester: pytest.Pytester): if sys.version_info.minor < 9: message = "E*CancelledError" else: - message = "E*asyncio.exceptions.CancelledError: RuntimeError: Server exited with return code: 0" # noqa: E501 + message = "E*asyncio.exceptions.CancelledError: Server process exited with return code: 0" # noqa: E501 results.stdout.fnmatch_lines(message) diff --git a/lib/pytest-lsp/tests/test_plugin.py b/lib/pytest-lsp/tests/test_plugin.py index 2755edf..a964aca 100644 --- a/lib/pytest-lsp/tests/test_plugin.py +++ b/lib/pytest-lsp/tests/test_plugin.py @@ -71,7 +71,7 @@ async def test_capabilities(client): if sys.version_info.minor < 9: message = "E*CancelledError" else: - message = "E*asyncio.exceptions.CancelledError: RuntimeError: Server exited with return code: 0" # noqa: E501 + message = "E*asyncio.exceptions.CancelledError: Server process exited with return code: 0" # noqa: E501 results.stdout.fnmatch_lines(message) @@ -102,14 +102,15 @@ async def test_capabilities(client): setup_test(pytester, "completion_exit.py", test_code) results = pytester.runpytest("-vv") - results.assert_outcomes(failed=1) + results.assert_outcomes(failed=1, errors=1) if sys.version_info.minor < 9: message = "E*CancelledError" else: - message = "E*asyncio.exceptions.CancelledError: RuntimeError: Server exited with return code: 0" # noqa: E501 + message = "E*asyncio.exceptions.CancelledError: Server process exited with return code: 0" # noqa: E501 results.stdout.fnmatch_lines(message) + results.stdout.fnmatch_lines("E*RuntimeError: Client has been stopped.") def test_detect_server_crash(pytester: pytest.Pytester): @@ -132,7 +133,7 @@ async def test_capabilities(client): message = "E*CancelledError" else: message = [ - "E*asyncio.exceptions.CancelledError: RuntimeError: Server exited with return code: 1", # noqa: E501 + "E*asyncio.exceptions.CancelledError: Server process exited with return code: 1", # noqa: E501 "E*ZeroDivisionError: division by zero", ] From 951e6aee773cf67031fe322b788104cd19c3ac25 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 21 Jun 2023 18:00:36 +0000 Subject: [PATCH 06/63] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/pytest-lsp/guide/getting-started-fail-output.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/pytest-lsp/guide/getting-started-fail-output.txt b/docs/pytest-lsp/guide/getting-started-fail-output.txt index ab4b727..f566e0f 100644 --- a/docs/pytest-lsp/guide/getting-started-fail-output.txt +++ b/docs/pytest-lsp/guide/getting-started-fail-output.txt @@ -44,4 +44,3 @@ E asyncio.exceptions.CancelledError: Server process exited with return cod ============================================== short test summary info ============================================== ERROR test_server.py::test_completions - asyncio.exceptions.CancelledError: Server process exited with return code: 0 ================================================= 1 error in 1.15s ================================================== - From aac33c44a44af486062a594161383c420d21ae85 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Thu, 22 Jun 2023 20:26:22 +0100 Subject: [PATCH 07/63] lsp-devtools: Have the agent set the `session` and `timestamp` --- lib/lsp-devtools/lsp_devtools/agent/__init__.py | 13 +++++++++---- lib/lsp-devtools/lsp_devtools/agent/protocol.py | 6 ++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/lsp-devtools/lsp_devtools/agent/__init__.py b/lib/lsp-devtools/lsp_devtools/agent/__init__.py index 76a6fff..26831be 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/agent/__init__.py @@ -4,6 +4,7 @@ import subprocess import sys from typing import List +from uuid import uuid4 from .agent import Agent from .agent import logger @@ -31,13 +32,17 @@ class MessageHandler(logging.Handler): def __init__(self, client: AgentClient, *args, **kwargs): super().__init__(*args, **kwargs) self.client = client + self.session = str(uuid4()) def emit(self, record: logging.LogRecord): - message = MessageText( - text=record.args[0], # type: ignore - source=record.__dict__["source"], + self.client.protocol.message_text_notification( + MessageText( + text=record.args[0], # type: ignore + session=self.session, + timestamp=record.created, + source=record.__dict__["source"], + ) ) - self.client.protocol.message_text_notification(message) async def main(args, extra: List[str]): diff --git a/lib/lsp-devtools/lsp_devtools/agent/protocol.py b/lib/lsp-devtools/lsp_devtools/agent/protocol.py index 742c734..c871746 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/protocol.py +++ b/lib/lsp-devtools/lsp_devtools/agent/protocol.py @@ -14,6 +14,12 @@ class MessageText: text: str """The captured text.""" + timestamp: float + """The timestamp of when the message was recorded.""" + + session: str + """The session id.""" + source: str """The source the text was captured from e.g. client.""" From 4bfea9c1733330f974dae04baf4af70a0d8248e0 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Thu, 22 Jun 2023 20:28:27 +0100 Subject: [PATCH 08/63] lsp-devtools: Add a high level start_tcp implementation to the server --- lib/lsp-devtools/lsp_devtools/agent/server.py | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/lsp-devtools/lsp_devtools/agent/server.py b/lib/lsp-devtools/lsp_devtools/agent/server.py index 8d4972a..3b9bd9f 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/server.py +++ b/lib/lsp-devtools/lsp_devtools/agent/server.py @@ -1,9 +1,12 @@ +import asyncio import json import re +import threading from typing import Any from typing import Callable from typing import Optional +from pygls.client import aio_readline from pygls.protocol import default_converter from pygls.server import Server @@ -27,10 +30,32 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._client_buffer = [] self._server_buffer = [] + self._stop_event = threading.Event() + self._tcp_server = None def feature(self, feature_name: str, options: Optional[Any] = None): return self.lsp.fm.feature(feature_name, options) + async def start_tcp(self, host: str, port: int) -> None: + async def handle_client(reader, writer): + self.lsp.connection_made(writer) + await aio_readline(self._stop_event, reader, self.lsp.data_received) + + writer.close() + await writer.wait_closed() + + if self._tcp_server is not None: + self._tcp_server.cancel() + + server = await asyncio.start_server(handle_client, host, port) + async with server: + self._tcp_server = asyncio.create_task(server.serve_forever()) + await self._tcp_server + + async def stop(self): + if self._tcp_server is not None: + self._tcp_server.cancel() + MESSAGE_PATTERN = re.compile( r"^(?:[^\r\n]+\r\n)*" @@ -41,9 +66,7 @@ def feature(self, feature_name: str, options: Optional[Any] = None): ) -def parse_rpc_message( - ls: AgentServer, message: MessageText, callback: Callable[[dict], None] -): +def parse_rpc_message(ls: AgentServer, message: MessageText, callback): """Parse json-rpc messages coming from the agent. Originally adatped from the ``data_received`` method on pygls' ``JsonRPCProtocol`` From 8282081e74d968766b2d8ffac5c0d5a1d1eefd65 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Thu, 22 Jun 2023 20:29:38 +0100 Subject: [PATCH 09/63] lsp-devtools: Parse json fields as json When reading `LspMessage` objects out of the database, the JSON values will be stored as a string. By providing a `converter` we can automatically have the fields be parsed back into JSON objects --- .../lsp_devtools/handlers/__init__.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/lsp-devtools/lsp_devtools/handlers/__init__.py b/lib/lsp-devtools/lsp_devtools/handlers/__init__.py index 4b8219a..66d13d9 100644 --- a/lib/lsp-devtools/lsp_devtools/handlers/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/handlers/__init__.py @@ -1,3 +1,4 @@ +import json import logging from typing import Any from typing import Mapping @@ -15,6 +16,13 @@ MessageSource = Literal["client", "server"] +def maybe_json(value): + try: + return json.loads(value) + except Exception: + return value + + @attrs.define class LspMessage: """A container that holds a message from the LSP protocol, with some additional @@ -37,13 +45,13 @@ class LspMessage: method: Optional[str] """The ``method`` field, if it exists.""" - params: Optional[Any] + params: Optional[Any] = attrs.field(converter=maybe_json) """The ``params`` field, if it exists.""" - result: Optional[Any] + result: Optional[Any] = attrs.field(converter=maybe_json) """The ``result`` field, if it exists.""" - error: Optional[Any] + error: Optional[Any] = attrs.field(converter=maybe_json) """The ``error`` field, if it exists.""" @classmethod @@ -81,7 +89,7 @@ class LspHandler(logging.Handler): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.session_id = "" + self.session_id = str(uuid4()) def handle_message(self, message: LspMessage): """Called each time a message is processed.""" @@ -94,9 +102,6 @@ def emit(self, record: logging.LogRecord): timestamp = record.created source = record.__dict__["source"] - if message.get("method", None) == "initialize": - self.session_id = str(uuid4()) - self.handle_message( LspMessage.from_rpc( session=self.session_id, From a4e4ac78f1afbe364836738fd6f9123573433990 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Thu, 22 Jun 2023 20:31:18 +0100 Subject: [PATCH 10/63] lsp-devtools: Handle cancellations and Ctrl-C --- lib/lsp-devtools/lsp_devtools/record/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/lsp-devtools/lsp_devtools/record/__init__.py b/lib/lsp-devtools/lsp_devtools/record/__init__.py index 5933391..d2f6139 100644 --- a/lib/lsp-devtools/lsp_devtools/record/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/record/__init__.py @@ -1,8 +1,8 @@ import argparse +import asyncio import json import logging import pathlib -import sys from functools import partial from logging import LogRecord from typing import List @@ -142,10 +142,11 @@ def start_recording(args, extra: List[str]): try: print(f"Waiting for connection on {host}:{port}...", end="\r", flush=True) - server.start_tcp(host, port) - except Exception: - # TODO: Error handling - raise + asyncio.run(server.start_tcp(host, port)) + except asyncio.CancelledError: + pass + except KeyboardInterrupt: + pass def setup_filter_args(cmd: argparse.ArgumentParser): From 791ed2ec345302f8622a551e46068bf150ed669a Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Thu, 22 Jun 2023 20:32:03 +0100 Subject: [PATCH 11/63] lsp-devtools: Align tui app to new architecture The tui app ditches the logging.Handler setup entirely. Instead the app now hosts an async TCP, JSON-RPC server for LSP agents to connect to. Upon receiving messages the new centralised `Database` object is responsible for saving the messages to the underlying SQLite DB. --- lib/lsp-devtools/lsp_devtools/tui/__init__.py | 191 ++++++++++-------- lib/lsp-devtools/lsp_devtools/tui/app.css | 5 + lib/lsp-devtools/lsp_devtools/tui/client.py | 54 ----- lib/lsp-devtools/lsp_devtools/tui/database.py | 109 ++++++++++ 4 files changed, 219 insertions(+), 140 deletions(-) delete mode 100644 lib/lsp-devtools/lsp_devtools/tui/client.py create mode 100644 lib/lsp-devtools/lsp_devtools/tui/database.py diff --git a/lib/lsp-devtools/lsp_devtools/tui/__init__.py b/lib/lsp-devtools/lsp_devtools/tui/__init__.py index 6d8c6a8..f1c49ef 100644 --- a/lib/lsp-devtools/lsp_devtools/tui/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/tui/__init__.py @@ -1,23 +1,24 @@ import argparse import asyncio import json +import logging import pathlib -import threading +import re from datetime import datetime from typing import Any from typing import Dict from typing import List -from typing import Optional -from typing import Tuple -import aiosqlite import appdirs from rich.highlighter import ReprHighlighter from rich.text import Text from textual import events +from textual import log +from textual import on from textual.app import App from textual.app import ComposeResult from textual.containers import Container +from textual.containers import ScrollableContainer from textual.events import Ready from textual.widgets import DataTable from textual.widgets import Footer @@ -25,14 +26,18 @@ from textual.widgets import Tree from textual.widgets.tree import TreeNode -from lsp_devtools.record import setup_filter_args +from lsp_devtools.agent import MESSAGE_TEXT_NOTIFICATION +from lsp_devtools.agent import AgentServer +from lsp_devtools.agent import MessageText +from lsp_devtools.handlers import LspMessage -from .client import Ping -from .client import TUIAgentClient -from .client import connect_to_agent +from .database import Database +from .database import PingMessage +logger = logging.getLogger(__name__) -class ObjectViewer(Tree): + +class MessageViewer(Tree): """Used to inspect the fields of an object.""" def __init__(self, *args, **kwargs) -> None: @@ -63,19 +68,15 @@ def walk_object(self, label: str, node: TreeNode, obj: Any): node.set_label(Text.assemble(label, " = ", self.highlighter(repr(obj)))) -RPCData = Dict[int, Tuple[Optional[str], Optional[str], Optional[str]]] - - class MessagesTable(DataTable): """Datatable used to display all messages between client and server""" - def __init__(self, dbpath: pathlib.Path, viewer: ObjectViewer): + def __init__(self, db: Database, viewer: MessageViewer): super().__init__() - self.dbpath = dbpath - self.dbquery = "SELECT rowid, * FROM protocol WHERE rowid > ?" - self.rpcdata: RPCData = {} - self.max_row = -1 + self.db = db + self.rpcdata: Dict[int, LspMessage] = {} + self.max_row = 0 self.viewer = viewer @@ -92,50 +93,42 @@ def on_key(self, event: events.Key): return rowid = int(self.get_row_at(self.cursor_row)[0]) - params, result, error = self.rpcdata[rowid] + message = self.rpcdata[rowid] + name = "" + obj = {} - if params: + if message.params: name = "params" - message = json.loads(params) + obj = message.params - elif result: + elif message.result: name = "result" - message = json.loads(result) + obj = message.result - elif error: + elif message.error: name = "error" - message = json.loads(error) + obj = message.error - else: - name = "data" - message = {} - - self.viewer.set_object(name, message) + self.viewer.set_object(name, obj) async def update(self): """Trigger a re-run of the query to pull in new data.""" - async with aiosqlite.connect(self.dbpath) as conn: - async with conn.execute(self.dbquery, (self.max_row,)) as cursor: - async for row in cursor: - rowid = row[0] - timestamp = row[2] - source = row[3] - id_ = row[4] - method = row[5] - params = row[6] - result = row[7] - error = row[8] + messages = await self.db.get_messages(self.max_row - 1) + for message in messages: + self.max_row += 1 + self.rpcdata[self.max_row] = message - self.rpcdata[rowid] = (params, result, error) + # Surely there's a more direct way to do this? + dt = datetime.fromtimestamp(message.timestamp) + time = dt.isoformat(timespec="milliseconds") + time = time[time.find("T") + 1 :] - # Surely there's a more direct way to do this? - dt = datetime.fromtimestamp(timestamp) - time = dt.isoformat(timespec="milliseconds") - time = time[time.find("T") + 1 :] + self.add_row( + str(self.max_row), time, message.source, message.id, message.method + ) - self.add_row(str(rowid), time, source, id_, method) - self.max_row = rowid + self.move_cursor(row=self.max_row, animate=True) class Sidebar(Container): @@ -144,28 +137,26 @@ class Sidebar(Container): class LSPInspector(App): CSS_PATH = pathlib.Path(__file__).parent / "app.css" - BINDINGS = [("ctrl+b", "toggle_sidebar", "Sidebar"), ("q", "quit", "Quit")] + BINDINGS = [("ctrl+b", "toggle_sidebar", "Sidebar"), ("ctrl+c", "quit", "Quit")] - def __init__(self, dbpath: pathlib.Path, *args, **kwargs): + def __init__(self, db: Database, server: AgentServer, *args, **kwargs): super().__init__(*args, **kwargs) - self.dbpath = dbpath + self.db = db + self.db.app = self """Where the data for the app is being held""" - self.client: Optional[TUIAgentClient] = None - """Client used to interact with the LSPAgent hosting the server we're - inspecting.""" + self.server = server + """Server used to manage connections to lsp servers.""" - self.loop: Optional[asyncio.AbstractEventLoop] = None - """Accessed by the AgentClient to push messages into the UI""" + self._async_tasks = [] def compose(self) -> ComposeResult: yield Header() - viewer = ObjectViewer("") - messages = MessagesTable(self.dbpath, viewer) - - yield Container(messages, Sidebar(viewer)) + viewer = MessageViewer("") + messages = MessagesTable(self.db, viewer) + yield Container(ScrollableContainer(messages), Sidebar(viewer)) yield Footer() def action_toggle_sidebar(self) -> None: @@ -179,12 +170,14 @@ def action_toggle_sidebar(self) -> None: self.screen.set_focus(None) sidebar.add_class("-hidden") - async def on_ready(self, event: Ready): - self.loop = asyncio.get_running_loop() + @on(PingMessage) + async def on_ping(self, message: PingMessage): await self.update_table() - async def on_ping(self, message: Ping): - """Fired when the agent client receives new messages""" + async def on_ready(self, event: Ready): + self._async_tasks.append( + asyncio.create_task(self.server.start_tcp("localhost", 8765)) + ) await self.update_table() async def update_table(self): @@ -192,35 +185,64 @@ async def update_table(self): await table.update() async def action_quit(self): - if self.client: - self.client._stop_event.set() + await self.server.stop() + await self.db.close() await super().action_quit() -def start_client(client, host, port): - try: - client.start_ws_client(host, port) - except Exception: - # TODO: Surface the error somehow - pass +MESSAGE_PATTERN = re.compile( + r"^(?:[^\r\n]+\r\n)*" + + r"Content-Length: (?P\d+)\r\n" + + r"(?:[^\r\n]+\r\n)*\r\n" + + r"(?P{.*)", + re.DOTALL, +) -def tui(args, extra: List[str]): - dbpath = args.to_sqlite - if not dbpath.parent.exists(): - dbpath.parent.mkdir(parents=True) +async def handle_message(ls: AgentServer, message: MessageText): + """Handle messages received from the connected lsp server.""" - app = LSPInspector(dbpath) - client = connect_to_agent(args, app) - app.client = client + data = message.text + message_buf = ls._client_buffer if message.source == "client" else ls._server_buffer - agent_thread = threading.Thread( - name="AgentClient", target=start_client, args=(client, args.host, args.port) - ) - agent_thread.start() + while len(data): + # Append the incoming chunk to the message buffer + message_buf.append(data) + + # Look for the body of the message + msg = "".join(message_buf) + found = MESSAGE_PATTERN.fullmatch(msg) + body = found.group("body") if found else "" + length = int(found.group("length")) if found else 1 + + if len(body) < length: + # Message is incomplete; bail until more data arrives + return + + # Message is complete; + # extract the body and any remaining data, + # and reset the buffer for the next message + body, data = body[:length], body[length:] + message_buf.clear() + + rpc = json.loads(body) + await ls.db.add_message(message.session, message.timestamp, message.source, rpc) + + +def setup_server(db: Database): + server = AgentServer() + server.db = db + server.feature(MESSAGE_TEXT_NOTIFICATION)(handle_message) + return server + + +def tui(args, extra: List[str]): + db = Database(args.dbpath) + server = setup_server(db) + + app = LSPInspector(db, server) app.run() - agent_thread.join() def cli(commands: argparse._SubParsersAction): @@ -242,7 +264,6 @@ def cli(commands: argparse._SubParsersAction): type=pathlib.Path, metavar="DB", default=default_db, - dest="to_sqlite", # to be compatible with code borrowed from record command. help="the database path to use", ) @@ -259,6 +280,4 @@ def cli(commands: argparse._SubParsersAction): connect.add_argument( "-p", "--port", type=int, default=8765, help="the port to connect to." ) - - setup_filter_args(cmd) cmd.set_defaults(run=tui) diff --git a/lib/lsp-devtools/lsp_devtools/tui/app.css b/lib/lsp-devtools/lsp_devtools/tui/app.css index c2c97d4..77ba509 100644 --- a/lib/lsp-devtools/lsp_devtools/tui/app.css +++ b/lib/lsp-devtools/lsp_devtools/tui/app.css @@ -7,3 +7,8 @@ Sidebar { Sidebar.-hidden { offset-x: 100%; } + + +DataTable { + height: 100%; +} diff --git a/lib/lsp-devtools/lsp_devtools/tui/client.py b/lib/lsp-devtools/lsp_devtools/tui/client.py deleted file mode 100644 index 87c576f..0000000 --- a/lib/lsp-devtools/lsp_devtools/tui/client.py +++ /dev/null @@ -1,54 +0,0 @@ -import asyncio -import logging -import typing -from functools import partial - -from textual.message import Message - -from lsp_devtools.agent import MESSAGE_TEXT_NOTIFICATION -from lsp_devtools.agent import AgentClient -from lsp_devtools.agent import MessageText -from lsp_devtools.agent import parse_rpc_message -from lsp_devtools.record import logger -from lsp_devtools.record import setup_sqlite_output - -if typing.TYPE_CHECKING: - from . import LSPInspector - - -class Ping(Message): - """Sent when the UI needs a refresh.""" - - -class TUIAgentClient(AgentClient): - def __init__(self, app: "LSPInspector"): - self.app = app - super().__init__() - - -def log_message(ls: TUIAgentClient, source: str, message: dict): - logger.info("%s", message, extra={"source": source}) - app = ls.app - - # The event loop only becomes available once the on_ready event has fired - # and the UI has bootstrapped itself. So it's possible we recevie - # messages before the app is ready to accept them. - if app.loop is None: - return - - asyncio.run_coroutine_threadsafe(app.post_message(Ping(app)), app.loop) - - -def recv_message(ls: TUIAgentClient, message: MessageText): - logfn = partial(log_message, ls, message.source) - parse_rpc_message(ls, message, logfn) - - -def connect_to_agent(args, app: "LSPInspector"): - client = TUIAgentClient(app) - client.feature(MESSAGE_TEXT_NOTIFICATION)(recv_message) - - logger.setLevel(logging.INFO) - setup_sqlite_output(args) - - return client diff --git a/lib/lsp-devtools/lsp_devtools/tui/database.py b/lib/lsp-devtools/lsp_devtools/tui/database.py new file mode 100644 index 0000000..998c00d --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/tui/database.py @@ -0,0 +1,109 @@ +import json +import pathlib +import sys +from contextlib import asynccontextmanager +from typing import Optional + +import aiosqlite +from textual import log +from textual.message import Message + +from lsp_devtools.handlers import LspMessage + +if sys.version_info.minor < 9: + import importlib_resources as resources +else: + import importlib.resources as resources # type: ignore[no-redef] + + +class PingMessage(Message): + """Sent when there are updates in the db.""" + + +class Database: + """Controls access to the backing sqlite database.""" + + def __init__(self, dbpath: Optional[pathlib.Path] = None, app=None): + self.dbpath = dbpath or ":memory:" + self.db: Optional[aiosqlite.Connection] = None + self.app = app + + async def close(self): + if self.db: + await self.db.close() + + @asynccontextmanager + async def cursor(self): + """Get a connection to the database.""" + + if self.db is None: + if ( + isinstance(self.dbpath, pathlib.Path) + and not self.dbpath.parent.exists() + ): + self.dbpath.parent.mkdir(parents=True) + + resource = resources.files("lsp_devtools.handlers").joinpath("dbinit.sql") + schema = resource.read_text(encoding="utf8") + + self.db = await aiosqlite.connect(self.dbpath) + await self.db.executescript(schema) + await self.db.commit() + + cursor = await self.db.cursor() + yield cursor + + await self.db.commit() + + async def add_message(self, session: str, timestamp: float, source: str, rpc: dict): + """Add a new rpc message to the database.""" + + msg_id = rpc.get("id", None) + method = rpc.get("method", None) + params = rpc.get("params", None) + result = rpc.get("result", None) + error = rpc.get("error", None) + + async with self.cursor() as cursor: + await cursor.execute( + "INSERT INTO protocol VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + session, + timestamp, + source, + msg_id, + method, + json.dumps(params) if params else None, + json.dumps(result) if result else None, + json.dumps(error) if error else None, + ), + ) + + if self.app is not None: + self.app.post_message(PingMessage()) + + async def get_messages(self, max_row=-1): + """Get messages from the databse""" + + query = "SELECT * FROM protocol WHERE rowid > ?" + + async with self.cursor() as cursor: + await cursor.execute(query, (max_row,)) + + rows = await cursor.fetchall() + results = [] + for row in rows: + results.append( + LspMessage( + session=row[0], + timestamp=row[1], + source=row[2], + id=row[3], + method=row[4], + params=row[5], + result=row[6], + error=row[7], + ) + ) + + return results From 906a79f12097077a35a0fd555cbbaab3d4594d76 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sat, 1 Jul 2023 21:03:31 +0100 Subject: [PATCH 12/63] Fix latest/stable doc builds take 2 --- docs/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Makefile b/docs/Makefile index 4ae965d..b16343c 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -14,7 +14,7 @@ endif html-build: BUILDDIR=$(BUILDDIR) sphinx-build -b html . _build/$(BUILDDIR)/en/ - echo "version=$(BUILDDIR)" >> $GITHUB_OUTPUT + echo "version=$(BUILDDIR)" >> $(GITHUB_OUTPUT) html-local: From 4be9ef37effbccbcbe0d7d27c2efe66f74779fc9 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sat, 1 Jul 2023 20:59:03 +0100 Subject: [PATCH 13/63] Add pyrightconfig.json files --- lib/lsp-devtools/pyrightconfig.json | 3 +++ lib/pytest-lsp/pyrightconfig.json | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 lib/lsp-devtools/pyrightconfig.json create mode 100644 lib/pytest-lsp/pyrightconfig.json diff --git a/lib/lsp-devtools/pyrightconfig.json b/lib/lsp-devtools/pyrightconfig.json new file mode 100644 index 0000000..0622ac8 --- /dev/null +++ b/lib/lsp-devtools/pyrightconfig.json @@ -0,0 +1,3 @@ +{ + "venv": ".env" +} diff --git a/lib/pytest-lsp/pyrightconfig.json b/lib/pytest-lsp/pyrightconfig.json new file mode 100644 index 0000000..0622ac8 --- /dev/null +++ b/lib/pytest-lsp/pyrightconfig.json @@ -0,0 +1,3 @@ +{ + "venv": ".env" +} From 3458fb4352d0e18017ac1241ada395b6bc79f652 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sat, 1 Jul 2023 20:59:53 +0100 Subject: [PATCH 14/63] Replace deprecated ::set-output --- scripts/should-build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/should-build.sh b/scripts/should-build.sh index e42dad5..99f4e9a 100755 --- a/scripts/should-build.sh +++ b/scripts/should-build.sh @@ -33,5 +33,5 @@ if [ -z "$changes" ]; then echo "There is nothing to do." else echo "Changes detected, doing build!" - echo "::set-output name=build::true" + echo "build::true" >> $GITHUB_OUTPUT fi From 9656be2a8fa527c43c1b07e89533af46d2e5bb21 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sat, 1 Jul 2023 21:07:35 +0100 Subject: [PATCH 15/63] Temporarily use git snapshot version of pygls --- docs/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index 92e6e63..617260a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,4 +3,5 @@ sphinx sphinx-design furo +git+https://github.com/openlawlibrary/pygls.git#egg=pygls -e lib/pytest-lsp From 234df84af91709b9124f752e1aa9eec79cfd2540 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 20:53:51 +0000 Subject: [PATCH 16/63] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/mirrors-mypy: v1.3.0 → v1.4.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.3.0...v1.4.1) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6bc33d3..42351dc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,7 +34,7 @@ repos: exclude: 'lib/pytest-lsp/pytest_lsp/gen.py' - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.3.0' + rev: 'v1.4.1' hooks: - id: mypy name: mypy (pytest-lsp) From 8c557092ceb68bcd4608d776f3b6142c83e60c64 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 2 Jul 2023 00:01:24 +0100 Subject: [PATCH 17/63] docs: Add `@pytest.mark.asycnio` markers to example code --- docs/pytest-lsp/guide/getting-started-fail-output.txt | 3 +-- docs/pytest-lsp/guide/getting-started.rst | 2 +- docs/pytest-lsp/guide/language-client.rst | 8 ++++---- lib/pytest-lsp/tests/examples/diagnostics/t_server.py | 2 ++ lib/pytest-lsp/tests/examples/getting-started/t_server.py | 2 ++ .../tests/examples/window-log-message/t_server.py | 2 ++ .../tests/examples/window-show-document/t_server.py | 2 ++ .../tests/examples/window-show-message/t_server.py | 2 ++ 8 files changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/pytest-lsp/guide/getting-started-fail-output.txt b/docs/pytest-lsp/guide/getting-started-fail-output.txt index f566e0f..3cfde65 100644 --- a/docs/pytest-lsp/guide/getting-started-fail-output.txt +++ b/docs/pytest-lsp/guide/getting-started-fail-output.txt @@ -1,6 +1,5 @@ $ pytest -================================================ test session starts ========================= -======================= +================================================ test session starts ================================================ platform linux -- Python 3.11.3, pytest-7.2.0, pluggy-1.0.0 rootdir: /tmp/pytest-of-alex/pytest-38/test_getting_started_fail0, configfile: tox.ini plugins: asyncio-0.21.0, typeguard-3.0.2, lsp-0.3.0 diff --git a/docs/pytest-lsp/guide/getting-started.rst b/docs/pytest-lsp/guide/getting-started.rst index 02fcf1b..947bb6d 100644 --- a/docs/pytest-lsp/guide/getting-started.rst +++ b/docs/pytest-lsp/guide/getting-started.rst @@ -37,7 +37,7 @@ With the framework in place, we can go ahead and define our first test case .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/getting-started/t_server.py :language: python - :start-at: async def test_ + :start-at: @pytest.mark.asyncio All that's left is to run the test suite! diff --git a/docs/pytest-lsp/guide/language-client.rst b/docs/pytest-lsp/guide/language-client.rst index 510b127..6a0aa06 100644 --- a/docs/pytest-lsp/guide/language-client.rst +++ b/docs/pytest-lsp/guide/language-client.rst @@ -14,7 +14,7 @@ The client maintains a record of any :attr:`~pytest_lsp.LanguageClient.diagnosti .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/diagnostics/t_server.py :language: python - :start-at: async def test_ + :start-at: @pytest.mark.asyncio .. note:: @@ -40,7 +40,7 @@ Any :lsp:`window/logMessage` notifications sent from the server will be accessib .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/window-log-message/t_server.py :language: python - :start-at: async def test_ + :start-at: @pytest.mark.asyncio .. card:: server.py @@ -92,7 +92,7 @@ Similar to ``window/logMessage`` above, the client records any :lsp:`window/show .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/window-show-document/t_server.py :language: python - :start-at: async def test_ + :start-at: @pytest.mark.asyncio .. card:: server.py @@ -111,7 +111,7 @@ Similar to ``window/logMessage`` above, the client records any :lsp:`window/show .. literalinclude:: ../../../lib/pytest-lsp/tests/examples/window-show-message/t_server.py :language: python - :start-at: async def test_ + :start-at: @pytest.mark.asyncio .. card:: server.py diff --git a/lib/pytest-lsp/tests/examples/diagnostics/t_server.py b/lib/pytest-lsp/tests/examples/diagnostics/t_server.py index 9d42709..3331be4 100644 --- a/lib/pytest-lsp/tests/examples/diagnostics/t_server.py +++ b/lib/pytest-lsp/tests/examples/diagnostics/t_server.py @@ -6,6 +6,7 @@ from lsprotocol.types import InitializeParams from lsprotocol.types import TextDocumentItem +import pytest import pytest_lsp from pytest_lsp import ClientServerConfig from pytest_lsp import LanguageClient @@ -25,6 +26,7 @@ async def client(lsp_client: LanguageClient): await lsp_client.shutdown_session() +@pytest.mark.asyncio async def test_diagnostics(client: LanguageClient): """Ensure that the server implements diagnostics correctly.""" diff --git a/lib/pytest-lsp/tests/examples/getting-started/t_server.py b/lib/pytest-lsp/tests/examples/getting-started/t_server.py index 7b5f8fd..0abbade 100644 --- a/lib/pytest-lsp/tests/examples/getting-started/t_server.py +++ b/lib/pytest-lsp/tests/examples/getting-started/t_server.py @@ -7,6 +7,7 @@ from lsprotocol.types import Position from lsprotocol.types import TextDocumentIdentifier +import pytest import pytest_lsp from pytest_lsp import ClientServerConfig from pytest_lsp import LanguageClient @@ -26,6 +27,7 @@ async def client(lsp_client: LanguageClient): await lsp_client.shutdown_session() +@pytest.mark.asyncio async def test_completions(client: LanguageClient): """Ensure that the server implements completions correctly.""" diff --git a/lib/pytest-lsp/tests/examples/window-log-message/t_server.py b/lib/pytest-lsp/tests/examples/window-log-message/t_server.py index 0a215c8..bd68f44 100644 --- a/lib/pytest-lsp/tests/examples/window-log-message/t_server.py +++ b/lib/pytest-lsp/tests/examples/window-log-message/t_server.py @@ -7,6 +7,7 @@ from lsprotocol.types import Position from lsprotocol.types import TextDocumentIdentifier +import pytest import pytest_lsp from pytest_lsp import ClientServerConfig from pytest_lsp import LanguageClient @@ -26,6 +27,7 @@ async def client(lsp_client: LanguageClient): await lsp_client.shutdown_session() +@pytest.mark.asyncio async def test_completions(client: LanguageClient): results = await client.text_document_completion_async( params=CompletionParams( diff --git a/lib/pytest-lsp/tests/examples/window-show-document/t_server.py b/lib/pytest-lsp/tests/examples/window-show-document/t_server.py index b6ec862..bdad0a9 100644 --- a/lib/pytest-lsp/tests/examples/window-show-document/t_server.py +++ b/lib/pytest-lsp/tests/examples/window-show-document/t_server.py @@ -7,6 +7,7 @@ from lsprotocol.types import Position from lsprotocol.types import TextDocumentIdentifier +import pytest import pytest_lsp from pytest_lsp import ClientServerConfig from pytest_lsp import LanguageClient @@ -26,6 +27,7 @@ async def client(lsp_client: LanguageClient): await lsp_client.shutdown_session() +@pytest.mark.asyncio async def test_completions(client: LanguageClient): test_uri = "file:///path/to/file.txt" results = await client.text_document_completion_async( diff --git a/lib/pytest-lsp/tests/examples/window-show-message/t_server.py b/lib/pytest-lsp/tests/examples/window-show-message/t_server.py index d7de616..12dab5f 100644 --- a/lib/pytest-lsp/tests/examples/window-show-message/t_server.py +++ b/lib/pytest-lsp/tests/examples/window-show-message/t_server.py @@ -7,6 +7,7 @@ from lsprotocol.types import Position from lsprotocol.types import TextDocumentIdentifier +import pytest import pytest_lsp from pytest_lsp import ClientServerConfig from pytest_lsp import LanguageClient @@ -26,6 +27,7 @@ async def client(lsp_client: LanguageClient): await lsp_client.shutdown_session() +@pytest.mark.asyncio async def test_completions(client: LanguageClient): results = await client.text_document_completion_async( params=CompletionParams( From 3edb24138ca40ce44d39b7c6435864e737e3cb36 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 2 Jul 2023 00:02:58 +0100 Subject: [PATCH 18/63] docs: Add dedicated troubleshooting page --- docs/pytest-lsp/guide.rst | 1 + docs/pytest-lsp/guide/fixtures.rst | 30 ------ docs/pytest-lsp/guide/troubleshooting.rst | 118 ++++++++++++++++++++++ 3 files changed, 119 insertions(+), 30 deletions(-) create mode 100644 docs/pytest-lsp/guide/troubleshooting.rst diff --git a/docs/pytest-lsp/guide.rst b/docs/pytest-lsp/guide.rst index bf421e3..6bdfb6a 100644 --- a/docs/pytest-lsp/guide.rst +++ b/docs/pytest-lsp/guide.rst @@ -8,3 +8,4 @@ User Guide guide/language-client guide/client-capabilities guide/fixtures + guide/troubleshooting diff --git a/docs/pytest-lsp/guide/fixtures.rst b/docs/pytest-lsp/guide/fixtures.rst index 07b80f5..81546d6 100644 --- a/docs/pytest-lsp/guide/fixtures.rst +++ b/docs/pytest-lsp/guide/fixtures.rst @@ -3,36 +3,6 @@ Fixtures .. highlight:: none -Fixture Scope -------------- - -Setting your client `fixture's scope `__ to something like ``session`` will allow you to reuse the same client-server connection across multiple test cases. -However, you're likely to encounter an error like the following:: - - __________________________ ERROR at setup of test_capabilities _________________________ - ScopeMismatch: You tried to access the function scoped fixture event_loop with a session - scoped request object, involved factories: - /.../site-packages/pytest_lsp/plugin.py:201: def the_fixture(request) - - -This is due to the default `event_loop `__ fixture provided by `pytest-asyncio`_ not living long enough to support your client. -To fix this you can override the ``event_loop`` fixture, setting its scope to match that of your client. - -.. code-block:: python - - @pytest.fixture(scope="session") - def event_loop(): - """Redefine `pytest-asyncio's default event_loop fixture to match the scope - of our client fixture.""" - policy = asyncio.get_event_loop_policy() - loop = policy.new_event_loop() - yield loop - loop.close() - - -.. _pytest-asyncio: https://github.com/pytest-dev/pytest-asyncio - - Parameterised Fixtures ---------------------- diff --git a/docs/pytest-lsp/guide/troubleshooting.rst b/docs/pytest-lsp/guide/troubleshooting.rst new file mode 100644 index 0000000..55c8b32 --- /dev/null +++ b/docs/pytest-lsp/guide/troubleshooting.rst @@ -0,0 +1,118 @@ +Troubleshooting +=============== + +My tests won't run! +------------------- + +You may encounter an issue where some of your test cases that use ``pytest-lsp`` are unexpectedly skipped. + +.. code-block:: none + + ================================ test session starts ================================= + platform linux -- Python 3.10.6, pytest-7.3.2, pluggy-1.1.0 + rootdir: /home/username/projects/lsp/pytest-lsp + plugins: lsp-0.3.0, typeguard-3.0.2, asyncio-0.21.0 + asyncio: mode=strict + collected 1 item + + test_server.py s [100%] + + ================================== warnings summary ================================== + test_server.py::test_completions + /home/username/projects/lsp/pytest-lsp/venv/lib/python3.10/site-packages/_pytest/python.py:183: PytestUnhandledCoroutineWarning: async def functions are not natively supported and have been skipped. + You need to install a suitable plugin for your async framework, for example: + - anyio + - pytest-asyncio + - pytest-tornasync + - pytest-trio + - pytest-twisted + warnings.warn(PytestUnhandledCoroutineWarning(msg.format(nodeid))) + + =========================== 1 skipped, 1 warning in 0.64s ============================ + +It's likely that you forgot to add a ``@pytest.mark.asyncio`` marker to your test function(s) + +.. code-block:: python + + import pytest + + @pytest.mark.asyncio + async def test_server(client: LanguageClient): + ... + +Alternatively, if you prefer, you can set the following configuration option in your project's ``pyproject.toml`` + +.. code-block:: toml + + [tool.pytest.ini_options] + asyncio_mode = "auto" + +In which case `pytest-asyncio`_ will automatically collect and run any ``async`` test function in your test suite. + +``ScopeMismatch`` Error +----------------------- + +Setting your client `fixture's scope `__ to something like ``session`` will allow you to reuse the same client-server connection across multiple test cases. +However, you're likely to encounter an error like the following:: + + __________________________ ERROR at setup of test_capabilities _________________________ + ScopeMismatch: You tried to access the function scoped fixture event_loop with a session + scoped request object, involved factories: + /.../site-packages/pytest_lsp/plugin.py:201: def the_fixture(request) + + +This is due to the default `event_loop `__ fixture provided by `pytest-asyncio`_ not living long enough to support your client. +To fix this you can override the ``event_loop`` fixture, setting its scope to match that of your client. + +.. code-block:: python + + @pytest.fixture(scope="session") + def event_loop(): + """Redefine `pytest-asyncio's default event_loop fixture to match the scope + of our client fixture.""" + policy = asyncio.get_event_loop_policy() + loop = policy.new_event_loop() + yield loop + loop.close() + + +.. _pytest-asyncio: https://github.com/pytest-dev/pytest-asyncio + +``DeprecationWarning``: Unclosed event loop +------------------------------------------- + +Depending on the version of ``pygls`` (the LSP implementation used by ``pytest-lsp``) you have installed, you may encounter a ``DeprecationWarning`` abount an unclosed event loop. + +.. code-block:: none + + ================================ test session starts ================================= + platform linux -- Python 3.10.6, pytest-7.3.2, pluggy-1.1.0 + rootdir: /home/username/projects/lsp/pytest-lsp + plugins: lsp-0.3.0, typeguard-3.0.2, asyncio-0.21.0 + asyncio: mode=strict + collected 1 item + + test_server.py . [100%] + + ================================== warnings summary ================================== + test_server.py::test_completions + /home/username/projects/lsp/pytest-lsp/venv/lib/python3.10/site-packages/pytest_asyncio/plugin.py:444: DeprecationWarning: pytest-asyncio detected an unclosed event loop when tearing down the event_loop + fixture: <_UnixSelectorEventLoop running=False closed=False debug=False> + pytest-asyncio will close the event loop for you, but future versions of the + library will no longer do so. In order to ensure compatibility with future + versions, please make sure that: + 1. Any custom "event_loop" fixture properly closes the loop after yielding it + 5. Your code does not modify the event loop in async fixtures or tests + + warnings.warn( + + -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html + =========================== 1 passed, 1 warning in 0.64s ============================= + +This is a known issue in ``pygls v1.0.2`` and older, upgrading your ``pygls`` version to ``TBD`` should resolve the issue. + +.. note:: + + While this issue has been `fixed `_ upstream, it is not yet generally available. + However, the warning itself is fairly mild - ``pytest-lsp``/``pygls`` are not cleaning the event loop up correctly but are otherwise working as expected. + It should be safe to ignore this while waiting for the fix to become available. From bac6e8f860ba6a487a6272d58368e045ee71d63e Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 2 Jul 2023 00:03:49 +0100 Subject: [PATCH 19/63] docs: Add `sphinx-copybutton` extension --- docs/conf.py | 1 + docs/requirements.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index eaec57c..8cb8fd1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,6 +24,7 @@ "sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx.ext.intersphinx", + "sphinx_copybutton", "sphinx_design", "supported_clients", ] diff --git a/docs/requirements.txt b/docs/requirements.txt index 617260a..81a7b98 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,7 @@ # This assumes you are running the pip install command from the root of the repo e.g. # $ pip install -r docs/requirements.txt sphinx +sphinx-copybutton sphinx-design furo git+https://github.com/openlawlibrary/pygls.git#egg=pygls From 6a55b626b2d13de2507bfa570fdc4f81aa4fe86f Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 2 Jul 2023 00:05:37 +0100 Subject: [PATCH 20/63] docs: Add "this is the unstable docs" banner --- docs/conf.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 8cb8fd1..36f5e1d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,6 +13,9 @@ from docutils import nodes # noqa: E402 from sphinx.application import Sphinx # noqa: E402 +DEV_BUILD = os.getenv("BUILDDIR", None) == "latest" +BRANCH = "develop" if DEV_BUILD else "release" + project = "LSP Devtools" copyright = "2023, Alex Carney" author = "Alex Carney" @@ -48,6 +51,19 @@ html_theme = "furo" html_title = "LSP Devtools" # html_static_path = ["_static"] +html_theme_options = { + "source_repository": "https://github.com/swyddfa/lsp-devtools/", + "source_branch": BRANCH, + "source_directory": "docs/", +} + +if DEV_BUILD: + html_theme_options["announcement"] = ( + "This is the unstable version of the documentation, features may change or " + "be removed without warning. " + 'Click here ' + "to view the released version" + ) def lsp_role(name, rawtext, text, lineno, inliner, options={}, content=[]): From 8f72f66f1d9fd8be859e9c4d63e5a0ea555cc065 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 2 Jul 2023 23:06:27 +0100 Subject: [PATCH 21/63] lsp-devtools: Add ability to take a screenshot to tui --- lib/lsp-devtools/lsp_devtools/tui/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/lsp-devtools/lsp_devtools/tui/__init__.py b/lib/lsp-devtools/lsp_devtools/tui/__init__.py index f1c49ef..069036e 100644 --- a/lib/lsp-devtools/lsp_devtools/tui/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/tui/__init__.py @@ -137,7 +137,11 @@ class Sidebar(Container): class LSPInspector(App): CSS_PATH = pathlib.Path(__file__).parent / "app.css" - BINDINGS = [("ctrl+b", "toggle_sidebar", "Sidebar"), ("ctrl+c", "quit", "Quit")] + BINDINGS = [ + ("ctrl+b", "toggle_sidebar", "Sidebar"), + ("ctrl+c", "quit", "Quit"), + ("ctrl+s", "screenshot", "Take Screenshot"), + ] def __init__(self, db: Database, server: AgentServer, *args, **kwargs): super().__init__(*args, **kwargs) @@ -159,6 +163,10 @@ def compose(self) -> ComposeResult: yield Container(ScrollableContainer(messages), Sidebar(viewer)) yield Footer() + def action_screenshot(self): + self.bell() + self.save_screenshot(None, "./") + def action_toggle_sidebar(self) -> None: sidebar = self.query_one(Sidebar) self.set_focus(None) From f64c1690e3c5940c0fb4c4d5de09e7292b69ac14 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 2 Jul 2023 23:16:40 +0100 Subject: [PATCH 22/63] lsp-devtools: Add `--save-output` option to record command Utilising the export feature of `rich.Console`, the new `--save-output` option can be used to save console output to either HTML, SVG or plain text files --- .../lsp_devtools/record/__init__.py | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/lib/lsp-devtools/lsp_devtools/record/__init__.py b/lib/lsp-devtools/lsp_devtools/record/__init__.py index d2f6139..2f66f32 100644 --- a/lib/lsp-devtools/lsp_devtools/record/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/record/__init__.py @@ -8,6 +8,7 @@ from typing import List from typing import Optional +from rich.console import Console from rich.console import ConsoleRenderable from rich.logging import RichHandler from rich.traceback import Traceback @@ -20,6 +21,11 @@ from .filters import LSPFilter +EXPORTERS = { + ".html": ("save_html", {}), + ".svg": ("save_svg", {"title": ""}), + ".txt": ("save_text", {}), +} logger = logging.getLogger(__name__) @@ -72,9 +78,11 @@ def log_rpc_message(ls: AgentServer, message: MessageText): parse_rpc_message(ls, message, logfn) -def setup_stdout_output(args): +def setup_stdout_output(args) -> Console: """Log to stdout.""" - handler = RichLSPHandler(level=logging.INFO) + + console = Console(record=args.save_output is not None) + handler = RichLSPHandler(level=logging.INFO, console=console) handler.addFilter( LSPFilter( message_source=args.message_source, @@ -87,6 +95,7 @@ def setup_stdout_output(args): ) logger.addHandler(handler) + return console def setup_file_output(args): @@ -128,6 +137,7 @@ def start_recording(args, extra: List[str]): logger.setLevel(logging.INFO) server.feature(MESSAGE_TEXT_NOTIFICATION)(log_func) + console: Optional[Console] = None host = args.host port = args.port @@ -138,7 +148,7 @@ def start_recording(args, extra: List[str]): setup_sqlite_output(args) else: - setup_stdout_output(args) + console = setup_stdout_output(args) try: print(f"Waiting for connection on {host}:{port}...", end="\r", flush=True) @@ -148,6 +158,16 @@ def start_recording(args, extra: List[str]): except KeyboardInterrupt: pass + if console is not None and args.save_output is not None: + destination = args.save_output + exporter_name, kwargs = EXPORTERS.get(destination.suffix, (None, None)) + if exporter_name is None: + console.print(f"Unable to save output to '{destination.suffix}' files") + return + + exporter = getattr(console, exporter_name) + exporter(str(destination), **kwargs) + def setup_filter_args(cmd: argparse.ArgumentParser): """Add arguments that can be used to filter messages.""" @@ -272,6 +292,18 @@ def cli(commands: argparse._SubParsersAction): type=pathlib.Path, help="save messages to a SQLite DB", ) + output.add_argument( + "--save-output", + default=None, + metavar="DEST", + type=pathlib.Path, + help=( + "only applies when printing messages to the console. " + "This makes use of the rich.Console's export feature to save its output in " + "HTML, SVG or plain text format. The format used will be picked " + "automatically based on the desintation's file extension." + ), + ) cmd.set_defaults(run=start_recording) From 3c93b882c5e9bf98c666c4711012e02c3521854a Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 2 Jul 2023 23:18:28 +0100 Subject: [PATCH 23/63] lsp-devtools: Format objects as "pretty" JSON by default --- lib/lsp-devtools/lsp_devtools/record/formatters.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/lsp-devtools/lsp_devtools/record/formatters.py b/lib/lsp-devtools/lsp_devtools/record/formatters.py index 8def8fe..0a9ee48 100644 --- a/lib/lsp-devtools/lsp_devtools/record/formatters.py +++ b/lib/lsp-devtools/lsp_devtools/record/formatters.py @@ -1,3 +1,4 @@ +import json import re from typing import Any from typing import Callable @@ -16,6 +17,13 @@ cache = lru_cache(None) +def format_json(obj: dict) -> str: + if isinstance(obj, str): + return obj + + return json.dumps(obj, indent=2) + + def format_position(position: dict) -> str: return f"{position['line']}:{position['character']}" @@ -147,7 +155,7 @@ class FormatString: VARIABLE = re.compile(r"{\.([^}]+)}") def __init__(self, pattern: str): - self.pattern = pattern + self.pattern = pattern.replace("\\n", "\n").replace("\\t", "\t") self._parse() def _parse(self): @@ -164,7 +172,7 @@ def _parse(self): formatter = get_formatter(fmt) else: accessor = variable - formatter = str + formatter = format_json parts.append(Value(accessor=accessor, formatter=formatter)) idx = end From 084b128658df33af9c8cb3ea24a2d23c70ecb24d Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 2 Jul 2023 23:19:47 +0100 Subject: [PATCH 24/63] docs: Start documenting the lsp-devtools side of things --- docs/_static/custom.css | 4 + docs/conf.py | 3 +- docs/images/lsp-devtools-architecture.svg | 17 + docs/images/record-client-capabilities.svg | 1311 +++++++++++++++++ docs/images/record-example.svg | 192 +++ docs/images/record-log-messages.svg | 357 +++++ docs/images/tui-screenshot.svg | 294 ++++ docs/index.rst | 21 +- docs/lsp-devtools/changelog.rst | 4 + docs/lsp-devtools/guide.rst | 9 + .../guide/example-to-file-output.json | 27 + docs/lsp-devtools/guide/getting-started.rst | 105 ++ docs/lsp-devtools/guide/record-command.rst | 315 ++++ docs/lsp-devtools/guide/tui-command.rst | 2 + docs/pytest-lsp/guide/client-capabilities.rst | 2 + docs/pytest-lsp/guide/getting-started.rst | 7 +- docs/pytest-lsp/guide/language-client.rst | 36 +- .../guide/window-log-message-output.txt | 31 + 18 files changed, 2698 insertions(+), 39 deletions(-) create mode 100644 docs/_static/custom.css create mode 100644 docs/images/lsp-devtools-architecture.svg create mode 100644 docs/images/record-client-capabilities.svg create mode 100644 docs/images/record-example.svg create mode 100644 docs/images/record-log-messages.svg create mode 100644 docs/images/tui-screenshot.svg create mode 100644 docs/lsp-devtools/changelog.rst create mode 100644 docs/lsp-devtools/guide.rst create mode 100644 docs/lsp-devtools/guide/example-to-file-output.json create mode 100644 docs/lsp-devtools/guide/getting-started.rst create mode 100644 docs/lsp-devtools/guide/record-command.rst create mode 100644 docs/lsp-devtools/guide/tui-command.rst create mode 100644 docs/pytest-lsp/guide/window-log-message-output.txt diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 0000000..2f2a077 --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,4 @@ +.scrollable-svg { + max-height: 450px; + overflow: auto; +} diff --git a/docs/conf.py b/docs/conf.py index 36f5e1d..b2672f0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,7 +50,7 @@ html_theme = "furo" html_title = "LSP Devtools" -# html_static_path = ["_static"] +html_static_path = ["_static"] html_theme_options = { "source_repository": "https://github.com/swyddfa/lsp-devtools/", "source_branch": BRANCH, @@ -77,4 +77,5 @@ def lsp_role(name, rawtext, text, lineno, inliner, options={}, content=[]): def setup(app: Sphinx): + app.add_css_file("custom.css") app.add_role("lsp", lsp_role) diff --git a/docs/images/lsp-devtools-architecture.svg b/docs/images/lsp-devtools-architecture.svg new file mode 100644 index 0000000..95e5411 --- /dev/null +++ b/docs/images/lsp-devtools-architecture.svg @@ -0,0 +1,17 @@ + + + + + + + + Language ClientLanguage ServerAgentAgent Serverstdinstdinstdoutstdouttcp \ No newline at end of file diff --git a/docs/images/record-client-capabilities.svg b/docs/images/record-client-capabilities.svg new file mode 100644 index 0000000..cca9a7c --- /dev/null +++ b/docs/images/record-client-capabilities.svg @@ -0,0 +1,1311 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 22:59:30CLIENTNeovim v0.9.1 +{ +"window"{ +"showDocument"{ +"support": true                                            +},                                                           +"workDoneProgress": true,                                    +"showMessage"{ +"messageActionItem"{ +"additionalPropertiesSupport": false                     +} +} +},                                                             +"textDocument"{ +"references"{ +"dynamicRegistration": false                               +},                                                           +"semanticTokens"{ +"overlappingTokenSupport": true,                           +"dynamicRegistration": false,                              +"serverCancelSupport": false,                              +"augmentsSyntaxTokens": true,                              +"tokenModifiers"[ +"declaration",                                           +"definition",                                            +"readonly",                                              +"static",                                                +"deprecated",                                            +"abstract",                                              +"async",                                                 +"modification",                                          +"documentation",                                         +"defaultLibrary" +],                                                         +"tokenTypes"[ +"namespace",                                             +"type",                                                  +"class",                                                 +"enum",                                                  +"interface",                                             +"struct",                                                +"typeParameter",                                         +"parameter",                                             +"variable",                                              +"property",                                              +"enumMember",                                            +"event",                                                 +"function",                                              +"method",                                                +"macro",                                                 +"keyword",                                               +"modifier",                                              +"comment",                                               +"string",                                                +"number",                                                +"regexp",                                                +"operator",                                              +"decorator" +],                                                         +"multilineTokenSupport": false,                            +"requests"{ +"range": false,                                          +"full"{ +"delta": true                                          +} +},                                                         +"formats"[ +"relative" +] +},                                                           +"documentSymbol"{ +"hierarchicalDocumentSymbolSupport": true,                 +"dynamicRegistration": false,                              +"symbolKind"{ +"valueSet"[ +1,                                                     +2,                                                     +3,                                                     +4,                                                     +5,                                                     +6,                                                     +7,                                                     +8,                                                     +9,                                                     +10,                                                    +11,                                                    +12,                                                    +13,                                                    +14,                                                    +15,                                                    +16,                                                    +17,                                                    +18,                                                    +19,                                                    +20,                                                    +21,                                                    +22,                                                    +23,                                                    +24,                                                    +25,                                                    +26 +] +} +},                                                           +"signatureHelp"{ +"dynamicRegistration": false,                              +"signatureInformation"{ +"documentationFormat"[ +"markdown",                                            +"plaintext" +],                                                       +"parameterInformation"{ +"labelOffsetSupport": true                             +},                                                       +"activeParameterSupport": true                           +} +},                                                           +"documentHighlight"{ +"dynamicRegistration": false                               +},                                                           +"synchronization"{ +"didSave": true,                                           +"willSaveWaitUntil": true,                                 +"dynamicRegistration": false,                              +"willSave": true                                           +},                                                           +"declaration"{ +"linkSupport": true                                        +},                                                           +"codeAction"{ +"codeActionLiteralSupport"{ +"codeActionKind"{ +"valueSet"[ +"",                                                  +"quickfix",                                          +"refactor",                                          +"refactor.extract",                                  +"refactor.inline",                                   +"refactor.rewrite",                                  +"source",                                            +"source.organizeImports" +] +} +},                                                         +"dynamicRegistration": false,                              +"isPreferredSupport": true,                                +"dataSupport": true,                                       +"resolveSupport"{ +"properties"[ +"edit" +] +} +},                                                           +"definition"{ +"linkSupport": true                                        +},                                                           +"callHierarchy"{ +"dynamicRegistration": false                               +},                                                           +"implementation"{ +"linkSupport": true                                        +},                                                           +"completion"{ +"completionItem"{ +"insertTextModeSupport"{ +"valueSet"[ +1,                                                   +2 +] +},                                                       +"snippetSupport": true,                                  +"commitCharactersSupport": true,                         +"preselectSupport": true,                                +"deprecatedSupport": true,                               +"documentationFormat"[ +"markdown",                                            +"plaintext" +],                                                       +"tagSupport"{ +"valueSet"[ +1 +] +},                                                       +"labelDetailsSupport": true,                             +"resolveSupport"{ +"properties"[ +"documentation",                                     +"detail",                                            +"additionalTextEdits" +] +},                                                       +"insertReplaceSupport": true                             +},                                                         +"dynamicRegistration": false,                              +"completionList"{ +"itemDefaults"[ +"commitCharacters",                                    +"editRange",                                           +"insertTextFormat",                                    +"insertTextMode",                                      +"data" +] +},                                                         +"insertTextMode"1,                                       +"completionItemKind"{ +"valueSet"[ +1,                                                     +2,                                                     +3,                                                     +4,                                                     +5,                                                     +6,                                                     +7,                                                     +8,                                                     +9,                                                     +10,                                                    +11,                                                    +12,                                                    +13,                                                    +14,                                                    +15,                                                    +16,                                                    +17,                                                    +18,                                                    +19,                                                    +20,                                                    +21,                                                    +22,                                                    +23,                                                    +24,                                                    +25 +] +},                                                         +"contextSupport": true                                     +},                                                           +"hover"{ +"dynamicRegistration": false,                              +"contentFormat"[ +"markdown",                                              +"plaintext" +] +},                                                           +"rename"{ +"dynamicRegistration": false,                              +"prepareSupport": true                                     +},                                                           +"typeDefinition"{ +"linkSupport": true                                        +},                                                           +"publishDiagnostics"{ +"tagSupport"{ +"valueSet"[ +1,                                                     +2 +] +},                                                         +"relatedInformation": true                                 +} +},                                                             +"workspace"{ +"semanticTokens"{ +"refreshSupport": true                                     +},                                                           +"symbol"{ +"hierarchicalWorkspaceSymbolSupport": true,                +"dynamicRegistration": false,                              +"symbolKind"{ +"valueSet"[ +1,                                                     +2,                                                     +3,                                                     +4,                                                     +5,                                                     +6,                                                     +7,                                                     +8,                                                     +9,                                                     +10,                                                    +11,                                                    +12,                                                    +13,                                                    +14,                                                    +15,                                                    +16,                                                    +17,                                                    +18,                                                    +19,                                                    +20,                                                    +21,                                                    +22,                                                    +23,                                                    +24,                                                    +25,                                                    +26 +] +} +},                                                           +"applyEdit": true,                                           +"configuration": true,                                       +"workspaceFolders": true,                                    +"workspaceEdit"{ +"resourceOperations"[ +"rename",                                                +"create",                                                +"delete" +] +},                                                           +"didChangeWatchedFiles"{ +"dynamicRegistration": false,                              +"relativePatternSupport": true                             +} +} +} + + + + diff --git a/docs/images/record-example.svg b/docs/images/record-example.svg new file mode 100644 index 0000000..d097a43 --- /dev/null +++ b/docs/images/record-example.svg @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 16:39:50CLIENT{ +"params"{ +"position"{ +"character"26,                                                                                                                 +"line"34 +},                                                                                                                                 +"textDocument"{ +"uri""file:///var/home/alex/Projects/lsp-devtools/docs/index.rst" +} +},                                                                                                                                   +"method""textDocument/definition",                                                                                                 +"id"2,                                                                                                                             +"jsonrpc""2.0" +} +16:39:50SERVER{ +"id"2,                                                                                                                             +"jsonrpc""2.0",                                                                                                                    +"result"[ +{ +"uri""file:///var/home/alex/Projects/lsp-devtools/docs/pytest-lsp/guide/window-log-message-output.txt",                        +"range"{ +"start"{ +"line"0,                                                                                                                   +"character"0 +},                                                                                                                             +"end"{ +"line"1,                                                                                                                   +"character"0 +} +} +} +] +} + + + + diff --git a/docs/images/record-log-messages.svg b/docs/images/record-log-messages.svg new file mode 100644 index 0000000..c170c66 --- /dev/null +++ b/docs/images/record-log-messages.svg @@ -0,0 +1,357 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.directives' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.roles' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.rst.directives' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.rst.roles' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.autodoc' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.codeblocks' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.domains' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.directives' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.images' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.includes' +23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.roles' +23:00:12SERVERLog: [esbonio.lsp] User Config { +"buildDir""${confDir}/_build" +} +23:00:12SERVERLog: [esbonio.lsp] Workspace Folder: 'file:///var/home/alex/Projects/lsp-devtools' +23:00:13SERVERLog: [esbonio.lsp] Sphinx Args { +"buildername""html",                                                             +"confdir""/var/home/alex/Projects/lsp-devtools/docs",                            +"confoverrides"{},                                                               +"doctreedir""/var/home/alex/Projects/lsp-devtools/docs/_build/doctrees",         +"freshenv": false,                                                                 +"keep_going": false,                                                               +"outdir""/var/home/alex/Projects/lsp-devtools/docs/_build/html",                 +"parallel"1,                                                                     +"srcdir""/var/home/alex/Projects/lsp-devtools/docs",                             +"status": null,                                                                    +"tags"[],                                                                        +"verbosity"0,                                                                    +"warning": null,                                                                   +"warningiserror": false                                                            +} +23:00:13SERVERLog: Running Sphinx v6.2.1 +23:00:13SERVERLog: [esbonio.lsp] Traceback (most recent call last):                                +  File                                                                               +"/var/home/alex/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/registry +.py", line 442, in load_extension                                                    +    mod = import_module(extname) +          ^^^^^^^^^^^^^^^^^^^^^^                                                     +  File "/usr/lib64/python3.11/importlib/__init__.py", line 126, in import_module     +    return _bootstrap._gcd_import(name[level:], package, level) +           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                      +  File "<frozen importlib._bootstrap>", line 1204, in _gcd_import +  File "<frozen importlib._bootstrap>", line 1176, in _find_and_load +  File "<frozen importlib._bootstrap>", line 1140, in _find_and_load_unlocked        +ModuleNotFoundError: No module named 'sphinx_copybutton' + +The above exception was the direct cause of the following exception:                 + +Traceback (most recent call last):                                                   +  File "/var/home/alex/Projects/esbonio/lib/esbonio/esbonio/lsp/sphinx/__init__.py", +line 149, in _initialize_sphinx                                                      +    return self.create_sphinx_app(self.user_config)  # type: ignore                  +           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                                  +  File "/var/home/alex/Projects/esbonio/lib/esbonio/esbonio/lsp/sphinx/__init__.py", +line 343, in create_sphinx_app                                                       +    app = Sphinx(**self.sphinx_args) +          ^^^^^^^^^^^^^^^^^^^^^^^^^^                                                 +  File                                                                               +"/var/home/alex/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/applicat +ion.py", line 229, in __init__                                                       +self.setup_extension(extension) +  File                                                                               +"/var/home/alex/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/applicat +ion.py", line 404, in setup_extension                                                +self.registry.load_extension(self, extname) +  File                                                                               +"/var/home/alex/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/registry +.py", line 445, in load_extension                                                    +    raise ExtensionError(__('Could not import extension %s') % extname,              +sphinx.errors.ExtensionError: Could not import extension sphinx_copybutton           +(exception: No module named 'sphinx_copybutton') +23:00:13SERVERLog: [esbonio.lsp] Publishing 1 diagnostics for:                                     +file:///var/home/alex/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/re +gistry.py + + + + diff --git a/docs/images/tui-screenshot.svg b/docs/images/tui-screenshot.svg new file mode 100644 index 0000000..511ac0f --- /dev/null +++ b/docs/images/tui-screenshot.svg @@ -0,0 +1,294 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LSPInspector + + + + + + + + + + LSPInspector + Time          Source  ID  Method                          ▼ params + 1   16:46:08.867  client  1   initialize                      ├── uri = 'file:///var/home/alex/Projects/esbonio/.env/lib64 + 2   16:46:10.253  server  window/logMessage               └── ▼ diagnostics + 3   16:46:10.253  server  window/logMessage               └── ▼ 0 + 4   16:46:10.254  server  window/logMessage               ├── ▼ range + 5   16:46:10.254  server  window/logMessage               │   ├── ▼ start + 6   16:46:10.254  server  window/logMessage               │   │   ├── line = 444 + 7   16:46:10.254  server  window/logMessage               │   │   └── character = 0 + 8   16:46:10.255  server  window/logMessage               │   └── ▼ end + 9   16:46:10.255  server  window/logMessage               │   ├── line = 445 + 10  16:46:10.255  server  window/logMessage               │   └── character = 0 + 11  16:46:10.255  server  window/logMessage               ├── message = 'Could not import extension sphinx_cop + 12  16:46:10.256  server  window/logMessage               ├── severity = 1 + 13  16:46:10.270  server  1  └── source = 'conf.py' + 14  16:46:10.271  client  initialized                      + 15  16:46:10.271  client  textDocument/didOpen             + 16  16:46:10.277  server  window/logMessage                + 17  16:46:10.277  server  window/logMessage                + 18  16:46:10.466  server  window/logMessage                + 19  16:46:10.467  server  window/logMessage                + 20  16:46:10.782  server  window/logMessage                + 21  16:46:10.783  server  window/logMessage                + 22  16:46:10.786  server  textDocument/publishDiagnostics  + 23  16:46:10.795  server  esbonio/buildComplete            + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CTRL+C  Quit  CTRL+B  Sidebar  CTRL+S  Take Screenshot  + + + diff --git a/docs/index.rst b/docs/index.rst index 55a6b42..b274137 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,13 +4,25 @@ LSP Devtools The LSP Devtools project provides a number of tools that aim to make the process of developing language servers and clients easier. +lsp-devtools +------------ + +.. toctree:: + :hidden: + :caption: lsp-devtools + + lsp-devtools/guide + lsp-devtools/changelog + + +The `lsp-devtools `_ package provides a collection of CLI utilities that help inspect and visualise the interactions between a language client and a server. + +See the :doc:`lsp-devtools/guide/getting-started` guide for details. pytest-lsp ---------- .. toctree:: - :maxdepth: 1 - :glob: :hidden: :caption: pytest-lsp @@ -18,9 +30,10 @@ pytest-lsp pytest-lsp/reference pytest-lsp/changelog +`pytest-lsp `_ is a pytest plugin for writing end-to-end tests for language servers. - -``pytest-lsp`` is a pytest plugin for writing end-to-end tests for language servers. +.. literalinclude:: ./pytest-lsp/guide/window-log-message-output.txt + :language: none It works by running the language server in a subprocess and communicating with it over stdio, just like a real language client. This also means ``pytest-lsp`` can be used to test language servers written in any language - not just Python. diff --git a/docs/lsp-devtools/changelog.rst b/docs/lsp-devtools/changelog.rst new file mode 100644 index 0000000..9327b09 --- /dev/null +++ b/docs/lsp-devtools/changelog.rst @@ -0,0 +1,4 @@ +Changelog +========= + +.. include:: ../../lib/lsp-devtools/CHANGES.rst diff --git a/docs/lsp-devtools/guide.rst b/docs/lsp-devtools/guide.rst new file mode 100644 index 0000000..01be0ae --- /dev/null +++ b/docs/lsp-devtools/guide.rst @@ -0,0 +1,9 @@ +User Guide +---------- + +.. toctree:: + :maxdepth: 2 + + guide/getting-started + guide/record-command + guide/tui-command diff --git a/docs/lsp-devtools/guide/example-to-file-output.json b/docs/lsp-devtools/guide/example-to-file-output.json new file mode 100644 index 0000000..87a4408 --- /dev/null +++ b/docs/lsp-devtools/guide/example-to-file-output.json @@ -0,0 +1,27 @@ +{'jsonrpc': '2.0', 'id': 1, 'params': {'rootUri': 'file:///var/home/username/Projects/lsp-devtools', 'workspaceFolders': [{'uri': 'file:///var/home/username/Projects/lsp-devtools', 'name': '/var/home/username/Projects/lsp-devtools'}], 'capabilities': {'window': {'workDoneProgress': True, 'showMessage': {'messageActionItem': {'additionalPropertiesSupport': False}}, 'showDocument': {'support': True}}, 'workspace': {'semanticTokens': {'refreshSupport': True}, 'workspaceFolders': True, 'applyEdit': True, 'configuration': True, 'symbol': {'dynamicRegistration': False, 'symbolKind': {'valueSet': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]}, 'hierarchicalWorkspaceSymbolSupport': True}, 'didChangeWatchedFiles': {'relativePatternSupport': True, 'dynamicRegistration': False}, 'workspaceEdit': {'resourceOperations': ['rename', 'create', 'delete']}}, 'textDocument': {'typeDefinition': {'linkSupport': True}, 'definition': {'linkSupport': True}, 'signatureHelp': {'signatureInformation': {'activeParameterSupport': True, 'parameterInformation': {'labelOffsetSupport': True}, 'documentationFormat': ['markdown', 'plaintext']}, 'dynamicRegistration': False}, 'callHierarchy': {'dynamicRegistration': False}, 'declaration': {'linkSupport': True}, 'synchronization': {'didSave': True, 'willSave': True, 'dynamicRegistration': False, 'willSaveWaitUntil': True}, 'semanticTokens': {'requests': {'range': False, 'full': {'delta': True}}, 'formats': ['relative'], 'overlappingTokenSupport': True, 'dynamicRegistration': False, 'tokenTypes': ['namespace', 'type', 'class', 'enum', 'interface', 'struct', 'typeParameter', 'parameter', 'variable', 'property', 'enumMember', 'event', 'function', 'method', 'macro', 'keyword', 'modifier', 'comment', 'string', 'number', 'regexp', 'operator', 'decorator'], 'augmentsSyntaxTokens': True, 'tokenModifiers': ['declaration', 'definition', 'readonly', 'static', 'deprecated', 'abstract', 'async', 'modification', 'documentation', 'defaultLibrary'], 'multilineTokenSupport': False, 'serverCancelSupport': False}, 'references': {'dynamicRegistration': False}, 'documentHighlight': {'dynamicRegistration': False}, 'documentSymbol': {'dynamicRegistration': False, 'symbolKind': {'valueSet': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]}, 'hierarchicalDocumentSymbolSupport': True}, 'implementation': {'linkSupport': True}, 'completion': {'dynamicRegistration': False, 'insertTextMode': 1, 'completionList': {'itemDefaults': ['commitCharacters', 'editRange', 'insertTextFormat', 'insertTextMode', 'data']}, 'completionItemKind': {'valueSet': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]}, 'contextSupport': True, 'completionItem': {'resolveSupport': {'properties': ['documentation', 'detail', 'additionalTextEdits']}, 'insertReplaceSupport': True, 'snippetSupport': True, 'commitCharactersSupport': True, 'preselectSupport': True, 'deprecatedSupport': True, 'documentationFormat': ['markdown', 'plaintext'], 'insertTextModeSupport': {'valueSet': [1, 2]}, 'labelDetailsSupport': True, 'tagSupport': {'valueSet': [1]}}}, 'hover': {'contentFormat': ['markdown', 'plaintext'], 'dynamicRegistration': False}, 'publishDiagnostics': {'relatedInformation': True, 'tagSupport': {'valueSet': [1, 2]}}, 'codeAction': {'dynamicRegistration': False, 'isPreferredSupport': True, 'dataSupport': True, 'resolveSupport': {'properties': ['edit']}, 'codeActionLiteralSupport': {'codeActionKind': {'valueSet': ['', 'quickfix', 'refactor', 'refactor.extract', 'refactor.inline', 'refactor.rewrite', 'source', 'source.organizeImports']}}}, 'rename': {'prepareSupport': True, 'dynamicRegistration': False}}}, 'rootPath': '/var/home/username/Projects/lsp-devtools', 'processId': 24997, 'clientInfo': {'version': '0.9.1', 'name': 'Neovim'}, 'initializationOptions': {'server': {'logLevel': 'debug'}, 'sphinx': {'buildDir': '${confDir}/_build'}}, 'trace': 'off'}, 'method': 'initialize'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.directives'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.roles'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.rst.directives'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.rst.roles'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.autodoc'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.codeblocks'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.domains'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.directives'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.images'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.includes'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Loaded extension 'esbonio.lsp.sphinx.roles'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'id': 1, 'jsonrpc': '2.0', 'result': {'capabilities': {'textDocumentSync': {'openClose': True, 'change': 2, 'willSave': False, 'willSaveWaitUntil': False, 'save': True}, 'completionProvider': {'triggerCharacters': ['>', '.', ':', '`', '<', '/'], 'resolveProvider': True}, 'hoverProvider': True, 'definitionProvider': True, 'implementationProvider': {}, 'documentSymbolProvider': True, 'codeActionProvider': True, 'documentLinkProvider': {}, 'executeCommandProvider': {'commands': ['esbonio.server.build', 'esbonio.server.configuration', 'esbonio.server.preview']}, 'workspace': {'workspaceFolders': {'supported': True, 'changeNotifications': True}, 'fileOperations': {}}}, 'serverInfo': {'name': 'esbonio', 'version': '0.16.1'}}} +{'jsonrpc': '2.0', 'method': 'initialized', 'params': {}} +{'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': 'file:///var/home/username/Projects/lsp-devtools/docs/index.rst', 'text': 'LSP Devtools\n============\n\nThe LSP Devtools project provides a number of tools that aim to make the\nprocess of developing language servers and clients easier.\n\nlsp-devtools\n------------\n\n.. toctree::\n :hidden:\n :caption: lsp-devtools\n\n lsp-devtools/guide\n lsp-devtools/changelog\n\n\nThe `lsp-devtools `_ package provides a collection of CLI utilities that help inspect and visualise the interactions between a language client and a server.\n\nSee the :doc:`lsp-devtools/guide/getting-started` guide for details.\n\npytest-lsp\n----------\n\n.. toctree::\n :hidden:\n :caption: pytest-lsp\n\n pytest-lsp/guide\n pytest-lsp/reference\n pytest-lsp/changelog\n\n`pytest-lsp `_ is a pytest plugin for writing end-to-end tests for language servers.\n\n.. literalinclude:: ./pytest-lsp/guide/window-log-message-output.txt\n :language: none\n\nIt works by running the language server in a subprocess and communicating with it over stdio, just like a real language client.\nThis also means ``pytest-lsp`` can be used to test language servers written in any language - not just Python.\n\n``pytest-lsp`` relies on `pygls `__ for its language server protocol implementation.\n\nSee the :doc:`pytest-lsp/guide/getting-started` guide for details on how to write your first test case.\n', 'languageId': 'rst', 'version': 0}}} +{'params': {'type': 4, 'message': '[esbonio.lsp] User Config {\n "buildDir": "${confDir}/_build"\n}'}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': "[esbonio.lsp] Workspace Folder: 'file:///var/home/username/Projects/lsp-devtools'"}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': '[esbonio.lsp] Sphinx Args {\n "buildername": "html",\n "confdir": "/var/home/username/Projects/lsp-devtools/docs",\n "confoverrides": {},\n "doctreedir": "/var/home/username/Projects/lsp-devtools/docs/_build/doctrees",\n "freshenv": false,\n "keep_going": false,\n "outdir": "/var/home/username/Projects/lsp-devtools/docs/_build/html",\n "parallel": 1,\n "srcdir": "/var/home/username/Projects/lsp-devtools/docs",\n "status": null,\n "tags": [],\n "verbosity": 0,\n "warning": null,\n "warningiserror": false\n}'}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': 'Running Sphinx v6.2.1'}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': '[esbonio.lsp] Traceback (most recent call last):\n File "/var/home/username/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/registry.py", line 442, in load_extension\n mod = import_module(extname)\n ^^^^^^^^^^^^^^^^^^^^^^\n File "/usr/lib64/python3.11/importlib/__init__.py", line 126, in import_module\n return _bootstrap._gcd_import(name[level:], package, level)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File "", line 1204, in _gcd_import\n File "", line 1176, in _find_and_load\n File "", line 1140, in _find_and_load_unlocked\nModuleNotFoundError: No module named \'sphinx_copybutton\'\n\nThe above exception was the direct cause of the following exception:\n\nTraceback (most recent call last):\n File "/var/home/username/Projects/esbonio/lib/esbonio/esbonio/lsp/sphinx/__init__.py", line 149, in _initialize_sphinx\n return self.create_sphinx_app(self.user_config) # type: ignore\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File "/var/home/username/Projects/esbonio/lib/esbonio/esbonio/lsp/sphinx/__init__.py", line 343, in create_sphinx_app\n app = Sphinx(**self.sphinx_args)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^\n File "/var/home/username/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/application.py", line 229, in __init__\n self.setup_extension(extension)\n File "/var/home/username/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/application.py", line 404, in setup_extension\n self.registry.load_extension(self, extname)\n File "/var/home/username/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/registry.py", line 445, in load_extension\n raise ExtensionError(__(\'Could not import extension %s\') % extname,\nsphinx.errors.ExtensionError: Could not import extension sphinx_copybutton (exception: No module named \'sphinx_copybutton\')'}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'type': 4, 'message': '[esbonio.lsp] Publishing 1 diagnostics for: file:///var/home/username/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/registry.py'}, 'method': 'window/logMessage', 'jsonrpc': '2.0'} +{'params': {'uri': 'file:///var/home/username/Projects/esbonio/.env/lib64/python3.11/site-packages/sphinx/registry.py', 'diagnostics': [{'range': {'start': {'line': 444, 'character': 0}, 'end': {'line': 445, 'character': 0}}, 'message': 'Could not import extension sphinx_copybutton', 'severity': 1, 'source': 'conf.py'}]}, 'method': 'textDocument/publishDiagnostics', 'jsonrpc': '2.0'} +{'method': 'esbonio/buildComplete', 'jsonrpc': '2.0', 'params': {'config': {'sphinx': {'buildDir': '/var/home/username/Projects/lsp-devtools/docs/_build/html', 'confDir': '/var/home/username/Projects/lsp-devtools/docs', 'doctreeDir': '/var/home/username/Projects/lsp-devtools/docs/_build/doctrees', 'srcDir': '/var/home/username/Projects/lsp-devtools/docs', 'command': ['sphinx-build', '-M', 'html', '/var/home/username/Projects/lsp-devtools/docs', '/var/home/username/Projects/lsp-devtools/docs/_build/html', '-d', '/var/home/username/Projects/lsp-devtools/docs/_build/doctrees'], 'version': '6.2.1'}, 'server': {'logLevel': 'debug'}}, 'error': True, 'warnings': 0}} +{'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'contentChanges': [{'text': '\n\n', 'range': {'start': {'character': 58, 'line': 4}, 'end': {'character': 0, 'line': 5}}, 'rangeLength': 1}], 'textDocument': {'uri': 'file:///var/home/username/Projects/lsp-devtools/docs/index.rst', 'version': 4}}} +{'jsonrpc': '2.0', 'id': 2, 'method': 'shutdown'} +{'id': 2, 'jsonrpc': '2.0', 'result': None} +{'jsonrpc': '2.0', 'method': 'exit'} diff --git a/docs/lsp-devtools/guide/getting-started.rst b/docs/lsp-devtools/guide/getting-started.rst new file mode 100644 index 0000000..6e8aace --- /dev/null +++ b/docs/lsp-devtools/guide/getting-started.rst @@ -0,0 +1,105 @@ +Getting Started +=============== + +.. highlight:: none + +This guide will introduce you to the tools available in the ``lsp-devtools`` package. +If you have not done so already, you can install it using ``pipx`` :: + + pipx install lsp-devtools + +.. admonition:: Did you say pipx? + + `pipx `_ is a tool that automates the process of installing Python packages into their own isolated Python environments - useful for standalone applications like ``lsp-devtools`` + +The LSP Agent +------------- + +In order to use most of the tools in ``lsp-devtools`` you need to wrap your language server with the LSP Agent. +The agent is a simple program that sits inbetween a language client and the server as shown in the diagram below. + +.. figure:: /images/lsp-devtools-architecture.svg + + ``lsp-devtools`` architecture + +The agent acts as a messenger, forwarding messages from the client to the server and vice versa. +However, it sends an additional copy of each message over a local TCP connection to some "Server" application - typically another ``lsp-devtools`` command like ``record`` or ``tui``. + +In general, using ``lsp-devtools`` can be broken down into a 3 step process. + +#. Configure your language client to launch your language server via the agent, rather than launching it directly. + +#. Start the server application e.g. ``lsp-devtools record`` or ``lsp-devtools tui`` + +#. Start your language client. + +.. _lsp-devtools-configure-client: + +Configuring your client +^^^^^^^^^^^^^^^^^^^^^^^ + +In order to wrap your language server with the LSP Agent, you need to be able to modify the command your language client uses to start your language server to the following:: + + lsp-devtools agent -- + +The ``agent`` command will interpret anything given after the double dashes (``--``) to be the command used to invoke your language server. +By default, the agent will attempt to connect to a server application on ``localhost:8765`` but this can be changed using the ``--host `` and ``--port `` arguments:: + + lsp-devtools agent --host 127.0.0.1 --port 1234 -- + +.. tip:: + + Since the agent only requires your server's start command, you can use ``lsp-devtools`` with a server written in any language. + + +As an example, let's configure Neovim to launch the ``esbonio`` language server via the agent. +Using `nvim-lspconfig `_ a standard configuration might look something like the following + +.. code-block:: lua + + lspconfig.esbonio.setup{ + capabilities = capabilities, + cmd = { "esbonio" }, + filetypes = {"rst"}, + init_options = { + server = { + logLevel = "debug" + }, + sphinx = { + buildDir = "${confDir}/_build" + } + }, + on_attach = on_attach, + } + +To update this to launch the server via the agent, we need only modify the ``cmd`` field (or add one if it does not exist) to include ``lsp-devtools agent --`` + +.. code-block:: diff + + lspconfig.esbonio.setup{ + capabilities = capabilities, + - cmd = { "esbonio" }, + + cmd = { "lsp-devtools", "agent", "--", "esbonio" }, + ... + } + +Server Applications +------------------- + +Once you have your client configured, you need to start the application the agent is going to try to connect to. +Currently ``lsp-devtools`` provides the following applications + +``lsp-devtools record`` + As the name suggests, this command supports recording all (or a subset of) messages in a LSP session to a text file or SQLite database. + However, it can also print these messages direct to the console with support for filtering and custom formatting of message contents. + + .. figure:: /images/record-example.svg + + See :doc:`/lsp-devtools/guide/record-command` for details + +``lsp-devtools tui`` + An interactive terminal application, powered by `textual `_. + + .. figure:: /images/tui-screenshot.svg + + See :doc:`/lsp-devtools/guide/tui-command` for details diff --git a/docs/lsp-devtools/guide/record-command.rst b/docs/lsp-devtools/guide/record-command.rst new file mode 100644 index 0000000..b195ced --- /dev/null +++ b/docs/lsp-devtools/guide/record-command.rst @@ -0,0 +1,315 @@ +Recording Sessions +================== + +.. important:: + + This guide assumes that you have already :ref:`configured your client ` to wrap your language server with the LSP Agent. + +.. highlight:: none + +.. program:: lsp-devtools record + +The ``lsp-devtools record`` command can be used to either record an LSP session to a file, SQLite database or print the received messages direct to the console. +Running the ``lsp-devtools record`` command you should see a message like the following:: + + $ lsp-devtools record + Waiting for connection on localhost:8765... + +once the agent connects, the record command will by default, start printing all LSP messages to the console, with the JSON contents pretty printed. + +.. figure:: /images/record-example.svg + + +Example Commands +---------------- + +Here are some example usages of the ``record`` command that you may find useful. + +**Capture the client's capabilities** + +The following command will only capture and show the ``ClientCapabilities`` sent during the ``initialize`` request - useful for :ref:`adding clients to pytest-lsp `! 😉 + +:: + + lsp-devtools record -f "{.params.clientInfo.name} v{.params.clientInfo.version}\\n{.params.capabilities}" + +.. figure:: /images/record-client-capabilities.svg + :figclass: scrollable-svg + + +**Format and show any window/logMessages** + +This can be used to replicate the ``Output`` log panel in VSCode in editors that do not provide a similar facility. + +:: + + lsp-devtools record -f "{.params.type|MessageType}: {.params.message}" + +.. figure:: /images/record-log-messages.svg + :figclass: scrollable-svg + +Read on for a comprehensive overview of all the available command line options. + +Connection Options +------------------ + +By default, the LSP agent and other commands will attempt to connect to each other on ``localhost:8765``. +The following options can be used to change this behavior + +.. option:: --host + + The host to bind to. + +.. option:: -p , --port + + The port number to open the connection on. + + +Alternate Destinations +---------------------- + +As well as printing to console, the record command supports a number of other output destinations. + +.. option:: --to-file + + Saves all collected messages to a plain text file with each line representing a complete JSON-RPC message:: + + lsp-devtools record --to-file example.json + + See :download:`here <./example-to-file-output.json>` for example of the output produced by this command. + +.. option:: --to-sqlite + + Save messages to a SQLite database:: + + lsp-devtools record --to-sqlite example.db + + This database can then be opened in other tools like `datasette `_, `SQLite Browser `_ or even ``lsp-devtools`` own :doc:`/lsp-devtools/guide/tui-command`. + + .. dropdown:: DB Schema + + Here is the schema currently used by ``lsp-devtools``. + **Note:** Except perhaps the base ``protocol`` table, this schema is not stable and may change between ``lsp-devtools`` releases. + + .. literalinclude:: ../../../lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql + :language: sql + +.. option:: --save-output + + Print to console as normal but additionally, the ouput will be saved into a text file using the + `export `__ + feature of rich's ``Console`` object:: + + lsp-devtools record --save-output filename.{html,svg,txt} + + Depending on the file extension used, this will save the output as plain text or rendered as an SVG image or HTML webpage - useful for generating screenshots for your documentation! + +Filtering Messages +------------------ + +Once it gets going, the LSP protocol can generate *a lot* of messages! +To help you focus on the messages you are interested in the ``record`` command provides the following options for selecting a subset of messages to show. + +.. option:: --message-source + + The following values are accepted + + ``client`` + Only show messages sent from the client + + ``server`` + Only show messages sent from the server + + ``both`` (the default) + Show message sent from both client and server + +.. option:: --include-message-type + + Only show messages of the given type. + This option can be used more than once to select multiple message types. + The following values are accepted + + ``request`` + Show only JSON-RPC request messages + + ``response`` + Show only JSON-RPC response messages, matches responses containing either successful results or error codes. + + ``result`` + Show only JSON-RPC response messages containing successful results + + ``error`` + Show only JSON-RPC response messages that contain errors. + + ``notification`` + Show only JSON-RPC notification messages + +.. option:: --include-method + + Only show messages with the given method name. + This option can be used more than once to select multiple methods. + +.. option:: --exclude-message-type + + Like :option:`--include-message-type`, but omit matches rather than showing them + +.. option:: --exclude-method + + Like :option:`--include-method`, but omit matches rather than showing them + +If multiple options from this list are used, they will be ANDed together, for example:: + + lsp-devtools record --message-source client \ + --include-message-type request \ + --include-message-type notification + +will only show requests or notifications that have been sent by the client. + +Formatting messages +------------------- + +.. note:: + + These options do not apply when using the :option:`--to-sqlite` option. + + +.. option:: -f , --format-message + + Set the format string to use when formatting messages. + By default, the ``record`` command will simply print the JSON contents of a message however, you can supply a custom format string to use instead. + + .. tip:: + + Format strings are also a powerful filtering mechanism! - any messages that do not fit with the supplied format will not be shown + + Format strings use the following syntax + + .. admonition:: Feedback Wanted! + + We're looking for feedback on this syntax, especially when it comes to formatting lists of items. + Let us know by `opening an issue `_ if you have any thoughts or suggested improvements + + + Similar to Python's :ref:`python:formatstrings` a pair of braces (``{}``) denote a placeholder where a value can be inserted. + Inside the braces you can then select and the message field you want to be inserted using a dot-separated syntax that should feel familiar if you've ever used `jq `_:: + + Message: + { + "method": "textDocument/completion", + "params": { + "position": {"line": 1, "character": 2}, + "textDocument": {"uri": "file:///path/to/file.txt"}, + } + } + + Format String: + "{.params.position.line}:{.params.position.character}" + + Result: + 1:2 + + The pipe symbol (``|``) can be used to pass the selected field to a formatter e.g. ``Position``:: + + Message: + { + "method": "textDocument/completion", + "params": { + "position": {"line": 1, "character": 2}, + "textDocument": {"uri": "file:///path/to/file.txt"}, + } + } + + Format String: + "{.params.position|Position}" + + Result: + 1:2 + + See :ref:`lsp-devtools-record-formatters` for details on all available formatters. + Fields that contain an array of items can be accessed with square brackets (``[]``), by default items in an array will be separated by newlines when formatted:: + + Message: + { + "result": { + "items": [{"label": "one"}, {"label": "two"}, {"label": "three"}] + } + } + + Format String: + "{.result.items[].label}" + + Result: + one + two + three + + However, you can specify a custom separator inside the brackets:: + + Message: + { + "result": { + "items": [{"label": "one"}, {"label": "two"}, {"label": "three"}] + } + } + + Format String: + "{.result.items[\n- ].label}" + + Result: + - one + - two + - three + + The brackets also support Python's standard list indexing rules:: + + Message: + { + "result": { + "items": [{"label": "one"}, {"label": "two"}, {"label": "three"}] + } + } + + Format String: Result: + "{.result.items[0].label}" one + "{.result.items[-1].label}" three + "{.result.items[0:2].label}" "one\ntwo" + + Finally, if you want to supply an index *and* adjust the separator you can separate them with the ``#`` symbol:: + + Message: + { + "result": { + "items": [{"label": "one"}, {"label": "two"}, {"label": "three"}] + } + } + + Format String: + "{.result.items[0:2#\n- ].label}" + + Result: + - one + - two + +.. _lsp-devtools-record-formatters: + +Formatters +^^^^^^^^^^ + +``lsp-devtools`` knows how to format the following LSP Types + +``Position`` + ``{"line": 1, "character": 2}`` will be rendered as ``1:2`` + +``Range`` + ``{"start": {"line": 1, "character": 2}, "end": {"line": 3, "character": 4}}`` will be rendered as ``1:2-3:4`` + +Additionally, any enum type can be used as a formatter in which case a number will be replaced with the corresponding name, for example:: + + Format String: + "{.type|MessageType}" + + Value: Result: + 1 Error + 2 Warning + 3 Info + 4 Log diff --git a/docs/lsp-devtools/guide/tui-command.rst b/docs/lsp-devtools/guide/tui-command.rst new file mode 100644 index 0000000..b482117 --- /dev/null +++ b/docs/lsp-devtools/guide/tui-command.rst @@ -0,0 +1,2 @@ +TUI Application +=============== diff --git a/docs/pytest-lsp/guide/client-capabilities.rst b/docs/pytest-lsp/guide/client-capabilities.rst index b11dd9f..301144a 100644 --- a/docs/pytest-lsp/guide/client-capabilities.rst +++ b/docs/pytest-lsp/guide/client-capabilities.rst @@ -10,6 +10,8 @@ field which is used to inform the server which parts of the specification the cl Setting this field to the right value ``pytest-lsp`` can pretend to be a particular editor at a particular version and check to see if the server adapts accordingly. +.. _pytest-lsp-supported-clients: + Supported Clients ----------------- diff --git a/docs/pytest-lsp/guide/getting-started.rst b/docs/pytest-lsp/guide/getting-started.rst index 947bb6d..d8a92c4 100644 --- a/docs/pytest-lsp/guide/getting-started.rst +++ b/docs/pytest-lsp/guide/getting-started.rst @@ -1,8 +1,14 @@ Getting Started =============== +.. highlight:: none + This guide will walk you through the process of writing your first test case using ``pytest-lsp``. +If you have not done so already, you can install the ``pytest-lsp`` package using pip:: + + pip install pytest-lsp + A Simple Language Server ------------------------ @@ -42,7 +48,6 @@ With the framework in place, we can go ahead and define our first test case All that's left is to run the test suite! .. literalinclude:: ./getting-started-fail-output.txt - :language: none We forgot to start the server! Add the following to the bottom of ``server.py``. diff --git a/docs/pytest-lsp/guide/language-client.rst b/docs/pytest-lsp/guide/language-client.rst index 6a0aa06..1c35ee7 100644 --- a/docs/pytest-lsp/guide/language-client.rst +++ b/docs/pytest-lsp/guide/language-client.rst @@ -49,39 +49,9 @@ Any :lsp:`window/logMessage` notifications sent from the server will be accessib :start-at: @server.feature :end-at: return items -If a test case fails ``pytest-lsp`` will also include any captured log messages in the error report:: - - ================================== test session starts ==================================== - platform linux -- Python 3.11.2, pytest-7.2.0, pluggy-1.0.0 - rootdir: /..., configfile: tox.ini - plugins: typeguard-2.13.3, asyncio-0.20.2, lsp-0.2.1 - asyncio: mode=Mode.AUTO - collected 1 item - - test_server.py F [100%] - - ======================================== FAILURES ========================================= - ____________________________________ test_completions _____________________________________ - - client = - ... - E assert False - - test_server.py:35: AssertionError - ---------------------------- Captured window/logMessages call ----------------------------- - LOG: Suggesting item 0 - LOG: Suggesting item 1 - LOG: Suggesting item 2 - LOG: Suggesting item 3 - LOG: Suggesting item 4 - LOG: Suggesting item 5 - LOG: Suggesting item 6 - LOG: Suggesting item 7 - LOG: Suggesting item 8 - LOG: Suggesting item 9 - ================================ short test summary info ================================== - FAILED test_server.py::test_completions - assert False - =================================== 1 failed in 1.02s ===================================== +If a test case fails ``pytest-lsp`` will also include any captured log messages in the error report + +.. literalinclude:: ./window-log-message-output.txt ``window/showDocument`` ----------------------- diff --git a/docs/pytest-lsp/guide/window-log-message-output.txt b/docs/pytest-lsp/guide/window-log-message-output.txt new file mode 100644 index 0000000..bd7a0d4 --- /dev/null +++ b/docs/pytest-lsp/guide/window-log-message-output.txt @@ -0,0 +1,31 @@ +================================== test session starts ==================================== +platform linux -- Python 3.11.2, pytest-7.2.0, pluggy-1.0.0 +rootdir: /..., configfile: tox.ini +plugins: typeguard-2.13.3, asyncio-0.20.2, lsp-0.2.1 +asyncio: mode=Mode.AUTO +collected 1 item + +test_server.py F [100%] + +======================================== FAILURES ========================================= +____________________________________ test_completions _____________________________________ + +client = + ... +E assert False + +test_server.py:35: AssertionError +---------------------------- Captured window/logMessages call ----------------------------- + LOG: Suggesting item 0 + LOG: Suggesting item 1 + LOG: Suggesting item 2 + LOG: Suggesting item 3 + LOG: Suggesting item 4 + LOG: Suggesting item 5 + LOG: Suggesting item 6 + LOG: Suggesting item 7 + LOG: Suggesting item 8 + LOG: Suggesting item 9 +================================ short test summary info ================================== +FAILED test_server.py::test_completions - assert False +=================================== 1 failed in 1.02s ===================================== From 0e1521ce1d14c166004817e7b5de80d62abc12e7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 2 Jul 2023 22:25:37 +0000 Subject: [PATCH 25/63] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/images/lsp-devtools-architecture.svg | 6 +++--- docs/images/record-client-capabilities.svg | 4 ++-- docs/images/record-example.svg | 4 ++-- docs/images/record-log-messages.svg | 4 ++-- docs/images/tui-screenshot.svg | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/images/lsp-devtools-architecture.svg b/docs/images/lsp-devtools-architecture.svg index 95e5411..58d2267 100644 --- a/docs/images/lsp-devtools-architecture.svg +++ b/docs/images/lsp-devtools-architecture.svg @@ -1,6 +1,6 @@ - + - + - Language ClientLanguage ServerAgentAgent Serverstdinstdinstdoutstdouttcp \ No newline at end of file + Language ClientLanguage ServerAgentAgent Serverstdinstdinstdoutstdouttcp diff --git a/docs/images/record-client-capabilities.svg b/docs/images/record-client-capabilities.svg index cca9a7c..9108529 100644 --- a/docs/images/record-client-capabilities.svg +++ b/docs/images/record-client-capabilities.svg @@ -988,9 +988,9 @@ - + - + 22:59:30CLIENTNeovim v0.9.1 { diff --git a/docs/images/record-example.svg b/docs/images/record-example.svg index d097a43..564b19a 100644 --- a/docs/images/record-example.svg +++ b/docs/images/record-example.svg @@ -149,9 +149,9 @@ - + - + 16:39:50CLIENT{ "params"{ diff --git a/docs/images/record-log-messages.svg b/docs/images/record-log-messages.svg index c170c66..55a89d2 100644 --- a/docs/images/record-log-messages.svg +++ b/docs/images/record-log-messages.svg @@ -273,9 +273,9 @@ - + - + 23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.directives' 23:00:12SERVERLog: [esbonio.lsp] Loaded extension 'esbonio.lsp.roles' diff --git a/docs/images/tui-screenshot.svg b/docs/images/tui-screenshot.svg index 511ac0f..690b7e4 100644 --- a/docs/images/tui-screenshot.svg +++ b/docs/images/tui-screenshot.svg @@ -228,7 +228,7 @@ - + From 7c8725865b3c83ce786133ee981d30366cd191fb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 17 Jul 2023 21:44:08 +0000 Subject: [PATCH 26/63] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.3.0 → 23.7.0](https://github.com/psf/black/compare/23.3.0...23.7.0) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 42351dc..3d515b1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.7.0 hooks: - id: black exclude: 'lib/pytest-lsp/pytest_lsp/gen.py' From 4b7621dc2851461d56874e1121a3e47e9918f497 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 31 Jul 2023 21:56:15 +0000 Subject: [PATCH 27/63] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/PyCQA/flake8: 6.0.0 → 6.1.0](https://github.com/PyCQA/flake8/compare/6.0.0...6.1.0) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3d515b1..cf9e8c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: exclude: 'lib/pytest-lsp/pytest_lsp/gen.py' - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 args: [--config=lib/lsp-devtools/setup.cfg] From 45405b3383bfc019abe2d951fe3cd9596c55ae47 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Thu, 6 Jul 2023 00:08:05 +0100 Subject: [PATCH 28/63] nix: Update flake.lock --- lib/pytest-lsp/flake.lock | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/pytest-lsp/flake.lock b/lib/pytest-lsp/flake.lock index f14cad0..5b38e8d 100644 --- a/lib/pytest-lsp/flake.lock +++ b/lib/pytest-lsp/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1680273054, - "narHash": "sha256-Bs6/5LpvYp379qVqGt9mXxxx9GSE789k3oFc+OAL07M=", + "lastModified": 1688556768, + "narHash": "sha256-mhd6g0iJGjEfOr3+6mZZOclUveeNr64OwxdbNtLc8mY=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3364b5b117f65fe1ce65a3cdd5612a078a3b31e3", + "rev": "27bd67e55fe09f9d68c77ff151c3e44c4f81f7de", "type": "github" }, "original": { @@ -22,13 +22,31 @@ "utils": "utils" } }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, "utils": { + "inputs": { + "systems": "systems" + }, "locked": { - "lastModified": 1678901627, - "narHash": "sha256-U02riOqrKKzwjsxc/400XnElV+UtPUQWpANPlyazjH0=", + "lastModified": 1687709756, + "narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=", "owner": "numtide", "repo": "flake-utils", - "rev": "93a2b84fc4b70d9e089d029deacc3583435c2ed6", + "rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7", "type": "github" }, "original": { From 8614d6255faec9f608f9fffcf2ac8af68b76ae92 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Thu, 6 Jul 2023 00:08:23 +0100 Subject: [PATCH 29/63] nix: Switch to git snapshot of pygls for now --- lib/pytest-lsp/nix/pytest-lsp-overlay.nix | 32 +++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/pytest-lsp/nix/pytest-lsp-overlay.nix b/lib/pytest-lsp/nix/pytest-lsp-overlay.nix index 31242ad..9468b69 100644 --- a/lib/pytest-lsp/nix/pytest-lsp-overlay.nix +++ b/lib/pytest-lsp/nix/pytest-lsp-overlay.nix @@ -1,13 +1,41 @@ final: prev: { pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [( python-final: python-prev: { + + # TODO: Remove once https://github.com/NixOS/nixpkgs/pull/233870 is merged + typeguard = python-prev.typeguard.overridePythonAttrs (oldAttrs: rec { + version = "3.0.2"; + format = "pyproject"; + + src = prev.fetchPypi { + inherit version; + pname = oldAttrs.pname; + sha256 = "sha256-/uUpf9so+Onvy4FCte4hngI3VQnNd+qdJwta+CY1jVo="; + }; + + propagatedBuildInputs = with python-prev; [ + importlib-metadata + typing-extensions + ]; + + }); + + pygls = python-prev.pygls.overridePythonAttrs (_: { + src = prev.fetchFromGitHub { + owner = "openlawlibrary"; + repo = "pygls"; + rev = "main"; + hash = "sha256-KjnuGQy3/YBSZyXYNWz4foUsFRbinujGxCkQjRSK4PE="; + }; + }); + pytest-lsp = python-prev.buildPythonPackage { pname = "pytest-lsp"; - version = "0.2.1"; + version = "0.3.0"; src = ./..; - propagatedBuildInputs = with python-prev; [ + propagatedBuildInputs = with python-final; [ pygls pytest pytest-asyncio From 853dc92d9a823df37076d9ead0859349b433c329 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Thu, 6 Jul 2023 00:08:52 +0100 Subject: [PATCH 30/63] nix: Better devShell definition Rather than installing the `pytest-lsp` plugin (and therefore requiring a rebuild anytime the source code changes) we just put the working directory on the `PYTHONPATH` Also drop Python 3.7 from the matrix --- lib/pytest-lsp/flake.nix | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/pytest-lsp/flake.nix b/lib/pytest-lsp/flake.nix index d847fa3..0812115 100644 --- a/lib/pytest-lsp/flake.nix +++ b/lib/pytest-lsp/flake.nix @@ -20,18 +20,18 @@ let pkgs = import nixpkgs { inherit system; overlays = [ pytest-lsp-overlay ]; }; in - eachPythonVersion [ "37" "38" "39" "310" "311" ] (pyVersion: - - - let - pytest-lsp = pkgs."python${pyVersion}Packages".pytest-lsp.overridePythonAttrs (_: { doCheck = false; }); - in - + eachPythonVersion [ "38" "39" "310" "311" ] (pyVersion: with pkgs; mkShell { name = "py${pyVersion}"; + shellHook = '' + export PYTHONPATH="./:$PYTHONPATH" + ''; + packages = with pkgs."python${pyVersion}Packages"; [ - pytest-lsp + pygls + pytest + pytest-asyncio ]; } ) From b99e8d750c53d1b0e771817e42a6d81ede3063bd Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 24 Jul 2023 13:01:07 +0100 Subject: [PATCH 31/63] nix: Initial top-level flake definition --- flake.lock | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 13 ++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..8d0a4ab --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1688585123, + "narHash": "sha256-+xFOB4WaRUHuZI7H1tWHTrwY4BnbPmh8M1n/XhPRH0w=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "23de9f3b56e72632c628d92b71c47032e14a3d4d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "utils": "utils" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1687709756, + "narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..1c335ce --- /dev/null +++ b/flake.nix @@ -0,0 +1,13 @@ +{ + description = "Developer tooling for language servers"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, utils }: + { + overlays.default = import ./lib/pytest-lsp/nix/pytest-lsp-overlay.nix; + }; +} From ea82512e2c90f9df28bd2be30bbe243195e5bb50 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Thu, 10 Aug 2023 20:31:40 +0100 Subject: [PATCH 32/63] pytest-lsp: Update tox definition, enable coverage Pull pygls from the `main` branch for now. --- lib/pytest-lsp/.gitignore | 1 + lib/pytest-lsp/pyproject.toml | 22 ++++++++-------------- lib/pytest-lsp/tox.ini | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 14 deletions(-) create mode 100644 lib/pytest-lsp/.gitignore create mode 100644 lib/pytest-lsp/tox.ini diff --git a/lib/pytest-lsp/.gitignore b/lib/pytest-lsp/.gitignore new file mode 100644 index 0000000..6350e98 --- /dev/null +++ b/lib/pytest-lsp/.gitignore @@ -0,0 +1 @@ +.coverage diff --git a/lib/pytest-lsp/pyproject.toml b/lib/pytest-lsp/pyproject.toml index 29fec5a..7325ae0 100644 --- a/lib/pytest-lsp/pyproject.toml +++ b/lib/pytest-lsp/pyproject.toml @@ -58,6 +58,14 @@ typecheck = [ [tool.setuptools.packages.find] include = ["pytest_lsp*"] +[tool.coverage.run] +source_pkgs = ["pytest_lsp"] + +[tool.coverage.report] +show_missing = true +skip_covered = true +sort = "Cover" + [tool.isort] force_single_line = true profile = "black" @@ -111,17 +119,3 @@ showcontent = true directory = "removed" name = "Removed" showcontent = true - - -[tool.tox] -legacy_tox_ini = """ -[tox] -isolated_build = True -skip_missing_interpreters = true -envlist = py{37,38,39,310,311} - -[testenv] -extras= dev -commands = - pytest {posargs} -""" diff --git a/lib/pytest-lsp/tox.ini b/lib/pytest-lsp/tox.ini new file mode 100644 index 0000000..7bf9df9 --- /dev/null +++ b/lib/pytest-lsp/tox.ini @@ -0,0 +1,19 @@ +[tox] +isolated_build = true +skip_missing_interpreters = true +min_version = 4.0 +envlist = py{38,39,310,311,312} + +[testenv] +description = "Run pytest-lsp's test suite" +package = wheel +wheel_build_env = .pkg +deps = + coverage[toml] + git+https://github.com/openlawlibrary/pygls +commands_pre = + coverage erase +commands = + coverage run -m pytest {posargs} +commands_post = + coverage report From eee8b4a8d87b70d26114329093e1263f9ab1d9ad Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Thu, 10 Aug 2023 20:36:36 +0100 Subject: [PATCH 33/63] pytest-lsp: Drop python 3.7, support python 3.12 --- .github/workflows/pytest-lsp-pr.yml | 5 +++-- lib/pytest-lsp/changes/75.misc.rst | 1 + lib/pytest-lsp/pyproject.toml | 3 +-- 3 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 lib/pytest-lsp/changes/75.misc.rst diff --git a/.github/workflows/pytest-lsp-pr.yml b/.github/workflows/pytest-lsp-pr.yml index 116aa12..c861a78 100644 --- a/.github/workflows/pytest-lsp-pr.yml +++ b/.github/workflows/pytest-lsp-pr.yml @@ -13,7 +13,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] os: [ubuntu-latest] steps: @@ -23,6 +23,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - run: | python --version @@ -43,7 +44,7 @@ jobs: cd lib/pytest-lsp version=$(echo ${{ matrix.python-version }} | tr -d .) - python -m tox -e `tox -l | grep $version | tr '\n' ','` + python -m tox run -f "py${version}" name: Test - name: Package diff --git a/lib/pytest-lsp/changes/75.misc.rst b/lib/pytest-lsp/changes/75.misc.rst new file mode 100644 index 0000000..99f8272 --- /dev/null +++ b/lib/pytest-lsp/changes/75.misc.rst @@ -0,0 +1 @@ +Drop support for Python 3.7, add support for Python 3.12 diff --git a/lib/pytest-lsp/pyproject.toml b/lib/pytest-lsp/pyproject.toml index 7325ae0..25b87e8 100644 --- a/lib/pytest-lsp/pyproject.toml +++ b/lib/pytest-lsp/pyproject.toml @@ -7,7 +7,7 @@ name = "pytest-lsp" version = "0.3.0" description = "pytest plugin for end-to-end testing of language servers" readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.8" license = { text = "MIT" } authors = [{ name = "Alex Carney", email = "alcarneyme@gmail.com" }] classifiers = [ @@ -17,7 +17,6 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", From 4b281c0cd05f158e8d3faf750f171e07547b0e42 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Thu, 10 Aug 2023 20:41:22 +0100 Subject: [PATCH 34/63] pytest-lsp: Passthrough requested fixtures to user fixture func --- docs/pytest-lsp/guide/fixtures.rst | 11 ++++ lib/pytest-lsp/changes/71.enhancement.rst | 1 + lib/pytest-lsp/pytest_lsp/plugin.py | 37 +++++++++++-- .../examples/fixture-passthrough/server.py | 18 +++++++ .../examples/fixture-passthrough/t_server.py | 52 +++++++++++++++++++ lib/pytest-lsp/tests/test_examples.py | 1 + 6 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 lib/pytest-lsp/changes/71.enhancement.rst create mode 100644 lib/pytest-lsp/tests/examples/fixture-passthrough/server.py create mode 100644 lib/pytest-lsp/tests/examples/fixture-passthrough/t_server.py diff --git a/docs/pytest-lsp/guide/fixtures.rst b/docs/pytest-lsp/guide/fixtures.rst index 81546d6..3e5cfd0 100644 --- a/docs/pytest-lsp/guide/fixtures.rst +++ b/docs/pytest-lsp/guide/fixtures.rst @@ -13,3 +13,14 @@ This can be used to run the same set of tests while pretending to be a different :language: python :start-at: @pytest_lsp.fixture :end-at: await lsp_client.shutdown_session() + + +Requesting Other Fixtures +------------------------- + +As you would expect, it's possible to request other fixtures to help set up your client. + +.. literalinclude:: ../../../lib/pytest-lsp/tests/examples/fixture-passthrough/t_server.py + :language: python + :start-at: @pytest.fixture + :end-at: await lsp_client.shutdown_session() diff --git a/lib/pytest-lsp/changes/71.enhancement.rst b/lib/pytest-lsp/changes/71.enhancement.rst new file mode 100644 index 0000000..d1d97a9 --- /dev/null +++ b/lib/pytest-lsp/changes/71.enhancement.rst @@ -0,0 +1 @@ +Fixtures created with the `@pytest_lsp.fixture` decorator can now request additional pytest fixtures diff --git a/lib/pytest-lsp/pytest_lsp/plugin.py b/lib/pytest-lsp/pytest_lsp/plugin.py index c529b7b..7e1682a 100644 --- a/lib/pytest-lsp/pytest_lsp/plugin.py +++ b/lib/pytest-lsp/pytest_lsp/plugin.py @@ -121,17 +121,46 @@ async def anext(it): return await it.__anext__() -def get_fixture_arguments(fn: Callable, client: LanguageClient, request) -> dict: - """Return the arguments to pass to the user's fixture function""" +def get_fixture_arguments( + fn: Callable, + client: LanguageClient, + request: pytest.FixtureRequest, +) -> dict: + """Return the arguments to pass to the user's fixture function. + + Parameters + ---------- + fn + The user's fixture function + + client + The language client instance to inject + + request + pytest's request fixture + + Returns + ------- + dict + The set of arguments to pass to the user's fixture function + """ kwargs = {} + required_parameters = set(inspect.signature(fn).parameters.keys()) - parameters = inspect.signature(fn).parameters - if "request" in parameters: + # Inject the 'request' fixture if requested + if "request" in required_parameters: kwargs["request"] = request + required_parameters.remove("request") + # Inject the language client for name, cls in typing.get_type_hints(fn).items(): if issubclass(cls, LanguageClient): kwargs[name] = client + required_parameters.remove(name) + + # Assume all remaining parameters are pytest fixtures + for name in required_parameters: + kwargs[name] = request.getfixturevalue(name) return kwargs diff --git a/lib/pytest-lsp/tests/examples/fixture-passthrough/server.py b/lib/pytest-lsp/tests/examples/fixture-passthrough/server.py new file mode 100644 index 0000000..2a54853 --- /dev/null +++ b/lib/pytest-lsp/tests/examples/fixture-passthrough/server.py @@ -0,0 +1,18 @@ +from lsprotocol.types import TEXT_DOCUMENT_COMPLETION +from lsprotocol.types import CompletionItem +from lsprotocol.types import CompletionParams +from pygls.server import LanguageServer + +server = LanguageServer("hello-world", "v1") + + +@server.feature(TEXT_DOCUMENT_COMPLETION) +def completion(ls: LanguageServer, params: CompletionParams): + return [ + CompletionItem(label="hello"), + CompletionItem(label="world"), + ] + + +if __name__ == "__main__": + server.start_io() diff --git a/lib/pytest-lsp/tests/examples/fixture-passthrough/t_server.py b/lib/pytest-lsp/tests/examples/fixture-passthrough/t_server.py new file mode 100644 index 0000000..890454c --- /dev/null +++ b/lib/pytest-lsp/tests/examples/fixture-passthrough/t_server.py @@ -0,0 +1,52 @@ +import sys + +import pytest +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 +from pytest_lsp import client_capabilities + + +@pytest.fixture(scope="module") +def client_name(): + return "neovim" + + +@pytest_lsp.fixture( + config=ClientServerConfig(server_command=[sys.executable, "server.py"]), +) +async def client(client_name: str, lsp_client: LanguageClient): + # Setup + params = InitializeParams(capabilities=client_capabilities(client_name)) + await lsp_client.initialize_session(params) + + yield + + # Teardown + await lsp_client.shutdown_session() + + +async def test_completions(client: LanguageClient): + """Ensure that the server implements completions correctly.""" + + 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 == ["hello", "world"] diff --git a/lib/pytest-lsp/tests/test_examples.py b/lib/pytest-lsp/tests/test_examples.py index ae6fcb7..e900f12 100644 --- a/lib/pytest-lsp/tests/test_examples.py +++ b/lib/pytest-lsp/tests/test_examples.py @@ -27,6 +27,7 @@ def setup_test(pytester: pytest.Pytester, example_name: str): [ ("diagnostics", dict(passed=1)), ("getting-started", dict(passed=1)), + ("fixture-passthrough", dict(passed=1)), ("parameterised-clients", dict(passed=2)), ("window-log-message", dict(passed=1)), ("window-show-document", dict(passed=1)), From 83c39c701cbf3091a1bb3e07c18acdaac6975feb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 14 Aug 2023 21:34:40 +0000 Subject: [PATCH 35/63] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/mirrors-mypy: v1.4.1 → v1.5.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.4.1...v1.5.0) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cf9e8c5..7431495 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,7 +34,7 @@ repos: exclude: 'lib/pytest-lsp/pytest_lsp/gen.py' - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.4.1' + rev: 'v1.5.0' hooks: - id: mypy name: mypy (pytest-lsp) From c568cfb1f00a25057eb912772e98d61db9fdc10a Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sat, 19 Aug 2023 22:52:53 +0100 Subject: [PATCH 36/63] lsp-devtools: Retry connection indefinitely Using `stamina` the `AgentClient` will repeatedly try to connect to a server instance, backing off until it's trying once every 60 seconds. Meanwhile the agent will run as normal, allowing the lsp client and server to communicate with each other. Captured messages will be stored up until a connection is available when they will forwarded onto the `AgentServer` en masse. --- lib/lsp-devtools/changes/77.enhancement.rst | 1 + .../lsp_devtools/agent/__init__.py | 29 +++++++++++++------ lib/lsp-devtools/lsp_devtools/agent/client.py | 18 ++++++++++-- lib/lsp-devtools/pyproject.toml | 1 + 4 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 lib/lsp-devtools/changes/77.enhancement.rst diff --git a/lib/lsp-devtools/changes/77.enhancement.rst b/lib/lsp-devtools/changes/77.enhancement.rst new file mode 100644 index 0000000..3439059 --- /dev/null +++ b/lib/lsp-devtools/changes/77.enhancement.rst @@ -0,0 +1 @@ +If the agent is unable to connect to a server app immediately, it will now retry indefinitely until it succeeds or the language server exits diff --git a/lib/lsp-devtools/lsp_devtools/agent/__init__.py b/lib/lsp-devtools/lsp_devtools/agent/__init__.py index 26831be..a6bccaa 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/agent/__init__.py @@ -33,17 +33,26 @@ def __init__(self, client: AgentClient, *args, **kwargs): super().__init__(*args, **kwargs) self.client = client self.session = str(uuid4()) + self._buffer: List[MessageText] = [] def emit(self, record: logging.LogRecord): - self.client.protocol.message_text_notification( - MessageText( - text=record.args[0], # type: ignore - session=self.session, - timestamp=record.created, - source=record.__dict__["source"], - ) + message = MessageText( + text=record.args[0], # type: ignore + session=self.session, + timestamp=record.created, + source=record.__dict__["source"], ) + if not self.client.connected: + self._buffer.append(message) + return + + # Send any buffered messages + while len(self._buffer) > 0: + self.client.protocol.message_text_notification(self._buffer.pop(0)) + + self.client.protocol.message_text_notification(message) + async def main(args, extra: List[str]): if extra is None: @@ -68,8 +77,10 @@ async def main(args, extra: List[str]): logger.setLevel(logging.INFO) logger.addHandler(handler) - await client.start_tcp(args.host, args.port) - await agent.start() + await asyncio.gather( + client.start_tcp(args.host, args.port), + agent.start(), + ) def run_agent(args, extra: List[str]): diff --git a/lib/lsp-devtools/lsp_devtools/agent/client.py b/lib/lsp-devtools/lsp_devtools/agent/client.py index 68f94b3..5fccd28 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/client.py +++ b/lib/lsp-devtools/lsp_devtools/agent/client.py @@ -2,6 +2,7 @@ from typing import Any from typing import Optional +import stamina from pygls.client import Client from pygls.client import aio_readline from pygls.protocol import default_converter @@ -36,6 +37,7 @@ def __init__(self): super().__init__( protocol_cls=AgentProtocol, converter_factory=default_converter ) + self.connected = False def _report_server_error(self, error, source): # Bail on error @@ -47,13 +49,25 @@ def feature(self, feature_name: str, options: Optional[Any] = None): # TODO: Upstream this... or at least something equivalent. async def start_tcp(self, host: str, port: int): - reader, writer = await asyncio.open_connection(host, port) + # 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 + # try indefinitely. + retries = stamina.retry_context( + on=OSError, + attempts=None, + timeout=None, + wait_initial=1, + wait_max=60, + ) + async for attempt in retries: + with attempt: + reader, writer = await asyncio.open_connection(host, port) - # adapter = TCPTransportAdapter(writer) self.protocol.connection_made(writer) connection = asyncio.create_task( aio_readline(self._stop_event, reader, self.protocol.data_received) ) + self.connected = True self._async_tasks.append(connection) # TODO: Upstream this... or at least something equivalent. diff --git a/lib/lsp-devtools/pyproject.toml b/lib/lsp-devtools/pyproject.toml index a44b00f..d58560e 100644 --- a/lib/lsp-devtools/pyproject.toml +++ b/lib/lsp-devtools/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "aiosqlite", "importlib-resources; python_version<\"3.9\"", "pygls", + "stamina", "textual>=0.14.0", "typing-extensions; python_version<\"3.8\"", ] From a56dac703c9f2def4fe02ae245f611db5d4d001b Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sat, 19 Aug 2023 23:18:18 +0100 Subject: [PATCH 37/63] lsp-devtool: Drop Python 3.7 --- .github/workflows/lsp-devtools-pr.yml | 2 +- lib/lsp-devtools/changes/77.misc.rst | 1 + lib/lsp-devtools/lsp_devtools/handlers/__init__.py | 7 +------ lib/lsp-devtools/lsp_devtools/record/filters.py | 7 +------ lib/lsp-devtools/pyproject.toml | 4 +--- 5 files changed, 5 insertions(+), 16 deletions(-) create mode 100644 lib/lsp-devtools/changes/77.misc.rst diff --git a/.github/workflows/lsp-devtools-pr.yml b/.github/workflows/lsp-devtools-pr.yml index 44d1d9e..70ee63f 100644 --- a/.github/workflows/lsp-devtools-pr.yml +++ b/.github/workflows/lsp-devtools-pr.yml @@ -13,7 +13,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11"] os: [ubuntu-latest] steps: diff --git a/lib/lsp-devtools/changes/77.misc.rst b/lib/lsp-devtools/changes/77.misc.rst new file mode 100644 index 0000000..aa98fdb --- /dev/null +++ b/lib/lsp-devtools/changes/77.misc.rst @@ -0,0 +1 @@ +Drop Python 3.7 support diff --git a/lib/lsp-devtools/lsp_devtools/handlers/__init__.py b/lib/lsp-devtools/lsp_devtools/handlers/__init__.py index 66d13d9..33f6686 100644 --- a/lib/lsp-devtools/lsp_devtools/handlers/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/handlers/__init__.py @@ -1,18 +1,13 @@ import json import logging from typing import Any +from typing import Literal from typing import Mapping from typing import Optional from uuid import uuid4 import attrs -try: - from typing import Literal -except ImportError: - from typing_extensions import Literal # type: ignore[assignment] - - MessageSource = Literal["client", "server"] diff --git a/lib/lsp-devtools/lsp_devtools/record/filters.py b/lib/lsp-devtools/lsp_devtools/record/filters.py index b6ec12c..1429fb7 100644 --- a/lib/lsp-devtools/lsp_devtools/record/filters.py +++ b/lib/lsp-devtools/lsp_devtools/record/filters.py @@ -1,5 +1,6 @@ import logging from typing import Dict +from typing import Literal from typing import Set from typing import Union @@ -7,12 +8,6 @@ from .formatters import FormatString -try: - from typing import Literal -except ImportError: - from typing_extensions import Literal # type: ignore[assignment] - - logger = logging.getLogger(__name__) MessageSource = Literal["client", "server", "both"] diff --git a/lib/lsp-devtools/pyproject.toml b/lib/lsp-devtools/pyproject.toml index d58560e..8677817 100644 --- a/lib/lsp-devtools/pyproject.toml +++ b/lib/lsp-devtools/pyproject.toml @@ -7,7 +7,7 @@ name = "lsp-devtools" version = "0.1.1" description = "Developer tooling for language servers" readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.8" license = { text = "MIT" } authors = [{ name = "Alex Carney", email = "alcarneyme@gmail.com" }] classifiers = [ @@ -16,7 +16,6 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -29,7 +28,6 @@ dependencies = [ "pygls", "stamina", "textual>=0.14.0", - "typing-extensions; python_version<\"3.8\"", ] [project.urls] From 5a181192dad3b43bf120e07fc9b44a93aa20d312 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sat, 19 Aug 2023 23:58:42 +0100 Subject: [PATCH 38/63] lsp-devtools: Update tox definition Also - Comment out websocket code for now - Fix tests - Enable coverage --- .gitignore | 1 + lib/lsp-devtools/lsp_devtools/agent/client.py | 27 ++++++++++--------- .../lsp_devtools/record/formatters.py | 2 +- lib/lsp-devtools/pyproject.toml | 25 ++++++----------- .../tests/record/test_formatters.py | 8 +++--- lib/lsp-devtools/tox.ini | 21 +++++++++++++++ 6 files changed, 49 insertions(+), 35 deletions(-) create mode 100644 lib/lsp-devtools/tox.ini diff --git a/.gitignore b/.gitignore index f117763..dfd8b34 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.coverage .env .tox *.pyc diff --git a/lib/lsp-devtools/lsp_devtools/agent/client.py b/lib/lsp-devtools/lsp_devtools/agent/client.py index 5fccd28..e91d916 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/client.py +++ b/lib/lsp-devtools/lsp_devtools/agent/client.py @@ -6,26 +6,27 @@ from pygls.client import Client from pygls.client import aio_readline from pygls.protocol import default_converter -from websockets.client import WebSocketClientProtocol from lsp_devtools.agent.protocol import AgentProtocol +# 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 +# class WebSocketClientTransportAdapter: +# """Protocol adapter for the WebSocket client interface.""" - def close(self) -> None: - """Stop the WebSocket server.""" - print("-- CLOSING --") - self._loop.create_task(self._ws.close()) +# def __init__(self, ws: WebSocketClientProtocol, loop: asyncio.AbstractEventLoop): +# self._ws = ws +# self._loop = loop - def write(self, data: Any) -> None: - """Create a task to write specified data into a WebSocket.""" - asyncio.ensure_future(self._ws.send(data)) +# 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(Client): diff --git a/lib/lsp-devtools/lsp_devtools/record/formatters.py b/lib/lsp-devtools/lsp_devtools/record/formatters.py index 0a9ee48..d8cb5cb 100644 --- a/lib/lsp-devtools/lsp_devtools/record/formatters.py +++ b/lib/lsp-devtools/lsp_devtools/record/formatters.py @@ -155,7 +155,7 @@ class FormatString: VARIABLE = re.compile(r"{\.([^}]+)}") def __init__(self, pattern: str): - self.pattern = pattern.replace("\\n", "\n").replace("\\t", "\t") + self.pattern = pattern # .replace("\\n", "\n").replace("\\t", "\t") self._parse() def _parse(self): diff --git a/lib/lsp-devtools/pyproject.toml b/lib/lsp-devtools/pyproject.toml index 8677817..7bd8185 100644 --- a/lib/lsp-devtools/pyproject.toml +++ b/lib/lsp-devtools/pyproject.toml @@ -42,10 +42,6 @@ dev = [ "pre-commit", "tox", ] -test=[ - "pytest-cov", - "pytest-timeout", -] typecheck=[ "mypy", "importlib_resources", @@ -60,6 +56,14 @@ lsp-devtools = "lsp_devtools.cli:main" [tool.setuptools.packages.find] include = ["lsp_devtools*"] +[tool.coverage.run] +source_pkgs = ["lsp_devtools"] + +[tool.coverage.report] +show_missing = true +skip_covered = true +sort = "Cover" + [tool.isort] force_single_line = true profile = "black" @@ -100,16 +104,3 @@ showcontent = true directory = "misc" name = "Misc" showcontent = true - -[tool.tox] -legacy_tox_ini = """ -[tox] -isolated_build = True -skip_missing_interpreters = true -envlist = py{37,38,39,310,311} - -[testenv] -extras=test -commands = - pytest {posargs} -""" diff --git a/lib/lsp-devtools/tests/record/test_formatters.py b/lib/lsp-devtools/tests/record/test_formatters.py index d6e6ca7..c6f7066 100644 --- a/lib/lsp-devtools/tests/record/test_formatters.py +++ b/lib/lsp-devtools/tests/record/test_formatters.py @@ -27,7 +27,7 @@ "textDocument": {"uri": "file:///path/to/file.txt"}, }, }, - "textDocument/completion file:///path/to/file.txt:{'line': 1, 'character': 2}", # noqa: E501 + 'textDocument/completion file:///path/to/file.txt:{\n "line": 1,\n "character": 2\n}', # noqa: E501 ), ( "{.method} {.params.textDocument.uri}:{.params.position|Position}", @@ -61,7 +61,7 @@ "items": [{"label": "one"}, {"label": "two"}, {"label": "three"}] } }, - "{'label': 'one'}\n{'label': 'two'}\n{'label': 'three'}", + '{\n "label": "one"\n}\n{\n "label": "two"\n}\n{\n "label": "three"\n}', ), ( "{.result.items[].label}", @@ -88,7 +88,7 @@ "items": [{"label": "one"}, {"label": "two"}, {"label": "three"}] } }, - "{'label': 'one'}", + '{\n "label": "one"\n}', ), ( "{.result.items[-1]}", @@ -97,7 +97,7 @@ "items": [{"label": "one"}, {"label": "two"}, {"label": "three"}] } }, - "{'label': 'three'}", + '{\n "label": "three"\n}', ), ( "- {.result.items[0].label}", diff --git a/lib/lsp-devtools/tox.ini b/lib/lsp-devtools/tox.ini new file mode 100644 index 0000000..223b1c1 --- /dev/null +++ b/lib/lsp-devtools/tox.ini @@ -0,0 +1,21 @@ +[tox] +isolated_build = True +skip_missing_interpreters = true +min_version = 4.0 +envlist = py{38,39,310,311} + +[testenv] +description = "Run lsp-devtools' test suite" +package = wheel +wheel_build_env = .pkg +deps = + coverage[toml] + pytest + + git+https://github.com/openlawlibrary/pygls +commands_pre = + coverage erase +commands = + coverage run -m pytest {posargs} +commands_post = + coverage report From 73750ddbdeb4c3e883cf1d88dbc87a566c175b04 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 21 Aug 2023 21:40:51 +0000 Subject: [PATCH 39/63] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/mirrors-mypy: v1.5.0 → v1.5.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.5.0...v1.5.1) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7431495..bc1ad4e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,7 +34,7 @@ repos: exclude: 'lib/pytest-lsp/pytest_lsp/gen.py' - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.5.0' + rev: 'v1.5.1' hooks: - id: mypy name: mypy (pytest-lsp) From 58fe14d69c25f23a3dac749222723d7fce2a7191 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 27 Aug 2023 19:14:57 +0100 Subject: [PATCH 40/63] pytest-lsp: Enable testing of non lsp servers This further simplifies the architecture by removing the `ClientServer` object. Also by basing most of the plugin of the base `Client` from `pygls` this should enable non LSP, JSON-RPC servers to "just work" within the same framework --- lib/pytest-lsp/changes/73.enhancement.rst | 2 + lib/pytest-lsp/changes/73.misc.rst | 1 + lib/pytest-lsp/pytest_lsp/__init__.py | 10 +-- lib/pytest-lsp/pytest_lsp/client.py | 2 +- lib/pytest-lsp/pytest_lsp/plugin.py | 93 +++++++---------------- 5 files changed, 32 insertions(+), 76 deletions(-) create mode 100644 lib/pytest-lsp/changes/73.enhancement.rst create mode 100644 lib/pytest-lsp/changes/73.misc.rst diff --git a/lib/pytest-lsp/changes/73.enhancement.rst b/lib/pytest-lsp/changes/73.enhancement.rst new file mode 100644 index 0000000..dbe479e --- /dev/null +++ b/lib/pytest-lsp/changes/73.enhancement.rst @@ -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. diff --git a/lib/pytest-lsp/changes/73.misc.rst b/lib/pytest-lsp/changes/73.misc.rst new file mode 100644 index 0000000..e4049c7 --- /dev/null +++ b/lib/pytest-lsp/changes/73.misc.rst @@ -0,0 +1 @@ +``make_test_client`` has been renamed to ``make_test_lsp_client`` diff --git a/lib/pytest-lsp/pytest_lsp/__init__.py b/lib/pytest-lsp/pytest_lsp/__init__.py index 86fef4a..c205546 100644 --- a/lib/pytest-lsp/pytest_lsp/__init__.py +++ b/lib/pytest-lsp/pytest_lsp/__init__.py @@ -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", ] diff --git a/lib/pytest-lsp/pytest_lsp/client.py b/lib/pytest-lsp/pytest_lsp/client.py index 38441e1..1049ee0 100644 --- a/lib/pytest-lsp/pytest_lsp/client.py +++ b/lib/pytest-lsp/pytest_lsp/client.py @@ -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.""" diff --git a/lib/pytest-lsp/pytest_lsp/plugin.py b/lib/pytest-lsp/pytest_lsp/plugin.py index 7e1682a..4278757 100644 --- a/lib/pytest-lsp/pytest_lsp/plugin.py +++ b/lib/pytest-lsp/pytest_lsp/plugin.py @@ -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 Client 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[[], Client] = 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) -> Client: + """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): @@ -123,7 +83,7 @@ async def anext(it): def get_fixture_arguments( fn: Callable, - client: LanguageClient, + client: Client, request: pytest.FixtureRequest, ) -> dict: """Return the arguments to pass to the user's fixture function. @@ -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 @@ -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, Client): kwargs[name] = client required_parameters.remove(name) @@ -183,10 +143,9 @@ 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: @@ -194,7 +153,7 @@ async def the_fixture(request): except StopAsyncIteration: pass - yield client_server.client + yield client if inspect.isasyncgen(result): try: @@ -202,7 +161,7 @@ async def the_fixture(request): except StopAsyncIteration: pass - await client_server.stop() + await client.stop() return the_fixture From f0dc264283fa06a4b8e1a9bc6b756b728e47f62d Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Thu, 31 Aug 2023 20:19:13 +0100 Subject: [PATCH 41/63] pytest-lsp: Align to upstream renames --- lib/pytest-lsp/pytest_lsp/client.py | 2 +- lib/pytest-lsp/pytest_lsp/plugin.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/pytest-lsp/pytest_lsp/client.py b/lib/pytest-lsp/pytest_lsp/client.py index 1049ee0..6eabb3c 100644 --- a/lib/pytest-lsp/pytest_lsp/client.py +++ b/lib/pytest-lsp/pytest_lsp/client.py @@ -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 diff --git a/lib/pytest-lsp/pytest_lsp/plugin.py b/lib/pytest-lsp/pytest_lsp/plugin.py index 4278757..82328cf 100644 --- a/lib/pytest-lsp/pytest_lsp/plugin.py +++ b/lib/pytest-lsp/pytest_lsp/plugin.py @@ -11,7 +11,7 @@ import attrs import pytest import pytest_asyncio -from pygls.client import Client +from pygls.client import JsonRPCClient from pytest_lsp.client import LanguageClient from pytest_lsp.client import make_test_lsp_client @@ -26,7 +26,7 @@ class ClientServerConfig: server_command: List[str] """The command to use to start the language server.""" - client_factory: Callable[[], Client] = attrs.field( + client_factory: Callable[[], JsonRPCClient] = attrs.field( default=make_test_lsp_client, ) """Factory function to use when constructing the test client instance.""" @@ -34,7 +34,7 @@ class ClientServerConfig: server_env: Optional[Dict[str, str]] = attrs.field(default=None) """Environment variables to set when starting the server.""" - async def start(self) -> Client: + async def start(self) -> JsonRPCClient: """Return the client instance to use for the test.""" client = self.client_factory() @@ -83,7 +83,7 @@ async def anext(it): def get_fixture_arguments( fn: Callable, - client: Client, + client: JsonRPCClient, request: pytest.FixtureRequest, ) -> dict: """Return the arguments to pass to the user's fixture function. @@ -114,7 +114,7 @@ def get_fixture_arguments( # Inject the language client for name, cls in typing.get_type_hints(fn).items(): - if issubclass(cls, Client): + if issubclass(cls, JsonRPCClient): kwargs[name] = client required_parameters.remove(name) From 2255675ca8a94ca1132508f2637f023a3ac53f6d Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 8 Sep 2023 16:24:57 +0100 Subject: [PATCH 42/63] pytest-lsp: Document generic RPC server testing --- docs/conf.py | 1 + docs/pytest-lsp/guide.rst | 1 + .../guide/testing-json-rpc-servers.rst | 60 +++++++++++++++++++ docs/pytest-lsp/reference.rst | 8 +-- .../tests/examples/generic-rpc/server.py | 30 ++++++++++ .../tests/examples/generic-rpc/t_server.py | 50 ++++++++++++++++ lib/pytest-lsp/tests/test_examples.py | 12 ++++ 7 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 docs/pytest-lsp/guide/testing-json-rpc-servers.rst create mode 100644 lib/pytest-lsp/tests/examples/generic-rpc/server.py create mode 100644 lib/pytest-lsp/tests/examples/generic-rpc/t_server.py diff --git a/docs/conf.py b/docs/conf.py index b2672f0..538e610 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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), } diff --git a/docs/pytest-lsp/guide.rst b/docs/pytest-lsp/guide.rst index 6bdfb6a..b8b4f4d 100644 --- a/docs/pytest-lsp/guide.rst +++ b/docs/pytest-lsp/guide.rst @@ -9,3 +9,4 @@ User Guide guide/client-capabilities guide/fixtures guide/troubleshooting + guide/testing-json-rpc-servers diff --git a/docs/pytest-lsp/guide/testing-json-rpc-servers.rst b/docs/pytest-lsp/guide/testing-json-rpc-servers.rst new file mode 100644 index 0000000..1005c59 --- /dev/null +++ b/docs/pytest-lsp/guide/testing-json-rpc-servers.rst @@ -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 diff --git a/docs/pytest-lsp/reference.rst b/docs/pytest-lsp/reference.rst index 8768a30..d6daa55 100644 --- a/docs/pytest-lsp/reference.rst +++ b/docs/pytest-lsp/reference.rst @@ -8,7 +8,7 @@ LanguageClient .. autoclass:: LanguageClient :members: - :inherited-members: + :show-inheritance: Test Setup @@ -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 ------ diff --git a/lib/pytest-lsp/tests/examples/generic-rpc/server.py b/lib/pytest-lsp/tests/examples/generic-rpc/server.py new file mode 100644 index 0000000..3381860 --- /dev/null +++ b/lib/pytest-lsp/tests/examples/generic-rpc/server.py @@ -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() diff --git a/lib/pytest-lsp/tests/examples/generic-rpc/t_server.py b/lib/pytest-lsp/tests/examples/generic-rpc/t_server.py new file mode 100644 index 0000000..38b6e3c --- /dev/null +++ b/lib/pytest-lsp/tests/examples/generic-rpc/t_server.py @@ -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 diff --git a/lib/pytest-lsp/tests/test_examples.py b/lib/pytest-lsp/tests/test_examples.py index e900f12..d493e3d 100644 --- a/lib/pytest-lsp/tests/test_examples.py +++ b/lib/pytest-lsp/tests/test_examples.py @@ -101,6 +101,18 @@ def test_getting_started_fail(pytester: pytest.Pytester): results.stdout.fnmatch_lines(message) +def test_generic_rpc(pytester: pytest.Pytester): + """Ensure that the generic rpc example works as expected""" + + setup_test(pytester, "generic-rpc") + + results = pytester.runpytest("--log-cli-level", "info") + results.assert_outcomes(passed=1, failed=1) + + results.stdout.fnmatch_lines(" *LOG: a=1") + results.stdout.fnmatch_lines(" *LOG: b=2") + + def test_window_log_message_fail(pytester: pytest.Pytester): """Ensure that the initial getting started example fails as expected.""" From accf953f9b87383bdc7241d199d7a8bed64e863a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 11 Sep 2023 22:11:06 +0000 Subject: [PATCH 43/63] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.7.0 → 23.9.1](https://github.com/psf/black/compare/23.7.0...23.9.1) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bc1ad4e..1256056 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 23.9.1 hooks: - id: black exclude: 'lib/pytest-lsp/pytest_lsp/gen.py' From 4062a3573be091429009c0864e0e48517c124072 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 11 Sep 2023 10:34:46 +0100 Subject: [PATCH 44/63] lsp-devtools: Align to upstream rename --- lib/lsp-devtools/lsp_devtools/agent/client.py | 4 ++-- lib/lsp-devtools/lsp_devtools/agent/server.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/lsp-devtools/lsp_devtools/agent/client.py b/lib/lsp-devtools/lsp_devtools/agent/client.py index e91d916..f36c8f9 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/client.py +++ b/lib/lsp-devtools/lsp_devtools/agent/client.py @@ -3,7 +3,7 @@ from typing import Optional import stamina -from pygls.client import Client +from pygls.client import JsonRPCClient from pygls.client import aio_readline from pygls.protocol import default_converter @@ -29,7 +29,7 @@ # asyncio.ensure_future(self._ws.send(data)) -class AgentClient(Client): +class AgentClient(JsonRPCClient): """Client for connecting to an AgentServer instance.""" protocol: AgentProtocol diff --git a/lib/lsp-devtools/lsp_devtools/agent/server.py b/lib/lsp-devtools/lsp_devtools/agent/server.py index 3b9bd9f..41f20f8 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/server.py +++ b/lib/lsp-devtools/lsp_devtools/agent/server.py @@ -3,7 +3,6 @@ import re import threading from typing import Any -from typing import Callable from typing import Optional from pygls.client import aio_readline From 884d5a45cd2d07c3fd9b17860f5fd4c928d0c856 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Thu, 14 Sep 2023 18:52:33 +0100 Subject: [PATCH 45/63] nix: Update overlay --- lib/pytest-lsp/nix/pytest-lsp-overlay.nix | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/pytest-lsp/nix/pytest-lsp-overlay.nix b/lib/pytest-lsp/nix/pytest-lsp-overlay.nix index 9468b69..b971989 100644 --- a/lib/pytest-lsp/nix/pytest-lsp-overlay.nix +++ b/lib/pytest-lsp/nix/pytest-lsp-overlay.nix @@ -20,13 +20,30 @@ final: prev: { }); + lsprotocol = python-prev.lsprotocol.overridePythonAttrs(oldAttrs: rec { + version = "2023.0.0a3"; + + src = prev.fetchFromGitHub { + rev = version; + owner = "microsoft"; + repo = oldAttrs.pname; + sha256 = "sha256-Q4jvUIMMaDX8mvdmRtYKHB2XbMEchygO2NMmMQdNkTc="; + }; + }); + pygls = python-prev.pygls.overridePythonAttrs (_: { + format = "pyproject"; + src = prev.fetchFromGitHub { owner = "openlawlibrary"; repo = "pygls"; rev = "main"; - hash = "sha256-KjnuGQy3/YBSZyXYNWz4foUsFRbinujGxCkQjRSK4PE="; + hash = "sha256-JpopfqeLNi23TuZ5mkPEShUPScd1fB0IDXSVGvDYFXE="; }; + + nativeBuildInputs = with python-prev; [ + poetry-core + ]; }); pytest-lsp = python-prev.buildPythonPackage { From ddc4beed7fa8ebc4b3a11ce0252fa4ad44dfb342 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sat, 9 Sep 2023 20:24:18 +0100 Subject: [PATCH 46/63] workflow: Enable tests on Windows --- .github/workflows/pytest-lsp-pr.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pytest-lsp-pr.yml b/.github/workflows/pytest-lsp-pr.yml index c861a78..cad5b0c 100644 --- a/.github/workflows/pytest-lsp-pr.yml +++ b/.github/workflows/pytest-lsp-pr.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] - os: [ubuntu-latest] + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v3 @@ -38,24 +38,25 @@ jobs: # dev version number e.g. v1.2.3-dev4 ./scripts/make-release.sh pytest-lsp name: Set Version - if: matrix.python-version == '3.10' + if: matrix.python-version == '3.10' && matrix.os == 'ubuntu-latest' - run: | cd lib/pytest-lsp version=$(echo ${{ matrix.python-version }} | tr -d .) python -m tox run -f "py${version}" + shell: bash name: Test - name: Package run: | cd lib/pytest-lsp python -m build - if: always() && matrix.python-version == '3.10' + if: always() && matrix.python-version == '3.10' && matrix.os == 'ubuntu-latest' - name: 'Upload Artifact' uses: actions/upload-artifact@v3 with: name: 'dist' path: lib/pytest-lsp/dist - if: always() && matrix.python-version == '3.10' + if: always() && matrix.python-version == '3.10' && matrix.os == 'ubuntu-latest' From 29d11d1d85eea14270e6f5cfa5f68634352b3963 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sat, 9 Sep 2023 20:28:38 +0100 Subject: [PATCH 47/63] pytest-lsp: Update changelog --- lib/pytest-lsp/changes/72.enhancement.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 lib/pytest-lsp/changes/72.enhancement.rst diff --git a/lib/pytest-lsp/changes/72.enhancement.rst b/lib/pytest-lsp/changes/72.enhancement.rst new file mode 100644 index 0000000..92d85b5 --- /dev/null +++ b/lib/pytest-lsp/changes/72.enhancement.rst @@ -0,0 +1,2 @@ +It is now possible to set the environment variables that the server under test is launched with. + From b962a7c5908ceedfcedc2e028b59d6cc29f0dfc9 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 2 Oct 2023 22:52:14 +0100 Subject: [PATCH 48/63] pytest-lsp: Use raw string literals This *should* prevent any issues with `\` path separators on Windows --- lib/pytest-lsp/tests/test_client.py | 4 ++-- lib/pytest-lsp/tests/test_plugin.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pytest-lsp/tests/test_client.py b/lib/pytest-lsp/tests/test_client.py index 1bfc74e..102bc7f 100644 --- a/lib/pytest-lsp/tests/test_client.py +++ b/lib/pytest-lsp/tests/test_client.py @@ -52,14 +52,14 @@ def test_client_capabilities( @pytest_lsp.fixture( config=ClientServerConfig( - server_command=["{python}", "{server}"], + server_command=[r"{python}", r"{server}"], ) ) async def client(lsp_client: LanguageClient): await lsp_client.initialize_session( InitializeParams( capabilities=client_capabilities("{client_spec}"), - root_uri="{root_uri}" + root_uri=r"{root_uri}" ) ) yield diff --git a/lib/pytest-lsp/tests/test_plugin.py b/lib/pytest-lsp/tests/test_plugin.py index a964aca..15f5b13 100644 --- a/lib/pytest-lsp/tests/test_plugin.py +++ b/lib/pytest-lsp/tests/test_plugin.py @@ -33,14 +33,14 @@ def setup_test(pytester: pytest.Pytester, server_name: str, test_code: str): @pytest_lsp.fixture( config=ClientServerConfig( - server_command=["{python}", "{server}"], + server_command=[r"{python}", r"{server}"], ) ) async def client(lsp_client: LanguageClient): await lsp_client.initialize_session( InitializeParams( capabilities=client_capabilities("visual-studio-code"), - root_uri="{root_uri}" + root_uri=r"{root_uri}" ) ) yield From a3150db8883d439ebf9262f86321b13605c626b1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 2 Oct 2023 21:58:21 +0000 Subject: [PATCH 49/63] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- lib/pytest-lsp/changes/72.enhancement.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pytest-lsp/changes/72.enhancement.rst b/lib/pytest-lsp/changes/72.enhancement.rst index 92d85b5..5afc834 100644 --- a/lib/pytest-lsp/changes/72.enhancement.rst +++ b/lib/pytest-lsp/changes/72.enhancement.rst @@ -1,2 +1 @@ It is now possible to set the environment variables that the server under test is launched with. - From ca0939a2a3c092d7f7b508a8483ac38468666919 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Thu, 27 Jul 2023 18:58:53 +0100 Subject: [PATCH 50/63] lsp-devtools: Filter messages by session --- lib/lsp-devtools/lsp_devtools/tui/__init__.py | 19 ++++++--- lib/lsp-devtools/lsp_devtools/tui/database.py | 40 ++++++++++++++++--- lib/lsp-devtools/pyproject.toml | 3 +- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/lib/lsp-devtools/lsp_devtools/tui/__init__.py b/lib/lsp-devtools/lsp_devtools/tui/__init__.py index 069036e..d914604 100644 --- a/lib/lsp-devtools/lsp_devtools/tui/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/tui/__init__.py @@ -88,12 +88,14 @@ def __init__(self, db: Database, viewer: MessageViewer): self.add_column("ID") self.add_column("Method") - def on_key(self, event: events.Key): - if event.key != "enter": + @on(DataTable.RowHighlighted) + def show_object(self, event: DataTable.RowHighlighted): + """Show the message object on the currently highlighted row.""" + + rowid = int(self.get_row_at(event.cursor_row)[0]) + if (message := self.rpcdata.get(rowid, None)) is None: return - rowid = int(self.get_row_at(self.cursor_row)[0]) - message = self.rpcdata[rowid] name = "" obj = {} @@ -111,10 +113,15 @@ def on_key(self, event: events.Key): self.viewer.set_object(name, obj) + def _get_query_params(self): + """Return the set of query parameters to use when populating the table.""" + return dict(max_row=self.max_row - 1) + async def update(self): """Trigger a re-run of the query to pull in new data.""" - messages = await self.db.get_messages(self.max_row - 1) + query_params = self._get_query_params() + messages = await self.db.get_messages(**query_params) for message in messages: self.max_row += 1 self.rpcdata[self.max_row] = message @@ -146,8 +153,8 @@ class LSPInspector(App): def __init__(self, db: Database, server: AgentServer, *args, **kwargs): super().__init__(*args, **kwargs) + db.app = self self.db = db - self.db.app = self """Where the data for the app is being held""" self.server = server diff --git a/lib/lsp-devtools/lsp_devtools/tui/database.py b/lib/lsp-devtools/lsp_devtools/tui/database.py index 998c00d..8d20057 100644 --- a/lib/lsp-devtools/lsp_devtools/tui/database.py +++ b/lib/lsp-devtools/lsp_devtools/tui/database.py @@ -82,13 +82,43 @@ async def add_message(self, session: str, timestamp: float, source: str, rpc: di if self.app is not None: self.app.post_message(PingMessage()) - async def get_messages(self, max_row=-1): - """Get messages from the databse""" - - query = "SELECT * FROM protocol WHERE rowid > ?" + async def get_messages( + self, + *, + session: str = "", + max_row: Optional[int] = None, + ): + """Get messages from the database + + Parameters + ---------- + session + If set, only return messages with the given session id + + max_row + If set, only return messages with a row id greater than ``max_row`` + """ + + base_query = "SELECT * FROM protocol" + where = [] + parameters = [] + + if session: + where.append("session = ?") + parameters.append(session) + + if max_row: + where.append("rowid > ?") + parameters.append(max_row) + + if where: + conditions = " AND ".join(where) + query = " ".join([base_query, "WHERE", conditions]) + else: + query = base_query async with self.cursor() as cursor: - await cursor.execute(query, (max_row,)) + await cursor.execute(query, tuple(parameters)) rows = await cursor.fetchall() results = [] diff --git a/lib/lsp-devtools/pyproject.toml b/lib/lsp-devtools/pyproject.toml index 7bd8185..8bead31 100644 --- a/lib/lsp-devtools/pyproject.toml +++ b/lib/lsp-devtools/pyproject.toml @@ -27,7 +27,8 @@ dependencies = [ "importlib-resources; python_version<\"3.9\"", "pygls", "stamina", - "textual>=0.14.0", + "textual>=0.38.0", + "typing-extensions; python_version<\"3.8\"", ] [project.urls] From 7be99a57093c2d6ade6a73ee5911681df279d2a3 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 6 Oct 2023 18:08:13 +0100 Subject: [PATCH 51/63] lsp-devtools: Remove `lsp-devtools capabilities` command --- lib/lsp-devtools/changes/83.misc.rst | 1 + lib/lsp-devtools/lsp_devtools/cli.py | 1 - .../lsp_devtools/cmds/__init__.py | 0 .../lsp_devtools/cmds/capabilities.py | 35 ------------------- 4 files changed, 1 insertion(+), 36 deletions(-) create mode 100644 lib/lsp-devtools/changes/83.misc.rst delete mode 100644 lib/lsp-devtools/lsp_devtools/cmds/__init__.py delete mode 100644 lib/lsp-devtools/lsp_devtools/cmds/capabilities.py diff --git a/lib/lsp-devtools/changes/83.misc.rst b/lib/lsp-devtools/changes/83.misc.rst new file mode 100644 index 0000000..be42256 --- /dev/null +++ b/lib/lsp-devtools/changes/83.misc.rst @@ -0,0 +1 @@ +The ``lsp-devtools capabilities`` command has been removed in favour of ``lsp-devtools record`` diff --git a/lib/lsp-devtools/lsp_devtools/cli.py b/lib/lsp-devtools/lsp_devtools/cli.py index 24cc1d0..1425eb6 100644 --- a/lib/lsp-devtools/lsp_devtools/cli.py +++ b/lib/lsp-devtools/lsp_devtools/cli.py @@ -11,7 +11,6 @@ BUILTIN_COMMANDS = [ "lsp_devtools.agent", - "lsp_devtools.cmds.capabilities", # TODO: Remove in favour of record + cli args "lsp_devtools.record", "lsp_devtools.tui", ] diff --git a/lib/lsp-devtools/lsp_devtools/cmds/__init__.py b/lib/lsp-devtools/lsp_devtools/cmds/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/lib/lsp-devtools/lsp_devtools/cmds/capabilities.py b/lib/lsp-devtools/lsp_devtools/cmds/capabilities.py deleted file mode 100644 index d520ed3..0000000 --- a/lib/lsp-devtools/lsp_devtools/cmds/capabilities.py +++ /dev/null @@ -1,35 +0,0 @@ -import argparse -import json - -from lsprotocol.types import INITIALIZE -from lsprotocol.types import InitializeParams -from pygls.server import LanguageServer - - -def capabilities(args, extra): - server = LanguageServer(name="capabilities-dumper", version="v1.0") - - @server.feature(INITIALIZE) - def on_initialize(ls: LanguageServer, params: InitializeParams): - client_info = params.client_info - if client_info: - client_name = client_info.name.lower().replace(" ", "_") - client_version = client_info.version or "unknown" - else: - client_name = "unknown" - client_version = "unknown" - - filename = f"{client_name}_v{client_version}.json" - with open(filename, "w") as f: - obj = params.capabilities - json.dump(ls.lsp._converter.unstructure(obj), f, indent=2) - - server.start_io() - - -def cli(commands: argparse._SubParsersAction): - cmd = commands.add_parser( - "capabilities", - help="dummy lsp server for recording a client's capabilities.", - ) - cmd.set_defaults(run=capabilities) From 3f50c7a8a4b1e85fe3ed6bdcb0e5dfa4d7737f59 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 6 Oct 2023 18:09:24 +0100 Subject: [PATCH 52/63] lsp-devtools: Include workspace folders in the `sessions` view --- lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql b/lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql index 18604d1..fcadb11 100644 --- a/lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql +++ b/lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql @@ -58,6 +58,7 @@ SELECT json_extract(params, "$.clientInfo.name") as client_name, json_extract(params, "$.clientInfo.version") as client_version, json_extract(params, "$.rootUri") as root_uri, + json_extract(params, "$.workspaceFolders") as workspace_folders, params, result FROM requests WHERE method = 'initialize'; From d903f14ddda73dabbdba92f5b90309f2a0170022 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 6 Oct 2023 18:16:18 +0100 Subject: [PATCH 53/63] lsp-devtools: Proof of concept language client This adds a proof of concept language client built on textual's TextArea. The idea is to eventually be able to "play" with a server while inpersonating a particular client - all while being able to inspect the traffic sent to/from the server. The low-level stuff is technically done? The server starts, we can capture and display the traffic - even text sync appears to work! It's now "just" a case of implementing a UI and adding support for all the LSP methods! --- lib/lsp-devtools/changes/83.feature.rst | 1 + lib/lsp-devtools/changes/83.misc.rst | 2 + lib/lsp-devtools/lsp_devtools/cli.py | 5 +- .../lsp_devtools/client/__init__.py | 180 ++++++++++++++++++ lib/lsp-devtools/lsp_devtools/client/app.css | 35 ++++ .../lsp_devtools/client/editor.py | 155 +++++++++++++++ lib/lsp-devtools/lsp_devtools/client/lsp.py | 40 ++++ .../lsp_devtools/{tui => }/database.py | 63 ++++-- .../{tui => inspector}/__init__.py | 43 +++-- .../lsp_devtools/{tui => inspector}/app.css | 0 lib/lsp-devtools/pyproject.toml | 5 +- 11 files changed, 484 insertions(+), 45 deletions(-) create mode 100644 lib/lsp-devtools/changes/83.feature.rst create mode 100644 lib/lsp-devtools/lsp_devtools/client/__init__.py create mode 100644 lib/lsp-devtools/lsp_devtools/client/app.css create mode 100644 lib/lsp-devtools/lsp_devtools/client/editor.py create mode 100644 lib/lsp-devtools/lsp_devtools/client/lsp.py rename lib/lsp-devtools/lsp_devtools/{tui => }/database.py (69%) rename lib/lsp-devtools/lsp_devtools/{tui => inspector}/__init__.py (90%) rename lib/lsp-devtools/lsp_devtools/{tui => inspector}/app.css (100%) diff --git a/lib/lsp-devtools/changes/83.feature.rst b/lib/lsp-devtools/changes/83.feature.rst new file mode 100644 index 0000000..a755fc1 --- /dev/null +++ b/lib/lsp-devtools/changes/83.feature.rst @@ -0,0 +1 @@ +**Experimental** Add proof of concept ``lsp-devtools client`` command that builds on textual's ``TextArea`` widget to offer an interactive language server client. diff --git a/lib/lsp-devtools/changes/83.misc.rst b/lib/lsp-devtools/changes/83.misc.rst index be42256..864e34e 100644 --- a/lib/lsp-devtools/changes/83.misc.rst +++ b/lib/lsp-devtools/changes/83.misc.rst @@ -1 +1,3 @@ The ``lsp-devtools capabilities`` command has been removed in favour of ``lsp-devtools record`` + +The ``lsp-devtools tui`` command has been renamed to ``lsp-devtools inspect`` diff --git a/lib/lsp-devtools/lsp_devtools/cli.py b/lib/lsp-devtools/lsp_devtools/cli.py index 1425eb6..3ec1ff6 100644 --- a/lib/lsp-devtools/lsp_devtools/cli.py +++ b/lib/lsp-devtools/lsp_devtools/cli.py @@ -11,8 +11,9 @@ BUILTIN_COMMANDS = [ "lsp_devtools.agent", + "lsp_devtools.client", + "lsp_devtools.inspector", "lsp_devtools.record", - "lsp_devtools.tui", ] @@ -36,7 +37,7 @@ def load_command(commands: argparse._SubParsersAction, name: str): def main(): cli = argparse.ArgumentParser( - prog="lsp-devtools", description="Development tooling for language servers" + prog="lsp-devtools", description="Developer tooling for language servers" ) cli.add_argument("--version", action="version", version=f"%(prog)s v{__version__}") commands = cli.add_subparsers(title="commands") diff --git a/lib/lsp-devtools/lsp_devtools/client/__init__.py b/lib/lsp-devtools/lsp_devtools/client/__init__.py new file mode 100644 index 0000000..bf281e6 --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/client/__init__.py @@ -0,0 +1,180 @@ +import argparse +import asyncio +import logging +import os +import pathlib +from typing import List +from typing import Optional +from typing import Set +from uuid import uuid4 + +import platformdirs +from lsprotocol import types +from pygls import uris as uri +from pygls.capabilities import get_capability +from textual import events +from textual import log +from textual import on +from textual.app import App +from textual.app import ComposeResult +from textual.containers import ScrollableContainer +from textual.containers import Vertical +from textual.widgets import DirectoryTree +from textual.widgets import Footer +from textual.widgets import Header +from textual.widgets import TextArea + +from lsp_devtools.agent import logger +from lsp_devtools.database import Database +from lsp_devtools.database import DatabaseLogHandler +from lsp_devtools.inspector import MessagesTable +from lsp_devtools.inspector import MessageViewer + +from .editor import TextEditor +from .lsp import LanguageClient + + +class Explorer(DirectoryTree): + @on(DirectoryTree.FileSelected) + def open_file(self, event: DirectoryTree.FileSelected): + if not self.parent: + return + + editor = self.parent.query_one(TextEditor) + editor.open_file(event.path) + editor.focus() + + +class Devtools(Vertical): + pass + + +class LSPClient(App): + """A simple LSP client for use with language servers.""" + + CSS_PATH = pathlib.Path(__file__).parent / "app.css" + BINDINGS = [ + ("f2", "toggle_explorer", "Explorer"), + ("f12", "toggle_devtools", "Devtools"), + # ("ctrl+g", "refresh_table", "Refresh table"), + ] + + def __init__( + self, db: Database, server_command: List[str], session: str, *args, **kwargs + ): + super().__init__(*args, **kwargs) + + self.db = db + db.app = self + + self.session = session + self.server_command = server_command + self.lsp_client = LanguageClient() + + self._async_tasks: List[asyncio.Task] = [] + + def compose(self) -> ComposeResult: + message_viewer = MessageViewer("") + messages_table = MessagesTable(self.db, message_viewer, session=self.session) + + yield Header() + yield Explorer(".") + yield TextEditor(self.lsp_client) + devtools = Devtools(ScrollableContainer(messages_table), message_viewer) + devtools.add_class("-hidden") + yield devtools + yield Footer() + + def action_toggle_devtools(self) -> None: + devtools = self.query_one(Devtools) + is_visible = not devtools.has_class("-hidden") + + if is_visible: + self.screen.focus_next() + devtools.add_class("-hidden") + + else: + devtools.remove_class("-hidden") + self.screen.set_focus(devtools) + + def action_toggle_explorer(self) -> None: + explorer = self.query_one(Explorer) + is_visible = not explorer.has_class("-hidden") + + if is_visible and explorer.has_focus: + self.screen.focus_next() + explorer.add_class("-hidden") + + else: + explorer.remove_class("-hidden") + self.screen.set_focus(explorer) + + async def on_ready(self, event: events.Ready): + editor = self.query_one(TextEditor) + + # Start the lsp server. + await self.lsp_client.start_io(self.server_command[0], *self.server_command[1:]) + result = await self.lsp_client.initialize_async( + types.InitializeParams( + capabilities=types.ClientCapabilities(), + process_id=os.getpid(), + root_uri=uri.from_fs_path(os.getcwd()), + ) + ) + + editor.capabilities = result.capabilities + self.lsp_client.initialized(types.InitializedParams()) + + @on(Database.Update) + async def update_table(self, event: Database.Update): + table = self.query_one(MessagesTable) + await table.update() + + async def action_quit(self): + await self.lsp_client.shutdown_async(None) + self.lsp_client.exit(None) + await self.lsp_client.stop() + await super().action_quit() + + +def client(args, extra: List[str]): + if len(extra) == 0: + raise ValueError("Missing server command.") + + db = Database(args.dbpath) + + session = str(uuid4()) + dbhandler = DatabaseLogHandler(db, session=session) + dbhandler.setLevel(logging.INFO) + + logger.setLevel(logging.INFO) + logger.addHandler(dbhandler) + + app = LSPClient(db, session=session, server_command=extra) + app.run() + + asyncio.run(db.close()) + + +def cli(commands: argparse._SubParsersAction): + cmd: argparse.ArgumentParser = commands.add_parser( + "client", + help="launch an LSP client with built in inspector", + description="""\ +Open a simple text editor to drive a given language server. +""", + ) + + default_db = pathlib.Path( + platformdirs.user_cache_dir(appname="lsp-devtools", appauthor="swyddfa"), + "sessions.db", + ) + cmd.add_argument( + "--dbpath", + type=pathlib.Path, + metavar="DB", + default=default_db, + help="the database path to use", + ) + + cmd.set_defaults(run=client) diff --git a/lib/lsp-devtools/lsp_devtools/client/app.css b/lib/lsp-devtools/lsp_devtools/client/app.css new file mode 100644 index 0000000..df160f1 --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/client/app.css @@ -0,0 +1,35 @@ +Screen { + layers: base overlay; +} + +Explorer { + width: 30; + dock: left; + transition: offset 300ms out_cubic; +} +Explorer.-hidden { + display: none; +} + +Header { + dock: top; +} + +Devtools { + width: 40%; + dock: right; + transition: offset 300ms out_cubic; +} +Devtools.-hidden { + display: none; +} + +MessagesTable { + height: 100%; +} + +CompletionList { + height: 10; + width: 30; + layer: overlay; +} diff --git a/lib/lsp-devtools/lsp_devtools/client/editor.py b/lib/lsp-devtools/lsp_devtools/client/editor.py new file mode 100644 index 0000000..0241976 --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/client/editor.py @@ -0,0 +1,155 @@ +import asyncio +import pathlib +from typing import Optional +from typing import Set + +from lsprotocol import types +from pygls import uris as uri +from pygls.capabilities import get_capability +from textual import events +from textual import log +from textual import on +from textual.binding import Binding +from textual.widgets import OptionList +from textual.widgets import TextArea + +from .lsp import LanguageClient + + +class CompletionList(OptionList): + BINDINGS = [ + Binding("escape", "dismiss", "Dismiss", show=False), + Binding("ctrl+j", "dismiss", "Dismiss", show=False), + ] + + @classmethod + def fromresult(cls, result): + """Build a list of completion candidates based on a response from the + language server.""" + candidates = cls() + + if result is None: + return candidates + + if isinstance(result, types.CompletionList): + items = result.items + else: + items = result + + if len(items) == 0: + return candidates + + candidates.add_options(sorted([i.label for i in items])) + return candidates + + def on_blur(self, event: events.Blur): + self.action_dismiss() + + def action_dismiss(self): + self.remove() + if self.parent: + self.app.set_focus(self.parent) + + +class TextEditor(TextArea): + def __init__(self, lsp_client: LanguageClient, *args, **kwargs): + super().__init__(*args, **kwargs) + self.uri = None + self.version = 0 + + self.lsp_client = lsp_client + self.capabilities: Optional[types.ServerCapabilities] = None + + self._tasks: Set[asyncio.Task] = set() + + @property + def completion_triggers(self): + return get_capability( + self.capabilities, "completion_provider.trigger_characters", set() + ) + + def open_file(self, path: pathlib.Path): + self.uri = uri.from_fs_path(str(path.resolve())) + if self.uri is None: + return + + content = path.read_text() + self.version = 0 + self.load_text(content) + + self.lsp_client.text_document_did_open( + types.DidOpenTextDocumentParams( + text_document=types.TextDocumentItem( + uri=self.uri, + language_id="restructuredtext", + version=self.version, + text=content, + ) + ) + ) + + def edit(self, edit): + super().edit(edit) + + if self.uri is None: + return + + self.version += 1 + start_line, start_col = edit.from_location + end_line, end_col = edit.to_location + + self.lsp_client.text_document_did_change( + types.DidChangeTextDocumentParams( + text_document=types.VersionedTextDocumentIdentifier( + version=self.version, uri=self.uri + ), + content_changes=[ + types.TextDocumentContentChangeEvent_Type1( + text=edit.text, + range=types.Range( + start=types.Position(line=start_line, character=start_col), + end=types.Position(line=end_line, character=end_col), + ), + ) + ], + ) + ) + + if len(edit.text) == 0: + return + + char = edit.text[-1] + if char in self.completion_triggers: + self.trigger_completion(end_line, end_col) + + def trigger_completion(self, line: int, character: int): + """Trigger completion at the given location.""" + task = asyncio.create_task( + self.lsp_client.text_document_completion_async( + types.CompletionParams( + text_document=types.TextDocumentIdentifier(uri=self.uri), + position=types.Position(line=line, character=character), + ) + ) + ) + + self._tasks.add(task) + task.add_done_callback(self.show_completions) + + def show_completions(self, task: asyncio.Task): + self._tasks.discard(task) + + candidates = CompletionList.fromresult(task.result()) + if candidates.option_count == 0: + return + + row, col = self.cursor_location + candidates.offset = (col + 2, row + 1) + + self.mount(candidates) + self.app.set_focus(candidates) + + @on(OptionList.OptionSelected) + def completion_selected(self, event: OptionList.OptionSelected): + log(f"{event.option} was selected!") + event.option_list.action_dismiss() diff --git a/lib/lsp-devtools/lsp_devtools/client/lsp.py b/lib/lsp-devtools/lsp_devtools/client/lsp.py new file mode 100644 index 0000000..5f14d18 --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/client/lsp.py @@ -0,0 +1,40 @@ +import importlib.metadata +import json + +from pygls.lsp.client import BaseLanguageClient +from pygls.protocol import LanguageServerProtocol + +from lsp_devtools.agent import logger +from lsp_devtools.database import Database + +VERSION = importlib.metadata.version("lsp-devtools") + + +class RecordingLSProtocol(LanguageServerProtocol): + """A version of the LanguageServerProtocol that also records all the traffic.""" + + def __init__(self, server, converter): + super().__init__(server, converter) + + def _procedure_handler(self, message): + logger.info( + "%s", + json.dumps(message, default=self._serialize_message), + extra={"source": "server"}, + ) + return super()._procedure_handler(message) + + def _send_data(self, data): + logger.info( + "%s", + json.dumps(data, default=self._serialize_message), + extra={"source": "client"}, + ) + return super()._send_data(data) + + +class LanguageClient(BaseLanguageClient): + """A language client for integrating with a textual text edit.""" + + def __init__(self): + super().__init__("lsp-devtools", VERSION, protocol_cls=RecordingLSProtocol) diff --git a/lib/lsp-devtools/lsp_devtools/tui/database.py b/lib/lsp-devtools/lsp_devtools/database.py similarity index 69% rename from lib/lsp-devtools/lsp_devtools/tui/database.py rename to lib/lsp-devtools/lsp_devtools/database.py index 8d20057..f96fecb 100644 --- a/lib/lsp-devtools/lsp_devtools/tui/database.py +++ b/lib/lsp-devtools/lsp_devtools/database.py @@ -1,8 +1,12 @@ +import asyncio import json +import logging import pathlib import sys from contextlib import asynccontextmanager from typing import Optional +from typing import Set +from uuid import uuid4 import aiosqlite from textual import log @@ -16,17 +20,17 @@ import importlib.resources as resources # type: ignore[no-redef] -class PingMessage(Message): - """Sent when there are updates in the db.""" - - class Database: """Controls access to the backing sqlite database.""" - def __init__(self, dbpath: Optional[pathlib.Path] = None, app=None): + class Update(Message): + """Sent when there are updates to the database""" + + def __init__(self, dbpath: Optional[pathlib.Path] = None): self.dbpath = dbpath or ":memory:" self.db: Optional[aiosqlite.Connection] = None - self.app = app + self.app = None + self._handlers: Dict[str, set] = {} async def close(self): if self.db: @@ -80,7 +84,7 @@ async def add_message(self, session: str, timestamp: float, source: str, rpc: di ) if self.app is not None: - self.app.post_message(PingMessage()) + self.app.post_message(Database.Update()) async def get_messages( self, @@ -99,7 +103,7 @@ async def get_messages( If set, only return messages with a row id greater than ``max_row`` """ - base_query = "SELECT * FROM protocol" + base_query = "SELECT rowid, * FROM protocol" where = [] parameters = [] @@ -123,17 +127,38 @@ async def get_messages( rows = await cursor.fetchall() results = [] for row in rows: - results.append( - LspMessage( - session=row[0], - timestamp=row[1], - source=row[2], - id=row[3], - method=row[4], - params=row[5], - result=row[6], - error=row[7], - ) + message = LspMessage( + session=row[1], + timestamp=row[2], + source=row[3], + id=row[4], + method=row[5], + params=row[6], + result=row[7], + error=row[8], ) + results.append((row[0], message)) + return results + + +class DatabaseLogHandler(logging.Handler): + """A logging handler that records messages in the given database.""" + + def __init__(self, db: Database, *args, session=None, **kwargs): + super().__init__(*args, **kwargs) + self.db = db + self.session = session or str(uuid4()) + self._tasks: Set[asyncio.Task] = set() + + def emit(self, record: logging.LogRecord): + body = json.loads(record.args[0]) + task = asyncio.create_task( + self.db.add_message( + self.session, record.created, record.__dict__["source"], body + ) + ) + + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) diff --git a/lib/lsp-devtools/lsp_devtools/tui/__init__.py b/lib/lsp-devtools/lsp_devtools/inspector/__init__.py similarity index 90% rename from lib/lsp-devtools/lsp_devtools/tui/__init__.py rename to lib/lsp-devtools/lsp_devtools/inspector/__init__.py index d914604..9c4d3df 100644 --- a/lib/lsp-devtools/lsp_devtools/tui/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/inspector/__init__.py @@ -9,7 +9,7 @@ from typing import Dict from typing import List -import appdirs +import platformdirs from rich.highlighter import ReprHighlighter from rich.text import Text from textual import events @@ -29,11 +29,9 @@ from lsp_devtools.agent import MESSAGE_TEXT_NOTIFICATION from lsp_devtools.agent import AgentServer from lsp_devtools.agent import MessageText +from lsp_devtools.database import Database from lsp_devtools.handlers import LspMessage -from .database import Database -from .database import PingMessage - logger = logging.getLogger(__name__) @@ -71,12 +69,14 @@ def walk_object(self, label: str, node: TreeNode, obj: Any): class MessagesTable(DataTable): """Datatable used to display all messages between client and server""" - def __init__(self, db: Database, viewer: MessageViewer): + def __init__(self, db: Database, viewer: MessageViewer, session=None): super().__init__() self.db = db + self.rpcdata: Dict[int, LspMessage] = {} self.max_row = 0 + self.session: Optional[str] = session self.viewer = viewer @@ -91,6 +91,8 @@ def __init__(self, db: Database, viewer: MessageViewer): @on(DataTable.RowHighlighted) def show_object(self, event: DataTable.RowHighlighted): """Show the message object on the currently highlighted row.""" + if event.cursor_row < 0: + return rowid = int(self.get_row_at(event.cursor_row)[0]) if (message := self.rpcdata.get(rowid, None)) is None: @@ -115,27 +117,30 @@ def show_object(self, event: DataTable.RowHighlighted): def _get_query_params(self): """Return the set of query parameters to use when populating the table.""" - return dict(max_row=self.max_row - 1) + query = dict(max_row=self.max_row) + + if self.session is not None: + query["session"] = self.session + + return query async def update(self): """Trigger a re-run of the query to pull in new data.""" query_params = self._get_query_params() messages = await self.db.get_messages(**query_params) - for message in messages: - self.max_row += 1 - self.rpcdata[self.max_row] = message + for idx, message in messages: + self.max_row = idx + self.rpcdata[idx] = message # Surely there's a more direct way to do this? dt = datetime.fromtimestamp(message.timestamp) time = dt.isoformat(timespec="milliseconds") time = time[time.find("T") + 1 :] - self.add_row( - str(self.max_row), time, message.source, message.id, message.method - ) + self.add_row(str(idx), time, message.source, message.id, message.method) - self.move_cursor(row=self.max_row, animate=True) + self.move_cursor(row=self.row_count, animate=True) class Sidebar(Container): @@ -160,7 +165,7 @@ def __init__(self, db: Database, server: AgentServer, *args, **kwargs): self.server = server """Server used to manage connections to lsp servers.""" - self._async_tasks = [] + self._async_tasks: List[asyncio.Task] = [] def compose(self) -> ComposeResult: yield Header() @@ -185,10 +190,6 @@ def action_toggle_sidebar(self) -> None: self.screen.set_focus(None) sidebar.add_class("-hidden") - @on(PingMessage) - async def on_ping(self, message: PingMessage): - await self.update_table() - async def on_ready(self, event: Ready): self._async_tasks.append( asyncio.create_task(self.server.start_tcp("localhost", 8765)) @@ -262,8 +263,8 @@ def tui(args, extra: List[str]): def cli(commands: argparse._SubParsersAction): cmd: argparse.ArgumentParser = commands.add_parser( - "tui", - help="launch TUI", + "inspect", + help="launch an interactive LSP session inspector", description="""\ This command opens a text user interface that can be used to inspect and manipulate an LSP session interactively. @@ -271,7 +272,7 @@ def cli(commands: argparse._SubParsersAction): ) default_db = pathlib.Path( - appdirs.user_cache_dir(appname="lsp-devtools", appauthor="swyddfa"), + platformdirs.user_cache_dir(appname="lsp-devtools", appauthor="swyddfa"), "sessions.db", ) cmd.add_argument( diff --git a/lib/lsp-devtools/lsp_devtools/tui/app.css b/lib/lsp-devtools/lsp_devtools/inspector/app.css similarity index 100% rename from lib/lsp-devtools/lsp_devtools/tui/app.css rename to lib/lsp-devtools/lsp_devtools/inspector/app.css diff --git a/lib/lsp-devtools/pyproject.toml b/lib/lsp-devtools/pyproject.toml index 8bead31..08965b3 100644 --- a/lib/lsp-devtools/pyproject.toml +++ b/lib/lsp-devtools/pyproject.toml @@ -22,10 +22,10 @@ classifiers = [ "Programming Language :: Python :: 3.11", ] dependencies = [ - "appdirs", "aiosqlite", "importlib-resources; python_version<\"3.9\"", - "pygls", + "platformdirs", + "pygls>=1.1.0", "stamina", "textual>=0.38.0", "typing-extensions; python_version<\"3.8\"", @@ -46,7 +46,6 @@ dev = [ typecheck=[ "mypy", "importlib_resources", - "types-appdirs", "types-setuptools", ] prometheus = ["prometheus_client"] From b06017d9a65585daa849e46c7dc16a5b3d9e4550 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 6 Oct 2023 18:24:31 +0100 Subject: [PATCH 54/63] lsp-devtools: More compact towncrier settings --- lib/lsp-devtools/pyproject.toml | 37 +++++++-------------------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/lib/lsp-devtools/pyproject.toml b/lib/lsp-devtools/pyproject.toml index 08965b3..8e25117 100644 --- a/lib/lsp-devtools/pyproject.toml +++ b/lib/lsp-devtools/pyproject.toml @@ -75,32 +75,11 @@ title_format = "v{version} - {project_date}" issue_format = "`#{issue} `_" underlines = ["-", "^", "\""] -[[tool.towncrier.type]] -directory = "feature" -name = "Features" -showcontent = true - -[[tool.towncrier.type]] -directory = "fix" -name = "Fixes" -showcontent = true - -[[tool.towncrier.type]] -directory = "doc" -name = "Docs" -showcontent = true - -[[tool.towncrier.type]] -directory = "breaking" -name = "Breaking Changes" -showcontent = true - -[[tool.towncrier.type]] -directory = "deprecated" -name = "Deprecated" -showcontent = true - -[[tool.towncrier.type]] -directory = "misc" -name = "Misc" -showcontent = true +type = [ + { name = "Features", directory = "feature", showcontent = true }, + { name = "Fixes", directory = "fix", showcontent = true }, + { name = "Docs", directory = "doc", showcontent = true }, + { name = "Breaking Changes", directory = "breaking", showcontent = true }, + { name = "Deprecated", directory = "deprecated", showcontent = true }, + { name = "Misc", directory = "misc", showcontent = true }, +] From d640561fde158a0474919c62d6ae7c9453e4c0ff Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 6 Oct 2023 18:46:00 +0100 Subject: [PATCH 55/63] lsp-devtools: Linting fixes --- .pre-commit-config.yaml | 3 ++- lib/lsp-devtools/lsp_devtools/agent/client.py | 2 +- lib/lsp-devtools/lsp_devtools/agent/server.py | 6 +++++- lib/lsp-devtools/lsp_devtools/client/__init__.py | 5 ----- lib/lsp-devtools/lsp_devtools/client/editor.py | 12 +++++++++--- lib/lsp-devtools/lsp_devtools/client/lsp.py | 1 - lib/lsp-devtools/lsp_devtools/database.py | 13 ++++++++----- lib/lsp-devtools/lsp_devtools/inspector/__init__.py | 10 ++++++---- 8 files changed, 31 insertions(+), 21 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1256056..c906bc0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,8 +54,9 @@ repos: - aiosqlite - attrs - importlib-resources + - platformdirs - pygls + - stamina - textual - - types-appdirs - websockets files: 'lib/lsp-devtools/lsp_devtools/.*\.py' diff --git a/lib/lsp-devtools/lsp_devtools/agent/client.py b/lib/lsp-devtools/lsp_devtools/agent/client.py index f36c8f9..03f5662 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/client.py +++ b/lib/lsp-devtools/lsp_devtools/agent/client.py @@ -64,7 +64,7 @@ async def start_tcp(self, host: str, port: int): with attempt: reader, writer = await asyncio.open_connection(host, port) - self.protocol.connection_made(writer) + self.protocol.connection_made(writer) # type: ignore[arg-type] connection = asyncio.create_task( aio_readline(self._stop_event, reader, self.protocol.data_received) ) diff --git a/lib/lsp-devtools/lsp_devtools/agent/server.py b/lib/lsp-devtools/lsp_devtools/agent/server.py index 41f20f8..f9633a2 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/server.py +++ b/lib/lsp-devtools/lsp_devtools/agent/server.py @@ -11,6 +11,7 @@ from lsp_devtools.agent.protocol import AgentProtocol from lsp_devtools.agent.protocol import MessageText +from lsp_devtools.database import Database class AgentServer(Server): @@ -27,6 +28,9 @@ def __init__(self, *args, **kwargs): kwargs["converter_factory"] = default_converter super().__init__(*args, **kwargs) + + self.db: Optional[Database] = None + self._client_buffer = [] self._server_buffer = [] self._stop_event = threading.Event() @@ -35,7 +39,7 @@ def __init__(self, *args, **kwargs): def feature(self, feature_name: str, options: Optional[Any] = None): return self.lsp.fm.feature(feature_name, options) - async def start_tcp(self, host: str, port: int) -> None: + async def start_tcp(self, host: str, port: int) -> None: # type: ignore[override] async def handle_client(reader, writer): self.lsp.connection_made(writer) await aio_readline(self._stop_event, reader, self.lsp.data_received) diff --git a/lib/lsp-devtools/lsp_devtools/client/__init__.py b/lib/lsp-devtools/lsp_devtools/client/__init__.py index bf281e6..23f6024 100644 --- a/lib/lsp-devtools/lsp_devtools/client/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/client/__init__.py @@ -4,16 +4,12 @@ import os import pathlib from typing import List -from typing import Optional -from typing import Set from uuid import uuid4 import platformdirs from lsprotocol import types from pygls import uris as uri -from pygls.capabilities import get_capability from textual import events -from textual import log from textual import on from textual.app import App from textual.app import ComposeResult @@ -22,7 +18,6 @@ from textual.widgets import DirectoryTree from textual.widgets import Footer from textual.widgets import Header -from textual.widgets import TextArea from lsp_devtools.agent import logger from lsp_devtools.database import Database diff --git a/lib/lsp-devtools/lsp_devtools/client/editor.py b/lib/lsp-devtools/lsp_devtools/client/editor.py index 0241976..fff312d 100644 --- a/lib/lsp-devtools/lsp_devtools/client/editor.py +++ b/lib/lsp-devtools/lsp_devtools/client/editor.py @@ -48,7 +48,7 @@ def on_blur(self, event: events.Blur): def action_dismiss(self): self.remove() if self.parent: - self.app.set_focus(self.parent) + self.app.set_focus(self.parent) # type: ignore class TextEditor(TextArea): @@ -65,7 +65,9 @@ def __init__(self, lsp_client: LanguageClient, *args, **kwargs): @property def completion_triggers(self): return get_capability( - self.capabilities, "completion_provider.trigger_characters", set() + self.capabilities, # type: ignore + "completion_provider.trigger_characters", + set(), ) def open_file(self, path: pathlib.Path): @@ -124,6 +126,10 @@ def edit(self, edit): def trigger_completion(self, line: int, character: int): """Trigger completion at the given location.""" + + if self.uri is None: + return + task = asyncio.create_task( self.lsp_client.text_document_completion_async( types.CompletionParams( @@ -152,4 +158,4 @@ def show_completions(self, task: asyncio.Task): @on(OptionList.OptionSelected) def completion_selected(self, event: OptionList.OptionSelected): log(f"{event.option} was selected!") - event.option_list.action_dismiss() + event.option_list.action_dismiss() # type: ignore diff --git a/lib/lsp-devtools/lsp_devtools/client/lsp.py b/lib/lsp-devtools/lsp_devtools/client/lsp.py index 5f14d18..897505f 100644 --- a/lib/lsp-devtools/lsp_devtools/client/lsp.py +++ b/lib/lsp-devtools/lsp_devtools/client/lsp.py @@ -5,7 +5,6 @@ from pygls.protocol import LanguageServerProtocol from lsp_devtools.agent import logger -from lsp_devtools.database import Database VERSION = importlib.metadata.version("lsp-devtools") diff --git a/lib/lsp-devtools/lsp_devtools/database.py b/lib/lsp-devtools/lsp_devtools/database.py index f96fecb..f3eff5d 100644 --- a/lib/lsp-devtools/lsp_devtools/database.py +++ b/lib/lsp-devtools/lsp_devtools/database.py @@ -4,12 +4,15 @@ import pathlib import sys from contextlib import asynccontextmanager +from typing import Any +from typing import Dict +from typing import List from typing import Optional from typing import Set from uuid import uuid4 import aiosqlite -from textual import log +from textual.app import App from textual.message import Message from lsp_devtools.handlers import LspMessage @@ -29,7 +32,7 @@ class Update(Message): def __init__(self, dbpath: Optional[pathlib.Path] = None): self.dbpath = dbpath or ":memory:" self.db: Optional[aiosqlite.Connection] = None - self.app = None + self.app: Optional[App] = None self._handlers: Dict[str, set] = {} async def close(self): @@ -104,8 +107,8 @@ async def get_messages( """ base_query = "SELECT rowid, * FROM protocol" - where = [] - parameters = [] + where: List[str] = [] + parameters: List[Any] = [] if session: where.append("session = ?") @@ -153,7 +156,7 @@ def __init__(self, db: Database, *args, session=None, **kwargs): self._tasks: Set[asyncio.Task] = set() def emit(self, record: logging.LogRecord): - body = json.loads(record.args[0]) + body = json.loads(record.args[0]) # type: ignore task = asyncio.create_task( self.db.add_message( self.session, record.created, record.__dict__["source"], body diff --git a/lib/lsp-devtools/lsp_devtools/inspector/__init__.py b/lib/lsp-devtools/lsp_devtools/inspector/__init__.py index 9c4d3df..a531c00 100644 --- a/lib/lsp-devtools/lsp_devtools/inspector/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/inspector/__init__.py @@ -8,12 +8,11 @@ from typing import Any from typing import Dict from typing import List +from typing import Optional import platformdirs from rich.highlighter import ReprHighlighter from rich.text import Text -from textual import events -from textual import log from textual import on from textual.app import App from textual.app import ComposeResult @@ -117,7 +116,7 @@ def show_object(self, event: DataTable.RowHighlighted): def _get_query_params(self): """Return the set of query parameters to use when populating the table.""" - query = dict(max_row=self.max_row) + query: Dict[str, Any] = dict(max_row=self.max_row) if self.session is not None: query["session"] = self.session @@ -243,7 +242,10 @@ async def handle_message(ls: AgentServer, message: MessageText): message_buf.clear() rpc = json.loads(body) - await ls.db.add_message(message.session, message.timestamp, message.source, rpc) + if ls.db is not None: + await ls.db.add_message( + message.session, message.timestamp, message.source, rpc + ) def setup_server(db: Database): From f0ecbe2c10005929c2645987660a696f12dd9ae4 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 6 Oct 2023 19:05:18 +0100 Subject: [PATCH 56/63] Mark 3.12 support --- lib/lsp-devtools/pyproject.toml | 1 + lib/pytest-lsp/pyproject.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/lsp-devtools/pyproject.toml b/lib/lsp-devtools/pyproject.toml index 8e25117..1d37dfc 100644 --- a/lib/lsp-devtools/pyproject.toml +++ b/lib/lsp-devtools/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] dependencies = [ "aiosqlite", diff --git a/lib/pytest-lsp/pyproject.toml b/lib/pytest-lsp/pyproject.toml index 25b87e8..81d55da 100644 --- a/lib/pytest-lsp/pyproject.toml +++ b/lib/pytest-lsp/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] dependencies = [ "importlib-resources; python_version<\"3.9\"", From 16840cdb20769dc6bae89f71b22019b4b6a15ac2 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 6 Oct 2023 19:05:37 +0100 Subject: [PATCH 57/63] pytest-lsp: Compact towncrier settings --- lib/pytest-lsp/pyproject.toml | 49 +++++++---------------------------- 1 file changed, 10 insertions(+), 39 deletions(-) diff --git a/lib/pytest-lsp/pyproject.toml b/lib/pytest-lsp/pyproject.toml index 81d55da..e3071cb 100644 --- a/lib/pytest-lsp/pyproject.toml +++ b/lib/pytest-lsp/pyproject.toml @@ -80,42 +80,13 @@ title_format = "v{version} - {project_date}" issue_format = "`#{issue} `_" underlines = ["-", "^", "\""] -[[tool.towncrier.type]] -directory = "feature" -name = "Features" -showcontent = true - -[[tool.towncrier.type]] -directory = "enhancement" -name = "Enhancements" -showcontent = true - -[[tool.towncrier.type]] -directory = "fix" -name = "Fixes" -showcontent = true - -[[tool.towncrier.type]] -directory = "doc" -name = "Docs" -showcontent = true - -[[tool.towncrier.type]] -directory = "breaking" -name = "Breaking Changes" -showcontent = true - -[[tool.towncrier.type]] -directory = "deprecated" -name = "Deprecated" -showcontent = true - -[[tool.towncrier.type]] -directory = "misc" -name = "Misc" -showcontent = true - -[[tool.towncrier.type]] -directory = "removed" -name = "Removed" -showcontent = true +type = [ + { name = "Features", directory = "feature", showcontent = true }, + { name = "Enhancements", directory = "enhancement", showcontent = true }, + { name = "Fixes", directory = "fix", showcontent = true }, + { name = "Docs", directory = "doc", showcontent = true }, + { name = "Breaking Changes", directory = "breaking", showcontent = true }, + { name = "Deprecated", directory = "deprecated", showcontent = true }, + { name = "Misc", directory = "misc", showcontent = true }, + { name = "Removed", directory = "removed", showcontent = true }, +] From 9caee54ed86cce2f7065db79d615f19a6c5b42e6 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 6 Oct 2023 19:06:43 +0100 Subject: [PATCH 58/63] pytest-lsp: Update pygls version --- docs/pytest-lsp/guide/troubleshooting.rst | 2 +- lib/pytest-lsp/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pytest-lsp/guide/troubleshooting.rst b/docs/pytest-lsp/guide/troubleshooting.rst index 55c8b32..bd93473 100644 --- a/docs/pytest-lsp/guide/troubleshooting.rst +++ b/docs/pytest-lsp/guide/troubleshooting.rst @@ -109,7 +109,7 @@ Depending on the version of ``pygls`` (the LSP implementation used by ``pytest-l -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html =========================== 1 passed, 1 warning in 0.64s ============================= -This is a known issue in ``pygls v1.0.2`` and older, upgrading your ``pygls`` version to ``TBD`` should resolve the issue. +This is a known issue in ``pygls v1.0.2`` and older, upgrading your ``pygls`` version to ``1.1.0`` or newer should resolve the issue. .. note:: diff --git a/lib/pytest-lsp/pyproject.toml b/lib/pytest-lsp/pyproject.toml index e3071cb..a797d67 100644 --- a/lib/pytest-lsp/pyproject.toml +++ b/lib/pytest-lsp/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] dependencies = [ "importlib-resources; python_version<\"3.9\"", - "pygls>=1.0.0", + "pygls>=1.1.0", "pytest", "pytest-asyncio", ] From 481bd936798fa5f777901498b6be9c1678eca5e6 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 6 Oct 2023 19:19:45 +0100 Subject: [PATCH 59/63] pytest-lsp: Typing fixes --- .pre-commit-config.yaml | 3 +- lib/pytest-lsp/pytest_lsp/client.py | 85 ++++++++++++--------------- lib/pytest-lsp/pytest_lsp/plugin.py | 3 +- lib/pytest-lsp/pytest_lsp/protocol.py | 2 +- 4 files changed, 44 insertions(+), 49 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c906bc0..4c1202c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,10 +41,11 @@ repos: args: [--explicit-package-bases,--check-untyped-defs] additional_dependencies: - importlib-resources + - platformdirs - pygls - pytest - pytest-asyncio - - types-appdirs + - websockets files: 'lib/pytest-lsp/pytest_lsp/.*\.py' - id: mypy diff --git a/lib/pytest-lsp/pytest_lsp/client.py b/lib/pytest-lsp/pytest_lsp/client.py index 6eabb3c..7a60d18 100644 --- a/lib/pytest-lsp/pytest_lsp/client.py +++ b/lib/pytest-lsp/pytest_lsp/client.py @@ -7,23 +7,12 @@ from typing import Dict from typing import List from typing import Optional -from typing import Type +from typing import Union +from lsprotocol import types from lsprotocol.converters import get_converter -from lsprotocol.types import TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS -from lsprotocol.types import WINDOW_LOG_MESSAGE -from lsprotocol.types import WINDOW_SHOW_DOCUMENT -from lsprotocol.types import WINDOW_SHOW_MESSAGE -from lsprotocol.types import ClientCapabilities -from lsprotocol.types import Diagnostic -from lsprotocol.types import InitializedParams -from lsprotocol.types import InitializeParams -from lsprotocol.types import InitializeResult -from lsprotocol.types import LogMessageParams -from lsprotocol.types import PublishDiagnosticsParams -from lsprotocol.types import ShowDocumentParams -from lsprotocol.types import ShowDocumentResult -from lsprotocol.types import ShowMessageParams +from pygls.exceptions import JsonRpcException +from pygls.exceptions import PyglsError from pygls.lsp.client import BaseLanguageClient from pygls.protocol import default_converter @@ -42,30 +31,28 @@ class LanguageClient(BaseLanguageClient): """Used to drive language servers under test.""" - def __init__( - self, - protocol_cls: Type[LanguageClientProtocol] = LanguageClientProtocol, - *args, - **kwargs, - ): - super().__init__( - "pytest-lsp-client", __version__, protocol_cls=protocol_cls, *args, **kwargs - ) + protocol: LanguageClientProtocol + + def __init__(self, *args, **kwargs): + if "protocol_cls" not in kwargs: + kwargs["protocol_cls"] = LanguageClientProtocol - self.capabilities: Optional[ClientCapabilities] = None + super().__init__("pytest-lsp-client", __version__, *args, **kwargs) + + self.capabilities: Optional[types.ClientCapabilities] = None """The client's capabilities.""" - self.shown_documents: List[ShowDocumentParams] = [] + self.shown_documents: List[types.ShowDocumentParams] = [] """Used to keep track of the documents requested to be shown via a ``window/showDocument`` request.""" - self.messages: List[ShowMessageParams] = [] + self.messages: List[types.ShowMessageParams] = [] """Holds any received ``window/showMessage`` requests.""" - self.log_messages: List[LogMessageParams] = [] + self.log_messages: List[types.LogMessageParams] = [] """Holds any received ``window/logMessage`` requests.""" - self.diagnostics: Dict[str, List[Diagnostic]] = {} + self.diagnostics: Dict[str, List[types.Diagnostic]] = {} """Used to hold any recieved diagnostics.""" self.error: Optional[Exception] = None @@ -86,8 +73,8 @@ async def server_exit(self, server: asyncio.subprocess.Process): stderr = "" if server.stderr is not None: - stderr = await server.stderr.read() - stderr = stderr.decode("utf8") + stderr_bytes = await server.stderr.read() + stderr = stderr_bytes.decode("utf8") loop = asyncio.get_running_loop() loop.call_soon( @@ -95,13 +82,15 @@ async def server_exit(self, server: asyncio.subprocess.Process): f"Server process exited with return code: {server.returncode}\n{stderr}", ) - def report_server_error(self, error: Exception, source: Type[Exception]): + def report_server_error( + self, error: Exception, source: Union[PyglsError, JsonRpcException] + ): """Called when the server does something unexpected, e.g. sending malformed JSON.""" self.error = error tb = "".join(traceback.format_exc()) - message = f"{source.__name__}: {error}\n{tb}" + message = f"{source.__name__}: {error}\n{tb}" # type: ignore loop = asyncio.get_running_loop() loop.call_soon(cancel_all_tasks, message) @@ -109,7 +98,9 @@ def report_server_error(self, error: Exception, source: Type[Exception]): if self._stop_event: self._stop_event.set() - async def initialize_session(self, params: InitializeParams) -> InitializeResult: + async def initialize_session( + self, params: types.InitializeParams + ) -> types.InitializeResult: """Make an ``initialize`` request to a lanaguage server. It will also automatically send an ``initialized`` notification once @@ -135,7 +126,7 @@ async def initialize_session(self, params: InitializeParams) -> InitializeResult params.process_id = os.getpid() response = await self.initialize_async(params) - self.initialized(InitializedParams()) + self.initialized(types.InitializedParams()) return response @@ -186,32 +177,34 @@ def make_test_lsp_client() -> LanguageClient: converter_factory=default_converter, ) - @client.feature(TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS) - def publish_diagnostics(client: LanguageClient, params: PublishDiagnosticsParams): + @client.feature(types.TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS) + def publish_diagnostics( + client: LanguageClient, params: types.PublishDiagnosticsParams + ): client.diagnostics[params.uri] = params.diagnostics - @client.feature(WINDOW_LOG_MESSAGE) - def log_message(client: LanguageClient, params: LogMessageParams): + @client.feature(types.WINDOW_LOG_MESSAGE) + def log_message(client: LanguageClient, params: types.LogMessageParams): client.log_messages.append(params) levels = [logger.error, logger.warning, logger.info, logger.debug] levels[params.type.value - 1](params.message) - @client.feature(WINDOW_SHOW_MESSAGE) + @client.feature(types.WINDOW_SHOW_MESSAGE) def show_message(client: LanguageClient, params): client.messages.append(params) - @client.feature(WINDOW_SHOW_DOCUMENT) + @client.feature(types.WINDOW_SHOW_DOCUMENT) def show_document( - client: LanguageClient, params: ShowDocumentParams - ) -> ShowDocumentResult: + client: LanguageClient, params: types.ShowDocumentParams + ) -> types.ShowDocumentResult: client.shown_documents.append(params) - return ShowDocumentResult(success=True) + return types.ShowDocumentResult(success=True) return client -def client_capabilities(client_spec: str) -> ClientCapabilities: +def client_capabilities(client_spec: str) -> types.ClientCapabilities: """Find the capabilities that correspond to the given client spec. Parameters @@ -241,4 +234,4 @@ def client_capabilities(client_spec: str) -> ClientCapabilities: converter = get_converter() capabilities = json.loads(filename.read_text()) - return converter.structure(capabilities, ClientCapabilities) + return converter.structure(capabilities, types.ClientCapabilities) diff --git a/lib/pytest-lsp/pytest_lsp/plugin.py b/lib/pytest-lsp/pytest_lsp/plugin.py index 82328cf..af7e94c 100644 --- a/lib/pytest-lsp/pytest_lsp/plugin.py +++ b/lib/pytest-lsp/pytest_lsp/plugin.py @@ -3,6 +3,7 @@ import sys import textwrap import typing +from typing import Any from typing import Callable from typing import Dict from typing import List @@ -104,7 +105,7 @@ def get_fixture_arguments( dict The set of arguments to pass to the user's fixture function """ - kwargs = {} + kwargs: Dict[str, Any] = {} required_parameters = set(inspect.signature(fn).parameters.keys()) # Inject the 'request' fixture if requested diff --git a/lib/pytest-lsp/pytest_lsp/protocol.py b/lib/pytest-lsp/pytest_lsp/protocol.py index a521ec1..24adfb2 100644 --- a/lib/pytest-lsp/pytest_lsp/protocol.py +++ b/lib/pytest-lsp/pytest_lsp/protocol.py @@ -41,7 +41,7 @@ def _handle_notification(self, method_name, params): async def send_request_async(self, method, params=None): result = await super().send_request_async(method, params) check_result_against_client_capabilities( - self._server.capabilities, method, result + self._server.capabilities, method, result # type: ignore ) return result From 0785ce405c4cbde486dd283d20a5307a09ebe5c2 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 6 Oct 2023 19:42:28 +0100 Subject: [PATCH 60/63] workflow: Switch to using trusted publishers --- .github/workflows/lsp-devtools-release.yml | 66 +++++++++ .github/workflows/pytest-lsp-release.yml | 67 +++++++++ .github/workflows/release.yml | 157 --------------------- 3 files changed, 133 insertions(+), 157 deletions(-) create mode 100644 .github/workflows/lsp-devtools-release.yml create mode 100644 .github/workflows/pytest-lsp-release.yml delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/lsp-devtools-release.yml b/.github/workflows/lsp-devtools-release.yml new file mode 100644 index 0000000..cb486da --- /dev/null +++ b/.github/workflows/lsp-devtools-release.yml @@ -0,0 +1,66 @@ +name: Release: lsp-devtools + +on: + push: + branches: + - release + paths: + - 'lib/lsp-devtools/**' + +jobs: + release: + name: lsp-devtools release + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/lsp-devtools + permissions: + id-token: write + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - run: | + sudo apt update + sudo apt install pandoc + + python --version + python -m pip install --upgrade pip + python -m pip install build bump2version towncrier docutils + name: Install Build Tools + + - run: | + set -e + + ./scripts/make-release.sh lsp-devtools + name: Set Version + id: info + + - name: Package + run: | + cd lib/lsp-devtools + python -m build + + - name: 'Upload Artifact' + uses: actions/upload-artifact@v3 + with: + name: 'dist' + path: lib/lsp-devtools/dist + + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: lib/lsp-devtools/dist/ + + - name: Create Release + run: | + gh release create "${RELEASE_TAG}" \ + --title "lsp-devtools v${VERSION} - ${RELEASE_DATE}" \ + -F lib/lsp-devtools/.changes.html \ + ./lib/lsp-devtools/dist/* + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pytest-lsp-release.yml b/.github/workflows/pytest-lsp-release.yml new file mode 100644 index 0000000..4380593 --- /dev/null +++ b/.github/workflows/pytest-lsp-release.yml @@ -0,0 +1,67 @@ +name: Release: pytest-lsp + +on: + push: + branches: + - release + paths: + - 'lib/pytest-lsp/**' + +jobs: + release: + name: pytest-lsp release + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/pytest-lsp + permissions: + id-token: write + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - run: | + sudo apt update + sudo apt install pandoc + + python --version + python -m pip install --upgrade pip + python -m pip install build bump2version towncrier docutils + + name: Install Build Tools + + - run: | + set -e + + ./scripts/make-release.sh pytest-lsp + name: Set Version + id: info + + - name: Package + run: | + cd lib/pytest-lsp + python -m build + + - name: 'Upload Artifact' + uses: actions/upload-artifact@v3 + with: + name: 'dist' + path: lib/pytest-lsp/dist + + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: lib/pytest-lsp/dist/ + + - name: Create Release + run: | + gh release create "${RELEASE_TAG}" \ + --title "pytest-lsp v${VERSION} - ${RELEASE_DATE}" \ + -F lib/pytest-lsp/.changes.html \ + ./lib/pytest-lsp/dist/* + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index d3a8814..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,157 +0,0 @@ -name: Release - -on: - push: - branches: - - release - -jobs: - # Simple job the checks to see which parts we actually have to build. - trigger: - name: Trigger - runs-on: ubuntu-latest - outputs: - lsp-devtools: ${{steps.check-lsp-devtools.outputs.build}} - pytest-lsp: ${{steps.check-pytest-lsp.outputs.build}} - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - run: | - if [ -z "${BASE_REF}" ]; then - echo "BASE=HEAD^" >> $GITHUB_ENV - else - echo "BASE=origin/${BASE_REF}" >> $GITHUB_ENV - fi - name: Determine base - env: - BASE_REF: ${{ github.base_ref }} - - - id: check-lsp-devtools - run: | - set -e - echo ${BASE} - - ./scripts/should-build.sh lsp-devtools - name: "Build lsp-devtools?" - - - id: check-pytest-lsp - run: | - set -e - echo ${BASE} - - ./scripts/should-build.sh pytest-lsp - name: "Build pytest-lsp?" - - lsp-devtools: - name: lsp-devtools - needs: trigger - if: always() && needs.trigger.outputs.lsp-devtools - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - - run: | - sudo apt update - sudo apt install pandoc - - python --version - python -m pip install --upgrade pip - python -m pip install build bump2version towncrier docutils - name: Install Build Tools - - - run: | - set -e - - ./scripts/make-release.sh lsp-devtools - name: Set Version - id: info - - - name: Package - run: | - cd lib/lsp-devtools - python -m build - - - name: 'Upload Artifact' - uses: actions/upload-artifact@v3 - with: - name: 'dist' - path: lib/lsp-devtools/dist - - - name: Publish - id: assets - run: | - cd lib/lsp-devtools - python -m pip install twine - python -m twine upload dist/* -u alcarney -p ${{ secrets.PYPI_PASS }} - - - name: Create Release - run: | - gh release create "${RELEASE_TAG}" \ - --title "lsp-devtools v${VERSION} - ${RELEASE_DATE}" \ - -F lib/lsp-devtools/.changes.html \ - ./lib/lsp-devtools/dist/* - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - pytest-lsp: - name: pytest-lsp - needs: trigger - if: always() && needs.trigger.outputs.pytest-lsp - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - - run: | - sudo apt update - sudo apt install pandoc - - python --version - python -m pip install --upgrade pip - python -m pip install build bump2version towncrier docutils - - name: Install Build Tools - - - run: | - set -e - - ./scripts/make-release.sh pytest-lsp - name: Set Version - id: info - - - name: Package - run: | - cd lib/pytest-lsp - python -m build - - - name: 'Upload Artifact' - uses: actions/upload-artifact@v3 - with: - name: 'dist' - path: lib/pytest-lsp/dist - - - name: Publish - run: | - cd lib/pytest-lsp - python -m pip install twine - python -m twine upload dist/* -u alcarney -p ${{ secrets.PYPI_PASS }} - - - name: Create Release - run: | - gh release create "${RELEASE_TAG}" \ - --title "pytest-lsp v${VERSION} - ${RELEASE_DATE}" \ - -F lib/pytest-lsp/.changes.html \ - ./lib/pytest-lsp/dist/* - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From c0c8f692942713cf3283c3e185063bc91a7694ca Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 6 Oct 2023 19:51:37 +0100 Subject: [PATCH 61/63] Add .readthedocs.yaml --- .readthedocs.yaml | 23 +++++++++++++++++++++++ docs/requirements.txt | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..411b29d --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,23 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Optional but recommended, declare the Python requirements required +# to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - requirements: docs/requirements.txt diff --git a/docs/requirements.txt b/docs/requirements.txt index 81a7b98..8476ae3 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,5 +4,5 @@ sphinx sphinx-copybutton sphinx-design furo -git+https://github.com/openlawlibrary/pygls.git#egg=pygls +pygls>=1.1.0 -e lib/pytest-lsp From 8f54c853539bafa40b4bf68c5b6fe7b5c6d25327 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 6 Oct 2023 19:52:34 +0100 Subject: [PATCH 62/63] workflow: Fix syntax --- .github/workflows/lsp-devtools-release.yml | 2 +- .github/workflows/pytest-lsp-release.yml | 2 +- .pre-commit-config.yaml | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lsp-devtools-release.yml b/.github/workflows/lsp-devtools-release.yml index cb486da..05d20b2 100644 --- a/.github/workflows/lsp-devtools-release.yml +++ b/.github/workflows/lsp-devtools-release.yml @@ -1,4 +1,4 @@ -name: Release: lsp-devtools +name: 'Release: lsp-devtools' on: push: diff --git a/.github/workflows/pytest-lsp-release.yml b/.github/workflows/pytest-lsp-release.yml index 4380593..4903dd5 100644 --- a/.github/workflows/pytest-lsp-release.yml +++ b/.github/workflows/pytest-lsp-release.yml @@ -1,4 +1,4 @@ -name: Release: pytest-lsp +name: 'Release: pytest-lsp' on: push: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4c1202c..c5c5f50 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,7 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: + - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace From dc6fc53a16fac274cf0e86ad4f99508d2bef6d85 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 6 Oct 2023 19:58:15 +0100 Subject: [PATCH 63/63] workflow: Remove documentation workflow --- .github/workflows/docs.yml | 59 -------------------------------------- 1 file changed, 59 deletions(-) delete mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 6dca44f..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Documentation -on: - pull_request: - branches: - - release - - develop - paths: - - 'docs/**' - - 'lib/pytest-lsp/**' - push: - branches: - - release - - develop - paths: - - 'docs/**' - - 'lib/pytest-lsp/**' - -jobs: - docs: - name: Documentation - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - - run: | - set -e - - python --version - python -m pip install --upgrade pip - python -m pip install -r docs/requirements.txt - - name: Setup Environment - - - id: build - run: | - set -e - - cd docs - make html - name: Build Docs - - - name: 'Upload Aritfact' - uses: actions/upload-artifact@v3 - with: - name: 'docs' - path: 'docs/_build/${{ steps.build.outputs.version }}' - - - name: 'Publish Docs' - uses: JamesIves/github-pages-deploy-action@v4 - with: - branch: gh-pages - folder: docs/_build/${{ steps.build.outputs.version }} - target-folder: docs/${{ steps.build.outputs.version }} - clean: true - if: success() && ( startsWith(github.ref, 'refs/heads/release') || startsWith(github.ref, 'refs/heads/develop') )