尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python模块导入全解析:从ModuleNotFoundError到循环导入的排查与解决

Python模块导入全解析:从ModuleNotFoundError到循环导入的排查与解决 1. 从一顿操作猛如虎到ImportError先搞清楚Python到底怎么找模块你有没有遇到过这种情况明明pip install已经装好了某个第三方库写代码的时候也自动补全了结果一运行就摔在ModuleNotFoundError: No module named xxx上。更气人的是身边同事跑同样的代码一点问题没有你的环境却死活不认账。我第一次被这个坑折磨是在一个项目里同时用了虚拟环境和全局Python装包的时候忘了激活虚拟环境装完自信满满地运行结果就给我来了这么一出。后来带过不少新人发现大部分Python模块导入问题其实都源自同一个根源没弄懂Python的模块查找机制也分不清import语句背后到底走了哪几条路径。Python解析import xxx的时候做的事情其实非常朴素——它按顺序去几个固定的位置找名为xxx的模块文件.py文件或包包含__init__.py的目录找到第一个就停下来加载找不到就抛ModuleNotFoundError。这个查找顺序大致如下当前脚本所在目录或者说当前工作目录。PYTHONPATH环境变量里列出的路径。Python安装目录下的site-packages第三方库的默认安装位置。标准库所在目录。这四条路径全部汇总在一个叫sys.path的列表里。你可以打开Python交互式环境输入以下代码亲眼看一看import sys for path in sys.path: print(path)你会看到一大堆路径顺序就决定了导入时的优先级。很多导入问题说白了就是sys.path里的内容和你的预期不一致。比如你把模块文件放在了一个不在sys.path里的自定义目录那Python当然找不到它。理解了这一点后面排查问题就有了方向。这篇文章里我会把最常见的几类导入报错、排查套路、以及循环导入、相对导入这些进阶场景一次讲透每一类都会带上可以直接复现的实例如果你正在被导入问题折磨照着操作大概率能解决。2. ModuleNotFoundError的三大典型现场与逐层拆解2.1 模块根本没装这个最简单但也最容易看走眼最常见的场景就是你确实运行了pip install但Python还是说找不到。这时不要急着怀疑人生按顺序确认下面几件事。先确认安装命令是不是真的执行成功了。有的终端窗口开了很久环境变量没刷新虽然pip提示安装成功但新开的Python进程还是读不到。这时候关掉终端重开一个或者直接重启IDE问题常常就解决了。再确认你用的pip属于哪个Python。这一步是重灾区。很多人电脑里同时装了Python 2和Python 3或者通过Anaconda、Homebrew、pyenv等工具管理多个Python版本输入pip和输入python -m pip的时候对应的可能是完全不同的两个环境。举个例子你在终端里执行python -m pip install requests这个命令保证把requests装到当前python命令对应的那个环境里。如果你直接执行pip install requests那用的可能是另一个环境的pip。所以最稳妥的检查方式是在终端里这样操作python -c import sys; print(sys.executable) python -m pip show requests第一条命令打印当前Python解释器的完整路径第二条命令确认requests是否安装在这个解释器对应的site-packages里。如果sys.executable指向的路径和你预期的不一致那你就要反思一下是不是激活了错误的虚拟环境。顺便说一句如果你用的是虚拟环境强烈建议每个项目都建一个激活之后再装包是基本操作。但有时候你明明激活了pip却还是指向全局环境这多半是激活脚本没生效或者你在IDE里跑的Python解释器压根不是终端里激活的那个。这时候在IDE里检查一下当前项目的解释器路径PyCharm里就是Settings - Project - Python Interpreter确认它指向虚拟环境里的python。2.2 模块装了但版本或架构对不上还有一种情况pip show明明能看到模块但导入还是失败。这时候要怀疑是不是装了一个“残缺”的包或者装错了平台版本。比如scipy、numpy这类带C扩展的库如果pip用了错误的二进制包导入时可能报ImportError: DLL load failed或者numpy.core.multiarray failed to import这就是典型的架构不匹配。Windows上32位Python和64位Python混用、Python 3.7和Python 3.8的预编译包不通用都会引发这种问题。遇到这种报错最直接的解决办法是彻底卸载重装并且指定版本python -m pip uninstall numpy scipy python -m pip install numpy scipy --only-binaryall--only-binaryall强制只使用预编译的wheel包避免现场编译造成的各种乱子。如果你在macOS上遇到clang: error这类编译错误通常就是系统缺了Xcode Command Line Tools跑一下xcode-select --install装一下就好。2.3 模块在另一个目录你写文件的姿势不对还有一种特别容易踩的坑就是你自己的模块文件没有放到sys.path覆盖的范围内。比如你的项目结构是这样project/ ├── main.py └── utils/ ├── __init__.py └── helper.py在main.py里写import helper会报错因为helper.py在utils子目录里Python的查找路径默认不深入到子目录。正确的写法是from utils import helper或者from utils.helper import some_function。但如果你在utils/helper.py里写了import main同样会报错因为Python执行main.py的时候sys.path[0]是main.py所在目录也就是project/helper.py运行时当前目录虽然还是project/但它自己并不在project/的直接子模块查找范围里——这里涉及的是相对导入和包的概念后面专门讲。先记住一个排查原则先打印sys.path再看你的模块文件放在哪里两相对照90%的路径问题都能一眼看穿。3. 打印sys.path、改PYTHONPATH还是用sys.path.append三种定位和解决方案3.1 先用print(sys.path)看清现状遇到导入问题第一件事永远是确认Python当前到底在哪些路径下找模块。把下面这段代码放到你报错的那个脚本的最顶部import sys print(Python解释器:, sys.executable) print(查找路径:) for p in sys.path: print( -, p)运行之后你会得到一份类似这样的输出Python解释器: /usr/local/bin/python3.9 查找路径: - /Users/me/project - /usr/local/lib/python39.zip - /usr/local/lib/python3.9 - /usr/local/lib/python3.9/lib-dynload - /usr/local/lib/python3.9/site-packages你看这些路径第一项/Users/me/project就是当前脚本所在目录只要你的模块文件在这个目录下或者在这个目录下有对应的包名目录就能被找到。如果这里压根没有你放模块的那个目录那就是查找范围的问题。3.2 三种修法按场景选确认是路径问题后有三种修法从临时到长期按需选择。方法一临时性修改在代码里把路径加进去。import sys sys.path.append(/Users/me/project/custom_modules) import my_helper # 现在可以导入了这种方式适合快速验证或者你的脚本本身就是一个需要动态加载外部模块的工具。缺点很明显路径是硬编码换个机器就失效而且混在业务代码里不好维护。方法二改环境变量PYTHONPATH。在终端里这样设置export PYTHONPATH/Users/me/project/custom_modules:$PYTHONPATH python your_script.py这样设置后Python在启动时会把PYTHONPATH里的路径自动加到sys.path里而且排位比较靠前仅次于脚本所在目录。如果你不想每次都手动输入可以把这行加到 shell 的配置文件里.bashrc、.zshrc等。但要注意PYTHONPATH会影响全局所有Python脚本如果你设了多个路径将来排查别的问题时干扰项会变多。方法三做成包用安装方式解决。最干净、最专业的方式是把你的模块做成一个包然后以开发模式安装到当前环境。在项目根目录下建一个setup.py或者用pyproject.toml内容大致如下from setuptools import setup, find_packages setup( namemy_custom_package, version0.1.0, packagesfind_packages(), )然后在项目根目录执行python -m pip install -e .-e表示editable模式也就是开发模式。安装之后这个包里的模块在任何位置都能被直接导入因为Python会把它注册到site-packages里。之后你改代码不用重新安装改动即时生效。这是我最推荐的方式尤其是多个脚本之间共享公共代码的时候比到处写sys.path.append干净一百倍。3.3 一个容易忽略的细节脚本运行方式影响sys.path[0]很多人没注意到sys.path的第一项到底是“当前工作目录”还是“脚本所在目录”取决于你是怎么运行脚本的。如果你直接运行python /path/to/project/main.py那么sys.path[0]是/path/to/project也就是脚本所在目录。但如果你在别的目录下用python -c或者python -i来导入这个脚本情况可能完全不同。再来看一个经常坑人的场景假设main.py在project/目录下你在project/目录外执行了python -m project.main这时候Python会把当前工作目录加到sys.path[0]同时基于包名解析模块。这其实就是-m参数的作用告诉Python“按模块方式运行”自动把当前目录作为顶层包搜索路径。所以如果你在项目外执行python /Users/me/project/main.py和cd /Users/me/project python main.py结果可能不一样。前者能跑通后者也能跑通但如果你在main.py里写了import config而config.py放在/Users/me/project下第二种方式能找到第一种方式其实也能找到因为脚本目录会被自动加入。真正的差异出现在你有嵌套包、相对导入依赖包路径的时候这时-m方式更可靠。建议在项目目录下使用python -m main或者python main.py来运行核心脚本保持工作目录和项目根目录一致能少踩很多坑。4. 循环导入A引用BB又引用A死锁现场还原与解法4.1 一次真实的报错现场循环导入是Python导入问题里最考验理解深度的一种。直接看一个例子。假设我们有这样一个项目project/ ├── a.py └── b.pya.py内容from b import func_b def func_a(): print(A) func_b()b.py内容from a import func_a def func_b(): print(B) func_a()当a.py被执行的时候Python开始加载a.py第一行执行from b import func_b于是Python去加载b.py而b.py的第一行又执行from a import func_a——此时a.py还没加载完func_a在a.py的模块命名空间里还不存在于是Python抛出ImportError: cannot import name func_a from partially initialized module a。这个报错里最关键的信息是partially initialized module意思是“模块只初始化了一半”。因为a.py在还没执行完定义func_a的那一行之前就被b.py反过来请求了。4.2 三种干净的解决思路解法一把导入语句放到函数内部。这是最快的临时解法。把循环依赖的导入语句从模块顶层挪到函数内部# a.py def func_a(): from b import func_b print(A) func_b()这样一来from b import func_b只有在func_a被调用的时候才执行此时a.py已经完整加载了func_a也已定义完毕b.py再反向导入func_a时就不会出错。解法二调整模块职责把共享部分抽出来。循环导入的根本原因是两个模块之间有双向依赖这种设计本身就有味道。比较优雅的做法是新建一个common.py把两个模块都需要的公共变量或函数放进去让a.py和b.py都只依赖common.py不再互相依赖。这是治本的办法推荐在大型项目里真正落实。解法三延迟导入或使用importlib。延迟导入就是把导入动作延后到真正需要的时候比如放在函数里和第一种思路类似。另一种方式是用importlib动态导入# a.py def func_a(): import importlib b importlib.import_module(b) b.func_b()这种方式的好处是显式地表达“运行时才加载”缺点是代码可读性稍差、有动态导入的性能损耗。一般我推荐优先用方案一或方案二除非你有非常特殊的动态加载需求。4.3 怎么快速定位循环导入的源头如果你碰到的循环导入在大型项目里手动寻找互相引用的链条非常痛苦。可以用一个小技巧在报错堆栈里Python会打印出“import”链的完整路径就像这样ImportError: cannot import name func_a from partially initialized module a (most likely due to a circular import)最底部那一长串File ...a.py, line 1, in module就是完整的调用链顺着它就能看到a - b - a的循环路径。如果链路太长看不清楚还有一种笨办法在项目里用grep或者IDE的“Find Usages”功能搜索关键模块的导入关系画一张简单的依赖图一眼就能看出有没有环。5. 包的__init__.py和相对导入为什么有时候加个点就好了5.1 没有__init__.py目录就只是一个普通文件夹在Python 3.3之前一个目录要被当成包导入必须包含__init__.py文件可以是空文件。Python 3.3之后引入了“命名空间包”的概念没有__init__.py的目录也能被导入为包但这会带来一些隐蔽的坑后面再展开。所以我的建议是只要你打算把一个目录作为包来导入就给这个目录加一个__init__.py哪怕里面什么都不写。原因很简单第一保证旧版兼容性第二__init__.py里可以统一控制包的对外接口第三避免命名空间包在复杂项目里产生的稀奇古怪问题。__init__.py的另一个实用技巧是统一导出。假设你的包结构是这样的mypackage/ ├── __init__.py ├── module_a.py └── module_b.py如果你想让别人直接from mypackage import say_hello而不是from mypackage.module_a import say_hello可以在__init__.py里写上from .module_a import say_hello from .module_b import do_something这样mypackage的对外接口就被收敛到了__init__.py一个地方使用者不用关心内部模块怎么拆分你以后重构内部结构也不会破坏外部调用。5.2 相对导入的.和..到底指什么经常有新手在包内写from . import module_a然后问我“这个点是什么意思”。这里的.表示“当前包内”..表示“当前包的上级包内”。这种写法的前提是你所在的文件必须是一个包的一部分也就是它的上级目录必须有__init__.py或者被当成命名空间包。举个例子你的目录结构如下project/ ├── main.py └── pkg/ ├── __init__.py ├── a.py └── sub/ ├── __init__.py └── b.py在pkg/sub/b.py里写from .. import a表示导入pkg/a.py写from . import __init__倒是没人那么干但写from . import something表示从pkg/sub包里导入something模块。使用相对导入有几个关键注意事项不能直接运行相对导入的模块。如果你跑到pkg/sub/目录下执行python b.py会报ImportError: attempted relative import with no known parent package因为直接运行一个.py文件时Python把它当作顶层模块是没有“包”这个概念的。要用python -m方式来运行。在项目根目录下执行python -m pkg.sub.b这样Python会把pkg当顶层包b.py里的相对导入才能正常工作。5.3 命名空间包是个啥为什么小心用当你删掉了某个目录下的__init__.py但这个目录又确实能被导入Python 3就会把它当作“命名空间包”处理。命名空间包允许一个包分散在多个目录里导入时Python会把所有符合名称的目录片段合并成一个逻辑包。听起来很方便但也带来了麻烦命名空间包没有__init__.py所以你不能在包导入时执行任何初始化代码也不能用__path__来做一些包级别的动态处理。另外如果你在不同目录里放了一个同名文件命名空间包可能会让你意外地“合并”两个根本不想干的目录导致导入到错误的模块。还是那句话普通情况下显式地给每个包目录加上__init__.py不要依赖命名空间包的隐式行为。6. 你装的包和import的名字对不上包名与导入名的错位陷阱6.1 经典案例Pillow与PIL、BeautifulSoup与bs4这类问题最典型的表现就是你用pip install装了一个包但代码里import的名字和pip安装时的名字长得完全不一样。你执行的是pip install pillow但代码里写的是from PIL import Image。你执行的是pip install beautifulsoup4但代码里写的是from bs4 import BeautifulSoup。你执行的是pip install opencv-python但代码里写的是import cv2。原因很简单PyPI上的项目名distribution name和导入时的模块名import name是两码事。PyPI允许开发者给项目起一个方便搜索和记忆的名字但导入时用的名字取决于包内部的实际模块结构。这种情况虽然是“已知规则”但每次都能坑到一大批人。排查方法也很简单装完包之后用pip show 包名看它的Location然后去这个目录下看看有没有对应名称的模块文件夹。6.2 自建模块与第三方库重名还有一个更隐蔽的坑你自己的代码文件叫requests.py结果运行的时候Python优先找到了你写的requests.py而不是第三方库requests。还记得前面说的sys.path查找顺序吗脚本所在目录排在最前面所以你的requests.py会“遮蔽”第三方库。这种情况下import requests导入的是你自己的文件然后大概率因为缺少第三方库的API而报AttributeError或者奇怪的TypeError。解决办法很简单不要给你的模块文件命名为那些常见的第三方库名。这一点真的值得反复强调。6.3 检查一个模块到底是从哪个文件加载的如果你怀疑自己导入的模块“不是你以为的那个”可以用下面这段代码来验证import requests print(requests.__file__)输出结果会告诉你这个模块到底是从哪个.py文件或.pyc文件加载的。如果路径指向你的项目目录而你以为用的是site-packages里的版本那就说明重名遮蔽了。同理当你在多个环境之间切换时用print(module.__file__)确认版本是避免混乱的好习惯。7. 我在实际项目中积累的几条导入规范最后分享几条经验都是踩过的坑换来的权当是给后来者的一点参考。第一每个项目必须用虚拟环境。无论是venv、virtualenv还是conda都可以。虚拟环境隔离了第三方库你的项目A和项目B可以各自使用不同版本的requests互不干扰。省下的时间绝对比配置环境的成本多得多。第二项目内的模块导入路径要统一。不要有的地方用import config有的用from utils import config有的用from project.utils import config。要么统一用相对路径在包内部用.要么统一从项目根目录出发用-m方式运行脚本。一个项目里混用多种风格排查起来会让人崩溃。第三别在代码里到处写sys.path.append。如果确实需要临时加路径可以把加路径的逻辑集中到一个配置文件或启动脚本里而不是散落在各个模块里。我见过某个项目里十来个.py文件开头都有sys.path.append后来重构成包安装方式之后不仅启动速度变快了代码也清爽多了。第四遇到导入报错先打印sys.path和module.__file__。很多人碰到ImportError第一反应是去搜索报错原文但与其搜来搜去不如先确认“Python到底去哪里找模块”和“实际加载了哪个文件”这两个信息往往能直接定位80%的问题。第五如果你在IDE里碰到导入问题尤其是PyCharm里用AltEnter导入模块不生效先去检查项目解释器设置而不是怀疑IDE坏了。AltEnter只是帮你补全代码前提是当前解释器里确实有那个模块。设置里的解释器指向了全局Python但你的包装在虚拟环境里那当然怎么按都导入不了。模块导入问题就是这样看起来花样百出拆到根源上其实都是对查找机制和环境管理的理解不够。把sys.path的查找顺序、包的定义方式、运行方式这几个概念吃透再配合上面这些排查手段这一类问题基本都能在十分钟内解决。
返回列表