Skip to content

gh-129889: Support context manager protocol by contextvars.Token #129888

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Feb 12, 2025
15 changes: 15 additions & 0 deletions Doc/library/contextvars.rst
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,21 @@ Context Variables
the value of the variable to what it was before the corresponding
*set*.

The token supports :ref:`context manager protocol <context-managers>`
to restore the corresponding context variable value at the exit from
:keyword:`with` block::

var = ContextVar('var', default='default value')

with var.set('new value'):
assert var.get() == 'new value'

assert var.get() == 'default value'

.. versionadded:: next

Added support for usage as a context manager.

.. attribute:: Token.var

A read-only property. Points to the :class:`ContextVar` object
Expand Down
8 changes: 7 additions & 1 deletion Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

****************************
What's new in Python 3.14
****************************
Expand Down Expand Up @@ -362,6 +361,13 @@ concurrent.futures
supplying a *mp_context* to :class:`concurrent.futures.ProcessPoolExecutor`.
(Contributed by Gregory P. Smith in :gh:`84559`.)

contextvars
-----------

* Support context manager protocol by :class:`contextvars.Token`.
(Contributed by Andrew Svetlov in :gh:`129889`.)


ctypes
------

Expand Down
109 changes: 109 additions & 0 deletions Lib/test/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,115 @@ def sub(num):
tp.shutdown()
self.assertEqual(results, list(range(10)))

def test_token_contextmanager_with_default(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe more test with a re-entrant context? as well as a test when an exception is raised in the context body?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review, I'll add tests.
I don't expect any problems though since the implementation is really trivial.

Copy link
Member

@picnixz picnixz Feb 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Up to you though! Generally, I'm not actually adding those kind of tests to test the implementation but rather to spot possible regressions if we change something. I actually don't know if we are that pedantic when testing other context managers so feel free not to burden the tests if you think it's not worth it!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests are cheap and easy to write,
I've added two: one for exit when an exception is raised and another for reentrancy.

Please feel free to ask for additional tests if needed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Small question, but is there a necessity to check what happens if we call c.reset() inside the context manager? or multiple c.set() as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple c.set() doesn't affect the context manager, the value will be reset on exit.
c.reset() is safe if the other token was used; resetting with the same token raises RuntimeErorr as in the following example:

with c.set(1) as token:
    c.reset(token)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple additional tests were added to demonstrate mentioned scenarios.

ctx = contextvars.Context()
c = contextvars.ContextVar('c', default=42)

def fun():
with c.set(36):
self.assertEqual(c.get(), 36)

self.assertEqual(c.get(), 42)

ctx.run(fun)

def test_token_contextmanager_without_default(self):
ctx = contextvars.Context()
c = contextvars.ContextVar('c')

def fun():
with c.set(36):
self.assertEqual(c.get(), 36)

with self.assertRaisesRegex(LookupError, "<ContextVar name='c'"):
c.get()

ctx.run(fun)

def test_token_contextmanager_on_exception(self):
ctx = contextvars.Context()
c = contextvars.ContextVar('c', default=42)

def fun():
with c.set(36):
self.assertEqual(c.get(), 36)
raise ValueError("custom exception")

self.assertEqual(c.get(), 42)

with self.assertRaisesRegex(ValueError, "custom exception"):
ctx.run(fun)

def test_token_contextmanager_reentrant(self):
ctx = contextvars.Context()
c = contextvars.ContextVar('c', default=42)

def fun():
token = c.set(36)
with self.assertRaisesRegex(
RuntimeError,
"<Token .+ has already been used once"
):
with token:
with token:
self.assertEqual(c.get(), 36)

self.assertEqual(c.get(), 42)

ctx.run(fun)

def test_token_contextmanager_multiple_c_set(self):
ctx = contextvars.Context()
c = contextvars.ContextVar('c', default=42)

def fun():
with c.set(36):
self.assertEqual(c.get(), 36)
c.set(24)
self.assertEqual(c.get(), 24)
c.set(12)
self.assertEqual(c.get(), 12)

self.assertEqual(c.get(), 42)

ctx.run(fun)

def test_token_contextmanager_with_explicit_reset_the_same_token(self):
ctx = contextvars.Context()
c = contextvars.ContextVar('c', default=42)

def fun():
with self.assertRaisesRegex(
RuntimeError,
"<Token .+ has already been used once"
):
with c.set(36) as token:
self.assertEqual(c.get(), 36)
c.reset(token)

self.assertEqual(c.get(), 42)

self.assertEqual(c.get(), 42)

ctx.run(fun)

def test_token_contextmanager_with_explicit_reset_another_token(self):
ctx = contextvars.Context()
c = contextvars.ContextVar('c', default=42)

def fun():
with c.set(36):
self.assertEqual(c.get(), 36)

token = c.set(24)
self.assertEqual(c.get(), 24)
c.reset(token)
self.assertEqual(c.get(), 36)

self.assertEqual(c.get(), 42)

ctx.run(fun)


# HAMT Tests

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Support context manager protocol by :class:`contextvars.Token`. Patch by
Andrew Svetlov.
53 changes: 52 additions & 1 deletion Python/clinic/context.c.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 38 additions & 0 deletions Python/context.c
Original file line number Diff line number Diff line change
Expand Up @@ -1231,9 +1231,47 @@ static PyGetSetDef PyContextTokenType_getsetlist[] = {
{NULL}
};

/*[clinic input]
_contextvars.Token.__enter__ as token_enter

Enter into Token context manager.
[clinic start generated code]*/

static PyObject *
token_enter_impl(PyContextToken *self)
/*[clinic end generated code: output=9af4d2054e93fb75 input=41a3d6c4195fd47a]*/
{
return Py_NewRef(self);
}

/*[clinic input]
_contextvars.Token.__exit__ as token_exit

type: object
val: object
tb: object
/

Exit from Token context manager, restore the linked ContextVar.
[clinic start generated code]*/

static PyObject *
token_exit_impl(PyContextToken *self, PyObject *type, PyObject *val,
PyObject *tb)
/*[clinic end generated code: output=3e6a1c95d3da703a input=7f117445f0ccd92e]*/
{
int ret = PyContextVar_Reset((PyObject *)self->tok_var, (PyObject *)self);
if (ret < 0) {
return NULL;
}
Py_RETURN_NONE;
}

static PyMethodDef PyContextTokenType_methods[] = {
{"__class_getitem__", Py_GenericAlias,
METH_O|METH_CLASS, PyDoc_STR("See PEP 585")},
TOKEN_ENTER_METHODDEF
TOKEN_EXIT_METHODDEF
{NULL}
};

Expand Down
Loading