v2.8.0: Add project directory support, improve dependency resolution, and fix syntax injection

- Added direct project directory drag-and-drop support with automatic root workspace and requirements.txt detection
- Added smart entry script prioritization and interactive selection dialog for multi-script projects
- Expanded AST dependency analysis across the entire project workspace to capture sub-package imports
- Implemented local module and resource whitelisting to prevent unintended pip installations of local packages
- Expanded PyPI mappings and Auto-Hooks rules for C extensions and dynamic libraries (PyCryptodome, NaCl, pynput, etc.)
- Added automatic search path injection (`--paths` for PyInstaller and `PYTHONPATH` for Nuitka) for sub-directory entries
- Improved `requirements.txt` parser to safely handle inline comments and environment markers
- Added cross-hierarchy import detection and workspace configuration tips for standalone scripts
- Fixed SyntaxError caused by console pause code injection preceding `from __future__` statements
- Fixed TypeError crash caused by `os.walk` iteration variable shadowing the global translation function
Update QUICKSTART.md、README.MD...
This commit is contained in:
QwejayHuang committed 2026-08-31 19:57:05 +08:00
1 parent 1f24757a71
commit 448dbdbbf3
29 files changed
+2391 -369

No files matched your search

+546
View File
@@ -0,0 +1,546 @@
# -*- 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 time
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"
current_path = Path(__file__).resolve()
if current_path.parent.name == "tests":
ROOT_DIR = current_path.parent.parent
else:
ROOT_DIR = current_path.parent
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))
class Colors:
CYAN = "\033[96m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
RED = "\033[91m"
RESET = "\033[0m"
BOLD = "\033[1m"
def log_pass(msg):
print(f" {Colors.GREEN}✔ [PASS]{Colors.RESET} {msg}")
def log_warn(msg):
print(f" {Colors.YELLOW}⚠ [WARN]{Colors.RESET} {msg}")
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 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:
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)
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}"
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
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication(["qp_test", "-platform", "offscreen"])
with tempfile.TemporaryDirectory(prefix="qp_e2e_pyi_") as tmp_dir:
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()
# =====================================================================
# 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}")
try:
import nuitka
except ImportError:
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:
start_t = time.time()
# 测试 Nuitka 的文件夹模式 (Standalone)
run_e2e_via_mainwindow("Nuitka", Path(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)
if __name__ == "__main__":
main()