问题
----
上一步把 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` 嵌套组件(属独立工作量)。
380 lines
16 KiB
Python
380 lines
16 KiB
Python
#!/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())
|