contextlib 提供了哪些工具?@contextmanager 和 ExitStack 怎么用?
简化版
contextlib 是标准库里「造上下文管理器 + 组合上下文管理器」的工具箱,省得你为每个 with 都手写一个带 __enter__/__exit__ 的类。最常用的六个:① @contextmanager——把一个「yield 一次的生成器函数」变成上下文管理器:yield 之前是 __enter__、yield 出去的值给 as、yield 之后是 __exit__;② suppress(异常类)——安静吞掉指定异常,替代 try/except X: pass;③ closing(obj)——给只有 close()、没有 __exit__ 的老对象套上 with;④ ExitStack()——动态数量的上下文管理器统一管理(要打开 N 个文件、N 在运行时才知道),退出时按后进先出逐个关闭;⑤ nullcontext(x)——「什么都不做」的占位上下文,解决「有时要 with 有时不要」的分支重复;⑥ redirect_stdout/redirect_stderr——临时重定向标准输出。另外 @contextmanager 写的东西还能当装饰器用(ContextDecorator),异步版是 @asynccontextmanager + AsyncExitStack。最大的坑:@contextmanager 里如果不用 try/finally 包住 yield,一旦 with 体内抛异常,yield 之后的清理代码根本不会执行(资源泄漏)。核心记忆:@contextmanager 用生成器写 with、清理代码必须放 finally、动态数量用 ExitStack。
详细版
工具速查表:
| 工具 | 解决什么问题 | 等价的手写版 |
|---|---|---|
@contextmanager | 不想为一个 with 写整个类 | 带 __enter__/__exit__ 的类 |
suppress(FileNotFoundError) | 吞掉预期内的异常 | try: ... except X: pass |
closing(obj) | 对象只有 close() 没有 __exit__ | try: ... finally: obj.close() |
ExitStack() | 数量不定 / 条件性的资源 | 手写嵌套 with(数量固定才行) |
nullcontext() | 「有时要 with,有时不要」 | if/else 两份重复代码 |
redirect_stdout(f) | 临时把 print 重定向 | 手动存取 sys.stdout |
@asynccontextmanager | 异步版的 @contextmanager | __aenter__/__aexit__ 类 |
AsyncExitStack() | 异步版的 ExitStack | —— |
from contextlib import (contextmanager, suppress, closing, ExitStack,
nullcontext, redirect_stdout)
import io, time
# ① @contextmanager:yield 前=进入,yield 后=退出
@contextmanager
def timer(label):
start = time.perf_counter()
try: # ★ 必须 try/finally
yield start # 这个值给 as
finally:
print(f"{label} 耗时 {time.perf_counter()-start:.3f}s") # 异常时也会打印
with timer("查询") as t0:
sum(range(10**6))
# ② suppress:吞掉预期异常
import os
with suppress(FileNotFoundError):
os.remove("可能不存在.txt") # 不存在也不炸
# ③ ExitStack:数量在运行时才知道
paths = ["a.txt", "b.txt", "c.txt"] # 数量不定
with ExitStack() as stack:
files = [stack.enter_context(open(p, "w")) for p in paths]
for f in files:
f.write("x")
# 退出时按★后进先出★依次关闭 c、b、a
# ④ nullcontext:消除 if/else 重复
def process(path, lock=None):
with (lock or nullcontext()): # 没有锁就用"空上下文"
return open(path).read()
# ⑤ redirect_stdout:抓住第三方库的 print
buf = io.StringIO()
with redirect_stdout(buf):
print("这句被抓走了")
print("捕获到:", buf.getvalue().strip()) # 捕获到: 这句被抓走了
# ⑥ @contextmanager 的对象还能当装饰器(ContextDecorator)
@timer("批处理")
def batch(): # 等价于函数体外面套一层 with timer(...)
sum(range(10**5))
batch()
# ⑦ closing:老式对象只有 close()
class OldConn:
def close(self): print("closed")
with closing(OldConn()) as c: # 给它补上 __exit__
pass
⚠️
@contextmanager的头号坑:清理代码必须放在finally里。@contextmanager的实现方式是:__enter__时把生成器推进到yield(拿到 yield 出来的值),__exit__时——如果with体正常结束就调next()让生成器跑完;如果with体抛了异常,则把这个异常通过gen.throw(exc)扔回yield那一行。也就是说,异常是在yield处「爆炸」的:如果你写成yield; 清理()(没有 try),异常一炸,清理()这行就被跳过了,连接不会归还、锁不会释放、文件不会关闭——而这恰恰是最需要清理的时候。所以模板永远是try: yield x finally: 清理()。相关地:如果你想「吞掉」with体里的异常,要在except里return(不要yield第二次,那会抛RuntimeError: generator didn't stop);反过来,__exit__返回真值会吞异常,而@contextmanager里默认不吞——异常会继续往外传。
完整版教学
一、@contextmanager:用生成器代替一个类
手写类的版本(8 行 + 两个方法):
class Timer:
def __init__(self, label): self.label = label
def __enter__(self):
self.t0 = time.perf_counter()
return self.t0 # ← 这个值给 as
def __exit__(self, exc_type, exc, tb):
print(self.label, time.perf_counter() - self.t0)
return False # ← False = 不吞异常
@contextmanager 版本(等价,但只有一个函数):
@contextmanager
def timer(label):
t0 = time.perf_counter()
try:
yield t0 # ← 这一行就是 __enter__ 的 return
finally:
print(label, time.perf_counter() - t0) # ← 这就是 __exit__
对应关系(背下来):
yield 之前的代码 ←→ __enter__ 的函数体
yield 出去的值 ←→ __enter__ 的返回值(给 as)
yield 之后的代码 ←→ __exit__ 的函数体
try/finally ←→ 保证异常时 __exit__ 也执行
执行时序(with 体内没有异常):
进入 with → 生成器跑到 yield 暂停 → 执行 with 体 → 恢复生成器跑完 → 退出
执行时序(with 体内抛了 ValueError):
进入 with → 跑到 yield 暂停 → with 体抛 ValueError
→ contextlib 调用 gen.throw(ValueError) → ★异常从 yield 那一行抛出★
→ finally 执行清理 → 异常继续向外传播(默认不吞)
必须"只 yield 一次":
yield 两次 → RuntimeError: generator didn't stop
一次都不 yield → RuntimeError: generator didn't yield
@contextmanager 的本质是把一个「只 yield 一次」的生成器包装成上下文管理器:ContextManager.__enter__ 内部做的是 next(gen)——让生成器跑到 yield 处暂停,并把 yield 出来的值作为 as 的绑定;__exit__ 内部做的是「让生成器继续跑完」。理解这一点,所有行为就都能推出来了:为什么必须只 yield 一次(yield 两次说明「退出」之后还有一段身体,语义上说不通,直接报 RuntimeError)、为什么 with 体的异常会在 yield 那一行爆炸(contextlib 是用 gen.throw() 把异常送回生成器的暂停点)、为什么清理必须放 finally(否则异常一炸就跳过了)。写法上它比手写类短一半,且「获取资源—使用—释放资源」三步在一个函数里从上往下读,比拆在两个方法里更直观。
二、清理代码为什么必须放在 finally
✗ 错误写法(漏 try/finally):
@contextmanager
def get_conn():
conn = pool.acquire()
yield conn
pool.release(conn) # ← with 体抛异常时,这行永远不执行!
with get_conn() as c:
raise ValueError("业务出错")
→ 异常从 yield 那行抛出 → 直接向外传播 → release 被跳过
→ 连接泄漏;跑 1000 次就把连接池耗干(而且是"越出错泄漏越快")
✓ 正确写法:
@contextmanager
def get_conn():
conn = pool.acquire()
try:
yield conn
finally:
pool.release(conn) # ← 正常/异常/return/break 都会执行
想在出错时做额外处理(比如回滚):
@contextmanager
def transaction(conn):
tx = conn.begin()
try:
yield conn
except Exception:
tx.rollback()
raise # ★ 记得 raise,否则异常被静默吞掉
else:
tx.commit() # 只有没异常才提交
想"吞掉"某类异常(少用):
try:
yield
except TimeoutError:
logger.warning("超时,已忽略")
# 不 raise = 吞掉;★千万别在这里再 yield 一次(RuntimeError)★
三种收尾语义对照:
finally → 无论如何都执行(释放资源)
except → 只有出异常才执行(回滚、告警)
else → 只有没出异常才执行(提交)
这是 @contextmanager 唯一的高频事故点,值得单独记:上下文管理器存在的意义就是「异常时也保证清理」,而漏掉 try/finally 恰好让它在异常时不清理——功能退化成了一个普通函数调用,还给人一种「我已经用 with 管好了」的错觉,泄漏会拖很久才被发现(表现是连接池慢慢耗干、文件句柄数缓慢上涨)。模板要记牢三段式的分工:finally 放「无论如何都要做」的释放;except 放「出错才做」的回滚并记得 raise 重新抛出(不 raise 就等于把业务异常静默吞了,比泄漏更可怕);else 放「没出错才做」的提交。这套结构就是数据库事务上下文管理器的标准写法。
三、suppress / closing / nullcontext:三个小工具
① suppress(*异常类):安静吞掉指定异常
传统写法 suppress 写法
try: with suppress(FileNotFoundError):
os.remove(p) os.remove(p)
except FileNotFoundError:
pass
★ 注意语义:异常一旦发生,with 体内★剩下的语句会被跳过★
with suppress(ValueError):
a = int("x") # 抛 ValueError
print("这句不会执行") ← 直接跳到 with 之后
★ 只吞你写明的那类,别 suppress(Exception) 当万能创可贴
② closing(obj):给只有 close() 的对象补上 with
from urllib.request import urlopen
with closing(urlopen(url)) as page: # 老式对象没有 __exit__
data = page.read()
等价于 try: ... finally: obj.close()
(注意:现代的 open()、socket、requests.Session 自带 __exit__,不需要 closing)
③ nullcontext(值=None):什么都不做的占位上下文
问题:有时需要加锁、有时不需要 → 写成 if/else 会重复整段业务代码
✗ if lock:
with lock: 干活() ← 业务代码写了两遍
else:
干活()
✓ with (lock or nullcontext()):
干活() ← 只写一遍
还能带值(常用于"文件路径或已打开的文件对象都接受"的 API):
✓ cm = open(f) if isinstance(f, str) else nullcontext(f)
with cm as fp: # 传路径就负责开关;传文件对象就原样用、不替调用方关闭
fp.read()
这三个小工具都是「消除样板代码」的:suppress 把四行 try/except/pass 压成一行,但要注意它的语义是跳出整个 with 体(异常发生点之后的语句不再执行),并且只该 suppress 你确实预期会发生的那一类异常——suppress(Exception) 会把真正的 bug 也一起吞掉。closing 是给「只有 close() 方法、没实现 __exit__」的老式对象补协议用的,现代库大多自带 __exit__,用不上它。nullcontext 看起来没用,实际非常解痒:它专治「条件性的 with」——不用它就得把业务代码在 if 和 else 里各写一遍;它还能 nullcontext(已打开的文件),让一个 API 同时接受「路径」和「已打开的对象」,且不会去关闭调用方传进来的对象(谁打开谁负责关,这是很重要的资源礼仪)。
四、ExitStack:数量不定的资源怎么管
问题场景:要合并 N 个文件,N 在运行时才知道
✗ 固定嵌套:with open(a) as f1, open(b) as f2: ← 只能写死数量
✗ 手写 try/finally:得自己记住已经打开了哪几个、逆序关闭、还要处理"开到一半失败"
ExitStack = 一个"待关闭资源的栈",退出时后进先出(LIFO)逐个调用它们的 __exit__
with ExitStack() as stack:
files = [stack.enter_context(open(p)) for p in paths] # 开几个都行
合并(files)
# 退出:按 逆序 关闭(最后打开的最先关)
为什么必须逆序(LIFO):资源常有依赖关系
打开顺序: 连接 → 事务 → 游标
关闭顺序: 游标 → 事务 → 连接 ← 反着来才安全
(先关连接再关游标 = 对已死的连接操作,报错或泄漏)
★ 部分失败也安全:
开到第 7 个文件时磁盘满了抛异常 → 前 6 个已入栈的照样被正确关闭
(手写循环 + try/finally 想做到这点要写十几行)
三个常用能力:
stack.enter_context(cm) 把一个上下文管理器压栈,返回它的 __enter__ 值
stack.callback(func, *args) 压入一个"退出时要调用的普通函数"(不必是上下文管理器)
stack.pop_all() ★把所有清理动作转移出去★ → 用于"成功就不清理"
pop_all 的经典用法(要么全成、要么全回滚):
with ExitStack() as stack:
conns = [stack.enter_context(connect(h)) for h in hosts]
握手校验(conns) # 任何一步失败 → 已建立的连接全部关闭
stack.pop_all() # 全部成功 → 撤销清理,把连接交出去
return conns
条件性入栈也很自然:
with ExitStack() as stack:
if need_lock: stack.enter_context(lock)
if need_trace: stack.enter_context(profiler())
干活()
ExitStack 解决的是「with a, b, c: 这种写法只能应付编译期就知道数量的资源」这个硬限制。它内部维护一个栈,enter_context 把上下文管理器压栈并立刻执行其 __enter__,退出时逆序(LIFO)弹栈调用每个 __exit__——逆序不是随意的设计,而是因为资源常有依赖关系(连接→事务→游标必须反过来关)。它另一个被低估的价值是部分失败的安全性:打开第 7 个资源时炸了,前 6 个照样被正确清理,这段逻辑手写要十几行还容易漏。stack.callback(fn, args) 让你把任意「退出时要做的事」(删临时目录、发通知)也纳入同一套管理。而 pop_all() 是实现「要么全部成功、要么全部回滚」的钥匙:先把资源都挂在栈上,中途任何失败都自动清理,全部成功后调 pop_all() 撤销清理责任,把资源完好地交给调用方。
五、当装饰器用:ContextDecorator
@contextmanager 造出来的对象,天生也能当装饰器(继承自 ContextDecorator)
@contextmanager
def timing(label):
t0 = time.perf_counter()
try:
yield
finally:
print(label, time.perf_counter() - t0)
用法一(with): with timing("A"): 干活()
用法二(装饰器): @timing("B")
def f(): 干活() ← 等价于函数体外面套一层 with
★ 关键陷阱:装饰器用法下,"每次调用函数"都会重新执行一遍生成器
@timing("B")
def f(): ...
f(); f() # 计时两次,各自独立 ✓(因为装饰器每次调用都新建一个上下文实例)
但如果你手动这样写就废了:
cm = timing("B") # 只造了一个实例
with cm: ...
with cm: ... # ✗ RuntimeError: generator didn't yield(生成器已耗尽,不可重入)
→ @contextmanager 造的上下文管理器是"一次性"的;
需要重复/嵌套使用,就每次现调用一次工厂函数(timing("B")),
或者改写成手写类(类实例可以设计成可重入的,如 threading.RLock)
自己写类想要这个能力:继承 contextlib.ContextDecorator
class Timing(ContextDecorator):
def __enter__(self): ...
def __exit__(self, *exc): ...
→ Timing() 既能 with 也能当 @Timing() 用
什么时候用装饰器形态:整个函数体都要被包住(打点、计时、临时切配置)
什么时候用 with 形态:只包函数里的一小段
@contextmanager 产出的对象同时也是装饰器,这来自 ContextDecorator 基类——@timing("B") 等价于「把整个函数体塞进 with timing("B"):」。这个双形态很实用:整段函数都要计时/打点就用装饰器形态,只包其中几行就用 with 形态,一份代码两种用法。但要牢记一个陷阱:@contextmanager 生成的上下文管理器实例是一次性的(底层是个生成器,跑完就耗尽了),把它存成变量反复 with 会抛 RuntimeError: generator didn't yield。装饰器形态之所以能反复调用不出事,是因为装饰器每次调用函数时都会重新调用一次工厂函数造新实例。需要一个「可重入、可复用」的上下文管理器时(类似 threading.RLock),就得老老实实手写类并继承 ContextDecorator。
六、异步版本与选型总结
异步世界的对应物:
@contextmanager → @asynccontextmanager (用 async def + yield)
ExitStack → AsyncExitStack (enter_async_context / aclose)
closing → aclosing (3.10+,await obj.aclose())
nullcontext → nullcontext 也支持 async with(3.10+)
@asynccontextmanager
async def get_session():
s = aiohttp.ClientSession()
try:
yield s
finally:
await s.close() # ← 异步清理必须 await
async with get_session() as s: ...
★ AsyncExitStack 可以混装同步和异步资源:
async with AsyncExitStack() as st:
conn = await st.enter_async_context(async_conn()) # 异步的
f = st.enter_context(open("log.txt")) # 同步的也能进
选型决策树:
只用一次、逻辑简单 → @contextmanager(最省事)
需要重入 / 复用同一个实例 / 有状态 → 手写类(__enter__/__exit__)
数量不定 / 条件性 / 要部分失败安全 → ExitStack
只是想吞个异常 → suppress
对象只有 close() → closing
"有时要 with 有时不要" → nullcontext
异步资源 → @asynccontextmanager / AsyncExitStack
统一心法:把"成对出现的操作"(开/关、加锁/解锁、进/出、改配置/还原)
交给上下文管理器,就不会因为中途 return、break、异常而漏掉后半截。
异步场景下全套工具都有对应版本,规则也一模一样,只是清理动作前要 await(finally: await s.close()),而且必须用 async with。AsyncExitStack 尤其顺手:它能同时容纳同步和异步资源(enter_context 与 enter_async_context 混用),在「一个异步任务里既要开数据库连接又要写本地文件」时避免了两套嵌套。最后把选型收敛成一句心法:凡是「成对出现」的操作——开/关、加锁/解锁、改配置/还原现场、进/出目录——都交给上下文管理器,这样无论中途 return、break 还是抛异常,后半截都不会漏;至于用哪个工具,按「逻辑简单用 @contextmanager、要复用用类、数量不定用 ExitStack」三档来选就够了。
记忆钩子:「contextlib 就记四件事:①
@contextmanager用『只 yield 一次的生成器』写 with——yield 前=__enter__、yield 的值给as、yield 后=__exit__;②清理代码必须裹在try/finally里,因为 with 体的异常是被gen.throw()送回 yield 那一行『就地爆炸』的,没有 finally 就直接跳过清理(越出错泄漏越快),事务模板是try: yield / except: rollback; raise / else: commit;③数量不定、条件性、要『部分失败也安全』的资源用ExitStack(LIFO 逆序关闭,pop_all()实现全成才交付);④小工具三件套:suppress吞预期异常、closing给只有 close() 的老对象补协议、nullcontext消除『有时要 with 有时不要』的分支重复。异步全套加 a:@asynccontextmanager/AsyncExitStack/aclosing。还有一个隐藏坑:@contextmanager造的实例是一次性的,存成变量反复 with 会RuntimeError: generator didn't yield。」
七、常见误区与追问
- 误区:
@contextmanager里yield后面的代码一定会执行,不用写try/finally。 恰恰相反——with体内抛出的异常,contextlib 是通过gen.throw(exc)送回yield那一行重新抛出的,所以异常会从yield处向外炸,yield之后的语句被整个跳过。写成conn = acquire(); yield conn; release(conn)时,正常路径看起来一切正常,一旦业务代码抛异常,release就不执行了——而这正是最需要释放的时刻,结果是「越出错泄漏越快」的连接池耗尽,且往往拖到线上高峰才暴露。永远写成try: yield x finally: 清理()。 - 误区:
@contextmanager造出来的对象可以存成变量反复使用。 它底层是一个生成器,跑完就耗尽了,属于一次性对象:cm = timer("x")之后第一次with cm:正常,第二次会抛RuntimeError: generator didn't yield;嵌套使用同一个实例同样会炸。要重复使用就每次重新调用工厂函数(with timer("x"):每次都新建一个),要「可重入」(像RLock那样同一线程可以嵌套获取)就必须手写类。装饰器形态@timer("x")之所以能反复调用函数而不出事,正是因为它每次调用都重新创建了新实例。 - 误区:
suppress(Exception)是个方便的兜底写法。 这是把except Exception: pass换了个漂亮外衣,同样会吞掉真正的 bug:AttributeError、KeyError、拼错的方法名、下游服务的严重错误全被静默忽略,程序带着错误的中间状态继续跑,排查时连一行日志都没有。suppress只该用于「你明确预期会发生、且发生了确实什么都不用做」的具体异常类,最典型的就是suppress(FileNotFoundError)删除可能不存在的文件。另外要记住它的控制流语义:异常一旦发生,with体内后续语句全部跳过,直接跳到with之后——别在suppress块里放多条互相依赖的语句。 - 误区:
ExitStack只是「少写几层嵌套 with」的语法糖。 它真正不可替代的地方有三个:① 数量在运行时才确定(with a, b, c:的写法必须在代码里写死数量);② 条件性资源(if need_lock: stack.enter_context(lock),用嵌套 with 得写成分支重复的业务代码);③ 部分失败的安全清理——打开第 7 个资源时抛异常,前 6 个已入栈的会被正确逆序关闭,手写循环要实现这点得十几行还容易漏。再加上pop_all()能实现「全部成功才把资源交出去、中途任何失败自动全回滚」,这些都不是语法糖能概括的。 - 误区:
with里的异常被__exit__处理后就不会外传。 默认是会外传的:__exit__只有返回真值时才表示「异常已被我吞掉」,返回None/False(大多数实现的默认)异常继续向外传播。@contextmanager的对应规则是:在生成器里except SomeError: 处理且不raise就等于吞掉,raise或不捕获就继续外传。实际写事务上下文时最常见的 bug 就是except Exception: tx.rollback()后忘了raise——数据回滚了,但调用方以为一切正常,错误被静默咽下去。 - 追问:
@contextmanager和手写__enter__/__exit__的类,各自适合什么场景? 用@contextmanager的场景:逻辑简单、一次性使用、「获取—使用—释放」三步想写在一个函数里从上往下读(计时、临时改配置、开个事务),代码量约为类写法的一半。必须手写类的场景:① 需要可重入或可复用(同一个实例被多次/嵌套with,生成器版会RuntimeError);② 上下文管理器本身要携带状态或提供额外方法(比如with lock:之外还想调lock.locked());③ 需要继承体系或被其他类混入;④ 对__exit__的异常处理要做复杂判断(拿到exc_type/exc/tb三个参数分类处理,比在生成器里写多个except更清晰)。两者可以互补:类写法配合继承ContextDecorator同样能获得「既能 with 又能当装饰器」的能力。 - 追问:
ExitStack为什么按后进先出(LIFO)顺序退出? 因为资源之间通常存在依赖关系,而依赖的建立顺序天然是从底层到上层:先连数据库、再开事务、再建游标;先创建临时目录、再在里面建文件。释放时必须反过来(游标→事务→连接、文件→目录),否则就会「对已经失效的底层资源做操作」——关掉连接后再关游标会报错,删掉目录后再关文件会失败。这与嵌套with a: with b:的退出顺序(先退 b 再退 a)完全一致,ExitStack只是把这个顺序从语法嵌套搬到了运行时的栈里。同样的道理,stack.callback()注册的普通清理函数也遵守 LIFO。 - 追问:
nullcontext除了「条件性加锁」还有什么典型用途? 最典型的是写一个「既接受文件路径、也接受已打开的文件对象」的 API:如果传的是路径,就open()并在用完后关闭;如果传的是调用方已经打开的对象,就直接用、且绝不能替调用方关闭它(否则调用方后续再用就是「操作已关闭的文件」)。写法是cm = open(f) if isinstance(f, str) else nullcontext(f),然后统一with cm as fp:——nullcontext(f)的__enter__直接返回f、__exit__什么都不做,完美表达了「谁打开谁负责关」的资源礼仪。类似的还有「调试模式下才启用 profiler、否则空转」、「测试里用pytest.raises(X),正常路径用nullcontext()」这样把两条分支统一成一行的写法。
八、加强记忆
contextlib 是「造 with + 组合 with」的工具箱。核心是 @contextmanager:把一个「只 yield 一次的生成器」变成上下文管理器——yield 之前 = __enter__、yield 出去的值给 as、yield 之后 = __exit__,一个函数顶一个类。它的唯一高频事故是漏写 try/finally:with 体的异常是被 gen.throw() 送回 yield 那一行就地爆炸的,没有 finally 就直接跳过清理,形成「越出错泄漏越快」的连接池耗尽;事务模板固定为 try: yield / except: rollback(); raise / else: commit()(except 里别忘了 raise,否则业务异常被静默吞掉)。ExitStack 负责嵌套 with 干不了的三件事:数量运行时才知道、条件性资源、部分失败也能安全清理,退出按 LIFO 逆序(因为连接→事务→游标必须反着关),pop_all() 实现「全部成功才把资源交出去」。三个小工具:suppress 吞掉明确预期的具体异常(别写 suppress(Exception),且注意异常后 with 体剩余语句会被跳过)、closing 给只有 close() 的老对象补协议、nullcontext 消除「有时要 with 有时不要」的分支重复(还能让 API 同时接受路径和已打开对象、且不越权关闭别人的资源)。异步版全套加 a:@asynccontextmanager、AsyncExitStack(可混装同步异步资源)、aclosing。最后两个易忘点:@contextmanager 造的实例是一次性的(存成变量反复 with 会 RuntimeError: generator didn't yield,要重入就手写类),以及 __exit__ 返回真值才吞异常、默认继续外传。