"""IMP-27: Shared catalog.yaml loader (single file-read + mtime cache). Phase Q evolution 중 block_reference, block_selector, renderer 가 각각 templates/ catalog.yaml 을 읽고 mtime 캐시하던 중복을 한 곳으로 통합한다. call-site signature 는 그대로 유지되며, 각 wrapper 는 본 모듈의 결과를 자신이 약속하는 형태(list[dict] / root dict / id→path projection)로 변환만 수행한다. Functions: load_root_catalog() -> dict : raw catalog dict (matches block_selector contract) load_blocks() -> list[dict] : root_catalog.get("blocks", []) projection get_block_by_id(block_id, catalog=None) -> dict | None get_catalog_mtime() -> float : current cached mtime (renderer projection key) """ from __future__ import annotations import logging from pathlib import Path import yaml logger = logging.getLogger(__name__) CATALOG_PATH = Path(__file__).parent.parent / "templates" / "catalog.yaml" _catalog_cache: dict | None = None _catalog_mtime: float = 0.0 def load_root_catalog() -> dict: """Load templates/catalog.yaml as root dict, with mtime caching. Missing file → logs warning and returns ``{"blocks": []}`` (matches the pre-IMP-27 behavior of block_selector.load_catalog and renderer._load_catalog_map). """ global _catalog_cache, _catalog_mtime if not CATALOG_PATH.exists(): logger.warning(f"catalog.yaml 미발견: {CATALOG_PATH}") return {"blocks": []} current_mtime = CATALOG_PATH.stat().st_mtime if _catalog_cache is not None and current_mtime == _catalog_mtime: return _catalog_cache with open(CATALOG_PATH, encoding="utf-8") as f: _catalog_cache = yaml.safe_load(f) _catalog_mtime = current_mtime block_count = len((_catalog_cache or {}).get("blocks", [])) logger.info(f"[catalog] load: {block_count} blocks") return _catalog_cache def load_blocks() -> list[dict]: """Return blocks list (= root_catalog.get('blocks', [])).""" return load_root_catalog().get("blocks", []) def get_block_by_id(block_id: str, catalog: dict | None = None) -> dict | None: """Locate a block entry by id. ``catalog=None`` → uses shared loader. caller-supplied catalog dict is accepted as-is so the existing block_selector contract (catalog-injected) keeps working unchanged. """ if catalog is None: catalog = load_root_catalog() for block in catalog.get("blocks", []): if block.get("id") == block_id: return block return None def get_catalog_mtime() -> float: """Current cached mtime (renderer projection caches key off this).""" return _catalog_mtime