diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6022761 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +YOUDIS_RUN_BACKEND=1 +YOUDIS_RUN_DISCORD=1 +YOUDIS_BACKEND_HOST=127.0.0.1 +YOUDIS_BACKEND_PORT=8000 +YOUDIS_BACKEND_HEALTH_TIMEOUT=20 +YOUDIS_BACKEND_HEALTH_INTERVAL=0.5 +YOUDIS_BACKEND_URL=http://127.0.0.1:8000 +YOUDIS_POLL_INTERVAL_SECONDS=2 +YOUDIS_YTDLP_EXECUTABLE=yt-dlp +YOUDIS_CONFIG_DIR=/home/user/proj/youdis/test/ +YOUDIS_DOWNLOAD_DIR=/home/user/proj/youdis/downloads +DISCORD_BOT_TOKEN= +DISCORD_BOT_SCOPE=2147491904 diff --git a/.gitignore b/.gitignore index e164256..d682957 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ env/ # --- environment files --- .env .env.* +!.env.example *.local # --- emacs --- @@ -32,4 +33,4 @@ staticfiles/ media/ # --- misc --- -.DS_Store \ No newline at end of file +.DS_Store diff --git a/README.md b/README.md index 1e43bc2..3808b4c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,16 @@ v2 architecture draft: see `docs/architecture-v2.org` +default v2 app run: +``` +python3 ./youdis.py +``` + +direct component runs still work for testing: +``` +python3 -m uvicorn youdis.main:app --host 127.0.0.1 --port 8000 +python3 -m youdis.adapters.discord +``` + build and run the docker container ``` DISCORD_BOT_TOKEN = [discord bot token] diff --git a/pm/tasks-v2.org b/pm/tasks-v2.org index c0296d8..cc7efb8 100644 --- a/pm/tasks-v2.org +++ b/pm/tasks-v2.org @@ -242,7 +242,56 @@ delete or retire legacy bot behaviors that no longer fit once the backend split - cleanup updated user-facing deployment artifacts to match the v2 naming and architecture, including `DISCORD_BOT_TOKEN` and removal of `users.json` references - archived planning docs were intentionally left untouched: `pm/tasks.org` is historical and `pm/notes.org` is personal working notes -* [ ] 2.0.4: fix automation and build pipeline (3) +* [ ] 2.0.4: make youdis.py app runner for backend + adapters (3) +Build a simple Python orchestration layer in youdis.py so the standard app stack can be launched from one entrypoint while backend and adapters remain independently runnable for testing. + +** pm notes +- keep components independently runnable +- youdis.py as app-level orchestrator; let Docker just run python3 /app/youdis.py +- no plugin discovery or hot-loading +- Docker should package and launch the app, not decide internal process topology +- preserve the ability to run backend or adapters directly for debugging +- optimize for one obvious default run path + +** Acceptance Criteria +1. define the default app startup path + - youdis.py launches the standard stack for v2 + - backend and Discord adapter startup order is explicit + - shutdown behavior is coherent enough for local/dev use +2. preserve modular run paths + - backend can still be run directly + - Discord adapter can still be run directly + - orchestration layer does not bury component-level testing +3. keep orchestration simple + - no dynamic adapter discovery + - no hot reloading framework + - configured components are started explicitly +4. document runtime ownership + - clarify what Python orchestrates vs what Docker orchestrates + - identify env vars or flags that control which components start + - leave room for future Zulip/XMPP adapters without redesigning the runner + +** evidence +- commit: +- tests: + 1. `python3 -m py_compile ./youdis.py ./youdis/adapters/discord.py` + 2. backend direct run still works: `python3 -m uvicorn youdis.main:app --host 127.0.0.1 --port 8000` + 3. discord direct run still works: `python3 -m youdis.adapters.discord` + 4. app runner backend-only smoke test: `YOUDIS_RUN_DISCORD=0 timeout 5s python3 ./youdis.py` + 5. app runner default path: `python3 ./youdis.py` +- date: + +** notes +- `youdis.py` is now the default v2 app runner and starts the standard stack explicitly rather than dynamically discovering adapters +- backend starts first and must pass a health check before the Discord adapter is launched +- backend and Discord adapter remain directly runnable for debugging and tests +- Docker is intended to invoke `python3 /app/youdis.py`; Python owns app orchestration while Docker owns packaging and runtime environment +- runner flags currently include `YOUDIS_RUN_BACKEND`, `YOUDIS_RUN_DISCORD`, `YOUDIS_BACKEND_HOST`, and `YOUDIS_BACKEND_PORT` + + +* ==== BACKLOG ==== +Tasks below this line are inactive and should not be touched. +* [ ] 2.0.X: fix automation and build pipeline (3) repair and simplify the build/update/deploy path so it matches the new backend-plus-frontend structure ** pm notes - this should come after architecture and discord integration stabilize. no point polishing the pipeline for the wrong shape. @@ -267,8 +316,6 @@ repair and simplify the build/update/deploy path so it matches the new backend-p ** notes -* ==== BACKLOG ==== -Tasks below this line are inactive and should not be touched. * [ ] X.x.x: clean up discord adapter UI ** acceptance criteria diff --git a/youdis.py b/youdis.py index 168ce2a..0252c6a 100644 --- a/youdis.py +++ b/youdis.py @@ -1,7 +1,147 @@ #!/usr/bin/env python3 -"""Launcher shim for the Discord adapter.""" +"""App runner for the standard v2 stack.""" -from youdis.adapters.discord import main +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__": diff --git a/youdis/adapters/discord.py b/youdis/adapters/discord.py index c4ecf11..74a97ee 100644 --- a/youdis/adapters/discord.py +++ b/youdis/adapters/discord.py @@ -222,3 +222,7 @@ def main() -> None: bot.add_command(status) bot.add_command(interrupt) bot.start(api_token) + + +if __name__ == "__main__": + main() diff --git a/youdis/env.py b/youdis/env.py index 82934c6..5a0d792 100644 --- a/youdis/env.py +++ b/youdis/env.py @@ -1,8 +1,13 @@ from pathlib import Path -from dotenv import load_dotenv +try: + from dotenv import load_dotenv +except ModuleNotFoundError: # pragma: no cover - optional local convenience dependency + load_dotenv = None def load_project_dotenv() -> None: + if load_dotenv is None: + return repo_root = Path(__file__).resolve().parent.parent load_dotenv(repo_root / ".env", override=False)