简介:面向需要把 Python 脚本转换为独立可执行文件、并在未安装 Python 的目标机上分发的开发者,这份安装包围绕 PyInstaller 打包工具提供了源码、测试与说明材料,可用于了解打包工具的组成、基础配置和跨平台使用思路。压缩包共 19 个文件,以 txt 说明、py 脚本、pkg-info 元数据和 cfg 配置为主,整体约 37KB,体积小巧,目录结构便于快速浏览和离线查阅。已有 589 人学习或下载,适合 Python 初学者、自学者以及需要轻量参考资料的技术人员。结合包内脚本、测试用例和文档,读者可辅助理解依赖收集、单文件与目录输出、多平台构建、命令行参数控制、spec 配置及 dist 产物等环节,也能为排查模块缺失、资源文件处理和打包结果验证提供线索,有助于对打包流程建立整体认识,适合作为 PyInstaller 入门实践和源码结构学习的轻量资料。
1. 版本先对齐:PyInstaller 不是一个 pip install 就能出包的工具
上周有个做图像处理 Demo 的 A 同学来找我,说他用 PyInstaller 打出来的 exe,在自己机器上双击一切正常,拷到同事那台没装过 Python 的电脑上,黑框一闪就没了。折腾到半夜,最后发现是打包机 Python 3.11、目标机还在 Windows 7,运行库根本对不上。PyInstaller 安装包这件事,麻烦的从来不是敲下 pip install 的那一行,而是装之前有没有把 Python 版本、PyInstaller 大版本、目标系统这三样东西对齐。装错了不会当场报错,只会在别人的机器上翻车。下面按「装什么版本 → 怎么装 → 怎么配 → 坏了怎么救 → 装完怎么验」走一遍,适合要把 Python 脚本做成免安装可执行文件、交付给非开发同事的人。
2. 环境钉死:PyInstaller 5.x 与 6.x 的 Python 版本分界
2.1 用 venv 隔离出一个干净的打包环境
打包环境最忌讳的就是拿全局 site-packages 直接上。全局环境里躺着几十个包,PyInstaller 的静态分析虽然只顺着 import 链走,但不少第三方库自带 hook,一被扫到就往产物里塞东西,最后 exe 从 20MB 膨胀到 200MB,还附带各种找不到来源的 DLL 冲突。我一般的做法是每个要出包的项目单独建一个虚拟环境,目录名固定叫.venv,这样同事拿到代码、CI 跑构建,路径都是同一个。
# 在项目根目录建一个干净虚拟环境,目录名固定 .venv python -m venv .venv # Windows cmd 下激活 .venv\Scripts\activate.bat # Windows PowerShell 下激活 .venv\Scripts\Activate.ps1 # macOS / Linux 下激活 source .venv/bin/activate # 确认当前解释器确实是虚拟环境里的那一个 python -c "import sys; print(sys.executable)"python -m venv的-m表示用模块方式调用,避免 PATH 里存在多个 python 时调错解释器;.venv是目录名,用点开头是为了让 git 默认忽略它,也方便编辑器自动识别。最后那条print(sys.executable)很关键,输出的路径里必须带.venv,如果打出来的是系统的/usr/bin/python或者C:\Python311\python.exe,说明激活没生效,后面装什么都装到全局去了。
PowerShell 用户十有八九会在这里撞一次墙:直接跑Activate.ps1会报「无法加载文件,因为在此系统上禁止运行脚本」。这不是环境坏了,是执行策略拦的。解决办法是按当前用户放开脚本执行权限:
# 只对当前用户放开,不需要管理员权限,退出重登后依然有效 Set-ExecutionPolicy -Scope CurrentUser RemoteSigned # 确认策略生效 Get-ExecutionPolicy -Scope CurrentUserRemoteSigned的含义是本地脚本可直接跑、从网络下载的脚本要有签名,比Unrestricted稳妥得多,不要图省事改成Bypass。
还有一点容易被忽略:如果项目原来用的是 conda 环境,建议打包时另开一个纯 venv。conda 环境里的 Python 会带上自己的 MKL、OpenSSL 等运行库,PyInstaller 分析时可能把这些一起打进产物,体积和兼容性都不好控制。conda 用来做实验没问题,出包我坚持用 venv。
2.2 装 PyInstaller:版本钉死和 pip 源怎么配
裸敲pip install pyinstaller能装上,但不建议这么干。PyInstaller 的 5.x 和 6.x 是两条明显不同的线,5.x 还照顾得到 Python 3.7 的老项目,6.x 起最低要求 Python 3.8,并且 onedir 产物的目录结构动了刀,多出一层_internal。团队里只要有人装到不同大版本,打出来的东西就不一样,出了问题排查起来就是黑匣子。
# 先把 pip、setuptools、wheel 升上去,老 pip 解析 wheel 依赖容易失败 python -m pip install --upgrade pip setuptools wheel # 新项目走 6.x 线,钉大版本不钉小版本 python -m pip install "pyinstaller>=6,<7" # 老项目还停在 Python 3.7 的,退到 5.x 线 python -m pip install "pyinstaller>=5,<6" # 安装完成后冻结环境,方便同事复现和 CI 使用 python -m pip freeze > requirements.txt">=6,<7"这种写法是 PEP 440 的版本区间约束,意思是大版本锁定在 6,补丁版本允许自动升级。为什么不写死到具体小版本?因为补丁版本通常是 hook 修复和兼容性更新,锁太死反而错过。但大版本必须锁,spec 文件结构、命令参数在不同大版本之间是会变的。pip freeze输出的 requirements.txt 会带精确版本号,这份文件才是给 CI 和同事用的。
国内或者内网环境,pip 直连官方源经常卡在下载阶段,常见做法是配一个镜像地址:
# 临时指定源安装,不写进全局配置,适合只装一次的场景 python -m pip install -i https://<你的镜像地址>/simple "pyinstaller>=6,<7" # 长期使用就写进配置,之后所有安装都走这个源 python -m pip config set global.index-url https://<你的镜像地址>/simple python -m pip config set global.trusted-host <你的镜像地址的域名>-i是--index-url的缩写,只对当次命令生效;pip config set写进的是用户级配置文件,Windows 在%APPDATA%\pip\pip.ini,Linux/macOS 在~/.config/pip/pip.conf。trusted-host是给走 HTTP 的内网源用的,如果源本身是 HTTPS 就不需要这一行。把镜像地址换成公司内网源或者你手上在用的公共镜像,别照抄。
装完还要留意一个隐形问题:PATH 里同时存在多个 Python 时,pyinstaller这个可执行文件可能指向另一个环境装的那份。所以后面所有命令我都统一写成python -m PyInstaller,用当前解释器去调模块,谁也别想调错。
2.3 装完先自检:三条命令确认工具链可用
装完别急着打包,先用三条命令确认工具链是通的。这一步花三十秒,能省掉后面半小时的困惑。
# 1. 看装到了哪个大版本线 python -m PyInstaller --version # 2. 确认它认的是哪个 Python 解释器 python -c "import PyInstaller, sys; print(PyInstaller.__file__); print(sys.version)" # 3. 看帮助里有没有你要用的参数,不同大版本参数名有差异 python -m PyInstaller --help第一条输出形如6.x.x,只要大版本对得上就继续。第二条打印的是 PyInstaller 包的安装位置和 Python 版本,重点看包路径是否落在.venv目录下——如果打出来在C:\Users\xxx\AppData\Roaming\Python\...,说明刚才装到用户级目录去了,虚拟环境里其实没有,后面打包时会莫名奇妙地多出一堆依赖。
不同安装方式的风险差异可以对照着看:
| 安装方式 | 典型命令 | 主要风险 |
|---|---|---|
| 虚拟环境内安装 | python -m pip install "pyinstaller>=6,<7" | 需要每次激活环境,略繁琐 |
| 全局安装 | pip install pyinstaller | 多项目版本冲突,产物不可复现 |
| 用户级安装 | pip install --user pyinstaller | 容易和 venv 混用,PATH 指向不明 |
| conda 环境安装 | conda install -c conda-forge pyinstaller | 可能带入 conda 自带运行库,体积偏大 |
第三条--help别跳过。5.x 和 6.x 在部分参数上有调整,比如--add-data的路径分隔符说明、--collect-all的可用性,先看一眼心里有数,比报错之后再翻文档快得多。
3. 第一次出包:pyinstaller 命令与 spec 文件的两条路径
3.1 最短命令跑通一个单文件可执行程序
假设入口脚本叫main.py,同目录下有个assets文件夹放配置和图标。第一次出包别想着一步到位,先用最少的参数把链路跑通。
# 最小可用命令:单文件模式,指定产物名,入口是 main.py python -m PyInstaller --onefile --name image_demo main.py跑完项目根目录会多出三样东西:build/是中间缓存,可以随时删;dist/里躺着最终产物;image_demo.spec是这次构建的完整配置快照。--onefile表示把所有东西塞进一个可执行文件,--name指定产物名,不写的话默认取入口脚本的文件名。
常用参数我整理成一张表,出包时照着挑:
| 参数 | 作用 | 什么时候必须加 |
|---|---|---|
--onefile | 打成单个可执行文件 | 交付给非开发同事、要求免安装 |
--onedir | 打成一个目录 | 体积大、启动慢、需要热更新资源 |
--name | 指定产物名称 | 入口文件名和想要的名字不一致 |
--noconsole/--windowed | 不弹控制台窗口 | GUI 程序,避免黑色命令行窗口跟着出来 |
--icon | 指定图标文件 | Windows 用.ico,macOS 用.icns |
--clean | 构建前清理缓存 | 改过依赖或升级过 PyInstaller 之后 |
--noconfirm | 覆盖产物不再询问 | 放在 CI 脚本里,避免卡在交互提示 |
--paths | 追加模块搜索路径 | 源码分在src/子目录里 |
--add-data | 塞入数据文件 | 有配置、模板、图标、模型文件 |
--hidden-import | 手工补隐藏导入 | 遇到 ModuleNotFoundError |
--clean这个参数值得单独说。PyInstaller 会在build/下缓存分析结果,改了依赖版本或者升级了 PyInstaller 本身之后不清缓存,很容易出现「明明重装了库,打出来还是旧行为」的玄学问题。我现在的习惯是:本地调试时不清,准备交付前必加一次--clean。
3.2 spec 文件才是真正的配置文件
第一次跑完生成的image_demo.spec,本质是一个 Python 脚本,PyInstaller 每次构建就是执行它。命令行参数只适合临时试,参数一多就没法维护,正确姿势是把所有配置沉淀到 spec 里,之后统一用python -m PyInstaller image_demo.spec构建。
# -*- mode: python ; coding: utf-8 -*- # image_demo.spec —— 首次由 PyInstaller 生成,之后手工维护 a = Analysis( ['main.py'], # 入口脚本,可以写多个 pathex=['src'], # 额外模块搜索路径,等价于 --paths binaries=[], # 需要一起打包的动态库,一般交给 hook 处理 datas=[('assets/config.json', 'assets')], # (源路径, 包内目标目录) hiddenimports=['pkg_resources'], # 静态分析看不到的模块 hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=['tkinter', 'matplotlib'], # 明确排除,用来压体积 noarchive=False, ) pyz = PYZ(a.pure, a.zipped_data) exe = EXE( pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], name='image_demo', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, upx_exclude=[], runtime_tmpdir=None, console=False, # 等价于 --noconsole icon='assets/app.ico', )几个字段的含义需要说清楚。pathex是模块搜索路径列表,源码放在src/下时,这里不写就会报找不到自己写的包,效果等同于命令行的--paths。datas每一项是一个二元组,前一个是构建机上的真实路径,后一个是打进包里的目标目录,写错位置程序运行时就找不到文件。excludes是最有效的瘦身手段,像tkinter、matplotlib这种被间接引用进来、实际根本没用的库,明确排除掉体积能掉一大截。upx=True表示如果系统里装了 UPX 就用它压二进制,没装则自动跳过,不会报错。
6.x 生成的 spec 结构和 5.x 略有出入,字段顺序、EXE 的参数列表都可能不一样,甚至 onedir 模式下会多出COLLECT段。以实际生成的为准,别拿网上抄来的 spec 直接覆盖,很容易因为字段缺失导致构建报错。
改完 spec 之后,构建命令固定成这一条:
# 以后所有构建都走 spec,命令行参数不再重复写 python -m PyInstaller --clean --noconfirm image_demo.spec--clean清缓存,--noconfirm跳过覆盖确认。这两条放一起就是 CI 里能直接用的构建命令。spec 文件要进版本库,因为它是产物的唯一真相,改了依赖、加了资源,都在这里体现。
3.3 onefile 与 onedir 的取舍:体积、启动速度与误报
这是打包时第一个要做的选择,也是最容易选错的。onefile 看着舒服,一个 exe 拷过去就能跑;onedir 看着散,一堆文件堆在目录里。但两者的运行时行为完全不同:onefile 每次启动都要把内容解压到系统临时目录,程序退出后再清理,启动慢一到三秒是常态,遇到杀软实时扫描还会更慢。
| 对比项 | --onefile | --onedir |
|---|---|---|
| 交付形态 | 单个可执行文件 | 一个目录,含主程序与依赖 |
| 启动速度 | 慢,需先解压到临时目录 | 快,直接加载 |
| 体积 | 略小(压缩过) | 略大但文件可见 |
| 杀软误报 | 更容易被拦 | 相对好一些 |
| 资源更新 | 需重新打包 | 可直接替换目录内文件 |
| 临时目录依赖 | 依赖系统 temp 可写 | 不依赖 |
选型上我的判断标准很朴素:交付对象是一次性使用的工具、给非开发同事双击运行,就 onefile;需要频繁启动、或者要给运维留可替换的配置和模型文件,就 onedir。GUI 程序还要记得加--noconsole,否则用户双击之后背后跟着一个黑框,体验很差,而这个黑框恰恰是排查问题的最佳入口——两难,取舍见仁见智。
4. 依赖收口:hiddenimports、datas 与 sys._MEIPASS 三处配置
4.1 hiddenimports:模块明明装了却报 ModuleNotFoundError
这是打包后最高频的一类问题。现象很明确:源码直接跑没问题,打出来的程序一启动就抛ModuleNotFoundError: No module named 'xxx'。原因是 PyInstaller 靠静态分析 import 语句来收集依赖,凡是动态 import 的都看不见。
# 这类写法 PyInstaller 静态分析不出来,打包后会缺模块 module_name = "json" mod = __import__(module_name) # importlib 动态加载同样看不见 import importlib plugin = importlib.import_module("myproject.plugins.csv_plugin")解决办法分三档。手工补最少量的模块:
# 针对性补一个隐藏导入,体积影响最小 python -m PyInstaller --onefile --hidden-import=pkg_resources --name image_demo main.py整包收集,把某个库的子模块、数据文件、hook 一次性拉进来:
# --collect-all 会把包的子模块和数据文件全带上,代价是体积 python -m PyInstaller --onefile --collect-all sklearn --name image_demo main.py自己写 hook,一劳永逸:
# hook-myplugin.py —— 自定义 hook,告诉 PyInstaller 这个包需要额外带什么 hiddenimports = ['myplugin.backends.csv', 'myplugin.backends.json'] datas = [('myplugin/templates', 'myplugin/templates')]hook 文件放在项目里的任意目录,构建时加--additional-hooks-dir=./hooks指过去。hook 的命名必须是hook-<包名>.py,PyInstaller 扫描到这个文件就会自动应用。团队项目里我更推荐 hook,因为配置跟着代码走,新同事拉下来就能用,不依赖口头交代。
4.2 datas 与 add-data:路径分隔符在不同平台的写法
数据文件是用--add-data塞进去的,写法是源路径<分隔符>包内目标目录。这个分隔符在不同系统上不一样,是踩坑重灾区:
# Windows:源和目标之间用分号 python -m PyInstaller --onefile --add-data "assets/config.json;assets" main.py # macOS / Linux:源和目标之间用冒号 python -m PyInstaller --onefile --add-data "assets/config.json:assets" main.py写反了不会立刻报错,构建照样成功,运行的时候才发现配置文件读不到,程序抛个 FileNotFoundError 出来。跨平台项目我建议直接写进 spec 的datas列表,用元组形式,就没有分隔符的问题了:
# spec 里的 datas 用元组,跨平台写法统一,不受分隔符影响 datas = [ ('assets/config.json', 'assets'), ('assets/templates', 'assets/templates'), ('models/classifier.onnx', 'models'), ]目录形式的源路径会整个目录递归拷进去,这一点比自己写通配符稳妥。还要注意,datas里的源路径是相对构建时的工作目录,所以在 CI 里构建,工作目录得和本地一致,否则路径对不上。
4.3 运行时路径:sys._MEIPASS 与资源定位
开发时读文件用相对路径open("assets/config.json"),打包后就找不到——因为运行时的工作目录变了,onefile 模式下程序实际是从临时解压目录运行的。正确写法是统一走一个路径解析函数:
import sys from pathlib import Path def resource_path(relative: str) -> Path: """打包态从临时解压目录取资源,开发态从脚本所在目录取。""" base = getattr(sys, "_MEIPASS", None) if base is None: # 没打包,正常按脚本目录找 base = Path(__file__).resolve().parent return Path(base) / relative # 读取打包进去的配置 config_file = resource_path("assets/config.json")sys._MEIPASS是 PyInstaller 注入的属性,onefile 模式下指向临时解压目录,onedir 模式下也存在但指向_internal目录。用getattr带默认值的方式取,开发态就不会因为属性不存在而报错。所有读资源的地方都走这个函数,是唯一能让开发态和打包态行为一致的做法。
提示:
sys._MEIPASS指向的目录是只读的临时目录,程序退出就被清掉。要写用户配置、缓存、日志,必须写到用户目录或者程序所在目录,别往这个路径里写,写了下次启动就没了。
5. 翻车排查:PyInstaller 打包后起不来的五类现场
5.1 双击闪退,命令行里却能看到完整报错
现象:双击 exe 窗口一闪就消失,什么提示都没有;在命令行里cd dist然后./image_demo.exe执行,却能把堆栈信息完整打出来。这种情况几乎全是--noconsole造成的——控制台被关掉了,异常没地方输出。
原因:PyInstaller 把 stdout 和 stderr 重定向掉了,异常信息直接丢失。GUI 程序常见,加上--noconsole之后,所有 print 和 traceback 都成了哑巴。
解决:先在开发机上用控制台模式构建一份调试版,把问题定位出来;确认无误后再加--noconsole出正式版。排查期间可以在 spec 里临时把console=True改回去,或者单独留一份image_demo_debug.spec,产物名加_debug后缀,方便现场让用户跑一遍把日志抓回来。
# 调试构建:保留控制台,报错能直接看到 python -m PyInstaller --onefile --name image_demo_debug main.py5.2 产物 300MB,一半是根本没用的库
现象:明明是几十行的脚本,打出来 200MB 往上,压缩之后还是大得离谱。用--onedir看一眼目录,里面躺着numpy、scipy、matplotlib,可项目里根本没用过。
原因:某个间接依赖 import 了这些库,PyInstaller 顺着模块图一路收集,加上有些库自带 hook 会把整个包拉进来,体积就失控了。另外全局环境打包也会把无关的包卷进来。
解决:先在 spec 的excludes里明确排掉确认不用的库,再用--onedir模式出一次包,对着目录里的文件名逐个判断哪个是多余的。
# spec 的 Analysis 段,显式排除确定不用的重量级库 excludes=[ 'tkinter', # 没做 GUI 的话直接排掉 'matplotlib', # 只出图不给用户看的话排掉 'scipy', 'pytest', 'IPython', ],排完体积通常能掉一半以上。前提是排之前先跑一遍核心功能确认没被误伤,排除错了就是运行时 ImportError。
5.3 目标机器提示缺少 VCRUNTIME140.dll 或 api-ms-win-crt 系列
现象:程序在你机器上好好的,拷到一台全新装的系统上,弹窗提示缺少VCRUNTIME140.dll、VCRUNTIME140_1.dll或者一堆api-ms-win-crt-*.dll。
原因:Python 本身依赖 Visual C++ 运行库,这些 DLL 在开发机上基本都有(装过各种软件就带上了),但干净系统上不一定存在。PyInstaller 打包的是 Python 解释器和你的依赖,系统级的运行库不在它的收集范围内。
解决:让目标机器装上对应的运行库,或者打包时把所需 DLL 一并带上。常见做法是在 spec 的binaries里显式添加,把开发机上同名的 DLL 一起收进去:
# spec 里手工补运行库,路径按你本机的实际位置改 binaries = [ ('C:/Windows/System32/vcruntime140.dll', '.'), ('C:/Windows/System32/vcruntime140_1.dll', '.'), ]更省事的做法是打包机上跑一次dumpbin /dependents image_demo.exe,把依赖树列出来,缺什么补什么。交付前把目标环境要求写清楚,比事后一个个补 DLL 高效得多。
5.4 被杀毒软件直接删掉或标记为风险
现象:exe 刚拷过去,还没来得及双击,文件就消失了,或者被提示「已隔离」。有时候是管理后台直接删的,用户完全不知情。
原因:PyInstaller 的启动器(bootloader)行为特征和部分恶意程序的打包方式相似,加上 onefile 模式启动时会释放内容到临时目录再执行,这个动作特别容易触发启发式检测。这不是你的代码有问题。
解决:优先改成--onedir模式,把释放到临时目录这一步省掉,被拦的概率明显下降。如果业务必须单文件,就把产物提交到公司内部的安全扫描平台走白名单流程,提前登记哈希。另一个动作是数字签名,有企业证书的给 exe 签个名,能解决大部分误报。
注意:别去网上找所谓的「免杀」工具处理产物,那属于另一个性质的问题,正规交付走内部安全流程就行。
5.5 项目路径里有中文或空格,构建到一半失败
现象:构建过程中突然报一堆编码相关的异常,或者在某一步卡住不动,换台机器又是好的。
原因:PyInstaller 底层调用 bootloader 和一系列子进程,路径里出现空格、中文、特殊字符时,某些环节的参数拼接会失效。Windows 用户目录默认是C:\Users\名字,中文名的话项目放在桌面就中招了。
解决:把项目挪到一个纯英文、无空格的路径下重新构建,比如D:\work\image_demo。这不光影响构建,运行时的临时目录也会受影响。我现在的习惯是开发目录一律D:\work\,一个中文字都不留,省掉一类完全没必要的排查。
6. 出包验证:三台干净机器上的冒烟测试清单
打包成功不等于能交付,这两件事差着十万八千里。我的习惯是产物出来之后,至少找三台干净机器跑一遍——这里的干净指没装 Python、没装过你这套依赖的环境。三台机器的角色分工是:一台和开发机同版本系统(验证基本功能)、一台低一个大版本的系统(验证向下兼容,最容易翻车的地方)、一台用户实际在用的系统镜像(验证真实环境)。
验证顺序固定成四步,一步都不能跳:
# 第 1 步:在干净机器上直接双击运行,看主界面/主流程是否起来 # 这一步专抓缺 DLL、缺运行库的问题 # 第 2 步:走一遍最核心的功能路径 # 不是点几下看没崩,而是完整跑一次真实任务的输入到输出 # 第 3 步:关掉程序再开一次 # 专门抓那些第一次能跑、第二次起不来的临时文件依赖问题 # 第 4 步:把程序所在目录挪到别的盘符再跑一次 # 抓绝对路径硬编码、相对路径假设错误的问题第 3 步尤其重要。onefile 模式每次启动都从临时目录解压,如果代码里往sys._MEIPASS写了东西、下次又去读,第一次能跑第二次就找不到,这种问题在开发机上极少暴露。
产物本身也要核对几项,我一般用一张清单过:
| 核对项 | 检查方法 | 不合格的表现 |
|---|---|---|
| 版本号 | 程序里打印版本,与发布记录比对 | 打出来的还是上一版 |
| 图标与名称 | 文件属性里看 | 显示默认图标或 pyinstaller 字样 |
| 文件大小 | 与上次构建对比 | 突然翻倍或腰斩,说明依赖有变动 |
| 启动耗时 | 秒表计,或程序内打点 | 超过 5 秒要查是不是误收了大库 |
| 退出码 | 命令行执行echo $?/echo %ERRORLEVEL% | 正常退出应为 0 |
最后说个习惯。早些年我图省事,本地跑通就把 exe 发出去,结果在一次跨系统交付里,用户那边连续三个人打不开,排查了两天才定位到运行库缺失。从那以后我每次出包都强制走一遍「三台干净机器 + 四步验证」,哪怕再赶时间也不跳。多花的这二十分钟,比事后远程连过去查一个闪退便宜太多。希望这套流程能帮到你,下次打包少走点弯路。
本文还有配套的精品资源,点击获取