#!/usr/bin/env python3 """App runner for the standard v2 stack.""" from __future__ import annotations import asyncio import os import sys from dataclasses import dataclass from pathlib import Path from urllib.error import URLError from urllib.request import urlopen from youdis.env import load_project_dotenv load_project_dotenv() REPO_ROOT = Path(__file__).resolve().parent DEFAULT_BACKEND_HOST = os.getenv("YOUDIS_BACKEND_HOST", "127.0.0.1") DEFAULT_BACKEND_PORT = int(os.getenv("YOUDIS_BACKEND_PORT", "8000")) RUN_BACKEND = os.getenv("YOUDIS_RUN_BACKEND", "1") not in {"0", "false", "False"} RUN_DISCORD = os.getenv("YOUDIS_RUN_DISCORD", "1") not in {"0", "false", "False"} BACKEND_HEALTH_TIMEOUT = float(os.getenv("YOUDIS_BACKEND_HEALTH_TIMEOUT", "20")) BACKEND_HEALTH_INTERVAL = float(os.getenv("YOUDIS_BACKEND_HEALTH_INTERVAL", "0.5")) @dataclass class ManagedProcess: name: str process: asyncio.subprocess.Process def backend_url() -> str: return f"http://{DEFAULT_BACKEND_HOST}:{DEFAULT_BACKEND_PORT}" def build_child_env() -> dict[str, str]: env = os.environ.copy() env.setdefault("YOUDIS_BACKEND_URL", backend_url()) return env async def start_backend(env: dict[str, str]) -> ManagedProcess: process = await asyncio.create_subprocess_exec( sys.executable, "-m", "uvicorn", "youdis.main:app", "--host", DEFAULT_BACKEND_HOST, "--port", str(DEFAULT_BACKEND_PORT), cwd=str(REPO_ROOT), env=env, ) return ManagedProcess(name="backend", process=process) async def start_discord(env: dict[str, str]) -> ManagedProcess: process = await asyncio.create_subprocess_exec( sys.executable, "-m", "youdis.adapters.discord", cwd=str(REPO_ROOT), env=env, ) return ManagedProcess(name="discord", process=process) async def wait_for_backend_health(timeout_seconds: float) -> None: deadline = asyncio.get_running_loop().time() + timeout_seconds health_url = f"{backend_url()}/health" while True: try: with urlopen(health_url, timeout=2) as response: if response.status == 200: return except URLError: pass if asyncio.get_running_loop().time() >= deadline: raise TimeoutError(f"backend did not become healthy at {health_url}") await asyncio.sleep(BACKEND_HEALTH_INTERVAL) async def stop_process(proc: ManagedProcess) -> None: if proc.process.returncode is not None: return proc.process.terminate() try: await asyncio.wait_for(proc.process.wait(), timeout=10) except asyncio.TimeoutError: proc.process.kill() await proc.process.wait() async def run() -> int: managed: list[ManagedProcess] = [] env = build_child_env() if not RUN_BACKEND and not RUN_DISCORD: print("nothing to start: both YOUDIS_RUN_BACKEND and YOUDIS_RUN_DISCORD are disabled") return 1 try: if RUN_BACKEND: backend = await start_backend(env) managed.append(backend) print(f"started backend on {backend_url()}") await wait_for_backend_health(BACKEND_HEALTH_TIMEOUT) print("backend is healthy") if RUN_DISCORD: discord = await start_discord(env) managed.append(discord) print("started discord adapter") wait_tasks = [asyncio.create_task(proc.process.wait()) for proc in managed] done, pending = await asyncio.wait(wait_tasks, return_when=asyncio.FIRST_COMPLETED) for task in pending: task.cancel() first_returncode = 0 for task in done: first_returncode = task.result() break return first_returncode finally: for proc in reversed(managed): await stop_process(proc) def main() -> None: try: raise SystemExit(asyncio.run(run())) except KeyboardInterrupt: raise SystemExit(130) if __name__ == "__main__": main()