attempted relative import with no known parent package 和 cannot import name 'X' from partially initialized module,几乎覆盖了"明明文件在那儿却导不进来"的全部场景。根因不在文件是否存在,而在导入时 sys.path 是什么、模块执行到了哪一步。
1. import 到底做了什么
一次 import pkg.mod 分五步:查 sys.modules 缓存 → 查 sys.path 上的每个目录(pkg/__init__.py 或 pkg/mod.py)→ 创建模块对象并立刻放进 sys.modules → 执行该模块顶层代码 → 把名字绑定到当前命名空间。
| 阶段 | 关键行为 | 出错时的表现 |
|---|---|---|
| 缓存命中 | 直接返回 sys.modules 里的对象 |
改了文件进程里还是旧代码 |
| 查找 | 遍历 sys.path,先找 pkg/__init__.py |
ModuleNotFoundError |
| 注册 | 模块对象在执行前进 sys.modules |
循环导入时拿到"半成品" |
| 执行 | 顶层代码全跑一遍 | 副作用、启动变慢、异常抛出 |
| 绑定 | import pkg.mod 只绑定 pkg |
想用 mod.f() 得写 pkg.mod.f() |
import sys
before = set(sys.modules)
import json.decoder # 触发父包与子模块加载
print(sorted(set(sys.modules) - before)[:5])
print(sys.modules["json"].decoder is sys.modules["json.decoder"]) # True
import json
print(json is sys.modules["json"]) # 同一对象,第二次 import 不再执行
"注册在执行之前"最容易被忽略:它既是循环导入能"部分成功"的原因,也是"拿到半个模块"的来源。
2. sys.path 与两种启动方式
sys.path 的第一项取决于你怎么启动:
| 启动方式 | sys.path[0] |
能导同目录模块吗 | 能当包用吗 |
|---|---|---|---|
python script.py |
脚本所在目录 | 能 | 不能(无父包) |
python -m pkg.mod |
当前工作目录 | 取决于 cwd | 能(__package__ 有值) |
pytest |
按 rootdir 与 conftest.py 处理 |
— | 能 |
交互式 / -c |
""(即 cwd) |
能 | 视 cwd 而定 |
import os, sys
print("argv[0] :", sys.argv[0])
print("__package__:", __package__) # 直接运行时是 None 或 ""
print("path[0] :", sys.path[0])
print("PYTHONPATH :", os.environ.get("PYTHONPATH", "<empty>"))
PYTHONPATH 插在脚本目录之后、标准库之前,这是"本地能跑、CI 报错"的常见来源:本地 shell 里有仓库根目录,CI 里没有。别把它当项目配置,只用于临时排查。
3. 绝对导入与相对导入
| 写法 | 依赖 __package__ |
只能在包内用 | 可读性 |
|---|---|---|---|
from pkg.sub import util |
否 | 否 | 高,路径一目了然 |
from . import util |
是 | 是 | 中,重构友好 |
from ..core import util |
是 | 是 | 低,跨层时不直观 |
import util |
否 | 否 | 危险,靠 sys.path 碰运气 |
规则只有一条:相对导入只在"作为包的一部分被导入"时成立。直接 python pkg/mod.py 时 __package__ 是空串,.util 无从解析,于是报 attempted relative import with no known parent package。修法不是加 sys.path 补丁,而是改用 python -m pkg.mod 启动。
4. 循环导入:成因与三种解法
循环导入的本质是 A 执行到一半需要 B,B 又要 A 里尚未定义的名字。A 已在 sys.modules(半成品),所以 import a 不报错,但 from a import Foo 失败——Foo 还没执行到。
| 解法 | 做法 | 适用 | 代价 |
|---|---|---|---|
| 延迟导入 | import 移到函数体内 |
个别调用点需要 | 可读性下降 |
| 抽公共模块 | 双方依赖下沉到 base.py |
结构性依赖 | 需要重构 |
| 仅类型导入 | if TYPE_CHECKING: + 字符串注解 |
只为注解 | 运行时要 get_type_hints |
# bad: service.py 顶部 from .repo import Repo,repo.py 顶部 from .service import Svc
# good 1:延迟导入
def build() -> "Svc":
from .repo import Repo
return Svc(Repo())
# good 2:抽公共层 —— models.py 谁都不导入
from .models import Order
# good 3:只为注解而导入
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .repo import Repo
def load(repo: "Repo") -> list[Order]: ...
第三种要记住运行时后果:Repo 运行时不存在,任何真实使用都会 NameError。
5. __init__.py 的副作用
导入包等于执行 __init__.py,于是包级初始化会放大成启动成本与隐式依赖。
| 写法 | 后果 | 建议 |
|---|---|---|
from .api import app |
导入子模块即启动应用 | 只在入口模块做 |
| 建连接池、读配置 | 任何 import pkg 都触发 IO |
改为显式 init() |
logging.basicConfig() |
全局日志被库改写 | 库不该配 root logger |
空 __init__.py |
只是标记常规包 | 最省事 |
没有 __init__.py |
可能是命名空间包 | 见下一节 |
# pkg/__init__.py —— 只导出符号,不做动作
from .core import Engine
from .errors import PkgError
__all__ = ["Engine", "PkgError"]
收益是可预测的启动时间:import pkg 不该联网、不该读文件、不该起线程。
6. 命名空间包与包声明
没有 __init__.py 的目录在 Python 3 里是命名空间包(PEP 420):它不会创建新模块文件,而是把多个目录拼成一个逻辑包。适合插件式分发,不适合普通应用——因为 find_packages() 找不到它,打包后常常"源码能跑、装完就丢"。
# pyproject.toml
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.11"
[tool.setuptools.packages.find]
include = ["myapp*"]
exclude = ["tests*", "docs*"]
| 场景 | 声明方式 | 常见错误 |
|---|---|---|
| 常规包 | [tool.setuptools.packages.find] include=["myapp*"] |
忘了 include,把 tests 打包进去 |
| 单模块 | py-modules = ["cli"] |
写成 packages 后装不上 |
| 命名空间包 | [tool.setuptools.packages.find] namespaces = true |
默认 false,子包被漏掉 |
| src 布局 | package-dir = {"" = "src"} |
只设 package-dir 不设 where |
src 布局(src/myapp/...)能强制"必须先安装才能 import",从根上消掉"本地靠 cwd 侥幸成功"的问题。
7. __all__ 与可见性
| 位置 | 作用 | 不影响 |
|---|---|---|
模块级 __all__ |
from m import * 导出的名字集合 |
显式 from m import x 仍可拿到 |
包级 __all__ |
子模块的再导出清单 | 只要子模块存在就能直接导入 |
| 下划线前缀 | 约定私有,工具会提示 | 运行时不阻止 |
__all__ 是文档兼 lint 信号,不是访问控制。真正的边界靠"不导出 + 不文档化",以及 mypy 的 --no-implicit-reexport。
8. importlib 动态导入
import importlib
def load_plugin(name: str):
mod = importlib.import_module(f"myapp.plugins.{name}") # 等价 __import__ 但返回子模块
cls = getattr(mod, "Plugin", None)
if cls is None:
raise LookupError(f"{name} 未提供 Plugin")
return cls()
print(load_plugin("csv"))
| API | 返回什么 | 风险 |
|---|---|---|
importlib.import_module("a.b") |
子模块 a.b |
名字来自外部输入时可执行任意模块顶层代码 |
__import__("a.b") |
顶层包 a |
语义反直觉,别直接用 |
importlib.util.spec_from_file_location |
从任意路径加载 | 绕过包结构,__package__ 为空 |
pkgutil.iter_modules |
枚举可导入模块 | 会触发 __init__.py 执行 |
插件名来自配置文件或请求参数时,要先用白名单校验(name in ALLOWED),否则等于给了对方一个"执行任意模块顶层代码"的入口。
9. 缓存与 importlib.reload
import importlib, sys
print("pkg.helper" in sys.modules) # 首次导入后 True
importlib.reload(sys.modules["pkg.helper"])
print("已重新执行顶层代码")
# 更彻底:清缓存后重导(注意:别在库代码里这么干)
for k in [k for k in sys.modules if k.startswith("pkg.")]:
del sys.modules[k]
reload 只重新执行目标模块的顶层代码,不会更新别处已经 from m import f 绑定的引用——那些名字仍指向旧函数对象。所以热重载在真实项目里几乎总要配套"重新获取引用"的逻辑,否则改了等于没改。
10. 排错:六种典型现场
| 现象 | 根因 | 处理 |
|---|---|---|
attempted relative import with no known parent package |
直接 python pkg/mod.py |
改 python -m pkg.mod |
cannot import name 'X' from partially initialized module |
循环导入 | 延迟导入或抽公共模块 |
| 本地通过、CI 失败 | 本地 PYTHONPATH 或 cwd 兜住了缺失的安装 |
CI 里 pip install -e . 并清空 PYTHONPATH |
| 改了文件名仍导入旧模块 | __pycache__ 里的 .pyc 或进程缓存 |
删 __pycache__,确认新进程 |
装完后 ModuleNotFoundError |
包没被打进 wheel(命名空间包或漏 include) |
检查 packages.find 配置 |
自己的 json.py 遮蔽标准库 |
sys.path[0] 优先于标准库 |
重命名文件,别与标准库同名 |
第一条与第六条常常一起出现:脚本名叫 logging.py 时,import logging 会先拿到你自己那个文件,然后所有 logging.info 报 AttributeError——排查时先看 logging.__file__。
11. 边界:这些事导入机制本就管不了
| 需求 | 该用什么 | 原因 |
|---|---|---|
| 运行时插件发现 | 入口点(importlib.metadata.entry_points) |
目录扫描不可靠 |
| 热更新生产代码 | 重启进程 / 滚动发布 | reload 不更新已绑定引用 |
| 隔离同名模块 | 独立虚拟环境 / 容器 | 同一 sys.path 只能有一个 |
| 控制导入耗时 | 延迟导入 + 启动 profiling | 导入即执行,无法"只看不跑" |
| 模块级单例 | 显式容器或 functools.lru_cache |
依赖 sys.modules 隐式单例太脆 |
一句话:sys.modules 是进程内的模块注册表,不是包管理器。它保证"同一进程里一个模块只有一份",其余职责都在打包工具和部署方式上。
12. 自测
import pkg.mod之后,当前命名空间里绑定的是哪个名字?- 为什么模块对象会在顶层代码执行之前就被放进
sys.modules? python pkg/mod.py与python -m pkg.mod在__package__上有什么区别?- 循环导入时,
import a不报错而from a import Foo报错,原因是什么? - 为什么在
__init__.py里建连接池会拖慢整个进程的启动? importlib.reload为什么经常"改了等于没改"?