Skip to content

Commit f3a5f93

Browse files
committed
gh-99633: Add context manager support to contextvars.Context
1 parent cc607c1 commit f3a5f93

File tree

7 files changed

+439
-9
lines changed

7 files changed

+439
-9
lines changed

Doc/library/contextvars.rst

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -150,20 +150,24 @@ Manual Context Management
150150
considered to be *entered*.
151151

152152
*Entering* a context, which can be done by calling its :meth:`~Context.run`
153-
method, makes the context the current context by pushing it onto the top of
154-
the current thread's context stack.
153+
method or by using it as a :term:`context manager`, makes the context the
154+
current context by pushing it onto the top of the current thread's context
155+
stack.
155156

156157
*Exiting* from the current context, which can be done by returning from the
157-
callback passed to the :meth:`~Context.run` method, restores the current
158-
context to what it was before the context was entered by popping the context
159-
off the top of the context stack.
158+
callback passed to :meth:`~Context.run` or by exiting the :keyword:`with`
159+
statement suite, restores the current context to what it was before the
160+
context was entered by popping the context off the top of the context stack.
160161

161162
Since each thread has its own context stack, :class:`ContextVar` objects
162163
behave in a similar fashion to :func:`threading.local` when values are
163164
assigned in different threads.
164165

165-
Attempting to enter an already entered context, including contexts entered in
166-
other threads, raises a :exc:`RuntimeError`.
166+
Attempting to do either of the following raises a :exc:`RuntimeError`:
167+
168+
* Entering an already entered context, including contexts entered in
169+
other threads.
170+
* Exiting from a context that is not the current context.
167171

168172
After exiting a context, it can later be re-entered (from any thread).
169173

@@ -176,6 +180,50 @@ Manual Context Management
176180

177181
Context implements the :class:`collections.abc.Mapping` interface.
178182

183+
.. versionadded:: 3.14
184+
Added support for the :term:`context management protocol`.
185+
186+
When used as a :term:`context manager`, the value bound to the identifier
187+
given in the :keyword:`with` statement's :keyword:`!as` clause (if present)
188+
is the :class:`!Context` object itself.
189+
190+
Example:
191+
192+
.. testcode::
193+
194+
import contextvars
195+
196+
var = contextvars.ContextVar("var")
197+
var.set("initial")
198+
print(var.get()) # 'initial'
199+
200+
# Copy the current Context and enter the copy.
201+
with contextvars.copy_context() as ctx:
202+
var.set("updated")
203+
print(var in ctx) # 'True'
204+
print(ctx[var]) # 'updated'
205+
print(var.get()) # 'updated'
206+
207+
# Exited ctx, so the observed value of var has reverted.
208+
print(var.get()) # 'initial'
209+
# But the updated value is still recorded in ctx.
210+
print(ctx[var]) # 'updated'
211+
212+
# Re-entering ctx restores the updated value of var.
213+
with ctx:
214+
print(var.get()) # 'updated'
215+
216+
.. testoutput::
217+
:hide:
218+
219+
initial
220+
True
221+
updated
222+
updated
223+
initial
224+
updated
225+
updated
226+
179227
.. method:: run(callable, *args, **kwargs)
180228

181229
Enters the Context, executes ``callable(*args, **kwargs)``, then exits the

Doc/whatsnew/3.14.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,13 @@ ast
226226
(Contributed by Tomas R in :gh:`116022`.)
227227

228228

229+
contextvars
230+
-----------
231+
232+
* Added support for the :term:`context management protocol` to
233+
:class:`contextvars.Context`. (Contributed by Richard Hansen in :gh:`99634`.)
234+
235+
229236
ctypes
230237
------
231238

Lib/test/test_context.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import asyncio
12
import concurrent.futures
3+
import contextlib
24
import contextvars
35
import functools
46
import gc
57
import random
8+
import threading
69
import time
710
import unittest
811
import weakref
@@ -361,6 +364,88 @@ def sub(num):
361364
tp.shutdown()
362365
self.assertEqual(results, list(range(10)))
363366

367+
@isolated_context
368+
def test_context_manager(self):
369+
cvar = contextvars.ContextVar('cvar', default='initial')
370+
self.assertEqual(cvar.get(), 'initial')
371+
with contextvars.copy_context():
372+
self.assertEqual(cvar.get(), 'initial')
373+
cvar.set('updated')
374+
self.assertEqual(cvar.get(), 'updated')
375+
self.assertEqual(cvar.get(), 'initial')
376+
377+
def test_context_manager_as_binding(self):
378+
ctx = contextvars.copy_context()
379+
with ctx as ctx_as_binding:
380+
self.assertIs(ctx_as_binding, ctx)
381+
382+
@isolated_context
383+
def test_context_manager_nested(self):
384+
cvar = contextvars.ContextVar('cvar', default='default')
385+
with contextvars.copy_context() as outer_ctx:
386+
cvar.set('outer')
387+
with contextvars.copy_context() as inner_ctx:
388+
self.assertIsNot(outer_ctx, inner_ctx)
389+
self.assertEqual(cvar.get(), 'outer')
390+
cvar.set('inner')
391+
self.assertEqual(outer_ctx[cvar], 'outer')
392+
self.assertEqual(cvar.get(), 'inner')
393+
self.assertEqual(cvar.get(), 'outer')
394+
self.assertEqual(cvar.get(), 'default')
395+
396+
@isolated_context
397+
def test_context_manager_enter_again_after_exit(self):
398+
cvar = contextvars.ContextVar('cvar', default='initial')
399+
self.assertEqual(cvar.get(), 'initial')
400+
with contextvars.copy_context() as ctx:
401+
cvar.set('updated')
402+
self.assertEqual(cvar.get(), 'updated')
403+
self.assertEqual(cvar.get(), 'initial')
404+
with ctx:
405+
self.assertEqual(cvar.get(), 'updated')
406+
self.assertEqual(cvar.get(), 'initial')
407+
408+
@threading_helper.requires_working_threading()
409+
def test_context_manager_rejects_exit_from_different_thread(self):
410+
ctx = contextvars.copy_context()
411+
thread = threading.Thread(target=ctx.__enter__)
412+
thread.start()
413+
thread.join()
414+
with self.assertRaises(RuntimeError):
415+
ctx.__exit__(None, None, None)
416+
417+
def test_context_manager_is_not_reentrant(self):
418+
with self.subTest('context manager then context manager'):
419+
with contextvars.copy_context() as ctx:
420+
with self.assertRaises(RuntimeError):
421+
with ctx:
422+
pass
423+
with self.subTest('context manager then run method'):
424+
with contextvars.copy_context() as ctx:
425+
with self.assertRaises(RuntimeError):
426+
ctx.run(lambda: None)
427+
with self.subTest('run method then context manager'):
428+
ctx = contextvars.copy_context()
429+
430+
def fn():
431+
with self.assertRaises(RuntimeError):
432+
with ctx:
433+
pass
434+
435+
ctx.run(fn)
436+
437+
def test_context_manager_rejects_noncurrent_exit(self):
438+
with contextvars.copy_context() as outer_ctx:
439+
with contextvars.copy_context() as inner_ctx:
440+
self.assertIsNot(outer_ctx, inner_ctx)
441+
with self.assertRaises(RuntimeError):
442+
outer_ctx.__exit__(None, None, None)
443+
444+
def test_context_manager_rejects_nonentered_exit(self):
445+
ctx = contextvars.copy_context()
446+
with self.assertRaises(RuntimeError):
447+
ctx.__exit__(None, None, None)
448+
364449

365450
class GeneratorContextTest(unittest.TestCase):
366451
def test_default_is_none(self):
@@ -633,6 +718,145 @@ def makegen_outer():
633718
self.assertEqual(gen_outer.send(ctx_outer), ('inner', 'outer'))
634719
self.assertEqual(cvar.get(), 'updated')
635720

721+
722+
class AsyncContextTest(unittest.IsolatedAsyncioTestCase):
723+
async def test_asyncio_independent_contexts(self):
724+
"""Check that coroutines are run with independent contexts.
725+
726+
Changes to context variables outside a coroutine should not affect the
727+
values seen inside the coroutine and vice-versa. (This might be
728+
implemented by manually setting the context before executing each step
729+
of (send to) a coroutine, or by ensuring that the coroutine is an
730+
independent coroutine before executing any steps.)
731+
"""
732+
cvar = contextvars.ContextVar('cvar', default='A')
733+
updated1 = asyncio.Event()
734+
updated2 = asyncio.Event()
735+
736+
async def task1():
737+
self.assertIs(cvar.get(), 'A')
738+
await asyncio.sleep(0)
739+
cvar.set('B')
740+
await asyncio.sleep(0)
741+
updated1.set()
742+
await updated2.wait()
743+
self.assertIs(cvar.get(), 'B')
744+
745+
async def task2():
746+
await updated1.wait()
747+
self.assertIs(cvar.get(), 'A')
748+
await asyncio.sleep(0)
749+
cvar.set('C')
750+
await asyncio.sleep(0)
751+
updated2.set()
752+
await asyncio.sleep(0)
753+
self.assertIs(cvar.get(), 'C')
754+
755+
async with asyncio.TaskGroup() as tg:
756+
tg.create_task(task1())
757+
tg.create_task(task2())
758+
759+
self.assertIs(cvar.get(), 'A')
760+
761+
async def test_asynccontextmanager_is_dependent_by_default(self):
762+
"""Async generator in asynccontextmanager is dependent by default.
763+
764+
Context switches during the yield of a generator wrapped with
765+
contextlib.asynccontextmanager should be visible to the generator by
766+
default (for backwards compatibility).
767+
"""
768+
cvar = contextvars.ContextVar('cvar', default='A')
769+
770+
@contextlib.asynccontextmanager
771+
async def makecm():
772+
await asyncio.sleep(0)
773+
self.assertEqual(cvar.get(), 'A')
774+
await asyncio.sleep(0)
775+
# Everything above runs during __aenter__.
776+
yield cvar.get()
777+
# Everything below runs during __aexit__.
778+
await asyncio.sleep(0)
779+
self.assertEqual(cvar.get(), 'C')
780+
await asyncio.sleep(0)
781+
cvar.set('D')
782+
await asyncio.sleep(0)
783+
784+
cm = makecm()
785+
val = await cm.__aenter__()
786+
self.assertEqual(val, 'A')
787+
self.assertEqual(cvar.get(), 'A')
788+
cvar.set('B')
789+
790+
with contextvars.copy_context():
791+
cvar.set('C')
792+
await cm.__aexit__(None, None, None)
793+
self.assertEqual(cvar.get(), 'D')
794+
self.assertEqual(cvar.get(), 'B')
795+
796+
async def test_asynccontextmanager_independent(self):
797+
cvar = contextvars.ContextVar('cvar', default='A')
798+
799+
@contextlib.asynccontextmanager
800+
async def makecm():
801+
# Context.__enter__ called from a generator makes the generator
802+
# independent while the `with` statement suite runs.
803+
# (Alternatively we could have set the generator's _context
804+
# property.)
805+
with contextvars.copy_context():
806+
await asyncio.sleep(0)
807+
self.assertEqual(cvar.get(), 'A')
808+
await asyncio.sleep(0)
809+
# Everything above runs during __aenter__.
810+
yield cvar.get()
811+
# Everything below runs during __aexit__.
812+
await asyncio.sleep(0)
813+
self.assertEqual(cvar.get(), 'A')
814+
await asyncio.sleep(0)
815+
cvar.set('D')
816+
await asyncio.sleep(0)
817+
818+
cm = makecm()
819+
val = await cm.__aenter__()
820+
self.assertEqual(val, 'A')
821+
self.assertEqual(cvar.get(), 'A')
822+
cvar.set('B')
823+
with contextvars.copy_context():
824+
cvar.set('C')
825+
await cm.__aexit__(None, None, None)
826+
self.assertEqual(cvar.get(), 'C')
827+
self.assertEqual(cvar.get(), 'B')
828+
829+
async def test_generator_switch_between_independent_dependent(self):
830+
cvar = contextvars.ContextVar('cvar', default='default')
831+
with contextvars.copy_context() as ctx1:
832+
cvar.set('in ctx1')
833+
with contextvars.copy_context() as ctx2:
834+
cvar.set('in ctx2')
835+
with contextvars.copy_context() as ctx3:
836+
cvar.set('in ctx3')
837+
838+
async def makegen():
839+
await asyncio.sleep(0)
840+
yield cvar.get()
841+
await asyncio.sleep(0)
842+
yield cvar.get()
843+
await asyncio.sleep(0)
844+
with ctx2:
845+
yield cvar.get()
846+
await asyncio.sleep(0)
847+
yield cvar.get()
848+
await asyncio.sleep(0)
849+
yield cvar.get()
850+
851+
gen = makegen()
852+
self.assertEqual(await anext(gen), 'default')
853+
with ctx1:
854+
self.assertEqual(await anext(gen), 'in ctx1')
855+
self.assertEqual(await anext(gen), 'in ctx2')
856+
with ctx3:
857+
self.assertEqual(await anext(gen), 'in ctx2')
858+
self.assertEqual(await anext(gen), 'in ctx1')
859+
636860
# HAMT Tests
637861

638862

Misc/ACKS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,7 @@ Michael Handler
716716
Andreas Hangauer
717717
Milton L. Hankins
718718
Carl Bordum Hansen
719+
Richard Hansen
719720
Stephen Hansen
720721
Barry Hantman
721722
Lynda Hardman
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Added support for the :term:`context management protocol` to
2+
:class:`contextvars.Context`. Patch by Richard Hansen.

0 commit comments

Comments
 (0)