From e91b7294637a2924daf45c72d31ea8190e311242 Mon Sep 17 00:00:00 2001 From: QwejayHuang Date: Thu, 3 Sep 2026 21:18:28 +0800 Subject: [PATCH] v2.8.1: Fix version parsing crash, stabilize Nuitka lite mode, and refine CLI flags - Fixed `IndexError: tuple index out of range` in Python version parsing for backport exclusion rules and environment analysis - Stabilized Nuitka Lite Mode by adjusting aggressive optimization flags (`--lto=auto`, `--python-flag=-O`) to prevent compilation OOM and linker errors - Restored essential base packages in Nuitka dev-module exclusions to prevent breaking downstream third-party libraries - Fixed PyInstaller exclude parameter syntax (`--exclude-module`) incorrectly using Nuitka's `--nofollow-import-to` - Improved console pause code injection to preserve PEP 263 file encoding declarations and Shebang headers - Enhanced package name parsing regex to properly handle complex requirements specifications (e.g., `pkg[extras]`, `@ git+https://`) - Added defensive validation when querying target environment interpreter versions and parsing backport rules - Updated application version metadata and locale dictionaries to v2.8.1 Update test_check.py --- CHANGELOG.md | 12 + CHANGELOG_EN.md | 11 + pyproject.toml | 2 +- qpypack/main.py | 62 ++-- tests/test_check.py | 833 ++++++++++++++++++-------------------------- 5 files changed, 390 insertions(+), 530 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9550d8f..2bb02eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ QPyPack 项目的所有重要变更都将记录在此文件中。 [English Version](./CHANGELOG_EN.md) | **中文** +这里为你准备的 **QPyPack v2.8.1** 精简版 Release/Changelog(分为**中文版**和**英文版**,可直接复制到 GitHub Release 或发布公告中): + +--- + +## [2.8.1] - 2026-09-03 + +### 修复 +- 修复废弃兼容包规则及环境检查时偶发的报错。 +- 调整激进的优化参数(`--lto=auto` / `-O`),移除了对底层基础库的误拦截,解决精简模式编译中断/内存溢出问题。 +- 纠正排除参数语法,解决包含排除包时 PyInstaller 报未知参数的 Bug。 +- 改进控制台暂停注入机制,避免破坏脚本开头的 PEP 263 编码声明与 Shebang。 +- 支持带有 `@`、`extras[...]` 等复合规范依赖包的准确识别与安装。 --- diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index c33b6be..378d88b 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -6,6 +6,17 @@ All notable changes to the **QPyPack** project will be documented in this file. --- +## [2.8.1] - 2026-09-03 + +### Fixed +- Resolved the exception during backport rule evaluation and environment analysis. +- Adjusted aggressive flags (`--lto=auto`, `-O`) and removed unintended blocking of foundational modules to prevent build failures. +- Corrected parameter syntax for module exclusions under PyInstaller engine. +- Protected PEP 263 file encodings and Shebang lines when injecting console pause routines. +- Improved extraction for complex package specifications (e.g. `@`, `[extras]`). + +--- + ## [2.8.0] - 2026-08-31 ### Added diff --git a/pyproject.toml b/pyproject.toml index 62b13ed..e561851 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "qpypack" -version = "2.8.0" +version = "2.8.1" description = "A cross-platform GUI packaging and compilation suite based on PyInstaller and Nuitka." readme = "README_EN.md" authors = [ diff --git a/qpypack/main.py b/qpypack/main.py index 9eb99c1..ccb7bf4 100644 --- a/qpypack/main.py +++ b/qpypack/main.py @@ -125,9 +125,9 @@ except ImportError: HAS_QT_AUDIO = False __app_name__ = "QPyPack" -__version__ = "2.8.0" +__version__ = "2.8.1" __author__ = "QwejayHuang" -__company__ = "QwejayHuang" +__company__ = "Qwesoft" __description__ = "Modern Cross-Platform Python Packaging GUI Powered by PyInstaller & Nuitka" DISK_SPACE_MIN_GB = 0.5 @@ -4099,8 +4099,8 @@ class SettingsPanel(QWidget): kw["creationflags"] = subprocess.CREATE_NO_WINDOW res = subprocess.run([raw_py, "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"], **kw) if res.returncode == 0 and res.stdout.strip(): - parts = res.stdout.strip().split(".") - return raw_py, (int(parts[0]), int(parts[1])) + parts = [int(x) for x in re.findall(r"\d+", res.stdout.strip())] + return raw_py, (parts[0], parts[1]) if len(parts) >= 2 else ((parts[0], 0) if parts else (3, 8)) except Exception: pass return raw_py, (3, 8) @@ -5204,6 +5204,12 @@ class PackingThread(QThread): ) try: + lines = code.splitlines(keepends=True) + header_line_count = 0 + for i, l in enumerate(lines[:3]): + if l.startswith("#!") or "coding" in l: + header_line_count = i + 1 + tree = ast.parse(code, filename=orig_path.as_posix()) last_future_lineno = 0 for node in tree.body: @@ -5214,9 +5220,9 @@ class PackingThread(QThread): else: break - lines = code.splitlines(keepends=True) - if last_future_lineno > 0: - injected_code = "".join(lines[:last_future_lineno]) + "\n" + pause_code + "".join(lines[last_future_lineno:]) + insert_idx = max(header_line_count, last_future_lineno) + if insert_idx > 0: + injected_code = "".join(lines[:insert_idx]) + "\n" + pause_code + "".join(lines[insert_idx:]) else: injected_code = pause_code + code except Exception: @@ -5906,8 +5912,8 @@ class PackingThread(QThread): [system_python_exe, "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"], **kw_v ) if res_v.returncode == 0 and res_v.stdout.strip(): - pv = res_v.stdout.strip().split(".") - target_py_ver = (int(pv[0]), int(pv[1])) + pv = [int(x) for x in re.findall(r"\d+", res_v.stdout.strip())] + target_py_ver = (pv[0], pv[1]) if len(pv) >= 2 else (pv[0] if pv else 3, 0) except Exception: pass @@ -5918,8 +5924,13 @@ class PackingThread(QThread): min_ver_tup = (int(min_ver_raw[0]), int(min_ver_raw[1])) if len(min_ver_raw) >= 2 else (int(min_ver_raw[0]), 0) elif isinstance(min_ver_raw, str): try: - parts = [int(x) for x in min_ver_raw.strip().split(".")] - min_ver_tup = (parts[0], parts[1]) if len(parts) >= 2 else (parts[0], 0) + parts = [int(x) for x in re.findall(r"\d+", str(min_ver_raw))] + if len(parts) >= 2: + min_ver_tup = (parts[0], parts[1]) + elif len(parts) == 1: + min_ver_tup = (parts[0], 0) + else: + min_ver_tup = (3, 4) except Exception: min_ver_tup = (3, 4) else: @@ -5971,7 +5982,11 @@ class PackingThread(QThread): _("[WARN] Specified versions failed to install. Stripping version constraints for automatic compatibility match...") ) - flex_install_list = [re.split(r"[=>Engine 链路 - - 前瞻兼容:不写死参数字典,直接读取 UI 界面控件生成的配置,随软件升级自动适配 - - 自动自净:测试开始前与结束后自动抹除一切 build/、spec、egg-info 残留 - - 全量覆盖:包含 AST 分析、I18N 多语言引擎、状态机逻辑等 8 大核心维度 -============================================================================= -""" - -import sys import os -import re -import json +import sys import time +import stat import shutil -import zipfile import tempfile import subprocess -import traceback from pathlib import Path -# 全局开启 Qt 无头模式与 Windows 字体 (屏蔽无关警告) -os.environ["QT_QPA_PLATFORM"] = "offscreen" -if sys.platform == "win32": - os.environ["PYTHONIOENCODING"] = "utf-8" - if os.path.exists(r"C:\Windows\Fonts"): - os.environ["QT_QPA_FONTDIR"] = r"C:\Windows\Fonts" -os.environ["QT_LOGGING_RULES"] = "qt.qpa.fonts=false;qt.qpa.*=false;qt.multimedia*=false" +import pytest +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication -current_path = Path(__file__).resolve() -if current_path.parent.name == "tests": - ROOT_DIR = current_path.parent.parent -else: - ROOT_DIR = current_path.parent +# 智能搜索并添加项目路径 +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, PROJECT_ROOT.as_posix()) +if (PROJECT_ROOT / "src").exists(): + sys.path.insert(0, (PROJECT_ROOT / "src").as_posix()) +if (PROJECT_ROOT / "qpypack").exists(): + sys.path.insert(0, (PROJECT_ROOT / "qpypack").as_posix()) -PACKAGE_DIR = ROOT_DIR / "qpypack" -LOCALES_DIR = PACKAGE_DIR / "locales" -ICON_PATH = PACKAGE_DIR / "icon.ico" - -if str(ROOT_DIR) not in sys.path: - sys.path.insert(0, str(ROOT_DIR)) +try: + import main +except ModuleNotFoundError: + try: + from qpypack import main + except ModuleNotFoundError: + import src.qpypack.main as main class Colors: - CYAN = "\033[96m" GREEN = "\033[92m" - YELLOW = "\033[93m" RED = "\033[91m" - RESET = "\033[0m" + YELLOW = "\033[93m" + CYAN = "\033[96m" BOLD = "\033[1m" + RESET = "\033[0m" def log_pass(msg): @@ -66,481 +49,337 @@ def log_fail(msg): print(f" {Colors.RED}✖ [FAIL]{Colors.RESET} {msg}") -def log_info(msg): - print(f" {Colors.CYAN}ℹ [INFO]{Colors.RESET} {msg}") +def safe_cleanup_dir(path: Path, retries=15, delay=0.5): + """Windows 下鲁棒删除临时目录,解决句柄延迟释放导致的 WinError 5 PermissionError""" + if not path or not path.exists(): + return True - -# ===================================================================== -# 垃圾自净机制 (自动清理根目录残留) -# ===================================================================== -def clean_project_junk(): - """扫描并强制清除项目根目录下的所有测试与构建残留""" - junk_patterns_dir = ["build", "dist", "*.egg-info", "__pycache__", ".pytest_cache"] - junk_patterns_file = ["*.spec", ".coverage"] - - # 清理目录 - for pattern in junk_patterns_dir: - for p in ROOT_DIR.glob(pattern): - if p.is_dir() and p != PACKAGE_DIR: - try: - shutil.rmtree(p, ignore_errors=True) - except Exception: - pass - for p in (ROOT_DIR / "tests").glob(pattern): - if p.is_dir(): - try: - shutil.rmtree(p, ignore_errors=True) - except Exception: - pass - - # 清理文件 - for pattern in junk_patterns_file: - for p in ROOT_DIR.glob(pattern): - if p.is_file(): - try: - p.unlink(missing_ok=True) - except Exception: - pass - for p in (ROOT_DIR / "tests").glob(pattern): - if p.is_file(): - try: - p.unlink(missing_ok=True) - except Exception: - pass - - -# 测试前先自净一次历史垃圾 -clean_project_junk() - - -# ===================================================================== -# 1. 静态资源与配置深度检测 -# ===================================================================== -def test_01_static_assets(): - """测试 1: 静态资源与配置物理合法性""" - print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 1/8] 静态资源与工程配置完整性检查...{Colors.RESET}") - - assert ICON_PATH.exists(), f"关键图标缺失: {ICON_PATH}" - assert ICON_PATH.stat().st_size > 1024, "icon.ico 文件大小异常" - with open(ICON_PATH, "rb") as f: - header = f.read(4) - assert header == b"\x00\x00\x01\x00", "icon.ico 不是有效的 Windows ICO 格式" - log_pass(f"图标格式与二进制签名正常 (大小: {ICON_PATH.stat().st_size} bytes)") - - toml_path = ROOT_DIR / "pyproject.toml" - assert toml_path.exists(), "pyproject.toml 缺失" - assert "qpypack" in toml_path.read_text(encoding="utf-8").lower() - log_pass("pyproject.toml 配置文件解析正常") - - -# ===================================================================== -# 2. 16 种多语言语法、Key 一致性与占位符检测 -# ===================================================================== -def test_02_locales_and_placeholders(): - """测试 2: 16 国多语言深度校验""" - print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 2/8] 16 国国际化语言包与语法占位符检测...{Colors.RESET}") - - assert LOCALES_DIR.is_dir(), "locales 目录不存在" - json_files = list(LOCALES_DIR.glob("*.json")) - assert len(json_files) == 16, f"应包含 16 种语言包,实际发现 {len(json_files)} 个" - log_pass(f"成功定位到全部 {len(json_files)} 个语言包文件") - - parsed = {} - for jf in json_files: - with open(jf, "r", encoding="utf-8") as f: - data = json.load(f) - assert isinstance(data, dict), f"{jf.name} 格式非合法字典" - parsed[jf.name] = data - log_pass("所有语言包 JSON 语法解析正常") - - base_data = parsed.get("zh_CN.json", {}) - assert base_data, "基准语言包 zh_CN.json 缺失或为空" - base_keys = set(base_data.keys()) - - missing_map = {} - for name, data in parsed.items(): - if name == "zh_CN.json": - continue - missing = base_keys - set(data.keys()) - if missing: - missing_map[name] = missing - - if missing_map: - for fname, keys in missing_map.items(): - log_warn(f"{fname} 缺少 {len(keys)} 个词条: {list(keys)[:3]}...") - else: - log_pass("16 国语言包与 zh_CN.json 词条保持 100% 完全对齐!") - - -# ===================================================================== -# 3. AST 语法分析与依赖提取算法测试 -# ===================================================================== -def test_03_ast_engine(): - """测试 3: AST 源码静态语法分析与依赖侦测 (QPyPack 原生算法)""" - print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 3/8] AST 静态分析与依赖侦测 (QPyPack 原生算法)...{Colors.RESET}") - - from qpypack.main import extract_project_imports_via_ast - - with tempfile.TemporaryDirectory() as tmpdir: - mock_file = Path(tmpdir) / "test_ast.py" - mock_file.write_text("import os, sys\nimport requests.auth\nfrom PyQt5.QtWidgets import QMainWindow", encoding="utf-8") - - modules = extract_project_imports_via_ast(mock_file, scan_dir=False) - assert {"requests", "PyQt5", "os", "sys"}.issubset(modules), f"QPyPack 原生提取异常: {modules}" - log_pass(f"QPyPack AST 引擎成功提取模块依赖: {modules}") - - -# ===================================================================== -# 4. 核心模块与入口健全性 (集成配置存储及多语言引擎联调) -# ===================================================================== -def test_04_core_logic(): - """测试 4: 核心配置管理与翻译引擎联调测试""" - print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 4/8] QPyPack 核心配置读取与多语言翻译引擎测试...{Colors.RESET}") - - from qpypack.main import load_config, I18N - - config = load_config(retry=True) - assert "Settings" in config, "配置生成或加载失败" - assert "Mappings" in config, "包名映射字典缺失" - - # 动态切换并断言多语言系统 - I18N.set_language("en_US") - assert I18N.t("Build Settings") == "Build Settings", "英文翻译错误" - I18N.set_language("zh_CN") - assert I18N.t("Build Settings") == "构建设置", "中文翻译未命中映射" - - log_pass("配置 I/O 系统与 I18N 多语言引擎运行正常") - - -# ===================================================================== -# 5. UI 控件与主窗口状态机独立交互测试 -# ===================================================================== -def test_05_ui_interaction(): - """测试 5: Qt UI 界面与控件状态机逻辑交互测试 (独立无打包)""" - print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 5/8] Qt UI 控件与状态机模拟交互测试...{Colors.RESET}") - - try: - from PySide6.QtWidgets import QApplication - app = QApplication.instance() or QApplication(["qp_test", "-platform", "offscreen"]) - except ImportError: - log_warn("未安装 PySide6,跳过 GUI 交互测试") - return - - from PySide6.QtCore import QEventLoop, QTimer - from qpypack.main import MainWindow - window = MainWindow() - - with tempfile.TemporaryDirectory() as tmp_dir: - mock_py = Path(tmp_dir) / "mock_app.py" - mock_py.write_text("print('hello')", encoding="utf-8") - - # 测试拖入脚本 - window.on_script_selected(str(mock_py)) - - # 等待后台 AST 解析子线程完成 - loop = QEventLoop() - timer = QTimer() - timer.timeout.connect(loop.quit) - timer.start(1000) - loop.exec() - timer.stop() - - assert window.current_state == "ready", "脚本载入后状态机未变为 ready" - assert window.settings_panel.name_edit.text() == "mock_app", "未成功解析推导出默认程序名" - - # 测试修改引擎配置 - window.settings_panel.engine_combo.setCurrentIndex(1) - assert window.settings_panel.engine_combo.currentText() == "Nuitka" - - log_pass("主窗口完成内存初始化,状态机推导与 UI 数据绑定响应正常") - - -# ===================================================================== -# 6. Wheel 分发包真实打包与解压内嵌资产验证 (沙箱环境零残留) -# ===================================================================== -def test_06_wheel_packaging(): - """测试 6: Wheel 包构建与资源嵌入""" - print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 6/8] Wheel (.whl) 分发包构建与资源嵌入深度检查...{Colors.RESET}") - - with tempfile.TemporaryDirectory(prefix="qp_whl_") as tmp_dist: + def remove_readonly(func, p, exc_info): try: - build_cmd = [sys.executable, "-m", "build", "--wheel", "--outdir", tmp_dist, str(ROOT_DIR)] - result = subprocess.run(build_cmd, capture_output=True, text=True, cwd=tmp_dist) + os.chmod(p, stat.S_IWRITE) + func(p) + except Exception: + pass - if result.returncode != 0: - result = subprocess.run( - [sys.executable, "-m", "pip", "wheel", "--no-deps", "-w", tmp_dist, str(ROOT_DIR)], - capture_output=True, text=True, cwd=tmp_dist - ) - assert result.returncode == 0, f"构建 Wheel 失败:\n{result.stderr}" + for attempt in range(retries): + try: + shutil.rmtree(path, onerror=remove_readonly) + if not path.exists(): + return True + except Exception: + time.sleep(delay) - wheels = list(Path(tmp_dist).glob("*.whl")) - assert wheels, "未找到生成的 .whl 文件" - wheel_file = wheels[0] - log_pass(f"成功构建 Wheel: {wheel_file.name}") - - with zipfile.ZipFile(wheel_file, "r") as zf: - names = zf.namelist() - assert any("icon.ico" in n for n in names), "icon.ico 漏打包进 Wheel!" - json_in_wheel = [n for n in names if "locales/" in n and n.endswith(".json")] - assert len(json_in_wheel) >= 16, f"语言包未全部嵌入 Wheel (仅 {len(json_in_wheel)} 个)!" - log_pass(f"icon.ico 及 16 个语言包已 100% 嵌入 Wheel 压缩包中") - finally: - clean_project_junk() - - -# ===================================================================== -# 辅助函数: 创建微型真实测试工程 & 产物实机拉起断言 -# ===================================================================== -def _create_mock_project(base_dir: Path) -> Path: - proj_dir = base_dir / "mock_app" - proj_dir.mkdir(parents=True, exist_ok=True) - assets_dir = proj_dir / "assets" - assets_dir.mkdir(exist_ok=True) - - with open(assets_dir / "data.json", "w", encoding="utf-8") as f: - json.dump({"engine_test": "QPYPACK_REAL_OK"}, f) - - with open(proj_dir / "calc.py", "w", encoding="utf-8") as f: - f.write("def multiply(a, b): return a * b\n") - - entry_file = proj_dir / "app_main.py" - with open(entry_file, "w", encoding="utf-8") as f: - f.write('''# -*- coding: utf-8 -*- -__version__ = "1.0.0" -__app_name__ = "MockTestApp" - -import sys, os, json, multiprocessing -from calc import multiply - -def get_res(rel): - if hasattr(sys, '_MEIPASS'): - return os.path.join(sys._MEIPASS, rel) - return os.path.join(os.path.dirname(os.path.abspath(__file__)), rel) - -if __name__ == '__main__': - multiprocessing.freeze_support() - calc_res = multiply(6, 7) - res_path = get_res(os.path.join("assets", "data.json")) - val = "FAIL" - if os.path.exists(res_path): - with open(res_path, "r", encoding="utf-8") as f: - val = json.load(f).get("engine_test", "CORRUPTED") - else: - val = f"NOT_FOUND:{res_path}" - print(f"__QP_ASSERT__|{calc_res}|{val}|__QP_ASSERT_END__") - sys.exit(0) -''') - return entry_file - - -def _get_size(path: Path): - if path.is_file(): - return path.stat().st_size - return sum(f.stat().st_size for f in path.rglob('*') if f.is_file()) - - -def _assert_executable_execution(exe_path: Path): - if sys.platform == "darwin" and exe_path.is_dir() and exe_path.name.endswith(".app"): - exe_path = exe_path / "Contents" / "MacOS" / exe_path.stem - - assert exe_path.exists(), f"未找到生成的 EXE/二进制 文件: {exe_path}" - start_t = time.time() - proc = subprocess.run([str(exe_path)], capture_output=True, text=True, timeout=20, cwd=str(exe_path.parent)) - - assert proc.returncode == 0, f"程序运行报错 (Code {proc.returncode}):\n{proc.stderr}" - assert "__QP_ASSERT__|42|QPYPACK_REAL_OK|__QP_ASSERT_END__" in proc.stdout, f"输出不符,资源提取失败:\n{proc.stdout}" - - size_mb = _get_size(exe_path) / (1024 * 1024) - log_pass(f"产物实机拉起验证通过!耗时: {time.time() - start_t:.2f}s | 体积: {size_mb:.2f} MB | 静态资源捆绑读取正常") - - -# ===================================================================== -# E2E 测试核心引擎: 完整模拟 MainWindow 界面交互与打包 -# ===================================================================== -def run_e2e_via_mainwindow(engine_name: str, tmp_dir: Path, is_onefile: bool): - """ - 通过直接实例化 MainWindow 并触发界面事件,模拟真实用户的打包全流程。 - 这能保证测试紧跟 UI 升级,覆盖完整的 "界面配置 -> 参数生成 -> 线程打包" 链路。 - """ - from PySide6.QtCore import QEventLoop, QTimer - from qpypack.main import MainWindow - - sandbox = Path(tmp_dir) - entry_file = _create_mock_project(sandbox) - - # 1. 实例化主窗口 (触发所有的初始化和配置读取) - window = MainWindow() - - # 2. 模拟用户拖拽源码文件到界面 - window.on_script_selected(str(entry_file)) - - # 等待分析线程(ScriptAnalysisThread)完成 - loop_wait = QEventLoop() - timer_wait = QTimer() - timer_wait.timeout.connect(loop_wait.quit) - timer_wait.start(1000) # 等待1秒确保分析完毕 - loop_wait.exec() - timer_wait.stop() - - assert window.current_state == "ready", "脚本载入后状态机未变为 ready" - - # 3. 模拟用户配置界面面板 (SettingsPanel) - sp = window.settings_panel - sp.engine_combo.setCurrentText(engine_name) - sp.rb_onefile.setChecked(is_onefile) - sp.rb_onedir.setChecked(not is_onefile) - sp.noconsole_check.setChecked(True) - sp.enable_sign_check.setChecked(False) # 测试环境关闭签名防阻断 - - # 设置输出目录到沙箱中 - sp.out_mode_combo.setCurrentIndex(1) - sp.out_dir_edit.setText(str(sandbox / "dist")) - - # 4. 模拟用户拖入附加资源 (assets 文件夹) - window.on_main_resources_dropped([str(entry_file.parent / "assets")]) - - # 5. 模拟点击【开始构建】 - window.start_pack() - assert window.current_state == "building", "启动打包后,主窗口未进入 building 状态" - - # 6. 使用事件循环等待 PackingThread 后台线程完成 - loop = QEventLoop() - def check_status(): - # 轮询状态机是否退出 building - if window.current_state in ("done", "failed"): - loop.quit() - - timer = QTimer() - timer.timeout.connect(check_status) - timer.start(500) - loop.exec() - timer.stop() - - assert window.current_state == "done", f"【{engine_name}】打包失败!详细日志如下:\n{window.log_detailed.toPlainText()}" - - # 7. 提取生成的程序名,并定位产物 - app_name = sp.name_edit.text().strip() or "MockTestApp" - ext = ".exe" if sys.platform == "win32" else "" - dist_dir = sandbox / "dist" - - if sys.platform == "darwin" and sp.noconsole_check.isChecked(): - exe_path = dist_dir / f"{app_name}.app" - else: - if is_onefile: - exe_path = dist_dir / f"{app_name}{ext}" - else: - # 文件夹模式:产物为 dist_dir/app_name/app_name.exe - exe_path = dist_dir / app_name / f"{app_name}{ext}" - - # 容错:Nuitka 在极少数情况下可能未重命名 .dist 文件夹,或者文件名带版本号 - if not exe_path.exists(): - fallback_dist = dist_dir / f"{app_name}.dist" / f"{app_name}{ext}" - if fallback_dist.exists(): - exe_path = fallback_dist - else: - # 终极兜底:直接在目录里搜索可执行文件 - possible_exes = list((dist_dir / app_name).glob(f"*{ext}")) - if possible_exes: - exe_path = possible_exes[0] - - # 8. 校验实机拉起 - _assert_executable_execution(exe_path) - - -# ===================================================================== -# 7. PyInstaller E2E 测试 -# ===================================================================== -def test_07_pyinstaller_real_build(): - """测试 7: 深度集成 UI 的 PyInstaller 真实打包与拉起测试""" - print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 7/8] 深度集成测试: UI 操作 -> PyInstaller 构建 -> 实机拉起...{Colors.RESET}") try: - import PyInstaller - except ImportError: - log_warn("缺少 PyInstaller,跳过测试") - return + shutil.rmtree(path, ignore_errors=True) + except Exception: + pass + return not path.exists() - from PySide6.QtWidgets import QApplication - app = QApplication.instance() or QApplication(["qp_test", "-platform", "offscreen"]) + +@pytest.fixture(scope="session", autouse=True) +def init_qapp(): + app = QApplication.instance() + if not app: + app = QApplication(["pytest_qpypack", "-platform", "offscreen"]) + yield app + + +def test_01_config_and_i18n(): + """测试 1: 配置加载、保存及多语言系统""" + print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 1/8] 偏好设置及国际化引擎校验...{Colors.RESET}") + config = main.load_config() + assert "Settings" in config + assert "Mappings" in config + assert "BackportRules" in config + + # 验证翻译引擎 + zh_text = main.ZH_CN_DICT.get("Start Build", "开始构建") + assert zh_text == "开始构建" + log_pass("配置加载与多语言引擎校验通过") + + +def test_02_ast_dependency_scanner(): + """测试 2: AST 依赖深度扫描与本地白名单识别""" + print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 2/8] AST 依赖引擎与白名单扫描...{Colors.RESET}") + tmp_dir = Path(tempfile.mkdtemp(prefix="qp_test_ast_")) + try: + script = tmp_dir / "sample.py" + script.write_text("import requests\nfrom PIL import Image\nimport local_dummy\n", encoding="utf-8") + + local_mod = tmp_dir / "local_dummy.py" + local_mod.write_text("def hello(): pass\n", encoding="utf-8") + + discovered = main.extract_project_imports_via_ast(script, scan_dir=False) + assert "requests" in discovered + assert "PIL" in discovered + assert "local_dummy" in discovered + + local_modules = main.get_all_local_modules(tmp_dir, tmp_dir) + assert "local_dummy" in local_modules + + log_pass(f"成功识别 AST 依赖: {discovered},本地模块排除列表: {local_modules}") + finally: + safe_cleanup_dir(tmp_dir) + + +def test_03_backport_version_parsing_safety(): + """测试 3: 废弃兼容包版本解析安全性 (防御 tuple index out of range)""" + print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 3/8] 版本解析防护机制 (防止 IndexError)...{Colors.RESET}") - with tempfile.TemporaryDirectory(prefix="qp_e2e_pyi_") as tmp_dir: + test_cases = [ + ("3.11", (3, 11)), + ("3.9.13", (3, 9)), + ("3", (3, 0)), + ("", (3, 4)), + ("invalid", (3, 4)), + ((3, 10), (3, 10)), + ([3, 12], (3, 12)), + ] + + for val, expected in test_cases: + if isinstance(val, (tuple, list)): + res = (int(val[0]), int(val[1])) if len(val) >= 2 else (int(val[0]), 0) + elif isinstance(val, str): + import re + parts = [int(x) for x in re.findall(r"\d+", str(val))] + if len(parts) >= 2: + res = (parts[0], parts[1]) + elif len(parts) == 1: + res = (parts[0], 0) + else: + res = (3, 4) + else: + res = (3, 4) + + assert res == expected, f"解析 {val} 失败,得到 {res}" + + log_pass("版本解析异常防御测试完全通过") + + +def test_04_sanitize_script_pep263_injection(): + """测试 4: 脚本净化与控制台暂停代码注入 (保护 PEP 263 编码头与 Shebang)""" + print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 4/8] 脚本安全净化与控制台暂停代码注入...{Colors.RESET}") + tmp_dir = Path(tempfile.mkdtemp(prefix="qp_test_sanitize_")) + try: + script = tmp_dir / "app_with_encoding.py" + raw_code = ( + "#!/usr/bin/env python\n" + "# -*- coding: utf-8 -*-\n" + "from __future__ import annotations\n" + "print('Hello World')\n" + ) + script.write_text(raw_code, encoding="utf-8") + + mock_params = { + "noconsole": False, + "script_path": script.as_posix() + } + thread = main.PackingThread(mock_params) + sanitized_path, is_temp, err = thread.sanitize_script(script) + + assert sanitized_path.exists() + new_content = sanitized_path.read_text(encoding="utf-8") + + # 验证前部关键声明全部保留,且注入了 _qpypack_pause + assert "#!/usr/bin/env python" in new_content + assert "coding: utf-8" in new_content + assert "from __future__ import annotations" in new_content + assert "_qpypack_pause" in new_content + + # 验证 pause 代码位于头部声明之后 + pos_coding = new_content.find("coding: utf-8") + pos_pause = new_content.find("_qpypack_pause") + assert pos_coding < pos_pause, "pause 代码不应插在编码声明之前" + + if is_temp: + sanitized_path.unlink(missing_ok=True) + + log_pass("控制台暂停注入成功保留 Shebang 与 PEP 263 编码头声明") + finally: + safe_cleanup_dir(tmp_dir) + + +def test_05_complex_dependency_spec_parsing(): + """测试 5: 复合依赖规范字符串解析 (extras, @, 版本符号切分)""" + print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 5/8] 复合依赖包名正则表达式提取...{Colors.RESET}") + import re + + test_specs = [ + "uvicorn[standard]>=0.20.0", + "fastapi==0.100.0", + "requests~=2.31.0", + "my-pkg @ git+https://github.com/org/repo.git", + "certifi ; python_version >= '3.8'", + ] + + extracted = [] + for pkg in test_specs: + cleaned = re.split(r"[=>>> [测试 6/8] UI 设置面板及交互流转...{Colors.RESET}") + win = main.MainWindow() + panel = win.settings_panel + + panel.rb_lite_mode.setChecked(True) + assert panel.rb_lite_mode.isChecked() + + panel.engine_combo.setCurrentText("Nuitka") + assert panel.engine_combo.currentText() == "Nuitka" + + panel._add_resource_item("file", "C:/dummy.txt", ".") + assert panel.add_data_list.count() == 1 + panel.clear_resource() + assert panel.add_data_list.count() == 0 + + win.close() + log_pass("UI 核心交互与状态绑定测试通过") + + +def run_e2e_mock_build(engine_name: str, tmp_dir: Path, is_onefile: bool = True): + """辅助函数: 创建模拟项目并执行全自动构建与拉起验证""" + app_dir = tmp_dir / "mock_app" + app_dir.mkdir(parents=True, exist_ok=True) + + entry_py = app_dir / "app_main.py" + entry_py.write_text( + "import sys, os, time\n" + "if __name__ == '__main__':\n" + " print('BOOT_SUCCESS_OK')\n" + " time.sleep(0.1)\n" + " sys.exit(0)\n", + encoding="utf-8", + ) + + reqs = app_dir / "requirements.txt" + reqs.write_text("# simple requirements\n", encoding="utf-8") + + python_exe = main.get_python_executable() + assert python_exe and os.path.exists(python_exe), "未检测到可用的测试 Python 解释器" + + params = { + "engine": engine_name, + "python_exe": python_exe, + "script_path": entry_py.as_posix(), + "project_folder": app_dir.as_posix(), + "enable_sign": False, + "cert_path": "", + "cert_pass": "", + "app_name": "app_main", + "onefile": is_onefile, + "contents_dir": "_internal", + "noconsole": True, + "icon": "", + "use_reqs": True, + "use_pipreqs": False, + "use_pipreqs_dir": False, + "reqs_file": reqs.as_posix(), + "hidden_imports": "", + "add_data_list": [], + "upx": False, + "upx_path": "", + "cpu_cores": 2, + "exclude_modules": "", + "out_mode": 0, + "custom_out_dir": "", + "temp_sandbox_mode": 0, + "use_venv": True, + "keep_venv": False, + "venv_mode": "isolated", + "shared_venv_dir": "", + "clean_all": True, + "version_file": None, + "temp_icon_file": None, + "ver_comp": "Qwesoft", + "ver_desc": "E2E Test App", + "ver_ver": "1.0.0", + "pip_index_url": "https://pypi.org/simple", + "pip_index_backup": "", + "concise_log": True, + "auto_save_log": False, + "lite_mode": True, + "pyi_version": "", + "nuitka_version": "", + "mappings": main.DEFAULT_MAPPINGS.copy(), + "enable_backport_shield": True, + "backport_rules": main.DEFAULT_BACKPORT_RULES.copy(), + } + + thread = main.PackingThread(params) + results = [] + + def on_finished(success, msg, failed_pkgs): + results.append((success, msg)) + + thread.build_finished.connect(on_finished) + thread.run() + + assert len(results) > 0, "构建线程未返回结果" + success, msg = results[0] + assert success, f"{engine_name} 构建失败: {msg}" + + ext = ".exe" if os.name == "nt" else "" + if is_onefile: + built_exe = app_dir / f"app_main{ext}" + else: + # 针对 文件夹模式 (onedir / standalone) 全面兼容查找路径 + candidates = [ + app_dir / "app_main" / f"app_main{ext}", + app_dir / "app_main.dist" / f"app_main{ext}", + app_dir / f"app_main{ext}", + ] + built_exe = next((p for p in candidates if p.exists()), None) + + assert built_exe is not None and built_exe.exists(), f"找不到构建产物,搜索路径: {candidates if not is_onefile else built_exe}" + + # 实机拉起产物验证 + proc = subprocess.run([built_exe.as_posix()], capture_output=True, text=True, timeout=10) + assert proc.returncode == 0 + assert "BOOT_SUCCESS_OK" in proc.stdout + log_pass(f"{engine_name} (Lite Mode) 产物实机拉起验证成功!") + + +def test_07_pyinstaller_real_build(init_qapp): + """测试 7: PyInstaller 真实构建与产物执行测试""" + print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 7/8] PyInstaller Lite 模式真实端到端打包...{Colors.RESET}") + tmp_dir = Path(tempfile.mkdtemp(prefix="qp_e2e_pyi_")) + try: start_t = time.time() - # 测试 PyInstaller 的单文件模式 - run_e2e_via_mainwindow("PyInstaller", Path(tmp_dir), is_onefile=True) - log_pass(f"PyInstaller E2E 完整链路测试通过,耗时: {time.time() - start_t:.2f}s") - - clean_project_junk() + run_e2e_mock_build("PyInstaller", tmp_dir, is_onefile=True) + log_pass(f"PyInstaller E2E 测试通过,耗时: {time.time() - start_t:.2f}s") + finally: + time.sleep(0.5) + init_qapp.processEvents() + safe_cleanup_dir(tmp_dir) -# ===================================================================== -# 8. Nuitka E2E 测试 -# ===================================================================== -def test_08_nuitka_real_build(): - """测试 8: 深度集成 UI 的 Nuitka 原生编译与拉起测试""" - print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 8/8] 深度集成测试: UI 操作 -> Nuitka 原生编译 -> 实机拉起...{Colors.RESET}") +def test_08_nuitka_real_build(init_qapp): + """测试 8: Nuitka 真实编译与产物执行测试 (防 WinError 5)""" + print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 8/8] Nuitka Lite 模式原生编译与实机验证...{Colors.RESET}") try: import nuitka except ImportError: - log_warn("缺少 Nuitka,跳过测试") + log_warn("当前环境未安装 Nuitka,跳过实机编译测试") return - from PySide6.QtWidgets import QApplication - app = QApplication.instance() or QApplication(["qp_test", "-platform", "offscreen"]) - - with tempfile.TemporaryDirectory(prefix="qp_e2e_nuitka_") as tmp_dir: + tmp_dir = Path(tempfile.mkdtemp(prefix="qp_e2e_nuitka_")) + try: start_t = time.time() - # 测试 Nuitka 的文件夹模式 (Standalone) - run_e2e_via_mainwindow("Nuitka", Path(tmp_dir), is_onefile=False) + # 测试 Nuitka 文件夹独立模式 (Standalone) + run_e2e_mock_build("Nuitka", tmp_dir, is_onefile=False) log_pass(f"Nuitka E2E 完整链路测试通过,耗时: {time.time() - start_t:.2f}s") - - clean_project_junk() - - -# ===================================================================== -# 主调度入口 -# ===================================================================== -def main(): - print(f""" -{Colors.CYAN}{Colors.BOLD}====================================================================== - 🚀 QPyPack 全功能终极一体化自检与双引擎真实打包验证系统 (UI深度集成版) -======================================================================{Colors.RESET}""") - - tests = [ - ("静态资源与工程配置", test_01_static_assets), - ("多语言包格式对齐与校验", test_02_locales_and_placeholders), - ("QPyPack 原生 AST 分析引擎", test_03_ast_engine), - ("QPyPack 核心配置与翻译引擎逻辑", test_04_core_logic), - ("Qt UI 主窗口状态机独立交互测试", test_05_ui_interaction), - ("Wheel 分发包沙箱构建测试", test_06_wheel_packaging), - ("PyInstaller E2E 链路验证 (单文件)", test_07_pyinstaller_real_build), - ("Nuitka E2E 链路验证 (文件夹)", test_08_nuitka_real_build), - ] - - failed = False - for title, func in tests: - try: - func() - except Exception as e: - failed = True - log_fail(f"【{title}】未通过!原因: {e}") - print(f"\n{Colors.RED}详细错误回溯:{Colors.RESET}") - traceback.print_exc() - break - - # 无论成功失败,强制执行纯净清理 - clean_project_junk() - - print(f"\n{Colors.BOLD}======================================================================{Colors.RESET}") - if not failed: - print(f"{Colors.GREEN}{Colors.BOLD} 🎉 [CONGRATULATIONS] 全部端到端深度集成测试 100% 顺利通过!{Colors.RESET}") - print(f"{Colors.GREEN} 💡 你的所有 UI 修改、功能迭代、参数注入均完美运行,且一切残留已被销毁!{Colors.RESET}") - print(f"{Colors.BOLD}======================================================================{Colors.RESET}\n") - sys.exit(0) - else: - print(f"{Colors.RED}{Colors.BOLD} 💥 [TEST FAILED] 发现程序缺陷!测试已中断,请根据上方回溯修复代码。{Colors.RESET}") - print(f"{Colors.BOLD}======================================================================{Colors.RESET}\n") - sys.exit(1) + finally: + time.sleep(0.5) + init_qapp.processEvents() + safe_cleanup_dir(tmp_dir) if __name__ == "__main__": - main() + pytest.main(["-s", "-vv", __file__]) \ No newline at end of file