fix(packaging): verify bundled startup dependencies

This commit is contained in:
chengma
2026-07-27 17:44:15 +08:00
parent 38d7285a0e
commit a18391dae7
7 changed files with 106 additions and 14 deletions
+11
View File
@@ -7,11 +7,21 @@ include config files, SQLite databases, Chrome profiles, generated images,
logs, prompts, or other operator data.
"""
import importlib.util
import os
from PyInstaller.utils.hooks import collect_data_files
block_cipher = None
try:
backports_tarfile_spec = importlib.util.find_spec("backports.tarfile")
except ModuleNotFoundError:
backports_tarfile_spec = None
if backports_tarfile_spec is None:
raise SystemExit(
"Missing backports.tarfile. Run: "
"py -3.10 -m pip install -r requirements-build.txt"
)
default_prompt_datas = collect_data_files(
"app.default_prompts",
includes=["**/*.txt"],
@@ -36,6 +46,7 @@ a = Analysis(
"PySide6.QtCore",
"PySide6.QtGui",
"PySide6.QtWidgets",
"backports.tarfile",
],
hookspath=[],
hooksconfig={},
+3 -3
View File
@@ -13,7 +13,7 @@
py -3.10 -m pip install -r requirements-build.txt
```
`requirements-build.txt` 会先安装 `requirements.txt` 的运行依赖,再安装 PyInstaller。依赖必须安装到 Python 3.10 环境里,避免 PATH 上的 Python 3.7/3.12/3.14 打出不可比对的包。运行依赖固定使用 NumPy 1.26.4,以兼容 PySide6/Shiboken 6.5.3;正式打包前不得单独升级到 NumPy 2.x。
`requirements-build.txt` 会先安装 `requirements.txt` 的运行依赖,再精确安装 PyInstaller 6.11.1、pyinstaller-hooks-contrib 2026.6、setuptools 80.9.0 和 backports.tarfile 1.2.0。依赖必须安装到 Python 3.10 环境里,避免 PATH 上的 Python 3.7/3.12/3.14 或未锁定的全局构建包装出不可比对的包。运行依赖固定使用 NumPy 1.26.4,以兼容 PySide6/Shiboken 6.5.3;正式打包前不得单独升级到 NumPy 2.x。
## 二、打包命令
@@ -38,7 +38,7 @@ dist\cmshopee\
正式分发入口是 `release\蝦皮圈優化助手<APP_VERSION>.zip`。`dist\cmshopee\` 只是 PyInstaller 构建中间产物,不直接作为正式发包名称。
当前 `requirements-build.txt` 锁定 PyInstaller 6.11.1,onedir 产物必须是 `cmshopee.exe` + `_internal\` 集中依赖布局。`scripts\build_exe.ps1` 会校验 `_internal\` 存在;缺失时直接失败,避免把不一致的打包环境误发给用户。
当前 `requirements-build.txt` 锁定完整构建工具链,`cmshopee.spec` 还会显式检查并收集 `backports.tarfile`,避免 setuptools 的 `pkg_resources/jaraco` 运行时钩子因 vendored 依赖漏收集而在应用入口前崩溃。onedir 产物必须是 `cmshopee.exe` + `_internal\` 集中依赖布局。`scripts\build_exe.ps1` 会校验 `_internal\` 存在,并在组装 release 和压缩 ZIP 前运行 `cmshopee.exe --startup-smoke-test`;自检会完成 PyInstaller 运行时钩子与 GUI 模块导入后立即退出,不创建 GUI、不准备 `data\`、不检查更新或会员状态。自检非零退出或 30 秒超时会直接判定构建失败。
发布包只包含程序文件和依赖文件:
@@ -107,7 +107,7 @@ release\
1. 修改 `app/version.py` 中的 `APP_VERSION`。
2. 运行语法检查和单元测试。
3. 运行 `powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1`。
3. 运行 `powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1`;确认日志包含 `Verified packaged startup smoke test`。
4. 检查 `version.txt`、GUI 标题栏、`package-manifest.json`、压缩包文件名和 `release-metadata<APP_VERSION>.json` 的版本一致。
5. 在无 Python 环境的 Windows 10/11 机器上解压 `蝦皮圈優化助手<APP_VERSION>.zip` 并启动验证。
+16 -9
View File
@@ -3,7 +3,7 @@ id: T-703
title: 修复打包版缺少 backports 并增加 EXE 启动自检
phase: 8
deps: [T-625]
status: TODO
status: DONE
created: 2026-07-27
---
@@ -61,14 +61,14 @@ ModuleNotFoundError: No module named 'backports'
## 验收要点
- [ ] 构建环境安装 `requirements-build.txt` 后可导入 `backports.tarfile`。
- [ ] `cmshopee.spec` 显式包含 `backports.tarfile`,缺少依赖时构建立即失败。
- [ ] `py -3.10 main.py --startup-smoke-test` 正常退出,不创建 GUI 或业务数据。
- [ ] 构建脚本在压缩发布包前运行打包 EXE 启动自检,并处理非零退出和 30 秒超时。
- [ ] 新打包的 `cmshopee.exe --startup-smoke-test` 返回 0,不再出现 `No module named 'backports'`。
- [ ] 发布目录和 ZIP 不包含 `data/`、配置、数据库、图片、日志或登录态。
- [ ] 本轮发布包关闭并隐藏会员订阅检测,但源码在打包后恢复当前开发状态。
- [ ] 验证通过:
- [x] 构建环境安装 `requirements-build.txt` 后可导入 `backports.tarfile`。
- [x] `cmshopee.spec` 显式包含 `backports.tarfile`,缺少依赖时构建立即失败。
- [x] `py -3.10 main.py --startup-smoke-test` 正常退出,不创建 GUI 或业务数据。
- [x] 构建脚本在压缩发布包前运行打包 EXE 启动自检,并处理非零退出和 30 秒超时。
- [x] 新打包的 `cmshopee.exe --startup-smoke-test` 返回 0,不再出现 `No module named 'backports'`。
- [x] 发布目录和 ZIP 不包含 `data/`、配置、数据库、图片、日志或登录态。
- [x] 本轮发布包关闭并隐藏会员订阅检测,但源码在打包后恢复当前开发状态。
- [x] 验证通过:
- `py -3.10 -m unittest discover -s tests -p test_packaging.py`
- `py -3.10 -m unittest discover -s tests`
- `py -3.10 -m ruff check app tests main.py`
@@ -86,3 +86,10 @@ ModuleNotFoundError: No module named 'backports'
## 执行记录
- 2026-07-27:根据 `0.1.5.2` 打包版真实启动异常创建任务。确认异常发生在 PyInstaller `pyi_rth_pkgres` 运行时钩子,早于 `main.py`;构建警告和发布目录共同证明 `setuptools` vendored `jaraco.context` 已收集、`backports` 未收集,与会员订阅临时开关无关。
- 2026-07-27:`requirements-build.txt` 精确锁定 PyInstaller 6.11.1、pyinstaller-hooks-contrib 2026.6、setuptools 80.9.0 和 backports.tarfile 1.2.0;安装后确认 Python 3.10 可从独立分发目录导入 `backports.tarfile`。
- 2026-07-27:`cmshopee.spec` 在分析前检查 `backports.tarfile`,缺失时给出构建依赖安装命令并中止;主程序 hidden import 显式包含该模块。真实构建日志已出现 `Analyzing hidden import 'backports.tarfile'`。
- 2026-07-27:`main.py` 增加内部参数 `--startup-smoke-test`,在 `app.gui` 成功导入后直接返回 0,不创建 GUI 或进入数据、更新、会员、Chrome 和业务流程。构建脚本在 release 组装与 ZIP 压缩前以隐藏窗口运行打包 EXE,非零退出或 30 秒超时会失败并终止发布。
- 2026-07-27:扩展打包回归测试,覆盖构建依赖版本、spec 依赖检查、源码启动自检和 PowerShell 自检顺序;既有版本格式断言同步兼容当前三段或四段纯数字版本,不修改用户当前 `app/version.py`。
- 2026-07-27:真实运行 `scripts/build_exe.ps1` 成功,dist 和 release 两处 `cmshopee.exe --startup-smoke-test` 均返回 0,未再出现 `No module named 'backports'`。构建警告只剩可选的 `backports.zstd`,不影响当前依赖或启动。
- 2026-07-27:按发布要求在构建期间临时关闭并隐藏会员订阅检测;生成 `release/蝦皮圈優化助手0.1.5.2.zip` 后已精确恢复源码开关,`app/gui/main_window.py` 无未提交差异。新 ZIP 大小 71,420,205 字节,SHA-256 为 `d1929859b178ac409725c885a936b96109afc1840bc6dd2b74064089e158506f`。
- 2026-07-27:验证通过:`py -3.10 -m unittest discover -s tests -p test_packaging.py`(12 项)、`py -3.10 -m unittest discover -s tests`(702 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1`、release EXE 启动自检和 `git diff --check`。
+11 -1
View File
@@ -18,5 +18,15 @@ if PROJECT_ROOT not in sys.path:
from app.gui import main
STARTUP_SMOKE_TEST_ARG = "--startup-smoke-test"
def _entrypoint(argv=None):
args = sys.argv[1:] if argv is None else list(argv)
if STARTUP_SMOKE_TEST_ARG in args:
return 0
return main()
if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(_entrypoint())
+3
View File
@@ -1,2 +1,5 @@
-r requirements.txt
pyinstaller==6.11.1
pyinstaller-hooks-contrib==2026.6
setuptools==80.9.0
backports.tarfile==1.2.0
+25
View File
@@ -61,6 +61,29 @@ function Assert-NoExcludedData {
}
}
function Assert-PackagedStartup {
param(
[string]$ExePath,
[int]$TimeoutSeconds = 30
)
$process = Start-Process `
-FilePath $ExePath `
-ArgumentList @("--startup-smoke-test") `
-PassThru `
-WindowStyle Hidden
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
if (-not $process.HasExited) {
Stop-Process -Id $process.Id -Force
}
throw "打包程序启动自检超时(${TimeoutSeconds}秒): $ExePath"
}
$process.Refresh()
if ($process.ExitCode -ne 0) {
throw "打包程序启动自检失败,退出码: $($process.ExitCode)"
}
}
function Compress-ArchiveWithRetry {
param(
[string]$SourcePath,
@@ -160,6 +183,8 @@ if (-not (Test-Path -LiteralPath $internalDir)) {
throw "Build output missing _internal directory. Expected PyInstaller 6.x onedir layout (cmshopee.exe + _internal). Ensure PyInstaller >= 6.0 is installed: py -3.10 -m pip install -r requirements-build.txt"
}
Write-Host "Verified onedir layout: _internal present"
Assert-PackagedStartup -ExePath $exePath -TimeoutSeconds 30
Write-Host "Verified packaged startup smoke test"
$releaseRoot = Join-Path $repoRoot "release"
$releaseName = "$appName$appVersion"
+37 -1
View File
@@ -1,5 +1,7 @@
import hashlib
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
@@ -30,6 +32,9 @@ class PackagingTests(unittest.TestCase):
self.assertIn('"app.default_prompts"', spec)
self.assertIn('includes=["**/*.txt"]', normalized)
self.assertIn("datas=default_prompt_datas", normalized)
self.assertIn('find_spec("backports.tarfile")', spec)
self.assertIn('"backports.tarfile"', spec)
self.assertIn("requirements-build.txt", spec)
self.assertIn("COLLECT(", spec)
self.assertIn('name="cmshopee"', normalized)
cover_prompt = self.read_text("app/default_prompts/cover/cover_prompt.txt")
@@ -66,12 +71,35 @@ class PackagingTests(unittest.TestCase):
self.assertIn("cmshopee.spec", script)
self.assertIn("Decode-Utf8Base64", script)
def test_build_requirements_pin_pyinstaller_runtime_dependencies(self):
requirements = self.read_text("requirements-build.txt")
for dependency in (
"pyinstaller==6.11.1",
"pyinstaller-hooks-contrib==2026.6",
"setuptools==80.9.0",
"backports.tarfile==1.2.0",
):
self.assertIn(dependency, requirements)
def test_source_startup_smoke_test_imports_gui_and_exits(self):
result = subprocess.run(
[sys.executable, str(REPO_ROOT / "main.py"), "--startup-smoke-test"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
timeout=30,
check=False,
)
self.assertEqual(0, result.returncode, result.stderr)
def test_release_version_source_is_single_place(self):
version_py = self.read_text("app/version.py")
self.assertIn('APP_NAME = "蝦皮圈優化助手"', version_py)
self.assertIn('APP_CODE_NAME = "cmshopee"', version_py)
self.assertRegex(version.APP_VERSION, r"^\d+\.\d+\.\d+$")
self.assertRegex(version.APP_VERSION, r"^\d+(?:\.\d+){2,3}$")
self.assertEqual(
f"{version.APP_NAME} v{version.APP_VERSION}",
version.display_name(),
@@ -122,6 +150,14 @@ class PackagingTests(unittest.TestCase):
self.assertNotIn('release-metadata*.json', script)
self.assertIn('cmshopee-updater.spec', script)
self.assertIn('cmshopee-updater.exe', script)
self.assertIn("Assert-PackagedStartup", script)
self.assertIn("--startup-smoke-test", script)
self.assertIn("WaitForExit", script)
self.assertIn("Stop-Process", script)
self.assertLess(
script.index("Assert-PackagedStartup -ExePath"),
script.index('$releaseRoot = Join-Path $repoRoot "release"'),
)
def test_updater_spec_builds_a_windowed_standalone_executable(self):
spec = self.read_text("cmshopee-updater.spec")