pythonparts-mcp 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,140 @@
1
+ """
2
+ Tool: typecheck_file
3
+
4
+ Runs pyright against a file using the ALLPLAN runtime context and returns
5
+ structured diagnostics.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from fastmcp import FastMCP
16
+
17
+ from ..pyright_config import get_pyright_config_dir, get_python_interpreter
18
+ from . import READ_ONLY_TOOL_ANNOTATIONS
19
+
20
+ # Pyright severity labels → canonical names
21
+ _SEVERITY_MAP = {
22
+ "error": "error",
23
+ "warning": "warning",
24
+ "information": "info",
25
+ }
26
+
27
+
28
+ def _run_pyright(file_path: str, config_dir: Path) -> dict:
29
+ """Invoke pyright and return its parsed JSON output."""
30
+ result = subprocess.run(
31
+ [
32
+ sys.executable, "-m", "pyright",
33
+ "--outputjson",
34
+ "--project", str(config_dir),
35
+ "--pythonpath", get_python_interpreter(),
36
+ file_path,
37
+ ],
38
+ capture_output=True,
39
+ text=True,
40
+ stdin=subprocess.DEVNULL, # prevent inheriting the MCP server's stdin
41
+ timeout=120,
42
+ )
43
+
44
+ # pyright exits with 1 when diagnostics are found — that is not an error
45
+ # for us. Only fail if we cannot parse the output at all.
46
+ # pyright may print warnings before the JSON object; find the first '{'
47
+ stdout = result.stdout
48
+ json_start = stdout.find("{")
49
+ if json_start == -1:
50
+ raise RuntimeError(
51
+ f"pyright produced no JSON output (exit {result.returncode}):\n"
52
+ f"stdout: {stdout}\nstderr: {result.stderr}"
53
+ )
54
+ try:
55
+ return json.loads(stdout[json_start:])
56
+ except json.JSONDecodeError as exc:
57
+ raise RuntimeError(
58
+ f"pyright produced unparseable output (exit {result.returncode}):\n"
59
+ f"stdout: {stdout}\nstderr: {result.stderr}"
60
+ ) from exc
61
+
62
+
63
+ def _is_false_positive(diagnostic: dict) -> bool:
64
+ """
65
+ Return True for known false positives that should be suppressed.
66
+
67
+ BuildingElement attributes are populated dynamically from the .pyp file at
68
+ runtime, so pyright cannot verify them statically. These errors are expected
69
+ and not actionable.
70
+ """
71
+ return (
72
+ diagnostic.get("rule") == "reportAttributeAccessIssue"
73
+ and "BuildingElement" in diagnostic.get("message", "")
74
+ )
75
+
76
+
77
+ def _map_diagnostics(raw: dict) -> list[dict]:
78
+ """Flatten pyright's per-file diagnostic list into a single list, excluding false positives."""
79
+ diagnostics: list[dict] = []
80
+ for file_result in raw.get("generalDiagnostics", []):
81
+ severity = _SEVERITY_MAP.get(file_result.get(
82
+ "severity", ""), file_result.get("severity", ""))
83
+ range_ = file_result.get("range", {})
84
+ start = range_.get("start", {})
85
+ entry = {
86
+ "severity": severity,
87
+ "message": file_result.get("message", ""),
88
+ "file": file_result.get("file", ""),
89
+ "line": start.get("line", 0) + 1, # pyright is 0-based
90
+ "column": start.get("character", 0) + 1,
91
+ "rule": file_result.get("rule", ""),
92
+ }
93
+ if not _is_false_positive(entry):
94
+ diagnostics.append(entry)
95
+ return diagnostics
96
+
97
+
98
+ def register_typecheck_tools(mcp: FastMCP) -> None:
99
+ """Register all type-checking tools on the given FastMCP instance."""
100
+
101
+ @mcp.tool(annotations=READ_ONLY_TOOL_ANNOTATIONS)
102
+ def typecheck_file(file_path: str) -> dict:
103
+ """
104
+ Run a full type-check against the given file using the ALLPLAN Python
105
+ runtime and its bundled .pyi stubs.
106
+
107
+ Args:
108
+ file_path: Absolute path to the Python file to check.
109
+
110
+ Returns:
111
+ ok: False only if the tool itself failed (e.g. pyright not found).
112
+ data.error_count: Number of errors reported.
113
+ data.warning_count: Number of warnings reported.
114
+ data.diagnostics: List of diagnostic items, each with severity, message,
115
+ file, line, column, and rule fields.
116
+ """
117
+ path = Path(file_path)
118
+ if not path.is_file():
119
+ return {
120
+ "ok": False,
121
+ "error": f"File not found: {file_path}",
122
+ }
123
+
124
+ config_dir = get_pyright_config_dir()
125
+
126
+ try:
127
+ raw = _run_pyright(file_path, config_dir)
128
+ except RuntimeError as exc:
129
+ return {"ok": False, "error": str(exc)}
130
+
131
+ diagnostics = _map_diagnostics(raw)
132
+
133
+ return {
134
+ "ok": True,
135
+ "data": {
136
+ "error_count": sum(1 for d in diagnostics if d["severity"] == "error"),
137
+ "warning_count": sum(1 for d in diagnostics if d["severity"] == "warning"),
138
+ "diagnostics": diagnostics,
139
+ },
140
+ }
server/worker.py ADDED
@@ -0,0 +1,184 @@
1
+ """
2
+ Persistent worker subprocesses running under the ALLPLAN-embedded interpreter.
3
+
4
+ ALLPLAN's Python modules are native extensions that can only be imported by the
5
+ interpreter shipped in ``Prg\\Python``, and loading their DLLs costs seconds. A
6
+ worker is therefore spawned once, on first use, and kept alive for the lifetime
7
+ of the MCP server so that cost is paid only once.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import atexit
13
+ import json
14
+ import logging
15
+ import queue
16
+ import subprocess
17
+ import threading
18
+ import time
19
+ from collections.abc import Callable
20
+ from pathlib import Path
21
+
22
+ from .config import AllplanConfig, get_config
23
+ from .paths import interpreter_path
24
+
25
+ _SHUTDOWN_TIMEOUT_SECONDS = 5
26
+ _logger = logging.getLogger(__name__)
27
+
28
+
29
+ class WorkerGoneError(RuntimeError):
30
+ """The worker process died or fell out of sync with the request/response protocol."""
31
+
32
+
33
+ class PersistentWorker:
34
+ """A worker script running under the ALLPLAN interpreter, reused across calls.
35
+
36
+ The protocol is one JSON line in, one JSON line out. The first line sent is an
37
+ init handshake that the worker acknowledges before it accepts any request.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ script: Path,
43
+ build_init_params: Callable[[AllplanConfig], dict],
44
+ *,
45
+ init_timeout: float,
46
+ request_timeout: float,
47
+ ) -> None:
48
+ self._script = script
49
+ self._build_init_params = build_init_params
50
+ self._init_timeout = init_timeout
51
+ self._request_timeout = request_timeout
52
+ self._proc: subprocess.Popen[str] | None = None
53
+ self._lines: queue.Queue[str | None] = queue.Queue()
54
+ self._lock = threading.Lock()
55
+ self._init_data: dict = {}
56
+ atexit.register(self.shutdown)
57
+
58
+ @property
59
+ def init_data(self) -> dict:
60
+ """Payload the worker reported in its handshake ack, empty until first use."""
61
+ return self._init_data
62
+
63
+ @property
64
+ def is_running(self) -> bool:
65
+ """Whether the subprocess is alive, per ``Popen.poll()``."""
66
+ return self._proc is not None and self._proc.poll() is None
67
+
68
+ def request(self, payload: dict) -> dict:
69
+
70
+ """Send one request and return the worker's response, or a structured error.
71
+
72
+ Every failure mode leaves the worker dead but the caller alive: the next
73
+ request spawns a fresh process.
74
+ """
75
+ with self._lock:
76
+ try:
77
+ proc = self._ensure_running()
78
+ self._write(proc, payload)
79
+ return self._read(self._request_timeout)
80
+ except TimeoutError:
81
+ self._kill()
82
+ return {
83
+ "ok": False,
84
+ "error": f"The worker did not respond within {self._request_timeout:.0f}s "
85
+ "and was restarted. A snippet that blocks forever cannot be recovered "
86
+ "from — make sure it terminates.",
87
+ }
88
+ except (WorkerGoneError, BrokenPipeError, OSError) as exc:
89
+ self._kill()
90
+ return {"ok": False, "error": str(exc)}
91
+
92
+ def shutdown(self) -> None:
93
+ """Ask the worker to exit by closing its stdin, then kill it if it lingers."""
94
+ proc = self._proc
95
+ if proc is None or proc.poll() is not None:
96
+ self._proc = None
97
+ return
98
+ try:
99
+ if proc.stdin is not None:
100
+ proc.stdin.close()
101
+ proc.wait(timeout=_SHUTDOWN_TIMEOUT_SECONDS)
102
+ except (OSError, subprocess.TimeoutExpired):
103
+ proc.kill()
104
+ finally:
105
+ self._proc = None
106
+
107
+ def _ensure_running(self) -> subprocess.Popen[str]:
108
+ if self._proc is not None and self._proc.poll() is None:
109
+ return self._proc
110
+ self._proc = self._spawn(get_config())
111
+ return self._proc
112
+
113
+ def _spawn(self, cfg: AllplanConfig) -> subprocess.Popen[str]:
114
+ self._lines = queue.Queue()
115
+ proc = subprocess.Popen(
116
+ [str(interpreter_path(cfg.prg)), str(self._script)],
117
+ stdin=subprocess.PIPE,
118
+ stdout=subprocess.PIPE,
119
+ stderr=subprocess.DEVNULL,
120
+ text=True,
121
+ encoding="utf-8",
122
+ errors="replace",
123
+ bufsize=1,
124
+ )
125
+ self._proc = proc
126
+
127
+ threading.Thread(target=self._pump, args=(proc, self._lines), daemon=True).start()
128
+
129
+ self._write(proc, self._build_init_params(cfg))
130
+ ack = self._read(self._init_timeout)
131
+ if not ack.get("ok"):
132
+ proc.kill()
133
+ raise WorkerGoneError(f"Worker init failed: {ack.get('error', ack)}")
134
+
135
+ self._init_data = ack.get("data", {})
136
+ return proc
137
+
138
+ @staticmethod
139
+ def _pump(proc: subprocess.Popen[str], lines: queue.Queue[str | None]) -> None:
140
+ """Drain the worker's stdout into a queue so reads can be given a deadline."""
141
+ assert proc.stdout is not None
142
+ for line in proc.stdout:
143
+ lines.put(line)
144
+ lines.put(None)
145
+
146
+ @staticmethod
147
+ def _write(proc: subprocess.Popen[str], payload: dict) -> None:
148
+ if proc.stdin is None:
149
+ raise WorkerGoneError("Worker stdin is not available.")
150
+ proc.stdin.write(json.dumps(payload) + "\n")
151
+ proc.stdin.flush()
152
+
153
+ def _read(self, timeout: float) -> dict:
154
+ deadline = time.monotonic() + timeout
155
+ while True:
156
+ remaining = deadline - time.monotonic()
157
+ if remaining <= 0:
158
+ raise TimeoutError
159
+ try:
160
+ line = self._lines.get(timeout=remaining)
161
+ except queue.Empty:
162
+ raise TimeoutError from None
163
+
164
+ if line is None:
165
+ raise WorkerGoneError("The worker exited unexpectedly.")
166
+
167
+ # The ALLPLAN DLLs occasionally print standalone log lines to stdout, so
168
+ # skip anything before the JSON, and skip lines that contain no JSON at all.
169
+ json_start = line.find("{")
170
+ if json_start == -1:
171
+ continue
172
+ try:
173
+ return json.loads(line[json_start:])
174
+ except json.JSONDecodeError as exc:
175
+ raise WorkerGoneError(f"Malformed worker response: {exc}. Raw: {line[:300]}") from exc
176
+
177
+ def _kill(self) -> None:
178
+ proc = self._proc
179
+ self._proc = None
180
+ if proc is not None:
181
+ try:
182
+ proc.kill()
183
+ except OSError:
184
+ pass