v2.3.5: Core updates and documentation

This commit is contained in:
QwejayHuang committed 2026-08-07 01:39:23 +08:00
1 parent 7a1ec3db16
commit 60173c2ec4
4 files changed
+274 -169

No files matched your search

+18
View File
@@ -32,6 +32,14 @@ QPyPack 是一个为 Python 开发者设计的图形化打包配置工具。它
---
### Changelog
**2.3.5**
* **防漏包机制**:彻底废弃极易误判的短路判定,重构依赖拉取架构为“三重绝对安全网”(Requirements -> Pipreqs -> AST源码强扫)。即使前置分析工具漏报,最终的 AST 保底扫描也会强行抓取 `PySide6` 等核心隐式库并塞入沙盒,彻底终结漏包闪退问题。
* **工作区“零污染”改造**:贯彻绝对纯净的构建理念。将 `QPyPack.ini` 配置文件平滑迁移至全局系统路径(`~/.qpypack/config.ini`),并将所有 AST 缓存、依赖清单等中间产物全部转移至操作系统 `Temp` 目录,确保不留任何垃圾文件污染用户的工程代码区。
* **构建日志工程化规范**:全面重构控制台与 UI 日志的输出文案,引入规范化的 CI/CD 生命周期状态标签(如 `[Init]`, `[Env]`, `[Deps]`, `[Build]`, `[Pack]`),大幅提升状态诊断的清晰度与专业性。
* **虚拟环境沙盒智能自愈**:在分配独立隔离的 venv 打包沙盒后,首个初始化动作变更为静默自动升级 `pip`,彻底压制并清除了因老旧模块引发的大段黄色警告乱码。
* **核心 BUG 修复**:精准修复了因底层 `configparser` 映射字典降级读取失败,导致抛出 `'dict' object has no attribute 'lower'` 的致命性系统崩溃问题。
* **优化日志表述**:调整了日志输出文字的语言表述,现在更加专业化。
**2.3.0**
* **UI框架重构**:由 PyQt5 升级至 PySide6。优化了现代高分屏(High-DPI)下的文字与图标缩放表现。
* **精简架构**:移除 `cx_Freeze` 打包引擎,专注打磨更现代、更高效的 PyInstaller 与 Nuitka 双核心引擎。
@@ -84,3 +92,13 @@ qpypack
### 预编译版本下载
若不想配置 Python 环境,可直接下载打包好的独立可执行文件:
[GitHub Releases 下载链接](https://github.com/Qwejay/PyPack/releases)
## Star History
<a href="https://www.star-history.com/?repos=Qwejay%2FQPyPack&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Qwejay/QPyPack&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Qwejay/QPyPack&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Qwejay/QPyPack&type=date&legend=top-left" />
</picture>
</a>
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "qpypack"
version = "2.3.0"
version = "2.3.5"
description = "A modern graphical packaging config tool for PyInstaller, Nuitka."
readme = "README.md"
authors = [
@@ -19,7 +19,7 @@ classifiers = [
]
dependencies = [
"PyQt5>=5.15.0",
"PySide6>=6.0.0",
]
[project.urls]
-2
View File
@@ -1,2 +0,0 @@
PyQt5==5.15.11
pyqt5_sip==12.18.0
+254 -165
View File
@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*-
__app_name__ = "QPyPack"
__version__ = "2.3.0"
__version__ = "2.3.6"
__author__ = "QwejayHuang"
__company__ = "QwejayHuang"
__description__ = "自动化 Python 脚本打包构建工具"
@@ -15,10 +15,11 @@ import tempfile
import re
import time
import stat
import ast
import json
import locale
import math
import threading
import configparser
from pathlib import Path
if os.name == 'nt':
@@ -54,6 +55,81 @@ MATERIAL_ICONS = {
'close': 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'
}
_CONFIG_DIR = Path.home() / ".qpypack"
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_FILE = (_CONFIG_DIR / "config.ini").as_posix()
DEFAULT_MAPPINGS = {
'win32com': 'pywin32', 'win32api': 'pywin32', 'win32con': 'pywin32', 'win32gui': 'pywin32',
'win32clipboard': 'pywin32', 'win32print': 'pywin32', 'win32file': 'pywin32', 'win32security': 'pywin32',
'cv2': 'opencv-python', 'PIL': 'pillow', 'Pillow': 'pillow', 'bs4': 'beautifulsoup4', 'sklearn': 'scikit-learn',
'yaml': 'pyyaml', 'fitz': 'pymupdf', 'dotenv': 'python-dotenv'
}
def load_config():
config = configparser.ConfigParser()
if not os.path.exists(CONFIG_FILE):
config['Mappings'] = DEFAULT_MAPPINGS
config['Settings'] = {
'engine': 'PyInstaller', 'pip_index': 'https://pypi.tuna.tsinghua.edu.cn/simple',
'onefile': 'True', 'noconsole': 'True', 'clean_all': 'True',
'auto_icon': 'True', 'use_venv': 'True', 'use_reqs': 'True',
'use_pipreqs': 'True', 'upx': 'False'
}
try:
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
config.write(f)
except: pass
else:
config.read(CONFIG_FILE, encoding='utf-8')
if 'Mappings' not in config: config['Mappings'] = DEFAULT_MAPPINGS
if 'Settings' not in config: config['Settings'] = {}
return config
def save_config(config):
try:
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
config.write(f)
except: pass
def is_cloud_locked(filepath):
try:
with open(filepath, 'rb') as f:
return b"__CLOUDSYNC_ENC__" in f.read(1024)
except:
return False
def extract_imports_via_ast(script_path, python_exe):
code_snippet = (
"import ast, sys, json, re\n"
"try:\n"
" with open(sys.argv[1], 'rb') as f: raw = f.read()\n"
" code = raw.decode('utf-8-sig') if raw.startswith(b'\\xef\\xbb\\xbf') else raw.decode('utf-8', errors='ignore')\n"
" imports = set()\n"
" for node in ast.walk(ast.parse(code)):\n"
" if isinstance(node, ast.Import): imports.update(a.name.split('.')[0] for a in node.names)\n"
" elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: imports.add(node.module.split('.')[0])\n"
" print('__QPYPACK_RES__:' + json.dumps(list(imports)))\n"
"except:\n"
" try:\n"
" m = re.findall(r'^\\s*(?:from|import)\\s+([a-zA-Z0-9_]+)', code, re.M)\n"
" print('__QPYPACK_RES__:' + json.dumps(list(set(m))))\n"
" except: print('__QPYPACK_RES__:[]')\n"
)
try:
env = os.environ.copy()
env.pop("PYTHONHOME", None)
env.pop("PYTHONPATH", None)
flags = getattr(subprocess, 'CREATE_NO_WINDOW', 0)
proc = subprocess.run([python_exe, "-c", code_snippet, script_path],
capture_output=True, text=True, env=env, creationflags=flags)
m = re.search(r'__QPYPACK_RES__:(.*)', proc.stdout)
if m:
return set(json.loads(m.group(1).strip()))
return set()
except:
return set()
def get_svg_icon(name, color="#5F6368", size=24):
path_data = MATERIAL_ICONS.get(name, "")
svg_str = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="{size}" height="{size}"><path fill="{color}" d="{path_data}"/></svg>'
@@ -70,23 +146,30 @@ def get_svg_icon(name, color="#5F6368", size=24):
def get_svg_pixmap(name, color="#5F6368", size=64):
return get_svg_icon(name, color, size).pixmap(size, size)
def get_stdlib_names():
libs = {'os', 'sys', 're', 'math', 'time', 'datetime', 'json', 'urllib', 'sqlite3', 'csv',
'subprocess', 'shutil', 'threading', 'multiprocessing', 'queue', 'socket',
'collections', 'itertools', 'functools', 'random', 'hashlib', 'base64',
'binascii', 'xml', 'logging', 'argparse', 'typing', 'pathlib', 'traceback',
'warnings', 'tempfile', 'platform', 'zipfile', 'tarfile', 'gzip', 'bz2',
'lzma', 'hmac', 'ssl', 'email', 'http', 'uuid', 'io', 'contextlib', 'winreg'}
'lzma', 'hmac', 'ssl', 'email', 'http', 'uuid', 'io', 'contextlib', 'winreg',
'concurrent', 'ctypes', 'dataclasses', 'enum', 'importlib', 'inspect',
'pickle', 'copy', 'ast', 'asyncio', 'calendar', 'configparser',
'curses', 'decimal', 'difflib', 'getopt', 'getpass', 'glob', 'html',
'mimetypes', 'numbers', 'operator', 'pdb', 'pprint', 'profile', 'pstats',
'runpy', 'sched', 'secrets', 'selectors', 'shelve', 'shlex',
'signal', 'site', 'smtpd', 'smtplib', 'sndhdr', 'socketserver',
'stat', 'statistics', 'string', 'struct', 'symtable', 'sysconfig',
'syslog', 'tabnanny', 'telnetlib', 'termios', 'future',
'textwrap', 'timeit', 'tkinter', 'token', 'tokenize', 'trace',
'tracemalloc', 'tty', 'turtle', 'turtledemo', 'types',
'unittest', 'uu', 'venv', 'wave', '__future__',
'weakref', 'webbrowser', 'winsound', 'wsgiref', 'xdrlib',
'xmlrpc', 'zipapp', 'zipimport', 'zlib', 'zoneinfo'}
if sys.version_info >= (3, 10): libs.update(sys.stdlib_module_names)
return libs
STD_LIBS = get_stdlib_names()
KNOWN_MAPPINGS = {
'win32com': 'pywin32', 'win32api': 'pywin32', 'win32con': 'pywin32', 'win32gui': 'pywin32',
'cv2': 'opencv-python', 'PIL': 'pillow', 'Pillow': 'pillow', 'bs4': 'beautifulsoup4', 'sklearn': 'scikit-learn',
'yaml': 'pyyaml', 'fitz': 'pymupdf', 'dotenv': 'python-dotenv'
}
def get_resource_path(relative_path):
if hasattr(sys, '_MEIPASS'):
@@ -98,7 +181,6 @@ def get_resource_path(relative_path):
return os.path.join(base_path, relative_path)
def find_system_python():
"""全方位、深层次、抗毒化的核心级 Python 环境嗅探探针"""
candidates = []
for name in ('python', 'python3', 'pythonw'):
@@ -472,24 +554,32 @@ class DropArea(QFrame):
layout.addSpacing(18)
self.label = QLabel("将脚本(.py)拖拽至此处\n或 点击浏览")
self.label = QLabel("将 Python 脚本 (.py/.pyw) 拖拽至此处\n或 点击浏览文件")
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.label.setStyleSheet("QLabel { background: transparent; color: #5F6368; font-size: 16px; font-weight: bold; border: none; }")
layout.addWidget(self.label)
layout.addSpacing(8)
self.sub_label = QLabel("智能解析工程依赖、附件及隐藏模块")
self.sub_label = QLabel("智能解析工程依赖、静态附件及隐藏模块树")
self.sub_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.sub_label.setStyleSheet("QLabel { background: transparent; color: #9AA0A6; font-size: 13px; border: none; }")
layout.addWidget(self.sub_label)
layout.addStretch(1)
def reset(self):
self.icon_widget.reset()
self.label.setText("将 Python 脚本 (.py/.pyw) 拖拽至此处\n或 点击浏览文件")
self.label.setStyleSheet("QLabel { background: transparent; color: #5F6368; font-size: 16px; font-weight: bold; border: none; }")
self.sub_label.setText("智能解析工程依赖、静态附件及隐藏模块树")
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls() and event.mimeData().urls()[0].toLocalFile().lower().endswith('.py'):
event.acceptProposedAction()
self.setStyleSheet("#DropArea { background-color: #E8F0FE; border: 2px dashed #1A73E8; border-radius: 12px; }")
if event.mimeData().hasUrls():
file_path = event.mimeData().urls()[0].toLocalFile().lower()
if file_path.endswith('.py') or file_path.endswith('.pyw'):
event.acceptProposedAction()
self.setStyleSheet("#DropArea { background-color: #E8F0FE; border: 2px dashed #1A73E8; border-radius: 12px; }")
def dragLeaveEvent(self, event):
self.setStyleSheet("#DropArea { background-color: #f8f9fa; border: 2px dashed #dadce0; border-radius: 12px; } #DropArea:hover { background-color: #f1f3f4; border: 2px dashed #bdc1c6; }")
@@ -497,12 +587,19 @@ class DropArea(QFrame):
def dropEvent(self, event: QDropEvent):
self.dragLeaveEvent(event)
urls = event.mimeData().urls()
if urls and urls[0].toLocalFile().lower().endswith('.py'):
self.fileDropped.emit(urls[0].toLocalFile())
if urls:
file_path = urls[0].toLocalFile()
if file_path.lower().endswith(('.py', '.pyw')):
self.fileDropped.emit(file_path)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
fp, _ = QFileDialog.getOpenFileName(self, "选择 Python 源代码文件", "", "Python Scripts (*.py)")
fp, _ = QFileDialog.getOpenFileName(
self,
"选择 Python 源代码文件",
"",
"Python Scripts (*.py *.pyw);;All Files (*)"
)
if fp: self.fileDropped.emit(fp)
def set_success(self, filename, custom_icon_path=None):
@@ -517,12 +614,12 @@ class DropArea(QFrame):
self.icon_widget.set_file_pixmap(pixmap, 88)
self.label.setText(f"已加载:{filename}")
self.label.setText(f"文件已加载:{filename}")
self.label.setStyleSheet("QLabel { background: transparent; color: #1A73E8; font-size: 16px; font-weight: bold; border: none; }")
self.sub_label.setText("准备就绪,随时可开始构建")
self.sub_label.setText("系统准备就绪,随时可启动构建引擎")
def start_build_anim(self):
self.sub_label.setText("正在执行模块提取与打包...")
self.sub_label.setText("构建引擎运行中:正在执行模块分析与环境封装...")
self.icon_widget.start_building()
def stop_build_anim(self):
@@ -545,24 +642,18 @@ class DropArea(QFrame):
self.icon_widget.start_success()
self.label.setText("构建完成!")
self.label.setText("构建任务圆满完成!")
self.label.setStyleSheet("QLabel { background: transparent; color: #1E8E3E; font-size: 20px; font-weight: bold; border: none; }")
self.sub_label.setText("可打开目录查看或重置工作区")
self.sub_label.setText("您可以打开输出目录查看产物,或重置工作区")
def show_failure(self):
size = 128
self.icon_widget.set_custom_pixmap(get_svg_pixmap('close', color="#D93025", size=size), size)
self.icon_widget.start_failure()
self.label.setText("构建失败!")
self.label.setText("构建任务异常中断!")
self.label.setStyleSheet("QLabel { background: transparent; color: #D93025; font-size: 20px; font-weight: bold; border: none; }")
self.sub_label.setText("请检查下方的日志诊断报告")
def reset(self):
self.icon_widget.reset()
self.label.setText("将脚本(.py)拖拽至此处\n或 点击浏览")
self.label.setStyleSheet("QLabel { background: transparent; color: #5F6368; font-size: 16px; font-weight: bold; border: none; }")
self.sub_label.setText("智能解析工程依赖、附件及隐藏模块")
self.sub_label.setText("请查阅下方的执行日志报告以进行问题排查")
# ======================== 设置面板 ========================
@@ -589,6 +680,7 @@ class SettingsPanel(QWidget):
QTabBar::tab:hover:!selected { background: #e8eaed; }
""")
self.init_ui()
self.load_from_config()
def init_ui(self):
layout = QVBoxLayout(self)
@@ -629,7 +721,7 @@ class SettingsPanel(QWidget):
self.btn_save.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.btn_save.setIcon(get_svg_icon('check', "white"))
self.btn_save.setStyleSheet(self.parent_win.primary_btn_style)
self.btn_save.clicked.connect(self.parent_win.show_main)
self.btn_save.clicked.connect(self.parent_win.save_settings_and_return)
btn_lay.addWidget(self.btn_save)
self.btn_back = AnimatedButton("")
@@ -642,6 +734,37 @@ class SettingsPanel(QWidget):
layout.addLayout(btn_lay)
def load_from_config(self):
config = load_config()
if 'Settings' in config:
s = config['Settings']
self.engine_combo.setCurrentText(s.get('engine', 'PyInstaller'))
self.onefile_check.setChecked(s.getboolean('onefile', True))
self.noconsole_check.setChecked(s.getboolean('noconsole', True))
self.clean_all_check.setChecked(s.getboolean('clean_all', True))
self.auto_icon_check.setChecked(s.getboolean('auto_icon', True))
self.pip_source_edit.setText(s.get('pip_index', 'https://pypi.tuna.tsinghua.edu.cn/simple'))
self.venv_check.setChecked(s.getboolean('use_venv', True))
self.reqs_check.setChecked(s.getboolean('use_reqs', True))
self.pipreqs_check.setChecked(s.getboolean('use_pipreqs', True))
self.upx_check.setChecked(s.getboolean('upx', False))
def save_to_config(self):
config = load_config()
if 'Settings' not in config: config['Settings'] = {}
s = config['Settings']
s['engine'] = self.engine_combo.currentText()
s['onefile'] = str(self.onefile_check.isChecked())
s['noconsole'] = str(self.noconsole_check.isChecked())
s['clean_all'] = str(self.clean_all_check.isChecked())
s['auto_icon'] = str(self.auto_icon_check.isChecked())
s['pip_index'] = self.pip_source_edit.text().strip()
s['use_venv'] = str(self.venv_check.isChecked())
s['use_reqs'] = str(self.reqs_check.isChecked())
s['use_pipreqs'] = str(self.pipreqs_check.isChecked())
s['upx'] = str(self.upx_check.isChecked())
save_config(config)
def build_basic_tab(self):
scroll = QScrollArea()
scroll.setWidgetResizable(True)
@@ -689,13 +812,9 @@ class SettingsPanel(QWidget):
grid_mode.setVerticalSpacing(15)
self.onefile_check = QCheckBox("单文件模式 (OneFile)")
self.onefile_check.setChecked(True)
self.noconsole_check = QCheckBox("隐藏控制台 (GUI模式)")
self.noconsole_check.setChecked(True)
self.clean_all_check = QCheckBox("编译后自动清理缓存")
self.clean_all_check.setChecked(True)
self.auto_icon_check = QCheckBox("自动匹配同目录图标")
self.auto_icon_check.setChecked(True)
grid_mode.addWidget(self.onefile_check, 0, 0)
grid_mode.addWidget(self.noconsole_check, 0, 1)
@@ -726,11 +845,10 @@ class SettingsPanel(QWidget):
form_env.setContentsMargins(15, 20, 15, 15)
form_env.setVerticalSpacing(12)
self.pip_source_edit = QLineEdit("https://pypi.tuna.tsinghua.edu.cn/simple")
self.pip_source_edit = QLineEdit()
form_env.addRow("PIP 镜像:", self.pip_source_edit)
self.venv_check = QCheckBox("启用独立打包环境")
self.venv_check.setChecked(True)
form_env.addRow("", self.venv_check)
lay.addWidget(grp_env)
@@ -741,9 +859,7 @@ class SettingsPanel(QWidget):
grid_dep = QGridLayout()
self.reqs_check = QCheckBox("优先读取 requirements.txt")
self.reqs_check.setChecked(True)
self.pipreqs_check = QCheckBox("启用 pipreqs 深度代码分析")
self.pipreqs_check.setChecked(True)
grid_dep.addWidget(self.reqs_check, 0, 0)
grid_dep.addWidget(self.pipreqs_check, 0, 1)
dep_lay.addLayout(grid_dep)
@@ -859,28 +975,18 @@ class SettingsPanel(QWidget):
def auto_scan_hidden(self):
script_path = self.parent_win.script_path
if not script_path: return QMessageBox.warning(self, "提示", "请先在主界面加载脚本!")
try:
raw = Path(script_path).read_bytes()
if b"__CLOUDSYNC_ENC__" in raw[:1024]: return QMessageBox.warning(self, "错误", "脚本被云盘加密,请解密!")
try: code = raw.decode('utf-8-sig')
except: code = raw.decode('gbk', errors='ignore')
if not script_path: return QMessageBox.warning(self, "构建约束", "请先加载有效的 Python 源代码文件!")
if is_cloud_locked(script_path):
return QMessageBox.warning(self, "I/O 错误", "目标脚本处于云盘加密或锁定状态,请解密后重试。")
hidden = set()
try:
tree = ast.parse(code)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names: hidden.add(alias.name.split('.')[0])
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
hidden.add(node.module.split('.')[0])
except:
hidden = set(re.findall(r'^\s*(?:from|import)\s+([a-zA-Z0-9_]+)', code, re.M))
try:
python_exe = get_python_executable()
hidden = extract_imports_via_ast(script_path, python_exe)
hidden = [m for m in hidden if m not in STD_LIBS]
self.hidden_edit.setText(','.join(hidden))
QMessageBox.information(self, "扫描完成", f"捕获到 {len(hidden)} 项依赖。")
except Exception as e: QMessageBox.warning(self, "错误", f"扫描失败: {e}")
QMessageBox.information(self, "AST 分析完成", f"语法树解析成功,共定位到 {len(hidden)} 项非标准库依赖。")
except Exception as e:
QMessageBox.warning(self, "分析异常", f"AST 语法树解析过程中发生异常: {e}")
def add_resource(self):
choice = QMessageBox.question(self, "添加资源", "Yes=文件, No=文件夹", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
@@ -959,40 +1065,45 @@ class PackingThread(QThread):
timer = threading.Timer(timeout, kill_proc)
timer.start()
buffer = []
last_emit = time.time()
for line in self.process.stdout:
if self._is_cancelled:
self.process.terminate()
return False
self.progress.emit(line.strip())
buffer.append(line.strip())
if len(buffer) >= 15 or (time.time() - last_emit) > 0.1:
self.progress.emit('\n'.join(buffer))
buffer.clear()
last_emit = time.time()
if buffer:
self.progress.emit('\n'.join(buffer))
self.process.wait()
if is_timeout[0]:
self.progress.emit(f"[提示] 该步骤执行超时 ({timeout}秒),已自动跳过并进入下一步...")
self.progress.emit(f"[System] 子进程执行超时 (>{timeout}s),已自动中断并继续执行后续流水线...")
return False
return self.process.returncode == 0
except FileNotFoundError as e:
cmd_name = cmd[0] if isinstance(cmd, list) and cmd else str(cmd)
if "python" in str(cmd_name).lower():
self.progress.emit(f"[环境缺失] 无法找到系统宿主 Python。请确保您已正确安装 Python 并添加至系统 PATH。")
self.progress.emit(f"[Error] 宿主环境异常:无法定位 Python 解释器,请检查系统环境变量配置。")
else:
self.progress.emit(f"[系统异常] 找不到可执行程序: {cmd_name}。详细报错: {e}")
self.progress.emit(f"[Error] 进程调用失败:缺失系统指令或程序 \"{cmd_name}\" ({e})")
return False
except Exception as e:
self.progress.emit(f"[系统异常] 进程执行失败: {e}")
self.progress.emit(f"[Error] 子进程执行发生系统级异常: {e}")
return False
finally:
if timer:
timer.cancel()
def sanitize_script(self, orig_path: Path):
try:
raw = orig_path.read_bytes()
if b"__CLOUDSYNC_ENC__" in raw[:1024]:
return None, False, "目标脚本被云盘锁定加密!"
except Exception:
pass
if is_cloud_locked(orig_path):
return None, False, "目标脚本处于云盘加密或锁定状态,请解密后重试。"
return orig_path, False, ""
def run(self):
@@ -1004,7 +1115,7 @@ class PackingThread(QThread):
ext = ".exe" if os.name == "nt" else ""
try:
self.progress.emit("[清理] 初始化构建空间...")
self.progress.emit("[Init] 正在初始化工作区并清理历史残留数据...")
robust_rmtree(Path.cwd() / "build")
robust_rmtree(Path.cwd() / "dist")
@@ -1012,124 +1123,98 @@ class PackingThread(QThread):
script_dir = script_path.parent
build_script_path, is_temp, err_msg = self.sanitize_script(script_path)
if not build_script_path and err_msg: return self.finished.emit(False, f"[安全拦截] {err_msg}")
if not build_script_path and err_msg: return self.finished.emit(False, f"[Error] I/O 异常: {err_msg}")
script_posix = build_script_path.as_posix()
script_imports = set()
required_pkgs_normalized = set()
try:
code_text = build_script_path.read_text(encoding='utf-8', errors='ignore')
try:
for node in ast.walk(ast.parse(code_text)):
if isinstance(node, ast.Import):
for alias in node.names: script_imports.add(alias.name.split('.')[0])
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
script_imports.add(node.module.split('.')[0])
except:
script_imports = set(re.findall(r'^\s*(?:from|import)\s+([a-zA-Z0-9_]+)', code_text, re.M))
required_pkgs_normalized = {KNOWN_MAPPINGS.get(m, m).lower().replace('_', '-') for m in script_imports if m not in STD_LIBS}
except Exception as e:
self.progress.emit(f"[依赖预检] 解析依赖失败: {e}")
system_python_exe = get_python_executable()
self.progress.emit(f"[环境] Python: {system_python_exe}")
self.progress.emit(f"[Env] 宿主解释器路径: {system_python_exe}")
script_imports = set()
try:
script_imports = extract_imports_via_ast(script_posix, system_python_exe)
except Exception as e:
self.progress.emit(f"[Warn] 源码 AST 预处理异常: {e}")
pip_args = ["-i", pip_idx] if pip_idx else []
if self.params['use_venv']:
self.progress.emit("[环境] 初始化独立打包环境...")
self.progress.emit("[Env] 正在分配独立隔离的虚拟环境 (Virtual Environment)...")
self.venv_dir = Path(tempfile.mkdtemp(prefix="qpypack_env_")).resolve()
if not self.run_cmd([system_python_exe, "-m", "venv", self.venv_dir.as_posix()]):
return self.finished.emit(False, "虚拟环境初始化失败。这通常是因为您的 Python 环境被精简或已损坏。")
return self.finished.emit(False, "[Error] 虚拟环境(venv)创建失败。宿主 Python 环境可能存在内核文件缺失或权限受限。")
python_exe = (self.venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python")).as_posix()
self.progress.emit("[Env] 正在同步并升级 Pip 构建工具链...")
self.run_cmd([python_exe, "-m", "pip", "install", "--upgrade", "pip", "-q"] + pip_args)
else:
python_exe = system_python_exe
pip_args = ["-i", pip_idx] if pip_idx else []
engine_pkg = "nuitka" if engine == "Nuitka" else "pyinstaller"
self.progress.emit(f"[环境] 下载核心组件...")
self.progress.emit(f"[Env] 正在配置基础构建引擎 ({engine_pkg} & 核心支持库)...")
core_pkgs = [engine_pkg]
if engine == "PyInstaller":
core_pkgs.append("pillow")
elif engine == "Nuitka":
core_pkgs.append("zstandard")
self.run_cmd([python_exe, "-m", "pip", "install", "-q"] + core_pkgs + pip_args)
req_success = False
if self.params.get('use_reqs'):
req_file = script_dir / "requirements.txt"
if req_file.exists():
self.progress.emit("[依赖] 部署 requirements.txt...")
self.progress.emit("[Deps] 阶段 1/3: 正在读取并部署清单依赖 (requirements.txt)...")
try:
if is_cloud_locked(req_file): raise ValueError("清单文件被安全锁定")
raw_req = req_file.read_bytes()
if b"__CLOUDSYNC_ENC__" in raw_req[:1024]: raise ValueError("文件被锁定")
try: req_content = raw_req.decode('utf-8-sig')
except: req_content = raw_req.decode(locale.getpreferredencoding(), errors='ignore')
temp_req = script_dir / "pypack_temp_reqs.txt"
temp_req = Path(tempfile.gettempdir()) / f"qpypack_temp_reqs_{int(time.time())}.txt"
temp_req.write_text(req_content, encoding='utf-8')
if self.run_cmd([python_exe, "-m", "pip", "install", "-q", "-r", temp_req.as_posix()] + pip_args):
req_success = True
self.run_cmd([python_exe, "-m", "pip", "install", "-q", "-r", temp_req.as_posix()] + pip_args)
temp_req.unlink(missing_ok=True)
except Exception as e: self.progress.emit(f"[警告] {e}")
except Exception as e: self.progress.emit(f"[Warn] 清单解析或网络请求异常: {e}")
if self.params.get('use_pipreqs') and not req_success:
self.progress.emit("[依赖] 执行 pipreqs 深度推导...")
if self.params.get('use_pipreqs'):
self.progress.emit("[Deps] 阶段 2/3: 正在启动深度依赖分析 (Pipreqs)...")
self.run_cmd([python_exe, "-m", "pip", "install", "pipreqs", "-q"] + pip_args)
temp_pipreqs = script_dir / "pypack_pipreqs_out.txt"
temp_pipreqs = Path(tempfile.gettempdir()) / f"qpypack_pipreqs_{int(time.time())}.txt"
pypi_server = None
if pip_idx:
pypi_server = re.sub(r'/simple/?$', '/pypi', pip_idx.strip(), flags=re.I).rstrip('/')
pipreqs_cmd = [
python_exe, "-m", "pipreqs.pipreqs",
script_dir.as_posix(),
"--encoding", "utf-8",
"--force",
"--savepath", temp_pipreqs.as_posix()
python_exe, "-m", "pipreqs.pipreqs", script_dir.as_posix(),
"--encoding", "utf-8", "--force", "--savepath", temp_pipreqs.as_posix()
]
if pypi_server:
if pypi_server:
pipreqs_cmd.extend(["--pypi-server", pypi_server])
self.progress.emit(f"[依赖] pipreqs 已重定向至本地镜像接口: {pypi_server}")
self.progress.emit(f"[Deps] 依赖分析镜像地址: {pypi_server}")
if self.run_cmd(pipreqs_cmd, timeout=15):
if temp_pipreqs.exists():
pipreqs_incomplete = False
try:
pipreqs_text = temp_pipreqs.read_text(encoding='utf-8', errors='ignore').lower().replace('_', '-')
for pkg in required_pkgs_normalized:
if not re.search(r'\b' + re.escape(pkg) + r'\b', pipreqs_text):
pipreqs_incomplete = True
self.progress.emit(f"[警告] pipreqs 未能成功匹配装载该模块: {pkg}")
except:
pipreqs_incomplete = True
if self.run_cmd(pipreqs_cmd, timeout=15) and temp_pipreqs.exists():
self.run_cmd([python_exe, "-m", "pip", "install", "-q", "-r", temp_pipreqs.as_posix()] + pip_args)
temp_pipreqs.unlink(missing_ok=True)
if self.run_cmd([python_exe, "-m", "pip", "install", "-q", "-r", temp_pipreqs.as_posix()] + pip_args):
if not pipreqs_incomplete:
req_success = True
else:
self.progress.emit("[依赖] 检测到部分核心模块漏报,准备进入本地解析...")
temp_pipreqs.unlink(missing_ok=True)
else:
self.progress.emit("[提示] pipreqs 深度解析超时或失败...")
config = load_config()
known_mappings = DEFAULT_MAPPINGS.copy()
if 'Mappings' in config:
for k, v in config['Mappings'].items():
known_mappings[k] = v
ast_pkgs = [known_mappings.get(m, m) for m in script_imports if m not in STD_LIBS]
if ast_pkgs:
self.progress.emit(f"[Deps] 阶段 3/3: 启动 AST 源码扫描...")
self.progress.emit(f"==> [Deps] 正在对齐隐式依赖项: {', '.join(ast_pkgs)}")
if not self.run_cmd([python_exe, "-m", "pip", "install", "-q"] + ast_pkgs + pip_args):
self.progress.emit("[Warn] 批量依赖同步存在冲突,正在降级为逐项安全安装模式...")
for pkg in ast_pkgs:
self.run_cmd([python_exe, "-m", "pip", "install", "-q", pkg] + pip_args)
if not req_success and (self.params.get('use_reqs') or self.params.get('use_pipreqs')):
self.progress.emit("[依赖] 正在启用 AST 本地语法树解析...")
try:
pkgs_to_install = [KNOWN_MAPPINGS.get(m, m) for m in script_imports if m not in STD_LIBS]
if pkgs_to_install:
self.progress.emit(f"[依赖] 同步外部模块: {', '.join(pkgs_to_install)}")
if not self.run_cmd([python_exe, "-m", "pip", "install", "-q"] + pkgs_to_install + pip_args):
self.progress.emit("[警告] 部分外部依赖在沙盒中安装失败,这可能导致最终打包的 EXE 运行报错!")
except Exception as e: self.progress.emit(f"[异常] {e}")
if self._is_cancelled: return self.finished.emit(False, "[System] 构建任务已被用户主动终止。")
if self._is_cancelled: return self.finished.emit(False, "构建已被中断。")
self.progress.emit(f"[编译] 启动 {engine} 封装引擎...")
self.progress.emit(f"[Build] 正在初始化 {engine} 编译引擎,执行可执行程序生成任务...")
cmd = []
app_name = self.params['app_name']
icon_path = Path(self.params['icon']).resolve().as_posix() if self.params.get('icon') else None
@@ -1214,9 +1299,9 @@ class PackingThread(QThread):
cmd.append(script_posix)
success = self.run_cmd(cmd, cwd=Path.cwd().resolve().as_posix())
if self._is_cancelled: return self.finished.emit(False, "构建已被中断。")
if self._is_cancelled: return self.finished.emit(False, "[System] 构建任务已被用户主动终止。")
self.progress.emit("[收尾] 归档可执行文件...")
self.progress.emit("[Pack] 编译阶段完成,正在提取并归档最终可执行产物...")
cwd = Path.cwd().resolve()
if engine == "PyInstaller": src_out = cwd / "dist" / (f"{app_name}{ext}" if self.params['onefile'] else app_name)
elif engine == "Nuitka": src_out = self.temp_out_dir / (f"{app_name}{ext}" if self.params['onefile'] else f"{app_name}.dist")
@@ -1228,23 +1313,23 @@ class PackingThread(QThread):
if final_out.is_dir(): shutil.rmtree(final_out, ignore_errors=True)
else: final_out.unlink(missing_ok=True)
shutil.move(src_out.as_posix(), final_out.as_posix())
except Exception as e: self.progress.emit(f"[归档异常] 程序被占用: {e}")
else: self.progress.emit(f"[错误] 未找到程序: {src_out}")
except Exception as e: self.progress.emit(f"[Error] 产物转移异常,文件可能被系统防御机制拦截或占用: {e}")
else: self.progress.emit(f"[Error] 构建目标缺失,未能在沙盒中定位到有效产物: {src_out}")
if success and final_out.exists():
self.progress.emit("[收尾] 执行收尾指令...")
self.finished.emit(True, f"[构建成功] 程序路径: {final_out.resolve().as_posix()}")
self.progress.emit("[Pack] 正在校验最终程序完整性...")
self.finished.emit(True, f"[Success] 构建结束!\n==> 输出路径: {final_out.resolve().as_posix()}")
else:
self.finished.emit(False, "[构建失败] 请检查日志。")
self.finished.emit(False, "[Failed] 构建任务异常中断,请参阅上方运行日志以排查错误。")
except Exception as e:
self.finished.emit(False, f"[系统异常] {str(e)}")
self.finished.emit(False, f"[System Error] 发生未处理的严重错误: {str(e)}")
finally:
if is_temp and build_script_path and build_script_path.exists():
try: build_script_path.unlink()
except: pass
if self.params['clean_all']:
self.progress.emit("[清理] 抹除环境与沙盒...")
self.progress.emit("[Clean] 正在释放工作区,抹除虚拟环境与沙盒临时数据...")
for p in [self.venv_dir, self.temp_workpath, self.temp_out_dir]:
if p and p.exists(): robust_rmtree(p)
@@ -1433,6 +1518,10 @@ class MainWindow(QMainWindow):
def show_main(self):
self._animate_switch(self.main_panel)
def save_settings_and_return(self):
self.settings_panel.save_to_config()
self.show_main()
def _animate_switch(self, target_widget):
self.anim = QPropertyAnimation(self.stacked_layout.currentWidget(), b"geometry")
self.anim.setDuration(250)
@@ -1444,11 +1533,10 @@ class MainWindow(QMainWindow):
def on_script_selected(self, path):
path = Path(path).resolve().as_posix()
try:
if b"__CLOUDSYNC_ENC__" in Path(path).read_bytes()[:1024]:
QMessageBox.critical(self, "加载失败", "脚本已被锁定!")
return
except: pass
if is_cloud_locked(path):
QMessageBox.critical(self, "加载失败", "目标脚本处于云盘加密或锁定状态,请解密后重试。")
return
self.script_path = path
@@ -1458,7 +1546,8 @@ class MainWindow(QMainWindow):
desc = "Python Executable"
try:
content = Path(path).read_text(encoding='utf-8', errors='ignore')
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read(10240)
v_match = re.search(r'^(?:__version__|VERSION|version)\s*=\s*[\'"]([^\'"]+)[\'"]', content, re.M | re.I)
if v_match:
@@ -1505,19 +1594,19 @@ class MainWindow(QMainWindow):
if not self.log_container.isVisible(): self.toggle_log()
self.log.clear()
self.append_log(f"已识别脚本: {path}")
self.append_log(f"[System] 源文件绑定成功: {path}")
self.update_ui_state("ready")
def cancel_pack(self):
if self.thread and self.thread.isRunning():
self.thread.cancel()
self.status_label.setText(" 状态: 已被用户终止")
self.status_label.setText(" 状态: 任务已被用户主动终止")
self.drop_area.stop_build_anim()
self.update_ui_state("ready")
def start_pack(self):
if not self.script_path or not Path(self.script_path).exists():
QMessageBox.warning(self, "提示", "请先加载需要打包的 Python 脚本!")
QMessageBox.warning(self, "构建约束", "请先加载有效的 Python 源代码文件!")
return
sp = self.settings_panel