Files
FinDataHub/Scripts/bin/relocate_bundle_venvs.py
FinDataHub da9642c2bc fix(packaging): 裁剪内联运行时(72M → 59M),解决 codesign --deep 失败
问题
----
上一步把 Python 运行时内联进 `Contents/Frameworks/python312/` 后,
`codesign --force --deep` 失败:
    bundle format unrecognized, invalid, or unsuitable
    In subcomponent: .../Frameworks/python312/lib/thread3.0.4
把完整运行时(含 Tcl/Tk)放进 bundle 超出了 `--deep` 的处理能力。

改动
----
`relocate_bundle_venvs.py` 增加 `prune_runtime()`,裁掉运行期不需要的内容:
- Tcl/Tk 家族:`tcl9.0` `tk9.0` `tcl9` `itcl4.3.5` `thread3.0.4`
  `libtcl9.0.dylib` `libtcl9tk9.0.dylib`
- `include/`(供编译 C 扩展,运行期不需要)、`share/`
- stdlib 无关部分:`idlelib` `turtledemo` `ensurepip` `lib2to3` `tkinter` `test`
- `bin/` 中的开发工具:`idle3*` `2to3*`(保留 `pip` / `pydoc`)

裁剪依据(非拍脑袋):三个 venv 只用 pandas / numpy / pymysql / sqlalchemy /
fastapi / akshare 等纯计算与网络库,**不用 tkinter** ——
全仓已安装包中仅 `tqdm/tk.py` 引用它,那是可选的 GUI 进度条、未启用。

被验证抓出的第三处自身错误
--------------------------
`do_relocate()` 在「全部 venv 已自包含」时**提前 return**,
导致紧接着的裁剪被静默跳过(实测:apply 输出"已自包含,无需处理",
`thread3.0.4` 仍在,签名照旧失败)。
→ 已改为:venv 无需重写时**仍执行裁剪**。教训:早退分支里别忘掉并行的收尾动作。

结果与验证
----------
- 运行时 72M → **59M**(裁剪 15 项,释放 6.8 MB)
- 三个 venv 功能回归通过:python 3.12.13 / pymysql 2.2.8 均可导入
- `tushare-fetcher --help`、`akshare-fetcher --help` 均正常
- `tkinter` 确认已不可导入(符合裁剪预期,未被使用)

签名现状(如实说明)
--------------------
`codesign --deep` 仍无法覆盖内联的完整 stdlib(报
`In subcomponent: .../lib/python3.12`)。该 bundle 现为 **ad-hoc 签名 + 主可执行文件已签**。
对**本地、无隔离标志**的 app 启动无影响(app 进程 45167 一直在正常运行),
且运行期 venv 生成 `__pycache__` 本来就会打破封条 —— 与项目既有认知一致。
如需可分发/公证的签名,应改为把运行时放进 `Contents/MacOS/` 之外的独立签名域,
或在构建后逐项 `codesign` 嵌套组件(属独立工作量)。
2026-09-19 22:37:19 +08:00

380 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
#!/usr/bin/env python3
"""relocate_bundle_venvs.py — 把 .app 内 venv 依赖的 Python 运行时搬进 bundle,使其自包含
问题
----
`build-app.sh` 用 `rsync -a` 把各服务的 `.venv` 拷进 `.app`。`rsync -a` **保留软链接**,
而这三个 venv 的 `python` 是软链接,指向用户级目录:
Contents/Resources/tushare_fetcher/.venv/bin/python
→ /Users/<用户>/.local/share/uv/python/cpython-3.12-macos-aarch64-none/bin/python3.12
`pyvenv.cfg` 的 `home` 同样指向该目录,`.venv/bin/` 下 console script
(如 `tushare-fetcher`)的 **shebang 也是绝对路径**。
后果:已安装的 app **依赖用户机器上的 uv 托管 Python**。只要执行
`uv python uninstall 3.12`、`uv python upgrade`,或清理 `~/.local/share/uv`,
**整个 app 的 Python 侧全部失效** —— 而这属于用户日常会做的操作。
本脚本做的事
------------
1. 把被指向的 Python 运行时整个拷进 `<app>/Contents/Frameworks/python312/`(约 72MB)
2. 重写每个 bundle 内 venv:
- `.venv/bin/python*` 软链接 → 指向 bundle 内的运行时
- `pyvenv.cfg` 的 `home` → bundle 内的 `bin`
- `.venv/bin/*` 里以原 runtime 绝对路径开头的 **shebang** → bundle 内路径
3. `--check` 模式:只验证「是否已自包含」,不做修改(可用于启动自检 / CI)
用法
----
python3 Scripts/bin/relocate_bundle_venvs.py --check /Applications/FinDataHub.app
python3 Scripts/bin/relocate_bundle_venvs.py --dry-run /Applications/FinDataHub.app
python3 Scripts/bin/relocate_bundle_venvs.py --apply /Applications/FinDataHub.app
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import sys
from pathlib import Path
VENV_RELS = (
"Contents/Resources/tushare_fetcher/.venv",
"Contents/Resources/akshare_fetcher/.venv",
"Contents/Resources/kuaixun-service/.venv",
)
RUNTIME_DST_REL = "Contents/Frameworks/python312"
# 可从内联运行时里裁掉的内容(不裁剪会白占 ~14MB,且 Tcl/Tk 会让 codesign 失败:
# `bundle format unrecognized ... In subcomponent: lib/thread3.0.4`)
#
# 依据:本项目的三个 venv 只用 pandas / numpy / pymysql / sqlalchemy / fastapi / akshare
# 等纯计算与网络库,**不用 tkinter**(全仓已安装包中仅 `tqdm/tk.py` 引用它,
# 那是可选的 GUI 进度条,未启用)。`include/` 供编译 C 扩展用,运行期不需要。
PRUNE_RELS = (
# Tcl/Tk 家族(含引发 codesign 失败的 thread3.0.4)
"lib/tcl9.0", "lib/tk9.0", "lib/tcl9", "lib/tcl8.6", "lib/tk8.6",
"lib/itcl4.3.5", "lib/itcl4.3.0", "lib/thread3.0.4",
"lib/libtcl9.0.dylib", "lib/libtcl9tk9.0.dylib",
"lib/libtcl8.6.dylib", "lib/libtk8.6.dylib",
# 运行期不需要
"include", "share",
# stdlib 中与运行无关的部分
"lib/python3.12/idlelib", "lib/python3.12/turtledemo",
"lib/python3.12/ensurepip", "lib/python3.12/lib2to3",
"lib/python3.12/tkinter", "lib/python3.12/test",
# bin 中的开发工具(保留 pip / pydoc)
"bin/idle3.12", "bin/idle3", "bin/2to3-3.12", "bin/2to3",
)
# 认定"外部运行时"的判定:指向本 bundle 之外且路径含这些片段
EXTERNAL_HINTS = (".local/share/uv/python", "/opt/homebrew", "/usr/local", "/Users/")
SHEBANG_RE = re.compile(rb"^#!(/[^\n]*)")
def runtime_of_venv(venv: Path) -> str | None:
"""返回该 venv 当前依赖的 python 可执行文件真实路径。"""
py = venv / "bin" / "python"
if py.is_symlink():
target = os.readlink(py)
if not os.path.isabs(target):
target = str((py.parent / target).resolve())
return str(Path(target).resolve())
cfg = venv / "pyvenv.cfg"
if cfg.exists():
for line in cfg.read_text(encoding="utf-8", errors="ignore").splitlines():
if line.startswith("home"):
return line.split("=", 1)[1].strip() + "/python3.12"
return None
def is_self_contained(app: Path, venv: Path) -> tuple[bool, str]:
"""venv 是否已完全落在 bundle 内。"""
rt = runtime_of_venv(venv)
if rt is None:
return False, "无法确定依赖的 python 运行时"
app_s = str(app.resolve())
rt_s = str(Path(rt).resolve())
if not rt_s.startswith(app_s):
return False, f"运行时在 bundle 之外: {rt_s}"
cfg = venv / "pyvenv.cfg"
if cfg.exists():
for line in cfg.read_text(encoding="utf-8", errors="ignore").splitlines():
if line.startswith("home"):
home = line.split("=", 1)[1].strip()
if not str(Path(home).resolve()).startswith(app_s):
return False, f"pyvenv.cfg home 在 bundle 之外: {home}"
# shebang 检查:.venv/bin 下的脚本必须用 **本 venv 自己的 python**。
# 只判"是否在 bundle 内"不够 —— 指向 bundle 内的基础解释器同样在 bundle 内,
# 但那样会丢失 venv 上下文(site-packages / editable .pth 失效,
# 实测表现为 console script 报 ModuleNotFoundError)。
venv_py = str(venv / "bin" / "python")
for f in (venv / "bin").iterdir():
if not f.is_file() or f.is_symlink():
continue
try:
head = f.open("rb").readline(400)
except OSError:
continue
m = SHEBANG_RE.match(head)
if m:
interp = m.group(1).decode(errors="ignore")
if interp != venv_py:
return False, f"{f.name} 的 shebang 非本 venv python: {interp}"
# editable .pth 也不能指向 bundle 之外
for sp in (venv / "lib").glob("python*/site-packages"):
for pth in sp.glob("*_editable_impl_*.pth"):
for line in pth.read_text(encoding="utf-8", errors="ignore").splitlines():
s = line.strip()
if s.startswith("/") and not s.startswith(app_s):
return False, f"{pth.name} 指向 bundle 外: {s}"
return True, "已自包含"
def fix_editable_pth(app: Path, venv: Path, dry: bool) -> int:
"""把指向 bundle 之外的 editable `.pth` 改指到 bundle 内的 src。
为什么需要:`akshare_fetcher/.venv` 里的 `_editable_impl_akshare_fetcher.pth`
指向的是**仓库路径** `<repo>/Services/akshare_fetcher/src`(tushare 侧则已指向
bundle 内 src)。这意味着**部署的 app 依赖源码仓库存在于原路径** ——
换一台机器、或移动/删除仓库,akshare 采集器就会 `ModuleNotFoundError`。
"""
# venv 同级目录(Contents/Resources/<svc>)下的 src
svc_dir = venv.parent
local_src = svc_dir / "src"
site_pkgs = list((venv / "lib").glob("python*/site-packages"))
if not site_pkgs:
return 0
n = 0
for sp in site_pkgs:
for pth in sp.glob("*_editable_impl_*.pth"):
lines = pth.read_text(encoding="utf-8", errors="ignore").splitlines()
changed = False
out = []
for line in lines:
s = line.strip()
if s.startswith("/Users/") or s.startswith("/opt/") or s.startswith("/private/"):
if not s.startswith(str(app)):
if local_src.is_dir():
out.append(str(local_src))
changed = True
print(f" {pth.name}: {s} → {local_src}")
continue
out.append(line)
if changed:
n += 1
if not dry:
pth.write_text("\n".join(out) + "\n", encoding="utf-8")
return n
def prune_runtime(app: Path, dry: bool) -> int:
"""裁掉内联运行时里运行期不需要的内容(见 PRUNE_RELS 的理由)。
不裁剪有两个后果:白占约 14MB;且 **Tcl/Tk 会让 codesign 失败** ——
`codesign --deep` 报 `bundle format unrecognized ... In subcomponent:
lib/thread3.0.4`,导致 .app 签名不完整。
"""
rt = app / RUNTIME_DST_REL
if not rt.is_dir():
return 0
freed = 0
n = 0
for rel in PRUNE_RELS:
target = rt / rel
if not target.exists():
continue
try:
size = sum(f.stat().st_size for f in target.rglob("*") if f.is_file())
except OSError:
size = 0
if dry:
print(f" [dry-run] 将删除 {RUNTIME_DST_REL}/{rel}({size / 1024 / 1024:.1f} MB)")
continue
try:
if target.is_dir() and not target.is_symlink():
shutil.rmtree(target)
else:
target.unlink()
freed += size
n += 1
except OSError as e:
print(f" · 跳过 {rel}: {e}")
if not dry and n:
print(f" ✓ 裁剪运行时 {n} 项,释放约 {freed / 1024 / 1024:.1f} MB")
return n
def do_relocate(app: Path, dry: bool) -> int:
present = [rel for rel in VENV_RELS if (app / rel / "bin").is_dir()]
if not present:
print(f"✗ 在 {app} 下未找到任何 bundle 内 venv")
return 2
# 必须检查**全部** venv 才能决定是否需要动手:
# 只看第一个会在"首个已自包含、其余未自包含"时提前返回,漏修其余(实测踩到:
# tushare 的 .pth 本就指向 bundle 内,而 akshare 的指向仓库路径)。
pending = []
for rel in present:
ok, why = is_self_contained(app, app / rel)
if not ok:
pending.append((rel, why))
if not pending:
# venv 已自包含**不等于**无需处理:运行时裁剪(Tcl/Tk 等)仍要跑。
# 实测踩到:这里直接 return 导致裁剪被静默跳过、codesign 仍失败。
print(f"✓ 全部 {len(present)} 个 venv 已自包含")
n = prune_runtime(app, dry=dry)
if not dry and not n:
print(" · 运行时无需裁剪")
return 0
print("待处理:")
for rel, why in pending:
print(f" - {rel}: {why}")
# 以第一个 venv 的运行时作为要内联的运行时
src_rt = Path(runtime_of_venv(app / present[0])).resolve()
src_root = src_rt.parent.parent # .../python/3.12.13 安装根
dst_root = app / RUNTIME_DST_REL
print(f" 运行时源: {src_root}")
print(f" 运行时目标: {dst_root}")
# 运行时可能已在 bundle 内(例如只补修 .pth / shebang 的场景)——
# 此时 **不能再拷**(会把自身拷成嵌套副本),直接沿用现有副本。
runtime_inside = src_root.resolve().is_relative_to(app.resolve())
dst_bin = dst_root / "bin" # 无条件定义:后面重写 pyvenv.cfg 要用
dst_py = None
if runtime_inside and (dst_root / "bin").is_dir():
print(" · 运行时已在 bundle 内,跳过拷贝")
for cand in ("python3.12", "python3", "python"):
if (dst_root / "bin" / cand).exists():
dst_py = (dst_root / "bin" / cand).resolve()
break
else:
size_mb = sum(f.stat().st_size for f in src_root.rglob("*") if f.is_file()) / 1024 / 1024
print(f" 待拷贝体积: {size_mb:.0f} MB")
if dry:
print("\n [dry-run] 将执行:")
if dst_py is None:
print(f" 1) 拷贝运行时 → {dst_root}")
for rel in present:
print(f" 2) 重写 {rel}/bin/python* 软链接、pyvenv.cfg、"
f"bin/* 的 shebang、editable .pth")
prune_runtime(app, dry=True)
return 0
# ① 拷贝运行时(若尚未在 bundle 内)
if dst_py is None:
if dst_root.exists():
shutil.rmtree(dst_root)
dst_root.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src_root, dst_root, symlinks=True)
print(" ✓ 运行时已拷贝")
for cand in ("python3.12", "python3", "python"):
if (dst_bin / cand).exists():
dst_py = (dst_bin / cand).resolve()
break
if dst_py is None:
print(" ✗ 拷贝后的运行时里找不到 python 可执行文件")
return 3
print(f" ✓ 目标解释器: {dst_py}")
# ② 裁掉运行期不需要的运行时内容(Tcl/Tk 等,见 PRUNE_RELS 说明)
prune_runtime(app, dry=False)
# ③ 重写各 venv
for rel in present:
venv = app / rel
if not (venv / "bin").is_dir():
continue
# python* 软链接
for name in ("python", "python3", "python3.12"):
link = venv / "bin" / name
if link.exists() or link.is_symlink():
if link.is_symlink() or link.is_file():
link.unlink()
link.symlink_to(dst_py)
# pyvenv.cfg
cfg = venv / "pyvenv.cfg"
if cfg.exists():
lines = cfg.read_text(encoding="utf-8", errors="ignore").splitlines()
out = []
for line in lines:
if line.startswith("home"):
out.append(f"home = {dst_bin}")
elif line.startswith("executable"):
out.append(f"executable = {dst_py}")
else:
out.append(line)
cfg.write_text("\n".join(out) + "\n", encoding="utf-8")
# bin/* 的 shebang
#
# ⚠️ 必须指向 **venv 自己的 python**(`<venv>/bin/python`),
# 不能指向基础解释器。基础解释器执行时不带 venv 上下文 →
# venv 的 site-packages 不在 sys.path、editable `.pth` 不生效 →
# `from akshare_fetcher.cli import app` 直接 ModuleNotFoundError。
# 实测踩到:首版误写成基础解释器,tushare 侧侥幸可用、akshare 侧必挂。
venv_py = venv / "bin" / "python"
n_fixed = 0
for f in (venv / "bin").iterdir():
if not f.is_file() or f.is_symlink():
continue
try:
data = f.read_bytes()
except OSError:
continue
m = SHEBANG_RE.match(data[:400])
if not m:
continue
interp = m.group(1).decode(errors="ignore")
if interp == str(venv_py):
continue
if interp.startswith(str(app)) or any(h in interp for h in EXTERNAL_HINTS):
new = f"#!{venv_py}".encode() + data[m.end():]
f.write_bytes(new)
os.chmod(f, 0o755)
n_fixed += 1
n_pth = fix_editable_pth(app, venv, dry=False)
print(f" ✓ {rel}: python 链接 / pyvenv.cfg 已重写,shebang {n_fixed} 个,"
f"editable .pth {n_pth} 个")
return 0
def main() -> int:
ap = argparse.ArgumentParser()
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--check", action="store_true", help="只验证是否已自包含")
g.add_argument("--dry-run", action="store_true", help="打印将执行的动作")
g.add_argument("--apply", action="store_true", help="实际执行内联")
ap.add_argument("app", help=".app 路径,如 /Applications/FinDataHub.app")
args = ap.parse_args()
app = Path(args.app)
if not app.is_dir():
print(f"✗ 不是目录: {app}")
return 2
if args.check:
bad = 0
for rel in VENV_RELS:
venv = app / rel
if not (venv / "bin").is_dir():
continue
ok, why = is_self_contained(app, venv)
print(f" {'✓' if ok else '✗'} {rel}: {why}")
bad += 0 if ok else 1
if bad:
print(f"\n★ {bad} 个 venv 依赖 bundle 之外的 Python —— "
f"用户清理 ~/.local/share/uv 或卸载该版本后会整体失效")
else:
print("\n✓ 全部 venv 已自包含")
return 1 if bad else 0
return do_relocate(app, dry=args.dry_run)
if __name__ == "__main__":
sys.exit(main())