Files
Aislo/M01_MasterData/M01_MasterData_Store.py
T
eomsangdon 26ceeaf9b0 feat(M01): 요소 찾기에 기계 구분·상세구분 싣기 · 소요량/계수 로직키 눌러 로직 탭 연결
_brief 응답에 구분·상세구분 추가(테스트 컨테이너 기계 후보 거름용)
표 상세화면 용도의 로직키를 누르면 로직 탭으로 전환 + 키 클립보드 복사(찾기용)
로직 목록을 직접 열지는 못함(로직 부분은 sub1 담당) — _화면_계약.md 한 줄 추가
2026-09-20 23:07:45 +09:00

693 lines
27 KiB
Python

"""M01 마스터 데이터 파일 저장소 — `resources/master_data/` 첫 층 JSON 읽기·검사·쓰기.
계약: `resources/master_data/_화면_계약.md` · 파일 모양: `_틀.md`
검사·계산 엔진: `scripts/check_master.py`.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
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_keys as mk # noqa: E402
FOLDER: Path = cm.MASTER # 시험은 사본 폴더로 바꿈
_CHAPTER = re.compile(r"_(\d\d)장_(.+)\.json$") # 「_03장_토공사.json」
# ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로
_LOCK = threading.Lock()
class StoreError(Exception):
def __init__(self, status: int, detail):
super().__init__(detail)
self.status, self.detail = status, detail
# ── 읽기 ──────────────────────────────────────────────────────────────
def _path(file: str) -> Path:
path = FOLDER / file
if "/" in file or "\\" in file or file.startswith("_") or not path.is_file():
raise StoreError(404, f"없는 파일 「{file}」")
return path
def version_of(raw: bytes) -> str:
return hashlib.sha256(raw).hexdigest()[:16]
def read(file: str) -> tuple[dict, str]:
raw = _path(file).read_bytes()
return json.loads(raw, parse_float=Decimal, parse_int=Decimal), version_of(raw)
def items_key(data: dict) -> str:
return "표" if data.get("그룹") in cm.mf.TABLE_GROUPS else "줄"
def chapter_of(file: str, data: dict | None = None) -> str:
"""「공통 03장 토공사」 · 「13장 구조물」 — 부문은 파일 머리."""
m = _CHAPTER.search(file)
if not m:
return ""
division = (data or {}).get("부문") or ""
return f"{division} {m.group(1)}{m.group(2).replace('_', ' ')}".strip()
def _hit(row: dict, q: str) -> bool:
q = q.lower()
return not q or any(
q in str(row.get(k, "")).lower() for k in ("키", "원문번호", "이름", "옛이름")
)
def _book(data: dict) -> str | None:
"""머리 원문 이름 — 한 테이블 파일(기계)은 머리 「원문」 이 갈래 묶음이라 이름이 묶음 안에."""
return data.get("원문") if isinstance(data.get("원문"), str) else None
def groups() -> list[dict]:
out = {g: {"group": g, "files": 0, "rows": 0} for g in cm.mf.GROUPS}
for data in cm.load(folder=FOLDER).values():
slot = out.get(data.get("그룹"))
if slot:
slot["files"] += 1
slot["rows"] += len(data.get(items_key(data)) or [])
return list(out.values())
def files_of(group: str) -> list[dict]:
if group not in cm.mf.GROUPS:
raise StoreError(404, f"없는 그룹 「{group}」")
out = []
for path in [*FOLDER.glob(f"{group}.json"), *FOLDER.glob(f"{group}_*.json")]:
data, version = read(path.name)
out.append(
{
"file": path.name,
"book": _book(data),
"chapter": chapter_of(path.name, data),
"edition": data.get("판"),
"rows": len(data.get(items_key(data)) or []),
"version": version,
"order": data.get("차례") or 0,
}
)
return sorted(out, key=lambda f: (f["order"], f["file"]))
_REF_KEY = re.compile(r"^[A-Z]{2}\d{6}$")
_REF_FILE = {
"LB": "인력.json",
"MT": "재료_자재품목.json",
"MP": "재료_품셈재료.json",
"EQ": "기계.json",
}
def _ref_keys(row: dict) -> list[str]:
"""줄이 가리키는 키 — 준용 · 연결."""
put = (row.get("준용"), row.get("연결"))
return [v for v in put if isinstance(v, str) and _REF_KEY.match(v)]
def refs_of(rows_: list[dict]) -> dict[str, str]:
"""참조 칸에 키 옆에 보일 이름 — {키: 「이름 규격」}."""
want = {k for r in rows_ for k in _ref_keys(r)}
out: dict[str, str] = {}
for file in {_REF_FILE[k[:2]] for k in want if k[:2] in _REF_FILE}:
for r in read(file)[0].get("줄") or []:
if r.get("키") in want:
out[r["키"]] = f"{r.get('이름') or ''} {r.get('규격') or ''}".strip()
return out
_STATES = ("공표", "산정", "미공표", "미확보", "추정") # 인력 「상태」 거름 차례
def subs(file: str = "", group: str = "") -> dict:
"""하위 거름 목록 — 한 테이블 파일은 머리 묶음(인력 · 기계 모두 「구분」 → 「상세구분」 ·
기계는 「원문」(건설품셈 · 산림품셈)도 머리에 있으나 「구분」 이 우선) ·
장 그룹(소요량 · 계수 · 로직)은 장 파일에서 모음(구분 = 원문 + 부문 · 상세구분 = 장 · 차례대로)."""
if group:
if group not in cm.mf.GROUPS:
raise StoreError(404, f"없는 그룹 「{group}」")
found: dict[str, dict] = {}
for info in files_of(group):
name = mk.CHAPTER.match(info["file"])
if not name:
continue
data = read(info["file"])[0]
kind = " ".join(x for x in (data.get("원문"), data.get("부문")) if x)
slot = found.setdefault(kind, {"name": kind, "book": data.get("원문"), "details": []})
slot["details"].append(mk.detail_of(info["file"]))
return {"file": "", "version": "", "slot": "구분", "subs": list(found.values())}
data, version = read(file)
slot = next((s for s in cm.MERGED.values() if isinstance(data.get(s), dict)), "")
lines = data.get(items_key(data)) or []
has_state = any("상태" in r for r in lines)
def details_of(name: str, head: dict) -> list:
"""머리에 상세구분이 없으면 줄에서 모음(재료_유가전력 — 지역 · 계약종별)."""
if head.get("상세구분"):
return head["상세구분"]
return list(
dict.fromkeys(
r["상세구분"] for r in lines if r.get("구분") == name and r.get("상세구분")
)
)
return {
"file": file,
"version": version,
"slot": slot,
"states": list(_STATES) if has_state else [],
"subs": [
{"name": name, "book": head.get("원문"), "details": details_of(name, head)}
for name, head in (data.get(slot) or {}).items()
],
}
def rows(
file: str,
page: int,
size: int,
q: str,
unlinked: bool = False,
sub: str = "",
detail: str = "",
state: str = "",
) -> dict:
data, version = read(file)
hits = [r for r in data.get(items_key(data)) or [] if _hit(r, q)]
if sub: # 인력·기계 구분(우선) · 기계 원문(책)도 받음
hits = [r for r in hits if sub in {r.get(s) for s in cm.MERGED.values()}]
if detail: # 인력 상세구분 · 기계 상세구분(기종)
hits = [r for r in hits if r.get("상세구분") == detail]
if state: # 인력 상태
hits = [r for r in hits if r.get("상태") == state]
if unlinked: # 품셈재료 — 아직 못 이은 줄
hits = [r for r in hits if not r.get("연결")]
page, size = max(page, 1), min(max(size, 1), 500)
shown = hits[(page - 1) * size : page * size]
return {
"file": file,
"version": version,
"total": len(hits),
"page": page,
"size": size,
"rows": shown,
"refs": refs_of(shown),
}
_TABLE_HEADS = (
"키",
"원문번호",
"구분",
"상세구분",
"이름",
"기준",
"출처",
"비고",
"조건",
"값칸",
)
def tables(
file: str = "",
group: str = "",
sub: str = "",
detail: str = "",
q: str = "",
page: int = 1,
size: int = 30,
usage: str = "",
) -> dict:
"""표 목록 — 파일 하나 또는 그룹 전체(구분 · 상세구분 · 용도 대상 · 찾기로 거름 · 장 차례대로 · 쪽 나눔).
`usage` = 용도.대상(인력 · 기계 · 재료 · 할증 · 환산 · 작업량 조건) 하나."""
if file:
names = [file]
elif group in cm.mf.TABLE_GROUPS:
names = [f["file"] for f in files_of(group)]
else:
raise StoreError(404, f"없는 표형 그룹 「{group}」")
hits, version = [], ""
for name in names:
data, version = read(name)
if items_key(data) != "표":
raise StoreError(400, f"표형 파일 아님 「{name}」")
for t in data["표"]:
if sub and t.get("구분") != sub:
continue
if detail and t.get("상세구분") != detail:
continue
if usage and usage not in ((t.get("용도") or {}).get("대상") or []):
continue
if _hit(t, q):
hits.append(
{
"file": name,
**{k: t.get(k) for k in _TABLE_HEADS},
"count": len(t.get("줄") or []),
}
)
page, size = max(page, 1), min(max(size, 1), 500)
return {
"file": file,
"version": version if file else "",
"total": len(hits),
"page": page,
"size": size,
"tables": hits[(page - 1) * size : page * size],
}
def _one(data: dict, key: str) -> dict:
for row in data.get(items_key(data)) or []:
if str(row.get("키")) == key:
return row
raise StoreError(404, f"없는 키 「{key}」")
def table(file: str, key: str) -> dict:
data, version = read(file)
return {"file": file, "version": version, "table": _one(data, key)}
def _logic_files(files: dict[str, dict]):
for name, data in files.items():
if data.get("그룹") == "로직":
yield name, data
def logics(sub: str, detail: str, q: str, blocked: int | None) -> list[dict]:
"""로직 목록 — 구분 · 상세구분 · 찾기로 거름(장 차례대로 · 파일을 가로지름)."""
files = cm.load(folder=FOLDER)
whole = cm.mf.Master(files)
out = []
for name, data in sorted(_logic_files(files), key=lambda f: f[1].get("차례") or 0):
for row in data.get("줄") or []:
if (sub and row.get("구분") != sub) or (detail and row.get("상세구분") != detail):
continue
if not _hit(row, q):
continue
reasons = cm.mf.check_logic(whole, row)[0]
if blocked is not None and bool(reasons) != bool(blocked):
continue
out.append(
{
"file": name,
**{
k: row.get(k)
for k in ("키", "원문번호", "구분", "상세구분", "이름", "결과단위", "출처")
},
"blocked": bool(reasons),
"reasons": reasons,
}
)
return out
def logic(key: str) -> dict:
"""로직 하나 — 키는 전체에서 하나."""
files = cm.load(folder=FOLDER)
for name, data in _logic_files(files):
for row in data.get("줄") or []:
if str(row.get("키")) == key:
whole = cm.mf.Master(files)
reasons = cm.mf.check_logic(whole, row)[0]
_, version = read(name)
return {
"file": name,
"version": version,
"logic": row,
"blocked": bool(reasons),
"reasons": reasons,
"prices": _prices(whole, row),
}
raise StoreError(404, f"없는 로직 「{key}」")
def _brief(ref: str, row: dict) -> dict:
return {
"ref": ref,
**{
k: row.get(k)
for k in ("원문번호", "구분", "상세구분", "이름", "규격", "단위", "값", "값칸")
},
}
def _pick_brief(whole, cond: dict) -> dict | None:
"""고르기 조건 줄의 단가 미리보기 — 후보 수와 시험 계산이 쓸 줄(값 열 다섯 포함)."""
try:
keys = cm.mf.candidates(whole, cond, {})
brief = _market_brief(whole.get(cm.mf.pick(whole, cond, {})))
except (cm.mf.FormulaError, KeyError, TypeError):
return None
return {**brief, "조건": cond, "후보": len(keys)}
def _prices(whole, row: dict) -> dict:
"""호표 요소 → 요소 줄 요약(단가 자동 칸) · `{이름}` 낀 원문번호·하위 로직은 계산 때 정해짐.
품셈재료는 연결을 따라간 낮은 값과 출처 · 자재지역 후보는 계산 때 정해짐(값 None).
재료 고르기 조건 줄은 열쇠가 조건 글(`구분|상세구분|규격|대표`) · 값 = 시험 계산이 쓸 줄."""
out = {}
for item in row.get("호표") or []:
if isinstance(item.get("요소"), dict):
out[cm.mf.cond_text(item["요소"])] = _pick_brief(whole, item["요소"])
continue
ref = str(item.get("요소", ""))
if item.get("종류") != "로직" and "{" not in ref:
try:
out[ref] = _brief(ref, whole.get(ref))
except cm.mf.FormulaError:
out[ref] = None
continue
if ref[:2] == cm.mf.LINKED:
try:
value, source = cm.mf.element(whole, ref, {})
except (cm.mf.FormulaError, KeyError, TypeError):
value, source = None, None
out[ref].update({"값": value, "출처": source})
return out
def elements(group: str, q: str, limit: int) -> dict:
"""요소 찾기 창 — 그룹 전체에서 키·원문번호·이름 찾기 · `ref` 는 식에 그대로 넣는 키."""
if group not in cm.mf.GROUPS:
raise StoreError(404, f"없는 그룹 「{group}」")
hits = []
for name, data in cm.load(folder=FOLDER).items():
if data.get("그룹") != group:
continue
for row in data.get(items_key(data)) or []:
if _hit(row, q):
hits.append({**_brief(str(row.get("키")), row), "file": name})
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
_PICK = {"price": "재료_자재품목.json", "job": "인력.json"}
def _squash(text) -> str:
return "".join(str(text or "").lower().split())
def _lowest(row: dict) -> object:
"""자재품목 줄 — 값 열 다섯 가운데 낮은 값(시험 계산용) · 값 칸이 있는 줄은 그 값 그대로."""
if "값" in row:
return row["값"]
nums = [row[s] for s in cm.mf.SLOTS if row.get(s) is not None]
return min(nums, default=None)
def pick(kind: str, q: str, limit: int) -> dict:
"""고르기 창 — `price` 자재품목(관급 줄 포함 · ref = 키) · `job` 값 있는 공표 노임 직종
(미확보·추정 빼고 모든 구분)(ref = 키) · 이름·규격 낱말이 모두 들어간 줄."""
if kind not in _PICK:
raise StoreError(404, f"없는 고르기 「{kind}」")
words = [_squash(w) for w in q.split()]
hits = []
for row in read(_PICK[kind])[0].get("줄") or []:
if kind == "job" and (
row.get("값") is None or row.get("구분") == "미확보" or row.get("상태") == "추정"
):
continue
text = _squash(f"{row.get('이름')} {row.get('규격')} {' '.join(row.get('옛이름') or [])}")
if all(w in text for w in words):
hits.append(
{
"ref": str(row.get("키")),
**{k: row.get(k) for k in ("원문번호", "구분", "이름", "규격", "단위")},
"값": _lowest(row),
"관급": row.get("관급") is not None,
}
)
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
def _market_brief(row: dict) -> dict:
"""후보 한 줄 — 값 열 다섯을 다 주고 `값` 에 시험 계산이 쓸 낮은 값(없으면 null)."""
return {
"ref": str(row.get("키")),
**{k: row.get(k) for k in ("구분", "상세구분", "이름", "규격", "단위")},
"값들": {s: row.get(s) for s in cm.mf.SLOTS},
"값": _lowest(row),
"관급": row.get("관급") is not None,
}
def materials(sub: str, detail: str, spec: str, region: str, limit: int) -> dict:
"""재료 고르기 조건 안 후보 목록 — 화면·테스트 컨테이너가 같이 씀.
`기본` = 시험 계산이 쓸 줄(대표 줄이 없을 때의 첫 줄) · 그 줄을 `items` 맨 앞에 둠."""
whole = cm.master(folder=FOLDER)
cond = {"구분": sub, "상세구분": detail or None, "규격": spec or None}
env = {cm.mf.REGION: region} if region else {}
keys = cm.mf.candidates(whole, cond, env)
first = cm.mf.pick(whole, cond, env) if keys else None
keys = [first] + [k for k in keys if k != first] if first else keys
return {
"total": len(keys),
"기본": first,
"items": [_market_brief(whole.get(k)) for k in keys[: min(max(limit, 1), 200)]],
}
# ── 시험 계산 ──────────────────────────────────────────────────────────
def _swap(files: dict, key: str, row: dict, file: str | None) -> str:
"""저장 전 시험 — 메모리 사본에서 그 로직을 고친 줄로 바꿈(없으면 `file` 에 더함) · 돌려받는 것 = 계산할 키."""
row = _dec(row)
for name, data in _logic_files(files):
items = data.get("줄") or []
for i, old in enumerate(items):
if str(old.get("키")) == key:
items[i] = {**row, "키": key}
return key
if file not in files or files[file].get("그룹") != "로직":
raise StoreError(404, f"없는 로직 파일 「{file}」")
new = mk.table_id("로직", files[file].get("원문")) + "999999" # 아직 번호 안 받은 새 로직
files[file].setdefault("줄", []).append({**row, "키": new})
return new
def calc(key: str, inputs: dict, row: dict | None = None, file: str | None = None):
given = {k: _dec(v) for k, v in inputs.items()}
files = cm.load(folder=FOLDER)
if row is not None:
key = _swap(files, str(row.get("키") or key), row, file)
try:
result = cm.calc(files, key, given)
except (cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return {"ok": False, "reason": str(e) or type(e).__name__}
if "결과" in result:
return {"ok": True, "result": result["결과"], "middle": result["중간"]}
return {
"ok": True,
"lines": result["줄"],
"sums": {k: result[k] for k in cm.mf.BUNDLE},
"middle": result["중간"],
}
# ── 저장 ──────────────────────────────────────────────────────────────
def _dec(v):
"""화면이 보낸 수 → Decimal(파일을 읽은 모양과 같게)."""
if isinstance(v, bool) or v is None:
return v
if isinstance(v, (int, float)):
return Decimal(str(v))
if isinstance(v, list):
return [_dec(x) for x in v]
if isinstance(v, dict):
return {k: _dec(x) for k, x in v.items()}
return v
def _chapter_cols(file: str, data: dict) -> dict:
"""장 파일에 더하는 줄의 갈래 — 구분 = 원문 + 부문 · 상세구분 = 「NN장 장 제목」."""
if not mk.CHAPTER.match(file):
return {}
return {
"구분": " ".join(x for x in (data.get("원문"), data.get("부문")) if x),
"상세구분": mk.detail_of(file),
}
def _follow(row: dict, items: list[dict], file: str) -> dict:
"""인력 줄의 준용 = 그 직종의 값을 다시 읽어 값 칸에 채움(화면이 보낸 값을 믿지 않음)."""
ref = row.get("준용")
if file != "인력.json" or not isinstance(ref, str) or not _REF_KEY.match(ref):
return row
src = next((r for r in items if r.get("키") == ref), None)
if src is None or src.get("값") is None:
return row
return {**row, "값": src["값"]}
def _apply(data: dict, changes: list[dict], file: str, book: dict) -> None:
"""고침 · 더함(키는 대장의 다음 번호 · 원문번호 없으면 빈 글) · 지움 — 키는 바꾸지 않음."""
items = data.setdefault(items_key(data), [])
tid = mk.table_id(data.get("그룹"), _book(data))
for ch in changes:
op, key, row = ch.get("op"), ch.get("key"), _dec(ch.get("row"))
if op not in ("edit", "add", "delete"):
raise StoreError(400, f"모르는 op 「{op}」")
if op != "add":
at = next((i for i, r in enumerate(items) if str(r.get("키")) == key), None)
if at is None:
raise StoreError(404, f"{file} · 없는 키 「{key}」")
if op != "delete" and not isinstance(row, dict):
raise StoreError(400, f"{file} · row 없음")
if op != "delete":
row = _follow(row, items, file)
if op == "add":
number = str(row.get("원문번호") or "")
cols = _chapter_cols(file, data)
drop = ("키", "원문번호", *cols)
rest = {k: v for k, v in row.items() if k not in drop}
key = mk.issue(book, tid, number, file)
# 자재품목처럼 줄에 원문번호를 두지 않는 테이블은 키만 — 번호는 대장에만
head = {"키": key} if tid in mk.NO_NUMBER else {"키": key, "원문번호": number}
items.append({**head, **cols, **rest})
elif op == "edit":
if str(row.get("키")) != key:
raise StoreError(400, f"{file} · 키는 바꾸지 않음 「{key}」")
items[at] = row
else:
del items[at]
def save(batch: list[dict]) -> list[dict]:
with _LOCK:
stale = [p.get("file", "") for p in batch if read(p.get("file", ""))[1] != p.get("version")]
if stale:
raise StoreError(409, {"stale": stale})
before, after = cm.load(folder=FOLDER), cm.load(folder=FOLDER) # after = 고칠 사본
book_path = FOLDER / mk.BOOK.name
book = mk.load_book(book_path)
issued = dict(book["다음"])
names = [part["file"] for part in batch]
for part in batch:
_apply(after[part["file"]], part.get("changes") or [], part["file"], book)
mk.finish(book)
old = set(cm.check_saved(before, names, mk.load_book(book_path)))
new = [x for x in cm.check_saved(after, names, book) if x not in old]
if new:
raise StoreError(422, {"errors": new})
if book["다음"] != issued:
mk.save_book(book, book_path)
out = []
for name in dict.fromkeys(names):
old_text = (FOLDER / name).read_bytes().decode("utf-8")
text = keep_shape(old_text, before[name], after[name]) or dump(after[name])
tmp = FOLDER / f".{name}.tmp"
tmp.write_text(text, encoding="utf-8", newline="\n")
os.replace(tmp, FOLDER / name)
out.append({"file": name, "version": version_of(text.encode("utf-8"))})
return out
# ── 쓰기 모양 ─────────────────────────────────────────────────────────
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) -> str | None:
"""고친 요소만 새로 씀 — 안 바뀐 요소는 옛 글 그대로(한 줄 요소는 한 줄 · 들여쓰기 · 줄바꿈).
머리가 바뀌었거나 옛 글을 못 읽으면 None(통째로 dump)."""
key = items_key(after)
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")