"""依赖安装器:把 sounddevice(及可选 PyQt5)装进项目内的 _vendor 目录。 为什么不用 ``pip install sounddevice`` 就完事? -------------------------------------------------- * 项目内的 ``_vendor`` 目录让整个软件"自包含":换台电脑、拷个 U 盘就能跑, 不污染系统 Python 环境,也不需要管理员权限; * 某些受限环境(企业代理、受管控的临时目录、沙箱)里 pip 会因为无法写入 自己的临时目录而失败,本脚本提供了 **直接下载 wheel 并解压** 的兜底路径, 完全不依赖 pip 的临时目录机制。 用法:: python install_deps.py # 安装必需依赖(sounddevice) python install_deps.py --with-pyqt5 # 同时安装图形界面依赖 PyQt5(较大) python install_deps.py --check # 只检查当前依赖状态 """ from __future__ import annotations import argparse import io import json import os import platform import ssl import subprocess import sys import sysconfig import zipfile HERE = os.path.dirname(os.path.abspath(__file__)) VENDOR = os.path.join(HERE, "_vendor") # 每一组都会被装进 _vendor;顺序即安装顺序 PACKAGE_SETS = { "sounddevice": ["sounddevice"], "pyqt5": ["PyQt5", "PyQt5-Qt5", "PyQt5-sip"], } # --------------------------------------------------------------- 平台标签 def platform_tag() -> str | None: """返回当前平台在 wheel 文件名里的标签(如 win_amd64)。""" system = platform.system().lower() machine = platform.machine().lower() if system == "windows": if machine in ("amd64", "x86_64"): return "win_amd64" if machine in ("arm64", "aarch64"): return "win_arm64" return "win32" if system == "darwin": return "macosx" if system == "linux": return "manylinux" return None def python_tags() -> list[str]: """当前解释器可接受的 ABI 标签,用于挑选正确的 wheel。""" ver = f"cp{sys.version_info.major}{sys.version_info.minor}" return [ver, "abi3", "py3", "py2.py3", ver.replace("cp", "cp3")] def _ssl_context() -> ssl.SSLContext: try: import certifi # type: ignore return ssl.create_default_context(cafile=certifi.where()) except Exception: return ssl.create_default_context() def _http_get_json(url: str, timeout: int = 40) -> dict: import urllib.request req = urllib.request.Request(url, headers={"User-Agent": "RecorderStudio-installer"}) with urllib.request.urlopen(req, context=_ssl_context(), timeout=timeout) as resp: return json.loads(resp.read().decode("utf-8")) def _http_get_bytes(url: str, timeout: int = 120) -> bytes: import urllib.request req = urllib.request.Request(url, headers={"User-Agent": "RecorderStudio-installer"}) with urllib.request.urlopen(req, context=_ssl_context(), timeout=timeout) as resp: return resp.read() def pick_wheel(files: list[dict], plat: str | None) -> dict | None: """从 PyPI 的文件列表里挑出最适合当前平台的 wheel。""" wheels = [f for f in files if f["filename"].endswith(".whl")] if not wheels: return None def score(name: str) -> int: base = name[:-4] parts = base.split("-") if len(parts) < 5: return -1 py_tag, abi_tag, plat_tag = parts[-3], parts[-2], parts[-1] s = 0 # 平台匹配 tags = plat_tag.split(".") if plat and any(plat in t for t in tags): s += 100 elif "any" in plat_tag: s += 40 else: return -1 # Python/ABI 匹配 want = python_tags() if any(t == abi_tag for t in want): s += 30 elif "abi3" in abi_tag: s += 20 elif "any" in abi_tag or abi_tag == "none": s += 5 else: return -1 if any(py_tag == t or py_tag.startswith("py3") for t in want): s += 10 # 64 位优先 if "64" in plat_tag or "amd64" in plat_tag or "arm64" in plat_tag: s += 2 return s best, best_score = None, 0 for w in wheels: s = score(w["filename"]) if s > best_score: best, best_score = w, s return best def install_via_wheel_download(pkg: str) -> tuple[bool, str]: """直接下载 wheel 并解压到 _vendor(不依赖 pip 的临时目录)。""" plat = platform_tag() try: data = _http_get_json(f"https://pypi.org/pypi/{pkg}/json") except Exception as exc: return False, f"无法访问 PyPI:{exc}" wheel = pick_wheel(data.get("urls") or [], plat) if wheel is None: return False, (f"PyPI 上没有适配当前平台({plat} / Python " f"{sys.version_info.major}.{sys.version_info.minor})的 wheel") try: blob = _http_get_bytes(wheel["url"]) except Exception as exc: return False, f"下载失败:{exc}" try: os.makedirs(VENDOR, exist_ok=True) with zipfile.ZipFile(io.BytesIO(blob)) as z: z.extractall(VENDOR) except Exception as exc: return False, f"解压失败:{exc}" return True, f"{wheel['filename']}({wheel['size'] / 1024:.0f} KB)" def install_via_pip(pkg: str) -> tuple[bool, str]: """优先尝试 pip(把临时目录也指到项目内,避免受管控目录写入失败)。""" tmp = os.path.join(HERE, "_build", "tmp") os.makedirs(tmp, exist_ok=True) env = dict(os.environ, TEMP=tmp, TMP=tmp, TMPDIR=tmp, PIP_DISABLE_PIP_VERSION_CHECK="1") cmd = [sys.executable, "-m", "pip", "install", "--no-input", "--no-cache-dir", "--target", VENDOR, pkg] try: proc = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=900) except Exception as exc: return False, f"pip 调用失败:{exc}" if proc.returncode == 0: return True, "pip 安装成功" tail = (proc.stderr or proc.stdout or "").strip().splitlines() return False, tail[-1] if tail else "pip 返回非零退出码" def ensure(pkg: str, *, verbose: bool = True) -> bool: ok, msg = install_via_pip(pkg) if ok: print(f" ✓ {pkg}:{msg}") return True if verbose: print(f" · {pkg}:pip 方式失败({msg}),改用直接下载 wheel…") ok, msg = install_via_wheel_download(pkg) if ok: print(f" ✓ {pkg}:{msg}") return True print(f" ✗ {pkg}:安装失败 —— {msg}") return False # ------------------------------------------------------------------ 检查 def check(verbose: bool = True) -> dict: """检查依赖是否就绪(会临时把 _vendor 加入搜索路径)。""" if VENDOR not in sys.path: sys.path.insert(0, VENDOR) status = {} for mod, label in (("numpy", "numpy(数值运算,必需)"), ("sounddevice", "sounddevice(音频后端,必需)"), ("PyQt5", "PyQt5(图形界面,必需)")): try: m = __import__(mod) ver = getattr(m, "__version__", "") if mod == "PyQt5": from PyQt5.QtCore import QT_VERSION_STR # noqa: F401 ver = QT_VERSION_STR status[mod] = (True, str(ver)) if verbose: print(f" ✓ {label}:{ver}") except Exception as exc: status[mod] = (False, str(exc)) if verbose: print(f" ✗ {label}:未安装({exc})") if status.get("sounddevice", (False,))[0]: try: import sounddevice as sd n = len([d for d in sd.query_devices() if d["max_input_channels"] > 0]) if verbose: print(f" ✓ PortAudio:{sd.get_portaudio_version()[1]}," f"检测到 {n} 个输入设备") status["devices"] = (n > 0, str(n)) except Exception as exc: status["devices"] = (False, str(exc)) if verbose: print(f" ✗ 音频设备枚举失败:{exc}") return status def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description="RecorderStudio 依赖安装器") p.add_argument("--with-pyqt5", action="store_true", help="同时安装 PyQt5(图形界面所需,约 60 MB)") p.add_argument("--check", action="store_true", help="只检查依赖状态") args = p.parse_args(argv) print(f"Python {sys.version.split()[0]} {sysconfig.get_platform()} " f"目标目录 {VENDOR}\n") if args.check: st = check() needed = [k for k in ("numpy", "sounddevice", "PyQt5") if not st.get(k, (False,))[0]] if needed: print(f"\n缺少:{', '.join(needed)} 请运行 python install_deps.py") return 1 print("\n依赖齐全,可以启动:python -m recorder.gui") return 0 print("正在安装依赖:") ok = True for pkg in PACKAGE_SETS["sounddevice"]: ok &= ensure(pkg) if args.with_pyqt5: for pkg in PACKAGE_SETS["pyqt5"]: ok &= ensure(pkg) print("\n安装后检查:") st = check() missing = [k for k in ("numpy", "sounddevice", "PyQt5") if not st.get(k, (False,))[0]] if not ok or missing: print("\n以下依赖仍不可用:" + "、".join(missing)) if "PyQt5" in missing and not args.with_pyqt5: print("提示:图形界面需要 PyQt5,请加参数重新运行:" "python install_deps.py --with-pyqt5") print("也可以手动安装:pip install --target _vendor sounddevice PyQt5") return 1 print("\n全部就绪!启动方式:") print(" · 图形界面:python -m recorder.gui (或双击 启动录音机.bat)") print(" · 命令行: python -m recorder.cli --list-devices") print(" · 自检: python -m recorder.selftest") return 0 if __name__ == "__main__": sys.exit(main())