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. logs, prompts, or other operator data.
""" """
import importlib.util
import os import os
from PyInstaller.utils.hooks import collect_data_files from PyInstaller.utils.hooks import collect_data_files
block_cipher = None 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( default_prompt_datas = collect_data_files(
"app.default_prompts", "app.default_prompts",
includes=["**/*.txt"], includes=["**/*.txt"],
@@ -36,6 +46,7 @@ a = Analysis(
"PySide6.QtCore", "PySide6.QtCore",
"PySide6.QtGui", "PySide6.QtGui",
"PySide6.QtWidgets", "PySide6.QtWidgets",
"backports.tarfile",
], ],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
+3 -3
View File
@@ -13,7 +13,7 @@
py -3.10 -m pip install -r requirements-build.txt 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 构建中间产物,不直接作为正式发包名称。 正式分发入口是 `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`。 1. 修改 `app/version.py` 中的 `APP_VERSION`。
2. 运行语法检查和单元测试。 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` 的版本一致。 4. 检查 `version.txt`、GUI 标题栏、`package-manifest.json`、压缩包文件名和 `release-metadata<APP_VERSION>.json` 的版本一致。
5. 在无 Python 环境的 Windows 10/11 机器上解压 `蝦皮圈優化助手<APP_VERSION>.zip` 并启动验证。 5. 在无 Python 环境的 Windows 10/11 机器上解压 `蝦皮圈優化助手<APP_VERSION>.zip` 并启动验证。
+16 -9
View File
@@ -3,7 +3,7 @@ id: T-703
title: 修复打包版缺少 backports 并增加 EXE 启动自检 title: 修复打包版缺少 backports 并增加 EXE 启动自检
phase: 8 phase: 8
deps: [T-625] deps: [T-625]
status: TODO status: DONE
created: 2026-07-27 created: 2026-07-27
--- ---
@@ -61,14 +61,14 @@ ModuleNotFoundError: No module named 'backports'
## 验收要点 ## 验收要点
- [ ] 构建环境安装 `requirements-build.txt` 后可导入 `backports.tarfile`。 - [x] 构建环境安装 `requirements-build.txt` 后可导入 `backports.tarfile`。
- [ ] `cmshopee.spec` 显式包含 `backports.tarfile`,缺少依赖时构建立即失败。 - [x] `cmshopee.spec` 显式包含 `backports.tarfile`,缺少依赖时构建立即失败。
- [ ] `py -3.10 main.py --startup-smoke-test` 正常退出,不创建 GUI 或业务数据。 - [x] `py -3.10 main.py --startup-smoke-test` 正常退出,不创建 GUI 或业务数据。
- [ ] 构建脚本在压缩发布包前运行打包 EXE 启动自检,并处理非零退出和 30 秒超时。 - [x] 构建脚本在压缩发布包前运行打包 EXE 启动自检,并处理非零退出和 30 秒超时。
- [ ] 新打包的 `cmshopee.exe --startup-smoke-test` 返回 0,不再出现 `No module named 'backports'`。 - [x] 新打包的 `cmshopee.exe --startup-smoke-test` 返回 0,不再出现 `No module named 'backports'`。
- [ ] 发布目录和 ZIP 不包含 `data/`、配置、数据库、图片、日志或登录态。 - [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 -p test_packaging.py`
- `py -3.10 -m unittest discover -s tests` - `py -3.10 -m unittest discover -s tests`
- `py -3.10 -m ruff check app tests main.py` - `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:根据 `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 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__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(_entrypoint())
+3
View File
@@ -1,2 +1,5 @@
-r requirements.txt -r requirements.txt
pyinstaller==6.11.1 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 { function Compress-ArchiveWithRetry {
param( param(
[string]$SourcePath, [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" 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" 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" $releaseRoot = Join-Path $repoRoot "release"
$releaseName = "$appName$appVersion" $releaseName = "$appName$appVersion"
+37 -1
View File
@@ -1,5 +1,7 @@
import hashlib import hashlib
import json import json
import subprocess
import sys
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -30,6 +32,9 @@ class PackagingTests(unittest.TestCase):
self.assertIn('"app.default_prompts"', spec) self.assertIn('"app.default_prompts"', spec)
self.assertIn('includes=["**/*.txt"]', normalized) self.assertIn('includes=["**/*.txt"]', normalized)
self.assertIn("datas=default_prompt_datas", 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("COLLECT(", spec)
self.assertIn('name="cmshopee"', normalized) self.assertIn('name="cmshopee"', normalized)
cover_prompt = self.read_text("app/default_prompts/cover/cover_prompt.txt") 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("cmshopee.spec", script)
self.assertIn("Decode-Utf8Base64", 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): def test_release_version_source_is_single_place(self):
version_py = self.read_text("app/version.py") version_py = self.read_text("app/version.py")
self.assertIn('APP_NAME = "蝦皮圈優化助手"', version_py) self.assertIn('APP_NAME = "蝦皮圈優化助手"', version_py)
self.assertIn('APP_CODE_NAME = "cmshopee"', 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( self.assertEqual(
f"{version.APP_NAME} v{version.APP_VERSION}", f"{version.APP_NAME} v{version.APP_VERSION}",
version.display_name(), version.display_name(),
@@ -122,6 +150,14 @@ class PackagingTests(unittest.TestCase):
self.assertNotIn('release-metadata*.json', script) self.assertNotIn('release-metadata*.json', script)
self.assertIn('cmshopee-updater.spec', script) self.assertIn('cmshopee-updater.spec', script)
self.assertIn('cmshopee-updater.exe', 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): def test_updater_spec_builds_a_windowed_standalone_executable(self):
spec = self.read_text("cmshopee-updater.spec") spec = self.read_text("cmshopee-updater.spec")