Files
recorder-studio/install_deps.py
T

272 lines
9.9 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""依赖安装器:把 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())