- 인력.json 392 줄이 같은 열 한 벌(키 · 원문번호 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 값 · 출처 · 일시간 · 상태 · 옛이름 · 비고 · 준용) - 건설업 미공표 14 는 4-라 산정값을 값에 · 상태 「산정」 · 근거는 비고 한 줄 · 산정·신뢰도·업종·부문·환산비·후보 등 열두 칸 없앰 - 머리 「구분」 에 원문 · 기관 · 판 · 공표일 · 원천 · 상세구분 등록 — 새 조사는 한 항목 더하고 줄만 붙이면 끝 - 엔진은 준용이 있으면 그 직종 값 · 없으면 값 · check_master 인력 열 검사 · M01 구분·상세구분 거름과 구분 목록 API · 빌더도 새 열 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
544 lines
21 KiB
Python
544 lines
21 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 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": data.get("원문"),
|
|
"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",
|
|
"MN": "재료_나라장터자재.json",
|
|
"MP": "재료_품셈재료.json",
|
|
"EQ": "기계.json",
|
|
}
|
|
|
|
|
|
def _ref_keys(row: dict) -> list[str]:
|
|
"""줄이 가리키는 키 — 준용 · 연결 · 조달 출처(「나라장터:<키>」)."""
|
|
put = (
|
|
row.get("준용"),
|
|
row.get("연결"),
|
|
(row.get("값") or {}).get("조달", {}).get("출처")
|
|
if isinstance(row.get("값"), dict)
|
|
else None,
|
|
)
|
|
out = [v.removeprefix("나라장터:") for v in put if isinstance(v, str)]
|
|
return [k for k in out if _REF_KEY.match(k)]
|
|
|
|
|
|
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
|
|
|
|
|
|
def subs(file: str) -> dict:
|
|
"""하위 거름 목록 — 인력 「구분」 · 기계 「세부분류」 묶음(머리에 등록된 것) · 그 밖은 빈 목록."""
|
|
data, version = read(file)
|
|
slot = next((s for s in ("구분", "세부분류") if isinstance(data.get(s), dict)), "")
|
|
return {
|
|
"file": file,
|
|
"version": version,
|
|
"slot": slot,
|
|
"subs": [
|
|
{"name": name, "book": head.get("원문"), "details": head.get("상세구분") or []}
|
|
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 = "",
|
|
) -> 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("구분"), r.get("세부분류"))]
|
|
if detail: # 인력 상세구분
|
|
hits = [r for r in hits if r.get("상세구분") == detail]
|
|
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),
|
|
}
|
|
|
|
|
|
def tables(file: str, q: str) -> dict:
|
|
data, version = read(file)
|
|
if items_key(data) != "표":
|
|
raise StoreError(400, f"표형 파일 아님 「{file}」")
|
|
heads = ("키", "원문번호", "이름", "기준", "출처", "조건", "값칸")
|
|
return {
|
|
"file": file,
|
|
"version": version,
|
|
"tables": [
|
|
{**{k: t.get(k) for k in heads}, "count": len(t.get("줄") or [])}
|
|
for t in data["표"]
|
|
if _hit(t, q)
|
|
],
|
|
}
|
|
|
|
|
|
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(book: str, chapter: 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):
|
|
if (book and data.get("원문") != book) or (chapter and chapter_of(name, data) != chapter):
|
|
continue
|
|
for row in data.get("줄") or []:
|
|
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,
|
|
"book": data.get("원문"),
|
|
"chapter": chapter_of(name, data),
|
|
**{k: row.get(k) for k in ("키", "원문번호", "이름", "결과단위", "출처")},
|
|
"blocked": bool(reasons),
|
|
"reasons": reasons,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def logic(book: str, key: str) -> dict:
|
|
"""로직 하나 — 키는 전체에서 하나(`book` 은 화면 호환으로만 받음)."""
|
|
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 _prices(whole, row: dict) -> dict:
|
|
"""호표 요소 → 요소 줄 요약(단가 자동 칸) · `{이름}` 낀 원문번호·하위 로직은 계산 때 정해짐.
|
|
품셈재료는 연결을 따라간 낮은 값과 출처 · 자재지역 후보는 계산 때 정해짐(값 None)."""
|
|
out = {}
|
|
for item in row.get("호표") or []:
|
|
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 = {
|
|
"procure": "재료_나라장터자재.json",
|
|
"price": "재료_시중물가.json",
|
|
"job": "인력.json",
|
|
}
|
|
|
|
|
|
def _squash(text) -> str:
|
|
return "".join(str(text or "").lower().split())
|
|
|
|
|
|
def _lowest(value) -> object:
|
|
"""시중물가 줄 — 시중 다섯 칸 · 조달 값 가운데 낮은 값 · 그 밖은 `값` 그대로."""
|
|
if not isinstance(value, dict):
|
|
return value
|
|
nums = [v for v in value["시중"].values() if isinstance(v, (int, float))]
|
|
supply = value["조달"].get("값")
|
|
return min(nums + ([supply] if supply is not None else []), default=None)
|
|
|
|
|
|
def pick(kind: str, q: str, limit: int) -> dict:
|
|
"""고르기 창 — `procure` 나라장터자재(ref = 나라장터:<키>) · `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("구분") == "미확보"):
|
|
continue
|
|
text = _squash(f"{row.get('이름')} {row.get('규격')} {' '.join(row.get('옛이름') or [])}")
|
|
if all(w in text for w in words):
|
|
key = str(row.get("키"))
|
|
hits.append(
|
|
{
|
|
"ref": f"나라장터:{key}" if kind == "procure" else key,
|
|
**{k: row.get(k) for k in ("원문번호", "구분", "이름", "규격", "단위")},
|
|
"값": _lowest(row.get("값")),
|
|
"관급": kind == "price" and row["값"]["조달"].get("값") is not None,
|
|
}
|
|
)
|
|
return {"total": len(hits), "items": hits[: 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(book: str, 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 _apply(data: dict, changes: list[dict], file: str, book: dict) -> None:
|
|
"""고침 · 더함(키는 대장의 다음 번호 · 원문번호 없으면 빈 글) · 지움 — 키는 바꾸지 않음."""
|
|
items = data.setdefault(items_key(data), [])
|
|
tid = mk.table_id(data.get("그룹"), data.get("원문"))
|
|
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 == "add":
|
|
number = str(row.get("원문번호") or "")
|
|
rest = {k: v for k, v in row.items() if k not in ("키", "원문번호")}
|
|
items.append({"키": mk.issue(book, tid, number, file), "원문번호": number, **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")
|