python-hwpx 3.2.0__py3-none-any.whl → 3.3.1__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/agent/blueprint/model.py +103 -7
- hwpx/tools/package_validator.py +325 -226
- hwpx/visual/oracle.py +162 -19
- {python_hwpx-3.2.0.dist-info → python_hwpx-3.3.1.dist-info}/METADATA +2 -2
- {python_hwpx-3.2.0.dist-info → python_hwpx-3.3.1.dist-info}/RECORD +10 -10
- {python_hwpx-3.2.0.dist-info → python_hwpx-3.3.1.dist-info}/WHEEL +0 -0
- {python_hwpx-3.2.0.dist-info → python_hwpx-3.3.1.dist-info}/entry_points.txt +0 -0
- {python_hwpx-3.2.0.dist-info → python_hwpx-3.3.1.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-3.2.0.dist-info → python_hwpx-3.3.1.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-3.2.0.dist-info → python_hwpx-3.3.1.dist-info}/top_level.txt +0 -0
hwpx/agent/blueprint/model.py
CHANGED
|
@@ -242,10 +242,7 @@ def _validate_resource(value: object, *, name: str) -> dict[str, Any]:
|
|
|
242
242
|
return record
|
|
243
243
|
|
|
244
244
|
|
|
245
|
-
def
|
|
246
|
-
"""Validate and detach one strict ``hwpx.agent-blueprint/v1`` manifest."""
|
|
247
|
-
|
|
248
|
-
manifest = _object(value, "blueprint")
|
|
245
|
+
def _validate_manifest_header(manifest: dict[str, Any]) -> None:
|
|
249
246
|
_exact_keys(manifest, required=_MANIFEST_KEYS, name="blueprint")
|
|
250
247
|
if manifest["schemaVersion"] != BLUEPRINT_SCHEMA:
|
|
251
248
|
raise AgentContractError("invalid_syntax", "unsupported blueprint schema", target="schemaVersion")
|
|
@@ -257,12 +254,16 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
|
|
|
257
254
|
if mode not in BLUEPRINT_MODES:
|
|
258
255
|
raise AgentContractError("invalid_syntax", "unsupported blueprint mode", target="mode")
|
|
259
256
|
|
|
257
|
+
|
|
258
|
+
def _validate_source(manifest: dict[str, Any]) -> None:
|
|
260
259
|
source = _object(manifest["source"], "source")
|
|
261
260
|
_exact_keys(source, required={"revision", "label"}, name="source")
|
|
262
261
|
if not REVISION_PATTERN.fullmatch(str(source["revision"])):
|
|
263
262
|
raise AgentContractError("invalid_syntax", "source.revision must be sha256", target="source.revision")
|
|
264
263
|
_validate_public_json(source, name="source")
|
|
265
264
|
|
|
265
|
+
|
|
266
|
+
def _validate_root(manifest: dict[str, Any]) -> dict[str, Any]:
|
|
266
267
|
root = _object(manifest["root"], "root")
|
|
267
268
|
_exact_keys(
|
|
268
269
|
root,
|
|
@@ -275,7 +276,12 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
|
|
|
275
276
|
raise AgentContractError("unknown_kind", "root kind is unknown", target="root.kind")
|
|
276
277
|
if not str(root["sourcePath"]).startswith("/"):
|
|
277
278
|
raise AgentContractError("invalid_syntax", "root sourcePath must be semantic", target="root.sourcePath")
|
|
279
|
+
return root
|
|
280
|
+
|
|
278
281
|
|
|
282
|
+
def _validate_nodes(
|
|
283
|
+
manifest: dict[str, Any],
|
|
284
|
+
) -> tuple[list[dict[str, Any]], set[str], dict[str, dict[str, Any]]]:
|
|
279
285
|
nodes = _list(manifest["nodes"], "nodes")
|
|
280
286
|
if not nodes or len(nodes) > MAX_BLUEPRINT_NODES:
|
|
281
287
|
raise AgentContractError("resource_limit", "node count is outside limits", target="nodes")
|
|
@@ -321,7 +327,15 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
|
|
|
321
327
|
_validate_public_json(node["sourceHint"], name=f"{name}.sourceHint")
|
|
322
328
|
detached_nodes.append(node)
|
|
323
329
|
nodes_by_id[node_id] = node
|
|
330
|
+
return detached_nodes, node_ids, nodes_by_id
|
|
331
|
+
|
|
324
332
|
|
|
333
|
+
def _check_node_tree_shape(
|
|
334
|
+
root: dict[str, Any],
|
|
335
|
+
detached_nodes: list[dict[str, Any]],
|
|
336
|
+
node_ids: set[str],
|
|
337
|
+
nodes_by_id: dict[str, dict[str, Any]],
|
|
338
|
+
) -> str:
|
|
325
339
|
if str(root["blueprintId"]) not in node_ids:
|
|
326
340
|
raise AgentContractError("invariant_violation", "root does not reference a node", target="root")
|
|
327
341
|
for index, node in enumerate(detached_nodes):
|
|
@@ -344,6 +358,14 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
|
|
|
344
358
|
raise AgentContractError(
|
|
345
359
|
"invariant_violation", "blueprint nodes must form one rooted tree", target="nodes"
|
|
346
360
|
)
|
|
361
|
+
return root_id
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _check_node_reachability(
|
|
365
|
+
root_id: str,
|
|
366
|
+
node_ids: set[str],
|
|
367
|
+
nodes_by_id: dict[str, dict[str, Any]],
|
|
368
|
+
) -> None:
|
|
347
369
|
visited: set[str] = set()
|
|
348
370
|
visiting: set[str] = set()
|
|
349
371
|
|
|
@@ -364,6 +386,10 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
|
|
|
364
386
|
if visited != node_ids:
|
|
365
387
|
raise AgentContractError("invariant_violation", "blueprint contains orphan nodes", target="nodes")
|
|
366
388
|
|
|
389
|
+
|
|
390
|
+
def _validate_dependency_records(
|
|
391
|
+
manifest: dict[str, Any],
|
|
392
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
|
367
393
|
styles = _list(manifest["styles"], "styles")
|
|
368
394
|
numbering = _list(manifest["numbering"], "numbering")
|
|
369
395
|
resources = _list(manifest["resources"], "resources")
|
|
@@ -382,6 +408,14 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
|
|
|
382
408
|
detached_resources = [
|
|
383
409
|
_validate_resource(item, name=f"resources[{index}]") for index, item in enumerate(resources)
|
|
384
410
|
]
|
|
411
|
+
return detached_styles, detached_numbering, detached_resources
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _check_dependency_uniqueness(
|
|
415
|
+
detached_styles: list[dict[str, Any]],
|
|
416
|
+
detached_numbering: list[dict[str, Any]],
|
|
417
|
+
detached_resources: list[dict[str, Any]],
|
|
418
|
+
) -> list[str]:
|
|
385
419
|
style_keys = [str(item["key"]) for item in detached_styles]
|
|
386
420
|
numbering_keys = [str(item["key"]) for item in detached_numbering]
|
|
387
421
|
resource_keys = [str(item["key"]) for item in detached_resources]
|
|
@@ -393,10 +427,18 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
|
|
|
393
427
|
raise AgentContractError("invariant_violation", "resource asset paths must be unique", target="resources")
|
|
394
428
|
if sum(int(item["size"]) for item in detached_resources) > MAX_TOTAL_ASSET_BYTES:
|
|
395
429
|
raise AgentContractError("resource_limit", "total asset bytes exceed limit", target="resources")
|
|
430
|
+
return dependency_keys
|
|
431
|
+
|
|
396
432
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
433
|
+
def _check_node_dependency_refs(
|
|
434
|
+
detached_nodes: list[dict[str, Any]],
|
|
435
|
+
detached_styles: list[dict[str, Any]],
|
|
436
|
+
detached_numbering: list[dict[str, Any]],
|
|
437
|
+
detached_resources: list[dict[str, Any]],
|
|
438
|
+
) -> None:
|
|
439
|
+
style_key_set = {str(item["key"]) for item in detached_styles}
|
|
440
|
+
numbering_key_set = {str(item["key"]) for item in detached_numbering}
|
|
441
|
+
resource_key_set = {str(item["key"]) for item in detached_resources}
|
|
400
442
|
for index, node in enumerate(detached_nodes):
|
|
401
443
|
if not set(node["styleRefs"]) <= style_key_set:
|
|
402
444
|
raise AgentContractError("invariant_violation", "node has an orphan style reference", target=f"nodes[{index}].styleRefs")
|
|
@@ -405,6 +447,12 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
|
|
|
405
447
|
if not set(node["resourceRefs"]) <= resource_key_set:
|
|
406
448
|
raise AgentContractError("invariant_violation", "node has an orphan resource reference", target=f"nodes[{index}].resourceRefs")
|
|
407
449
|
|
|
450
|
+
|
|
451
|
+
def _validate_reference_records(
|
|
452
|
+
manifest: dict[str, Any],
|
|
453
|
+
node_ids: set[str],
|
|
454
|
+
dependency_keys: list[str],
|
|
455
|
+
) -> tuple[list[dict[str, Any]], dict[str, list[str]]]:
|
|
408
456
|
references = _list(manifest["references"], "references")
|
|
409
457
|
if len(references) > MAX_REFERENCES:
|
|
410
458
|
raise AgentContractError("resource_limit", "reference count exceeds limit", target="references")
|
|
@@ -423,6 +471,13 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
|
|
|
423
471
|
if target in node_ids:
|
|
424
472
|
reference_edges[str(record["from"])].append(target)
|
|
425
473
|
reference_records.append(record)
|
|
474
|
+
return reference_records, reference_edges
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _check_reference_inventory(
|
|
478
|
+
detached_nodes: list[dict[str, Any]],
|
|
479
|
+
reference_records: list[dict[str, Any]],
|
|
480
|
+
) -> None:
|
|
426
481
|
for index, node in enumerate(detached_nodes):
|
|
427
482
|
declared_fields = {
|
|
428
483
|
str(record["field"])
|
|
@@ -431,6 +486,12 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
|
|
|
431
486
|
}
|
|
432
487
|
if set(node["references"]) != declared_fields:
|
|
433
488
|
raise AgentContractError("invariant_violation", "node reference inventory is inconsistent", target=f"nodes[{index}].references")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _check_reference_acyclicity(
|
|
492
|
+
node_ids: set[str],
|
|
493
|
+
reference_edges: dict[str, list[str]],
|
|
494
|
+
) -> None:
|
|
434
495
|
reference_visited: set[str] = set()
|
|
435
496
|
reference_visiting: set[str] = set()
|
|
436
497
|
|
|
@@ -448,6 +509,8 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
|
|
|
448
509
|
for node_id in sorted(node_ids):
|
|
449
510
|
visit_reference(node_id)
|
|
450
511
|
|
|
512
|
+
|
|
513
|
+
def _validate_metadata_sections(manifest: dict[str, Any]) -> list[Any]:
|
|
451
514
|
unsupported = _list(manifest["unsupported"], "unsupported")
|
|
452
515
|
for index, item in enumerate(unsupported):
|
|
453
516
|
record = _object(item, f"unsupported[{index}]")
|
|
@@ -471,7 +534,10 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
|
|
|
471
534
|
if fidelity["ceiling"] not in FIDELITY_LEVELS:
|
|
472
535
|
raise AgentContractError("invalid_syntax", "fidelity ceiling is invalid", target="fidelity.ceiling")
|
|
473
536
|
fidelity["reasons"] = [str(item) for item in _list(fidelity["reasons"], "fidelity.reasons")]
|
|
537
|
+
return unsupported
|
|
474
538
|
|
|
539
|
+
|
|
540
|
+
def _verify_manifest_integrity(manifest: dict[str, Any], verify_hash: bool) -> None:
|
|
475
541
|
_validate_public_json(manifest, name="blueprint")
|
|
476
542
|
declared_hash = manifest["blueprintHash"]
|
|
477
543
|
if not SHA256_PATTERN.fullmatch(str(declared_hash)):
|
|
@@ -479,6 +545,36 @@ def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool =
|
|
|
479
545
|
if verify_hash and declared_hash != blueprint_hash(manifest):
|
|
480
546
|
raise AgentContractError("verification_failed", "blueprintHash mismatch", target="blueprintHash")
|
|
481
547
|
canonical_manifest_bytes(manifest, include_hash=True)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def validate_blueprint_manifest(value: Mapping[str, Any], *, verify_hash: bool = True) -> dict[str, Any]:
|
|
551
|
+
"""Validate and detach one strict ``hwpx.agent-blueprint/v1`` manifest."""
|
|
552
|
+
|
|
553
|
+
manifest = _object(value, "blueprint")
|
|
554
|
+
_validate_manifest_header(manifest)
|
|
555
|
+
_validate_source(manifest)
|
|
556
|
+
root = _validate_root(manifest)
|
|
557
|
+
|
|
558
|
+
detached_nodes, node_ids, nodes_by_id = _validate_nodes(manifest)
|
|
559
|
+
root_id = _check_node_tree_shape(root, detached_nodes, node_ids, nodes_by_id)
|
|
560
|
+
_check_node_reachability(root_id, node_ids, nodes_by_id)
|
|
561
|
+
|
|
562
|
+
detached_styles, detached_numbering, detached_resources = _validate_dependency_records(manifest)
|
|
563
|
+
dependency_keys = _check_dependency_uniqueness(
|
|
564
|
+
detached_styles, detached_numbering, detached_resources
|
|
565
|
+
)
|
|
566
|
+
_check_node_dependency_refs(
|
|
567
|
+
detached_nodes, detached_styles, detached_numbering, detached_resources
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
reference_records, reference_edges = _validate_reference_records(
|
|
571
|
+
manifest, node_ids, dependency_keys
|
|
572
|
+
)
|
|
573
|
+
_check_reference_inventory(detached_nodes, reference_records)
|
|
574
|
+
_check_reference_acyclicity(node_ids, reference_edges)
|
|
575
|
+
|
|
576
|
+
unsupported = _validate_metadata_sections(manifest)
|
|
577
|
+
_verify_manifest_integrity(manifest, verify_hash)
|
|
482
578
|
return {
|
|
483
579
|
**manifest,
|
|
484
580
|
"nodes": detached_nodes,
|
hwpx/tools/package_validator.py
CHANGED
|
@@ -8,13 +8,15 @@ import xml.etree.ElementTree as ET
|
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
from pathlib import Path, PurePosixPath
|
|
10
10
|
from typing import Any, BinaryIO, Literal, Sequence
|
|
11
|
-
from zipfile import ZIP_STORED, BadZipFile, ZipFile
|
|
11
|
+
from zipfile import ZIP_STORED, BadZipFile, ZipFile, ZipInfo
|
|
12
12
|
|
|
13
13
|
from lxml import etree as LET # type: ignore[reportMissingImports]
|
|
14
14
|
|
|
15
15
|
from ..oxml.namespaces import HWPML_COMPAT_ROOT_NAMESPACES
|
|
16
16
|
from ..opc.relationships import (
|
|
17
17
|
MAIN_ROOTFILE_MEDIA_TYPE,
|
|
18
|
+
ManifestRelationships,
|
|
19
|
+
RootFileRef,
|
|
18
20
|
is_header_part_name,
|
|
19
21
|
is_section_part_name,
|
|
20
22
|
parse_container_rootfiles,
|
|
@@ -562,6 +564,312 @@ def _fallback_named_parts(
|
|
|
562
564
|
return matches
|
|
563
565
|
|
|
564
566
|
|
|
567
|
+
def _check_mimetype(
|
|
568
|
+
zf: ZipFile,
|
|
569
|
+
infos: list[ZipInfo],
|
|
570
|
+
name_set: set[str],
|
|
571
|
+
issues: list[PackageValidationIssue],
|
|
572
|
+
) -> None:
|
|
573
|
+
if MIMETYPE_PATH not in name_set:
|
|
574
|
+
_error(issues, MIMETYPE_PATH, "missing required file")
|
|
575
|
+
return
|
|
576
|
+
mimetype_bytes = _safe_read(zf, MIMETYPE_PATH)
|
|
577
|
+
if mimetype_bytes is None:
|
|
578
|
+
_error(
|
|
579
|
+
issues,
|
|
580
|
+
MIMETYPE_PATH,
|
|
581
|
+
"unable to read entry for integrity validation",
|
|
582
|
+
)
|
|
583
|
+
return
|
|
584
|
+
try:
|
|
585
|
+
mimetype = mimetype_bytes.decode("utf-8").strip()
|
|
586
|
+
except UnicodeDecodeError:
|
|
587
|
+
mimetype = "<binary>"
|
|
588
|
+
if mimetype != EXPECTED_MIMETYPE:
|
|
589
|
+
_error(
|
|
590
|
+
issues,
|
|
591
|
+
MIMETYPE_PATH,
|
|
592
|
+
f"expected {EXPECTED_MIMETYPE!r}, got {mimetype!r}",
|
|
593
|
+
)
|
|
594
|
+
if infos[0].filename != MIMETYPE_PATH:
|
|
595
|
+
_error(issues, MIMETYPE_PATH, "must be the first ZIP entry")
|
|
596
|
+
if zf.getinfo(MIMETYPE_PATH).compress_type != ZIP_STORED:
|
|
597
|
+
_error(issues, MIMETYPE_PATH, "must use ZIP_STORED")
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _check_required_presence(
|
|
601
|
+
name_set: set[str], issues: list[PackageValidationIssue]
|
|
602
|
+
) -> None:
|
|
603
|
+
if CONTAINER_PATH not in name_set:
|
|
604
|
+
_error(issues, CONTAINER_PATH, "missing required file")
|
|
605
|
+
if VERSION_PATH not in name_set:
|
|
606
|
+
_warning(
|
|
607
|
+
issues,
|
|
608
|
+
VERSION_PATH,
|
|
609
|
+
"missing optional version.xml; minimum editor-open package set allows omission",
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _check_preview_text(
|
|
614
|
+
zf: ZipFile, name_set: set[str], issues: list[PackageValidationIssue]
|
|
615
|
+
) -> None:
|
|
616
|
+
if PREVIEW_TEXT_PATH not in name_set:
|
|
617
|
+
_warning(
|
|
618
|
+
issues,
|
|
619
|
+
PREVIEW_TEXT_PATH,
|
|
620
|
+
"missing Preview/PrvText.txt; macOS Hancom compatibility may require it",
|
|
621
|
+
)
|
|
622
|
+
return
|
|
623
|
+
preview_bytes = _safe_read(zf, PREVIEW_TEXT_PATH)
|
|
624
|
+
if preview_bytes is None:
|
|
625
|
+
_error(issues, PREVIEW_TEXT_PATH, "unable to read preview text entry")
|
|
626
|
+
elif len(preview_bytes) > 1024 * 1024:
|
|
627
|
+
_warning(
|
|
628
|
+
issues,
|
|
629
|
+
PREVIEW_TEXT_PATH,
|
|
630
|
+
"Preview/PrvText.txt is unusually large; expected a compact text snapshot",
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _parse_all_xml(
|
|
635
|
+
zf: ZipFile, names: list[str], issues: list[PackageValidationIssue]
|
|
636
|
+
) -> dict[str, ET.Element]:
|
|
637
|
+
xml_roots: dict[str, ET.Element] = {}
|
|
638
|
+
for name in names:
|
|
639
|
+
if not (name.endswith(".xml") or name.endswith(".hpf")):
|
|
640
|
+
continue
|
|
641
|
+
payload = _safe_read(zf, name)
|
|
642
|
+
if payload is None:
|
|
643
|
+
_error(issues, name, "unable to read entry for XML parsing")
|
|
644
|
+
continue
|
|
645
|
+
try:
|
|
646
|
+
root = _parse_xml(payload)
|
|
647
|
+
xml_roots[name] = root
|
|
648
|
+
_check_hwpml_compat_root(issues, name, payload, root)
|
|
649
|
+
_check_line_seg_text_positions(issues, name, root)
|
|
650
|
+
_check_table_editor_acceptance(issues, name, root)
|
|
651
|
+
_check_section_properties_location(issues, name, root)
|
|
652
|
+
_check_header_editor_acceptance(issues, name, root)
|
|
653
|
+
_check_bold_fontref_axis(issues, name, root)
|
|
654
|
+
except ValueError as exc:
|
|
655
|
+
_error(issues, name, str(exc))
|
|
656
|
+
return xml_roots
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _check_rootfile_parts(
|
|
660
|
+
rootfiles: tuple[RootFileRef, ...],
|
|
661
|
+
name_set: set[str],
|
|
662
|
+
issues: list[PackageValidationIssue],
|
|
663
|
+
) -> None:
|
|
664
|
+
for rootfile in rootfiles:
|
|
665
|
+
if rootfile.full_path not in name_set:
|
|
666
|
+
_error(
|
|
667
|
+
issues,
|
|
668
|
+
CONTAINER_PATH,
|
|
669
|
+
f"rootfile points to missing part {rootfile.full_path!r}",
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _check_manifest_hrefs(
|
|
674
|
+
relationships: ManifestRelationships,
|
|
675
|
+
selected_rootfile: RootFileRef,
|
|
676
|
+
name_set: set[str],
|
|
677
|
+
issues: list[PackageValidationIssue],
|
|
678
|
+
) -> None:
|
|
679
|
+
for item in relationships.items:
|
|
680
|
+
if item.resolved_path not in name_set:
|
|
681
|
+
_error(
|
|
682
|
+
issues,
|
|
683
|
+
selected_rootfile.full_path,
|
|
684
|
+
f"manifest href missing from archive: {item.href!r} -> {item.resolved_path!r}",
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
for idref in relationships.dangling_idrefs:
|
|
688
|
+
_warning(
|
|
689
|
+
issues,
|
|
690
|
+
selected_rootfile.full_path,
|
|
691
|
+
f"spine itemref references missing manifest id {idref!r}",
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _resolve_section_paths(
|
|
696
|
+
relationships: ManifestRelationships,
|
|
697
|
+
selected_rootfile: RootFileRef,
|
|
698
|
+
name_set: set[str],
|
|
699
|
+
issues: list[PackageValidationIssue],
|
|
700
|
+
) -> list[str]:
|
|
701
|
+
section_paths = [
|
|
702
|
+
path for path in relationships.spine_paths if is_section_part_name(path)
|
|
703
|
+
]
|
|
704
|
+
resolved_section_paths: list[str]
|
|
705
|
+
if section_paths:
|
|
706
|
+
resolved_section_paths = list(dict.fromkeys(section_paths))
|
|
707
|
+
for path in section_paths:
|
|
708
|
+
if path not in name_set:
|
|
709
|
+
_error(
|
|
710
|
+
issues,
|
|
711
|
+
selected_rootfile.full_path,
|
|
712
|
+
f"spine section part missing from archive: {path!r}",
|
|
713
|
+
)
|
|
714
|
+
else:
|
|
715
|
+
fallback_sections = [
|
|
716
|
+
name for name in sorted(name_set) if is_section_part_name(name)
|
|
717
|
+
]
|
|
718
|
+
if fallback_sections:
|
|
719
|
+
resolved_section_paths = fallback_sections
|
|
720
|
+
_warning(
|
|
721
|
+
issues,
|
|
722
|
+
selected_rootfile.full_path,
|
|
723
|
+
"manifest spine does not resolve any section parts; engine will fall back "
|
|
724
|
+
"to filename-based section discovery",
|
|
725
|
+
)
|
|
726
|
+
else:
|
|
727
|
+
resolved_section_paths = []
|
|
728
|
+
_error(
|
|
729
|
+
issues,
|
|
730
|
+
selected_rootfile.full_path,
|
|
731
|
+
"no section parts found in manifest spine or archive fallback",
|
|
732
|
+
)
|
|
733
|
+
return resolved_section_paths
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _check_header_section_counts(
|
|
737
|
+
relationships: ManifestRelationships,
|
|
738
|
+
xml_roots: dict[str, ET.Element],
|
|
739
|
+
resolved_section_paths: list[str],
|
|
740
|
+
name_set: set[str],
|
|
741
|
+
issues: list[PackageValidationIssue],
|
|
742
|
+
) -> None:
|
|
743
|
+
resolved_header_paths = list(dict.fromkeys(relationships.header_paths))
|
|
744
|
+
if not resolved_header_paths and HEADER_PATH in name_set:
|
|
745
|
+
resolved_header_paths = [HEADER_PATH]
|
|
746
|
+
for header_path in resolved_header_paths:
|
|
747
|
+
header_root = xml_roots.get(header_path)
|
|
748
|
+
if header_root is None or _local_name(header_root) != "head":
|
|
749
|
+
continue
|
|
750
|
+
declared_section_count = header_root.get("secCnt")
|
|
751
|
+
if declared_section_count is None:
|
|
752
|
+
_warning(
|
|
753
|
+
issues,
|
|
754
|
+
header_path,
|
|
755
|
+
"hh:head secCnt is missing; resolved section count cannot be cross-checked",
|
|
756
|
+
)
|
|
757
|
+
continue
|
|
758
|
+
try:
|
|
759
|
+
parsed_section_count = int(declared_section_count)
|
|
760
|
+
except ValueError:
|
|
761
|
+
_error(
|
|
762
|
+
issues,
|
|
763
|
+
header_path,
|
|
764
|
+
f"hh:head secCnt must be an integer, got {declared_section_count!r}",
|
|
765
|
+
)
|
|
766
|
+
continue
|
|
767
|
+
if parsed_section_count != len(resolved_section_paths):
|
|
768
|
+
_error(
|
|
769
|
+
issues,
|
|
770
|
+
header_path,
|
|
771
|
+
"hh:head secCnt does not match resolved section count: "
|
|
772
|
+
f"declared={parsed_section_count}, resolved={len(resolved_section_paths)}",
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _check_header_fallback(
|
|
777
|
+
relationships: ManifestRelationships,
|
|
778
|
+
name_set: set[str],
|
|
779
|
+
selected_rootfile: RootFileRef,
|
|
780
|
+
issues: list[PackageValidationIssue],
|
|
781
|
+
) -> None:
|
|
782
|
+
if not relationships.header_paths and HEADER_PATH in name_set:
|
|
783
|
+
_warning(
|
|
784
|
+
issues,
|
|
785
|
+
selected_rootfile.full_path,
|
|
786
|
+
"manifest spine does not resolve a header part; engine will fall back to "
|
|
787
|
+
f"{HEADER_PATH!r}",
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
for path in relationships.header_paths:
|
|
791
|
+
if path not in name_set:
|
|
792
|
+
_error(
|
|
793
|
+
issues,
|
|
794
|
+
selected_rootfile.full_path,
|
|
795
|
+
f"header part missing from archive: {path!r}",
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _check_master_page_parts(
|
|
800
|
+
relationships: ManifestRelationships,
|
|
801
|
+
name_set: set[str],
|
|
802
|
+
selected_rootfile: RootFileRef,
|
|
803
|
+
issues: list[PackageValidationIssue],
|
|
804
|
+
) -> None:
|
|
805
|
+
if not relationships.master_page_paths:
|
|
806
|
+
fallback_master_pages = _fallback_named_parts(
|
|
807
|
+
name_set, token="master", extra_token="page"
|
|
808
|
+
)
|
|
809
|
+
if fallback_master_pages:
|
|
810
|
+
_warning(
|
|
811
|
+
issues,
|
|
812
|
+
selected_rootfile.full_path,
|
|
813
|
+
"manifest does not reference masterPage parts; engine will fall back to "
|
|
814
|
+
"filename-based discovery",
|
|
815
|
+
)
|
|
816
|
+
for path in relationships.master_page_paths:
|
|
817
|
+
if path not in name_set:
|
|
818
|
+
_error(
|
|
819
|
+
issues,
|
|
820
|
+
selected_rootfile.full_path,
|
|
821
|
+
f"masterPage part missing from archive: {path!r}",
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _check_history_parts(
|
|
826
|
+
relationships: ManifestRelationships,
|
|
827
|
+
name_set: set[str],
|
|
828
|
+
selected_rootfile: RootFileRef,
|
|
829
|
+
issues: list[PackageValidationIssue],
|
|
830
|
+
) -> None:
|
|
831
|
+
if not relationships.history_paths:
|
|
832
|
+
fallback_histories = _fallback_named_parts(name_set, token="history")
|
|
833
|
+
if fallback_histories:
|
|
834
|
+
_warning(
|
|
835
|
+
issues,
|
|
836
|
+
selected_rootfile.full_path,
|
|
837
|
+
"manifest does not reference history parts; engine will fall back to "
|
|
838
|
+
"filename-based discovery",
|
|
839
|
+
)
|
|
840
|
+
for path in relationships.history_paths:
|
|
841
|
+
if path not in name_set:
|
|
842
|
+
_error(
|
|
843
|
+
issues,
|
|
844
|
+
selected_rootfile.full_path,
|
|
845
|
+
f"history part missing from archive: {path!r}",
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _check_version_part(
|
|
850
|
+
relationships: ManifestRelationships,
|
|
851
|
+
name_set: set[str],
|
|
852
|
+
selected_rootfile: RootFileRef,
|
|
853
|
+
issues: list[PackageValidationIssue],
|
|
854
|
+
) -> None:
|
|
855
|
+
if relationships.version_path is None and VERSION_PATH in name_set:
|
|
856
|
+
_warning(
|
|
857
|
+
issues,
|
|
858
|
+
selected_rootfile.full_path,
|
|
859
|
+
"manifest does not reference a version part; engine will fall back to "
|
|
860
|
+
f"{VERSION_PATH!r}",
|
|
861
|
+
)
|
|
862
|
+
elif (
|
|
863
|
+
relationships.version_path is not None
|
|
864
|
+
and relationships.version_path not in name_set
|
|
865
|
+
):
|
|
866
|
+
_error(
|
|
867
|
+
issues,
|
|
868
|
+
selected_rootfile.full_path,
|
|
869
|
+
f"manifest version part missing from archive: {relationships.version_path!r}",
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
|
|
565
873
|
def validate_package(source: str | Path | bytes | BinaryIO) -> PackageValidationReport:
|
|
566
874
|
checked_parts: list[str] = []
|
|
567
875
|
issues: list[PackageValidationIssue] = []
|
|
@@ -593,77 +901,11 @@ def validate_package(source: str | Path | bytes | BinaryIO) -> PackageValidation
|
|
|
593
901
|
if bad_entry is not None:
|
|
594
902
|
_error(issues, bad_entry, "ZIP CRC/integrity check failed")
|
|
595
903
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
mimetype_bytes = _safe_read(zf, MIMETYPE_PATH)
|
|
600
|
-
if mimetype_bytes is None:
|
|
601
|
-
_error(
|
|
602
|
-
issues,
|
|
603
|
-
MIMETYPE_PATH,
|
|
604
|
-
"unable to read entry for integrity validation",
|
|
605
|
-
)
|
|
606
|
-
else:
|
|
607
|
-
try:
|
|
608
|
-
mimetype = mimetype_bytes.decode("utf-8").strip()
|
|
609
|
-
except UnicodeDecodeError:
|
|
610
|
-
mimetype = "<binary>"
|
|
611
|
-
if mimetype != EXPECTED_MIMETYPE:
|
|
612
|
-
_error(
|
|
613
|
-
issues,
|
|
614
|
-
MIMETYPE_PATH,
|
|
615
|
-
f"expected {EXPECTED_MIMETYPE!r}, got {mimetype!r}",
|
|
616
|
-
)
|
|
617
|
-
if infos[0].filename != MIMETYPE_PATH:
|
|
618
|
-
_error(issues, MIMETYPE_PATH, "must be the first ZIP entry")
|
|
619
|
-
if zf.getinfo(MIMETYPE_PATH).compress_type != ZIP_STORED:
|
|
620
|
-
_error(issues, MIMETYPE_PATH, "must use ZIP_STORED")
|
|
621
|
-
|
|
622
|
-
if CONTAINER_PATH not in name_set:
|
|
623
|
-
_error(issues, CONTAINER_PATH, "missing required file")
|
|
624
|
-
if VERSION_PATH not in name_set:
|
|
625
|
-
_warning(
|
|
626
|
-
issues,
|
|
627
|
-
VERSION_PATH,
|
|
628
|
-
"missing optional version.xml; minimum editor-open package set allows omission",
|
|
629
|
-
)
|
|
904
|
+
_check_mimetype(zf, infos, name_set, issues)
|
|
905
|
+
_check_required_presence(name_set, issues)
|
|
906
|
+
_check_preview_text(zf, name_set, issues)
|
|
630
907
|
|
|
631
|
-
|
|
632
|
-
_warning(
|
|
633
|
-
issues,
|
|
634
|
-
PREVIEW_TEXT_PATH,
|
|
635
|
-
"missing Preview/PrvText.txt; macOS Hancom compatibility may require it",
|
|
636
|
-
)
|
|
637
|
-
else:
|
|
638
|
-
preview_bytes = _safe_read(zf, PREVIEW_TEXT_PATH)
|
|
639
|
-
if preview_bytes is None:
|
|
640
|
-
_error(issues, PREVIEW_TEXT_PATH, "unable to read preview text entry")
|
|
641
|
-
elif len(preview_bytes) > 1024 * 1024:
|
|
642
|
-
_warning(
|
|
643
|
-
issues,
|
|
644
|
-
PREVIEW_TEXT_PATH,
|
|
645
|
-
"Preview/PrvText.txt is unusually large; expected a compact text snapshot",
|
|
646
|
-
)
|
|
647
|
-
|
|
648
|
-
xml_roots: dict[str, ET.Element] = {}
|
|
649
|
-
for name in names:
|
|
650
|
-
if not (name.endswith(".xml") or name.endswith(".hpf")):
|
|
651
|
-
continue
|
|
652
|
-
payload = _safe_read(zf, name)
|
|
653
|
-
if payload is None:
|
|
654
|
-
_error(issues, name, "unable to read entry for XML parsing")
|
|
655
|
-
continue
|
|
656
|
-
try:
|
|
657
|
-
root = _parse_xml(payload)
|
|
658
|
-
xml_roots[name] = root
|
|
659
|
-
_check_hwpml_compat_root(issues, name, payload, root)
|
|
660
|
-
_check_line_seg_text_positions(issues, name, root)
|
|
661
|
-
_check_table_editor_acceptance(issues, name, root)
|
|
662
|
-
_check_section_properties_location(issues, name, root)
|
|
663
|
-
_check_header_editor_acceptance(issues, name, root)
|
|
664
|
-
_check_bold_fontref_axis(issues, name, root)
|
|
665
|
-
except ValueError as exc:
|
|
666
|
-
_error(issues, name, str(exc))
|
|
908
|
+
xml_roots = _parse_all_xml(zf, names, issues)
|
|
667
909
|
|
|
668
910
|
container_root = xml_roots.get(CONTAINER_PATH)
|
|
669
911
|
if container_root is None:
|
|
@@ -674,13 +916,7 @@ def validate_package(source: str | Path | bytes | BinaryIO) -> PackageValidation
|
|
|
674
916
|
_error(issues, CONTAINER_PATH, "declares no rootfile entries")
|
|
675
917
|
return PackageValidationReport(tuple(checked_parts), tuple(issues))
|
|
676
918
|
|
|
677
|
-
|
|
678
|
-
if rootfile.full_path not in name_set:
|
|
679
|
-
_error(
|
|
680
|
-
issues,
|
|
681
|
-
CONTAINER_PATH,
|
|
682
|
-
f"rootfile points to missing part {rootfile.full_path!r}",
|
|
683
|
-
)
|
|
919
|
+
_check_rootfile_parts(rootfiles, name_set, issues)
|
|
684
920
|
|
|
685
921
|
selected_rootfile, used_rootfile_fallback = select_main_rootfile(rootfiles)
|
|
686
922
|
if selected_rootfile is None:
|
|
@@ -709,154 +945,17 @@ def validate_package(source: str | Path | bytes | BinaryIO) -> PackageValidation
|
|
|
709
945
|
known_parts=name_set,
|
|
710
946
|
)
|
|
711
947
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
selected_rootfile.full_path,
|
|
724
|
-
f"spine itemref references missing manifest id {idref!r}",
|
|
725
|
-
)
|
|
726
|
-
|
|
727
|
-
section_paths = [
|
|
728
|
-
path for path in relationships.spine_paths if is_section_part_name(path)
|
|
729
|
-
]
|
|
730
|
-
resolved_section_paths: list[str]
|
|
731
|
-
if section_paths:
|
|
732
|
-
resolved_section_paths = list(dict.fromkeys(section_paths))
|
|
733
|
-
for path in section_paths:
|
|
734
|
-
if path not in name_set:
|
|
735
|
-
_error(
|
|
736
|
-
issues,
|
|
737
|
-
selected_rootfile.full_path,
|
|
738
|
-
f"spine section part missing from archive: {path!r}",
|
|
739
|
-
)
|
|
740
|
-
else:
|
|
741
|
-
fallback_sections = [
|
|
742
|
-
name for name in sorted(name_set) if is_section_part_name(name)
|
|
743
|
-
]
|
|
744
|
-
if fallback_sections:
|
|
745
|
-
resolved_section_paths = fallback_sections
|
|
746
|
-
_warning(
|
|
747
|
-
issues,
|
|
748
|
-
selected_rootfile.full_path,
|
|
749
|
-
"manifest spine does not resolve any section parts; engine will fall back "
|
|
750
|
-
"to filename-based section discovery",
|
|
751
|
-
)
|
|
752
|
-
else:
|
|
753
|
-
resolved_section_paths = []
|
|
754
|
-
_error(
|
|
755
|
-
issues,
|
|
756
|
-
selected_rootfile.full_path,
|
|
757
|
-
"no section parts found in manifest spine or archive fallback",
|
|
758
|
-
)
|
|
759
|
-
|
|
760
|
-
resolved_header_paths = list(dict.fromkeys(relationships.header_paths))
|
|
761
|
-
if not resolved_header_paths and HEADER_PATH in name_set:
|
|
762
|
-
resolved_header_paths = [HEADER_PATH]
|
|
763
|
-
for header_path in resolved_header_paths:
|
|
764
|
-
header_root = xml_roots.get(header_path)
|
|
765
|
-
if header_root is None or _local_name(header_root) != "head":
|
|
766
|
-
continue
|
|
767
|
-
declared_section_count = header_root.get("secCnt")
|
|
768
|
-
if declared_section_count is None:
|
|
769
|
-
_warning(
|
|
770
|
-
issues,
|
|
771
|
-
header_path,
|
|
772
|
-
"hh:head secCnt is missing; resolved section count cannot be cross-checked",
|
|
773
|
-
)
|
|
774
|
-
continue
|
|
775
|
-
try:
|
|
776
|
-
parsed_section_count = int(declared_section_count)
|
|
777
|
-
except ValueError:
|
|
778
|
-
_error(
|
|
779
|
-
issues,
|
|
780
|
-
header_path,
|
|
781
|
-
f"hh:head secCnt must be an integer, got {declared_section_count!r}",
|
|
782
|
-
)
|
|
783
|
-
continue
|
|
784
|
-
if parsed_section_count != len(resolved_section_paths):
|
|
785
|
-
_error(
|
|
786
|
-
issues,
|
|
787
|
-
header_path,
|
|
788
|
-
"hh:head secCnt does not match resolved section count: "
|
|
789
|
-
f"declared={parsed_section_count}, resolved={len(resolved_section_paths)}",
|
|
790
|
-
)
|
|
791
|
-
|
|
792
|
-
if not relationships.header_paths and HEADER_PATH in name_set:
|
|
793
|
-
_warning(
|
|
794
|
-
issues,
|
|
795
|
-
selected_rootfile.full_path,
|
|
796
|
-
"manifest spine does not resolve a header part; engine will fall back to "
|
|
797
|
-
f"{HEADER_PATH!r}",
|
|
798
|
-
)
|
|
799
|
-
|
|
800
|
-
for path in relationships.header_paths:
|
|
801
|
-
if path not in name_set:
|
|
802
|
-
_error(
|
|
803
|
-
issues,
|
|
804
|
-
selected_rootfile.full_path,
|
|
805
|
-
f"header part missing from archive: {path!r}",
|
|
806
|
-
)
|
|
807
|
-
|
|
808
|
-
if not relationships.master_page_paths:
|
|
809
|
-
fallback_master_pages = _fallback_named_parts(
|
|
810
|
-
name_set, token="master", extra_token="page"
|
|
811
|
-
)
|
|
812
|
-
if fallback_master_pages:
|
|
813
|
-
_warning(
|
|
814
|
-
issues,
|
|
815
|
-
selected_rootfile.full_path,
|
|
816
|
-
"manifest does not reference masterPage parts; engine will fall back to "
|
|
817
|
-
"filename-based discovery",
|
|
818
|
-
)
|
|
819
|
-
for path in relationships.master_page_paths:
|
|
820
|
-
if path not in name_set:
|
|
821
|
-
_error(
|
|
822
|
-
issues,
|
|
823
|
-
selected_rootfile.full_path,
|
|
824
|
-
f"masterPage part missing from archive: {path!r}",
|
|
825
|
-
)
|
|
826
|
-
|
|
827
|
-
if not relationships.history_paths:
|
|
828
|
-
fallback_histories = _fallback_named_parts(name_set, token="history")
|
|
829
|
-
if fallback_histories:
|
|
830
|
-
_warning(
|
|
831
|
-
issues,
|
|
832
|
-
selected_rootfile.full_path,
|
|
833
|
-
"manifest does not reference history parts; engine will fall back to "
|
|
834
|
-
"filename-based discovery",
|
|
835
|
-
)
|
|
836
|
-
for path in relationships.history_paths:
|
|
837
|
-
if path not in name_set:
|
|
838
|
-
_error(
|
|
839
|
-
issues,
|
|
840
|
-
selected_rootfile.full_path,
|
|
841
|
-
f"history part missing from archive: {path!r}",
|
|
842
|
-
)
|
|
843
|
-
|
|
844
|
-
if relationships.version_path is None and VERSION_PATH in name_set:
|
|
845
|
-
_warning(
|
|
846
|
-
issues,
|
|
847
|
-
selected_rootfile.full_path,
|
|
848
|
-
"manifest does not reference a version part; engine will fall back to "
|
|
849
|
-
f"{VERSION_PATH!r}",
|
|
850
|
-
)
|
|
851
|
-
elif (
|
|
852
|
-
relationships.version_path is not None
|
|
853
|
-
and relationships.version_path not in name_set
|
|
854
|
-
):
|
|
855
|
-
_error(
|
|
856
|
-
issues,
|
|
857
|
-
selected_rootfile.full_path,
|
|
858
|
-
f"manifest version part missing from archive: {relationships.version_path!r}",
|
|
859
|
-
)
|
|
948
|
+
_check_manifest_hrefs(relationships, selected_rootfile, name_set, issues)
|
|
949
|
+
resolved_section_paths = _resolve_section_paths(
|
|
950
|
+
relationships, selected_rootfile, name_set, issues
|
|
951
|
+
)
|
|
952
|
+
_check_header_section_counts(
|
|
953
|
+
relationships, xml_roots, resolved_section_paths, name_set, issues
|
|
954
|
+
)
|
|
955
|
+
_check_header_fallback(relationships, name_set, selected_rootfile, issues)
|
|
956
|
+
_check_master_page_parts(relationships, name_set, selected_rootfile, issues)
|
|
957
|
+
_check_history_parts(relationships, name_set, selected_rootfile, issues)
|
|
958
|
+
_check_version_part(relationships, name_set, selected_rootfile, issues)
|
|
860
959
|
|
|
861
960
|
return PackageValidationReport(tuple(checked_parts), tuple(issues))
|
|
862
961
|
|
hwpx/visual/oracle.py
CHANGED
|
@@ -39,6 +39,7 @@ import shutil
|
|
|
39
39
|
import subprocess
|
|
40
40
|
import sys
|
|
41
41
|
import tempfile
|
|
42
|
+
import time
|
|
42
43
|
from importlib import resources
|
|
43
44
|
from pathlib import Path
|
|
44
45
|
|
|
@@ -64,6 +65,71 @@ _MAC_APP_CANDIDATES = (
|
|
|
64
65
|
"/Applications/Hancom Office HWP 2024.app",
|
|
65
66
|
"/Applications/Hancom Office HWP 2022.app",
|
|
66
67
|
)
|
|
68
|
+
_STRUCTURAL_ONLY_ENV = "HWPX_ORACLE_STRUCTURAL_ONLY"
|
|
69
|
+
_TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"})
|
|
70
|
+
# The reachability probe must answer well under the customer E2E budget; a
|
|
71
|
+
# TCC-blocked osascript otherwise hangs until the full render timeout.
|
|
72
|
+
_MAC_PROBE_TIMEOUT = 5.0
|
|
73
|
+
_MAC_PROBE_SCRIPT = 'tell application "System Events" to count processes'
|
|
74
|
+
# Process-lifetime probe verdict per osascript binary: the TCC grant cannot
|
|
75
|
+
# change for an already-running process, so one probe per process is honest.
|
|
76
|
+
_MAC_PROBE_CACHE: dict[str, bool] = {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_BUDGET_ENV = "HWPX_ORACLE_BUDGET_SECONDS"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def structural_only() -> bool:
|
|
83
|
+
"""True when ``HWPX_ORACLE_STRUCTURAL_ONLY`` requests no-oracle operation.
|
|
84
|
+
|
|
85
|
+
In this mode every verification degrades to the labelled structural path:
|
|
86
|
+
``resolve_oracle`` returns :class:`NullOracle` and the Mac GUI backend
|
|
87
|
+
reports ``available() == False`` even when Hancom is installed.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
return os.environ.get(_STRUCTURAL_ONLY_ENV, "").strip().lower() in _TRUTHY_ENV_VALUES
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def env_budget_seconds() -> float | None:
|
|
94
|
+
"""The externally-declared oracle budget, or ``None`` when unset/invalid.
|
|
95
|
+
|
|
96
|
+
``HWPX_ORACLE_BUDGET_SECONDS`` is the single deadline a hosting process
|
|
97
|
+
(customer E2E, installed verify) declares once; ``resolve_oracle`` threads
|
|
98
|
+
it into every backend subprocess timeout. Non-numeric values are ignored;
|
|
99
|
+
zero or negative means the budget is already exhausted.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
raw = os.environ.get(_BUDGET_ENV, "").strip()
|
|
103
|
+
if not raw:
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
value = float(raw)
|
|
107
|
+
except ValueError:
|
|
108
|
+
return None
|
|
109
|
+
return max(0.0, value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _deadline_from(budget_seconds: float | None) -> float | None:
|
|
113
|
+
"""Turn an optional budget into an absolute monotonic deadline."""
|
|
114
|
+
|
|
115
|
+
if budget_seconds is None:
|
|
116
|
+
return None
|
|
117
|
+
return time.monotonic() + max(0.0, budget_seconds)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _clamped_timeout(base: float, deadline: float | None) -> float | None:
|
|
121
|
+
"""Clamp ``base`` to the remaining deadline budget.
|
|
122
|
+
|
|
123
|
+
``None`` means the budget is already exhausted: the caller must degrade
|
|
124
|
+
without spawning the subprocess at all.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
if deadline is None:
|
|
128
|
+
return base
|
|
129
|
+
remaining = deadline - time.monotonic()
|
|
130
|
+
if remaining <= 0:
|
|
131
|
+
return None
|
|
132
|
+
return min(base, remaining)
|
|
67
133
|
|
|
68
134
|
|
|
69
135
|
class RenderBackend:
|
|
@@ -108,10 +174,14 @@ class WindowsComOracle(RenderBackend):
|
|
|
108
174
|
powershell: str | None = None,
|
|
109
175
|
timeout: float = 300.0,
|
|
110
176
|
dpi: int = 150,
|
|
177
|
+
budget_seconds: float | None = None,
|
|
111
178
|
) -> None:
|
|
112
179
|
self._powershell = powershell or "powershell"
|
|
113
180
|
self.timeout = timeout
|
|
114
181
|
self.dpi = dpi
|
|
182
|
+
# Single externally-propagated deadline: every subprocess timeout in
|
|
183
|
+
# one public call is clamped so the whole call fits this budget.
|
|
184
|
+
self.budget_seconds = budget_seconds
|
|
115
185
|
|
|
116
186
|
def available(self) -> bool:
|
|
117
187
|
"""True only on Windows with the ``HWPFrame.HwpObject`` COM class registered."""
|
|
@@ -142,6 +212,10 @@ class WindowsComOracle(RenderBackend):
|
|
|
142
212
|
result: dict[str, str | None] = {src: None for src, _ in pairs}
|
|
143
213
|
if not pairs or not self.available():
|
|
144
214
|
return result
|
|
215
|
+
deadline = _deadline_from(self.budget_seconds)
|
|
216
|
+
run_timeout = _clamped_timeout(self.timeout + 60.0 * len(pairs), deadline)
|
|
217
|
+
if run_timeout is None:
|
|
218
|
+
return result
|
|
145
219
|
|
|
146
220
|
tmp = tempfile.mkdtemp(prefix="hwpx-render-")
|
|
147
221
|
try:
|
|
@@ -159,7 +233,7 @@ class WindowsComOracle(RenderBackend):
|
|
|
159
233
|
]
|
|
160
234
|
try:
|
|
161
235
|
subprocess.run(
|
|
162
|
-
cmd, capture_output=True, timeout=
|
|
236
|
+
cmd, capture_output=True, timeout=run_timeout,
|
|
163
237
|
check=False,
|
|
164
238
|
)
|
|
165
239
|
except (subprocess.TimeoutExpired, OSError):
|
|
@@ -230,6 +304,10 @@ class WindowsComOracle(RenderBackend):
|
|
|
230
304
|
return []
|
|
231
305
|
if not self.available():
|
|
232
306
|
return [self._unverified_entry(p) for p in paths]
|
|
307
|
+
deadline = _deadline_from(self.budget_seconds)
|
|
308
|
+
run_timeout = _clamped_timeout(self.timeout + 60.0 * len(paths), deadline)
|
|
309
|
+
if run_timeout is None:
|
|
310
|
+
return [self._unverified_entry(p) for p in paths]
|
|
233
311
|
|
|
234
312
|
abs_paths = [os.path.abspath(p) for p in paths]
|
|
235
313
|
# path -> requested (original) string, for surfacing the caller's path.
|
|
@@ -251,7 +329,7 @@ class WindowsComOracle(RenderBackend):
|
|
|
251
329
|
try:
|
|
252
330
|
subprocess.run(
|
|
253
331
|
cmd, capture_output=True,
|
|
254
|
-
timeout=
|
|
332
|
+
timeout=run_timeout, check=False,
|
|
255
333
|
)
|
|
256
334
|
except (subprocess.TimeoutExpired, OSError):
|
|
257
335
|
# Subprocess never finished: prefer the crash-safe checkpoint
|
|
@@ -285,11 +363,13 @@ class WindowsComOracle(RenderBackend):
|
|
|
285
363
|
|
|
286
364
|
opened_raw = record.get("opened")
|
|
287
365
|
opened = bool(opened_raw) if opened_raw is not None else None
|
|
288
|
-
text_length =
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
366
|
+
text_length: int | None = None
|
|
367
|
+
text_length_raw = record.get("textLength")
|
|
368
|
+
if isinstance(text_length_raw, (bool, int, float, str)):
|
|
369
|
+
try:
|
|
370
|
+
text_length = int(text_length_raw)
|
|
371
|
+
except (TypeError, ValueError):
|
|
372
|
+
text_length = None
|
|
293
373
|
error = record.get("error")
|
|
294
374
|
parsed: bool | None
|
|
295
375
|
if opened is None:
|
|
@@ -345,13 +425,13 @@ class WindowsComOracle(RenderBackend):
|
|
|
345
425
|
by_path[os.path.abspath(src)] = norm
|
|
346
426
|
out: list[dict[str, object]] = []
|
|
347
427
|
for abs_path in abs_paths:
|
|
348
|
-
|
|
349
|
-
if
|
|
428
|
+
found = by_path.get(abs_path)
|
|
429
|
+
if found is None:
|
|
350
430
|
out.append(self._unverified_entry(requested.get(abs_path, abs_path)))
|
|
351
431
|
else:
|
|
352
432
|
# Surface the caller's original path string.
|
|
353
|
-
|
|
354
|
-
out.append(
|
|
433
|
+
found["path"] = requested.get(abs_path, found.get("path"))
|
|
434
|
+
out.append(found)
|
|
355
435
|
return out
|
|
356
436
|
|
|
357
437
|
def _merge_checkpoint(
|
|
@@ -409,10 +489,14 @@ class MacHancomOracle(RenderBackend):
|
|
|
409
489
|
timeout: float = 300.0,
|
|
410
490
|
dpi: int = 150,
|
|
411
491
|
osascript: str = "osascript",
|
|
492
|
+
budget_seconds: float | None = None,
|
|
412
493
|
) -> None:
|
|
413
494
|
self.timeout = timeout
|
|
414
495
|
self.dpi = dpi
|
|
415
496
|
self._osascript = osascript
|
|
497
|
+
# Single externally-propagated deadline: every subprocess timeout in
|
|
498
|
+
# one public call is clamped so the whole call fits this budget.
|
|
499
|
+
self.budget_seconds = budget_seconds
|
|
416
500
|
|
|
417
501
|
def _app_path(self) -> str | None:
|
|
418
502
|
for candidate in _MAC_APP_CANDIDATES:
|
|
@@ -433,12 +517,46 @@ class MacHancomOracle(RenderBackend):
|
|
|
433
517
|
return line
|
|
434
518
|
return None
|
|
435
519
|
|
|
520
|
+
def _automation_reachable(self) -> bool:
|
|
521
|
+
"""Fast preflight for GUI automation (System Events + Automation TCC).
|
|
522
|
+
|
|
523
|
+
An installed Hancom does NOT imply the GUI transport works: without a
|
|
524
|
+
logged-in GUI session or with Automation/Apple Events denied, osascript
|
|
525
|
+
blocks until its own timeout — far beyond any caller budget. This probe
|
|
526
|
+
answers within :data:`_MAC_PROBE_TIMEOUT` seconds and is cached for the
|
|
527
|
+
process lifetime (TCC grants cannot change for a running process).
|
|
528
|
+
"""
|
|
529
|
+
|
|
530
|
+
cached = _MAC_PROBE_CACHE.get(self._osascript)
|
|
531
|
+
if cached is not None:
|
|
532
|
+
return cached
|
|
533
|
+
reachable = False
|
|
534
|
+
try:
|
|
535
|
+
proc = subprocess.run(
|
|
536
|
+
[self._osascript, "-e", _MAC_PROBE_SCRIPT],
|
|
537
|
+
capture_output=True, text=True,
|
|
538
|
+
timeout=_MAC_PROBE_TIMEOUT, check=False,
|
|
539
|
+
)
|
|
540
|
+
reachable = proc.returncode == 0
|
|
541
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
542
|
+
reachable = False
|
|
543
|
+
_MAC_PROBE_CACHE[self._osascript] = reachable
|
|
544
|
+
return reachable
|
|
545
|
+
|
|
436
546
|
def available(self) -> bool:
|
|
437
|
-
"""True only on macOS with
|
|
547
|
+
"""True only on macOS with Hancom installed AND GUI automation reachable.
|
|
548
|
+
|
|
549
|
+
``HWPX_ORACLE_STRUCTURAL_ONLY`` forces ``False`` so no caller can enter
|
|
550
|
+
GUI automation in structural-only operation.
|
|
551
|
+
"""
|
|
438
552
|
|
|
553
|
+
if structural_only():
|
|
554
|
+
return False
|
|
439
555
|
if sys.platform != "darwin":
|
|
440
556
|
return False
|
|
441
|
-
|
|
557
|
+
if self._app_path() is None:
|
|
558
|
+
return False
|
|
559
|
+
return self._automation_reachable()
|
|
442
560
|
|
|
443
561
|
def render_pdf(self, hwpx_path: str, out_pdf: str | None = None) -> str | None:
|
|
444
562
|
"""Render a single ``.hwpx`` to PDF via the GUI; returns the path or ``None``."""
|
|
@@ -466,6 +584,14 @@ class MacHancomOracle(RenderBackend):
|
|
|
466
584
|
except OSError:
|
|
467
585
|
pass
|
|
468
586
|
|
|
587
|
+
deadline = _deadline_from(self.budget_seconds)
|
|
588
|
+
run_timeout = _clamped_timeout(self.timeout + 60.0, deadline)
|
|
589
|
+
if run_timeout is None:
|
|
590
|
+
return None
|
|
591
|
+
# The AppleScript receives its own internal wait limit; keep it inside
|
|
592
|
+
# the clamped subprocess timeout so the script never outlives the budget.
|
|
593
|
+
script_timeout = max(1, int(min(self.timeout, run_timeout)))
|
|
594
|
+
|
|
469
595
|
cleanup_staged = False
|
|
470
596
|
try:
|
|
471
597
|
if not staged_is_source:
|
|
@@ -475,12 +601,12 @@ class MacHancomOracle(RenderBackend):
|
|
|
475
601
|
resources.files("hwpx.visual").joinpath(_MAC_BACKEND_SCRIPT)
|
|
476
602
|
) as script:
|
|
477
603
|
cmd = [
|
|
478
|
-
self._osascript, str(script), staged, out_pdf, str(
|
|
604
|
+
self._osascript, str(script), staged, out_pdf, str(script_timeout),
|
|
479
605
|
]
|
|
480
606
|
try:
|
|
481
607
|
subprocess.run(
|
|
482
608
|
cmd, capture_output=True, text=True,
|
|
483
|
-
timeout=
|
|
609
|
+
timeout=run_timeout, check=False,
|
|
484
610
|
)
|
|
485
611
|
except (subprocess.TimeoutExpired, OSError):
|
|
486
612
|
return None
|
|
@@ -507,6 +633,11 @@ class MacHancomOracle(RenderBackend):
|
|
|
507
633
|
"""
|
|
508
634
|
if not self.available():
|
|
509
635
|
return False
|
|
636
|
+
deadline = _deadline_from(self.budget_seconds)
|
|
637
|
+
run_timeout = _clamped_timeout(self.timeout + 60.0, deadline)
|
|
638
|
+
if run_timeout is None:
|
|
639
|
+
return False
|
|
640
|
+
script_timeout = max(1, int(min(self.timeout, run_timeout)))
|
|
510
641
|
src = os.path.abspath(hwpx_path)
|
|
511
642
|
try:
|
|
512
643
|
before = os.stat(src).st_mtime_ns
|
|
@@ -515,11 +646,11 @@ class MacHancomOracle(RenderBackend):
|
|
|
515
646
|
with resources.as_file(
|
|
516
647
|
resources.files("hwpx.visual").joinpath(_MAC_REFRESH_SCRIPT)
|
|
517
648
|
) as script:
|
|
518
|
-
cmd = [self._osascript, str(script), src, str(
|
|
649
|
+
cmd = [self._osascript, str(script), src, str(script_timeout)]
|
|
519
650
|
try:
|
|
520
651
|
proc = subprocess.run(
|
|
521
652
|
cmd, capture_output=True, text=True,
|
|
522
|
-
timeout=
|
|
653
|
+
timeout=run_timeout, check=False,
|
|
523
654
|
)
|
|
524
655
|
except (subprocess.TimeoutExpired, OSError):
|
|
525
656
|
return False
|
|
@@ -551,17 +682,29 @@ def resolve_oracle(
|
|
|
551
682
|
timeout: float = 300.0,
|
|
552
683
|
dpi: int = 150,
|
|
553
684
|
osascript: str = "osascript",
|
|
685
|
+
budget_seconds: float | None = None,
|
|
554
686
|
) -> RenderBackend:
|
|
555
687
|
"""Return the best reachable render backend (Windows COM → Mac GUI → Null).
|
|
556
688
|
|
|
557
689
|
Windows COM is canonical (CI/scale); Mac GUI is the dev/spot-check fallback;
|
|
558
690
|
:class:`NullOracle` is the degrade sentinel when no Hancom is reachable.
|
|
691
|
+
``HWPX_ORACLE_STRUCTURAL_ONLY`` short-circuits to :class:`NullOracle`, and
|
|
692
|
+
``budget_seconds`` propagates one external deadline into every backend
|
|
693
|
+
subprocess timeout.
|
|
559
694
|
"""
|
|
560
695
|
|
|
561
|
-
|
|
696
|
+
if structural_only():
|
|
697
|
+
return NullOracle()
|
|
698
|
+
if budget_seconds is None:
|
|
699
|
+
budget_seconds = env_budget_seconds()
|
|
700
|
+
windows = WindowsComOracle(
|
|
701
|
+
powershell=powershell, timeout=timeout, dpi=dpi, budget_seconds=budget_seconds,
|
|
702
|
+
)
|
|
562
703
|
if windows.available():
|
|
563
704
|
return windows
|
|
564
|
-
mac = MacHancomOracle(
|
|
705
|
+
mac = MacHancomOracle(
|
|
706
|
+
timeout=timeout, dpi=dpi, osascript=osascript, budget_seconds=budget_seconds,
|
|
707
|
+
)
|
|
565
708
|
if mac.available():
|
|
566
709
|
return mac
|
|
567
710
|
return NullOracle()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-hwpx
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.1
|
|
4
4
|
Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 자동화 라이브러리
|
|
5
5
|
Author: python-hwpx Maintainers
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -70,7 +70,7 @@ Dynamic: license-file
|
|
|
70
70
|
`hwpx-mcp-server`와 `hwpx-plugin`은 같은 프로젝트가 직접 유지보수하는 first-party 연동 구성요소입니다.
|
|
71
71
|
“first-party”는 프로젝트 유지보수 관계를 뜻하며, 한컴 또는 제3자의 공식 인증을 뜻하지 않습니다.
|
|
72
72
|
|
|
73
|
-
현재 PyPI 공개 릴리스는 `python-hwpx 3.
|
|
73
|
+
현재 PyPI 공개 릴리스는 `python-hwpx 3.3.1`입니다. 일반
|
|
74
74
|
`pip install python-hwpx`로 이 릴리스를 설치할 수 있습니다.
|
|
75
75
|
현재 패키지 분류는 `Development Status :: 3 - Alpha`입니다. 이 분류는 API와 제품의
|
|
76
76
|
성숙도를 나타내며, 공개 버전이나 플러그인의 최소 호환 버전을 대신하지 않습니다.
|
|
@@ -28,7 +28,7 @@ hwpx/agent/blueprint/bundle.py,sha256=K9-lcCgx2j1EsHzdP0cFn3ejn-YcLQ1NnNiPZNjq25
|
|
|
28
28
|
hwpx/agent/blueprint/catalog.py,sha256=D2ax7gQxhoqqtL70LWjcUME6I9TMb2Q7MqR48c19_tc,4669
|
|
29
29
|
hwpx/agent/blueprint/dump.py,sha256=HgEMr5dI5ozXwZn9tH-2IlFtWphxT_qz7hlZmSn49MU,21745
|
|
30
30
|
hwpx/agent/blueprint/mapping.py,sha256=bzSYbTrNEumBkP74nEY5M20-BEC4rBGCRvo3QZNQKxc,12761
|
|
31
|
-
hwpx/agent/blueprint/model.py,sha256=
|
|
31
|
+
hwpx/agent/blueprint/model.py,sha256=7CegZTyVxFtzeDXHQrAogwS5Hf-MEBSEzcarUl1gNbE,31204
|
|
32
32
|
hwpx/agent/blueprint/native.py,sha256=O6ZyBmKMw-O-SoEzlWkIgA4E7zXog1Ba9-4Ug5AekpM,28761
|
|
33
33
|
hwpx/agent/blueprint/replay.py,sha256=EzkjGsFCYkjADZVaX1V8g4LAlZIXOFn7cZJmmCNNpSY,20047
|
|
34
34
|
hwpx/benchmark/__init__.py,sha256=7CfgxEQ6nUrKHy3-V9VkeaMoGrb12QA8mHzpcfF2ItE,623
|
|
@@ -147,7 +147,7 @@ hwpx/tools/markdown_export.py,sha256=FejutCpQHbycO185uljcSwfZuwXMTbGEgXtf5e-a4_k
|
|
|
147
147
|
hwpx/tools/object_finder.py,sha256=7i6XI1-r7-ar_IzSZQ82hfOcxVzJFK2XjMDB8oxcmMA,13478
|
|
148
148
|
hwpx/tools/official_lint.py,sha256=9iszI4CAAEMGdPCOhmQ6XonO1jc1XE2BJXMHgv9hIvY,18600
|
|
149
149
|
hwpx/tools/package_reconcile.py,sha256=y1Hl7hbPh4YaV59LTdDLzQwgn4g1qEnFmSjmajnrEbA,2416
|
|
150
|
-
hwpx/tools/package_validator.py,sha256=
|
|
150
|
+
hwpx/tools/package_validator.py,sha256=fYkJevV_soQuM8LEUQMkwYaTy2k2HV4VWBwfIXFt29Y,33306
|
|
151
151
|
hwpx/tools/page_guard.py,sha256=nDAVPcvrnuyDxVTA_j22wiYD7CXAD6XlzsMzaz3h_q8,9701
|
|
152
152
|
hwpx/tools/pii.py,sha256=N3c36eqblaVQ7o6jiT1BV0WrJK_G3gKhQh6MQGAbvCs,12617
|
|
153
153
|
hwpx/tools/read_fidelity.py,sha256=g4r2GNVExEtL0C-6JMkPIjmH6CtJqNSS_-KsSPTnBbw,10541
|
|
@@ -185,15 +185,15 @@ hwpx/visual/diff.py,sha256=0X5T9IgwRZU3td-7vnPrlowovtGud7P_ymq0KVehlKk,5677
|
|
|
185
185
|
hwpx/visual/fixture_corpus.py,sha256=Bcy7nscf6RY7sHemGKaqzsyRrWp5ioQdP2wQFFoY20A,6676
|
|
186
186
|
hwpx/visual/hancom_worker.py,sha256=r70lUnHSdIF4h3plIk_SIkoXpzuACJp1FBE1WMBt5Ic,10161
|
|
187
187
|
hwpx/visual/masks.py,sha256=oXhgynAb4uKjJtZ2BGHHdAjyvWGqSFlZFQ-iJxzHiuo,1832
|
|
188
|
-
hwpx/visual/oracle.py,sha256=
|
|
188
|
+
hwpx/visual/oracle.py,sha256=f3JqplfB5cE_Tx7HaCUQ4aBv7DhMpsp79a4i3BYHr1A,37069
|
|
189
189
|
hwpx/visual/page_qa.py,sha256=AuJCySfLIdXjPeekhAc3YnrIWOsN0sI35Pnz8BZ6Pro,7278
|
|
190
190
|
hwpx/visual/qa_contracts.py,sha256=1cjoiRTAWgi7NgE8SPc6bKxdoUfKx9nyou1mgC47TxY,10506
|
|
191
191
|
hwpx/visual/qa_metrics.py,sha256=I7RdybN-f1Fvcv2j-ceDPoek51nOkMui1Zrd7TEX_C8,9838
|
|
192
192
|
hwpx/visual/report.py,sha256=2RhXN1KBYOZTim9FNpeUhaaDHR7oFxI6Z2DLUkDIiwE,1717
|
|
193
|
-
python_hwpx-3.
|
|
194
|
-
python_hwpx-3.
|
|
195
|
-
python_hwpx-3.
|
|
196
|
-
python_hwpx-3.
|
|
197
|
-
python_hwpx-3.
|
|
198
|
-
python_hwpx-3.
|
|
199
|
-
python_hwpx-3.
|
|
193
|
+
python_hwpx-3.3.1.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
|
|
194
|
+
python_hwpx-3.3.1.dist-info/licenses/NOTICE,sha256=k48h6EaGQE8Y1c0dS9sIOOcz4YqkbcImWClF7pBOgsg,2473
|
|
195
|
+
python_hwpx-3.3.1.dist-info/METADATA,sha256=inWS_guBKXWildz4JpZwh2xIpqw-Zm-2PKjiShL3h78,12566
|
|
196
|
+
python_hwpx-3.3.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
197
|
+
python_hwpx-3.3.1.dist-info/entry_points.txt,sha256=PVUBTBQtBHiflQ9XBjfd004w-LrDibgZjwzxgu8Tx7w,480
|
|
198
|
+
python_hwpx-3.3.1.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
|
|
199
|
+
python_hwpx-3.3.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|