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
This commit is contained in:
QwejayHuang committed 2026-09-03 21:52:39 +08:00
1 parent 448dbdbbf3
commit e91b729463
5 files changed
+390 -530

No files matched your search

+12
View File
@@ -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[...]` 等复合规范依赖包的准确识别与安装。
---
+11
View File
@@ -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
+1 -1
View File
@@ -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 = [
+30 -32
View File
@@ -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"[=><!~]", pkg)[0].strip() for pkg in dedup_install_list]
def extract_pure_pkg_name(pkg_str):
m = re.match(r"^([a-zA-Z0-9_\-\.]+)", pkg_str.strip())
return m.group(1).lower() if m else pkg_str.strip().lower()
flex_install_list = [re.split(r"[=><!~@;\[]", pkg)[0].strip() for pkg in dedup_install_list if re.split(r"[=><!~@;\[]", pkg)[0].strip()]
installed_any = False
for pkg in flex_install_list:
@@ -6511,27 +6526,10 @@ class PackingThread(QThread):
for ex in safe_dev_excludes:
cmd.append(f"--exclude-module={ex}")
elif engine == "Nuitka":
cmd.append("--python-flag=-OO")
cmd.append("--lto=yes")
cmd.append("--python-flag=-O")
cmd.append("--lto=auto")
nuitka_dev_excludes = [
"binder",
"tkinter.test",
"pip",
"wheel",
"distutils",
"pkg_resources",
"jupyter",
"notebook",
"IPython",
"pytest",
"_pytest",
"unittest",
"setuptools",
"pydoc",
"doctest",
"test",
"tests",
"unittest", "doctest", "pdb", "pydoc", "test", "pytest", "IPython", "jupyter"
]
if any(q in imports_lower for q in ("pyside6", "pyqt6", "pyside2", "pyqt5")):
+336 -497
View File
@@ -1,57 +1,40 @@
# -*- coding: utf-8 -*-
"""
=============================================================================
QPyPack 全功能终极一体化自检与双引擎真实打包验证系统 (UI 深度集成完整版)
=============================================================================
特性:
- 深度集成:直接实例化 MainWindow 模拟用户拖拽与点击,完整测试 UI->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"[=><!~@;\[]", pkg)[0].strip()
if cleaned:
extracted.append(cleaned)
assert extracted == ["uvicorn", "fastapi", "requests", "my-pkg", "certifi"]
log_pass(f"复合依赖包名提取完全符合预期: {extracted}")
def test_06_ui_settings_panel(init_qapp):
"""测试 6: 构建设置面板组件与参数状态双向绑定"""
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 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__])