refactor(#27): IMP-27 K5 catalog loader + _get_block_by_id cleanup

Consolidate three duplicated catalog readers and two _get_block_by_id
implementations behind a single shared module (src/catalog.py) that owns
file-read + mtime cache. All caller signatures and return contracts
remain byte-identical.

Units:
- u1 NEW src/catalog.py (76 lines): load_root_catalog / load_blocks /
  get_block_by_id / get_catalog_mtime as the sole file-read +
  mtime-cache owner.
- u2 src/block_reference.py: _load_catalog delegates to load_blocks
  (list[dict] preserved); _get_block_by_id (no-arg) delegates to
  catalog.get_block_by_id. Module-level _catalog_cache removed.
- u3 src/block_selector.py: load_catalog delegates to load_root_catalog
  (root dict preserved); _get_block_by_id (catalog-injected sig
  preserved) delegates to catalog.get_block_by_id. Module-level
  _catalog_cache / _catalog_mtime / CATALOG_PATH removed.
- u4 src/renderer.py: _load_catalog_map and
  _load_catalog_map_with_variants consume catalog.load_blocks; renderer
  projection caches kept local but keyed via
  catalog.get_catalog_mtime(). Per-projection invalidation keys
  (_CATALOG_MAP_MTIME / _CATALOG_VARIANT_MAP_MTIME) introduced. import
  yaml, CATALOG_PATH, legacy _CATALOG_MTIME removed.
- tests NEW tests/test_catalog_shared_loader.py (421 lines, 23 cases):
  shared loader + 3 wrappers covering single file-read, contract
  preservation, signature preservation, shared cache, private state
  absence, mtime invalidation propagation to renderer projections.

Verification:
- pytest tests/test_catalog_shared_loader.py -v: 23/23 PASS in 0.13s.
- pytest tests/ -q --ignore=tests/matching: 365/365 PASS in 38.10s.
- src/fit_verifier.py, src/space_allocator.py, src/pipeline.py and
  templates/catalog.yaml unchanged (git diff empty).

Out of scope:
- catalog.yaml schema/path unchanged.
- Catalog direct-read call sites in fit_verifier / space_allocator /
  pipeline left for a separate follow-up axis.
- Phase Z 22-step runtime, frame_selection, light_edit/restructure
  flows untouched.

Refs: IMP-27 (gitea #27), INSIGHT-MAP §5 K5, PHASE-Q-AUDIT §2.10
This commit is contained in:
2026-05-20 19:31:26 +09:00
parent 2896bb691c
commit 909bf75edc
5 changed files with 550 additions and 101 deletions

View File

@@ -13,86 +13,76 @@ from collections import OrderedDict
from pathlib import Path
from typing import Any
import yaml
from jinja2 import Environment, FileSystemLoader
from src import catalog as _catalog_mod
logger = logging.getLogger(__name__)
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
STATIC_DIR = Path(__file__).parent.parent / "static"
CATALOG_PATH = TEMPLATES_DIR / "catalog.yaml"
# 카테고리 검색 순서
BLOCK_CATEGORIES = ["headers", "cards", "tables", "visuals", "emphasis", "media"]
# catalog.yaml에서 id → template 경로 매핑 로드 (BF-10: mtime 체크로 자동 갱신)
# id → template 경로 매핑 (IMP-27: src.catalog 공유 로더 위임, renderer-local projection cache)
_CATALOG_MAP: dict[str, str] | None = None
_CATALOG_MTIME: float = 0.0
_CATALOG_MAP_MTIME: float = 0.0
# Phase R: variant별 template 경로 캐시 (renderer-local projection)
_CATALOG_VARIANT_MAP: dict[str, str] | None = None
_CATALOG_VARIANT_MAP_MTIME: float = 0.0
def _load_catalog_map() -> dict[str, str]:
"""catalog.yaml에서 블록 id → template 경로 매핑을 로드한다.
"""블록 id → template 경로 projection (IMP-27: src.catalog 공유 로더 위임).
파일 수정시간(mtime)을 확인하여, 변경 시에만 재로드한다.
catalog 파일 읽기와 mtime 캐싱은 ``src.catalog`` 가 단독 소유. 본 함수는
그 결과를 ``id → template`` 형태로 변환한 renderer-local projection 캐시만
유지하며, projection 무효화는 ``src.catalog.get_catalog_mtime()`` 키잉.
"""
global _CATALOG_MAP, _CATALOG_MTIME
global _CATALOG_MAP, _CATALOG_MAP_MTIME
current_mtime = CATALOG_PATH.stat().st_mtime if CATALOG_PATH.exists() else 0.0
blocks = _catalog_mod.load_blocks()
current_mtime = _catalog_mod.get_catalog_mtime()
if _CATALOG_MAP is not None and _CATALOG_MTIME == current_mtime:
return _CATALOG_MAP # 파일 변경 없음 → 캐시 재사용
if _CATALOG_MAP is not None and _CATALOG_MAP_MTIME == current_mtime:
return _CATALOG_MAP
# 변경 감지 또는 첫 로드 → 새로 읽기
_CATALOG_MTIME = current_mtime
_CATALOG_MAP_MTIME = current_mtime
_CATALOG_MAP = {}
if CATALOG_PATH.exists():
try:
with open(CATALOG_PATH, encoding="utf-8") as f:
catalog = yaml.safe_load(f)
for block in catalog.get("blocks", []):
block_id = block.get("id", "")
template = block.get("template", "")
if block_id and template:
_CATALOG_MAP[block_id] = template
logger.info(f"catalog.yaml 로드: {len(_CATALOG_MAP)}개 블록 매핑")
except Exception as e:
logger.warning(f"catalog.yaml 로드 실패: {e}")
else:
logger.warning(f"catalog.yaml 미발견: {CATALOG_PATH}")
for block in blocks:
block_id = block.get("id", "")
template = block.get("template", "")
if block_id and template:
_CATALOG_MAP[block_id] = template
logger.info(f"catalog.yaml 로드: {len(_CATALOG_MAP)}개 블록 매핑")
return _CATALOG_MAP
# Phase R: variant별 template 경로 캐시
_CATALOG_VARIANT_MAP: dict[str, str] | None = None
def _load_catalog_map_with_variants() -> dict[str, str]:
"""catalog.yaml에서 variant별 template 경로 매핑을 로드한다.
"""variant별 template 경로 projection (IMP-27: src.catalog 공유 로더 위임).
키: "block_id--variant_id" → 값: template 경로
키: "block_id--variant_id" → 값: template 경로.
"""
global _CATALOG_VARIANT_MAP
global _CATALOG_VARIANT_MAP, _CATALOG_VARIANT_MAP_MTIME
# _load_catalog_map이 이미 캐시 관리하므로 같은 mtime 사용
_load_catalog_map() # 캐시 갱신 보장
blocks = _catalog_mod.load_blocks()
current_mtime = _catalog_mod.get_catalog_mtime()
if _CATALOG_VARIANT_MAP is not None and _CATALOG_MTIME == (CATALOG_PATH.stat().st_mtime if CATALOG_PATH.exists() else 0.0):
if _CATALOG_VARIANT_MAP is not None and _CATALOG_VARIANT_MAP_MTIME == current_mtime:
return _CATALOG_VARIANT_MAP
_CATALOG_VARIANT_MAP_MTIME = current_mtime
_CATALOG_VARIANT_MAP = {}
if CATALOG_PATH.exists():
try:
with open(CATALOG_PATH, encoding="utf-8") as f:
catalog = yaml.safe_load(f)
for block in catalog.get("blocks", []):
block_id = block.get("id", "")
for variant in block.get("variants", []):
vid = variant.get("id", "default")
vtemplate = variant.get("template", "")
if vid != "default" and vtemplate:
_CATALOG_VARIANT_MAP[f"{block_id}--{vid}"] = vtemplate
except Exception as e:
logger.warning(f"catalog variant 로드 실패: {e}")
for block in blocks:
block_id = block.get("id", "")
for variant in block.get("variants", []):
vid = variant.get("id", "default")
vtemplate = variant.get("template", "")
if vid != "default" and vtemplate:
_CATALOG_VARIANT_MAP[f"{block_id}--{vid}"] = vtemplate
return _CATALOG_VARIANT_MAP