2526 字
约 8 分钟
14
S10-X11 · 包与模块:导入机制与循环导入

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. 自测

  1. import pkg.mod 之后,当前命名空间里绑定的是哪个名字?
  2. 为什么模块对象会在顶层代码执行之前就被放进 sys.modules?
  3. python pkg/mod.py 与 python -m pkg.mod 在 __package__ 上有什么区别?
  4. 循环导入时,import a 不报错而 from a import Foo 报错,原因是什么?
  5. 为什么在 __init__.py 里建连接池会拖慢整个进程的启动?
  6. importlib.reload 为什么经常"改了等于没改"?
S10-X11 · 包与模块:导入机制与循环导入
https://www.clxhxhhr.top/posts/5860/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。