Python KeyError 根本原因与四大防御策略

📅 2026/6/16 13:26:54
Python KeyError 根本原因与四大防御策略
1. 为什么 KeyError 是每个 Python 开发者绕不开的“第一道坎”刚入行那会儿我带过几个实习生几乎所有人——无论之前写过 Java 还是 JavaScript——第一次独立调试一个数据处理脚本时都会在控制台里看到那行刺眼的KeyError: xxx。有人当场截图发到群里问“这字典明明 print 出来了key 怎么就找不着”也有人直接加了十几个if key in dict.keys():嵌套判断代码瞬间膨胀三倍还漏掉了一个嵌套三层的子字典里的 key。我当时没急着讲原理而是翻出自己三年前写的生产环境日志整整 47 条告警全指向同一个KeyError: user_profile而那个 key 其实只在 3% 的用户数据里存在。这件事让我彻底明白KeyError 不是语法错误它是数据世界的“幽灵”——它不阻止你写代码却专挑你最信任的数据结构下手在运行时突然掀桌子。它高频、隐蔽、后果严重但偏偏又极其容易被“临时补丁”掩盖。这篇文章要做的不是罗列try/except的几种写法而是带你拆解它的发生逻辑、识别它的伪装形态、建立一套可落地的防御体系。你会看到为什么.get()在某些场景下反而比try/except更危险为什么用in判断有时会慢得离谱为什么defaultdict在并发环境下可能埋下深坑甚至如何用一行代码让 KeyError 自己“开口说话”告诉你缺失的到底是哪个字段、来自哪条原始数据、触发了哪个业务规则。它适合所有正在和 JSON API、配置文件、数据库映射、用户输入打交道的 Python 开发者尤其是那些已经能写出功能代码却总在上线后被奇怪的 KeyError 搞得半夜爬起来改 bug 的人。2. KeyError 的本质不是“键不存在”而是“访问契约被打破”2.1 从内存模型看 KeyError 的真实身份很多教程说“KeyError 就是字典里没有这个 key”这就像说“车祸就是车撞了”。它描述了现象但没解释根源。要真正驯服它得回到 Python 的对象模型底层。字典dict在 CPython 中是一个哈希表hash table它的核心操作__getitem__并非简单地“查表”而是一套严格的契约执行流程哈希计算对传入的 key 调用hash(key)得到一个整数哈希值。桶定位用哈希值对内部数组长度取模定位到一个“桶”bucket。键比对在该桶中遍历所有已存储的键值对用运算符逐个比对key是否与存储的键相等。契约验证如果遍历完该桶没找到任何key stored_key为True的项则认为“键不存在”此时__getitem__方法主动抛出KeyError。关键点在于第 4 步——KeyError是dict.__getitem__方法有意识、有目的抛出的异常它不是一个被动的“找不到”信号而是一个契约违约通知。这个契约就是“当你调用d[key]时你向 Python 承诺这个 key 必定存在于字典 d 中。” 如果承诺落空Python 就用KeyError来提醒你“嘿你签的合同里写了这个 key 必须有现在它没了责任在你。”提示理解这一点至关重要。它解释了为什么d.get(key)不会抛出KeyError——因为get()方法的契约是“我尽力找找到了给你值找不到给你 None 或默认值”它从不承诺 key 一定存在。而d[key]的契约是“我保证给你值所以你必须保证 key 存在”。2.2 常见的“伪 KeyError”陷阱你以为是字典其实不是真正的痛点往往藏在表象之下。我见过太多人对着KeyError: status抓耳挠腮最后发现根本不是字典的问题。以下是三个高频伪装形态它们会让标准的“检查 key 是否存在”方案完全失效陷阱一JSON 解析后的None值import json # 假设这是从 API 获取的原始响应 raw_response {data: null, code: 200} data json.loads(raw_response) # data {data: None, code: 200} # 错误示范以为 data[data] 是个字典直接访问其子键 # result data[data][status] # TypeError: NoneType object is not subscriptable # 注意这里报的是 TypeError不是 KeyError但新手常误以为是 KeyError # 正确做法先确认 data[data] 是 dict 类型 if isinstance(data.get(data), dict): status data[data].get(status, unknown) else: status data_missing这个例子揭示了一个残酷事实KeyError的兄弟TypeError经常和它结伴出现。当你的“字典”其实是None、list、str或其他类型时.get()会返回None但后续的[status]操作会立刻触发TypeError。这比单纯的KeyError更难排查因为它发生在链式访问的第二步。陷阱二嵌套字典中的“中间层断裂”# 一个典型的用户数据结构 user_data { profile: { name: Alice, settings: { theme: dark } } } # 错误示范试图一步到位获取 theme # theme user_data[profile][settings][theme] # 如果 profile 缺失这里就 KeyError # 正确的防御性写法推荐 def safe_get_nested(d, *keys, defaultNone): 安全获取嵌套字典的值 for key in keys: if isinstance(d, dict) and key in d: d d[key] else: return default return d theme safe_get_nested(user_data, profile, settings, theme, defaultlight)这里的问题在于KeyError可能发生在profile、settings或theme任意一层。用if profile in user_data只能防住第一层对第二层settings完全无效。你需要的是一个能穿透多层的“探针”而不是一层一层的if。陷阱三字符串 key 的隐形差异# 数据源可能来自 CSV、YAML 或用户输入key 的格式千奇百怪 config { database_url: mysql://..., DB_URL: postgres://..., # 注意大小写 database-url: sqlite://... # 注意连字符 } # 错误示范硬编码 key忽略数据源的实际格式 # url config[database_url] # 可能 KeyError如果实际 key 是 DB_URL # 正确做法标准化 key 访问 def normalize_key(key: str) - str: 将 key 标准化为小写下划线格式 return key.lower().replace(-, _).replace( , _) # 构建一个标准化的映射 normalized_config {normalize_key(k): v for k, v in config.items()} url normalized_config.get(database_url, default://) # 稳了这个陷阱非常隐蔽。它源于数据来源的多样性。API 返回的 key 可能是camelCase配置文件是snake_case数据库字段是UPPER_SNAKE而你的代码却固执地只认一种。KeyError在这里成了数据治理混乱的晴雨表。3. 四种核心防御策略从“亡羊补牢”到“未雨绸缪”3.1 策略一LBYLLook Before You Leap—— “先看后跳”的谨慎派LBYL 的核心思想是“事前检查”在执行高风险操作前先确认所有前提条件都满足。它直观、易懂是新手最自然的选择。基础用法in操作符user {name: Bob, age: 30} if email in user: send_welcome_email(user[email]) else: log_missing_email(user[name])in操作符是检查 key 是否存在的最快方式时间复杂度为 O(1)因为它直接利用了字典的哈希表特性无需遍历。进阶用法批量检查与默认值填充# 一个更复杂的场景处理用户注册表单 required_fields [username, email, password] optional_fields [first_name, last_name, avatar_url] form_data {username: charlie, email: cexample.com} # 1. 检查所有必需字段是否齐全 missing_required [field for field in required_fields if field not in form_data] if missing_required: raise ValueError(fMissing required fields: {missing_required}) # 2. 为可选字段提供默认值避免后续 KeyError user_profile {} for field in optional_fields: user_profile[field] form_data.get(field, ) # get() 在这里很安全 # 3. 合并必需字段此时已确认存在 for field in required_fields: user_profile[field] form_data[field] # 这里不会 KeyError这个例子展示了 LBYL 的威力它不仅能防止错误还能进行结构化校验和数据预处理。通过一次性检查所有required_fields你可以获得一个清晰的错误报告missing_required列表而不是在user_profile[username]处失败后再跑到user_profile[email]处失败。实操心得LBYL 在数据校验、配置初始化、API 响应解析等场景中是黄金法则。但请记住它的代价每次in检查都是一次哈希计算和一次桶查找。如果你在一个循环里反复检查同一个 key或者在性能敏感的代码路径如高频交易、实时渲染中使用它可能成为瓶颈。这时EAFP 会是更好的选择。3.2 策略二EAFPEasier to Ask for Forgiveness than Permission—— “先干再说”的务实派EAFP 是 Python 社区推崇的“Pythonic”风格。它假设一切正常大胆执行然后用try/except捕获并优雅地处理异常。它更简洁且在“成功路径”上通常比 LBYL 更快。基础用法单层捕获user {name: David} try: email user[email] send_notification(email) except KeyError as e: # e.args 是一个元组e.args[0] 就是缺失的 key 名 logger.warning(fUser {user.get(name, unknown)} has no email. Key: {e.args[0]}) email no-emailplaceholder.com进阶用法多层嵌套与异常链def process_user_data(raw_json: str) - dict: try: data json.loads(raw_json) # 尝试获取嵌套的 user.profile.settings.theme theme data[user][profile][settings][theme] return {theme: theme, status: success} except json.JSONDecodeError as e: # 捕获 JSON 解析错误 raise ValueError(fInvalid JSON: {e}) from e except KeyError as e: # 捕获任何一层的 KeyError # 关键技巧构建详细的上下文信息 missing_key e.args[0] context fMissing key {missing_key} in path: user-profile-settings-theme raise ValueError(context) from e except TypeError as e: # 捕获类型错误比如某个中间层是 None raise ValueError(fType error in nested access: {e}) from e # 使用 try: result process_user_data({user: {profile: null}}) except ValueError as e: # e.__cause__ 就是原始的 KeyError可以追溯完整链路 print(fRoot cause: {e.__cause__})这个例子展示了 EAFP 的精髓异常不是失败而是信息丰富的事件。通过raise ... from e你构建了一条清晰的异常链让调试者一眼就能看到是 JSON 解析错了还是 key 缺失了还是类型不对每一层都有明确的归属。实操心得EAFP 在主业务逻辑、外部服务调用、不确定数据源处理中是首选。它的优势在于“一次尝试多重保护”——一个try块可以同时捕获KeyError、TypeError、ValueError等多种异常让你的错误处理逻辑高度集中。但切记不要用except:捕获所有异常。这会吞掉KeyboardInterruptCtrlC和SystemExit导致程序无法被正常终止。3.3 策略三.get()方法—— “宽容的旁观者”.get()是最温和的防御手段它不抛异常也不做检查只是“尽力而为”。基础用法提供默认值user {name: Eve} # 如果 email 不存在返回 N/A email user.get(email, N/A) # 如果 email 不存在返回 None这是 get 的默认行为 email user.get(email)进阶用法默认值的“惰性求值”from datetime import datetime def expensive_default(): 一个耗时的默认值生成函数 print(Generating default timestamp...) return datetime.now().isoformat() user {name: Frank} # 错误示范默认值函数会被无条件执行 # timestamp user.get(created_at, expensive_default()) # 即使 key 存在也会执行 # 正确示范用 lambda 包裹实现惰性求值 timestamp user.get(created_at, lambda: expensive_default()) # 但注意lambda 返回的是函数对象不是值需要手动调用 if callable(timestamp): timestamp timestamp()这个技巧非常重要。.get()的第二个参数是立即求值的。如果你传入一个函数调用expensive_default()它会在get()执行时就被调用无论 key 是否存在。为了实现“只在 key 不存在时才计算”你需要用lambda创建一个闭包然后在确认需要时再调用它。终极用法.get()的链式安全访问# 结合前面的 safe_get_nested我们可以用 get 实现更简洁的版本 def safe_get(d, *keys, defaultNone): for key in keys: if not isinstance(d, dict): return default d d.get(key, default) if d is default: return default return d # 使用 user {profile: {settings: {theme: blue}}} theme safe_get(user, profile, settings, theme, defaultlight) # blue missing safe_get(user, profile, prefs, language, defaulten) # en这个safe_get函数将.get()的宽容性与嵌套访问的需求完美结合代码简洁语义清晰。实操心得.get()是读取配置、处理可选参数、构建默认状态的利器。但它有一个致命弱点它无法区分“key 不存在”和“key 存在但值为 None”。如果你的业务逻辑中“key 存在且值为 None” 和 “key 不存在” 代表两种完全不同的含义那么.get()就会失效你必须回归到in检查或try/except。3.4 策略四defaultdict与setdefault()—— “自动化的管家”当你的代码需要频繁地为“不存在的 key”创建默认值时defaultdict就是那个不知疲倦的管家。defaultdict的核心机制from collections import defaultdict # 创建一个 defaultdict当访问不存在的 key 时会自动调用 int() 创建 0 counter defaultdict(int) counter[apple] 1 # 相当于 counter[apple] counter.get(apple, 0) 1 counter[banana] 1 print(counter) # defaultdict(class int, {apple: 1, banana: 1}) # 创建一个 defaultdict用 list 作为工厂函数 grouped_data defaultdict(list) grouped_data[fruits].append(apple) grouped_data[fruits].append(banana) grouped_data[vegetables].append(carrot) print(grouped_data) # defaultdict(class list, {fruits: [apple, banana], vegetables: [carrot]})defaultdict的秘密在于它的default_factory参数。它不是一个静态的默认值而是一个可调用对象callable。每次遇到缺失的 keydefaultdict就会调用这个 callable 来生成一个全新的、独立的默认值。这使得它在处理分组、计数、累积等场景时代码量锐减。setdefault()的精准控制user {name: Grace} # setdefault(key, default) 的行为 # 如果 key 存在返回 user[key] 的值 # 如果 key 不存在将 user[key] 设为 default并返回 default email user.setdefault(email, graceplaceholder.com) print(user) # {name: Grace, email: graceplaceholder.com} print(email) # graceplaceholder.com # 再次调用因为 key 已存在所以返回现有值不修改字典 email2 user.setdefault(email, newplaceholder.com) print(email2) # graceplaceholder.com (原值) print(user) # {name: Grace, email: graceplaceholder.com} (未变)setdefault()是.get()和赋值操作的原子化组合。它确保了“读取-设置-返回”这一系列操作的线程安全性在单个字典操作层面。在多线程环境中if email not in user: user[email] default是不安全的因为两个线程可能同时通过if检查然后都执行赋值导致后赋的值覆盖前赋的值。而setdefault()是 CPython 中的一个原子操作天然规避了这个问题。实操心得defaultdict是数据聚合、缓存初始化、树形结构构建的神器。但请警惕它的“副作用”它会默默地、不可逆地向你的字典中添加新 key。如果你的字典是只读的配置或者你希望严格控制字典的 schema那么defaultdict就是个“甜蜜的陷阱”。setdefault()则是懒加载、单例模式、缓存填充的完美搭档它只在真正需要时才改变字典状态。4. 高级实战构建一个可调试、可监控的 KeyError 防御体系4.1 为 KeyError 添加“灵魂”自定义异常与上下文注入生产环境里一个孤零零的KeyError: user_id是毫无价值的。我们需要它“开口说话”告诉我们更多。import traceback from typing import Any, Dict, Optional class ContextualKeyError(KeyError): 一个携带丰富上下文的 KeyError def __init__( self, missing_key: str, source: str unknown, data_sample: Optional[Dict] None, operation: str access, **context ): super().__init__(missing_key) self.missing_key missing_key self.source source self.data_sample data_sample or {} self.operation operation self.context context def __str__(self): base_msg fKeyError: {self.missing_key} not found in {self.source} during {self.operation}. if self.data_sample: sample_keys list(self.data_sample.keys())[:3] base_msg f Sample keys: {sample_keys} if self.context: context_str , .join([f{k}{v} for k, v in self.context.items()]) base_msg f Context: {context_str} return base_msg # 使用装饰器自动注入上下文 def contextualize_keyerror(source_name: str): def decorator(func): def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except KeyError as e: # 提取缺失的 key missing_key e.args[0] if e.args else unknown # 尝试获取第一个参数作为数据样本通常是字典 data_sample args[0] if args and isinstance(args[0], dict) else {} raise ContextualKeyError( missing_keymissing_key, sourcesource_name, data_sampledata_sample, operationfunc.__name__, argsstr(args[:2]), # 只记录前两个参数避免日志爆炸 kwargslist(kwargs.keys()) ) from e return wrapper return decorator # 应用到你的核心函数上 contextualize_keyerror(user_api_response) def extract_user_info(api_response: dict) - dict: return { id: api_response[user_id], name: api_response[user_name], email: api_response[user_email] } # 当它失败时... try: result extract_user_info({user_id: 123, user_name: Helen}) except ContextualKeyError as e: print(str(e)) # 输出 # KeyError: user_email not found in user_api_response during extract_user_info. # Sample keys: [user_id, user_name] Context: args({user_id: 123, user_name: Helen},), kwargs[]这个ContextualKeyError不仅保留了原生KeyError的所有能力还注入了source哪里出的问题、data_sample当时的数据长什么样、operation执行了什么操作等关键信息。配合装饰器它可以无侵入式地为你的整个代码库赋能。4.2 在日志系统中追踪 KeyError 的“指纹”光有异常还不够我们需要把它变成可观测的指标。import logging import time from collections import defaultdict # 创建一个专门用于统计 KeyError 的日志处理器 class KeyErrorTracker(logging.Handler): def __init__(self): super().__init__() self.error_counts defaultdict(lambda: {count: 0, last_seen: 0, samples: []}) def emit(self, record): if record.exc_info and isinstance(record.exc_info[1], KeyError): exc_type, exc_value, exc_traceback record.exc_info key_name str(exc_value).strip(\) if exc_value.args else unknown # 生成一个指纹source key_name fingerprint f{record.name}.{key_name} self.error_counts[fingerprint][count] 1 self.error_counts[fingerprint][last_seen] time.time() # 保存一个简短的样本避免内存爆炸 if len(self.error_counts[fingerprint][samples]) 5: self.error_counts[fingerprint][samples].append({ message: record.getMessage(), time: time.time() }) def get_report(self) - Dict[str, Any]: 生成一个可用于监控的报告 report {} for fingerprint, info in self.error_counts.items(): report[fingerprint] { count: info[count], last_seen_seconds_ago: int(time.time() - info[last_seen]), samples: info[samples] } return report # 初始化并添加到根 logger key_error_tracker KeyErrorTracker() logging.getLogger().addHandler(key_error_tracker) # 在你的应用启动时定期打印报告 def print_keyerror_report(): report key_error_tracker.get_report() for fingerprint, info in report.items(): print(f[ALERT] {fingerprint}: {info[count]} times, last {info[last_seen_seconds_ago]}s ago) # 每分钟调用一次 # schedule.every(1).minutes.do(print_keyerror_report)这个KeyErrorTracker就像一个“异常雷达”。它不拦截异常而是默默记录下每一次KeyError的指纹模块名缺失的 key、发生频率、最后一次发生时间甚至保存了最近几次的错误样本。你可以轻松地将它接入 Prometheus把keyerror_count{fingerprintapi.user.email}变成一个监控图表当这个数字突然飙升时你就知道上游数据源可能出了问题。4.3 用类型提示Type Hints在 IDE 中提前预警最好的错误处理是在错误发生之前就把它扼杀在摇篮里。Python 的类型提示系统就是你的第一道防线。from typing import TypedDict, Optional, Dict, Any # 定义一个精确的类型 class UserResponse(TypedDict): user_id: int user_name: str user_email: str # 可选字段用 NotRequiredPython 3.11或 Optional user_avatar_url: Optional[str] # 现在你的 IDE如 PyCharm, VSCode会为你提供智能提示 def process_user(user_data: UserResponse) - str: # IDE 会提示user_data 有 user_id, user_name, user_email 等属性 # 如果你写 user_data[user_phone]IDE 会立刻标红警告 return fHello, {user_data[user_name]}! Your ID is {user_data[user_id]} # 对于动态结构可以用 Protocol from typing import Protocol class HasEmail(Protocol): def get(self, key: str, default: Any ...) - Any: ... def send_email(obj: HasEmail) - None: email obj.get(email, defaultcompany.com) # 这里 IDE 不会报错因为 HasEmail 协议保证了 get 方法的存在TypedDict是为字典结构量身定制的类型。它告诉 IDE“这个字典必须有这些 key且它们的值必须是这些类型。” 一旦你违反了这个约定IDE 就会在你敲下.或[的那一刻就给出警告而不是等到运行时才抛出KeyError。这极大地提升了开发效率和代码健壮性。5. 常见问题与排查技巧实录那些年我们踩过的坑5.1 问题速查表从报错信息快速定位根源报错信息最可能的原因排查步骤解决方案KeyError: xxx字典中确实没有名为xxx的 key1.print(list(your_dict.keys()))2.print(your_dict)查看完整结构使用.get(xxx, default)或if xxx in your_dict:KeyError: (xxx,)你试图用一个元组(xxx,)作为 key 去访问字典1.print(type(your_key))2.print(repr(your_key))检查 key 的来源确保它不是意外的元组。用your_key[0]提取元素。KeyError: bxxx字典的 key 是bytes类型而你用str去访问1.print([type(k) for k in your_dict.keys()])2.print(list(your_dict.keys()))将你的 key 转为 bytesyour_dict[bxxx]或your_dict[bytes(xxx, utf-8)]KeyError: 123字典的 key 是int类型而你用str去访问或反之1.print([type(k) for k in your_dict.keys()])确保 key 的类型匹配。your_dict[123]vsyour_dict[123]是完全不同的 key。KeyError: xxx(在json.loads()后)JSON 中该字段的值为null导致解析后为None而你把它当字典用了1.print(type(your_dict.get(xxx)))2.print(your_dict.get(xxx))在访问子键前先用isinstance(your_dict.get(xxx), dict)做类型检查。5.2 独家避坑技巧提升防御等级的 5 个小动作技巧一永远不要信任dict.keys()的返回值# 错误示范keys() 返回的是一个视图对象不是列表 user {name: Ivy} keys_view user.keys() user[email] ivyexample.com # 动态添加新 key # 此时 keys_view 已经包含了 email print(email in keys_view) # True # 但如果你把它转成了 list就固化了那一刻的状态 keys_list list(user.keys()) user[phone] 123 # 再添加一个 print(phone in keys_list) # False因为 keys_list 是旧的快照dict.keys()返回的是一个动态视图view它会随着字典的变化而变化。而list(dict.keys())是一个静态快照。在需要“冻结”当前 key 集合的场景如配置校验用list()是安全的但在需要“实时反映”字典状态的场景如监控直接用in操作符作用于dict.keys()视图才是正确的。技巧二用dict.setdefault()替代if not key in dict: dict[key] value# 错误示范非原子操作多线程下有竞态条件 if cache not in config: config[cache] {ttl: 300, enabled: True} # 正确示范原子操作线程安全 config.setdefault(cache, {ttl: 300, enabled: True})这是一个经典的竞态条件Race Condition案例。两个线程同时执行if cache not in config都得到True然后都执行赋值后执行的会覆盖先执行的。setdefault()是 CPython 中的一个原子操作从根本上杜绝了这个问题。技巧三在defaultdict中用lambda而非list作为工厂函数来避免共享引用# 错误示范所有缺失的 key 都会共享同一个 list 对象 bad_default defaultdict(list) bad_default[a].append(1) bad_default[b].append(2) print(bad_default[a]) # [1, 2] !! print(bad_default[b]) # [1, 2] !! # 正确示范每次调用 lambda 都创建一个新 list good_default defaultdict(lambda: []) good_default[a].append(1) good_default[b].append(2) print(good_default[a]) # [1] print(good_default[b]) # [2]defaultdict(list)中的list是一个类型对象defaultdict会调用list()来创建新实例。但list本身是一个可变对象如果工厂函数是list它会每次都返回同一个[]实例。而lambda: []每次都会创建一个新的空列表这才是我们想要的。技巧四用pprint替代print来查看深层嵌套字典import pprint # 一个复杂的嵌套结构 deep_data { users: [ {id: 1, profile: {name: Jack, settings: {theme: dark, lang: en}}}, {id: 2, profile: {name: Kate, settings: {theme: light, lang: zh}}} ] } # 错误示范print 输出一团乱麻 # print(deep_data) # 正确示范pprint 格式化输出层次分明 pprint.pprint(deep_data, width40, depth3) # 输出 # {users: [{id: 1, # profile: {...}}, # {id: 2, # profile: {...}}]}pprintPretty Print是调试嵌套数据结构的神器。它能自动缩进、换行、截断过长的值并支持depth参数来控制打印深度让你一眼就能看清数据的骨架极大加速KeyError的定位过程。技巧五在单元测试中故意制造 KeyError 来验证你的防御逻辑import pytest def test_user_profile_extraction(): # 测试正常情况 normal_data {user_id: 100, user_name: Leo, user_email: leoexample.com} assert extract_user_info(normal_data) {id: 100, name: Leo, email: leoexample.com} # 测试缺失 email 的情况 —— 这里我们期望它返回一个默认值而不是抛出 KeyError missing_email {user_id: 101, user_name: Mia} result extract_user_info(missing_email) assert result[email] no-emailplaceholder.com # 验证默认值生效 # 测试完全空的数据 —— 这里我们期望它抛出一个 ContextualKeyError with pytest.raises(ContextualKeyError) as exc_info: extract_user_info({}) assert user_id in str(exc_info.value) # 验证异常信息包含正确的 key好的测试不是只测“happy path”更要测“unhappy path”。通过在测试中主动构造缺失 key 的数据你可以确保你的.get()、try/except、