python-hwpx-automation 6.0.3__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.
Files changed (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,604 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import hashlib
7
+ import logging
8
+ import os
9
+ import re
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Dict, Optional, Sequence, cast
13
+ from uuid import uuid4
14
+
15
+ from .. import quality as quality_contract
16
+ from ..storage import LocalDocumentStorage, build_hwpx_verification_report
17
+ from ..workspace import (
18
+ WorkspaceMissingParentGuard,
19
+ WorkspaceOutputGuard,
20
+ WorkspacePathError,
21
+ )
22
+ from ..core.transactions import (
23
+ BackupReport,
24
+ backup_path_for,
25
+ rotate_and_backup,
26
+ rotated_backup_path,
27
+ semantic_diff,
28
+ )
29
+ from ..upstream import (
30
+ HwpxDocument,
31
+ new_document,
32
+ )
33
+
34
+ from .context import DocumentContext
35
+
36
+ logger = logging.getLogger("hwpx_automation.hwpx_ops")
37
+
38
+
39
+ @dataclasses.dataclass(frozen=True, slots=True)
40
+ class _ExactSidecarMutation:
41
+ """One identity-bound sidecar publication and its exact prestate."""
42
+
43
+ before_guard: WorkspaceOutputGuard
44
+ before_bytes: bytes | None
45
+ publication: WorkspaceOutputGuard
46
+
47
+
48
+ @dataclasses.dataclass(frozen=True, slots=True)
49
+ class _ExactRecoveryPublication:
50
+ """One randomly named exact recovery publication and its immutable bytes."""
51
+
52
+ base_path: Path
53
+ data: bytes
54
+ mode: int | None
55
+ marker: str
56
+ publication: WorkspaceOutputGuard
57
+
58
+
59
+ @dataclasses.dataclass(frozen=True, slots=True)
60
+ class _ExactBackupResult:
61
+ """Backup receipt plus reversible identity-bound sidecar publications."""
62
+
63
+ report: BackupReport
64
+ mutations: tuple[_ExactSidecarMutation, ...] = ()
65
+ recoveries: tuple[_ExactRecoveryPublication, ...] = ()
66
+
67
+
68
+ class SavePolicy:
69
+ def __init__(self, context: DocumentContext) -> None:
70
+ self._context = context
71
+
72
+ def _ensure_backup(self, path: Path) -> Optional[Path]:
73
+ return self._context.storage.ensure_backup(path)
74
+
75
+ def _maybe_backup(self, path: Path) -> None:
76
+ self._context.storage.maybe_backup(path)
77
+
78
+ def _save_document(
79
+ self, document: HwpxDocument, target: Path, *, quality: Any = None
80
+ ) -> Dict[str, Any]:
81
+ try:
82
+ return self._context.storage.save_document(
83
+ document, target, quality=quality
84
+ )
85
+ except quality_contract.CapabilitySkewError as exc:
86
+ # Fail closed on core/automation/plugin skew (plan §2 Phase F).
87
+ raise self._context._new_error(
88
+ "CAPABILITY_SKEW",
89
+ f"capability handshake skew; writes are blocked: {exc}",
90
+ details={"capability": exc.state, "path": str(target)},
91
+ ) from exc
92
+ except quality_contract.QualityGateError as exc:
93
+ # visual_complete gate failed under an elevated policy → ok=false with
94
+ # a structured, retry-able error the model can act on.
95
+ raise self._context._new_error(
96
+ exc.code,
97
+ f"visual_complete gate failed: {exc}",
98
+ details={
99
+ "path": str(target),
100
+ "visualComplete": exc.block,
101
+ "suggestedRetry": exc.block.get("suggestedRetry"),
102
+ },
103
+ ) from exc
104
+ except PermissionError as exc:
105
+ raise self._context._new_error(
106
+ "PERMISSION_DENIED",
107
+ f"문서 저장 권한이 없습니다: {target}",
108
+ details={"path": str(target)},
109
+ ) from exc
110
+ except Exception as exc: # pragma: no cover - delegated to backend
111
+ raise self._context._new_error(
112
+ "DOCUMENT_SAVE_FAILED",
113
+ f"failed to save '{target}': {exc}",
114
+ details={"path": str(target)},
115
+ ) from exc
116
+
117
+ def _save_transaction_document(
118
+ self, document: HwpxDocument, target: Path, *, quality: Any = None
119
+ ) -> Dict[str, Any]:
120
+ backup = rotate_and_backup(target)
121
+ verification = self._save_document(document, target, quality=quality)
122
+ if not isinstance(verification, dict):
123
+ verification = build_hwpx_verification_report(target)
124
+ verification["filePath"] = str(target)
125
+ verification["backup"] = backup.to_dict()
126
+ if backup.backup_path is not None:
127
+ try:
128
+ verification["semanticDiff"] = semantic_diff(
129
+ backup.backup_path,
130
+ target,
131
+ )
132
+ except Exception as exc: # pragma: no cover - diagnostic fallback
133
+ verification["semanticDiff"] = {
134
+ "schemaVersion": "hwpx.semantic-diff.v1",
135
+ "changed": True,
136
+ "summary": f"Semantic diff unavailable: {exc}",
137
+ "items": [],
138
+ "error": str(exc),
139
+ }
140
+ return verification
141
+
142
+ @staticmethod
143
+ def _report_for_bytes(data: bytes, *, file_path: Path) -> Dict[str, Any]:
144
+ return build_hwpx_verification_report(data, file_path=file_path)
145
+
146
+ @staticmethod
147
+ def _semantic_diff_bytes(before: bytes, after: bytes) -> Dict[str, Any]:
148
+ return semantic_diff(before, after)
149
+
150
+ def _capture_exact_sidecar_guard(self, path: Path) -> WorkspaceOutputGuard:
151
+ """Authorize one derived sidecar name without following a final alias."""
152
+
153
+ if not isinstance(
154
+ self._context.storage, LocalDocumentStorage
155
+ ): # pragma: no cover
156
+ raise TypeError("exact sidecar guards require local storage")
157
+ lexical_path = path.parent.resolve(strict=True) / path.name
158
+ guard = self._context.storage.capture_output_guard(path)
159
+ if guard.path != lexical_path:
160
+ raise WorkspacePathError(
161
+ "derived backup path must not be a symlink or alias",
162
+ code="WORKSPACE_PATH_INVALID",
163
+ reason="output_target_alias",
164
+ )
165
+ return guard
166
+
167
+ @staticmethod
168
+ def _absent_publication_guard(
169
+ guard: WorkspaceOutputGuard,
170
+ ) -> WorkspaceOutputGuard:
171
+ """Represent the exact absent state produced by a guarded deletion."""
172
+
173
+ return dataclasses.replace(
174
+ guard,
175
+ target_existed=False,
176
+ target_device=None,
177
+ target_inode=None,
178
+ target_digest=None,
179
+ target_mode=None,
180
+ )
181
+
182
+ def _assert_exact_sidecar_publication(
183
+ self,
184
+ guard: WorkspaceOutputGuard,
185
+ ) -> None:
186
+ """Revalidate either an exact file publication or guarded absence."""
187
+
188
+ if guard.target_existed:
189
+ self._context._local_storage().read_guarded_bytes(guard)
190
+ return
191
+ observed = self._capture_exact_sidecar_guard(guard.path)
192
+ if (
193
+ observed.target_existed
194
+ or observed.path != guard.path
195
+ or observed.root != guard.root
196
+ or observed.root_device != guard.root_device
197
+ or observed.root_inode != guard.root_inode
198
+ or observed.parent_device != guard.parent_device
199
+ or observed.parent_inode != guard.parent_inode
200
+ ):
201
+ raise WorkspacePathError(
202
+ "deleted sidecar changed before completion",
203
+ code="WORKSPACE_PATH_CHANGED",
204
+ reason="output_target_changed",
205
+ )
206
+
207
+ def _publish_exact_recovery(
208
+ self,
209
+ base_path: Path,
210
+ data: bytes,
211
+ *,
212
+ mode: int | None,
213
+ marker: str,
214
+ max_candidates: int = 32,
215
+ ) -> _ExactRecoveryPublication:
216
+ """Publish recovery bytes without overwriting an existing sidecar."""
217
+
218
+ storage = self._context._local_storage()
219
+ safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", base_path.name)[:48]
220
+ name_digest = hashlib.sha256(os.fsencode(base_path.name)).hexdigest()[:12]
221
+ for _ in range(max_candidates):
222
+ candidate = base_path.with_name(
223
+ f".{safe_name}.{marker}.{name_digest}.{uuid4().hex}.recovery"
224
+ )
225
+ try:
226
+ guard = self._capture_exact_sidecar_guard(candidate)
227
+ except WorkspacePathError:
228
+ continue
229
+ if guard.target_existed:
230
+ continue
231
+ try:
232
+ publication = storage.atomic_publish_bytes(
233
+ guard,
234
+ data,
235
+ mode=mode,
236
+ )
237
+ storage.read_guarded_bytes(publication)
238
+ return _ExactRecoveryPublication(
239
+ base_path=base_path,
240
+ data=data,
241
+ mode=mode,
242
+ marker=marker,
243
+ publication=publication,
244
+ )
245
+ except (OSError, RuntimeError):
246
+ # A publish-then-claim race may have installed and then
247
+ # replaced this candidate before the returned token could be
248
+ # verified. Preserve that external winner and retry the same
249
+ # immutable preimage at a fresh unpredictable name.
250
+ continue
251
+ raise RuntimeError(f"no available exact recovery sidecar for {base_path.name}")
252
+
253
+ def _preserve_exact_preimages(
254
+ self,
255
+ preimages: Sequence[tuple[Path, bytes, int | None]],
256
+ *,
257
+ marker: str,
258
+ ) -> tuple[_ExactRecoveryPublication, ...] | None:
259
+ """Preserve every preimage before any destructive mutation begins."""
260
+
261
+ publications: list[_ExactRecoveryPublication] = []
262
+ for path, data, mode in preimages:
263
+ try:
264
+ publications.append(
265
+ self._publish_exact_recovery(
266
+ path,
267
+ data,
268
+ mode=mode,
269
+ marker=marker,
270
+ )
271
+ )
272
+ except (OSError, RuntimeError):
273
+ return None
274
+ return tuple(publications)
275
+
276
+ def _cleanup_exact_recoveries(
277
+ self,
278
+ recoveries: Sequence[_ExactRecoveryPublication],
279
+ ) -> tuple[bool, bool]:
280
+ """Remove proven recoveries, republishing all if any cleanup loses CAS."""
281
+
282
+ storage = self._context._local_storage()
283
+ for recovery in recoveries:
284
+ try:
285
+ storage.read_guarded_bytes(recovery.publication)
286
+ storage.remove_guarded_output(recovery.publication)
287
+ except (FileNotFoundError, OSError, RuntimeError):
288
+ return False, self._republish_exact_recoveries(recoveries)
289
+ return True, True
290
+
291
+ def _republish_exact_recoveries(
292
+ self,
293
+ recoveries: Sequence[_ExactRecoveryPublication],
294
+ ) -> bool:
295
+ """Recreate immutable recovery copies after cleanup or claim loss."""
296
+
297
+ for item in recoveries:
298
+ try:
299
+ self._publish_exact_recovery(
300
+ item.base_path,
301
+ item.data,
302
+ mode=item.mode,
303
+ marker=item.marker,
304
+ )
305
+ except (OSError, RuntimeError):
306
+ return False
307
+ return True
308
+
309
+ def _rotate_and_backup_exact(
310
+ self,
311
+ target: Path,
312
+ *,
313
+ target_guard: WorkspaceOutputGuard | None = None,
314
+ target_bytes: bytes | None = None,
315
+ max_backups: int = 5,
316
+ ) -> _ExactBackupResult:
317
+ """Rotate local sidecars from no-follow guards and an exact preimage."""
318
+
319
+ if not isinstance(self._context.storage, LocalDocumentStorage):
320
+ return _ExactBackupResult(
321
+ rotate_and_backup(target, max_backups=max_backups)
322
+ )
323
+ guard = target_guard or self._context.storage.capture_output_guard(target)
324
+ if not guard.target_existed:
325
+ return _ExactBackupResult(BackupReport(None))
326
+ preimage = (
327
+ target_bytes
328
+ if target_bytes is not None
329
+ else self._context.storage.read_guarded_bytes(guard)
330
+ )
331
+ # Revalidate ownership immediately before mutating any sidecar.
332
+ if self._context.storage.read_guarded_bytes(guard) != preimage:
333
+ raise WorkspacePathError(
334
+ "backup source changed before snapshot",
335
+ code="WORKSPACE_PATH_CHANGED",
336
+ reason="output_target_changed",
337
+ )
338
+ source_report = self._report_for_bytes(preimage, file_path=target)
339
+ if not source_report["openSafety"]["ok"]:
340
+ raise self._context._new_error(
341
+ "BACKUP_SOURCE_OPEN_SAFETY_FAILED",
342
+ "backup source failed open-safety verification",
343
+ )
344
+
345
+ backup = backup_path_for(target)
346
+ paths = [backup] + [
347
+ rotated_backup_path(target, index) for index in range(1, max_backups + 1)
348
+ ]
349
+ guards = [self._capture_exact_sidecar_guard(path) for path in paths]
350
+ states: list[tuple[bytes, int | None] | None] = []
351
+ for slot_guard in guards:
352
+ states.append(
353
+ (
354
+ self._context.storage.read_guarded_bytes(slot_guard),
355
+ slot_guard.target_mode,
356
+ )
357
+ if slot_guard.target_existed
358
+ else None
359
+ )
360
+
361
+ recoveries = self._preserve_exact_preimages(
362
+ [
363
+ (path, state[0], state[1])
364
+ for path, state in zip(paths, states)
365
+ if state is not None
366
+ ],
367
+ marker="rollback-recovery",
368
+ )
369
+ if recoveries is None:
370
+ raise RuntimeError("backup rotation preimages could not be preserved")
371
+ rotated: list[Path] = []
372
+ mutations: list[_ExactSidecarMutation] = []
373
+ try:
374
+ for index in range(max_backups, 0, -1):
375
+ destination_guard = guards[index]
376
+ desired = states[index - 1]
377
+ if desired is None:
378
+ if not destination_guard.target_existed:
379
+ continue
380
+ publication = self._absent_publication_guard(destination_guard)
381
+ mutations.append(
382
+ _ExactSidecarMutation(
383
+ before_guard=destination_guard,
384
+ before_bytes=cast(tuple[bytes, int | None], states[index])[0],
385
+ publication=publication,
386
+ )
387
+ )
388
+ self._context.storage.remove_guarded_output(destination_guard)
389
+ self._assert_exact_sidecar_publication(publication)
390
+ continue
391
+ publication = self._context.storage.atomic_publish_bytes(
392
+ destination_guard,
393
+ desired[0],
394
+ mode=desired[1],
395
+ )
396
+ mutations.append(
397
+ _ExactSidecarMutation(
398
+ before_guard=destination_guard,
399
+ before_bytes=(
400
+ cast(tuple[bytes, int | None], states[index])[0] if states[index] is not None else None
401
+ ),
402
+ publication=publication,
403
+ )
404
+ )
405
+ self._context.storage.read_guarded_bytes(publication)
406
+ rotated.append(paths[index])
407
+
408
+ backup_publication = self._context.storage.atomic_publish_bytes(
409
+ guards[0],
410
+ preimage,
411
+ mode=guard.target_mode,
412
+ )
413
+ mutations.append(
414
+ _ExactSidecarMutation(
415
+ before_guard=guards[0],
416
+ before_bytes=states[0][0] if states[0] is not None else None,
417
+ publication=backup_publication,
418
+ )
419
+ )
420
+ self._context.storage.read_guarded_bytes(backup_publication)
421
+ for mutation in mutations:
422
+ self._assert_exact_sidecar_publication(mutation.publication)
423
+ except BaseException:
424
+ self._rollback_exact_backup_mutations(
425
+ mutations,
426
+ preimages_preserved=True,
427
+ )
428
+ raise
429
+ return _ExactBackupResult(
430
+ BackupReport(backup, tuple(rotated)),
431
+ tuple(mutations),
432
+ recoveries,
433
+ )
434
+
435
+ def _rollback_exact_backup_mutations(
436
+ self,
437
+ mutations: Sequence[_ExactSidecarMutation],
438
+ *,
439
+ preimages_preserved: bool = False,
440
+ ) -> None:
441
+ """Restore every sidecar candidate that is still exactly ours."""
442
+
443
+ if not preimages_preserved:
444
+ recoveries = self._preserve_exact_preimages(
445
+ [
446
+ (
447
+ mutation.publication.path,
448
+ mutation.before_bytes,
449
+ mutation.before_guard.target_mode,
450
+ )
451
+ for mutation in mutations
452
+ if mutation.before_bytes is not None
453
+ ],
454
+ marker="rollback-recovery",
455
+ )
456
+ if recoveries is None:
457
+ return
458
+
459
+ storage = self._context._local_storage()
460
+ for mutation in reversed(mutations):
461
+ try:
462
+ self._assert_exact_sidecar_publication(mutation.publication)
463
+ except (FileNotFoundError, OSError, RuntimeError):
464
+ continue
465
+ try:
466
+ if mutation.before_bytes is None:
467
+ storage.remove_guarded_output(mutation.publication)
468
+ else:
469
+ storage.atomic_publish_bytes(
470
+ mutation.publication,
471
+ mutation.before_bytes,
472
+ mode=mutation.before_guard.target_mode,
473
+ )
474
+ except (FileNotFoundError, OSError, RuntimeError):
475
+ # An external replacement that wins the CAS belongs to the
476
+ # external writer. Every original preimage was preserved
477
+ # before rollback began, so no later destructive step can
478
+ # erase the last recoverable generation.
479
+ continue
480
+
481
+ def _write_patched(
482
+ self,
483
+ target_path,
484
+ data: bytes,
485
+ payload: Dict[str, Any],
486
+ *,
487
+ output_guard: (
488
+ WorkspaceOutputGuard | WorkspaceMissingParentGuard | None
489
+ ) = None,
490
+ output_precondition: (
491
+ WorkspaceOutputGuard | WorkspaceMissingParentGuard | None
492
+ ) = None,
493
+ publication_sink: Callable[[WorkspaceOutputGuard], None] | None = None,
494
+ ) -> Dict[str, Any]:
495
+ """Atomic temp-write + open-safety gate for a byte-preserving result
496
+ (shared by byte_preserving_patch / apply_table_ops)."""
497
+ candidate_bytes = bytes(data)
498
+ report = build_hwpx_verification_report(
499
+ candidate_bytes,
500
+ file_path=target_path,
501
+ )
502
+ if not report["openSafety"]["ok"]:
503
+ raise self._context._new_error(
504
+ "FORM_FILL_OPEN_SAFETY_FAILED",
505
+ "form-fill output failed open-safety verification: "
506
+ + report["openSafety"]["summary"],
507
+ )
508
+ report["filePath"] = str(target_path)
509
+ report["byteIdentical"] = payload["byteIdentical"]
510
+ report["changedParts"] = payload["changedParts"]
511
+ report["skipped"] = payload["skipped"]
512
+ payload["verificationReport"] = report
513
+ payload["openSafety"] = report["openSafety"]
514
+
515
+ if isinstance(self._context.storage, LocalDocumentStorage):
516
+ precondition = (
517
+ output_precondition
518
+ or output_guard
519
+ or self._context.storage.capture_output_precondition(target_path)
520
+ )
521
+ materialized_guard: WorkspaceOutputGuard | None = None
522
+ try:
523
+ materialized_guard = self._context.storage.materialize_output_guard(
524
+ precondition
525
+ )
526
+ publication = self._context.storage.atomic_publish_bytes(
527
+ materialized_guard,
528
+ candidate_bytes,
529
+ )
530
+ if publication_sink is not None:
531
+ publication_sink(publication)
532
+ payload["_workspacePublication"] = publication
533
+ except BaseException:
534
+ if materialized_guard is not None:
535
+ self._context.storage.cleanup_owned_parent_directories(
536
+ materialized_guard
537
+ )
538
+ raise
539
+ else:
540
+ target_path.parent.mkdir(parents=True, exist_ok=True)
541
+ fd, tmp_name = tempfile.mkstemp(
542
+ prefix=f".{target_path.stem}.",
543
+ suffix=target_path.suffix or ".hwpx",
544
+ dir=str(target_path.parent),
545
+ )
546
+ tmp_path = Path(tmp_name)
547
+ try:
548
+ os.close(fd)
549
+ tmp_path.write_bytes(candidate_bytes)
550
+ os.replace(tmp_path, target_path)
551
+ finally:
552
+ tmp_path.unlink(missing_ok=True)
553
+ return payload
554
+
555
+ def save(self, path: str) -> Dict[str, Any]:
556
+ document, resolved = self._context._open_document(path)
557
+ verification_report = self._save_document(document, resolved)
558
+ return {"ok": True, "verificationReport": verification_report}
559
+
560
+ def save_as(self, path: str, out: str) -> Dict[str, Any]:
561
+ document, resolved = self._context._open_document(path)
562
+ out_path = self._context._resolve_output_path(out)
563
+ verification_report = self._save_document(document, out_path)
564
+ return {"outPath": str(out_path), "verificationReport": verification_report}
565
+
566
+ def fill_template(
567
+ self,
568
+ source: str,
569
+ output: str,
570
+ replacements: Dict[str, str],
571
+ *,
572
+ preserve_style: bool = True,
573
+ split_newlines: bool = True,
574
+ ) -> Dict[str, Any]:
575
+ document, _ = self._context._open_document(source)
576
+ out_path = self._context._resolve_output_path(output)
577
+
578
+ replaced_count = 0
579
+ for needle, replacement in replacements.items():
580
+ if not needle:
581
+ continue
582
+ content = replacement
583
+ if not split_newlines:
584
+ content = content.replace("\r\n", " ").replace("\n", " ")
585
+
586
+ replaced_count += document.replace_text_in_runs(needle, content)
587
+
588
+ if not preserve_style:
589
+ logger.debug(
590
+ "fill_template called with preserve_style=False, but current backend always preserves run style"
591
+ )
592
+
593
+ verification_report = self._save_document(document, out_path)
594
+ return {
595
+ "outPath": str(out_path),
596
+ "replacedCount": replaced_count,
597
+ "verificationReport": verification_report,
598
+ }
599
+
600
+ def make_blank(self, out: str) -> Dict[str, Any]:
601
+ document = new_document()
602
+ out_path = self._context._resolve_output_path(out)
603
+ verification_report = self._save_document(document, out_path)
604
+ return {"outPath": str(out_path), "verificationReport": verification_report}