《Python编程入门》2.3 项目结构与 pyproject.toml 初探

单文件脚本撑不起真实项目。本节先讲一个标准 Python 项目该长什么样,对比 src 布局与平铺布局的取舍,说明 tests、README、.gitignore 各放什么;再拆开 pyproject.toml 的三段结构(build-system、project、tool),讲清 requires-python 的含义;最后贴出 uv init 实际生成的文件内容,让你照着就能搭起项目骨架。

本节目标:搭起一个标准 Python 项目的骨架,读懂 pyproject.toml 的三段结构;读完后你能自己规划目录、写出合规的 pyproject.toml,并理解 uv init 生成的每个文件是干什么的。
适用版本:Python 3.12+(实测 3.14.6)

2.3 项目结构与 pyproject.toml 初探

上一节我们写了 hello.py——一个孤零零的文件。但真实项目会有几十上百个文件、依赖外部库、还要写测试。如果目录随手一放,半年后连自己都找不到东西。本节先确定「东西该放哪儿」,再认识那个负责声明「这是什么项目、依赖什么」的文件。

一个标准项目目录长什么样

先看一个典型的 Python 项目骨架:

my-project/
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
├── uv.lock
├── src/
│   └── my_project/
│       ├── __init__.py
│       └── main.py
└── tests/
    └── test_main.py

各部分的职责:

路径作用是否提交 Git
pyproject.toml项目元数据与依赖声明是
uv.lock / poetry.lock锁定精确依赖版本是
README.md项目说明,给人看的门面是
.gitignore告诉 Git 忽略哪些生成物是
.python-version指定解释器版本是
src/真正的源码是
tests/测试代码是
.venv/虚拟环境,本机生成否

最后一行很关键:.venv/ 里装的是本机解释器与依赖,不同机器路径、二进制都不同,绝不能提交。别人克隆你的仓库后,自己跑一条创建命令就能重建。.gitignore 的作用就是拦住这类不该进版本库的东西。

src 布局还是平铺布局

源码放在哪里,有两种主流做法。

平铺布局(flat layout):包目录直接放在项目根下。

my-project/
├── pyproject.toml
├── my_project/
│   └── __init__.py
└── tests/

src 布局(src layout):包目录放进 src/ 里。

my-project/
├── pyproject.toml
├── src/
│   └── my_project/
│       └── __init__.py
└── tests/

两者都能用,但 src 布局有一个实打实的好处:它逼你验证「安装后的包能不能用」。

在平铺布局下,因为项目根目录天然就在 sys.path 上,你在项目根里 import my_project 时,导入的其实是当前目录里的源码,而不是安装后的版本。于是会出现一种隐蔽的坑:代码在开发时跑得好好的,打包安装后却因为漏了某个文件而崩——因为你从没真正测试过安装后的形态。

src 布局把源码挪进 src/,项目根不再直接暴露包,你就必须先把项目装进环境(pip install -e . 或 uv sync)才能 import。这一小步强迫你在开发阶段就接近真实安装形态,把问题提前暴露。

维度平铺布局src 布局
上手难度更低稍高
开发时导入可能导入源码而非安装版强制走安装版
打包隐患容易漏测提前暴露
适用小脚本、临时项目要发布或长期维护的库

结论很清晰:临时脚本用平铺,正经项目用 src。新版 uv init 默认就是 src 布局。

tests、README 与 .gitignore

tests/。 测试代码单独放,不要和源码混在一起。通常一个测试文件对应一个源码模块,文件名以 test_ 开头,这样 pytest 能自动发现。测试的价值在第 14 章展开,此处只要先把目录留出来。

README.md。 项目门面,别人打开你的仓库第一眼看到的东西。至少写清三件事:项目是干什么的、怎么安装、怎么运行。哪怕只有三行,也比空着强。

.gitignore。 一个 Python 项目至少应该忽略这些:

# Python 生成物
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info

# 虚拟环境
.venv

__pycache__/ 是解释器缓存的字节码(还记得 1.3 节的编译模型吗),*.py[oc] 匹配 .pyc 和 .pyo,build/、dist/、*.egg-info 是打包产物,.venv 是本机环境。这些全都不该进版本库。

pyproject.toml 的三段结构

pyproject.toml 是现代 Python 项目的中心配置文件。它取代了老旧的 setup.py 与 setup.cfg,用 TOML 格式写成。一个典型的文件分三段:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "my-project"
version = "0.1.0"
description = "一个示例项目"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "requests==2.34.2",
]

[project.scripts]
my-project = "my_project.main:main"

[tool.ruff]
line-length = 100

第一段 [build-system]:怎么把项目打包。 requires 列出构建时需要的工具,build-backend 指定用哪个构建后端。常见的后端有 hatchling、setuptools、uv_build、poetry-core。注意这段描述的是构建,日常运行不需要它——但工具链需要它来判断项目如何被安装。

第二段 [project]:这个项目是什么。 这是元数据核心,几乎每项都有明确含义:

字段含义
name项目名,也是发布到 PyPI 时的包名
version版本号,遵循语义化版本
description一句话简介
readmeREADME 文件路径
requires-python支持的 Python 版本范围
dependencies运行时依赖列表
[project.scripts]命令行入口,名字 = "模块:函数"

dependencies 里的每一项都应该固定版本,比如 "requests==2.34.2",而不是只写 "requests"。精确锁定能保证换台机器装出完全一样的结果,这是可复现构建的基础。第 15 章会专门讨论版本约束的取舍。

第三段 [tool.*]:各工具的配置。 这一段没有统一规范,而是「谁的工具谁说了算」。比如 [tool.ruff] 配置代码检查器 Ruff,[tool.pytest.ini_options] 配置 pytest,[tool.mypy] 配置类型检查器。工具越多,这一段越长。好处是所有配置集中在一个文件,不用满项目找 .ruff.toml、pytest.ini、mypy.ini。

requires-python:给解释器划一条下限

requires-python = ">=3.12" 这行的意思是:这个项目至少需要 Python 3.12。它有两个作用:

一是安装时校验。当有人用 3.10 去装你的项目,包管理器会直接拒绝并报错,而不是装完再运行到一半崩溃。

二是作为语法下限的声明。本书的基线就是 ">=3.12"——因为 3.12 引入了 PEP 695 泛型语法(type X = ...、class C[T]:)等特性,代码里用得到。如果你的项目还要兼容更老的版本,就得把下限调低,同时避免使用高版本才有的语法。

requires-python = ">=3.12"        # 本项目基线
requires-python = ">=3.13"        # 用到 3.13 特性(如 typing.TypeIs)时
requires-python = ">=3.14"        # 只跑在最新稳定线时

写这行时要想清楚:你的目标用户装的是什么版本?写高了会把一部分人挡在门外,写低了则要用兼容写法牺牲一些便利。

uv init 生成的真实项目

理论讲完,看一个真实产物。用 uv 生成一个新项目:

uv init demo
Initialized project `demo` at `/private/tmp/uvtest/demo`

生成的目录结构如下:

demo/
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
└── src/
    └── demo/
        └── __init__.py

注意它默认采用 src 布局,并顺手初始化了一个 Git 仓库。pyproject.toml 的实际内容(作者信息由你的 Git 配置填入):

[project]
name = "demo"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
    { name = "Your Name", email = "you@example.com" }
]
requires-python = ">=3.14"
dependencies = []

[project.scripts]
demo = "demo:main"

[build-system]
requires = ["uv_build>=0.12.23,<0.13.0"]
build-backend = "uv_build"

对照前面讲的三段:[build-system] 用了 uv_build 后端;[project] 填好了名字、版本、requires-python(这里跟随你的解释器写成了 >=3.14,你可以手动改成 >=3.12);[project.scripts] 注册了一个叫 demo 的命令行入口,指向 demo:main。

再看 src/demo/__init__.py:

def main() -> None:
    print("Hello from demo!")

以及 .python-version:

3.14

这个文件记录项目期望的解释器版本,uv 会据此自动挑选(没有的话就下载)对应解释器,团队协作时能保证大家用的是同一个版本。

运行项目自带的入口:

uv run demo
Hello from demo!

uv run 会自动确保虚拟环境存在、依赖装齐,然后执行——你连激活环境都省了。加上一个依赖试试:

uv add "requests==2.34.2"
 + certifi==2026.7.22
 + charset-normalizer==3.5.2
 + idna==3.20
 + requests==2.34.2
 + urllib3==2.8.0

uv add 会同时做三件事:把依赖写进 pyproject.toml 的 dependencies、解析并写入锁文件 uv.lock、把包装进虚拟环境。之后 uv run 就都基于这份锁定结果执行。

这里要克制一点:本节只讲「项目长什么样、配置怎么读」。至于依赖如何解析、版本约束怎么选、wheel 怎么构建、怎么发布到 PyPI,是第 15 章的完整主题,现在不必深挖。你可以先把 pyproject.toml 当成「项目的身份证」来看待即可。

小结

  • 标准项目应包含 pyproject.toml、README.md、.gitignore、src/、tests/;.venv/ 与 __pycache__/ 绝不提交。
  • src 布局比平铺布局多一步「必须先安装」,但正是这一步提前暴露打包问题,正经项目推荐 src 布局。
  • pyproject.toml 分三段:[build-system] 管构建、[project] 管元数据与依赖、[tool.*] 管各工具配置。
  • requires-python = ">=3.12" 既是安装校验门槛,也是语法下限声明;本书基线就是它。
  • 依赖要固定版本(如 requests==2.34.2),配合锁文件实现可复现构建。
  • uv init 默认生成 src 布局项目,uv run / uv add 把环境、依赖、运行一条龙包办。

到这里,环境、工具、项目骨架都齐了。下一章 3.1 数值、字符串与 f-string 格式化 正式进入语言本身——从最基本的数据类型讲起。若想先看依赖管理的完整图景,可以读专题文章 现代 Python 工具链 与 pyproject.toml 与依赖管理 。

阅读导航:上一节:2.2 编辑器、REPL 与第一个脚本 · 下一节:3.1 数值、字符串与 f-string 格式化 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时