import sys import io import traceback import ast import time import json import os from contextlib import redirect_stdout, redirect_stderr from typing import Dict, Any, Optional import threading import asyncio import signal from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request from pydantic import BaseModel from starlette.responses import HTMLResponse # Set up proper signal handling for Docker def signal_handler(signum, frame): print(f"Received signal {signum}, shutting down gracefully...") sys.exit(0) signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) # --- Custom IO Redirection for Interactive Execution --- class WebSocketInputOutput: """ Redirects stdin/stdout/stderr to/from a WebSocket connection. Handles interactive input by waiting for messages from the client. """ def __init__(self, websocket: WebSocket): self.websocket = websocket self.input_queue = asyncio.Queue() self.output_buffer = io.StringIO() self.error_buffer = io.StringIO() self.closed = False async def write(self, s: str): """Writes to stdout/stderr and sends to WebSocket.""" self.output_buffer.write(s) try: # Send output in chunks or lines to prevent large messages await self.websocket.send_text(json.dumps({"type": "output", "content": s})) except WebSocketDisconnect: self.closed = True print("WebSocket disconnected during write.") except Exception as e: print(f"Error sending output over WebSocket: {e}") self.closed = True def flush(self): """Flushes the buffer (no-op for now, as we send immediately).""" pass async def readline(self) -> str: """Reads a line from stdin, waiting for input from WebSocket.""" try: await self.websocket.send_text(json.dumps({"type": "input_request"})) line = await self.input_queue.get() # Wait for input from client return line except WebSocketDisconnect: self.closed = True print("WebSocket disconnected during readline.") raise EOFError("Input stream closed due to WebSocket disconnect") except Exception as e: print(f"Error requesting input over WebSocket: {e}") self.closed = True raise EOFError(f"Input stream error: {e}") # For compatibility with sys.stdin, which expects a file-like object def _get_input_line(self): """Synchronous wrapper for readline for use with exec.""" # This is a bit tricky: exec is synchronous, but WebSocket is async. # We need to bridge this. A simple way is to run the async part # in the event loop, but it's usually better to refactor the executor # to be fully async if input() is truly interactive. # For this example, we'll use a blocking call to asyncio.run # which is generally discouraged in a running event loop, # but works for simple cases or if the exec is in a separate thread. # A more robust solution involves a custom Future/Event loop integration. try: # This will block the current thread until input is available return asyncio.run(self.readline()) except Exception as e: print(f"Synchronous input wrapper error: {e}") raise def read(self, n=-1): """Reads n characters. For simplicity, we'll treat it as readline.""" return self._get_input_line() # Or implement more complex buffering def __getattr__(self, name): """Delegate other attributes if needed, or raise AttributeError.""" # This is a placeholder; real implementation would be more robust # For now, we only need write, flush, and readline. raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") # --- Python Syntax Validation FSM (unchanged) --- class PythonSyntaxFSM: """Finite State Machine for Python syntax validation""" def __init__(self): self.reset() def reset(self): self.state = 'normal' self.bracket_stack = [] self.in_string = False self.string_delimiter = None self.line_number = 0 self.errors = [] self.warnings = [] def validate_code(self, code: str) -> Dict[str, Any]: """Validate Python code using FSM approach""" self.reset() if not code or not code.strip(): return { 'valid': False, 'errors': [{'line': 0, 'message': 'Empty code input', 'type': 'input_error'}], 'warnings': [], 'total_issues': 1 } lines = code.split('\n') for i, line in enumerate(lines): self.line_number = i + 1 self.validate_line(line) # Check for unclosed brackets at end if self.bracket_stack: self.errors.append({ 'line': self.line_number, 'type': 'syntax_error', 'message': f'Unclosed bracket: {self.bracket_stack[-1]}', 'severity': 'error' }) return { 'valid': len(self.errors) == 0, 'errors': self.errors, 'warnings': self.warnings, 'total_issues': len(self.errors) + len(self.warnings) } def validate_line(self, line: str): """Validate a single line using FSM logic""" if not line.strip(): return # Check indentation leading_spaces = len(line) - len(line.lstrip()) if leading_spaces % 4 != 0 and line.strip(): self.warnings.append({ 'line': self.line_number, 'type': 'style_warning', 'message': 'Inconsistent indentation (PEP 8 recommends 4 spaces)', 'severity': 'warning' }) # Process character by character for brackets and strings i = 0 while i < len(line): char = line[i] # Handle string states if self.in_string: if char == self.string_delimiter and (i == 0 or line[i-1] != '\\'): self.in_string = False self.string_delimiter = None i += 1 continue # Handle comment detection if char == '#': break # Rest of line is comment # Handle string detection if char in ['"', "'"]: # Check for triple quotes if i + 2 < len(line) and line[i:i+3] == char * 3: self.string_delimiter = char * 3 i += 3 else: self.string_delimiter = char i += 1 self.in_string = True continue # Handle brackets if char in '([{': self.bracket_stack.append(char) elif char in ')]}': if not self.bracket_stack: self.errors.append({ 'line': self.line_number, 'type': 'syntax_error', 'message': f'Unmatched closing bracket: {char}', 'severity': 'error' }) else: expected = {'(': ')', '[': ']', '{': '}'} last_open = self.bracket_stack[-1] if expected[last_open] != char: self.errors.append({ 'line': self.line_number, 'type': 'syntax_error', 'message': f'Mismatched brackets: expected {expected[last_open]}, got {char}', 'severity': 'error' }) else: self.bracket_stack.pop() i += 1 # Check Python syntax patterns stripped = line.strip() control_keywords = ['def ', 'class ', 'if ', 'elif ', 'for ', 'while ', 'try:', 'except'] for keyword in control_keywords: if stripped.startswith(keyword): if not stripped.endswith(':') and keyword != 'except': self.errors.append({ 'line': self.line_number, 'type': 'syntax_error', 'message': f'{keyword.strip()} statement must end with colon', 'severity': 'error' }) break # --- Secure Python Executor (modified for WebSockets) --- class SecurePythonExecutor: """Secure Python code executor with sandboxing and timeout, now interactive.""" def __init__(self, timeout: int = 10): self.timeout = timeout self.restricted_modules = { 'os', 'sys', 'subprocess', 'socket', 'urllib', 'requests', 'shutil', 'glob', 'pickle', 'marshal', 'shelve', 'dbm', 'sqlite3', 'threading', 'multiprocessing', 'ctypes', 'importlib', 'builtins', '__builtin__', 'imp', 'zipimport' } # Create safe builtins # IMPORTANT: 'input' is now included and will be redirected! self.safe_builtins = { 'print': print, 'len': len, 'range': range, 'str': str, 'int': int, 'float': float, 'bool': bool, 'list': list, 'dict': dict, 'tuple': tuple, 'set': set, 'frozenset': frozenset, 'abs': abs, 'max': max, 'min': min, 'sum': sum, 'sorted': sorted, 'reversed': reversed, 'enumerate': enumerate, 'zip': zip, 'map': map, 'filter': filter, 'all': all, 'any': any, 'type': type, 'isinstance': isinstance, 'hasattr': hasattr, 'getattr': getattr, 'setattr': setattr, 'delattr': delattr, 'round': round, 'pow': pow, 'divmod': divmod, 'chr': chr, 'ord': ord, 'hex': hex, 'oct': oct, 'bin': bin, 'format': format, 'repr': repr, 'ascii': ascii, 'iter': iter, 'next': next, 'slice': slice, 'callable': callable, 'id': id, 'hash': hash, 'vars': vars, 'dir': dir, 'help': help, 'Exception': Exception, 'ValueError': ValueError, 'TypeError': TypeError, 'IndexError': IndexError, 'KeyError': KeyError, 'AttributeError': AttributeError, 'NameError': NameError, 'ZeroDivisionError': ZeroDivisionError, 'input': input # Now included for interactive use } async def execute_code(self, code: str, websocket_io: WebSocketInputOutput) -> Dict[str, Any]: """Execute Python code in a secure environment with WebSocket IO.""" start_time = time.time() # Input validation if not code or not code.strip(): error_msg = 'No code provided' await websocket_io.write(f"Error: {error_msg}\n") return { 'success': False, 'output': '', 'error': error_msg, 'execution_time': 0, 'validation': { 'valid': False, 'errors': [{'message': error_msg, 'line': 0}], 'warnings': [], 'total_issues': 1 } } # Pre-execution validation fsm = PythonSyntaxFSM() validation_result = fsm.validate_code(code) # Check for restricted imports restricted_check = self.check_restricted_imports(code) if not restricted_check['allowed']: error_msg = f"Security violation: Restricted module '{restricted_check['module']}' is not allowed" await websocket_io.write(f"Error: {error_msg}\n") return { 'success': False, 'output': '', 'error': error_msg, 'execution_time': 0, 'validation': validation_result } # Execute code with timeout result = await self.run_with_timeout(code, websocket_io) # Pass websocket_io execution_time = time.time() - start_time return { 'success': result['success'], 'output': result['output'], 'error': result['error'], 'execution_time': round(execution_time, 3), 'validation': validation_result } def check_restricted_imports(self, code: str) -> Dict[str, Any]: """Check for restricted module imports""" try: tree = ast.parse(code) for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: if alias.name.split('.')[0] in self.restricted_modules: return {'allowed': False, 'module': alias.name} elif isinstance(node, ast.ImportFrom): if node.module and node.module.split('.')[0] in self.restricted_modules: return {'allowed': False, 'module': node.module} return {'allowed': True, 'module': None} except Exception: return {'allowed': True, 'module': None} # If parsing fails, let execution handle it async def run_with_timeout(self, code: str, websocket_io: WebSocketInputOutput) -> Dict[str, Any]: """Run code with timeout protection, interacting via WebSocketIO.""" result = {'success': False, 'output': '', 'error': ''} # Store original stdin/stdout/stderr original_stdin = sys.stdin original_stdout = sys.stdout original_stderr = sys.stderr # Use a threading.Event to signal completion from the execution thread execution_finished_event = threading.Event() def target(): nonlocal result try: # Redirect sys.stdin, sys.stdout, sys.stderr for this thread sys.stdin = websocket_io sys.stdout = websocket_io sys.stderr = websocket_io # Create restricted execution environment restricted_globals = { '__builtins__': self.safe_builtins, '__name__': '__main__', '__doc__': None, } # Execute the code exec(code, restricted_globals) result['success'] = True except EOFError as e: # This happens if the WebSocket disconnects during input() result['error'] = f"Input stream closed: {str(e)}" result['success'] = False except Exception as e: error_msg = f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}" # Write error to WebSocket and internal buffer try: asyncio.run(websocket_io.write(f"Error: {error_msg}\n")) except Exception as write_e: print(f"Failed to write error to WebSocket: {write_e}") websocket_io.error_buffer.write(error_msg) result['success'] = False finally: # Restore original stdin/stdout/stderr for the thread pool (if applicable) sys.stdin = original_stdin sys.stdout = original_stdout sys.stderr = original_stderr execution_finished_event.set() # Signal completion # Run the execution in a separate thread to allow for timeout thread = threading.Thread(target=target) thread.daemon = True # Allow the program to exit even if thread is running thread.start() # Wait for the thread to finish or timeout thread.join(timeout=self.timeout) if thread.is_alive(): result['error'] = f"Code execution timed out after {self.timeout} seconds" result['success'] = False # Attempt to send timeout message to client try: await websocket_io.write(f"Error: {result['error']}\n") except Exception: pass # Ignore if websocket is already closed else: # Execution finished within timeout result['output'] = websocket_io.output_buffer.getvalue() if websocket_io.error_buffer.getvalue(): result['error'] = websocket_io.error_buffer.getvalue() result['success'] = False else: result['success'] = True # Send a final message to the client indicating execution is complete try: await websocket_io.send_text(json.dumps({"type": "execution_complete", "result": result})) except WebSocketDisconnect: pass # Ignore if websocket is already closed except Exception as e: print(f"Error sending execution_complete message: {e}") return result # Initialize the executor executor = SecurePythonExecutor(timeout=10) # Initialize FastAPI app app = FastAPI( title="Interactive Python Code Executor API", description="A secure WebSocket API for executing Python code interactively.", version="1.0.0", ) # Serve the HTML frontend @app.get("/", response_class=HTMLResponse) async def get_index(): """Serves the interactive frontend.""" # This assumes index.html is in the same directory as app.py with open("index.html", "r") as f: return HTMLResponse(content=f.read()) # WebSocket endpoint for interactive execution @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): await websocket.accept() websocket_io = WebSocketInputOutput(websocket) print(f"WebSocket connection established: {websocket.client}") try: while True: message = await websocket.receive_text() data = json.loads(message) if data["type"] == "code": code = data["content"] print(f"Received code from {websocket.client}: {code[:50]}...") # Execute code asynchronously in the background asyncio.create_task(executor.execute_code(code, websocket_io)) elif data["type"] == "input": user_input = data["content"] print(f"Received input from {websocket.client}: {user_input.strip()[:50]}...") await websocket_io.input_queue.put(user_input + "\n") # Add newline for readline elif data["type"] == "ping": # Respond to pings to keep connection alive await websocket.send_text(json.dumps({"type": "pong"})) else: await websocket_io.write(f"Unknown message type: {data['type']}\n") except WebSocketDisconnect: print(f"WebSocket disconnected: {websocket.client}") except json.JSONDecodeError: print(f"Received invalid JSON from {websocket.client}: {message}") except Exception as e: print(f"WebSocket error for {websocket.client}: {e}") finally: websocket_io.closed = True # Clean up any pending input requests if the client disconnects while not websocket_io.input_queue.empty(): try: websocket_io.input_queue.get_nowait() except asyncio.QueueEmpty: pass print(f"WebSocket connection closed for {websocket.client}") # This block is for local development using `uvicorn` # Hugging Face Spaces will use `uvicorn` directly via the Dockerfile CMD if __name__ == "__main__": import uvicorn print("Starting Interactive Python Code Executor API (FastAPI)...") print(f"Python version: {sys.version}") print(f"Working directory: {os.getcwd()}") server_name = os.getenv('FASTAPI_SERVER_NAME', '0.0.0.0') server_port = int(os.getenv('FASTAPI_SERVER_PORT', 7860)) print(f"Starting server on {server_name}:{server_port}") try: uvicorn.run( "app:app", # app:app means the `app` object in `app.py` host=server_name, port=server_port, reload=False, # Set to True for local dev to auto-reload on code changes log_level="info" ) except Exception as e: print(f"Failed to start server: {e}") sys.exit(1)