Skip to content

Commit 114af04

Browse files
author
Lukas Geiger
committed
feat: LSP-Diagnostics & Completion + Tests
- features/lsp_client.py: Diagnostics thread-sicher ueber Qt-Signale, Completion-Anfragen beim Tippen - tests/: test_lsp_client.py, test_lsp_runtime.py
1 parent 45812ec commit 114af04

5 files changed

Lines changed: 171 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
77

88
### Hinzugefügt
99
- Headless-Smoke-Test für MainWindow-Instanziierung
10+
- Optionale LSP-Runtime-Tests für `python-lsp-server[all]`:
11+
Diagnostics bei Syntaxfehlern und Completion über `pylsp`.
1012
- `__all__`-Exports in allen Modul-`__init__.py`
1113
- LSP-Diagnostics und LSP-Completion sind jetzt im Editor verdrahtet:
1214
Diagnostics laufen thread-sicher über Qt-Signale, Completion-Anfragen werden
@@ -17,6 +19,10 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
1719
- Diverse ungenutzte Imports entfernt (core, features, languages, ui)
1820
- Fenstertitel liest die Version jetzt aus `version.py` statt aus einem Hardcode
1921
- Theme-Wechsel setzt Palette und QSS gemeinsam; Light-Mode bleibt nicht mehr auf Dark-Basis hängen
22+
- Python-LSP-Erkennung startet `pylsp` jetzt auch über `python -m pylsp`,
23+
wenn das Script nicht auf `PATH` liegt, das Modul aber installiert ist.
24+
- LSP-Subprocess-Pipes werden beim Stoppen geschlossen; der Runtime-Test läuft
25+
dadurch ohne ResourceWarnings.
2026

2127
### Geändert
2228
- Deutschsprachige Doku sowie Python-Kommentare, Docstrings und naheliegende UI-Texte

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,17 @@ Alternativ per Doppelklick auf `start.bat`.
3636

3737
### Optionale LSP-Server
3838

39-
- Python: `pip install python-lsp-server`
39+
- Python: `pip install "python-lsp-server[all]"` für Completion plus Diagnostics
40+
(`pip install python-lsp-server` reicht nur für Completion)
4041
- TypeScript: `npm install -g typescript-language-server`
4142
- Rust: `rustup component add rust-analyzer`
4243
- Go: `go install golang.org/x/tools/gopls@latest`
4344
- C++: `clangd` bzw. LLVM
4445

46+
Der Python-LSP wird bevorzugt über `pylsp` auf `PATH` gestartet. Falls das
47+
Script nach der Installation nicht auf `PATH` liegt, nutzt CodeBox den aktuellen
48+
Python-Interpreter als Fallback: `python -m pylsp`.
49+
4550
## Lokaler Windows-Build
4651

4752
```bat

features/lsp_client.py

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
- textDocument/publishDiagnostics (Fehler/Warnungen)
1212
"""
1313

14+
import importlib.util
1415
import json
1516
import subprocess
17+
import sys
1618
import threading
1719
import shutil
1820
from typing import Optional, Dict, List, Callable
@@ -21,7 +23,7 @@
2123

2224
# Bekannte LSP-Server pro Sprache
2325
LSP_SERVERS = {
24-
"Python": {"cmd": ["pylsp"], "check": "pylsp"},
26+
"Python": {"cmd": ["pylsp"], "check": "pylsp", "module": "pylsp"},
2527
"JavaScript": {"cmd": ["typescript-language-server", "--stdio"], "check": "typescript-language-server"},
2628
"TypeScript": {"cmd": ["typescript-language-server", "--stdio"], "check": "typescript-language-server"},
2729
"Rust": {"cmd": ["rust-analyzer"], "check": "rust-analyzer"},
@@ -83,22 +85,37 @@ def __init__(self, language: str, root_path: str = "."):
8385
def server_config(self) -> Optional[dict]:
8486
return LSP_SERVERS.get(self.language)
8587

86-
def is_available(self) -> bool:
87-
"""Prüft, ob der LSP-Server installiert ist."""
88+
def _resolve_command(self) -> Optional[List[str]]:
89+
"""Ermittelt das startbare Server-Kommando.
90+
91+
Bei Python ist `pylsp.exe` nach einer pip-Installation nicht immer auf
92+
PATH. Wenn das Modul im aktuellen Interpreter vorhanden ist, startet
93+
CodeBox den Server deshalb über `python -m pylsp`.
94+
"""
8895
config = self.server_config
8996
if not config:
90-
return False
91-
return shutil.which(config["check"]) is not None
97+
return None
98+
check = config.get("check")
99+
if check and shutil.which(check):
100+
return list(config["cmd"])
101+
module = config.get("module")
102+
if module and importlib.util.find_spec(module):
103+
return [sys.executable, "-m", module]
104+
return None
105+
106+
def is_available(self) -> bool:
107+
"""Prüft, ob der LSP-Server installiert ist."""
108+
return self._resolve_command() is not None
92109

93110
def start(self) -> bool:
94111
"""Startet den LSP-Server-Prozess."""
95-
if not self.is_available():
112+
command = self._resolve_command()
113+
if not command:
96114
return False
97115

98-
config = self.server_config
99116
try:
100117
self.process = subprocess.Popen(
101-
config["cmd"],
118+
command,
102119
stdin=subprocess.PIPE,
103120
stdout=subprocess.PIPE,
104121
stderr=subprocess.PIPE,
@@ -131,18 +148,34 @@ def start(self) -> bool:
131148
def stop(self):
132149
"""Stoppt den LSP-Server."""
133150
self._running = False
134-
if self.process:
151+
process = self.process
152+
if process:
135153
try:
136154
self._send_request("shutdown", {})
137155
self._send_notification("exit", {})
138156
except (BrokenPipeError, OSError):
139157
pass
140158
try:
141-
self.process.terminate()
142-
self.process.wait(timeout=3)
159+
process.terminate()
160+
process.wait(timeout=3)
143161
except subprocess.TimeoutExpired:
144-
self.process.kill()
145-
self.process = None
162+
process.kill()
163+
process.wait(timeout=3)
164+
finally:
165+
for stream in (process.stdin, process.stdout, process.stderr):
166+
if stream:
167+
try:
168+
stream.close()
169+
except OSError:
170+
pass
171+
self.process = None
172+
if (
173+
self._reader_thread
174+
and self._reader_thread.is_alive()
175+
and threading.current_thread() is not self._reader_thread
176+
):
177+
self._reader_thread.join(timeout=1)
178+
self._reader_thread = None
146179

147180
def did_open(self, uri: str, language_id: str, text: str, version: int = 1):
148181
"""Benachrichtigt den Server über eine geöffnete Datei."""

tests/test_lsp_client.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import unittest
2+
from unittest.mock import patch
3+
4+
from features.lsp_client import LSPClient
5+
6+
7+
class LSPClientCommandResolutionTests(unittest.TestCase):
8+
def test_prefers_server_executable_when_on_path(self):
9+
client = LSPClient("Python")
10+
11+
with patch("features.lsp_client.shutil.which", return_value="C:/bin/pylsp.exe"):
12+
self.assertTrue(client.is_available())
13+
self.assertEqual(client._resolve_command(), ["pylsp"])
14+
15+
def test_python_server_falls_back_to_current_interpreter_module(self):
16+
client = LSPClient("Python")
17+
18+
with patch("features.lsp_client.shutil.which", return_value=None), \
19+
patch("features.lsp_client.importlib.util.find_spec", return_value=object()), \
20+
patch("features.lsp_client.sys.executable", "C:/Python/python.exe"):
21+
self.assertTrue(client.is_available())
22+
self.assertEqual(
23+
client._resolve_command(),
24+
["C:/Python/python.exe", "-m", "pylsp"],
25+
)
26+
27+
def test_unavailable_when_no_executable_or_module_exists(self):
28+
client = LSPClient("Python")
29+
30+
with patch("features.lsp_client.shutil.which", return_value=None), \
31+
patch("features.lsp_client.importlib.util.find_spec", return_value=None):
32+
self.assertFalse(client.is_available())
33+
self.assertIsNone(client._resolve_command())
34+
35+
def test_unknown_language_is_unavailable(self):
36+
client = LSPClient("Unknown")
37+
38+
self.assertFalse(client.is_available())
39+
self.assertIsNone(client._resolve_command())
40+
41+
42+
if __name__ == "__main__":
43+
unittest.main()

tests/test_lsp_runtime.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import os
2+
import tempfile
3+
import threading
4+
import unittest
5+
from pathlib import Path
6+
7+
from features.lsp_client import LSPClient
8+
9+
10+
@unittest.skipUnless(
11+
os.environ.get("CODEBOX_LSP_RUNTIME") == "1",
12+
"Set CODEBOX_LSP_RUNTIME=1 and install python-lsp-server[all] to run.",
13+
)
14+
class PythonLSPRuntimeTests(unittest.TestCase):
15+
def test_pylsp_diagnostics_and_completion_roundtrip(self):
16+
client = LSPClient("Python", tempfile.mkdtemp(prefix="codebox-lsp-"))
17+
if not client.is_available():
18+
self.skipTest("pylsp is not available on PATH or as python -m pylsp")
19+
20+
root = Path(client.root_path)
21+
path = root / "sample.py"
22+
uri = path.resolve().as_uri()
23+
diagnostics = []
24+
completions = []
25+
diag_event = threading.Event()
26+
completion_event = threading.Event()
27+
28+
def on_diagnostics(params):
29+
if params.get("uri") != uri:
30+
return
31+
batch = params.get("diagnostics", [])
32+
diagnostics[:] = batch
33+
if batch:
34+
diag_event.set()
35+
36+
def on_completion(result):
37+
items = result.get("items", []) if isinstance(result, dict) else (result or [])
38+
completions[:] = [
39+
item.get("label") if isinstance(item, dict) else str(item)
40+
for item in items
41+
]
42+
completion_event.set()
43+
44+
try:
45+
self.assertTrue(client.start())
46+
client.on_diagnostics = on_diagnostics
47+
48+
broken_text = "def broken(:\n pass\n"
49+
path.write_text(broken_text, encoding="utf-8")
50+
client.did_open(uri, "python", broken_text, version=1)
51+
self.assertTrue(
52+
diag_event.wait(15),
53+
"No diagnostics received; install python-lsp-server[all] with lint plugins.",
54+
)
55+
self.assertTrue(
56+
any("invalid syntax" in item.get("message", "") for item in diagnostics)
57+
)
58+
59+
completion_text = "import os\nos.pa"
60+
path.write_text(completion_text, encoding="utf-8")
61+
client.did_change(uri, completion_text, version=2)
62+
client.request_completion(uri, 1, 5, callback=on_completion)
63+
self.assertTrue(completion_event.wait(10), "No completion response received.")
64+
self.assertIn("path", {item for item in completions if item})
65+
finally:
66+
client.stop()
67+
68+
69+
if __name__ == "__main__":
70+
unittest.main()

0 commit comments

Comments
 (0)