新起一个 Python 项目,最先崩的往往不是业务逻辑,而是"别人的机器上跑不起来"。格式不统一、类型不检查、测试靠手点、CI 形同虚设,这四件少一件,项目就会在第三周开始还债。下面这份清单可以直接抄进仓库:格式 → 静态检查 → 类型 → 测试 → 打包 → CI,每步都配可执行的配置。
语境为 Python 3.11+ / Linux。文中输出标注为"典型输出",格式随工具版本变化。
1. 可照抄的六步清单
顺序不能乱:格式先行,因为改动面最大、争议最小,先跑一遍能让后续 diff 干净。
| 步骤 | 目标 | 工具 | 验收信号 |
|---|---|---|---|
| 1 格式 | 没人再争论空格与换行 | ruff format |
diff 里不再出现纯格式改动 |
| 2 静态检查 | 抓未使用变量、可变默认参数、bare except |
ruff check |
返回 0 |
| 3 类型 | 让函数签名成为契约 | mypy 或 pyright |
严格模式下 0 error |
| 4 测试 | 把"我知道它能跑"变成"任何人都能验证" | pytest + coverage |
新增代码分支被覆盖 |
| 5 打包 | 依赖与入口可声明 | pyproject.toml + build |
pip install . 成功 |
| 6 CI | 让前五步在别人机器上自动发生 | GitHub Actions / GitLab CI | 检查不过不能合并 |
2. 工具选型:少即是多
选型的关键是减少需要维护的配置数量。
| 职责 | 选择 | 理由 |
|---|---|---|
| 格式 + lint | ruff 一体承担 | 一段 pyproject.toml 替代 black+isort+flake8 三套配置,本地检查秒级返回 |
| 类型检查 | mypy 或 pyright,二选一 | 同时跑两套会产生互相矛盾的报错,团队会集体关掉 |
| 测试 | pytest + pytest-cov | 断言语法简单、fixture 生态成熟 |
| 依赖锁定 | uv / pip-tools / poetry 任选其一 | 只要产出锁文件即可 |
一条硬规则:同一职责只允许一个工具。两套格式检查器同时存在的结果,一定是所有人都不跑。
3. 最小可用的 pyproject.toml
[project]
name = "demo-service"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["pydantic>=2.7", "httpx>=0.27"]
[project.optional-dependencies]
dev = ["ruff>=0.6", "mypy>=1.11", "pytest>=8.2", "pytest-cov>=5.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
3.1 ruff 的 select 与 ignore
[tool.ruff]
line-length = 100
target-version = "py311"
src = ["src", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes:未使用变量/导入
"I", # isort:导入顺序
"B", # flake8-bugbear:可变默认参数等陷阱
"UP", # pyupgrade:用新语法替换旧写法
"SIM", # 可简化写法
]
ignore = [
"E501", # 行长交给 formatter,避免与 format 互相打回
]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101"] # 测试里的 assert 是正常用法
| 键 | 作用 | 不配的后果 |
|---|---|---|
select |
决定启用哪些规则族 | 默认只开 E/F,B 类真陷阱不会报 |
ignore |
关掉与 formatter 冲突的规则 | ruff format 与 ruff check 互相打回,陷入死循环 |
per-file-ignores |
按目录放宽 | 测试代码被迫写一堆 # noqa |
src |
告诉 ruff 第一方包在哪 | 导入排序把自家模块当第三方 |
3.2 mypy 的 strict 与 disallow_untyped_defs
[tool.mypy]
python_version = "3.11"
files = ["src"]
strict = true # 总开关
disallow_untyped_defs = true # 显式声明,防被后人改掉
warn_unused_ignores = true # 防 # type: ignore 泛滥
warn_redundant_casts = true
[[tool.mypy.overrides]]
module = ["some_legacy_sdk.*"] # 缺 stub 的库单模块放行
ignore_missing_imports = true
strict = true 本身包含 disallow_untyped_defs。再单独写一遍,是因为总开关可能被后续同事改动,而显式声明能在 review 时被看见。warn_unused_ignores 是治"忽略注释泛滥"的关键:某处忽略一旦不再需要,mypy 会报未使用,逼你删掉。
3.3 pytest 的 testpaths 与 addopts
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = [
"-q",
"--strict-markers",
"--strict-config",
"--cov=src",
"--cov-report=term-missing",
]
markers = [
"slow: 标记耗时用例",
"integration: 需要外部依赖的用例",
]
| 键 | 价值 |
|---|---|
testpaths |
限定收集范围,避免误扫 build/、.venv/ |
pythonpath |
不装包也能 import 到 src/ 下的代码 |
--strict-markers |
打错 marker 名直接报错,而不是静默失效 |
--strict-config |
配置里写了不存在的键会报错 |
--cov-report=term-missing |
直接看哪几行没跑到,比百分比有用 |
4. pre-commit:把检查前移到提交时
CI 是最后一道门,pre-commit 是第一道。第一道门的作用是让问题在离开本地前就被拦下。
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: local
hooks:
- id: mypy
name: mypy (src only)
entry: mypy
language: system
pass_filenames: false
$ pre-commit install
$ pre-commit run --all-files
Ruff....................................................Failed
- hook id: ruff
- exit code: 1
Found 3 errors (2 fixed, 1 remaining).
--exit-non-zero-on-fix 很关键:hook 自动改了文件却返回 0,会造成"提交成功但工作区已被改动",下一次提交混入你并不知道的内容。
5. CI 流水线:顺序与失败即停
顺序原则是从快到慢,让最便宜的检查最先失败。
# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --frozen --extra dev
- run: uv run ruff format --check .
- run: uv run ruff check .
- run: uv run mypy
- run: uv run pytest
- run: uv run python -m build
| 顺序 | 步骤 | 失败即停的意义 |
|---|---|---|
| 1 | 装依赖 | 锁文件与声明不一致时立刻暴露 |
| 2 | format --check |
纯格式问题,秒级返回,不浪费 runner |
| 3 | ruff check |
逻辑可疑写法 |
| 4 | mypy |
契约破坏 |
| 5 | pytest |
行为回归 |
| 6 | build |
防止"本地能跑,发布失败" |
--frozen 不是可选项。它禁止在安装时改动锁文件,一旦 pyproject.toml 与锁文件漂移就直接失败——这正是你想要的行为。没有它,CI 会静默解析出新版本依赖,"本地过、CI 不过"就成了常态。
6. 覆盖率数字的正确用法
覆盖率是排查工具,不是目标。当 KPI 的后果,是有人写一堆只调用不 assert 的测试把数字抬上去。
| 用法 | 恰当做法 | 反模式 |
|---|---|---|
| 看整体百分比 | 只作趋势参考 | 设 95% 门槛卡合并 |
| 看未覆盖行 | --cov-report=term-missing 逐行看 |
只截一张百分比截图 |
| 看分支 | branch = true 看 if/else 两侧 |
行覆盖 100%,分支只走一半 |
| 看新增代码 | 只对本次 diff 设门槛 | 让历史低覆盖拖住新代码 |
[tool.coverage.run]
branch = true
source = ["src"]
[tool.coverage.report]
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]
show_missing = true
branch = true 之后报告里会出现形如 12->14 的标记,意思是"第 12 行的分支没有走到第 14 行"。这比 92% 这个数字信息量大得多:它直接告诉你哪个异常分支从来没被验证过。
7. 依赖锁文件与可复现构建
$ uv lock && uv sync --frozen # 固化解析结果到 uv.lock
$ pip-compile pyproject.toml -o requirements.txt # pip-tools 等价做法
| 问题 | 有锁文件 | 无锁文件 |
|---|---|---|
| 半年后重建环境 | 版本逐字复现 | 装到当时最新,行为可能变化 |
| CI 与本地一致性 | 同源解析 | 各解析各的 |
| 依赖升级 | 一次显式升级 + 一次提交 | 悄悄发生 |
锁文件必须提交进版本库,否则它只是本地缓存。把 --frozen 这类严格开关写进 CI,让漂移变成失败而不是静默。
8. 新项目目录骨架
demo-service/
├── pyproject.toml
├── uv.lock
├── .pre-commit-config.yaml
├── .github/workflows/ci.yml
├── src/demo_service/{__init__,config,cli,core}.py
└── tests/{conftest,test_core}.py
包放在 src/ 下,是为了避免测试时 import 到工作目录里的偶然副本;tests/ 与包平级而不放进包内,是为了让打包产物不带测试代码。集成测试另开 tests/integration/,按目录或 marker 与单元测试分开跑。
9. 排错:常见坑对照
| 现象 | 真实原因 | 处理 |
|---|---|---|
| 本地过、CI 挂 | 本地 Python 版本与 CI 不一致 | CI 显式钉版本,收紧 requires-python |
| 版本一致仍挂 | 没按锁文件安装 | 加 --frozen |
mypy 报一堆第三方库缺 stub |
无 stub 的库 | [[tool.mypy.overrides]] 单模块放行,不要全局关 |
到处是 # type: ignore |
被逼着"先让它过" | 打开 warn_unused_ignores,逐步删除 |
format 与 check 互相打回 |
格式规则没 ignore | 在 ignore 里关掉与 formatter 冲突的规则 |
| 覆盖率很高但线上仍出错 | 测试只调用不校验 | 看 term-missing,补异常分支断言 |
| pre-commit 改了文件却提交成功 | 没加 --exit-non-zero-on-fix |
补上该参数 |
10. 边界:这些检查管到哪为止
| 场景 | 清单能做的 | 需要别的做法 |
|---|---|---|
| 逻辑正确性 | 保证已写的测试通过 | 设计评审、对拍、属性测试 |
| 性能退化 | 基本无 | 基准测试单独跑,不进 CI 默认路径 |
| API 兼容性 | 部分由类型保护 | 契约测试、版本策略 |
| 数据库迁移 | 基本无 | 迁移工具 dry-run 与回滚验证 |
一句话:清单管的是下限,不是上限。 它保证代码可读、可安装、可验证,不保证设计正确。
11. 自测
- 为什么
ruff format与ruff check会互相打回,该在哪一栏修? strict = true已包含disallow_untyped_defs,为什么还要显式写一遍?--exit-non-zero-on-fix解决的是什么问题?- CI 里去掉
uv sync --frozen的--frozen会发生什么? branch = true相比只开行覆盖,多告诉了你什么?- 新项目为什么建议用
src/布局而不是把包放在仓库根目录?