当前位置: 首页 > article >正文

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。


http://www.kler.cn/a/599656.html

相关文章:

  • GNSS(GPS、北斗等)与UWB的融合定位例程,matlab,二维平面,使用卡尔曼滤波
  • docker-dockerfile书写
  • Elasticsearch 面试备战指南
  • Siri接入DeepSeek快捷指令
  • JAVA小项目:拼图游戏(简单易懂可上手)
  • 6.1、认证技术基础与原理
  • 基于CentOS系统搭建Samba服务
  • 【第21章】亿级电商订单系统架构-详细设计
  • 常见框架漏洞—Shiro
  • 【大模型LLM第十四篇】Agent学习之anthropic-quickstarts Agent
  • Lisp语言的学习路线
  • 使用现有三自变量和腐蚀速率数据拟合高精度模型,并进行预测和自变量贡献度分析的一般步骤
  • 聊聊langchain4j的HTTP Client
  • 力扣刷题78. 子集
  • python 中match...case 和 C switch case区别
  • 前端传来的不同类型参数,后端 SpringMVC 怎么接收?
  • 【Unity Shader编程】之透明物体渲染
  • 【VUE】day07 路由
  • FFmpeg6.1.1 MSYS2+GCC 源码编译
  • 【Java SE】单例设计模式