Python 包导入与 __init__.py 机制:从循环导入到相对路径
你有没有遇到过这种场景:明明文件就在那,IDE 也能跳转,但 python main.py 一运行就报 ModuleNotFoundError: No module named 'xxx'?
又或者,你的项目跑得好好的,某天加了一个新文件后突然出现 ImportError: cannot import name 'foo' from partially initialized module 'bar'(多半是循环导入),你盯着堆栈看了半小时找不到原因。
更让人抓狂的是:from package import module 和 import package.module 行为为什么不一样?__init__.py 到底在什么时候被执行?相对导入 from . import x 为什么在脚本模式下直接崩溃?
这些问题的背后,都指向同一个核心:Python 的包导入机制。 今天这篇文章,我会带你从 __init__.py 的三种身份出发,完整拆解 Python 的模块加载流程,并给出循环导入的四种破解之法和项目结构最佳实践。
一、重新认识 __init__.py:不只是个空文件
1.1 三种身份
很多教程告诉你:__init__.py 用来”把目录标记为包”。这是事实,但严重低估了它的能力。实际上,__init__.py 在 Python 包的导入流程中扮演着三种截然不同的角色:
| 身份 | 作用 | 触发时机 |
|---|---|---|
| 包标记 | 告诉 Python:这是一个包(Python 3.3+ 有 namespace package,但显式包仍需它) | 解释器扫描目录时 |
| 包初始化代码 | 包第一次被导入时执行一次的代码 | 任何子模块被导入前 |
| 包门面(Facade) | 暴露包的公共 API,控制 from package import * 的行为 | 模块导入时 |
我们一个一个看。
1.2 包标记:namespace package 与普通包
从 Python 3.3 开始,没有 __init__.py 的目录也可以作为包导入,这叫 namespace package:
# 没有 __init__.py 的目录mypkg/├── a.py└── b.py# 依然可以导入!import mypkg.aimport mypkg.b听起来很美好对吧?但 namespace package 有一个致命缺陷:无法执行初始化代码。比如你想在包导入时打印日志、注册插件、设置默认配置,namespace package 做不到。
更糟的是,命名冲突时不会报错。如果你的项目目录和某个第三方包同名,namespace package 会默默合并,可能导致诡异的 bug。
生产环境建议:永远保留 __init__.py。即使它是空的。
1.3 包初始化代码:被打包成 .whl 也照常执行
当你写 import mypkg 时,Python 会执行 mypkg/__init__.py 里的所有顶层代码,且只执行一次:
print("🚀 mypkg 被导入了!")__version__ = "1.0.0"
# 初始化包级状态_DEFAULT_CONFIG = {"debug": False, "timeout": 30}
def get_config(): return _DEFAULT_CONFIG.copy()import mypkg # 第一次导入:执行 __init__.pyimport mypkg # 第二次导入:什么都不做(命中 sys.modules 缓存)输出:
🚀 mypkg 被导入了!这就是为什么很多 SDK 的初始化代码(注册插件、设置日志、加载配置)都放在 __init__.py 里。这层执行是 import 系统保证的,无论你最终用 wheel、zipapp 还是源码分发。
1.4 包门面:控制 from package import *
如果包里有几十个内部模块,你希望用户只看到 5 个公共 API,怎么做?用 __all__:
from .core import Engine, Requestfrom .utils import loggerfrom .errors import APIError
# 控制 from mypkg import * 的行为__all__ = ["Engine", "Request", "APIError"] # logger 不在公共 API 中
__version__ = "1.0.0"不设置 __all__ 时,from package import * 默认会导入所有不以下划线开头的名字,但这种行为很容易污染命名空间。最佳实践:永远显式定义 __all__。
1.5 动手验证:观察 __init__.py 的执行时机
写一个最小的实验,亲眼看一下 __init__.py 到底在什么时候被执行的:
import timeprint(f"[{time.time():.3f}] demo_pkg.__init__ 被执行")
# demo_pkg/core.pyimport timeprint(f"[{time.time():.3f}] demo_pkg.core 被加载")
def hello(): return "hello from core"import timeprint(f"[{time.time():.3f}] 程序启动")
import demo_pkgprint(f"[{time.time():.3f}] import demo_pkg 完成")
import demo_pkg.core # 触发 core.py 加载print(f"[{time.time():.3f}] import demo_pkg.core 完成")
demo_pkg.core.hello()输出(时间戳可能不同,但顺序一致):
[1234567890.001] 程序启动[1234567890.002] demo_pkg.__init__ 被执行[1234567890.003] import demo_pkg 完成[1234567890.004] demo_pkg.core 被加载[1234567890.005] import demo_pkg.core 完成可以看到:__init__.py 在第一次导入包时执行,而子模块 core.py 是在你显式导入它的时候才加载。这印证了我们前面的说法——import demo_pkg 不会自动加载所有子模块。
二、import 的执行流程:四个步骤
理解 import 机制,关键是把”导入”拆成四个独立的步骤:
import mypkg.submod执行时,Python 实际做了这些事:
1. 查找(Find) └── 在 sys.path 中搜索 mypkg2. 加载(Load) └── 执行 mypkg/__init__.py,把 mypkg 加入 sys.modules3. 绑定(Bind) └── 在当前命名空间创建名字 mypkg4. 继续处理子模块 └── 在 mypkg 里查找 submod └── 加载并绑定到 mypkg.submod关键细节:
sys.modules是模块缓存字典,key 是模块名,value 是模块对象- 同一个模块无论被 import 多少次,只执行一次顶层代码
- 子模块不会自动导入父包的全部子模块——必须显式 import
来看一个真实坑:
# mypkg/__init__.py 是空的# mypkg/utils.py 里定义了 helper()
import mypkgmypkg.utils.helper() # ❌ AttributeError: module 'mypkg' has no attribute 'utils'很多人以为 import mypkg 会自动加载所有子模块。不会。你必须:
import mypkg.utils # 显式导入mypkg.utils.helper()或者在 __init__.py 里预加载常用子模块:
from . import utils, errors, core # 预加载三、sys.path:import 到底去哪找模块?
import 找不到模块时,99% 的根因是 sys.path 不对。它是一个路径列表,Python 按顺序搜索:
import sysprint(sys.path)# 典型输出:# ['', '/usr/lib/python311.zip', '/usr/lib/python3.11', ...]第一个元素 '' 表示当前工作目录。这就是为什么 python main.py 和 python subdir/main.py 行为可能不一样——sys.path[0] 不同。
3.1 修改 sys.path 的四种方式(按推荐度排序)
# 方式 1:设置 PYTHONPATH 环境变量(推荐用于开发)# export PYTHONPATH=/path/to/your/libs
# 方式 2:使用 .pth 文件(推荐用于部署)# 在 site-packages 下创建 myproject.pth,内容为项目根路径
# 方式 3:运行时修改(不推荐,会污染全局)import syssys.path.insert(0, "/path/to/libs")
# 方式 4:用相对路径的 importlib(推荐用于脚本)from pathlib import Pathimport syssys.path.insert(0, str(Path(__file__).parent.parent))3.2 实战:在 scripts 目录中导入上级包
最常见的 ModuleNotFoundError 场景:
myproject/├── mypkg/│ ├── __init__.py│ └── core.py└── scripts/ └── run.py # 想 import mypkg.core直接 python scripts/run.py 会失败,因为 sys.path[0] 是 scripts/,找不到 mypkg。
解决方案:在 scripts/run.py 顶部加入:
import sysfrom pathlib import Pathsys.path.insert(0, str(Path(__file__).resolve().parents[1]))或者用 -m 模式运行:
# 从项目根目录执行cd myprojectpython -m scripts.run # 这样 sys.path[0] 是 myproject/python -m 是最规范的运行方式,建议所有项目都用这个。
3.3 sys.path 顺序的”踩坑现场”
一个真实的生产环境 bug:项目用了 request 库做 HTTP 调用,但生产环境报 AttributeError: module 'requests' has no attribute 'Session'。最后发现是项目里有个文件叫 request.py(少了个 s),导致 Python 在 sys.path[0] 找到了这个文件,import 进去的是一个空模块。
# 项目里有这样一个文件# project_root/utils/request.py ❌ 千万别这么命名
# 任何地方写 import requests 时import requests # 优先找到 ./utils/request.py避免这种坑的纪律:
- 永远不要用标准库或第三方库的同名文件(
json.py、os.py、request.py) - 永远不要给项目根目录起和某个包冲突的名字
- 在 CI 中加一个
find . -name "*.py" | xargs grep检查可疑命名
sys.path 这个”看似简单”的列表,是 Python 项目 80% import 问题的根源。遇到 ModuleNotFoundError 第一反应应该是:打印 sys.path 看实际路径。
四、循环导入:四种破解之道
循环导入是 Python 项目最常见的”玄学 bug”之一。看个经典例子:
from b import func_b
def func_a(): return func_b()
# b.pyfrom a import func_a # 💥 ImportError
def func_b(): return func_a()为什么会报错?
执行 import a 时:
- Python 开始加载
a a的第一行from b import func_b触发加载bb的第一行from a import func_a,但a还没加载完,a是个”半成品”模块- Python 在
sys.modules找到a(虽然是半成品),尝试从中取func_a - 如果
func_a还没定义 →ImportError: cannot import name 'func_a'
4.1 方案一:延迟导入(最常用)
把 import 放进函数/方法内部,运行时再解析:
def func_a(): from b import func_b # 延迟到调用时 return func_b()
# b.pydef func_b(): from a import func_a return func_a()优点:简单直接。缺点:每次调用都要做一次 import 检查(有缓存所以很快,但代码不优雅)。
4.2 方案二:重构模块结构(最根本)
循环导入本质上是设计问题。如果 a 和 b 互相依赖,往往意味着有第三个模块 c 应该被抽出来:
重构前:a.py ⇄ b.py
重构后:a.py ─→ c.py ←─ b.py# c.py(被两个模块共享)def shared_logic(): pass
# a.pyfrom .c import shared_logic
# b.pyfrom .c import shared_logic这是根治的方法。如果你的项目出现循环导入,先尝试重构。
4.3 方案三:TYPE_CHECKING 守卫(类型提示专用)
如果循环导入只是为了类型提示,用 typing.TYPE_CHECKING:
from __future__ import annotationsfrom typing import TYPE_CHECKING
if TYPE_CHECKING: from b import B # 只在类型检查器运行时导入
def create_b() -> "B": # 用字符串注解,运行时不需要 B from b import B return B()TYPE_CHECKING 在运行时永远是 False,所以这段代码不会触发真正的 import,但 mypy/pylance 仍然能识别 B 的类型。
4.4 方案四:import 而不是 from(最简但有限)
import b # 这样写不会在加载 a 时执行 b 的 import 语句
def func_a(): return b.func_b()注意:import b 不会触发 b.py 里的顶层代码触发循环,但仍然会加载 b。这个方案只能解决一类特定的循环,不是万能药。
4.5 实战:诊断循环导入
当生产环境报 ImportError: cannot import name 'X' from partially initialized module 'Y' 时,按这个流程诊断:
# 步骤 1:先看堆栈"""Traceback (most recent call last): File "a.py", line 1, in <module> from b import func_b File "b.py", line 1, in <module> from a import func_a # 💥 a 还没加载完ImportError: cannot import name 'func_a' from partially initialized module 'a'"""
# 步骤 2:画依赖图# 用 Python 自带的 modulefinderimport modulefinderfinder = modulefinder.ModuleFinder()finder.run_script("main.py")finder.report() # 打印所有模块依赖
# 步骤 3:定位循环点# 在堆栈中找到两次出现的模块名,就是循环的"接缝"诊断时一个常用技巧:把 from X import Y 改成 import X,然后在用的时候 X.Y。这样不会触发 X 的初始化,但代价是每次都要写完整路径。
五、相对导入 vs 绝对导入
5.1 Python 2 的悲剧:隐式相对导入
在 Python 2 时代,没有显式相对导入,包内的 import 默认为是”相对的”:
# python 2 - 同一个包内import sibling_module # 隐式相对这个设计的代价是:你写 import json 时,Python 会先在当前包里找 json.py——容易和标准库或第三方包冲突。
Python 3 修复了这个问题:默认是绝对导入。
5.2 显式相对导入:. 和 ..
from . import sibling # mypkg/subpkg/sibling.pyfrom .. import utils # mypkg/utils.pyfrom ..core import Engine # mypkg/core.pyfrom .sibling import helper # 明确导入子模块的成员. 表示当前包,.. 表示父包。和文件系统路径完全一致。
5.3 相对导入的”必须条件”
相对导入只能在包内使用。在脚本模式下(python script.py),文件没有”包”的归属,. 无意义。
这就是为什么:
python mypkg/subpkg/module.py # 💥 ImportError: attempted relative import with no known parent package
python -m mypkg.subpkg.module # ✅ 正常python -m 让 Python 把 mypkg.subpkg.module 当作包内模块执行,. 才有意义。
5.4 选绝对导入还是相对导入?
社区共识是:生产代码用绝对导入,测试和内部模块可以用相对导入。
# ✅ 推荐:绝对导入(更清晰,IDE 跳转更准)from myproject.core.processor import Engine
# ✅ 适用于:包内部紧密耦合的模块from .processor import Enginefrom ..utils import logger
# ❌ 避免:跨多级相对导入from ...legacy.deprecated import OldThing # 太深,重构时易碎绝对导入的优势是明确的”完全限定名”,一眼能看出模块来自哪个包。相对导入在包内重构(移动包路径)时不会失效,但深度不要超过两级。
六、生产级项目结构最佳实践
6.1 src layout vs flat layout
两种主流布局:
# Flat layout(老派)myproject/├── mypkg/│ └── __init__.py└── tests/
# Src layout(现代推荐)myproject/├── src/│ └── mypkg/│ └── __init__.py└── tests/强烈推荐 src layout。原因:在 flat layout 下,pytest 可能会优先导入当前目录的 mypkg/ 而不是安装的版本,导致”测试通过但打包后报错”。src layout 强制使用安装版本,避免这类坑。
6.2 init.py 的内容守则
# ❌ 反模式:在 __init__.py 里做重活import requests # 顶层 import 整个第三方库DATABASE = connect_to_db() # 顶层副作用for f in glob("*.json"): load_config(f) # 顶层 I/O
# ✅ 正解:保持轻量from .core import main_api # 只暴露门面__version__ = "1.0.0"__all__ = ["main_api"]为什么? __init__.py 的执行时机很早,做重活会拖慢所有 import 链,还会让包难以测试。
6.3 一个完整的生产级包结构
mycorp-data-utils/├── pyproject.toml├── src/│ └── mycorp_data_utils/│ ├── __init__.py # 暴露门面 API│ ├── _version.py # 版本号(独立文件)│ ├── core/│ │ ├── __init__.py│ │ └── processor.py│ ├── utils/│ │ ├── __init__.py│ │ └── helpers.py│ └── errors.py├── tests/│ ├── unit/│ └── integration/└── scripts/ └── benchmark.py__init__.py 应该是这样的:
"""mycorp-data-utils: 数据处理工具集"""from .core.processor import DataProcessor, BatchProcessorfrom .errors import ValidationError, ProcessingErrorfrom ._version import __version__
__all__ = [ "DataProcessor", "BatchProcessor", "ValidationError", "ProcessingError", "__version__",]七、关键总结
把全文浓缩成几个要点:
| 要点 | 核心原则 |
|---|---|
__init__.py | 永远保留,用于包初始化和门面控制 |
__all__ | 显式定义,控制 import * 行为 |
sys.path | 用 python -m 代替 python script.py |
| 循环导入 | 优先重构;延迟导入兜底;类型用 TYPE_CHECKING |
| 相对导入 | 仅在包内有效,必须用 python -m 运行 |
| 包结构 | 优先 src layout;__init__.py 保持轻量 |
八、延伸阅读
- Python 官方文档:The import system — 最权威的 import 系统参考
- PEP 328 — Imports: Multi-Line and Absolute/Relative — 绝对/相对导入的诞生背景
- PEP 420 — Implicit Namespace Packages — namespace package 规范
- Real Python: Python Modules and Packages — 配套实战教程
- Hynek Schlawack: Testing & Packaging — src layout 的设计哲学
- 《CPython 源码剖析:import 流程》 — 想看 CPython 内部实现的同学
读完这篇,你应该能回答这些问题了:
__init__.py的三个身份分别是什么?sys.path的查找顺序是怎样的?- 循环导入有哪四种破解方案?
- 相对导入为什么必须在
python -m模式下才能用? - 为什么生产项目推荐 src layout?
下次再遇到 ModuleNotFoundError,你就能快速定位根因了。Happy Pythoning!
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!