272 lines
9.9 KiB
Python
272 lines
9.9 KiB
Python
"""依赖安装器:把 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())
|