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.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- 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}
|