lsp-devtools 0.2.2__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/CHANGES.md +25 -0
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/PKG-INFO +5 -9
- lsp_devtools-0.2.4/hatch.toml +19 -0
- lsp_devtools-0.2.4/lsp_devtools/__init__.py +1 -0
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/agent/__init__.py +20 -45
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/agent/agent.py +101 -28
- lsp_devtools-0.2.4/lsp_devtools/agent/client.py +73 -0
- lsp_devtools-0.2.4/lsp_devtools/agent/protocol.py +7 -0
- lsp_devtools-0.2.4/lsp_devtools/agent/server.py +92 -0
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/client/__init__.py +9 -6
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/client/editor/text_editor.py +2 -3
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/client/lsp.py +17 -2
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/database.py +16 -23
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/handlers/__init__.py +21 -21
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/handlers/sql.py +1 -6
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/inspector/__init__.py +27 -70
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/record/__init__.py +50 -36
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/record/filters.py +17 -15
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/record/formatters.py +15 -20
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/record/visualize.py +5 -8
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/pyproject.toml +4 -12
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/tests/record/test_filters.py +17 -19
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/tests/record/test_formatters.py +1 -1
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/tests/record/test_record.py +4 -6
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/tests/servers/simple.py +8 -2
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/tests/test_agent.py +9 -4
- lsp_devtools-0.2.2/hatch.toml +0 -9
- lsp_devtools-0.2.2/lsp_devtools/__init__.py +0 -1
- lsp_devtools-0.2.2/lsp_devtools/agent/client.py +0 -116
- lsp_devtools-0.2.2/lsp_devtools/agent/protocol.py +0 -47
- lsp_devtools-0.2.2/lsp_devtools/agent/server.py +0 -108
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/.gitignore +0 -0
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/LICENSE +0 -0
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/README.md +0 -0
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/__main__.py +0 -0
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/cli.py +0 -0
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/client/app.css +0 -0
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/client/editor/__init__.py +0 -0
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/client/editor/completion.py +0 -0
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/handlers/dbinit.sql +0 -0
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/inspector/app.css +0 -0
- {lsp_devtools-0.2.2 → lsp_devtools-0.2.4}/lsp_devtools/py.typed +0 -0
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
## v0.2.4 - 2024-11-23
|
|
2
|
+
|
|
3
|
+
### Fixes
|
|
4
|
+
|
|
5
|
+
- The `lsp-devtools agent` should now suppress `asyncio.CancelledError` exceptions allowing the agent to process to terminate gracefully ([#191](https://github.com/swyddfa/lsp-devtools/issues/191))
|
|
6
|
+
|
|
7
|
+
### Misc
|
|
8
|
+
|
|
9
|
+
- Drop Python 3.8 support ([#190](https://github.com/swyddfa/lsp-devtools/issues/190))
|
|
10
|
+
- Migrate to pygls `v2.0a2` ([#192](https://github.com/swyddfa/lsp-devtools/issues/192))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## v0.2.3 - 2024-05-22
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Enhancements
|
|
17
|
+
|
|
18
|
+
- The `lsp-devtools agent` now forwards the server's `stderr` channel ([#165](https://github.com/swyddfa/lsp-devtools/issues/165))
|
|
19
|
+
|
|
20
|
+
### Fixes
|
|
21
|
+
|
|
22
|
+
- All `lsp-devtools` commands should no longer crash when encountering messages containing unicode characters ([#157](https://github.com/swyddfa/lsp-devtools/issues/157))
|
|
23
|
+
- Commands like `lsp-devtools record` should now continue to function after encountering an error ([#158](https://github.com/swyddfa/lsp-devtools/issues/158))
|
|
24
|
+
|
|
25
|
+
|
|
1
26
|
## v0.2.2 - 2024-01-29
|
|
2
27
|
|
|
3
28
|
|
|
@@ -1,31 +1,27 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: lsp-devtools
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Developer tooling for language servers
|
|
5
5
|
Project-URL: Bug Tracker, https://github.com/swyddfa/lsp-devtools/issues
|
|
6
|
-
Project-URL: Documentation, https://
|
|
6
|
+
Project-URL: Documentation, https://lsp-devtools.readthedocs.io/en/latest/
|
|
7
7
|
Project-URL: Source Code, https://github.com/swyddfa/lsp-devtools
|
|
8
8
|
Author-email: Alex Carney <alcarneyme@gmail.com>
|
|
9
9
|
License: MIT
|
|
10
|
-
License-File: LICENSE
|
|
11
10
|
Classifier: Development Status :: 3 - Alpha
|
|
12
11
|
Classifier: License :: OSI Approved :: MIT License
|
|
13
12
|
Classifier: Programming Language :: Python
|
|
14
13
|
Classifier: Programming Language :: Python :: 3
|
|
15
14
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
17
15
|
Classifier: Programming Language :: Python :: 3.9
|
|
18
16
|
Classifier: Programming Language :: Python :: 3.10
|
|
19
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
-
Requires-Python: >=3.
|
|
19
|
+
Requires-Python: >=3.9
|
|
22
20
|
Requires-Dist: aiosqlite
|
|
23
|
-
Requires-Dist: importlib-resources; python_version < '3.9'
|
|
24
21
|
Requires-Dist: platformdirs
|
|
25
|
-
Requires-Dist: pygls>=
|
|
22
|
+
Requires-Dist: pygls>=2.0a2
|
|
26
23
|
Requires-Dist: stamina
|
|
27
24
|
Requires-Dist: textual>=0.41.0
|
|
28
|
-
Requires-Dist: typing-extensions; python_version < '3.8'
|
|
29
25
|
Description-Content-Type: text/markdown
|
|
30
26
|
|
|
31
27
|
# lsp-devtools: Developer tooling for language servers
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[version]
|
|
2
|
+
path = "lsp_devtools/__init__.py"
|
|
3
|
+
validate-bump = false
|
|
4
|
+
|
|
5
|
+
[build.targets.sdist]
|
|
6
|
+
include = ["lsp_devtools", "tests", "CHANGES.md"]
|
|
7
|
+
|
|
8
|
+
[build.targets.wheel]
|
|
9
|
+
packages = ["lsp_devtools"]
|
|
10
|
+
|
|
11
|
+
[envs.hatch-test]
|
|
12
|
+
extra-dependencies = ["pytest-asyncio"]
|
|
13
|
+
|
|
14
|
+
[envs.hatch-test.env-vars]
|
|
15
|
+
UV_PRERELEASE="allow"
|
|
16
|
+
|
|
17
|
+
[envs.hatch-static-analysis]
|
|
18
|
+
config-path = "ruff_defaults.toml"
|
|
19
|
+
dependencies = ["ruff==0.8.0"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.4"
|
|
@@ -1,66 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import argparse
|
|
2
4
|
import asyncio
|
|
3
|
-
import logging
|
|
4
5
|
import subprocess
|
|
5
6
|
import sys
|
|
6
|
-
from typing import List
|
|
7
|
-
from uuid import uuid4
|
|
8
7
|
|
|
9
8
|
from .agent import Agent
|
|
9
|
+
from .agent import RPCMessage
|
|
10
10
|
from .agent import logger
|
|
11
|
+
from .agent import parse_rpc_message
|
|
11
12
|
from .client import AgentClient
|
|
12
|
-
from .protocol import MESSAGE_TEXT_NOTIFICATION
|
|
13
|
-
from .protocol import MessageText
|
|
14
13
|
from .server import AgentServer
|
|
15
|
-
from .server import parse_rpc_message
|
|
16
14
|
|
|
17
15
|
__all__ = [
|
|
18
16
|
"Agent",
|
|
19
17
|
"AgentClient",
|
|
20
18
|
"AgentServer",
|
|
19
|
+
"RPCMessage",
|
|
21
20
|
"logger",
|
|
22
|
-
"MESSAGE_TEXT_NOTIFICATION",
|
|
23
|
-
"MessageText",
|
|
24
21
|
"parse_rpc_message",
|
|
25
22
|
]
|
|
26
23
|
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def __init__(self, client: AgentClient, *args, **kwargs):
|
|
33
|
-
super().__init__(*args, **kwargs)
|
|
34
|
-
self.client = client
|
|
35
|
-
self.session = str(uuid4())
|
|
36
|
-
self._buffer: List[MessageText] = []
|
|
37
|
-
|
|
38
|
-
def emit(self, record: logging.LogRecord):
|
|
39
|
-
message = MessageText(
|
|
40
|
-
text=record.args[0], # type: ignore
|
|
41
|
-
session=self.session,
|
|
42
|
-
timestamp=record.created,
|
|
43
|
-
source=record.__dict__["source"],
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
if not self.client.connected:
|
|
47
|
-
self._buffer.append(message)
|
|
48
|
-
return
|
|
25
|
+
async def forward_stderr(server: asyncio.subprocess.Process):
|
|
26
|
+
"""Forward the server's stderr to the agent's stderr."""
|
|
27
|
+
if server.stderr is None:
|
|
28
|
+
return
|
|
49
29
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
30
|
+
# EOF is signalled with an empty bytestring
|
|
31
|
+
while (line := await server.stderr.readline()) != b"":
|
|
32
|
+
sys.stderr.buffer.write(line)
|
|
53
33
|
|
|
54
|
-
self.client.protocol.message_text_notification(message)
|
|
55
34
|
|
|
56
|
-
|
|
57
|
-
async def main(args, extra: List[str]):
|
|
35
|
+
async def main(args, extra: list[str]):
|
|
58
36
|
if extra is None:
|
|
59
37
|
print("Missing server start command", file=sys.stderr)
|
|
60
38
|
return 1
|
|
61
39
|
|
|
62
40
|
command, *arguments = extra
|
|
63
|
-
|
|
64
41
|
server = await asyncio.create_subprocess_exec(
|
|
65
42
|
command,
|
|
66
43
|
*arguments,
|
|
@@ -68,23 +45,21 @@ async def main(args, extra: List[str]):
|
|
|
68
45
|
stdout=subprocess.PIPE,
|
|
69
46
|
stderr=subprocess.PIPE,
|
|
70
47
|
)
|
|
71
|
-
agent = Agent(server, sys.stdin.buffer, sys.stdout.buffer)
|
|
72
|
-
|
|
73
48
|
client = AgentClient()
|
|
74
|
-
|
|
75
|
-
handler.setLevel(logging.INFO)
|
|
76
|
-
|
|
77
|
-
logger.setLevel(logging.INFO)
|
|
78
|
-
logger.addHandler(handler)
|
|
49
|
+
agent = Agent(server, sys.stdin.buffer, sys.stdout.buffer, client.forward_message)
|
|
79
50
|
|
|
80
51
|
await asyncio.gather(
|
|
81
52
|
client.start_tcp(args.host, args.port),
|
|
82
53
|
agent.start(),
|
|
54
|
+
forward_stderr(server),
|
|
83
55
|
)
|
|
84
56
|
|
|
85
57
|
|
|
86
|
-
def run_agent(args, extra:
|
|
87
|
-
|
|
58
|
+
def run_agent(args, extra: list[str]):
|
|
59
|
+
try:
|
|
60
|
+
asyncio.run(main(args, extra))
|
|
61
|
+
except asyncio.CancelledError:
|
|
62
|
+
pass
|
|
88
63
|
|
|
89
64
|
|
|
90
65
|
def cli(commands: argparse._SubParsersAction):
|
|
@@ -2,35 +2,81 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import inspect
|
|
5
|
+
import json
|
|
5
6
|
import logging
|
|
6
7
|
import re
|
|
7
8
|
import sys
|
|
8
9
|
import typing
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from datetime import timezone
|
|
9
12
|
from functools import partial
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
import attrs
|
|
10
16
|
|
|
11
17
|
if typing.TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Coroutine
|
|
19
|
+
from typing import Any
|
|
12
20
|
from typing import BinaryIO
|
|
13
|
-
from typing import
|
|
14
|
-
from typing import
|
|
15
|
-
|
|
21
|
+
from typing import Callable
|
|
22
|
+
from typing import Union
|
|
23
|
+
|
|
24
|
+
from pygls.io_ import AsyncReader
|
|
25
|
+
|
|
26
|
+
MessageHandler = Callable[[bytes], Union[None, Coroutine[Any, Any, None]]]
|
|
16
27
|
|
|
28
|
+
UTC = timezone.utc
|
|
17
29
|
logger = logging.getLogger("lsp_devtools.agent")
|
|
18
30
|
|
|
19
31
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
await dest.drain()
|
|
32
|
+
@attrs.define
|
|
33
|
+
class RPCMessage:
|
|
34
|
+
"""A Json-RPC message."""
|
|
24
35
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
headers: dict[str, str]
|
|
37
|
+
|
|
38
|
+
body: dict[str, Any]
|
|
39
|
+
|
|
40
|
+
def __getitem__(self, key: str):
|
|
41
|
+
return self.headers[key]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_rpc_message(data: bytes) -> RPCMessage:
|
|
45
|
+
"""Parse a JSON-RPC message from the given set of bytes."""
|
|
46
|
+
|
|
47
|
+
headers: dict[str, str] = {}
|
|
48
|
+
body: dict[str, Any] | None = None
|
|
49
|
+
headers_complete = False
|
|
50
|
+
|
|
51
|
+
for line in data.split(b"\r\n"):
|
|
52
|
+
if line == b"":
|
|
53
|
+
if "Content-Length" not in headers:
|
|
54
|
+
raise ValueError("Missing 'Content-Length' header")
|
|
31
55
|
|
|
56
|
+
headers_complete = True
|
|
57
|
+
continue
|
|
32
58
|
|
|
33
|
-
|
|
59
|
+
if headers_complete:
|
|
60
|
+
length = int(headers["Content-Length"])
|
|
61
|
+
if len(line) != length:
|
|
62
|
+
raise ValueError("Incorrect 'Content-Length'")
|
|
63
|
+
|
|
64
|
+
body = json.loads(line)
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
if (idx := line.find(b":")) < 0:
|
|
68
|
+
raise ValueError(f"Invalid header: {line!r}")
|
|
69
|
+
|
|
70
|
+
name, value = line[:idx], line[idx + 1 :]
|
|
71
|
+
headers[name.decode("utf8").strip()] = value.decode("utf8").strip()
|
|
72
|
+
|
|
73
|
+
if body is None:
|
|
74
|
+
raise ValueError("Missing message body")
|
|
75
|
+
|
|
76
|
+
return RPCMessage(headers, body)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def aio_readline(reader: AsyncReader, message_handler: MessageHandler):
|
|
34
80
|
CONTENT_LENGTH_PATTERN = re.compile(rb"^Content-Length: (\d+)\r\n$")
|
|
35
81
|
|
|
36
82
|
# Initialize message buffer
|
|
@@ -70,7 +116,7 @@ async def aio_readline(reader: asyncio.StreamReader, message_handler):
|
|
|
70
116
|
|
|
71
117
|
async def get_streams(
|
|
72
118
|
stdin, stdout
|
|
73
|
-
) ->
|
|
119
|
+
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
74
120
|
"""Convert blocking stdin/stdout streams into async streams."""
|
|
75
121
|
loop = asyncio.get_running_loop()
|
|
76
122
|
|
|
@@ -90,29 +136,35 @@ class Agent:
|
|
|
90
136
|
enabling them to be recorded."""
|
|
91
137
|
|
|
92
138
|
def __init__(
|
|
93
|
-
self,
|
|
139
|
+
self,
|
|
140
|
+
server: asyncio.subprocess.Process,
|
|
141
|
+
stdin: BinaryIO,
|
|
142
|
+
stdout: BinaryIO,
|
|
143
|
+
handler: MessageHandler,
|
|
94
144
|
):
|
|
95
145
|
self.stdin = stdin
|
|
96
146
|
self.stdout = stdout
|
|
97
147
|
self.server = server
|
|
148
|
+
self.handler = handler
|
|
149
|
+
self.session_id = str(uuid4())
|
|
98
150
|
|
|
99
|
-
self._tasks:
|
|
100
|
-
self.reader:
|
|
101
|
-
self.writer:
|
|
151
|
+
self._tasks: set[asyncio.Task] = set()
|
|
152
|
+
self.reader: asyncio.StreamReader | None = None
|
|
153
|
+
self.writer: asyncio.StreamWriter | None = None
|
|
102
154
|
|
|
103
155
|
async def start(self):
|
|
104
156
|
# Get async versions of stdin/stdout
|
|
105
157
|
self.reader, self.writer = await get_streams(self.stdin, self.stdout)
|
|
106
158
|
|
|
107
159
|
# Keep mypy happy
|
|
108
|
-
|
|
109
|
-
|
|
160
|
+
if self.server.stdin is None or self.server.stdout is None:
|
|
161
|
+
raise RuntimeError("Unable to find server I/O streams")
|
|
110
162
|
|
|
111
163
|
# Connect stdin to the subprocess' stdin
|
|
112
164
|
client_to_server = asyncio.create_task(
|
|
113
165
|
aio_readline(
|
|
114
166
|
self.reader,
|
|
115
|
-
partial(forward_message, "client", self.server.stdin),
|
|
167
|
+
partial(self.forward_message, "client", self.server.stdin),
|
|
116
168
|
),
|
|
117
169
|
)
|
|
118
170
|
self._tasks.add(client_to_server)
|
|
@@ -121,7 +173,7 @@ class Agent:
|
|
|
121
173
|
server_to_client = asyncio.create_task(
|
|
122
174
|
aio_readline(
|
|
123
175
|
self.server.stdout,
|
|
124
|
-
partial(forward_message, "server", self.writer),
|
|
176
|
+
partial(self.forward_message, "server", self.writer),
|
|
125
177
|
),
|
|
126
178
|
)
|
|
127
179
|
self._tasks.add(server_to_client)
|
|
@@ -133,6 +185,30 @@ class Agent:
|
|
|
133
185
|
self._watch_server_process(),
|
|
134
186
|
)
|
|
135
187
|
|
|
188
|
+
async def forward_message(
|
|
189
|
+
self, source: str, dest: asyncio.StreamWriter, message: bytes
|
|
190
|
+
):
|
|
191
|
+
"""Forward the given message to the destination channel"""
|
|
192
|
+
|
|
193
|
+
# Forward the message as-is to the client/server
|
|
194
|
+
dest.write(message)
|
|
195
|
+
await dest.drain()
|
|
196
|
+
|
|
197
|
+
# Include some additional metadata before passing it onto the devtool.
|
|
198
|
+
# TODO: How do we make sure we choose the same encoding as `message`?
|
|
199
|
+
now = datetime.now(tz=UTC).isoformat()
|
|
200
|
+
fields = [
|
|
201
|
+
f"Message-Source: {source}\r\n".encode(),
|
|
202
|
+
f"Message-Session: {self.session_id}\r\n".encode(),
|
|
203
|
+
f"Message-Timestamp: {now}\r\n".encode(),
|
|
204
|
+
message,
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
if inspect.iscoroutine(res := self.handler(b"".join(fields))):
|
|
208
|
+
task = asyncio.create_task(res)
|
|
209
|
+
self._tasks.add(task)
|
|
210
|
+
task.add_done_callback(self._tasks.discard)
|
|
211
|
+
|
|
136
212
|
async def _watch_server_process(self):
|
|
137
213
|
"""Once the server process exits, ensure that the agent is also shutdown."""
|
|
138
214
|
ret = await self.server.wait()
|
|
@@ -148,13 +224,10 @@ class Agent:
|
|
|
148
224
|
except TimeoutError:
|
|
149
225
|
self.server.kill()
|
|
150
226
|
|
|
151
|
-
args = {}
|
|
152
|
-
if sys.version_info.minor > 8:
|
|
153
|
-
args["msg"] = "lsp-devtools agent is stopping."
|
|
154
|
-
|
|
155
227
|
# Cancel the tasks connecting client to server
|
|
156
228
|
for task in self._tasks:
|
|
157
|
-
|
|
229
|
+
logger.debug("cancelling: %s", task)
|
|
230
|
+
task.cancel(msg="lsp-devtools agent is stopping.")
|
|
158
231
|
|
|
159
232
|
if self.writer:
|
|
160
233
|
self.writer.close()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
import stamina
|
|
8
|
+
from pygls.client import JsonRPCClient
|
|
9
|
+
from pygls.protocol import default_converter
|
|
10
|
+
|
|
11
|
+
from lsp_devtools.agent.protocol import AgentProtocol
|
|
12
|
+
|
|
13
|
+
if typing.TYPE_CHECKING:
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AgentClient(JsonRPCClient):
|
|
18
|
+
"""Client for connecting to an AgentServer instance."""
|
|
19
|
+
|
|
20
|
+
protocol: AgentProtocol
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
super().__init__(
|
|
24
|
+
protocol_cls=AgentProtocol, converter_factory=default_converter
|
|
25
|
+
)
|
|
26
|
+
self.connected = False
|
|
27
|
+
self._buffer: list[bytes] = []
|
|
28
|
+
self._tasks: set[asyncio.Task[Any]] = set()
|
|
29
|
+
|
|
30
|
+
def _report_server_error(self, error, source):
|
|
31
|
+
# Bail on error
|
|
32
|
+
# TODO: Report the actual error somehow
|
|
33
|
+
self._stop_event.set()
|
|
34
|
+
|
|
35
|
+
def feature(self, feature_name: str, options: Any | None = None):
|
|
36
|
+
return self.protocol.fm.feature(feature_name, options)
|
|
37
|
+
|
|
38
|
+
async def start_tcp(self, host: str, port: int):
|
|
39
|
+
# The user might not have started the server app immediately and since the
|
|
40
|
+
# agent will live as long as the wrapper language server we may as well
|
|
41
|
+
# try indefinitely.
|
|
42
|
+
retries = stamina.retry_context(
|
|
43
|
+
on=OSError,
|
|
44
|
+
attempts=None,
|
|
45
|
+
timeout=None,
|
|
46
|
+
wait_initial=1,
|
|
47
|
+
wait_max=60,
|
|
48
|
+
)
|
|
49
|
+
async for attempt in retries:
|
|
50
|
+
with attempt:
|
|
51
|
+
await super().start_tcp(host, port)
|
|
52
|
+
self.connected = True
|
|
53
|
+
|
|
54
|
+
def forward_message(self, message: bytes):
|
|
55
|
+
"""Forward the given message to the server instance."""
|
|
56
|
+
|
|
57
|
+
if not self.connected or self.protocol.writer is None:
|
|
58
|
+
self._buffer.append(message)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Send any buffered messages
|
|
62
|
+
while len(self._buffer) > 0:
|
|
63
|
+
res = self.protocol.writer.write(self._buffer.pop(0))
|
|
64
|
+
if inspect.isawaitable(res):
|
|
65
|
+
task = asyncio.ensure_future(res)
|
|
66
|
+
task.add_done_callback(self._tasks.discard)
|
|
67
|
+
self._tasks.add(task)
|
|
68
|
+
|
|
69
|
+
res = self.protocol.writer.write(message)
|
|
70
|
+
if inspect.isawaitable(res):
|
|
71
|
+
task = asyncio.ensure_future(res)
|
|
72
|
+
task.add_done_callback(self._tasks.discard)
|
|
73
|
+
self._tasks.add(task)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import traceback
|
|
7
|
+
import typing
|
|
8
|
+
|
|
9
|
+
from pygls.protocol import default_converter
|
|
10
|
+
from pygls.server import JsonRPCServer
|
|
11
|
+
|
|
12
|
+
from lsp_devtools.agent.agent import aio_readline
|
|
13
|
+
from lsp_devtools.agent.protocol import AgentProtocol
|
|
14
|
+
from lsp_devtools.database import Database
|
|
15
|
+
|
|
16
|
+
if typing.TYPE_CHECKING:
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from lsp_devtools.agent.agent import MessageHandler
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AgentServer(JsonRPCServer):
|
|
23
|
+
"""A pygls server that accepts connections from agents allowing them to send their
|
|
24
|
+
collected messages."""
|
|
25
|
+
|
|
26
|
+
lsp: AgentProtocol
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*args,
|
|
31
|
+
logger: logging.Logger | None = None,
|
|
32
|
+
handler: MessageHandler | None = None,
|
|
33
|
+
**kwargs,
|
|
34
|
+
):
|
|
35
|
+
if "protocol_cls" not in kwargs:
|
|
36
|
+
kwargs["protocol_cls"] = AgentProtocol
|
|
37
|
+
|
|
38
|
+
if "converter_factory" not in kwargs:
|
|
39
|
+
kwargs["converter_factory"] = default_converter
|
|
40
|
+
|
|
41
|
+
super().__init__(*args, **kwargs)
|
|
42
|
+
|
|
43
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
44
|
+
self.handler = handler or self._default_handler
|
|
45
|
+
self.db: Database | None = None
|
|
46
|
+
|
|
47
|
+
self._client_buffer: list[str] = []
|
|
48
|
+
self._server_buffer: list[str] = []
|
|
49
|
+
self._tcp_server: asyncio.Task | None = None
|
|
50
|
+
|
|
51
|
+
def _default_handler(self, data: bytes):
|
|
52
|
+
message = self.protocol.structure_message(json.loads(data))
|
|
53
|
+
self.protocol.handle_message(message)
|
|
54
|
+
|
|
55
|
+
def _report_server_error(self, error: Exception, source):
|
|
56
|
+
"""Report internal server errors."""
|
|
57
|
+
tb = "".join(
|
|
58
|
+
traceback.format_exception(type(error), error, error.__traceback__)
|
|
59
|
+
)
|
|
60
|
+
self.logger.error("%s: %s", type(error).__name__, error)
|
|
61
|
+
self.logger.debug("%s", tb)
|
|
62
|
+
|
|
63
|
+
def feature(self, feature_name: str, options: Any | None = None):
|
|
64
|
+
return self.lsp.fm.feature(feature_name, options)
|
|
65
|
+
|
|
66
|
+
async def start_tcp(self, host: str, port: int) -> None: # type: ignore[override]
|
|
67
|
+
async def handle_client(
|
|
68
|
+
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
69
|
+
):
|
|
70
|
+
self.protocol.set_writer(writer)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
await aio_readline(reader, self.handler)
|
|
74
|
+
except asyncio.CancelledError:
|
|
75
|
+
pass
|
|
76
|
+
finally:
|
|
77
|
+
writer.close()
|
|
78
|
+
await writer.wait_closed()
|
|
79
|
+
|
|
80
|
+
# Uncomment if we ever need to introduce a mode where the server stops
|
|
81
|
+
# automatically once a session ends.
|
|
82
|
+
#
|
|
83
|
+
# self.stop()
|
|
84
|
+
|
|
85
|
+
server = await asyncio.start_server(handle_client, host, port)
|
|
86
|
+
async with server:
|
|
87
|
+
self._tcp_server = asyncio.create_task(server.serve_forever())
|
|
88
|
+
await self._tcp_server
|
|
89
|
+
|
|
90
|
+
def stop(self):
|
|
91
|
+
if self._tcp_server is not None:
|
|
92
|
+
self._tcp_server.cancel()
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import argparse
|
|
2
4
|
import asyncio
|
|
3
5
|
import logging
|
|
4
6
|
import os
|
|
5
7
|
import pathlib
|
|
6
|
-
from typing import List
|
|
7
8
|
from uuid import uuid4
|
|
8
9
|
|
|
9
10
|
import platformdirs
|
|
@@ -54,7 +55,7 @@ class LSPClient(App):
|
|
|
54
55
|
]
|
|
55
56
|
|
|
56
57
|
def __init__(
|
|
57
|
-
self, db: Database, server_command:
|
|
58
|
+
self, db: Database, server_command: list[str], session: str, *args, **kwargs
|
|
58
59
|
):
|
|
59
60
|
super().__init__(*args, **kwargs)
|
|
60
61
|
|
|
@@ -65,11 +66,13 @@ class LSPClient(App):
|
|
|
65
66
|
self.server_command = server_command
|
|
66
67
|
self.lsp_client = LanguageClient()
|
|
67
68
|
|
|
68
|
-
self._async_tasks:
|
|
69
|
+
self._async_tasks: list[asyncio.Task] = []
|
|
69
70
|
|
|
70
71
|
def compose(self) -> ComposeResult:
|
|
71
72
|
message_viewer = MessageViewer("")
|
|
72
|
-
messages_table = MessagesTable(
|
|
73
|
+
messages_table = MessagesTable(
|
|
74
|
+
self.db, message_viewer, session=self.lsp_client.session_id
|
|
75
|
+
)
|
|
73
76
|
|
|
74
77
|
yield Header()
|
|
75
78
|
yield Explorer(".")
|
|
@@ -138,14 +141,14 @@ class LSPClient(App):
|
|
|
138
141
|
await super().action_quit()
|
|
139
142
|
|
|
140
143
|
|
|
141
|
-
def client(args, extra:
|
|
144
|
+
def client(args, extra: list[str]):
|
|
142
145
|
if len(extra) == 0:
|
|
143
146
|
raise ValueError("Missing server command.")
|
|
144
147
|
|
|
145
148
|
db = Database(args.dbpath)
|
|
146
149
|
|
|
147
150
|
session = str(uuid4())
|
|
148
|
-
dbhandler = DatabaseLogHandler(db
|
|
151
|
+
dbhandler = DatabaseLogHandler(db)
|
|
149
152
|
dbhandler.setLevel(logging.INFO)
|
|
150
153
|
|
|
151
154
|
logger.setLevel(logging.INFO)
|
|
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|
|
3
3
|
import contextlib
|
|
4
4
|
import pathlib
|
|
5
5
|
import typing
|
|
6
|
-
from typing import List
|
|
7
6
|
from typing import Union
|
|
8
7
|
|
|
9
8
|
from lsprotocol import types
|
|
@@ -15,7 +14,7 @@ from textual.widgets import TextArea
|
|
|
15
14
|
if typing.TYPE_CHECKING:
|
|
16
15
|
from lsp_devtools.client.lsp import LanguageClient
|
|
17
16
|
|
|
18
|
-
CompletionResult = Union[
|
|
17
|
+
CompletionResult = Union[list[types.CompletionItem], types.CompletionList, None]
|
|
19
18
|
|
|
20
19
|
|
|
21
20
|
# TODO: Refactor to
|
|
@@ -104,7 +103,7 @@ class TextEditor(TextArea):
|
|
|
104
103
|
version=self.version, uri=self.uri
|
|
105
104
|
),
|
|
106
105
|
content_changes=[
|
|
107
|
-
types.
|
|
106
|
+
types.TextDocumentContentChangePartial(
|
|
108
107
|
text=edit.text,
|
|
109
108
|
range=types.Range(
|
|
110
109
|
start=types.Position(line=start_line, character=start_col),
|