안전한 쓰기 계약 (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-Closed —
mode="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 — 요청 보존 등급
값 |
의미 |
|---|---|
|
지원되는 국소 mutation만 허용한다. 미수정 part는 전부 바이트 동일하게 유지돼야 한다. |
|
변경된 part의 재직렬화를 명시적으로 허용한다. |
|
사전 측정에서 달성 가능한 가장 강한 등급을 자동 선택한다. 절대 예외를 던지지 않는다. |
fallback — 보존 등급 미달 시 동작 (mode="patch"에서만 의미 있음)
값 |
의미 |
|---|---|
|
요청한 patch 등급을 만족하지 못하면 출력하지 않고 |
|
명시적으로 허용한 경우에만 rebuild로 강등하고, 그 사실을 영수증의 |
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를 이름 그대로 지시하고,
실행기는:
정적 선검증 — 파일을 열지 않고 스키마·op 어휘·인자 형상을 fail-closed로 검증한다(미지 필드 즉시 거부).
인메모리 체이닝 — 각 op를
output_path없이 바이트→바이트로 연결한다. 개별 op가 게이트 실패에도 파일을 쓰는 동작(publish="always")을 실행기가 쓰기 소유로 차단한다.원자 쓰기 — 전 step 성공 + 최종 open-safety 검증 통과 후 단 한 번의
os.replace로만 output을 쓴다. 그 전 어떤 실패에서도 output과 source는 바이트 불변이다(테스트가 바이트 비교로 증명).
결과 hwpx.plan-report/v1은 step별 hwpx.mutation-report/v1 사영(입력 바이트
스레딩 = 실측 등급)과 원본→최종 집계 사영을 싣는다 — step 합성 밖의 변경은
집계에서 unexpected로 드러난다. 보존 등급 플로어는 v1 어휘(전부 스플라이스)
에서 항상 patch다.
관련 문서
실측 코퍼스 메트릭 — 바이트 보존 497/497(patch 경로) 실측
지원 매트릭스 — 능력별 Parse/Preserve/Edit/Create/Render 등급