2578 字
约 8 分钟
16
S10-X05 · Python 工程化清单:格式、类型、测试、CI

新起一个 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. 自测

  1. 为什么 ruff format 与 ruff check 会互相打回,该在哪一栏修?
  2. strict = true 已包含 disallow_untyped_defs,为什么还要显式写一遍?
  3. --exit-non-zero-on-fix 解决的是什么问题?
  4. CI 里去掉 uv sync --frozen 的 --frozen 会发生什么?
  5. branch = true 相比只开行覆盖,多告诉了你什么?
  6. 新项目为什么建议用 src/ 布局而不是把包放在仓库根目录?
S10-X05 · Python 工程化清单:格式、类型、测试、CI
https://www.clxhxhhr.top/posts/5747/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。