paraview-mcp-python 0.1.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,350 @@
1
+ """Headless pvpython execution helpers used as a fallback transport.
2
+
3
+ This module lets the MCP server launch a separate ``pvpython`` / ``pvbatch``
4
+ process to execute scripts without requiring a running bridge. It mirrors
5
+ the ``HeadlessBlenderExecutor`` from the Blender MCP server.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import json
13
+ import os
14
+ import tempfile
15
+ import textwrap
16
+ import time
17
+ import traceback
18
+ import uuid
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ RESULT_PREFIX = "__PARAVIEW_MCP_RESULT__="
23
+
24
+
25
+ def _cap_output(text: str, limit: int = 50_000) -> str:
26
+ if len(text) <= limit:
27
+ return text
28
+ return text[:limit] + f"\n… (truncated, {len(text)} total chars)"
29
+
30
+
31
+ def _safe_json(value: Any) -> Any:
32
+ if value is None:
33
+ return None
34
+ try:
35
+ json.dumps(value)
36
+ return value
37
+ except (TypeError, ValueError):
38
+ return repr(value)
39
+
40
+
41
+ def _extract_payload(stdout: str) -> tuple[dict[str, Any] | None, str]:
42
+ """Split out the structured result payload from raw pvpython stdout."""
43
+ payload = None
44
+ clean_lines: list[str] = []
45
+ for line in stdout.splitlines():
46
+ if line.startswith(RESULT_PREFIX):
47
+ payload = json.loads(line[len(RESULT_PREFIX) :])
48
+ else:
49
+ clean_lines.append(line)
50
+ cleaned = "\n".join(clean_lines)
51
+ if stdout.endswith("\n"):
52
+ cleaned += "\n"
53
+ return payload, cleaned
54
+
55
+
56
+ def _build_wrapper_script(code_path: Path, args_path: Path) -> str:
57
+ return textwrap.dedent(
58
+ f"""
59
+ import io
60
+ import json
61
+ import pathlib
62
+ import traceback
63
+ from contextlib import redirect_stdout, redirect_stderr
64
+
65
+ try:
66
+ import paraview.simple as pvs
67
+ except ImportError:
68
+ pvs = None
69
+
70
+ code = pathlib.Path({code_path.as_posix()!r}).read_text(encoding="utf-8")
71
+ args = json.loads(pathlib.Path({args_path.as_posix()!r}).read_text(encoding="utf-8"))
72
+ namespace = {{
73
+ "pvs": pvs,
74
+ "args": args,
75
+ "__result__": None,
76
+ }}
77
+ stdout_buf = io.StringIO()
78
+ stderr_buf = io.StringIO()
79
+
80
+ try:
81
+ with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
82
+ exec(compile(code, "<mcp-headless-script>", "exec"), namespace)
83
+ payload = {{
84
+ "result": namespace.get("__result__"),
85
+ "stdout": stdout_buf.getvalue(),
86
+ "stderr": stderr_buf.getvalue(),
87
+ "error": None,
88
+ "timed_out": False,
89
+ "cancelled": False,
90
+ }}
91
+ except Exception as exc:
92
+ tb = traceback.format_exception(type(exc), exc, exc.__traceback__)
93
+ payload = {{
94
+ "result": None,
95
+ "stdout": stdout_buf.getvalue(),
96
+ "stderr": stderr_buf.getvalue(),
97
+ "error": "".join(tb).strip(),
98
+ "timed_out": False,
99
+ "cancelled": False,
100
+ }}
101
+
102
+ print({RESULT_PREFIX!r} + json.dumps(payload, ensure_ascii=True, default=repr))
103
+ """
104
+ )
105
+
106
+
107
+ class HeadlessPvpythonExecutor:
108
+ """Run ParaView scripts in a separate headless ``pvpython`` process."""
109
+
110
+ def __init__(self, pvpython_binary: str | None = None):
111
+ self.pvpython_binary = pvpython_binary or os.environ.get("PVPYTHON_BIN", "pvpython")
112
+
113
+ async def execute(
114
+ self,
115
+ *,
116
+ code: str | None = None,
117
+ script_path: str | None = None,
118
+ args: dict[str, Any] | None = None,
119
+ timeout_seconds: int | None = None,
120
+ process_holder: dict[str, Any] | None = None,
121
+ ) -> dict[str, Any]:
122
+ if code and script_path:
123
+ raise ValueError("Provide either 'code' or 'script_path', not both")
124
+ if not code and not script_path:
125
+ raise ValueError("Either 'code' or 'script_path' must be provided")
126
+
127
+ if script_path is not None:
128
+ code = Path(script_path).read_text(encoding="utf-8")
129
+
130
+ args = args or {}
131
+ start = time.monotonic()
132
+
133
+ with tempfile.TemporaryDirectory(prefix="paraview-mcp-headless-") as tmpdir:
134
+ tmp = Path(tmpdir)
135
+ code_path = tmp / "script.py"
136
+ args_path = tmp / "args.json"
137
+ wrapper_path = tmp / "wrapper.py"
138
+
139
+ code_path.write_text(code or "", encoding="utf-8")
140
+ args_path.write_text(json.dumps(args), encoding="utf-8")
141
+ wrapper_path.write_text(_build_wrapper_script(code_path, args_path), encoding="utf-8")
142
+
143
+ cmd = [self.pvpython_binary, str(wrapper_path)]
144
+
145
+ proc = await asyncio.create_subprocess_exec(
146
+ *cmd,
147
+ stdout=asyncio.subprocess.PIPE,
148
+ stderr=asyncio.subprocess.PIPE,
149
+ )
150
+ if process_holder is not None:
151
+ process_holder["process"] = proc
152
+
153
+ try:
154
+ stdout_b, stderr_b = await asyncio.wait_for(
155
+ proc.communicate(),
156
+ timeout=(timeout_seconds if timeout_seconds and timeout_seconds > 0 else None),
157
+ )
158
+ except asyncio.TimeoutError:
159
+ proc.kill()
160
+ stdout_b, stderr_b = await proc.communicate()
161
+ elapsed = time.monotonic() - start
162
+ return {
163
+ "result": None,
164
+ "stdout": _cap_output(stdout_b.decode("utf-8", errors="replace")),
165
+ "stderr": _cap_output(stderr_b.decode("utf-8", errors="replace")),
166
+ "error": f"Execution exceeded timeout of {timeout_seconds}s",
167
+ "duration_seconds": round(elapsed, 4),
168
+ "timed_out": True,
169
+ "cancelled": False,
170
+ }
171
+ except asyncio.CancelledError:
172
+ proc.terminate()
173
+ stdout_b, stderr_b = await proc.communicate()
174
+ elapsed = time.monotonic() - start
175
+ return {
176
+ "result": None,
177
+ "stdout": _cap_output(stdout_b.decode("utf-8", errors="replace")),
178
+ "stderr": _cap_output(stderr_b.decode("utf-8", errors="replace")),
179
+ "error": "Execution cancelled",
180
+ "duration_seconds": round(elapsed, 4),
181
+ "timed_out": False,
182
+ "cancelled": True,
183
+ }
184
+
185
+ elapsed = time.monotonic() - start
186
+ stdout = stdout_b.decode("utf-8", errors="replace")
187
+ stderr = stderr_b.decode("utf-8", errors="replace")
188
+ try:
189
+ payload, clean_stdout = _extract_payload(stdout)
190
+ except json.JSONDecodeError as exc:
191
+ error = f"Headless pvpython returned an invalid result payload: {exc}"
192
+ if stderr.strip():
193
+ error = f"{error}\n{stderr.strip()}"
194
+ return {
195
+ "result": None,
196
+ "stdout": _cap_output(stdout),
197
+ "stderr": _cap_output(stderr),
198
+ "error": error,
199
+ "duration_seconds": round(elapsed, 4),
200
+ "timed_out": False,
201
+ "cancelled": False,
202
+ }
203
+
204
+ if payload is None:
205
+ error = f"Headless pvpython exited with code {proc.returncode} without a result payload"
206
+ if stderr.strip():
207
+ error = f"{error}\n{stderr.strip()}"
208
+ return {
209
+ "result": None,
210
+ "stdout": _cap_output(stdout),
211
+ "stderr": _cap_output(stderr),
212
+ "error": error,
213
+ "duration_seconds": round(elapsed, 4),
214
+ "timed_out": False,
215
+ "cancelled": False,
216
+ }
217
+
218
+ return {
219
+ "result": _safe_json(payload.get("result")),
220
+ "stdout": _cap_output(clean_stdout + payload.get("stdout", "")),
221
+ "stderr": _cap_output(stderr + payload.get("stderr", "")),
222
+ "error": payload.get("error"),
223
+ "duration_seconds": round(elapsed, 4),
224
+ "timed_out": bool(payload.get("timed_out")),
225
+ "cancelled": bool(payload.get("cancelled")),
226
+ }
227
+
228
+
229
+ class HeadlessJobManager:
230
+ """Track async headless pvpython executions inside the MCP server process."""
231
+
232
+ def __init__(self):
233
+ self._jobs: dict[str, dict[str, Any]] = {}
234
+
235
+ async def create_job(
236
+ self,
237
+ executor: HeadlessPvpythonExecutor,
238
+ *,
239
+ code: str | None = None,
240
+ script_path: str | None = None,
241
+ args: dict[str, Any] | None = None,
242
+ timeout_seconds: int | None = None,
243
+ ) -> str:
244
+ job_id = f"headless-job-{uuid.uuid4().hex[:8]}"
245
+ process_holder: dict[str, Any] = {}
246
+ job: dict[str, Any] = {
247
+ "job_id": job_id,
248
+ "status": "queued",
249
+ "created_at": time.time(),
250
+ "started_at": None,
251
+ "completed_at": None,
252
+ "result": None,
253
+ "stdout": "",
254
+ "stderr": "",
255
+ "error": None,
256
+ "cancelled": False,
257
+ "timed_out": False,
258
+ "process_holder": process_holder,
259
+ "task": None,
260
+ }
261
+ self._jobs[job_id] = job
262
+
263
+ async def runner():
264
+ job["status"] = "running"
265
+ job["started_at"] = time.time()
266
+ try:
267
+ result = await executor.execute(
268
+ code=code,
269
+ script_path=script_path,
270
+ args=args,
271
+ timeout_seconds=timeout_seconds,
272
+ process_holder=process_holder,
273
+ )
274
+ except asyncio.CancelledError:
275
+ job["error"] = "Execution cancelled"
276
+ job["cancelled"] = True
277
+ job["status"] = "cancelled"
278
+ job["completed_at"] = time.time()
279
+ raise
280
+ except Exception as exc:
281
+ job["error"] = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)).strip()
282
+ job["status"] = "failed"
283
+ job["completed_at"] = time.time()
284
+ else:
285
+ job["result"] = result.get("result")
286
+ job["stdout"] = result.get("stdout", "")
287
+ job["stderr"] = result.get("stderr", "")
288
+ job["error"] = result.get("error")
289
+ job["cancelled"] = bool(result.get("cancelled"))
290
+ job["timed_out"] = bool(result.get("timed_out"))
291
+ job["completed_at"] = time.time()
292
+ if job["cancelled"]:
293
+ job["status"] = "cancelled"
294
+ elif job["error"]:
295
+ job["status"] = "failed"
296
+ else:
297
+ job["status"] = "succeeded"
298
+ finally:
299
+ process_holder.pop("process", None)
300
+
301
+ job["task"] = asyncio.create_task(runner())
302
+ return job_id
303
+
304
+ def get_status(self, job_id: str) -> dict[str, Any]:
305
+ job = self._jobs.get(job_id)
306
+ if not job:
307
+ raise ValueError(f"Unknown job: {job_id}")
308
+ return {
309
+ "job_id": job["job_id"],
310
+ "status": job["status"],
311
+ "created_at": job["created_at"],
312
+ "started_at": job["started_at"],
313
+ "completed_at": job["completed_at"],
314
+ "result": job["result"],
315
+ "stdout": job["stdout"],
316
+ "stderr": job["stderr"],
317
+ "error": job["error"],
318
+ "cancelled": job["cancelled"],
319
+ "timed_out": job["timed_out"],
320
+ }
321
+
322
+ def list_jobs(self) -> dict[str, Any]:
323
+ jobs = [
324
+ {
325
+ "job_id": job["job_id"],
326
+ "status": job["status"],
327
+ "created_at": job["created_at"],
328
+ }
329
+ for job in self._jobs.values()
330
+ ]
331
+ return {"jobs": jobs}
332
+
333
+ async def cancel(self, job_id: str) -> dict[str, Any]:
334
+ job = self._jobs.get(job_id)
335
+ if not job:
336
+ raise ValueError(f"Unknown job: {job_id}")
337
+
338
+ proc = job["process_holder"].get("process")
339
+ if proc is not None and proc.returncode is None:
340
+ proc.terminate()
341
+ task = job.get("task")
342
+ if task is not None and not task.done():
343
+ task.cancel()
344
+ with contextlib.suppress(asyncio.CancelledError):
345
+ await task
346
+ if job["status"] in {"queued", "running"}:
347
+ job["status"] = "cancelled"
348
+ job["completed_at"] = time.time()
349
+ job["cancelled"] = True
350
+ return {"job_id": job_id, "status": job["status"]}