안전한 쓰기 계약 (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"는 달성 가능한 등급을 채택하므로 등급 미달에 따른PreservationDowngradeError를 발생시키지 않는다. I/O 오류나 패키지·안전 검증 실패는 여전히 발생할 수 있다. patch 등급을 강제하려면mode="patch"를 명시하라.
return_report — 영수증 반환
True이면 저장 경로(또는 스트림)를 반환하는 대신 MutationReport를 돌려준다.
to_bytes는 항상 bytes를 반환하므로 이 파라미터가 없다.
사용 예
from hwpx import HwpxDocument
from hwpx.mutation_report import PreservationDowngradeError
doc = HwpxDocument.open("신청서.hwpx")
result = doc.tables.fill_by_path({"성명 > right": "홍길동"})
if result["failed_count"] or result["applied_count"] != 1:
raise ValueError(result["failed"])
# 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
기존 양식의 요청값까지 확인하고 발행하기
저장 성공만으로 과업 완료를 판단하지 않는다. 실행 가능한
편집 예제의
fill_existing_form(source, output, values)는 유일한 라벨의 오른쪽 셀을
채우는 경로다. values는 {"성명": "홍길동"}처럼 라벨과 문자열 값을 받는다.
원본과 출력이 다른 파일인지 확인한다(심볼릭 링크·하드 링크 포함).
요청한 모든 라벨이 유일한 대상 셀로 해석되는지 먼저 확인한다.
expected_values={"성명": "기존 이름"}을 주면 수정 전 값도 전건 대조한다.채움 결과의 실패·적용 건수를 확인하고
patch/error로 임시 사본을 저장한다.임시 사본을 다시 열어 대상 위치와 값 전건을 확인한다.
원본 변경 여부를 확인한 뒤 검증된 사본을 최종 출력으로 원자적으로 옮긴다.
실패 시 기존 출력은 교체되지 않는다. 이는 단일 작업용 예제이며, 동시 편집의
revision·session 제어는 automation workflow를 사용한다. 응답은 요청 반영,
저장 영수증, 수정 part 내부 보존 미검증, 시각 미검증을 분리한다.
긴 값으로 인한 줄바꿈·쪽수 변화는 한컴 재조판 확인이 필요하다. patch만으로
수정한 part 내부의 미수정 영역이나 페이지 배치까지 동일하다고 판단하지 않는다.
실제 기관 양식에는 라벨과 입력칸 사이에 좁은 빈 셀이 있을 수 있다.
right는 바로 다음 셀을 뜻하며 “사람이 의도한 입력칸”을 추측하지 않는다.
예상 기존 값이 어긋나면 지도를 다시 확인하고 doc.tables.fill_by_path의
명시적 경로(예: 작성일자 > right > right)를 선택한다. 이 예제는 그런 경우
다른 셀을 자동으로 선택하지 않는다.
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 차이 때문에 편집이 있으면 절대 보장하지 않는다.
검증 4항목 (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 등급