Skip to content

Commit eb110b6

Browse files
committed
gh-99633: Add context manager support to contextvars.Context
1 parent 2781ec9 commit eb110b6

File tree

6 files changed

+265
-11
lines changed

6 files changed

+265
-11
lines changed

Doc/library/contextvars.rst

+61-9
Original file line numberDiff line numberDiff line change
@@ -144,16 +144,72 @@ Manual Context Management
144144
To get a copy of the current context use the
145145
:func:`~contextvars.copy_context` function.
146146

147+
When *entering* a Context, either by calling the :meth:`Context.run` method
148+
or using the Context object as a :term:`context manager`, the Context becomes
149+
the *current Context*. When *exiting* the current Context, either by
150+
returning from the callback passed to :meth:`Context.run` or by exiting the
151+
:keyword:`with` statement suite, the current Context reverts back to what it
152+
was before the exited Context was entered.
153+
154+
Attempting to do any of the following will raise a :exc:`RuntimeError`:
155+
156+
* Entering an already entered Context.
157+
* Exiting from a Context that is not the current Context.
158+
* Exiting a Context from a different thread than the one used to enter the
159+
Context.
160+
161+
After exiting a Context, it can later be re-entered (from any thread).
162+
163+
Any changes to :class:`ContextVar` values via the :meth:`ContextVar.set`
164+
method are recorded in the current Context. The :meth:`ContextVar.get`
165+
method returns the value associated with the current Context. Thus, exiting
166+
a Context effectively reverts any changes made to context variables while the
167+
Context was entered. (If desired, the values can be restored by re-entering
168+
the Context.)
169+
147170
Context implements the :class:`collections.abc.Mapping` interface.
148171

172+
.. versionadded:: 3.12
173+
A Context object can be used as a :term:`context manager`. The
174+
:meth:`Context.__enter__` and :meth:`Context.__exit__` methods
175+
(automatically called by the :keyword:`with` statement) enters and exits
176+
the Context, respectively. The value returned from
177+
:meth:`Context.__enter__`, and thus bound to the identifier given in the
178+
:keyword:`with` statement's :keyword:`!as` clause if present, is the
179+
Context object itself.
180+
181+
Example:
182+
183+
.. testcode::
184+
185+
import contextvars
186+
187+
var = contextvars.ContextVar("var")
188+
var.set("initial")
189+
190+
# Copy the current Context and enter it.
191+
with contextvars.copy_context() as ctx:
192+
var.set("updated")
193+
assert var in ctx
194+
assert ctx[var] == "updated"
195+
assert var.get() == "updated"
196+
197+
# Exited ctx, so the value of var should have reverted.
198+
assert var.get() == "initial"
199+
# But the updated value is still recorded in ctx.
200+
assert ctx[var] == "updated"
201+
202+
# Re-entering ctx should restore the updated value of var.
203+
with ctx:
204+
assert var.get() == "updated"
205+
149206
.. method:: run(callable, *args, **kwargs)
150207

151-
Execute ``callable(*args, **kwargs)`` code in the context object
152-
the *run* method is called on. Return the result of the execution
153-
or propagate an exception if one occurred.
208+
Enters the Context, executes ``callable(*args, **kwargs)``, then exits the
209+
Context. Returns *callable*'s return value, or propagates an exception if
210+
one occurred.
154211

155-
Any changes to any context variables that *callable* makes will
156-
be contained in the context object::
212+
Example::
157213

158214
var = ContextVar('var')
159215
var.set('spam')
@@ -181,10 +237,6 @@ Manual Context Management
181237
# However, outside of 'ctx', 'var' is still set to 'spam':
182238
# var.get() == 'spam'
183239

184-
The method raises a :exc:`RuntimeError` when called on the same
185-
context object from more than one OS thread, or when called
186-
recursively.
187-
188240
.. method:: copy()
189241

190242
Return a shallow copy of the context object.

Lib/test/test_context.py

+69
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import functools
44
import gc
55
import random
6+
import threading
67
import time
78
import unittest
89
import weakref
@@ -360,6 +361,74 @@ def sub(num):
360361
tp.shutdown()
361362
self.assertEqual(results, list(range(10)))
362363

364+
@isolated_context
365+
def test_context_manager_works(self):
366+
cvar = contextvars.ContextVar('cvar', default='initial')
367+
self.assertEqual(cvar.get(), 'initial')
368+
with contextvars.copy_context():
369+
self.assertEqual(cvar.get(), 'initial')
370+
cvar.set('updated')
371+
self.assertEqual(cvar.get(), 'updated')
372+
self.assertEqual(cvar.get(), 'initial')
373+
374+
def test_context_manager_as_binding(self):
375+
ctx = contextvars.copy_context()
376+
with ctx as ctx_as_binding:
377+
self.assertIs(ctx_as_binding, ctx)
378+
379+
@isolated_context
380+
def test_context_manager_enter_again_after_exit(self):
381+
cvar = contextvars.ContextVar('cvar', default='initial')
382+
self.assertEqual(cvar.get(), 'initial')
383+
with contextvars.copy_context() as ctx:
384+
cvar.set('updated')
385+
self.assertEqual(cvar.get(), 'updated')
386+
self.assertEqual(cvar.get(), 'initial')
387+
with ctx:
388+
self.assertEqual(cvar.get(), 'updated')
389+
self.assertEqual(cvar.get(), 'initial')
390+
391+
@threading_helper.requires_working_threading()
392+
def test_context_manager_rejects_exit_from_different_thread(self):
393+
ctx = contextvars.copy_context()
394+
thread = threading.Thread(target=ctx.__enter__)
395+
thread.start()
396+
thread.join()
397+
with self.assertRaises(RuntimeError):
398+
ctx.__exit__(None, None, None)
399+
400+
def test_context_manager_rejects_recursive_enter_mgr_then_mgr(self):
401+
with contextvars.copy_context() as ctx:
402+
with self.assertRaises(RuntimeError):
403+
with ctx:
404+
pass
405+
406+
def test_context_manager_rejects_recursive_enter_mgr_then_run(self):
407+
with contextvars.copy_context() as ctx:
408+
with self.assertRaises(RuntimeError):
409+
ctx.run(lambda: None)
410+
411+
def test_context_manager_rejects_recursive_enter_run_then_mgr(self):
412+
ctx = contextvars.copy_context()
413+
414+
def fn():
415+
with self.assertRaises(RuntimeError):
416+
with ctx:
417+
pass
418+
419+
ctx.run(fn)
420+
421+
def test_context_manager_rejects_noncurrent_exit(self):
422+
with contextvars.copy_context() as ctx:
423+
with contextvars.copy_context():
424+
with self.assertRaises(RuntimeError):
425+
ctx.__exit__(None, None, None)
426+
427+
def test_context_manager_rejects_nonentered_exit(self):
428+
ctx = contextvars.copy_context()
429+
with self.assertRaises(RuntimeError):
430+
ctx.__exit__(None, None, None)
431+
363432

364433
# HAMT Tests
365434

Misc/ACKS

+1
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,7 @@ Manus Hand
691691
Andreas Hangauer
692692
Milton L. Hankins
693693
Carl Bordum Hansen
694+
Richard Hansen
694695
Stephen Hansen
695696
Barry Hantman
696697
Lynda Hardman
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add :term:`context manager` methods to :class:`contextvars.Context`.

Python/clinic/context.c.h

+68-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Python/context.c

+65-1
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,6 @@ _PyContext_Exit(PyThreadState *ts, PyObject *octx)
153153
}
154154

155155
if (ts->context != (PyObject *)ctx) {
156-
/* Can only happen if someone misuses the C API */
157156
PyErr_SetString(PyExc_RuntimeError,
158157
"cannot exit context: thread state references "
159158
"a different context object");
@@ -558,6 +557,69 @@ context_tp_contains(PyContext *self, PyObject *key)
558557
}
559558

560559

560+
/*[clinic input]
561+
_contextvars.Context.__enter__
562+
563+
Context manager enter.
564+
565+
Automatically called by the 'with' statement. Using the Context object as a
566+
context manager is an alternative to calling the Context.run() method. Example
567+
usage:
568+
569+
var = contextvars.ContextVar('var')
570+
var.set('initial')
571+
572+
with contextvars.copy_context():
573+
# The current Context is a new copy of the previous Context. Updating a
574+
# context variable inside this 'with' statement only affects the new
575+
# copy.
576+
var.set('updated')
577+
do_something_interesting()
578+
579+
# Now that the 'with' statement is done executing, the value of the 'var'
580+
# context variable has reverted back to its value before the 'with'
581+
# statement.
582+
assert var.get() == 'initial'
583+
[clinic start generated code]*/
584+
585+
static PyObject *
586+
_contextvars_Context___enter___impl(PyContext *self)
587+
/*[clinic end generated code: output=7374aea8983b777a input=22868e8274d3cd32]*/
588+
{
589+
PyThreadState *ts = _PyThreadState_GET();
590+
if (_PyContext_Enter(ts, (PyObject *)self)) {
591+
return NULL;
592+
}
593+
return Py_NewRef(self);
594+
}
595+
596+
597+
/*[clinic input]
598+
_contextvars.Context.__exit__
599+
exc_type: object
600+
exc_val: object
601+
exc_tb: object
602+
/
603+
604+
Context manager exit.
605+
606+
Automatically called at the conclusion of a 'with' statement when the Context is
607+
used as a context manager. See the Context.__enter__() method for more details.
608+
[clinic start generated code]*/
609+
610+
static PyObject *
611+
_contextvars_Context___exit___impl(PyContext *self, PyObject *exc_type,
612+
PyObject *exc_val, PyObject *exc_tb)
613+
/*[clinic end generated code: output=4608fa9151f968f1 input=ff70cbbf6a112b1d]*/
614+
{
615+
PyThreadState *ts = _PyThreadState_GET();
616+
if (_PyContext_Exit(ts, (PyObject *)self)) {
617+
return NULL;
618+
}
619+
Py_RETURN_NONE;
620+
}
621+
622+
561623
/*[clinic input]
562624
_contextvars.Context.get
563625
key: object
@@ -677,6 +739,8 @@ context_run(PyContext *self, PyObject *const *args,
677739

678740

679741
static PyMethodDef PyContext_methods[] = {
742+
_CONTEXTVARS_CONTEXT___ENTER___METHODDEF
743+
_CONTEXTVARS_CONTEXT___EXIT___METHODDEF
680744
_CONTEXTVARS_CONTEXT_GET_METHODDEF
681745
_CONTEXTVARS_CONTEXT_ITEMS_METHODDEF
682746
_CONTEXTVARS_CONTEXT_KEYS_METHODDEF

0 commit comments

Comments
 (0)