Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 90 additions & 15 deletions scripts/build_exe.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
- Windows: PyInstaller
- macOS / Linux: Nuitka

为避免把本机 pip 全量库打进产物(参考 Issue #20),构建过程在临时干净 venv 中进行,
仅安装 requirements.txt 与对应打包工具。

用法:
python scripts/build_exe.py # 完整构建
python scripts/build_exe.py --dry-run # 仅打印命令,不执行
Expand All @@ -19,13 +22,40 @@
import os
import subprocess
import sys
import venv
from pathlib import Path

if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8")

ROOT = Path(__file__).resolve().parent.parent
LAUNCHER = ROOT / "scripts" / "launcher.py"
BUILD_DIR = ROOT / "build"
BUILD_VENV = BUILD_DIR / ".venv-build"

# 防御性排除:常见重型/无关库。即便在干净 venv 中通常不会被静态分析触及,
# 但加上显式排除更稳妥,也能让构建意图在命令行上可见。
EXCLUDED_MODULES = (
"torch",
"torchvision",
"torchaudio",
"tensorflow",
"tf_keras",
"keras",
"jax",
"flax",
"transformers",
"datasets",
"accelerate",
"scipy",
"pandas",
"sklearn",
"scikit-learn",
"matplotlib",
"IPython",
"jupyter",
"notebook",
)


def build_args() -> list[str]:
Expand All @@ -36,16 +66,19 @@ def build_args() -> list[str]:

if sys.platform == "win32":
sep = ";"
return [
args = [
"--onefile",
"--name=rp",
f"--add-data={data_dir}{sep}src/data",
f"--distpath={ROOT / 'dist'}",
f"--paths={ROOT}",
"--clean",
"--noconfirm",
str(LAUNCHER),
]
for mod in EXCLUDED_MODULES:
args.append(f"--exclude-module={mod}")
args.append(str(LAUNCHER))
return args

cmd = [
"--onefile",
Expand All @@ -56,12 +89,22 @@ def build_args() -> list[str]:
f"--output-dir={ROOT / 'dist'}",
"--static-libpython=no",
"--lto=yes",
str(LAUNCHER),
]
for mod in EXCLUDED_MODULES:
cmd.append(f"--nofollow-import-to={mod}")
cmd.append(str(LAUNCHER))
return cmd


def _venv_python() -> str:
"""构建用 venv 内 Python 解释器的路径。"""
if sys.platform == "win32":
return str(BUILD_VENV / "Scripts" / "python.exe")
return str(BUILD_VENV / "bin" / "python")


def _command() -> list[str]:
"""构造完整打包命令(首项 Python 路径由 main() 在执行时替换为 venv 内的解释器)。"""
if sys.platform == "win32":
return [sys.executable, "-m", "PyInstaller", *build_args()]
return [sys.executable, "-m", "nuitka", *build_args()]
Expand All @@ -83,19 +126,45 @@ def _build_env() -> dict[str, str]:
return env


def _ensure_builder() -> None:
"""按平台安装对应打包工具。"""
if sys.platform == "win32":
try:
import PyInstaller # noqa: F401
except ImportError:
subprocess.check_call([sys.executable, "-m", "pip", "install", "pyinstaller"])
def _builder_package() -> str:
"""当前平台对应的打包工具包名。"""
return "pyinstaller" if sys.platform == "win32" else "nuitka"


def _ensure_clean_venv() -> None:
"""创建/复用干净构建 venv,仅安装 requirements.txt + 打包工具。

解决 Issue #20:本机 pip 全量库(如 torch、jupyter 等)会被 PyInstaller/Nuitka
当作依赖一起打进产物,导致文件条目数超过 32 位无符号整数上限、产物极度臃肿。
使用独立 venv 后,打包工具只看见项目真实依赖。
"""
BUILD_DIR.mkdir(exist_ok=True)
py = _venv_python()
req = ROOT / "requirements.txt"
builder_pkg = _builder_package()

# 复用:venv 存在、stamp 不早于 requirements.txt
marker = BUILD_VENV / ".stamp"
if Path(py).is_file() and marker.is_file() and marker.stat().st_mtime >= req.stat().st_mtime:
print(f"复用构建 venv: {BUILD_VENV}")
return

try:
import nuitka # noqa: F401
except ImportError:
subprocess.check_call([sys.executable, "-m", "pip", "install", "nuitka"])
print(f"创建构建用 venv: {BUILD_VENV}")
env_builder = venv.EnvBuilder(
system_site_packages=False,
clear=True,
with_pip=True,
)
env_builder.create(str(BUILD_VENV))

print("升级 pip 并安装运行时依赖(requirements.txt)...")
subprocess.check_call([py, "-m", "pip", "install", "--upgrade", "pip"])
subprocess.check_call([py, "-m", "pip", "install", "-r", str(req)])
print(f"安装打包工具: {builder_pkg}")
subprocess.check_call([py, "-m", "pip", "install", builder_pkg])

marker.touch()
print(f"构建 venv 就绪: {py}")


def _binary_name() -> str:
Expand Down Expand Up @@ -127,10 +196,16 @@ def main(argv: list[str] | None = None) -> int:

cmd = _command()
if args.dry_run:
# dry-run 模式下若 venv 已存在,复用其 Python 路径让命令更真实
if Path(_venv_python()).is_file():
cmd[0] = _venv_python()
print(" ".join(cmd))
return 0

_ensure_builder()
_ensure_clean_venv()
# 用干净 venv 中的 Python 执行打包,避免本机 pip 库被打包
cmd[0] = _venv_python()

subprocess.check_call(cmd, cwd=ROOT, env=_build_env())
exe = ROOT / "dist" / _binary_name()
if exe.is_file():
Expand Down
100 changes: 100 additions & 0 deletions tests/test_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,103 @@ def test_build_main_dry_run():
from scripts.build_exe import main

assert main(["--dry-run"]) == 0


# ---------- 修复 Issue #20:避免打包本机所有 pip 库 ----------


def test_build_args_excludes_known_heavy_modules():
"""torch / jupyter 等本机常见干扰库必须显式排除,否则会被 PyInstaller/Nuitka 一并打进产物。"""
from scripts.build_exe import EXCLUDED_MODULES, build_args

args = build_args()
if sys.platform == "win32":
flag = "--exclude-module"
else:
flag = "--nofollow-import-to"
excluded = [a for a in args if a.startswith(flag + "=")]
assert len(excluded) == len(EXCLUDED_MODULES)
for mod in EXCLUDED_MODULES:
assert f"{flag}={mod}" in excluded


def test_venv_python_matches_platform_layout():
"""构建用 venv 的 Python 路径必须符合平台布局(Windows: Scripts\\python.exe;其它: bin/python)。"""
from scripts.build_exe import _venv_python

path = Path(_venv_python())
if sys.platform == "win32":
assert path.parts[-2:] == ("Scripts", "python.exe")
else:
assert path.parts[-2:] == ("bin", "python")


def test_ensure_clean_venv_uses_requirements_txt(monkeypatch, tmp_path):
"""干净 venv 必须从 requirements.txt 安装依赖,而不是从系统 Python 复制。"""
from scripts import build_exe

monkeypatch.setattr(build_exe, "ROOT", tmp_path)
(tmp_path / "requirements.txt").write_text("dummy==0.0.0\n", encoding="utf-8")

installed_with = []

class _FakeEnvBuilder:
def __init__(self, *args, **kwargs):
pass

def create(self, _path):
base = Path(_path) / ("Scripts" if sys.platform == "win32" else "bin")
base.mkdir(parents=True, exist_ok=True)
(base / ("python.exe" if sys.platform == "win32" else "python")).touch()
(Path(_path) / ".stamp").touch()

monkeypatch.setattr(build_exe.venv, "EnvBuilder", _FakeEnvBuilder)
monkeypatch.setattr(
build_exe.subprocess,
"check_call",
lambda cmd, *a, **kw: installed_with.append(cmd),
)

build_exe._ensure_clean_venv()

req_install = next(
(
c
for c in installed_with
if "-r" in c and str(tmp_path / "requirements.txt") in " ".join(map(str, c))
),
None,
)
assert req_install is not None, "必须从 requirements.txt 安装依赖"


def test_main_uses_venv_python_when_running_build(monkeypatch):
"""实际执行打包时,必须用 venv 内的 Python 而不是当前解释器,避免本机 pip 库被打包。"""
from scripts import build_exe

captured = {}

monkeypatch.setattr(build_exe, "_ensure_clean_venv", lambda: None)

fake_venv_py = "/fake/venv/python"
monkeypatch.setattr(build_exe, "_venv_python", lambda: fake_venv_py)

def _fake_check_call(cmd, *args, **kwargs):
captured["cmd"] = cmd

monkeypatch.setattr(build_exe.subprocess, "check_call", _fake_check_call)

class _NoExe:
def is_file(self):
return False

def stat(self):
raise OSError("no exe")

monkeypatch.setattr(build_exe, "_binary_name", lambda: "rp")
# 跳过最终 exe 检查和 env 同步,直接观察 cmd
monkeypatch.setattr(build_exe, "_sync_env_to_dist", lambda: None)

build_exe.main([])

assert captured["cmd"][0] == fake_venv_py
Loading