Ruff FURB189(subclass-builtin)详解:检测并自动改写 dict / list / str 子类化,并豁免 Stub 文件
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
Ruff 的refurb规则组中包含一条FURB189(subclass-builtin)规则,用于检测对dict、list、str三个内置类型的直接子类化,并建议改用collections模块中的UserDict、UserList、UserString。该规则在 preview 模式下可用,且明确豁免 stub 文件(.pyi),以保证类型存根能忠实表达运行时实现。本文以仓库中的 mdtest 文档 subclass-builtin.md 为核心,结合 规则实现源码 与 测试夹具,完整讲解该规则的启用方式、判定逻辑、自动修复行为,以及为什么 stub 文件不受检查。
规则背景:为什么子类化内置类型是危险的
内置类型并不一致地调用自身的 dunder 方法。以dict为例,dict.__init__和dict.update()会绕过__setitem__,导致继承行为不可靠。规则文档中给出的典型例子是:
class UppercaseDict(dict): def __setitem__(self, key, value): super().__setitem__(key.upper(), value) d = UppercaseDict({"a": 1, "b": 2}) # Bypasses __setitem__ print(d) # {'a': 1, 'b': 2}构造UppercaseDict({"a": 1, "b": 2})时,__setitem__根本没有被触发,键值并未被大写。改用UserDict后则符合预期:
from collections import UserDict class UppercaseDict(UserDict): def __setitem__(self, key, value): super().__setitem__(key.upper(), value) d = UppercaseDict({"a": 1, "b": 2}) # Uses __setitem__ print(d) # {'A': 1, 'B': 2}这正是FURB189的诊断动机。诊断消息与修复标题在源码 subclass_builtin.rs 中定义:
- 诊断消息:
Subclassing{subclass}can be error prone, usecollections.{replacement}instead - 修复标题:
Replace withcollections.{replacement}``
启用规则:需要 preview 模式
根据 mdtest 文档 给出的配置,该规则目前处于 preview 阶段,启用方式为:
[lint] preview = true select = ["FURB189"]这一点与源码中的元信息一致:subclass_builtin.rs 通过宏标注了规则的预览起始版本与类别:
#[violation_metadata(preview_since = "0.7.3", category = Category::Suspicious)]即FURB189自 0.7.3 版本进入 preview,分类为Suspicious(可疑用法)。规则代码与检查函数的绑定位于 codes.rs:
(Refurb, "189") => rules::refurb::rules::SubclassBuiltin,检查入口挂在 AST 分析器处理类定义(Stmt::ClassDef)的位置,见 statement.rs。
判定逻辑:单基类、下标表达式与内置符号解析
从 检查函数源码 看,判定流程为:
- stub 文件直接返回(详见后文专节);
- 取出类定义的基类参数
Arguments,要求恰好只有一个基类(let [base] = &**bases),否则返回; - 通过
map_subscript剥离基类上的下标表达式,只检查名称部分。例如class SubscriptDict(dict[str, str])会识别出基类是dict,且修复时只替换dict这一段,保留下标结构; - 用
checker.semantic().resolve_builtin_symbol确认该名称确实解析到内置符号,避免误伤同名的局部类; - 名称命中
str/dict/list三者之一时才报告诊断。
SupportedBuiltins枚举与替换目标的映射关系为:
| 被检测的内置类型 | 建议替换为 |
|---|---|
dict | collections.UserDict |
list | collections.UserList |
str | collections.UserString |
对照 测试夹具 FURB189.py 可以验证这些边界行为:
# positives —— 全部命中 FURB189 class D(dict): pass class L(list): pass class S(str): pass class SubscriptDict(dict[str, str]): pass # 命中,下标部分被保留 class SubscriptList(list[str]): pass # 命中 # currently not detected —— 当前不检测 class SetOnceDict(SetOnceMappingMixin, dict): pass # 多基类,直接跳过 # negatives —— 不命中 class C: pass class I(int): pass # int 不在检测范围 class ActivityState(str, Enum, metaclass=CaseInsensitiveEnumMeta): ... # 多基类,跳过值得注意的是,夹具中明确标注了# currently not detected:由于源码要求“恰好一个基类”,class SetOnceDict(SetOnceMappingMixin, dict)这种混合了 Mixin 的写法当前不会被报告,这是该规则的一个已知检测边界。命中与未命中的诊断输出保存在快照 ruff_linter__rules__refurb__tests__subclass-builtin_FURB189.py.snap 中,其中每条诊断都附带note: This is an unsafe fix and may change runtime behavior的提示。
自动修复:不安全的替换 + 自动导入
SubclassBuiltin实现了AlwaysFixableViolation,即始终附带修复方案。修复由两部分组成(见 fix 构造代码):
- 通过
checker.importer().get_or_import_symbol在文件中插入或复用from collections import UserDict / UserList / UserString导入; - 用
Edit::range_replacement将基类名称部分(下标表达式内部)替换为导入绑定名。
例如class SubscriptDict(dict[str, str])的修复结果会变为:
from collections import UserDict class SubscriptDict(UserDict[str, str]): ...该修复被标记为unsafe,原因是isinstance(x, dict)、isinstance(x, list)、isinstance(x, str)这类检查在使用对应User*类后会失效(UserDict并非dict的子类)。源码 docstring 中给出的建议是:如果你无法控制下游代码,可忽略该检查;如果可以控制,应把类型检查改为抽象基类,例如dict->collections.abc.MutableMapping、list->collections.abc.MutableSequence;对str则不存在等价转换。这也解释了为什么快照输出中每条修复都强调运行时行为可能改变。
核心豁免:Stub 文件(.pyi)中允许子类化内置类型
这是 mdtest 文档 的主体内容:在 stub 文件中子类化内置类型必须被允许,因为 stub 的职责是忠实表达运行时实现(包括第三方库或 CPython 自身的实现细节),而这些实现往往不在编写 stub 的人控制范围内。
mdtest 中给出的验证样例为一段.pyi代码,期望不产生任何诊断:
class D(dict): ... class L(list): ... class S(str): ... class SubscriptDict(dict[str, str]): ... class SubscriptList(list[str]): ...对应实现非常直接:检查函数在进入基类分析之前,首先判断源文件类型,stub 直接放行(subclass_builtin.rs):
/// FURB189 pub(crate) fn subclass_builtin(checker: &Checker, class: &StmtClassDef) { if checker.source_type.is_stub() { return; } // ...后续基类判定... }规则 docstring 中也把这一行为写进了文档说明(第 19-20 行):
This rule does not apply to stub files, which should faithfully represent the runtime implementation and may be out of the author's control.
也就是说,同一个class D(dict)在普通.py文件中会触发FURB189诊断,而在.pyi文件中则完全静默。这种“同一代码、不同文件类型、不同 lint 结果”的行为,正是 Ruff mdtest 机制存在的意义之一:mdtest 目录 下的 Markdown 文件内嵌toml配置块和代码块,作为文档与测试一体的回归用例,确保“stub 文件豁免”这一行为不会在未来的重构中被破坏。
如何在仓库中验证该规则的行为
结合本仓库的组织方式,可以从三个层面复现和核对FURB189的行为:
- 单元测试快照:规则测试在 refurb/mod.rs 中注册(
Rule::SubclassBuiltin对应夹具FURB189.py),运行cargo test -p ruff_linter refurb可重新生成/比对snapshots/下的诊断快照,快照文件即为上文引用的.snap路径; - mdtest 文档:subclass-builtin.md 以
preview = true加select = ["FURB189"]的配置声明了测试环境,其 pyi 代码块即“无诊断”断言本身; - 手动运行:在任意 Python 项目中配置相同的
[lint]块后执行ruff check <file>(或ruff check --isolated --preview --select FURB189 <file>临时启用),观察诊断输出与ruff check --fix的自动改写结果,即可得到与快照一致的效果。
小结
FURB189是 Ruff 在 preview 阶段提供的、针对dict/list/str直接子类化的可疑用法检测:它利用内置类型绕过 dunder 方法的特性问题,引导开发者改用UserDict/UserList/UserString,并提供“自动导入 + 基类替换”的不安全修复。其两条关键边界都可在源码中直接验证:一是仅处理单基类定义(多基类如 Mixin 组合当前不检测),二是stub 文件整体豁免(checker.source_type.is_stub()提前返回),后者由专门的 mdtest 文档作为回归用例固化。理解这两条边界,能帮助你正确评估该规则在真实代码库中的适用范围与修复风险。
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考