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 shutilclass PyInstallerGUI:def __init__(self, root):self.root = rootself.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)breakframe.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("错误", "请先选择主脚本文件")returntry: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 = contentself.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') + 1issues.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) + 1line = 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._MEIPASSexcept 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" + contentelse: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("错误", "必须选择主脚本文件")returncmd = ["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_direlse:return path, "."elif os.path.isdir(path):# 对于目录,保留目录名return os.path.join(path, "*"), os.path.basename(path)return None, Nonedef 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:breakif 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_widgetdef 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。