Python 基础体系 · 第 66/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 代码质量:Ruff、格式化、Lint、导入排序和规则治理

Python 工程中的“代码质量”不是一个单独的工具开关,而是一条由多个阶段组成的处理链:

源代码
  │
  ├── 格式化(format)
  │      统一排版,尽量不改变程序语义
  │
  ├── Lint(静态检查)
  │      根据规则发现错误、风险和不一致
  │
  ├── 导入排序(import sorting)
  │      按来源、层次和名称整理 import
  │
  ├── 类型检查(mypy / Pyright)
  │      检查静态类型关系
  │
  └── 测试(pytest)
         验证运行时行为

Ruff 把 Python 工程中原本由 Flake8、Pyflakes、pycodestyle、isort、pyupgrade 以及许多插件分别承担的部分能力,集中到一个命令行工具中;它同时提供 Lint 和格式化功能。Ruff 的官方定位是 Python linter 和 formatter,而不是类型检查器或测试运行器。(docs.astral.sh)

因此,合理的边界是:

  • 格式化器决定代码“长什么样”;
  • Lint判断代码是否违反某些可检查规则;
  • 导入排序器处理 import 的分组和顺序;
  • 类型检查器判断类型关系是否成立;
  • 测试判断程序行为是否符合预期。

如果把这些职责混在一起,规则会互相冲突,自动修复会变得不可预测,CI 也会出现“本地能过、流水线失败”的问题。


一、先区分四个核心概念

1. 格式化是什么

格式化是把同一份程序语义转换成统一的文本表示。

例如,下面两个函数通常具有相同的运行时行为:

def add(a,b):
    return a+b
def add(a, b):
    return a + b

格式化器会选择一种稳定的表达形式,例如:

def add(a, b):
    return a + b

这里需要注意一个边界:

格式化器主要处理排版,不负责证明程序逻辑正确。

它通常会处理:

  • 缩进;
  • 空格;
  • 括号;
  • 多行表达式;
  • 尾随逗号;
  • 字符串引号风格;
  • 空行;
  • 部分注释和文档字符串中的代码。

它不会因为下面的逻辑错误而报错:

def divide(total: int, count: int) -> float:
    return total * count

从语法和格式角度看,这段代码可能完全合法;但函数名和实现之间存在业务错误。这类问题要依靠测试、代码审查或更高层的静态分析发现。


2. Lint 是什么

Lint 是依据一组规则分析源代码,并输出诊断信息的过程。

一条 Lint 规则通常包含:

  1. 一个规则编号;
  2. 一个规则名称;
  3. 一组触发条件;
  4. 一个诊断位置;
  5. 可选的自动修复;
  6. 可选的修复安全等级。

例如,未使用的导入:

import os


def greet(name: str) -> str:
    return f"Hello, {name}"

可能触发 F401

F401 `os` imported but unused

Ruff 的规则采用类似 Flake8 的编码体系。规则编码通常由字母前缀和数字组成,例如 F401;前缀表示规则来源或规则类别,选择器既可以写完整规则,也可以写类别前缀。(docs.astral.sh)

例如:

[tool.ruff.lint]
select = ["E", "F"]
ignore = ["E501"]

可以理解为:

启用 E 类规则
启用 F 类规则
但排除 E501

规则集合可以形式化为:

Reffective=(RselectRextend-select)RignoreR_{\text{effective}} = (R_{\text{select}} \cup R_{\text{extend-select}}) - R_{\text{ignore}}

其中:

  • RselectR_{\text{select}}:通过 select 选择的规则;
  • Rextend-selectR_{\text{extend-select}}:额外增加的规则;
  • RignoreR_{\text{ignore}}:被排除的规则;
  • ReffectiveR_{\text{effective}}:最终实际生效的规则集合。

例如:

[tool.ruff.lint]
select = ["E", "F"]
extend-select = ["B"]
ignore = ["E501", "B008"]

假设:

E = {E101, E201, E501}
F = {F401, F841}
B = {B008, B023}

那么最终集合是:

(E ∪ F ∪ B) - {E501, B008}
= {E101, E201, F401, F841, B023}

这个推导解释了为什么不应随意同时维护大量重叠配置:规则前缀、完整规则、默认规则和目录级配置叠加后,最终结果不容易从文件表面直接看出来。


3. 导入排序是什么

导入排序不是简单的字母排序。

一个 Python 文件中的 import 通常具有不同来源:

from __future__ import annotations

import os
import sys

import httpx
from pydantic import BaseModel

from myapp.config import Settings
from .models import User

它至少涉及三个维度:

  1. 导入来源:标准库、第三方包、项目自身;
  2. 语句形式import xfrom x import y
  3. 名称顺序:模块名、成员名、别名和相对导入层级。

导入排序的目标不是改变业务逻辑,而是建立稳定的依赖可见性。例如,通过空行可以快速看出:

标准库依赖
第三方依赖
当前项目依赖

这对代码审查很重要:新增一个第三方包时,审查者能够在 import 区域直接看到依赖变化。

Ruff 的导入排序能力由 Lint 规则 I 提供,行为兼容 isort 的主要用法。重要的是:

Ruff Formatter 本身不会排序导入。

官方推荐的顺序是先执行导入排序和 Lint 修复,再执行格式化:

ruff check --select I --fix
ruff format

因为格式化器负责排版,而导入排序属于 Lint 阶段,两者是不同的数据处理过程。(docs.astral.sh)


4. 规则治理是什么

规则治理是决定哪些规则启用、在哪里启用、谁可以豁免、自动修复做到什么程度,以及如何逐步升级规则的过程。

没有规则治理时,项目往往会出现四种问题:

  • 所有人都直接启用 ALL,旧代码产生大量噪音;
  • 为了让 CI 通过,团队大量添加无理由的 # noqa
  • 自动修复改变了运行时行为;
  • 不同目录使用不同规则,却没有明确边界。

规则治理的核心不是“规则越多越好”,而是让每条规则都具有明确的工程目的:

规则
  ├── 为什么启用?
  ├── 发现什么风险?
  ├── 能否自动修复?
  ├── 修复是否安全?
  ├── 哪些文件允许豁免?
  └── 违规后由谁处理?

二、Ruff 在 Python 3.14 工程中的位置

Python 3.14 工程通常至少需要在项目元数据中声明支持版本:

[project]
name = "example-app"
version = "0.1.0"
requires-python = ">=3.14"

pyproject.toml 是 Python 打包和工具配置的标准配置文件。当前规范定义了 [build-system][project][tool] 等表;第三方工具应当把自己的配置放在 [tool.<工具名>] 下。(packaging.python.org)

Ruff 可以直接使用:

[tool.ruff]
target-version = "py314"
line-length = 88

[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I", "B", "UP", "RUF"]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

这里有两个版本信息:

  • requires-python = ">=3.14" 是项目对安装环境的声明;
  • target-version = "py314" 是 Ruff 进行语法分析、自动升级和格式判断时采用的最低目标版本。

Ruff 当前支持 py314 作为目标版本,并且官方建议:如果已经写了 project.requires-python,优先使用这个标准字段,因为其他工具也能够理解它;只有两者不一致时,显式的 target-version 才优先。(docs.astral.sh)

例如,下面的代码依赖 Python 3.10 以上的联合类型语法:

def parse(value: int | str) -> int:
    return int(value)

如果项目实际支持 Python 3.9,那么把 Ruff 目标设为 py314 会让自动升级规则错误地认为新语法是可用的。因此目标版本必须表示项目真正支持的最低 Python 版本,而不是开发者本机安装的版本。

如果项目只支持 Python 3.14:

[project]
requires-python = ">=3.14"

[tool.ruff]
target-version = "py314"

如果项目支持 Python 3.11 到 3.14:

[project]
requires-python = ">=3.11"

[tool.ruff]
target-version = "py311"

这里的“目标版本”不是“最新版本”,而是兼容性下限。


三、安装 Ruff,并验证实际执行版本

Ruff 可以作为项目开发依赖安装。使用 uv 时:

uv add --dev ruff

也可以使用:

python -m pip install ruff

Ruff 官方文档同时提供了 uvpippipx 和独立安装方式。(docs.astral.sh)

安装后先不要直接修改整个仓库,而应验证命令和版本:

ruff --version
ruff check --help
ruff format --help

在项目中固定 Ruff 版本后,CI 和本地应使用同一版本。原因是:

  • 新版本可能增加规则;
  • 新版本可能改变格式化结果;
  • 新版本可能修复或调整自动修复;
  • 不同版本的规则集合可能不同。

如果开发者本地运行的是一个版本,而 CI 使用另一个版本,可能出现:

本地 format 后提交
CI 又认为文件未格式化

这不是 Python 代码的问题,而是工具版本漂移造成的确定性缺失。


四、格式化:ruff format 的职责与边界

1. 基本命令

ruff format

该命令会就地格式化当前目录下发现的 Python 文件。

格式化单个文件:

ruff format src/example.py

只检查、不修改:

ruff format --check

查看将要发生的变化:

ruff format --diff

其中:

  • ruff format 会写回文件;
  • ruff format --check 发现未格式化文件时返回非零退出码;
  • ruff format --diff 输出差异,适合审查或 CI 诊断。

官方文档明确区分了就地格式化和 --check 检查模式。(docs.astral.sh)


2. 一个完整的格式化示例

原始代码:

def build_message(name:str,items:list[str])->dict[str,object]:
    result={"name":name,"items":items}
    return result

执行:

ruff format src/example.py

可能得到:

def build_message(
    name: str,
    items: list[str],
) -> dict[str, object]:
    result = {"name": name, "items": items}
    return result

格式化器做了这些事情:

  1. 在类型标注冒号后补空格;
  2. 在箭头两侧统一空格;
  3. 在字典字面量中统一空格;
  4. 根据行宽把函数参数拆成多行;
  5. 为多行参数添加尾随逗号。

尾随逗号的作用不仅是风格统一,还能降低后续修改的差异:

def build_message(
    name: str,
    items: list[str],
    locale: str,
) -> dict[str, object]:
    ...

新增或删除一个参数时,通常只修改一行,避免整段参数列表被重新排版。


3. 格式化器不负责所有“风格规则”

Ruff Formatter 与 Black 的设计目标相近,但它提供的格式化选项是有限的。它不是一个任意可编程的排版引擎,也不应该承担所有风格审查。(docs.astral.sh)

例如,下面这些规则属于 Lint 范畴:

  • 是否存在未使用变量;
  • 是否使用了过时的 Python 写法;
  • 是否导入了禁止的模块;
  • 是否在错误的位置导入;
  • 是否违反特定插件规则。

因此:

ruff format

不能替代:

ruff check

同样:

ruff check

也不能替代:

ruff format --check

一个文件可能已经格式正确,但仍然存在 Lint 错误:

import os


def value() -> int:
    unused = 1
    return 42

它的排版可以完全符合格式化规则,但 osunused 仍然可能触发未使用规则。


五、Lint:从默认规则到显式规则集

1. 查看诊断

ruff check

检查单个目录:

ruff check src tests

检查单个文件:

ruff check src/example.py

假设文件内容为:

import os


def calculate(value: int) -> int:
    unused = 10
    return value * 2

可能得到:

src/example.py:1:8: F401 `os` imported but unused
src/example.py:5:5: F841 Local variable `unused` is assigned to but never used

每条诊断至少包含:

文件:行:列: 规则编号 说明

这使得编辑器、CI 和代码审查工具可以定位具体源代码位置。


2. 默认规则不等于完整规则集

Ruff 默认启用一组常用规则,并且会避开与格式化器重叠的部分样式规则。官方将默认规则集定位为一个适合入门和渐进采用的起点,而不是所有项目都必须使用的最终规则集。(docs.astral.sh)

工程中通常应当显式写出关键规则类别:

[tool.ruff.lint]
select = [
    "E4",   # import 和语法风格相关
    "E7",   # 代码结构相关
    "E9",   # 运行前可发现的错误
    "F",    # Pyflakes
    "I",    # isort 风格导入排序
    "B",    # bugbear
    "UP",   # Python 语法升级
    "RUF",  # Ruff 自身规则
]

选择 E4 而不是整个 E,是因为 E 可能包含与 Formatter 重叠的规则,例如行长度。选择范围越窄,规则意图越清晰。

如果想扩展默认规则:

[tool.ruff.lint]
extend-select = ["B", "I", "UP"]

如果想使用完全明确的规则集合:

[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I"]

二者的工程含义不同:

  • extend-select:保留默认规则,再增加规则;
  • select:用显式集合定义主要规则范围。

对于长期维护的项目,建议在团队熟悉 Ruff 后逐步转向更明确的规则治理,而不是长期依赖“工具默认值”。


3. selectextend-selectignore 的组合

考虑配置:

[tool.ruff.lint]
select = ["E", "F"]
extend-select = ["I", "B"]
ignore = ["E501", "B008"]

其计算步骤是:

第一步:
R1 = E ∪ F

第二步:
R2 = R1 ∪ I ∪ B

第三步:
Reffective = R2 - {E501, B008}

结果是:

  • E 类规则生效,但 E501 除外;
  • F 类规则生效;
  • I 类规则生效;
  • B 类规则生效,但 B008 除外。

不要把 ignore 理解为“关闭整个插件”。例如:

ignore = ["B"]

会关闭所有 B 类规则;而:

ignore = ["B008"]

只关闭其中一条。


六、自动修复:安全修复与不安全修复

1. 安全修复

ruff check --fix

Ruff 默认只应用被标记为安全的修复。

典型的安全修复包括:

import os


def greet(name: str) -> str:
    return f"Hello, {name}"

删除未使用的 os

def greet(name: str) -> str:
    return f"Hello, {name}"

这里删除的是没有被引用的导入,通常不会改变函数结果。

但“安全”不是数学上的绝对保证。Python 具有动态导入、模块副作用和元编程能力,因此即使某个修复通常被认为安全,也应在重要代码上检查差异并运行测试。Ruff 文档也明确说明,动态语言中很难对所有修复做出完全确定的保证。(docs.astral.sh)


2. 不安全修复

ruff check --fix --unsafe-fixes

不安全修复可能改变:

  • 运行时异常类型;
  • 求值顺序;
  • 注释;
  • 类型标注含义;
  • 某些动态行为。

例如,把:

head = values[0]

改写成:

head = next(iter(values))

在非空列表上都能取得第一个元素,但空集合时:

[][0]

抛出:

IndexError

而:

next(iter([]))

抛出:

StopIteration

上游代码如果专门捕获 IndexError,这个自动修复就改变了行为。因此 Ruff 将此类修复标为不安全。(docs.astral.sh)

生产仓库不应直接执行:

ruff check --fix --unsafe-fixes .

更稳妥的流程是:

ruff check --fix .
git diff
pytest
ruff check .
ruff format --check .

如果确实需要不安全修复,应先在独立提交中查看差异,再执行完整测试。


3. 控制哪些规则允许修复

可以限制自动修复范围:

[tool.ruff.lint]
fixable = ["F", "I", "UP"]
unfixable = ["B"]

含义是:

  • 允许修复 FIUP
  • 禁止自动修复 B

如果只想禁止删除未使用导入:

[tool.ruff.lint]
fixable = ["ALL"]
unfixable = ["F401"]

这适合导入可能具有副作用的特殊项目。但普通应用代码不应因为个别边界情况而全局禁止 F401,更好的做法是对特定文件或导入写出明确意图。


七、导入排序的内部分类逻辑

1. 标准分组

考虑以下代码:

from __future__ import annotations

import json
import os

import httpx
from pydantic import BaseModel

from example.config import Settings
from .models import User

通常可以拆成:

future
standard-library
third-party
first-party
local-folder

标准形式是:

from __future__ import annotations

import json
import os

import httpx
from pydantic import BaseModel

from example.config import Settings
from .models import User

这里的空行不是为了美观而存在,而是表达依赖层次:

futurestandard librarythird partyfirst partylocal\text{future} \rightarrow \text{standard library} \rightarrow \text{third party} \rightarrow \text{first party} \rightarrow \text{local}

这不是 Python 解释器要求的执行顺序;Python 只要求 from __future__ 导入必须出现在允许的位置。其余分组主要是可读性和审查约定。


2. 项目根目录决定 first-party

Ruff 需要判断某个包是第三方依赖还是当前项目的一部分。例如:

from example.config import Settings

如果项目布局是:

project/
├── pyproject.toml
└── src/
    └── example/
        ├── __init__.py
        └── config.py

可以配置:

[tool.ruff]
src = ["src"]

这样,example 更容易被归类为 first-party。

如果使用 src 布局而没有正确配置项目根目录,导入排序可能把本项目模块错误地归到 third-party。此时问题不是排序算法“随机”,而是工具没有足够的信息判断包归属。

Ruff 的项目根目录通常由包含 pyproject.tomlruff.toml.ruff.toml 的目录决定;导入分类会据此分析。(docs.astral.sh)


3. 使用 I 规则排序

只检查导入排序:

ruff check --select I .

只修复导入排序:

ruff check --select I --fix .

完整流程:

ruff check --fix .
ruff format .

不要把下面两条命令理解成等价:

ruff format

和:

ruff check --select I --fix

第一条处理排版,第二条处理 import 的语义分类和顺序。


4. 不要启用与 Formatter 冲突的 isort 选项

Ruff 官方列出了一些可能和 Formatter 冲突的导入排序选项,例如:

  • force-single-line
  • force-wrap-aliases
  • lines-after-imports
  • lines-between-types
  • split-on-trailing-comma

如果这些选项采用非默认值,Formatter 可能把导入重新折叠或重新排版。官方建议移除不兼容配置。(docs.astral.sh)

例如,不建议这样配置:

[tool.ruff.lint.isort]
force-single-line = true

除非团队明确接受它与 Formatter 的排版策略之间的差异。

更稳妥的起点是:

[tool.ruff.lint.isort]
known-first-party = ["example"]

只补充项目归属信息,不主动改变大量默认排版策略。


八、格式化与导入排序的执行顺序

把源代码看成文本 SS,导入排序变换记为 II,格式化变换记为 FF

理想情况下,希望满足:

F(I(S))=F(I(F(S)))F(I(S)) = F(I(F(S)))

也就是说,无论先排版还是先排序,最后结果应该稳定。

但实际工具对导入多行结构的处理并不完全独立。例如:

from example.utils import (
    parse_config,
    validate_config,
)

导入排序可能调整成员顺序,Formatter 可能根据行宽重新决定是否折叠。若配置相互冲突,可能出现:

第一次运行:文件发生变化
第二次运行:文件再次发生变化
第三次运行:仍然发生变化

这叫非幂等格式化流程

一个理想的格式化工具满足:

F(F(S))=F(S)F(F(S)) = F(S)

即第一次格式化后,第二次格式化不再产生变化。

工程上应当验证幂等性:

ruff check --fix .
ruff format .
git diff --exit-code

如果第一次执行后存在差异,再执行一次:

ruff check --fix .
ruff format .
git diff --exit-code

第二次仍然有变化,说明配置或工具链之间可能存在冲突。Ruff Formatter 官方也强调,正确配置时,Formatter 不应引入新的 Lint 错误;启用了冲突规则时会发出警告。(docs.astral.sh)


九、推荐的 pyproject.toml 起点

下面是一份适用于 Python 3.14、src 布局的基础配置:

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

[project]
name = "example-app"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = []

[tool.ruff]
target-version = "py314"
line-length = 88
src = ["src"]

[tool.ruff.lint]
select = [
    "E4",
    "E7",
    "E9",
    "F",
    "I",
    "B",
    "UP",
    "RUF",
]

# 格式化器已经负责主要排版,不额外启用容易冲突的风格规则。
ignore = [
    "E501",
]

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = [
    "S101",
]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "lf"

其中:

  • [build-system] 声明构建后端及其构建依赖;
  • [project] 声明包的元数据和 Python 兼容范围;
  • [tool.ruff] 声明 Ruff 级别的公共配置;
  • [tool.ruff.lint] 声明 Lint 规则;
  • [tool.ruff.format] 声明格式化规则;
  • [tool.ruff.lint.per-file-ignores] 对特定文件模式施加局部例外。

pyproject.toml 中的 [build-system] 是构建配置,[project] 通常承载项目元数据,而工具配置应放在 [tool] 下。(packaging.python.org)

这里的 S101 只是示例。测试中是否允许 assert,应由项目实际规则决定;如果没有启用对应规则,写入 per-file-ignores 不会产生效果,也可能误导维护者。


十、配置文件的发现和覆盖边界

Ruff 支持:

pyproject.toml
ruff.toml
.ruff.toml

pyproject.toml 中,配置以:

[tool.ruff]

开始;在 ruff.toml.ruff.toml 中,直接写:

line-length = 88

配置文件的语义相同,只是表前缀不同。(docs.astral.sh)

一个重要边界是:

Ruff 不会像某些配置系统那样把多个配置文件逐层合并;通常使用离当前文件最近的配置文件,父级配置不会继续叠加。

这对 monorepo 很重要:

repo/
├── pyproject.toml
├── service_a/
│   └── ruff.toml
└── service_b/
    └── pyproject.toml

如果 service_a 内部存在更近的配置文件,它可能形成自己的规则边界。这样做可以支持多个子项目,但也会造成:

  • 同一仓库不同目录的规则不同;
  • 根目录修改不一定影响子项目;
  • CI 从不同工作目录执行时,结果可能不同。

因此需要明确选择一种策略:

单一规则边界

repo/
└── pyproject.toml

适合大多数单项目仓库。

多个独立项目

repo/
├── service_a/
│   ├── pyproject.toml
│   └── ruff.toml
└── service_b/
    ├── pyproject.toml
    └── ruff.toml

适合真正独立发布、独立测试、独立依赖的多个项目,但必须在 CI 中分别运行。


十一、规则豁免:从 noqa 到局部配置

1. 行级豁免

value = eval(source)  # noqa: S307

含义是只忽略这一行的 S307

不要写成:

value = eval(source)  # noqa

# noqa 会隐藏该行的所有规则,未来新增规则时也可能被一并掩盖。

更清晰的写法是:

value = eval(source)  # noqa: S307 - 输入来自受信任配置文件

Ruff 支持通过 # noqa: CODE 进行针对规则的行级豁免,也支持使用 RUF100 检查无效或多余的 noqa 指令。(docs.astral.sh)


2. 文件级豁免

# ruff: noqa: D100

它会忽略整个文件中的 D100

完全关闭整个文件的检查:

# ruff: noqa

这种写法风险较高,因为它把整个文件从规则系统中移除了。更合理的原则是:

优先行级豁免
其次文件级、指定规则豁免
最后才考虑整个文件豁免

3. 路径级豁免

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"]
"src/example/compat.py" = ["UP006"]

路径级豁免适合表达稳定的文件类别差异:

  • 测试文件允许某些测试专用写法;
  • 兼容层保留旧语法;
  • 生成代码不适合按手写代码规则检查;
  • __init__.py 具有重新导出的特殊职责。

它不适合用来掩盖普通代码中的普遍问题。例如:

[tool.ruff.lint.per-file-ignores]
"src/**/*.py" = ["F401", "F841", "B"]

这实际上关闭了核心代码的大量错误检查,规则配置就失去了价值。


4. 关闭豁免检查

为了发现已经失效的豁免,可以执行:

ruff check --select RUF100 .

或者在 CI 中启用 RUF100

如果代码为:

value = 1  # noqa: F401

但该行实际没有 F401,这条豁免就是无效的。无效豁免的危害在于它会让读者以为这里存在一个经过确认的特殊情况,实际却没有任何规则需要忽略。


十二、规则治理:如何逐步引入,而不是一次性爆炸

阶段一:先统一格式

ruff format .
ruff format --check .

格式化通常适合先做,因为它主要改变文本表示,规则争议较少。

阶段二:启用低争议规则

[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I"]

这组规则主要覆盖:

  • 基本代码结构;
  • 明显错误;
  • 未使用导入和变量;
  • 导入排序。

阶段三:引入自动升级和 bug 风险规则

[tool.ruff.lint]
extend-select = ["B", "UP", "RUF"]

这一步需要代码审查,因为:

  • UP 可能推动语法现代化;
  • B 可能发现潜在运行时陷阱;
  • RUF 可能引入 Ruff 特有约定。

阶段四:评估更严格的规则

例如文档字符串、命名、复杂度、类型标注相关规则。这些规则常常与项目类型有关,库项目、Web 服务、脚本和数据分析项目不应机械地使用同一套规则。

规则是否应该启用,可以用一个简单决策表判断:

问题 结论
违规是否经常导致真实缺陷? 优先启用
违规是否容易自动修复? 可在本地自动修复
是否经常产生合理例外? 考虑局部豁免
是否与 Formatter 冲突? 不要同时启用
是否需要大量人工判断? 先在 CI 警告或试运行
是否只代表个人偏好? 不要强制为错误

十三、遗留项目的渐进迁移

假设旧项目已经存在大量 UP035 违规。直接启用:

ruff check --select UP035 .

会让 CI 立刻失败。

一种迁移策略是先把已有违规作为基线,避免新代码继续增加问题:

ruff check --select UP035 --add-noqa .

Ruff 会在现有违规位置加入对应的 # noqa,之后再逐步删除这些豁免。官方文档提供了 --add-noqa--add-ignore 两种迁移方式。(docs.astral.sh)

例如,原始代码:

from typing import Iterable

加入基线后可能变成:

from typing import Iterable  # noqa: UP035

这个过程的逻辑是:

旧问题:
  允许存在,但显式记录

新代码:
  不允许新增同类问题

迁移完成:
  删除历史 noqa
  将规则变成正常阻断规则

但基线豁免会增加代码噪音,因此不能把它当作最终状态。每条豁免都应当有收敛路径:

新增规则
  │
  ├── 统计现有违规
  ├── 建立基线或分目录修复
  ├── 禁止新增
  ├── 定期减少豁免数量
  └── 完成后删除基线

十四、CI 中的检查顺序

一个明确的 CI 流程可以写成:

ruff check .
ruff format --check .
pytest

如果希望 CI 自动检查导入排序,ruff check . 必须确保 I 类规则已经启用。

本地开发可以使用自动修复:

ruff check --fix .
ruff format .
pytest

两者的职责不同:

本地:
  修改文件,缩短反馈循环

CI:
  只检查,不修改提交内容

不要在 CI 中静默修改工作区后再判断结果。否则开发者可能只看到“构建失败”,却不知道 CI 修改了哪些文件。


GitHub Actions 示例

name: quality

on:
  push:
  pull_request:

jobs:
  quality:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.14"

      - name: Install Ruff
        run: python -m pip install ruff

      - name: Check lint
        run: ruff check .

      - name: Check format
        run: ruff format --check .

      - name: Install test dependencies
        run: python -m pip install pytest

      - name: Run tests
        run: pytest

Ruff 也提供 GitHub Action 和 pre-commit 集成;使用 pre-commit 时,如果 Lint hook 开启 --fix,应放在 Formatter hook 之前。(docs.astral.sh)

如果希望在 CI 中得到 GitHub 直接识别的诊断:

      - name: Check lint
        run: ruff check --output-format=github .

十五、pre-commit 中的顺序问题

一个典型配置是:

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.16.5
    hooks:
      - id: ruff-check
        args: [--fix]
      - id: ruff-format

执行过程是:

提交文件
  │
  ├── ruff-check --fix
  │      修复未使用导入、导入排序和其他可修复规则
  │
  └── ruff-format
         重新整理最终排版

如果把 Formatter 放在自动修复前面,Lint 修复可能又生成新的排版差异,导致一次提交需要运行多次 hook。Ruff 官方建议开启 --fix 时,把 Ruff Lint hook 放在 Formatter 之前。(docs.astral.sh)

在团队中还需要决定:

  • hook 是否只检查暂存文件;
  • 是否允许 hook 修改文件;
  • 是否把 Formatter 放在编辑器保存动作中;
  • CI 是否再次全量检查。

通常可以采用:

编辑器:自动格式化
pre-commit:快速修复和检查
CI:全量、只读检查

十六、Formatter 与特定 Lint 规则的冲突

Ruff Formatter 官方列出了一些不建议和 Formatter 同时启用的规则,包括:

  • W191:制表符缩进;
  • E111E114E117:某些缩进规则;
  • D203D206:文档字符串空行或缩进;
  • Q000Q004:引号规则;
  • COM812COM819:尾随逗号规则;
  • 部分隐式字符串拼接规则。

这些规则的问题不是一定错误,而是它们可能与 Formatter 对同一段文本的决策不同。官方说明默认配置不会启用这些冲突规则;如果项目主动启用了它们,应重新评估。(docs.astral.sh)

例如:

[tool.ruff.lint]
extend-select = ["Q"]

同时:

[tool.ruff.format]
quote-style = "double"

这会让两个系统同时对字符串引号负责。结果可能是:

Formatter 改成双引号
Lint 又认为应该使用另一种引号

正确做法是让一个工具成为权威来源。使用 Ruff Formatter 时,应避免启用与其重复管理同一格式属性的 Lint 规则。


十七、代码质量工具之间的职责边界

Ruff 不能替代类型检查和测试。

Ruff 能发现的问题

import os


def total(values: list[int]) -> int:
    return sum(values)

它可以发现 os 未使用。

mypy 或 Pyright 更适合发现的问题

def total(values: list[int]) -> int:
    return sum(values)


result: str = total([1, 2, 3])

这里函数返回 int,但赋值目标是 str。这是类型关系错误,应由 mypy 或 Pyright 检查。

pytest 更适合发现的问题

def divide(total: int, count: int) -> float:
    return total * count

如果需求是除法,Lint 可能无法知道 * 是业务错误;测试可以通过输入输出验证:

def test_divide() -> None:
    assert divide(10, 2) == 5

因此建议将检查链明确分层:

ruff format --check
  → 文本排版稳定

ruff check
  → 规则级静态检查

mypy 或 pyright
  → 类型关系检查

pytest
  → 运行时行为检查

这四个阶段互相补充,而不是互相替代。


十八、常见失败表现与诊断方法

失败一:ruff format --check 失败,但本地看不出差异

先查看差异:

ruff format --diff .

再确认版本:

ruff --version

常见原因:

  • 本地和 CI Ruff 版本不同;
  • 行结束符不同;
  • 使用了不同的配置文件;
  • CI 从另一个目录执行;
  • 某个子目录存在更近的 ruff.toml

失败二:导入排序把项目模块识别成第三方模块

检查项目布局:

project/
├── pyproject.toml
└── src/
    └── example/

确认:

[tool.ruff]
src = ["src"]

如果仍然有问题,再检查包名和当前工作目录。导入分类依赖项目根目录和源代码目录,不能只靠模块名称猜测。


失败三:Formatter 和 Lint 反复修改同一文件

先分开执行:

ruff check --select I --fix .
ruff format .
ruff check .

观察是否出现 Formatter 警告。如果启用了 QCOM、特殊 isort 选项或缩进类规则,优先检查这些配置。


失败四:ruff check --fix 删除了看似“有用”的导入

检查这个导入是否依赖副作用:

import example.plugins

如果它的作用是触发插件注册,而代码中没有显式引用,Lint 可能认为它未使用。

这时不要直接写裸豁免:

import example.plugins  # noqa

应当指定规则并说明意图:

import example.plugins  # noqa: F401 - 导入时注册插件

如果这种模式在整个项目普遍存在,可以考虑重构注册机制,而不是大面积关闭 F401


失败五:启用新规则后出现大量旧问题

不要立即把规则加入永久 ignore

ignore = ["UP035", "B008", "D100", "D101"]

这会把“尚未治理”伪装成“明确不治理”。

更好的诊断顺序是:

ruff check --select UP035 .
ruff check --select UP035 --output-format=json .

先统计问题,再判断:

  • 是否需要批量自动修复;
  • 是否只有特定目录受影响;
  • 是否应建立临时基线;
  • 是否规则与项目设计冲突;
  • 是否需要修改源代码而不是配置。

十九、一个端到端工作流

假设项目结构如下:

example-app/
├── pyproject.toml
├── src/
│   └── example/
│       ├── __init__.py
│       └── app.py
├── tests/
│   └── test_app.py
└── .github/
    └── workflows/
        └── quality.yml

初始化工具:

uv add --dev ruff pytest

第一次检查:

uv run ruff check .
uv run ruff format --check .

修复导入和安全 Lint:

uv run ruff check --fix .

格式化:

uv run ruff format .

查看变更:

git diff

运行测试:

uv run pytest

最后用只读命令确认:

uv run ruff check .
uv run ruff format --check .
uv run pytest

完整状态转换可以表示为:

flowchart TD
    A[编辑源代码] --> B[ruff check --fix]
    B --> C[ruff format]
    C --> D[git diff 审查]
    D --> E{是否接受变更}
    E -- 否 --> F[撤销或手工修改]
    F --> B
    E -- 是 --> G[ruff check]
    G --> H[ruff format --check]
    H --> I[mypy 或 Pyright]
    I --> J[pytest]
    J --> K[提交与 CI]

关键路径是:

  1. 先应用可接受的自动修复;
  2. 再统一最终排版;
  3. 审查自动修改的差异;
  4. 用只读检查确认仓库处于稳定状态;
  5. 运行类型检查和测试;
  6. 最后交给 CI 重复验证。

二十、最终规则设计原则

一套可维护的 Ruff 配置通常具备以下性质:

1. 格式化只有一个权威工具

如果选择:

ruff format

就不要再让 Black、YAPF 和一组引号规则同时修改相同代码区域。

2. 导入排序明确属于 Lint 阶段

使用:

[tool.ruff.lint]
extend-select = ["I"]

并按顺序执行:

ruff check --fix
ruff format

3. 规则按风险而不是个人偏好启用

优先启用能发现真实错误的规则,再逐步加入风格和复杂度规则。

4. 自动修复必须区分安全等级

默认使用:

ruff check --fix

对于:

ruff check --fix --unsafe-fixes

必须审查差异并运行测试。

5. 豁免必须窄化并可解释

优先:

# noqa: F401 - 通过导入触发插件注册

而不是:

# noqa

6. CI 检查必须是只读的

CI 应该报告问题,而不是悄悄修改代码。修改应发生在开发者本地或显式的自动化修复任务中。

7. 配置必须和项目元数据一致

Python 版本至少要协调这两个字段:

[project]
requires-python = ">=3.14"

[tool.ruff]
target-version = "py314"

如果二者不一致,代码升级规则、类型语法和运行环境之间就可能出现矛盾。

Ruff 的价值不只是“运行速度快”,而是把格式化、Lint、导入排序和自动修复放入一套可以版本化、审查和在 CI 中重复执行的机制中。真正决定工程质量的,不是启用了多少规则,而是每条规则的职责、边界、豁免方式和迁移路径是否清晰。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。