python-hwpx 3.6.0__py3-none-any.whl → 3.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hwpx/__init__.py CHANGED
@@ -85,6 +85,10 @@ from .ingest import (
85
85
  HwpxMarkdownConverter,
86
86
  UnsupportedDocumentFormat,
87
87
  )
88
+ from .mutation_report import (
89
+ MutationReport,
90
+ PreservationDowngradeError,
91
+ )
88
92
  from .patch import (
89
93
  BytePreservingPatchResult,
90
94
  ParagraphTextPatch,
@@ -146,6 +150,8 @@ __all__ = [
146
150
  "ParagraphInfo",
147
151
  "PackageValidationReport",
148
152
  "BytePreservingPatchResult",
153
+ "MutationReport",
154
+ "PreservationDowngradeError",
149
155
  "PlanValidationReport",
150
156
  "ParagraphTextPatch",
151
157
  "PatchApplied",
@@ -5,8 +5,18 @@ from __future__ import annotations
5
5
 
6
6
  import warnings
7
7
  from os import PathLike
8
- from typing import TYPE_CHECKING, Any, BinaryIO, Sequence
9
-
8
+ from typing import TYPE_CHECKING, Any, BinaryIO, Literal, Sequence
9
+
10
+ from ..mutation_report import (
11
+ Fallback,
12
+ Mode,
13
+ MutationReport,
14
+ PreservationDowngradeError,
15
+ PreservationMeasurement,
16
+ VerificationSummary,
17
+ VerificationValue,
18
+ measure_save,
19
+ )
10
20
  from ..opc.package import _UNCHECKED_SAVE_TOKEN
11
21
  from ..quality import QualityPolicy
12
22
  from ..quality.report import OpenSafetyReport
@@ -120,27 +130,178 @@ def _gate_and_write(
120
130
  return report
121
131
 
122
132
 
123
- def save_to_path(doc: "HwpxDocument", path: str | PathLike[str]) -> str | PathLike[str]:
124
- """Persist pending changes to *path* and return the same path."""
133
+ def _build_measured(
134
+ doc: "HwpxDocument", *, reset_dirty: bool
135
+ ) -> tuple[bytes, PreservationMeasurement]:
136
+ """Serialize + open-safety validate exactly like ``_to_bytes_raw`` and measure
137
+ the built archive's preservation against the pre-save part payloads.
138
+
139
+ The pre-save snapshot is captured *before* the ``serialize()`` updates are
140
+ applied so a per-save normalizer touching a part the editor never declared
141
+ dirty surfaces as an ``unexpected`` change (Safe Write Contract §2).
142
+ """
143
+
144
+ package = doc._package
145
+ # Measure against the OPEN-TIME baseline, not the current package state: a
146
+ # low-level ``set_part`` applied before this save already lives in
147
+ # ``_files``, and measuring against it would silently launder that change
148
+ # through a ``mode="patch"`` grade (Safe Write Contract §1, fail-closed).
149
+ source_members = dict(
150
+ getattr(package, "_opened_members", None) or package._files
151
+ )
152
+ source_infos = dict(
153
+ getattr(package, "_opened_zip_infos", None) or package._zip_infos
154
+ )
155
+ updates = doc._root.serialize()
156
+ predeclared = set(updates)
157
+ if updates:
158
+ for part_name, payload in updates.items():
159
+ package.set_part(part_name, payload)
160
+ result = package._save_to_bytes(verify_open_safety=True, mark_clean=False)
161
+ if not isinstance(result, bytes):
162
+ raise TypeError("package.save(None) must return bytes")
163
+ doc._run_open_safety_validation(result)
164
+ if reset_dirty:
165
+ _mark_save_clean(doc)
166
+ measurement = measure_save(source_members, source_infos, result, predeclared)
167
+ return result, measurement
168
+
169
+
170
+ def _resolve_grade(
171
+ mode: Mode,
172
+ fallback: Fallback,
173
+ measurement: PreservationMeasurement,
174
+ ) -> tuple[Literal["patch", "rebuild"], bool]:
175
+ """Return ``(actual_mode, fallback_used)``; raise before any write when a
176
+ requested ``patch`` grade is unmet and ``fallback="error"`` (§1)."""
177
+
178
+ if mode == "patch" and not measurement.patch_grade_ok:
179
+ if fallback == "error":
180
+ raise PreservationDowngradeError(
181
+ requested_mode=mode,
182
+ achieved_grade=measurement.achieved_grade,
183
+ offending_parts=measurement.offending_parts,
184
+ suggestion=(
185
+ "route these edits through a byte-preserving primitive "
186
+ "(hwpx.patch/table_patch/body_patch) or pass fallback='rebuild'."
187
+ ),
188
+ )
189
+ return "rebuild", True
190
+ if mode == "auto":
191
+ return measurement.achieved_grade, False
192
+ if mode == "patch":
193
+ return "patch", False
194
+ return "rebuild", False
195
+
196
+
197
+ def _verification_summary(report: "VisualCompleteReport") -> VerificationSummary:
198
+ """Reflect what the pipeline actually ran; never fabricate a pass (§2)."""
199
+
200
+ package: VerificationValue = "passed" if report.ok else "failed"
201
+ open_safety: VerificationValue = "passed" if report.open_safety.ok else "failed"
202
+ # Open-safety validation (which includes the reopen probe) already ran during
203
+ # serialize and raised on failure, so reaching here means reopen passed.
204
+ reopen: VerificationValue = "passed"
205
+ status = report.visual_complete_status
206
+ visual: VerificationValue = (
207
+ "passed"
208
+ if status == "verified"
209
+ else "failed"
210
+ if status == "failed"
211
+ else "not_performed"
212
+ )
213
+ return VerificationSummary(
214
+ package=package, open_safety=open_safety, reopen=reopen, visual=visual
215
+ )
216
+
217
+
218
+ def _compose_mutation_report(
219
+ *,
220
+ path: str | None,
221
+ requested_mode: Mode,
222
+ actual_mode: Literal["patch", "rebuild"],
223
+ fallback_used: bool,
224
+ measurement: PreservationMeasurement,
225
+ report: "VisualCompleteReport",
226
+ ) -> MutationReport:
227
+ return MutationReport(
228
+ requested_mode=requested_mode,
229
+ actual_mode=actual_mode,
230
+ fallback_used=fallback_used,
231
+ changed_parts=measurement.changed_parts,
232
+ preservation=measurement.preservation,
233
+ verification=_verification_summary(report),
234
+ path=path,
235
+ )
236
+
237
+
238
+ def save_to_path(
239
+ doc: "HwpxDocument",
240
+ path: str | PathLike[str],
241
+ *,
242
+ mode: Mode = "auto",
243
+ fallback: Fallback = "error",
244
+ return_report: bool = False,
245
+ ) -> str | PathLike[str] | MutationReport:
246
+ """Persist pending changes to *path*.
247
+
248
+ Returns *path* by default; ``return_report=True`` returns the
249
+ :class:`~hwpx.mutation_report.MutationReport` receipt instead. ``mode`` and
250
+ ``fallback`` are the Safe Write Contract grade controls — ``mode="patch"``
251
+ with ``fallback="error"`` raises :class:`PreservationDowngradeError` and
252
+ writes nothing when the save cannot keep every untouched part byte-identical.
253
+ """
125
254
 
126
255
  _run_pre_save_validation(doc)
127
- archive_bytes = doc._to_bytes_raw(reset_dirty=False)
128
- _gate_and_write(doc,
129
- archive_bytes, output_path=path, source_label="document.save_to_path"
256
+ archive_bytes, measurement = _build_measured(doc, reset_dirty=False)
257
+ actual_mode, fallback_used = _resolve_grade(mode, fallback, measurement)
258
+ report = _gate_and_write(
259
+ doc, archive_bytes, output_path=path, source_label="document.save_to_path"
130
260
  )
131
261
  _mark_save_clean(doc)
262
+ if return_report:
263
+ return _compose_mutation_report(
264
+ path=str(path),
265
+ requested_mode=mode,
266
+ actual_mode=actual_mode,
267
+ fallback_used=fallback_used,
268
+ measurement=measurement,
269
+ report=report,
270
+ )
132
271
  return path
133
272
 
134
273
 
135
- def save_to_stream(doc: "HwpxDocument", stream: BinaryIO) -> BinaryIO:
136
- """Persist pending changes to *stream* and return the same stream."""
274
+ def save_to_stream(
275
+ doc: "HwpxDocument",
276
+ stream: BinaryIO,
277
+ *,
278
+ mode: Mode = "auto",
279
+ fallback: Fallback = "error",
280
+ return_report: bool = False,
281
+ ) -> BinaryIO | MutationReport:
282
+ """Persist pending changes to *stream*.
283
+
284
+ Returns *stream* by default; ``return_report=True`` returns the
285
+ :class:`~hwpx.mutation_report.MutationReport` receipt instead. See
286
+ :func:`save_to_path` for the ``mode``/``fallback`` grade semantics.
287
+ """
137
288
 
138
289
  _run_pre_save_validation(doc)
139
- archive_bytes = doc._to_bytes_raw(reset_dirty=False)
140
- _gate_and_write(doc,
141
- archive_bytes, output_stream=stream, source_label="document.save_to_stream"
290
+ archive_bytes, measurement = _build_measured(doc, reset_dirty=False)
291
+ actual_mode, fallback_used = _resolve_grade(mode, fallback, measurement)
292
+ report = _gate_and_write(
293
+ doc, archive_bytes, output_stream=stream, source_label="document.save_to_stream"
142
294
  )
143
295
  _mark_save_clean(doc)
296
+ if return_report:
297
+ return _compose_mutation_report(
298
+ path=None,
299
+ requested_mode=mode,
300
+ actual_mode=actual_mode,
301
+ fallback_used=fallback_used,
302
+ measurement=measurement,
303
+ report=report,
304
+ )
144
305
  return stream
145
306
 
146
307
 
@@ -193,11 +354,26 @@ def save_report(
193
354
  return report
194
355
 
195
356
 
196
- def to_bytes(doc: "HwpxDocument") -> bytes:
197
- """Serialize pending changes and return the HWPX archive as bytes."""
357
+ def to_bytes(
358
+ doc: "HwpxDocument",
359
+ *,
360
+ mode: Mode = "auto",
361
+ fallback: Fallback = "error",
362
+ ) -> bytes:
363
+ """Serialize pending changes and return the HWPX archive as bytes.
364
+
365
+ ``mode``/``fallback`` enforce the Safe Write Contract grade the same way as
366
+ :func:`save_to_path`: ``mode="patch"`` with ``fallback="error"`` raises
367
+ :class:`PreservationDowngradeError` before returning when the archive is not
368
+ patch-grade. There is no ``return_report`` — the enforcement is via the typed
369
+ exception, so the byte return stays unchanged.
370
+ """
198
371
 
199
372
  _run_pre_save_validation(doc)
200
- return doc._to_bytes_raw()
373
+ archive_bytes, measurement = _build_measured(doc, reset_dirty=False)
374
+ _resolve_grade(mode, fallback, measurement)
375
+ _mark_save_clean(doc)
376
+ return archive_bytes
201
377
 
202
378
 
203
379
  def _to_bytes_raw(
@@ -239,6 +415,11 @@ def _to_bytes_for_validation(doc: "HwpxDocument") -> bytes:
239
415
  def _mark_save_clean(doc: "HwpxDocument") -> None:
240
416
  doc._root.reset_dirty()
241
417
  doc._package.version_info.mark_clean()
418
+ # The published archive is the new preservation baseline for any further
419
+ # save on this document instance.
420
+ package = doc._package
421
+ package._opened_members = dict(package._files)
422
+ package._opened_zip_infos = dict(package._zip_infos)
242
423
 
243
424
 
244
425
  def save(
hwpx/agent/commands.py CHANGED
@@ -14,9 +14,7 @@ import copy
14
14
  import hashlib
15
15
  import json
16
16
  import re
17
- import zipfile
18
17
  from collections.abc import Callable, Mapping, MutableMapping
19
- from io import BytesIO
20
18
  from pathlib import Path
21
19
  from typing import Any
22
20
  from xml.etree import ElementTree as ET
@@ -24,6 +22,7 @@ from xml.etree import ElementTree as ET
24
22
  from lxml import etree as LET # type: ignore[reportAttributeAccessIssue] # lxml has no complete bundled typing
25
23
 
26
24
  from hwpx.document import HwpxDocument
25
+ from hwpx.mutation_report import member_diff_bytes
27
26
  from hwpx.oxml import HwpxOxmlTable
28
27
  from hwpx.quality import QualityPolicy, SavePipeline
29
28
  from hwpx.tools.package_validator import validate_editor_open_safety
@@ -1172,24 +1171,9 @@ def _quality_policy(value: str | Mapping[str, Any] | None) -> QualityPolicy:
1172
1171
 
1173
1172
 
1174
1173
  def _member_diff(before: bytes, after: bytes) -> dict[str, Any]:
1175
- try:
1176
- with zipfile.ZipFile(BytesIO(before)) as old_zip, zipfile.ZipFile(BytesIO(after)) as new_zip:
1177
- old_names = set(old_zip.namelist())
1178
- new_names = set(new_zip.namelist())
1179
- shared = sorted(old_names & new_names)
1180
- changed = [name for name in shared if old_zip.read(name) != new_zip.read(name)]
1181
- unchanged = len(shared) - len(changed)
1182
- return {
1183
- "ok": True,
1184
- "changedMembers": changed,
1185
- "addedMembers": sorted(new_names - old_names),
1186
- "removedMembers": sorted(old_names - new_names),
1187
- "unchangedMemberCount": unchanged,
1188
- "beforeMemberCount": len(old_names),
1189
- "afterMemberCount": len(new_names),
1190
- }
1191
- except (OSError, zipfile.BadZipFile) as exc:
1192
- return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
1174
+ # Shared with the Safe Write Contract's MutationReport spine: one uncompressed
1175
+ # member comparison, one home for the diff shape (mutation_report.py).
1176
+ return member_diff_bytes(before, after)
1193
1177
 
1194
1178
 
1195
1179
  def _verify_header_story_candidates(
hwpx/agent/model.py CHANGED
@@ -15,6 +15,15 @@ from collections.abc import Mapping, Sequence
15
15
  from dataclasses import dataclass, field
16
16
  from typing import Any
17
17
 
18
+ from ..mutation_report import (
19
+ ChangedPart,
20
+ MutationReport,
21
+ PreservationCounts,
22
+ PreservationSummary,
23
+ verification_from_open_safety,
24
+ visual_value_from_status,
25
+ )
26
+
18
27
  AGENT_NODE_SCHEMA = "hwpx.agent-node/v1"
19
28
  AGENT_COMMAND_SCHEMA = "hwpx.agent-command/v1"
20
29
  AGENT_BATCH_SCHEMA = "hwpx.agent-batch/v1"
@@ -667,6 +676,53 @@ class AgentBatchResult:
667
676
  "error": None if self.error is None else self.error.to_dict(),
668
677
  }
669
678
 
679
+ def as_mutation_report(self) -> MutationReport:
680
+ """Project this transaction result onto ``hwpx.mutation-report/v1`` (specs/032
681
+ §3). Additive — the fields above are untouched.
682
+
683
+ The agent write is a family-A rebuild (``document.to_bytes()``), so
684
+ ``actualMode`` is ``"rebuild"`` and changed parts carry no ranges (a rebuilt
685
+ part is re-serialized whole). Preservation and verification are read from the
686
+ already-measured ``verification_report`` (``bytePreservation`` = the shared
687
+ ``_member_diff``, ``openSafety``, ``realHancom``); layers that report never
688
+ measured — local ZIP records, an absent oracle — stay zero-verified /
689
+ ``not_performed`` rather than being promoted to a pass.
690
+ """
691
+
692
+ report = self.verification_report
693
+ byte = report.get("bytePreservation") or {}
694
+ changed_names = [
695
+ *byte.get("changedMembers", ()),
696
+ *byte.get("addedMembers", ()),
697
+ *byte.get("removedMembers", ()),
698
+ ]
699
+ changed_parts = tuple(
700
+ ChangedPart(path=str(name), reason="dirty-part", ranges=None)
701
+ for name in changed_names
702
+ )
703
+ unchanged = byte.get("unchangedMemberCount")
704
+ preservation = PreservationSummary(
705
+ untouched_part_payloads=PreservationCounts(
706
+ verified=unchanged if isinstance(unchanged, int) else 0, changed=0
707
+ ),
708
+ untouched_local_zip_records=PreservationCounts(verified=0, changed=0),
709
+ whole_package_identical=bool(byte) and not changed_names,
710
+ )
711
+ real_hancom = report.get("realHancom") or {}
712
+ verification = verification_from_open_safety(
713
+ report.get("openSafety"),
714
+ visual=visual_value_from_status(real_hancom.get("status")),
715
+ )
716
+ return MutationReport(
717
+ requested_mode="rebuild",
718
+ actual_mode="rebuild",
719
+ fallback_used=False,
720
+ changed_parts=changed_parts,
721
+ preservation=preservation,
722
+ verification=verification,
723
+ path=self.output_filename,
724
+ )
725
+
670
726
 
671
727
  def agent_contract_manifest() -> dict[str, Any]:
672
728
  """Return the deterministic, inspectable v1 contract manifest."""
hwpx/body_patch.py CHANGED
@@ -35,6 +35,7 @@ from dataclasses import dataclass
35
35
  from pathlib import Path
36
36
  from typing import Any, Mapping, Sequence
37
37
 
38
+ from .mutation_report import MutationReport, project_byte_splice
38
39
  from .patch import (
39
40
  _finalize,
40
41
  _patch_zip_entries,
@@ -113,6 +114,21 @@ class BodyOpsResult:
113
114
  "openSafety": self.open_safety,
114
115
  }
115
116
 
117
+ def as_mutation_report(self, *, source: bytes | str | Path | None = None) -> MutationReport:
118
+ """Project this body-ops result onto ``hwpx.mutation-report/v1`` (specs/032
119
+ §3). Additive — the fields above are untouched. This path never renders,
120
+ so the visual verdict stays ``not_performed``. Pass the original *source*
121
+ for real ranges and a measured preservation summary.
122
+ """
123
+
124
+ return project_byte_splice(
125
+ data=self.data,
126
+ changed_part_names=self.changed_parts,
127
+ byte_identical=self.byte_identical,
128
+ open_safety=self.open_safety,
129
+ source=source,
130
+ )
131
+
116
132
 
117
133
  def _op_replace_text(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
118
134
  find = str(op["find"])
hwpx/document.py CHANGED
@@ -9,7 +9,17 @@ from datetime import datetime
9
9
  import logging
10
10
 
11
11
  from os import PathLike
12
- from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, Mapping, Sequence, cast, overload
12
+ from typing import (
13
+ TYPE_CHECKING,
14
+ Any,
15
+ BinaryIO,
16
+ Iterator,
17
+ Literal,
18
+ Mapping,
19
+ Sequence,
20
+ cast,
21
+ overload,
22
+ )
13
23
 
14
24
 
15
25
  from .oxml import (
@@ -40,6 +50,7 @@ from .opc.package import (
40
50
  HwpxPackage,
41
51
  )
42
52
  from .oxml.namespaces import register_owpml_namespaces
53
+ from .mutation_report import Fallback, Mode, MutationReport
43
54
  from .quality import QualityPolicy, SavePipeline, VisualCompleteReport
44
55
  from .templates import blank_document_bytes
45
56
 
@@ -1682,20 +1693,92 @@ class HwpxDocument:
1682
1693
  )
1683
1694
 
1684
1695
 
1685
- def save_to_path(self, path: str | PathLike[str]) -> str | PathLike[str]:
1686
- """Persist pending changes to *path* and return the same path."""
1696
+ @overload
1697
+ def save_to_path(
1698
+ self,
1699
+ path: str | PathLike[str],
1700
+ *,
1701
+ mode: Mode = ...,
1702
+ fallback: Fallback = ...,
1703
+ return_report: Literal[False] = ...,
1704
+ ) -> str | PathLike[str]: ...
1705
+
1706
+ @overload
1707
+ def save_to_path(
1708
+ self,
1709
+ path: str | PathLike[str],
1710
+ *,
1711
+ mode: Mode = ...,
1712
+ fallback: Fallback = ...,
1713
+ return_report: Literal[True],
1714
+ ) -> MutationReport: ...
1715
+
1716
+ def save_to_path(
1717
+ self,
1718
+ path: str | PathLike[str],
1719
+ *,
1720
+ mode: Mode = "auto",
1721
+ fallback: Fallback = "error",
1722
+ return_report: bool = False,
1723
+ ) -> str | PathLike[str] | MutationReport:
1724
+ """Persist pending changes to *path* and return the same path.
1725
+
1726
+ ``return_report=True`` returns the Safe Write Contract
1727
+ :class:`~hwpx.mutation_report.MutationReport` instead. ``mode="patch"``
1728
+ with ``fallback="error"`` raises
1729
+ :class:`~hwpx.mutation_report.PreservationDowngradeError` and writes
1730
+ nothing when an untouched part would not stay byte-identical.
1731
+ """
1687
1732
 
1688
1733
  return _persistence.save_to_path(
1689
1734
  self,
1690
1735
  path=path,
1736
+ mode=mode,
1737
+ fallback=fallback,
1738
+ return_report=return_report,
1691
1739
  )
1692
1740
 
1693
- def save_to_stream(self, stream: BinaryIO) -> BinaryIO:
1694
- """Persist pending changes to *stream* and return the same stream."""
1741
+ @overload
1742
+ def save_to_stream(
1743
+ self,
1744
+ stream: BinaryIO,
1745
+ *,
1746
+ mode: Mode = ...,
1747
+ fallback: Fallback = ...,
1748
+ return_report: Literal[False] = ...,
1749
+ ) -> BinaryIO: ...
1750
+
1751
+ @overload
1752
+ def save_to_stream(
1753
+ self,
1754
+ stream: BinaryIO,
1755
+ *,
1756
+ mode: Mode = ...,
1757
+ fallback: Fallback = ...,
1758
+ return_report: Literal[True],
1759
+ ) -> MutationReport: ...
1760
+
1761
+ def save_to_stream(
1762
+ self,
1763
+ stream: BinaryIO,
1764
+ *,
1765
+ mode: Mode = "auto",
1766
+ fallback: Fallback = "error",
1767
+ return_report: bool = False,
1768
+ ) -> BinaryIO | MutationReport:
1769
+ """Persist pending changes to *stream* and return the same stream.
1770
+
1771
+ ``return_report=True`` returns the Safe Write Contract
1772
+ :class:`~hwpx.mutation_report.MutationReport` instead. See
1773
+ :meth:`save_to_path` for the ``mode``/``fallback`` grade semantics.
1774
+ """
1695
1775
 
1696
1776
  return _persistence.save_to_stream(
1697
1777
  self,
1698
1778
  stream=stream,
1779
+ mode=mode,
1780
+ fallback=fallback,
1781
+ return_report=return_report,
1699
1782
  )
1700
1783
 
1701
1784
  def save_report(
@@ -1730,10 +1813,21 @@ class HwpxDocument:
1730
1813
  ledger=ledger,
1731
1814
  )
1732
1815
 
1733
- def to_bytes(self) -> bytes:
1734
- """Serialize pending changes and return the HWPX archive as bytes."""
1816
+ def to_bytes(
1817
+ self,
1818
+ *,
1819
+ mode: Mode = "auto",
1820
+ fallback: Fallback = "error",
1821
+ ) -> bytes:
1822
+ """Serialize pending changes and return the HWPX archive as bytes.
1823
+
1824
+ ``mode="patch"`` with ``fallback="error"`` raises
1825
+ :class:`~hwpx.mutation_report.PreservationDowngradeError` before
1826
+ returning when the archive is not patch-grade; the byte return itself is
1827
+ unchanged.
1828
+ """
1735
1829
 
1736
- return _persistence.to_bytes(self)
1830
+ return _persistence.to_bytes(self, mode=mode, fallback=fallback)
1737
1831
 
1738
1832
  def _to_bytes_raw(
1739
1833
  self,