2

I'm trying to make a quick and dirty caching system for Python, using the trick that a context-manager can be made to conditionally skip the code in its context — see Skipping execution of -with- block. I've stumbled upon a weird failure case of this and I was wondering if someone can help understand and fix this.

Before anyone says this, I know what I'm doing is terrible and I shouldn't do it, etc, etc.

Anyway, here is the code for the tricky context manager:

import sys
import inspect

class SkippableContext(object):

    def __init__(self,mode=0):
        """
        if mode = 0, proceed as normal
        if mode = 1, do not execute block
        """
        self.mode=mode

    def __enter__(self):
        if self.mode==1:
            print('   ... Skipping Context')
            # Do some magic
            sys.settrace(lambda *args, **keys: None)
            frame = inspect.currentframe(1)
            frame.f_trace = self.trace

        return 'SET BY TRICKY CONTEXT MANAGER!!'

    def trace(self, frame, event, arg):
        raise

    def __exit__(self, type, value, traceback):
        return True

And here is the test code:

print('==== First Pass with skipping disabled ====')

c='not set'
with SkippableContext(mode=0) as c:
    print('Should Get into here')
    c = 'set in context'
print('c: {}'.format(c))

print('==== Second Pass with skipping enabled ====')

c='not set'
with SkippableContext(mode=1) as c:
    print('This code is not printed')
    c = 'set in context'
print('c: {}'.format(c))

c='not set'
with SkippableContext(mode=1) as c:
    print('This code is not printed')
    c = 'set in context'
print('c: {}'.format(c))

print('==== Third Pass: Same as second pass but in a loop ====')

for i in range(2):
    c='not set'
    with SkippableContext(mode=1) as c:  # For some reason, assinging c fails on the second iteration!
        print('This code is not printed')
        c = 'set in context'
    print('c: {}'.format(c))

The output generated by the test code is as expected, except for the very last line, where c is not set:

==== First Pass with skipping disabled ====
Should Get into here
c: set in context
==== Second Pass with skipping enabled ====
   ... Skipping Context
c: SET BY TRICKY CONTEXT MANAGER!!
   ... Skipping Context
c: SET BY TRICKY CONTEXT MANAGER!!
==== Third Pass: Same as second pass but in a loop ====
   ... Skipping Context
c: SET BY TRICKY CONTEXT MANAGER!!
   ... Skipping Context
c: not set

Why is c not set in the second run of the loop? Is there some hack to fix the bug in this hack?

martineau
  • 119,623
  • 25
  • 170
  • 301
Peter
  • 12,274
  • 9
  • 71
  • 86
  • 1
    What is the `1` passed at `inspect.currentframe(1)`? I'm not seeing `currentFrame` taking any arguments. – Carcigenicate Aug 22 '19 at 22:36
  • That's for "1 step up in the stack" - ie. the frame of the caller of the context manager. – Peter Aug 22 '19 at 22:37
  • Weird. I can't find a single doc reference to a function `inspect.currentframe` that takes an argument. This doesn't run for me in IntelliJ. I just wanted to play around with it. That answer is in Python 2 seemingly, but even the 2.7 docs show it without any parameters. – Carcigenicate Aug 22 '19 at 22:45
  • When I execute your test code, on the _second pass_, the result is: `TypeError: currentframe() takes 0 positional arguments but 1 was given` — so it never even gets to the loop. – martineau Aug 22 '19 at 23:51
  • Ya, I'm not sure what `inspect` this is expecting. – Carcigenicate Aug 23 '19 at 01:11
  • 1
    AFAIK, `frame = sys._getframe(1)` would do the right thing — but that doesn't fix/explain the loop issue. – martineau Aug 23 '19 at 01:14
  • 1
    To make things clear for everyone, **in *Python 2* (even in *.7.16*), `inspect.currentframe` is defined as `sys._getframe`** (although the doc doesn't reflect it). Also, the behavior can be generalized: when increasing the loop steps, the even (*0* based) iterations would yield `SET BY TRICKY CONTEXT MANAGER!!` while odd ones would yield `not set`. – CristiFati Aug 23 '19 at 21:49
  • For anyone crawling down this rabbithole in the future, I ended up abandoning this approach in favour of of something only slightly less hacky: https://stackoverflow.com/a/58647294/851699 – Peter Oct 31 '19 at 16:22
  • You may be interested in a very similar edge case where the `as` variable is not accessible after the context: https://stackoverflow.com/questions/12594148/skipping-execution-of-with-block#comment104091057_54765496 – bers Jan 14 '20 at 14:13

1 Answers1

4

The awful hack you're using does a lot of things with nasty, subtle consequences. I doubt the author fully understood it (if they did, they wouldn't have used a bare raise, and they wouldn't have tried to pass inspect.currentframe an argument it doesn't take). Incidentally, the incorrect usage of inspect.currentframe causes the code to fail with a TypeError instead of doing what you describe, so for the rest of this answer, I'll assume that call is replaced with sys._getframe(1), which produces the described behavior.


One of the things the hack relies on is setting a local trace function with frame.f_trace = self.trace. This local trace function will raise an exception on the first line inside the with block... or at least, that's what it normally does.

Python calls trace functions when certain trace events happen. One of those trace events is the start of a new source line. Python determines that a new source line has started by checking whether the current bytecode instruction index corresponds to either the first instruction of a line, or an instruction at an index prior to the last instruction executed. You can see that in maybe_call_line_trace in Python/ceval.c.

Python only updates instr_prev, the variable used to determine the last instruction executed, when tracing is active. However, once the local trace function raises an exception, it is automatically deactivated, and instr_prev stops receiving updates.

When the local trace function is set, the next two instructions it could activate on are the STORE_NAME to set c (or STORE_FAST if you put the code in a function), and the LOAD_NAME to load the print function for the next line (or LOAD_GLOBAL if you put the code in a function).

The first time through the loop, it activates on LOAD_NAME, and instr_prev is set to that instruction's index. The local trace function is then disabled, because it raised an exception.

The second time through the loop, instr_prev is still set to the index of the LOAD_NAME, so Python thinks the STORE_NAME marks the beginning of a new line. The local trace function activates on STORE_NAME, and the exception prevents the assignment to c.

You can see the instructions where the local trace function activates by inspecting frame.f_lasti in trace, and comparing the results to the instruction indices in the output of dis.dis. For example, the following variant of your code:

import sys
import inspect
import dis

class SkippableContext(object):
    def __enter__(self):
        print('   ... Skipping Context')
        sys.settrace(lambda *args, **keys: None)
        frame = sys._getframe(1)
        frame.f_trace = self.trace
        return 'SET BY TRICKY CONTEXT MANAGER!!'

    def trace(self, frame, event, arg):
        print(frame.f_lasti)
        raise Exception

    def __exit__(self, type, value, traceback):
        return True

def f():
    for i in range(2):
        c='not set'
        with SkippableContext() as c:
            print('This code is not printed')
            c = 'set in context'
        print('c: {}'.format(c))

f()
dis.dis(f)

produces the following output:

   ... Skipping Context
26
c: SET BY TRICKY CONTEXT MANAGER!!
   ... Skipping Context
24
c: not set
 21           0 SETUP_LOOP              64 (to 66)
              2 LOAD_GLOBAL              0 (range)
              4 LOAD_CONST               1 (2)
              6 CALL_FUNCTION            1
              8 GET_ITER
        >>   10 FOR_ITER                52 (to 64)
             12 STORE_FAST               0 (i)

 22          14 LOAD_CONST               2 ('not set')
             16 STORE_FAST               1 (c)

 23          18 LOAD_GLOBAL              1 (SkippableContext)
             20 CALL_FUNCTION            0
             22 SETUP_WITH              18 (to 42)
             24 STORE_FAST               1 (c)

 24          26 LOAD_GLOBAL              2 (print)
             28 LOAD_CONST               3 ('This code is not printed')
             30 CALL_FUNCTION            1
             32 POP_TOP

 25          34 LOAD_CONST               4 ('set in context')
             36 STORE_FAST               1 (c)
             38 POP_BLOCK
             40 LOAD_CONST               0 (None)
        >>   42 WITH_CLEANUP_START
             44 WITH_CLEANUP_FINISH
             46 END_FINALLY

 26          48 LOAD_GLOBAL              2 (print)
             50 LOAD_CONST               5 ('c: {}')
             52 LOAD_METHOD              3 (format)
             54 LOAD_FAST                1 (c)
             56 CALL_METHOD              1
             58 CALL_FUNCTION            1
             60 POP_TOP
             62 JUMP_ABSOLUTE           10
        >>   64 POP_BLOCK
        >>   66 LOAD_CONST               0 (None)
             68 RETURN_VALUE

The 26 printed the first time corresponds to the index of the LOAD_GLOBAL, and the 24 printed the second time corresponds to the index of the STORE_FAST.

user2357112
  • 260,549
  • 28
  • 431
  • 505
  • 2
    The article is years old, from *Python 2* "era", where `inspect.currentframe(level)` was *OK*, even if not documented (check my comment to the question). – CristiFati Aug 23 '19 at 21:46
  • Thank you for this detailed explanation. I think I understand the mechanism now. This also made me notice that it will actually skip the `as c:` on odd iterations, not just after the first. I'll see if I can use this to find a solution or at least detect the loop case and raise an exception. – Peter Aug 26 '19 at 17:00