baltamatica-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.
- baltamatica_mcp/__init__.py +3 -0
- baltamatica_mcp/__main__.py +6 -0
- baltamatica_mcp/backend_bex.py +316 -0
- baltamatica_mcp/backend_cli.py +323 -0
- baltamatica_mcp/bex_shutdown.py +158 -0
- baltamatica_mcp/engine.py +177 -0
- baltamatica_mcp/serializer.py +320 -0
- baltamatica_mcp/server.py +213 -0
- baltamatica_mcp-0.2.0.dist-info/METADATA +425 -0
- baltamatica_mcp-0.2.0.dist-info/RECORD +13 -0
- baltamatica_mcp-0.2.0.dist-info/WHEEL +4 -0
- baltamatica_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- baltamatica_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""BEX backend client for the Baltamatica JSON-over-TCP bridge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from baltamatica_mcp.engine import (
|
|
12
|
+
Artifact,
|
|
13
|
+
EngineError,
|
|
14
|
+
EngineUnavailableError,
|
|
15
|
+
ExecutionResult,
|
|
16
|
+
VariableInfo,
|
|
17
|
+
VariableListResult,
|
|
18
|
+
)
|
|
19
|
+
from baltamatica_mcp.serializer import (
|
|
20
|
+
encode_for_set,
|
|
21
|
+
present_binary_value,
|
|
22
|
+
present_structured,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
DEFAULT_BEX_HOST = "127.0.0.1"
|
|
26
|
+
DEFAULT_BEX_PORT = 31415
|
|
27
|
+
DEFAULT_ARTIFACT_TYPE = "application/octet-stream"
|
|
28
|
+
# Max bytes for a single response line (large binary get_variable payloads).
|
|
29
|
+
_STREAM_LIMIT = 64 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BexProtocolError(EngineError):
|
|
33
|
+
"""Raised when the BEX bridge returns malformed protocol data."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _BexConnectionLost(EngineUnavailableError):
|
|
37
|
+
"""Internal signal used to retry a dropped transport once."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BexEngine:
|
|
41
|
+
"""Client for a Baltamatica BEX plugin speaking newline-delimited JSON."""
|
|
42
|
+
|
|
43
|
+
backend = "bex"
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
host: str = DEFAULT_BEX_HOST,
|
|
48
|
+
port: int = DEFAULT_BEX_PORT,
|
|
49
|
+
timeout: float = 30.0,
|
|
50
|
+
reconnect: bool = True,
|
|
51
|
+
) -> None:
|
|
52
|
+
if port <= 0 or port > 65535:
|
|
53
|
+
raise ValueError(f"Invalid BEX port: {port}")
|
|
54
|
+
if timeout <= 0:
|
|
55
|
+
raise ValueError(f"Invalid BEX timeout: {timeout}")
|
|
56
|
+
|
|
57
|
+
self.host = host
|
|
58
|
+
self.port = port
|
|
59
|
+
self.timeout = timeout
|
|
60
|
+
self.reconnect = reconnect
|
|
61
|
+
self._reader: asyncio.StreamReader | None = None
|
|
62
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
63
|
+
self._request_counter = 0
|
|
64
|
+
|
|
65
|
+
async def execute_code(self, code: str) -> ExecutionResult:
|
|
66
|
+
response = await self._request("execute_code", {"code": code})
|
|
67
|
+
return _with_output_artifacts(_execution_result_from_response(response))
|
|
68
|
+
|
|
69
|
+
async def run_script(self, file_path: str) -> ExecutionResult:
|
|
70
|
+
response = await self._request("run_script", {"file_path": file_path})
|
|
71
|
+
return _with_output_artifacts(_execution_result_from_response(response))
|
|
72
|
+
|
|
73
|
+
async def clear_workspace(self) -> ExecutionResult:
|
|
74
|
+
response = await self._request("clear_workspace", {})
|
|
75
|
+
return _execution_result_from_response(response)
|
|
76
|
+
|
|
77
|
+
async def set_variable(self, name: str, data: Any, dtype: str | None = None) -> ExecutionResult:
|
|
78
|
+
resolved_dtype, dims, raw = encode_for_set(data, dtype)
|
|
79
|
+
response = await self._request(
|
|
80
|
+
"set_variable",
|
|
81
|
+
{
|
|
82
|
+
"name": name,
|
|
83
|
+
"dtype": resolved_dtype,
|
|
84
|
+
"dims": dims,
|
|
85
|
+
"data_b64": base64.b64encode(raw).decode("ascii"),
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
return _execution_result_from_response(response)
|
|
89
|
+
|
|
90
|
+
async def list_variables(self) -> VariableListResult:
|
|
91
|
+
response = await self._request("list_variables", {})
|
|
92
|
+
return VariableListResult(
|
|
93
|
+
success=bool(response.get("success", False)),
|
|
94
|
+
variables=_variables_from_response(response),
|
|
95
|
+
output=_string_field(response, "output"),
|
|
96
|
+
error=_error_from_response(response),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def get_variable(self, name: str) -> ExecutionResult:
|
|
100
|
+
response = await self._request("get_variable", {"name": name})
|
|
101
|
+
result = _execution_result_from_response(response)
|
|
102
|
+
value = result.value
|
|
103
|
+
if isinstance(value, dict) and "data_b64" in value:
|
|
104
|
+
presented, artifacts = present_binary_value(value, name=name)
|
|
105
|
+
result = replace(
|
|
106
|
+
result,
|
|
107
|
+
value=presented,
|
|
108
|
+
artifacts=(result.artifacts or []) + artifacts,
|
|
109
|
+
)
|
|
110
|
+
elif isinstance(value, dict) and value.get("type") in {
|
|
111
|
+
"numeric_array",
|
|
112
|
+
"logical_array",
|
|
113
|
+
"struct",
|
|
114
|
+
"cell",
|
|
115
|
+
}:
|
|
116
|
+
result = replace(result, value=present_structured(value))
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
async def close(self) -> None:
|
|
120
|
+
"""Close the current TCP connection, if one is open."""
|
|
121
|
+
|
|
122
|
+
writer = self._writer
|
|
123
|
+
self._reader = None
|
|
124
|
+
self._writer = None
|
|
125
|
+
if writer is None:
|
|
126
|
+
return
|
|
127
|
+
writer.close()
|
|
128
|
+
await writer.wait_closed()
|
|
129
|
+
|
|
130
|
+
async def _request(self, method: str, params: dict[str, object]) -> dict[str, Any]:
|
|
131
|
+
payload = {
|
|
132
|
+
"id": self._next_request_id(),
|
|
133
|
+
"method": method,
|
|
134
|
+
"params": params,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
return await self._send_once(payload)
|
|
139
|
+
except _BexConnectionLost:
|
|
140
|
+
if not self.reconnect:
|
|
141
|
+
raise
|
|
142
|
+
return await self._send_once(payload)
|
|
143
|
+
|
|
144
|
+
async def _send_once(self, payload: dict[str, object]) -> dict[str, Any]:
|
|
145
|
+
reader, writer = await self._ensure_connection()
|
|
146
|
+
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + b"\n"
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
writer.write(data)
|
|
150
|
+
await asyncio.wait_for(writer.drain(), timeout=self.timeout)
|
|
151
|
+
line = await asyncio.wait_for(reader.readline(), timeout=self.timeout)
|
|
152
|
+
except asyncio.TimeoutError as exc:
|
|
153
|
+
await self.close()
|
|
154
|
+
raise TimeoutError(
|
|
155
|
+
f"BEX request '{payload['method']}' timed out after {self.timeout:g} seconds."
|
|
156
|
+
) from exc
|
|
157
|
+
except (ConnectionError, OSError) as exc:
|
|
158
|
+
await self.close()
|
|
159
|
+
raise _BexConnectionLost(f"BEX connection lost: {exc}") from exc
|
|
160
|
+
|
|
161
|
+
if not line:
|
|
162
|
+
await self.close()
|
|
163
|
+
raise _BexConnectionLost("BEX connection closed before a response was received.")
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
response = json.loads(line.decode("utf-8"))
|
|
167
|
+
except json.JSONDecodeError as exc:
|
|
168
|
+
raise BexProtocolError(f"BEX response is not valid JSON: {exc}") from exc
|
|
169
|
+
|
|
170
|
+
if not isinstance(response, dict):
|
|
171
|
+
raise BexProtocolError("BEX response must be a JSON object.")
|
|
172
|
+
if response.get("id") != payload["id"]:
|
|
173
|
+
raise BexProtocolError(
|
|
174
|
+
f"BEX response id mismatch: expected {payload['id']!r}, "
|
|
175
|
+
f"got {response.get('id')!r}."
|
|
176
|
+
)
|
|
177
|
+
return response
|
|
178
|
+
|
|
179
|
+
async def _ensure_connection(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
180
|
+
if self._connection_is_usable():
|
|
181
|
+
return self._reader, self._writer # type: ignore[return-value]
|
|
182
|
+
|
|
183
|
+
await self.close()
|
|
184
|
+
try:
|
|
185
|
+
self._reader, self._writer = await asyncio.wait_for(
|
|
186
|
+
# A large stream limit so big get_variable responses (base64 of a
|
|
187
|
+
# large matrix) fit in a single readline() instead of raising
|
|
188
|
+
# LimitOverrunError at the 64 KB default.
|
|
189
|
+
asyncio.open_connection(self.host, self.port, limit=_STREAM_LIMIT),
|
|
190
|
+
timeout=self.timeout,
|
|
191
|
+
)
|
|
192
|
+
except asyncio.TimeoutError as exc:
|
|
193
|
+
raise EngineUnavailableError(
|
|
194
|
+
f"Timed out connecting to BEX bridge at {self.host}:{self.port}."
|
|
195
|
+
) from exc
|
|
196
|
+
except OSError as exc:
|
|
197
|
+
raise EngineUnavailableError(
|
|
198
|
+
f"Failed to connect to BEX bridge at {self.host}:{self.port}: {exc}"
|
|
199
|
+
) from exc
|
|
200
|
+
return self._reader, self._writer
|
|
201
|
+
|
|
202
|
+
def _connection_is_usable(self) -> bool:
|
|
203
|
+
return (
|
|
204
|
+
self._reader is not None
|
|
205
|
+
and self._writer is not None
|
|
206
|
+
and not self._writer.is_closing()
|
|
207
|
+
and not self._reader.at_eof()
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _next_request_id(self) -> str:
|
|
211
|
+
self._request_counter += 1
|
|
212
|
+
return str(self._request_counter)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _with_output_artifacts(result: ExecutionResult) -> ExecutionResult:
|
|
216
|
+
"""Merge BALTAMATICA_ARTIFACT markers found in captured output into artifacts.
|
|
217
|
+
|
|
218
|
+
Baltamatica cannot export figures to files, but data-export functions
|
|
219
|
+
(writematrix, writetable, save, ...) can, and a script/command can announce
|
|
220
|
+
the resulting file with a BALTAMATICA_ARTIFACT line. Since execute_code and
|
|
221
|
+
run_script now capture console output, those markers are reported here too.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
from baltamatica_mcp.backend_cli import parse_artifacts
|
|
225
|
+
|
|
226
|
+
if not result.output:
|
|
227
|
+
return result
|
|
228
|
+
marker_artifacts = parse_artifacts(result.output)
|
|
229
|
+
if not marker_artifacts:
|
|
230
|
+
return result
|
|
231
|
+
return replace(result, artifacts=(result.artifacts or []) + marker_artifacts)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _execution_result_from_response(response: dict[str, Any]) -> ExecutionResult:
|
|
235
|
+
return ExecutionResult(
|
|
236
|
+
success=bool(response.get("success", False)),
|
|
237
|
+
output=_string_field(response, "output"),
|
|
238
|
+
error=_error_from_response(response),
|
|
239
|
+
artifacts=_artifacts_from_response(response),
|
|
240
|
+
value=_value_from_response(response),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _error_from_response(response: dict[str, Any]) -> str | None:
|
|
245
|
+
error = response.get("error")
|
|
246
|
+
if error is None:
|
|
247
|
+
return None if response.get("success", False) else "BEX request failed."
|
|
248
|
+
if isinstance(error, dict):
|
|
249
|
+
code = str(error.get("code") or "ERROR")
|
|
250
|
+
message = str(error.get("message") or "")
|
|
251
|
+
return f"{code}: {message}" if message else code
|
|
252
|
+
return str(error)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _artifacts_from_response(response: dict[str, Any]) -> list[Artifact]:
|
|
256
|
+
raw_artifacts = response.get("artifacts") or []
|
|
257
|
+
if not isinstance(raw_artifacts, list):
|
|
258
|
+
return []
|
|
259
|
+
|
|
260
|
+
artifacts: list[Artifact] = []
|
|
261
|
+
for item in raw_artifacts:
|
|
262
|
+
if not isinstance(item, dict):
|
|
263
|
+
continue
|
|
264
|
+
path = str(item.get("path") or "")
|
|
265
|
+
if not path:
|
|
266
|
+
continue
|
|
267
|
+
artifacts.append(
|
|
268
|
+
Artifact(
|
|
269
|
+
path=path,
|
|
270
|
+
type=str(item.get("type") or DEFAULT_ARTIFACT_TYPE),
|
|
271
|
+
exists=bool(item.get("exists", False)),
|
|
272
|
+
size=_int_field(item.get("size")),
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
return artifacts
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _value_from_response(response: dict[str, Any]) -> dict[str, Any] | None:
|
|
279
|
+
value = response.get("value")
|
|
280
|
+
return value if isinstance(value, dict) else None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _variables_from_response(response: dict[str, Any]) -> list[VariableInfo]:
|
|
284
|
+
raw_variables = response.get("variables") or []
|
|
285
|
+
if not isinstance(raw_variables, list):
|
|
286
|
+
return []
|
|
287
|
+
|
|
288
|
+
variables: list[VariableInfo] = []
|
|
289
|
+
for item in raw_variables:
|
|
290
|
+
if not isinstance(item, dict):
|
|
291
|
+
continue
|
|
292
|
+
name = str(item.get("name") or "")
|
|
293
|
+
if not name:
|
|
294
|
+
continue
|
|
295
|
+
variables.append(
|
|
296
|
+
VariableInfo(
|
|
297
|
+
name=name,
|
|
298
|
+
size=str(item.get("size") or ""),
|
|
299
|
+
bytes=_int_field(item.get("bytes")),
|
|
300
|
+
class_name=str(item.get("class_name") or ""),
|
|
301
|
+
attributes=str(item.get("attributes") or ""),
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
return variables
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _string_field(data: dict[str, Any], key: str) -> str:
|
|
308
|
+
value = data.get(key)
|
|
309
|
+
return "" if value is None else str(value)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _int_field(value: object) -> int:
|
|
313
|
+
try:
|
|
314
|
+
return int(value) if value is not None else 0
|
|
315
|
+
except (TypeError, ValueError):
|
|
316
|
+
return 0
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""CLI backend for running Baltamatica commands through baltamaticaC.sh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import locale
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from baltamatica_mcp.engine import (
|
|
17
|
+
Artifact,
|
|
18
|
+
ExecutionResult,
|
|
19
|
+
EngineUnavailableError,
|
|
20
|
+
VariableInfo,
|
|
21
|
+
VariableListResult,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
DEFAULT_EXECUTABLE = "baltamaticaC.sh"
|
|
25
|
+
ENV_EXECUTABLE = "BALTAMATICA_CLI"
|
|
26
|
+
ARTIFACT_PREFIX = "BALTAMATICA_ARTIFACT="
|
|
27
|
+
VAR_NAME_PATTERN = re.compile(r"^[A-Za-z]\w*$")
|
|
28
|
+
ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
|
|
29
|
+
NOISE_OUTPUT_PATTERNS = (
|
|
30
|
+
"Failed to delete old log:",
|
|
31
|
+
)
|
|
32
|
+
ERROR_OUTPUT_PATTERNS = (
|
|
33
|
+
"错误使用函数",
|
|
34
|
+
"未定义的变量或函数",
|
|
35
|
+
"未定义的函数",
|
|
36
|
+
"是未定义的变量或函数",
|
|
37
|
+
"位于输入的第",
|
|
38
|
+
"位于文件",
|
|
39
|
+
)
|
|
40
|
+
WHOS_ROW_PATTERN = re.compile(
|
|
41
|
+
r"^\s*(?P<name>[A-Za-z]\w*)\s+"
|
|
42
|
+
r"(?P<size>\S+)\s+"
|
|
43
|
+
r"(?P<bytes>\d+)\s+"
|
|
44
|
+
r"(?P<class_name>\S+)"
|
|
45
|
+
r"(?:\s+(?P<attributes>.*?))?\s*$"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class CliProcessResult:
|
|
51
|
+
"""Raw process result from the Baltamatica CLI."""
|
|
52
|
+
|
|
53
|
+
returncode: int
|
|
54
|
+
stdout: str
|
|
55
|
+
stderr: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CliEngine:
|
|
59
|
+
"""Execute Baltamatica code by spawning the command-line runtime."""
|
|
60
|
+
|
|
61
|
+
backend = "cli"
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
executable: str | None = None,
|
|
66
|
+
timeout: float = 30.0,
|
|
67
|
+
state_file: str | Path | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._configured_executable = executable
|
|
70
|
+
self.timeout = timeout
|
|
71
|
+
self.state_file = Path(state_file) if state_file else _default_state_file()
|
|
72
|
+
|
|
73
|
+
async def execute_code(self, code: str) -> ExecutionResult:
|
|
74
|
+
return await self._execute_command(self._wrap_stateful_code(code))
|
|
75
|
+
|
|
76
|
+
async def run_script(self, file_path: str) -> ExecutionResult:
|
|
77
|
+
script_command = f"run('{_escape_baltamatica_string(Path(file_path).as_posix())}')"
|
|
78
|
+
return await self.execute_code(script_command)
|
|
79
|
+
|
|
80
|
+
async def clear_workspace(self) -> ExecutionResult:
|
|
81
|
+
if self.state_file.exists():
|
|
82
|
+
self.state_file.unlink()
|
|
83
|
+
return await self._execute_command("clear")
|
|
84
|
+
|
|
85
|
+
async def set_variable(self, name: str, data: object, dtype: str | None = None) -> ExecutionResult:
|
|
86
|
+
from baltamatica_mcp.serializer import to_baltamatica_literal
|
|
87
|
+
|
|
88
|
+
return await self.execute_code(f"{name} = {to_baltamatica_literal(data, dtype)};")
|
|
89
|
+
|
|
90
|
+
async def list_variables(self) -> VariableListResult:
|
|
91
|
+
result = await self._execute_command(self._wrap_readonly_code("whos"))
|
|
92
|
+
if not result.success:
|
|
93
|
+
return VariableListResult(
|
|
94
|
+
success=False,
|
|
95
|
+
variables=[],
|
|
96
|
+
output=result.output,
|
|
97
|
+
error=result.error,
|
|
98
|
+
)
|
|
99
|
+
return VariableListResult(
|
|
100
|
+
success=True,
|
|
101
|
+
variables=parse_whos_output(result.output),
|
|
102
|
+
output=result.output,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
async def get_variable(self, name: str) -> ExecutionResult:
|
|
106
|
+
if not VAR_NAME_PATTERN.match(name):
|
|
107
|
+
raise ValueError(f"Invalid variable name: {name}")
|
|
108
|
+
return await self._execute_command(self._wrap_readonly_code(f"disp({name})"))
|
|
109
|
+
|
|
110
|
+
def _resolve_executable(self) -> str:
|
|
111
|
+
configured = self._configured_executable or os.environ.get(ENV_EXECUTABLE)
|
|
112
|
+
if configured:
|
|
113
|
+
path = Path(configured).expanduser()
|
|
114
|
+
if path.is_absolute() or len(path.parts) > 1:
|
|
115
|
+
if not path.exists():
|
|
116
|
+
raise EngineUnavailableError(
|
|
117
|
+
f"Baltamatica CLI executable does not exist: {path}"
|
|
118
|
+
)
|
|
119
|
+
if not path.is_file():
|
|
120
|
+
raise EngineUnavailableError(f"Baltamatica CLI path is not a file: {path}")
|
|
121
|
+
return str(path)
|
|
122
|
+
resolved = shutil.which(configured)
|
|
123
|
+
if resolved:
|
|
124
|
+
return resolved
|
|
125
|
+
raise EngineUnavailableError(
|
|
126
|
+
f"Baltamatica CLI executable not found on PATH: {configured}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
resolved = shutil.which(DEFAULT_EXECUTABLE)
|
|
130
|
+
if resolved:
|
|
131
|
+
return resolved
|
|
132
|
+
raise EngineUnavailableError(
|
|
133
|
+
f"Baltamatica CLI executable not found. Set {ENV_EXECUTABLE} or pass --cli-executable."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
async def _run_cli(self, argv: Sequence[str]) -> CliProcessResult:
|
|
137
|
+
return await asyncio.to_thread(self._run_cli_sync, argv)
|
|
138
|
+
|
|
139
|
+
async def _execute_command(self, code: str) -> ExecutionResult:
|
|
140
|
+
process = await self._run_cli([self._resolve_executable(), "-nodesktop", "-s", code])
|
|
141
|
+
output = _combine_output(process.stdout, process.stderr)
|
|
142
|
+
artifacts = parse_artifacts(output)
|
|
143
|
+
if process.returncode != 0:
|
|
144
|
+
return ExecutionResult(
|
|
145
|
+
success=False,
|
|
146
|
+
output=output,
|
|
147
|
+
error=_process_error(process),
|
|
148
|
+
artifacts=artifacts,
|
|
149
|
+
)
|
|
150
|
+
output_error = detect_baltamatica_error(output)
|
|
151
|
+
if output_error:
|
|
152
|
+
return ExecutionResult(
|
|
153
|
+
success=False,
|
|
154
|
+
output=output,
|
|
155
|
+
error=output_error,
|
|
156
|
+
artifacts=artifacts,
|
|
157
|
+
)
|
|
158
|
+
return ExecutionResult(success=True, output=output, artifacts=artifacts)
|
|
159
|
+
|
|
160
|
+
def _wrap_stateful_code(self, code: str) -> str:
|
|
161
|
+
state_path = _escape_baltamatica_string(self.state_file.as_posix())
|
|
162
|
+
return (
|
|
163
|
+
f"if exist('{state_path}','file'), load('{state_path}'); end; "
|
|
164
|
+
f"{code}; "
|
|
165
|
+
f"save('{state_path}');"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def _wrap_readonly_code(self, code: str) -> str:
|
|
169
|
+
state_path = _escape_baltamatica_string(self.state_file.as_posix())
|
|
170
|
+
return f"if exist('{state_path}','file'), load('{state_path}'); end; {code};"
|
|
171
|
+
|
|
172
|
+
def _run_cli_sync(self, argv: Sequence[str]) -> CliProcessResult:
|
|
173
|
+
creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
|
|
174
|
+
try:
|
|
175
|
+
process = subprocess.run(
|
|
176
|
+
argv,
|
|
177
|
+
capture_output=True,
|
|
178
|
+
check=False,
|
|
179
|
+
stdin=subprocess.DEVNULL,
|
|
180
|
+
creationflags=creationflags,
|
|
181
|
+
timeout=self.timeout,
|
|
182
|
+
)
|
|
183
|
+
except subprocess.TimeoutExpired as exc:
|
|
184
|
+
raise TimeoutError(
|
|
185
|
+
f"Baltamatica CLI timed out after {self.timeout:g} seconds."
|
|
186
|
+
) from exc
|
|
187
|
+
except OSError as exc:
|
|
188
|
+
raise EngineUnavailableError(f"Failed to start Baltamatica CLI: {exc}") from exc
|
|
189
|
+
|
|
190
|
+
return CliProcessResult(
|
|
191
|
+
returncode=process.returncode,
|
|
192
|
+
stdout=_decode_process_output(process.stdout),
|
|
193
|
+
stderr=_decode_process_output(process.stderr),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _combine_output(stdout: str | None, stderr: str | None) -> str:
|
|
198
|
+
output = _clean_process_output(stdout or "")
|
|
199
|
+
error_output = _clean_process_output(stderr or "")
|
|
200
|
+
if output and error_output:
|
|
201
|
+
return f"{output}\n{error_output}"
|
|
202
|
+
return output or error_output
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _decode_process_output(output: bytes | None) -> str:
|
|
206
|
+
if not output:
|
|
207
|
+
return ""
|
|
208
|
+
for encoding in ("utf-8", locale.getpreferredencoding(False), "gbk"):
|
|
209
|
+
try:
|
|
210
|
+
return output.decode(encoding)
|
|
211
|
+
except UnicodeDecodeError:
|
|
212
|
+
continue
|
|
213
|
+
return output.decode("utf-8", errors="replace")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _clean_process_output(output: str) -> str:
|
|
217
|
+
lines = []
|
|
218
|
+
for line in _strip_ansi(output).splitlines():
|
|
219
|
+
stripped = line.strip()
|
|
220
|
+
if any(stripped.startswith(pattern) for pattern in NOISE_OUTPUT_PATTERNS):
|
|
221
|
+
continue
|
|
222
|
+
lines.append(line.rstrip())
|
|
223
|
+
return "\n".join(lines).strip()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _process_error(process: CliProcessResult) -> str:
|
|
227
|
+
message = process.stderr.strip() or process.stdout.strip()
|
|
228
|
+
if message:
|
|
229
|
+
return f"Baltamatica CLI exited with code {process.returncode}: {message}"
|
|
230
|
+
return f"Baltamatica CLI exited with code {process.returncode}."
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def detect_baltamatica_error(output: str) -> str | None:
|
|
234
|
+
"""Detect Baltamatica error text when the CLI exits with status 0."""
|
|
235
|
+
|
|
236
|
+
plain_output = _strip_ansi(output)
|
|
237
|
+
for pattern in ERROR_OUTPUT_PATTERNS:
|
|
238
|
+
if pattern in plain_output:
|
|
239
|
+
first_line = next(
|
|
240
|
+
(line.strip() for line in plain_output.splitlines() if line.strip()),
|
|
241
|
+
plain_output.strip(),
|
|
242
|
+
)
|
|
243
|
+
return f"Baltamatica reported an error: {first_line}"
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _strip_ansi(value: str) -> str:
|
|
248
|
+
return ANSI_PATTERN.sub("", value)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _escape_baltamatica_string(value: str) -> str:
|
|
252
|
+
return value.replace("'", "''")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _default_state_file() -> Path:
|
|
256
|
+
return Path(
|
|
257
|
+
tempfile.NamedTemporaryFile(prefix="baltamatica_mcp_", suffix=".mat", delete=True).name
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def parse_whos_output(output: str) -> list[VariableInfo]:
|
|
262
|
+
variables: list[VariableInfo] = []
|
|
263
|
+
for line in output.splitlines():
|
|
264
|
+
match = WHOS_ROW_PATTERN.match(line)
|
|
265
|
+
if not match:
|
|
266
|
+
continue
|
|
267
|
+
variables.append(
|
|
268
|
+
VariableInfo(
|
|
269
|
+
name=match.group("name"),
|
|
270
|
+
size=match.group("size"),
|
|
271
|
+
bytes=int(match.group("bytes")),
|
|
272
|
+
class_name=match.group("class_name"),
|
|
273
|
+
attributes=(match.group("attributes") or "").strip(),
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
return variables
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def parse_artifacts(output: str) -> list[Artifact]:
|
|
280
|
+
artifacts: list[Artifact] = []
|
|
281
|
+
for line in output.splitlines():
|
|
282
|
+
stripped = line.strip()
|
|
283
|
+
if not stripped.startswith(ARTIFACT_PREFIX):
|
|
284
|
+
continue
|
|
285
|
+
spec = stripped.removeprefix(ARTIFACT_PREFIX).strip()
|
|
286
|
+
if not spec:
|
|
287
|
+
continue
|
|
288
|
+
artifact_type, artifact_path = _parse_artifact_spec(spec)
|
|
289
|
+
path = Path(artifact_path).expanduser().resolve()
|
|
290
|
+
artifacts.append(
|
|
291
|
+
Artifact(
|
|
292
|
+
path=str(path),
|
|
293
|
+
type=artifact_type,
|
|
294
|
+
exists=path.exists() and path.is_file(),
|
|
295
|
+
size=path.stat().st_size if path.exists() and path.is_file() else 0,
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
return artifacts
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _parse_artifact_spec(spec: str) -> tuple[str, str]:
|
|
302
|
+
if ":" not in spec:
|
|
303
|
+
return _guess_artifact_type(spec), spec
|
|
304
|
+
|
|
305
|
+
maybe_type, maybe_path = spec.split(":", 1)
|
|
306
|
+
if "/" not in maybe_type:
|
|
307
|
+
return _guess_artifact_type(spec), spec
|
|
308
|
+
return maybe_type.strip(), maybe_path.strip()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _guess_artifact_type(path: str) -> str:
|
|
312
|
+
suffix = Path(path).suffix.lower()
|
|
313
|
+
if suffix == ".png":
|
|
314
|
+
return "image/png"
|
|
315
|
+
if suffix in {".jpg", ".jpeg"}:
|
|
316
|
+
return "image/jpeg"
|
|
317
|
+
if suffix == ".svg":
|
|
318
|
+
return "image/svg+xml"
|
|
319
|
+
if suffix == ".pdf":
|
|
320
|
+
return "application/pdf"
|
|
321
|
+
if suffix == ".csv":
|
|
322
|
+
return "text/csv"
|
|
323
|
+
return "application/octet-stream"
|