简介PyInstaller 安装包是一份面向 Python 开发者的打包工具资源用于将 Python 脚本及其依赖转换为独立的可执行文件使程序在不安装 Python 解释器的环境中也能直接运行解决软件分发和部署过程中的环境依赖问题。当前压缩包共包含19个文件主要由 py 脚本、txt 说明、pkg-info 元数据、cfg 配置等类型构成核心脚本承担打包入口txt 测试用例与说明文档覆盖基本功能验证pkg-info 记录模块依赖关系整体体积仅 37KB结构轻量适合学习早期 PyInstaller 的打包逻辑与配置写法。已有 587 人学习下载可供需要理解打包原理、准备离线安装包或进行打包测试的初学者直接参考。通过解压阅读其中的源码、配置项和测试文件能够观察依赖分析、构建参数等关键流程也可复用其目录结构来梳理自己的打包项目。1. 先把话说在前面PyInstaller 安装包解决的是“没有 Python 也能跑”的交付问题给同事发一个你写的 Python 小工具最怕的不是代码写得糙而是他那台电脑没装 Python 环境。PyInstaller 就是用来干这事的把解释器、第三方库和你的脚本一起打进一个可执行文件Windows 上是 .exeLinux 上是可执行二进制macOS 上是 .app。这个安装包能让不装 Python 的人双击就能跑也能让你把一个 Flask 服务装到内网机器上顺手把源码藏着。不过 PyInstaller 不是傻瓜按钮你真拿去打包会遇到缺模块、闪退、被杀毒软件误报这些问题。这篇文章就按一条完整的打包流程把命令、参数、资源文件和踩坑点一次讲清楚。2. 装好 PyInstaller 并跑通第一个打包命令从 pip 安装到单文件 exe 出现的完整流程2.1 安装前的环境判断Python 版本、架构与虚拟环境PyInstaller 目前对 Python 3.8 到 3.12 的支持比较完整3.13 在部分平台也能跑但我不建议在交付项目里用刚出的大版本。你要先确认两件事一是你的 Python 是多少位的另一个是你在哪个平台打包。PyInstaller 不支持跨平台交叉编译在 Windows 上打不出 Linux 的包反过来也不行。目标机器是什么系统打包机最好就是什么系统。然后是虚拟环境。这一点我放在最前面说因为绝大多数打包翻车都和环境有关。如果你直接在全局环境里pip install pyinstallerPyInstaller 会把你全局环境里所有 pip 装过的库都扫一遍有些是显式依赖有些只是碰巧装过的。比如你只是写了个处理 Excel 的小脚本全局环境里有 numpy、pandas 全家桶打出来的包会白白胖胖体积虚高还容易被杀毒软件盯上。正确做法是在项目目录单独建一个虚拟环境把运行需要的依赖装进去再用这个环境里的 pyinstaller 打包。# 创建虚拟环境目录 .venv python -m venv .venv # Windows 下激活 .venv\Scripts\activate # Linux/macOS 下激活 source .venv/bin/activate # 升级 pip 并安装业务依赖 python -m pip install --upgrade pip pip install requests openpyxl逻辑说明venv 创建后所有 pip 命令都只作用在这个环境里。安装 pyinstaller 之前先把业务依赖装好这样 PyInstaller 的依赖分析器会以虚拟环境里的包为准避免把无关库卷进来。这里有个细节虚拟环境不要建在项目之外再改 PYTHONPATH那样分析器会扫描到意外路径我们就让项目目录和环境目录保持邻居关系简单直接。参数说明python -m venv后面的.venv是目录名你可以换成任意名字但不要用中文。如果你发现激活后命令提示符前面没有出现(.venv)说明激活失败了最常见原因是 Windows 的 PowerShell 执行策略限制需要先执行Set-ExecutionPolicy -Scope CurrentUser RemoteSigned再重新激活。2.2 安装 PyInstaller 的两种方式并验证安装PyInstaller 安装方式其实就两种pip 直接装或者下载源码离线装。大部分场景 pip 就够了。但如果你所在的开发环境完全隔离外网离线安装才需要找源码包那不是常态。# 安装最新版 pip install pyinstaller # 指定版本保证构建可复现 pip install pyinstaller6.6.0 # 验证安装 pyinstaller --version逻辑说明指定版本主要是为了让团队的构建脚本可复现。同一个项目PyInstaller 6.x 和 5.x 打出来的包体积、启动行为、hook 覆盖范围都有差异。我曾经遇到过一个项目5.x 打包后一切正常换到 6.0 后原有的自定义 hook 不生效了回退版本才恢复。所以不追新也是一个可行策略。参数说明如果 pip 安装时出现externally managed environment报错说明你用到了系统自带的 Python它限制了 pip 往全局目录写文件。这时候优先用虚拟环境而不是加--break-system-packages强闯。强闯会污染系统环境之后你再装别的版本就会遇到一堆说不清的冲突。2.3 最小打包命令从脚本到可执行文件假设你有一个最简单的脚本app.py内容就是打印一句话。打包方式如下pyinstaller -F -n hello app.py参数说明-F表示生成单文件可执行程序它会运行时把所有依赖解压到临时目录再执行-n指定输出名不写默认用脚本名。执行完之后dist目录下会出现hello.exeWindows或同名二进制这就是安装包本体。逻辑说明PyInstaller 执行时会依次做依赖分析、hook 处理、资源收集、二进制压缩。命令跑起来以后你会看到大量INFO:开头的日志比如Analyzing: app.py、Processing pre-safe-import-module hook这些不是错误是分析阶段在扫描 import 关系。等日志走到Building EXE from EXE-00.toc说明已经进入最后的写二进制阶段这一步通常需要十几秒到几分钟取决于依赖大小。# 加上 --clean 和 -y避免缓存干扰 pyinstaller --clean -y -F -n hello app.py--clean会在打包前清掉上一轮的中间缓存-y表示对生成的 dist/build 目录直接覆盖不弹交互确认。建议每次打包都带上这两个参数尤其在代码改了很多之后否则有可能用到一个旧缓存的 toc 文件导致新资源没进去。2.4 先看一眼生成的 build 与 dist 目录别急着交付刚打出第一个包很多人的习惯是直接双击。我更建议先把目录结构摸一遍因为后面排查问题时会一直用到这几个目录。# 查看 dist 目录结构 ls -R dist # 查看构建过程里的警告文件 cat build/hello/warn-hello.txt逻辑说明build目录是 PyInstaller 的中间产物里面有一个warn-*.txt文件记录了依赖分析阶段所有“找不到”或“可疑”的模块。比如missing module named resource - imported by ...这类警告大量存在绝大多数是无害的因为有些标准库模块在特定平台上本来就不存在PyInstaller 只是记录一下。但如果警告里出现了你自己代码 import 的某个包那就说明它没被打进去后面必然报错。dist目录里是真正的交付物。单文件模式下只有一个 exe目录模式下会有一堆 dll、pyd 和 exe。你要记住一个结论exe 只是一个引导程序单文件模式下它启动时会把内置的库解压到系统临时目录目录名一般以_MEI开头运行完自动清理。你的程序如果用了相对路径读取旁边的文件这个阶段就会踩坑我们会到第 4.3 节细说。3. 打包参数怎么设从单文件到目录模式再到自定义图标和版本信息3.1 -F 与 -D 的取舍什么时候用单文件什么时候用目录打包第一个包时我用了-F但这只是最省事的模式。真正生产环境我要看交付场景来选。PyInstaller 的-D模式是默认的输出一个目录里面放着 exe 和它依赖的库-F是把这些全部塞进一个 exe。参数输出启动速度分发方式适用场景-F单个 exe慢需要先解压拷贝一个文件方便给客户、给非技术同事双击即用-D目录含 exe 和若干 dll/pyd快直接加载压缩成 zip 后发送自己使用、内网部署、服务端-F模式最大的问题在于程序运行时会产生一个临时解压目录如果杀毒软件盘查严格解压过程会有几秒延迟甚至被误报。而-D模式更贴近普通软件安装后的形态exe 身边就是它的 dll路径关系清晰调试也方便。我给非技术客户端强制用-F给自己用的自动化脚本或服务端进程用-D。补充一点很多人以为-F打出的单文件体积比-D小其实不会。-F只是把同样的内容压进一个文件里解压后大小相当磁盘上省一个文件而已。如果你以“体积小”为理由选-F这个理由站不住。3.2 --name、--icon、--version-file 三个最常用的外观参数PyInstaller 允许你自定义安装包的名字、图标和版本信息这三个参数虽然不影响运行但直接决定交付时的专业度。pyinstaller -F -w -n 数据清洗助手 --iconlogo.ico --version-fileversion_info.txt app.py逻辑说明-w是窗口模式适合纯 GUI 脚本不弹控制台黑框如果你还要看日志就不要加-w让它以控制台模式运行错误能直接打在终端里。--icon只对 Windows 上的 exe 生效图标文件要求.ico格式直接改 png 后缀不好用得用转换工具处理。--version-file需要一个文本文件描述版本信息格式比较死手写容易错。版本信息文件标准格式大致如下VSVersionInfo( ffiFixedFileInfo( filevers(1, 0, 0, 0), prodvers(1, 0, 0, 0), mask0x3f, flags0x0, OS0x40004, fileType0x1, subtype0x0, date(0, 0) ), kids[ StringFileInfo( [ StringTable( u040904B0, [StringStruct(uFileDescription, u数据清洗助手), StringStruct(uProductName, uDataCleaner), StringStruct(uFileVersion, u1.0.0), StringStruct(uProductVersion, u1.0.0)]) ]) ] )这段是 Windows 版本信息资源的 Python 结构化描述。不建议你从零手敲我一般先从一个现成项目复制一份改掉里面的名称和版本号。如果不想折腾可以暂时不写--version-file等稳定下来再补。右键 exe 点属性能看到产物名、版本、图标这些信息在客户那里是一种信任度信号。3.3 用 --hidden-import、--collect-all、--paths 应对依赖缺失命令敲多了你会发现PyInstaller 没法识别所有依赖尤其是这几种情况代码里用importlib.import_module()动态加载模块外部插件式框架在运行时才拼接模块名某些模块放在非标准路径下。解决方法分三类。# 显式指定一个需要打包的模块 pyinstaller -F --hidden-importwin32timezone --hidden-importdotenv app.py # 把整个包及其子模块、数据文件全部收集进来 pyinstaller -F --collect-allpandas app.py # 追加 Python 模块搜索路径 pyinstaller -F --paths./libs --paths/home/user/custom lib app.py参数说明--hidden-import包名可以重复写多次每次强制添加一个模块。--collect-all是--collect-data、--collect-binaries、--collect-submodules三个参数的合体适合 pandas、scipy 这类内部存在大量动态加载的库。代价是体积变大因为会把不必要的子模块也收进来。如果你确定只缺一两个模块优先用--hidden-import这样更克制。--paths很少被新手注意但它能解决一类很实际的问题你的业务代码分在多个目录不把它们都放进 sys.path 里PyInstaller 就找不到 import。比如项目结构是main.py和common/平级而代码里写from common import utils打包时加一条--paths./就能稳住。3.4 打包配置固化用 spec 文件代替一长串命令命令行参数一多就容易忘而且团队里两个人打包参数不一致出来的行为就不同。PyInstaller 每次运行都会在项目目录生成一个.spec文件它是一个 Python 语法文件描述打包的完整配置。之后只需要pyinstaller hello.spec不用再敲一长串命令。spec 文件关键结构如下a Analysis( [app.py], pathex[.], binaries[], datas[(config.json, .), (assets, assets)], hiddenimports[pandas._libs.tslibs], hookspath[], runtime_hooks[], excludes[tkinter], noarchiveFalse, ) pyz PYZ(a.pure) exe EXE( pyz, a.scripts, a.binaries, a.datas, [], name数据清洗助手, debugFalse, iconlogo.ico, consoleFalse, )参数说明Analysis里的datas是资源文件列表每一项是(源路径, 目标目录)元组等价于命令行的--add-data。excludes填上你确定不要的包比如很多命令行工具没必要带 tkinter填上后体积能明显降下来。EXE部分的consoleFalse等价于命令行的-w含义是不要控制台窗口。逻辑说明spec 是真正的 Python 文件所以你可以用变量、条件判断比如根据sys.platform给 Windows 和 Linux 分别设置不同console值。我一般会把版本号写成一个变量生成产物名用f{NAME}-{VERSION}拼出来以后每次发版只改一处。4. 打包过程中的常见问题排查与避坑从缺模块到被杀毒软件误报4.1 ModuleNotFoundError为什么本地能运行打包后找不到包现象本地跑一切正常打包完 exe 一运行提示ModuleNotFoundError: No module named xxx。原因这个模块是通过__import__、importlib或字符串拼接方式动态导入的PyInstaller 静态分析时没有抓到另一种情况是模块本身依赖了一个非 Python 的动态库而 PyInstaller 没有把它识别成二进制依赖。解决顺序第一步打开build/工程名/warn-工程名.txt搜索这个模块名看它是否出现在“missing module”列表里。第二步用--hidden-import显式加进打包列表。第三步如果加了仍报错说明这个包还有子模块或数据文件缺失换--collect-all包名整体收进来。第四步还是不行的确认你打包时激活了虚拟环境实际生效的解释器和你本地运行用的解释器是不是同一个。pyinstaller -F --hidden-importdotenv --hidden-importwin32timezone app.py逻辑说明win32timezone是 pywin32 里一个经常被漏掉的子模块典型例子。dotenv在 Flask 或 pydantic 场景下会被动态引用PyInstaller 有时候捕捉不到。这些都是玄学背后有规律的好案例先看 warning 文件再动手加参数不要凭感觉猜。4.2 打包后的 exe 启动秒退闪退问题的定位思路现象双击 exe屏幕闪一下命令框就没了什么信息都没看到。原因程序启动后立刻抛异常进程退出。如果打包时用了-w窗口模式控制台被隐藏traceback 全部吞掉你连报错内容都看不到。解决排错阶段不要用-w先打成控制台模式再到命令行里运行 exe让错误打印在当前终端里。# 以目录模式和控制台模式打包 pyinstaller -D app.py # 在命令行运行 exe dist/app/app.exe如果命令行里也没有报错还有一种很隐蔽的情况你的脚本顶部执行了会触发执行代码的 import而这个 import 访问了路径资源但资源还没解压好导致OpenError。另外如果脚本里用了multiprocessingWindows 上会递归启动子进程需要搭配if __name__ __main__:保护否则打包后必闪退。定位闪退最直接的办法就是在main()入口处包一层 Python 的traceback并写到日志文件。这样即使 exe 闪退日志还能留下最后一行。4.3 路径问题sys._MEIPASS 到底是干什么的现象打包后读不到config.json日志显示路径不对。原因在单文件模式下程序运行时会解压到临时目录系统当前工作目录CWD并不是 exe 所在目录甚至每次运行临时目录名都随机。你用./config.json这种相对路径看运气在命令行里启动和从资源管理器双击结果都可能不一样。解决需要借助 PyInstaller 注入的全局变量sys._MEIPASS。它是一个绝对路径指向运行时解压目录。我们写一个小函数统一处理资源路径import sys import os def resource_path(relative_path): base_path getattr(sys, _MEIPASS, os.path.abspath(.)) return os.path.join(base_path, relative_path)逻辑说明源码模式下sys._MEIPASS不存在getattr会返回我们给的默认值os.path.abspath(.)也就是当前目录打包后它返回临时解压目录。这样同一套代码在开发和打包两个阶段都能工作。读取配置文件、图片、模型权重时都走这个函数不要直接写open(config.json)。参数说明-D目录模式下_MEIPASS指向的是 exe 所在目录所以这个函数同样适用。我习惯把函数抽到独立的utils/path_utils.py里所有模块共用避免每个文件各自复制一份路径逻辑以后维护起来头疼。4.4 杀毒软件误报与文件体积虚高这是 PyInstaller 绕不开的两件事现象打好的 exe 放网盘被拦截复制到客户机上 Windows Defender 直接杀掉进程都没起来。原因PyInstaller 生成的可执行文件自带嵌入式运行时和自解压逻辑行为特征像加壳工具杀毒引擎会拿启发式规则判断。PyInstaller 如果检测到系统装了 UPX会默认用 UPX 压缩二进制而 UPX 恰好是最容易触发误报的元凶之一。解决第一打包时加--noupx明令禁止 UPX 参与。第二如果项目正规用代码签名证书给 exe 签名签过名的程序误报率大幅下降这是最有效的长效手段。第三换新版 PyInstaller老版本的特征更容易被杀软识别。第四内部工具暂时不签名的可以让客户 IT 部门先把程序加入白名单再分发。pyinstaller -F --noupx --exclude-moduletkinter --exclude-moduleunittest app.py逻辑说明体积方面一个含 Python 解释器的空程序PyInstaller 打出来至少 6~10 MB这正常。如果打到 100 MB 以上先看是不是把不用的模块全扫进来了。--exclude-module可以逐个排除比如 tkinter、unittest、pydoc 这些和业务无关的模块。注意排除要谨慎如果某个 hook 间接依赖被排除的模块运行时会报缺库。5. 数据文件与资源文件的打包配置文件、图片、模型怎么跟着程序走5.1 使用 --add-data 把资源文件打进包里很多打包项目不止一个 py 文件还需要配置文件、logo、模型权重。PyInstaller 只能处理 Python 模块和二进制库非 Python 资源必须显式声明否则它们不会出现在包里。命令语法有一个明显的平台差异Windows 上源路径和目标路径用分号分隔Linux/macOS 用冒号。# Windows 示例把 config.json 和 assets 目录打进包 pyinstaller -F \ --add-data config.json;. \ --add-data assets;assets \ app.py # Linux/macOS 示例 pyinstaller -F \ --add-data config.json:. \ --add-data assets:assets \ app.py参数说明--add-data的参数是“源路径 分隔符 目标相对目录”。左边的config.json是项目根目录下的文件右边的.表示解压后的资源根目录。assets;assets表示把整个 assets 目录复制到资源的 assets 子目录下。在-F模式下这些文件会压缩进 exe在-D模式下它们会平铺到 dist 目录里路径关系保持不变。逻辑说明这里最容易翻车的点就是分隔符。如果你在 Windows 上写冒号PyInstaller 会报错或把路径解析错反过来同理。我在团队里统一约定打包命令写成脚本文件脚本里直接按当前系统写定分隔符不让成员手动敲。5.2 运行时如何读取打包后的资源文件路径5.2 节我们继续深入资源读取。打包后的 exe 在工作目录上的行为非常“玄学”你要做的不是理解它每个瞬间在哪里而是统一用绝对路径拼出来。import sys import os import json def resource_path(relative_path): base_path getattr(sys, _MEIPASS, os.path.abspath(.)) return os.path.join(base_path, relative_path) def load_config(): config_file resource_path(config.json) with open(config_file, r, encodingutf-8) as f: return json.load(f)逻辑说明load_config用resource_path(config.json)拼出完整的绝对路径。这个路径在源码模式下是项目目录下的config.json在打包后是临时解压目录下的config.json。为什么要这么绕因为 PyInstaller 的单文件模式是“临时解压用完即删”如果你在用户桌面双击 exe当前工作目录是桌面程序根本找不到 config.json除非你把它放在 exe 旁边并硬编码dirname(sys.executable)。5.3 一个最小但完整的资源打包示例带配置文件和图片的小工具我们做一个完整的小项目目录结构如下project/ ├── app.py ├── config.json └── assets/ └── logo.pngapp.py的代码如下import sys import os import json def resource_path(relative_path): base_path getattr(sys, _MEIPASS, os.path.abspath(.)) return os.path.join(base_path, relative_path) def main(): config_file resource_path(config.json) logo_file resource_path(os.path.join(assets, logo.png)) with open(config_file, encodingutf-8) as f: config json.load(f) print(配置内容:, config) print(Logo 存在:, os.path.exists(logo_file)) if __name__ __main__: main()打包命令pyinstaller -F \ --add-data config.json;. \ --add-data assets;assets \ -n resource-demo \ app.py逻辑说明config.json被放进包根目录运行时通过resource_path(config.json)找到assets整个目录被放进assets/子目录运行时通过resource_path(assets/logo.png)找到。验证方法是在命令行里运行dist/resource-demo.exe如果输出显示 Logo 存在说明资源打包成功。5.4 在 -F 模式下处理可写配置把文件复制到用户目录有一个场景很多人没意识到-F模式下exe 每次启动都会把内置的文件解压到临时目录程序运行期间你改了这个临时目录里的 config.json进程一退出临时目录就被清掉。你改了个寂寞。因此如果你的程序允许用户修改配置就不能直接读写包内资源。正确做法是第一次启动时把包内的默认配置复制到用户目录之后读写用户目录那份。import sys import os import shutil def get_user_data_dir(): home os.path.expanduser(~) path os.path.join(home, .myapp) os.makedirs(path, exist_okTrue) return path def ensure_config(): src resource_path(config.json) dst os.path.join(get_user_data_dir(), config.json) if not os.path.exists(dst): shutil.copyfile(src, dst) return dst参数说明os.path.expanduser(~)获取当前用户的主目录Windows 上一般是C:\Users\用户名。把配置放在~/.myapp下既避开了 Program Files 没有写权限的问题也保证多用户各自独立。这算是一个常见做法我自己写工具默认都会改成这个套路。6. 最后一步验证安装包能不能在你没装 Python 的机器上跑以及我踩过的一个坑6.1 验证清单从干净虚拟机到同事的电脑打包完成后不能只在自己机器上双击一次就认为没问题。我自己是这么验证的找一台没有 Python 的干净 Windows 机器虚拟机即可拷贝 dist 目录或单个 exe 过去双击运行看是否正常。在命令行里运行 exe确认退出码为 0日志没有报错。如果程序用到资源文件检查资源文件是否存在、内容是否可读。切到非项目目录运行比如放到C:\temp\下面再试一次排查路径写死的代码。用不同权限的普通用户试一下避免写文件权限问题在最后一步爆雷。这五条每条都踩出过事故。尤其第四条我自己就遇到过在项目目录运行永远正常发到客户那里就报“配置找不到”最后定位到代码里用了open(config.json)相对路径被当前工作目录绑架了。6.2 用命令行参数与日志验证安装包的完整性我在工具类程序里默认加一个--debug参数让程序启动后把关键路径和配置信息写到日志文件方便异地排查。import sys import os import logging logging.basicConfig( filenamelaunch.log, levellogging.DEBUG, format%(asctime)s %(levelname)s %(message)s ) def main(): logging.debug(exe path: %s, sys.executable) logging.debug(resource path: %s, resource_path(.)) if --debug in sys.argv: logging.debug(启动参数: %s, sys.argv) if __name__ __main__: main()逻辑说明sys.executable在打包后指向 exe 的绝对路径在源码模式指向 Python 解释器路径。用日志记录下来你一眼就能看出程序跑的是哪个文件。如果客户机器上报了问题让客户把 launch.log 发回来比远程猜快得多。6.3 我习惯留的后手打包前自动构建脚本最后分享一个我现在还在用的习惯。每次打包前我不会手动敲一串 pyinstaller 命令而是写一个构建脚本把版本号、输出名、资源文件全部固化进去。脚本里先建一个新的虚拟环境安装 requirements 固定的依赖再跑 pyinstaller。这样即使换了电脑也能复现完全一样的安装包。#!/usr/bin/env bash set -e VERSION1.0.0 NAME数据清洗助手 python -m venv .venv-build source .venv-build/bin/activate pip install -r requirements.txt pip install pyinstaller pyinstaller --clean -y \ -F -w \ --name $NAME-$VERSION \ --iconlogo.ico \ --add-data config.json;. \ --add-data assets;assets \ --hidden-importsome_lib \ --noupx \ app.py deactivate看着繁琐但省掉了一种很隐蔽的坑你昨天打包的版本和今天打包的版本依赖不一致客户机上的行为就不同。有了脚本每次都是同一条链路唯一变化的只有版本号。我最早做打包时图省事直接用全局环境结果打出来的 exe 有 300 多 MB传网盘还被告警。后来规规矩矩建虚拟环境、关 UPX、把资源路径统一走 resource_path翻车率才降下来。这行里没有万能的按钮但把构建和验证步骤固化下来比什么都管用希望帮到你。本文还有配套的精品资源点击获取