# -*- 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", }, )