组织你的 Python 项目:一份最佳实践指南
一个结构清晰、组织良好的项目是可维护、可扩展和易于协作的基石。本文档旨在提供一份关于如何组织一个典型 Python 项目的现代最佳实践指南。
参考资料:
1. 为什么项目结构很重要?
良好的项目结构不仅仅是为了美观。当一个潜在的贡献者或用户访问你的代码仓库时,清晰的结构是他们能读懂你项目的第一步。更重要的是,在长期的开发和维护中,一个逻辑清晰的结构能够:
- 降低认知负担:让团队成员能快速定位到他们需要查找或修改的代码。
- 简化依赖和导入:避免复杂的相对导入和路径问题。
- 方便自动化:使测试、构建和部署等自动化流程更容易配置。
2. "黄金标准"项目结构示例
以下是一个被社区广泛认可的、适用于大多数中小型 Python 应用或库的项目结构。
my_project/
├── .gitignore # Git 忽略文件列表
├── docs/ # 文档目录
│ ├── conf.py
│ └── index.rst
├── src/ # 源代码目录 (src-layout)
│ └── my_package/ # 你的 Python 包
│ ├── __init__.py
│ ├── module1.py
│ └── module2.py
├── tests/ # 测试目录
│ ├── test_module1.py
│ └── test_module2.py
├── LICENSE # 项目许可证
├── Makefile # (可选) 任务运行器
├── pyproject.toml # 现代 Python 项目的核心配置文件
└── README.md # 项目说明
3. 各部分详解
README.md
这是你项目的门面。它应该清晰地说明:
- 项目是做什么的。
- 如何安装和配置。
- 一个快速上手的使用示例。
- 如何为项目做出贡献。
LICENSE
法律保障文件,定义了他人可以如何使用、修改和分发你的代码。如果你不确定使用哪个许可证,可以访问 choosealicense.com。缺少许可证会使许多人无法放心地使用你的代码。
.gitignore
告诉 Git 哪些文件或目录不应该被纳入版本控制。一个典型的 Python .gitignore
文件会包含:
- 虚拟环境目录 (
.venv/
,env/
) - Python 缓存文件 (
__pycache__/
,*.pyc
) - IDE 和操作系统生成的文件 (
.idea/
,.vscode/
,.DS_Store
) - 构建产物 (
build/
,dist/
,*.egg-info
)
pyproject.toml
这是现代 Python 项目的核心。根据 PEP 518 和 PEP 621,这个文件统一了项目的构建信息和元数据,取代了旧的 setup.py
, setup.cfg
, 和 requirements.txt
的组合。
它应该包含:
- 项目元数据:
[project]
表,包含名称、版本、描述、作者、许可证等。 - 项目依赖:
[project.dependencies]
列表,定义了项目运行所必需的库。 - 开发依赖: 通常在
[project.optional-dependencies]
中定义一个名为dev
或test
的分组。 - 构建系统信息:
[build-system]
表,指定构建项目所需的工具(如poetry-core
或setuptools
)。
src/
目录布局 (Src Layout)
这是现代 Python 项目结构的一个关键实践:将你的主要源代码包放在一个 src
目录下。
为什么使用 src
布局?
- 避免意外的导入: 如果你的包直接放在根目录,你可能会在开发时不小心通过相对路径导入了它,而这个包实际上并没有被正确安装。当别人试图通过
pip
安装和使用你的包时,就会出现ImportError
。src
布局强制你必须以可编辑模式 (pip install -e .
) 安装你的项目来进行本地开发,从而确保你的测试环境和用户的安装环境行为一致。 - 清晰的职责分离: 它明确地将你的源代码与项目的其他部分(如
docs
,tests
, 配置文件)分离开。
tests/
目录
用于存放你所有的测试代码。
- 与源代码分离: 将测试放在顶层的
tests
目录,而不是你的包内部,可以防止它们被意外地打包到最终的发行版中。 - 运行测试: 你可以使用像
pytest
这样的工具来自动发现并运行tests
目录下的所有测试。
docs/
目录
用于存放项目的详细文档。通常使用 Sphinx 工具来生成 HTML 文档,它可以自动从你代码的文档字符串 (docstrings) 中提取 API 参考。
Makefile
(可选)
虽然 make
最初是为 C 项目设计的,但它是一个非常方便的通用任务运行器。你可以用它来为项目定义一系列常用命令的快捷方式。
一个简单的 Makefile
可能看起来像这样:
.PHONY: install test docs clean
install:
# 安装开发依赖
pip install -e ".[dev]"
test:
# 运行测试
pytest
docs:
# 构建文档
sphinx-build docs/ docs/_build
clean:
# 清理构建缓存
rm -rf build/ dist/ .eggs/ __pycache__/
这样,你只需要运行 make install
, make test
等简单命令,而无需记住完整的命令行。
4. 进阶结构:在单个代码库中管理多个包
当项目变得非常庞大时,你可能会遇到一种更复杂的场景:在一个单一的代码仓库(Monorepo)中,你需要维护和分发多个可以被独立安装的包,但它们共享同一个 src
目录。例如,acme.core
和 acme.client
。
对于这种情况,Python 提供了命名空间包 (Namespace Packages) 机制。
核心概念:命名空间包
与常规包的区别:
- 常规包 (Regular Package) 在其目录下必须包含一个
__init__.py
文件。 - 命名空间包 (Namespace Package) 则恰恰相反,它的顶层目录一定不能包含
__init__.py
文件。
- 常规包 (Regular Package) 在其目录下必须包含一个
工作原理: 当 Python 解释器遇到一个没有
__init__.py
的目录时,它会将其视为一个命名空间。这允许多个物理上分离的目录贡献到同一个逻辑包名下。
结构示例
假设我们有一个 acme
命名空间,下面包含 core
和 client
两个独立的子包。
my_monorepo/
├── src/
│ └── acme/ # 命名空间包的顶层 (没有 __init__.py)
│ ├── core/ # acme.core 子包 (常规包)
│ │ ├── __init__.py
│ │ └── logic.py
│ └── client/ # acme.client 子包 (常规包)
│ ├── __init__.py
│ └── app.py
│
├── pyproject.toml # 用于构建 acme.core 的配置文件
└── pyproject.client.toml # 用于构建 acme.client 的配置文件 (这是一种可能的组织方式)
构建挑战与解决方案
挑战: 标准规定,一个 pyproject.toml
文件定义一个项目。那么我们如何从同一个源代码树构建出两个不同的分发包呢?
解决方案: 主流的做法是为每个需要独立分发的子包创建独立的构建配置。虽然 pyproject.toml
本身不支持在一个文件里定义多个项目,但我们可以借助构建工具的灵活性来实现。
方法:为每个包使用独立的 pyproject.toml
这是最清晰、最标准的方式。你可以为每个子包创建一个专门的目录,用于存放其构建配置。
my_monorepo/
├── .git/
├── src/
│ └── acme/
│ ├── core/
│ │ └── ...
│ └── client/
│ └── ...
│
├── packages/ # 为每个可分发的包创建一个目录
│ ├── acme-core/
│ │ └── pyproject.toml
│ └── acme-client/
│ └── pyproject.toml
│
└── README.md
然后,在每个子包的 pyproject.toml
文件中,你需要告诉构建后端(例如 hatchling
, setuptools
)去哪里寻找源代码。
packages/acme-core/pyproject.toml
示例 (使用 hatchling):
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "acme.core"
version = "0.1.0"
# ... 其他元数据 ...
[tool.hatch.build.targets.wheel]
# 明确告诉 hatchling 只打包 src/acme/core
packages = ["../../src/acme/core"]
通过这种方式,当你进入 packages/acme-core/
目录并运行构建命令(如 python -m build
)时,它只会将 src/acme/core
下的代码打包成 acme.core
这个分发包。acme.client
的配置与此类似。
关于"统一出口"的澄清
Python 没有与 JavaScript 的 index.js
完全对等的"统一出口"文件概念。
__init__.py
文件是单个包的入口和门面,而不是整个src
目录的。- 在上面的多包场景中,
src/acme/core/__init__.py
负责定义acme.core
包的公共 API,而src/acme/client/__init__.py
则负责定义acme.client
包的公共 API。 - 不存在一个文件可以同时导出
acme.core
和acme.client
的内容。它们是两个独立的世界,最终会被安装到用户环境中的同一个acme
命名空间下。