Skip to content

Commit

Permalink
Merge pull request #36 from swyddfa/develop
Browse files Browse the repository at this point in the history
New Release
  • Loading branch information
alcarney authored Jan 14, 2023
2 parents 3543529 + d174929 commit 638035b
Show file tree
Hide file tree
Showing 43 changed files with 259 additions and 130 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ jobs:
set -e
cd lib/lsp-devtools
tox -e mypy
tox -e py310
- name: Package
run: |
Expand Down
55 changes: 55 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
exclude: '.bumpversion.cfg$'
repos:

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace

- repo: https://github.com/psf/black
rev: 22.12.0
hooks:
- id: black
exclude: 'lib/pytest-lsp/pytest_lsp/gen.py'

- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
hooks:
- id: flake8
args: [--config=lib/lsp-devtools/setup.cfg]

- repo: https://github.com/pycqa/isort
rev: 5.11.4
hooks:
- id: isort
name: isort (python)
args: [--profile,black,--force-single-line]
exclude: 'lib/pytest-lsp/pytest_lsp/gen.py'

- repo: https://github.com/pre-commit/mirrors-mypy
rev: 'v0.991'
hooks:
- id: mypy
name: mypy (pytest-lsp)
args: [--explicit-package-bases,--check-untyped-defs]
additional_dependencies:
- importlib-resources
- pygls
- pytest
- pytest-asyncio
- types-appdirs
files: 'lib/pytest-lsp/pytest_lsp/.*\.py'

- id: mypy
name: mypy (lsp-devtools)
args: [--explicit-package-bases,--check-untyped-defs]
additional_dependencies:
- aiosqlite
- attrs
- importlib-resources
- pygls
- textual
- types-appdirs
- websockets
files: 'lib/lsp-devtools/lsp_devtools/.*\.py'
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@
]
}
]
}
}
65 changes: 64 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,65 @@
# LSP Devtools
<h1 align="center">LSP Devtools</h1>

[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/swyddfa/lsp-devtools/develop.svg)](https://results.pre-commit.ci/latest/github/swyddfa/lsp-devtools/develop)

This repo is an attempt at building the developer tooling I wished existed when I first started working on [Esbonio](https://github.com/swyddfa/esbonio/).

**Everything here is early in its development, so expect plenty of bugs and missing features.**

This is a monorepo containing a number of sub-projects.

## `lib/lsp-devtools` - A grab bag of development utilities

[![PyPI](https://img.shields.io/pypi/v/lsp-devtools?style=flat-square)](https://pypi.org/project/lsp-devtools)[![PyPI - Downloads](https://img.shields.io/pypi/dm/lsp-devtools?style=flat-square)](https://pypistats.org/packages/lsp-devtools)[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://github.com/swyddfa/lsp-devtools/blob/develop/lib/lsp-devtools/LICENSE)

![TUI Screenshot](https://user-images.githubusercontent.com/2675694/212438877-d332dd84-14b4-4568-b36f-4c3e04d4f95f.png)

A collection of cli utilities aimed at aiding the development of language servers and/or clients.

- `agent`: Used to wrap an lsp server allowing messages sent between it and the client to be intercepted and inspected by other tools.
- `record`: Connects to an agent and record traffic to file, sqlite db or console. Supports filtering and formatting the output
- `tui`: A text user interface to visualise and inspect LSP traffic. Powered by [textual](https://textual.textualize.io/)

## `lib/pytest-lsp` - End-to-end testing of language servers with pytest

[![PyPI](https://img.shields.io/pypi/v/pytest-lsp?style=flat-square)](https://pypi.org/project/pytest-lsp)[![PyPI - Downloads](https://img.shields.io/pypi/dm/pytest-lsp?style=flat-square)](https://pypistats.org/packages/pytest-lsp)[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://github.com/swyddfa/lsp-devtools/blob/develop/lib/pytest-lsp/LICENSE)

`pytest-lsp` is a pytest plugin for writing end-to-end tests for language servers.

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.

`pytest-lsp` relies on the [`pygls`](https://github.com/openlawlibrary/pygls) library for its language server protocol implementation.

```python
import sys
import pytest
import pytest_lsp
from pytest_lsp import ClientServerConfig


@pytest_lsp.fixture(
scope='session',
config=ClientServerConfig(
server_command=[sys.executable, "-m", "esbonio"],
root_uri="file:///path/to/test/project/root/"
),
)
async def client():
pass


@pytest.mark.asyncio
async def test_completion(client):
test_uri="file:///path/to/test/project/root/test_file.rst"
result = await client.completion_request(test_uri, line=5, character=23)

assert len(result.items) > 0
```

## `app/` - Prototype Devtools Web UI

![UI Screenshot](https://user-images.githubusercontent.com/2675694/191863035-5bb5d1c9-00b6-40de-b3e2-f81cdb9eb375.png)

This is little more than a proof of concept, currently setup to communicate with an agent over websockets.
Hopefully, this can eventually be repurposed/extended to be used on lsp servers hosted entirely in the browser e.g. pyodide.
19 changes: 8 additions & 11 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information

project = 'LSP Devtools'
copyright = '2022, Alex Carney'
author = 'Alex Carney'
project = "LSP Devtools"
copyright = "2022, Alex Carney"
author = "Alex Carney"

# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
Expand All @@ -23,18 +23,15 @@
autodoc_member_order = "groupwise"
autodoc_typehints = "description"

intersphinx_mapping = {
"python": ("https://docs.python.org/3/", None)
}

templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
intersphinx_mapping = {"python": ("https://docs.python.org/3/", None)}

templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]


# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output

html_theme = 'furo'
html_theme = "furo"
html_title = "LSP Devtools"
html_static_path = ['_static']
html_static_path = ["_static"]
2 changes: 0 additions & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,3 @@ This also means ``pytest-lsp`` can be used to test language servers written in a
result = await client.completion_request(test_uri, line=5, character=23)
assert len(result.items) > 0
1 change: 0 additions & 1 deletion docs/pytest-lsp/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,3 @@ API Reference

.. autoclass:: ClientServer
:members:

1 change: 1 addition & 0 deletions lib/lsp-devtools/MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
include lsp_devtools/handlers/dbinit.sql
include lsp_devtools/py.typed
include lsp_devtools/tui/app.css
1 change: 1 addition & 0 deletions lib/lsp-devtools/changes/33.fix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix PyPi packaging
11 changes: 3 additions & 8 deletions lib/lsp-devtools/lsp_devtools/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

from pygls.server import aio_readline


logger = logging.getLogger("lsp_devtools.agent")


Expand All @@ -32,7 +31,7 @@ async def check_server_process(

while not stop_event.is_set():
retcode = server_process.poll()
print('.')
print(".")
if retcode is not None:

# Cancel any pending tasks.
Expand Down Expand Up @@ -68,9 +67,7 @@ async def start(self):
executor=self.thread_pool_executor,
stop_event=self.stop_event,
rfile=self.stdin,
proxy=partial(
forward_message, "client", self.server_process.stdin
),
proxy=partial(forward_message, "client", self.server_process.stdin),
)

# Connect the subprocess' stdout to stdout
Expand All @@ -79,9 +76,7 @@ async def start(self):
executor=self.thread_pool_executor,
stop_event=self.stop_event,
rfile=self.server_process.stdout,
proxy=partial(
forward_message, "server", self.stdout
),
proxy=partial(forward_message, "server", self.stdout),
)

# Run both connections concurrently.
Expand Down
4 changes: 3 additions & 1 deletion lib/lsp-devtools/lsp_devtools/agent/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ def start_ws_client(self, host: str, port: int):
async def client_connection(host: str, port: int):
"""Create and run a client connection."""

self._client = await websockets.connect(f"ws://{host}:{port}") # type: ignore
self._client = await websockets.connect( # type: ignore
f"ws://{host}:{port}"
)
self.lsp.transport = WebSocketClientTransportAdapter(
self._client, self.loop
)
Expand Down
1 change: 0 additions & 1 deletion lib/lsp-devtools/lsp_devtools/agent/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import attrs
from pygls.protocol import JsonRPCProtocol


MESSAGE_TEXT_NOTIFICATION = "message/text"


Expand Down
8 changes: 4 additions & 4 deletions lib/lsp-devtools/lsp_devtools/agent/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ class AgentServer(Server):

def __init__(self, *args, **kwargs):

if 'protocol_cls' not in kwargs:
kwargs['protocol_cls'] = AgentProtocol
if "protocol_cls" not in kwargs:
kwargs["protocol_cls"] = AgentProtocol

if 'converter_factory' not in kwargs:
kwargs['converter_factory'] = default_converter
if "converter_factory" not in kwargs:
kwargs["converter_factory"] = default_converter

super().__init__(*args, **kwargs)
1 change: 1 addition & 0 deletions lib/lsp-devtools/lsp_devtools/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def main():
cli = argparse.ArgumentParser(
prog="lsp-devtools", description="Development tooling for language servers"
)
cli.add_argument("--version", action="version", version=f"%(prog)s v{__version__}")
commands = cli.add_subparsers(title="commands")

for mod in BUILTIN_COMMANDS:
Expand Down
2 changes: 1 addition & 1 deletion lib/lsp-devtools/lsp_devtools/cmds/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def on_initialize(ls: LanguageServer, params: InitializeParams):
filename = f"{client_name}_v{client_version}.json"
with open(filename, "w") as f:
obj = params.capabilities
json.dump(ls.lsp._converter, f, indent=2)
json.dump(ls.lsp._converter.unstructure(obj), f, indent=2)

server.start_io()

Expand Down
13 changes: 9 additions & 4 deletions lib/lsp-devtools/lsp_devtools/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@

import attrs

MessageSource = Literal['client', 'server']
MessageSource = Literal["client", "server"]


@attrs.define
class LspMessage:
"""A container that holds a message from the LSP protocol, with some additional metadata."""
"""A container that holds a message from the LSP protocol, with some additional
metadata."""

Source = MessageSource

Expand Down Expand Up @@ -74,7 +76,7 @@ class LspHandler(logging.Handler):

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.session_id = ''
self.session_id = ""

def handle_message(self, message: LspMessage):
"""Called each time a message is processed."""
Expand All @@ -93,6 +95,9 @@ def emit(self, record: logging.LogRecord):

self.handle_message(
LspMessage.from_rpc(
session=self.session_id, timestamp=timestamp, source=source, message=message
session=self.session_id,
timestamp=timestamp,
source=source,
message=message,
)
)
5 changes: 2 additions & 3 deletions lib/lsp-devtools/lsp_devtools/record/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import argparse
import json
import logging
import pathlib
import re
from typing import List
from typing import Optional

Expand Down Expand Up @@ -48,7 +46,8 @@ def render(
# Abuse the log level column to display the source of the message,
source = record.__dict__["source"]
color = "red" if source == "client" else "blue"
res.columns[1]._cells[0] = f"[bold][{color}]{source.upper()}[/{color}][/bold]" # type: ignore
message_source = f"[bold][{color}]{source.upper()}[/{color}][/bold]"
res.columns[1]._cells[0] = message_source # type: ignore

return res

Expand Down
5 changes: 4 additions & 1 deletion lib/lsp-devtools/lsp_devtools/record/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ class LSPFilter(logging.Filter):
exclude_methods: Set[str] = attrs.field(factory=set, converter=set)
"""Exclude messages associated with the given method."""

formatter: FormatString = attrs.field(default="", converter=FormatString) # type: ignore
formatter: FormatString = attrs.field(
default="",
converter=FormatString,
) # type: ignore
"""Format messages according to the given string"""

_response_method_map: Dict[Union[int, str], str] = attrs.field(factory=dict)
Expand Down
3 changes: 2 additions & 1 deletion lib/lsp-devtools/lsp_devtools/tui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ def __init__(self, dbpath: pathlib.Path, *args, **kwargs):
"""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"""
"""Client used to interact with the LSPAgent hosting the server we're
inspecting."""

self.loop: Optional[asyncio.AbstractEventLoop] = None
"""Accessed by the AgentClient to push messages into the UI"""
Expand Down
1 change: 0 additions & 1 deletion lib/lsp-devtools/lsp_devtools/tui/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import typing
from functools import partial

from textual.app import App
from textual.message import Message

from lsp_devtools.agent import MESSAGE_TEXT_NOTIFICATION
Expand Down
2 changes: 1 addition & 1 deletion lib/lsp-devtools/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ prometheus = ["prometheus_client"]
lsp-devtools = "lsp_devtools.cli:main"

[tool.setuptools.packages.find]
include = ["lsp_devtools"]
include = ["lsp_devtools*"]

[tool.isort]
force_single_line = true
Expand Down
1 change: 1 addition & 0 deletions lib/lsp-devtools/setup.cfg
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
[flake8]
max-line-length = 88
ignore = E203,W503
2 changes: 1 addition & 1 deletion lib/lsp-devtools/setup.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from setuptools import setup

setup()
setup()
1 change: 0 additions & 1 deletion lib/lsp-devtools/tests/record/test_filters.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import itertools
import logging
from typing import List
from typing import Optional
from typing import Tuple

import pytest
Expand Down
Loading

0 comments on commit 638035b

Please sign in to comment.