"""CLI configuration from YAML file and environment variables.""" import logging import os from pathlib import Path from typing import Any import yaml DEFAULT_CONFIG_PATH = Path.home() / ".upstage-cloud-dev" / "solar-eval-config.yaml" logger = logging.getLogger(__name__) def default_projects_dir() -> Path: """CBS 공용 데이터 홈 아래의 프로젝트 루트 (huggingface 캐시와 같은 방식). - 기본값: ``~/.cache/cbs-data/solar-eval-projects`` - ``CBS_DATA_HOME`` 이 설정되면 데이터 홈 자체를 옮긴다: ``$CBS_DATA_HOME/solar-eval-projects`` - ``XDG_CACHE_HOME`` 이 설정되면 캐시 루트로 존중한다: ``$XDG_CACHE_HOME/cbs-data/solar-eval-projects`` """ data_home = os.environ.get("CBS_DATA_HOME") if data_home: return Path(data_home).expanduser() / "solar-eval-projects" cache_root = os.environ.get("XDG_CACHE_HOME", "~/.cache") return Path(cache_root).expanduser() / "cbs-data" / "solar-eval-projects" def find_repo_root() -> Path | None: """이 패키지가 harness 레포 체크아웃 안에 vendored 되어 있으면 레포 루트를 찾는다. ``SOLAR_EVAL_CONFIG_ROOT`` 환경변수가 있으면 그 경로를 쓴다 (테스트/특수 환경용). 레포 밖 standalone 설치면 None — 이때는 데이터 홈 단일 루트로 동작한다. """ override = os.environ.get("SOLAR_EVAL_CONFIG_ROOT") if override: path = Path(override).expanduser() return path if path.is_dir() else None for parent in Path(__file__).resolve().parents: if (parent / "pnpm-workspace.yaml").is_file(): return parent return None def discover_config_dirs(repo_root: Path | None) -> dict[str, Path]: """레포의 ``accounts/*/*/03-evaluation/project.yaml`` 에서 프로젝트명 → config 디렉토리 맵. hydra/DVC 식 분리의 발견 단계: config(project.yaml·prompts·pipelines)는 git 이 정본, datasets/artifacts 는 데이터 홈이 정본이다. """ if repo_root is None: return {} found: dict[str, Path] = {} for manifest in sorted(repo_root.glob("accounts/*/*/03-evaluation/project.yaml")): try: with open(manifest) as f: doc = yaml.safe_load(f) except Exception as e: logger.warning("Failed to read %s: %s", manifest, e) continue name = doc.get("name") if isinstance(doc, dict) else None if isinstance(name, str) and name: found[name] = manifest.parent return found def _load_yaml_config(config_path: Path | None = None) -> dict[str, Any]: """YAML 설정 파일 로드. 파일 없으면 빈 dict 반환.""" path = config_path or Path(os.environ.get("SOLAR_EVAL_CONFIG", str(DEFAULT_CONFIG_PATH))) if not path.is_file(): return {} try: with open(path) as f: data = yaml.safe_load(f) return data if isinstance(data, dict) else {} except Exception as e: logger.warning("Failed to load config from %s: %s", path, e) return {} class CLIConfig: """Configuration for the Solar Eval CLI. Priority (low → high): 1. Hard-coded defaults (projects_dir: ~/.cache/cbs-data/solar-eval-projects) 2. YAML config file (~/.upstage-cloud-dev/solar-eval-config.yaml) 3. Environment variables (SOLAR_EVAL_URL, CBS_DATA_HOME, SOLAR_EVAL_PROJECTS, etc.) 4. CLI options (--remote, etc.) """ def __init__(self, config_path: Path | None = None) -> None: yaml_cfg = _load_yaml_config(config_path) yaml_remote = yaml_cfg.get("remote_url") or yaml_cfg.get("remote") self.remote_url: str | None = os.environ.get("SOLAR_EVAL_URL", yaml_remote) yaml_timeout = yaml_cfg.get("timeout", 300) self.timeout = int(os.environ.get("SOLAR_EVAL_TIMEOUT", yaml_timeout)) # SOLAR_EVAL_PROJECTS(구체) > CBS_DATA_HOME(데이터 홈) > YAML projects_dir > 캐시 기본값 env_projects = os.environ.get("SOLAR_EVAL_PROJECTS") yaml_projects = yaml_cfg.get("projects_dir") if env_projects: self.projects_dir = Path(env_projects).expanduser() explicit_root = True elif os.environ.get("CBS_DATA_HOME") or not yaml_projects: self.projects_dir = default_projects_dir() explicit_root = False else: self.projects_dir = Path(yaml_projects).expanduser() explicit_root = True # config 오버레이 (hydra/W&B/DVC 패턴): 표준 데이터 홈에서는 레포의 # accounts/*/*/03-evaluation 이 config 정본이다. 명시적으로 지정된 루트 # (SOLAR_EVAL_PROJECTS·YAML projects_dir·--projects-dir)는 과거 아카이브 등 # 자급자족 트리를 가리키는 용도이므로 오버레이하지 않는다. self.repo_root: Path | None = None if explicit_root else find_repo_root() self.config_dirs: dict[str, Path] = discover_config_dirs(self.repo_root) # Load provider API keys from YAML (env vars take precedence) upstage_cfg = yaml_cfg.get("upstage", {}) if upstage_cfg.get("api_key") and not os.environ.get("UPSTAGE_API_KEY"): os.environ["UPSTAGE_API_KEY"] = upstage_cfg["api_key"] openai_cfg = yaml_cfg.get("openai", {}) if openai_cfg.get("api_key") and not os.environ.get("OPENAI_API_KEY"): os.environ["OPENAI_API_KEY"] = openai_cfg["api_key"] @property def is_remote(self) -> bool: return self.remote_url is not None def override_projects_dir(self, path: Path) -> None: """``--projects-dir`` 플래그: 지정한 루트를 자급자족 아카이브로 쓴다 (오버레이 해제).""" self.projects_dir = path self.repo_root = None self.config_dirs = {} def is_repo_managed(self, project: str) -> bool: """이 프로젝트의 config 정본이 레포(accounts)에 있는가.""" return project in self.config_dirs def config_dir(self, project: str) -> Path: """config(project.yaml·prompts·pipelines) 루트 — 레포 관리면 레포 쪽, 아니면 데이터 홈.""" return self.config_dirs.get(project) or self.projects_dir / project def data_dir(self, project: str) -> Path: """data(datasets·artifacts) 루트 — 항상 데이터 홈.""" return self.projects_dir / project def artifacts_dir(self, project: str) -> Path: """Run 산출물 디렉토리 — 항상 데이터 홈에 쓴다.""" return self.data_dir(project) / "artifacts" def prompts_dir(self, project: str) -> Path: """프롬프트 디렉토리 — 레포 관리 프로젝트면 git 쪽이 정본.""" return self.config_dir(project) / "prompts" def pipelines_dir(self, project: str) -> Path: """파이프라인 디렉토리 — 레포 관리 프로젝트면 git 쪽이 정본.""" return self.config_dir(project) / "pipelines" config = CLIConfig()