python-hwpx-automation 7.3.2__py3-none-any.whl → 7.3.4__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 (27) hide show
  1. hwpx_automation/core/content.py +18 -2
  2. hwpx_automation/core/document.py +2 -3
  3. hwpx_automation/core/search.py +13 -10
  4. hwpx_automation/core/transactions.py +15 -3
  5. hwpx_automation/errors.py +54 -1
  6. hwpx_automation/handlers/agent_document.py +5 -0
  7. hwpx_automation/handlers/read_export.py +14 -10
  8. hwpx_automation/identity.json +2 -2
  9. hwpx_automation/mixed_form.py +3 -1
  10. hwpx_automation/office/rendering/oracle.py +15 -0
  11. hwpx_automation/ops_services/context.py +49 -3
  12. hwpx_automation/ops_services/form_fields.py +3 -0
  13. hwpx_automation/ops_services/package_validation.py +1 -1
  14. hwpx_automation/ops_services/planning.py +1 -1
  15. hwpx_automation/ops_services/read_query.py +21 -16
  16. hwpx_automation/ops_services/save_policy.py +3 -0
  17. hwpx_automation/ops_services/transactions.py +1 -0
  18. hwpx_automation/runtime.py +26 -3
  19. hwpx_automation/storage.py +116 -10
  20. hwpx_automation/upstream.py +56 -2
  21. {python_hwpx_automation-7.3.2.dist-info → python_hwpx_automation-7.3.4.dist-info}/METADATA +3 -3
  22. {python_hwpx_automation-7.3.2.dist-info → python_hwpx_automation-7.3.4.dist-info}/RECORD +27 -27
  23. {python_hwpx_automation-7.3.2.dist-info → python_hwpx_automation-7.3.4.dist-info}/WHEEL +0 -0
  24. {python_hwpx_automation-7.3.2.dist-info → python_hwpx_automation-7.3.4.dist-info}/entry_points.txt +0 -0
  25. {python_hwpx_automation-7.3.2.dist-info → python_hwpx_automation-7.3.4.dist-info}/licenses/LICENSE +0 -0
  26. {python_hwpx_automation-7.3.2.dist-info → python_hwpx_automation-7.3.4.dist-info}/licenses/NOTICE +0 -0
  27. {python_hwpx_automation-7.3.2.dist-info → python_hwpx_automation-7.3.4.dist-info}/top_level.txt +0 -0
@@ -14,8 +14,15 @@ from uuid import uuid4
14
14
  from xml.etree import ElementTree as ET
15
15
 
16
16
  from ..compat import patch_python_hwpx
17
- from ..storage import build_hwpx_open_safety_report
18
- from ..upstream import HP_NS as _HP_NS, HwpxDocument, repair_pathological_text_spacing
17
+ from ..storage import build_hwpx_open_safety_report, open_local_document
18
+ from ..upstream import (
19
+ HP_NS as _HP_NS,
20
+ HwpxDocument,
21
+ is_hwp5_source,
22
+ is_hwp5_target,
23
+ repair_pathological_text_spacing,
24
+ )
25
+ from .document import save_doc
19
26
  from .formatting import resolve_style_id
20
27
  from .locations import resolve_paragraph_reference
21
28
 
@@ -500,6 +507,15 @@ def copy_document_file(source: str, destination: str = None) -> str:
500
507
  destination = f"{stem}_copy.{ext}"
501
508
  source_path = Path(source)
502
509
  destination_path = Path(destination)
510
+ if is_hwp5_source(source_path) != is_hwp5_target(destination_path):
511
+ # The destination extension picks the format, so a copy between .hwp
512
+ # and .hwpx is written through the document model, not byte-copied.
513
+ document = open_local_document(source_path, role="copy source")
514
+ try:
515
+ save_doc(document, str(destination_path))
516
+ finally:
517
+ document.close()
518
+ return str(destination_path)
503
519
  if source_path.suffix.lower() != ".hwpx" and destination_path.suffix.lower() != ".hwpx":
504
520
  shutil.copy2(source_path, destination_path)
505
521
  return str(destination_path)
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
  from typing import Any
6
6
 
7
7
  from ..configuration import env_value
8
- from ..storage import LocalDocumentStorage, require_hwpx_editor_open_safe
8
+ from ..storage import LocalDocumentStorage, open_local_document
9
9
  from ..upstream import HwpxDocument, blank_document_template_bytes, open_document
10
10
 
11
11
 
@@ -18,8 +18,7 @@ def _local_storage() -> LocalDocumentStorage:
18
18
  def open_doc(path: str) -> HwpxDocument:
19
19
  if not os.path.exists(path):
20
20
  raise FileNotFoundError(f"파일을 찾을 수 없습니다: {path}")
21
- require_hwpx_editor_open_safe(Path(path), role="local HWPX open")
22
- return open_document(path)
21
+ return open_local_document(Path(path), role="local HWPX open")
23
22
 
24
23
 
25
24
  def save_doc(doc: HwpxDocument, path: str, *, quality: Any = None) -> dict[str, Any]:
@@ -93,11 +93,16 @@ def _replace_in_runs(runs: list[Any], find_text: str, replace_text: str) -> int:
93
93
  if not runs:
94
94
  return 0
95
95
 
96
- replaced = _replace_within_runs(runs, find_text, replace_text)
97
- merged_after_simple = "".join(run.text or "" for run in runs)
98
- if find_text in merged_after_simple:
99
- replaced += _replace_across_runs(runs, find_text, replace_text)
100
- return replaced
96
+ # Decide from the text before any replacement: re-scanning afterwards would find
97
+ # find_text again inside replace_text when the one contains the other.
98
+ if not _has_match_across_runs([run.text or "" for run in runs], find_text):
99
+ return _replace_within_runs(runs, find_text, replace_text)
100
+ return _replace_across_runs(runs, find_text, replace_text)
101
+
102
+
103
+ def _has_match_across_runs(texts: list[str], find_text: str) -> bool:
104
+ """Whether some occurrence of *find_text* in the joined text spans two runs."""
105
+ return "".join(texts).count(find_text) != sum(text.count(find_text) for text in texts)
101
106
 
102
107
 
103
108
  def _replace_within_xml_runs(run_elements: list[Any], find_text: str, replace_text: str) -> int:
@@ -134,11 +139,9 @@ def _replace_in_xml_runs(run_elements: list[Any], find_text: str, replace_text:
134
139
  if not run_elements:
135
140
  return 0
136
141
 
137
- replaced = _replace_within_xml_runs(run_elements, find_text, replace_text)
138
- merged_after_simple = "".join(_xml_run_text(run_element) for run_element in run_elements)
139
- if find_text in merged_after_simple:
140
- replaced += _replace_across_xml_runs(run_elements, find_text, replace_text)
141
- return replaced
142
+ if not _has_match_across_runs([_xml_run_text(run_element) for run_element in run_elements], find_text):
143
+ return _replace_within_xml_runs(run_elements, find_text, replace_text)
144
+ return _replace_across_xml_runs(run_elements, find_text, replace_text)
142
145
 
143
146
 
144
147
  def find_in_doc(doc: Any, text_to_find: str, match_case: bool = True, max_results: int = 50) -> dict:
@@ -12,8 +12,12 @@ from pathlib import Path
12
12
  from typing import Any, Iterable
13
13
 
14
14
  from .. import quality as quality_contract
15
- from ..storage import build_hwpx_verification_report, require_hwpx_editor_open_safe
16
- from ..upstream import HwpxDocument, open_document
15
+ from ..storage import (
16
+ build_hwpx_verification_report,
17
+ require_hwpx_editor_open_safe,
18
+ require_native_hwp5,
19
+ )
20
+ from ..upstream import HwpxDocument, hwp5_document_bytes, is_hwp5_target, open_document
17
21
 
18
22
  _MAX_SUMMARY_ITEMS = 20
19
23
  _MAX_SNIPPET = 80
@@ -341,9 +345,13 @@ def save_dry_run(
341
345
  document: HwpxDocument, target: str | Path, *, quality: Any = None
342
346
  ) -> dict[str, Any]:
343
347
  target_path = Path(target)
348
+ hwp_target = is_hwp5_target(target_path)
349
+ if hwp_target:
350
+ require_native_hwp5()
351
+ # The gate checks HWPX parts, so an .hwp target is staged as HWPX.
344
352
  fd, tmp_name = tempfile.mkstemp(
345
353
  prefix=f".{target_path.stem}.dry-run.",
346
- suffix=target_path.suffix or ".hwpx",
354
+ suffix=".hwpx" if hwp_target else (target_path.suffix or ".hwpx"),
347
355
  dir=str(target_path.parent),
348
356
  )
349
357
  tmp_path = Path(tmp_name)
@@ -366,6 +374,10 @@ def save_dry_run(
366
374
  "dry-run HWPX failed open-safety verification: "
367
375
  + verification["openSafety"]["summary"]
368
376
  )
377
+ if hwp_target:
378
+ # Raises Hwp5Error (hwp5-write-unsupported) as the real save would.
379
+ hwp5_document_bytes(document)
380
+ verification["format"] = "hwp"
369
381
  diff = semantic_diff(target_path, tmp_path) if target_path.exists() else None
370
382
  verification["filePath"] = str(target_path)
371
383
  verification["dryRunTempDeleted"] = True
hwpx_automation/errors.py CHANGED
@@ -73,6 +73,7 @@ DEFAULT_ERROR_SUGGESTIONS: dict[str, str] = {
73
73
  "EMPTY_MATCH": "찾을 텍스트나 앵커 조건을 실제 문서 내용에 맞게 수정하세요.",
74
74
  "HWP_CONVERSION_FAILED": "원본 HWP가 손상되지 않았는지 확인하고 변환 로그의 원인을 기준으로 다시 시도하세요.",
75
75
  "HWP_TEXT_EXTRACT_FAILED": "HWP 파일을 다른 뷰어에서 열어 유효성을 확인한 뒤 텍스트 추출을 다시 실행하세요.",
76
+ "HWPX_PACKAGE_REQUIRED": "copy_document로 .hwpx 사본을 만들어 이 도구를 쓰고, .hwp가 필요하면 결과를 copy_document로 .hwp 경로에 복사하세요. search_and_replace·set_table_cell_text 같은 문서 편집 도구는 .hwp를 그대로 편집합니다.",
76
77
  "IDEMPOTENT_REPLAY": "이미 적용된 planId입니다. 최신 preview/plan을 새로 만든 뒤 apply를 다시 호출하세요.",
77
78
  "MEMO_NOT_FOUND": "list_memos 또는 문서 리소스에서 memoId를 다시 확인한 뒤 호출하세요.",
78
79
  "MISSING_NODE": "대상 섹션, 문단, 표, 셀이 아직 존재하는지 조회한 뒤 새 앵커로 다시 실행하세요.",
@@ -81,7 +82,10 @@ DEFAULT_ERROR_SUGGESTIONS: dict[str, str] = {
81
82
  "PIPELINE_ERROR": "details.pipelineCode와 hint를 확인한 뒤 preview부터 다시 생성하세요.",
82
83
  "PLAN_RECORD_MISSING": "preview_edit 또는 plan_edit으로 planId를 새로 발급받은 뒤 apply를 다시 호출하세요.",
83
84
  "PREVIEW_REQUIRED": "preview_edit으로 변경 내용을 확인한 뒤 반환된 planId로 apply_edit을 호출하세요.",
84
- "READ_ONLY_HWP_DOCUMENT": "HWP 바이너리는 직접 저장할 수 없습니다. 먼저 HWPX로 변환한 뒤 편집하세요.",
85
+ "HWP_DOCUMENT_UNREADABLE": "한/글에서 문서를 열어 다시 저장한 뒤 사용하세요. details.hwp5Code가 원인입니다.",
86
+ "HWP_ENCRYPTED_DOCUMENT": "한/글에서 문서 암호·배포용 보호를 풀고 다시 저장한 뒤 사용하세요. DRM 문서는 열 수 없습니다.",
87
+ "HWP_WRITE_UNSUPPORTED": "HWP 5.0으로 쓸 수 없는 내용이 있습니다. 출력 경로를 .hwpx로 지정해 저장하세요.",
88
+ "READ_ONLY_HWP_DOCUMENT": "python-hwpx 6.6.0 이상을 설치하면 .hwp 문서를 그대로 열고 편집·저장할 수 있습니다.",
85
89
  "RENDER_PREVIEW_INVALID_MODE": "mode는 'pages' 또는 'long'으로 지정한 뒤 다시 실행하세요.",
86
90
  "RENDER_PREVIEW_INVALID_SCREENSHOT_MODE": "screenshot은 'auto', 'require', 'off' 중 하나로 지정하세요.",
87
91
  "RENDER_PREVIEW_UNAVAILABLE": "matching python-hwpx 버전을 설치하고 MCP 서버를 새로 시작한 뒤 다시 실행하세요.",
@@ -175,3 +179,52 @@ def build_error_payload(
175
179
  suggestion=suggestion
176
180
  or suggestion_for_error(code, details=details, hint=hint),
177
181
  ).model_dump(exclude_none=True, by_alias=True)
182
+
183
+
184
+ class HwpDocumentError(RuntimeError):
185
+ """An ``.hwp`` request the installed engine cannot carry out."""
186
+
187
+ def __init__(
188
+ self, message: str, *, code: str, details: Optional[Dict[str, Any]] = None
189
+ ) -> None:
190
+ super().__init__(message)
191
+ self.code = code
192
+ self.message = message
193
+ self.details = details
194
+
195
+ def to_payload(self) -> Dict[str, Any]:
196
+ return build_error_payload(
197
+ code=self.code, message=self.message, details=self.details
198
+ )
199
+
200
+
201
+ _HWP5_ENCRYPTED_CODES = frozenset({"hwp5-password", "hwp5-distribution", "hwp5-drm"})
202
+
203
+
204
+ def hwp5_error_payload(exc: BaseException) -> Optional[Dict[str, Any]]:
205
+ """The MCP error for python-hwpx's ``Hwp5Error``; None for any other exception.
206
+
207
+ Matched by its ``hwp5-*`` code so this module needs no core import.
208
+ """
209
+
210
+ hwp5_code = getattr(exc, "code", None)
211
+ if not isinstance(hwp5_code, str) or not hwp5_code.startswith("hwp5-"):
212
+ return None
213
+ details = {"hwp5Code": hwp5_code}
214
+ if hwp5_code == "hwp5-write-unsupported":
215
+ return build_error_payload(
216
+ code="HWP_WRITE_UNSUPPORTED",
217
+ message="문서에 HWP 5.0(.hwp)으로 쓸 수 없는 내용이 있어 아무것도 저장하지 않았습니다.",
218
+ details=details,
219
+ )
220
+ if hwp5_code in _HWP5_ENCRYPTED_CODES:
221
+ return build_error_payload(
222
+ code="HWP_ENCRYPTED_DOCUMENT",
223
+ message="암호·배포용·DRM으로 보호된 HWP 문서는 열 수 없습니다.",
224
+ details=details,
225
+ )
226
+ return build_error_payload(
227
+ code="HWP_DOCUMENT_UNREADABLE",
228
+ message="HWP 5.0(.hwp) 문서를 읽을 수 없습니다.",
229
+ details=details,
230
+ )
@@ -9,6 +9,8 @@ from typing import Any
9
9
 
10
10
 
11
11
  from ..document_state import document_state_payload
12
+ from ..errors import HwpDocumentError
13
+ from ..storage import require_hwpx_package
12
14
  from ..agent_document import (
13
15
  AgentChildLimit,
14
16
  AgentCommandList,
@@ -100,6 +102,7 @@ def apply_document_commands(
100
102
  try:
101
103
  resolved_input = resolve_path(filename)
102
104
  resolved_output = resolve_path(output)
105
+ require_hwpx_package(resolved_input, output=resolved_output)
103
106
  quality_contract.assert_write_capability()
104
107
  store = ScopedIdempotencyStore(
105
108
  RUNTIME_SERVICES.idempotency_cache,
@@ -132,6 +135,8 @@ def apply_document_commands(
132
135
  if Path(state_path).exists():
133
136
  payload.update(document_state_payload(state_path))
134
137
  return payload
138
+ except HwpDocumentError:
139
+ raise
135
140
  except Exception as exc:
136
141
  return agent_batch_error_payload(
137
142
  exc,
@@ -35,6 +35,7 @@ from ..office.compliance import DEFAULT_POLICY, mask_pii
35
35
  from ..runtime_services import RUNTIME_SERVICES
36
36
  from ..upstream import (
37
37
  HP_NS,
38
+ hwp5_conversion_report,
38
39
  open_document,
39
40
  )
40
41
  from ..utils.helpers import default_max_chars, resolve_path, truncate_response
@@ -777,16 +778,19 @@ def get_document_info(filename: str) -> dict:
777
778
  path = resolve_path(filename)
778
779
  doc = open_doc(path)
779
780
  file_size = Path(path).stat().st_size
780
- return _with_document_state(
781
- {
782
- "filename": filename,
783
- "sections": len(doc.sections),
784
- "paragraphs": _paragraph_count(doc),
785
- "tables": _table_count(doc),
786
- "file_size": str(file_size),
787
- },
788
- path,
789
- )
781
+ info: dict[str, Any] = {
782
+ "filename": filename,
783
+ "sections": len(doc.sections),
784
+ "paragraphs": _paragraph_count(doc),
785
+ "tables": _table_count(doc),
786
+ "file_size": str(file_size),
787
+ }
788
+ conversion = hwp5_conversion_report(doc)
789
+ if conversion is not None:
790
+ # An .hwp is edited through the document model; this is what it left out.
791
+ info["format"] = "hwp"
792
+ info["hwpConversion"] = conversion
793
+ return _with_document_state(info, path)
790
794
 
791
795
 
792
796
  def get_document_text(
@@ -7,9 +7,9 @@
7
7
  "candidate": {
8
8
  "pythonHwpx": "6.6.0",
9
9
  "canonicalDistribution": "python-hwpx-automation",
10
- "canonicalAutomation": "7.3.2",
10
+ "canonicalAutomation": "7.3.4",
11
11
  "compatibilityDistribution": "hwpx-mcp-server",
12
- "compatibility": "7.3.2",
12
+ "compatibility": "7.3.4",
13
13
  "plugin": "2.4.0",
14
14
  "contractHash": "5e5c23651f92785a"
15
15
  },
@@ -41,7 +41,7 @@ from pydantic import (
41
41
  from .execution_lock import PUBLIC_MUTATION_LOCK
42
42
  from .office.agent._batch_publication import GuardedSavePipeline
43
43
  from .office.rendering import resolve_hancom_backend
44
- from .storage import build_hwpx_open_safety_report
44
+ from .storage import build_hwpx_open_safety_report, require_hwpx_package
45
45
  from .utils.helpers import resolve_path
46
46
  from .workspace import (
47
47
  WorkspaceMissingParentGuard,
@@ -1374,6 +1374,7 @@ def analyze_mixed_form_plan(
1374
1374
 
1375
1375
  request = _authorize_public_plan(_payload(plan, apply=False))
1376
1376
  source = Path(request["source"])
1377
+ require_hwpx_package(source, output=request["output"])
1377
1378
  source_snapshot = _capture_path_snapshot(source)
1378
1379
  if source_snapshot.data is None:
1379
1380
  raise FileNotFoundError(source)
@@ -1494,6 +1495,7 @@ def _apply_canonical_mixed_form_plan_locked(
1494
1495
 
1495
1496
  compiled = validate_mixed_form_plan(compiled).to_dict()
1496
1497
  source, output = _authorize_compiled_plan(compiled)
1498
+ require_hwpx_package(source, output=output)
1497
1499
  source_snapshot = _capture_path_snapshot(source)
1498
1500
  if source_snapshot.data is None: # pragma: no cover - compiled plan invariant
1499
1501
  raise FileNotFoundError(source)
@@ -149,6 +149,16 @@ def _complete_package(path: str) -> bool:
149
149
  return False
150
150
 
151
151
 
152
+ def _unchanged(path: str, before: os.stat_result) -> bool:
153
+ """True when ``path`` still has the size and modification time in ``before``."""
154
+
155
+ try:
156
+ now = os.stat(path)
157
+ except OSError:
158
+ return False
159
+ return (now.st_size, now.st_mtime_ns) == (before.st_size, before.st_mtime_ns)
160
+
161
+
152
162
  def _promote_file(staged: str, target: str) -> None:
153
163
  """Move a finished file to ``target`` without leaving a partial ``target``.
154
164
 
@@ -442,6 +452,8 @@ class WindowsComOracle(RenderBackend):
442
452
  by a complete package: a failed refresh leaves it as it was. Returns
443
453
  True when the file was re-saved. Only an ``.hwpx`` file is refreshed
444
454
  (Hancom saves it as HWPX), and a link is followed to the file it names.
455
+ A document that changed while Hancom ran (size or modification time)
456
+ is not overwritten: the refresh returns False and the change stays.
445
457
  """
446
458
 
447
459
  src = os.path.realpath(hwpx_path)
@@ -459,6 +471,7 @@ class WindowsComOracle(RenderBackend):
459
471
  try:
460
472
  os.makedirs(os.path.dirname(job_src))
461
473
  os.makedirs(os.path.dirname(job_out))
474
+ before = os.stat(src)
462
475
  shutil.copyfile(src, job_src)
463
476
  except OSError:
464
477
  return False
@@ -467,6 +480,8 @@ class WindowsComOracle(RenderBackend):
467
480
  entry = entries[0] if entries else None
468
481
  if not (isinstance(entry, dict) and entry.get("saved") and _complete_package(job_out)):
469
482
  return False
483
+ if not _unchanged(src, before):
484
+ return False
470
485
  try:
471
486
  _promote_file(job_out, src)
472
487
  except OSError:
@@ -4,13 +4,16 @@ from __future__ import annotations
4
4
 
5
5
  import hashlib
6
6
  import logging
7
+ from io import BytesIO
7
8
  from pathlib import Path
9
+ from zipfile import ZipFile
8
10
  from typing import Any, Callable, Dict, List, Optional, Tuple
9
11
 
12
+ from ..errors import HwpDocumentError, hwp5_error_payload
10
13
  from ..hwp_support import HwpBinaryError, extract_hwp_text
11
14
  from ..core.locator import RegisteredHandle
12
15
  from ..core.context import default_session_lifecycle_policy
13
- from ..storage import DocumentStorage, LocalDocumentStorage
16
+ from ..storage import DocumentStorage, LocalDocumentStorage, require_hwpx_package
14
17
  from ..workspace import (
15
18
  WorkspacePathError,
16
19
  )
@@ -18,6 +21,8 @@ from ..upstream import (
18
21
  HwpxDocument,
19
22
  HwpxOxmlParagraph,
20
23
  HwpxOxmlTable,
24
+ hwpx_view_bytes,
25
+ native_hwp5_supported,
21
26
  )
22
27
 
23
28
  logger = logging.getLogger("hwpx_automation.hwpx_ops")
@@ -158,12 +163,50 @@ class DocumentContext:
158
163
  def _relative_path(self, path: Path) -> str:
159
164
  return self.storage.relative_path(path)
160
165
 
166
+ def _is_legacy_hwp(self, resolved: Path) -> bool:
167
+ """An ``.hwp`` the installed core cannot open; only its preview text is readable."""
168
+
169
+ return resolved.suffix.lower() == ".hwp" and not native_hwp5_supported()
170
+
171
+ def _text_source(self, resolved: Path) -> Path | ZipFile:
172
+ """What the text extractor reads: the HWPX file, or an ``.hwp``'s HWPX model."""
173
+
174
+ if resolved.suffix.lower() != ".hwp":
175
+ return resolved
176
+ try:
177
+ return ZipFile(BytesIO(hwpx_view_bytes(resolved)))
178
+ except Exception as exc:
179
+ hwp_error = self._hwp_error(exc)
180
+ if hwp_error is None:
181
+ raise
182
+ raise hwp_error from exc
183
+
184
+ def _hwp_error(self, exc: BaseException) -> Optional[RuntimeError]:
185
+ """The operation error for an ``.hwp`` refusal, or None for any other failure."""
186
+
187
+ if isinstance(exc, HwpDocumentError):
188
+ return self._new_error(exc.code, exc.message, details=exc.details)
189
+ payload = hwp5_error_payload(exc)
190
+ if payload is None:
191
+ return None
192
+ return self._new_error(
193
+ payload["code"], payload["message"], details=payload.get("details")
194
+ )
195
+
196
+ def _require_hwpx_package(self, source: Path, output: Optional[str] = None) -> None:
197
+ """Refuse ``.hwp`` for editors that patch HWPX package bytes."""
198
+
199
+ try:
200
+ require_hwpx_package(source, output=output)
201
+ except HwpDocumentError as exc:
202
+ raise self._new_error(exc.code, exc.message, details=exc.details) from exc
203
+
161
204
  def _open_document(self, path: str) -> Tuple[HwpxDocument, Path]:
162
205
  resolved = self._resolve_path(path)
163
- if resolved.suffix.lower() == ".hwp":
206
+ if self._is_legacy_hwp(resolved):
164
207
  raise self._new_error(
165
208
  "READ_ONLY_HWP_DOCUMENT",
166
- "HWP 파일은 편집이 불가합니다. 먼저 convert_hwp_to_hwpx 도구로 HWPX 변환 후 편집하세요.",
209
+ "설치된 python-hwpx는 HWP 5.0(.hwp) 문서를 열거나 저장하지 못합니다.",
167
210
  )
168
211
  try:
169
212
  document, resolved = self.storage.open_document(path)
@@ -186,6 +229,9 @@ class DocumentContext:
186
229
  details={"requestedName": Path(path).name},
187
230
  ) from exc
188
231
  except Exception as exc: # pragma: no cover - delegated to backend
232
+ hwp_error = self._hwp_error(exc)
233
+ if hwp_error is not None:
234
+ raise hwp_error from exc
189
235
  raise self._new_error(
190
236
  "DOCUMENT_OPEN_FAILED",
191
237
  f"failed to open '{path}': {exc}",
@@ -299,6 +299,7 @@ class FormFieldService:
299
299
  ) from exc
300
300
 
301
301
  source_path = self._context._resolve_path(path)
302
+ self._context._require_hwpx_package(source_path, output)
302
303
  output_precondition = None
303
304
  if isinstance(self._context.storage, LocalDocumentStorage):
304
305
  output_precondition = (
@@ -468,6 +469,7 @@ class FormFieldService:
468
469
  "installed python-hwpx does not provide hwpx.body_patch.apply_body_ops",
469
470
  ) from exc
470
471
  source_path = self._context._resolve_path(path)
472
+ self._context._require_hwpx_package(source_path, output)
471
473
  output_precondition = None
472
474
  if isinstance(self._context.storage, LocalDocumentStorage):
473
475
  output_precondition = (
@@ -633,6 +635,7 @@ class FormFieldService:
633
635
  ) from exc
634
636
 
635
637
  blank = self._context._resolve_path(path)
638
+ self._context._require_hwpx_package(blank, output)
636
639
  md = self._context._resolve_path(review_md)
637
640
  output_precondition = None
638
641
  if isinstance(self._context.storage, LocalDocumentStorage):
@@ -135,7 +135,7 @@ class PackageValidationService:
135
135
  resolved = self._context._resolve_path(path)
136
136
  patterns = [re.compile(pat) for pat in (forbid_patterns or [])]
137
137
  warnings: List[Dict[str, Any]] = []
138
- with create_text_extractor(resolved) as extractor:
138
+ with create_text_extractor(self._context._text_source(resolved)) as extractor:
139
139
  for paragraph in extractor.iter_document_paragraphs():
140
140
  text = paragraph.text()
141
141
  if max_line_len is not None and len(text) > max_line_len:
@@ -39,7 +39,7 @@ class PlanningService:
39
39
  def _ensure_planner_document(self, doc_id: str, path: str) -> None:
40
40
  resolved = self._context._resolve_path(path)
41
41
  paragraphs: List[str] = []
42
- with create_text_extractor(resolved) as extractor:
42
+ with create_text_extractor(self._context._text_source(resolved)) as extractor:
43
43
  for paragraph in extractor.iter_document_paragraphs():
44
44
  paragraphs.append(paragraph.text(preserve_breaks=True))
45
45
  self._manager.register_document(doc_id, "\n".join(paragraphs))
@@ -18,6 +18,7 @@ from ..upstream import (
18
18
  AnnotationOptions,
19
19
  create_object_finder,
20
20
  create_text_extractor,
21
+ hwp5_conversion_report,
21
22
  )
22
23
 
23
24
  from .context import DocumentContext
@@ -45,7 +46,7 @@ class ReadQueryService:
45
46
  def get_paragraphs_by_handle(self, handle_id: str) -> Dict[str, Any]:
46
47
  handle = self._context.get_registered_handle(handle_id)
47
48
  resolved = self._context._resolve_path(handle.path)
48
- if resolved.suffix.lower() == ".hwp":
49
+ if self._context._is_legacy_hwp(resolved):
49
50
  paragraphs, _, _ = self._context._read_only_hwp_paragraphs(handle.path)
50
51
  hwp_serialized = [
51
52
  ParagraphResourceEntry(paragraphIndex=index, text=text)
@@ -57,7 +58,7 @@ class ReadQueryService:
57
58
  return model.model_dump(by_alias=True)
58
59
 
59
60
  serialized: List[ParagraphResourceEntry] = []
60
- with create_text_extractor(resolved) as extractor:
61
+ with create_text_extractor(self._context._text_source(resolved)) as extractor:
61
62
  for paragraph in extractor.iter_document_paragraphs():
62
63
  serialized.append(
63
64
  ParagraphResourceEntry(
@@ -73,7 +74,7 @@ class ReadQueryService:
73
74
  def get_tables_by_handle(self, handle_id: str) -> Dict[str, Any]:
74
75
  handle = self._context.get_registered_handle(handle_id)
75
76
  resolved = self._context._resolve_path(handle.path)
76
- if resolved.suffix.lower() == ".hwp":
77
+ if self._context._is_legacy_hwp(resolved):
77
78
  model = DocumentTablesResource(handleId=handle.handle_id, tables=[])
78
79
  return model.model_dump(by_alias=True)
79
80
 
@@ -92,7 +93,7 @@ class ReadQueryService:
92
93
 
93
94
  def open_info(self, path: str) -> Dict[str, Any]:
94
95
  resolved = self._context._resolve_path(path)
95
- if resolved.suffix.lower() == ".hwp":
96
+ if self._context._is_legacy_hwp(resolved):
96
97
  paragraphs, _, source = self._context._read_only_hwp_paragraphs(path)
97
98
  stat = resolved.stat()
98
99
  meta = {
@@ -123,6 +124,10 @@ class ReadQueryService:
123
124
  "size": stat.st_size,
124
125
  "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
125
126
  }
127
+ conversion = hwp5_conversion_report(document)
128
+ if conversion is not None:
129
+ meta["format"] = "hwp"
130
+ meta["hwpConversion"] = conversion
126
131
  return {
127
132
  "meta": meta,
128
133
  "sectionCount": section_count,
@@ -169,7 +174,7 @@ class ReadQueryService:
169
174
  with_footnotes: bool = False,
170
175
  ) -> Dict[str, Any]:
171
176
  resolved = self._context._resolve_path(path)
172
- if resolved.suffix.lower() == ".hwp":
177
+ if self._context._is_legacy_hwp(resolved):
173
178
  hwp_paragraphs, _, _ = self._context._read_only_hwp_paragraphs(path)
174
179
  effective_limit = (
175
180
  self._context.paging_limit if limit is None else max(1, limit)
@@ -195,7 +200,7 @@ class ReadQueryService:
195
200
  paragraphs: List[str] = []
196
201
  next_offset: Optional[int] = None
197
202
  start = max(0, offset)
198
- with create_text_extractor(resolved) as extractor:
203
+ with create_text_extractor(self._context._text_source(resolved)) as extractor:
199
204
  paragraph_iter = extractor.iter_document_paragraphs()
200
205
  sentinel = object()
201
206
 
@@ -239,7 +244,7 @@ class ReadQueryService:
239
244
  unique_indexes.add(int(index))
240
245
 
241
246
  resolved = self._context._resolve_path(path)
242
- if resolved.suffix.lower() == ".hwp":
247
+ if self._context._is_legacy_hwp(resolved):
243
248
  paragraphs, _, _ = self._context._read_only_hwp_paragraphs(path)
244
249
  hwp_collected = {
245
250
  idx: paragraphs[idx] for idx in unique_indexes if idx < len(paragraphs)
@@ -266,7 +271,7 @@ class ReadQueryService:
266
271
  )
267
272
 
268
273
  collected: Dict[int, str] = {}
269
- with create_text_extractor(resolved) as extractor:
274
+ with create_text_extractor(self._context._text_source(resolved)) as extractor:
270
275
  for paragraph in extractor.iter_document_paragraphs():
271
276
  para_index = paragraph.index
272
277
  if para_index in unique_indexes and para_index not in collected:
@@ -292,7 +297,7 @@ class ReadQueryService:
292
297
 
293
298
  def text_extract_report(self, path: str, mode: str = "plain") -> Dict[str, Any]:
294
299
  resolved = self._context._resolve_path(path)
295
- if resolved.suffix.lower() == ".hwp":
300
+ if self._context._is_legacy_hwp(resolved):
296
301
  paragraphs, _, source = self._context._read_only_hwp_paragraphs(path)
297
302
  return {
298
303
  "content": "\n".join(paragraphs)
@@ -307,7 +312,7 @@ class ReadQueryService:
307
312
  endnote="inline",
308
313
  control="placeholder",
309
314
  )
310
- with create_text_extractor(resolved) as extractor:
315
+ with create_text_extractor(self._context._text_source(resolved)) as extractor:
311
316
  content = extractor.extract_text(
312
317
  annotations=annotations,
313
318
  include_nested=True,
@@ -322,11 +327,11 @@ class ReadQueryService:
322
327
  lock_keywords: Optional[Sequence[str]] = None,
323
328
  ) -> Dict[str, Any]:
324
329
  resolved = self._context._resolve_path(path)
325
- if resolved.suffix.lower() == ".hwp":
330
+ if self._context._is_legacy_hwp(resolved):
326
331
  paragraphs, _, source = self._context._read_only_hwp_paragraphs(path)
327
332
  else:
328
333
  paragraphs = []
329
- with create_text_extractor(resolved) as extractor:
334
+ with create_text_extractor(self._context._text_source(resolved)) as extractor:
330
335
  for paragraph in extractor.iter_document_paragraphs():
331
336
  paragraphs.append(paragraph.text(preserve_breaks=True))
332
337
  source = "hwpx.text_extractor"
@@ -474,7 +479,7 @@ class ReadQueryService:
474
479
  return snippet
475
480
 
476
481
  pattern = re.compile(query) if is_regex else None
477
- if resolved.suffix.lower() == ".hwp":
482
+ if self._context._is_legacy_hwp(resolved):
478
483
  paragraphs, _, _ = self._context._read_only_hwp_paragraphs(path)
479
484
  for para_index, text in enumerate(paragraphs):
480
485
  if is_regex:
@@ -512,7 +517,7 @@ class ReadQueryService:
512
517
  start = found + len(query)
513
518
  return {"matches": matches}
514
519
 
515
- with create_text_extractor(resolved) as extractor:
520
+ with create_text_extractor(self._context._text_source(resolved)) as extractor:
516
521
  for paragraph in extractor.iter_document_paragraphs():
517
522
  text = paragraph.text()
518
523
  if is_regex:
@@ -558,7 +563,7 @@ class ReadQueryService:
558
563
  max_results: int = 200,
559
564
  ) -> Dict[str, Any]:
560
565
  resolved = self._context._resolve_path(path)
561
- finder = create_object_finder(resolved)
566
+ finder = create_object_finder(self._context._text_source(resolved))
562
567
  objects = []
563
568
  for found in finder.iter(tag=tag_name, limit=max_results):
564
569
  element = found.element
@@ -582,7 +587,7 @@ class ReadQueryService:
582
587
  max_results: int = 200,
583
588
  ) -> Dict[str, Any]:
584
589
  resolved = self._context._resolve_path(path)
585
- finder = create_object_finder(resolved)
590
+ finder = create_object_finder(self._context._text_source(resolved))
586
591
  tag_filter = None if element_type in {None, "", "*"} else element_type
587
592
  attr_matcher: Any = value if value is not None else (lambda _: True)
588
593
  objects = []
@@ -108,6 +108,9 @@ class SavePolicy:
108
108
  details={"path": str(target)},
109
109
  ) from exc
110
110
  except Exception as exc: # pragma: no cover - delegated to backend
111
+ hwp_error = self._context._hwp_error(exc)
112
+ if hwp_error is not None:
113
+ raise hwp_error from exc
111
114
  raise self._context._new_error(
112
115
  "DOCUMENT_SAVE_FAILED",
113
116
  f"failed to save '{target}': {exc}",
@@ -427,6 +427,7 @@ class TransactionService:
427
427
  ) from exc
428
428
 
429
429
  source_path = self._context._resolve_path(path)
430
+ self._context._require_hwpx_package(source_path, output)
430
431
  output_precondition = None
431
432
  if isinstance(self._context.storage, LocalDocumentStorage):
432
433
  output_precondition = self._context.storage.capture_output_precondition(
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import json
7
7
  import re
8
+ import zipfile
8
9
  from typing import Any, cast
9
10
 
10
11
  import mcp.types as mcp_types
@@ -17,7 +18,12 @@ from hwpx_automation.office.agent.model import RECOVERABILITY
17
18
  from . import __version__
18
19
  from . import quality as quality_contract
19
20
  from .configuration import env_value
20
- from .errors import build_error_payload, mcp_code_for_error
21
+ from .errors import (
22
+ HwpDocumentError,
23
+ build_error_payload,
24
+ hwp5_error_payload,
25
+ mcp_code_for_error,
26
+ )
21
27
  from .fastmcp_adapter import configure_runtime
22
28
  from .hwpx_ops import HwpxOperationError
23
29
  from .network_policy import NetworkPolicyError
@@ -81,11 +87,28 @@ def _exception_chain(exc: BaseException) -> list[BaseException]:
81
87
  return chain
82
88
 
83
89
 
90
+ def _document_error_payload(item: BaseException) -> dict[str, Any] | None:
91
+ """The payload for an operation, ``.hwp`` or non-package failure; else None."""
92
+
93
+ if isinstance(item, (HwpxOperationError, HwpDocumentError)):
94
+ return item.to_payload()
95
+ hwp5_payload = hwp5_error_payload(item)
96
+ if hwp5_payload is not None:
97
+ return hwp5_payload
98
+ if isinstance(item, zipfile.BadZipFile):
99
+ return build_error_payload(
100
+ code="HWPX_PACKAGE_REQUIRED",
101
+ message="이 도구는 HWPX 패키지(ZIP)를 읽는데, 대상 파일은 HWPX 패키지가 아닙니다.",
102
+ )
103
+ return None
104
+
105
+
84
106
  def _classified_error_payload(exc: BaseException | None) -> dict[str, Any]:
85
107
  if exc is not None:
86
108
  for item in _exception_chain(exc):
87
- if isinstance(item, HwpxOperationError):
88
- return item.to_payload()
109
+ document_payload = _document_error_payload(item)
110
+ if document_payload is not None:
111
+ return document_payload
89
112
  if isinstance(item, AgentContractError):
90
113
  return build_error_payload(
91
114
  code=item.code,
@@ -32,7 +32,18 @@ else:
32
32
  _OPEN_SAFETY_CLASSIFIER_IMPORT_ERROR = None
33
33
 
34
34
  from . import quality as quality_contract
35
- from .upstream import HwpxDocument, open_document, validate_document_path
35
+ from .errors import HwpDocumentError
36
+ from .upstream import (
37
+ HwpxDocument,
38
+ hwp5_conversion_report,
39
+ hwp5_document_bytes,
40
+ hwpx_view_bytes,
41
+ is_hwp5_source,
42
+ is_hwp5_target,
43
+ native_hwp5_supported,
44
+ open_document,
45
+ validate_document_path,
46
+ )
36
47
  from .workspace import (
37
48
  LEGACY_SANDBOX_ROOT_ENV,
38
49
  LEGACY_WORKSPACE_ROOTS_ENV,
@@ -231,8 +242,7 @@ class LocalDocumentStorage:
231
242
 
232
243
  def open_document(self, path: str) -> Tuple[HwpxDocument, Path]:
233
244
  resolved = self.resolve_path(path)
234
- require_hwpx_editor_open_safe(resolved, role="local HWPX open")
235
- document = open_document(resolved)
245
+ document = open_local_document(resolved, role="local HWPX open")
236
246
  return document, resolved
237
247
 
238
248
  def save_document(
@@ -241,12 +251,15 @@ class LocalDocumentStorage:
241
251
  # General document saves use the SavePipeline gate. Byte-preserving
242
252
  # form writers have their own guarded open-safety publication path.
243
253
  quality_contract.assert_write_capability()
254
+ if is_hwp5_target(target):
255
+ require_native_hwp5()
244
256
  guard = self.capture_output_guard(target)
245
257
  self.maybe_backup(target)
246
258
  pre_save_snapshot = build_hwpx_presave_snapshot(target)
247
259
  # Validate in an isolated temp, then publish through the identity-bound
248
260
  # workspace guard. Candidate creation never follows the output parent.
249
- tmp_fd, tmp_path_str = tempfile.mkstemp(suffix=target.suffix)
261
+ # The gate always checks HWPX parts, so an .hwp target is staged as HWPX.
262
+ tmp_fd, tmp_path_str = tempfile.mkstemp(suffix=_staging_suffix(target))
250
263
  tmp_path = Path(tmp_path_str)
251
264
  try:
252
265
  os.close(tmp_fd)
@@ -259,7 +272,8 @@ class LocalDocumentStorage:
259
272
  "saved HWPX failed open-safety verification: "
260
273
  + verification_report["openSafety"]["summary"]
261
274
  )
262
- self.atomic_write_bytes(guard, tmp_path.read_bytes())
275
+ data = _published_bytes(document, target, tmp_path, verification_report)
276
+ self.atomic_write_bytes(guard, data)
263
277
  verification_report["filePath"] = str(target)
264
278
  verification_report["visualComplete"] = quality_contract.visual_complete_block(report)
265
279
  return verification_report
@@ -304,6 +318,75 @@ def require_hwpx_editor_open_safe(
304
318
  return open_safety
305
319
 
306
320
 
321
+ def require_native_hwp5() -> None:
322
+ """Refuse ``.hwp`` work clearly when the installed core cannot read or write it."""
323
+
324
+ if not native_hwp5_supported():
325
+ raise HwpDocumentError(
326
+ "설치된 python-hwpx는 HWP 5.0(.hwp) 문서를 열거나 저장하지 못합니다.",
327
+ code="READ_ONLY_HWP_DOCUMENT",
328
+ )
329
+
330
+
331
+ def open_local_document(path: Path, *, role: str) -> HwpxDocument:
332
+ """Open a local HWPX or HWP 5.0 document for editing.
333
+
334
+ An HWPX package must pass the editor open-safety check first. An ``.hwp``
335
+ file is converted by python-hwpx, which refuses damaged, encrypted and
336
+ DRM files itself (``Hwp5Error``).
337
+ """
338
+
339
+ if is_hwp5_source(path):
340
+ require_native_hwp5()
341
+ return open_document(path)
342
+ require_hwpx_editor_open_safe(path, role=role)
343
+ return open_document(path)
344
+
345
+
346
+ def require_hwpx_package(
347
+ source: Path | str, *, output: Path | str | None = None
348
+ ) -> None:
349
+ """Refuse an ``.hwp`` source or output for editors that patch HWPX package bytes."""
350
+
351
+ if is_hwp5_source(source) or (output is not None and is_hwp5_target(output)):
352
+ raise HwpDocumentError(
353
+ "이 도구는 HWPX 패키지의 바이트를 직접 고치므로 HWP 5.0(.hwp) 문서를 입력이나 출력으로 쓸 수 없습니다.",
354
+ code="HWPX_PACKAGE_REQUIRED",
355
+ )
356
+
357
+
358
+ def _staging_suffix(target: Path) -> str:
359
+ return ".hwpx" if is_hwp5_target(target) else (target.suffix or ".hwpx")
360
+
361
+
362
+ def _published_bytes(
363
+ document: HwpxDocument,
364
+ target: Path,
365
+ staged: Path,
366
+ verification_report: Dict[str, Any],
367
+ ) -> bytes:
368
+ """The bytes to publish at *target*: the verified HWPX, or HWP 5.0 for ``.hwp``.
369
+
370
+ The HWP 5.0 bytes are reopened before anything is written, and the report
371
+ records what an ``.hwp`` source could not carry into the document model.
372
+ """
373
+
374
+ conversion = hwp5_conversion_report(document)
375
+ if conversion is not None and (conversion["unconverted"] or conversion["dropped"]):
376
+ verification_report["hwpConversion"] = conversion
377
+ verification_report["warnings"].append(
378
+ "content the .hwp source could not carry into the document model is not in the saved file"
379
+ )
380
+ if not is_hwp5_target(target):
381
+ return staged.read_bytes()
382
+ data = hwp5_document_bytes(document)
383
+ reopened = open_document(data)
384
+ reopened.close()
385
+ verification_report["format"] = "hwp"
386
+ verification_report["fileSizeBytes"] = len(data)
387
+ return data
388
+
389
+
307
390
  @dataclass(slots=True)
308
391
  class _RestDocumentClient:
309
392
  """Default HTTP client used by :class:`HttpDocumentStorage`."""
@@ -423,7 +506,10 @@ class HttpDocumentStorage:
423
506
  try:
424
507
  with os.fdopen(tmp_fd, "wb") as tmp_fh:
425
508
  tmp_fh.write(payload)
426
- require_hwpx_editor_open_safe(tmp_path, role="HTTP storage open")
509
+ if is_hwp5_source(tmp_path):
510
+ require_native_hwp5()
511
+ else:
512
+ require_hwpx_editor_open_safe(tmp_path, role="HTTP storage open")
427
513
  os.replace(tmp_path, local_path)
428
514
  except Exception:
429
515
  tmp_path.unlink(missing_ok=True)
@@ -437,6 +523,8 @@ class HttpDocumentStorage:
437
523
  self, document: HwpxDocument, target: Path, *, quality: Any = None
438
524
  ) -> Dict[str, Any]:
439
525
  quality_contract.assert_write_capability()
526
+ if is_hwp5_target(target):
527
+ require_native_hwp5()
440
528
  remote_key = str(target)
441
529
  cache_path = self._cache.get(remote_key)
442
530
  if cache_path is None:
@@ -445,7 +533,7 @@ class HttpDocumentStorage:
445
533
 
446
534
  pre_save_snapshot = build_hwpx_presave_snapshot(cache_path if cache_path.exists() else None)
447
535
  tmp_fd, tmp_path_str = tempfile.mkstemp(
448
- suffix=cache_path.suffix or ".hwpx",
536
+ suffix=_staging_suffix(cache_path),
449
537
  dir=str(cache_path.parent),
450
538
  )
451
539
  tmp_path = Path(tmp_path_str)
@@ -460,7 +548,9 @@ class HttpDocumentStorage:
460
548
  + verification_report["openSafety"]["summary"]
461
549
  )
462
550
  verification_report["visualComplete"] = quality_contract.visual_complete_block(report)
463
- payload = tmp_path.read_bytes()
551
+ payload = _published_bytes(document, target, tmp_path, verification_report)
552
+ if is_hwp5_target(target):
553
+ tmp_path.write_bytes(payload)
464
554
  except quality_contract.QualityGateError:
465
555
  tmp_path.unlink(missing_ok=True)
466
556
  raise
@@ -491,6 +581,19 @@ def build_hwpx_presave_snapshot(
491
581
  return _collect_hwpx_snapshot(source)
492
582
 
493
583
 
584
+ def _hwpx_view(source: HwpxVerificationSource) -> HwpxVerificationSource:
585
+ """*source* itself, or for an ``.hwp`` the HWPX package of its document model.
586
+
587
+ HWPX verification reads package parts, so an HWP 5.0 file is checked
588
+ through the model python-hwpx opens it to.
589
+ """
590
+
591
+ if is_hwp5_source(source):
592
+ require_native_hwp5()
593
+ return hwpx_view_bytes(source)
594
+ return source
595
+
596
+
494
597
  def _issue_messages(report: Any, attr_name: str = "issues") -> List[str]:
495
598
  return [str(issue) for issue in getattr(report, attr_name, ())]
496
599
 
@@ -520,6 +623,7 @@ def build_hwpx_open_safety_report(
520
623
  document_payload: Dict[str, Any]
521
624
  reopen_payload: Dict[str, Any]
522
625
 
626
+ source = _hwpx_view(source)
523
627
  dependency_error = _open_safety_dependency_error()
524
628
  if dependency_error is not None:
525
629
  package_payload = {
@@ -606,8 +710,9 @@ def build_hwpx_verification_report(
606
710
  *,
607
711
  file_path: Path | None = None,
608
712
  ) -> Dict[str, Any]:
609
- snapshot = _collect_hwpx_snapshot(source)
610
- open_safety = build_hwpx_open_safety_report(source)
713
+ view = _hwpx_view(source)
714
+ snapshot = _collect_hwpx_snapshot(view)
715
+ open_safety = build_hwpx_open_safety_report(view)
611
716
  displayed_path = file_path or (source if isinstance(source, Path) else None)
612
717
  file_size = len(source) if isinstance(source, bytes) else source.stat().st_size
613
718
  totals = snapshot["totals"]
@@ -681,6 +786,7 @@ def _collect_hwpx_snapshot(source: HwpxVerificationSource) -> Dict[str, Any]:
681
786
  "suspiciousPatterns": 0,
682
787
  }
683
788
 
789
+ source = _hwpx_view(source)
684
790
  archive_source = BytesIO(source) if isinstance(source, bytes) else source
685
791
  with zipfile.ZipFile(archive_source) as archive:
686
792
  names = set(archive.namelist())
@@ -12,6 +12,7 @@ from copy import deepcopy
12
12
  from os import PathLike
13
13
  from typing import Any, Iterable, Literal
14
14
  from xml.etree import ElementTree as ET
15
+ from zipfile import ZipFile
15
16
 
16
17
  from .compat import patch_python_hwpx
17
18
 
@@ -59,6 +60,59 @@ def open_document(source: str | PathLike[str] | Any) -> HwpxDocument:
59
60
  return HwpxDocument.open(source)
60
61
 
61
62
 
63
+ # HWP 5.0 (.hwp) files are OLE2 compound files; HWPX files are ZIP packages.
64
+ _OLE2_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
65
+
66
+
67
+ def native_hwp5_supported() -> bool:
68
+ """Whether the installed python-hwpx opens and writes ``.hwp`` itself (6.6.0+)."""
69
+
70
+ return hasattr(HwpxDocument, "conversion_report")
71
+
72
+
73
+ def is_hwp5_source(source: str | PathLike[str] | bytes) -> bool:
74
+ """Whether *source* holds an HWP 5.0 file, judged by its leading bytes."""
75
+
76
+ if isinstance(source, bytes):
77
+ return source[:8] == _OLE2_MAGIC
78
+ try:
79
+ with open(source, "rb") as stream:
80
+ return stream.read(8) == _OLE2_MAGIC
81
+ except OSError:
82
+ return False
83
+
84
+
85
+ def is_hwp5_target(path: str | PathLike[str]) -> bool:
86
+ """Whether saving to *path* writes HWP 5.0; the extension picks the format."""
87
+
88
+ return str(path).lower().endswith(".hwp")
89
+
90
+
91
+ def hwp5_conversion_report(document: HwpxDocument) -> dict[str, dict[str, int]] | None:
92
+ """What opening an ``.hwp`` left out of the model; None for an HWPX source."""
93
+
94
+ report = getattr(document, "conversion_report", None)
95
+ if report is None:
96
+ return None
97
+ return {"unconverted": dict(report.unconverted), "dropped": dict(report.dropped)}
98
+
99
+
100
+ def hwp5_document_bytes(document: HwpxDocument) -> bytes:
101
+ """Serialize *document* as HWP 5.0; core raises ``Hwp5Error`` before writing anything."""
102
+
103
+ return document.to_bytes(format="hwp")
104
+
105
+
106
+ def hwpx_view_bytes(source: str | PathLike[str] | bytes) -> bytes:
107
+ """The HWPX package of the document model an ``.hwp`` source opens to."""
108
+
109
+ document = HwpxDocument.open(source)
110
+ try:
111
+ return document.to_bytes()
112
+ finally:
113
+ document.close()
114
+
115
+
62
116
  def new_document() -> HwpxDocument:
63
117
  return HwpxDocument.new()
64
118
 
@@ -71,11 +125,11 @@ def open_package(path: str | PathLike[str]) -> HwpxPackage:
71
125
  return HwpxPackage.open(path)
72
126
 
73
127
 
74
- def create_text_extractor(path: str | PathLike[str]) -> TextExtractor:
128
+ def create_text_extractor(path: str | PathLike[str] | ZipFile) -> TextExtractor:
75
129
  return TextExtractor(path)
76
130
 
77
131
 
78
- def create_object_finder(path: str | PathLike[str]) -> ObjectFinder:
132
+ def create_object_finder(path: str | PathLike[str] | ZipFile) -> ObjectFinder:
79
133
  return ObjectFinder(path)
80
134
 
81
135
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hwpx-automation
3
- Version: 7.3.2
3
+ Version: 7.3.4
4
4
  Summary: Task-oriented HWPX document automation for Python, with an optional MCP adapter.
5
5
  Author: Kohkyuhyun
6
6
  License-Expression: Apache-2.0
@@ -74,7 +74,7 @@ Dynamic: license-file
74
74
 
75
75
  <!-- release-state: release-approved -->
76
76
  > [!NOTE]
77
- > 발행이 승인된 7.3.2 후보 (release-approved; 원격 발행 대기): `python-hwpx 6.6.0 → python-hwpx-automation 7.3.2 → hwpx-plugin 2.4.0` (`5e5c23651f92785a`).
77
+ > 발행이 승인된 7.3.4 후보 (release-approved; 원격 발행 대기): `python-hwpx 6.6.0 → python-hwpx-automation 7.3.4 → hwpx-plugin 2.4.0` (`5e5c23651f92785a`).
78
78
  > 공개 트레인: `python-hwpx 6.6.0 → python-hwpx-automation 7.3.1 → hwpx-plugin 2.4.0`.
79
79
  > 공개 좌표는 core·automation PyPI와 plugin GitHub
80
80
  > Release·marketplace·실제 marketplace 설치 관찰 후에만 승격합니다.
@@ -162,7 +162,7 @@ PDF와 쪽 PNG로 렌더합니다. 별도 환경에서 실행하므로 호출하
162
162
  고정 버전을 건드리지 않습니다.
163
163
 
164
164
  ```bash
165
- uvx --from "python-hwpx-automation[oracle]==7.3.2" \
165
+ uvx --from "python-hwpx-automation[oracle]==7.3.4" \
166
166
  hwpx render-pdf in.hwpx out.pdf --png out/page --json
167
167
  ```
168
168
 
@@ -8,7 +8,7 @@ hwpx_automation/capabilities.py,sha256=6arb_5qH-ZvFcKftDW0r4jWeJI24QfameTZRzLQbz
8
8
  hwpx_automation/compat.py,sha256=c08KFbs1IzbXAZbVAUOzuoWoR8BXnBad313rL8e-WcM,1582
9
9
  hwpx_automation/configuration.py,sha256=kZmkeDPTDgvl1l-QMkdD2vg4rwZsvfk9pMESrpgoMPI,1371
10
10
  hwpx_automation/document_state.py,sha256=18ihSR9i062BaVnni24BEBUPyDIBVwPvKGWFMIVDmjw,2979
11
- hwpx_automation/errors.py,sha256=vDmByHrKBWgMJ7v2WZPvDhXwERehuOAC7AbhyqTxQAM,10810
11
+ hwpx_automation/errors.py,sha256=EJzNnYaJ4cbYZEqp-zCc0nofVSJJGeYjdRcD_cIX0Ro,13277
12
12
  hwpx_automation/execution_lock.py,sha256=-KEYeY7iNaGVV6MwKHA7uacmnLkm_BPzSODLLGnBgMY,459
13
13
  hwpx_automation/fastmcp_adapter.py,sha256=fLCm1hOay3LiQFf0fGFMSCb3Dq36weuJHvOb7ScYUd8,27858
14
14
  hwpx_automation/form_fill.py,sha256=m_e3Awxa2wCPwPNSlTgGH7ytylxV0u8FEqDdAeyyFsw,48717
@@ -16,12 +16,12 @@ hwpx_automation/form_output_models.py,sha256=qRNtqNjsmTynMWRU6p7SWIfcqglWyAxffmO
16
16
  hwpx_automation/hwp_converter.py,sha256=6i1dVntfbAl1esGicvIhquJKDldg0PxGaIdfiE2VzK8,7611
17
17
  hwpx_automation/hwp_support.py,sha256=EbM5PQiK9QDJDwFhA1dCxh3I7-5wst5pJXgcr4VK3tY,3371
18
18
  hwpx_automation/hwpx_ops.py,sha256=39oO_S_dTQHny1qJhJzUjoQTblAKLTQBnIzITlBwyLY,50991
19
- hwpx_automation/identity.json,sha256=6mqoUGAXtjfWC3AgfgYxo7idLydg9i-U3D5I8U5xQH4,9820
19
+ hwpx_automation/identity.json,sha256=1ftzXE-vp1sbPbEvmWZlcIu4QOzH4huWVXzIV5UtUA8,9820
20
20
  hwpx_automation/identity.py,sha256=g62OiZ_Bvo3wL8NCHFPmAEfwDSJY4AG_cc6aBipU9zI,473
21
21
  hwpx_automation/ingest_adapters.py,sha256=7Lm1Pc__tPKdso0rRORLyy1pmUScr8VVmnTnleSYMXo,2726
22
22
  hwpx_automation/markdown_plan.py,sha256=n2H_vhRM-el6Y5KLi21tz-4WzK78-wlE8i8hsUVWu2Y,6886
23
23
  hwpx_automation/mcp_cli.py,sha256=rgLgj4bhOyYMHCpSGMy-KgryVieH4nFbV1Y4sV98Slo,798
24
- hwpx_automation/mixed_form.py,sha256=UHDqqM7OrSb7ZBN0V-0VspA_TFcx3v9xQkTxVUsyAXk,108724
24
+ hwpx_automation/mixed_form.py,sha256=EML3LWACXxEp3Z2BJAG9jG7ARik0Zj8UT1zSeVRRs0Y,108853
25
25
  hwpx_automation/mutation_models.py,sha256=SEIZYPxlp2FvnFClOsWS08KWBIL7kiMsvX1n6STpJTE,11690
26
26
  hwpx_automation/network_policy.py,sha256=ZemScVr_7w1XbAtuBjVVhO3XMiditjyn5FzQjcaER40,7512
27
27
  hwpx_automation/preview_output_models.py,sha256=eKreT-GLLxsUnz3I7nqRjAvMOwbBAbHJsACSVPr5ma0,2490
@@ -30,38 +30,38 @@ hwpx_automation/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
30
30
  hwpx_automation/quality.py,sha256=D4S1Zx57V2yEzLPADGk9n_PxuNnxgC5Qw0gnu_72AEQ,13052
31
31
  hwpx_automation/quality_generation.py,sha256=pakrQ2K0dLC0faCAi7KVr2k09fEBCz8K-M9Qtjr8Mp4,30427
32
32
  hwpx_automation/resources.py,sha256=ksAU7yLkp66h7XOizFwVVXnQ7DBdTx7qYJ81-lnJygY,4859
33
- hwpx_automation/runtime.py,sha256=e8L3IFLbiqKkrIzyHFBRjoI2K9jkgxSuwr_vWJdFa_c,13029
33
+ hwpx_automation/runtime.py,sha256=QRby9BiHIJfAJogsDsApBlXcLS8oSKVwB3fVFKSdYrw,13789
34
34
  hwpx_automation/runtime_services.py,sha256=E74uv1RWasAF93hk-g1zIcWZVvgsOB1nL77zmTmFhYM,3567
35
35
  hwpx_automation/server.py,sha256=U2IxqrGJ1EBjNnY8QzJah7ZG0MdXEVZcR0NM1FJ7Qhg,12224
36
- hwpx_automation/storage.py,sha256=LsIZpp5nc3E9MlG3Wwmewr-527vjO8UDA0w7P2Mgkt8,28969
36
+ hwpx_automation/storage.py,sha256=DfHKo8Q7sRtV44SVCV2V5jkexnkpY7oCZgfUXXg5rFQ,32652
37
37
  hwpx_automation/tool_bindings.py,sha256=aHl8N8yTtFrmGKRjdKlNMZEed1qVvC27xF9Y-cfY10I,9816
38
38
  hwpx_automation/tool_contract.py,sha256=z8W2RIUSAgiNH8F3TTZhSIKz_L2AYThy6I4uULYzR7c,38794
39
- hwpx_automation/upstream.py,sha256=WAyly1-iky7WFzD2ZrPg1K6kurz2up0U_1oNW8wGDug,25830
39
+ hwpx_automation/upstream.py,sha256=4JPxC0BzhfVDyLNpnnYGwQ5NNXNXy0YSLu23oPLQP3c,27569
40
40
  hwpx_automation/visual_qa.py,sha256=ei2WnH0YvXrCQKa0WmnxaWNX_whRQfSLyDoClEHk0Qs,29813
41
41
  hwpx_automation/workspace.py,sha256=WogK_yXmGG42kRMhtruLncRJ6WoKqVh3eMfnItEWtLs,67525
42
42
  hwpx_automation/core/__init__.py,sha256=OHgM3he4rGageZEFB4iNv2ni7c6mr1xNNVfMGO1bzz4,74
43
- hwpx_automation/core/content.py,sha256=_S_k3lFDmTrr0F4yOrPo60zpaWMTq0d3mAXMmwe5GVA,31233
43
+ hwpx_automation/core/content.py,sha256=K3iogv6Qsb9xy3wPud6QYSZZRJvPtWKuWvK8rG76qqc,31789
44
44
  hwpx_automation/core/context.py,sha256=cG6XC5TpCB-_D8QDnqRsOwpxdstQWfb0gtmYBDzNV50,2966
45
45
  hwpx_automation/core/diff.py,sha256=9_YLUyO88Yepvq-Hdr35fakjJ0plc1Krkc0IErMvGMo,1522
46
- hwpx_automation/core/document.py,sha256=_7jLJOfU4WdVnOk9tqzhk-bnVqE95-PYueUskCsUT5M,1179
46
+ hwpx_automation/core/document.py,sha256=3PradsOWv6akKEsA1cM8XaUwp9NDI5aqIEtD1yxkYuY,1135
47
47
  hwpx_automation/core/formatting.py,sha256=FsomTQeTC385wreflLhwzP18kCD-jLfP9j1Xu0fHYVI,15770
48
48
  hwpx_automation/core/handles.py,sha256=v7HCAW2cWVu3OarPiDFRSixNncP1FIVRcTIPp5MeWAA,705
49
49
  hwpx_automation/core/locations.py,sha256=FVbgLhGka9jaUeU2lG8illML15cxi31ouWP05oQBzI4,7634
50
50
  hwpx_automation/core/locator.py,sha256=Q3CfVniPaMSXQ2ABV0JXmTNROkHtKZLz5VR4mCmlJs8,5122
51
51
  hwpx_automation/core/plan.py,sha256=sNFIko5USDhvs7NA_gy4hSUV40q4vIQTc_dZ2d-nFXE,22744
52
52
  hwpx_automation/core/resources.py,sha256=PNPhcGEwq4BrRdMrytgIfeT2_4nrDFB9z97OfxCaQBc,1198
53
- hwpx_automation/core/search.py,sha256=OmUs0wLPxMrmkhbBiiiQQC-YKG8zjz4bHvJbmCae_eU,12280
54
- hwpx_automation/core/transactions.py,sha256=wViQxRy0FPlPQKtDZSeFSKxjnK632RtQYN7XI5bRrlU,16452
53
+ hwpx_automation/core/search.py,sha256=M5tSM9DUPvX0Qza6mphkP98PlM41irqGEf1R67C6-Vo,12575
54
+ hwpx_automation/core/transactions.py,sha256=4Dn13n3fmDl7PHZ3AGRyf09gioNLcC-TgD6-TrqHqzQ,16909
55
55
  hwpx_automation/core/txn.py,sha256=gXbv3JFRnYnWY3L57YeTdpFyQaBBMlvLd-dix7NVFog,1449
56
56
  hwpx_automation/handlers/__init__.py,sha256=iLzNXNd-ernCvKTi4V8h2XzvFDoM4biZp34KIL0fgsg,83
57
57
  hwpx_automation/handlers/_shared.py,sha256=DShvk2vhvtc6Ufy-gVol_S9yP-u_2Gj-iuxArQVne4o,13009
58
- hwpx_automation/handlers/agent_document.py,sha256=4ZtJOIxFrimYoIAPgVXH8jMUmT4xDzMCBct9wuiAYkA,8195
58
+ hwpx_automation/handlers/agent_document.py,sha256=RBA3A8braZ3B2zMqSavUHZ_Uk_M1Olwm2O4-SwTVS5U,8388
59
59
  hwpx_automation/handlers/authoring.py,sha256=7qcFFViTUZQyjhpvNbE6zD8QOsMpDOOMTV8sUKV9MZs,26209
60
60
  hwpx_automation/handlers/content_edit.py,sha256=qZH5TcMrSt4zNYCI6Wy_oCiilLLi7d1RYvHQNDjPVKo,42291
61
61
  hwpx_automation/handlers/form_fill.py,sha256=oAalDItZwIinHWYIvMUpEIZTPKXpasGSgWoKyQQLYVw,22382
62
62
  hwpx_automation/handlers/layout_style.py,sha256=j-vivR4KHbrSAfslx1c2Lba__b8zv-S6-epTjmwbBRs,23705
63
63
  hwpx_automation/handlers/quality_render.py,sha256=JK6xedmHYtf8KWrYzI5A2L69pI_Dz0nfQqibz2L5SM8,24763
64
- hwpx_automation/handlers/read_export.py,sha256=tUelild88uWMrypZeilbM4fod70eF5h-skdAnIPOYCg,44840
64
+ hwpx_automation/handlers/read_export.py,sha256=f8phMXVUlUIsY1WWgXonKCLpnqqXmNpa0kFNfBrayws,45085
65
65
  hwpx_automation/handlers/specialized.py,sha256=wvq0P-7MiZCN36Ba4Q1V-9Tl_T7MB3Cn0rC4RGjxt_k,29223
66
66
  hwpx_automation/handlers/tracked_changes.py,sha256=bYlVFVsuF6DSvlxOdvv4MavPI2IByudTkYl8_NDoN8U,20598
67
67
  hwpx_automation/handlers/workflow.py,sha256=xjkuocWyFKFFdPYX2sQDxtwyGZwn1hqX2SZ6e-TpIBI,2988
@@ -179,7 +179,7 @@ hwpx_automation/office/rendering/detectors.py,sha256=k7t79nWIBSqStNNdwPohmUG2zwr
179
179
  hwpx_automation/office/rendering/diff.py,sha256=SJW7HgDyBFbJDL2yHM1cCnCSkGP7CoYuqonABoWh_eo,5743
180
180
  hwpx_automation/office/rendering/fixture_corpus.py,sha256=fqx9rRwLCBZ0unhgWCm8V-_xs5597AbBdiDaW5ey5M8,7595
181
181
  hwpx_automation/office/rendering/mac_session.py,sha256=DwNpzWwhxIhu_v5PBlLZd6YcPuMlcXREhswKsvIV82k,4021
182
- hwpx_automation/office/rendering/oracle.py,sha256=jlDyXpeaDAPxzrgdiMpJXWYOyLQCgJ6NubNKx6QstaQ,44211
182
+ hwpx_automation/office/rendering/oracle.py,sha256=dzsRIEmmC7NVzWY0ZhScT0L8jyDlz5HQJXES9U_BKUs,44782
183
183
  hwpx_automation/office/rendering/page_qa.py,sha256=BG5kqboMg7SyAi6tAi78LYq1ZmRh30uo72JpPN4Hk2Q,7352
184
184
  hwpx_automation/office/rendering/qa_contracts.py,sha256=1cjoiRTAWgi7NgE8SPc6bKxdoUfKx9nyou1mgC47TxY,10506
185
185
  hwpx_automation/office/rendering/qa_metrics.py,sha256=ijskhoieoEGk4bwos70XAvA8qDVTOOu4CJUH783cB9w,9859
@@ -190,17 +190,17 @@ hwpx_automation/ops_services/__init__.py,sha256=OUtolsBwPX7kzfmOmanHZK98HFU5ylYw
190
190
  hwpx_automation/ops_services/_border_fill.py,sha256=2ruoojd7xLvbC829O95mYnE75RgjbAHcYRZ0HymTMWw,9143
191
191
  hwpx_automation/ops_services/composition.py,sha256=xMWDquck6Ii8YQlUZwS3oW5F753FgN64tbSv8O32xWA,1944
192
192
  hwpx_automation/ops_services/content_layout.py,sha256=2iq1V2UbTJy299Vl-iTPBvvN8VETAs0fDVUu4SJVKGw,19488
193
- hwpx_automation/ops_services/context.py,sha256=9-p-uQ9KtG7NlZfK64gCDiU0ZwobeYApBqb3x4EAdUM,8397
194
- hwpx_automation/ops_services/form_fields.py,sha256=myuKlTyLQC-ievDPjWtdben_PF--qZS6epnBmUrBjEM,31259
193
+ hwpx_automation/ops_services/context.py,sha256=LT7rAio0MgChQ5PDt50sZH_XlrPbpV6QTKoyKS8bXZ4,10240
194
+ hwpx_automation/ops_services/form_fields.py,sha256=uc_1a-M1JClRGVbRfxhxWNDI1lsy29nJVAFZmxbRf20,31448
195
195
  hwpx_automation/ops_services/media.py,sha256=ewqIPMZVFxesh9Sr1Uc7HcX6Es2GIdHRhh2J9IQq6LM,7945
196
196
  hwpx_automation/ops_services/memo_style.py,sha256=j5y5gRjkrLRNnH7q_u855raKfHUAcCURpqHTe09eFso,16585
197
- hwpx_automation/ops_services/package_validation.py,sha256=ICMqGuuxUoGRTQL8uGvsdlaGShLip67Oj5JENSrHabo,6321
198
- hwpx_automation/ops_services/planning.py,sha256=_ZNerdBZ_DERzSsJ5WMJF5Z6xgl6yHjbgJNIuXxSGy8,6625
197
+ hwpx_automation/ops_services/package_validation.py,sha256=vPxHG174N8MIbkEVareO73speR-rYNtve9zSpWVChAI,6349
198
+ hwpx_automation/ops_services/planning.py,sha256=3yN3e-5zdD6pFuIKoB5IvwuVX0hLVGX6hGoppHJfYDI,6653
199
199
  hwpx_automation/ops_services/preview_export.py,sha256=1NAQT3j1Ej98MIv4pCDDnYd83w4tTemHmoFWTidRWHw,23504
200
- hwpx_automation/ops_services/read_query.py,sha256=9qn0GQdfrm_ImlSMrDulNngDcB8wD7caFbLVDxrcPQo,23192
201
- hwpx_automation/ops_services/save_policy.py,sha256=-YMEfwzR9gsrED5EUPgeGkGDFbQ76m4NXaYEkqEoV1Q,23686
200
+ hwpx_automation/ops_services/read_query.py,sha256=z13Exfvweu_qhTFW0EBR8sn5GaVWhe9HlLuFkuNUaug,23655
201
+ hwpx_automation/ops_services/save_policy.py,sha256=sOh6FDy9E6niI6KnwpzWbIf-hnGrmxGw9oOG15vnsi4,23819
202
202
  hwpx_automation/ops_services/tables.py,sha256=psGpwWY8B2Gx611sRri4X7xIQaiZ0DtZvOadj8n1t14,19089
203
- hwpx_automation/ops_services/transactions.py,sha256=Z4OUQiRKfrt4nUceqqy4nDF8p14dFevbFKsb0Rafhps,26317
203
+ hwpx_automation/ops_services/transactions.py,sha256=iLSrZOj8a2q3tGcCeVQu0-2xH-NqPt5YoQgz37qkNIo,26382
204
204
  hwpx_automation/utils/__init__.py,sha256=MIE-UrcJ-Trz80UxicViKiGCXNqe3L5lC1f0acVZwUM,65
205
205
  hwpx_automation/utils/helpers.py,sha256=nTuzGdXmhDJFODSs2pQZNNOdvrKeU457rJq4Vo3W5cY,762
206
206
  hwpx_automation/utils/read_budget.py,sha256=K71qu5D9fblRXFvSQqe8e7yFOMRK0UuWR5xSpq350Jk,4416
@@ -219,10 +219,10 @@ hwpx_automation/workflow/rendering.py,sha256=MAuL3vSS0vfo-R8ws55E52ddnBrtoYmYikT
219
219
  hwpx_automation/workflow/service.py,sha256=dKhcWQVHVBh8752pIDPSvHPXpsQsmB25hrJD7IsgObc,35945
220
220
  hwpx_automation/workflow/state_machine.py,sha256=8nFj3WPoqBj0H2j9WRbdAUCT7tm3q_ieQsaEX7LPnE4,2230
221
221
  hwpx_automation/workflow/store.py,sha256=S2BTR5g21WgFYfi8ZwvgWQCv7wxUSA8ttlMS9-zNeoU,31663
222
- python_hwpx_automation-7.3.2.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
223
- python_hwpx_automation-7.3.2.dist-info/licenses/NOTICE,sha256=B6vl9SzBdZ0LlAeEd89Q3P6r_rlXZWD6ZJK6Ntz3s6A,644
224
- python_hwpx_automation-7.3.2.dist-info/METADATA,sha256=jJzZjAs4hDke7LtsNYTGPHd1QGRmvQ5WXPAkVW9384k,13819
225
- python_hwpx_automation-7.3.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
226
- python_hwpx_automation-7.3.2.dist-info/entry_points.txt,sha256=6YDtwFKwyk9BNTR_iCVV0XTwtDRVwENVNvv3wHM81KQ,114
227
- python_hwpx_automation-7.3.2.dist-info/top_level.txt,sha256=jQZZqF1u9rVIJvragSUf8HCHkm0gFVaCViNDZEKfh1A,16
228
- python_hwpx_automation-7.3.2.dist-info/RECORD,,
222
+ python_hwpx_automation-7.3.4.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
223
+ python_hwpx_automation-7.3.4.dist-info/licenses/NOTICE,sha256=B6vl9SzBdZ0LlAeEd89Q3P6r_rlXZWD6ZJK6Ntz3s6A,644
224
+ python_hwpx_automation-7.3.4.dist-info/METADATA,sha256=M2pqQrmq0CGrOdqY4eXWCCcnx-nHDDticK32dOFqieg,13819
225
+ python_hwpx_automation-7.3.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
226
+ python_hwpx_automation-7.3.4.dist-info/entry_points.txt,sha256=6YDtwFKwyk9BNTR_iCVV0XTwtDRVwENVNvv3wHM81KQ,114
227
+ python_hwpx_automation-7.3.4.dist-info/top_level.txt,sha256=jQZZqF1u9rVIJvragSUf8HCHkm0gFVaCViNDZEKfh1A,16
228
+ python_hwpx_automation-7.3.4.dist-info/RECORD,,