안전한 쓰기 계약 (Safe Write Contract)

python-hwpx의 모든 대표 저장 경로(save_to_path · save_to_stream · to_bytes)는 요청한 보존 등급을 쓰기 전에 판정하고, 실제로 무엇을 바꿨는지 측정한 영수증을 돌려주는 계약을 따른다. 이 계약의 이름은 hwpx.mutation-report/v1이며, 영수증 객체는 hwpx.mutation_report.MutationReport다.

Python 블록 판정: 실행 분류 ledger가 이 current manual의 모든 Python 블록을 동결합니다. 독립 실행 예제 표시가 없는 블록은 API 표기, 기존 입력 파일, 또는 앞 문맥이 필요한 조각입니다.

핵심 원칙은 두 가지다.

  • Fail-Closedmode="patch" + fallback="error"(기본값)로 요청했는데 미수정 part를 바이트 동일하게 유지할 수 없으면, 아무것도 쓰지 않고 PreservationDowngradeError를 던진다. 사용자 동의 없는 무음 rebuild는 없다.

  • Measured, not asserted — 영수증의 보존 수치는 주장이 아니라, 빌드된 아카이브를 저장 직전 part 페이로드와 비교해 측정한 결과다. “byte-preserving”이라는 한 단어로 뭉개지 않고 세 층으로 분리해 보고한다.

이름 확정(4.0.0). save_to_path · save_to_stream · to_bytes는 목적지별 명시 메서드로 장기 확정됐다 — 개명 재론은 없다. (구 HwpxDocument.save() 호환 래퍼는 4.0.0에서 제거됐다. 4.0.0 마이그레이션 참조.)

파라미터

세 저장 메서드 모두 같은 시그니처를 공유한다.

def save_to_path(path, *, mode="auto", fallback="error", return_report=False): ...
def save_to_stream(stream, *, mode="auto", fallback="error", return_report=False): ...
def to_bytes(*, mode="auto", fallback="error"): ...   # bytes 반환, return_report 없음

mode — 요청 보존 등급

의미

"patch"

지원되는 국소 mutation만 허용한다. 미수정 part는 전부 바이트 동일하게 유지돼야 한다.

"rebuild"

변경된 part의 재직렬화를 명시적으로 허용한다.

"auto" (기본값)

사전 측정에서 달성 가능한 가장 강한 등급을 자동 선택한다. 절대 예외를 던지지 않는다.

fallback — 보존 등급 미달 시 동작 (mode="patch"에서만 의미 있음)

의미

"error" (기본값)

요청한 patch 등급을 만족하지 못하면 출력하지 않고 PreservationDowngradeError를 던진다.

"rebuild"

명시적으로 허용한 경우에만 rebuild로 강등하고, 그 사실을 영수증의 fallbackUsed=true로 남긴다.

mode="auto"는 달성 가능한 등급을 그대로 채택하므로 fallback과 무관하게 예외를 던지지 않는다. fail-closed 강제가 필요하면 mode="patch"를 명시하라.

return_report — 영수증 반환

True이면 저장 경로(또는 스트림)를 반환하는 대신 MutationReport를 돌려준다. to_bytes는 항상 bytes를 반환하므로 이 파라미터가 없다.

사용 예

from hwpx import HwpxDocument
from hwpx.mutation_report import PreservationDowngradeError

doc = HwpxDocument.open("신청서.hwpx")
doc.fill_by_path({"성명 > right": "홍길동"})

# 1) 영수증과 함께 저장 (달성 가능한 최강 등급 자동 선택)
report = doc.save_to_path("신청서-완료.hwpx", return_report=True)
print(report.actual_mode)          # "patch" 또는 "rebuild"
print(report.preservation.untouched_part_payloads.to_dict())
#   → {"verified": 17, "changed": 0}

# 2) patch 등급을 강제 — 미달이면 아무것도 쓰지 않고 예외
try:
    doc.save_to_path("신청서-완료.hwpx", mode="patch", fallback="error")
except PreservationDowngradeError as exc:
    print(exc.offending_parts)     # 바이트 동일성을 깨는 part 목록
    print(exc.suggestion)          # 어떻게 patch 경로로 우회할지 제안

MutationReport

report.to_dict()hwpx.mutation-report/v1 JSON을 그대로 반환한다.

{
  "schemaVersion": "hwpx.mutation-report/v1",
  "ok": true,
  "path": "신청서-완료.hwpx",
  "requestedMode": "auto",
  "actualMode": "patch",
  "fallbackUsed": false,
  "changedParts": [
    {
      "path": "Contents/section0.xml",
      "reason": "dirty-part",
      "ranges": [
        {"start": 14402, "end": 14431, "coordinateSpace": "uncompressed-part-bytes"}
      ]
    }
  ],
  "preservation": {
    "untouchedPartPayloads": {"verified": 17, "changed": 0},
    "untouchedLocalZipRecords": {"verified": 17, "changed": 0},
    "wholePackageIdentical": false
  },
  "verification": {
    "package": "passed",
    "openSafety": "passed",
    "reopen": "passed",
    "visual": "not_performed"
  }
}

필드 의미

  • requestedMode / actualMode — 요청한 등급과 실제 사용한 등급. 강등이 일어났다면 둘이 다르고 fallbackUsed=true가 된다.

  • changedParts[].reason"dirty-part"(에디터가 사전 선언한 변경) 또는 "unexpected"(선언하지 않았는데 바뀐 part). unexpected가 하나라도 있으면 patch 등급이 아니다.

  • changedParts[].ranges — byte-splice 경로에서만 채워지는 변경 스팬. rebuild 등급 part는 페이로드 전체가 변경이므로 null이다. 좌표계는 항상 uncompressed-part-bytes(압축 해제된 part 바이트)로 명시된다 — ZIP 압축 오프셋과 혼동하지 않도록 계약에 고정돼 있다.

보존 3층 (preservation)

“byte-preserving”을 한 단어로 표현하면 fallback 동작과 충돌하므로, 서로 다른 세 보증을 분리해 보고한다.

  • untouchedPartPayloads — 손대지 않은 part의 압축 해제 페이로드 동일성. 이것이 바이트 보존 해자의 핵심 지표다.

  • untouchedLocalZipRecords — 손대지 않은 part의 ZIP local-record 메타데이터 (타임스탬프·압축 방식·플래그 등) 동일성. 내용 파생 필드(CRC·크기)는 제외한다.

  • wholePackageIdentical — 전체 패키지 바이트 동일성. no-op(변경 없음)일 때만 참이 될 수 있다. deflate·producer 차이 때문에 편집이 있으면 절대 보장하지 않는다.

검증 3항목 (verification)

각 항목은 "passed" / "failed" / "not_performed" 세 값만 가진다. 렌더를 돌리지 않았으면 not_performed이지 무음 pass가 아니다(No Silent True).

  • package — 패키지 구조 검증 결과

  • openSafety — 에디터 오픈 안전성 게이트 결과

  • reopen — 저장 직후 재오픈 프로브 결과

  • visual — 실한컴 렌더 비교 결과(오라클이 붙었을 때만 passed/failed)

report.ok는 위 네 항목 중 "failed"가 하나도 없을 때 참이다.

PreservationDowngradeError

mode="patch" + fallback="error"에서 patch 등급을 달성할 수 없을 때, 출력 전에 던져진다(specs/032 §1). 부착 속성:

  • requested_mode — 요청 등급 ("patch")

  • achieved_grade — 실제 달성 가능했던 등급 ("patch" / "rebuild")

  • offending_parts — 바이트 동일성을 깨는 part 이름 튜플

  • suggestion — byte-preserving 프리미티브(hwpx.patch · hwpx.table_patch · hwpx.body_patch)로 우회하거나 fallback="rebuild"를 쓰라는 안내 문자열

편집 계획 실행기와의 관계 (hwpx.plan, 5.6.0+)

다단 편집을 안전-쓰기 계약 위에서 합성하려면 hwpx.plan.apply_edit_plan을 사용한다. 계획의 각 step은 기존 바이트-스플라이스 op를 이름 그대로 지시하고, 실행기는:

  1. 정적 선검증 — 파일을 열지 않고 스키마·op 어휘·인자 형상을 fail-closed로 검증한다(미지 필드 즉시 거부).

  2. 인메모리 체이닝 — 각 op를 output_path 없이 바이트→바이트로 연결한다. 개별 op가 게이트 실패에도 파일을 쓰는 동작(publish="always")을 실행기가 쓰기 소유로 차단한다.

  3. 원자 쓰기 — 전 step 성공 + 최종 open-safety 검증 통과 후 단 한 번의 os.replace로만 output을 쓴다. 그 전 어떤 실패에서도 output과 source는 바이트 불변이다(테스트가 바이트 비교로 증명).

결과 hwpx.plan-report/v1은 step별 hwpx.mutation-report/v1 사영(입력 바이트 스레딩 = 실측 등급)과 원본→최종 집계 사영을 싣는다 — step 합성 밖의 변경은 집계에서 unexpected로 드러난다. 보존 등급 플로어는 v1 어휘(전부 스플라이스) 에서 항상 patch다.

관련 문서