fix(packaging): verify bundled startup dependencies
This commit is contained in:
@@ -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
@@ -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
@@ -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`。
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user