|
| 1 | +import importlib.util |
| 2 | +import logging |
| 3 | +import socket |
| 4 | +import subprocess |
| 5 | +import sys |
| 6 | +from pathlib import Path |
| 7 | +from typing import Optional |
| 8 | + |
| 9 | +import rich |
| 10 | +import rich_click as click |
| 11 | +import uvicorn |
| 12 | +from rich.logging import RichHandler |
| 13 | +from rich.panel import Panel |
| 14 | + |
| 15 | +from codegen.extensions.events.codegen_app import CodegenApp |
| 16 | + |
| 17 | +logger = logging.getLogger(__name__) |
| 18 | + |
| 19 | + |
| 20 | +def setup_logging(debug: bool): |
| 21 | + """Configure rich logging with colors.""" |
| 22 | + logging.basicConfig( |
| 23 | + level=logging.DEBUG if debug else logging.INFO, |
| 24 | + format="%(message)s", |
| 25 | + handlers=[ |
| 26 | + RichHandler( |
| 27 | + rich_tracebacks=True, |
| 28 | + tracebacks_show_locals=debug, |
| 29 | + markup=True, |
| 30 | + show_time=False, |
| 31 | + ) |
| 32 | + ], |
| 33 | + ) |
| 34 | + |
| 35 | + |
| 36 | +def load_app_from_file(file_path: Path) -> CodegenApp: |
| 37 | + """Load a CodegenApp instance from a Python file. |
| 38 | +
|
| 39 | + Args: |
| 40 | + file_path: Path to the Python file containing the CodegenApp |
| 41 | +
|
| 42 | + Returns: |
| 43 | + The CodegenApp instance from the file |
| 44 | +
|
| 45 | + Raises: |
| 46 | + click.ClickException: If no CodegenApp instance is found |
| 47 | + """ |
| 48 | + try: |
| 49 | + # Import the module from file path |
| 50 | + spec = importlib.util.spec_from_file_location("app_module", file_path) |
| 51 | + if not spec or not spec.loader: |
| 52 | + msg = f"Could not load module from {file_path}" |
| 53 | + raise click.ClickException(msg) |
| 54 | + |
| 55 | + module = importlib.util.module_from_spec(spec) |
| 56 | + spec.loader.exec_module(module) |
| 57 | + |
| 58 | + # Find CodegenApp instance |
| 59 | + for attr_name in dir(module): |
| 60 | + attr = getattr(module, attr_name) |
| 61 | + if isinstance(attr, CodegenApp): |
| 62 | + return attr |
| 63 | + |
| 64 | + msg = f"No CodegenApp instance found in {file_path}" |
| 65 | + raise click.ClickException(msg) |
| 66 | + |
| 67 | + except Exception as e: |
| 68 | + msg = f"Error loading app from {file_path}: {e!s}" |
| 69 | + raise click.ClickException(msg) |
| 70 | + |
| 71 | + |
| 72 | +def create_app_module(file_path: Path) -> str: |
| 73 | + """Create a temporary module that exports the app for uvicorn.""" |
| 74 | + # Add the file's directory to Python path |
| 75 | + file_dir = str(file_path.parent.absolute()) |
| 76 | + if file_dir not in sys.path: |
| 77 | + sys.path.insert(0, file_dir) |
| 78 | + |
| 79 | + # Create a module that imports and exposes the app |
| 80 | + module_name = f"codegen_app_{file_path.stem}" |
| 81 | + module_code = f""" |
| 82 | +from {file_path.stem} import app |
| 83 | +app = app.app |
| 84 | +""" |
| 85 | + module_path = file_path.parent / f"{module_name}.py" |
| 86 | + module_path.write_text(module_code) |
| 87 | + |
| 88 | + return f"{module_name}:app" |
| 89 | + |
| 90 | + |
| 91 | +def start_ngrok(port: int) -> Optional[str]: |
| 92 | + """Start ngrok and return the public URL""" |
| 93 | + try: |
| 94 | + import requests |
| 95 | + |
| 96 | + # Start ngrok |
| 97 | + process = subprocess.Popen(["ngrok", "http", str(port)], stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
| 98 | + |
| 99 | + # Wait a moment for ngrok to start |
| 100 | + import time |
| 101 | + |
| 102 | + time.sleep(2) |
| 103 | + |
| 104 | + # Get the public URL from ngrok's API |
| 105 | + try: |
| 106 | + response = requests.get("http://localhost:4040/api/tunnels") |
| 107 | + data = response.json() |
| 108 | + |
| 109 | + # Get the first https URL |
| 110 | + for tunnel in data["tunnels"]: |
| 111 | + if tunnel["proto"] == "https": |
| 112 | + return tunnel["public_url"] |
| 113 | + |
| 114 | + logger.warning("No HTTPS tunnel found") |
| 115 | + return None |
| 116 | + |
| 117 | + except requests.RequestException: |
| 118 | + logger.exception("Failed to get ngrok URL from API") |
| 119 | + logger.info("Get your public URL from: http://localhost:4040") |
| 120 | + return None |
| 121 | + |
| 122 | + except FileNotFoundError: |
| 123 | + logger.exception("ngrok not found. Please install it first: https://ngrok.com/download") |
| 124 | + return None |
| 125 | + |
| 126 | + |
| 127 | +def find_available_port(start_port: int = 8000, max_tries: int = 100) -> int: |
| 128 | + """Find an available port starting from start_port.""" |
| 129 | + for port in range(start_port, start_port + max_tries): |
| 130 | + try: |
| 131 | + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: |
| 132 | + s.bind(("", port)) |
| 133 | + return port |
| 134 | + except OSError: |
| 135 | + continue |
| 136 | + msg = f"No available ports found between {start_port} and {start_port + max_tries}" |
| 137 | + raise click.ClickException(msg) |
| 138 | + |
| 139 | + |
| 140 | +@click.command(name="serve") |
| 141 | +@click.argument("file", type=click.Path(exists=True, path_type=Path)) |
| 142 | +@click.option("--host", default="127.0.0.1", help="Host to bind to") |
| 143 | +@click.option("--port", default=8000, help="Port to bind to") |
| 144 | +@click.option("--debug", is_flag=True, help="Enable debug mode with hot reloading") |
| 145 | +@click.option("--public", is_flag=True, help="Expose the server publicly using ngrok") |
| 146 | +@click.option("--workers", default=1, help="Number of worker processes") |
| 147 | +@click.option("--repos", multiple=True, help="GitHub repositories to analyze") |
| 148 | +def serve_command(file: Path, host: str = "127.0.0.1", port: int = 8000, debug: bool = False, public: bool = False, workers: int = 4, repos: list[str] = []): |
| 149 | + """Run a CodegenApp server from a Python file. |
| 150 | +
|
| 151 | + FILE is the path to a Python file containing a CodegenApp instance |
| 152 | + """ |
| 153 | + # Configure rich logging |
| 154 | + setup_logging(debug) |
| 155 | + |
| 156 | + try: |
| 157 | + if debug: |
| 158 | + workers = 1 |
| 159 | + |
| 160 | + # Find available port if the specified one is in use |
| 161 | + try: |
| 162 | + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: |
| 163 | + s.bind((host, port)) |
| 164 | + except OSError: |
| 165 | + port = find_available_port(port) |
| 166 | + logger.warning(f"Port {port} was in use, using port {port} instead") |
| 167 | + |
| 168 | + # Always create module for uvicorn |
| 169 | + app_import_string = create_app_module(file) |
| 170 | + reload_dirs = [str(file.parent)] if debug else None |
| 171 | + |
| 172 | + # Print server info |
| 173 | + rich.print( |
| 174 | + Panel( |
| 175 | + f"[green]Starting CodegenApp server[/green]\n" |
| 176 | + f"[dim]File:[/dim] {file}\n" |
| 177 | + f"[dim]URL:[/dim] http://{host}:{port}\n" |
| 178 | + f"[dim]Workers:[/dim] {workers}\n" |
| 179 | + f"[dim]Debug:[/dim] {'enabled' if debug else 'disabled'}", |
| 180 | + title="[bold]Server Info[/bold]", |
| 181 | + border_style="blue", |
| 182 | + ) |
| 183 | + ) |
| 184 | + |
| 185 | + # Start ngrok if --public flag is set |
| 186 | + if public: |
| 187 | + public_url = start_ngrok(port) |
| 188 | + if public_url: |
| 189 | + logger.info(f"Public URL: {public_url}") |
| 190 | + logger.info("Use these webhook URLs in your integrations:") |
| 191 | + logger.info(f" Slack: {public_url}/slack/events") |
| 192 | + logger.info(f" GitHub: {public_url}/github/events") |
| 193 | + logger.info(f" Linear: {public_url}/linear/events") |
| 194 | + |
| 195 | + # Run the server with workers |
| 196 | + uvicorn.run( |
| 197 | + app_import_string, |
| 198 | + host=host, |
| 199 | + port=port, |
| 200 | + reload=debug, |
| 201 | + reload_dirs=reload_dirs, |
| 202 | + log_level="debug" if debug else "info", |
| 203 | + workers=workers, |
| 204 | + ) |
| 205 | + |
| 206 | + except Exception as e: |
| 207 | + msg = f"Server error: {e!s}" |
| 208 | + raise click.ClickException(msg) |
| 209 | + |
| 210 | + |
| 211 | +if __name__ == "__main__": |
| 212 | + serve_command() |
0 commit comments