From fbc3bdb6b58db3597a8a1ae35c85c2e9df3abef7 Mon Sep 17 00:00:00 2001 From: Commander AI Lab Agent Date: Wed, 18 Mar 2026 04:55:54 +0000 Subject: [PATCH] fix: add _resolve_precon_dir() auto-detection so CFG.precon_dir is not empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The precon deck list was missing after the monolith refactor because PRECON_DIR was hardcoded in services/precon_service.py with no CLI arg, env var, or auto-resolve fallback — unlike forge_jar, forge_dir, etc. Changes: - Add precon_dir field to Config class (models/state.py) - Add --precon-dir CLI arg and PRECON_DIR env var support - Add _resolve_precon_dir() with project-root, parent-dir, and forge_dir-relative detection; creates dir as fallback for downloads - Refactor precon_service.py to use _get_precon_dir() helper that reads CFG.precon_dir at runtime instead of a hardcoded module-level Path - Update routes/precon.py to use _get_precon_dir() for install endpoints - Wire _on_startup to resolve precon_dir for uvicorn-direct launches - Remove unused PRECON_DIR/PRECON_INDEX imports from routes/lab.py - Audit confirms all other CFG path fields already have fallbacks --- lab_api.py | 33 +++++++++++++++++++++++++++++++++ models/state.py | 1 + routes/lab.py | 2 -- routes/precon.py | 7 ++++--- routes/shared.py | 1 + services/precon_service.py | 27 ++++++++++++++++++++++----- 6 files changed, 61 insertions(+), 10 deletions(-) diff --git a/lab_api.py b/lab_api.py index b55d47f..16f289c 100644 --- a/lab_api.py +++ b/lab_api.py @@ -64,6 +64,9 @@ app.include_router(ml_router) async def _on_startup(): """Ensure DB and precon index are ready (supports uvicorn --reload).""" import routes.shared as _shared + # Ensure precon_dir is resolved even when started via uvicorn directly + if not CFG.precon_dir: + CFG.precon_dir = _resolve_precon_dir() _shared.init_collection_db() if not _shared.PRECON_INDEX: _shared.download_precon_database() @@ -98,6 +101,7 @@ def _parse_args(): p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR", "")) p.add_argument("--forge-decks-dir", default=os.environ.get("FORGE_DECKS_DIR", "")) p.add_argument("--lab-jar", default=os.environ.get("LAB_JAR", "")) + p.add_argument("--precon-dir", default=os.environ.get("PRECON_DIR", "")) p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080"))) p.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", "")) p.add_argument("--pplx-key", default=os.environ.get("PPLX_API_KEY", "")) @@ -225,6 +229,33 @@ def _resolve_lab_jar() -> str: return "" +def _resolve_precon_dir() -> str: + """Auto-detect the precon-decks directory. + + The precon-decks dir lives in the project root and contains downloaded + .dck files plus precon-index.json. We check relative to this file + (lab_api.py) first, then common sibling paths. + """ + project_root = Path(__file__).parent + # 1. Standard location: /precon-decks + candidate = project_root / "precon-decks" + if candidate.is_dir(): + return str(candidate) + # 2. Check parent directory (in case running from a subdirectory) + candidate = project_root.parent / "precon-decks" + if candidate.is_dir(): + return str(candidate) + # 3. Derive from forge_dir if available + if CFG.forge_dir: + candidate = Path(CFG.forge_dir) / "precon-decks" + if candidate.is_dir(): + return str(candidate) + # 4. Fall back: create the directory in the project root so downloads work + fallback = project_root / "precon-decks" + fallback.mkdir(parents=True, exist_ok=True) + return str(fallback) + + def main(): args = _parse_args() setup_logging(logging.DEBUG if args.verbose else logging.INFO) @@ -233,6 +264,7 @@ def main(): CFG.forge_dir = args.forge_dir or _resolve_forge_dir() CFG.forge_decks_dir = args.forge_decks_dir or _resolve_forge_decks_dir() CFG.lab_jar = args.lab_jar or _resolve_lab_jar() + CFG.precon_dir = args.precon_dir or _resolve_precon_dir() CFG.port = args.port CFG.ximilar_api_key = args.ximilar_key CFG.pplx_api_key = args.pplx_key @@ -245,6 +277,7 @@ def main(): log.info(f" Forge JAR: {CFG.forge_jar or 'NOT SET'}") log.info(f" Forge Dir: {CFG.forge_dir or 'NOT SET'}") log.info(f" Decks Dir: {CFG.forge_decks_dir or 'NOT SET'}") + log.info(f" Precon Dir: {CFG.precon_dir or 'NOT SET'}") log.info(f" Lab JAR: {CFG.lab_jar or 'NOT SET'}") log.info(f" Results: {CFG.results_dir}") log.info(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET'}") diff --git a/models/state.py b/models/state.py index febb7fe..2ec8ebd 100644 --- a/models/state.py +++ b/models/state.py @@ -20,6 +20,7 @@ class Config: forge_dir: str = "" forge_decks_dir: str = "" lab_jar: str = "" + precon_dir: str = "" results_dir: str = "results" port: int = 8080 ximilar_api_key: str = "" diff --git a/routes/lab.py b/routes/lab.py index af68557..2daadbe 100644 --- a/routes/lab.py +++ b/routes/lab.py @@ -38,8 +38,6 @@ from routes.shared import ( StartResponse, StatusResponse, AI_PROFILES, - PRECON_DIR, - PRECON_INDEX, build_java_command, run_batch_subprocess, _run_deepseek_batch_thread, diff --git a/routes/precon.py b/routes/precon.py index ab6ec58..8ba9eed 100644 --- a/routes/precon.py +++ b/routes/precon.py @@ -16,7 +16,8 @@ from pathlib import Path from fastapi import APIRouter, HTTPException import routes.shared as _shared # module reference, not value import -from routes.shared import CFG, PRECON_DIR, download_precon_database, log +from routes.shared import CFG, download_precon_database, log +from services.precon_service import _get_precon_dir router = APIRouter(tags=["precon"]) @@ -36,7 +37,7 @@ async def install_precon(req: dict): if not file_name: raise HTTPException(400, "fileName is required") - src = PRECON_DIR / file_name + src = _get_precon_dir() / file_name if not src.exists(): raise HTTPException(404, f"Precon not found: {file_name}") @@ -71,7 +72,7 @@ async def install_precons_batch(req: dict): results = [] for file_name in file_names: - src = PRECON_DIR / file_name + src = _get_precon_dir() / file_name if not src.exists(): results.append({"fileName": file_name, "installed": False, "error": "not found"}) continue diff --git a/routes/shared.py b/routes/shared.py index f80e1c2..52b1aab 100644 --- a/routes/shared.py +++ b/routes/shared.py @@ -186,6 +186,7 @@ from services.import_service import ( from services.precon_service import ( PRECON_DIR, PRECON_INDEX, GITHUB_PRECON_URL, PRECON_CACHE_HOURS, load_precon_index, _sanitize_filename, _deck_to_dck, download_precon_database, + _get_precon_dir, ) # ══════════════════════════════════════════════════════════════ diff --git a/services/precon_service.py b/services/precon_service.py index 1dc686b..38ede0d 100644 --- a/services/precon_service.py +++ b/services/precon_service.py @@ -12,7 +12,9 @@ from urllib.request import urlopen, Request log = logging.getLogger("commander_ai_lab.api") -PRECON_DIR = Path(__file__).parent.parent / "precon-decks" +# Fallback default — used only when CFG.precon_dir has not been resolved yet. +_DEFAULT_PRECON_DIR = Path(__file__).parent.parent / "precon-decks" + PRECON_INDEX: list[dict] = [] GITHUB_PRECON_URL = ( "https://raw.githubusercontent.com/taw/magic-preconstructed-decks-data/" @@ -21,9 +23,23 @@ GITHUB_PRECON_URL = ( PRECON_CACHE_HOURS = 168 +def _get_precon_dir() -> Path: + """Return the active precon-decks directory from CFG, with fallback.""" + from models.state import CFG + if CFG.precon_dir: + return Path(CFG.precon_dir) + return _DEFAULT_PRECON_DIR + + +# Re-export for backwards compat — callers that import PRECON_DIR at module +# level will get the default. Runtime code should call _get_precon_dir(). +PRECON_DIR = _DEFAULT_PRECON_DIR + + def load_precon_index(): global PRECON_INDEX - idx_path = PRECON_DIR / "precon-index.json" + precon_dir = _get_precon_dir() + idx_path = precon_dir / "precon-index.json" if idx_path.exists(): with open(idx_path, "r", encoding="utf-8") as f: PRECON_INDEX = json.load(f) @@ -61,7 +77,8 @@ def _deck_to_dck(deck_data: dict) -> str: def download_precon_database(force: bool = False) -> dict: global PRECON_INDEX - idx_path = PRECON_DIR / "precon-index.json" + precon_dir = _get_precon_dir() + idx_path = precon_dir / "precon-index.json" if not force and idx_path.exists(): try: with open(idx_path, "r", encoding="utf-8") as f: @@ -91,7 +108,7 @@ def download_precon_database(force: bool = False) -> dict: and (d.get('format') or '').lower() == 'commander' ] log.info(f" Precons: Found {len(commander_decks)} Commander precon decks") - PRECON_DIR.mkdir(parents=True, exist_ok=True) + precon_dir.mkdir(parents=True, exist_ok=True) name_counts = Counter(_sanitize_filename(d['name']) for d in commander_decks) dup_names = {n for n, c in name_counts.items() if c > 1} index = [] @@ -102,7 +119,7 @@ def download_precon_database(force: bool = False) -> dict: sc = (deck.get('set_code') or 'unk').upper() safe_name = f"{safe_name}_{sc}" file_name = f"{safe_name}.dck" - dck_path = PRECON_DIR / file_name + dck_path = precon_dir / file_name commanders = deck.get('commander', []) cmdr_names = [c['name'] for c in commanders] if commanders else [] total_cards = sum(c.get('count', 1) for c in deck.get('cards', [])) + sum(c.get('count', 1) for c in commanders)