给一个没有类型注解的旧项目接上 mypy,直接 --strict 会淹没在几百个错误里,直接默认配置又什么都查不到。这篇造了一个「旧项目 + 新代码」的小环境,把 mypy 的严格程度一档一档拧紧,看每一档到底多出了什么错误,以及一个实用的「旧宽新严」渐进方案。
legacy/orders.py(节选,没有任何注解) def order_total(order): total = 0 for item in order["items"]: total = total + item["price"] * item["qty"] return total + "1" # 旧代码里的 bug: int + str class Cache: def __init__(self): self.data = {} self.hits = 0
newcode/service.py(节选,有注解) def discounted(order: dict[str, object], rate: float) -> float: base: float = order_total(order) return base * rate def cache_hits(c: Cache) -> int: return c.hits + "1" # 新代码里自己写错的
mypy 版本: mypy 2.3.1 (compiled: yes)
Success: no issues found in 6 source files
默认配置下 mypy 不检查没有类型注解的函数的函数体(它们被当作「动态代码」)。所以 order_total 里的 int + "1" 完全不报;连新代码里的 c.hits + "1" 也没报——因为 Cache.__init__ 没有注解,里面的 self.hits = 0 没有被推断,c.hits 是 Any。
默认配置 0 个错误
--check-untyped-defs 2 个错误
--disallow-untyped-defs 10 个错误
--strict 14 个错误
--check-untyped-defs 下报告的 2 个错误(默认配置一个都看不到):
legacy/orders.py:11: error: Unsupported operand types for + ("int" and "str") [operator]
newcode/service.py:13: error: Unsupported operand types for + ("int" and "str") [operator]
Found 2 errors in 2 files (checked 6 source files)
--strict 下的 14 个错误按类型统计:
no-untyped-def 10
operator 2
no-untyped-call 2--check-untyped-defs:开始检查无注解函数的函数体,2 个错误立刻出现——旧代码里那个 int + str,以及新代码里的 c.hits + "1"(现在 hits 被推断成 int 了)。这一档最有价值:不需要改任何代码就能发现真正的 bug。--disallow-untyped-defs 变成 10 个:每个没写签名的函数都算错误,这是「要求补注解」的一档。--strict 是 14 个,里面 10 个 no-untyped-def、2 个 operator、2 个 no-untyped-call(有类型的代码调用了无类型的函数)。
probe.py:3: note: Revealed type is "def (x: Any, default: Any =) -> Any" probe.py:4: note: Revealed type is "Any" probe.py:5: note: Revealed type is "Any" probe.py:6: note: Revealed type is "Any"
没有注解的函数,参数和返回值都是 Any。safe_int("3")、merge(...)、Cache().get("k") 得到的全是 Any。所以新代码把 safe_int 的结果标成 str(其实是 int)——不报错,因为 mypy 没有信息可对比。Any 不是「未知类型」,而是「关闭检查」,每多一个无类型的函数,就多一个检查不到的洞。
mypy.ini [mypy] check_untyped_defs = true [mypy-legacy.*] ignore_errors = true [mypy-newcode.*] strict = true
mypy.ini: 全局 check_untyped_defs; legacy.* ignore_errors; newcode.* strict → 3 个错误
newcode/service.py:5: error: Call to untyped function "order_total" in typed context [no-untyped-call]
newcode/service.py:9: error: Call to untyped function "safe_int" in typed context [no-untyped-call]
newcode/service.py:13: error: Unsupported operand types for + ("int" and "str") [operator]
Found 3 errors in 1 file (checked 6 source files)整体打开 check_untyped_defs,旧模块 ignore_errors(暂时压住,不必一次修完),新模块 strict。3 个错误全在新代码里:一个是它自己的 int + str,另两个是「在有类型的上下文里调用了无类型的旧函数」——这是渐进式的关键信号:新代码想严格,就会推着你去给它依赖的旧函数补类型。补完一个,就把对应模块从 ignore_errors 名单里拿掉。
默认: Found 1 error in 1 file (checked 1 source file)
--warn-unused-ignores:
ign.py:2: error: Unused "type: ignore" comment [unused-ignore]
Found 1 error in 1 file (checked 1 source file)三行里有一行的 # type: ignore 是多余的(y: int = 1 本来就对)。默认配置下多余的 ignore 不会被发现,还悄悄留着;打开 --warn-unused-ignores 立刻报出来。渐进接入时会大量使用 ignore,这个开关能保证它们不会变成永远不清理的死注释;写 [assignment] 这样带错误码的形式,可以避免误吞别的错误。
60 个模块: 冷启动 0.55 s | 无改动再跑 0.10 s | 改了 1 个文件再跑 0.11 s | 缓存目录大小 2.6 MB
60 个小模块:冷启动 0.55 s,无改动再跑 0.10 s,只改一个文件再跑 0.11 s;缓存目录只有 2.6 MB。这个规模太小,不能推到大项目,但说明缓存命中后开销很低,把 mypy 放进 pre-commit 或 CI 不会拖垮反馈速度。
ignore_missing_imports)、--warn-return-any、mypy daemon(dmypy)、与 pyright 的差异