Files
QPyPack/qpypack/main.py
T

2769 lines
128 KiB
Python

import sys
import os
import shutil
import subprocess
import tempfile
import re
import time
import stat
import json
import locale
import math
import threading
import configparser
from pathlib import Path
if os.name == 'nt':
try:
import winreg
except ImportError:
winreg = None
else:
winreg = None
os.environ["QT_LOGGING_RULES"] = "qt.text.font.db=false"
os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1"
os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
from PySide6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget,
QPushButton, QLabel, QLineEdit, QFileDialog, QCheckBox,
QComboBox, QFrame, QStackedLayout, QFormLayout, QTextEdit,
QGraphicsOpacityEffect, QGroupBox, QGridLayout, QTabWidget,
QMessageBox, QInputDialog, QFileIconProvider, QSizePolicy, QScrollArea,
QGraphicsDropShadowEffect, QSpinBox, QListWidget, QListWidgetItem)
from PySide6.QtCore import Qt, QThread, Signal, QPropertyAnimation, QEasingCurve, QParallelAnimationGroup, QFileInfo, QVariantAnimation, QTimer, QPointF, QRectF, QRect
from PySide6.QtGui import QFont, QDragEnterEvent, QDropEvent, QTextCursor, QIcon, QPixmap, QPainter, QColor, QPen, QImage, QImageWriter
from PySide6.QtSvg import QSvgRenderer
__app_name__ = "QPyPack"
__version__ = "2.5.5"
__author__ = "QwejayHuang"
__company__ = "QwejayHuang"
__description__ = "基于 PyInstaller 与 Nuitka 的跨平台 Python 应用打包构建工具"
MATERIAL_ICONS = {
'settings': 'M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.73,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.43-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.49-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z',
'refresh': 'M17.65,6.35C16.2,4.9,14.21,4,12,4c-4.42,0-7.99,3.58-7.99,8s3.57,8,7.99,8c3.73,0,6.84-2.55,7.73-6h-2.08 c-0.82,2.33-3.04,4-5.65,4c-3.31,0-6-2.69-6-6s2.69-6,6-6c1.66,0,3.14,0.69,4.22,1.78L13,11h7V4L17.65,6.35z',
'play': 'M8 5v14l11-7z',
'stop': 'M6 6h12v12H6z',
'folder': 'M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z',
'expand_more': 'M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z',
'expand_less': 'M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z',
'check': 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z',
'package': 'M20,2H4C3,2,2,2.9,2,4v3.01C2,7.73,2.43,8.35,3,8.7V20c0,1.1,1.1,2,2,2h14c0.9,0,2-0.9,2-2V8.7c0.57-0.35,1-0.97,1-1.69V4 C22,2.9,21,2,20,2z M19,20H5V9h14V20z M20,7H4V4h16V7z M9,12h6v2H9V12z',
'back': 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z',
'info': 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z',
'python': 'M12.06,1.48c-3.14,0-3.52,0.67-3.52,0.67l-0.01,2.44h3.63v0.52H7.43C5.12,5.11,4.5,6.58,4.5,8.81c0,2.34,0.38,3.48,2.3,3.48 h1.14v-1.62c0-1.48,1.23-2.65,2.7-2.65h3.69c1.47,0,2.66-1.19,2.66-2.65V3.88C16.99,1.83,14.67,1.48,12.06,1.48z M10.22,2.83 c0.41,0,0.73,0.33,0.73,0.74c0,0.41-0.33,0.74-0.73,0.74c-0.4,0-0.73-0.33-0.73-0.74C9.49,3.16,9.82,2.83,10.22,2.83z M16.71,9.89 v1.62c0,1.48-1.23,2.65-2.7,2.65H10.3c-1.47,0-2.66,1.19-2.66,2.65v1.49c0,2.05,2.32,2.41,4.92,2.41c3.14,0,3.52-0.67,3.52-0.67 l0.01-2.44h-3.63v-0.52h4.73c2.31,0,2.93-1.47,2.93-3.7c0-2.34-0.38-3.48-2.3-3.48H16.71z M13.88,18.96c0.41,0,0.73,0.33,0.73,0.74c0,0.41-0.33,0.74-0.73,0.74c-0.4,0-0.73-0.33-0.73-0.74C13.15,19.29,13.48,18.96,13.88,18.96z',
'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', 'concise_log': 'True',
'cpu_cores': str(os.cpu_count() or 2), 'upx_path': '',
'exclude_modules': '', 'out_mode': '0', 'custom_out_dir': '',
'sound_notify': 'True', 'auto_save_log': 'False',
'use_reqs_file': '', 'add_data_list': '', 'custom_python_path': '',
'pyi_version': '', 'nuitka_version': '', 'pipreqs_version': '',
'lite_mode': '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'] = {}
default_updates = {
'concise_log': 'True',
'cpu_cores': str(os.cpu_count() or 2),
'upx_path': '',
'exclude_modules': '',
'out_mode': '0',
'custom_out_dir': '',
'sound_notify': 'True',
'auto_save_log': 'False',
'use_reqs_file': '',
'add_data_list': '',
'custom_python_path': '',
'pyi_version': '',
'nuitka_version': '',
'pipreqs_version': '',
'lite_mode': 'False'
}
updated = False
for k, v in default_updates.items():
if k not in config['Settings']:
config['Settings'][k] = v
updated = True
if updated:
try: save_config(config)
except: pass
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 Exception:
return False
def extract_imports_via_ast(script_path, python_exe):
code_snippet = (
"import ast, sys, json, re\n"
"code = ''\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)
env["PYTHONUTF8"] = "1"
env["PYTHONIOENCODING"] = "utf-8"
kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, "encoding": "utf-8", "env": env}
if os.name == 'nt': kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
proc = subprocess.run([python_exe, "-c", code_snippet, script_path], **kwargs)
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"><path fill="{color}" d="{path_data}"/></svg>'
renderer = QSvgRenderer()
renderer.load(svg_str.encode('utf-8'))
render_size = max(size * 4, 128)
pixmap = QPixmap(render_size, render_size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
renderer.render(painter)
painter.end()
return QIcon(pixmap)
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',
'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()
def get_resource_path(relative_path):
if hasattr(sys, '_MEIPASS'):
base_path = sys._MEIPASS
elif '__compiled__' in globals():
base_path = os.path.dirname(os.path.abspath(__file__))
else:
base_path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_path, relative_path)
def find_system_python():
candidates = []
for name in ('python', 'python3', 'pythonw'):
p = shutil.which(name)
if p: candidates.append(p)
if winreg is not None:
try:
py = shutil.which("py")
if py:
clean_env = os.environ.copy()
clean_env.pop("PYTHONHOME", None)
clean_env.pop("PYTHONPATH", None)
clean_env["PYTHONUTF8"] = "1"
proc = subprocess.Popen(
[py, "-c", "import sys; print(sys.executable)"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
text=True, encoding="utf-8", env=clean_env, creationflags=subprocess.CREATE_NO_WINDOW
)
out, _ = proc.communicate(timeout=3)
if out and os.path.exists(out.strip()):
candidates.append(out.strip())
except: pass
try:
for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE):
try:
with winreg.OpenKey(hive, r"SOFTWARE\Python\PythonCore") as core_key:
for i in range(winreg.QueryInfoKey(core_key)[0]):
sub = winreg.EnumKey(core_key, i)
try:
with winreg.OpenKey(core_key, rf"{sub}\InstallPath") as pkey:
path = winreg.QueryValueEx(pkey, "")[0]
exe = os.path.join(path, "python.exe")
if os.path.exists(exe): candidates.append(exe)
except: pass
except: pass
except: pass
search_paths = [
os.environ.get("LOCALAPPDATA", "") + r"\Programs\Python",
os.environ.get("PROGRAMFILES", "") + r"\Python",
os.environ.get("PROGRAMFILES(X86)", "") + r"\Python",
r"C:\Python", r"C:\Program Files\Python", r"C:\Program Files (x86)\Python"
]
for base in search_paths:
if base and os.path.exists(base):
try:
for d in os.listdir(base):
if d.lower().startswith("python"):
exe = os.path.join(base, d, "python.exe")
if os.path.exists(exe): candidates.append(exe)
except: pass
user_profile = os.environ.get("USERPROFILE", "")
if user_profile:
for c_dir in ("miniconda3", "anaconda3", "Miniconda3", "Anaconda3"):
for base in (user_profile, "C:\\", "D:\\"):
exe = os.path.join(base, c_dir, "python.exe")
if os.path.exists(exe): candidates.append(exe)
else:
unix_bases = [
"/usr/bin", "/usr/local/bin", "/opt/homebrew/bin",
os.path.expanduser("~/.pyenv/shims"), os.path.expanduser("~/.local/bin")
]
user_profile = os.environ.get("HOME", "") or os.environ.get("USERPROFILE", "")
if user_profile:
for c_dir in ("miniconda3", "anaconda3", "mambaforge", ".conda"):
base_dir = os.path.join(user_profile, c_dir)
if os.path.exists(base_dir):
exe = os.path.join(base_dir, "bin", "python")
if os.path.exists(exe): unix_bases.append(os.path.join(base_dir, "bin"))
envs_dir = os.path.join(base_dir, "envs")
if os.path.exists(envs_dir):
try:
for env in os.listdir(envs_dir):
p_bin = os.path.join(envs_dir, env, "bin")
if os.path.exists(p_bin): unix_bases.append(p_bin)
except: pass
for base in unix_bases:
if os.path.exists(base):
try:
for f in os.listdir(base):
if f.startswith("python3") or f == "python":
exe = os.path.join(base, f)
if os.path.isfile(exe) and os.access(exe, os.X_OK):
candidates.append(exe)
except: pass
seen = set()
unique_candidates = []
for cand in candidates:
cand = os.path.normpath(cand)
if cand not in seen:
seen.add(cand)
unique_candidates.append(cand)
for cand in unique_candidates:
if not os.path.exists(cand): continue
if os.name == 'nt' and "WindowsApps" in cand:
try:
if os.path.getsize(cand) == 0: continue
except: continue
clean_env = os.environ.copy()
clean_env.pop("PYTHONHOME", None)
clean_env.pop("PYTHONPATH", None)
try:
kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, "encoding": "utf-8", "env": clean_env, "timeout": 3}
if os.name == 'nt': kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
proc = subprocess.run([cand, "-V"], **kwargs)
if proc.returncode == 0:
return cand
except:
continue
return "python"
def get_python_executable():
try:
config = load_config()
custom_path = config['Settings'].get('custom_python_path', '').strip()
if custom_path and os.path.exists(custom_path) and os.path.isfile(custom_path):
return custom_path
except Exception:
pass
if getattr(sys, 'frozen', False) or '__compiled__' in globals():
return find_system_python()
exe_name = Path(sys.executable).name.lower()
if exe_name in ('python.exe', 'python3.exe', 'pythonw.exe', 'python', 'python3'):
return sys.executable
return find_system_python()
def remove_readonly(func, path, exc_info):
try: os.chmod(path, stat.S_IWRITE); func(path)
except: pass
def robust_rmtree(path: Path, retries=15, delay=0.8):
if not path.exists(): return True
for _ in range(retries):
try:
shutil.rmtree(path, onerror=remove_readonly)
if not path.exists(): return True
except: time.sleep(delay)
return False
def parse_add_data(add_data_str):
datas = []
for d in add_data_str.split(','):
d = d.strip()
if not d: continue
parts = d.rsplit(':', 1)
if len(parts) == 2 and not (os.name == 'nt' and len(parts[0]) == 1):
src_path = parts[0].strip()
dst_path = parts[1].strip()
else:
src_path = d.strip()
dst_path = "."
datas.append((src_path, dst_path))
return datas
def play_alert(success=True):
try:
if os.name == 'nt':
import winsound
winsound.MessageBeep(winsound.MB_OK if success else winsound.MB_ICONHAND)
else:
sys.stdout.write('\a')
sys.stdout.flush()
except:
pass
def convert_image_to_format(src_path, dest_path, dest_format):
src = Path(src_path).resolve()
dst = Path(dest_path).resolve()
fmt = dest_format.lower()
if sys.platform == "darwin" and fmt == "icns":
try:
proc = subprocess.run(
["sips", "-s", "format", "icns", src.as_posix(), "--out", dst.as_posix()],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
if proc.returncode == 0 and dst.exists():
return True
except:
pass
try:
img = QImage(src.as_posix())
if not img.isNull():
if fmt == "ico":
img = img.scaled(256, 256, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
elif fmt == "icns":
img = img.scaled(512, 512, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
writer = QImageWriter(dst.as_posix(), fmt.upper().encode('utf-8'))
if writer.write(img):
return True
except:
pass
try:
from PIL import Image
img = Image.open(src.as_posix())
if fmt == "ico":
img.save(dst.as_posix(), format="ICO", sizes=[(256, 256), (128, 128), (64, 64), (32, 32), (16, 16)])
return True
elif fmt == "icns":
img.save(dst.as_posix(), format="ICNS", sizes=[(512, 512), (256, 256), (128, 128), (64, 64)])
return True
else:
img.save(dst.as_posix(), format=dest_format.upper())
return True
except:
pass
return False
class AnimatedButton(QPushButton):
def __init__(self, text, parent=None):
super().__init__(text, parent)
self.opacity_effect = QGraphicsOpacityEffect(self)
self.setGraphicsEffect(self.opacity_effect)
self.opacity_effect.setOpacity(1.0)
self.animation_group = QParallelAnimationGroup()
self.pos_anim = QPropertyAnimation(self, b"geometry")
self.pos_anim.setDuration(150)
self.pos_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
self.op_anim = QPropertyAnimation(self.opacity_effect, b"opacity")
self.op_anim.setDuration(200)
self.op_anim.setEasingCurve(QEasingCurve.Type.InOutCubic)
self.is_hovered = False
def enterEvent(self, event):
if not self.is_hovered and self.isEnabled():
self.is_hovered = True
geom = self.geometry()
self.pos_anim.setStartValue(geom)
self.pos_anim.setEndValue(geom.adjusted(0, -2, 0, -2))
self.op_anim.setStartValue(1.0)
self.op_anim.setEndValue(0.85)
self.animation_group.start()
super().enterEvent(event)
def leaveEvent(self, event):
if self.is_hovered and self.isEnabled():
self.is_hovered = False
geom = self.geometry()
self.pos_anim.setStartValue(geom)
self.pos_anim.setEndValue(geom.adjusted(0, 2, 0, 2))
self.op_anim.setStartValue(0.85)
self.op_anim.setEndValue(1.0)
self.animation_group.start()
super().leaveEvent(event)
class TargetIconWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(200, 200)
self.pixmap = None
self.base_pixmap = None
self.file_pixmap = None
self.current_size = 88
self.is_building = False
self.spin_angle = 0
self.pulse_value = 0
self.anim_timer = QTimer(self)
self.anim_timer.setInterval(16)
self.anim_timer.timeout.connect(self._update_frame)
self.success_effect = QGraphicsDropShadowEffect(self)
self.success_effect.setOffset(0, 0)
self.success_effect.setColor(QColor(0, 0, 0, 0))
self.success_effect.setBlurRadius(0)
self.setGraphicsEffect(self.success_effect)
self.burst_value = 0.0
self.burst_anim = QVariantAnimation(self)
self.burst_anim.setDuration(600)
self.burst_anim.setLoopCount(1)
self.burst_anim.setStartValue(0.0)
self.burst_anim.setEndValue(1.0)
self.burst_anim.setEasingCurve(QEasingCurve.Type.OutQuad)
self.burst_anim.valueChanged.connect(self._animate_burst)
self.shake_offset = 0
self.shake_anim = QVariantAnimation(self)
self.shake_anim.setDuration(500)
self.shake_anim.setStartValue(0)
self.shake_anim.setEndValue(0)
self.shake_anim.setKeyValueAt(0.0, 0)
self.shake_anim.setKeyValueAt(0.1, -12)
self.shake_anim.setKeyValueAt(0.3, 12)
self.shake_anim.setKeyValueAt(0.5, -12)
self.shake_anim.setKeyValueAt(0.7, 12)
self.shake_anim.setKeyValueAt(0.9, -6)
self.shake_anim.setKeyValueAt(1.0, 0)
self.shake_anim.valueChanged.connect(self._animate_shake)
def set_default_pixmap(self, pixmap, size=88):
self.base_pixmap = pixmap
self.pixmap = pixmap
self.current_size = size
self.update()
def set_custom_pixmap(self, pixmap, size=88):
self.pixmap = pixmap
self.current_size = size
self.update()
def set_file_pixmap(self, pixmap, size=88):
self.file_pixmap = pixmap
self.pixmap = pixmap
self.current_size = size
self.update()
def start_building(self):
if getattr(self, 'file_pixmap', None) and not self.file_pixmap.isNull():
self.pixmap = self.file_pixmap
self.current_size = 88
self.is_building = True
self.spin_angle = 0
self.pulse_value = 0
self.burst_value = 0.0
self.shake_offset = 0
self.success_effect.setColor(QColor(0, 0, 0, 0))
self.burst_anim.stop()
self.shake_anim.stop()
self.anim_timer.start()
def stop_building(self):
self.is_building = False
self.anim_timer.stop()
self.update()
def start_success(self):
self.stop_building()
self.success_effect.setBlurRadius(40)
self.success_effect.setColor(QColor(255, 193, 7, 180))
self.burst_anim.start()
def start_failure(self):
self.stop_building()
self.success_effect.setBlurRadius(40)
self.success_effect.setColor(QColor(217, 48, 37, 180))
self.shake_anim.start()
def reset(self):
self.stop_building()
self.burst_anim.stop()
self.shake_anim.stop()
self.burst_value = 0.0
self.shake_offset = 0
self.success_effect.setColor(QColor(0, 0, 0, 0))
self.file_pixmap = None
self.pixmap = self.base_pixmap
self.current_size = 88
self.update()
def _update_frame(self):
self.spin_angle = (self.spin_angle + 4) % 360
self.pulse_value += 0.05
self.update()
def _animate_burst(self, val):
self.burst_value = val
self.update()
def _animate_shake(self, val):
self.shake_offset = val
self.update()
def paintEvent(self, event):
if not self.pixmap or self.pixmap.isNull():
return
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
center = self.rect().center()
center_x = center.x() + int(self.shake_offset)
icon_center_y = center.y()
draw_size = self.current_size
if self.is_building:
radius = (self.current_size / 2) + 12
pen = QPen(QColor(26, 115, 232, 200))
pen.setWidth(4)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
rect = QRectF(center_x - radius, center.y() - radius, radius * 2, radius * 2)
span_angle = int((140 + 60 * math.sin(self.pulse_value * 1.5)) * 16)
start_angle = int(-self.spin_angle * 16)
painter.drawArc(rect, start_angle, span_angle)
elif self.burst_value > 0.0:
pop_scale = 1.0 + math.sin(self.burst_value * math.pi) * 0.15
draw_size = int(self.current_size * pop_scale)
if self.burst_value < 1.0:
alpha = int(255 * (1.0 - self.burst_value))
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(26, 115, 232, alpha))
burst_radius_1 = (self.current_size / 2) + 10 + self.burst_value * 40
dot_size_1 = 8 * (1.0 - self.burst_value)
for i in range(8):
angle = math.radians(i * 45)
dx = center_x + math.cos(angle) * burst_radius_1
dy = center.y() + math.sin(angle) * burst_radius_1
painter.drawEllipse(QPointF(dx, dy), dot_size_1, dot_size_1)
painter.setBrush(QColor(255, 193, 7, alpha))
burst_radius_2 = (self.current_size / 2) + self.burst_value * 65
dot_size_2 = 6 * (1.0 - self.burst_value)
for i in range(8):
angle = math.radians(i * 45 + 22.5)
dx = center_x + math.cos(angle) * burst_radius_2
dy = center.y() + math.sin(angle) * burst_radius_2
painter.drawEllipse(QPointF(dx, dy), dot_size_2, dot_size_2)
pix_rect = QRect(
int(center_x - draw_size / 2),
int(icon_center_y - draw_size / 2),
draw_size,
draw_size
)
scaled_pix = self.pixmap.scaled(draw_size, draw_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
painter.drawPixmap(pix_rect, scaled_pix)
painter.end()
class DropArea(QFrame):
fileDropped = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("DropArea")
self.setAcceptDrops(True)
self.setFrameStyle(QFrame.Shape.NoFrame)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setStyleSheet("""
#DropArea { background-color: #f8f9fa; border: 2px dashed #dadce0; border-radius: 12px; }
#DropArea:hover { background-color: #f1f3f4; border: 2px dashed #bdc1c6; }
""")
self.init_ui()
def _get_default_pixmap(self, size=88):
icon_path = get_resource_path("icon.ico")
if os.path.exists(icon_path):
pixmap = QIcon(icon_path).pixmap(size, size)
if not pixmap.isNull():
return pixmap
return get_svg_pixmap('python', color="#9AA0A6", size=size)
def init_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 20, 10, 20)
layout.setSpacing(0)
layout.addStretch(1)
self.icon_widget = TargetIconWidget(self)
self.icon_widget.set_default_pixmap(self._get_default_pixmap(88))
h_layout = QHBoxLayout()
h_layout.addStretch(1)
h_layout.addWidget(self.icon_widget)
h_layout.addStretch(1)
layout.addLayout(h_layout)
layout.addSpacing(18)
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.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 dragEnterEvent(self, event: QDragEnterEvent):
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; }")
def dropEvent(self, event: QDropEvent):
self.dragLeaveEvent(event)
urls = event.mimeData().urls()
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 *.pyw);;All Files (*)"
)
if fp: self.fileDropped.emit(fp)
def set_loading(self, filename):
pixmap = get_svg_pixmap('package', color="#1A73E8", size=88)
self.icon_widget.set_file_pixmap(pixmap, 88)
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("正在解析依赖依赖与项目元数据,请稍候...")
def set_success(self, filename, custom_icon_path=None):
pixmap = None
if custom_icon_path and Path(custom_icon_path).exists():
pixmap = QIcon(str(custom_icon_path)).pixmap(88, 88)
if pixmap.isNull():
pixmap = None
if not pixmap:
pixmap = get_svg_pixmap('package', color="#1A73E8", size=88)
self.icon_widget.set_file_pixmap(pixmap, 88)
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("环境配置就绪,可执行构建")
def start_build_anim(self):
self.label.setText("正在初始化构建流程...")
self.label.setStyleSheet("QLabel { background: transparent; color: #1A73E8; font-size: 16px; font-weight: bold; border: none; }")
self.sub_label.setText("构建引擎准备中...")
self.icon_widget.start_building()
def stop_build_anim(self):
self.icon_widget.stop_building()
def show_success(self, custom_icon_path=None):
size = 128
pixmap_set = False
if custom_icon_path and Path(custom_icon_path).exists():
pix = QIcon(str(custom_icon_path)).pixmap(size, size)
if not pix.isNull():
self.icon_widget.set_custom_pixmap(pix, size)
pixmap_set = True
if not pixmap_set:
if self.icon_widget.base_pixmap and not self.icon_widget.base_pixmap.isNull():
self.icon_widget.set_custom_pixmap(self.icon_widget.base_pixmap, size)
else:
self.icon_widget.set_custom_pixmap(get_svg_pixmap('check', color="#1E8E3E", size=size), size)
self.icon_widget.start_success()
self.label.setText("构建任务执行成功")
self.label.setStyleSheet("QLabel { background: transparent; color: #1E8E3E; font-size: 20px; font-weight: bold; border: none; }")
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.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("将 Python 源代码文件 (.py/.pyw) 拖放到此处\n或 点击手动选择文件")
self.label.setStyleSheet("QLabel { background: transparent; color: #5F6368; font-size: 16px; font-weight: bold; border: none; }")
self.sub_label.setText("自动解析模块依赖、资源文件与隐式导入")
class PythonScannerThread(QThread):
scan_done = Signal(dict)
def run(self):
candidates = set()
for name in ('python', 'python3', 'pythonw'):
p = shutil.which(name)
if p: candidates.add(os.path.normpath(p))
if winreg is not None:
try:
for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE):
try:
with winreg.OpenKey(hive, r"SOFTWARE\Python\PythonCore") as core_key:
for i in range(winreg.QueryInfoKey(core_key)[0]):
sub = winreg.EnumKey(core_key, i)
try:
with winreg.OpenKey(core_key, rf"{sub}\InstallPath") as pkey:
path = winreg.QueryValueEx(pkey, "")[0]
exe = os.path.join(path, "python.exe")
if os.path.exists(exe): candidates.add(os.path.normpath(exe))
except: pass
except: pass
except: pass
search_paths = [
os.environ.get("LOCALAPPDATA", "") + r"\Programs\Python",
os.environ.get("PROGRAMFILES", "") + r"\Python",
os.environ.get("PROGRAMFILES(X86)", "") + r"\Python",
r"C:\Python", r"C:\Program Files\Python", r"C:\Program Files (x86)\Python"
]
for base in search_paths:
if base and os.path.exists(base):
try:
for d in os.listdir(base):
if d.lower().startswith("python"):
exe = os.path.join(base, d, "python.exe")
if os.path.exists(exe): candidates.add(os.path.normpath(exe))
except: pass
user_profile = os.environ.get("USERPROFILE", "")
if user_profile:
for c_dir in ("miniconda3", "anaconda3", "Miniconda3", "Anaconda3", ".conda"):
for base in (user_profile, "C:\\", "D:\\"):
base_dir = os.path.join(base, c_dir)
if os.path.exists(base_dir):
exe = os.path.join(base_dir, "python.exe")
if os.path.exists(exe): candidates.add(os.path.normpath(exe))
envs_dir = os.path.join(base_dir, "envs")
if os.path.exists(envs_dir):
try:
for env in os.listdir(envs_dir):
exe = os.path.join(envs_dir, env, "python.exe")
if os.path.exists(exe): candidates.add(os.path.normpath(exe))
except: pass
else:
unix_bases = [
"/usr/bin", "/usr/local/bin", "/opt/homebrew/bin",
os.path.expanduser("~/.pyenv/shims"), os.path.expanduser("~/.local/bin")
]
user_profile = os.environ.get("HOME", "") or os.environ.get("USERPROFILE", "")
if user_profile:
for c_dir in ("miniconda3", "anaconda3", "mambaforge", ".conda"):
base_dir = os.path.join(user_profile, c_dir)
if os.path.exists(base_dir):
exe = os.path.join(base_dir, "bin", "python")
if os.path.exists(exe): unix_bases.append(os.path.join(base_dir, "bin"))
envs_dir = os.path.join(base_dir, "envs")
if os.path.exists(envs_dir):
try:
for env in os.listdir(envs_dir):
p_bin = os.path.join(envs_dir, env, "bin")
if os.path.exists(p_bin): unix_bases.append(p_bin)
except: pass
for base in unix_bases:
if os.path.exists(base):
try:
for f in os.listdir(base):
if f.startswith("python3") or f == "python":
exe = os.path.join(base, f)
if os.path.isfile(exe) and os.access(exe, os.X_OK):
candidates.add(os.path.normpath(exe))
except: pass
resolved_candidates = set()
for cand in candidates:
try:
resolved_path = str(Path(cand).resolve())
if resolved_path.startswith("\\\\?\\UNC\\"):
resolved_path = "\\\\" + resolved_path[8:]
elif resolved_path.startswith("\\\\?\\"):
resolved_path = resolved_path[4:]
resolved_candidates.add(resolved_path)
except:
resolved_candidates.add(os.path.normpath(cand))
candidates = resolved_candidates
valid_pythons = {}
for cand in candidates:
if os.name == 'nt' and "WindowsApps" in cand:
try:
if os.path.getsize(cand) == 0: continue
except: continue
try:
clean_env = os.environ.copy()
clean_env.pop("PYTHONHOME", None)
clean_env.pop("PYTHONPATH", None)
clean_env["PYTHONUTF8"] = "1"
clean_env["PYTHONIOENCODING"] = "utf-8"
kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, "encoding": "utf-8", "env": clean_env, "timeout": 2}
if os.name == 'nt': kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
proc = subprocess.run([cand, "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')"], **kwargs)
if proc.returncode == 0:
ver = proc.stdout.strip()
valid_pythons[cand] = ver
except: pass
self.scan_done.emit(valid_pythons)
class SettingsPanel(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.parent_win = parent
self.upx_check = None
self.upx_path_container = None
self.out_dir_container = None
self.setStyleSheet("""
SettingsPanel { background-color: #f3f4f6; }
QLabel { color: #3c4043; font-size: 13px; font-weight: bold; background: transparent; }
QComboBox, QLineEdit, QSpinBox {
color: #3c4043; font-size: 13px; padding: 8px 12px;
border: 1px solid #dadce0; border-radius: 6px; background: #f8f9fa; min-height: 20px;
}
QComboBox:hover, QLineEdit:hover, QSpinBox:hover { border-color: #bdc1c6; background: #ffffff; }
QComboBox:focus, QLineEdit:focus, QSpinBox:focus { border-color: #1A73E8; background: #ffffff; }
QCheckBox { font-size: 13px; color: #3c4043; spacing: 10px; background: transparent; }
QCheckBox::indicator { width: 18px; height: 18px; border: 1px solid #bdc1c6; border-radius: 4px; background: white; }
QCheckBox::indicator:hover { border-color: #1A73E8; }
QCheckBox::indicator:checked { background: #1A73E8; border-color: #1A73E8; image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='white'><path d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/></svg>"); }
QFrame#SettingCard { background-color: #ffffff; border: 1px solid #e8eaed; border-radius: 12px; }
QFrame#SettingCard:hover { border: 1px solid #d2e3fc; }
QPushButton.ToolBtn { background: #f1f3f4; border: none; border-radius: 6px; padding: 8px 16px; color: #3c4043; font-weight: bold; }
QPushButton.ToolBtn:hover { background: #e8eaed; color: #202124; }
QPushButton.ToolBtn:pressed { background: #dadce0; }
QTabWidget#MainTabWidget::pane { border: none; background: transparent; top: -1px; }
QTabBar#MainTabBar::tab { background: transparent; border: none; padding: 12px 24px; color: #5f6368; font-weight: bold; font-size: 14px; border-bottom: 3px solid transparent; margin-right: 4px; }
QTabBar#MainTabBar::tab:selected { color: #1A73E8; border-bottom: 3px solid #1A73E8; }
QTabBar#MainTabBar::tab:hover:!selected { color: #202124; background: rgba(0,0,0,0.03); border-radius: 6px; }
QTabWidget#SubTabWidget::pane { border: none; background: transparent; }
QTabBar#SubTabBar { alignment: left; }
QTabBar#SubTabBar::tab { background: transparent; border: 1px solid transparent; padding: 6px 18px; color: #5f6368; font-weight: bold; font-size: 13px; margin: 0 8px 12px 0; border-radius: 15px; }
QTabBar#SubTabBar::tab:selected { background: #e8f0fe; color: #1A73E8; border: 1px solid #d2e3fc; }
QTabBar#SubTabBar::tab:hover:!selected { background: #e8eaed; color: #202124; }
QListWidget { border: 1px solid #dadce0; border-radius: 6px; background: #fff; outline: none; font-size: 12px;}
QListWidget::item { padding: 8px 12px; border-bottom: 1px solid #f1f3f4; color: #3c4043;}
QListWidget::item:selected { background: #e8f0fe; color: #1a73e8; font-weight:bold; border-radius: 4px;}
""")
self.init_ui()
self.load_from_config()
self.scanner_thread = PythonScannerThread()
self.scanner_thread.scan_done.connect(self.populate_python_combo)
self.scanner_thread.start()
def _create_card(self, title_text=""):
card = QFrame()
card.setObjectName("SettingCard")
lay = QVBoxLayout(card)
lay.setContentsMargins(20, 20, 20, 20)
lay.setSpacing(12)
if title_text:
lbl = QLabel(title_text)
lbl.setStyleSheet("font-size: 15px; font-weight: 800; color: #1a73e8; margin-bottom: 6px;")
lay.addWidget(lbl)
return card, lay
def init_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(15)
layout.setContentsMargins(20, 10, 20, 20)
self.tabs = QTabWidget()
self.tabs.setObjectName("MainTabWidget")
self.tabs.tabBar().setObjectName("MainTabBar")
self.tab_build = QWidget()
self.tab_pref = QWidget()
self.tab_about = QWidget()
self.tabs.addTab(self.tab_build, get_svg_icon('package', "#5F6368", 16), "构建参数")
self.tabs.addTab(self.tab_pref, get_svg_icon('settings', "#5F6368", 16), "全局设置")
self.tabs.addTab(self.tab_about, get_svg_icon('info', "#5F6368", 16), "关于")
self.build_build_tab()
self.build_pref_tab()
self.build_about_tab()
layout.addWidget(self.tabs)
btn_lay = QHBoxLayout()
btn_lay.setContentsMargins(0, 5, 0, 0)
btn_lay.setSpacing(12)
self.btn_reset = AnimatedButton("")
self.btn_reset.setFixedSize(44, 44)
self.btn_reset.setIcon(get_svg_icon('refresh', "#5F6368"))
self.btn_reset.setToolTip("重置为默认配置")
self.btn_reset.setStyleSheet(self.parent_win.icon_btn_style)
self.btn_reset.clicked.connect(self.parent_win.reset_all)
btn_lay.addWidget(self.btn_reset)
self.btn_save = AnimatedButton(" 保存并返回")
self.btn_save.setFixedHeight(44)
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.save_settings_and_return)
btn_lay.addWidget(self.btn_save)
self.btn_back = AnimatedButton("")
self.btn_back.setFixedSize(44, 44)
self.btn_back.setIcon(get_svg_icon('back', "#5F6368"))
self.btn_back.setToolTip("放弃修改并返回")
self.btn_back.setStyleSheet(self.parent_win.icon_btn_style)
self.btn_back.clicked.connect(self.parent_win.show_main)
btn_lay.addWidget(self.btn_back)
layout.addLayout(btn_lay)
def populate_python_combo(self, py_dict):
current_text = self.python_path_combo.currentText().strip()
self.python_path_combo.clear()
for path, ver in py_dict.items():
self.python_path_combo.addItem(f"{path} (Python {ver})", path)
if not current_text:
current_text = get_python_executable()
if current_text:
clean_text = current_text
if " (Python " in clean_text:
clean_text = clean_text.split(" (Python ")[0].strip()
clean_text = os.path.normpath(clean_text).lower()
found = False
for idx in range(self.python_path_combo.count()):
item_path = self.python_path_combo.itemData(idx)
if item_path and os.path.normpath(item_path).lower() == clean_text:
self.python_path_combo.setCurrentIndex(idx)
found = True
break
if not found:
self.python_path_combo.setCurrentText(current_text)
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.reqs_file_edit.setText(s.get('use_reqs_file', ''))
self.python_path_combo.setCurrentText(s.get('custom_python_path', ''))
if self.upx_check:
self.upx_check.setChecked(s.getboolean('upx', False))
self.on_upx_toggled(self.upx_check.isChecked())
self.upx_path_edit.setText(s.get('upx_path', ''))
self.cores_spin.setValue(s.getint('cpu_cores', os.cpu_count() or 2))
self.exclude_edit.setText(s.get('exclude_modules', ''))
self.out_mode_combo.setCurrentIndex(int(s.get('out_mode', '0')))
self.out_dir_edit.setText(s.get('custom_out_dir', ''))
self.on_out_mode_changed(self.out_mode_combo.currentIndex())
self.concise_log_check.setChecked(s.getboolean('concise_log', True))
self.sound_notify_check.setChecked(s.getboolean('sound_notify', True))
self.auto_save_log_check.setChecked(s.getboolean('auto_save_log', False))
self.lite_mode_check.setChecked(s.getboolean('lite_mode', False))
self.pyi_ver_edit.setText(s.get('pyi_version', ''))
self.nuitka_ver_edit.setText(s.get('nuitka_version', ''))
self.pipreqs_ver_edit.setText(s.get('pipreqs_version', ''))
self.add_data_list.clear()
res_str = s.get('add_data_list', '')
if res_str:
for part in res_str.split("|||"):
if part.count('|') == 2:
r_type, src, dst = part.split('|')
self._add_resource_item(r_type, src, dst)
else:
old_str = s.get('add_data', '')
if old_str:
for src, dst in parse_add_data(old_str):
r_type = 'dir' if Path(src).is_dir() else 'file'
self._add_resource_item(r_type, src, dst)
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['use_reqs_file'] = self.reqs_file_edit.text().strip()
raw_py = self.python_path_combo.currentText().strip()
if " (Python " in raw_py:
raw_py = raw_py.split(" (Python ")[0].strip()
s['custom_python_path'] = raw_py
if self.upx_check:
s['upx'] = str(self.upx_check.isChecked())
s['upx_path'] = self.upx_path_edit.text().strip()
s['cpu_cores'] = str(self.cores_spin.value())
s['exclude_modules'] = self.exclude_edit.text().strip()
s['out_mode'] = str(self.out_mode_combo.currentIndex())
s['custom_out_dir'] = self.out_dir_edit.text().strip()
s['concise_log'] = str(self.concise_log_check.isChecked())
s['sound_notify'] = str(self.sound_notify_check.isChecked())
s['auto_save_log'] = str(self.auto_save_log_check.isChecked())
s['lite_mode'] = str(self.lite_mode_check.isChecked())
s['pyi_version'] = self.pyi_ver_edit.text().strip()
s['nuitka_version'] = self.nuitka_ver_edit.text().strip()
s['pipreqs_version'] = self.pipreqs_ver_edit.text().strip()
res_list = []
for i in range(self.add_data_list.count()):
r_type, src, dst = self.add_data_list.item(i).data(Qt.ItemDataRole.UserRole)
res_list.append(f"{r_type}|{src}|{dst}")
s['add_data_list'] = "|||".join(res_list)
save_config(config)
def build_build_tab(self):
main_lay = QVBoxLayout(self.tab_build)
main_lay.setContentsMargins(0, 10, 0, 0)
self.sub_tabs = QTabWidget()
self.sub_tabs.setObjectName("SubTabWidget")
self.sub_tabs.tabBar().setObjectName("SubTabBar")
tab_basic = QWidget()
tab_assets = QWidget()
tab_advanced = QWidget()
self.sub_tabs.addTab(tab_basic, "基础设置")
self.sub_tabs.addTab(tab_assets, "资源与依赖")
self.sub_tabs.addTab(tab_advanced, "高级控制")
lay_basic = QVBoxLayout(tab_basic)
lay_basic.setContentsMargins(0, 5, 0, 0)
card1, c_lay1 = self._create_card("编译核心配置")
form1 = QFormLayout()
form1.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
form1.setSpacing(15)
self.engine_combo = QComboBox()
self.engine_combo.addItems(["PyInstaller", "Nuitka"])
self.engine_combo.currentIndexChanged.connect(self.on_engine_changed)
self.python_path_combo = QComboBox()
self.python_path_combo.setEditable(True)
self.python_path_combo.setPlaceholderText("留空则自动检索当前环境下的默认 Python 解释器")
btn_python_path = QPushButton("浏览")
btn_python_path.setProperty("class", "ToolBtn")
btn_python_path.clicked.connect(self.select_python_path)
py_cont = QWidget(); h_py = QHBoxLayout(py_cont); h_py.setContentsMargins(0,0,0,0)
h_py.addWidget(self.python_path_combo); h_py.addWidget(btn_python_path)
self.name_edit = QLineEdit()
self.name_edit.setPlaceholderText("默认自适应源文件名")
self.icon_edit = QLineEdit()
self.icon_preview = QLabel()
self.icon_preview.setFixedSize(24, 24)
self.icon_preview.setScaledContents(True)
self.icon_edit.textChanged.connect(self.update_icon_preview)
btn_icon = QPushButton("浏览")
btn_icon.setProperty("class", "ToolBtn")
btn_icon.clicked.connect(self.select_icon)
icon_cont = QWidget(); h_icon = QHBoxLayout(icon_cont); h_icon.setContentsMargins(0,0,0,0)
h_icon.addWidget(self.icon_edit); h_icon.addWidget(self.icon_preview); h_icon.addWidget(btn_icon)
form1.addRow("编译引擎 (Engine):", self.engine_combo)
form1.addRow("解释器 (Interpreter):", py_cont)
form1.addRow("可执行文件名 (Name):", self.name_edit)
form1.addRow("应用图标 (Icon):", icon_cont)
c_lay1.addLayout(form1)
card2, c_lay2 = self._create_card("运行属性 (Run Properties)")
h_mode = QHBoxLayout()
self.onefile_check = QCheckBox("单文件打包模式 (OneFile / Standalone)")
self.noconsole_check = QCheckBox("无控制台模式 (Windowed / No Console)")
h_mode.addWidget(self.onefile_check)
h_mode.addWidget(self.noconsole_check)
h_mode.addStretch()
c_lay2.addLayout(h_mode)
lay_basic.addWidget(card1)
lay_basic.addWidget(card2)
lay_basic.addStretch()
scroll_assets = QScrollArea()
scroll_assets.setWidgetResizable(True)
scroll_assets.setFrameShape(QFrame.Shape.NoFrame)
scroll_assets.setStyleSheet("background: transparent;")
cont_assets = QWidget()
lay_assets = QVBoxLayout(cont_assets)
lay_assets.setContentsMargins(0, 5, 0, 0)
card3, c_lay3 = self._create_card("依赖与虚拟环境控制")
form2 = QFormLayout()
form2.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
form2.setSpacing(15)
self.pip_source_edit = QLineEdit()
self.reqs_file_edit = QLineEdit(); self.reqs_file_edit.setPlaceholderText("留空则自动检索当前目录下的 requirements.txt")
btn_reqs = QPushButton("选择"); btn_reqs.setProperty("class", "ToolBtn"); btn_reqs.clicked.connect(self.select_reqs_file)
reqs_cont = QWidget(); h_reqs = QHBoxLayout(reqs_cont); h_reqs.setContentsMargins(0,0,0,0)
h_reqs.addWidget(self.reqs_file_edit); h_reqs.addWidget(btn_reqs)
self.hidden_edit = QLineEdit(); self.hidden_edit.setPlaceholderText("显式指定未被隐式导入的依赖,英文逗号分隔 (如: pandas, PyQt5)")
btn_scan = QPushButton("AST扫描"); btn_scan.setProperty("class", "ToolBtn"); btn_scan.clicked.connect(self.auto_scan_hidden)
hid_cont = QWidget(); h_hid = QHBoxLayout(hid_cont); h_hid.setContentsMargins(0,0,0,0)
h_hid.addWidget(self.hidden_edit); h_hid.addWidget(btn_scan)
self.exclude_edit = QLineEdit(); self.exclude_edit.setPlaceholderText("指定不进行打包的模块列表,英文逗号分隔 (如: tkinter, matplotlib)")
form2.addRow("PIP 源地址 (Index URL):", self.pip_source_edit)
form2.addRow("声明清单 (Requirements):", reqs_cont)
form2.addRow("隐式导入 (Hidden Imports):", hid_cont)
form2.addRow("排除模块 (Exclude):", self.exclude_edit)
c_lay3.addLayout(form2)
c_lay3.addSpacing(5)
g_dep = QGridLayout()
g_dep.setSpacing(10)
self.venv_check = QCheckBox("使用虚拟环境 (Virtualenv)")
self.reqs_check = QCheckBox("同步 Requirements 声明")
self.pipreqs_check = QCheckBox("使用 pipreqs 自动分析依赖")
g_dep.addWidget(self.venv_check, 0, 0)
g_dep.addWidget(self.reqs_check, 0, 1)
g_dep.addWidget(self.pipreqs_check, 1, 0, 1, 2)
c_lay3.addLayout(g_dep)
card4, c_lay4 = self._create_card("附加资源文件 (Data Files / Folders)")
self.add_data_list = QListWidget()
self.add_data_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
self.add_data_list.setFixedHeight(120)
self.add_data_list.itemDoubleClicked.connect(self.edit_resource)
c_lay4.addWidget(self.add_data_list)
btn_res_lay = QHBoxLayout()
self.btn_add_file = QPushButton("添加文件"); self.btn_add_file.setProperty("class", "ToolBtn"); self.btn_add_file.clicked.connect(self.add_resource_files)
self.btn_add_dir = QPushButton("添加目录"); self.btn_add_dir.setProperty("class", "ToolBtn"); self.btn_add_dir.clicked.connect(self.add_resource_dir)
self.btn_del_res = QPushButton("删除选中"); self.btn_del_res.setProperty("class", "ToolBtn"); self.btn_del_res.clicked.connect(self.del_resource)
self.btn_clear_res = QPushButton("清空"); self.btn_clear_res.setProperty("class", "ToolBtn"); self.btn_clear_res.clicked.connect(self.clear_resource)
btn_res_lay.addWidget(self.btn_add_file); btn_res_lay.addWidget(self.btn_add_dir); btn_res_lay.addWidget(self.btn_del_res); btn_res_lay.addWidget(self.btn_clear_res); btn_res_lay.addStretch()
c_lay4.addLayout(btn_res_lay)
lay_assets.addWidget(card3)
lay_assets.addWidget(card4)
lay_assets.addStretch()
scroll_assets.setWidget(cont_assets)
lay_tabs_assets = QVBoxLayout(tab_assets)
lay_tabs_assets.setContentsMargins(0,0,0,0)
lay_tabs_assets.addWidget(scroll_assets)
scroll_adv = QScrollArea()
scroll_adv.setWidgetResizable(True)
scroll_adv.setFrameShape(QFrame.Shape.NoFrame)
scroll_adv.setStyleSheet("background: transparent;")
cont_adv = QWidget()
lay_adv = QVBoxLayout(cont_adv)
lay_adv.setContentsMargins(0, 5, 0, 0)
card5, c_lay5 = self._create_card("版本信息与元数据 (Metadata)")
form3 = QFormLayout()
form3.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
form3.setSpacing(15)
self.ver_ver = QLineEdit("1.0.0")
self.ver_comp = QLineEdit("My Studio")
self.ver_desc = QLineEdit("Python Executable")
form3.addRow("应用版本 (Version):", self.ver_ver)
form3.addRow("公司名称 (Company):", self.ver_comp)
form3.addRow("应用描述 (Description):", self.ver_desc)
c_lay5.addLayout(form3)
card6, c_lay6 = self._create_card("性能与编译优化")
form4 = QFormLayout()
form4.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
form4.setSpacing(15)
self.cores_spin = QSpinBox(); self.cores_spin.setRange(1, os.cpu_count() or 4); self.cores_spin.setValue(os.cpu_count() or 2)
form4.addRow("并发构建线程数 (Jobs):", self.cores_spin)
self.upx_check = QCheckBox("启用 UPX 压缩优化")
self.upx_check.toggled.connect(self.on_upx_toggled)
self.upx_path_edit = QLineEdit(); self.upx_path_edit.setPlaceholderText("留空则自动检测环境变量")
btn_upx = QPushButton("选择"); btn_upx.setProperty("class", "ToolBtn"); btn_upx.clicked.connect(self.select_upx_path)
self.upx_path_container = QWidget(); h_upx = QHBoxLayout(self.upx_path_container); h_upx.setContentsMargins(0,0,0,0)
h_upx.addWidget(self.upx_path_edit); h_upx.addWidget(btn_upx)
self.upx_path_container.setVisible(False)
h_upx_row = QHBoxLayout()
h_upx_row.addWidget(self.upx_check)
h_upx_row.addWidget(self.upx_path_container)
form4.addRow("UPX 压缩工具 (UPX Path):", h_upx_row)
self.lite_mode_check = QCheckBox("启用精简模式 (剔除测试组件,深度缩减体积)")
self.lite_mode_check.setStyleSheet("color: #D93025; font-weight: bold;")
self.lite_mode_check.setToolTip("动态排除开发及测试环境冗余依赖,提升构建速度并缩减体积。")
c_lay6.addLayout(form4)
c_lay6.addWidget(self.lite_mode_check)
card7, c_lay7 = self._create_card("版本锁定 (Version Pinning)")
form5 = QFormLayout()
form5.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
form5.setSpacing(15)
self.pyi_ver_edit = QLineEdit()
self.pyi_ver_edit.setPlaceholderText("默认最新版 (旧版本可填 6.6.0)")
self.nuitka_ver_edit = QLineEdit()
self.nuitka_ver_edit.setPlaceholderText("默认最新版 (旧版本可填 4.1.3)")
self.pipreqs_ver_edit = QLineEdit()
self.pipreqs_ver_edit.setPlaceholderText("默认最新版 (旧版本可填 0.4.13)")
form5.addRow("PyInstaller 版本:", self.pyi_ver_edit)
form5.addRow("Nuitka 版本:", self.nuitka_ver_edit)
form5.addRow("Pipreqs 版本:", self.pipreqs_ver_edit)
c_lay7.addLayout(form5)
lay_adv.addWidget(card5)
lay_adv.addWidget(card6)
lay_adv.addWidget(card7)
lay_adv.addStretch()
scroll_adv.setWidget(cont_adv)
lay_tabs_adv = QVBoxLayout(tab_advanced)
lay_tabs_adv.setContentsMargins(0,0,0,0)
lay_tabs_adv.addWidget(scroll_adv)
main_lay.addWidget(self.sub_tabs)
self.on_engine_changed()
def build_pref_tab(self):
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setStyleSheet("background: transparent;")
content = QWidget()
content.setStyleSheet("background: transparent;")
lay = QVBoxLayout(content)
lay.setContentsMargins(0, 5, 0, 15)
lay.setSpacing(15)
card1, lay1 = self._create_card("输出设置")
self.out_mode_combo = QComboBox()
self.out_mode_combo.addItems(["默认路径 (源文件同级目录)", "自定义归档路径 (自定义目录)"])
self.out_mode_combo.currentIndexChanged.connect(self.on_out_mode_changed)
self.out_dir_edit = QLineEdit(); self.out_dir_edit.setPlaceholderText("选择具体的输出归档路径...")
btn_out_dir = QPushButton("浏览..."); btn_out_dir.setProperty("class", "ToolBtn"); btn_out_dir.clicked.connect(self.select_out_dir)
self.out_dir_container = QWidget(); h_out_dir = QHBoxLayout(self.out_dir_container); h_out_dir.setContentsMargins(0, 0, 0, 0)
h_out_dir.addWidget(self.out_dir_edit); h_out_dir.addWidget(btn_out_dir)
self.out_dir_container.setVisible(False)
form1 = QFormLayout()
form1.setVerticalSpacing(15)
form1.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
form1.addRow("输出路径规则:", self.out_mode_combo)
form1.addRow("目标输出目录:", self.out_dir_container)
lay1.addLayout(form1)
lay.addWidget(card1)
card2, lay2 = self._create_card("编译选项")
lay2.setSpacing(16)
self.concise_log_check = QCheckBox("精简日志输出")
self.auto_save_log_check = QCheckBox("自动保存编译日志")
self.auto_icon_check = QCheckBox("自动检索图标")
self.clean_all_check = QCheckBox("清理构建缓存")
self.sound_notify_check = QCheckBox("启用构建提示音")
for chk in (self.concise_log_check, self.auto_save_log_check, self.auto_icon_check, self.clean_all_check, self.sound_notify_check):
lay2.addWidget(chk)
lay.addWidget(card2)
lay.addStretch()
scroll.setWidget(content)
main_lay = QVBoxLayout(self.tab_pref)
main_lay.setContentsMargins(0,0,0,0)
main_lay.addWidget(scroll)
def build_about_tab(self):
main_lay = QVBoxLayout(self.tab_about)
main_lay.setContentsMargins(40, 20, 40, 20)
main_lay.setSpacing(15)
main_lay.addStretch(1)
logo_lbl = QLabel()
icon_path = get_resource_path("icon.ico")
if os.path.exists(icon_path):
high_res_pixmap = QIcon(icon_path).pixmap(256, 256)
if not high_res_pixmap.isNull():
logo_pixmap = high_res_pixmap.scaled(110, 110, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
logo_lbl.setPixmap(logo_pixmap)
else:
logo_lbl.setPixmap(get_svg_pixmap('package', color="#1A73E8", size=110))
else:
logo_lbl.setPixmap(get_svg_pixmap('package', color="#1A73E8", size=110))
logo_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
main_lay.addWidget(logo_lbl)
title_lbl = QLabel(__app_name__)
title_lbl.setStyleSheet("font-size: 36px; font-weight: 900; color: #202124; letter-spacing: -1px; margin-top: 10px;")
title_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
main_lay.addWidget(title_lbl)
ver_lbl = QLabel(f"Version {__version__} · GPL-3.0")
ver_lbl.setStyleSheet("font-size: 14px; font-weight: bold; color: #1A73E8; margin-bottom: 5px;")
ver_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
main_lay.addWidget(ver_lbl)
desc_lbl = QLabel(__description__)
desc_lbl.setStyleSheet("font-size: 14px; color: #5f6368;")
desc_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
main_lay.addWidget(desc_lbl)
main_lay.addSpacing(25)
btn_lay = QHBoxLayout()
btn_lay.setSpacing(15)
btn_lay.setAlignment(Qt.AlignmentFlag.AlignCenter)
def create_link_btn(text, url):
btn = QPushButton(text)
btn.setCursor(Qt.CursorShape.PointingHandCursor)
btn.setStyleSheet("""
QPushButton {
background-color: #f1f3f4;
color: #3c4043;
border: none;
border-radius: 8px;
padding: 10px 20px;
font-size: 13px;
font-weight: bold;
}
QPushButton:hover {
background-color: #e8eaed;
color: #1A73E8;
}
QPushButton:pressed {
background-color: #dadce0;
}
""")
btn.clicked.connect(lambda: __import__('webbrowser').open(url))
return btn
btn_github = create_link_btn("GitHub 仓库", "https://github.com/qwejay/QPyPack")
btn_issue = create_link_btn("反馈与建议", "https://github.com/qwejay/QPyPack/issues")
btn_pypi = create_link_btn("PyPI 主页", "https://pypi.org/project/qpypack/")
btn_lay.addWidget(btn_github)
btn_lay.addWidget(btn_issue)
btn_lay.addWidget(btn_pypi)
main_lay.addLayout(btn_lay)
main_lay.addStretch(1)
rights_lbl = QLabel(f"Copyright © {__company__}.")
rights_lbl.setStyleSheet("font-size: 12px; color: #bdc1c6; font-weight: bold;")
rights_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
main_lay.addWidget(rights_lbl)
def on_engine_changed(self):
if getattr(self, 'upx_check', None) is not None and getattr(self, 'upx_path_container', None) is not None:
engine = self.engine_combo.currentText()
is_pyi = (engine == "PyInstaller")
self.upx_check.setVisible(is_pyi)
self.upx_path_container.setVisible(is_pyi and self.upx_check.isChecked())
def on_upx_toggled(self, checked):
if getattr(self, 'upx_path_container', None) is not None:
self.upx_path_container.setVisible(checked)
def on_out_mode_changed(self, index):
show_custom = (index == 1)
if getattr(self, 'out_dir_container', None) is not None:
self.out_dir_container.setVisible(show_custom)
def select_out_dir(self):
d = QFileDialog.getExistingDirectory(self, "选择输出归档目录")
if d:
self.out_dir_edit.setText(Path(d).resolve().as_posix())
def select_upx_path(self):
d = QFileDialog.getExistingDirectory(self, "选择 UPX 工具根目录")
if d:
self.upx_path_edit.setText(Path(d).resolve().as_posix())
def select_reqs_file(self):
f, _ = QFileDialog.getOpenFileName(self, "选择依赖清单文件", "", "Requirements Files (*.txt);;All Files (*)")
if f:
self.reqs_file_edit.setText(Path(f).resolve().as_posix())
def select_python_path(self):
exe_filter = "Executable (*.exe);;All Files (*)" if os.name == 'nt' else "All Files (*)"
f, _ = QFileDialog.getOpenFileName(self, "选择 Python 解释器", "", exe_filter)
if f:
self.python_path_combo.setCurrentText(Path(f).resolve().as_posix())
def update_icon_preview(self, path):
if path and Path(path).exists():
pixmap = QPixmap(path)
if not pixmap.isNull():
self.icon_preview.setPixmap(pixmap.scaled(28, 28, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
return
self.icon_preview.clear()
def select_icon(self):
p, _ = QFileDialog.getOpenFileName(self, "选择图标", "", "Icon Files (*.ico *.svg *.png *.icns)")
if p: self.icon_edit.setText(Path(p).resolve().as_posix())
def auto_scan_hidden(self):
script_path = self.parent_win.script_path
if not script_path: return QMessageBox.warning(self, "构建约束", "请先加载有效的 Python 源代码文件!")
if is_cloud_locked(script_path):
return QMessageBox.warning(self, "I/O 错误", "目标脚本处于云盘加密或锁定状态,请解密后重试。")
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, "AST 分析完成", f"语法树解析成功,共定位到 {len(hidden)} 项非标准库依赖。")
except Exception as e:
QMessageBox.warning(self, "分析异常", f"AST 语法树解析过程中发生异常: {e}")
def add_resource_files(self):
files, _ = QFileDialog.getOpenFileNames(self, "选择附加文件", "", "All Files (*)")
for f in files:
src = Path(f).resolve().as_posix()
dst = "."
self._add_resource_item('file', src, dst)
def add_resource_dir(self):
folder = QFileDialog.getExistingDirectory(self, "选择附加文件夹")
if folder:
src = Path(folder).resolve().as_posix()
dst = Path(folder).name
self._add_resource_item('dir', src, dst)
def _add_resource_item(self, r_type, src, dst):
display_text = f"[{'文件' if r_type == 'file' else '目录'}] {src} -> {dst}"
item = QListWidgetItem(display_text)
item.setData(Qt.ItemDataRole.UserRole, (r_type, src, dst))
self.add_data_list.addItem(item)
def edit_resource(self, item=None):
if not item:
item = self.add_data_list.currentItem()
if not item: return
r_type, src, dst = item.data(Qt.ItemDataRole.UserRole)
new_dst, ok = QInputDialog.getText(self, "修改目标路径", "请输入打包后的相对目标路径\n(根目录请填 . ):", text=dst)
if ok and new_dst:
new_dst = new_dst.strip().replace('\\', '/')
if not new_dst: new_dst = "."
item.setData(Qt.ItemDataRole.UserRole, (r_type, src, new_dst))
item.setText(f"[{'文件' if r_type == 'file' else '目录'}] {src} -> {new_dst}")
def del_resource(self):
for item in self.add_data_list.selectedItems():
self.add_data_list.takeItem(self.add_data_list.row(item))
def clear_resource(self):
self.add_data_list.clear()
class ScriptAnalysisThread(QThread):
analysis_done = Signal(str, str, str, str, set)
def __init__(self, path):
super().__init__()
self.path = path
def run(self):
app_name = Path(self.path).stem
version = ""
author = "My Studio"
desc = "Python Executable"
script_imports = set()
try:
with open(self.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:
version = v_match.group(1)
c_match = re.search(r'^(?:__company__|COMPANY)\s*=\s*[\'"]([^\'"]+)[\'"]', content, re.M | re.I)
if c_match:
author = c_match.group(1)
else:
a_match = re.search(r'^(?:__author__|AUTHOR)\s*=\s*[\'"]([^\'"]+)[\'"]', content, re.M | re.I)
if a_match:
author = a_match.group(1)
n_match = re.search(r'^(?:__title__|__app_name__|APP_NAME)\s*=\s*[\'"]([^\'"]+)[\'"]', content, re.M | re.I)
if n_match:
app_name = n_match.group(1)
d_match = re.search(r'^(?:__description__|DESCRIPTION)\s*=\s*[\'"]([^\'"]+)[\'"]', content, re.M | re.I)
if d_match:
desc = d_match.group(1)
except:
pass
try:
python_exe = get_python_executable()
script_imports = extract_imports_via_ast(self.path, python_exe)
except:
pass
self.analysis_done.emit(app_name, version, author, desc, script_imports)
class PackingThread(QThread):
progress = Signal(str)
finished = Signal(bool, str)
def __init__(self, params):
super().__init__()
self.params = params
self.process = None
self._is_cancelled = False
self.venv_dir = None
self.temp_workpath = None
self.temp_out_dir = None
self.temp_dist_dir = None
self.all_raw_logs = []
def cancel(self):
self._is_cancelled = True
if self.process:
try:
if os.name == "nt": subprocess.run(["taskkill", "/F", "/T", "/PID", str(self.process.pid)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=subprocess.CREATE_NO_WINDOW)
else: self.process.kill()
except: pass
def run_cmd(self, cmd, cwd=None, timeout=None, silent_error=False):
if self._is_cancelled: return False
timer = None
is_timeout = [False]
cmd_raw_lines = []
clean_env = os.environ.copy()
clean_env.pop("PYTHONHOME", None)
clean_env.pop("PYTHONPATH", None)
clean_env["PYTHONUTF8"] = "1"
clean_env["PYTHONIOENCODING"] = "utf-8"
clean_env["LANG"] = "en_US.UTF-8"
clean_env["LC_ALL"] = "en_US.UTF-8"
try:
kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.STDOUT, "cwd": cwd,
"text": True, "encoding": "utf-8", "errors": "replace", "env": clean_env}
if os.name == 'nt': kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
self.process = subprocess.Popen(cmd, **kwargs)
if timeout:
def kill_proc():
is_timeout[0] = True
try:
if os.name == "nt":
subprocess.run(["taskkill", "/F", "/T", "/PID", str(self.process.pid)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=subprocess.CREATE_NO_WINDOW)
else:
self.process.kill()
except:
pass
timer = threading.Timer(timeout, kill_proc)
timer.start()
buffer = []
last_emit = time.time()
def is_noisy_line(l):
l_lower = l.lower()
if "error:" in l_lower:
return False
return any(kw in l_lower for kw in ["warning:", "info:", "deprecation:", "userwarning:", "futurewarning:"])
for line in self.process.stdout:
if self._is_cancelled:
self.process.terminate()
return False
stripped = line.rstrip('\r\n')
cmd_raw_lines.append(stripped)
self.all_raw_logs.append(stripped)
if self.params.get('concise_log', True) and is_noisy_line(stripped):
continue
buffer.append(stripped)
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"[WARN] 子进程执行超时 (>{timeout}s),正在终止执行...")
return False
success = self.process.returncode == 0
if not success and not silent_error and self.params.get('concise_log', True) and cmd_raw_lines:
self.progress.emit("\n" + "!"*10 + " [诊断回溯: 以下是由于该环节执行异常产生的完整原始日志] " + "!"*10)
self.progress.emit('\n'.join(cmd_raw_lines))
self.progress.emit("!"*60 + "\n")
return success
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"[ERROR] 环境异常:无法定位 Python 解释器,请检查环境变量配置。")
else:
self.progress.emit(f"[ERROR] 子进程调用失败:未检测到系统指令或外部程序 \"{cmd_name}\" ({e})")
return False
except Exception as e:
self.progress.emit(f"[ERROR] 子进程执行发生 system 级异常: {e}")
return False
finally:
if timer:
timer.cancel()
def sanitize_script(self, orig_path: Path):
if is_cloud_locked(orig_path):
return None, False, "目标脚本处于云盘加密或锁定状态,请解密后重试。"
if not self.params['noconsole']:
try:
raw = orig_path.read_bytes()
try: code = raw.decode('utf-8-sig')
except: code = raw.decode(locale.getpreferredencoding(), errors='ignore')
pause_code = "\n" + "#"*30 + "\n" + (
"try:\n"
" import sys\n"
" if sys.platform == 'win32':\n"
" import ctypes\n"
" kernel32 = ctypes.windll.kernel32\n"
" process_list = (ctypes.c_uint * 10)()\n"
" num_processes = kernel32.GetConsoleProcessList(process_list, 10)\n"
" if num_processes <= 2:\n"
" input('\\n执行完毕,按回车键退出...')\n"
"except:\n"
" pass\n"
)
temp_file = orig_path.parent / f"_qpypack_temp_entry_{int(time.time())}.py"
temp_file.write_text(code + pause_code, encoding='utf-8')
return temp_file, True, ""
except Exception as e:
self.progress.emit(f"[WARN] 自动注入控制台防闪退阻塞机制失败: {e}")
return orig_path, False, ""
def detect_python_syntax_errors(self):
import re
script_path = self.params['script_path']
script_name = Path(script_path).name
log_text = "\n".join(self.all_raw_logs)
file_line_pat = re.compile(r'File "([^"]+)", line (\d+)', re.I)
err_type_pat = re.compile(r'^(IndentationError|SyntaxError|TabError):\s*(.*)', re.M)
err_matches = list(err_type_pat.finditer(log_text))
if err_matches:
last_err = err_matches[-1]
err_type = last_err.group(1)
err_desc = last_err.group(2)
err_pos = last_err.start()
line_no = "未知"
file_name = script_name
file_line_matches = list(file_line_pat.finditer(log_text))
for m in reversed(file_line_matches):
if m.end() < err_pos:
matched_filepath = m.group(1)
if matched_filepath.endswith(('.py', '.pyw')):
line_no = m.group(2)
file_name = Path(matched_filepath).name
break
return {
"is_code_error": True,
"type": err_type,
"desc": err_desc,
"line": line_no,
"file": file_name
}
return {"is_code_error": False}
def run(self):
os.environ["NUITKA_ACCEPT_DOWNLOADS"] = "yes"
engine = self.params['engine']
pip_idx = self.params.get('pip_index_url', '').strip()
is_temp = False
build_script_path = None
ext = ".exe" if os.name == "nt" else ""
try:
self.progress.emit("[INFO] 正在初始化隔离构建环境...")
script_path = Path(self.params['script_path']).resolve()
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"[ERROR] I/O 异常: {err_msg}")
script_posix = build_script_path.as_posix()
system_python_exe = get_python_executable()
self.progress.emit(f"[INFO] Python 解释器路径: {system_python_exe}")
script_imports = set()
try:
script_imports = extract_imports_via_ast(script_posix, system_python_exe)
if self.params.get('lite_mode'):
scan_count = 0
for py_file in script_dir.rglob("*.py"):
if any(p.startswith('.') or p.lower() in ('venv', 'env', 'site-packages', 'node_modules', '__pycache__') for p in py_file.parts):
continue
if scan_count > 500: break
try:
script_imports.update(extract_imports_via_ast(py_file.as_posix(), system_python_exe))
scan_count += 1
except: pass
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("[INFO] 正在创建虚拟环境...")
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, "[ERROR] 虚拟环境 (virtualenv) 创建失败。当前 Python 环境可能缺失必要模块或权限受限。")
python_exe = (self.venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python")).as_posix()
self.progress.emit("[INFO] 正在同步并升级 pip 包管理器...")
self.run_cmd([python_exe, "-m", "pip", "install", "--upgrade", "pip", "-q"] + pip_args)
else:
python_exe = system_python_exe
engine_pkg = "nuitka" if engine == "Nuitka" else "pyinstaller"
if engine == "Nuitka" and self.params.get('nuitka_version'):
engine_pkg = f"nuitka=={self.params['nuitka_version']}"
elif engine == "PyInstaller" and self.params.get('pyi_version'):
engine_pkg = f"pyinstaller=={self.params['pyi_version']}"
self.progress.emit(f"[INFO] 正在安装编译引擎 [{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)
if self.params.get('use_reqs'):
custom_reqs = self.params.get('reqs_file', '').strip()
if custom_reqs and Path(custom_reqs).exists():
req_file = Path(custom_reqs)
else:
req_file = script_dir / "requirements.txt"
if req_file.exists():
self.progress.emit(f"[INFO] 依赖安装 [1/3]: 正在安装声明依赖 ({req_file.name})...")
try:
if is_cloud_locked(req_file): raise ValueError("依赖清单文件已被系统锁定")
raw_req = req_file.read_bytes()
try: req_content = raw_req.decode('utf-8-sig')
except: req_content = raw_req.decode(locale.getpreferredencoding(), errors='ignore')
temp_req = Path(tempfile.gettempdir()) / f"qpypack_temp_reqs_{int(time.time())}.txt"
temp_req.write_text(req_content, encoding='utf-8')
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"[WARN] 依赖清单读取或安装过程发生异常: {e}")
if self.params.get('use_pipreqs'):
self.progress.emit("[INFO] 依赖安装 [2/3]: 正在调用 pipreqs 分析项目依赖...")
pipreqs_pkg = "pipreqs"
if self.params.get('pipreqs_version'):
pipreqs_pkg = f"pipreqs=={self.params['pipreqs_version']}"
self.run_cmd([python_exe, "-m", "pip", "install", pipreqs_pkg, "-q"] + pip_args)
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()
]
if pypi_server:
pipreqs_cmd.extend(["--pypi-server", pypi_server])
self.progress.emit(f"[INFO] 依赖分析服务源地址: {pypi_server}")
self.progress.emit("[INFO] 正在查询各依赖库的版本,请稍候...")
success_pipreqs = self.run_cmd(pipreqs_cmd, timeout=15, silent_error=True)
if not success_pipreqs:
self.progress.emit("[INFO] UTF-8 扫描受阻,正在尝试利用兼容编码重新扫描...")
fallback_cmd = ["iso-8859-1" if c == "utf-8" else c for c in pipreqs_cmd]
success_pipreqs = self.run_cmd(fallback_cmd, timeout=15, silent_error=True)
if not success_pipreqs:
self.progress.emit("[WARN] pipreqs 深度扫描因编码异常中断,已跳过。将由后续 AST 扫描引擎补全依赖。")
if success_pipreqs 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)
config = load_config()
known_mappings = DEFAULT_MAPPINGS.copy()
if 'Mappings' in config:
for k, v in config['Mappings'].items():
known_mappings[k] = v
local_files = {p.stem.lower() for p in script_dir.glob("*")}
ast_pkgs = [
known_mappings.get(m, m)
for m in script_imports
if m not in STD_LIBS and m.lower() not in local_files
]
if ast_pkgs:
self.progress.emit(f"[INFO] 依赖安装 [3/3]: 正在通过 AST 静态扫描提取隐式依赖...")
self.progress.emit(f"[INFO] 正在解析并安装隐式导入依赖: {', '.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 self._is_cancelled: return self.finished.emit(False, "[INFO] 构建已被终止。")
self.progress.emit(f"[INFO] 正在启动 {engine} 引擎,开始编译二进制文件...")
cmd = []
app_name = self.params['app_name']
icon_path = Path(self.params['icon']).resolve().as_posix() if self.params.get('icon') else None
if engine == "PyInstaller":
self.temp_workpath = Path(tempfile.mkdtemp(prefix="qpypack_build_")).resolve()
self.temp_dist_dir = Path(tempfile.mkdtemp(prefix="qpypack_dist_")).resolve()
cmd = [
python_exe, "-m", "PyInstaller",
"--clean",
"--noconfirm",
f"--distpath={self.temp_dist_dir.as_posix()}",
f"--workpath={self.temp_workpath.as_posix()}",
f"--name={app_name}"
]
if self.params['onefile']:
cmd.append("--onefile")
else:
cmd.append("--onedir")
if self.params['noconsole']:
cmd.append("--noconsole")
else:
cmd.append("--console")
if icon_path:
cmd.extend(["--icon", icon_path])
cmd.extend(["--add-data", f"{icon_path}{os.pathsep}."])
if self.params.get('version_file') and os.name == "nt":
cmd.extend(["--version-file", self.params['version_file']])
elif sys.platform == "darwin":
comp = self.params.get('ver_comp', 'mycompany').strip().lower().replace(" ", "")
bundle_id = f"com.{comp or 'anonymous'}.{app_name.lower().replace(' ', '')}"
cmd.extend(["--osx-bundle-identifier", bundle_id])
if self.params.get('upx'):
upx_dir_custom = self.params.get('upx_path', '').strip()
if upx_dir_custom and Path(upx_dir_custom).exists():
cmd.append(f"--upx-dir={upx_dir_custom}")
else:
upx_dir_default = (Path.cwd() / "upx").resolve()
if upx_dir_default.exists(): cmd.append(f"--upx-dir={upx_dir_default.as_posix()}")
else:
cmd.append("--noupx")
for imp in self.params.get('hidden_imports', '').split(','):
if imp.strip(): cmd.extend(["--hidden-import", imp.strip()])
for r_type, src, dst in self.params.get('add_data_list', []):
cmd.extend(["--add-data", f"{src}{os.pathsep}{dst}"])
for excl in self.params.get('exclude_modules', '').split(','):
if excl.strip(): cmd.extend(["--exclude-module", excl.strip()])
elif engine == "Nuitka":
self.temp_out_dir = Path(tempfile.mkdtemp(prefix="nuitka_out_")).resolve()
cmd = [
python_exe, "-m", "nuitka",
"--remove-output",
"--assume-yes-for-downloads",
f"--output-dir={self.temp_out_dir.as_posix()}",
f"--output-filename={app_name}{ext}"
]
cores = self.params.get('cpu_cores', os.cpu_count() or 2)
cmd.append(f"--jobs={cores}")
if self.params['onefile']:
cmd.append("--onefile")
else:
cmd.append("--standalone")
if self.params['noconsole']:
cmd.append("--windows-console-mode=disable")
if sys.platform == "darwin":
cmd.append("--macos-create-app-bundle")
else:
cmd.append("--windows-console-mode=force")
if icon_path:
if os.name == "nt":
cmd.append(f"--windows-icon-from-ico={icon_path}")
elif sys.platform == "darwin":
cmd.append(f"--macos-app-icon={icon_path}")
cmd.append(f"--include-data-files={Path(icon_path).resolve().as_posix()}={Path(icon_path).name}")
if os.name == "nt":
if self.params.get('ver_comp'):
cmd.append(f"--company-name={self.params['ver_comp']}")
if self.params.get('ver_desc'):
cmd.append(f"--file-description={self.params['ver_desc']}")
if self.params.get('app_name'):
cmd.append(f"--product-name={self.params['app_name']}")
if self.params.get('ver_ver'):
v_str = self.params['ver_ver'].strip()
v_nums = re.findall(r'\d+', v_str)
v_clean = ".".join((v_nums + ['0', '0', '0', '0'])[:4])
cmd.append(f"--file-version={v_clean}")
cmd.append(f"--product-version={v_clean}")
elif sys.platform == "darwin":
if self.params.get('ver_comp'):
cmd.append(f"--company-name={self.params['ver_comp']}")
if self.params.get('ver_ver'):
cmd.append(f"--macos-app-version={self.params['ver_ver']}")
comp = self.params.get('ver_comp', 'mycompany').strip().lower().replace(" ", "")
bundle_id = f"com.{comp or 'anonymous'}.{app_name.lower().replace(' ', '')}"
cmd.append(f"--macos-signed-app-name={bundle_id}")
imports_lower = {m.lower() for m in script_imports}
if 'pyqt5' in imports_lower: cmd.append("--enable-plugin=pyqt5")
elif 'pyqt6' in imports_lower: cmd.append("--enable-plugin=pyqt6")
elif 'pyside2' in imports_lower: cmd.append("--enable-plugin=pyside2")
elif 'pyside6' in imports_lower: cmd.append("--enable-plugin=pyside6")
if 'matplotlib' in imports_lower: cmd.append("--enable-plugin=matplotlib")
if 'tkinter' in imports_lower: cmd.append("--enable-plugin=tk-inter")
for imp in self.params.get('hidden_imports', '').split(','):
if imp.strip(): cmd.append(f"--include-module={imp.strip()}")
for r_type, src, dst in self.params.get('add_data_list', []):
src_path = Path(src).resolve().as_posix()
if r_type == 'dir':
cmd.append(f"--include-data-dir={src_path}={dst}")
else:
filename = Path(src).name
if dst == ".":
nuitka_dst = filename
else:
nuitka_dst = os.path.normpath(os.path.join(dst, filename)).replace('\\', '/')
cmd.append(f"--include-data-files={src_path}={nuitka_dst}")
for excl in self.params.get('exclude_modules', '').split(','):
if excl.strip(): cmd.append(f"--nofollow-import-to={excl.strip()}")
if self.params.get('lite_mode'):
self.progress.emit("[INFO] 已开启精简模式,正在执行体积缩减策略...")
if not self.params.get('use_venv'):
self.progress.emit("[WARN] 强烈建议勾选 [虚拟环境] 以最大化精简效果。")
lite_excludes = ['pip', 'setuptools', 'distutils', 'wheel', 'pydoc', 'unittest', 'pytest', 'pdb', 'test']
for ex in lite_excludes:
if engine == "PyInstaller": cmd.append(f"--exclude-module={ex}")
elif engine == "Nuitka": cmd.append(f"--nofollow-import-to={ex}")
if engine == "Nuitka":
self.progress.emit("[INFO] 已启用 Nuitka 优化指令 (字节码除冗)...")
cmd.append("--python-flag=-OO")
cmd.append(script_posix)
success = self.run_cmd(cmd, cwd=script_dir.as_posix())
if self._is_cancelled: return self.finished.emit(False, "[INFO] 构建已被终止。")
self.progress.emit("[INFO] 编译核心完成,正在提取并归档编译产物...")
src_out = None
if engine == "PyInstaller":
if sys.platform == "darwin" and self.params['noconsole']:
src_out = self.temp_dist_dir / f"{app_name}.app"
else:
src_out = self.temp_dist_dir / (f"{app_name}{ext}" if self.params['onefile'] else app_name)
elif engine == "Nuitka":
if self.params['onefile']:
if sys.platform == "darwin" and self.params['noconsole']:
src_out = self.temp_out_dir / f"{app_name}.app"
else:
src_out = self.temp_out_dir / f"{app_name}{ext}"
else:
if sys.platform == "darwin" and self.params['noconsole']:
src_out = self.temp_out_dir / f"{app_name}.app"
else:
dist_dirs = list(self.temp_out_dir.glob("*.dist"))
if dist_dirs:
src_out = dist_dirs[0]
else:
src_out = self.temp_out_dir / f"{app_name}.dist"
out_mode = int(self.params.get('out_mode', 0))
custom_out = self.params.get('custom_out_dir', '').strip()
if out_mode == 1 and custom_out:
try:
final_out_dir = Path(custom_out)
final_out_dir.mkdir(parents=True, exist_ok=True)
except:
final_out_dir = script_dir
else:
final_out_dir = script_dir
final_out = final_out_dir / src_out.name
if success and src_out and src_out.exists():
try:
if final_out.exists():
if final_out.is_dir(): shutil.rmtree(final_out, onerror=remove_readonly)
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"[ERROR] 产物移交失败,文件可能被系统进程占用或权限不足: {e}")
else: self.progress.emit(f"[ERROR] 未在临时构建目录中定位到有效可执行产物: {src_out}")
if success and final_out.exists():
self.progress.emit("[INFO] 正在校验输出文件并生成最终产物...")
if self.params.get('auto_save_log') and self.all_raw_logs:
try:
log_file = final_out_dir / f"qpypack_build_{app_name}.log"
log_file.write_text('\n'.join(self.all_raw_logs), encoding='utf-8')
self.progress.emit(f"[INFO] 编译日志已导出至: {log_file.as_posix()}")
except: pass
self.finished.emit(True, f"[SUCCESS] 编译已完成,输出路径: {final_out.resolve().as_posix()}")
else:
err_info = self.detect_python_syntax_errors()
if err_info["is_code_error"]:
msg = (
f"[语法异常] 源程序存在语法不合规或缩进异常错误!\n"
f" - 错误源文件: {err_info['file']}\n"
f" - 异常类型: {err_info['type']}\n"
f" - 异常位置: 第 {err_info['line']} 行附近\n"
f" - 错误描述: {err_info['desc']}\n"
f"\n提示: 此问题为源码逻辑本身错误。请在确保本地运行无误后,再次执行编译流程。"
)
else:
if self.params.get('concise_log', True) and self.all_raw_logs:
self.progress.emit("\n" + "!"*10 + " [诊断回溯: 以下为最后段编译运行日志汇总] " + "!"*10)
self.progress.emit('\n'.join(self.all_raw_logs[-100:]))
msg = "[FAILED] 编译异常中断,请参阅上方日志以定位构建异常。"
self.finished.emit(False, msg)
except Exception as e:
if self.params.get('concise_log', True) and self.all_raw_logs:
self.progress.emit("\n" + "!"*10 + " [系统诊断: 原始日志] " + "!"*10)
self.progress.emit('\n'.join(self.all_raw_logs[-100:]))
self.finished.emit(False, f"[ERROR] 构建流程发生致命异常: {str(e)}")
finally:
if is_temp and build_script_path and build_script_path.exists():
try:
build_script_path.unlink()
pycache_dir = script_dir / "__pycache__"
if pycache_dir.exists():
robust_rmtree(pycache_dir)
except: pass
if self.params.get('version_file'):
try:
p = Path(self.params['version_file'])
if p.exists(): p.unlink()
except: pass
if self.params.get('temp_icon_file'):
try:
p = Path(self.params['temp_icon_file'])
if p.exists(): p.unlink()
except: pass
if self.params['clean_all']:
self.progress.emit("[INFO] 正在释放空间,安全清除临时构建环境...")
for p in [self.venv_dir, self.temp_workpath, self.temp_out_dir, self.temp_dist_dir]:
if p and p.exists(): robust_rmtree(p)
app_name = self.params.get('app_name', 'app')
for p in ["__pycache__", f"{app_name}.build", f"{app_name}.dist", f"{app_name}.onefile-build"]:
robust_rmtree(script_dir / p)
spec_file = script_dir / f"{app_name}.spec"
if spec_file.exists():
try: spec_file.unlink()
except: pass
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.script_path = ""
self.thread = None
self.analysis_thread = None
self.current_state = "idle"
self.init_style()
self.init_ui()
def init_style(self):
self.setWindowTitle(f"{__app_name__} {__version__}")
self.setMinimumSize(680, 620)
self.resize(680, 620)
icon_path = get_resource_path("icon.ico")
if os.path.exists(icon_path):
self.setWindowIcon(QIcon(icon_path))
elif getattr(sys, 'frozen', False):
provider = QFileIconProvider()
exe_icon = provider.icon(QFileInfo(sys.executable))
if not exe_icon.isNull(): self.setWindowIcon(exe_icon)
self.setStyleSheet("""
QMainWindow { background-color: #ffffff; }
QTextEdit { border: 1px solid #e8eaed; border-radius: 8px; background-color: #f8f9fa; font-family: Consolas, monospace; font-size: 13px; color: #3c4043; padding: 10px; }
QStatusBar { background-color: #f8f9fa; color: #5f6368; border-top: 1px solid #e8eaed; padding: 5px; }
QStatusBar QLabel { color: #5f6368; font-size: 13px; padding: 2px; background: transparent; }
""")
self.icon_btn_style = """
QPushButton { background-color: #f1f3f4; border: 1px solid transparent; border-radius: 8px; }
QPushButton:hover { background-color: #e8eaed; }
QPushButton:pressed { background-color: #dadce0; }
"""
self.primary_btn_style = """
QPushButton { background-color: #1A73E8; color: white; border: none; border-radius: 8px; font-size: 15px; font-weight: bold; }
QPushButton:hover { background-color: #1B66C9; } QPushButton:pressed { background-color: #174EA6; }
"""
self.danger_btn_style = """
QPushButton { background-color: #D93025; color: white; border: none; border-radius: 8px; font-size: 15px; font-weight: bold; }
QPushButton:hover { background-color: #C5221F; } QPushButton:pressed { background-color: #A50E0E; }
"""
self.success_btn_style = """
QPushButton { background-color: #1E8E3E; color: white; border: none; border-radius: 8px; font-size: 15px; font-weight: bold; }
QPushButton:hover { background-color: #188038; } QPushButton:pressed { background-color: #137333; }
"""
def init_ui(self):
central = QWidget()
self.setCentralWidget(central)
self.stacked_layout = QStackedLayout(central)
self.main_panel = QWidget()
layout = QVBoxLayout(self.main_panel)
layout.setSpacing(15)
layout.setContentsMargins(20, 20, 20, 20)
self.drop_area = DropArea(self)
self.drop_area.fileDropped.connect(self.on_script_selected)
layout.addWidget(self.drop_area, stretch=1)
self.log_container = QWidget()
log_lay = QVBoxLayout(self.log_container)
log_lay.setContentsMargins(0, 0, 0, 0)
self.log = QTextEdit()
self.log.setReadOnly(True)
self.log.setFixedHeight(120)
log_lay.addWidget(self.log)
self.log_container.hide()
layout.addWidget(self.log_container)
btn_layout = QHBoxLayout()
btn_layout.setSpacing(12)
btn_layout.setContentsMargins(0, 5, 0, 0)
self.btn_left = AnimatedButton("")
self.btn_left.setFixedSize(44, 44)
self.btn_left.setStyleSheet(self.icon_btn_style)
self.btn_left.clicked.connect(self.on_left_btn_clicked)
btn_layout.addWidget(self.btn_left)
self.btn_main = AnimatedButton("")
self.btn_main.setFixedHeight(44)
self.btn_main.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.btn_main.clicked.connect(self.on_main_btn_clicked)
btn_layout.addWidget(self.btn_main)
self.btn_right = AnimatedButton("")
self.btn_right.setFixedSize(44, 44)
self.btn_right.setIcon(get_svg_icon('settings', "#5F6368"))
self.btn_right.setToolTip("配置构建参数")
self.btn_right.setStyleSheet(self.icon_btn_style)
self.btn_right.clicked.connect(self.show_settings)
btn_layout.addWidget(self.btn_right)
layout.addLayout(btn_layout)
self.stacked_layout.addWidget(self.main_panel)
self.settings_panel = SettingsPanel(self)
self.stacked_layout.addWidget(self.settings_panel)
self.stacked_layout.setCurrentWidget(self.main_panel)
self.status_bar = self.statusBar()
self.status_label = QLabel(" 状态: 准备就绪")
self.status_bar.addWidget(self.status_label)
self.copyright_label = QLabel(f"Copyright © {__company__}. ")
self.copyright_label.setStyleSheet("color: #bdc1c6; font-size: 11px; font-weight: bold; background: transparent; padding-right: 5px;")
self.status_bar.addPermanentWidget(self.copyright_label)
self.update_ui_state("idle")
def set_status(self, text):
self.status_label.setText(text)
self.adjust_status_bar()
def adjust_status_bar(self):
if not hasattr(self, 'status_label') or not hasattr(self, 'copyright_label') or not hasattr(self, 'status_bar'):
return
text = self.status_label.text()
metrics = self.status_label.fontMetrics()
text_width = metrics.horizontalAdvance(text)
copyright_metrics = self.copyright_label.fontMetrics()
copyright_width = copyright_metrics.horizontalAdvance(self.copyright_label.text())
if text_width + copyright_width + 60 > self.status_bar.width():
self.copyright_label.hide()
else:
self.copyright_label.show()
def resizeEvent(self, event):
super().resizeEvent(event)
self.adjust_status_bar()
def closeEvent(self, event):
if self.thread and self.thread.isRunning():
self.thread.cancel()
self.thread.wait()
if self.analysis_thread and self.analysis_thread.isRunning():
self.analysis_thread.terminate()
self.analysis_thread.wait()
if hasattr(self, 'settings_panel') and hasattr(self.settings_panel, 'scanner_thread'):
if self.settings_panel.scanner_thread and self.settings_panel.scanner_thread.isRunning():
self.settings_panel.scanner_thread.terminate()
self.settings_panel.scanner_thread.wait()
super().closeEvent(event)
def update_ui_state(self, state):
self.current_state = state
self.btn_right.setEnabled(state != "building")
self.drop_area.setAcceptDrops(state != "building")
if state in ("idle", "ready"):
is_log_open = self.log_container.isVisible()
icon_name = 'expand_less' if is_log_open else 'expand_more'
self.btn_left.setIcon(get_svg_icon(icon_name, "#5F6368"))
self.btn_left.setToolTip("显示/隐藏执行日志")
self.btn_main.setText(" 开始构建")
self.btn_main.setIcon(get_svg_icon('play', "white"))
self.btn_main.setStyleSheet(self.primary_btn_style)
elif state == "building":
is_log_open = self.log_container.isVisible()
icon_name = 'expand_less' if is_log_open else 'expand_more'
self.btn_left.setIcon(get_svg_icon(icon_name, "#5F6368"))
self.btn_left.setToolTip("显示/隐藏执行日志")
self.btn_main.setText(" 停止构建")
self.btn_main.setIcon(get_svg_icon('stop', "white"))
self.btn_main.setStyleSheet(self.danger_btn_style)
elif state in ("done", "failed"):
self.btn_left.setIcon(get_svg_icon('refresh', "#5F6368"))
self.btn_left.setToolTip("重置工作区")
if state == "done":
self.btn_main.setText(" 打开输出目录")
self.btn_main.setIcon(get_svg_icon('folder', "white"))
self.btn_main.setStyleSheet(self.success_btn_style)
else:
self.btn_main.setText(" 重新构建")
self.btn_main.setIcon(get_svg_icon('refresh', "white"))
self.btn_main.setStyleSheet(self.danger_btn_style)
def on_left_btn_clicked(self):
if self.current_state in ("done", "failed"):
self.reset_all()
else:
self.toggle_log()
def on_main_btn_clicked(self):
if self.current_state in ("idle", "ready", "failed"):
self.start_pack()
elif self.current_state == "building":
self.cancel_pack()
elif self.current_state == "done":
self.open_dist()
def toggle_log(self):
if self.log_container.isVisible():
self.log_container.hide()
else:
self.log_container.show()
self.update_ui_state(self.current_state)
def show_settings(self):
self._animate_switch(self.settings_panel)
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)
self.anim.setEasingCurve(QEasingCurve.Type.OutCubic)
self.anim.setStartValue(self.stacked_layout.currentWidget().geometry())
self.stacked_layout.setCurrentWidget(target_widget)
self.anim.setEndValue(target_widget.geometry())
self.anim.start()
def on_script_selected(self, path):
path = Path(path).resolve().as_posix()
if is_cloud_locked(path):
QMessageBox.critical(self, "加载失败", "目标脚本处于锁定状态,请稍后重试。")
return
if self.script_path and self.script_path != path:
self.settings_panel.icon_edit.clear()
self.settings_panel.hidden_edit.clear()
self.settings_panel.add_data_list.clear()
self.script_path = path
self.drop_area.set_loading(Path(path).name)
self.set_status(f" 状态: 正在解析源文件 {Path(path).name} ...")
if self.analysis_thread and self.analysis_thread.isRunning():
self.analysis_thread.terminate()
self.analysis_thread.wait()
self.analysis_thread = ScriptAnalysisThread(self.script_path)
self.analysis_thread.analysis_done.connect(self.on_analysis_finished)
self.analysis_thread.start()
def on_analysis_finished(self, app_name, version, author, desc, script_imports):
path = self.script_path
if not path: return
if version:
self.settings_panel.ver_ver.setText(version)
else:
self.settings_panel.ver_ver.setText("1.0.0")
self.settings_panel.ver_comp.setText(author)
self.settings_panel.ver_desc.setText(desc)
gui_libs = {'pyqt5', 'pyqt6', 'pyside2', 'pyside6', 'tkinter', 'wx', 'kivy', 'libavg'}
has_gui = any(lib in {m.lower() for m in script_imports} for lib in gui_libs)
self.settings_panel.noconsole_check.setChecked(has_gui)
default_output_name = f"{app_name}_{version}" if version else app_name
self.settings_panel.name_edit.setText(default_output_name)
script_dir = Path(path).parent
auto_icon = None
if self.settings_panel.auto_icon_check.isChecked():
preferred_extensions = [".ico", ".icns", ".png", ".svg"]
if sys.platform == "darwin":
preferred_extensions = [".icns", ".png", ".svg", ".ico"]
elif sys.platform == "linux":
preferred_extensions = [".png", ".svg", ".ico", ".icns"]
found = False
for ext in preferred_extensions:
for name in ["icon", "logo", "ICON", "LOGO"]:
trial = script_dir / f"{name}{ext}"
if trial.exists():
auto_icon = trial
self.settings_panel.icon_edit.setText(trial.resolve().as_posix())
found = True
break
if found: break
self.drop_area.set_success(Path(path).name, custom_icon_path=auto_icon)
status_suffix = ",已配置为控制台模式" if not has_gui else ",已配置为无控制台模式"
self.set_status(f" 状态: 已载入源文件 {Path(path).name}{status_suffix}")
if not self.log_container.isVisible(): self.toggle_log()
self.log.clear()
self.append_log(f"[INFO] 载入应用入口文件成功: {path}")
if not has_gui:
self.append_log("[INFO] 静态扫描分析:未检测到图形框架。运行属性已自动配置为保留控制台。")
self.btn_main.setEnabled(True)
self.update_ui_state("ready")
def cancel_pack(self):
if self.thread and self.thread.isRunning():
self.thread.cancel()
self.set_status(" 状态: 构建已终止")
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 源代码文件!")
return
sp = self.settings_panel
app_name = sp.name_edit.text().strip() or Path(self.script_path).stem
engine = sp.engine_combo.currentText()
version_file = None
if engine == "PyInstaller" and os.name == "nt" and sp.ver_ver.text().strip():
try:
v_str = sp.ver_ver.text().strip()
v_nums = re.findall(r'\d+', v_str)
v_tuple = ",".join((v_nums + ['0', '0', '0', '0'])[:4])
comp_escaped = sp.ver_comp.text().replace("'", "\\'")
desc_escaped = sp.ver_desc.text().replace("'", "\\'")
v_str_escaped = v_str.replace("'", "\\'")
content = f'''VSVersionInfo(ffi=FixedFileInfo(filevers=({v_tuple}),prodvers=({v_tuple}),mask=0x3f,flags=0x0,OS=0x40004,fileType=0x1,subtype=0x0,date=(0,0)),kids=[StringFileInfo([StringTable('040904B0',[StringStruct('CompanyName','{comp_escaped}'),StringStruct('FileDescription','{desc_escaped}'),StringStruct('FileVersion','{v_str_escaped}'),StringStruct('ProductVersion','{v_str_escaped}'),StringStruct('OriginalFilename','{app_name}.exe')])])])'''
version_file = Path(tempfile.gettempdir()) / f"qpypack_{app_name}_version.txt"
version_file.write_text(content, encoding='utf-8')
except: pass
icon_path_str = sp.icon_edit.text().strip()
temp_icon_file = None
if icon_path_str and Path(icon_path_str).exists():
icon_path = Path(icon_path_str)
needed_ext = "ico" if os.name == "nt" else ("icns" if sys.platform == "darwin" else "png")
if icon_path.suffix.lower() != f".{needed_ext}":
temp_ico_name = f"qpypack_temp_icon_{int(time.time())}.{needed_ext}"
temp_ico = Path(tempfile.gettempdir()) / temp_ico_name
if convert_image_to_format(icon_path.as_posix(), temp_ico.as_posix(), needed_ext):
icon_path_str = temp_ico.as_posix()
temp_icon_file = temp_ico.as_posix()
else:
self.append_log(f"[WARN] 图像处理模块异常:目标图标转换失败,尝试使用源格式直接转交编译引擎。")
add_data_items = []
for i in range(sp.add_data_list.count()):
add_data_items.append(sp.add_data_list.item(i).data(Qt.ItemDataRole.UserRole))
params = {
'engine': engine,
'script_path': self.script_path,
'app_name': app_name,
'onefile': sp.onefile_check.isChecked(),
'noconsole': sp.noconsole_check.isChecked(),
'icon': icon_path_str,
'use_reqs': sp.reqs_check.isChecked(),
'use_pipreqs': sp.pipreqs_check.isChecked(),
'reqs_file': sp.reqs_file_edit.text().strip(),
'hidden_imports': sp.hidden_edit.text(),
'add_data_list': add_data_items,
'upx': sp.upx_check.isChecked() if engine == "PyInstaller" else False,
'upx_path': sp.upx_path_edit.text().strip(),
'cpu_cores': sp.cores_spin.value(),
'exclude_modules': sp.exclude_edit.text().strip(),
'out_mode': sp.out_mode_combo.currentIndex(),
'custom_out_dir': sp.out_dir_edit.text().strip(),
'use_venv': sp.venv_check.isChecked(),
'clean_all': sp.clean_all_check.isChecked(),
'version_file': version_file.as_posix() if version_file else None,
'temp_icon_file': temp_icon_file,
'ver_comp': sp.ver_comp.text(),
'ver_desc': sp.ver_desc.text(),
'ver_ver': sp.ver_ver.text(),
'pip_index_url': sp.pip_source_edit.text().strip(),
'concise_log': sp.concise_log_check.isChecked(),
'auto_save_log': sp.auto_save_log_check.isChecked(),
'lite_mode': sp.lite_mode_check.isChecked(),
'pyi_version': sp.pyi_ver_edit.text().strip(),
'nuitka_version': sp.nuitka_ver_edit.text().strip(),
'pipreqs_version': sp.pipreqs_ver_edit.text().strip()
}
self.log.clear()
if not self.log_container.isVisible(): self.toggle_log()
self.thread = PackingThread(params)
self.thread.progress.connect(self.append_log)
self.thread.finished.connect(self.on_pack_finished)
self.thread.start()
self.set_status(f" 状态: 正在通过 {engine} 构建...")
self.update_ui_state("building")
self.drop_area.start_build_anim()
def on_pack_finished(self, success, msg):
self.append_log("\n" + "━"*50 + "\n" + msg)
self.drop_area.stop_build_anim()
if self.settings_panel.sound_notify_check.isChecked():
play_alert(success)
if success:
icon_path = self.settings_panel.icon_edit.text().strip()
self.drop_area.show_success(icon_path)
self.set_status(" 状态: 构建完成")
self.update_ui_state("done")
else:
self.drop_area.show_failure()
self.set_status(" 状态: 构建失败")
self.update_ui_state("failed")
def open_dist(self):
if self.settings_panel.out_mode_combo.currentIndex() == 1 and self.settings_panel.out_dir_edit.text().strip():
target = Path(self.settings_panel.out_dir_edit.text().strip())
else:
target = Path(self.script_path).parent if self.script_path else Path.cwd()
if target.exists():
try:
if os.name == 'nt': os.startfile(target)
elif sys.platform == 'darwin': subprocess.call(('open', target.as_posix()))
else: subprocess.call(('xdg-open', target.as_posix()))
except: pass
def reset_all(self):
self.script_path = ""
self.settings_panel.name_edit.clear()
self.settings_panel.icon_edit.clear()
self.settings_panel.hidden_edit.clear()
self.settings_panel.add_data_list.clear()
self.log.clear()
if self.log_container.isVisible(): self.toggle_log()
self.drop_area.reset()
self.set_status(" 状态: 工作区已重置")
self.update_ui_state("idle")
def append_log(self, msg):
self.log.append(msg)
self.log.ensureCursorVisible()
for line in msg.split('\n'):
line = line.strip()
if not line:
continue
if line.startswith(("[INFO]", "[WARN]", "[SUCCESS]", "[FAILED]", "[ERROR]")):
clean_text = line
for prefix in ("[INFO]", "[WARN]", "[SUCCESS]", "[FAILED]", "[ERROR]"):
if clean_text.startswith(prefix):
clean_text = clean_text[len(prefix):].strip()
break
if self.current_state == "building" and clean_text:
if len(clean_text) > 35:
clean_text = clean_text[:32] + "..."
self.drop_area.label.setText(clean_text)
self.drop_area.label.setStyleSheet("QLabel { background: transparent; color: #1A73E8; font-size: 16px; font-weight: bold; border: none; }")
def main():
try:
QApplication.setHighDpiScaleFactorRoundingPolicy(Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
except Exception:
pass
app = QApplication(sys.argv)
app.setStyle("Fusion")
app.setFont(QFont("Microsoft YaHei", 9))
win = MainWindow()
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()