Skip to main content

Chapter 43: Packages, Namespace Packages, and Resource Loading

读完本章后,读者应能定位一个包导入问题到底落在包对象、__path__ 搜索路径、namespace package 聚合、相对导入上下文,还是资源文件访问边界上。包在 Python 里首先是 module object,然后才表现为目录、发行包或项目结构。判断包行为时,稳定入口是 module 的名字、__spec____package____path__sys.modules 中的对象关系。

本章用一个贯穿材料展开:应用 app 依赖一个可拆分安装的插件命名空间 acme.plugins,插件同时携带模板文件。这个场景覆盖普通包、namespace package、相对导入和 package data 四类问题。代码现象很短:app.main 导入 acme.plugins.csv_loader,插件内部通过相对导入拿到工具模块,并通过 importlib.resources 读取模板。

官方 Python 3.14 import system 把 package 定义为带有 __path__ 的 module,并说明 package 的 __path__ 会像受约束的 sys.path 一样参与子模块搜索。官方 importlib.resources 文档 把资源定义为与 module 或 package 关联的文件类对象,并提醒资源可能来自 zip 等非普通文件系统位置。本章会把这些文档事实整理成一个工程判断顺序:先确认包对象,再看搜索路径,再看相对导入上下文,最后看资源是否通过包边界访问。

本章以 CPython 3.14 的默认 import model 为主。PEP 420 的 native namespace package 从 Python 3.3 开始进入语言实现;importlib.resources.files() 从 Python 3.9 加入,Python 3.12 把参数名从 package 调整为 anchor,Python 3.13 让部分函数接受多个 path component。版本敏感的代码应按目标 Python 版本确认接口形态。

43.1 Package as Module Plus Search Path

package 的运行时定义是“带 __path__ 的 module object”。普通 module 只有自己的 global namespace,package 在 module namespace 之外还提供一组子模块搜索位置。这个差异解释了为什么 import app.main 会先得到 app 这个 module object,再在 app.__path__ 指向的位置查找 main

贯穿材料先使用普通包结构:

# 目录结构示意
# app/
# __init__.py
# main.py
# acme/
# __init__.py
# plugins/
# __init__.py
# csv_loader.py
# templates/
# report.txt

当解释器执行 from acme.plugins import csv_loader 时,它处理的对象链是 acmeacme.pluginsacme.plugins.csv_loader。每一段 dotted name 都有自己的 sys.modules key。acmeacme.plugins 是 package,因为它们各自有 __path__csv_loader 是普通 module,通常没有 __path__。这条链让包层级成为可检查的 runtime 状态,而非纯目录命名。

可以用下面的短片段观察 package 的核心属性:

import acme
import acme.plugins

print(acme.__name__)
print(acme.__package__)
print(acme.__spec__.name)
print(acme.__path__)
print(acme.plugins.__name__)
print(acme.plugins.__path__)

这段代码的重点不在打印值的具体格式,而在确认四个事实。__name__ 是当前 module object 的完整名字;__package__ 给相对导入提供包上下文;__spec__ 保存 finder 找到 module 时形成的导入合同;__path__ 给子模块搜索提供位置集合。普通包通常来自包含 __init__.py 的目录,导入时会执行这个 __init__.py,执行结果进入包对象的 __dict__

子模块导入还会写入父包 namespace。导入 acme.plugins.csv_loader 后,acme.plugins 这个包对象上会出现属性 csv_loader,并且该属性绑定到 sys.modules["acme.plugins.csv_loader"] 中的 module object。Python 官方文档把这称为 import system 的 invariant:如果父包和子模块都在 sys.modules 中,子模块会作为父包属性可见。这个规则让 import acme.plugins.csv_loader 之后可以写 acme.plugins.csv_loader

包对象、父包属性和 sys.modules 之间的关系可以用一条路径表示:

这张图的边界是普通导入路径,不展开 finder、loader 的全部协议细节。核心判断是:父包提供搜索空间,子模块加载后回写到父包 namespace,缓存对象放在 sys.modules 中。排查 AttributeError: module 'acme.plugins' has no attribute 'csv_loader' 时,应同时检查子模块是否完成加载,以及父包属性绑定是否已经发生。

普通包的 __init__.py 会带来一个工程边界:它是包对象初始化代码,也是包 namespace 的写入点。把大量副作用放进 __init__.py 会让任何子模块导入都先承担这些初始化成本。更稳定的做法是让 __init__.py 只建立明确导出、版本信息和轻量元数据,把重 I/O、插件扫描和运行时注册延后到显式函数中。

43.2 Namespace Package and Multi-Location Discovery

namespace package 解决同一个包名由多个位置共同贡献子模块的问题。它仍然是 module object,也仍然有 __path__,但它的 __path__ 通常是 _NamespacePath 这类由 import machinery 管理的 iterable。它聚合多个 portion,每个 portion 是一个可为同一包名贡献子模块的位置。

把贯穿材料改成可拆分插件后,目录结构可能变成这样:

# site-a/
# acme/
# plugins/
# csv_loader.py
# templates/
# report.txt
# site-b/
# acme/
# plugins/
# json_loader.py
# templates/
# report.txt

这里的 acmeacme.plugins 可以没有 __init__.py。当 site-asite-b 都在 sys.path 上时,解释器可以把两个位置下的 acme/plugins 聚合成同一个 namespace package。import acme.plugins.csv_loadersite-a 找到模块,import acme.plugins.json_loadersite-b 找到模块。两个模块共享同一个逻辑父包名 acme.plugins,但文件来源不同。

PEP 420 把这类能力称为 implicit namespace packages,并给出多目录 portion 与动态路径计算的规则。官方 import system 文档也说明 namespace package 可以由文件系统不同位置、zip 文件、网络位置或其它 import search 可达位置组成。工程上需要把 namespace package 理解为“搜索结果聚合”,而非一个固定目录对象。

动态路径计算是 namespace package 最容易被误判的地方。假设先导入了 acme.plugins.csv_loader,随后程序把新的插件目录加入 sys.path,再导入 acme.plugins.json_loader。namespace package 的 __path__ 可以在后续子模块导入时纳入新增 portion。这个行为让插件系统可以通过安装分发包扩展同一个命名空间,但也要求排查时检查当前 sys.path、父包 __path__ 和导入发生顺序。

下面的简化片段展示检查方向:

import sys
import acme.plugins

print(list(acme.plugins.__path__))
sys.path.append("/opt/acme_extra")

import acme.plugins.json_loader
print(list(acme.plugins.__path__))

这段代码只用于解释状态变化。第一次打印展示当前已知 portion,第二次打印可能包含新增搜索位置贡献的 portion。具体输出取决于路径内容、导入缓存和 Python 版本。稳定结论是:namespace package 的父包路径可以随父路径变化和后续搜索而更新,排查时应观察实际 __path__,而非只查看项目源码目录。

regular package 与 namespace package 的检查维度应一致:

维度regular packagenamespace package
包对象module objectmodule object
包判定__path____path__
初始化文件通常执行 __init__.pynative namespace package 通常无 __init__.py
搜索位置多数情况下是单个包目录可聚合多个 portion
典型风险__init__.py 副作用、导出混乱路径顺序、portion 冲突、资源重名

表中的差异会影响插件设计。插件命名空间适合让不同发行包贡献不同子模块,例如 acme.plugins.csv_loaderacme.plugins.json_loader。共享运行时初始化、全局 registry 和强顺序依赖适合放在普通包或显式入口函数中,因为 namespace package 本身没有一个统一的 __init__.py 初始化点。

namespace package 的资源也要谨慎判断。两个 portion 都有 templates/report.txt 时,资源读取的结果依赖 resource API 对 anchor 和路径的解析方式,以及 import system 暴露给资源读取层的 package 结构。工程上应使用不冲突的子目录或以插件名分隔资源,例如 templates/csv/report.txttemplates/json/report.txt,让资源路径表达所有权。

43.3 Relative Import and Package Context

relative import 的输入是当前 module 的 package context。文件路径只影响解释器如何启动当前代码,不能直接充当相对名字的解析基准。from .helpers import normalize 中的单个点表示当前 package,两个点表示父级 package。解释器需要知道当前 module 属于哪个 package,才能把相对名字解析成绝对 module name。

在贯穿材料里,插件模块可以这样组织:

# acme/plugins/csv_loader.py
from .helpers import normalize


def load_csv(raw_text):
return normalize(raw_text.splitlines())

这条导入在 acme.plugins.csv_loader 作为包内模块导入时解析为 acme.plugins.helpers。解析依据来自 module metadata,核心是 __package__,现代 import state 中也会通过 __spec__ 保存父包信息。相对导入成功的前提是当前 module 有可用的 package context,并且目标父包的 __path__ 能找到对应子模块。

常见失败场景是直接运行包内文件:

python acme/plugins/csv_loader.py

直接按文件路径执行时,这个文件会进入 __main__ module。此时它的可导入全名不再是 acme.plugins.csv_loader__package__ 通常缺失有效包上下文,from .helpers import normalize 就没有稳定基准。官方 import system 文档说明,直接从源码文件运行时,__main__.__spec__ 会是 None;使用 -m 执行可导入模块时,__spec__ 会按对应 module 或 package 设置。

更稳定的运行方式是从项目根路径执行模块名:

python -m acme.plugins.csv_loader

这个命令让解释器通过 import machinery 找到 acme.plugins.csv_loader,再把它作为 __main__ 执行。此时导入系统知道它的 package context,相对导入可以以 acme.plugins 为基准解析。排查“包内相对导入在 IDE 里失败、命令行成功”的问题时,应优先比较两种运行方式下的 __name____package____spec__ 和当前工作目录对应的 sys.path

相对导入还有一个语法边界:它只能使用 from ... import ... 形式。import .helpers 属于非法表达式,因为 import x.y 需要把 x.y 暴露为可用表达式,.helpers 本身无法作为表达式出现。代码中应写成 from . import helpersfrom .helpers import normalize

绝对导入和相对导入的选择应看包边界。包内部稳定依赖可以使用相对导入表达“同一个包内的兄弟模块”;跨顶层命名空间、公共 API 和外部依赖通常使用绝对导入,让依赖来源清晰。对 namespace package 来说,相对导入仍然依赖当前 module 的 package context;它不会把“同一物理目录”当成基准。只要当前 module 的完整名字是 acme.plugins.csv_loader.helpers 就会解析到 acme.plugins.helpers,随后由 acme.plugins.__path__ 决定从哪个 portion 找到它。

相对导入排查可以按四步走:先确认当前 module 的 __name__ 是否是可导入全名,再确认 __package__ 是否指向期望父包,然后确认父包对象有 __path__,最后确认目标模块确实存在于父包搜索路径可达位置。这个顺序把“路径问题”“运行方式问题”“包结构问题”分开,能快速定位失败层级。

43.4 importlib.resources and Package Data

package data 的稳定访问方式是通过 package 或 module anchor,而非拼接源码文件路径。importlib.resources 的目标是把“资源属于哪个 package”表达成运行时对象关系,让资源可以来自普通目录、zip import、wheel 安装结果或其它 loader 支持的位置。官方文档称资源是与 module 或 package 关联的 file-like resource,并指出资源与包目录之间只是近似类比。

贯穿材料中的插件需要读取模板:

# acme/plugins/csv_loader.py
from importlib import resources


def default_report_template():
template = resources.files(__package__).joinpath("templates", "report.txt")
return template.read_text(encoding="utf-8")

这里传入 __package__,表示资源 anchor 是当前模块所属 package,也就是 acme.pluginsresources.files(anchor) 返回 Traversable 对象,joinpath() 定位资源路径,read_text() 读取文本。这个写法的工程含义是:模板属于包边界的一部分,访问层通过 import system 暴露的资源接口读取它,并减少对 __file__ 真实目录的依赖。

旧式写法常见于源码树中:

from pathlib import Path

TEMPLATE = Path(__file__).with_name("templates") / "report.txt"

这段代码在普通源码目录里直观,但它把资源访问绑定到物理文件路径。包从 zip 文件导入、资源由 loader 虚拟提供、或者安装布局与源码布局不同的时候,__file__ 方案会暴露出边界问题。importlib.resources 把访问入口改成 package anchor,由 loader 决定资源如何被打开。

当下游 API 必须接收真实文件系统路径时,应使用 as_file() 的上下文管理器:

from importlib import resources


def render_with_external_tool():
traversable = resources.files(__package__).joinpath("templates", "report.txt")
with resources.as_file(traversable) as path:
return external_renderer(path)

as_file() 的关键点是生命周期。资源如果来自 zip,运行时可能需要临时解压成文件;上下文退出时,临时文件或目录会被清理。把 path 保存到全局变量或交给异步后台长期持有,会把资源生命周期拉出上下文边界。稳定做法是在 with 块内完成需要真实路径的动作,或者把资源内容读取成 bytes / str 后再传递。

importlib.resources 的版本边界需要写进工程判断。Python 3.9 加入 files();Python 3.12 中 files() 的参数名从 package 改成 anchor,并允许 non-package module 作为 anchor;Python 3.13 中部分函数接受多个 path component;Python 3.15 计划移除部分多 path component 文本读取时的显式 encoding 限制。面向多个 Python 版本发布库时,应在兼容层固定一组 API,例如优先使用 files(anchor).joinpath(...).read_text(encoding="utf-8")

资源属于分发边界,还涉及打包配置。源码树里存在 templates/report.txt 只能说明开发环境可见;wheel 或 sdist 中是否包含该文件取决于构建配置。排查资源缺失时,应同时检查包内路径、构建产物内容、安装后的 package 结构和 resource API 的 anchor。FileNotFoundError 只说明当前 anchor 下未找到对应资源,不能直接推出源码树没有该文件。

对 namespace package,资源读取要把所有权表达得更明确。将资源放在具体插件模块旁边,并以插件自己的 package 或 module 作为 anchor,比在共享 namespace 根下查找同名资源更稳定。例如 acme.plugins.csv_assets 作为普通包,内部包含 templates/report.txtcsv_loaderacme.plugins.csv_assets 读取资源。这样可以把资源所有权绑定到单个 distribution 的明确包边界,减少多个 portion 下同名资源的冲突。

43.5 Package Boundary Checklist

package boundary 排查应从 runtime 状态开始,目录长相只作为输入材料。import machinery 形成的 module object、__path____package____spec__sys.modules 才是运行时事实。下面的检查顺序覆盖本章四类问题:普通包、namespace package、相对导入和资源访问。

第一步,确认名字是否落在期望的 module object 上。检查 module.__name__module.__spec__.namesys.modules key 是否一致。如果一个文件既被 python path/to/file.py 直接执行,又被 import package.module 导入,进程内可能出现 __main__package.module 两个 module object。相对导入、全局状态和 class identity 都会受这个差异影响。

第二步,确认父包是否真是 package。判断条件是父 module object 是否有 __path__。有 __path__ 才能作为子模块搜索空间。普通 module 无法承载 parent.child 导入;同名文件 parent.py 与目录 parent/ 同时出现在搜索路径附近时,应观察最终导入的是哪个对象。

第三步,检查搜索路径层级。顶层包来自 sys.path;子模块来自父包的 __path__;path hooks 和 path importer cache 会参与具体查找。对 namespace package,还要把 list(package.__path__) 打印出来,看实际聚合了哪些 portion。路径顺序会影响同名模块和资源的命中结果。

第四步,检查相对导入上下文。包内模块应以可导入全名运行;命令行入口优先使用 python -m package.module。在异常现场打印 __name____package____spec__,可以直接判断相对导入是否有基准。attempted relative import with no known parent package 这类错误,通常说明运行入口没有提供有效 package context。

第五步,检查资源 anchor 和分发结果。resources.files(anchor) 的 anchor 应指向拥有资源的 package 或 module。资源路径应是 anchor 内部的相对路径。读取失败时,检查安装后的产物是否包含资源,再检查 anchor 是否指向预期包,最后检查 namespace package 下是否存在同名资源冲突。

下面的诊断片段可以放进临时脚本或异常现场,用来收集最小状态:

import importlib
import sys
from importlib import resources


def inspect_package(name):
module = importlib.import_module(name)
print("name:", module.__name__)
print("package:", getattr(module, "__package__", None))
print("spec:", getattr(module, "__spec__", None))
print("path:", list(getattr(module, "__path__", [])))
print("cached:", sys.modules.get(name) is module)


def inspect_resource(anchor, *parts):
root = resources.files(anchor)
target = root.joinpath(*parts)
print("resource:", target)
print("is_file:", target.is_file())

这段代码回答两个问题:一个名字最终绑定到哪个 module object,以及某个 anchor 下资源路径是否存在。它不会替代正式错误处理,也不会覆盖所有 loader 行为。它的价值在于把问题转成可观察事实:名字、包上下文、搜索路径、缓存对象和资源路径。

把本章贯穿材料收束成一个判断模型:app.main 导入插件时,acme.plugins 是父包搜索空间;如果它是普通包,__init__.py 提供初始化和 namespace;如果它是 namespace package,多个安装位置可以共同贡献子模块;插件内部相对导入依赖 __package__;模板文件读取应通过 importlib.resources 绑定到拥有资源的 package anchor。只要这四层状态分别可见,包边界问题就可以被定位到具体层级。

最小自检任务

阅读下面的结构和代码,判断三个问题:from .helpers import normalize 在哪种运行方式下有稳定 package context;acme.plugins 更像普通包还是 namespace package;模板读取应把哪个对象作为资源 anchor。

# project-a/acme/plugins/csv_loader.py
from importlib import resources
from .helpers import normalize


def template_text():
return resources.files(__package__).joinpath("templates", "report.txt").read_text(encoding="utf-8")

# project-a/acme/plugins/helpers.py

def normalize(lines):
return [line.strip() for line in lines]

# project-b/acme/plugins/json_loader.py

def load_json(raw_text):
return raw_text

project-aproject-b 都在 sys.path 上,两个目录中的 acme/acme/plugins/ 都没有 __init__.py

答案要点

from .helpers import normalizecsv_loaderacme.plugins.csv_loader 这个可导入全名加载时有稳定 package context,例如通过其它模块执行 import acme.plugins.csv_loader,或者从合适的项目根路径执行 python -m acme.plugins.csv_loader。直接运行 python project-a/acme/plugins/csv_loader.py 时,文件进入 __main__,相对导入缺少有效父包基准。

acme.plugins 更像 native namespace package。判断依据是两个 sys.path entry 都贡献了同一个 package name 下的 portion,并且对应目录没有 __init__.py。运行时应观察 acme.plugins.__path__,它可能包含 project-a/acme/pluginsproject-b/acme/plugins 两个位置。

模板读取应把拥有模板资源的包或模块作为 anchor。当前代码用 __package__,即 acme.plugins,可以表达“模板在当前插件所属 package 下”。在多 portion namespace 下,如果多个 distribution 都可能有 templates/report.txt,更稳定的设计是把资源放进具体插件拥有的明确资源包,例如 acme.plugins.csv_assets,再以该资源包为 anchor 读取。

本章知识点总结

  • 包对象:package 是带 __path__ 的 module object,目录只是常见来源。
  • 搜索路径:顶层包由 sys.path 定位,子模块由父包 __path__ 定位。
  • 父包绑定:子模块加载后会作为父包属性出现,并与 sys.modules 中的子模块对象对应。
  • 初始化边界:普通包的 __init__.py 会执行并写入包 namespace,适合放轻量导出和元数据。
  • 命名空间包:namespace package 通过多个 portion 聚合同一个逻辑包名,适合拆分插件分发。
  • 动态路径:namespace package 的 __path__ 可随父路径变化和后续导入纳入新 portion。
  • 相对导入:relative import 依赖当前 module 的 package context,核心检查对象是 __package____spec__
  • 运行入口:包内模块需要通过可导入全名执行,python -m package.module 能保留导入元数据。
  • 资源访问importlib.resources 通过 package 或 module anchor 读取资源,适配非普通文件系统来源。
  • 路径生命周期as_file() 提供临时真实路径,路径使用应收束在上下文管理器内部。
  • 版本边界importlib.resourcesfiles()anchor 和多 path component 行为随 Python 版本演进。
  • 排查顺序:先看 module name,再看父包 __path__,再看相对导入上下文,最后看资源 anchor 和分发产物。