Python 基础体系 · 第 20/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 模块与包:命名空间、init、入口和项目边界
Python 工程中经常同时出现“模块”“包”“命名空间”“入口”“项目”和“安装分发包”这些词。它们分别描述不同层次的问题:
- 模块(module):运行时可加载的 Python 模块对象,以及通常对应的代码单元。
- 包(package):具有层级名称、能够包含子模块的特殊模块。
- 命名空间(namespace):名称到对象的绑定关系。
- 入口(entry point):程序或扩展功能开始执行的位置。
- 项目(project):源代码、测试、构建配置和元数据组成的开发边界。
- 分发包(distribution package):可被安装工具安装的发布物,通常由 wheel 或 source distribution 表示。
如果把这些概念混为一谈,就会出现“目录名为什么不能直接导入”“__init__.py 到底要不要写”“python file.py 和 python -m package.module 为什么行为不同”“项目名和导入名为什么不一致”等问题。
一、先区分四个边界:文件、模块、包和项目
1. 模块不是文件,而是运行时对象
在最简单的情况下,一个 Python 文件可以对应一个模块:
hello.py
执行:
import hello
之后,解释器中会存在一个名为 hello 的模块对象:
import hello
print(type(hello))
print(hello.__name__)
print(hello.__file__)
典型输出类似:
<class 'module'>
hello
/path/to/hello.py
但“模块”和“文件”并不是同义词。Python 的模块也可以来自:
- Python 源文件;
- 编译后的扩展模块;
- zip 文件中的代码;
- 命名空间包;
- 自定义 Finder 和 Loader;
- 其他导入系统支持的来源。
Python 官方定义中,模块对象只有一种基本类型;包是具有 __path__ 属性的特殊模块。也就是说:
所有包都是模块,但并非所有模块都是包。(docs.python.org)
2. 包是模块命名空间的层级组织
下面的结构包含一个包和两个子模块:
shop/
__init__.py
price.py
checkout.py
它们对应的导入名称是:
shop
shop.price
shop.checkout
这里的点号不是文件系统语法,而是模块名称的层级分隔符。
import shop.price
执行后,当前作用域通常绑定的是顶层名称 shop:
import shop.price
print(shop)
print(shop.price)
而:
from shop import price
则会把 price 这个名称直接绑定到当前作用域。
因此,下面两种代码的名称绑定不同:
import shop.price
shop.price.calculate()
from shop import price
price.calculate()
导入系统负责“搜索并加载”模块;import 语句还负责把搜索结果绑定到当前作用域。内置 __import__() 主要执行导入搜索和模块创建,而名称绑定是 import 语句完成的。(docs.python.org)
3. 项目不是包
一个项目可能长这样:
inventory-project/
├── pyproject.toml
├── README.md
├── LICENSE
├── tests/
└── src/
└── inventory/
├── __init__.py
├── models.py
└── cli.py
这里:
inventory-project/是项目目录;src/inventory/是可导入的 Python 包;pyproject.toml描述项目的构建系统、元数据和工具配置;- 安装后得到的
inventory-project分发包,未必叫inventory; - Python 代码中使用的导入名是
inventory。
项目名、分发名和导入名可以相同,也可以不同:
[project]
name = "inventory-tools"
但代码仍然可能是:
import inventory
因此,“我安装了名为 A 的项目,却要导入 B”并不矛盾。安装工具处理的是分发元数据,Python 导入系统处理的是导入名称。
二、命名空间:名称到底绑定在哪里
1. 命名空间本质上是名称映射
可以把命名空间抽象为一个映射:
例如:
answer = 42
会在当前命名空间中建立:
"answer" -> 42
模块也有自己的命名空间。文件:
# constants.py
timeout = 30
def get_timeout():
return timeout
被导入后,constants 模块对象内部保存着这些名称:
import constants
print(constants.__dict__["timeout"])
print(constants.get_timeout())
输出:
30
30
因此:
constants.timeout
并不是从文件中重新读取变量,而是先取得模块对象,再从该模块命名空间中查找 timeout。
2. 每个模块有自己的顶层命名空间
下面两个文件分别定义同名变量:
# a.py
value = "from a"
# b.py
value = "from b"
主程序:
import a
import b
print(a.value)
print(b.value)
输出:
from a
from b
a.value 和 b.value 不冲突,是因为它们属于两个不同的模块命名空间。
如果使用:
from a import value
from b import value
print(value)
后一次导入会覆盖当前作用域中前一次的名称绑定。模块本身没有被覆盖,覆盖的是当前作用域里的 value:
from b
这也是工程中常写:
import requests
import internal_requests
或:
from package_a import value as a_value
from package_b import value as b_value
的原因:显式保留命名空间边界,减少名称碰撞。
3. 包命名空间不仅包含手写对象,还包含子模块属性
假设:
shop/
__init__.py
price.py
执行:
import shop.price
之后,通常同时存在:
sys.modules["shop"]
sys.modules["shop.price"]
并且:
shop.price
会指向 shop.price 模块对象。
这是导入系统维护的层级不变量:
如果包已经存在,而子模块加载完成,子模块对象会绑定到父包对应的属性上。(docs.python.org)
可以用下面的代码观察:
import sys
import shop.price
print(sys.modules["shop"] is shop)
print(sys.modules["shop.price"] is shop.price)
典型输出:
True
True
三、__init__.py:它不是“包标记文件”这么简单
1. 常规包的定义
传统的常规包通常是包含 __init__.py 的目录:
shop/
__init__.py
price.py
导入:
import shop
大致会发生:
- 找到
shop; - 创建名为
shop的模块对象; - 设置模块的导入相关属性;
- 执行
shop/__init__.py; - 将初始化结果保存在
shop的命名空间中; - 将模块放入
sys.modules。
因此,__init__.py 是包导入过程中的真实 Python 代码,不是只供工具识别的空文件。常规包被导入时,该文件会隐式执行。(docs.python.org)
例如:
# shop/__init__.py
print("initializing shop")
version = "1.0"
执行:
import shop
print(shop.version)
输出:
initializing shop
1.0
2. __init__.py 中的名称会成为包的公开属性
目录:
shop/
__init__.py
price.py
其中:
# shop/price.py
def calculate():
return 100
可以在 __init__.py 中重新导出:
# shop/__init__.py
from .price import calculate
__all__ = ["calculate"]
之后:
from shop import calculate
print(calculate())
输出:
100
这里发生了两层绑定:
.price被导入为shop.price;calculate被绑定到shop包的命名空间。
__all__ 主要影响:
from shop import *
它不会阻止用户显式导入其他名称,也不会自动把所有子模块加载进来。
3. __init__.py 不会自动导入所有子模块
下面的结构:
shop/
__init__.py
price.py
checkout.py
并不意味着:
import shop
一定会执行 price.py 和 checkout.py。
只有当 __init__.py 显式导入它们,或者其他代码显式导入它们时,它们才会加载:
# shop/__init__.py
from . import price
此时:
import shop
shop.price
才可直接使用。
如果没有这行代码,应写:
import shop.price
再使用:
shop.price
这一区别很重要,因为“包中存在文件”不等于“该文件已经执行”,而“文件已经执行”也不等于“所有名称都被重新导出到包顶层”。
4. __init__.py 中不应随意放重型副作用
由于导入包会执行 __init__.py,下面的代码可能带来不必要的副作用:
# 不适合放在普通包初始化中的代码
connect_to_database()
start_background_thread()
load_large_model()
导入行为常常发生在:
- 测试收集阶段;
- CLI 启动阶段;
- Web 服务 worker 初始化阶段;
- 文档构建阶段;
- 类型检查或静态分析阶段。
如果包初始化必须执行某些动作,应该明确把它们放入函数中,由入口或应用生命周期主动调用:
# shop/__init__.py
def initialize():
connect_to_database()
这样:
import shop
只建立代码和命名空间,不隐式启动外部资源。
四、常规包和命名空间包
1. 常规包与命名空间包的差别
Python 3 支持两类包:
| 类型 | 典型特征 | 是否执行 __init__.py |
|---|---|---|
| 常规包 | 目录中包含 __init__.py |
是 |
| 命名空间包 | 可由多个位置共同组成,通常没有 __init__.py |
没有该文件可执行 |
命名空间包的核心特征是:同一个包名可以由多个搜索位置贡献。
例如:
/opt/plugin_a/acme/
metrics/
cpu.py
/opt/plugin_b/acme/
metrics/
memory.py
如果两个路径都在 sys.path 中,acme 和 acme.metrics 可以由多个位置共同组成,最终使下面的导入成立:
from acme.metrics import cpu
from acme.metrics import memory
命名空间包的各个部分不要求物理上处于同一个目录,甚至可以来自 zip 文件或其他导入系统支持的位置。(docs.python.org)
2. 命名空间包没有统一的 __init__.py
如果 acme 是命名空间包,那么不能依赖:
acme/__init__.py
来完成统一初始化,因为它可能根本不存在,而且 acme 可能由多个发行物共同提供。
这带来一个直接边界:
- 常规包适合由一个项目完整拥有;
- 命名空间包适合多个独立项目向同一顶层名称贡献子包;
- 不应在命名空间包顶层假设某个项目的初始化代码一定会执行。
命名空间包的 __path__ 也不是普通列表,而是用于动态搜索多个包部分的特殊可迭代对象。父包路径或顶层 sys.path 发生变化后,后续导入可能触发新的包部分搜索。(docs.python.org)
3. 常见误解:没有 __init__.py 就一定不能导入
在现代 Python 中,下面的结构可能可以导入:
project_a/
acme/
metrics/
cpu.py
只要 project_a 位于搜索路径中,且没有其他常规包遮蔽它,Python 可以创建命名空间包 acme 和 acme.metrics。
但这并不意味着所有情况下都能导入。导入结果还取决于:
- 当前解释器的
sys.path; - 是否存在同名的常规包;
- 当前工作目录;
- 是否已经安装项目;
- 构建工具如何选择源代码目录;
- 是否有自定义导入钩子。
所以,“目录存在”只是文件系统事实,不是导入成功条件。
五、导入的最小模型:搜索、创建、执行、缓存
理解包和入口,必须知道导入不是简单的“读取文件”。
1. 第一次导入
对于:
import shop.price
可以用以下近似流程理解:
源代码执行 import
│
▼
检查 sys.modules
│
├── 已存在:直接复用模块对象
│
└── 不存在:调用 Finder 搜索 ModuleSpec
│
▼
Loader 创建模块
│
▼
模块预先放入 sys.modules
│
▼
执行模块代码
│
▼
绑定到父包属性
导入系统会先检查 sys.modules。模块代码执行之前,模块对象就会放入缓存,这一点可以防止模块直接或间接导入自身时无限递归,也能保证并发或嵌套导入过程中使用同一个模块对象。(docs.python.org)
2. 为什么模块代码只执行一次
# counter.py
print("counter imported")
import counter
import counter
通常只输出一次:
counter imported
第二次导入并不是重新执行文件,而是复用:
sys.modules["counter"]
可以验证:
import counter
import sys
print(counter is sys.modules["counter"])
输出:
True
这里的“一次”是针对同一个解释器进程和同一个模块名称而言。下面这些情况可能导致不同结果:
- 使用
importlib.reload(); - 以不同模块名加载同一文件;
- 直接执行文件后又按包名导入;
- 使用多个解释器进程;
- 修改
sys.path导致找到不同位置的同名模块。
3. 加载失败时的状态回滚不是完整事务
如果模块执行到一半失败:
# broken.py
value = 1
raise RuntimeError("failed")
那么失败的 broken 模块通常会从 sys.modules 中移除,但在导入过程中已经成功加载的其他副作用模块可能仍然保留。官方文档明确指出,失败模块会被移除,而已经成功加载或作为副作用加载的其他模块可以继续留在缓存中。(docs.python.org)
因此,导入失败不等于整个进程回到导入前状态。导入模块时执行网络连接、注册全局状态或修改外部资源,会使故障恢复更加复杂。
六、绝对导入、相对导入与 __package__
1. 绝对导入从顶层名称开始
from shop.price import calculate
会从顶层模块名称 shop 开始解析。
项目内通常优先使用完整的绝对导入:
from inventory.models import Product
这样模块的依赖关系可以直接从代码中看出。
2. 相对导入从当前包开始
假设:
inventory/
├── __init__.py
├── models.py
└── services/
├── __init__.py
└── pricing.py
在 inventory/services/pricing.py 中:
from ..models import Product
其中:
.表示当前包;..表示父包;- 每增加一个点,向上移动一层。
相对导入依赖当前模块的包上下文,主要由 __package__ 等模块元数据支持。相对导入只能使用 from ... import ... 形式,不能写成:
import ..models
因为 import 后面的模块名还必须能作为表达式访问。(docs.python.org)
3. 为什么直接执行文件会导致相对导入失败
inventory/
├── __init__.py
├── models.py
└── services/
├── __init__.py
└── pricing.py
如果执行:
python inventory/services/pricing.py
此时文件被当作顶层脚本运行,通常具有:
__name__ == "__main__"
__package__ is None
解释器不知道它是 inventory.services.pricing,因此:
from ..models import Product
可能失败:
ImportError: attempted relative import with no known parent package
从项目根目录使用模块方式运行:
python -m inventory.services.pricing
则解释器先按照模块名称定位它,包上下文成立,相对导入才有依据。
七、入口:__main__、__main__.py 和 -m
“入口”至少有三种不同含义,不能混成一个概念。
1. 顶层执行环境的 __main__
当 Python 直接执行一个文件:
python app.py
该文件中的:
__name__
值为:
"__main__"
因此常见写法是:
def main():
print("application started")
if __name__ == "__main__":
main()
导入时:
import app
app.__name__ 是:
app
所以 main() 不会自动调用。
直接执行和导入产生不同的模块身份:
直接执行:__main__
按名称导入:app
即使两者最终指向同一个源文件,它们也可能对应两个不同的模块对象,导致类身份、全局状态和单例对象出现重复。Python 文档特别指出,直接执行和导入对应的 __main__ 与普通模块会被视为不同模块。(docs.python.org)
2. python -m 运行模块
python -m inventory.cli
它不是把字符串当作文件路径,而是先按照模块命名空间搜索 inventory.cli,然后执行该模块。
这带来两个好处:
- 代码以包成员身份运行,包上下文通常正确;
- 启动方式与安装后的导入结构一致。
相对比:
python src/inventory/cli.py
后者依赖文件路径和当前工作目录,包上下文容易丢失。
可以在模块中观察差异:
print("__name__ =", __name__)
print("__package__ =", __package__)
print("__spec__ =", __spec__)
直接运行源文件时,__main__.__spec__ 通常为 None;使用 -m 时,__spec__ 会对应实际模块的规格信息。(docs.python.org)
3. 包的 __main__.py
如果要支持:
python -m inventory
可以添加:
inventory/
├── __init__.py
├── __main__.py
└── cli.py
__main__.py:
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
当 -m 后面是包名时,Python 会定位该包,并执行:
inventory.__main__
而不是执行 inventory/__init__.py 作为应用入口。__init__.py 负责包初始化,__main__.py 负责“把包作为程序运行”,这两个职责不同。(docs.python.org)
一个可运行示例:
# inventory/cli.py
import sys
def main():
print("args:", sys.argv[1:])
return 0
# inventory/__main__.py
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
执行:
python -m inventory one two
预期输出:
args: ['one', 'two']
4. __main__.py 不等于 if __name__ == "__main__"
这两个名称看起来相似,但作用不同:
if __name__ == "__main__":
main()
是一个条件判断,用来判断当前模块是否处于顶层执行环境。
package/__main__.py
是一个特殊文件名,用于定义:
python -m package
的包级执行内容。
可以在普通模块中使用条件判断,也可以在包中同时拥有 __main__.py。
八、入口与 pyproject.toml:运行入口和安装入口
__main__.py 解决的是:
python -m inventory
而 pyproject.toml 中的 [project.scripts] 解决的是安装后生成命令。
[project]
name = "inventory-tools"
version = "1.0.0"
[project.scripts]
inventory = "inventory.cli:main"
安装该项目后,安装工具会提供名为 inventory 的命令。其运行语义等价于:
import sys
from inventory.cli import main
sys.exit(main())
这里的 "inventory.cli:main" 可以拆成:
模块名:inventory.cli
对象名:main
冒号前是导入名称,冒号后是模块中的可调用对象。PyPA 的项目元数据规范将 [project.scripts] 定义为命令行入口表;命令入口指向的函数通常不接收参数,而是从 sys.argv 读取命令行参数,并可返回进程退出码。(packaging.python.org)
因此,三种入口的关系是:
| 方式 | 入口定位依据 | 典型用途 |
|---|---|---|
python app.py |
文件路径 | 临时脚本、调试 |
python -m inventory |
模块或包名称 | 包内应用、可复现启动 |
安装后的 inventory 命令 |
分发元数据中的脚本入口 | 用户命令、部署环境 |
推荐将真正业务逻辑放在普通函数中:
# inventory/cli.py
def main():
...
return 0
然后让不同入口都调用它:
# inventory/__main__.py
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
[project.scripts]
inventory = "inventory.cli:main"
这样,模块入口和安装脚本入口共享同一实现,不需要复制参数解析和错误处理逻辑。
九、项目边界:源代码树不等于导入根
1. flat layout 的行为
flat layout 可能是:
inventory-project/
├── pyproject.toml
├── inventory/
│ ├── __init__.py
│ └── cli.py
└── tests/
在项目根目录执行:
python -m inventory
当前工作目录通常会参与模块搜索,因此源码中的 inventory 可能可以直接导入。
但这种便利也隐藏了风险:
cd /tmp
python -c "import inventory"
可能失败,因为项目根目录不再位于 sys.path 中。
2. src layout 强制区分“源码存在”和“项目已安装”
src layout:
inventory-project/
├── pyproject.toml
├── src/
│ └── inventory/
│ ├── __init__.py
│ └── cli.py
└── tests/
在项目根目录直接执行:
python -c "import inventory"
通常不能仅凭当前目录找到 src/inventory,因为 Python 搜索的是项目根目录,而不是自动递归搜索 src。
安装项目后,导入才成立:
python -m pip install -e .
python -c "import inventory; print(inventory)"
src layout 的关键价值不是目录形式本身,而是让测试尽量验证“安装后的导入行为”,避免误把项目根目录中的源码误当成已安装包。PyPA 的命令行工具示例也区分了 flat layout 和 src layout:后者通常需要先安装,才能用 python -m package 从环境中导入包。(packaging.python.org)
3. 测试目录通常不是包的一部分
inventory-project/
├── src/
│ └── inventory/
└── tests/
└── test_cli.py
tests/ 是项目测试边界,不应因为测试文件能被某种启动方式导入,就认为它属于发布包。
生产代码不应依赖:
from tests.fixtures import ...
原因是:
- 发布物可能不包含
tests/; - 安装后的
sys.path不保证包含项目根目录; - 测试辅助代码可能依赖测试框架;
- 本地运行和安装运行会得到不同导入结果。
如果测试与生产代码共享逻辑,应把共享逻辑放入正式包,或者将测试辅助代码明确作为测试内部组件管理。
十、pyproject.toml 描述的是项目和分发,不是 Python 命名空间
一个最小的项目配置可以是:
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "inventory-tools"
version = "1.0.0"
requires-python = ">=3.14"
[project.scripts]
inventory = "inventory.cli:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
这几个表的边界不同:
[build-system]:构建项目所需的构建后端和依赖;[project]:项目名称、版本、依赖等核心元数据;[project.scripts]:安装后的命令行入口;[tool.*]:具体工具的配置。
PyPA 规范目前定义了 [build-system]、[project] 和 [tool] 三类主要表;[project.scripts]、[project.gui-scripts] 和 [project.entry-points] 分别承担命令和扩展发现相关配置。(packaging.python.org)
但 pyproject.toml 不会让下面的代码自动成立:
import inventory
导入能否成立,仍取决于:
- 分发物中是否包含
inventory包; - 包是否安装到当前解释器环境;
- 当前解释器的
sys.path是否能找到它; - 是否存在同名模块遮蔽;
- 包内部是否有导入错误。
构建配置改变的是“项目如何构建、安装和发布”,而不是 Python 语言层面的命名空间规则。
十一、一个完整项目示例
目录:
inventory-project/
├── pyproject.toml
├── src/
│ └── inventory/
│ ├── __init__.py
│ ├── __main__.py
│ ├── cli.py
│ └── pricing.py
└── tests/
└── test_pricing.py
pricing.py
def total(price: int, quantity: int) -> int:
if price < 0:
raise ValueError("price must be non-negative")
if quantity < 0:
raise ValueError("quantity must be non-negative")
return price * quantity
cli.py
import argparse
from .pricing import total
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("price", type=int)
parser.add_argument("quantity", type=int)
args = parser.parse_args()
try:
print(total(args.price, args.quantity))
except ValueError as exc:
parser.error(str(exc))
return 0
__main__.py
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
__init__.py
__version__ = "1.0.0"
pyproject.toml
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "inventory-tools"
version = "1.0.0"
requires-python = ">=3.14"
[project.scripts]
inventory = "inventory.cli:main"
安装开发版本:
python -m pip install -e .
使用模块入口:
python -m inventory 12 3
输出:
36
使用安装入口:
inventory 12 3
同样输出:
36
非法输入:
inventory -1 3
预期会由 argparse 输出错误信息并以非零状态退出。
这个例子中,调用链是:
python -m inventory
│
▼
inventory.__main__
│
▼
inventory.cli:main
│
▼
inventory.pricing:total
安装后的命令入口则直接定位到:
inventory.cli:main
两个入口共享业务函数,但入口职责不同。
十二、循环依赖:命名空间和执行时序的交叉故障
循环依赖不是“模块不能互相导入”这么简单,而是模块尚未执行完成时,另一个模块访问了它尚未建立的名称。
# a.py
from b import value_b
value_a = "A"
# b.py
from a import value_a
value_b = "B"
执行:
import a
大致时序是:
1. 创建 a
2. 将 a 放入 sys.modules
3. 执行 a,开始导入 b
4. 创建 b
5. 将 b 放入 sys.modules
6. 执行 b,尝试从 a 获取 value_a
7. 此时 a 尚未执行到 value_a = "A"
8. 导入失败
sys.modules 提前放入模块可以阻止无限递归,但不能保证模块已经初始化完成。这就是为什么循环依赖经常表现为:
ImportError: cannot import name ...
或:
AttributeError: partially initialized module ...
诊断时可以先检查:
import sys
print(sys.modules.get("a"))
print(sys.modules.get("b"))
更可靠的结构性修复通常是提取第三个低层模块:
a.py ─┐
├── common.py
b.py ─┘
让依赖关系从环:
变成有向无环结构:
如果循环依赖只存在于类型标注,可以考虑延迟解析、局部导入或重新设计模块边界,但不能把局部导入当成自动修复所有初始化顺序问题的方法。
十三、常见失败表现与诊断路径
1. ModuleNotFoundError
先区分三种对象:
项目目录不存在
源码包没有安装
模块名称写错或被遮蔽
检查当前解释器和搜索路径:
python -c "import sys; print(sys.executable); print(*sys.path, sep='\n')"
检查模块实际来源:
python -c "import inventory; print(inventory.__file__)"
如果结果指向意外目录,可能是当前目录中存在同名文件:
inventory.py
它可能遮蔽真正的 inventory 包。
2. “我明明有这个目录,为什么不能导入”
导入系统不搜索任意目录,而是搜索 sys.path 中的路径及其导入器支持的位置。
例如:
/work/project/src/inventory/
并不表示:
import inventory
一定成功。必须满足 /work/project/src 进入搜索路径,或者项目已经安装。
3. “python file.py 能运行,测试却导入失败”
直接运行文件时,文件所在目录可能被放入搜索路径;测试框架从项目根目录、安装环境或其他位置启动时,搜索路径可能不同。
应分别验证:
python path/to/file.py
python -m package.module
python -c "import package.module"
这三个命令测试的是不同的启动和导入上下文。
4. “修改了 __init__.py,为什么没有重新执行”
如果模块已经在 sys.modules 中,后续 import 通常复用缓存,不会重新执行文件。开发调试时可以重启解释器;importlib.reload() 虽然可以重新执行模块代码,但它不会自动重载所有依赖模块,也可能留下旧名称和旧对象,因此不应把它当作通用状态重置机制。
十四、最终模型:把每个概念放回正确层次
可以用下面的关系理解 Python 工程:
项目 project
├── pyproject.toml 描述构建、元数据、工具和安装入口
├── 源代码树
│ └── 导入包 package 提供模块命名空间
│ ├── __init__.py 常规包初始化
│ ├── __main__.py 包级执行入口
│ └── module.py 具体模块
└── tests/ 测试边界
安装后的分发物 distribution
└── 被安装到环境中
└── 进入 sys.path 可搜索范围
└── Python 才能按模块名称导入
最重要的因果链是:
而程序执行入口则是另一条链:
\text{文件路径、}-m\text{模块、安装命令} \rightarrow \text{顶层执行环境} \rightarrow \_\_main__ \rightarrow \text{应用函数}
因此:
__init__.py解决包初始化;__main__.py解决包级执行;if __name__ == "__main__"解决模块是否作为顶层代码运行;pyproject.toml解决项目构建、元数据和安装入口;sys.path决定模块从哪里被搜索;sys.modules决定已加载模块是否复用;- 命名空间决定名称最终绑定到哪个对象。
当这几个边界分别成立时,包结构、启动方式、测试环境和安装后的行为才能保持一致。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 上下文管理器:with、协议、ExitStack 与资源安全
- 下一篇:Python 导入系统:搜索路径、缓存、Finder、Loader 与循环依赖
- 延伸:Python pyproject.toml:构建系统、项目元数据、入口和工具配置
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论