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 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=self.timeout + 60.0 * len(pairs),
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=self.timeout + 60.0 * len(paths), check=False,
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 = record.get("textLength")
289
- try:
290
- text_length = int(text_length) if text_length is not None else None
291
- except (TypeError, ValueError):
292
- text_length = None
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
- norm = by_path.get(abs_path)
349
- if norm is None:
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
- norm["path"] = requested.get(abs_path, norm.get("path"))
354
- out.append(norm)
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 ``Hancom Office HWP.app`` installed."""
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
- return self._app_path() is not None
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(int(self.timeout)),
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=self.timeout + 60.0, check=False,
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(int(self.timeout))]
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=self.timeout + 60.0, check=False,
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
- windows = WindowsComOracle(powershell=powershell, timeout=timeout, dpi=dpi)
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(timeout=timeout, dpi=dpi, osascript=osascript)
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.2.0
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.2.0`입니다. 일반
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=VNO-tAB-lzTdW5XtSELxg_Aau369qRFBsJvNryHF_D8,31319
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.2.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
194
- python_hwpx-3.2.0.dist-info/licenses/NOTICE,sha256=k48h6EaGQE8Y1c0dS9sIOOcz4YqkbcImWClF7pBOgsg,2473
195
- python_hwpx-3.2.0.dist-info/METADATA,sha256=8nLPZJKUFUDSQWMnOr1dDQo4cJ71TQ_IsRY5vZeXu9I,12566
196
- python_hwpx-3.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
197
- python_hwpx-3.2.0.dist-info/entry_points.txt,sha256=PVUBTBQtBHiflQ9XBjfd004w-LrDibgZjwzxgu8Tx7w,480
198
- python_hwpx-3.2.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
199
- python_hwpx-3.2.0.dist-info/RECORD,,
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,,