上周同事拿着我发过去的 exe 来问我:为什么这工具在他电脑上双击就弹 FileNotFoundError,报错路径还指向一个他从没见过的C:\Users\xxx\AppData\Local\Temp\_MEI154832\config\default.yaml。同一个 exe,在我机器上跑得好好的。这类问题我前后遇到过不下十次,根因几乎都指向同一件事:PyInstaller 打包时资源文件没进去,或者进去了但代码用相对路径找不到它。
这篇就把「PyInstaller 打包多个资源文件」这件事从头到尾讲透。内容包括:为什么打包后资源会"消失"、--add-data的正确写法与分隔符坑、.spec里datas的可维护写法、运行时资源定位的标准函数、只读资源与可写数据的分离方案,以及体积优化和报错排查。适合已经能用 PyInstaller 打出单文件 exe、但一遇到图片、字体、配置、模板、图标这类附属文件就翻车的人;完全没用过 PyInstaller 的也能跟着走,我会把每一步的理由说清楚。
1. 先搞懂 PyInstaller 的运行目录模型,不然改了也是瞎改
资源找不到这件事,十个里有八个不是 PyInstaller 的锅,是作者对"程序运行时到底在哪个目录"这件事的认知错了。所以动手改命令之前,先花五分钟把模型建立起来。
1.1 单文件模式其实是个自解压包
用-F(--onefile)打出来的 exe,本质是一个自解压归档:里面塞了 Python 解释器、你 import 过的所有第三方库、以及你显式声明要带上的资源文件。每次运行,它会先把这些内容解压到一个临时目录,再把控制权交给你的代码,程序退出后这个临时目录一般会被清掉。这个临时目录的路径就存在sys._MEIPASS里。
想亲眼看到的话,在你的入口文件第一行加一句:
import sys print("MEIPASS =", getattr(sys, "_MEIPASS", "not frozen")) input("按回车继续")用--console打成 exe 双击运行,你会看到一个类似C:\Users\你的用户名\AppData\Local\Temp\_MEI154832的路径。你所有通过--add-data塞进去的资源,都被放在这个目录下面。注意两个后果:第一,这个路径每次启动都可能不一样,任何把它硬编码进代码或写进配置的做法都会翻车;第二,程序退出后目录被删,往里面写文件等于扔进垃圾桶。
1.2 开发机上跑得通,只是因为"当前工作目录"恰好对了
绝大多数翻车代码长这样:
with open("assets/config.json", "r", encoding="utf-8") as f: cfg = json.load(f)open收到相对路径时,是相对**当前工作目录(cwd)**去找的,不是相对脚本文件所在目录。你在 PyCharm 或 VS Code 里跑,cwd 通常是项目根目录,assets/config.json恰好就在那儿,一切正常。打包之后情况变成双重错位:cwd 可能是桌面、可能是某个快捷方式里配置的"起始位置"、也可能就是 exe 所在目录,而资源文件被解压到了_MEIPASS那个临时目录里——两者大概率不是同一个地方,于是必然找不到。
顺带提一个容易连带出错的地方:__file__在打包后的行为也和你想的不一样。单文件模式下它指向_MEIPASS内的某个临时路径,而如果你用了os.getcwd(),那就更没谱了。所以结论很硬:读任何随程序分发的资源,都必须走一个统一的、基于_MEIPASS的定位函数,第 4 节我会给出完整实现。
1.3 目录模式和单文件模式,先选对再谈资源
很多人一上手就用-F,觉得一个文件发出去最体面,然后在资源、启动速度、杀软误报上连踩三个坑。先把两种模式的差异看清楚:
| 对比维度 | 单文件(-F / onefile) | 目录模式(-D / onedir) |
|---|---|---|
| 分发形态 | 单个 exe | exe + 依赖目录(PyInstaller 6 为_internal) |
| 启动速度 | 慢,每次启动都要解压 | 快,直接加载 |
| 资源体积大时 | 启动明显卡顿,临时目录占用大 | 无额外开销 |
| 资源写入 | 临时目录可写,退出即删 | 目录可能只读(装在 Program Files 时) |
| 杀软误报概率 | 偏高(自解压行为) | 偏低 |
| 调试便利性 | 差,看不到内部文件 | 好,可直接翻目录 |
| 运行期资源根路径 | sys._MEIPASS(临时目录) | sys._MEIPASS(指向_internal) |
我的实际选择习惯是:调试期一律用目录模式,因为可以直接打开_internal目录确认资源到底有没有进去、层级对不对,排查效率差好几倍;只有正式对外分发、且资源总量控制在很小的时候才考虑单文件。如果你的资源里有几十 MB 的字体或模型文件,别犹豫,直接目录模式,单文件每次启动解压几十 MB 的体验很难让人接受。
2. --add-data 的三种写法与两个必须记住的坑
确认了模型之后,接下来就是把资源"交出去"。命令行方式最直接,适合资源少、变动频繁的场景,但有两个坑每年都要劝退一批人。
2.1 分隔符:Windows 用分号,macOS 和 Linux 用冒号
--add-data的语法是源路径<分隔符>目标路径,这个分隔符在 Windows 上是分号;,在 macOS 和 Linux 上是冒号:,因为 PyInstaller 用的是系统的os.pathsep。写成这样:
# Windows 命令行或 PowerShell pyinstaller --noconfirm --clean --windowed --name renamer ^ --add-data "assets;assets" ^ --add-data "config\default.yaml;config" ^ main.py# macOS / Linux pyinstaller --noconfirm --clean --windowed --name renamer \ --add-data "assets:assets" \ --add-data "config/default.yaml:config" \ main.py分隔符写反了会怎样?PyInstaller 通常不报错,而是把整串当成一个源路径去找,找不到就静默跳过,最后你得到一个能跑但少资源的包。这类沉默失败最坑,所以每加一条--add-data,都建议在打包日志里搜一下这行路径有没有被处理,或者干脆打完包用pyi-archive_viewer看一眼内容(第 5 节讲)。
注意:源路径建议用绝对路径或明确的项目相对路径,别依赖"运行 pyinstaller 时的 cwd"。在脚本里调用时更要把路径拼全,否则 CI 上跑一次、本地跑一次,结果可能完全不同。
2.2 目标路径的含义:它决定资源在包里的层级
第二列那个"目标路径"容易理解错。它指的是资源被打进包后所处的相对目录,相对于包的根(单文件模式就是_MEIPASS)。几个典型写法:
--add-data "assets;assets":把本地assets整个目录放进包的assets/下,assets/fonts/a.ttf会变成<包根>/assets/fonts/a.ttf。--add-data "config/default.yaml;config":单个文件放进<包根>/config/default.yaml,不会自动带上config/之外的任何东西。--add-data "templates;.":把templates目录的内容铺到包根,注意这与上一条不同,用.的时候目录本身不会成为一个层级(不同版本对目录 +.的处理略有差异,实测最稳的是显式写目录名)。
我一般遵循一条规则:包内层级和项目里的层级保持一致,也就是assets对应的目标也写assets。这样开发时的相对位置和打包后的相对位置完全对齐,定位代码只需要一个偏移量(_MEIPASS),心智负担最低。
2.3 资源一多就别硬堆命令行
三个以内资源,命令行最省事;超过五个,命令行会变成一坨没法维护的字符串,而且每加一个资源就要重新跑一遍完整打包。我踩过的具体麻烦有两个:一是在 Windows 批处理里换行要写^,漏一个就整行断掉;二是团队里每个人本地路径不一样,有人用 PowerShell 有人用 cmd,引号规则还不一样。
超过五个资源的正确姿势是直接进.spec。命令行能不能批量传?能,但没必要——.spec天生就是为这种场景设计的,而且它可进版本库、可 review、可代码化生成。这也是下一节的主题。
3. 用 .spec 把资源声明变成可维护的配置
.spec文件其实就是一段 Python 代码,PyInstaller 会执行它并收集里面定义的对象。它对资源的支持远比命令行灵活。
3.1 先生成 spec,再改它,然后只用它
第一次打包时不要手写 spec,让 PyInstaller 生成:
pyi-makespec --windowed --name renamer --icon assets/app.ico main.py这会产出renamer.spec。之后所有资源声明都改在这个文件里,用下面这条命令打包:
pyinstaller --noconfirm --clean renamer.spec这里有个高频坑:一旦用 spec 打包,命令行里的
--add-data、--hidden-import、--exclude-module等参数基本会被忽略。很多人改了半天命令行没生效,就是因为还在跑 spec。要么全用命令行,要么全用 spec,不要混着来。--distpath、--workpath、--clean、--noconfirm这几个还是有效的。
3.2 datas 里到底写几元组:版本差异要说清
Analysis的datas参数是一个列表,每个元素描述一条资源映射。历史上有两种写法:
# 老写法:三元组,第三个字段是类型码 datas = [('assets/app.ico', 'assets', 'DATA')] # PyInstaller 6.x 推荐:两元组 datas = [('assets/app.ico', 'assets')]从 6.0 开始类型码字段被标记为废弃,还写三元组会看到 warning。同一条 spec 在老版本和新版本之间的兼容性主要就差在这儿,所以别在新项目里抄五年前的老教程。另外一个细节:datas里的源路径是相对于执行打包命令时的 cwd,这很不牢靠,下面用SPECPATH解决。
3.3 用 SPECPATH 和 glob 收集整棵目录树
SPECPATH是 PyInstaller 执行 spec 时注入的全局变量,值就是 spec 文件所在目录。拿它做基准,路径就稳了:
# -*- mode: python ; coding: utf-8 -*- import os from pathlib import Path root = Path(SPECPATH) def collect_dir(src_dir, dest_prefix, skip_suffix=(), skip_names=()): """把 src_dir 整棵树收集成 datas 列表,包内层级保持不变。""" out = [] base = root / src_dir for p in sorted(base.rglob("*")): if p.is_dir(): continue if p.suffix.lower() in skip_suffix or p.name in skip_names: continue rel_parent = p.relative_to(base).parent.as_posix() dest = dest_prefix if rel_parent == "." else f"{dest_prefix}/{rel_parent}" out.append((str(p), dest)) return out datas = [] datas += collect_dir("assets", "assets", skip_suffix={".pyc", ".pyo", ".map", ".psd"}, skip_names={".DS_Store", "Thumbs.db"}) datas += collect_dir("templates", "templates") datas += [(str(root / "config" / "default.yaml"), "config")]这段代码解决三件事:路径不依赖 cwd;整目录递归收集且包内层级与磁盘一致;顺手过滤掉__pycache__、.DS_Store、设计稿源文件这类不该进包的垃圾。直接写('assets', 'assets')也能整目录带上,但过滤器没有,实测经常把几百 KB 的临时文件、编辑器备份一起打进去,包体积莫名其妙变大。
版本提示:老教程里常出现的
Tree('assets')属于 PyInstaller 内部结构,跨版本位置和签名变过好几次(PyInstaller.building.datastruct里确实有,但官方并不鼓励长期依赖)。用上面这种手写 glob 的方式更可控,也更好读。
3.4 第三方库自带的数据文件:别自己一个个抄
有些资源不在你的项目目录里,而在第三方包内部,比如某些库的证书文件、字体、模板、图标。手动枚举它们的路径是噩梦,用官方工具函数:
from PyInstaller.utils.hooks import collect_data_files, collect_all # 只收数据文件,返回 (src, dest) 列表,可以直接并进 datas datas += collect_data_files("mypkg") # 数据 + 二进制 + 隐藏导入一次收齐 d, b, h = collect_all("mypkg") datas += d binaries += b hiddenimports += h命令行等价物是--collect-data mypkg和--collect-all mypkg。什么时候必须用它们?当库在运行时动态拼路径去读自己的资源,而 hooks 没覆盖到的时候。举几个常见例子:带根证书的库需要把cacert.pem带进去;绘图类库需要mpl-data目录下的字体和样式表;多媒体类库需要自带字体;一些 UI 框架需要一整个 web 资源目录。判断方法很简单——先在打包后的 exe 上跑一遍完整功能,哪里报"找不到某文件",就去那个第三方包的安装目录里看有没有这个文件,有就说明是资源没收集,用上面的函数补上。
binaries是给.dll、.so、.pyd这类动态库用的,对应命令行参数--add-binary,分隔符规则和--add-data完全一致。Qt 类的插件(platforms、imageformats)通常由官方 hooks 处理,只有在插件是动态加载且 hooks 漏掉时才需要手动--add-binary。
4. 代码侧的 resource_path:一个函数解决九成定位问题
资源进包了,还得让代码找得到。这一节是全文最值得抄的部分。
4.1 标准实现,直接复制
import os import sys def resource_path(rel: str) -> str: """返回随程序分发的只读资源的绝对路径。""" base = getattr(sys, "_MEIPASS", None) if base is None: base = os.path.dirname(os.path.abspath(__file__)) return os.path.normpath(os.path.join(base, rel))三行核心逻辑,解释一下:
getattr(sys, "_MEIPASS", None):打包运行时有这个属性,开发环境没有。用getattr带默认值,是为了让同一份代码在 IDE 里也能跑,省掉两套路径逻辑。- 开发环境退回
__file__所在目录:这是脚本文件位置,等价于把脚本所在目录当作资源根,和打包后_MEIPASS作为资源根的结构对齐。 os.path.normpath:把assets/../assets/a.png这类路径规整掉,避免在 Windows 上出现混合分隔符导致的比较失败。
配合使用的时候,一律走这个函数,绝不写裸相对路径:
from pathlib import Path cfg_file = Path(resource_path("config/default.yaml")) with cfg_file.open("r", encoding="utf-8") as f: cfg = yaml.safe_load(f) font = resource_path("assets/fonts/SourceHanSans.ttf") icon = resource_path("assets/app.ico")一个容易忽略的点:如果你的程序会 fork 子进程或把路径塞进环境变量传给别的进程,注意别在模块导入阶段把它固化成一个全局常量再去序列化。函数式调用每次算一遍成本可以忽略,却避免了各种缓存不一致。
4.2 只读资源和可写数据必须分开
这是我在实际项目里踩得最狠的一个坑:程序需要保存用户配置,作者图省事直接写resource_path("config/default.yaml")。开发环境没问题,打包成单文件后写进了_MEIPASS临时目录,用户改完设置一关程序,全没了;更糟的是目录模式装在Program Files下时,那个位置根本不可写,直接抛PermissionError。
正确的划分方式:
| 类别 | 典型内容 | 存放位置 | 访问方式 |
|---|---|---|---|
| 只读资源 | 图标、字体、模板、默认配置、内置词表 | 打包进包内 | resource_path() |
| 可写数据 | 用户配置、日志、缓存、下载产物 | 用户数据目录 | user_dir() |
| 临时文件 | 处理中间结果、解压产物 | 系统临时目录 | tempfile模块 |
用户数据目录的取法按平台分支:
from pathlib import Path def user_dir(app: str = "renamer") -> Path: if sys.platform.startswith("win"): base = Path(os.environ.get("APPDATA") or Path.home()) elif sys.platform == "darwin": base = Path.home() / "Library" / "Application Support" else: base = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) d = base / app d.mkdir(parents=True, exist_ok=True) return d然后是"首次运行时把默认配置搬过去"的标准动作,缺少这一步,用户第一次打开就是空配置:
import shutil cfg_dir = user_dir() / "config" cfg_dir.mkdir(parents=True, exist_ok=True) user_cfg = cfg_dir / "config.yaml" if not user_cfg.exists(): shutil.copy2(resource_path("config/default.yaml"), user_cfg)这么一改,升级版本也不会覆盖用户配置,卸载时数据还在,符合大家的预期。
4.3 打包后异常静默消失,先给自己留条日志
用--windowed(或-w)打包的程序没有控制台,print没人看得到,未捕获的异常会让程序直接消失,用户只会说"双击没反应"。我现在的习惯是入口处做三件事:
import logging, sys, traceback from pathlib import Path log_file = user_dir() / "app.log" logging.basicConfig( filename=str(log_file), level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", encoding="utf-8", ) def main(): ... if __name__ == "__main__": try: main() except Exception: logging.error("未捕获异常\n%s", traceback.format_exc()) raise调试阶段则反过来,先加--console打包,把 traceback 直接打在窗口里看,定位完再切回--windowed。这一来一回十分钟,能省掉好几小时的瞎猜。
5. 完整案例:带模板、字体、图标和默认配置的小工具
光讲概念容易飘,我拿一个真做过的批量重命名小工具走一遍,结构可以直接套到你的项目上。
5.1 目录结构与资源清单
renamer/ ├── main.py ├── renamer.spec ├── assets/ │ ├── app.ico │ └── fonts/ │ └── SourceHanSans.ttf ├── templates/ │ └── default.txt ├── config/ │ └── default.yaml └── requirements.txtassets/app.ico是 exe 图标,assets/fonts是界面用字体,templates/default.txt是命名模板,config/default.yaml是默认配置。四类资源,正好覆盖单文件、整目录、多层目录三种形态。
5.2 代码里怎么读它们
from pathlib import Path import yaml class Config: def __init__(self): self.template = self._load_template() self.font_path = resource_path("assets/fonts/SourceHanSans.ttf") self.icon_path = resource_path("assets/app.ico") def _load_template(self): p = Path(resource_path("templates/default.txt")) return p.read_text(encoding="utf-8").strip() def load_config(): user_cfg = user_dir() / "config" / "config.yaml" if not user_cfg.exists(): user_cfg.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(resource_path("config/default.yaml"), user_cfg) return yaml.safe_load(user_cfg.read_text(encoding="utf-8"))5.3 spec 全文(单文件版)
# -*- mode: python ; coding: utf-8 -*- from pathlib import Path root = Path(SPECPATH) def collect_dir(src_dir, dest_prefix, skip_suffix=(), skip_names=()): out = [] base = root / src_dir for p in sorted(base.rglob("*")): if p.is_dir(): continue if p.suffix.lower() in skip_suffix or p.name in skip_names: continue rel_parent = p.relative_to(base).parent.as_posix() dest = dest_prefix if rel_parent == "." else f"{dest_prefix}/{rel_parent}" out.append((str(p), dest)) return out datas = [] datas += collect_dir("assets", "assets", skip_suffix={".pyc", ".pyo", ".psd"}, skip_names={".DS_Store", "Thumbs.db"}) datas += collect_dir("templates", "templates") datas += [(str(root / "config" / "default.yaml"), "config")] a = Analysis( ["main.py"], pathex=[str(root)], binaries=[], datas=datas, hiddenimports=[], hookspath=[], excludes=["tkinter", "unittest", "pydoc_data"], noarchive=False, ) pyz = PYZ(a.pure) exe = EXE( pyz, a.scripts, a.binaries, a.datas, [], name="renamer", debug=False, strip=False, upx=False, console=False, icon=str(root / "assets" / "app.ico"), )想改成目录模式,把EXE那段的a.binaries, a.datas换成[]并加exclude_binaries=True,后面再补一个COLLECT:
exe = EXE(pyz, a.scripts, [], exclude_binaries=True, name="renamer", console=False, icon=str(root / "assets" / "app.ico")) coll = COLLECT(exe, a.binaries, a.datas, strip=False, upx=False, name="renamer")5.4 打包、验证、确认资源真的进去了
pyinstaller --noconfirm --clean renamer.spec打完先别急着发,做两个动作。第一,用pyi-archive_viewer打开产物列内容:
pyi-archive_viewer dist/renamer.exe在交互界面里按L列出顶层目录,看看有没有assets、templates、config三个入口;进assets再看fonts在不在。少了任何一项,问题一定出在 spec 的datas上,跟代码无关。目录模式更简单,直接打开dist/renamer/_internal/用文件管理器看。
第二,故意把resource_path的返回值打印到日志里,跑一次看路径是否落在_MEIPASS下、文件是否真实存在。我一般会在启动日志里固定输出一行:
logging.info("resource root = %s", getattr(sys, "_MEIPASS", "dev")) logging.info("font exists = %s", Path(resource_path("assets/fonts/SourceHanSans.ttf")).exists())有了这一行,再有人报"字体没生效",看一眼日志就定了。
6. 资源带上之后,体积和报错的两个战场
资源一多,包会变大;包一变大,新的问题就来了。这一节是我踩坑密度最高的部分。
6.1 把体积从两百多兆压到四十兆
| 手段 | 具体做法 | 注意点 |
|---|---|---|
| 排除无用模块 | excludes加tkinter、unittest、pydoc_data、test | 别无脑排email、urllib,很多库间接依赖 |
| 换轻量依赖 | 图形处理换opencv-python-headless | 无窗口环境本来也用不上 GUI 部分 |
| 关闭 UPX | upx=False | UPX 压缩后杀软误报概率上升,部分 dll 还会加载失败 |
| 剥离符号 | Linux 下strip=True | Windows 上收益很小 |
| 精简资源 | 字体做子集化,图片压缩,删掉设计稿 | 这一步收益常常最大 |
| 目录模式替代单文件 | 避免重复解压带来的磁盘占用 | 分发形态会变 |
我最近一个项目从 230MB 降到 42MB,最大的贡献其实来自第三行和第五行:换掉了一个带完整 GUI 依赖的库,再把 12MB 的中文字体子集化成 2MB。
6.2 报错对照表:看到这些现象先查这几处
| 现象 | 大概率根因 | 处理方式 |
|---|---|---|
FileNotFoundError且路径含_MEI | 资源没进包,或代码用了相对路径 | 查datas,改用resource_path |
FileNotFoundError且路径是桌面或 exe 同级 | 用了os.getcwd()或裸相对路径 | 同上 |
ModuleNotFoundError只在打包后出现 | 动态导入未被分析到 | hiddenimports或--collect-all |
| 双击完全没反应 | windowed 模式吞掉了异常 | 写日志,或临时改console=True |
Failed to execute script 'main' | 启动阶段抛异常 | 看控制台 traceback |
| 构建日志里资源路径带乱码 | 构建路径含中文或空格 | 构建目录换成纯英文短路径 |
PermissionError写配置失败 | 往包内或 Program Files 写数据 | 改写到用户数据目录 |
| 启动卡十几秒 | 单文件模式下的大资源解压 | 换目录模式,或压缩资源 |
| 杀软直接删掉 exe | 自解压行为触发误报 | 换目录模式,或做代码签名 |
排查顺序建议固定成三步:先用--console复现拿到真实 traceback;再核对datas和包内实际内容;最后确认代码里的路径来源是不是resource_path。九成问题在前两步就暴露了,很少需要动到第三步。
6.3 打包完成的最后一公里:换台干净机器跑一遍
我见过太多"在我这没问题"的发布。有三个坑只有换机器才暴露:一是运行库缺失(目标机没装对应的 VC++ 运行库),二是权限差异(装在C:\Program Files下才暴露写目录不可写),三是杀软策略差异(自己机器上早已加白)。
我的发布前自检固定是这几条:
- 在干净虚拟机或容器里从零跑一遍,不装 Python 环境。
- 检查资源目录是否完整,用
pyi-archive_viewer或直接翻_internal。 - 故意触发一次写配置,确认落到了用户目录而不是包内。
- 用无控制台模式跑一次,确认异常会写进日志文件并且日志目录可创建。
- 记录 Python 版本、PyInstaller 版本、依赖版本,写进发布说明。
7. 多平台和流水线里,资源打包还有几个额外讲究
最后聊几个跨平台场景下的实际问题,主要给需要出多平台包或者接 CI 的人。
PyInstaller 不支持交叉编译。在 Windows 上只能打 Windows 包,Linux 包必须在 Linux 上打,macOS 包必须在 macOS 上打。做多平台就得配多平台 runner,或者用容器打 Linux 包。用容器的时候有个细节值得注意:基础镜像的 glibc 版本决定了产物的向下兼容范围,在较老的发行版镜像里构建,能覆盖的目标系统更广;反过来在最新版镜像里构建,拿到旧机器上可能直接报版本不满足。
CI 脚本里分隔符要按平台分支。同一份构建脚本,Linux runner 用冒号、Windows runner 用分号,最省心的做法是干脆不用命令行--add-data,统一走.spec,让平台差异只体现在 runner 上,不出现在构建参数里。
构建缓存和--clean的取舍。--clean会清掉缓存和临时文件,打包更慢但结果更可靠。我的经验是:代码改动走增量,资源改动一律加--clean。因为资源文件的增量判断在某些版本组合下不够可靠,出现过"明明加了文件但包里没有"的情况,加一次--clean就对了,为省那点时间不值当。
把 PyInstaller 版本钉死。5.x 到 6.x 在目录布局(多了_internal)、datas元组长度、Tree用法上都有变化。你的构建文档写得再好,也挡不住 runner 上自动升级到新版本。在requirements.txt里写死pyinstaller==6.x.y,是成本最低的稳定性保障。如果你的分发目录结构不能变,6.0 之后可以用contents_directory参数把_internal的名字改回去,保持和旧版本一致的目录形态。
产物的冒烟测试要脚本化。我给每个工具都保留一个--version或--selftest参数:启动后加载一遍所有内置资源、打印资源根路径、写一次日志再退出,返回码非零就判定失败。CI 里跑一次这个,能挡住绝大多数"打出来的包缺资源"的低级事故,比人工点开界面检查靠谱得多。
回头看我这些年跟 PyInstaller 资源问题打交道的过程,真正要记住的其实就三条:资源声明方式选一种并且只选一种(要么命令行要么 spec,别混),资源定位统一走resource_path这类函数(别用相对路径和getcwd),只读资源和可写数据分开放(包内写不了、写了也会丢)。把这三条落实到位,那些_MEIPASS带来的诡异现象基本就绝迹了。剩下唯一会让我多花时间的,是第三方库自带资源没被 hooks 收全——遇到这种情况别急着自己抄路径,先去翻那个库的安装目录,看清楚它运行时到底在找哪个文件,再用collect_data_files把整个目录收过来,通常一次就解决。