Python `with` context vs generators/coroutines/tasks

I want to experiment with using python with blocks to apply modifiers to action within that block. But I'm not sure if it's possible to do this sensibly in the presence of coroutines.

For example, suppose I have a WithContext object that temporarily pushes onto a stack like this:

class WithContext:
    stack = []
    def __init__(self, val):
        self.val = val
    def __enter__(self):
        WithContext.stack.append(self.val)
    def __exit__(self, exc_type, exc_val, exc_tb):
        WithContext.stack.pop()
def do_scoped_contextual_thing():
    print(WithContext.stack[-1])

(Obviously the stack member would have to be thread-local, but ignore that for now.)

Then this code:

with WithContext("a"):
    do_scoped_contextual_thing()
    with WithContext("b"):
        do_scoped_contextual_thing()
with WithContext("c"):
   do_scoped_contextual_thing()

Will print:

a
b
c

But now suppose I have a coroutine situation:

def coroutine():
    with WithContext("inside"):
        yield 1
        do_scoped_contextual_thing()
        yield 2

with WithContext("outside"):
    for e in coroutine():
        do_scoped_contextual_thing()
        print("got " + str(e))

I want this code to output:

outside
got 1
inside
outside
got 2

But actually it will output:

inside
got 1
inside
inside
got 2

The outsides changed to insides because the __enter__ inside the coroutine put a value on top of the stack, and __exit__ isn't called until the coroutine ends (instead of constantly enter-ing and exit-ing as you bounce in and out of the coroutine).

Is there a way to work around this problem? Are there "coroutine-local" variables?


I don't feel great about this, but I did modify your test code to re-enter the coroutine a few times. Similar to @CraigGidney's solution, this uses the inspect module to access and cache information on the call stack (aka, the "scope") in which a WithContext object is created.

I then basically search up the stack looking for a cached value, and use the id function to try and avoid holding references to the actual frame objects.

import inspect

class WithContext:
    stack = []
    frame_to_stack = {}
    def __init__(self, val):
        self.val = val
    def __enter__(self):
        stk = inspect.stack(context=3)
        caller_id = id(stk[1].frame)
        WithContext.frame_to_stack[caller_id] = len(WithContext.stack)
        WithContext.stack.append( (caller_id, self.val))

    def __exit__(self, exc_type, exc_val, exc_tb):
        wc = WithContext.stack.pop()
        del WithContext.frame_to_stack[wc[0]]

def do_scoped_contextual_thing():
    stack = inspect.stack(context=0)
    f2s = WithContext.frame_to_stack

    for f in stack:
        wcx = f2s.get(id(f.frame))

        if wcx is not None:
            break
    else:
        raise ValueError("No context object in scope.")

    print(WithContext.stack[wcx][1])

def coroutine():
    with WithContext("inside"):
        for n in range(3):
            yield 1
            do_scoped_contextual_thing()
            yield 2

with WithContext("outside"):
    for e in coroutine():
        do_scoped_contextual_thing()
        print("got " + str(e))

One possible half-broken "solution" is to associate the context with a stack frame's location, and check for that location when looking up the context.

class WithContext:
    _stacks = defaultdict(list)

    def __init__(self, val):
        self.val = val

    def __enter__(self):
        _, file, _, method, _, _ = inspect.stack()[1]
        WithContext._stacks[(file, method)].append(self.val)

    def __exit__(self, exc_type, exc_val, exc_tb):
        _, file, _, method, _, _ = inspect.stack()[1]
        WithContext._stacks[(file, method)].pop()

    @staticmethod
    def get_context():
        for frame in inspect.stack()[1:]:
            _, file, _, method, _, _ = frame
            r = WithContext._stacks[(file, method)]
            if r:
                return r[-1]
        raise ValueError("no context")

Note that constantly looking up stack frames is more expensive than just passing values around, and that you may not want to tell people you wrote this.

Note that this will still break in more complicated situations.

For example:

  • What if the same method is on the stack twice?
  • What if the generator is iterated a bit from one place, then a bit more from another place?
  • What about recursive generators?
  • What about async methods?

  • I had the same problem. Esentially I wanted to be able to execute code upon entering/leaving the running context of a coroutine, in my case to keep a call stack of sorts that is correct even in the case of interleaved yield s. It turns out tornado has support for this in the form of a StackContext which can be used as follows:

    @gen.coroutine
    def correct():
        yield run_with_stack_context(StackContext(ctx), other_coroutine)
    

    where ctx is a context manager that will enter and exit while the event loop is executing other_coroutine .

    See https://github.com/muhrin/plumpy/blob/8d6cd97d8b521e42f124e77b08bb34c8375cd1b8/plumpy/processes.py#L467 for how I use it.

    I've not looked into the implementation but tornado v5 switched to using asyncio as their default event loop so it should be compatible with that too.

    链接地址: http://www.djcxy.com/p/53194.html

    上一篇: 状态机表示

    下一篇: 具有`上下文与发生器/协程/任务的Python'