Skip to content

Commit ee46fa4

Browse files
committed
feat(pyinstaller): M1 - PyInstaller 单文件打包,本地验证通过
- config.py: 新增 BUNDLE_DIR 常量,用 sys.frozen 检测 PyInstaller 环境 PROJECT_ROOT 打包后指向 exe 所在目录(用户数据), BUNDLE_DIR 打包后指向 sys._MEIPASS(代码资源) - app.py: 导入 PROJECT_ROOT 替换 3 处 Path(__file__) - routes.py: 导入 PROJECT_ROOT + BUNDLE_DIR 替换 3 处 Path(__file__) - miniprogram.py: 导入 BUNDLE_DIR 替换 bridge_server 路径 - main.py: frozen 模式传入 app 对象而非字符串,避免动态导入失败 - testpilot_engine.spec: 新增打包配置,含 playwright/driver、alembic、 小程序 JS 脚本 验证结果: dist/testpilot-engine.exe (62.8MB) 正常启动 数据库同步 服务绑定 8900 /api/v1/health 返回 healthy
1 parent 171c954 commit ee46fa4

6 files changed

Lines changed: 166 additions & 16 deletions

File tree

main.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
poetry run uvicorn src.app:app --reload
88
"""
99

10+
import sys
11+
1012
import uvicorn
1113

1214
from src.core.config import get_config
@@ -15,13 +17,26 @@
1517
def main() -> None:
1618
"""启动 TestPilot AI 核心引擎服务。"""
1719
config = get_config()
18-
uvicorn.run(
19-
"src.app:app",
20-
host=config.server.host,
21-
port=config.server.port,
22-
reload=config.server.reload,
23-
log_level=config.server.log_level.lower(),
24-
)
20+
21+
if getattr(sys, "frozen", False):
22+
# PyInstaller 打包模式:必须传入 app 对象,不支持字符串动态导入
23+
from src.app import app as _app
24+
uvicorn.run(
25+
_app,
26+
host=config.server.host,
27+
port=config.server.port,
28+
reload=False,
29+
log_level=config.server.log_level.lower(),
30+
)
31+
else:
32+
# 开发模式:字符串方式支持 --reload 热重载
33+
uvicorn.run(
34+
"src.app:app",
35+
host=config.server.host,
36+
port=config.server.port,
37+
reload=config.server.reload,
38+
log_level=config.server.log_level.lower(),
39+
)
2540

2641

2742
if __name__ == "__main__":

src/api/routes.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from src.api.websocket import ws_manager
4141
from src.browser.automator import BrowserAutomator
4242
from src.core.ai_client import AIClient
43+
from src.core.config import BUNDLE_DIR, PROJECT_ROOT
4344
from src.core.exceptions import BrowserError, SandboxError
4445
from src.memory.store import MemoryStore
4546
from src.sandbox.manager import SandboxManager
@@ -85,7 +86,7 @@ def _auto_preview_url(base_url: str, blueprint_path: str) -> str:
8586
# 端口不可达 → 看蓝本所在目录是否在工作区中
8687
bp_path = Path(blueprint_path).resolve()
8788
app_dir = bp_path.parent
88-
project_root = Path(__file__).resolve().parent.parent.parent
89+
project_root = PROJECT_ROOT
8990

9091
# 确保是工作区子目录且含 index.html
9192
try:
@@ -148,7 +149,7 @@ async def list_preview_apps() -> list[dict]:
148149
from src.core.config import get_config
149150
cfg = get_config()
150151
port = cfg.server.port
151-
root = Path(__file__).resolve().parent.parent.parent
152+
root = PROJECT_ROOT
152153
apps = []
153154
for child in sorted(root.iterdir()):
154155
if not child.is_dir():
@@ -886,7 +887,7 @@ async def run_miniprogram_blueprint_test(req: dict) -> TestReportResponse:
886887
tmp_file.write_text(json.dumps(runner_input, ensure_ascii=False), encoding="utf-8")
887888

888889
# 调用Node.js执行器(跟run_blind_test.js一样的逻辑)
889-
runner_script = Path(__file__).parent.parent / "controller" / "miniprogram_runner.js"
890+
runner_script = BUNDLE_DIR / "controller" / "miniprogram_runner.js"
890891
if not runner_script.exists():
891892
raise RuntimeError(f"执行器脚本不存在: {runner_script}")
892893

src/app.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from src import __app_name__, __version__
1717
from src.api.routes import create_router
18+
from src.core.config import PROJECT_ROOT
1819
from src.api.websocket import ws_manager
1920
from src.browser.automator import BrowserAutomator
2021
from src.core.ai_client import AIClient
@@ -116,7 +117,7 @@ async def websocket_endpoint(ws: WebSocket) -> None:
116117
# /preview/{dirname}/ → 对应目录的静态文件
117118
# 这样用户不需要手动启动 http-server,一键启动引擎即可测试
118119

119-
project_root = Path(__file__).resolve().parent.parent
120+
project_root = PROJECT_ROOT
120121
_preview_dirs: dict[str, Path] = {}
121122

122123
# 扫描第一层子目录中含蓝本的目录(支持 testpilot.json 或 testpilot/ 子目录)
@@ -179,8 +180,8 @@ async def serve_preview(app_name: str, file_path: str):
179180

180181
# v13.0: 社区门户 / v2.0: Web仪表盘 静态文件服务
181182
# 优先使用 web/dist(社区门户),回退到 desktop/dist(旧仪表盘)
182-
community_dist = Path(__file__).resolve().parent.parent / "web" / "dist"
183-
dashboard_dist = Path(__file__).resolve().parent.parent / "desktop" / "dist"
183+
community_dist = PROJECT_ROOT / "web" / "dist"
184+
dashboard_dist = PROJECT_ROOT / "desktop" / "dist"
184185
spa_dist = community_dist if community_dist.is_dir() else dashboard_dist
185186

186187
if spa_dist.is_dir():

src/controller/miniprogram.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from loguru import logger
2828

2929
from src.controller.base import BaseController, DeviceInfo, Platform
30+
from src.core.config import BUNDLE_DIR
3031

3132

3233
class MiniProgramConfig:
@@ -174,7 +175,7 @@ async def launch(self) -> None:
174175
logger.info("启动桥接服务器(v9.0自包含模式)")
175176
logger.info("项目: {} | WS端口: {} | HTTP端口: {}", project_path, ws_port, self._http_port)
176177

177-
bridge_server = Path(__file__).parent / "miniprogram_bridge_server.js"
178+
bridge_server = BUNDLE_DIR / "controller" / "miniprogram_bridge_server.js"
178179
if not bridge_server.exists():
179180
raise RuntimeError(f"桥接服务器脚本不存在: {bridge_server}")
180181

src/core/config.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,25 @@
55
所有敏感信息(API密钥等)通过环境变量注入,绝不硬编码。
66
"""
77

8+
import sys
89
from pathlib import Path
910
from typing import Optional
1011

1112
from pydantic import Field
1213
from pydantic_settings import BaseSettings, SettingsConfigDict
1314

1415

15-
# 项目根目录(pyproject.toml 所在目录)
16-
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
16+
# ── PyInstaller 兼容路径 ─────────────────────────────────────────────────────
17+
# 打包后 __file__ 指向临时解压目录,需用不同基准:
18+
# PROJECT_ROOT → exe 旁边的目录(用户数据:.env、data/、logs/)
19+
# BUNDLE_DIR → 临时解压目录(代码文件:JS 脚本等打包资源)
20+
if getattr(sys, "frozen", False):
21+
PROJECT_ROOT = Path(sys.executable).parent
22+
BUNDLE_DIR: Path = Path(sys._MEIPASS) # type: ignore[attr-defined]
23+
else:
24+
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
25+
BUNDLE_DIR = PROJECT_ROOT
26+
# ─────────────────────────────────────────────────────────────────────────────
1727

1828

1929
class ServerConfig(BaseSettings):

testpilot_engine.spec

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# -*- mode: python ; coding: utf-8 -*-
2+
"""
3+
TestPilot AI 引擎打包配置
4+
用法: poetry run pyinstaller testpilot_engine.spec
5+
"""
6+
7+
import os
8+
import sys
9+
from pathlib import Path
10+
11+
# ── 基本路径 ──────────────────────────────────────────────────
12+
project_dir = Path(SPECPATH)
13+
venv_site_packages = project_dir / '.venv' / 'lib' / 'site-packages'
14+
15+
# ── 需要随二进制一起携带的数据目录 ────────────────────────────
16+
datas = [
17+
# playwright driver(含 node.exe 和 package/)
18+
(str(venv_site_packages / 'playwright' / 'driver'), 'playwright/driver'),
19+
# alembic 迁移脚本(数据库升级用)
20+
(str(project_dir / 'alembic'), 'alembic'),
21+
# alembic.ini
22+
(str(project_dir / 'alembic.ini'), '.'),
23+
# 小程序 JS 脚本(BUNDLE_DIR/controller/ 下可找到)
24+
(str(project_dir / 'src' / 'controller' / 'miniprogram_runner.js'), 'controller'),
25+
(str(project_dir / 'src' / 'controller' / 'miniprogram_bridge_server.js'), 'controller'),
26+
]
27+
28+
# ── 隐式导入(动态加载的模块,分析器找不到)────────────────────
29+
hiddenimports = [
30+
# FastAPI / Starlette
31+
'uvicorn.logging',
32+
'uvicorn.loops',
33+
'uvicorn.loops.auto',
34+
'uvicorn.loops.asyncio',
35+
'uvicorn.loops.uvloop',
36+
'uvicorn.protocols',
37+
'uvicorn.protocols.http',
38+
'uvicorn.protocols.http.auto',
39+
'uvicorn.protocols.http.h11_impl',
40+
'uvicorn.protocols.http.httptools_impl',
41+
'uvicorn.protocols.websockets',
42+
'uvicorn.protocols.websockets.auto',
43+
'uvicorn.protocols.websockets.websockets_impl',
44+
'uvicorn.protocols.websockets.wsproto_impl',
45+
'uvicorn.lifespan',
46+
'uvicorn.lifespan.on',
47+
'uvicorn.lifespan.off',
48+
'starlette.routing',
49+
'starlette.middleware',
50+
'starlette.middleware.cors',
51+
# Playwright
52+
'playwright',
53+
'playwright.sync_api',
54+
'playwright.async_api',
55+
# SQLAlchemy
56+
'sqlalchemy.dialects.sqlite',
57+
# jose
58+
'jose',
59+
'jose.jwt',
60+
# multipart
61+
'multipart',
62+
# email_validator (fastapi opt-dep)
63+
'email_validator',
64+
# passlib
65+
'passlib.handlers.bcrypt',
66+
'passlib.handlers.sha2_crypt',
67+
# websockets
68+
'websockets',
69+
'websockets.legacy',
70+
'websockets.legacy.server',
71+
]
72+
73+
a = Analysis(
74+
['main.py'],
75+
pathex=[str(project_dir)],
76+
binaries=[],
77+
datas=datas,
78+
hiddenimports=hiddenimports,
79+
hookspath=[],
80+
hooksconfig={},
81+
runtime_hooks=[],
82+
excludes=[
83+
# 排除用不到的大型包,减小体积
84+
'tkinter',
85+
'matplotlib',
86+
'numpy',
87+
'pandas',
88+
'scipy',
89+
'PIL',
90+
'IPython',
91+
'notebook',
92+
'pytest',
93+
'sphinx',
94+
],
95+
noarchive=False,
96+
optimize=0,
97+
)
98+
99+
pyz = PYZ(a.pure)
100+
101+
exe = EXE(
102+
pyz,
103+
a.scripts,
104+
a.binaries,
105+
a.datas,
106+
[],
107+
name='testpilot-engine',
108+
debug=False,
109+
bootloader_ignore_signals=False,
110+
strip=False,
111+
upx=False, # UPX 压缩在某些杀毒软件中会误报,关闭
112+
upx_exclude=[],
113+
runtime_tmpdir=None,
114+
console=True, # 保留控制台,方便用户看启动日志
115+
disable_windowed_traceback=False,
116+
argv_emulation=False,
117+
target_arch=None,
118+
codesign_identity=None,
119+
entitlements_file=None,
120+
# 图标(可选,后续替换为实际 .ico 文件)
121+
# icon='resources/icon.ico',
122+
)

0 commit comments

Comments
 (0)