ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

python -m 与 python xxx.py 的区别:相对导入与 ImportError 排查

python -m 与 python xxx.py 的区别:相对导入与 ImportError 排查 在Python里python xxx.py和python -m package.module的差别是很多人在import报错之后才开始认真琢磨的事。我见过不少项目里明明代码在别人机器上是好的自己一跑就弹ImportError排查半天发现只是启动命令不同。尤其是当你遇到attempted relative import with no known parent package这种报错时多半不是代码写错了而是你用了直接运行的方式去执行一个本该以模块方式运行的包内文件。这篇文章我想从一次实际踩坑经历讲起把这两种运行方式的底层差异、相对导入为什么必须在包环境下才能工作、以及独立脚本和包内模块该怎么选运行方式一次说清楚。1. 同一个包两种运行方式结果完全不同先看现象1.1 一个最小复现项目先造一个最小的项目结构后面所有讨论都基于它demo_proj/ ├── pkg/ │ ├── __init__.py │ ├── module_a.py │ └── module_b.py__init__.py是空文件只是为了声明pkg是一个包。module_a.py里用相对导入引用同包下的module_b.py# pkg/module_a.py from .module_b import helper def run(): print(module_a is running) print(helper():, helper()) if __name__ __main__: run()module_b.py内容很简单# pkg/module_b.py def helper(): return hello from module_b现在进入demo_proj目录分别用两种方式运行module_a.py。方式一直接运行脚本文件cd demo_proj python pkg/module_a.py结果Traceback (most recent call last): File /path/to/demo_proj/pkg/module_a.py, line 1, in module from .module_b import helper ImportError: attempted relative import with no known parent package方式二用-m以模块方式运行python -m pkg.module_a结果module_a is running helper(): hello from module_b同一份代码同一个目录只是启动命令不同一个是ImportError一个是正常执行。这就是整篇文章要解释的核心现象。1.2 改成绝对导入之后换了个错误继续报有些同学遇到上面的报错后第一反应是把相对导入改成绝对导入# pkg/module_a.py from pkg.module_b import helper结果在demo_proj目录下直接运行python pkg/module_a.py还是报错Traceback (most recent call last): File /path/to/demo_proj/pkg/module_a.py, line 1, in module from pkg.module_b import helper ModuleNotFoundError: No module named pkg这次错误换成了找不到pkg包。很多人看到这里就懵了明明pkg目录就在项目里为什么说找不到原因是当你执行python pkg/module_a.py时Python只把脚本所在目录——也就是pkg/这个目录——放进了模块搜索路径的最前面。在Python看来pkg/目录是根目录它里面没有pkg这个子包所以from pkg.module_b自然找不到。验证方法很简单在module_a.py里临时加两行打印import sys print(sys.path[0]) print(__file__)直接运行时第一行输出的就是/path/to/demo_proj/pkg。而用python -m pkg.module_a运行时第一行输出的是当前工作目录demo_proj所以pkg能被找到。1.3 为什么会有两个视角的存在很多人不理解为啥Python要搞出两种视角。直接运行脚本时Python把脚本所在目录当作顶层目录用-m运行时Python把你敲命令时的当前目录当作顶层目录。这两种视角在最简单的单文件脚本里没有区别但一旦代码变成多文件、有包结构、有相对导入区别就立刻暴露出来。说句实在话早期Python没有-m这种运行时大家写多文件项目只能靠手工改sys.path或者把脚本挪位置各种坑就是这么来的。后来-m模式就是用来解决以模块身份运行这个问题的。理解了这两种视角上面那两个报错你就不会再害怕了。2. 解释器内部的三个关键状态sys.path、name、package2.1 sys.pathPython查找模块的寻址表sys.path是一个目录列表Python导入模块时从前往后逐个目录查找。无论用哪种方式运行第三方包的路径site-packages都会在但排在最前面的当前目录是由启动方式决定的。用下面的代码在两种方式下分别打印import sys print(sys.path[0]:, repr(sys.path[0])) print(__name__:, __name__) print(__package__:, repr(__package__))我做了一张对比表这是理解全文的基础运行方式sys.path[0]__name____package__python xxx.py脚本所在目录的绝对路径__main__无None或空python -m pkg.module当前工作目录通常是空字符串表示当前目录__main__pkg注意一个容易误会的点python -m pkg.module运行时入口模块的__name__也是__main__不是pkg.module。它只是以__main__的身份去执行pkg/module.py这份代码同时把它属于pkg包这个信息记录在__package__里。这个两个名字的现象后面会详细说。现在你只需要记住sys.path[0]决定了绝对导入能不能找到你的项目包__package__决定了相对导入能不能解析。2.2name都是main但身份信息藏在旁边很多教材会告诉你if __name__ __main__是用来判断这个文件是不是被直接运行的。这句话没有错但被很多初学者简化成了被直接运行的文件就是脚本不是模块。严谨一点说python -m pkg.module_a运行入口文件时入口模块的名字也是__main__。所以入口模块里的if __name__ __main__照样会进。区别不在__name__而在__package__和sys.path。这就是为什么你可以在module_a.py里用if __name__ __main__把这层判断放在那里无论用哪种方式运行入口的主模块身份判断都是成立的。要判断当前是不是用模块方式运行的你需要看__package__或者看__spec__的存在情况但日常开发中用不这么细知道有区别就够了。2.3package相对导入的那颗定盘星相对导入里的点号比如from .module_b import helperPython到底怎么解析它用的是模块的__package__属性。如果__package__是pkg那from .module_b import helper就会被解析成from pkg.module_b import helper。如果__package__是空的Python就不知道这个点号指哪个包于是直接报错。直接运行python pkg/module_a.py时__package__没有值相对导入无从谈起。用python -m pkg.module_a运行时Python会先解析模块名把pkg.module_a拆出父包pkg然后设置__package__ pkg再执行代码。这相当于Python给了这个文件一张身份证你属于pkg包。所以相对导入必须在包环境下才能工作这句话本质上就是模块的__package__必须被正确设置且它的父包必须能被导入。-m模式就是帮你干这件事的。3. 为什么说过相对导入必须在包环境下才能工作3.1 点号的解析逻辑不只是找兄弟文件很多人会把from .module_b import helper理解成导入本文件旁边的module_b模块。这种理解能用但会让人忽略一个关键module_b这个名字前面的点号不是文件系统层面的当前目录而是模块命名空间层面的当前包名。举个生活化一点的类比你在某个公司包的某个部门模块里说找我们部门的小张就是相对的说法。但前提是你心里得清楚我们部门到底指哪个部门。__package__就是那个我们部门的指代。如果你连自己属于哪个部门都不知道这句话就没法执行。所以当Python报attempted relative import with no known parent package时翻译过来就是我不知道自己属于哪个包所以没法把点号解析成一个真正可导入的模块名。这不是module_b找不到而是连pkg这个名字都没定下来。3.2 谁负责给模块装上包身份直接运行脚本文件时Python循着文件路径去执行这份代码它不会逆向推断这个文件在哪一层包里。哪怕pkg/__init__.py就在旁边Python也完全不在意。在这个视角下pkg/module_a.py就是一个独立的顶层脚本。-m模式不同。你给的参数是pkg.module_a这是一个完整的模块路径。Python收到后会先做模块解析确定pkg是父包、module_a是包内模块然后按顺序把当前工作目录加入sys.path导入父包pkg执行pkg/__init__.py创建主模块对象设置__name__ __main____package__ pkg执行pkg/module_a.py的代码。这一步导入父包非常重要。它保证了pkg已经在sys.modules里相对导入往上找父包时不会扑空。这才是包环境二字的完整含义不仅__package__要正确父包本身也得真正被导入。3.3 beyond top-level package 的层级越界是怎么来的和attempted relative import with no known parent package经常一起出现的还有个错误attempted relative import beyond top-level package。两者容易混淆但原因差一层。看一个稍微复杂的结构demo_proj/ ├── pkg/ │ ├── __init__.py │ ├── sub/ │ │ ├── __init__.py │ │ └── module_c.py如果module_c.py里写from ... import something然后用python -m pkg.sub.module_c运行。此时__package__是pkg.sub三个点号要往上追两层pkg.sub往上一层是pkg再往上一层就要越过pkg这个顶层包了——上面没有其他包子了于是报beyond top-level package。换句话说no known parent package是没有包名beyond top-level package是包名有了但你的相对层次超出了顶层包的范围。排查时先看报错文案是哪一个再决定去查__package__还是查相对层级的点数。4. 独立脚本与包内模块两种代码的自我定位4.1 独立脚本的设计原则先定义清楚独立脚本指什么一个不依赖包上下文、可以单独用python xxx.py运行的文件。它通常具备这三个特征文件本身不需要从父包导入任何东西它导入其他代码时使用绝对导入并且导入路径通过sys.path能找到文件放在项目根目录而不是某个包的内部。一个规范的单文件小工具长这样# 项目根目录/tool.py import os import sys import json def process(path): ... if __name__ __main__: process(sys.argv[1])这种脚本没有包层面的依赖用python tool.py跑没有任何问题。因为sys.path[0]就是脚本所在目录它自己就在搜索路径里导入同目录的纯模块或第三方库都正常。4.2 包内模块的设计原则包内模块恰恰相反。它是某个包的一部分经常通过相对导入引用兄弟模块它的正常生存环境是包已导入、__package__已设置。前面例子里的pkg/module_a.py就是一个标准的包内模块。这种模块的设计初衷不是给你直接执行的而是给这个包里其他模块或外部入口导入用的。如果你非要用python pkg/module_a.py去执行它就会触发相对导入报错。这不是代码写错了而是你用错了运行方式就像你非要拿开瓶器去拧螺丝拧不动不能说螺丝有问题。4.3 判断一个文件该用哪种方式运行的快捷方法我在实际项目里总结了一个很快的判断方法分享给大家如果你的文件里出现了from .、from ..这种相对导入或者它放在某个包里且依赖同目录的兄弟文件那么它一定是包内模块。它更适合用python -m 包名.模块名来运行或者通过另一个入口脚本导入它。如果你的文件里全是顶层绝对导入且放在项目根目录那它就是独立脚本用python xxx.py直接运行即可。有一种碰巧能跑的情况最危险demo_proj下运行python pkg/module_a.py但module_a.py里写的是from module_b import helper。因为直接运行时sys.path[0]是pkg目录module_b就在这个目录下确实能导入成功代码能跑。但一旦你换到项目根目录用-m方式运行或者把文件挪个位置from module_b import helper立刻失效。这种歪打正着的代码是工程里的隐形炸弹我建议通过打印sys.path[0]和__file__的方式尽早发现。5. python -m 的运行细节入口模块、父包与main的双重身份5.1 -m 模式的完整执行流程python -m pkg.module实际做的事情远比多打了几个字符要多。用伪代码表示它的执行意向# 说明性伪代码帮助理解 -m 的流程 import sys import importlib # 1. 当前工作目录进入 sys.path 的起始位置 sys.path.insert(0, ) # 2. 解析 pkg.module得到模块的 spec spec importlib.util.find_spec(pkg.module) # 3. 导入父包 pkg执行 pkg/__init__.py pkg importlib.import_module(pkg) # 4. 创建名为 __main__ 的模块对象 main_module type(sys)(__main__) main_module.__package__ pkg main_module.__spec__ spec # 5. 以 __main__ 身份执行 pkg/module.py 代码 exec_module(spec, main_module)这个流程解释了三个关键点入口模块虽然是被当成主模块执行但它的__package__被正确设置成了pkg父包pkg会被提前导入所以你可以在相对导入时向上找到它sys.path里的起始位置是当前工作目录不是脚本所在目录所以项目根目录下的包都能被找到。这也是标题里入口模块、包内模块、父包这三个词的关系入口模块是你指定的启动点它位于某个包内而那个包就是它的父包。-m模式就是Python帮你把入口模块-包内模块-父包三者关系梳理清楚的关键手段。5.2 sys.modules 里的main与 pkg.module 双重身份这里有个有意思的细节。-m模式下同一份代码在执行期间sys.modules里其实有两个键都指向这个模块对象一个是__main__一个是pkg.module。这意味着什么如果你在pkg/module.py里写了import pkg.modulePython会命中缓存不会再次执行这份代码。但你如果通过sys.modules[pkg.module] is sys.modules[__main__]判断会发现它们是同一个对象严格说是同一次执行产生的模块对象。这个特性能解释一些疑难问题。比如你写代码时if __name__ __main__里的逻辑执行了一遍但你在另一个被导入的模块里通过sys.modules[pkg.module]去访问它的属性发现也能看到这个模块的属性。因为我建议在调试时打印一下import sys print(sys.modules.get(__main__)) print(sys.modules.get(pkg.module))看到两个引用指向同一份模块对象很多为什么这个模块好像被导入了两次的困惑就解开了。直接运行脚本时没有这层关系sys.modules[__main__]和sys.modules[pkg.module]是两个完全不同的模块对象。5.3 python -m pkg 与 pkg/main.py-m还能直接运行一个包比如python -m pkg。这种情况下Python会在pkg包内查找__main__.py文件然后执行它。这是包对外提供命令行入口的常见做法。pkg/ ├── __init__.py └── __main__.py__main__.py里照常可以用相对导入引用兄弟模块因为python -m pkg时__package__就是pkg包环境是完备的。这种设计让整个包像一个可以执行的程序而且不会污染包的导入接口。注意区分python -m pkg和python pkg/__main__.py。后者是直接运行一个文件__package__照样是空的。很多人以为既然__main__.py是包入口那直接运行它应该也行结果踩了同样的相对导入坑。结论很明确__main__.py同样只认-m不认双击运行。5.4 为什么 Python 生态里到处是-m 模式你有没有注意到安装包时官方推荐python -m pip install xxx而不是直接pip install xxx创建虚拟环境时是python -m venv .venv跑测试时很多人用python -m pytest。这些工具为什么清一色要求用-m因为-m模式能保证模块名解析发生在当前环境正确的sys.path下并且能正确处理包内模块之间的相对导入。比如你在一个虚拟环境里如果直接用pip命令可能因为PATH里的pip不是你当前Python环境对应的那个导致装错地方。用python -m pip就从根源上保证我执行的就是当前这个python解释器对应的pip工具。同理pytest作为包内模块用python -m pytest运行时当前目录会被加入sys.path你项目里的包结构能正常被检索直接敲pytest虽然也能跑但有时候会遇到导入路径不对、报找不到项目的包的问题。理解了-m的机制你会发现这些官方习惯背后是同一个逻辑。6. 常见 ImportError 排查思路哪些是运行方式惹的祸6.1 attempted relative import with no known parent package这个报错是本篇文章的主线之一也是最好识别的一类。看到它第一反应应该是有人用直接运行脚本的方式运行了一个包内模块。排查步骤检查文件里是否有相对导入from .或from ..如果有看启动命令是不是python 路径/文件.py把启动命令改成python -m 包名.模块名运行目录改到项目根目录。这个过程可以在一分钟内完成绝大多数情况下问题就解决了。6.2 No module named xxxsys.path 没把项目根目录放进去这类报错很迷惑人因为xxx明明就在项目里。其实问题不是文件不存在而是Python的模块搜索路径里没有那个文件所在的项目根目录。举个例子文章开头的from pkg.module_b import helper报No module named pkg就是因为直接运行时sys.path[0]指向了pkg目录项目根目录反而没在搜索路径里。排查时在报错文件的顶部加一行import sys; print(sys.path)然后对照两种情况直接运行sys.path[0]是脚本所在目录-m运行sys.path[0]是当前工作目录。然后问自己一个问题你import pkg时pkg目录在哪如果在当前工作目录下就用-m如果在脚本所在目录的上一层直接运行就会比较麻烦。大部分时候把运行方式改成-m并保证在项目根目录运行是最省事的解法。6.3 beyond top-level package 的边界排查这个报错我在3.3节讲过。排查时先确认模块的__package__是什么再数一数相对导入里的点号到底点了多少层。点号超过顶层包就会触发这个错误。处理方式有两种如果确实需要引用顶层包之外的东西说明这个模块的层级设计有问题考虑把公共代码下沉到更低的公共包如果只是点数写多了改回正确的点数即可。相对导入的点数与模块在包内的深度严格相关pkg.sub.module_c对应一个点号from . import xxx同一层模块两个点号from .. import xxx父包层也就是pkg三个点号from ... import xxx再往上一层也就是项目根目录这一层但项目根目录不是包所以报错。6.4 容易被误判的伪 ImportErrorDLL 加载、库迁移这类不算运行方式问题写过Python的人大概都见过各种五花八门的ImportError。不是所有ImportError都跟运行方式有关。我把常见情况分了三类方便你快速定位典型报错原因类别排查方向attempted relative import with no known parent package运行方式问题改成python -m 包名.模块名No module named pkg运行方式/路径问题看sys.path确认当前目录attempted relative import beyond top-level package相对层级问题数点号看包深度ImportError: DLL load failed while importing PyQt5.QtGui环境依赖缺失检查wheel是否匹配平台、VC运行库ImportError: numpy.core.multiarray failed to import版本不匹配重装与Python版本匹配的numpyfrom pettingzoo.mpe import ... ImportError: mpe has been moved库结构迁移按新版本API调整导入路径后三类报错哪怕你把sys.path翻个底朝天也修不好因为根本不是路径问题。PyQt5那种DLL load failed通常是Python位数、编译工具链和wheel不匹配numpy.core.multiarray报错多是numpy版本与某些扩展编译时的版本对不上需要在干净环境里重装mpe has been moved就更直接了是库作者改了包结构你得跟着改import语句。遇到ImportError先分类再决定排查方向。如果一上来就怀疑运行方式很容易浪费时间。7. 工程实践入口文件与运行方式怎么设计最省心7.1 推荐的项目结构把入口脚本和包分开是避免这类问题最根本的办法。我习惯的结构是这样myproject/ ├── main.py ├── mypackage/ │ ├── __init__.py │ ├── core.py │ └── cli.py约定很简单main.py放在项目根目录是唯一的直接运行入口。它内部只使用绝对导入比如from mypackage.core import runmypackage包里的模块之间使用相对导入比如core.py里from . import utils包内模块不直接运行要运行某个包内模块时用python -m mypackage.cli。这样团队里每个人拿到项目都知道想启动程序跑python main.py想跑包里的命令行工具用python -m mypackage.cli永远不会踩相对导入的坑。7.2 四种运行方式的适用场景运行命令入口文件包上下文适用场景python main.py项目根目录的独立脚本无标准程序入口python mypackage/cli.py包内文件无调试时偶尔用尽量不这么干python -m mypackage.cli包内模块有父包为mypackage包内模块需要独立执行python -m mypackage包内__main__.py有整个包作为命令行程序在团队项目里我会把第二行彻底排除掉因为python mypackage/cli.py即使能跑也是因为它碰巧没有相对导入、且没有依赖包内兄弟模块。一旦未来代码结构变化它就会变成一个莫名其妙的问题。7.3 IDE 里最容易踩的坑PyCharm、VSCode这些IDE的运行按钮默认执行的是python xxx.py这种直接运行方式。如果某个文件是包内模块且包含相对导入你在IDE里直接点运行大概率会看到attempted relative import。解决办法有两个。一是在IDE里找到运行配置把运行模式改成module填上mypackage.cli。PyCharm在Run/Debug Configurations里可以选Module nameVSCode的Python扩展一般需要在launch.json里配置module: mypackage.cli而不是program。第二个办法是干脆不用IDE运行包内模块只把main.py作为IDE的运行入口包内模块一律在命令行用-m跑。我更喜欢这个办法因为命令行下路径关系一目了然IDE配置一旦复杂起来反而容易藏问题。7.4 我平时怎么选个人经验简单粗暴凡是包内模块一律用python -m 包名.模块名凡是独立单文件用python xxx.py。目录位置永远从项目根目录开始敲命令pkg/这种路径前缀不直接出现在启动命令里。写代码时还有一个配套习惯包内模块不写if __name__ __main__下的逻辑代码把真正的工作逻辑全部封装成函数暴露出去入口脚本只负责调函数。这样每个模块既可以被导入也可以被-m运行两条路都走得通逻辑还不会重复。最后送大家一个检查小技巧当你看到一个ImportError又拿不准是不是运行方式问题最快的方法就是打印sys.path[0]和__package__。看两个值你就能判断出当前代码是在什么身份下运行的。这个判断如果靠猜十次有八次会猜错打印一下环境就现原形了。
返回列表