
上个月帮同事把一个用 pathlib 写的小工具打包成 exe 交付开发环境里跑得稳稳当当PyInstaller 打包过程也没有任何报错。结果对方双击 exe窗口一闪就没了。我让他打开命令行直接运行屏幕上留下一行红字ModuleNotFoundError: No module named scandir。查了大半天才发现问题根本不在于 scandir而在于我这个环境里躺着一个不该存在的pathlib回填包。这个坑非常典型而且网上讨论分散很多人遇到后第一反应是去装 scandir结果越修越乱。这篇文章就把这个“幽灵依赖”的来龙去脉、定位方法和根治方案完整捋一遍给同样在用 PyInstaller 打包 Python 工具的人一个可以直接抄作业的排查思路。1. 典型故障现场打包成功的 exe启动就报 scandir 缺失1.1 最常见的崩溃形式一闪而过的窗口和红色的 ModuleNotFoundError如果你是在 Windows 上打包这个场景会非常熟悉pyinstaller --onefile app.py跑完后dist目录里确实生成了 exe双击却没有任何反应。把 exe 拖进 cmd 里跑报错一般是这样的Traceback (most recent call last): File app.py, line 3, in module File PyInstaller\loader\pyiboot01_bootstrap.py, line 145, in __init__ ... ModuleNotFoundError: No module named scandir注意这里有几个迷惑点第一你的项目代码里根本没有import scandir第二报错堆栈里出现的文件名是pyiboot01_bootstrap.py这说明问题发生在 PyInstaller 自己的引导阶段程序根本还没跑到你的业务代码第三开发环境里 import 一切正常怎么到 exe 里就缺模块了这三条组合在一起基本可以确定是依赖收集环节出了问题而不是你少装了某个库。1.2 症状变体不只是 scandir还有各种 AttributeError如果恰好你的环境里装了 scandir或者 Python 版本更老报错还可能是这样AttributeError: module scandir has no attribute scandirImportError: cannot import name Path from pathlib程序能启动但一调用Path.is_relative_to()之类的新方法就抛AttributeError最后这一种最阴险因为从报错看像是你代码写得有问题。但真相是exe 里被塞进去的pathlib根本不是 Python 3.4 以后标准库里的那个而是一个十年前停止维护的第三方同名回填包它根本没有新版本的方法。1.3 为什么开发环境测不出来很多人会在这里卡住明明我在 IDE 里跑得好好的为什么打包出来就坏了原因在于 Python 解释器启动时sys.path的搜索顺序里标准库目录通常排在 site-packages 前面所以正常情况下import pathlib会命中标准库而 PyInstaller 做模块依赖分析时用的是自己的 modulegraph它在整个搜索路径里“找得到就记下来”的收集策略和解释器运行时并不完全一致。一旦 site-packages 里也存在一个同名模块就有机会被它优先收集进包。于是本地跑没问题打包后拿到没有 Python 环境的机器上启动阶段就直接崩。提示判断是不是这个坑第一反应不要是去装 scandir。scandir 只是被连累的真正的病灶是同名的 pathlib 回填包。2. 根因拆解为什么会有两个 pathlib以及 PyInstaller 为什么会拿错2.1 PEP 428 与标准库 pathlib 的来历pathlib 的面向对象文件系统路径接口在 Python 3.4 通过 PEP 428 进入标准库。从 3.4 到现在的 3.12、3.13它一直在持续演进比如 3.6 加入了PathLike协议3.9 加入了Path.is_relative_to()3.10 加入了Path.hardlink_to()3.12 加入了Path.walk()。如果你用 Python 3.8 以上开发正常情况下import pathlib拿到的必然是标准库实现它存在于 Python 安装目录的lib/pathlib.py中。2.2 PyPI 上那个“同名兄弟”专为 Python 2 时代服务的回填包问题出在 PyPI 上还有一个同样叫pathlib的包。它的定位是给 Python 2.6、2.7 以及 Python 3.3 及以下版本提供 pathlib 的回填实现。这个包最后的版本是 1.0.1发布于 Python 3 已经普及的年代之后基本停止维护。它存在本身没有错错的是它会静静地躺在你的 site-packages 里不被任何人注意。更麻烦的是这个回填包的兼容层代码里对较旧解释器环境会尝试导入scandir当年一个独立的目录扫描库。当 PyInstaller 把这个回填包收进 exe却没有把它的连带依赖一起收全运行时就必然在 import 阶段崩溃。这就是第一节那个ModuleNotFoundError: No module named scandir的直接机制。2.3 谁把它装进了你的环境我实践中遇到的引入方式无非这几种项目从 Python 2 时代迁移过来requirements.txt里残留了pathlib这一行。某个多年没更新的第三方库在自己的setup.py里写了install_requires[pathlib]且没有加环境标记正确的写法是pathlib; python_version 3.4。历史上有同事为了临时修复某个 Python 2 报错手动pip install pathlib之后就再没人管过。这类依赖不会在你升级 Python 版本后自动消失只会一直潜伏直到 PyInstaller 这类“全量搬运工”把它暴露出来。2.4 PyInstaller 的模块收集机制找到就算数PyInstaller 的核心机制是通过 modulegraph 静态扫描你的入口脚本及其所有 import把找到的 Python 模块全部复制到产物里。关键在于它对标准库模块并不总是有“强制锁定到标准库路径”的 hook。以 pathlib 为例PyInstaller 的 hook 体系里没有针对它的特殊处理因此“哪个 pathlib 被收录”完全取决于模块解析时的路径命中。一旦 site-packages 里存在回填包modulegraph 在扫描时完全有可能先命中它。加上如果你显式用过--hidden-import或者某些库在运行时通过importlib动态导入 pathlib这个概率还要更高。收集结果记录在build/应用名/Analysis-00.toc文件里你可以直接看到它收录的 pathlib 到底来自哪个物理路径。2.5 连带问题的放大scandir 被拖下水我这里把连锁反应完整展开方便你以后对号入座模块分析阶段PyInstaller 抓到了 site-packages 里的回填包pathlib.py。回填包源码头部有对scandir的条件导入或引用。PyInstaller 的静态分析要么没把 scandir 加入依赖图要么开发机上 scandir 装了旧版本、收集进来后和新解释器不兼容。打包产物的启动阶段引导逻辑加载 pathlib 时执行到导入 scandir 的代码直接抛 ModuleNotFoundError 或 AttributeError程序当场退出。这个链条解释了为什么报错如此“风马牛不相及”——问题的根源和你看到的错误模块名之间隔了一层。3. 确诊三件套用三条命令锁定问题来源3.1 第一步确认你环境里的 pathlib 到底是谁在项目对应的虚拟环境里依次执行pip show pathlib如果输出显示Name: pathlib、Version: 1.0.1、Location: ...site-packages那恭喜你已经找到了嫌疑犯。这里有个细节标准库模块也是可以pip show的在较新的 pip 版本下标准库模块会显示Version: 1.0左右且没有实际安装位置而第三方回填包的 Version 一定是 1.0.1Location 指向 site-packages。区分它们最直接的方式是看 Location。再看实际加载路径python -c import pathlib; print(pathlib.__file__)正常应该输出类似C:\Python310\lib\pathlib.py如果输出的是...\site-packages\pathlib.py或者干脆是pathlib.pyc位于 site-packages那就实锤了。3.2 第二步用 pipdeptree 找出是谁把它带进来的光卸载还不够你得知道谁引进了它否则下次安装依赖又会长回来。推荐直接用 pipdeptreepip install pipdeptree pipdeptree | grep pathlibWindows 下没有 grep 就用pipdeptree | findstr pathlib输出大概长这样pathlib1.0.1 └── legacy-lib2.1.3 [requires: pathlib]看到第二行你就明白是legacy-lib这个老库在要求安装 pathlib。这种“反向依赖”信息在修复时非常关键决定你是直接卸载还是连上游库一起处理。3.3 第三步查 PyInstaller 的解析日志和 toc 文件环境确认完毕再看打包器到底做了什么。重建一次并打开调试日志pyinstaller --clean --log-levelDEBUG app.py日志会非常长搜索pathlib关键词定位到类似这样的行DEBUG: Analyzing: ...site-packages\pathlib.py如果这里出现的是 site-packages 路径直接锁定。更可靠的证据在产物目录build/应用名/Analysis-00.toc文件里里面记录了所有被收集的模块及其来源路径。搜索(pathlib标准的记录应该指向lib\pathlib.pyc指向site-packages\pathlib.pyc就是异常。3.4 快速判别表标准库 pathlib 与回填包的区别判别维度标准库 pathlibPyPI 回填包 pathlib物理位置Python 安装目录lib/下site-packages/下pip 版本号无实际版本或显示为随解释器发布固定 1.0.1Path方法完整性随 Python 版本持续更新停留在 Python 3.4 时代能力维护状态随 CPython 维护基本停止维护有is_relative_to()Python 3.9 及以上有一定没有这个表最后一行的is_relative_to()非常实用后续验证阶段也可以拿它做“金丝雀检测”。4. 修复实操从卸载回填包到隔离构建环境4.1 方案 A直接卸载90% 场景适用如果你的项目运行在 Python 3.4 以上并且代码里没有显式依赖回填包的任何特性实际上有也不能依赖因为它是残缺的那么最干脆的修复就是pip uninstall pathlib pip uninstall scandir # 如果存在一并清掉卸载后建议顺手验证一下加载路径python -c import pathlib; print(pathlib.__file__)确认输出回到标准库路径再重新打包。这里有一点必须强调一定要用--clean参数或者手动删除 build 和 dist 目录。PyInstaller 会把上一次的分析结果缓存在 build 目录里不清缓存的情况下重新打包可能把旧的错误依赖又原封不动塞回去。很多人在论坛里说“我卸载了但还是报错”十有八九就是吃了缓存的亏。4.2 方案 B上游依赖强制要求 pathlib 时的处理如果第 3.2 步查出了明确的“肇事依赖”比如某个老库硬性requires: pathlib直接卸载会在下次安装依赖时被装回来。正确的做法是分两步走第一步看看这个老库有没有新版本。升级它pip install --upgrade legacy-lib很多老库在后续版本里已经删掉了对 pathlib 的依赖升级完再pipdeptree确认即可。第二步如果确实没有任何新版可用而你又是用 requirements.txt 管理环境可以在里面加一行环境标记约束强制只在 Python 3.4 以下才安装pathlib1.0.1; python_version 3.4这样在 Python 3.4 以上的环境里无论谁声明依赖 pathlibpip 都不会真正安装它。同时手动把环境中已装的卸载掉。如果你维护的是库本身正确做法是在pyproject.toml或setup.py里这样写依赖# pyproject.toml 的 dependencies 里 pathlib; python_version 3.4,这是把依赖“按解释器版本”分流的唯一正确姿势。4.3 方案 C用 spec 文件强制锁定我不推荐网上有些帖子会教你改.spec文件把excludedimports加上pathlib或者调整pathex。这里必须泼盆冷水不要排除 pathlib。你的代码依赖标准库的 pathlibPyInstaller 打包后的 exe 不会带着整份标准库而是把自己收集到的模块装进包你一旦把它 exclude 掉连正确的那个也没了。至于调pathex把标准库目录前置虽然逻辑上可行但非常脆弱换个 Python 版本、换个机器就可能失效。我的建议是spec 文件方案只适合做“最后一公里的微调”不该用来解决“环境里有不该存在的包”这类问题。正确顺序永远是先治理环境再谈打包配置。4.4 方案 D最推荐的工程化思路——独立构建环境如果你经常用 PyInstaller 打包或者要把打包流程固化到 CI 里我强烈建议准备一个纯净的虚拟环境专门做构建python -m venv build-env build-env\Scripts\activate pip install pyinstaller pip install -r requirements.txt # 仅运行时依赖 pyinstaller --clean app.py这个环境里只装打包必需的工具和运行时依赖不装任何开发期、测试期的可选包。回填包这种“历史遗留物”根本没有机会出现。这个思路治本而且能顺带解决 PyInstaller 打包体积莫名变大、exe 里混进一堆测试库之类的问题。注意requirements.txt里如果写死了pathlib请在纯净环境创建后先检查再安装。建议用 4.2 节的方式给旧依赖加上环境标记或者干脆在 requirements 里删掉这一行。4.5 修复后的重建步骤清单无论选哪个方案重建的步骤是一致的在项目虚拟环境执行pip list确认 pathlib 和 scandir 的状态。执行pipdeptree | findstr pathlibWindows确认反向依赖已清除。删除build/、dist/或者用pyinstaller --clean。重新打包观察日志里 pathlib 的来源路径。打开build/应用名/Analysis-00.toc确认 pathlib 指向标准库路径。5. 修复后的验证别让打包工具骗了你5.1 基础冒烟测试重新打包后先别急着发出去。在命令行里直接运行一次 exedist\app.exe没有异常输出且程序正常结束时说明启动阶段的导入问题已经解决。如果程序有命令行参数把常用的几种输入都跑一遍尤其是涉及文件路径操作的比如传入一个带中文和空格的目录路径。这一步很重要因为回填包时代遗留的字符串处理逻辑在 Windows 中文路径下经常埋雷。5.2 给 exe 加一个“自检金丝雀”为了确认 exe 里内置的确实是标准库 pathlib可以在入口脚本里临时加一段自检代码或者做一个带--self-check参数的小功能import sys import pathlib def self_check(): print(frozen:, getattr(sys, frozen, False)) print(pathlib file:, pathlib.__file__) print(has is_relative_to:, hasattr(pathlib.Path, is_relative_to)) print(MEIPASS:, getattr(sys, _MEIPASS, None)) if __name__ __main__: if --self-check in sys.argv: self_check() sys.exit(0) # 正常主逻辑...打包后运行dist\app.exe --self-check在 Python 3.9 以上的构建环境里has is_relative_to必须是True。如果是False说明回填包还是混进了包返回第 3 节重新查。pathlib.__file__在 onefile 模式下会指向sys._MEIPASS解压临时目录里的文件路径本身说明不了太多关键是hasattr的结果。5.3 进阶验证在干净机器上跑一次其实最残酷、也最有效的验证是找一台没装 Python 的干净 Windows 机器把 exe 复制过去跑一遍。没有解释器、没有 site-packages 干扰它能不能活全看 PyInstaller 塞进去了什么。这也是交付前最有说服力的测试。如果没有干净机器Windows 沙箱或者虚拟机的快照环境也可以。如果嫌手动麻烦可以用pyi-archive_viewer直接检查产物内部pyi-archive_viewer dist\app.exe输入A列出归档模块搜索 pathlib 相关条目。配合 toc 文件这一步可以确认最终产物里到底收录了哪些 pathlib 文件。5.4 顺带排查pathlib 和 PyInstaller 的另几个常见配合问题把回填包的坑填完我建议顺手检查一下你的代码里有没有这几个和 PyInstaller 打包后的行为差异相关的隐患它们经常和 pathlib 同时出现onefile 模式下__file__指向临时目录。用Path(__file__).parent定位资源文件在开发环境是对的但 onefile 模式的 exe 会把自身解压到临时目录的_MEIPASS此时__file__指向临时目录不是 exe 所在目录。想定位 exe 旁边的资源应该用Path(sys.executable).parent想定位打包进 exe 的资源用Path(sys._MEIPASS)。subprocess传参时注意 Path 对象转换。Python 3.6 以后系统接口普遍支持os.PathLike协议给subprocess.run传 Path 对象是安全的但如果程序里调用了某些 C 扩展库或者老版本第三方库它们可能只接收字符串。稳妥做法是str(path)后传入。macOS 的 .app 包中工作目录不可写。如果你在 macOS 用 PyInstaller 打 .app 包用户双击启动时Path.cwd()往往是/写入文件会失败。业务逻辑里应避免直接依赖 cwd需要写文件时先确定一个可写的用户目录。这些虽然不是回填包冲突本身但它们都属于“开发环境正常、打包后行为异常”的 pathlib 相关雷区打包前排查一遍能省掉大量交付后的远程调试。6. 长期预防依赖治理与打包前自检习惯6.1 从依赖声明上杜绝回填包进入项目最根本的预防是在所有库的依赖声明里给 pathlib 加上环境标记。自己维护的库务必写成pathlib; python_version 3.4而不是在install_requires里裸写pathlib。后者会让任何 Python 版本、任何环境下都安装回填包给未来所有用 PyInstaller 打包的人埋雷。代码评审时这一条应该纳入检查项。6.2 把依赖审计变成常规动作在项目维护里我习惯每季度做一次依赖审计核心就两条命令pip list --outdated pipdeptree重点看三类东西版本长期不动的包、依赖树里出现了标准库同名的第三方包、以及 Python 2 时代遗留的包。pathlib 回填包几乎必然以“版本 1.0.1 十年不动”的形态出现很好辨认。6.3 打包前自检命令把下面这段写成一个check_build_env.py脚本放在项目根目录每次打包前必须跑过一次import pathlib import sys p pathlib.__file__ if site-packages in p: print(f[ERROR] pathlib 来自 site-packages: {p}) print(请先执行: pip uninstall pathlib) sys.exit(1) print(f[OK] pathlib 来源: {p})Windows 和 Linux 下都适用因为标准库的 pathlib 一定位于 Python 安装目录的lib下不包含site-packages字样。把这个检查和pyinstaller --clean串成一条命令形成肌肉记忆python check_build_env.py pyinstaller --clean app.py6.4 一点个人体会这个坑我前前后后踩过两次。第一次是帮同事排查绕了很多弯路去研究 scandir 本身浪费了一下午第二次是在迁移一个 Python 2 老项目时有了经验五分钟就定位到是 pathlib 回填包。它给我的教训是PyInstaller 的报错信息经常是“声东击西”的启动阶段的 ModuleNotFoundError 不要只看报错名而要顺着依赖收集链路查源头。养成打包前检查环境、打包后看 toc 文件的习惯比记住任何一条具体报错都有用。希望这篇复盘能帮你少走这段弯路。