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,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
|
+
}
|