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()