一个 NameError 掩盖了真根因:IPython 补全在 Django shell 里二次崩溃——从 jedi 版本错位查到 CPython LOAD_GLOBAL

TL;DR

NameError: name 'sys' is not defined 出现在 IPython 的自动补全里,往往不是「缺一句 import sys」那么简单------它通常是二次故障 :底层补全引擎(jedi)抛了 TypeError: __init__() got an unexpected keyword argument 'column',IPython 的异常兜底分支想打印这个异常,却因为自己所在模块顶部忘了 import sys,在调用 sys.exc_info() 时再次崩溃。真正根因被 NameError 完全掩盖。这类 bug 的典型特征是:出错代码只在「异常处理分支」里执行,正常路径永远不会走到,单测和 CI 全都不覆盖。

本文用 IPython issue #12745(+1=140,22 条评论)复盘完整链路,从 CPython 的 LOAD_GLOBAL 名称解析讲到「为什么只在错误路径执行的代码最危险」,最后给出 5 个生产级触发场景和一套从表象挖到真根因的排障方法。

报错原文

在 Docker 容器里的 Python 3.8 环境执行 python manage.py shell(Django 命令,内部启动 IPython),输入代码触发自动补全时,出现两段 traceback:

text 复制代码
Traceback (most recent call last):
  File ".../IPython/core/completer.py", line 1373, in _jedi_matches
    interpreter = jedi.Interpreter(
TypeError: __init__() got an unexpected keyword argument 'column'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File ".../IPython/terminal/ptutils.py", line 116, in get_completions
    exc_type, exc_value, exc_tb = sys.exc_info()
NameError: name 'sys' is not defined

注意关键句 During handling of the above exception, another exception occurred:------这是 Python 异常链的标志。第一段是真根因 (jedi 版本与 IPython 不匹配),第二段是处理第一段异常时产生的二次异常,它把现场完全盖住了。

GitHub 真实案例

  • Issue:ipython/ipython#12745「NameError: name 'sys' is not defined」
  • 创建:2020-12-28,+1 = 140(远超 50 门槛),22 条评论,最终 closed
  • 场景:用户用 elasticsearch 客户端连 Docker 里的 ES,在 Django shell(IPython)里输入 es.info() 触发补全,IPython 补全系统抛 jedi 相关 TypeError,随后兜底分支抛 NameError,Django shell 直接退出

这个 issue 的「反直觉点」有两个:第一,表面报错 name 'sys' is not defined 出现在 IPython 自带的 ptutils.py 里------用户没有写任何用到 sys 的代码 ;第二,ptutils.py 是 IPython 官方模块,而官方模块居然会漏 import sys?答案是:漏 import 的代码只在一段几乎不会执行的异常兜底分支里,正常补全永远走不到那里。

根因:IPython 补全的二次崩溃

第一层:jedi 版本错位

IPython 7.19 的自动补全默认调用 jedi 的 Interpreter 类做静态分析。调用代码在 IPython/core/completer.py_jedi_matches()

python 复制代码
# IPython 7.19 期望的调用方式
interpreter = jedi.Interpreter(
    code, namespaces=..., column=..., line=..., path=...
)

用户环境里的 jedi 是新版本,Interpreter.__init__() 的签名已经变了,不接受 column 关键字参数,于是第一段 traceback 产生:

text 复制代码
TypeError: __init__() got an unexpected keyword argument 'column'

这是依赖版本漂移的典型症状:IPython 按自己预期的新 API 调用 jedi,实际装的 jedi 是另一个版本。注意:这个异常发生在「补全」这种低频内省路径上------用户只是敲代码时按了 Tab,主程序逻辑完全没有问题。

第二层:ptutils.py 兜底分支缺 import sys

IPython 的补全入口 IPythonPTCompleter.get_completions() 用 try/except 包住补全调用,准备在补全出错时打印异常而不是让交互 shell 崩掉:

python 复制代码
try:
    yield from self._get_completions(...)
except Exception as e:
    try:
        exc_type, exc_value, exc_tb = sys.exc_info()   # ← 这里用 sys
        traceback.print_exception(exc_type, exc_value, exc_tb)
    except AttributeError:
        print('Unrecoverable Error in completions')

问题在文件顶部。7.19.0 版本IPython/terminal/ptutils.py 头部 import 只有这些:

python 复制代码
# IPython 7.19.0(漏洞版)------没有 import sys!
import unicodedata
from wcwidth import wcwidth
from IPython.core.completer import (...)
from prompt_toolkit.completion import ...
import pygments.lexers as pygments_lexers
import os

sys 只在 116 行的 except 兜底分支里被引用,正常路径从不执行,所以作者没有意识到缺 import 。一旦补全真抛异常(如第一层的 jedi TypeError),控制流进入这段兜底代码,执行 sys.exc_info() 时,Python 在全局命名空间找不到 sys,于是抛出了第二个异常------NameError: name 'sys' is not defined

官方修复:7.20.0 在文件头补 import

IPython 7.20.0 的修复就是在同一个文件顶部补上缺失的 import(逐行可对照):

python 复制代码
# IPython 7.20.0(修复版)
import unicodedata
from wcwidth import wcwidth
import sys               # ← 新增:兜底分支要用
import traceback         # ← 新增:兜底分支要用
from IPython.core.completer import (...)

修复后,jedi 版本错位时兜底分支能正常打印第一段 TypeError,把真根因暴露出来。一行 import,排障体验天差地别。

CPython 层:sys.exc_info() 为什么找不到 sys

LOAD_GLOBAL 的名称解析顺序

sys.exc_info() 在字节码层面是两条指令:LOAD_GLOBAL sys 然后 LOAD_METHOD/LOAD_ATTR exc_info。CPython 3.11 前实现在 Python/ceval.c,3.12+ 拆到 Python/bytecodes.c(语义一致)。

LOAD_GLOBAL 的解析顺序是:局部命名空间 → 全局命名空间(模块的 __dict__)→ 内建命名空间(builtins)ptutils.py 的代码不在函数局部作用域内使用 sys(它是在 get_completions 方法的 except 分支里),所以查的是全局命名空间 ------也就是 ptutils 这个模块的 __dict__

模块 __dict__ 里的名字来自两条途径:文件顶部的 import 语句,以及模块级赋值。7.19.0 的 ptutils.py 既没 import sys 也没给 sys 赋值,所以模块 __dict__ 里根本没有 sys 这个键。

NameError 如何在 C 层被构造

三条路径都查不到 sys 后,解释器调用 _PyEval_FormatExcCheckArg(tstate, PyExc_NameError, NAME_ERROR_MSG, name) 生成异常,NAME_ERROR_MSG 就是 "name '%.200s' is not defined"。构造 NameError 时,CPython 会把缺失的名字写进异常实例的 .name 属性(Objects/exceptions.c),这样上层才能渲染出 Did you mean: ...? 这类建议。

所以 NameError: name 'sys' is not defined 的准确含义是:在 ptutils 模块的全局命名空间里找不到名为 sys 的绑定------不是环境坏了,不是 sys 模块没装(sys 是解释器自带模块,永远装得上),而是这个模块忘记把它 import 进自己的命名空间。

为什么「except 分支里的代码」最危险

这类 bug 有一个共同特征:出错代码只在异常处理分支里执行

特征 说明
正常路径不执行 单测、CI、联调全走正常路径,覆盖率永远是 0
只在「别人出错」时触发 触发条件 = 先有另一个 bug(如版本错位),属于二次故障
出错时机最差 系统已经在异常处理中,任何二次崩溃都会掩盖真根因
修复简单但发现极难 往往一行 import 就能修,但要先意识到「去看那个没人执行的 except 分支」

五种生产级触发场景

场景 1:依赖版本漂移 + 低频内省路径(本案例)

框架 A 按新版依赖 B 的 API 传参,实际装的 B 是旧版/新版,在自动补全、内省、代码检查等低频路径抛错;A 的 except 兜底代码又缺 import,用户只见 NameError 不见根因。

python 复制代码
# ❌ 错误示范:jedi 类补全直接按假设的新签名调用
import jedi
interpreter = jedi.Interpreter(code, column=0, line=1)  # jedi 旧版无 column 参数

# ✅ 正确做法:先探测版本/签名,或对补全调用做异常隔离并打印原始异常
import inspect
try:
    sig = inspect.signature(jedi.Interpreter.__init__)
    kwargs = {"column": 0} if "column" in sig.parameters else {}
    interpreter = jedi.Interpreter(code, **kwargs)
except Exception as e:
    import traceback
    traceback.print_exc()  # 兜底分支必须能打印真异常

中级视角:版本漂移不可怕,可怕的是「处理版本漂移的代码本身有 bug」。给低频内省路径做异常兜底时,兜底代码要当作生产代码一样审查------它往往只在最需要可靠的时候运行。

场景 2:只在异常处理分支引用从未导入的名字(ptutils 模式)

python 复制代码
# ❌ 错误示范:except 分支用了 sys/traceback,但模块顶部没 import
import logging

def parse_config(path: str) -> dict:
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)  # 还忘了 import json!
    except Exception:
        logger.exception("load failed")  # logging 倒是导了
        exc_type, exc_value, exc_tb = sys.exc_info()  # NameError: sys
        return {}

# ✅ 正确做法:文件顶部把所有用到的模块都 import 齐
import json
import sys
import logging

logger = logging.getLogger(__name__)

中级视角:写 try/except 时把 except 分支当作独立函数审查:里面用到的每个名字,都要能在模块/函数作用域里解析到。lint 工具(pylint 的 undefined-variable、pyflakes)能抓这种问题------把它加进 CI。

场景 3:exec/eval/importlib 动态执行受限命名空间

python 复制代码
# ❌ 错误示范:动态执行只注入部分名字,代码里却用了没注入的
code = "result = sys.maxsize"
exec(code, {"result": None})          # NameError: name 'sys' is not defined
# exec 第二个参数是全局命名空间,没传 builtins 也不会自动带 sys

# ✅ 正确做法:显式注入依赖,或使用 __builtins__ 完整的受限环境
code = "result = __import__('sys').maxsize"
namespace = {"result": None}
exec(code, namespace)
print(namespace["result"])

中级视角exec(code, globals) 的 globals 是独立命名空间,只含你给的东西加内建。配置热加载、规则引擎、模板沙箱天天踩这个坑。排障时先问:这段代码在哪个命名空间里执行?那个命名空间里有没有它引用的名字?

场景 4:条件性导入/延迟初始化

python 复制代码
# ❌ 错误示范:某分支才 import,其它分支用的时候没走过该分支
def handler(kind: str):
    if kind == "db":
        import sqlite3  # 条件性导入
    ...
    conn = sqlite3.connect("app.db")  # kind != "db" 时 NameError!

# ✅ 正确做法:模块顶部统一导入,或函数内无条件导入
import sqlite3  # 顶部导入,任何分支都能用

def handler(kind: str):
    ...

中级视角:条件性导入省的那点启动时间,远小于排障成本。多线程下初始化顺序不同更容易踩中------线程 A 还没走导入分支,线程 B 已经在用这个名字了。

场景 5:内嵌 shell/调试钩子升级后崩溃

Django shell、IPython、notebook 补全、监控 breadcrumb 这类组件升级后,在精简环境(容器、无网络、最小镜像)里崩溃,NameError 掩盖业务代码真实异常,误导排障方向。建议 :生产环境固定 IPython/jedi 版本组合,别让 pip 自由浮动;遇到 shell 层崩溃先 pip freeze | grep -E "ipython|jedi|prompt" 看版本。

排障流程:从表象 NameError 挖到真根因

步骤 命令/动作 看什么
1. 保存完整 traceback 别只看最后一行 During handling of the above exception------前面那一段才是真根因
2. 定位抛错文件 traceback 里的 File ".../xxx.py", line N 是否在第三方库内部?是否在 except 分支?
3. 检查该文件头部 import head -30 <文件> 用到的名字是否都在顶部 import?
4. 检查依赖版本 `pip freeze grep -E "ipython
5. 临时绕过补全 IPython 里 %config Completer.use_jedi = False 确认问题是否在 jedi 补全路径
6. 用 faulthandler/调试器 python -X dev manage.py shell 开发模式会显示更多 warning 和更完整异常链

核心心法:NameError 说「找不到名字」时,先问三个问题------① 这段代码在哪个命名空间执行?② 那个命名空间里应该有这个名字吗?③ 如果应该有,是哪个 import/赋值把它漏了?而不是急着「import sys 一下试试」。

实战:让二次故障「现形」

二次故障最坑的地方是:最终抛出的异常(NameError)和真根因(jedi TypeError)不在同一条异常链上------NameError 是在处理 TypeError 时新抛的,TypeError 已经被 Python 的异常上下文机制记在 __context__ 里,但普通打印只显示「During handling of the above exception」两段 traceback,很多人只读最后一段就跑去搜 NameError 的解法。

要系统性地让真根因现形,有三个层次的手段:

手段 1:永远保存完整 traceback,不要只看尾部。 日志采集(logging、sentry、自研上报)时把 traceback.format_exc() 的完整字符串落盘,而不是只记 str(e)。二次故障的两段 traceback 中,第一段的最后一行才是根因。

手段 2:显式打印异常链。

python 复制代码
import sys
import traceback

def safe_complete(code: str):
    """补全函数:异常时同时打印当前异常和它的 __context__/__cause__。"""
    try:
        return _do_complete(code)          # 内部可能抛 TypeError
    except Exception as e:
        # 打印完整异常链:当前异常 + 被吞掉的上下文异常
        traceback.print_exception(
            type(e), e, e.__traceback__
        )
        if e.__context__ is not None:
            print("--- 被吞掉的上下文异常(真根因常在这里)---")
            traceback.print_exception(
                type(e.__context__),
                e.__context__,
                e.__context__.__traceback__,
            )
        if e.__cause__ is not None:
            print("--- 显式 raise ... from 的原始异常 ---")
            traceback.print_exception(
                type(e.__cause__),
                e.__cause__,
                e.__cause__.__traceback__,
            )

IPython #12745 里,第二段 NameError 的 __context__ 正是第一段的 jedi TypeError。只要能打印出 e.__context__,根因立刻暴露。

手段 3:全局钩子兜底,防止二次崩溃掩盖一切。

python 复制代码
import sys

def _excepthook(exc_type, exc_value, exc_tb):
    """进程级兜底:任何未捕获异常都尝试打印完整链,即使打印本身出错也要留下痕迹。"""
    try:
        import traceback
        traceback.print_exception(exc_type, exc_value, exc_tb)
        # 打印被吞掉的上下文(如果有)
        ctx = getattr(exc_value, "__context__", None)
        if ctx is not None:
            sys.stderr.write("\n=== context (真根因可能在这里) ===\n")
            traceback.print_exception(type(ctx), ctx, ctx.__traceback__)
    except Exception:
        # 兜底的兜底:写一个最简单的标记,至少让人知道这里崩过
        sys.stderr.write("excepthook failed; original exc_type=%r\n" % (exc_type,))

sys.excepthook = _excepthook

把这段放到 Django/Flask/CLI 入口的最早处(manage.py 顶部、app 启动前),生产环境任何未捕获异常都会打印完整异常链。这正是 IPython 7.20.0 修复的本质------让兜底分支有能力打印真异常,而不是在打印时二次崩溃。

层次 手段 解决什么
1 完整 traceback 落盘 事后能回溯两段异常
2 打印 __context__/__cause__ 让被吞掉的根因现形
3 全局 sys.excepthook 进程级防止二次崩溃掩盖真相

预防:让 lint 在代码评审前就抓住「except 分支缺 import」

二次故障的根因是「只在错误路径执行的代码引用了未导入的名字」,这类问题几乎都能被静态检查提前抓住------关键是检查范围必须覆盖异常处理分支 ,而很多团队只跑 python -m py_compile(只查语法,不查名字)或只测正常路径。

推荐的预防组合:

bash 复制代码
# 1. pyflakes:能查出「引用了但未导入/未定义」的名字(含 except 分支内)
python3 -m pyflakes path/to/module.py
# 输出形如:ptutils.py:116: undefined name 'sys'

# 2. pylint 的 undefined-variable 检查(E0602),CI 里设成 error
python3 -m pylint --disable=all --enable=undefined-variable path/to/module.py

# 3. 自定义 AST 扫描:找出 except 分支里引用的全局名是否在顶部 import 过
python3 - <<'PY'
import ast, sys

for path in sys.argv[1:]:
    tree = ast.parse(open(path, encoding="utf-8").read())
    imported = set()
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            imported.update(a.asname or a.name.split(".")[0] for a in node.names)
        elif isinstance(node, ast.ImportFrom):
            imported.update(a.asname or a.name for a in node.names)
    # 简化版:全文件所有 Name 都应在 imported 或内建中,except 分支自然覆盖
    for node in ast.walk(tree):
        if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
            if node.id not in imported and node.id not in dir(__builtins__):
                print(f"{path}:{node.lineno}: possible undefined name {node.id!r}")
PY

第三段脚本是简化示意(未处理函数级局部变量、作用域遮蔽),真实项目直接上 pyflakes/pylint 即可。关键结论:把静态检查加进 pre-commit 或 CI,这类只在异常分支爆发的 NameError 会在合并前就被拦截,而不是等用户按 Tab 崩掉整个 shell 才发现。

总结:三层理解

  • 初级 :报错 name 'sys' is not defined = 当前命名空间没有 sys 这个绑定;在文件顶部 import sys 往往能解决表象。
  • 中级 :这是二次故障------真根因是第一段 TypeError: ... 'column'(jedi 版本错位),NameError 只是 IPython 处理该异常时自己崩溃。排障要看完整 traceback,尤其 During handling of the above exception 之前的部分。
  • 记忆锚点只在 except 分支执行的代码,是最容易被遗忘、最危险、最晚被发现的代码。 一行 import 的修复背后,是「异常处理路径也要按生产代码标准审查」的教训。

同类家族:名称解析相关报错速查

报错 触发点 典型根因
NameError: name 'xxx' is not defined 全局/局部找不到绑定 漏 import、拼写错、条件性导入、受限命名空间
UnboundLocalError: local variable 'xxx' referenced before assignment 函数内先引用后赋值 同名局部变量遮蔽全局、赋值顺序错误(NameError 的近亲,同源 C 层:LOAD_FAST/STORE_FAST)
AttributeError: module 'xxx' has no attribute 'yyy' 模块属性不存在 循环 import 时模块半初始化、版本 API 变更
ImportError: cannot import name 'xxx' import 语句解析失败 循环依赖、符号不存在、all 限制

原始出处ipython/ipython#12745(+1=140);源码对比:IPython 7.19.0 / 7.20.0 IPython/terminal/ptutils.py;CPython Python/ceval.c(LOAD_GLOBAL)、Objects/exceptions.cNameError.name

相关推荐
starrysky8101 小时前
SQL 注入 + msgpack 反序列化 = RCE:LangGraph Checkpointer 三洞连爆——Agent 的记忆层就是攻击面
angular.js
starrysky8101 小时前
NVD 9.8、官方 7.5:Redis TLS use-after-free 的评分之争——tlsProcessPendingData 悬指针踩空全解析
angular.js
巴勒个啦6 天前
2026 年 CSS 选型真相:我用 Tailwind v4 + 原生新特性重构了一个组件库
前端·angular.js
starrysky8108 天前
服务全绿、队列却 15 天没人消费?redis-py 8.0 默认切 RESP3 的阻塞命令坑
angular.js
starrysky8108 天前
写个管道就被 BrokenPipeError 打爆?先看 CPython 把 SIGPIPE 藏哪了
angular.js
starrysky81016 天前
画像空不是故障:Honcho peer card 观察者视角源码拆解——1956 条结论为何换不来一张卡
angular.js
starrysky81023 天前
Hindsight 记忆数据增删改实操:70 个 API 端点的 CRUD 地图
angular.js
starrysky81023 天前
从 472ms 到 150ms:Hindsight 的 SQL 模板为何能超越 Text2SQL
angular.js
starrysky8101 个月前
sqlite3.OperationalError: database is locked 为什么 timeout=10 秒没生效?SQLite 锁升级死锁路径完整排障
angular.js