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.pypython -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__ 属性的特殊模块。也就是说:

packagemodule\text{package} \subset \text{module}

所有包都是模块,但并非所有模块都是包。(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. 命名空间本质上是名称映射

可以把命名空间抽象为一个映射:

N:nameobjectN: \text{name} \rightarrow \text{object}

例如:

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.valueb.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 模块对象。

这是导入系统维护的层级不变量:

sys.modules["shop.price"]shop.price\texttt{sys.modules["shop.price"]} \equiv \texttt{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

大致会发生:

  1. 找到 shop
  2. 创建名为 shop 的模块对象;
  3. 设置模块的导入相关属性;
  4. 执行 shop/__init__.py
  5. 将初始化结果保存在 shop 的命名空间中;
  6. 将模块放入 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

这里发生了两层绑定:

  1. .price 被导入为 shop.price
  2. calculate 被绑定到 shop 包的命名空间。

__all__ 主要影响:

from shop import *

它不会阻止用户显式导入其他名称,也不会自动把所有子模块加载进来。

3. __init__.py 不会自动导入所有子模块

下面的结构:

shop/
    __init__.py
    price.py
    checkout.py

并不意味着:

import shop

一定会执行 price.pycheckout.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 中,acmeacme.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 可以创建命名空间包 acmeacme.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,然后执行该模块。

这带来两个好处:

  1. 代码以包成员身份运行,包上下文通常正确;
  2. 启动方式与安装后的导入结构一致。

相对比:

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

导入能否成立,仍取决于:

  1. 分发物中是否包含 inventory 包;
  2. 包是否安装到当前解释器环境;
  3. 当前解释器的 sys.path 是否能找到它;
  4. 是否存在同名模块遮蔽;
  5. 包内部是否有导入错误。

构建配置改变的是“项目如何构建、安装和发布”,而不是 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 ─┘

让依赖关系从环:

abaa \rightarrow b \rightarrow a

变成有向无环结构:

acommon,bcommona \rightarrow common,\qquad b \rightarrow common

如果循环依赖只存在于类型标注,可以考虑延迟解析、局部导入或重新设计模块边界,但不能把局部导入当成自动修复所有初始化顺序问题的方法。


十三、常见失败表现与诊断路径

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 才能按模块名称导入

最重要的因果链是:

项目配置构建与安装包进入解释器搜索路径模块被 Finder/Loader 定位和加载模块对象进入命名空间与 sys.modules\text{项目配置} \rightarrow \text{构建与安装} \rightarrow \text{包进入解释器搜索路径} \rightarrow \text{模块被 Finder/Loader 定位和加载} \rightarrow \text{模块对象进入命名空间与 sys.modules}

而程序执行入口则是另一条链:

\text{文件路径、}-m\text{模块、安装命令} \rightarrow \text{顶层执行环境} \rightarrow \_\_main__ \rightarrow \text{应用函数}

因此:

  • __init__.py 解决包初始化;
  • __main__.py 解决包级执行;
  • if __name__ == "__main__" 解决模块是否作为顶层代码运行;
  • pyproject.toml 解决项目构建、元数据和安装入口;
  • sys.path 决定模块从哪里被搜索;
  • sys.modules 决定已加载模块是否复用;
  • 命名空间决定名称最终绑定到哪个对象。

当这几个边界分别成立时,包结构、启动方式、测试环境和安装后的行为才能保持一致。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。