Python-server / app.py
Srevarshan1502's picture
added
605b91b
Raw
History Blame Contribute Delete
21.3 kB
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)