Files
2026-07-04 16:59:25 +08:00

217 lines
7.9 KiB
RPMSpec

# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller 打包配置 — 桌面客户端 (onedir 模式)。
为什么 onedir 而非 onefile:
- onefile 每次启动都解压到临时 _MEIxxxxx, APScheduler/多线程冲突
- onedir 启动更快, 调试更方便 (可看到目录结构), 原生库直接在目录里
- 体积差异通过压缩安装包弥补 (CI zip 打包)
入口: backend/app/desktop.py (桌面版入口, uvicorn + pywebview)
构建 (在项目根目录):
cd frontend && pnpm build # 先构建前端到 frontend/dist
pyinstaller packaging/tickflow.spec # 产物在 dist/TickFlowStockPanel/
"""
import sys
from pathlib import Path
from PyInstaller.utils.hooks import (
collect_all,
collect_submodules,
collect_data_files,
copy_metadata,
)
_IS_MACOS = sys.platform == "darwin"
block_cipher = None
# ── 资源路径基准: 项目根 (spec 文件在 packaging/ 下) ──────────────────
ROOT = Path(SPECPATH).parent
FRONTEND_DIST = str(ROOT / "frontend" / "dist")
TIERS_YAML = str(ROOT / "tiers.yaml")
BUILTIN_STRATEGIES = str(ROOT / "backend" / "app" / "strategy" / "builtin")
# 图标按平台选: Windows 用 .ico, macOS 用 .icns (PyInstaller 对 .ico 在
# mac 上静默忽略, 不换格式 Dock/Finder 会显示通用图标)。两者都由
# packaging/generate_icon.py 一并生成。
APP_ICON = str(ROOT / "packaging" / ("icon.icns" if _IS_MACOS else "icon.ico"))
# ── 收集带原生库的依赖 (.libs/ 目录必须完整, 否则启动崩) ─────────────
# polars / pyarrow / duckdb / fastexcel 都自带共享库子目录
datas = []
binaries = []
hiddenimports = []
for pkg in ("polars", "pyarrow", "duckdb", "fastexcel"):
d, b, h = collect_all(pkg)
datas += d
binaries += b
hiddenimports += h
# polars-runtime-32 (rtcompat 兼容内核): release.yml 用 --extra legacy-cpu 安装。
# 它是独立的伴侣二进制包 (含 .pyd/.so), 与 polars 主包分开发布,
# collect_all("polars") 抓不到它的目录 —— 必须显式收集, 否则老 CPU 用户
# 运行时 rtcompat 加载器找不到兼容库仍会崩 (Illegal instruction)。
# 不存在时 (未装 legacy-cpu) collect_all 返回空, 不影响普通构建。
try:
rt_d, rt_b, rt_h = collect_all("polars_runtime_32")
datas += rt_d
binaries += rt_b
hiddenimports += rt_h
except Exception:
pass
# polars 新 ABI 运行时目录 (_polars_runtime_32) 需显式收集子模块
hiddenimports += collect_submodules("polars")
# ── pywebview 平台后端 (动态导入, PyInstaller 默认抓不到) ────────────
hiddenimports += collect_submodules("webview")
hiddenimports += collect_submodules("webview.platforms")
# ── 系统通知后端 (winotify/plyer 按平台动态导入) ─────────────────────
if sys.platform == "win32":
hiddenimports += collect_submodules("winotify")
hiddenimports += collect_submodules("plyer")
hiddenimports += collect_submodules("plyer.platforms")
# ── uvicorn 动态导入的模块 (loop/protocol/logging 按字符串加载) ──────
hiddenimports += [
"uvicorn.logging",
"uvicorn.loops",
"uvicorn.loops.auto",
"uvicorn.loops.asyncio",
"uvicorn.protocols",
"uvicorn.protocols.http",
"uvicorn.protocols.http.auto",
"uvicorn.protocols.http.h11_impl",
"uvicorn.protocols.websockets",
"uvicorn.protocols.websockets.auto",
"uvicorn.lifespan",
"uvicorn.lifespan.on",
]
# ── fastapi / pydantic 元数据 (版本检测用) ───────────────────────────
# 注意: 任何用 importlib.metadata.version() 读版本的包, 都必须 copy_metadata,
# 否则 frozen 后报 PackageNotFoundError。tickflow 包内部就是这么读的。
# 用容错写法: 不存在的包跳过, 避免不同环境 (有无装某依赖) 导致构建失败。
def _safe_metadata(pkg):
"""收集包元数据, 包不存在时静默跳过。"""
try:
return copy_metadata(pkg)
except Exception:
return []
for pkg in (
"fastapi", "pydantic", "pydantic_settings", "starlette", "anyio",
"tickflow", # tickflow/__version__.py 用 importlib.metadata 读版本
"uvicorn", "polars", "duckdb", "pyarrow", "httpx", "numpy", "pandas",
"openai", "platformdirs", "winotify", "plyer", "apscheduler",
"python-dotenv", "fastexcel",
):
datas += _safe_metadata(pkg)
# ── 随包资源 (只读, 放进 _MEIPASS) ────────────────────────────────────
# 前端 dist → static/ (config.py frozen 模式读 _MEIPASS/static)
datas += [(FRONTEND_DIST, "static")]
# tiers.yaml → 包根 (config.py frozen 模式读 _MEIPASS/tiers.yaml)
datas += [(TIERS_YAML, ".")]
# 内置策略 → app/strategy/builtin/ (importlib 动态加载, 不能进 PYZ)
datas += [(BUILTIN_STRATEGIES, "app/strategy/builtin")]
# ── 排除不需要的重型依赖 (主包不含 vectorbt 回测链) ──────────────────
excludes = [
"vectorbt",
"numba",
"llvmlite",
"matplotlib",
"plotly",
"ipywidgets",
"nbformat",
"nbconvert",
"jupyter",
"IPython",
"pytest",
"pytest_asyncio",
"ruff",
"mypy",
]
a = Analysis(
[str(ROOT / "backend" / "app" / "desktop.py")],
pathex=[str(ROOT / "backend")],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=excludes,
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="TickFlowStockPanel",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False, # UPX 压缩原生库常导致崩溃, 关闭
console=False, # 桌面应用: 不显示控制台窗口 (调试时临时改 True 抓日志)
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=APP_ICON, # 应用图标 (与 favicon/logo 一致)
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name="TickFlowStockPanel",
)
# ── macOS: 封装成 .app 包 ────────────────────────────────────────────
# Windows/Linux: 上面 COLLECT 产出的 onedir 目录即是最终产物。
# macOS: 额外加 BUNDLE, 把目录包装成标准 .app (Contents/MacOS/...),
# 这样 Dock/Finder 能识别, 用户可双击启动, 并显示自定义 .icns 图标。
# BUNDLE 必须引用上面的 COLLECT (coll), 它会把 coll 的产物搬进 .app。
#
# PyInstaller 6 BUNDLE 关键参数 (见 building/osx.py):
# - version: → CFBundleShortVersionString (默认 0.0.0, 必须显式传)
# - info_plist (单数!): 用户自定义键, update 合并进默认 plist, 可覆盖任意字段
if _IS_MACOS:
import json
# 版本号从 frontend/package.json 读, 与 Release tag 对齐
_pkg_json = json.loads((ROOT / "frontend" / "package.json").read_text(encoding="utf-8"))
APP_VERSION = _pkg_json["version"]
app = BUNDLE(
coll,
name="TickFlowStockPanel.app",
icon=APP_ICON,
bundle_identifier="com.tickflow.stockpanel",
version=APP_VERSION, # → CFBundleShortVersionString / CFBundleVersion
info_plist={
"CFBundleName": "TickFlow Stock Panel",
"CFBundleDisplayName": "TickFlow 股票面板",
"CFBundleVersion": APP_VERSION,
"NSHighResolutionCapable": True,
"LSMinimumSystemVersion": "10.13",
},
)