From a18391dae713e7c8c11f646a634560fd309e4aea Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 27 Jul 2026 17:44:15 +0800 Subject: [PATCH] fix(packaging): verify bundled startup dependencies --- cmshopee.spec | 11 +++++++++++ docs/packaging.md | 6 +++--- docs/tasks/T-703.md | 25 ++++++++++++++++--------- main.py | 12 +++++++++++- requirements-build.txt | 3 +++ scripts/build_exe.ps1 | 25 +++++++++++++++++++++++++ tests/test_packaging.py | 38 +++++++++++++++++++++++++++++++++++++- 7 files changed, 106 insertions(+), 14 deletions(-) diff --git a/cmshopee.spec b/cmshopee.spec index 4d7cc87..efd2b1c 100644 --- a/cmshopee.spec +++ b/cmshopee.spec @@ -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={}, diff --git a/docs/packaging.md b/docs/packaging.md index 8c3fd9b..41af871 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -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\蝦皮圈優化助手.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.json` 的版本一致。 5. 在无 Python 环境的 Windows 10/11 机器上解压 `蝦皮圈優化助手.zip` 并启动验证。 diff --git a/docs/tasks/T-703.md b/docs/tasks/T-703.md index 3d5209e..a056c5b 100644 --- a/docs/tasks/T-703.md +++ b/docs/tasks/T-703.md @@ -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`。 diff --git a/main.py b/main.py index 901f2de..dab1a4f 100644 --- a/main.py +++ b/main.py @@ -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()) diff --git a/requirements-build.txt b/requirements-build.txt index da6636c..65706d1 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -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 diff --git a/scripts/build_exe.ps1 b/scripts/build_exe.ps1 index b7ed86e..ec85d46 100644 --- a/scripts/build_exe.ps1 +++ b/scripts/build_exe.ps1 @@ -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" diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 834db03..4a357c6 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -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")