小记 | 使用 PyInstaller 打包和交付 Python 项目

发布时间 2024-01-09 10:18:14作者: kingron

PyInstaller 可以将 Python 项目打包成一个可执行文件,或是一个文件夹,包含可执行文件以及依赖包。方便我们将 Python 项目交付给用户,方便用户使用的同时也可以一定程度的保护项目源代码。本文将介绍如何简单使用 PyInstaller 打包。

安装

使用 pip 安装即可:

pip install pyinstaller

简单使用

让我们新建一个项目命名为 miniapp,文件结构如下:

miniapp
	app
		__init__.py
		app.py

其中项目核心文件为 app.py,内容如下:

def run():
    print('欢迎使用 miniapp')


if __name__ == '__main__':
    run()

现在,进入到项目根目录 miniapp,运行如下命令:

pyinstaller -D app/app.py -n miniapp --clean

初次运行可能会出现下面的错误:

OSError: Python library not found: libpython3.7.so.1.0, libpython3.7mu.so.1.0, libpython3.7m.so, libpython3.7.so, libpython3.7m.so.1.0
    This means your Python installation does not come with proper shared library files.
    This usually happens due to missing development package, or unsuitable build parameters of the Python installation.

    * On Debian/Ubuntu, you need to install Python development packages:
      * apt-get install python3-dev
      * apt-get install python-dev
    * If you are building Python by yourself, rebuild with `--enable-shared` (or, `--enable-framework` on macOS).

这是因为 PyInstaller 需要 python-dev 环境,如果是使用 pyenv 可以用以下命令重新安装 Python:

env PYTHON_CONFIGURE_OPTS="--enable-shared" pyenv install 3.5.0

或者,可以安装 python-devel 版本,比如在 CentOS 上可以通过下面的命令查看可用版本:

# yum search python | grep devel
...
python3-devel.i686 : Libraries and header files needed for Python development
...

安装 python3-devel.i686 即可。

成功运行后目录下会多出来一些文件,现在的结构如下:

miniapp
	app
		__init__.py
		app.py
	build
		miniapp
			...
	dist
		miniapp
			_internal
				...
			miniapp
	miniapp.spec

其中 dist/miniapp/miniapp 就是打包出来的可执行文件了,我们现在可以将 dist/miniapp 整个文件夹压缩并打包交付给用户。用户拿到后解压并运行 miniapp 文件即可。以下是运行后的结果

# ./dist/miniapp/miniapp
欢迎使用 miniapp

也可以通过指定 -F 参数将依赖包与项目文件打成一个文件:

pyinstaller -F app/app.py -n miniapp --clean

此时可执行文件路径为:

# ./dist/miniapp
欢迎使用 miniapp

相对与单个文件的形式,单个文件夹在启动时会更快,而且在后期更新时,在没有依赖更新的情况下,仅需要交给客户项目文件即可,可以一定程度减少文件传输。

打包模块

一般来说,我们的项目很少会只有一个文件。现在,让我们为 miniapp 创建一个新的文件 core.py,作为核心逻辑的存放位置。现在的目录结构如下:

miniapp
	app
		__init__.py
		app.py
		core.py
	...

core.py 文件内容为:

def hello():
    print('这里是核心文件')

通常情况下,如果我们在 app.py 中有导入 app.core 的话,PyInstaller 会在打包时将 core.py 也一同编译进去,但若是涉及到动态导入的话,则 core.py 文件会缺失导致导入失败,比如现在将 app.py 逻辑更新如下:

import importlib


def run():
    print('欢迎使用 miniapp')
    core = importlib.import_module('app.core')
    core.hello()

if __name__ == '__main__':
    run()

再次打包并运行,会报错找不到模块 app

# pyinstaller -D app/app.py -n miniapp --clean
# ./dist/miniapp/miniapp
Traceback (most recent call last):
  File "app/app.py", line 39, in <module>
    run()
  File "app/app.py", line 34, in run
    core = importlib.import_module('app.core')
  File "importlib/__init__.py", line 127, in import_module
  File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
  File "<frozen importlib._bootstrap>", line 972, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
  File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
  File "<frozen importlib._bootstrap>", line 984, in _find_and_load_unlocked
ModuleNotFoundError: No module named 'app'
[15561] Failed to execute script 'app' due to unhandled exception!

可以通过指定 --hiddenimport 参数告诉 PyInstaller app 模块需要被一块打包,通过这样的方式再次打包后就可以正常运行了:

pyinstaller -D app/app.py -y -n miniapp --clean --hiddenimport app.core

使用 hooks

如果只有一个 app.core 是动态导入,通过传入 --hiddenimport 即可,如果项目中存在多个模块或依赖库存在动态导入,那么命令行的参数只会越来越长变得难以阅读,为此 PyInstaller 提供了 hook 模式可以便于我们通过 Python 文件维护这些动态导入的模块。

下面效仿 django 为项目添加一个 backends 模块,分别提供了对 MySQL 和 Oracle 数据库的支持,现在项目结构如下:

miniapp
	app
		__init__.py
		app.py
		core.py
		backends
			__init__.py
			mysql
				__init__.py
			oracle
				__init__.py
	...

app.py 中增加对支持的数据库的加载并打印到屏幕:

import importlib
import os
from pkgutil import iter_modules

support_backends = ()


def load_support_backends():
    global support_backends

    backends = importlib.import_module('app.backends')

    modules = []
    modpath = os.path.dirname(backends.__file__)
    for _, subpath, ispkg in iter_modules([modpath]):
        if not ispkg:
            continue
        module = importlib.import_module('app.backends.' + subpath)
        modules.append((subpath, module))

    support_backends = tuple(modules)


def run():
    print('欢迎使用 miniapp')

    core = importlib.import_module('app.core')
    core.hello()

    print('正在加载可用的数据库')
    load_support_backends()

    print('可用的数据库有: %s' % ', '.join(path for path, _ in support_backends))


if __name__ == '__main__':
    run()

现在在项目中新增一个文件夹用于管理 hooks,命名为 pyinstallerhooks,项目结构如下:

miniapp
	app
		...
	pyinstallerhooks
	...

pyinstallerhooks 下新建一个名为 hook-app.py 的文件,内容如下:

from PyInstaller.utils.hooks import collect_submodules

hiddenimports = collect_submodules('app')

然后使用命令重新打包,指定 --additional-hooks-dirpyinstallerhooks 文件夹路径,同时将 --hiddenimport 改为 app

pyinstaller -D app/app.py -y -n miniapp --clean --hiddenimport app --additional-hooks-dir pyinstallerhooks

再次运行,可以看到不管是 app.backends 还是 app.core 模块都有被正常加载:

# ./dist/miniapp/miniapp
欢迎使用 miniapp
这里是核心文件
正在加载可用的数据库
可用的数据库有: oracle, mysql

如果我们想要提供的包只支持 oracle,那么可以在 pyinstallerhooks 中对 app.backends 模块做更精细的控制。首先修改 hook-app.py 为:

from PyInstaller.utils.hooks import collect_submodules


def filter_backends(name):
    """排除 app.backends 下面的非 oracle 子模块"""
    if not name.startswith('app.backends'):
        return True
    
    return name.startswith(('app.backends.oracle'))


hiddenimports = collect_submodules('app', filter=filter_backends)

如果想对 app.backends.oracle 下面的子模块提供更进一步的控制,可以在 pyinstallerhooks 下面新建一个 hook-app.backends.oracle.py 文件,并写上具体逻辑,PyInstaller 会在打包时自动找到该文件并应用,同理其他模块也是如此。

再次打包并运行,结果如下:

# ./dist/miniapp/miniapp
欢迎使用 miniapp
这里是核心文件
正在加载可用的数据库
可用的数据库有: oracle

添加静态文件

项目增加了一些模板文件需要提供给用户,放在 templates 目录下:

miniapp
	app
		__init__.py
		app.py
		core.py
		backends
			...
		templates
			config.txt
	...

那么如何将 templates 下面的文件能被一起打包并被程序识别到呢,可以通过 --add-data 参数将文件放入指定的相对路径,现在将打包命令更改为:

pyinstaller -D app/app.py -y -n miniapp --clean --hiddenimport app --additional-hooks-dir pyinstallerhooks \
--add-data "./app/templates/:./templates"

其中 : 前的 ./app/templates/ 是我们打包时 templates 所在的相对路径,: 后的 ./templates 是期望打包后文件所在的路径。后者的 . 代表了打包后项目运行时的根目录。

app.py 文件修改为如下以获取可用的模板文件:

import importlib
import os
from pkgutil import iter_modules

support_backends = ()

# 获取项目的根路径
BASE_DIR = os.path.dirname(os.path.abspath(__file__))


def load_support_backends():
    global support_backends

    backends = importlib.import_module('app.backends')

    modules = []
    modpath = os.path.dirname(backends.__file__)
    for _, subpath, ispkg in iter_modules([modpath]):
        if not ispkg:
            continue
        module = importlib.import_module('app.backends.' + subpath)
        modules.append((subpath, module))

    support_backends = tuple(modules)


def run():
    print('欢迎使用 miniapp')

    core = importlib.import_module('app.core')
    core.hello()

    print('正在加载可用的数据库')
    load_support_backends()

    print('可用的数据库有: %s' % ', '.join(path for path, _ in support_backends))

    templates_path = os.path.join(BASE_DIR, 'templates')
    print('可用的模板文件有: %s' % ', '.join(os.listdir(templates_path)))


if __name__ == '__main__':
    run()

重新打包并运行,可以看到模板文件已经被正常加载:

# ./dist/miniapp/miniapp
欢迎使用 miniapp
这里是核心文件
正在加载可用的数据库
可用的数据库有: oracle
可用的模板文件有: config.txt

自省

如何判断当前代码是在打包后的环境运行,还是非打包环境运行呢,可以通过如下方式:

import sys

if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
    print('正在 PyInstaller 打包环境中运行')
else:
    print('正在非打包环境运行')

PyInstaller 会在运行时将打包文件放在 sys._MEIPASS 所指向的路径下,对于以文件夹方式打包的项目,该路径实际上就是 ./dist/miniapp/_internal,而对于单个文件的方式,这个路径实际上指向的是某个临时文件夹路径(比如 Linux 下的 /tmp/..),如果项目需要生成一些需要保留的文件,可以通过参数 --runtime-tmpdir 重新指定该路径。

参考:

更多用法可参考官方文档:PyInstaller Manual — PyInstaller 6.3.0 documentation