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,747 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Storage backends for HWPX document operations."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import os
8
+ import re
9
+ import shutil
10
+ import tempfile
11
+ import zipfile
12
+ from dataclasses import dataclass, field
13
+ from io import BytesIO
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Mapping, Optional, Protocol, Tuple, cast
16
+ from urllib import error, parse, request
17
+
18
+ try: # python-hwpx >= 2.10.3
19
+ from hwpx.tools.package_validator import validate_package
20
+ except Exception as exc: # pragma: no cover - depends on installed python-hwpx
21
+ validate_package = None
22
+ _PACKAGE_VALIDATOR_IMPORT_ERROR: Exception | None = exc
23
+ else:
24
+ _PACKAGE_VALIDATOR_IMPORT_ERROR = None
25
+
26
+ try: # python-hwpx >= 2.10.3
27
+ from hwpx.tools.package_validator import is_editor_open_blocking_issue
28
+ except Exception as exc: # pragma: no cover - depends on installed python-hwpx
29
+ is_editor_open_blocking_issue = None
30
+ _OPEN_SAFETY_CLASSIFIER_IMPORT_ERROR: Exception | None = exc
31
+ else:
32
+ _OPEN_SAFETY_CLASSIFIER_IMPORT_ERROR = None
33
+
34
+ from . import quality as quality_contract
35
+ from .upstream import HwpxDocument, open_document, validate_document_path
36
+ from .workspace import (
37
+ LEGACY_SANDBOX_ROOT_ENV,
38
+ LEGACY_WORKSPACE_ROOTS_ENV,
39
+ WORKSPACE_ROOTS_ENV,
40
+ WorkspaceConfigurationError,
41
+ WorkspaceMissingParentGuard,
42
+ WorkspaceOutputGuard,
43
+ WorkspaceResolver,
44
+ )
45
+ from .network_policy import NetworkPolicy, build_policy_opener
46
+
47
+ _REQUIRED_HWPX_FILES = [
48
+ "mimetype",
49
+ "Contents/content.hpf",
50
+ "Contents/header.xml",
51
+ "Contents/section0.xml",
52
+ ]
53
+ _SECTION_XML_RE = re.compile(r"^Contents/section\d+\.xml$")
54
+ _PLACEHOLDER_PATTERNS = [
55
+ re.compile(r"\[[^\[\]\n]{1,100}\]"),
56
+ re.compile(r"\[\[[^\[\]\n]{1,100}\]\]"),
57
+ re.compile(r"\{\{[^{}\n]{1,100}\}\}"),
58
+ re.compile(r"__[^_\n]{1,100}__"),
59
+ ]
60
+ _UNESCAPED_AMP_RE = re.compile(r"&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[0-9A-Fa-f]+;)")
61
+ _NESTED_OPENING_TAG_RE = re.compile(r"<hp:t\b[^>]*>[^<]*(<(?!/?hp:)[^>]+>)")
62
+ _EMPTY_HP_T_RE = re.compile(r"<hp:t\b[^>]*>\s*</hp:t>")
63
+
64
+ HwpxVerificationSource = Path | bytes
65
+
66
+
67
+ class DocumentStorage(Protocol):
68
+ """Protocol describing storage backends used by :class:`HwpxOps`."""
69
+
70
+ base_directory: Path
71
+
72
+ def resolve_path(self, path: str, *, must_exist: bool = True) -> Path:
73
+ """Return the backend-specific absolute path for *path*."""
74
+
75
+ def resolve_output_path(self, path: str) -> Path:
76
+ """Return a path suitable for writing output."""
77
+
78
+ def relative_path(self, path: Path) -> str:
79
+ """Return a user-friendly relative representation of *path*."""
80
+
81
+ def ensure_backup(self, path: Path) -> Optional[Path]:
82
+ """Create a backup of *path* if it exists, returning the backup path."""
83
+
84
+ def maybe_backup(self, path: Path) -> None:
85
+ """Create a backup of *path* when backend policy requires it."""
86
+
87
+ def open_document(self, path: str) -> Tuple[HwpxDocument, Path]:
88
+ """Open the document located at *path* and return it with the resolved path."""
89
+
90
+ def save_document(
91
+ self, document: HwpxDocument, target: Path, *, quality: Any = None
92
+ ) -> Dict[str, Any]:
93
+ """Persist *document* to *target* using backend specific rules."""
94
+
95
+
96
+ class LocalDocumentStorage:
97
+ """Filesystem based :class:`DocumentStorage` implementation."""
98
+
99
+ def __init__(
100
+ self,
101
+ *,
102
+ base_directory: Path | None = None,
103
+ auto_backup: bool = False,
104
+ logger: logging.Logger | None = None,
105
+ workspace_resolver: WorkspaceResolver | None = None,
106
+ ) -> None:
107
+ if workspace_resolver is not None and base_directory is not None:
108
+ raise ValueError("base_directory and workspace_resolver are mutually exclusive")
109
+ self._deferred_workspace_error: WorkspaceConfigurationError | None = None
110
+ if workspace_resolver is not None:
111
+ self._workspace: WorkspaceResolver | None = workspace_resolver
112
+ elif base_directory is not None:
113
+ self._workspace = WorkspaceResolver.from_roots([base_directory])
114
+ else:
115
+ # Implicit cwd fallback. When neither HWPX_AUTOMATION_WORKSPACE_ROOTS
116
+ # nor its supported HWPX_MCP_WORKSPACE_ROOTS fallback nor the
117
+ # legacy root is configured and the cwd is degenerate (e.g. a GUI MCP
118
+ # client launched from C:\Windows\System32 or /), defer the actionable
119
+ # error to first use so import and startup do not crash and
120
+ # mcp_server_health can still report the misconfiguration.
121
+ try:
122
+ self._workspace = WorkspaceResolver.from_environment()
123
+ except WorkspaceConfigurationError as exc:
124
+ if (
125
+ os.environ.get(WORKSPACE_ROOTS_ENV) is not None
126
+ or os.environ.get(LEGACY_WORKSPACE_ROOTS_ENV) is not None
127
+ or os.environ.get(LEGACY_SANDBOX_ROOT_ENV) is not None
128
+ ):
129
+ raise
130
+ self._workspace = None
131
+ self._deferred_workspace_error = exc
132
+ self.base_directory = (
133
+ self._workspace.primary_root
134
+ if self._workspace is not None
135
+ else Path(os.devnull)
136
+ )
137
+ self._auto_backup = auto_backup
138
+ self._logger = logger or logging.getLogger(__name__)
139
+
140
+ @property
141
+ def workspace(self) -> WorkspaceResolver:
142
+ if self._workspace is None:
143
+ # A degenerate/unconfigured cwd fallback deferred this error so the
144
+ # server could boot; surface it now as a clean WORKSPACE_ROOT_INVALID.
145
+ raise cast(WorkspaceConfigurationError, self._deferred_workspace_error)
146
+ return self._workspace
147
+
148
+ def resolve_path(self, path: str, *, must_exist: bool = True) -> Path:
149
+ return self.workspace.resolve(path, must_exist=must_exist)
150
+
151
+ def resolve_output_path(self, path: str) -> Path:
152
+ return self.workspace.resolve_output(path)
153
+
154
+ def capture_output_guard(
155
+ self,
156
+ path: str | os.PathLike[str],
157
+ *,
158
+ create_parents: bool = True,
159
+ ) -> WorkspaceOutputGuard:
160
+ return self.workspace.capture_output(path, create_parents=create_parents)
161
+
162
+ def capture_output_precondition(
163
+ self,
164
+ path: str | os.PathLike[str],
165
+ ) -> WorkspaceOutputGuard | WorkspaceMissingParentGuard:
166
+ return self.workspace.capture_output_precondition(path)
167
+
168
+ def materialize_output_guard(
169
+ self,
170
+ precondition: WorkspaceOutputGuard | WorkspaceMissingParentGuard,
171
+ ) -> WorkspaceOutputGuard:
172
+ return self.workspace.materialize_output_guard(precondition)
173
+
174
+ def cleanup_owned_parent_directories(
175
+ self,
176
+ guard: WorkspaceOutputGuard,
177
+ ) -> bool:
178
+ return self.workspace.cleanup_owned_parent_directories(guard)
179
+
180
+ def atomic_write_bytes(
181
+ self,
182
+ guard: WorkspaceOutputGuard,
183
+ data: bytes,
184
+ *,
185
+ mode: int | None = None,
186
+ ) -> Path:
187
+ return self.workspace.atomic_write_bytes(guard, data, mode=mode)
188
+
189
+ def atomic_publish_bytes(
190
+ self,
191
+ guard: WorkspaceOutputGuard,
192
+ data: bytes,
193
+ *,
194
+ mode: int | None = None,
195
+ ) -> WorkspaceOutputGuard:
196
+ """Publish and return the exact candidate identity for transaction ownership."""
197
+
198
+ return self.workspace.atomic_publish_bytes(guard, data, mode=mode)
199
+
200
+ def read_guarded_bytes(self, guard: WorkspaceOutputGuard) -> bytes:
201
+ """Read the exact preimage represented by an output guard."""
202
+
203
+ return self.workspace.read_guarded_bytes(guard)
204
+
205
+ def remove_guarded_output(self, guard: WorkspaceOutputGuard) -> None:
206
+ """Remove only the exact candidate represented by an output guard."""
207
+
208
+ self.workspace.remove_output(guard)
209
+
210
+ def relative_path(self, path: Path) -> str:
211
+ return self.workspace.display_path(path)
212
+
213
+ def ensure_backup(self, path: Path) -> Optional[Path]:
214
+ if not path.exists():
215
+ return None
216
+ if path.suffix.lower() == ".hwpx":
217
+ require_hwpx_editor_open_safe(path, role="backup source")
218
+ backup = path.with_suffix(path.suffix + ".bak")
219
+ shutil.copy2(path, backup)
220
+ return backup
221
+
222
+ def maybe_backup(self, path: Path) -> None:
223
+ if not self._auto_backup:
224
+ return
225
+ backup = self.ensure_backup(path)
226
+ if backup is not None:
227
+ self._logger.info(
228
+ "created backup",
229
+ extra={"path": str(path), "backup": str(backup)},
230
+ )
231
+
232
+ def open_document(self, path: str) -> Tuple[HwpxDocument, Path]:
233
+ resolved = self.resolve_path(path)
234
+ require_hwpx_editor_open_safe(resolved, role="local HWPX open")
235
+ document = open_document(resolved)
236
+ return document, resolved
237
+
238
+ def save_document(
239
+ self, document: HwpxDocument, target: Path, *, quality: Any = None
240
+ ) -> Dict[str, Any]:
241
+ # General document saves use the SavePipeline gate. Byte-preserving
242
+ # form writers have their own guarded open-safety publication path.
243
+ quality_contract.assert_write_capability()
244
+ guard = self.capture_output_guard(target)
245
+ self.maybe_backup(target)
246
+ pre_save_snapshot = build_hwpx_presave_snapshot(target)
247
+ # Validate in an isolated temp, then publish through the identity-bound
248
+ # workspace guard. Candidate creation never follows the output parent.
249
+ tmp_fd, tmp_path_str = tempfile.mkstemp(suffix=target.suffix)
250
+ tmp_path = Path(tmp_path_str)
251
+ try:
252
+ os.close(tmp_fd)
253
+ report = quality_contract.save_through_pipeline(
254
+ document, tmp_path, quality=quality
255
+ )
256
+ verification_report = build_hwpx_verification_report(tmp_path, pre_save_snapshot)
257
+ if not verification_report["openSafety"]["ok"]:
258
+ raise RuntimeError(
259
+ "saved HWPX failed open-safety verification: "
260
+ + verification_report["openSafety"]["summary"]
261
+ )
262
+ self.atomic_write_bytes(guard, tmp_path.read_bytes())
263
+ verification_report["filePath"] = str(target)
264
+ verification_report["visualComplete"] = quality_contract.visual_complete_block(report)
265
+ return verification_report
266
+ finally:
267
+ tmp_path.unlink(missing_ok=True)
268
+
269
+
270
+ class RemoteDocumentClient(Protocol):
271
+ """Protocol describing the minimal HTTP client interface required."""
272
+
273
+ def download(self, path: str) -> bytes:
274
+ """Return the binary payload for *path* from the remote service."""
275
+
276
+ def upload(self, path: str, data: bytes) -> None:
277
+ """Persist *data* to *path* on the remote service."""
278
+
279
+
280
+ def _report_allows_editor_open(report: Mapping[str, Any]) -> bool:
281
+ package = report.get("validatePackage", {})
282
+ reopen = report.get("reopen", {})
283
+ return bool(
284
+ isinstance(package, Mapping)
285
+ and isinstance(reopen, Mapping)
286
+ and package.get("ok")
287
+ and reopen.get("ok")
288
+ )
289
+
290
+
291
+ def require_hwpx_editor_open_safe(
292
+ source: HwpxVerificationSource,
293
+ *,
294
+ role: str,
295
+ ) -> Dict[str, Any]:
296
+ """Fail only on conditions expected to stop an editor from opening HWPX."""
297
+
298
+ open_safety = build_hwpx_open_safety_report(source)
299
+ if not _report_allows_editor_open(open_safety):
300
+ raise RuntimeError(
301
+ f"{role} failed open-safety verification: "
302
+ + open_safety["summary"]
303
+ )
304
+ return open_safety
305
+
306
+
307
+ @dataclass(slots=True)
308
+ class _RestDocumentClient:
309
+ """Default HTTP client used by :class:`HttpDocumentStorage`."""
310
+
311
+ base_url: str
312
+ timeout: float | None
313
+ headers: Mapping[str, str]
314
+ allow_private_network: bool | None = None
315
+ _network_policy: NetworkPolicy = field(init=False, repr=False)
316
+ _opener: Any = field(init=False, repr=False)
317
+
318
+ def __post_init__(self) -> None:
319
+ if not self.base_url:
320
+ raise ValueError("HTTP storage requires a base URL")
321
+ self._network_policy = (
322
+ NetworkPolicy.from_environment()
323
+ if self.allow_private_network is None
324
+ else NetworkPolicy(allow_private_network=self.allow_private_network)
325
+ )
326
+ self._opener = build_policy_opener(self._network_policy)
327
+
328
+ def download(self, path: str) -> bytes:
329
+ url = self._build_url(path)
330
+ req = request.Request(url, method="GET")
331
+ for key, value in self.headers.items():
332
+ req.add_header(key, value)
333
+ try:
334
+ with self._opener.open(req, timeout=self.timeout) as response:
335
+ return response.read()
336
+ except error.HTTPError as exc:
337
+ if exc.code == 404:
338
+ raise FileNotFoundError(path) from exc
339
+ raise RuntimeError(f"HTTP download failed: {exc}") from exc
340
+ except error.URLError as exc:
341
+ raise RuntimeError(f"HTTP download failed: {exc}") from exc
342
+
343
+ def upload(self, path: str, data: bytes) -> None:
344
+ url = self._build_url(path)
345
+ req = request.Request(url, data=data, method="PUT")
346
+ for key, value in self.headers.items():
347
+ req.add_header(key, value)
348
+ req.add_header("Content-Type", "application/octet-stream")
349
+ try:
350
+ with self._opener.open(req, timeout=self.timeout):
351
+ return None
352
+ except error.HTTPError as exc:
353
+ raise RuntimeError(f"HTTP upload failed: {exc}") from exc
354
+ except error.URLError as exc:
355
+ raise RuntimeError(f"HTTP upload failed: {exc}") from exc
356
+
357
+ def _build_url(self, path: str) -> str:
358
+ query = parse.urlencode({"path": path})
359
+ url = f"{self.base_url.rstrip('/')}/documents?{query}"
360
+ return self._network_policy.validate_url(url)
361
+
362
+
363
+ class HttpDocumentStorage:
364
+ """HTTP based :class:`DocumentStorage` implementation with local caching."""
365
+
366
+ def __init__(
367
+ self,
368
+ base_url: str | None = None,
369
+ *,
370
+ timeout: float | None = None,
371
+ headers: Mapping[str, str] | None = None,
372
+ client: RemoteDocumentClient | None = None,
373
+ logger: logging.Logger | None = None,
374
+ allow_private_network: bool | None = None,
375
+ ) -> None:
376
+ if client is None and not base_url:
377
+ raise ValueError("HTTP storage requires either a base URL or a client")
378
+
379
+ self.base_directory = Path("/")
380
+ self._logger = logger or logging.getLogger(__name__)
381
+ self._headers = dict(headers or {})
382
+ self._client = client or _RestDocumentClient(
383
+ base_url=base_url or "",
384
+ timeout=timeout,
385
+ headers=self._headers,
386
+ allow_private_network=allow_private_network,
387
+ )
388
+ self._cache_dir = Path(tempfile.mkdtemp(prefix="hwpx_http_cache_"))
389
+ self._cache: Dict[str, Path] = {}
390
+
391
+ def resolve_path(self, path: str, *, must_exist: bool = True) -> Path:
392
+ # HTTP storage treats the provided path as an opaque identifier.
393
+ return Path(path)
394
+
395
+ def resolve_output_path(self, path: str) -> Path:
396
+ return self.resolve_path(path, must_exist=False)
397
+
398
+ def relative_path(self, path: Path) -> str:
399
+ return str(path)
400
+
401
+ def ensure_backup(self, path: Path) -> Optional[Path]:
402
+ # Backups are left to the remote service.
403
+ return None
404
+
405
+ def maybe_backup(self, path: Path) -> None:
406
+ # No-op; backups must be handled remotely if supported.
407
+ return None
408
+
409
+ def open_document(self, path: str) -> Tuple[HwpxDocument, Path]:
410
+ try:
411
+ payload = self._client.download(path)
412
+ except FileNotFoundError:
413
+ raise
414
+ except Exception as exc: # pragma: no cover - handled in tests for fake clients
415
+ raise RuntimeError(f"HTTP storage open failed: {exc}") from exc
416
+
417
+ local_path = self._cache_path(path)
418
+ tmp_fd, tmp_path_str = tempfile.mkstemp(
419
+ suffix=local_path.suffix or ".hwpx",
420
+ dir=str(local_path.parent),
421
+ )
422
+ tmp_path = Path(tmp_path_str)
423
+ try:
424
+ with os.fdopen(tmp_fd, "wb") as tmp_fh:
425
+ tmp_fh.write(payload)
426
+ require_hwpx_editor_open_safe(tmp_path, role="HTTP storage open")
427
+ os.replace(tmp_path, local_path)
428
+ except Exception:
429
+ tmp_path.unlink(missing_ok=True)
430
+ raise
431
+ self._cache[path] = local_path
432
+
433
+ document = open_document(local_path)
434
+ return document, Path(path)
435
+
436
+ def save_document(
437
+ self, document: HwpxDocument, target: Path, *, quality: Any = None
438
+ ) -> Dict[str, Any]:
439
+ quality_contract.assert_write_capability()
440
+ remote_key = str(target)
441
+ cache_path = self._cache.get(remote_key)
442
+ if cache_path is None:
443
+ cache_path = self._cache_path(remote_key)
444
+ self._cache[remote_key] = cache_path
445
+
446
+ pre_save_snapshot = build_hwpx_presave_snapshot(cache_path if cache_path.exists() else None)
447
+ tmp_fd, tmp_path_str = tempfile.mkstemp(
448
+ suffix=cache_path.suffix or ".hwpx",
449
+ dir=str(cache_path.parent),
450
+ )
451
+ tmp_path = Path(tmp_path_str)
452
+
453
+ try:
454
+ os.close(tmp_fd)
455
+ report = quality_contract.save_through_pipeline(document, tmp_path, quality=quality)
456
+ verification_report = build_hwpx_verification_report(tmp_path, pre_save_snapshot)
457
+ if not verification_report["openSafety"]["ok"]:
458
+ raise RuntimeError(
459
+ "saved HWPX failed open-safety verification: "
460
+ + verification_report["openSafety"]["summary"]
461
+ )
462
+ verification_report["visualComplete"] = quality_contract.visual_complete_block(report)
463
+ payload = tmp_path.read_bytes()
464
+ except quality_contract.QualityGateError:
465
+ tmp_path.unlink(missing_ok=True)
466
+ raise
467
+ except Exception as exc: # pragma: no cover - unexpected save error
468
+ tmp_path.unlink(missing_ok=True)
469
+ raise RuntimeError(f"HTTP storage save failed: {exc}") from exc
470
+
471
+ try:
472
+ self._client.upload(remote_key, payload)
473
+ except Exception as exc: # pragma: no cover - handled in tests for fake clients
474
+ tmp_path.unlink(missing_ok=True)
475
+ raise RuntimeError(f"HTTP storage save failed: {exc}") from exc
476
+ os.replace(tmp_path, cache_path)
477
+ return verification_report
478
+
479
+ def _cache_path(self, path: str) -> Path:
480
+ suffix = Path(path).suffix or ".hwpx"
481
+ safe_name = parse.quote_plus(path)
482
+ filename = safe_name if safe_name.endswith(suffix) else f"{safe_name}{suffix}"
483
+ return self._cache_dir / filename
484
+
485
+
486
+ def build_hwpx_presave_snapshot(
487
+ source: HwpxVerificationSource | None,
488
+ ) -> Dict[str, Any] | None:
489
+ if source is None or (isinstance(source, Path) and not source.exists()):
490
+ return None
491
+ return _collect_hwpx_snapshot(source)
492
+
493
+
494
+ def _issue_messages(report: Any, attr_name: str = "issues") -> List[str]:
495
+ return [str(issue) for issue in getattr(report, attr_name, ())]
496
+
497
+
498
+ def _open_safety_dependency_error() -> str | None:
499
+ if validate_package is None:
500
+ detail = (
501
+ str(_PACKAGE_VALIDATOR_IMPORT_ERROR)
502
+ if _PACKAGE_VALIDATOR_IMPORT_ERROR is not None
503
+ else "hwpx.tools.package_validator.validate_package is unavailable"
504
+ )
505
+ return f"python-hwpx>=2.10.3 is required for HWPX open-safety validation: {detail}"
506
+ if is_editor_open_blocking_issue is None:
507
+ detail = (
508
+ str(_OPEN_SAFETY_CLASSIFIER_IMPORT_ERROR)
509
+ if _OPEN_SAFETY_CLASSIFIER_IMPORT_ERROR is not None
510
+ else "hwpx.tools.package_validator.is_editor_open_blocking_issue is unavailable"
511
+ )
512
+ return f"python-hwpx>=2.10.3 is required for HWPX open-safety validation: {detail}"
513
+ return None
514
+
515
+
516
+ def build_hwpx_open_safety_report(
517
+ source: HwpxVerificationSource,
518
+ ) -> Dict[str, Any]:
519
+ package_payload: Dict[str, Any]
520
+ document_payload: Dict[str, Any]
521
+ reopen_payload: Dict[str, Any]
522
+
523
+ dependency_error = _open_safety_dependency_error()
524
+ if dependency_error is not None:
525
+ package_payload = {
526
+ "ok": False,
527
+ "validatorOk": False,
528
+ "errors": [dependency_error],
529
+ "warnings": [],
530
+ "validatorErrors": [dependency_error],
531
+ }
532
+ else:
533
+ try:
534
+ assert validate_package is not None
535
+ assert is_editor_open_blocking_issue is not None
536
+ package_report = validate_package(source)
537
+ package_errors = _issue_messages(package_report, "errors")
538
+ blocking_issues = [
539
+ issue for issue in package_report.errors if is_editor_open_blocking_issue(issue)
540
+ ]
541
+ advisory_issues = [
542
+ issue for issue in package_report.errors if not is_editor_open_blocking_issue(issue)
543
+ ]
544
+ blocking_package_errors = [str(issue) for issue in blocking_issues]
545
+ compatibility_warnings = [str(issue) for issue in advisory_issues]
546
+ package_payload = {
547
+ "ok": not blocking_package_errors,
548
+ "validatorOk": bool(package_report.ok),
549
+ "errors": blocking_package_errors,
550
+ "warnings": [*_issue_messages(package_report, "warnings"), *compatibility_warnings],
551
+ "validatorErrors": package_errors,
552
+ }
553
+ except Exception as exc: # noqa: BLE001
554
+ package_payload = {
555
+ "ok": False,
556
+ "validatorOk": False,
557
+ "errors": [str(exc)],
558
+ "warnings": [],
559
+ "validatorErrors": [str(exc)],
560
+ }
561
+
562
+ try:
563
+ document_report = validate_document_path(source)
564
+ document_payload = {
565
+ "ok": bool(document_report.ok),
566
+ "errors": _issue_messages(document_report, "errors"),
567
+ "warnings": _issue_messages(document_report, "warnings"),
568
+ }
569
+ except Exception as exc: # noqa: BLE001
570
+ document_payload = {
571
+ "ok": False,
572
+ "errors": [str(exc)],
573
+ "warnings": [],
574
+ }
575
+
576
+ try:
577
+ reopened = open_document(source)
578
+ close = getattr(reopened, "close", None)
579
+ if callable(close):
580
+ close()
581
+ reopen_payload = {"ok": True, "error": None}
582
+ except Exception as exc: # noqa: BLE001
583
+ reopen_payload = {"ok": False, "error": str(exc)}
584
+
585
+ ok = bool(package_payload["ok"] and document_payload["ok"] and reopen_payload["ok"])
586
+ failures: List[str] = []
587
+ if not package_payload["ok"]:
588
+ failures.append("package validation failed")
589
+ if not document_payload["ok"]:
590
+ failures.append("document validation failed")
591
+ if not reopen_payload["ok"]:
592
+ failures.append("reopen failed")
593
+
594
+ return {
595
+ "ok": ok,
596
+ "summary": "open-safety verification passed" if ok else "; ".join(failures),
597
+ "validatePackage": package_payload,
598
+ "validateDocument": document_payload,
599
+ "reopen": reopen_payload,
600
+ }
601
+
602
+
603
+ def build_hwpx_verification_report(
604
+ source: HwpxVerificationSource,
605
+ pre_save_snapshot: Dict[str, Any] | None = None,
606
+ *,
607
+ file_path: Path | None = None,
608
+ ) -> Dict[str, Any]:
609
+ snapshot = _collect_hwpx_snapshot(source)
610
+ open_safety = build_hwpx_open_safety_report(source)
611
+ displayed_path = file_path or (source if isinstance(source, Path) else None)
612
+ file_size = len(source) if isinstance(source, bytes) else source.stat().st_size
613
+ totals = snapshot["totals"]
614
+ missing_files = snapshot["missing_files"]
615
+ warnings: List[str] = []
616
+ if missing_files:
617
+ warnings.append(f"missing required files: {', '.join(missing_files)}")
618
+ if totals["placeholders"]:
619
+ warnings.append("placeholder-like tokens remain in saved document")
620
+ if totals["suspiciousPatterns"]:
621
+ warnings.append("suspicious XML/text patterns detected in saved document")
622
+
623
+ diff_summary = {
624
+ "xmlLength": 0,
625
+ "hpTabs": 0,
626
+ "paragraphs": 0,
627
+ "tables": 0,
628
+ }
629
+ if pre_save_snapshot is not None:
630
+ before = pre_save_snapshot["totals"]
631
+ diff_summary = {
632
+ "xmlLength": totals["xmlLength"] - before["xmlLength"],
633
+ "hpTabs": totals["hpTabs"] - before["hpTabs"],
634
+ "paragraphs": totals["paragraphs"] - before["paragraphs"],
635
+ "tables": totals["tables"] - before["tables"],
636
+ }
637
+
638
+ ok = (
639
+ open_safety["ok"]
640
+ and not missing_files
641
+ and not totals["placeholders"]
642
+ and not totals["suspiciousPatterns"]
643
+ )
644
+ summary = "verification passed"
645
+ if not ok:
646
+ summary = "; ".join(warnings) if warnings else "verification failed"
647
+
648
+ return {
649
+ "ok": ok,
650
+ "summary": summary,
651
+ "filePath": str(displayed_path) if displayed_path is not None else "<memory>",
652
+ "fileSizeBytes": file_size,
653
+ "requiredFilesChecked": list(_REQUIRED_HWPX_FILES),
654
+ "missingFiles": missing_files,
655
+ "openSafety": open_safety,
656
+ "sectionReports": snapshot["section_reports"],
657
+ "totals": {
658
+ "sections": totals["sections"],
659
+ "xmlLength": totals["xmlLength"],
660
+ "hpTabs": totals["hpTabs"],
661
+ "paragraphs": totals["paragraphs"],
662
+ "tables": totals["tables"],
663
+ "placeholders": totals["placeholders"],
664
+ "suspiciousPatterns": totals["suspiciousPatterns"],
665
+ },
666
+ "diffSummary": diff_summary,
667
+ "warnings": warnings,
668
+ }
669
+
670
+
671
+ def _collect_hwpx_snapshot(source: HwpxVerificationSource) -> Dict[str, Any]:
672
+ missing_files: List[str] = []
673
+ section_reports: List[Dict[str, Any]] = []
674
+ totals = {
675
+ "sections": 0,
676
+ "xmlLength": 0,
677
+ "hpTabs": 0,
678
+ "paragraphs": 0,
679
+ "tables": 0,
680
+ "placeholders": 0,
681
+ "suspiciousPatterns": 0,
682
+ }
683
+
684
+ archive_source = BytesIO(source) if isinstance(source, bytes) else source
685
+ with zipfile.ZipFile(archive_source) as archive:
686
+ names = set(archive.namelist())
687
+ for required in _REQUIRED_HWPX_FILES:
688
+ if required not in names:
689
+ missing_files.append(required)
690
+
691
+ section_names = sorted(name for name in names if _SECTION_XML_RE.match(name))
692
+ totals["sections"] = len(section_names)
693
+ for section_name in section_names:
694
+ xml_text = archive.read(section_name).decode("utf-8", errors="replace")
695
+ section_index = int(section_name.removeprefix("Contents/section").removesuffix(".xml"))
696
+ placeholder_examples: List[str] = []
697
+ placeholder_count = 0
698
+ for pattern in _PLACEHOLDER_PATTERNS:
699
+ for match in pattern.findall(xml_text):
700
+ placeholder_count += 1
701
+ if match not in placeholder_examples and len(placeholder_examples) < 5:
702
+ placeholder_examples.append(match)
703
+
704
+ suspicious_patterns: List[str] = []
705
+ if _UNESCAPED_AMP_RE.search(xml_text):
706
+ suspicious_patterns.append("unescaped_ampersand")
707
+ if _EMPTY_HP_T_RE.search(xml_text):
708
+ suspicious_patterns.append("empty_hp_t")
709
+ if _NESTED_OPENING_TAG_RE.search(xml_text):
710
+ suspicious_patterns.append("nested_opening_tag_in_text")
711
+ if ">>" in xml_text or "<<" in xml_text:
712
+ suspicious_patterns.append("double_angle_marker")
713
+
714
+ paragraph_count = xml_text.count("<hp:p")
715
+ table_count = xml_text.count("<hp:tbl")
716
+ hp_tab_count = xml_text.count("<hp:tab")
717
+ xml_length = len(xml_text)
718
+
719
+ totals["xmlLength"] += xml_length
720
+ totals["hpTabs"] += hp_tab_count
721
+ totals["paragraphs"] += paragraph_count
722
+ totals["tables"] += table_count
723
+ totals["placeholders"] += placeholder_count
724
+ totals["suspiciousPatterns"] += len(suspicious_patterns)
725
+
726
+ section_reports.append(
727
+ {
728
+ "section": section_index,
729
+ "xmlDeclaration": xml_text.startswith("<?xml"),
730
+ "truncatedXml": bool(re.search(r"<[^>]*$", xml_text)),
731
+ "brokenTagPattern": bool(re.search(r"<[^>]*<", xml_text)),
732
+ "xmlLength": xml_length,
733
+ "hpTabs": hp_tab_count,
734
+ "paragraphs": paragraph_count,
735
+ "tables": table_count,
736
+ "placeholderCount": placeholder_count,
737
+ "placeholderExamples": placeholder_examples,
738
+ "suspiciousPatternCount": len(suspicious_patterns),
739
+ "suspiciousPatterns": suspicious_patterns,
740
+ }
741
+ )
742
+
743
+ return {
744
+ "missing_files": missing_files,
745
+ "section_reports": section_reports,
746
+ "totals": totals,
747
+ }