matlab-mcp-python 1.0.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.
- matlab_mcp/__init__.py +2 -0
- matlab_mcp/config.py +212 -0
- matlab_mcp/jobs/__init__.py +0 -0
- matlab_mcp/jobs/executor.py +366 -0
- matlab_mcp/jobs/models.py +95 -0
- matlab_mcp/jobs/tracker.py +95 -0
- matlab_mcp/matlab_helpers/mcp_checkcode.m +17 -0
- matlab_mcp/matlab_helpers/mcp_extract_props.m +440 -0
- matlab_mcp/matlab_helpers/mcp_progress.m +16 -0
- matlab_mcp/output/__init__.py +0 -0
- matlab_mcp/output/formatter.py +234 -0
- matlab_mcp/output/plotly_convert.py +59 -0
- matlab_mcp/output/plotly_style_mapper.py +552 -0
- matlab_mcp/output/thumbnail.py +69 -0
- matlab_mcp/pool/__init__.py +0 -0
- matlab_mcp/pool/engine.py +216 -0
- matlab_mcp/pool/manager.py +227 -0
- matlab_mcp/security/__init__.py +0 -0
- matlab_mcp/security/validator.py +154 -0
- matlab_mcp/server.py +755 -0
- matlab_mcp/session/__init__.py +0 -0
- matlab_mcp/session/manager.py +210 -0
- matlab_mcp/tools/__init__.py +0 -0
- matlab_mcp/tools/admin.py +28 -0
- matlab_mcp/tools/core.py +144 -0
- matlab_mcp/tools/custom.py +241 -0
- matlab_mcp/tools/discovery.py +137 -0
- matlab_mcp/tools/files.py +456 -0
- matlab_mcp/tools/jobs.py +184 -0
- matlab_mcp/tools/monitoring.py +50 -0
- matlab_mcp_python-1.0.0.dist-info/METADATA +779 -0
- matlab_mcp_python-1.0.0.dist-info/RECORD +35 -0
- matlab_mcp_python-1.0.0.dist-info/WHEEL +4 -0
- matlab_mcp_python-1.0.0.dist-info/entry_points.txt +2 -0
- matlab_mcp_python-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""MATLAB engine wrapper with lifecycle management.
|
|
2
|
+
|
|
3
|
+
Wraps a single matlab.engine instance with state tracking, health checks,
|
|
4
|
+
and workspace reset capabilities. Uses lazy import so tests can mock the
|
|
5
|
+
matlab.engine module.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from enum import Enum, auto
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EngineState(Enum):
|
|
19
|
+
STOPPED = auto()
|
|
20
|
+
STARTING = auto()
|
|
21
|
+
IDLE = auto()
|
|
22
|
+
BUSY = auto()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MatlabEngineWrapper:
|
|
26
|
+
"""Wraps a single MATLAB engine instance.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
engine_id:
|
|
31
|
+
Unique identifier for this engine (e.g., ``"engine-0"``).
|
|
32
|
+
pool_config:
|
|
33
|
+
``PoolConfig`` instance (used for matlab_root if needed).
|
|
34
|
+
workspace_config:
|
|
35
|
+
``WorkspaceConfig`` instance with default_paths and startup_commands.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, engine_id: str, pool_config: Any, workspace_config: Any) -> None:
|
|
39
|
+
self.engine_id = engine_id
|
|
40
|
+
self._pool_config = pool_config
|
|
41
|
+
self._workspace_config = workspace_config
|
|
42
|
+
|
|
43
|
+
self._engine: Any = None
|
|
44
|
+
self._state: EngineState = EngineState.STOPPED
|
|
45
|
+
self._idle_since: float = time.monotonic()
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
# State properties
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def state(self) -> EngineState:
|
|
53
|
+
return self._state
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def idle_seconds(self) -> float:
|
|
57
|
+
"""Seconds since the engine last became idle (0 if not idle)."""
|
|
58
|
+
if self._state == EngineState.IDLE:
|
|
59
|
+
return time.monotonic() - self._idle_since
|
|
60
|
+
return 0.0
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def is_alive(self) -> bool:
|
|
64
|
+
"""True if the underlying engine object exists and reports alive."""
|
|
65
|
+
if self._engine is None:
|
|
66
|
+
return False
|
|
67
|
+
alive_attr = getattr(self._engine, "is_alive", None)
|
|
68
|
+
if alive_attr is None:
|
|
69
|
+
# Real matlab.engine doesn't have is_alive — assume alive
|
|
70
|
+
return True
|
|
71
|
+
if callable(alive_attr):
|
|
72
|
+
return bool(alive_attr())
|
|
73
|
+
return bool(alive_attr)
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
# Lifecycle
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def _get_matlab_engine_module(self) -> Any:
|
|
80
|
+
"""Lazily import matlab.engine so tests can mock it."""
|
|
81
|
+
return importlib.import_module("matlab.engine")
|
|
82
|
+
|
|
83
|
+
def start(self) -> None:
|
|
84
|
+
"""Start the MATLAB engine and apply default paths and startup commands."""
|
|
85
|
+
logger.info("[%s] Starting MATLAB engine", self.engine_id)
|
|
86
|
+
self._state = EngineState.STARTING
|
|
87
|
+
|
|
88
|
+
matlab_engine = self._get_matlab_engine_module()
|
|
89
|
+
self._engine = matlab_engine.start_matlab()
|
|
90
|
+
|
|
91
|
+
# Apply default workspace paths
|
|
92
|
+
for path in self._workspace_config.default_paths:
|
|
93
|
+
try:
|
|
94
|
+
self._engine.addpath(path)
|
|
95
|
+
except Exception:
|
|
96
|
+
logger.warning("[%s] Failed to addpath: %s", self.engine_id, path)
|
|
97
|
+
|
|
98
|
+
# Run startup commands
|
|
99
|
+
for cmd in self._workspace_config.startup_commands:
|
|
100
|
+
try:
|
|
101
|
+
self._engine.eval(cmd, nargout=0)
|
|
102
|
+
except Exception:
|
|
103
|
+
logger.warning("[%s] Startup command failed: %s", self.engine_id, cmd)
|
|
104
|
+
|
|
105
|
+
self._state = EngineState.IDLE
|
|
106
|
+
self._idle_since = time.monotonic()
|
|
107
|
+
logger.info("[%s] MATLAB engine started", self.engine_id)
|
|
108
|
+
|
|
109
|
+
def stop(self) -> None:
|
|
110
|
+
"""Quit the MATLAB engine."""
|
|
111
|
+
if self._engine is not None:
|
|
112
|
+
try:
|
|
113
|
+
self._engine.quit()
|
|
114
|
+
except Exception:
|
|
115
|
+
logger.warning("[%s] Exception during engine quit", self.engine_id)
|
|
116
|
+
finally:
|
|
117
|
+
self._engine = None
|
|
118
|
+
self._state = EngineState.STOPPED
|
|
119
|
+
logger.info("[%s] MATLAB engine stopped", self.engine_id)
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# Operations
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def health_check(self) -> bool:
|
|
126
|
+
"""Run a trivial eval to confirm the engine is responsive."""
|
|
127
|
+
if self._engine is None:
|
|
128
|
+
return False
|
|
129
|
+
try:
|
|
130
|
+
self._engine.eval("1", nargout=0)
|
|
131
|
+
return True
|
|
132
|
+
except Exception:
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
def execute(self, code: str, nargout: int = 0, background: bool = False,
|
|
136
|
+
stdout: Any = None, stderr: Any = None) -> Any:
|
|
137
|
+
"""Run MATLAB code on the engine.
|
|
138
|
+
|
|
139
|
+
Parameters
|
|
140
|
+
----------
|
|
141
|
+
code: MATLAB code to evaluate.
|
|
142
|
+
nargout: Number of output arguments expected.
|
|
143
|
+
background: If True, return a future-like object immediately.
|
|
144
|
+
stdout: Stream to capture standard output (e.g. ``io.StringIO``).
|
|
145
|
+
stderr: Stream to capture standard error (e.g. ``io.StringIO``).
|
|
146
|
+
"""
|
|
147
|
+
if self._engine is None:
|
|
148
|
+
raise RuntimeError(f"[{self.engine_id}] Engine is not started")
|
|
149
|
+
kwargs: dict[str, Any] = {"nargout": nargout, "background": background}
|
|
150
|
+
if stdout is not None:
|
|
151
|
+
kwargs["stdout"] = stdout
|
|
152
|
+
if stderr is not None:
|
|
153
|
+
kwargs["stderr"] = stderr
|
|
154
|
+
return self._engine.eval(code, **kwargs)
|
|
155
|
+
|
|
156
|
+
def reset_workspace(self) -> None:
|
|
157
|
+
"""Reset the MATLAB workspace to a clean state.
|
|
158
|
+
|
|
159
|
+
Sequence: clear all, clear global, clear functions, fclose all,
|
|
160
|
+
restoredefaultpath, re-add configured paths, re-run startup commands.
|
|
161
|
+
"""
|
|
162
|
+
if self._engine is None:
|
|
163
|
+
raise RuntimeError(f"[{self.engine_id}] Engine is not started")
|
|
164
|
+
|
|
165
|
+
cleanup_commands = [
|
|
166
|
+
"clear all",
|
|
167
|
+
"clear global",
|
|
168
|
+
"clear functions",
|
|
169
|
+
"fclose all",
|
|
170
|
+
]
|
|
171
|
+
for cmd in cleanup_commands:
|
|
172
|
+
try:
|
|
173
|
+
self._engine.eval(cmd, nargout=0)
|
|
174
|
+
except Exception:
|
|
175
|
+
logger.warning("[%s] Reset command failed: %s", self.engine_id, cmd)
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
self._engine.restoredefaultpath()
|
|
179
|
+
except Exception:
|
|
180
|
+
logger.warning("[%s] restoredefaultpath failed", self.engine_id)
|
|
181
|
+
|
|
182
|
+
# Re-apply configured paths
|
|
183
|
+
for path in self._workspace_config.default_paths:
|
|
184
|
+
try:
|
|
185
|
+
self._engine.addpath(path)
|
|
186
|
+
except Exception:
|
|
187
|
+
logger.warning("[%s] Re-addpath failed: %s", self.engine_id, path)
|
|
188
|
+
|
|
189
|
+
# Re-run startup commands
|
|
190
|
+
for cmd in self._workspace_config.startup_commands:
|
|
191
|
+
try:
|
|
192
|
+
self._engine.eval(cmd, nargout=0)
|
|
193
|
+
except Exception:
|
|
194
|
+
logger.warning("[%s] Startup command failed on reset: %s", self.engine_id, cmd)
|
|
195
|
+
|
|
196
|
+
logger.debug("[%s] Workspace reset complete", self.engine_id)
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
# State transitions
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def mark_busy(self) -> None:
|
|
203
|
+
"""Transition engine to BUSY state."""
|
|
204
|
+
self._state = EngineState.BUSY
|
|
205
|
+
|
|
206
|
+
def mark_idle(self) -> None:
|
|
207
|
+
"""Transition engine to IDLE state and record timestamp."""
|
|
208
|
+
self._state = EngineState.IDLE
|
|
209
|
+
self._idle_since = time.monotonic()
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
# Dunder
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
216
|
+
return f"MatlabEngineWrapper(id={self.engine_id!r}, state={self._state.name})"
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""MATLAB engine pool manager.
|
|
2
|
+
|
|
3
|
+
Manages a pool of ``MatlabEngineWrapper`` instances, handling:
|
|
4
|
+
- Startup of a minimum number of engines in parallel
|
|
5
|
+
- Acquire/release with queueing when all engines are busy
|
|
6
|
+
- Scale-up on demand up to max_engines
|
|
7
|
+
- Scale-down of idle engines beyond min_engines
|
|
8
|
+
- Periodic health checks that replace dead engines
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any, Dict, List
|
|
15
|
+
|
|
16
|
+
from matlab_mcp.pool.engine import EngineState, MatlabEngineWrapper
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EnginePoolManager:
|
|
22
|
+
"""Manages a pool of MATLAB engine wrappers.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
config:
|
|
27
|
+
``AppConfig`` instance. Uses ``config.pool`` and ``config.workspace``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, config: Any, collector: Any = None) -> None:
|
|
31
|
+
self._config = config
|
|
32
|
+
self._pool_config = config.pool
|
|
33
|
+
self._workspace_config = config.workspace
|
|
34
|
+
self._collector = collector
|
|
35
|
+
|
|
36
|
+
# All engines (available + busy)
|
|
37
|
+
self._all_engines: List[MatlabEngineWrapper] = []
|
|
38
|
+
# Queue of engines available for acquisition
|
|
39
|
+
self._available: asyncio.Queue = asyncio.Queue()
|
|
40
|
+
# Lock to guard scale-up logic
|
|
41
|
+
self._scale_lock: asyncio.Lock = asyncio.Lock()
|
|
42
|
+
self._next_id: int = 0
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
# Internal helpers
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def _make_engine(self) -> MatlabEngineWrapper:
|
|
49
|
+
engine_id = f"engine-{self._next_id}"
|
|
50
|
+
self._next_id += 1
|
|
51
|
+
return MatlabEngineWrapper(engine_id, self._pool_config, self._workspace_config)
|
|
52
|
+
|
|
53
|
+
async def _start_engine_async(self) -> MatlabEngineWrapper:
|
|
54
|
+
"""Start a single engine in a thread executor and return it."""
|
|
55
|
+
loop = asyncio.get_running_loop()
|
|
56
|
+
engine = self._make_engine()
|
|
57
|
+
self._all_engines.append(engine)
|
|
58
|
+
await loop.run_in_executor(None, engine.start)
|
|
59
|
+
return engine
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Public API
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
async def start(self) -> None:
|
|
66
|
+
"""Start min_engines engines in parallel."""
|
|
67
|
+
min_engines = self._pool_config.min_engines
|
|
68
|
+
logger.info("Starting pool with %d engine(s)", min_engines)
|
|
69
|
+
|
|
70
|
+
tasks = [self._start_engine_async() for _ in range(min_engines)]
|
|
71
|
+
engines = await asyncio.gather(*tasks)
|
|
72
|
+
|
|
73
|
+
for engine in engines:
|
|
74
|
+
await self._available.put(engine)
|
|
75
|
+
|
|
76
|
+
logger.info("Pool started: %d engine(s) ready", min_engines)
|
|
77
|
+
|
|
78
|
+
async def acquire(self) -> MatlabEngineWrapper:
|
|
79
|
+
"""Return an available engine, scaling up if needed.
|
|
80
|
+
|
|
81
|
+
Blocks if the pool is at max capacity and all engines are busy.
|
|
82
|
+
"""
|
|
83
|
+
# Try to get an immediately available engine
|
|
84
|
+
try:
|
|
85
|
+
engine = self._available.get_nowait()
|
|
86
|
+
engine.mark_busy()
|
|
87
|
+
logger.info("Acquired engine %s (available=%d, busy=%d)",
|
|
88
|
+
engine.engine_id, self._available.qsize(),
|
|
89
|
+
len(self._all_engines) - self._available.qsize())
|
|
90
|
+
return engine
|
|
91
|
+
except asyncio.QueueEmpty:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
# Attempt scale-up under a lock to avoid races
|
|
95
|
+
async with self._scale_lock:
|
|
96
|
+
total = len(self._all_engines)
|
|
97
|
+
if total < self._pool_config.max_engines:
|
|
98
|
+
logger.info("Scaling up pool: starting new engine (%d/%d)",
|
|
99
|
+
total + 1, self._pool_config.max_engines)
|
|
100
|
+
engine = await self._start_engine_async()
|
|
101
|
+
engine.mark_busy()
|
|
102
|
+
if self._collector:
|
|
103
|
+
self._collector.record_event("engine_scale_up", {"engine_id": engine.engine_id, "total_after": len(self._all_engines)})
|
|
104
|
+
return engine
|
|
105
|
+
|
|
106
|
+
# At max capacity — wait for one to become available
|
|
107
|
+
logger.warning("Pool at max capacity (%d/%d busy) — waiting for available engine",
|
|
108
|
+
len(self._all_engines), self._pool_config.max_engines)
|
|
109
|
+
engine = await self._available.get()
|
|
110
|
+
engine.mark_busy()
|
|
111
|
+
logger.info("Acquired engine %s after wait (pool was full)", engine.engine_id)
|
|
112
|
+
return engine
|
|
113
|
+
|
|
114
|
+
async def release(self, engine: MatlabEngineWrapper) -> None:
|
|
115
|
+
"""Return an engine to the pool."""
|
|
116
|
+
logger.info("Releasing engine %s — resetting workspace", engine.engine_id)
|
|
117
|
+
loop = asyncio.get_running_loop()
|
|
118
|
+
await loop.run_in_executor(None, engine.reset_workspace)
|
|
119
|
+
engine.mark_idle()
|
|
120
|
+
await self._available.put(engine)
|
|
121
|
+
logger.info("Engine %s returned to pool (available=%d)",
|
|
122
|
+
engine.engine_id, self._available.qsize())
|
|
123
|
+
|
|
124
|
+
async def stop(self) -> None:
|
|
125
|
+
"""Stop all engines in the pool."""
|
|
126
|
+
logger.info("Stopping pool (%d engines)", len(self._all_engines))
|
|
127
|
+
loop = asyncio.get_running_loop()
|
|
128
|
+
stop_tasks = [
|
|
129
|
+
loop.run_in_executor(None, engine.stop)
|
|
130
|
+
for engine in self._all_engines
|
|
131
|
+
]
|
|
132
|
+
await asyncio.gather(*stop_tasks, return_exceptions=True)
|
|
133
|
+
self._all_engines.clear()
|
|
134
|
+
# Drain the queue
|
|
135
|
+
while not self._available.empty():
|
|
136
|
+
try:
|
|
137
|
+
self._available.get_nowait()
|
|
138
|
+
except asyncio.QueueEmpty:
|
|
139
|
+
break
|
|
140
|
+
logger.info("Pool stopped")
|
|
141
|
+
|
|
142
|
+
async def run_health_checks(self) -> None:
|
|
143
|
+
"""Check idle engines, replace dead ones, scale down excess idle engines.
|
|
144
|
+
|
|
145
|
+
- Engines that fail health check are stopped and replaced.
|
|
146
|
+
- Idle engines beyond min_engines are stopped and removed.
|
|
147
|
+
"""
|
|
148
|
+
loop = asyncio.get_running_loop()
|
|
149
|
+
min_engines = self._pool_config.min_engines
|
|
150
|
+
idle_timeout = self._pool_config.scale_down_idle_timeout
|
|
151
|
+
|
|
152
|
+
# Collect currently available (idle) engines by draining the queue
|
|
153
|
+
idle_engines: List[MatlabEngineWrapper] = []
|
|
154
|
+
while not self._available.empty():
|
|
155
|
+
try:
|
|
156
|
+
idle_engines.append(self._available.get_nowait())
|
|
157
|
+
except asyncio.QueueEmpty:
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
to_replace: List[MatlabEngineWrapper] = []
|
|
161
|
+
to_remove: List[MatlabEngineWrapper] = []
|
|
162
|
+
to_keep: List[MatlabEngineWrapper] = []
|
|
163
|
+
|
|
164
|
+
# How many busy engines do we have?
|
|
165
|
+
busy_count = sum(1 for e in self._all_engines if e.state == EngineState.BUSY)
|
|
166
|
+
idle_count = len(idle_engines)
|
|
167
|
+
total = busy_count + idle_count
|
|
168
|
+
|
|
169
|
+
for engine in idle_engines:
|
|
170
|
+
# Check health
|
|
171
|
+
healthy = await loop.run_in_executor(None, engine.health_check)
|
|
172
|
+
if not healthy:
|
|
173
|
+
logger.warning("[%s] Health check failed; replacing", engine.engine_id)
|
|
174
|
+
if self._collector:
|
|
175
|
+
self._collector.record_event("health_check_fail", {"engine_id": engine.engine_id, "error": "health check failed"})
|
|
176
|
+
to_replace.append(engine)
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
# Scale down if idle beyond timeout and above min
|
|
180
|
+
if (engine.idle_seconds > idle_timeout
|
|
181
|
+
and total > min_engines
|
|
182
|
+
and len(to_keep) + busy_count >= min_engines):
|
|
183
|
+
logger.info("[%s] Scaling down idle engine (idle %.0fs)",
|
|
184
|
+
engine.engine_id, engine.idle_seconds)
|
|
185
|
+
if self._collector:
|
|
186
|
+
self._collector.record_event("engine_scale_down", {"engine_id": engine.engine_id, "total_after": total - 1})
|
|
187
|
+
to_remove.append(engine)
|
|
188
|
+
total -= 1
|
|
189
|
+
else:
|
|
190
|
+
to_keep.append(engine)
|
|
191
|
+
|
|
192
|
+
# Stop engines to remove / replace
|
|
193
|
+
engines_to_stop = to_replace + to_remove
|
|
194
|
+
stop_tasks = [loop.run_in_executor(None, e.stop) for e in engines_to_stop]
|
|
195
|
+
await asyncio.gather(*stop_tasks, return_exceptions=True)
|
|
196
|
+
for e in engines_to_stop:
|
|
197
|
+
try:
|
|
198
|
+
self._all_engines.remove(e)
|
|
199
|
+
except ValueError:
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
# Start replacement engines for failed ones
|
|
203
|
+
replacement_tasks = [self._start_engine_async() for _ in to_replace]
|
|
204
|
+
new_engines = await asyncio.gather(*replacement_tasks, return_exceptions=True)
|
|
205
|
+
|
|
206
|
+
# Return healthy engines + replacements to queue
|
|
207
|
+
for engine in to_keep:
|
|
208
|
+
await self._available.put(engine)
|
|
209
|
+
for old_engine, new_engine in zip(to_replace, new_engines):
|
|
210
|
+
if isinstance(new_engine, Exception):
|
|
211
|
+
logger.error("Replacement engine failed to start: %s", new_engine)
|
|
212
|
+
else:
|
|
213
|
+
if self._collector:
|
|
214
|
+
self._collector.record_event("engine_replaced", {"old_id": old_engine.engine_id, "new_id": new_engine.engine_id})
|
|
215
|
+
await self._available.put(new_engine)
|
|
216
|
+
|
|
217
|
+
def get_status(self) -> Dict[str, int]:
|
|
218
|
+
"""Return pool status summary."""
|
|
219
|
+
total = len(self._all_engines)
|
|
220
|
+
available = self._available.qsize()
|
|
221
|
+
busy = total - available
|
|
222
|
+
return {
|
|
223
|
+
"total": total,
|
|
224
|
+
"available": available,
|
|
225
|
+
"busy": max(busy, 0),
|
|
226
|
+
"max": self._pool_config.max_engines,
|
|
227
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Security validator for MATLAB code and filenames.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- ``BlockedFunctionError`` — raised when blocked MATLAB code is detected.
|
|
5
|
+
- ``SecurityValidator`` — checks code and sanitizes filenames.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BlockedFunctionError(Exception):
|
|
17
|
+
"""Raised when MATLAB code contains a blocked function or construct."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Precompiled patterns for _strip_string_literals
|
|
21
|
+
_DOUBLE_QUOTED_RE = re.compile(r'"[^"\n]*"')
|
|
22
|
+
_SINGLE_QUOTED_RE = re.compile(r"(?<![a-zA-Z0-9_\)\]])'[^'\n]*'")
|
|
23
|
+
_COMMENT_RE = re.compile(r'%')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SecurityValidator:
|
|
27
|
+
"""Validates MATLAB code and filenames against a security policy.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
security_config:
|
|
32
|
+
``SecurityConfig`` instance with ``blocked_functions_enabled`` and
|
|
33
|
+
``blocked_functions`` attributes.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, security_config: Any, collector: Any = None) -> None:
|
|
37
|
+
self._config = security_config
|
|
38
|
+
self._collector = collector
|
|
39
|
+
|
|
40
|
+
# Precompile blocked-function patterns for check_code hot path
|
|
41
|
+
self._call_patterns: dict[str, re.Pattern] = {}
|
|
42
|
+
self._cmd_patterns: dict[str, re.Pattern] = {}
|
|
43
|
+
for func in security_config.blocked_functions:
|
|
44
|
+
if func != "!":
|
|
45
|
+
self._call_patterns[func] = re.compile(rf"\b{re.escape(func)}\s*\(")
|
|
46
|
+
self._cmd_patterns[func] = re.compile(rf"(?:^|;)\s*{re.escape(func)}\s+\S", re.MULTILINE)
|
|
47
|
+
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
# Code checking
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _strip_string_literals(code: str) -> str:
|
|
54
|
+
"""Remove MATLAB string literals and comments to avoid false positives.
|
|
55
|
+
|
|
56
|
+
Processing order per line:
|
|
57
|
+
1. Remove double-quoted strings "..."
|
|
58
|
+
2. Remove single-quoted strings '...' (MATLAB char arrays)
|
|
59
|
+
- A quote preceded by [a-zA-Z0-9_)] is a transpose operator, not a string.
|
|
60
|
+
3. Remove MATLAB comments (% to end of line)
|
|
61
|
+
|
|
62
|
+
Note: This is a best-effort heuristic; it handles the common cases
|
|
63
|
+
tested without a full MATLAB parser.
|
|
64
|
+
"""
|
|
65
|
+
processed_lines = []
|
|
66
|
+
for line in code.splitlines():
|
|
67
|
+
line = _DOUBLE_QUOTED_RE.sub('""', line)
|
|
68
|
+
line = _SINGLE_QUOTED_RE.sub("''", line)
|
|
69
|
+
comment_match = _COMMENT_RE.search(line)
|
|
70
|
+
if comment_match:
|
|
71
|
+
line = line[:comment_match.start()]
|
|
72
|
+
processed_lines.append(line)
|
|
73
|
+
return "\n".join(processed_lines)
|
|
74
|
+
|
|
75
|
+
def check_code(self, code: str) -> None:
|
|
76
|
+
"""Scan *code* for blocked functions/constructs.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
code:
|
|
81
|
+
MATLAB source code to check.
|
|
82
|
+
|
|
83
|
+
Raises
|
|
84
|
+
------
|
|
85
|
+
BlockedFunctionError
|
|
86
|
+
If a blocked construct is found (and blocking is enabled).
|
|
87
|
+
"""
|
|
88
|
+
if not self._config.blocked_functions_enabled:
|
|
89
|
+
logger.debug("Security check skipped (blocked_functions disabled)")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
# Strip string literals to avoid false positives
|
|
93
|
+
sanitized = self._strip_string_literals(code)
|
|
94
|
+
|
|
95
|
+
for func in self._config.blocked_functions:
|
|
96
|
+
if func == "!":
|
|
97
|
+
# Shell escape: lines starting with ! (after optional whitespace)
|
|
98
|
+
for line in sanitized.splitlines():
|
|
99
|
+
if line.lstrip().startswith("!"):
|
|
100
|
+
logger.warning("BLOCKED: shell escape '!' in code: %s", repr(code[:120]))
|
|
101
|
+
if self._collector:
|
|
102
|
+
self._collector.record_event("blocked_function", {"function": "!"})
|
|
103
|
+
raise BlockedFunctionError(
|
|
104
|
+
"Shell escape '!' is not allowed"
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
call_re = self._call_patterns[func]
|
|
108
|
+
cmd_re = self._cmd_patterns[func]
|
|
109
|
+
if call_re.search(sanitized) or cmd_re.search(sanitized):
|
|
110
|
+
logger.warning("BLOCKED: function '%s' in code: %s", func, repr(code[:120]))
|
|
111
|
+
if self._collector:
|
|
112
|
+
self._collector.record_event("blocked_function", {"function": func})
|
|
113
|
+
raise BlockedFunctionError(
|
|
114
|
+
f"Function '{func}' is not allowed"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
# Filename sanitization
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
_SAFE_FILENAME_RE = re.compile(r'^[a-zA-Z0-9._-]+$')
|
|
122
|
+
|
|
123
|
+
def sanitize_filename(self, filename: str) -> str:
|
|
124
|
+
"""Validate and return *filename* if safe.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
filename:
|
|
129
|
+
Proposed filename (basename only, no directory separators).
|
|
130
|
+
|
|
131
|
+
Returns
|
|
132
|
+
-------
|
|
133
|
+
str
|
|
134
|
+
The filename unchanged if it passes validation.
|
|
135
|
+
|
|
136
|
+
Raises
|
|
137
|
+
------
|
|
138
|
+
ValueError
|
|
139
|
+
If the filename is empty, contains path traversal (``..``),
|
|
140
|
+
or contains characters outside ``[a-zA-Z0-9._-]``.
|
|
141
|
+
"""
|
|
142
|
+
if not filename:
|
|
143
|
+
raise ValueError("Filename must not be empty")
|
|
144
|
+
|
|
145
|
+
if ".." in filename:
|
|
146
|
+
raise ValueError(f"Path traversal not allowed in filename: {filename!r}")
|
|
147
|
+
|
|
148
|
+
if not self._SAFE_FILENAME_RE.match(filename):
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"Filename contains invalid characters: {filename!r}. "
|
|
151
|
+
"Only [a-zA-Z0-9._-] are allowed."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return filename
|