Skip to content

Commit 45a8327

Browse files
committed
gh-99633: Add context manager support to contextvars.Context
1 parent 6d8da23 commit 45a8327

File tree

6 files changed

+215
-11
lines changed

6 files changed

+215
-11
lines changed

Doc/library/contextvars.rst

Lines changed: 61 additions & 9 deletions
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
210+
if 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

Lines changed: 57 additions & 0 deletions
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,62 @@ def sub(num):
360361
tp.shutdown()
361362
self.assertEqual(results, list(range(10)))
362363

364+
@isolated_context
365+
def test_context_manager_1(self):
366+
cvar = contextvars.ContextVar('cvar', default='initial')
367+
self.assertEqual(cvar.get(), 'initial')
368+
ctx = contextvars.copy_context()
369+
with ctx as tmp:
370+
self.assertIs(tmp, ctx)
371+
self.assertEqual(cvar.get(), 'initial')
372+
cvar.set('updated')
373+
self.assertEqual(cvar.get(), 'updated')
374+
self.assertEqual(cvar.get(), 'initial')
375+
self.assertEqual(ctx[cvar], 'updated')
376+
with ctx:
377+
self.assertEqual(cvar.get(), 'updated')
378+
379+
@threading_helper.requires_working_threading()
380+
def test_context_manager_2(self):
381+
ctx = contextvars.copy_context()
382+
thread = threading.Thread(target=ctx.__enter__)
383+
thread.start()
384+
thread.join()
385+
with self.assertRaises(RuntimeError):
386+
ctx.__exit__(None, None, None)
387+
388+
def test_context_manager_3(self):
389+
with contextvars.copy_context() as ctx:
390+
with self.assertRaises(RuntimeError):
391+
with ctx:
392+
pass
393+
394+
def test_context_manager_4(self):
395+
with contextvars.copy_context() as ctx:
396+
with self.assertRaises(RuntimeError):
397+
ctx.run(lambda: None)
398+
399+
def test_context_manager_5(self):
400+
ctx = contextvars.copy_context()
401+
402+
def fn():
403+
with self.assertRaises(RuntimeError):
404+
with ctx:
405+
pass
406+
407+
ctx.run(fn)
408+
409+
def test_context_manager_6(self):
410+
with contextvars.copy_context() as ctx:
411+
with contextvars.copy_context():
412+
with self.assertRaises(RuntimeError):
413+
ctx.__exit__(None, None, None)
414+
415+
def test_context_manager_7(self):
416+
ctx = contextvars.copy_context()
417+
with self.assertRaises(RuntimeError):
418+
ctx.__exit__(None, None, None)
419+
363420

364421
# HAMT Tests
365422

Misc/ACKS

Lines changed: 1 addition & 0 deletions
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
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add context manager methods to :class:`contextvars.Context`.

Python/clinic/context.c.h

Lines changed: 52 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Python/context.c

Lines changed: 43 additions & 1 deletion
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,47 @@ context_tp_contains(PyContext *self, PyObject *key)
558557
}
559558

560559

560+
/*[clinic input]
561+
_contextvars.Context.__enter__
562+
563+
Context manager enter.
564+
[clinic start generated code]*/
565+
566+
static PyObject *
567+
_contextvars_Context___enter___impl(PyContext *self)
568+
/*[clinic end generated code: output=7374aea8983b777a input=093b5c4808ddf1b1]*/
569+
{
570+
PyThreadState *ts = _PyThreadState_GET();
571+
if (_PyContext_Enter(ts, (PyObject *)self)) {
572+
return NULL;
573+
}
574+
return Py_NewRef(self);
575+
}
576+
577+
578+
/*[clinic input]
579+
_contextvars.Context.__exit__
580+
exc_type: object
581+
exc_val: object
582+
exc_tb: object
583+
/
584+
585+
Context manager exit.
586+
[clinic start generated code]*/
587+
588+
static PyObject *
589+
_contextvars_Context___exit___impl(PyContext *self, PyObject *exc_type,
590+
PyObject *exc_val, PyObject *exc_tb)
591+
/*[clinic end generated code: output=4608fa9151f968f1 input=31b822b221c4439b]*/
592+
{
593+
PyThreadState *ts = _PyThreadState_GET();
594+
if (_PyContext_Exit(ts, (PyObject *)self)) {
595+
return NULL;
596+
}
597+
Py_RETURN_FALSE;
598+
}
599+
600+
561601
/*[clinic input]
562602
_contextvars.Context.get
563603
key: object
@@ -677,6 +717,8 @@ context_run(PyContext *self, PyObject *const *args,
677717

678718

679719
static PyMethodDef PyContext_methods[] = {
720+
_CONTEXTVARS_CONTEXT___ENTER___METHODDEF
721+
_CONTEXTVARS_CONTEXT___EXIT___METHODDEF
680722
_CONTEXTVARS_CONTEXT_GET_METHODDEF
681723
_CONTEXTVARS_CONTEXT_ITEMS_METHODDEF
682724
_CONTEXTVARS_CONTEXT_KEYS_METHODDEF

0 commit comments

Comments
 (0)