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.
- openmd/__init__.py +3 -0
- openmd/__main__.py +5 -0
- openmd/adapters/__init__.py +1 -0
- openmd/adapters/base.py +95 -0
- openmd/adapters/bilibili/__init__.py +11 -0
- openmd/adapters/bilibili/adapter.py +104 -0
- openmd/adapters/bilibili/discovery.py +33 -0
- openmd/adapters/douyin/__init__.py +11 -0
- openmd/adapters/douyin/adapter.py +117 -0
- openmd/adapters/douyin/discovery.py +36 -0
- openmd/adapters/douyin/sidecar.py +77 -0
- openmd/adapters/generic_media/__init__.py +14 -0
- openmd/adapters/high_privacy.py +77 -0
- openmd/adapters/local_audio/__init__.py +14 -0
- openmd/adapters/local_audio/adapter.py +169 -0
- openmd/adapters/local_audio/asr.py +58 -0
- openmd/adapters/local_audio/backends/__init__.py +1 -0
- openmd/adapters/local_audio/backends/auto.py +177 -0
- openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
- openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
- openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
- openmd/adapters/opencli.py +770 -0
- openmd/adapters/pdf/__init__.py +11 -0
- openmd/adapters/pdf/adapter.py +153 -0
- openmd/adapters/pdf/backends/__init__.py +1 -0
- openmd/adapters/pdf/backends/markitdown.py +165 -0
- openmd/adapters/pdf/parser.py +56 -0
- openmd/adapters/qq_group/__init__.py +11 -0
- openmd/adapters/qq_group/adapter.py +289 -0
- openmd/adapters/qq_group/parsers/__init__.py +1 -0
- openmd/adapters/qq_group/parsers/html.py +60 -0
- openmd/adapters/qq_group/parsers/json.py +71 -0
- openmd/adapters/registry.py +149 -0
- openmd/adapters/screenshot/__init__.py +11 -0
- openmd/adapters/screenshot/adapter.py +154 -0
- openmd/adapters/screenshot/backends/__init__.py +1 -0
- openmd/adapters/screenshot/backends/paddle.py +15 -0
- openmd/adapters/screenshot/backends/rapidocr.py +176 -0
- openmd/adapters/screenshot/backends/tesseract.py +15 -0
- openmd/adapters/screenshot/ocr.py +84 -0
- openmd/adapters/wechat_group/__init__.py +11 -0
- openmd/adapters/wechat_group/adapter.py +160 -0
- openmd/adapters/wechat_group/wx_cli.py +268 -0
- openmd/adapters/wechat_moments/__init__.py +11 -0
- openmd/adapters/wechat_moments/adapter.py +167 -0
- openmd/adapters/wechat_moments/wx_cli.py +265 -0
- openmd/adapters/weixin/__init__.py +11 -0
- openmd/adapters/weixin/adapter.py +148 -0
- openmd/adapters/weixin/discovery.py +510 -0
- openmd/adapters/weixin/report.py +278 -0
- openmd/adapters/xiaohongshu.py +158 -0
- openmd/backends/__init__.py +1 -0
- openmd/backends/base.py +204 -0
- openmd/backends/bilibili.py +1480 -0
- openmd/backends/bilibili_wbi.py +277 -0
- openmd/backends/discourse.py +426 -0
- openmd/backends/douyin.py +868 -0
- openmd/backends/douyin_detail.py +124 -0
- openmd/backends/douyin_providers.py +602 -0
- openmd/backends/executor.py +546 -0
- openmd/backends/feishu.py +861 -0
- openmd/backends/generic_media.py +77 -0
- openmd/backends/interactive.py +60 -0
- openmd/backends/ocr.py +41 -0
- openmd/backends/opencli.py +82 -0
- openmd/backends/pdf.py +122 -0
- openmd/backends/registry.py +234 -0
- openmd/backends/screenshot.py +79 -0
- openmd/backends/video_metadata.py +167 -0
- openmd/backends/web_article.py +363 -0
- openmd/backends/wechat_group.py +35 -0
- openmd/backends/wechat_moments.py +35 -0
- openmd/backends/weixin.py +767 -0
- openmd/backends/x.py +455 -0
- openmd/backends/xiaoe.py +940 -0
- openmd/backends/xiaohongshu.py +911 -0
- openmd/backends/xiaohongshu_providers.py +876 -0
- openmd/backends/xiaoyuzhou.py +1017 -0
- openmd/backends/youtube.py +280 -0
- openmd/backends/youtube_discovery.py +1245 -0
- openmd/backends/youtube_transcript.py +213 -0
- openmd/browser_extension/background.js +231 -0
- openmd/browser_extension/icons/icon-128.png +0 -0
- openmd/browser_extension/icons/icon-16.png +0 -0
- openmd/browser_extension/icons/icon-32.png +0 -0
- openmd/browser_extension/icons/icon-48.png +0 -0
- openmd/browser_extension/icons/icon-512.png +0 -0
- openmd/browser_extension/idle.html +6 -0
- openmd/browser_extension/manifest.json +43 -0
- openmd/browser_extension/options.html +42 -0
- openmd/browser_extension/options.js +117 -0
- openmd/browser_extension/popup.css +71 -0
- openmd/browser_extension/popup.html +17 -0
- openmd/browser_extension/popup.js +59 -0
- openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
- openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
- openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
- openmd/browser_host.py +503 -0
- openmd/browser_setup.py +106 -0
- openmd/cli.py +2012 -0
- openmd/console.py +32 -0
- openmd/core/__init__.py +1 -0
- openmd/core/capabilities.py +162 -0
- openmd/core/content_validation.py +323 -0
- openmd/core/coverage.py +163 -0
- openmd/core/detector.py +247 -0
- openmd/core/doctor.py +104 -0
- openmd/core/execution_policy.py +164 -0
- openmd/core/filename.py +64 -0
- openmd/core/input_validation.py +91 -0
- openmd/core/local_config.py +96 -0
- openmd/core/manifest.py +156 -0
- openmd/core/models.py +168 -0
- openmd/core/opencli_output.py +34 -0
- openmd/core/operation_contract.py +60 -0
- openmd/core/output.py +194 -0
- openmd/core/package_contract.py +335 -0
- openmd/core/package_validator.py +489 -0
- openmd/core/package_writer.py +666 -0
- openmd/core/planner.py +345 -0
- openmd/core/preferences.py +61 -0
- openmd/core/readable.py +129 -0
- openmd/core/receipt.py +285 -0
- openmd/core/renderer.py +185 -0
- openmd/core/runlog.py +208 -0
- openmd/core/secrets.py +313 -0
- openmd/doctor_view.py +68 -0
- openmd/interactive.py +453 -0
- openmd/ocr/__init__.py +1 -0
- openmd/ocr/breakers.py +84 -0
- openmd/ocr/config.py +54 -0
- openmd/ocr/documents.py +522 -0
- openmd/ocr/endpoint.py +72 -0
- openmd/ocr/layout.py +92 -0
- openmd/ocr/layout_filter.py +118 -0
- openmd/ocr/layout_worker.py +89 -0
- openmd/ocr/native_math.py +220 -0
- openmd/ocr/office.py +305 -0
- openmd/ocr/ollama.py +200 -0
- openmd/ocr/otsl.py +51 -0
- openmd/ocr/ovis-prompt.txt +1 -0
- openmd/ocr/pipeline.py +384 -0
- openmd/ocr/settings.json +25 -0
- openmd/ocr/tiling.py +631 -0
- openmd/post_video.py +154 -0
- openmd/probe.py +397 -0
- openmd/routing/__init__.py +1 -0
- openmd/routing/cards/builtin.json +620 -0
- openmd/routing/cards/defuddle.json +70 -0
- openmd/routing/cards/x-tweet-fetcher.json +71 -0
- openmd/routing/cards/xyz-dl.json +60 -0
- openmd/routing/cards/yutto.json +68 -0
- openmd/routing/entry_shim.py +71 -0
- openmd/routing/lifecycle.py +459 -0
- openmd/routing/loader.py +138 -0
- openmd/routing/providers.py +158 -0
- openmd/routing/readers.py +108 -0
- openmd/routing/routes.json +504 -0
- openmd/routing/runner.py +192 -0
- openmd/routing/view.py +40 -0
- openmd/runtime/__init__.py +1 -0
- openmd/runtime/_windows_job.py +234 -0
- openmd/runtime/asr.py +217 -0
- openmd/runtime/asr_worker.py +119 -0
- openmd/runtime/gpu_lock.py +81 -0
- openmd/runtime/gpu_quarantine.py +85 -0
- openmd/runtime/interpreters.py +407 -0
- openmd/runtime/managed_tools.py +204 -0
- openmd/runtime/models.py +202 -0
- openmd/runtime/services.py +175 -0
- openmd/runtime/tempdirs.py +227 -0
- openmd_cli-0.1.0.dist-info/METADATA +168 -0
- openmd_cli-0.1.0.dist-info/RECORD +177 -0
- openmd_cli-0.1.0.dist-info/WHEEL +5 -0
- openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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})
|