serve.py (6.1 KB)
1 #!/usr/bin/env python3 2 """ 3 Starts HTTP server for local testing 4 Automatically manages a virtual environment for dependencies 5 """ 6 7 import http.server 8 import socketserver 9 import socket 10 import sys 11 import os 12 import subprocess 13 from pathlib import Path 14 15 DEFAULT_PORT = 8000 16 SCRIPT_DIR = Path(__file__).parent.absolute() 17 VENV_DIR = SCRIPT_DIR / "venv" 18 19 20 def setup_venv(): 21 """Create and setup virtual environment if it doesn't exist""" 22 # Determine the path to pip and python in the venv 23 if sys.platform == "win32": 24 pip_path = VENV_DIR / "Scripts" / "pip" 25 python_path = VENV_DIR / "Scripts" / "python" 26 else: 27 pip_path = VENV_DIR / "bin" / "pip" 28 python_path = VENV_DIR / "bin" / "python3" 29 30 # Check if venv needs to be created or recreated 31 if not VENV_DIR.exists() or not python_path.exists(): 32 if VENV_DIR.exists(): 33 print("Virtual environment incomplete, recreating...") 34 import shutil 35 shutil.rmtree(VENV_DIR) 36 else: 37 print("Creating virtual environment...") 38 39 try: 40 subprocess.check_call([sys.executable, "-m", "venv", str(VENV_DIR)]) 41 print("Virtual environment created successfully.") 42 except subprocess.CalledProcessError as e: 43 print(f"Error creating virtual environment: {e}") 44 sys.exit(1) 45 46 # Ensure pip is available 47 if not pip_path.exists(): 48 print("Installing pip in virtual environment...") 49 try: 50 subprocess.check_call([str(python_path), "-m", "ensurepip", "--upgrade"]) 51 except subprocess.CalledProcessError as e: 52 print(f"Error ensuring pip: {e}") 53 sys.exit(1) 54 55 check = subprocess.run( 56 [str(python_path), "-c", "import qrcode"], 57 capture_output=True 58 ) 59 if check.returncode != 0: 60 try: 61 subprocess.check_call([str(python_path), "-m", "pip", "install", "-q", "qrcode"]) 62 except subprocess.CalledProcessError: 63 print("Note: Could not install qrcode (offline?). QR codes will be unavailable.\n") 64 65 return python_path 66 67 68 def run_in_venv(): 69 """Re-run this script in the virtual environment""" 70 python_path = setup_venv() 71 72 # Re-run this script with the venv Python 73 try: 74 subprocess.check_call([str(python_path), __file__, "--in-venv"]) 75 except (KeyboardInterrupt, subprocess.CalledProcessError): 76 pass 77 sys.exit(0) 78 79 80 def get_local_ip(): 81 """Get the LAN IP other devices on the network can reach this machine at, 82 or None if it can't be determined.""" 83 try: 84 # Connect to a public DNS server (doesn't actually send data) to learn 85 # which local interface/IP would be used to reach the network. 86 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 87 s.connect(("8.8.8.8", 80)) 88 local_ip = s.getsockname()[0] 89 s.close() 90 return local_ip 91 except Exception: 92 return None 93 94 95 def find_available_port(start_port=DEFAULT_PORT, max_attempts=20): 96 """Return the first bindable port at or after start_port. Uses SO_REUSEADDR 97 so a socket lingering in TIME_WAIT from a prior run doesn't block us.""" 98 for port in range(start_port, start_port + max_attempts): 99 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 100 s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 101 try: 102 s.bind(("", port)) 103 return port 104 except OSError: 105 continue 106 finally: 107 s.close() 108 return None 109 110 111 def print_qr_code(url, caption="Scan to connect:"): 112 """Generate and print a QR code using block characters.""" 113 try: 114 import qrcode 115 116 qr = qrcode.QRCode( 117 version=1, 118 error_correction=qrcode.constants.ERROR_CORRECT_L, 119 box_size=1, 120 border=1, 121 ) 122 qr.add_data(url) 123 qr.make(fit=True) 124 125 matrix = qr.get_matrix() 126 127 # Half-block chars pack two matrix rows per terminal line, keeping the 128 # code compact and roughly square. 129 print(f"\n{caption}\n") 130 for y in range(0, len(matrix), 2): 131 line = " " 132 for x in range(len(matrix[y])): 133 top = matrix[y][x] 134 bottom = matrix[y + 1][x] if y + 1 < len(matrix) else False 135 if top and bottom: 136 line += "█" 137 elif top: 138 line += "▀" 139 elif bottom: 140 line += "▄" 141 else: 142 line += " " 143 print(line) 144 except ImportError: 145 print(f"\n{caption}\n (QR unavailable; open this URL manually) {url}") 146 except Exception as e: 147 print(f"\nCould not generate QR code: {e}\n open manually: {url}") 148 149 150 class QuietHandler(http.server.SimpleHTTPRequestHandler): 151 """SimpleHTTPRequestHandler that keeps connections alive, disables caching, 152 silences logging, and swallows broken-pipe noise. Shared by serve.py and 153 https_serve.py.""" 154 # HTTP/1.1 keeps the connection alive across requests so loading the app's 155 # files reuses one connection instead of a fresh one per file. Requires a 156 # threaded server, or a held-open connection would block all others. 157 protocol_version = "HTTP/1.1" 158 159 def end_headers(self): 160 self.send_header("Cache-Control", "no-cache") 161 super().end_headers() 162 163 def log_message(self, format, *args): 164 pass 165 166 def handle(self): 167 try: 168 super().handle() 169 except (BrokenPipeError, ConnectionResetError): 170 # Browser cancelled the request (normal for media streaming/preloading) 171 pass 172 173 174 def start_server(): 175 """Start the HTTP server (runs after venv is set up)""" 176 # Change to script directory 177 os.chdir(SCRIPT_DIR) 178 179 # Find an available port 180 port = find_available_port(DEFAULT_PORT) 181 182 if port is None: 183 print(f"Error: Could not find an available port (tried {DEFAULT_PORT}-{DEFAULT_PORT + 19})") 184 sys.exit(1) 185 186 # Get local IP for network access 187 local_ip = get_local_ip() 188 189 try: 190 with socketserver.ThreadingTCPServer(("", port), QuietHandler) as httpd: 191 httpd.daemon_threads = True 192 local_url = f"http://localhost:{port}" 193 194 print(f"\nServer running on port {port}") 195 print(f"Local access: {local_url}") 196 197 if local_ip: 198 network_url = f"http://{local_ip}:{port}" 199 print(f"Network access: {network_url}") 200 print_qr_code(network_url) 201 else: 202 print("Network access: unavailable (could not determine LAN IP)") 203 204 print("\nPress Ctrl+C to stop the server") 205 206 # Serve forever 207 httpd.serve_forever() 208 209 except KeyboardInterrupt: 210 print("\n\nShutting down server...") 211 sys.exit(0) 212 except Exception as e: 213 print(f"\nError starting server: {e}") 214 sys.exit(1) 215 216 217 def main(): 218 """Main entry point""" 219 # Check if we're already running in venv 220 if "--in-venv" not in sys.argv: 221 run_in_venv() 222 else: 223 start_server() 224 225 226 if __name__ == "__main__": 227 main()