python打包辅助工具
python打包辅助工具
PyInstaller 是一个非常流行的 Python 应用程序打包工具,它可以将 Python 脚本及其依赖项打包成独立的可执行文件,方便在没有 Python 环境的机器上运行。关于PyInstaller,可参见:https://blog.csdn.net/cnds123/article/details/115254418
PyInstaller打包过程对新手来说,实在是太麻烦了,能否用Tkinter实现GUI界面包装PyInstaller,方便使用呢?我这里给出一个实现。
界面上:
主脚本文件(必选)框及“浏览”按钮
exe图标(可选)框及“浏览”按钮
资源文件(--add-data)框及“浏览”按钮 (这个是多行框每按一次按钮添加一行,旁边应有删除行按钮,用于删除误加入的行)
隐藏控制台复选框
单文件(--onefile)和单文件夹 (--onedir,默认)处理 单选框
打包输出保存框(可选)及“浏览”按钮,不选默认在主脚本文件所在文件夹
打包信息框(这个应是多行框),用于显示进度或错误信息等
“路径合规检测”按钮,“sys._MEIPASS检测”用于要检查相关资源文件是否符合要求,检测 源码是否sys._MEIPASS动态拼接资源路径,如不符合,自动添加 resource_path(relative_path)函数,并此处理源码在相关资源文件路径合规。
“打包”按钮,执行打包生成exe文件
运行显示效果
本程序使用了一些模块是 Python 标准库的一部分,通常不需要额外安装,因为它们随 Python 解释器一起提供。
(1)tkinter:
Python 的标准 GUI 库,用于创建图形用户界面。
(2)subprocess:
用于运行外部命令和程序。
(3)sys:
提供对 Python 解释器的访问,例如获取命令行参数、退出程序等。
(4)os:
提供操作系统相关的功能,如文件路径操作、环境变量等。
(5)re:
提供正则表达式支持,用于字符串匹配和处理。
(6)threading:
提供线程支持,用于并发编程。
(7) shutil
提供文件和文件集合的高级操作,如复制、移动、删除等。
源码如下:
import tkinter as tk
from tkinter import ttk, filedialog, scrolledtext, messagebox
import subprocess
import sys
import os
import re
import threading
import shutil
class PyInstallerGUI:
def __init__(self, root):
self.root = root
self.root.title("PyInstaller GUI 打包工具 v1.2")
self.setup_ui()
self.resource_entries = []
self.current_output = ""
# 设置日志重定向
self.redirect_output()
def setup_ui(self):
main_frame = ttk.Frame(self.root, padding=10)
main_frame.pack(fill=tk.BOTH, expand=True)
# 主脚本文件
ttk.Label(main_frame, text="主脚本文件:").grid(row=0, column=0, sticky=tk.W)
self.script_entry = ttk.Entry(main_frame, width=50)
self.script_entry.grid(row=0, column=1, padx=5)
ttk.Button(main_frame, text="浏览", command=self.browse_script).grid(row=0, column=2)
# 图标文件
ttk.Label(main_frame, text="EXE图标:").grid(row=1, column=0, sticky=tk.W)
self.icon_entry = ttk.Entry(main_frame, width=50)
self.icon_entry.grid(row=1, column=1, padx=5)
ttk.Button(main_frame, text="浏览", command=self.browse_icon).grid(row=1, column=2)
# 资源文件
resource_frame = ttk.LabelFrame(main_frame, text="资源文件 (--add-data)")
resource_frame.grid(row=2, column=0, columnspan=3, sticky=tk.W+tk.E, pady=5)
self.resource_list = ttk.Frame(resource_frame)
self.resource_list.pack(fill=tk.X)
btn_frame = ttk.Frame(resource_frame)
btn_frame.pack(fill=tk.X)
ttk.Button(btn_frame, text="添加资源", command=self.add_resource_row).pack(side=tk.LEFT)
ttk.Button(btn_frame, text="清空所有", command=self.clear_resources).pack(side=tk.LEFT)
# 打包选项
option_frame = ttk.Frame(main_frame)
option_frame.grid(row=3, column=0, columnspan=3, sticky=tk.W, pady=5)
self.console_var = tk.IntVar(value=1)
ttk.Checkbutton(option_frame, text="隐藏控制台", variable=self.console_var).pack(side=tk.LEFT, padx=10)
self.mode_var = tk.StringVar(value="--onedir")
ttk.Radiobutton(option_frame, text="单文件夹", variable=self.mode_var,
value="--onedir").pack(side=tk.LEFT)
ttk.Radiobutton(option_frame, text="单文件", variable=self.mode_var,
value="--onefile").pack(side=tk.LEFT)
# 输出目录
ttk.Label(main_frame, text="输出目录:").grid(row=4, column=0, sticky=tk.W)
self.output_entry = ttk.Entry(main_frame, width=50)
self.output_entry.grid(row=4, column=1, padx=5)
ttk.Button(main_frame, text="浏览", command=self.browse_output).grid(row=4, column=2)
# 代码检测
ttk.Button(main_frame, text="路径合规检测", command=self.check_code).grid(row=5, column=0, pady=5)
# 打包按钮
ttk.Button(main_frame, text="开始打包", command=self.start_build).grid(row=5, column=1, pady=5)
# 日志输出
self.log_area = scrolledtext.ScrolledText(main_frame, width=80, height=15)
self.log_area.grid(row=6, column=0, columnspan=3, pady=10)
def add_resource_row(self, path=""):
row_frame = ttk.Frame(self.resource_list)
row_frame.pack(fill=tk.X, pady=2)
entry = ttk.Entry(row_frame, width=60)
entry.pack(side=tk.LEFT, padx=5)
entry.insert(0, path)
ttk.Button(row_frame, text="浏览",
command=lambda e=entry: self.browse_resource(e)).pack(side=tk.LEFT)
ttk.Button(row_frame, text="×",
command=lambda f=row_frame: self.remove_resource_row(f)).pack(side=tk.LEFT)
self.resource_entries.append(entry)
def remove_resource_row(self, frame):
for entry in self.resource_entries:
if entry.master == frame:
self.resource_entries.remove(entry)
break
frame.destroy()
def clear_resources(self):
for entry in self.resource_entries.copy():
self.remove_resource_row(entry.master)
def browse_script(self):
path = filedialog.askopenfilename(filetypes=[("Python文件", "*.py")])
if path:
self.script_entry.delete(0, tk.END)
self.script_entry.insert(0, path)
self.output_entry.delete(0, tk.END)
self.output_entry.insert(0, os.path.dirname(path))
def browse_icon(self):
path = filedialog.askopenfilename(filetypes=[("图标文件", "*.ico")])
if path:
self.icon_entry.delete(0, tk.END)
self.icon_entry.insert(0, path)
def browse_output(self):
path = filedialog.askdirectory()
if path:
self.output_entry.delete(0, tk.END)
self.output_entry.insert(0, path)
def browse_resource(self, entry):
if os.path.isdir(entry.get()):
path = filedialog.askdirectory(initialdir=entry.get())
else:
path = filedialog.askopenfilename(initialdir=os.path.dirname(entry.get()))
if path:
entry.delete(0, tk.END)
entry.insert(0, path)
def check_code(self):
script_path = self.script_entry.get()
if not script_path:
messagebox.showerror("错误", "请先选择主脚本文件")
return
try:
with open(script_path, 'r', encoding='utf-8') as f:
content = f.read()
# 创建备份
backup_path = script_path + '.bak'
shutil.copy2(script_path, backup_path)
# 更新模式以捕获更多类型的函数调用,包括pygame.image.load()
patterns = [
r"((?:[\w.]+\.)*(?:open|load|Image\.open))\((['\"])(?!(?:https?:|[\\/]|resource_path\())[^'\"]+\2\)"
]
issues = []
modified_content = content
self.log_area.delete('1.0', tk.END)
for pattern in patterns:
matches = list(re.finditer(pattern, content))
for match in reversed(matches): # 从后向前替换,以避免影响索引
if not self.is_in_comment(content, match.start()):
full_match = match.group(0)
func_name = match.group(1)
quote = match.group(2)
path = full_match[len(func_name)+2:-2] # 提取路径,去掉引号
line_number = content[:match.start()].count('\n') + 1
issues.append(f"第{line_number}行: 发现硬编码路径 - {full_match}")
replacement = f"{func_name}(resource_path({quote}{path}{quote}))"
modified_content = modified_content[:match.start()] + replacement + modified_content[match.end():]
# 详细输出替换信息
self.log_area.insert(tk.END, f"替换第{line_number}行:\n原始: {full_match}\n替换为: {replacement}\n\n")
sys_import = re.search(r"import\s+sys", content)
os_import = re.search(r"import\s+os", content)
resource_path_func = re.search(r"def\s+resource_path", content)
if not resource_path_func:
issues.append("未找到resource_path函数")
if issues:
msg = "检测到以下路径问题:\n\n" + "\n".join(issues)
self.log_area.insert(tk.END, "完整的修改后代码:\n\n" + modified_content)
if messagebox.askyesno("检测结果", msg + "\n\n修改详情已显示在日志框中。是否应用这些修改?"):
self.fix_code_issues(script_path, modified_content, sys_import, os_import, resource_path_func)
else:
messagebox.showinfo("检测结果", "代码路径处理符合规范")
except Exception as e:
messagebox.showerror("错误", f"文件读取失败:{str(e)}")
def is_in_comment(self, content, position):
line_start = content.rfind('\n', 0, position) + 1
line = content[line_start:content.find('\n', position)]
return '#' in line[:position - line_start]
def fix_code_issues(self, path, content, sys_import, os_import, resource_path_func):
imports = ""
if not sys_import:
imports += "import sys\n"
if not os_import:
imports += "import os\n"
# 改进的resource_path函数,不包含任何缩进
resource_path_code = '''
def resource_path(relative_path):
""" 获取资源的绝对路径,支持开发环境和PyInstaller打包后的环境 """
try:
# PyInstaller创建临时文件夹,将路径存储在_MEIPASS中
base_path = sys._MEIPASS
except Exception:
# 如果不是打包环境,则使用当前文件的目录
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
'''
if not resource_path_func:
# 在import语句之后添加resource_path函数
import_end = content.rfind('import')
if import_end == -1:
# 如果没有import语句,则在文件开头添加
content = imports + "\n" + resource_path_code.strip() + "\n\n" + content
else:
import_end = content.find('\n', import_end) + 1
# 确保resource_path函数与其他代码保持一致的缩进
indent = self.get_indent(content)
indented_resource_path_code = self.indent_code(resource_path_code.strip(), indent)
content = content[:import_end] + imports + "\n" + indented_resource_path_code + "\n\n" + content[import_end:]
try:
with open(path, 'w', encoding='utf-8') as f:
f.write(content)
messagebox.showinfo("成功", "代码已成功修复")
except Exception as e:
messagebox.showerror("错误", f"文件写入失败:{str(e)}")
# 在日志区域显示修改后的代码
self.log_area.delete('1.0', tk.END)
self.log_area.insert(tk.END, "修改后的代码:\n\n" + content)
def get_indent(self, content):
"""获取代码的主要缩进"""
lines = content.split('\n')
for line in lines:
if line.strip() and not line.strip().startswith('#'):
return line[:len(line) - len(line.lstrip())]
return ''
def indent_code(self, code, indent):
"""给代码块添加缩进"""
lines = code.split('\n')
indented_lines = [indent + line if line.strip() else line for line in lines]
return '\n'.join(indented_lines)
def start_build(self):
os.environ['PYTHONIOENCODING'] = 'utf-8' #处理在日志区显示的中文(如路径中的中文)乱码
if not self.script_entry.get():
messagebox.showerror("错误", "必须选择主脚本文件")
return
cmd = ["pyinstaller"]
if self.mode_var.get() == "--onefile":
cmd.append("--onefile")
else:
cmd.append("--onedir")
if self.console_var.get() == 1:
cmd.append("--noconsole")
if self.icon_entry.get():
cmd.extend(["--icon", f'"{self.icon_entry.get()}"'])
for entry in self.resource_entries:
if entry.get():
src, dest = self.parse_resource(entry.get())
if src and dest:
cmd.append(f'--add-data="{src};{dest}"')
output_dir = self.output_entry.get()
if output_dir:
cmd.extend(["--distpath", f'"{output_dir}"'])
cmd.append(f'"{self.script_entry.get()}"')
self.log_area.delete('1.0', tk.END)
self.log_area.insert(tk.END, "生成命令:\n" + " ".join(cmd) + "\n\n")
threading.Thread(target=self.run_command, args=(cmd,), daemon=True).start()
def parse_resource(self, path):
if os.path.isfile(path):
# 对于文件,保留其相对路径结构
script_dir = os.path.dirname(self.script_entry.get())
if path.startswith(script_dir):
rel_path = os.path.relpath(path, script_dir)
dest_dir = os.path.dirname(rel_path)
if not dest_dir:
dest_dir = "."
return path, dest_dir
else:
return path, "."
elif os.path.isdir(path):
# 对于目录,保留目录名
return os.path.join(path, "*"), os.path.basename(path)
return None, None
def run_command(self, cmd):
try:
process = subprocess.Popen(
" ".join(cmd),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=True,
encoding='utf-8',
errors='replace'
)
while True:
output = process.stdout.readline()
if output == '' and process.poll() is not None:
break
if output:
self.log_area.insert(tk.END, output)
self.log_area.see(tk.END)
self.log_area.update()
if process.returncode == 0:
messagebox.showinfo("完成", "打包成功完成!")
else:
messagebox.showerror("错误", f"打包失败,错误码:{process.returncode}")
except Exception as e:
messagebox.showerror("异常", f"执行出错:{str(e)}")
def redirect_output(self):
class StdoutRedirector:
def __init__(self, text_widget):
self.text_widget = text_widget
def write(self, message):
self.text_widget.insert(tk.END, message)
self.text_widget.see(tk.END)
sys.stdout = StdoutRedirector(self.log_area)
sys.stderr = StdoutRedirector(self.log_area)
if __name__ == "__main__":
root = tk.Tk()
app = PyInstallerGUI(root)
root.mainloop()
最后,特别提示,本程序是PyInstaller外壳,因此,必须先安装PyInstaller。