162 lines
4.3 KiB
Python
162 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
"""App runner for FastAPI backend and discord adapter."""
|
|
|
|
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"))
|
|
ENABLE_BACKEND = os.getenv("YOUDIS_RUN_BACKEND", "1") not in {"0", "false", "False"}
|
|
ENABLE_DISCORD = os.getenv("ENABLE_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)
|
|
|
|
|
|
ADAPTER_STARTERS = {
|
|
"discord": start_discord,
|
|
}
|
|
|
|
|
|
def selected_adapters() -> list[str]:
|
|
requested: list[str] = []
|
|
if ENABLE_DISCORD:
|
|
requested.append("discord")
|
|
return requested
|
|
|
|
|
|
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()
|
|
adapters = selected_adapters()
|
|
|
|
if not ENABLE_BACKEND and not adapters:
|
|
print("nothing to start: backend is disabled and no adapters are enabled")
|
|
return 1
|
|
|
|
try:
|
|
if ENABLE_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")
|
|
|
|
for adapter_name in adapters:
|
|
process = await ADAPTER_STARTERS[adapter_name](env)
|
|
managed.append(process)
|
|
print(f"started {adapter_name} 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()
|