diff --git a/.gitignore b/.gitignore index 3152e68..7feb0b7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ wheels/ # tmp files .tmp + +# Node.js dependencies (downloaded during build) +node_modules/ +package-lock.json diff --git a/build_hooks.py b/build_hooks.py new file mode 100644 index 0000000..77800ab --- /dev/null +++ b/build_hooks.py @@ -0,0 +1,63 @@ +"""Simple build hook to run npm install for Pyodide bundling.""" + +import subprocess +import sys +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class NpmInstallBuildHook(BuildHookInterface): + """Build hook that runs npm install to download Pyodide for bundling.""" + + PLUGIN_NAME = "custom" + + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + """Run npm install to download Pyodide files for bundling.""" + build_dir = Path(self.root) + package_json = build_dir / "package.json" + node_modules = build_dir / "node_modules" + + # Check if package.json exists + if not package_json.exists(): + print("⚠ No package.json found - skipping npm install") + return + + # Check if node_modules already exists and is populated + pyodide_dir = node_modules / "pyodide" + if pyodide_dir.exists() and (pyodide_dir / "pyodide.js").exists(): + print("✓ Pyodide already installed in node_modules") + return + + print("📦 Running npm install to download Pyodide for bundling...") + + try: + # Run npm install + result = subprocess.run( + ["npm", "install"], + cwd=build_dir, + capture_output=True, + text=True, + timeout=300, # 5 minute timeout + ) + + if result.returncode == 0: + print("✓ npm install completed successfully") + # Verify Pyodide was installed + if (pyodide_dir / "pyodide.js").exists(): + print("✓ Pyodide files ready for bundling") + else: + print("⚠ npm install succeeded but Pyodide files not found") + else: + print(f"✗ npm install failed with exit code {result.returncode}") + if result.stderr: + print(f"Error output: {result.stderr}") + + except subprocess.TimeoutExpired: + print("✗ npm install timed out after 5 minutes") + except FileNotFoundError: + print("✗ npm not found - please ensure Node.js and npm are installed") + sys.exit(1) + except Exception as e: + print(f"✗ npm install failed: {e}") diff --git a/package.json b/package.json new file mode 100644 index 0000000..3fb1c31 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "mip-mcp-pyodide-deps", + "version": "1.0.0", + "description": "Node.js dependencies for MIP-MCP Pyodide execution", + "private": true, + "scripts": { + "postinstall": "echo '✓ Pyodide installed successfully for bundling'" + }, + "dependencies": { + "pyodide": "^0.27.7" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/pyproject.toml b/pyproject.toml index fe91d82..fba310e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,6 +98,9 @@ indent-style = "space" [tool.hatch.build.targets.wheel] packages = ["src/mip_mcp"] +[tool.hatch.build.targets.wheel.hooks.custom] +path = "build_hooks.py" + [tool.hatch.build.targets.wheel.shared-data] "node_modules/pyodide/pyodide.js" = "mip_mcp/pyodide/pyodide.js" "node_modules/pyodide/pyodide.asm.js" = "mip_mcp/pyodide/pyodide.asm.js" diff --git a/src/mip_mcp/executor/pyodide_executor.py b/src/mip_mcp/executor/pyodide_executor.py index 7671000..e22a8dd 100644 --- a/src/mip_mcp/executor/pyodide_executor.py +++ b/src/mip_mcp/executor/pyodide_executor.py @@ -13,7 +13,6 @@ from ..models.responses import SolverProgress from ..utils.library_detector import MIPLibrary, MIPLibraryDetector from ..utils.logger import get_logger -from ..utils.pyodide_manager import PyodideManager logger = get_logger(__name__) @@ -42,7 +41,15 @@ def __init__(self, config: dict[str, Any]): "execution_timeout", 60.0 ) # Execution timeout for MCP - logger.info("Pyodide executor initialized") + # Track temporary files for cleanup + self._temp_files: list[str] = [] + self._script_file: str | None = None + + # Create isolated temporary directory for this executor instance + self.temp_dir = tempfile.mkdtemp(prefix="mip_mcp_executor_") + logger.info( + f"Pyodide executor initialized with isolated temp dir: {self.temp_dir}" + ) def set_progress_callback( self, callback: Callable[[SolverProgress], None] | None @@ -157,18 +164,12 @@ async def _initialize_pyodide(self) -> None: try: logger.info("Initializing Pyodide environment...") - # Get pyodide path from manager (should be available from server startup) - pyodide_path = PyodideManager.get_pyodide_path() + # Find pyodide installation (simple approach) + pyodide_path = await self._find_pyodide_path() if not pyodide_path: - # Fallback: try to ensure pyodide is available - logger.info("Pyodide not ready, attempting to initialize...") - if await PyodideManager.ensure_pyodide_available(): - pyodide_path = PyodideManager.get_pyodide_path() - - if not pyodide_path: - raise RuntimeError( - "Pyodide module not found. Server may not have initialized properly." - ) + raise RuntimeError( + "Pyodide not found. Please install with: npm install pyodide" + ) logger.info(f"Found Pyodide at: {pyodide_path}") @@ -178,6 +179,7 @@ async def _initialize_pyodide(self) -> None: with tempfile.NamedTemporaryFile(mode="w", suffix=".js", delete=False) as f: f.write(self._get_pyodide_script(pyodide_path)) script_path = f.name + self._script_file = script_path # Track for cleanup try: # Create a new Node.js process to run Pyodide @@ -213,6 +215,21 @@ async def _initialize_pyodide(self) -> None: f"Pyodide initialization failed: {init_result.get('error', 'unknown error')}" ) + # Mount the isolated temp directory + mount_result = await self._communicate_with_pyodide( + {"action": "mount", "path": self.temp_dir} + ) + + if not mount_result.get("success"): + logger.warning( + f"Failed to mount temp directory: {mount_result.get('error')}" + ) + # Continue anyway, fallback to virtual filesystem + else: + logger.info( + f"Mounted temp directory {self.temp_dir} to /mnt in Pyodide" + ) + self._pyodide_initialized = True logger.info("Pyodide environment initialized successfully") @@ -220,6 +237,7 @@ async def _initialize_pyodide(self) -> None: # Clean up script file with contextlib.suppress(OSError, FileNotFoundError): Path(script_path).unlink() + self._script_file = None # Clear tracking except Exception as e: logger.error(f"Failed to initialize Pyodide: {e}") @@ -248,66 +266,36 @@ async def _wait_for_process_ready(self) -> None: f"Error waiting for Pyodide process readiness: {e}" ) from e - def _check_bundled_pyodide(self) -> str | None: - """Check for bundled pyodide installation (from wheel).""" - try: - # Check if bundled pyodide files exist (installed from wheel) - import pkg_resources - - try: - pyodide_js_path = pkg_resources.resource_filename( - "mip_mcp", "pyodide/pyodide.js" - ) - if Path(pyodide_js_path).exists(): - return pyodide_js_path - except (ImportError, FileNotFoundError): - pass - - return None - - except Exception as e: - logger.debug(f"Error checking bundled pyodide: {e}") - return None - async def _find_pyodide_path(self) -> str | None: - """Find pyodide installation path.""" + """Find bundled pyodide installation path.""" try: - # First check for bundled pyodide (from wheel installation) - bundled_path = self._check_bundled_pyodide() - if bundled_path: - logger.info(f"Using bundled pyodide at: {bundled_path}") - return bundled_path - - # Try to find pyodide using Node.js + # Pyodide is bundled during build, so we only need to check bundled locations proc = await asyncio.create_subprocess_exec( "node", "-e", """ -try { - console.log(require.resolve('pyodide')); -} catch (e) { - // Try alternative paths - const path = require('path'); - const fs = require('fs'); - - const searchPaths = [ - path.join(process.cwd(), 'node_modules', 'pyodide'), - path.join(process.cwd(), '..', 'node_modules', 'pyodide'), - path.join(process.cwd(), '..', '..', 'node_modules', 'pyodide'), - path.join(__dirname, '..', '..', 'node_modules', 'pyodide') - ]; - - for (const searchPath of searchPaths) { - const packagePath = path.join(searchPath, 'package.json'); - if (fs.existsSync(packagePath)) { - console.log(path.join(searchPath, 'pyodide.js')); - process.exit(0); - } +const path = require('path'); +const fs = require('fs'); + +// Check for bundled Pyodide files (from wheel shared-data) +const bundledPaths = [ + // In site-packages/mip_mcp/pyodide/ (standard wheel installation) + path.join(__dirname, '..', '..', '..', 'mip_mcp', 'pyodide', 'pyodide.js'), + path.join(__dirname, '..', '..', 'mip_mcp', 'pyodide', 'pyodide.js'), + // Development: node_modules from build process + path.join(process.cwd(), 'node_modules', 'pyodide', 'pyodide.js') +]; + +for (const pyodidePath of bundledPaths) { + if (fs.existsSync(pyodidePath)) { + console.log(pyodidePath); + process.exit(0); } - - console.error('PYODIDE_NOT_FOUND'); - process.exit(1); } + +// If we reach here, bundling failed during build +console.error('PYODIDE_NOT_FOUND'); +process.exit(1); """, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -363,6 +351,17 @@ def _get_pyodide_script(self, pyodide_path: str) -> str: } } +async function mountTempDir(tempDirPath) { + try { + // Mount the host temp directory to /mnt in Pyodide filesystem + pyodide.FS.mkdir('/mnt'); + pyodide.FS.mount(pyodide.FS.filesystems.NODEFS, { root: tempDirPath }, '/mnt'); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } +} + async function executePython(code) { try { const result = await pyodide.runPythonAsync(code); @@ -424,6 +423,9 @@ def _get_pyodide_script(self, pyodide_path: str) -> str: case 'init': response = await initPyodide(); break; + case 'mount': + response = await mountTempDir(request.path); + break; case 'execute': response = await executePython(request.code); break; @@ -597,22 +599,34 @@ async def execute_mip_code( detected_library, ) - # Extract content (LP preferred, then MPS) - lp_content = json_data.get("lp_content") - mps_content = json_data.get("mps_content") - - # Automatic format detection: LP preferred, then MPS - content = None + # Get file paths from Pyodide execution and map to host filesystem + lp_file_path = json_data.get("lp_file_path") + mps_file_path = json_data.get("mps_file_path") + + # Map Pyodide paths (/mnt/...) to host filesystem paths + host_lp_path = None + host_mps_path = None + if lp_file_path and lp_file_path.startswith("/mnt/"): + host_lp_path = str( + Path(self.temp_dir) / lp_file_path[5:] + ) # Remove /mnt/ prefix + if mps_file_path and mps_file_path.startswith("/mnt/"): + host_mps_path = str( + Path(self.temp_dir) / mps_file_path[5:] + ) # Remove /mnt/ prefix + + # Determine which file to use (LP preferred, then MPS) + source_file_path = None file_format = None - if lp_content: - content = lp_content + if host_lp_path and Path(host_lp_path).exists(): + source_file_path = host_lp_path file_format = "lp" - elif mps_content: - content = mps_content + elif host_mps_path and Path(host_mps_path).exists(): + source_file_path = host_mps_path file_format = "mps" - if not content: - # Check if there were problems but failed to generate content + if not source_file_path: + # Check if there were problems but failed to generate files problems_info = json_data.get("problems_info", []) if problems_info: problem_errors = [ @@ -622,25 +636,25 @@ async def execute_mip_code( error_details = "; ".join(problem_errors) return ( json_data.get("stdout", ""), - f"Problem found but failed to generate content: {error_details}", + f"Problem found but failed to generate files: {error_details}", None, detected_library, ) return ( json_data.get("stdout", ""), - "No optimization file content generated", + "No optimization file generated", None, detected_library, ) - # Send progress for file generation + # Send progress for file processing self._send_progress( - "modeling", f"Generating {file_format.upper()} optimization file" + "modeling", f"Processing {file_format.upper()} optimization file" ) - # Write content to temporary file - temp_file = self._create_temp_file(content, file_format) + # Copy file from isolated directory to a new temporary file for return + temp_file = self._copy_optimization_file(source_file_path, file_format) # Send final modeling progress self._send_progress( @@ -689,6 +703,9 @@ def _prepare_execution_code( if data: data_setup = f"__data__ = {json.dumps(data)}\n" + # Pass isolated temp directory to Pyodide + temp_dir_setup = f"__temp_dir__ = '{self.temp_dir}'\n" + wrapper_code = f""" import io import sys @@ -702,6 +719,8 @@ def _prepare_execution_code( # Setup data if provided {data_setup} +# Setup isolated temp directory +{temp_dir_setup} def __convert_to_json_safe(obj, visited=None): '''Convert objects to JSON-safe format, handling tuple keys and complex objects.''' @@ -748,39 +767,24 @@ def __convert_to_json_safe(obj, visited=None): visited.discard(obj_id) def __extract_problem_info(globals_dict): - '''Extract PuLP problem information and generate LP/MPS content.''' + '''Extract PuLP problem information and write files to mounted filesystem.''' problems_info = [] - lp_content = None - mps_content = None + lp_file_path = None + mps_file_path = None for name, obj in globals_dict.items(): if hasattr(obj, 'writeLP') and hasattr(obj, 'writeMPS'): try: - # Generate LP content - import tempfile - import os - - # Create temporary files for LP and MPS - with tempfile.NamedTemporaryFile(mode='w', suffix='.lp', delete=False) as lp_file: - lp_path = lp_file.name + import time - with tempfile.NamedTemporaryFile(mode='w', suffix='.mps', delete=False) as mps_file: - mps_path = mps_file.name + # Use mounted filesystem at /mnt with unique filenames + unique_id = str(int(time.time() * 1000000) % 100000000) + lp_file_path = f"/mnt/problem_{{unique_id}}.lp" + mps_file_path = f"/mnt/problem_{{unique_id}}.mps" - # Write LP and MPS files - obj.writeLP(lp_path) - obj.writeMPS(mps_path) - - # Read content - with open(lp_path, 'r') as f: - lp_content = f.read() - - with open(mps_path, 'r') as f: - mps_content = f.read() - - # Clean up temporary files - os.unlink(lp_path) - os.unlink(mps_path) + # Write LP and MPS files to mounted filesystem (accessible from host) + obj.writeLP(lp_file_path) + obj.writeMPS(mps_file_path) problem_info = {{ 'name': name, @@ -788,7 +792,9 @@ def __extract_problem_info(globals_dict): 'status': str(getattr(obj, 'status', 'unknown')), 'num_variables': len(getattr(obj, 'variables', [])), 'num_constraints': len(getattr(obj, 'constraints', [])), - 'objective': str(getattr(obj, 'objective', 'none')) + 'objective': str(getattr(obj, 'objective', 'none')), + 'lp_file_path': lp_file_path, + 'mps_file_path': mps_file_path }} problems_info.append(problem_info) @@ -801,7 +807,7 @@ def __extract_problem_info(globals_dict): 'error': f"Failed to extract problem info: {{e}}" }}) - return problems_info, lp_content, mps_content + return problems_info, lp_file_path, mps_file_path try: # Execute user code @@ -814,27 +820,15 @@ def __extract_problem_info(globals_dict): # Extract and serialize all relevant information as JSON __globals_copy = dict(globals()) - # Find and extract PuLP problem information - __problems_info, __lp_content, __mps_content = __extract_problem_info(__globals_copy) + # Find and extract PuLP problem information (writes files to virtual fs) + __problems_info, __lp_file_path, __mps_file_path = __extract_problem_info(__globals_copy) - # Convert user variables to JSON-safe format - __variables_info = {{}} - for __var_name in list(__globals_copy.keys()): - if (not __var_name.startswith('_') and - __var_name not in ['pulp', 'io', 'sys', 'tempfile', 'json'] and - not callable(__globals_copy[__var_name])): - try: - __variables_info[__var_name] = __convert_to_json_safe(__globals_copy[__var_name]) - except Exception as __e: - __variables_info[__var_name] = f"" - - # Create comprehensive result data + # Create result data with file paths (avoids large JSON) __result_data = {{ 'stdout': __stdout__, - 'lp_content': __lp_content, - 'mps_content': __mps_content, + 'lp_file_path': __lp_file_path, + 'mps_file_path': __mps_file_path, 'problems_info': __problems_info, - 'variables_info': __variables_info, 'execution_status': 'success' }} @@ -852,8 +846,8 @@ def __extract_problem_info(globals_dict): # Create error result data __result_data = {{ 'stdout': __stdout__, - 'lp_content': None, - 'mps_content': None, + 'lp_file_path': None, + 'mps_file_path': None, 'problems_info': [], 'variables_info': {{}}, 'execution_status': 'error', @@ -917,23 +911,32 @@ def _indent_code(self, code: str, spaces: int) -> str: indent = " " * spaces return "\n".join(indent + line for line in code.split("\n")) - def _create_temp_file(self, content: str, format_type: str) -> str: - """Create temporary file with the given content. + def _copy_optimization_file(self, source_path: str, format_type: str) -> str: + """Copy optimization file from isolated directory to new temporary file. Args: - content: File content + source_path: Path to source file in isolated directory format_type: File format ("lp" or "mps") Returns: - Path to temporary file + Path to new temporary file """ + import shutil + suffix = f".{format_type.lower()}" with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) as f: - f.write(content) temp_path = f.name - logger.info(f"Created temporary {format_type.upper()} file: {temp_path}") + # Track temporary file for cleanup + self._temp_files.append(temp_path) + + # Copy the file content + shutil.copy2(source_path, temp_path) + + logger.info( + f"Copied {format_type.upper()} file from {source_path} to {temp_path}" + ) return temp_path async def validate_code(self, code: str) -> dict[str, Any]: @@ -1060,6 +1063,44 @@ async def cleanup(self): self._pyodide_initialized = False logger.info("Pyodide process cleanup completed") + # Clean up tracked temporary files + if hasattr(self, "_temp_files"): + for temp_file in self._temp_files: + try: + with contextlib.suppress(OSError, FileNotFoundError): + Path(temp_file).unlink() + logger.debug(f"Cleaned up temporary file: {temp_file}") + except Exception as e: + logger.warning(f"Failed to clean up temp file {temp_file}: {e}") + self._temp_files.clear() + + # Clean up Node.js script file if it still exists + if hasattr(self, "_script_file") and self._script_file: + try: + with contextlib.suppress(OSError, FileNotFoundError): + Path(self._script_file).unlink() + logger.debug(f"Cleaned up script file: {self._script_file}") + except Exception as e: + logger.warning( + f"Failed to clean up script file {self._script_file}: {e}" + ) + finally: + self._script_file = None + + # Clean up isolated temporary directory + if hasattr(self, "temp_dir") and self.temp_dir: + try: + import shutil + + shutil.rmtree(self.temp_dir, ignore_errors=True) + logger.info(f"Cleaned up isolated temp directory: {self.temp_dir}") + except Exception as e: + logger.warning( + f"Failed to clean up temp directory {self.temp_dir}: {e}" + ) + finally: + self.temp_dir = None + def __del__(self): """Cleanup on destruction.""" if self.pyodide_process and not self._cleanup_started: diff --git a/src/mip_mcp/server.py b/src/mip_mcp/server.py index a5c4210..9d0a97a 100644 --- a/src/mip_mcp/server.py +++ b/src/mip_mcp/server.py @@ -21,7 +21,6 @@ from .utils.config_manager import ConfigManager from .utils.executor_registry import ExecutorRegistry from .utils.logger import get_logger, setup_logging -from .utils.pyodide_manager import PyodideManager logger = get_logger(__name__) @@ -185,65 +184,27 @@ async def get_mip_examples(ctx: Context) -> ExamplesResponse: logger.info("MCP tools registered successfully") async def _initialize_pyodide(self): - """Initialize Pyodide during server startup.""" + """Check basic requirements (simplified).""" if self._pyodide_initialized: return - logger.info("Initializing Pyodide environment...") - success = await PyodideManager.ensure_pyodide_available() + # Simple check: can we run Node.js? + import shutil - if success: - pyodide_path = PyodideManager.get_pyodide_path() - logger.info(f"Pyodide ready at: {pyodide_path}") - self._pyodide_initialized = True - else: + if not shutil.which("node"): logger.warning( - "Pyodide initialization failed. Code execution may not work properly. " - "Consider installing Node.js and running 'npm install pyodide'" + "Node.js not found. Please install Node.js to use optimization features." ) + return + + logger.info("✓ Node.js available for Pyodide execution") + self._pyodide_initialized = True def run(self, show_banner: bool = True): """Run the MCP server.""" logger.info("Starting MIP MCP Server...") - # Check Pyodide availability at startup for better user experience - self._check_pyodide_sync() + # Pyodide initialization is now handled on-demand by each executor # Let FastMCP handle signals and shutdown naturally self.app.run(show_banner=show_banner) - - def _check_pyodide_sync(self): - """Quick synchronous check for Pyodide availability.""" - try: - # Check bundled first - bundled_path = PyodideManager._check_bundled_pyodide() - if bundled_path: - PyodideManager._pyodide_path = bundled_path - logger.info(f"✓ Pyodide ready (bundled): {bundled_path}") - return - - # Check various locations for package.json - from pathlib import Path - - possible_roots = [ - Path.cwd(), # Current working directory - Path( - __file__ - ).parent.parent.parent, # Project root relative to this file - Path.cwd(), # Alternative current directory - ] - - package_json_found = False - for root in possible_roots: - if (root / "package.json").exists(): - logger.info(f"✓ Pyodide setup ready - auto-install from {root}") - package_json_found = True - break - - if not package_json_found: - logger.info( - "Pyodide will be downloaded automatically when needed. " - "For fastest startup, consider: npm install pyodide" - ) - except Exception as e: - logger.error(f"Error during Pyodide availability check: {e}") diff --git a/src/mip_mcp/utils/node_dependency_manager.py b/src/mip_mcp/utils/node_dependency_manager.py deleted file mode 100644 index 0c0616a..0000000 --- a/src/mip_mcp/utils/node_dependency_manager.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Node.js dependency management for uvx installations.""" - -import asyncio -import logging -import shutil -from pathlib import Path - -logger = logging.getLogger(__name__) - - -class NodeDependencyManager: - """Manages Node.js dependencies for uvx installations.""" - - def __init__(self, project_root: Path | None = None): - """Initialize dependency manager. - - Args: - project_root: Project root directory. If None, auto-detect. - """ - self.project_root = project_root or self._find_project_root() - self.package_json = self.project_root / "package.json" - self.node_modules = self.project_root / "node_modules" - - def _find_project_root(self) -> Path: - """Find project root by looking for package.json.""" - current = Path(__file__).parent - while current != current.parent: - if (current / "package.json").exists(): - return current - current = current.parent - - # Fallback to src/mip_mcp directory - return Path(__file__).parent.parent - - def has_npm(self) -> bool: - """Check if npm is available in system.""" - return shutil.which("npm") is not None - - def needs_installation(self) -> bool: - """Check if Node.js dependencies need installation.""" - if not self.package_json.exists(): - return False - - if not self.node_modules.exists(): - return True - - # Check if pyodide specifically exists - pyodide_path = self.node_modules / "pyodide" - return not pyodide_path.exists() - - async def install_dependencies(self) -> bool: - """Install Node.js dependencies automatically. - - Returns: - True if installation succeeded, False otherwise. - """ - if not self.has_npm(): - logger.warning( - "npm not found. Please install Node.js to use this feature, " - "or use the PyPI wheel version instead." - ) - return False - - if not self.needs_installation(): - logger.debug("Node.js dependencies already installed") - return True - - try: - logger.info("Installing Node.js dependencies automatically...") - logger.info(f"Running: npm install in {self.project_root}") - - process = await asyncio.create_subprocess_exec( - "npm", - "install", - cwd=self.project_root, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - stdout, stderr = await process.communicate() - - if process.returncode == 0: - logger.info("Node.js dependencies installed successfully") - return True - else: - logger.error(f"npm install failed: {stderr.decode()}") - return False - - except Exception as e: - logger.error(f"Failed to install Node.js dependencies: {e}") - return False - - async def ensure_dependencies(self) -> bool: - """Ensure Node.js dependencies are available. - - Returns: - True if dependencies are available, False otherwise. - """ - if not self.needs_installation(): - return True - - return await self.install_dependencies() diff --git a/src/mip_mcp/utils/pyodide_manager.py b/src/mip_mcp/utils/pyodide_manager.py deleted file mode 100644 index ce587b3..0000000 --- a/src/mip_mcp/utils/pyodide_manager.py +++ /dev/null @@ -1,302 +0,0 @@ -"""Pyodide installation and management utilities.""" - -import asyncio -import logging -import shutil -import tempfile -from pathlib import Path - -logger = logging.getLogger(__name__) - - -class PyodideManager: - """Manages Pyodide installation and availability.""" - - _pyodide_path: str | None = None - _initialization_lock = asyncio.Lock() - - @classmethod - async def ensure_pyodide_available(cls) -> bool: - """Ensure Pyodide is available, downloading if necessary. - - Returns: - True if Pyodide is available, False otherwise. - """ - async with cls._initialization_lock: - if cls._pyodide_path: - return True - - logger.info("Checking Pyodide availability...") - - # Check bundled first - bundled_path = cls._check_bundled_pyodide() - if bundled_path: - cls._pyodide_path = bundled_path - logger.info(f"Using bundled Pyodide at: {bundled_path}") - return True - - # Check npm installation - npm_path = await cls._find_npm_pyodide() - if npm_path: - cls._pyodide_path = npm_path - logger.info(f"Using npm-installed Pyodide at: {npm_path}") - return True - - # Auto-install if possible - if await cls._auto_install_pyodide(): - # Try npm path again after installation - npm_path = await cls._find_npm_pyodide() - if npm_path: - cls._pyodide_path = npm_path - logger.info(f"Auto-installed Pyodide at: {npm_path}") - return True - - # Download directly as fallback - downloaded_path = await cls._download_pyodide() - if downloaded_path: - cls._pyodide_path = downloaded_path - logger.info(f"Downloaded Pyodide to: {downloaded_path}") - return True - - logger.error("Failed to make Pyodide available") - return False - - @classmethod - def get_pyodide_path(cls) -> str | None: - """Get the current Pyodide path.""" - return cls._pyodide_path - - @classmethod - def _check_bundled_pyodide(cls) -> str | None: - """Check for bundled pyodide installation (from wheel).""" - try: - import pkg_resources - - pyodide_js_path = pkg_resources.resource_filename( - "mip_mcp", "data/pyodide/pyodide.js" - ) - if Path(pyodide_js_path).exists(): - return pyodide_js_path - except (ImportError, FileNotFoundError): - pass - return None - - @classmethod - async def _find_npm_pyodide(cls) -> str | None: - """Find pyodide installation via npm/node.""" - try: - # Add temporary npm directory to search paths - import tempfile - - temp_npm_dir = Path(tempfile.gettempdir()) / "mip-mcp-npm" - - proc = await asyncio.create_subprocess_exec( - "node", - "-e", - f""" -try {{ - console.log(require.resolve('pyodide')); -}} catch (e) {{ - // Try alternative paths including temporary npm directory - const path = require('path'); - const fs = require('fs'); - - const searchPaths = [ - path.join(process.cwd(), 'node_modules', 'pyodide'), - path.join(process.cwd(), '..', 'node_modules', 'pyodide'), - path.join(process.cwd(), '..', '..', 'node_modules', 'pyodide'), - path.join(__dirname, '..', '..', 'node_modules', 'pyodide'), - path.join('{temp_npm_dir}', 'node_modules', 'pyodide') - ]; - - for (const searchPath of searchPaths) {{ - const pyodidePath = path.join(searchPath, 'pyodide.js'); - if (fs.existsSync(pyodidePath)) {{ - console.log(pyodidePath); - process.exit(0); - }} - }} - process.exit(1); -}} - """, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - stdout, stderr = await proc.communicate() - - if proc.returncode == 0: - pyodide_path = stdout.decode().strip() - if Path(pyodide_path).exists(): - return pyodide_path - - except Exception as e: - logger.debug(f"Failed to find npm pyodide: {e}") - - return None - - @classmethod - async def _auto_install_pyodide(cls) -> bool: - """Auto-install pyodide via npm if possible.""" - if not shutil.which("npm"): - logger.debug("npm not available for auto-installation") - return False - - # Try multiple installation strategies - strategies = [ - cls._npm_install_in_project_root, - cls._npm_install_global_temp, - ] - - for strategy in strategies: - try: - if await strategy(): - return True - except Exception as e: - logger.debug(f"Strategy {strategy.__name__} failed: {e}") - continue - - return False - - @classmethod - async def _npm_install_in_project_root(cls) -> bool: - """Install pyodide in project root if package.json exists.""" - project_root = cls._find_project_root() - if not project_root or not (project_root / "package.json").exists(): - return False - - logger.info("Installing Pyodide via npm in project root...") - proc = await asyncio.create_subprocess_exec( - "npm", - "install", - cwd=project_root, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - stdout, stderr = await proc.communicate() - if proc.returncode == 0: - logger.info("npm install completed successfully") - return True - else: - logger.warning(f"npm install failed: {stderr.decode()}") - return False - - @classmethod - async def _npm_install_global_temp(cls) -> bool: - """Install pyodide in a temporary directory.""" - temp_dir = Path(tempfile.gettempdir()) / "mip-mcp-npm" - temp_dir.mkdir(exist_ok=True) - - # Create a minimal package.json for pyodide installation - package_json = temp_dir / "package.json" - if not package_json.exists(): - package_json.write_text('{"name": "mip-mcp-pyodide", "version": "1.0.0"}') - - logger.info("Installing Pyodide via npm in temporary directory...") - proc = await asyncio.create_subprocess_exec( - "npm", - "install", - "pyodide@0.28.0", - cwd=temp_dir, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - stdout, stderr = await proc.communicate() - if proc.returncode == 0: - logger.info("Pyodide installed successfully in temporary directory") - return True - else: - logger.warning(f"npm install pyodide failed: {stderr.decode()}") - return False - - @classmethod - def _find_project_root(cls) -> Path | None: - """Find project root by looking for package.json.""" - current = Path(__file__).parent - while current != current.parent: - if (current / "package.json").exists(): - return current - current = current.parent - return None - - @classmethod - async def _download_pyodide(cls) -> str | None: - """Download Pyodide directly as a fallback.""" - try: - import aiohttp - - # Create a temporary directory for pyodide - temp_dir = Path(tempfile.gettempdir()) / "mip-mcp-pyodide" - temp_dir.mkdir(exist_ok=True) - - pyodide_js = temp_dir / "pyodide.js" - if pyodide_js.exists(): - # Check if other required files exist - required_files = [ - "pyodide.asm.js", - "pyodide.asm.wasm", - "python_stdlib.zip", - ] - if all((temp_dir / f).exists() for f in required_files): - return str(pyodide_js) - - # Download Pyodide complete package - logger.info("Downloading Pyodide complete package...") - async with aiohttp.ClientSession() as session: - base_url = "https://cdn.jsdelivr.net/pyodide/v0.28.0/full" - - # List of required Pyodide files - files_to_download = [ - "pyodide.js", - "pyodide.asm.js", - "pyodide.asm.wasm", - "python_stdlib.zip", - "pyodide_py.tar", # Python packages - ] - - # Download all required files - for filename in files_to_download: - file_path = temp_dir / filename - if file_path.exists(): - continue # Skip if already downloaded - - url = f"{base_url}/{filename}" - logger.debug(f"Downloading {filename}...") - - try: - async with session.get(url) as response: - if response.status == 200: - with file_path.open("wb") as f: - f.write(await response.read()) - logger.debug(f"Downloaded {filename}") - else: - logger.warning( - f"Failed to download {filename}: HTTP {response.status}" - ) - # Continue with other files - except Exception as e: - logger.warning(f"Error downloading {filename}: {e}") - # Continue with other files - - # Verify essential files are present - essential_files = ["pyodide.js", "pyodide.asm.js", "pyodide.asm.wasm"] - if all((temp_dir / f).exists() for f in essential_files): - logger.info("Pyodide essential files downloaded successfully") - return str(pyodide_js) - else: - missing = [ - f for f in essential_files if not (temp_dir / f).exists() - ] - logger.error( - f"Failed to download essential Pyodide files: {missing}" - ) - return None - - except ImportError: - logger.debug("aiohttp not available for download") - except Exception as e: - logger.warning(f"Failed to download Pyodide: {e}") - - return None diff --git a/tests/unit/test_tuple_key_fix.py b/tests/unit/test_tuple_key_fix.py index f279f4b..6450e8f 100644 --- a/tests/unit/test_tuple_key_fix.py +++ b/tests/unit/test_tuple_key_fix.py @@ -82,26 +82,25 @@ async def test_tuple_key_execution_success(self, executor): executor, "_execute_with_periodic_progress", new_callable=AsyncMock ) as mock_execute, ): - # Mock successful JSON-based execution + # Mock successful filesystem-based execution mock_execute.return_value = { "success": True, "json_data": { "execution_status": "success", "stdout": "Tuple key test completed successfully", - "lp_content": "\\* test *\\\nMinimize\nOBJ: x_0_a + x_0_b + x_1_a + x_1_b\nSubject To\n_C1: x_0_a + x_0_b + x_1_a + x_1_b >= 1\nBinaries\nx_0_a\nx_0_b\nx_1_a\nx_1_b\nEnd", - "mps_content": None, + "lp_file_path": "/mnt/problem_12345.lp", + "mps_file_path": "/mnt/problem_12345.mps", "problems_info": [{"name": "prob", "num_variables": 4}], - "variables_info": { - "var_map": { - "0_a": "x_0_a", - "0_b": "x_0_b", - "1_a": "x_1_a", - "1_b": "x_1_b", - } - }, }, } + # Create mock files in the executor's temp directory + from pathlib import Path + + lp_content = "\\* test *\\\nMinimize\nOBJ: x_0_a + x_0_b + x_1_a + x_1_b\nSubject To\n_C1: x_0_a + x_0_b + x_1_a + x_1_b >= 1\nBinaries\nx_0_a\nx_0_b\nx_1_a\nx_1_b\nEnd" + mock_lp_file = Path(executor.temp_dir) / "problem_12345.lp" + mock_lp_file.write_text(lp_content) + stdout, stderr, file_path, library = await executor.execute_mip_code( tuple_key_code )