openmd-cli 0.1.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.
Files changed (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,81 @@
1
+ """Interruptible cross-process GPU lease. The OS, not a PID file, owns the lock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import os
7
+ import time
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+
11
+
12
+ def gpu_lock_path() -> Path:
13
+ root = Path(os.environ.get("OPENMD_RUNTIME_DIR", str(Path.home() / ".openmd/runtime"))).expanduser()
14
+ return root.resolve() / "gpu.lock"
15
+
16
+
17
+ def release_torch_cache() -> None:
18
+ """Release allocations before handing the lease to another GPU backend."""
19
+ import gc
20
+ import sys
21
+
22
+ gc.collect()
23
+ torch = sys.modules.get("torch")
24
+ if torch is not None and torch.cuda.is_available():
25
+ torch.cuda.empty_cache()
26
+
27
+
28
+ @contextmanager
29
+ def gpu_lock(*, timeout: float | None = None, path: Path | None = None, check_quarantine: bool = True):
30
+ """Lock one persistent byte; never unlink it (unlinking creates split locks).
31
+
32
+ msvcrt/flock release locks on process death, including forced termination.
33
+ A stale file is therefore harmless and can be reused without deleting it.
34
+ Short polling keeps Windows Ctrl+C responsive while waiting for the lease.
35
+ """
36
+ limit = float(os.getenv("OPENMD_GPU_LOCK_TIMEOUT", "600")) if timeout is None else timeout
37
+ if not 0 <= limit < float("inf"):
38
+ raise ValueError("gpu_lock_timeout_invalid")
39
+ target = path or gpu_lock_path()
40
+ target.parent.mkdir(parents=True, exist_ok=True)
41
+ with target.open("a+b") as stream:
42
+ if target.stat().st_size == 0:
43
+ stream.write(b"\0")
44
+ stream.flush()
45
+ acquired = False
46
+ deadline = time.monotonic() + limit
47
+ try:
48
+ while not acquired:
49
+ stream.seek(0)
50
+ try:
51
+ if os.name == "nt":
52
+ import msvcrt
53
+
54
+ msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
55
+ else:
56
+ import fcntl
57
+
58
+ fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
59
+ acquired = True
60
+ except OSError as exc:
61
+ if exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}:
62
+ raise
63
+ if time.monotonic() >= deadline:
64
+ raise TimeoutError("gpu_lock_timeout: another Openmd OCR/ASR process holds the GPU") from exc
65
+ time.sleep(min(0.1, max(0, deadline - time.monotonic())))
66
+ from .gpu_quarantine import recover_quarantine
67
+
68
+ if check_quarantine:
69
+ recover_quarantine(target, deadline=deadline)
70
+ yield
71
+ finally:
72
+ if acquired:
73
+ stream.seek(0)
74
+ if os.name == "nt":
75
+ import msvcrt
76
+
77
+ msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
78
+ else:
79
+ import fcntl
80
+
81
+ fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
@@ -0,0 +1,85 @@
1
+ """Persistent uncertainty about an independent Ollama runner's GPU residency.
2
+
3
+ All operations run while the shared GPU lease is held. A marker is written
4
+ before inference, so process death cannot silently authorize another client.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import time
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+
16
+ class GPUQuarantined(RuntimeError):
17
+ @property
18
+ def code(self) -> str:
19
+ return str(self).split(":", 1)[0]
20
+
21
+
22
+ def quarantine_path(lock_path: Path | None = None) -> Path:
23
+ from .gpu_lock import gpu_lock_path
24
+
25
+ return (lock_path or gpu_lock_path()).with_name("gpu-quarantine.json")
26
+
27
+
28
+ def mark_quarantine(url: str, model: str, *, reason: str = "inference_in_progress") -> None:
29
+ from openmd.ocr.endpoint import validate_ollama_url
30
+
31
+ target = quarantine_path()
32
+ target.parent.mkdir(parents=True, exist_ok=True)
33
+ value = {"schema": "openmd.gpu-quarantine/v1", "pid": os.getpid(), "model": model,
34
+ "url": validate_ollama_url(url), "time": datetime.now(timezone.utc).isoformat(), "reason": reason}
35
+ temporary = target.with_suffix(f".{os.getpid()}.tmp")
36
+ with temporary.open("w", encoding="utf-8") as stream:
37
+ json.dump(value, stream)
38
+ stream.flush()
39
+ os.fsync(stream.fileno())
40
+ os.replace(temporary, target)
41
+
42
+
43
+ def clear_quarantine(lock_path: Path | None = None) -> None:
44
+ # Delete only the state marker, never the persistent OS lock inode.
45
+ quarantine_path(lock_path).unlink(missing_ok=True)
46
+
47
+
48
+ def recover_quarantine(lock_path: Path | None = None, *, deadline: float | None = None) -> None:
49
+ """Fail closed until /api/ps proves the recorded endpoint is empty."""
50
+ target = quarantine_path(lock_path)
51
+ if not target.exists():
52
+ return
53
+ from openmd.ocr.endpoint import validate_ollama_url
54
+ from openmd.ocr.ollama import api_json, running_models
55
+
56
+ try:
57
+ value = json.loads(target.read_text(encoding="utf-8"))
58
+ if value.get("schema") != "openmd.gpu-quarantine/v1" or not isinstance(value.get("model"), str):
59
+ raise ValueError("invalid quarantine")
60
+ url = validate_ollama_url(value["url"])
61
+ # A model name or stale PID cannot establish ownership of requests on
62
+ # this user-managed service. Recovery is strictly read-only.
63
+ end = time.monotonic() if deadline is None else deadline
64
+ while running_models(api_json(url, "/api/ps", timeout=max(0.1, min(3, end - time.monotonic())))):
65
+ remaining = end - time.monotonic()
66
+ if remaining <= 0:
67
+ raise GPUQuarantined("gpu_external_busy: Ollama still has resident models; wait for the other client or keep_alive expiry and retry. Openmd will not unload them. Inspect with openmd doctor --profile documents; explicit marker-only reset: openmd doctor --clear-gpu-quarantine")
68
+ time.sleep(min(0.1, remaining))
69
+ clear_quarantine(lock_path)
70
+ except GPUQuarantined:
71
+ raise
72
+ except Exception as exc:
73
+ raise GPUQuarantined("gpu_quarantined: cannot confirm empty Ollama state; no GPU model was loaded. Inspect the service; explicit marker-only reset: openmd doctor --clear-gpu-quarantine") from exc
74
+
75
+
76
+ def clear_quarantine_manually() -> dict:
77
+ """Explicit local marker reset under the OS lease; never contact Ollama."""
78
+ from .gpu_lock import gpu_lock
79
+
80
+ with gpu_lock(check_quarantine=False):
81
+ target = quarantine_path()
82
+ existed = target.exists()
83
+ clear_quarantine()
84
+ return {"marker_removed": existed, "models_unloaded": False, "ollama_contacted": False,
85
+ "next_action": "Marker reset only. Ensure the shared GPU is safe before starting OCR or ASR."}
@@ -0,0 +1,407 @@
1
+ """Choose and probe an existing ASR interpreter without changing environments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import json
7
+ import os
8
+ import re
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import threading
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from openmd.core.local_config import load_local_config
20
+ from openmd.core.secrets import sanitize_error_summary
21
+ from .models import checkout_root
22
+
23
+ from openmd.runtime.tempdirs import note_process_owner, owned_temporary_directory
24
+
25
+
26
+ REQUIRED_MODULES = ("torch", "ctranslate2", "faster_whisper", "qwen_asr", "funasr", "soundfile", "opencc")
27
+ _PROBE_CACHE: dict[tuple[object, ...], dict[str, Any]] = {}
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class AsrRuntime:
32
+ mode: str
33
+ python: Path | None
34
+ reason: str
35
+ probe: dict[str, Any] = field(default_factory=dict)
36
+
37
+ def as_dict(self) -> dict[str, Any]:
38
+ return {"mode": self.mode, "python": display_path(self.python), "reason": self.reason, "probe": self.probe}
39
+
40
+
41
+ def display_path(path: Path | None) -> str | None:
42
+ if path is None:
43
+ return None
44
+ value = str(path)
45
+ home = str(Path.home())
46
+ return "~" + value[len(home):] if value.lower().startswith(home.lower() + os.sep) else value
47
+
48
+
49
+ def worker_environment() -> dict[str, str]:
50
+ """Only local-runtime settings cross the process boundary; no cloud credentials."""
51
+ keep = {"PATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "TMPDIR", "USERPROFILE", "HOME", "APPDATA", "LOCALAPPDATA", "LANG", "LC_ALL", "HF_HOME", "HF_HUB_CACHE", "MODELSCOPE_CACHE", "CUDA_PATH", "CUDA_VISIBLE_DEVICES", "OPENMD_CONFIG", "OPENMD_MODEL_ROOTS", "OPENMD_ASR_CHINESE_SCRIPT", "OPENMD_RUNTIME_DIR", "OPENMD_GPU_LOCK_TIMEOUT"}
52
+ prefixes = ("OPENMD_FASTER_WHISPER_", "OPENMD_ASR_AUTO_", "OPENMD_QWEN3_", "OMP_", "MKL_", "KMP_")
53
+ env = {key: value for key, value in os.environ.items() if (key.upper() in keep or key.upper().startswith(prefixes)) and not re.search(r"TOKEN|COOKIE|SECRET|PASSWORD|CREDENTIAL|API.?KEY", key, re.I)}
54
+ # The worker cwd is deliberately unrelated to the caller. Resolve caller-owned
55
+ # relative locations before crossing that boundary; repository IDs stay IDs.
56
+ for key in ("OPENMD_CONFIG", "HF_HOME", "HF_HUB_CACHE", "MODELSCOPE_CACHE", "OPENMD_RUNTIME_DIR"):
57
+ if env.get(key):
58
+ env[key] = str(Path(env[key]).expanduser().resolve())
59
+ if env.get("OPENMD_MODEL_ROOTS"):
60
+ env["OPENMD_MODEL_ROOTS"] = os.pathsep.join(str(Path(part).expanduser().resolve()) for part in env["OPENMD_MODEL_ROOTS"].split(os.pathsep) if part)
61
+ for key in ("OPENMD_FASTER_WHISPER_MODEL", "OPENMD_ASR_AUTO_EN_MODEL", "OPENMD_QWEN3_MODEL", "OPENMD_QWEN3_VAD_MODEL"):
62
+ value = env.get(key)
63
+ if value:
64
+ path = Path(value).expanduser()
65
+ if path.is_dir() or path.is_absolute() or value.startswith((".", "~")) or "\\" in value:
66
+ env[key] = str(path.resolve())
67
+ env.update({"PYTHONIOENCODING": "utf-8", "OPENMD_ASR_WORKER": "1", "HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1", "MODELSCOPE_OFFLINE": "1"})
68
+ return env
69
+
70
+
71
+ def worker_command(python: Path, *args: str, module: str = "openmd.runtime.asr_worker") -> list[str]:
72
+ """Load only this Openmd package; worker dependencies belong to its interpreter.
73
+
74
+ Adding the controller's site-packages to PYTHONPATH shadows a worker's CUDA
75
+ Torch with the controller's CPU build after a wheel installation. An explicit
76
+ package spec preserves resources/__file__ without exposing sibling packages.
77
+ Isolated mode also ignores user PYTHONPATH and the caller's working directory.
78
+ """
79
+ bootstrap = (
80
+ "import importlib.util,runpy,sys; "
81
+ "p=sys.argv.pop(1); "
82
+ "s=importlib.util.spec_from_file_location('openmd',p); "
83
+ "m=importlib.util.module_from_spec(s); sys.modules['openmd']=m; "
84
+ "s.loader.exec_module(m); "
85
+ f"runpy.run_module({module!r},run_name='__main__')"
86
+ )
87
+ return [str(python), "-I", "-c", bootstrap, str(Path(__file__).resolve().parents[1] / "__init__.py"), *args]
88
+
89
+
90
+ class _ProcessGroup:
91
+ def __init__(self, pid):
92
+ self.pid = pid
93
+ self.closed = False
94
+
95
+ def close(self):
96
+ if not self.closed:
97
+ # The group remains ours after its leader exits. Do not poll the
98
+ # leader, or reconstruct a new tree from possibly recycled PIDs.
99
+ self.closed = True
100
+ try:
101
+ os.killpg(self.pid, signal.SIGKILL)
102
+ except ProcessLookupError:
103
+ pass
104
+
105
+
106
+ def owned_popen(*args, **kwargs):
107
+ """Establish subtree ownership before any worker code can execute.
108
+
109
+ Windows creation is atomically in a kill-on-close Job, suspended until
110
+ assignment has been checked; POSIX creates a new session/process group.
111
+ Keep the owner attached even after poll reports that the leader exited.
112
+ """
113
+ # A persistent browser bridge must start before the per-command owner exists.
114
+ # This helper recognizes only OpenCLI; every actual command still owns its tree.
115
+ from .services import bridge_profile_env, ensure_opencli_daemon
116
+
117
+ command = args[0] if args else kwargs.get("args")
118
+ ensure_opencli_daemon(command, env=kwargs.get("env"), cwd=kwargs.get("cwd"))
119
+ selected = bridge_profile_env(command, kwargs.get("env"))
120
+ if selected is not None:
121
+ kwargs["env"] = selected
122
+ if os.name == "nt":
123
+ from ._windows_job import JobPopen
124
+
125
+ kwargs["creationflags"] = kwargs.get("creationflags", 0) | subprocess.CREATE_NEW_PROCESS_GROUP
126
+ process = JobPopen(*args, **kwargs)
127
+ else:
128
+ kwargs["start_new_session"] = True
129
+ if kwargs.get("process_group") not in (None, -1):
130
+ raise ValueError("Owned children must use their new session's process group")
131
+ process = subprocess.Popen(*args, **kwargs)
132
+ process._openmd_owner = _ProcessGroup(process.pid)
133
+ try:
134
+ note_process_owner(process)
135
+ except BaseException:
136
+ terminate_quietly(process)
137
+ raise
138
+ return process
139
+
140
+
141
+ def wait_interruptibly(process: subprocess.Popen, timeout: float | None, *, poll_s: float = 0.2, cleanup_on_error: bool = True, cleanup_on_exit: bool = True, on_poll=None) -> int:
142
+ """Wait for a child while staying responsive to Ctrl+C.
143
+
144
+ Windows Popen.wait/communicate both have a blocking SIGINT grace wait.
145
+ Poll the process and sleep in short Python slices so no subprocess signal
146
+ handler can turn a negative remaining timeout into an infinite OS wait.
147
+ Raises ``subprocess.TimeoutExpired`` like ``Popen.wait``.
148
+ """
149
+ note_process_owner(process)
150
+ deadline = None if timeout is None else time.monotonic() + timeout
151
+ try:
152
+ while True:
153
+ if on_poll is not None:
154
+ on_poll()
155
+ result = process.poll()
156
+ if result is not None:
157
+ if cleanup_on_exit and getattr(process, "__dict__", {}).get("_openmd_owner") is not None:
158
+ kill_process_tree(process)
159
+ return result
160
+ remaining = poll_s if deadline is None else deadline - time.monotonic()
161
+ if remaining <= 0:
162
+ raise subprocess.TimeoutExpired(process.args, timeout)
163
+ # Popen.wait() has the same negative SIGINT grace-time bug as
164
+ # communicate() on Windows 3.12. poll() never blocks; Python sleep
165
+ # returns to the interpreter without subprocess's SIGINT handler.
166
+ time.sleep(min(poll_s, remaining))
167
+ except BaseException:
168
+ if cleanup_on_error:
169
+ terminate_quietly(process)
170
+ raise
171
+
172
+
173
+ def communicate_interruptibly(process: subprocess.Popen, timeout: float | None = None, *, input=None, poll_s: float = 0.2, on_poll=None):
174
+ """Drain pipes off the main thread; keep all signal handling in the short wait.
175
+
176
+ Windows Python 3.12 communicate() catches KeyboardInterrupt and may pass a
177
+ negative remaining timeout to WaitForSingleObject (INFINITE when -1 ms).
178
+ Running that I/O method on a daemon thread avoids its signal handler entirely,
179
+ including a blocking stdin write. The main thread uses our existing waiter.
180
+ """
181
+ deadline = None if timeout is None else time.monotonic() + timeout
182
+ finished = threading.Event()
183
+ output = []
184
+ errors = []
185
+ cleaned = False
186
+
187
+ def exchange():
188
+ try:
189
+ output.append(process.communicate(input=input))
190
+ except BaseException as exc:
191
+ errors.append(exc)
192
+ finally:
193
+ # Stream close can take the buffer lock. It must never run on the
194
+ # controller thread while a pipe reader is blocked holding it.
195
+ for stream in (process.stdin, process.stdout, process.stderr):
196
+ if stream is not None:
197
+ try:
198
+ stream.close()
199
+ except (OSError, ValueError):
200
+ pass
201
+ finished.set()
202
+
203
+ io_thread = threading.Thread(target=exchange, name="openmd-process-io", daemon=True)
204
+ io_thread.start()
205
+ try:
206
+ wait_interruptibly(process, timeout, poll_s=poll_s, cleanup_on_error=False, cleanup_on_exit=False, on_poll=on_poll)
207
+ # Descendants can hold pipes open after their parent exits. The same
208
+ # overall deadline covers pipe draining, as subprocess.communicate does.
209
+ while not finished.is_set():
210
+ if on_poll is not None:
211
+ on_poll()
212
+ remaining = poll_s if deadline is None else deadline - time.monotonic()
213
+ if remaining <= 0:
214
+ raise subprocess.TimeoutExpired(process.args, timeout)
215
+ finished.wait(min(poll_s, remaining))
216
+ if errors:
217
+ raise errors[0]
218
+ return output[0]
219
+ except BaseException as exc:
220
+ cleaned = True
221
+ terminate_quietly(process)
222
+ # A killed tree releases pipes. Join in bounded slices before closing
223
+ # handles, without letting cleanup replace the original cancellation.
224
+ cleanup_deadline = time.monotonic() + 2
225
+ while not finished.is_set() and time.monotonic() < cleanup_deadline:
226
+ finished.wait(poll_s)
227
+ if not finished.is_set() and os.name == "nt":
228
+ from ._windows_job import cancel_thread_io
229
+
230
+ # communicate uses additional reader threads on Windows. Cancel a
231
+ # blocked write/read as a bounded fallback after terminating the Job.
232
+ for thread in (io_thread, getattr(process, "_stdout_thread", None), getattr(process, "_stderr_thread", None)):
233
+ cancel_thread_io(thread)
234
+ finished.wait(poll_s)
235
+ if not finished.is_set() and hasattr(exc, "add_note"):
236
+ exc.add_note("Owned process terminated; pipe I/O did not finish within the 2.2s cleanup budget")
237
+ if isinstance(exc, subprocess.TimeoutExpired) and output:
238
+ exc.stdout, exc.stderr = output[0]
239
+ if os.name != "nt":
240
+ exc.stdout = exc.stdout.encode(process.encoding or "utf-8") if isinstance(exc.stdout, str) else exc.stdout
241
+ exc.stderr = exc.stderr.encode(process.encoding or "utf-8") if isinstance(exc.stderr, str) else exc.stderr
242
+ raise
243
+ finally:
244
+ # Normal, timeout and cancellation all close the same launch-time owner.
245
+ if not cleaned:
246
+ terminate_quietly(process)
247
+
248
+
249
+ def run_interruptibly(*popenargs, input=None, capture_output=False, timeout=None, check=False, **kwargs):
250
+ """subprocess.run contract with short waits and owned-tree cleanup on cancellation/timeout."""
251
+ if input is not None:
252
+ if kwargs.get("stdin") is not None:
253
+ raise ValueError("stdin and input arguments may not both be used.")
254
+ kwargs["stdin"] = subprocess.PIPE
255
+ if capture_output:
256
+ if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None:
257
+ raise ValueError("stdout and stderr arguments may not be used with capture_output.")
258
+ kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
259
+ process = owned_popen(*popenargs, **kwargs)
260
+ try:
261
+ stdout, stderr = communicate_interruptibly(process, timeout, input=input)
262
+ if check and process.returncode:
263
+ raise subprocess.CalledProcessError(process.returncode, process.args, output=stdout, stderr=stderr)
264
+ return subprocess.CompletedProcess(process.args, process.returncode, stdout, stderr)
265
+ finally:
266
+ terminate_quietly(process)
267
+
268
+
269
+ def terminate_quietly(process: subprocess.Popen, *, wait_s: float = 2) -> None:
270
+ """Kill the owned tree without letting cleanup errors replace the caller's exception."""
271
+ try:
272
+ kill_process_tree(process)
273
+ wait_interruptibly(process, wait_s, cleanup_on_error=False, cleanup_on_exit=False)
274
+ except Exception:
275
+ pass
276
+
277
+
278
+ def remove_tree_with_retry(path: Path, *, attempts: int = 40, delay_s: float = 0.25) -> bool:
279
+ """Best-effort removal that tolerates handles released a moment after a tree kill."""
280
+ import shutil
281
+
282
+ for _ in range(attempts):
283
+ try:
284
+ shutil.rmtree(path)
285
+ return True
286
+ except FileNotFoundError:
287
+ return True
288
+ except OSError:
289
+ time.sleep(delay_s)
290
+ return False
291
+
292
+
293
+ def kill_process_tree(process: subprocess.Popen | int) -> None:
294
+ """Kill an owned child tree (or a recorded browser-job child PID)."""
295
+ pid = process if isinstance(process, int) else process.pid
296
+ owner = None if isinstance(process, int) else getattr(process, "__dict__", {}).get("_openmd_owner")
297
+ if owner is not None:
298
+ owner.close()
299
+ return
300
+ if not isinstance(process, int) and process.poll() is not None:
301
+ return
302
+ if os.name == "nt":
303
+ taskkill = Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) / "System32" / "taskkill.exe"
304
+ killer = subprocess.Popen([str(taskkill), "/PID", str(pid), "/T", "/F"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
305
+ try:
306
+ wait_interruptibly(killer, 15, cleanup_on_error=False)
307
+ except BaseException:
308
+ killer.kill()
309
+ else:
310
+ try:
311
+ os.killpg(pid, signal.SIGKILL)
312
+ except ProcessLookupError:
313
+ pass
314
+ if not isinstance(process, int) and process.poll() is None:
315
+ process.kill()
316
+
317
+
318
+ def process_options() -> dict[str, Any]:
319
+ return {"creationflags": subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt" else {"start_new_session": True}
320
+
321
+
322
+ def _ready_for(report: dict[str, Any], backend: str) -> bool:
323
+ return bool(report.get("backend_readiness", {}).get(backend, report.get("ready", False)))
324
+
325
+
326
+ def probe_asr_runtime(python: Path, *, refresh: bool = False) -> dict[str, Any]:
327
+ """Probe metadata, actual CUDA visibility and local models in an isolated process."""
328
+ try:
329
+ stamp = python.stat().st_mtime_ns
330
+ except OSError:
331
+ return {"ready": False, "error_code": "asr_runtime_missing", "python": display_path(python)}
332
+ env = worker_environment()
333
+ config = load_local_config()
334
+ key = (str(python.resolve()), stamp, json.dumps(config, sort_keys=True), tuple(sorted((k, v) for k, v in env.items() if k.startswith(("OPENMD_", "HF_", "MODELSCOPE_", "CUDA_")))))
335
+ if not refresh and key in _PROBE_CACHE:
336
+ return _PROBE_CACHE[key]
337
+ command = worker_command(python, "--probe")
338
+ try:
339
+ with owned_temporary_directory(prefix="openmd-asr-probe-") as workdir:
340
+ process = owned_popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, cwd=workdir, **process_options())
341
+ try:
342
+ stdout, stderr = communicate_interruptibly(process, timeout=60)
343
+ except subprocess.TimeoutExpired:
344
+ kill_process_tree(process)
345
+ report = {"ready": False, "error_code": "asr_runtime_probe_timeout", "python": display_path(python)}
346
+ except BaseException:
347
+ kill_process_tree(process)
348
+ raise
349
+ else:
350
+ if process.returncode or len(stdout) > 1024 * 1024:
351
+ raise ValueError(sanitize_error_summary(stderr[-2048:].decode("utf-8", errors="replace")) or "probe exited without a valid report")
352
+ report = json.loads(stdout.decode("utf-8-sig"))
353
+ if not isinstance(report, dict) or report.get("schema") != "openmd.asr-probe/v1" or not isinstance(report.get("ready"), bool):
354
+ raise ValueError("invalid ASR probe schema")
355
+ except (OSError, ValueError, subprocess.SubprocessError) as exc:
356
+ report = {"ready": False, "error_code": "asr_runtime_probe_failed", "python": display_path(python), "error_summary": sanitize_error_summary(exc)}
357
+ _PROBE_CACHE[key] = report
358
+ return report
359
+
360
+
361
+ def select_asr_runtime(*, refresh: bool = False, backend: str = "asr.auto") -> AsrRuntime:
362
+ if os.getenv("OPENMD_ASR_WORKER") == "1":
363
+ return AsrRuntime("in_process", Path(sys.executable), "ASR worker: recursive handoff disabled")
364
+ config = load_local_config()
365
+ explicit = os.getenv("OPENMD_ASR_PYTHON") or config.get("asr_python")
366
+ if explicit:
367
+ selected = Path(explicit).expanduser()
368
+ if not selected.is_absolute():
369
+ selected = (checkout_root() or Path.cwd()) / selected
370
+ report = probe_asr_runtime(selected, refresh=refresh)
371
+ mode = "in_process" if selected.resolve() == Path(sys.executable).resolve() else "worker"
372
+ ready = _ready_for(report, backend)
373
+ return AsrRuntime(mode if ready else "unavailable", selected, "explicit ASR runtime" if ready else "Configured ASR runtime is not ready; run openmd doctor --profile media", report)
374
+
375
+ candidates: list[Path] = []
376
+ try:
377
+ current_has_stack = all(importlib.util.find_spec(name) is not None for name in REQUIRED_MODULES)
378
+ except (ValueError, ImportError):
379
+ current_has_stack = False
380
+ if current_has_stack:
381
+ candidates.append(Path(sys.executable))
382
+ root = checkout_root()
383
+ if root:
384
+ candidates.append(root / "out" / "asr-bench" / "venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python"))
385
+ reports: list[dict[str, Any]] = []
386
+ for candidate in dict.fromkeys(candidates):
387
+ report = probe_asr_runtime(candidate, refresh=refresh)
388
+ reports.append(report)
389
+ if _ready_for(report, backend):
390
+ mode = "in_process" if candidate.resolve() == Path(sys.executable).resolve() else "worker"
391
+ return AsrRuntime(mode, candidate, "Selected a local CUDA ASR runtime with all required models", report)
392
+ # A timeout or crashed probe is unknown readiness, not evidence that the GPU
393
+ # stack is absent. Do not silently change engine/device on transient failure.
394
+ if any(report.get("error_code") in {"asr_runtime_probe_timeout", "asr_runtime_probe_failed"} for report in reports):
395
+ return AsrRuntime("unavailable", None, "Local ASR runtime probe did not complete; retry openmd doctor --profile media before transcription", {"ready": False, "attempts": reports})
396
+ # Retain the documented CPU Whisper path only for confirmed missing stacks.
397
+ if backend in {"asr.auto", "asr.faster_whisper"}:
398
+ try:
399
+ from .models import configured_models
400
+
401
+ local_models = configured_models()
402
+ required = ("whisper-large-v3", "whisper-large-v3-turbo") if backend == "asr.auto" else ("whisper-large-v3",)
403
+ if importlib.util.find_spec("faster_whisper") is not None and all(local_models[key].path is not None for key in required):
404
+ return AsrRuntime("in_process", Path(sys.executable), "No complete CUDA worker is ready; using the documented local Whisper fallback", {"ready": False, "fallback_available": True, "attempts": reports})
405
+ except (ImportError, ValueError):
406
+ pass
407
+ return AsrRuntime("unavailable", None, "No complete local ASR runtime is ready; configure asr_python and local models, then run openmd doctor --profile media", {"ready": False, "attempts": reports})