python-hwpx 3.2.0__py3-none-any.whl → 3.3.0__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/visual/oracle.py +162 -19
- {python_hwpx-3.2.0.dist-info → python_hwpx-3.3.0.dist-info}/METADATA +2 -2
- {python_hwpx-3.2.0.dist-info → python_hwpx-3.3.0.dist-info}/RECORD +8 -8
- {python_hwpx-3.2.0.dist-info → python_hwpx-3.3.0.dist-info}/WHEEL +0 -0
- {python_hwpx-3.2.0.dist-info → python_hwpx-3.3.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-3.2.0.dist-info → python_hwpx-3.3.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-3.2.0.dist-info → python_hwpx-3.3.0.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-3.2.0.dist-info → python_hwpx-3.3.0.dist-info}/top_level.txt +0 -0
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.0
|
|
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.0`입니다. 일반
|
|
74
74
|
`pip install python-hwpx`로 이 릴리스를 설치할 수 있습니다.
|
|
75
75
|
현재 패키지 분류는 `Development Status :: 3 - Alpha`입니다. 이 분류는 API와 제품의
|
|
76
76
|
성숙도를 나타내며, 공개 버전이나 플러그인의 최소 호환 버전을 대신하지 않습니다.
|
|
@@ -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.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
|
|
194
|
+
python_hwpx-3.3.0.dist-info/licenses/NOTICE,sha256=k48h6EaGQE8Y1c0dS9sIOOcz4YqkbcImWClF7pBOgsg,2473
|
|
195
|
+
python_hwpx-3.3.0.dist-info/METADATA,sha256=Sst0elBTS2Ns4JL3oOUS9uC9lvH-A2rAkIoGLMqouOE,12566
|
|
196
|
+
python_hwpx-3.3.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
197
|
+
python_hwpx-3.3.0.dist-info/entry_points.txt,sha256=PVUBTBQtBHiflQ9XBjfd004w-LrDibgZjwzxgu8Tx7w,480
|
|
198
|
+
python_hwpx-3.3.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
|
|
199
|
+
python_hwpx-3.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|