"""M01 저장 글 모양 — 고친 줄만 새로 쓰고 나머지는 옛 글 그대로(`_틀.md` 2장 · 들여쓰기 2칸). `M01_MasterData_Store.save` 가 씀 — 파일을 통째로 다시 쓰면 손으로 맞춘 줄 모양이 사라짐. """ from __future__ import annotations import json import re from decimal import Decimal def _atom(v) -> str: if isinstance(v, Decimal): return str(v) return json.dumps(v, ensure_ascii=False) def _flat(v, tight: bool = False, inner: bool | None = None) -> str: """한 줄 글 — tight = 바깥 묶음을 붙여 씀 · inner = 안쪽 묶음을 붙여 씀(None 이면 바깥과 같음).""" inner = tight if inner is None else inner if isinstance(v, dict): if not v: return "{}" body = ", ".join(f"{_atom(k)}: {_flat(x, inner)}" for k, x in v.items()) return "{" + body + "}" if tight else "{ " + body + " }" if isinstance(v, list): return "[" + ", ".join(_flat(x, inner) for x in v) + "]" return _atom(v) def _skip(text: str, pos: int) -> int: while text[pos] in " \t\r\n": pos += 1 return pos def keep_shape(old_text: str, before: dict, after: dict, key: str) -> str | None: """고친 요소만 새로 씀 — 안 바뀐 요소는 옛 글 그대로(한 줄 요소는 한 줄 · 들여쓰기 · 줄바꿈). 머리가 바뀌었거나 옛 글을 못 읽으면 None(통째로 dump).""" head = lambda d: {k: v for k, v in d.items() if k != key} # noqa: E731 old_items, new_items = before.get(key) or [], after.get(key) or [] if head(before) != head(after) or not old_items or not new_items: return None for m in re.finditer(rf'"{key}"\s*:\s*\[', old_text): # 머리 속 같은 이름 칸은 건너뜀 spans, pos, dec = [], m.end(), json.JSONDecoder() try: while old_text[pos := _skip(old_text, pos)] != "]": _, end = dec.raw_decode(old_text, pos) spans.append((pos, end)) pos = _skip(old_text, end) pos += old_text[pos] == "," except (IndexError, ValueError): return None if len(spans) == len(old_items): break else: return None nl = "\r\n" if "\r\n" in old_text else "\n" lead = old_text[m.end() : spans[0][0]] indent = len(lead) - len(lead.rstrip(" ")) olds = {str(r.get("키")): (r, old_text[s:e]) for r, (s, e) in zip(old_items, spans)} shape, parts = old_text[spans[0][0] : spans[0][1]], [] for row in new_items: was, text = olds.get(str(row.get("키")), (None, None)) shape = text or shape if was == row: parts.append(text) elif "\n" not in shape: tight = shape.startswith('{"') parts.append(_flat(row, tight, tight or ': {"' in shape)) else: parts.append(dump(row, indent, expand=True).replace("\n", nl)) out = old_text[: m.end()] + lead + ("," + lead).join(parts) + old_text[spans[-1][1] :] same = json.loads(out, parse_float=Decimal, parse_int=Decimal) == after return out if same else None def dump(v, indent: int = 0, width: int = 160, expand: bool = False) -> str: """UTF-8 · 들여쓰기 2칸 · 한 줄에 들어가는 묶음은 한 줄로(expand 면 첫 층은 펼침).""" flat = _flat(v) if ( not isinstance(v, (dict, list)) or not v or (not expand and indent and len(flat) + indent <= width) ): return flat + ("" if indent else "\n") pad = " " * (indent + 2) if isinstance(v, dict): body = [f"{pad}{_atom(k)}: {dump(x, indent + 2, width)}" for k, x in v.items()] text = "{\n" + ",\n".join(body) + "\n" + " " * indent + "}" else: text = ( "[\n" + ",\n".join(pad + dump(x, indent + 2, width) for x in v) + "\n" + " " * indent + "]" ) return text + ("" if indent else "\n")