File size: 10,493 Bytes
d646dd7
 
 
 
 
 
 
 
 
 
 
 
330cf83
 
 
d646dd7
330cf83
d646dd7
 
 
 
 
330cf83
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330cf83
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330cf83
d646dd7
 
 
 
 
 
 
330cf83
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330cf83
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330cf83
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
330cf83
d646dd7
330cf83
d646dd7
 
330cf83
d646dd7
 
 
 
 
 
330cf83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d646dd7
 
 
 
 
 
330cf83
 
d646dd7
330cf83
 
d646dd7
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
#!/usr/bin/env python3
"""生成 Windows 离线部署包(wheels/ 目录),供 start.bat 在无外网环境一键安装。

在任意**有网**的机器上运行(Linux 开发机即可,跨平台拉取 Windows 轮子),
把产出的 wheels/ 整个目录拷到客户项目根目录:
start.bat / start.sh 检测到 wheels\\*.whl 后自动改走纯离线安装。

跨平台解析用 uv(uv pip compile --python-platform windows,能正确按目标平台
评估环境标记,剔除 Linux-only 依赖如 bleak 的 dbus-fast);无 uv 时回退纯
pip download(此时建议直接在一台 Windows 机器上运行本脚本)。

用法:
    python scripts/pack_wheels.py                 # 主程序必需依赖 + 手套工具包
    python scripts/pack_wheels.py --lite          # 仅极简采集版依赖(requirements-lite.txt,不带工具包)
    python scripts/pack_wheels.py --extras        # + mediapipe
    python scripts/pack_wheels.py --torch         # + CPU 版 torch(较大)
    python scripts/pack_wheels.py --no-toolkit    # 只打依赖轮子,不裁手套工具包
    python scripts/pack_wheels.py --out /tmp/wheels   # 自定义输出目录

产物:
    <out>/*.whl                        Windows 3.12 依赖轮子(含依赖闭包)
    <out>/python-3.12.10-amd64.exe     Python 安装包(客户机器无 Python 时用)
    <out>/toolkit/glove_toolkit.zip    手套工具包裁剪版(骨架解算用;见 pack_toolkit.py)
"""

from __future__ import annotations

import argparse
import shutil
import subprocess
import sys
import urllib.request
from pathlib import Path

PY_VERSION = "3.12"
PY_INSTALLER = "python-3.12.10-amd64.exe"
PY_INSTALLER_URLS = [
    f"https://mirrors.aliyun.com/python-release/windows/{PY_INSTALLER}",
    f"https://registry.npmmirror.com/-/binary/python/3.12.10/{PY_INSTALLER}",
    f"https://mirrors.huaweicloud.com/python/3.12.10/{PY_INSTALLER}",
]
ALIYUN = "https://mirrors.aliyun.com/pypi/simple/"
TUNA = "https://pypi.tuna.tsinghua.edu.cn/simple"
PYPI = "https://pypi.org/simple"
MIRRORS = [ALIYUN, TUNA, PYPI]
# pyrealsense2(D435/D405)已提升为 requirements.txt 的默认依赖,不再属于可选包
EXTRAS = ["mediapipe"]
TORCH_INDEXES = [
    ("https://mirrors.aliyun.com/pytorch-wheels/cpu/", ALIYUN),
    ("https://download.pytorch.org/whl/cpu", PYPI),
]
UV = shutil.which("uv")


def parse_requirements(path: Path) -> list[str]:
    """requirements.txt → 包规格列表(去注释、去空行)。"""
    specs = []
    for line in path.read_text(encoding="utf-8").splitlines():
        line = line.split("#", 1)[0].strip()
        if line:
            specs.append(line)
    return specs


def _run(cmd: list[str]) -> None:
    subprocess.run(cmd, check=True)


def _pip_download(lock: Path, out: Path, mirror: str) -> None:
    """按锁定文件抓取 Windows 轮子(--no-deps:锁定已含全部闭包,无需再解析)。"""
    _run([sys.executable, "-m", "pip", "download", "--no-deps",
          "-r", str(lock), "-d", str(out),
          "--platform", "win_amd64", "--python-version", PY_VERSION,
          "--only-binary=:all:", "-i", mirror])


def download_closure(specs: list[str], out: Path,
                     index: str | None = None,
                     extra_index: str | None = None,
                     lock_name: str = "requirements-win.txt") -> Path:
    """uv compile(目标 Windows)→ pip download 抓轮子。无 uv 时直接 pip download。"""
    lock = out / lock_name
    if UV is None:
        # 回退:在 Windows 机器上运行时 pip 会按本机平台评估标记,结果正确
        req = out / ".pack_wheels_req.tmp"
        req.write_text("\n".join(specs), encoding="utf-8")
        _run([sys.executable, "-m", "pip", "download", "-d", str(out),
              "-r", str(req), "--platform", "win_amd64",
              "--python-version", PY_VERSION, "--only-binary=:all:",
              "-i", index or MIRRORS[0]])
        req.unlink()
        return lock

    cmd = [UV, "pip", "compile", "--python-platform", "windows",
           "--python-version", PY_VERSION, "--output-file", str(lock)]
    if index:
        cmd += ["--index-url", index]
    if extra_index:
        cmd += ["--extra-index-url", extra_index]
    req = out / ".pack_wheels_req.tmp"
    req.write_text("\n".join(specs), encoding="utf-8")
    cmd.append(str(req))
    _run(cmd)
    req.unlink()
    # 按锁抓轮子(锁内索引头由 pip 原样生效;传同一镜像兜底非锁内包的查找)
    _pip_download(lock, out, index or MIRRORS[0])
    return lock


def download_python_installer(out: Path) -> None:
    target = out / PY_INSTALLER
    if target.exists() and target.stat().st_size > 10_000_000:
        print(f"  已存在: {target.name},跳过下载")
        return
    last_err: Exception | None = None
    for url in PY_INSTALLER_URLS:
        try:
            print(f"  下载 Python 安装包: {url}")
            with urllib.request.urlopen(url, timeout=120) as r:
                target.write_bytes(r.read())
            print(f"  完成: {target.name} ({target.stat().st_size / 1e6:.1f} MB)")
            return
        except Exception as e:  # noqa: BLE001 — 逐源回退
            last_err = e
    raise RuntimeError(f"Python 安装包下载失败: {last_err}")


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--lite", action="store_true",
                        help="仅打包极简采集版依赖(requirements-lite.txt)")
    parser.add_argument("--extras", action="store_true",
                        help="额外打包 mediapipe")
    parser.add_argument("--torch", action="store_true",
                        help="额外打包 CPU 版 torch(体积较大)")
    parser.add_argument("--out", type=Path,
                        default=Path(__file__).resolve().parent.parent / "wheels",
                        help="输出目录(默认仓库根目录 wheels/)")
    parser.add_argument("--no-python-installer", action="store_true",
                        help="跳过 Python 安装包下载")
    parser.add_argument("--no-toolkit", action="store_true",
                        help="不裁手套工具包(骨架解算随包,仅 --lite 时才可省)")
    args = parser.parse_args()

    if args.lite and (args.extras or args.torch):
        parser.error("--lite 与 --extras/--torch 互斥(极简版不装可选功能)")

    out = args.out
    out.mkdir(parents=True, exist_ok=True)

    repo_root = Path(__file__).resolve().parent.parent
    req_file = "requirements-lite.txt" if args.lite else "requirements.txt"
    specs = parse_requirements(repo_root / req_file)
    if not specs:
        print(f"[错误] {req_file} 为空或不存在", file=sys.stderr)
        return 1
    if args.extras:
        specs = specs + EXTRAS

    print(f"[1/4] 解析 {len(specs)} 个包的 Windows {PY_VERSION} 依赖闭包 -> {out}/ ...")
    if UV is None:
        print("  [提示] 未检测到 uv,回退纯 pip 解析(建议在一台 Windows 机器上运行,"
              "或 pip install uv 后重试)")

    last_err: Exception | None = None
    for mirror in MIRRORS:
        try:
            download_closure(specs, out, index=mirror)
            break
        except subprocess.CalledProcessError as e:
            last_err = e
            print(f"  源 {mirror} 失败,尝试下一个 ...")
    else:
        print(f"[错误] 依赖下载失败: {last_err}", file=sys.stderr)
        if UV is None:
            print("  提示: 本机无 uv,且 pip 跨平台解析可能因 Linux-only 依赖失败;"
                  "请 pip install uv 后重试,或直接在一台 Windows 机器上运行本脚本。",
                  file=sys.stderr)
        return 1

    if args.torch:
        print("[2/4] 解析 CPU 版 torch(Windows)...")
        torch_done = False
        for index, extra in TORCH_INDEXES:
            try:
                download_closure(["torch"], out, index=index, extra_index=extra,
                                 lock_name="torch-win.txt")
                torch_done = True
                break
            except subprocess.CalledProcessError as e:
                last_err = e
                print(f"  源 {index} 失败,尝试下一个 ...")
        if not torch_done:
            print(f"[错误] torch 下载失败: {last_err}", file=sys.stderr)
            return 1
        print("[2/4] torch 完成")
    else:
        print("[2/4] 跳过 torch(--torch 可打包 CPU 版)")

    if not args.no_python_installer:
        print("[3/4] 拉取 Python 安装包(客户机器无 Python 时 start.bat 会用)...")
        try:
            download_python_installer(out)
        except RuntimeError as e:
            print(f"[警告] {e};不影响 wheels 使用,手动放入同名文件即可",
                  file=sys.stderr)
    else:
        print("[3/4] 跳过 Python 安装包(--no-python-installer)")

    # 手套工具包(骨架解算)——普通版随包,极简版不需要
    if args.lite or args.no_toolkit:
        print("[4/4] 跳过手套工具包"
              + ("(--lite 极简版不含骨架解算)" if args.lite else "(--no-toolkit)"))
    else:
        print("[4/4] 裁剪手套工具包(骨架解算随包)...")
        rc = subprocess.run(
            [sys.executable, str(repo_root / "scripts" / "pack_toolkit.py"),
             "--out", str(out)]).returncode
        if rc != 0:
            # 不阻断 wheel 打包:工具包缺失只影响骨架,主程序照常
            print("[警告] 工具包裁剪失败(骨架解算将不可用)。"
                  "单独重试: python scripts/pack_toolkit.py --out "
                  f"{out}", file=sys.stderr)

    whls = sorted(out.glob("*.whl"))
    total = sum(w.stat().st_size for w in whls) / 1e6
    print()
    print("=" * 60)
    print(f"打包完成: {len(whls)} 个 wheel,共 {total:.0f} MB -> {out}/")
    if not (args.lite or args.no_toolkit):
        print(f"          手套工具包 -> {out}/toolkit/glove_toolkit.zip")
    print("交付方式: 把整个 wheels/ 目录拷到客户项目根目录,")
    print("          start.bat / start.sh 会自动改走纯离线安装,")
    print("          并把工具包展开到项目根(骨架解算开箱可用)。")
    print("=" * 60)
    return 0


if __name__ == "__main__":
    sys.exit(main())