Skip to content

组织你的 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 518PEP 621,这个文件统一了项目的构建信息和元数据,取代了旧的 setup.py, setup.cfg, 和 requirements.txt 的组合。

它应该包含:

  • 项目元数据: [project] 表,包含名称、版本、描述、作者、许可证等。
  • 项目依赖: [project.dependencies] 列表,定义了项目运行所必需的库。
  • 开发依赖: 通常在 [project.optional-dependencies] 中定义一个名为 devtest 的分组。
  • 构建系统信息: [build-system] 表,指定构建项目所需的工具(如 poetry-coresetuptools)。

src/ 目录布局 (Src Layout)

这是现代 Python 项目结构的一个关键实践:将你的主要源代码包放在一个 src 目录下。

为什么使用 src 布局?

  1. 避免意外的导入: 如果你的包直接放在根目录,你可能会在开发时不小心通过相对路径导入了它,而这个包实际上并没有被正确安装。当别人试图通过 pip 安装和使用你的包时,就会出现 ImportErrorsrc 布局强制你必须以可编辑模式 (pip install -e .) 安装你的项目来进行本地开发,从而确保你的测试环境和用户的安装环境行为一致。
  2. 清晰的职责分离: 它明确地将你的源代码与项目的其他部分(如 docs, tests, 配置文件)分离开。

tests/ 目录

用于存放你所有的测试代码。

  • 与源代码分离: 将测试放在顶层的 tests 目录,而不是你的包内部,可以防止它们被意外地打包到最终的发行版中。
  • 运行测试: 你可以使用像 pytest 这样的工具来自动发现并运行 tests 目录下的所有测试。

docs/ 目录

用于存放项目的详细文档。通常使用 Sphinx 工具来生成 HTML 文档,它可以自动从你代码的文档字符串 (docstrings) 中提取 API 参考。

Makefile (可选)

虽然 make 最初是为 C 项目设计的,但它是一个非常方便的通用任务运行器。你可以用它来为项目定义一系列常用命令的快捷方式。

一个简单的 Makefile 可能看起来像这样:

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.coreacme.client

对于这种情况,Python 提供了命名空间包 (Namespace Packages) 机制。

核心概念:命名空间包

  • 与常规包的区别:

    • 常规包 (Regular Package) 在其目录下必须包含一个 __init__.py 文件。
    • 命名空间包 (Namespace Package) 则恰恰相反,它的顶层目录一定不能包含 __init__.py 文件。
  • 工作原理: 当 Python 解释器遇到一个没有 __init__.py 的目录时,它会将其视为一个命名空间。这允许多个物理上分离的目录贡献到同一个逻辑包名下。

结构示例

假设我们有一个 acme 命名空间,下面包含 coreclient 两个独立的子包。

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):

toml
[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.coreacme.client 的内容。它们是两个独立的世界,最终会被安装到用户环境中的同一个 acme 命名空间下。