Python 代码质量工程化——mypy + ruff + pre-commit + CI 全套配置
Python 代码质量工程化——mypy + ruff + pre-commit + CI 全套配置
适读人群:希望把 Python 项目代码质量管起来的工程师、团队 TL、有代码洁癖的开发者 | 阅读时长:约13分钟 | 核心价值:一套能真正落地的代码质量工具链,不是收藏夹,是实际在用的方案
团队里加了个新同学,第一周就提交了一个 PR,里面有个函数接受了错误类型的参数,导致生产服务运行了两天之后才因为某个罕见输入触发了 AttributeError。
那次出故障的时候,我想了很多。如果我们有类型检查,这个问题在开发阶段就能发现。
那之后我花了大概三天时间,把整个团队的代码质量工具链搭起来了。效果很明显:之后6个月,因为类型错误导致的生产 bug 数量变成了零。
工具链全景
我用的工具组合:
- ruff:代码风格检查 + 格式化(取代 flake8 + black + isort)
- mypy:静态类型检查
- pre-commit:本地 git hook,提交前自动运行检查
- GitHub Actions(CI):远程再跑一遍,防止有人绕过本地 hook
为什么是 ruff 而不是 black + flake8?因为 ruff 是 Rust 写的,速度快 100 倍以上,而且一个工具解决格式化和 lint 两件事,配置更简单。
第一步:配置 ruff
# pyproject.toml
[tool.ruff]
target-version = "py311"
line-length = 100
indent-width = 4
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear(很有用,能发现很多潜在 bug)
"C4", # flake8-comprehensions
"UP", # pyupgrade(自动用新语法)
"N", # pep8-naming
"SIM", # flake8-simplify
]
ignore = [
"E501", # 行长度,已经用 line-length 控制
"B008", # FastAPI 的 Depends 会触发这个,要 ignore
"N815", # mixedCase 变量,有时候和外部 API 字段名对应需要保留
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"] # 测试里允许用 assert
"migrations/**/*.py" = ["ALL"] # 迁移文件不检查
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
[tool.ruff.lint.isort]
known-first-party = ["myapp"]验证:
ruff check . # 检查
ruff check --fix . # 自动修复
ruff format . # 格式化第二步:配置 mypy
mypy 的配置是最需要花时间的,因为给一个已有项目加类型检查,一开始会报很多错误。建议逐步推进,不要一次强求 100% 通过。
# pyproject.toml
[tool.mypy]
python_version = "3.11"
strict = false # 先不开 strict,等类型注解补充到一定程度再开
# 开启这些检查项,比完全 strict 宽松一些,但能发现主要问题
warn_return_any = true
warn_unused_configs = true
warn_redundant_casts = true
warn_unused_ignores = true
check_untyped_defs = true
disallow_incomplete_defs = true
no_implicit_optional = true
strict_optional = true
# 第三方库的类型存根
[[tool.mypy.overrides]]
module = [
"celery.*",
"redis.*",
"psycopg2.*",
]
ignore_missing_imports = true
# 对旧代码可以逐模块关闭检查
[[tool.mypy.overrides]]
module = "myapp.legacy.*"
ignore_errors = true踩坑实录一:mypy 在 FastAPI 里的类型问题
现象: 加了 mypy 之后,FastAPI 路由函数大量报类型错误,基本都是 Depends 的类型推断问题。
原因: FastAPI 大量使用依赖注入,mypy 对 Depends() 的类型推断不完善。
解法: 要么忽略这些具体的错误,要么用显式类型注解绕过:
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
# 用 Annotated 让 mypy 能正确推断类型
DbSession = Annotated[AsyncSession, Depends(get_db_session)]
CurrentUser = Annotated[User, Depends(get_current_user)]
@app.get("/users/me")
async def get_me(
db: DbSession, # mypy 能正确识别 db 是 AsyncSession
current_user: CurrentUser, # mypy 能正确识别 current_user 是 User
) -> UserResponse:
...这种写法比 db: AsyncSession = Depends(get_db_session) 更清晰,mypy 也更友好。
第三步:配置 pre-commit
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace # 去掉行尾空格
- id: end-of-file-fixer # 文件末尾确保有换行
- id: check-yaml # 检查 YAML 语法
- id: check-json # 检查 JSON 语法
- id: check-toml # 检查 TOML 语法
- id: check-merge-conflict # 检查是否有未解决的冲突标记
- id: detect-private-key # 检测是否有私钥
- id: check-added-large-files # 检查是否有大文件(默认500KB)
args: ['--maxkb=1000']
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.2.0
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
additional_dependencies:
- types-redis
- types-requests
- pydantic>=2.0.0
args: [--config-file=pyproject.toml]安装和使用:
pip install pre-commit
pre-commit install # 安装 git hook
pre-commit run --all-files # 手动对所有文件跑一次(第一次配置时用)安装之后,每次 git commit 时自动执行检查,不通过无法提交。
踩坑实录二:pre-commit 让团队产生抵触
现象: pre-commit 安装之后,有几个同学绕开了,直接用 git commit --no-verify。问他们,说"每次提交都要等1分钟太烦了"。
原因: mypy 检查在大型项目上确实很慢,整个项目跑下来要30~60秒。
解法:
- 让 mypy 只检查变更的文件:
# .pre-commit-config.yaml
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
# 只检查提交的文件,不是全部文件
pass_filenames: true
args: [--config-file=pyproject.toml, --follow-imports=silent]- 把慢检查放到 CI 里,pre-commit 本地只做快检查:
# .pre-commit-config.yaml(本地只跑 ruff,mypy 放 CI)
repos:
- repo: local
hooks:
- id: ruff-fast
name: ruff (fast)
language: system
entry: ruff check --fix
types: [python]
# ruff 很快,全部文件也就几秒这样本地 hook 3秒以内,大家就愿意用了。
第四步:GitHub Actions CI
# .github/workflows/quality.yml
name: Code Quality
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: |
pip install ruff mypy
pip install -r requirements.txt
pip install types-redis types-requests
- name: Run ruff
run: |
ruff check .
ruff format --check .
- name: Run mypy
run: mypy app/ --config-file=pyproject.toml
test:
runs-on: ubuntu-latest
needs: lint # lint 通过才跑测试
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: pip install -r requirements-dev.txt
- name: Run tests
run: pytest tests/ --cov=app --cov-report=xml -v
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml踩坑实录三:给旧项目加类型注解太痛苦
现象: 旧项目几万行代码,mypy 一跑报了 800 多个错误,改都不知道从哪改起。
解法: 分阶段推进,不要试图一次搞定。
阶段1:先把 ignore_errors = true 加到所有旧模块,让 mypy 能正常跑完(报0个错误)。
阶段2:新写的代码必须通过 mypy,新模块从 ignore_errors 名单里去掉。
阶段3:每个 sprint 从旧模块里挑一两个,补注解、去掉 ignore_errors。
6个月之后,ignore_errors 名单里的模块从 47 个减到了 8 个。不用一步到位,慢慢来,方向对了就够了。
完整的 pyproject.toml 模板
[project]
name = "myapp"
version = "1.0.0"
requires-python = ">=3.11"
[project.optional-dependencies]
dev = [
"ruff>=0.2.0",
"mypy>=1.8.0",
"pre-commit>=3.6.0",
"pytest>=7.4.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=4.1.0",
]
[tool.ruff]
# ... (见上文)
[tool.mypy]
# ... (见上文)
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "-v --tb=short"代码质量工具链不是一次性配置,需要随着团队习惯和项目发展持续调整。最重要的不是配置有多严格,是团队真的在用它,而不是用 --no-verify 绕过它。
