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;CPythonPython/ceval.c(LOAD_GLOBAL)、Objects/exceptions.c(NameError.name)