本节目标:搭起一个标准 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 | 一句话简介 |
readme | README 文件路径 |
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 格式化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。