xml-pipeline/xml_pipeline/console/lsp/client.py
dullfig e653d63bc1 Rename agentserver to xml_pipeline, add console example
OSS restructuring for open-core model:
- Rename package from agentserver/ to xml_pipeline/
- Update all imports (44 Python files, 31 docs/configs)
- Update pyproject.toml for OSS distribution (v0.3.0)
- Move prompt_toolkit from core to optional [console] extra
- Remove auth/server/lsp from core optional deps (-> Nextra)

New console example in examples/console/:
- Self-contained demo with handlers and config
- Uses prompt_toolkit (optional, falls back to input())
- No password auth, no TUI, no LSP — just the basics
- Shows how to use xml-pipeline as a library

Import changes:
- from agentserver.* -> from xml_pipeline.*
- CLI entry points updated: xml_pipeline.cli:main

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 21:41:19 -08:00

538 lines
16 KiB
Python

"""
YAML Language Server Protocol client.
Wraps communication with yaml-language-server for:
- Autocompletion
- Diagnostics (validation errors)
- Hover information
"""
from __future__ import annotations
import asyncio
import json
import subprocess
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Any
import logging
logger = logging.getLogger(__name__)
# Check for lsp-client availability
def _check_lsp_client() -> bool:
"""Check if lsp-client package is available."""
try:
import lsp_client # noqa: F401
return True
except ImportError:
return False
def _check_yaml_language_server() -> bool:
"""Check if yaml-language-server is installed."""
return shutil.which("yaml-language-server") is not None
def is_lsp_available() -> tuple[bool, str]:
"""
Check if LSP support is available.
Returns (available, reason) tuple.
"""
if not _check_lsp_client():
return False, "lsp-client package not installed (pip install lsp-client)"
if not _check_yaml_language_server():
return False, "yaml-language-server not found (npm install -g yaml-language-server)"
return True, "LSP available"
@dataclass
class LSPCompletion:
"""Normalized completion item from LSP."""
label: str
kind: str = "text" # text, keyword, property, value, snippet
detail: str = ""
documentation: str = ""
insert_text: str = ""
sort_text: str = ""
@classmethod
def from_lsp(cls, item: dict[str, Any]) -> "LSPCompletion":
"""Create from LSP CompletionItem."""
kind_map = {
1: "text",
2: "method",
3: "function",
5: "field",
6: "variable",
9: "module",
10: "property",
12: "value",
14: "keyword",
15: "snippet",
}
return cls(
label=item.get("label", ""),
kind=kind_map.get(item.get("kind", 1), "text"),
detail=item.get("detail", ""),
documentation=_extract_documentation(item.get("documentation")),
insert_text=item.get("insertText", item.get("label", "")),
sort_text=item.get("sortText", item.get("label", "")),
)
@dataclass
class LSPDiagnostic:
"""Normalized diagnostic from LSP."""
line: int
column: int
end_line: int
end_column: int
message: str
severity: str = "error" # error, warning, info, hint
source: str = "yaml-language-server"
@classmethod
def from_lsp(cls, diag: dict[str, Any]) -> "LSPDiagnostic":
"""Create from LSP Diagnostic."""
severity_map = {1: "error", 2: "warning", 3: "info", 4: "hint"}
range_data = diag.get("range", {})
start = range_data.get("start", {})
end = range_data.get("end", {})
return cls(
line=start.get("line", 0),
column=start.get("character", 0),
end_line=end.get("line", 0),
end_column=end.get("character", 0),
message=diag.get("message", ""),
severity=severity_map.get(diag.get("severity", 1), "error"),
source=diag.get("source", "yaml-language-server"),
)
@dataclass
class LSPHover:
"""Normalized hover information from LSP."""
contents: str
range_start_line: Optional[int] = None
range_start_col: Optional[int] = None
@classmethod
def from_lsp(cls, hover: dict[str, Any]) -> Optional["LSPHover"]:
"""Create from LSP Hover response."""
if not hover:
return None
contents = hover.get("contents")
if isinstance(contents, str):
text = contents
elif isinstance(contents, dict):
text = contents.get("value", str(contents))
elif isinstance(contents, list):
text = "\n".join(
c.get("value", str(c)) if isinstance(c, dict) else str(c)
for c in contents
)
else:
return None
range_data = hover.get("range", {})
start = range_data.get("start", {})
return cls(
contents=text,
range_start_line=start.get("line"),
range_start_col=start.get("character"),
)
def _extract_documentation(doc: Any) -> str:
"""Extract documentation string from LSP documentation field."""
if doc is None:
return ""
if isinstance(doc, str):
return doc
if isinstance(doc, dict):
return doc.get("value", "")
return str(doc)
class YAMLLSPClient:
"""
Client for communicating with yaml-language-server.
Uses stdio for communication with the language server process.
"""
def __init__(self, schema_uri: Optional[str] = None):
"""
Initialize the LSP client.
Args:
schema_uri: Default schema URI for YAML files
"""
self.schema_uri = schema_uri
self._process: Optional[subprocess.Popen] = None
self._reader_task: Optional[asyncio.Task] = None
self._request_id = 0
self._pending_requests: dict[int, asyncio.Future] = {}
self._diagnostics: dict[str, list[LSPDiagnostic]] = {}
self._initialized = False
self._lock = asyncio.Lock()
async def start(self) -> bool:
"""
Start the language server.
Returns True if started successfully.
"""
available, reason = is_lsp_available()
if not available:
logger.warning(f"LSP not available: {reason}")
return False
try:
self._process = subprocess.Popen(
["yaml-language-server", "--stdio"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Start reader task
self._reader_task = asyncio.create_task(self._read_messages())
# Initialize LSP
await self._initialize()
self._initialized = True
return True
except Exception as e:
logger.error(f"Failed to start yaml-language-server: {e}")
await self.stop()
return False
async def stop(self) -> None:
"""Stop the language server."""
self._initialized = False
if self._reader_task:
self._reader_task.cancel()
try:
await self._reader_task
except asyncio.CancelledError:
pass
self._reader_task = None
if self._process:
self._process.terminate()
try:
self._process.wait(timeout=2)
except subprocess.TimeoutExpired:
self._process.kill()
self._process = None
# Cancel pending requests
for future in self._pending_requests.values():
if not future.done():
future.cancel()
self._pending_requests.clear()
async def _initialize(self) -> None:
"""Send LSP initialize request."""
result = await self._request(
"initialize",
{
"processId": None,
"rootUri": None,
"capabilities": {
"textDocument": {
"completion": {
"completionItem": {
"snippetSupport": True,
"documentationFormat": ["markdown", "plaintext"],
}
},
"hover": {
"contentFormat": ["markdown", "plaintext"],
},
"publishDiagnostics": {},
}
},
"initializationOptions": {
"yaml": {
"validate": True,
"hover": True,
"completion": True,
"schemas": {},
}
},
},
)
logger.debug(f"LSP initialized: {result}")
# Send initialized notification
await self._notify("initialized", {})
async def did_open(self, uri: str, content: str) -> None:
"""Notify server that a document was opened."""
if not self._initialized:
return
await self._notify(
"textDocument/didOpen",
{
"textDocument": {
"uri": uri,
"languageId": "yaml",
"version": 1,
"text": content,
}
},
)
async def did_change(self, uri: str, content: str, version: int = 1) -> list[LSPDiagnostic]:
"""
Notify server of document change.
Returns current diagnostics for the document.
"""
if not self._initialized:
return []
await self._notify(
"textDocument/didChange",
{
"textDocument": {"uri": uri, "version": version},
"contentChanges": [{"text": content}],
},
)
# Wait briefly for diagnostics
await asyncio.sleep(0.1)
return self._diagnostics.get(uri, [])
async def did_close(self, uri: str) -> None:
"""Notify server that a document was closed."""
if not self._initialized:
return
await self._notify(
"textDocument/didClose",
{"textDocument": {"uri": uri}},
)
# Clear diagnostics
self._diagnostics.pop(uri, None)
async def completion(
self, uri: str, line: int, column: int
) -> list[LSPCompletion]:
"""
Request completions at a position.
Args:
uri: Document URI
line: 0-indexed line number
column: 0-indexed column number
Returns list of completion items.
"""
if not self._initialized:
return []
try:
result = await self._request(
"textDocument/completion",
{
"textDocument": {"uri": uri},
"position": {"line": line, "character": column},
},
)
if result is None:
return []
items = result.get("items", []) if isinstance(result, dict) else result
return [LSPCompletion.from_lsp(item) for item in items]
except Exception as e:
logger.debug(f"Completion request failed: {e}")
return []
async def hover(self, uri: str, line: int, column: int) -> Optional[LSPHover]:
"""
Request hover information at a position.
Args:
uri: Document URI
line: 0-indexed line number
column: 0-indexed column number
"""
if not self._initialized:
return None
try:
result = await self._request(
"textDocument/hover",
{
"textDocument": {"uri": uri},
"position": {"line": line, "character": column},
},
)
return LSPHover.from_lsp(result) if result else None
except Exception as e:
logger.debug(f"Hover request failed: {e}")
return None
def get_diagnostics(self, uri: str) -> list[LSPDiagnostic]:
"""Get current diagnostics for a document."""
return self._diagnostics.get(uri, [])
async def _request(self, method: str, params: dict[str, Any]) -> Any:
"""Send a request and wait for response."""
async with self._lock:
self._request_id += 1
req_id = self._request_id
message = {
"jsonrpc": "2.0",
"id": req_id,
"method": method,
"params": params,
}
future: asyncio.Future = asyncio.Future()
self._pending_requests[req_id] = future
try:
await self._send_message(message)
return await asyncio.wait_for(future, timeout=5.0)
except asyncio.TimeoutError:
logger.warning(f"LSP request timed out: {method}")
return None
finally:
self._pending_requests.pop(req_id, None)
async def _notify(self, method: str, params: dict[str, Any]) -> None:
"""Send a notification (no response expected)."""
message = {
"jsonrpc": "2.0",
"method": method,
"params": params,
}
await self._send_message(message)
async def _send_message(self, message: dict[str, Any]) -> None:
"""Send a JSON-RPC message to the server."""
if not self._process or not self._process.stdin:
return
content = json.dumps(message)
header = f"Content-Length: {len(content)}\r\n\r\n"
try:
self._process.stdin.write(header.encode())
self._process.stdin.write(content.encode())
self._process.stdin.flush()
except (BrokenPipeError, OSError) as e:
logger.error(f"Failed to send LSP message: {e}")
async def _read_messages(self) -> None:
"""Read messages from the server."""
if not self._process or not self._process.stdout:
return
loop = asyncio.get_event_loop()
try:
while True:
# Read header
header = b""
while b"\r\n\r\n" not in header:
chunk = await loop.run_in_executor(
None, self._process.stdout.read, 1
)
if not chunk:
return # EOF
header += chunk
# Parse content length
content_length = 0
for line in header.decode().split("\r\n"):
if line.startswith("Content-Length:"):
content_length = int(line.split(":")[1].strip())
break
if content_length == 0:
continue
# Read content
content = await loop.run_in_executor(
None, self._process.stdout.read, content_length
)
if not content:
return
# Parse and handle message
try:
message = json.loads(content.decode())
await self._handle_message(message)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse LSP message: {e}")
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"LSP reader error: {e}")
async def _handle_message(self, message: dict[str, Any]) -> None:
"""Handle an incoming LSP message."""
if "id" in message and "result" in message:
# Response to a request
req_id = message["id"]
if req_id in self._pending_requests:
future = self._pending_requests[req_id]
if not future.done():
future.set_result(message.get("result"))
elif "id" in message and "error" in message:
# Error response
req_id = message["id"]
if req_id in self._pending_requests:
future = self._pending_requests[req_id]
if not future.done():
error = message["error"]
future.set_exception(
Exception(f"LSP error: {error.get('message', error)}")
)
elif message.get("method") == "textDocument/publishDiagnostics":
# Diagnostics notification
params = message.get("params", {})
uri = params.get("uri", "")
diagnostics = [
LSPDiagnostic.from_lsp(d)
for d in params.get("diagnostics", [])
]
self._diagnostics[uri] = diagnostics
logger.debug(f"Received {len(diagnostics)} diagnostics for {uri}")
elif "method" in message:
# Other notification
logger.debug(f"LSP notification: {message.get('method')}")