返回

Python src-layout 项目结构添加子包层级及导入问题详解

python

Python src-layout 项目结构下添加子包层级

最近在处理多个 Python 项目,它们都采用了 src-layout 的结构,而且都有相同的顶层包名。 项目结构大概长这样:

package-subpackage1
├── .git/
├── src/
│   └── package/
│       └── subpackage1/
│           ├── __init__.py
│           └── code_subpackage1.py
└── pyproject.toml
package-subpackage2/
├── .git/
├── src/
│   └── package/
│       └── subpackage2/
│           ├── __init__.py
│           └── code_subpackage2.py
└── pyproject.toml

每个仓库的 .toml 文件都设置成:

[tool.setuptools.packages.find]
where = ["src"]
include = ["package.*"]

这样就能用下面的方式导入模块:

import package.subpackage1

import package.subpackage2

遇到的问题

现在,我想给 subpackage1 添加一个子包层级,变成这样:

package-subpackage1/
├── .git/
├── src/
│   └── package/
│       └── subpackage1/
│           ├── __init__.py
│           ├── code_subpackage1.py
│           └── subsubpackage1/
│               ├── __init__.py     
│               └── code_subsubpackage1.py    
└── pyproject.toml

理想情况下,我想这样调用:

import package.subpackage1
subpackage1.subsubpackage1.SomeClass

但当我尝试通过下面这种方式访问 subsubpackage1 时:

import package.subpackage1 as pack
pack.subsubpackage1

出现了错误:

AttributeError: module 'package.subpackage1' has no attribute 'subsubpackage1'

这倒也正常,毕竟我改变了原有的 src-layout 结构,自动发现机制似乎失效了。但奇怪的是,如果我之后手动导入 subsubpackage1

import package.subpackage1.subsubpackage1

subsubpackage1.py 里的所有类就"神奇地"可以通过以下方式访问了:

pack.subsubpackage1.SomeClass

我不太明白,为什么手动导入 subsubpackage1 后,它就能在之前导入的 subpackage1 里访问到了? 有没有人知道这是怎么回事?

问题原因分析

这个问题核心在于 Python 的模块导入机制和 setuptools 的包发现机制的交互。

  1. Python 模块导入机制: 当你执行 import package.subpackage1 时,Python 会:

    • 找到 package 目录。
    • 执行 package/__init__.py(如果存在)。
    • 找到 package/subpackage1 目录。
    • 执行 package/subpackage1/__init__.py(如果存在)。
    • subpackage1 作为一个模块对象添加到 package 的命名空间。

    此时,package.subpackage1 是一个模块对象,但它并不知道 subsubpackage1 的存在,因为 subpackage1/__init__.py 里没有显式地导入或定义 subsubpackage1

  2. 手动导入的影响: 当你执行 import package.subpackage1.subsubpackage1 时:

    • Python 找到了subsubpackage1
    • 然后它执行了 package/subpackage1/subsubpackage1/__init__.py
    • 这个操作 修改 了已经导入的package.subpackage1模块。 Python 会把 subsubpackage1 作为属性添加到 已经存在package.subpackage1 模块对象里. 这就是"魔法"发生的地方. 因为 package.subpackage1 对象已经在内存里了,第二次导入并没有创建新的对象,而是在已有的对象上添加了新的属性。
  3. setuptools 的包发现: setuptools 在打包时,会根据 pyproject.toml 的配置,自动查找需要包含的包。 include = ["package.*"] 告诉它包含 package 下的所有直接子包。它并不会递归地查找更深层级的子包。

解决方案

为了解决这个问题,让 package.subpackage1.subsubpackage1 能正常导入,有几个办法:

1. 修改 __init__.py

最直接的方法是修改 package/subpackage1/__init__.py,显式地导入 subsubpackage1

# package/subpackage1/__init__.py
from . import subsubpackage1

或者直接导入里面的类:

#package/subpackage1/__init__.py
from .subsubpackage1 import SomeClass

原理: 这样,在导入 package.subpackage1 时,subsubpackage1 也会被导入,并成为 subpackage1 的一个属性。

代码示例: 上面给出的就是代码。

安全建议: 没啥特别需要注意的。

2. 使用相对导入 (Recommended)

可以在code_subpackage1.py 里面, 用相对路径引入同级的其他模块,和子目录下的模块:

# in code_subpackage1.py
from . import other_module_in_subpackage1  #同级目录下.
from .subsubpackage1 import SomeClass   #subsubpackage1子目录下.

原理: Python 推荐, 也是最常用的包管理方案, 不需要再进行复杂的配置。 易于管理和维护。

代码示例: 上面给出的就是代码。

3. 修改 pyproject.toml (不太推荐)

可以修改 pyproject.toml,让 setuptools 递归地包含所有子包。可以修改配置, 指定更详细的匹配规则:

[tool.setuptools.packages.find]
where = ["src"]
include = ["package.subpackage1*"]

或者,用packages精确的指定哪些模块要包括进来:

[tool.setuptools]
packages = ["package","package.subpackage1", "package.subpackage1.subsubpackage1", "package.subpackage2"]

[tool.setuptools.packages.find]
where = ["src"]

原理:

  • include = ["package.subpackage1*"]: 通配符匹配更广。
  • packages: 手动列出.

代码示例: 就是上面 toml 文件的配置。

安全建议: 注意不要包含不必要的模块,防止意外暴露代码。 过于精确的配置, 后续不便于管理和维护.

4. 使用 Namespace Packages (进阶)

如果你的 package 是一个比较大的项目,并且希望有更灵活的结构,可以考虑使用 Namespace Packages。

原理: Namespace Packages 允许多个不同的目录,作为同一个顶层包的一部分。

实现步骤 (使用 native namespace packages PEP 420):

  1. 移除所有 __init__.py 文件(顶层包、子包都移除)。 这是最关键的一步!
  2. 每个 repository 的 pyproject.toml 保持不变(或者做最小限度的修改):
[tool.setuptools.packages.find]
where = ["src"]
include = ["package*"]

[tool.setuptools.packages.find]
where = ["src"]

  1. 文件结构可以保持不变,无需额外调整。

代码示例:

假设有两个仓库:

  • repo1: src/package/subpackage1/module1.py
  • repo2: src/package/subpackage2/module2.py

安装这两个仓库后,可以这样导入:

import package.subpackage1.module1
import package.subpackage2.module2

安全建议: Namespace Packages 适合大型、分布式项目,使用前需要仔细规划包结构。

Namespace Package的好处:

  • 结构可以分布在不同的仓库中。
  • 非常高的可拓展性.

希望以上内容可以帮你更好地理解这个问题, 和构建你的包!