Life, half is memory, half is to continue.
python多文件打包exe并实现自动更新
By Vincent. @2023.11.2
python多文件打包exe并实现自动更新

前言:最近项目上需要设计一个自动分析工具,要求做出来后在每个使用者(同事)电脑上都能独立运行,且方便自动更新。为此特意研究了pyinstaller来实现多文件打包成exe并能自动更新。

一、pyinstaller安装和使用说明

在pycharm里搜索pyinstaller安装,或在terminal界面输入如下安装:

pip install pyinstaller

安装完成后,可用pyinstaller将一个或多个py文件打包成exe,并可根据不同需求生成不同打包形式,同时在terminal界面输入如下指令查看各代码对应的形式。

pyinstaller -h

现例举几个最常用的指令加以解释说明,

codemeaningexplanation
-DCreate a one-folder bundle containing an executable (default).生成一个目录(包含多个文件)作为可执行文件
-FCreate a one-file bundled executable.生成单个可执行文件
–specpath DIRFolder to store the generated spec file (default: current directory)指定目录存放spec文件
-nName to assign to the bundled app and spec file (default: first script’s basename)指定项目名字
-p DIRA path to search for imports (like using PYTHONPATH). Multiple paths are allowed, separated by ‘;’, or use this option multiple times.设置 Python 导入模块的路径(和设置 PYTHONPATH 环境变量的作用相似)。也可使用路径分隔符(Windows 使用分号,Linux 使用冒号)来分隔多个路径
-dProvide assistance with debugging a frozen application.debug版本的可执行文件
-cOpen a console window for standard i/o (default).指定使用命令行窗口运行程序
-wdo not provide a console window for standard i/o.指定程序运行时不显示命令行窗口
-i <FILE.ico or FILE.exe,ID or FILE.icns or Image or “NONE”>FILE.ico: apply the icon to a Windows executable.
FILE.exe, ID:extract the icon with ID from an exe.
FILE.icns: apply the icon to the .app bundle on Mac OS.
添加图标
-m <FILE or XML>Add manifest FILE or XML to the exe.添加manifest文件或XML文件
-rAdd or update a resource to a Windows executable.添加或更新资源到可执行文件

二、多文件打包成exe

关于多个文件打包成exe,各博客大佬汇总出的方法主要是2种:

1、pyinstaller [主文件] -p [其他文件1] -p [其他文件2]

2、使用spec方式

本文将依次试验这两种方法并探究各自的实用性和优缺点。为此本文举一个例子说明:设计一个程序用于计算某打工人扣除五险一金+个税后的实际到手收入。

程序结构如下:

# main.py
import os
from ensurance import calcu5_ensure
from income_tax import calcu_tax
​
if __name__ == '__main__':
    base, extra = input("please input tax before income:").split()
    base, extra = float(base), float(extra)
    ensure5 = calcu5_ensure(base)
    inc_tax = calcu_tax(base)
    tax_after = base + extra - ensure5 - inc_tax
    print(f"Final tax-after income is {tax_after}.")
    temps = input("\n")
# ensurance.py
def calcu5_ensure(tax_before):
    five_per = [0.08, 0.02, 0, 0.003, 0, 0.12]
    five_per_count = [round(i * tax_before, 2) for i in five_per]
    return sum(five_per_count)
# income_tax.py
def calcu_tax(base):
    return base * 0.1

接下来就分别使用这两种方式进行打包。

2.1 pyinstaller [主文件] -p [其他文件1]

输入如下

pyinstaller main.py -p ensurance.py -p income_tax.py

打包成功!

并在.\dist\main路径下可找到main.exe文件,

双击exe文件可正常运行,

但是,针对于文件较多的时候使用-p的方法就比较麻烦,这种时候可采用方案2。

2.2 pyinstaller xx.spec

① 只打包主程序mian.py以生成.spec文件并只保留这个文件,删除目录下生成的dist和build文件

pyinstaller -F main.py

② 更改.spec文件内容,补充想要一并打包的py文件或其他resource文件等。spec文件内容如下(步骤①若选了-F则不会有coll)

式中参数的含义如下,

其中a和exe里的改动较多,pyz和coll基本没有要改的。且针对于a和exe中主要参数的含义如下,

回到刚才的例子,.spec文件需要修改第一行元素,

而后在terminal中输入,

pyinstaller main.spec

此时在.\dist路径下就能找到打包好了的main.exe,经验证该方法生成的exe也可正常运行。

三、exe自动更新

根据项目需求结合我司已有的远端服务器,假定主程序是main.exe,在每次更新的版本后我都会生成一个标明当前版本的xx.txt文件,升级工具是AutoClient.exe,他们的存放形式如下,

我的设计思路如下,

Fig 3.1

解读一下:

核心逻辑是运行AutoClient.exe这个升级工具,判断本地路径下的程序版本(读取xx.txt可识别)和远端路径下的版本是否一致,若不一致则需删除旧版本、下载新版本;若一致则无需更新。最后再调用最新版本的main.exe启动主程序。

所以第一步要先访问到服务器。

3.1 python连接到服务器

博客大佬有用NFS,FTP等服务器都可以实现,本文连接的是SFTP服务器。

安装paramiko库即可调用sftp,为方便调用我特意将sftp常用的几个功能封装在了MySFTP()类里,

class MySFTP():
    def __init__(self):
        self.sftp = None
        self.ssh = paramiko.SSHClient()
        self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
​
        # 重新设置下编码方式
        self.ssh.encoding = 'gbk'
        self.log_file = open("log.txt", "a")
        self.file_list = []
​
    def login(self, host_name, port, user_name, password):
        """
​
        :param host_name:
        :param port:
        :param user_name:
        :param password:
        :return:
        """
        try:
            self.debug_print('开始尝试连接到 %s' % host_name)
            self.ssh.connect(host_name, port, user_name, password)
            self.debug_print('成功连接到 %s' % host_name)
​
            self.debug_print('开始尝试登录到 %s' % host_name)
            self.sftp = self.ssh.open_sftp()
            self.debug_print('成功登录到 %s' % host_name)
        except Exception as err:
            self.deal_error("SFTP 连接或登录失败 ,错误描述为:%s" % err)
​
    def upload(self, local_path, remote_path):
        self.sftp.put(local_path, remote_path)
        self.debug_print('文件上传成功')
​
    def download(self, remote_path, local_path):
        self.sftp.get(remote_path, local_path)
        self.debug_print('文件下载成功')
​
    def delete(self):
        pass
​
    def listdir(self, remote_path):
        return self.sftp.listdir(remote_path)
​
    def debug_print(self, s):
        """ 打印日志
        """
        self.write_log(s)
​
    def write_log(self, log_str):
        """ 记录日志
            参数:
                log_str:日志
        """
        time_now = time.localtime()
        date_now = time.strftime('%Y-%m-%d %H:%M:%S', time_now)
        format_log_str = "%s ---> %s \n " % (date_now, log_str)
        print(format_log_str)
        self.log_file.write(format_log_str)
​
    def deal_error(self, e):
        """ 处理错误异常
            参数:
                e:异常
        """
        log_str = '发生错误: %s' % e
        self.write_log(log_str)
        sys.exit()

3.2 记录版本

将版本信息写入txt文件名上,将版本改动内容记录在txt文件里。

def write_version_txt():
    """
    保留每一版的修改信息
    :return:
    """
    text_dic = {}
    # v1版改动内容————————————————————————————————————————————————————————————————
    cur_version = 'version_v1'
    text_dic[cur_version] = "Establish the color analysis tool."
​
    # v2版改动内容————————————————————————————————————————————————————————————————
    # cur_version = 'version_v2'
    # text_dic[cur_version] = "Change the color std."
​
    # 汇总各个版本的改动,生成txt
    cur_ver_txt = f"{cur_version}.txt"
    # if os.path.exists(cur_ver_txt):
    #     os.remove(cur_ver_txt)
    with open(cur_ver_txt, "w") as f:
        json_str = json.dumps(text_dic, indent=0, ensure_ascii=False)
        f.write(json_str)
        f.write('\n')
    print(f"{cur_ver_txt} is generated!")

3.3 AutoClient.py实现自动更新逻辑

class UpdateFile():
    def __init__(self, local_folder, remote_folder):
        self.local_folder = local_folder
        self.remote_folder = remote_folder
        self.func = MySFTP()
        host_name, port, user_name, password = 'xx, 22, 'yy', 'zz'  # 此处需填写SFTP域名、端口、账号、密码
        self.func.login(host_name, port, user_name, password)
​
    def down_pkg(self):
        local_pkg_folder = self.local_folder + '/package/'
        remote_pkg_folder = self.remote_folder + '/package/'
        os.mkdir(local_pkg_folder)
        files = self.func.listdir(remote_pkg_folder)
        for file in files:
            self.func.download(remote_pkg_folder + file, local_pkg_folder + file)
​
    def get_cur_version(self):
        self.package_folder = self.local_folder + '/package'
        # 【1】get current version
        files = os.listdir(self.package_folder)
        local_ver_file = [file for file in files if 'version' in file][0]
        pattern = re.compile("version_(.*?).txt")
        cur_version = pattern.findall(local_ver_file)[0]
        print(f"Current version is {cur_version}")
        return cur_version
​
    def init_run(self):
        self.func.download(remote_folder + '/AutoClient.exe', local_folder + '/AutoClient.exe')
        self.down_pkg()
        self.func.debug_print(f"下载成功!")
​
    def update_run(self):
        files = self.func.listdir(remote_folder + '/package')
        remote_ver_file = [file for file in files if 'version' in file][0]
        pattern = re.compile("version_(.*?).txt")
        remote_version = pattern.findall(remote_ver_file)[0]
​
        cur_version = self.get_cur_version()
        if cur_version != remote_version:
            shutil.rmtree(self.package_folder)
            self.down_pkg()
        self.func.debug_print(f"{remote_version}更新成功!")
​
​
if __name__ == '__main__':
    # 【0】set local folder
    local_folder = 'D:/Test'
    remote_folder = '/xx/Test'
    obf = UpdateFile(local_folder, remote_folder)
    try:  # 【1】 download or update
        if not os.path.exists(local_folder):  # first download
            os.mkdir(local_folder)
            obf.init_run()
        else:  # update
            obf.update_run()
    except Exception as e:  # 【2】 deal exception
        print(e)
        shutil.rmtree(local_folder)
        os.mkdir(local_folder)
        obf.init_run()
    os.startfile(local_folder + '/package/main.exe')  # 【3】launch the main.exe

这段代码实现的就是Fig 3.1中的自动更新逻辑。

3.4 打包生成AutoClient.exe

步骤同第二章。

①生成.spec,删除dist和build文件夹

pyinstaller -F AutoClient.py

②修改.spec文件,[‘AutoClient.py’] → [‘AutoClient.py’, ‘sftp_connect.py’]

③打包成AutoClient.exe

pyinstaller AutoClient.spec

3.5 测试验收

① 将AutoClient.exe和package(目录下是main.exe和version_v1.txt)上传到服务器上。

② 在本地随机路径下运行AutoClient.exe(保持联网),将会在本地D盘生成一个D:\Test的文件夹,里面包含了package子文件夹和AutoClient.exe

③(无需任何操作)等下载完成后,会弹出cmd命令窗界面,按提示输入税前base和加班费,代码会返回计算结果即税后收入。

④ 更新代码,生成新的exe。

# 据相关部门规定,加班费收入也要纳税所以将加班费也计入应纳税额
inc_tax = calcu_tax(base + extra)

⑤将新的exe和version_v2.txt上传到服务器上。

⑥运行AutoClient.exe(保持联网),看程序是否会自动更新。

⑦结果展示,不出所料成功更新到v2版本,而且运行后加班费也扣了200的税╮(╯▽╰)╭

扫码分享收藏
扫码分享收藏