"""M01 마스터 한 번 읽어 두기 — 목록 길이 요청마다 마스터를 새로 읽지 않게. `M01_MasterData_Store.loaded` · `.blocks` 가 씀(읽기 전용 길만). 자국(파일 시각·크기)이 바뀐 파일만 다시 읽고, 로직 막힘도 미리 세어 둠 — 목록은 검사를 돌리지 않고 세어 둔 값을 씀. 고치는 길(`calc` · `text` · `save`)은 사본을 만지므로 그대로 `cm.load` 로 새로 읽음. 저장 전 검사(`check_saved`)도 여기 — 어느 로직이 어느 요소·표·로직을 보는지 세어 두고 그것으로 저장이 흔드는 줄만 검사함(`narrow`). """ from __future__ import annotations import json import sys import threading from decimal import Decimal from pathlib import Path SCRIPTS = Path(__file__).resolve().parents[1] / "resources/master_data/scripts" sys.path.insert(0, str(SCRIPTS)) import check_master as cm # noqa: E402 import master_material as mm # noqa: E402 (자재품목 테이블ID — 재료 고르기는 그 묶음을 훑음) _FRESH = threading.RLock() _READ: dict[str, tuple[tuple, dict]] = {} # 파일 이름 → (자국, 읽은 내용) _INDEX: dict = {} # 폴더 · 자국 · files · whole _BLOCK: dict[str, tuple[tuple, dict]] = {} # 로직 파일 → (자국, {키: `_check` 가 센 것}) _BASE: dict[str, tuple] = {} # 막힘을 셀 때 바탕이던, 로직 아닌 파일의 자국 _MARKED: dict[str, set] = {} # 우리가 고친 요소·표 파일 → 흔들린 자리(`narrow` 가 고른 것) def _stamps(folder: Path) -> dict[str, tuple]: """첫 층 JSON 자국 — {이름: (시각, 크기)} · 밑줄 파일은 `cm.load` 가 안 읽으므로 뺌.""" out = {} for path in sorted(folder.glob("*.json")): if not path.name.startswith("_"): stat = path.stat() out[path.name] = (stat.st_mtime_ns, stat.st_size) return out def _logic_files(files: dict[str, dict]): for name, data in files.items(): if data.get("그룹") == "로직": yield name, data class _Watch: """`Master.get` 을 감싸 그 로직 줄이 본 요소·표를 적어 둠 — 저장 때 검사를 좁히는 지도.""" def __init__(self, whole): self._whole, self.seen = whole, set() def get(self, ref): self.seen.add(str(ref)) return self._whole.get(ref) def __getattr__(self, name): return getattr(self._whole, name) def __setattr__(self, name, value): # 속에 적어 둠 — 미리 갈라 둔 것을 줄마다 다시 안 짜게 if name in ("_whole", "seen"): super().__setattr__(name, value) else: setattr(self._whole, name, value) def _check(whole, row: dict) -> tuple[list[str], set, set]: """(막힘 까닭, 부르는 로직, 본 요소·표) — 본 것은 키 · 「ID:원문번호」 · 재료 고르기가 있으면 자재품목 묶음 통째(「#MT」 · 후보를 그 안에서 고름).""" watch = _Watch(whole) why, calls = cm.mf.check_logic(watch, row) if any(isinstance(h.get("요소"), dict) for h in row.get("호표") or []): watch.seen.add("#" + mm.MARKET) return why, calls, watch.seen def forget() -> None: """읽어 둔 것을 버림 — 파일을 고친 자리(저장)에서 부름.""" with _FRESH: _READ.clear() _INDEX.clear() _BLOCK.clear() _BASE.clear() _MARKED.clear() def stale(names, marks=()) -> None: """그 파일만 다시 읽게 함 — 저장처럼 스스로 고친 자리에서 부름 (자국은 시각이 같은 틱이면 못 가릴 수 있으므로 고친 쪽이 알려 줌). `marks` = 요소·표에서 흔들린 자리 — 그 자리를 보던 로직 줄만 다시 셈.""" with _FRESH: for name in names: was = _READ.pop(name, (None, {}))[1] _BLOCK.pop(name, None) if was.get("그룹") != "로직": _MARKED.setdefault(name, set()).update(marks) _INDEX.pop("자국", None) def loaded(folder: Path) -> tuple[dict[str, dict], object]: """(파일 묶음, 색인) — **읽기 전용**. 고칠 것이면 `cm.load` 로 새로 읽을 것.""" with _FRESH: if _INDEX.get("폴더") != folder: # 시험은 사본 폴더로 바꿈 forget() _INDEX["폴더"] = folder now = _stamps(folder) for gone in [name for name in _READ if name not in now]: del _READ[gone] for name, stamp in now.items(): if _READ.get(name, (None,))[0] != stamp: raw = (folder / name).read_bytes() _READ[name] = (stamp, json.loads(raw, parse_float=Decimal, parse_int=Decimal)) if _INDEX.get("자국") != now: files = {name: _READ[name][1] for name in now} _INDEX.update(자국=now, files=files, whole=cm.mf.Master(files)) return _INDEX["files"], _INDEX["whole"] def blocks(folder: Path) -> dict[str, list[str]]: """로직 키마다 막힘 까닭 — 미리 세어 둔 값. 요소 파일이 바뀌면 모든 로직을, 로직 파일은 바뀐 그 파일과 그 로직을 부르던 줄만 다시 셈.""" with _FRESH: files, whole = loaded(folder) stamps = _INDEX["자국"] base = {n: s for n, s in stamps.items() if files[n].get("그룹") != "로직"} marks = None # 요소·표에서 흔들린 자리 — None 이면 무엇이 바뀐지 모름(통째로 다시) if _BASE != base: # 인력 · 재료 · 표가 바뀌면 바탕이 달라짐 off = {n for n in set(_BASE) | set(base) if _BASE.get(n) != base.get(n)} marks = set() for name in off: if name not in _MARKED: # 다른 창이 고친 파일 — 어디가 흔들렸는지 모름 marks = None break marks |= _MARKED[name] if marks is None: _BLOCK.clear() _BASE.clear() _BASE.update(base) _MARKED.clear() moved, fresh = set(), set() # 바뀐 로직 파일의 키(옛것 · 새것) names = {name for name, _ in _logic_files(files)} for gone in [name for name in _BLOCK if name not in names]: # 파일째 사라진 것 moved |= set(_BLOCK.pop(gone)[1]) for name, data in _logic_files(files): if _BLOCK.get(name, (None,))[0] == stamps[name]: continue got = {str(r.get("키")): _check(whole, r) for r in data.get("줄") or []} moved |= set(_BLOCK.get(name, (None, {}))[1]) | set(got) _BLOCK[name] = (stamps[name], got) fresh.add(name) for name, data in _logic_files(files): # 그 로직을 부르던 줄 · 바뀐 요소를 보던 줄 if name in fresh: continue got = _BLOCK[name][1] rows = {str(r.get("키")): r for r in data.get("줄") or []} shaken = [ k for k, (_, calls, seen) in got.items() if calls & moved or (marks and seen & marks) ] for key in shaken: got[key] = _check(whole, rows[key]) return {key: why for _, got in _BLOCK.values() for key, (why, *_) in got.items()} def counted(folder: Path) -> dict[str, tuple]: """로직 키마다 (막힘 까닭, 부르는 로직, 본 요소·표) — 미리 세어 둔 것.""" with _FRESH: blocks(folder) return {key: got for _, rows in _BLOCK.values() for key, got in rows.items()} def _rows_of(data: dict) -> list: return data.get("표") or data.get("줄") or [] def narrow(folder: Path, batch: list[dict], before: dict, after: dict): """(검사할 로직 키, 안 보는 줄이 부르는 키, 요소도 고쳤는지) — 저장이 흔드는 줄만. 로직 줄은 고친 줄과 **그 줄을 부르던 줄** · 요소·표 줄은 그 줄을 **보던 로직** (키 · 「ID:원문번호」 · 자재품목은 묶음 통째 — `_Watch` 가 적어 둔 지도). """ counts = counted(folder) calls = {key: got[1] for key, got in counts.items()} touched, gone, marks = set(), set(), set() for part in batch: name = part["file"] was, now = ({str(r.get("키")): r for r in _rows_of(side[name])} for side in (before, after)) keys = (set(now) - set(was)) | { str(c.get("key")) for c in part.get("changes") or [] if c.get("op") != "add" } if after[name].get("그룹") == "로직": touched |= keys gone |= set(was) - set(now) continue for key in keys | (set(was) - set(now)): # 지운 줄도 — 가리키던 자리가 끊김 marks |= {key, "#" + key[:2]} for side in (was, now): number = (side.get(key) or {}).get("원문번호") if number: marks.add(f"{key[:2]}:{number}") only = touched | {key for key, called in calls.items() if called & touched} only |= {key for key, got in counts.items() if got[2] & marks} return only, {key: called for key, called in calls.items() if key not in gone}, marks def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> list[str]: """저장 전 검사(M01) — 고친 파일의 틀 + 로직의 변수 · 키 + 조합. `files` = 폴더 전부(고친 뒤) · `book` = 키 대장 · `narrowed` = `narrow` 가 준 좁힘 (None 이면 전부 검사 — 마스터 전체 검사는 `check_master` 몫). """ whole = cm.mf.Master(files) out = [x for name in changed for x in cm.check_form(name, files[name])] out += cm.check_logics(files, whole, book, narrowed and narrowed[:2]) if narrowed and narrowed[2]: # 요소·표를 고쳤으면 연결 · 준용도 봄(로직 좁힘과 따로) out += cm.check_links(whole) return out + cm.mcb.check_all(files, whole) def warm(folder: Path) -> threading.Thread: """서버가 뜰 때 뒤에서 한 번 읽어 둠 — 켠 뒤 첫 목록이 3초 걸리던 것을 없앰.""" def run() -> None: try: blocks(folder) except Exception: # 마스터가 깨져 있어도 서버 시동을 막지 않음 — 목록에서 다시 드러남 pass thread = threading.Thread(target=run, name="m01-warm", daemon=True) thread.start() return thread