EDIT: As pointed out by Thierry Lathuille, PEP567, where ContextVar was introduced, was not designed to address generators (unlike the withdrawn PEP550). Still, the main question remains. How do I write stateful context managers that behave correctly with multiple threads, generators and asyncio tasks?
I have a library with some functions that can work in different "modes", so their behavior can be altered by a local context. I am looking at the contextvars module to implement this reliably, so I can use it from different threads, asynchronous contexts, etc. However, I am having trouble getting a simple example working right. Consider this minimal setup:
from contextlib import contextmanager
from contextvars import ContextVar
MODE = ContextVar('mode', default=0)
@contextmanager
def use_mode(mode):
    t = MODE.set(mode)
    try:
        yield
    finally:
        MODE.reset(t)
def print_mode():
   print(f'Mode {MODE.get()}')
Here is a small test with a generator function:
def first():
    print('Start first')
    print_mode()
    with use_mode(1):
        print('In first: with use_mode(1)')
        print('In first: start second')
        it = second()
        next(it)
        print('In first: back from second')
        print_mode()
        print('In first: continue second')
        next(it, None)
        print('In first: finish')
def second():
    print('Start second')
    print_mode()
    with use_mode(2):
        print('In second: with use_mode(2)')
        print('In second: yield')
        yield
        print('In second: continue')
        print_mode()
        print('In second: finish')
first()
I get the following output:
Start first
Mode 0
In first: with use_mode(1)
In first: start second
Start second
Mode 1
In second: with use_mode(2)
In second: yield
In first: back from second
Mode 2
In first: continue second
In second: continue
Mode 2
In second: finish
In first: finish
In the section:
In first: back from second
Mode 2
In first: continue second
It should be Mode 1 instead of Mode 2, because this was printed from first, where the applying context should be, as I understand it, use_mode(1). However, it seems that the use_mode(2) of second is stacked over it until the generator finishes. Are generators not supported by contextvars? If so, is there any way to support stateful context managers reliably? By reliably, I mean it should behave consistently whether I use:
asyncioYou've actually got an "interlocked context" there - without returning the __exit__ part for the second function it will not restore the context
with ContextVars, no matter what.
So, I came up with something here - and the best thing I could think of
is a decorator to explicit declare which callables will have their own context -
I created a ContextLocal class which works as a namespace, just like thread.local - and attributes in that namespace should behave properly as you expect.
I am finishing the code now - so I had not tested it yet for async or multi-threading, but it should work. If you can help me write a proper test, the solution below could become a Python package in itself.
(I had to resort to injecting an object in generator and co-routines frames locals  dictionary in order to clean up the context registry once a generator or co-routine is over - there is PEP 558 formalizing the behavior of locals() for Python 3.8+, and I don't remember now if this injection is allowed - it works up to 3.8 beta 3, though, so I think this usage is valid).
Anyway, here is the code (named as context_wrapper.py):
"""
Super context wrapper -
meant to be simpler to use and work in more scenarios than
Python's contextvars.
Usage:
Create one or more project-wide instances of "ContextLocal"
Decorate your functions, co-routines, worker-methods and generators
that should hold their own states with that instance's `context` method -
and use the instance as namespace for private variables that will be local
and non-local until entering another callable decorated
with `intance.context` - that will create a new, separated scope
visible inside  the decorated callable.
"""
import sys
from functools import wraps
__author__ = "João S. O. Bueno"
__license__ = "LGPL v. 3.0+"
class ContextError(AttributeError):
    pass
class ContextSentinel:
    def __init__(self, registry, key):
        self.registry = registry
        self.key = key
    def __del__(self):
        del self.registry[self.key]
_sentinel = object()
class ContextLocal:
    def __init__(self):
        super().__setattr__("_registry", {})
    def _introspect_registry(self, name=None):
        f = sys._getframe(2)
        while f:
            h = hash(f)
            if h in self._registry and (name is None or name in self._registry[h]):
                return self._registry[h]
            f = f.f_back
        if name:
            raise ContextError(f"{name !r} not defined in any previous context")
        raise ContextError("No previous context set")
    def __getattr__(self, name):
        namespace = self._introspect_registry(name)
        return namespace[name]
    def __setattr__(self, name, value):
        namespace = self._introspect_registry()
        namespace[name] = value
    def __delattr__(self, name):
        namespace = self._introspect_registry(name)
        del namespace[name]
    def context(self, callable_):
        @wraps(callable_)
        def wrapper(*args, **kw):
            f = sys._getframe()
            self._registry[hash(f)] = {}
            result = _sentinel
            try:
                result = callable_(*args, **kw)
            finally:
                del self._registry[hash(f)]
                # Setup context for generator or coroutine if one was returned:
                if result is not _sentinel:
                    frame = getattr(result, "gi_frame", getattr(result, "cr_frame", None))
                    if frame:
                        self._registry[hash(frame)] = {}
                        frame.f_locals["$context_sentinel"] = ContextSentinel(self._registry, hash(frame))
            return result
        return wrapper
Here is the modified version of your example to use with it:
from contextlib import contextmanager
from context_wrapper import ContextLocal
ctx = ContextLocal()
@contextmanager
def use_mode(mode):
    ctx.MODE = mode
    print("entering use_mode")
    print_mode()
    try:
        yield
    finally:
        pass
def print_mode():
   print(f'Mode {ctx.MODE}')
@ctx.context
def first():
    ctx.MODE = 0
    print('Start first')
    print_mode()
    with use_mode(1):
        print('In first: with use_mode(1)')
        print('In first: start second')
        it = second()
        next(it)
        print('In first: back from second')
        print_mode()
        print('In first: continue second')
        next(it, None)
        print('In first: finish')
        print_mode()
    print("at end")
    print_mode()
@ctx.context
def second():
    print('Start second')
    print_mode()
    with use_mode(2):
        print('In second: with use_mode(2)')
        print('In second: yield')
        yield
        print('In second: continue')
        print_mode()
        print('In second: finish')
first()
Here is the output of running that:
Start first
Mode 0
entering use_mode
Mode 1
In first: with use_mode(1)
In first: start second
Start second
Mode 1
entering use_mode
Mode 2
In second: with use_mode(2)
In second: yield
In first: back from second
Mode 1
In first: continue second
In second: continue
Mode 2
In second: finish
In first: finish
Mode 1
at end
Mode 1
(it will be slower than native contextvars by orders of magnitude as those are built-in Python runtime native code - but it seems easier to wrap-the-mind around to use by the same amount)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With