title: Why does symbol definition statement inside exec() sometimes have no effect on the local symbol table?
body: <p>The following code snippet works as expected:</p>
  <pre><code>def test():
      print(f'local symbol table before exec : {locals()}')
      exec('a = 0')
      print(f'local symbol table after exec  : {locals()}')
  
  test()
  # printed result:
  # local symbol table before exec : {}
  # local symbol table after exec  : {'a': 0}
  </code></pre>
  <p>However, once I add a symbol definition statement <code>a = 1</code> at the end of <code>test</code> function, it seems that the <code>exec</code> statement has no effect on the local symbol table:</p>
  <pre><code>def test():
      print(f'local symbol table before exec : {locals()}')
      exec('a = 0')
      print(f'local symbol table after exec  : {locals()}')
      a = 1
  
  test()
  # printed result:
  # local symbol table before exec : {}
  # local symbol table after exec  : {}
  </code></pre>
  <p><strong>So, why is this happening?</strong></p>
  <p>Here is my guess: symbols statically defined inside a function will be reserved at compile time in some way, and any symbol definition statements dynamically called inside the exec function wont be able to modify the local symbol table if the symbol is already reserved.</p>
  <p>Is that true? What is actually going on during the compile time?</p>
  <hr />
  <p><strong>Extra Test 1</strong>: replacing the exec argument with <code>'a = 0\nprint(locals())'</code></p>
  <pre><code>def test():
      print(f'local symbol table before exec : {locals()}')
      exec('a = 0\nprint(locals())')
      print(f'local symbol table after exec  : {locals()}')
  
  
  test()
  # printed result:
  # local symbol table before exec : {}
  # {'a': 0}
  # local symbol table after exec  : {'a': 0}
  </code></pre>
  <pre><code>def test():
      print(f'local symbol table before exec : {locals()}')
      exec('a = 0\nprint(locals())')
      print(f'local symbol table after exec  : {locals()}')
      a = 1
  
  
  test()
  # printed result:
  # local symbol table before exec : {}
  # {'a': 0}
  # local symbol table after exec  : {}
  </code></pre>
  <p>As we can see, the symbol <code>a</code> was successfully added to the local symbol table during the <code>exec()</code> execution, but it magically disappeared right after that with the existence of <code>a = 1</code>.</p>
  <hr />
  <p><strong>Extra Test 2</strong>: adding <code>return</code> statement before <code>a = 1</code></p>
  <pre><code>def test():
      print(f'local symbol table before exec : {locals()}')
      exec('a = 0\nprint(locals())')
      print(f'local symbol table after exec  : {locals()}')
      return
  
  
  test()
  # printed result:
  # local symbol table before exec : {}
  # {'a': 0}
  # local symbol table after exec  : {'a': 0}
  </code></pre>
  <pre><code>def test():
      print(f'local symbol table before exec : {locals()}')
      exec('a = 0\nprint(locals())')
      print(f'local symbol table after exec  : {locals()}')
      return
      a = 1
  
  
  test()
  # printed result:
  # local symbol table before exec : {}
  # {'a': 0}
  # local symbol table after exec  : {}
  </code></pre>
  <p>The <code>a = 1</code> is unreachable in the second <code>test()</code> function, but it still affects the behaviour of <code>exec()</code>.</p>
  <p>Even the <code>dis()</code> function from <code>dis</code> module can't tell the difference between these two <code>test()</code> functions. The outputs are exactly the same, which is shown below:</p>
  <pre><code>  5           0 LOAD_GLOBAL              0 (print)
                2 LOAD_CONST               1 ('local symbol table before exec : ')
                4 LOAD_GLOBAL              1 (locals)
                6 CALL_FUNCTION            0
                8 FORMAT_VALUE             0
               10 BUILD_STRING             2
               12 CALL_FUNCTION            1
               14 POP_TOP
  
    6          16 LOAD_GLOBAL              2 (exec)
               18 LOAD_CONST               2 ('a = 0\nprint(locals())')
               20 CALL_FUNCTION            1
               22 POP_TOP
  
    7          24 LOAD_GLOBAL              0 (print)
               26 LOAD_CONST               3 ('local symbol table after exec  : ')
               28 LOAD_GLOBAL              1 (locals)
               30 CALL_FUNCTION            0
               32 FORMAT_VALUE             0
               34 BUILD_STRING             2
               36 CALL_FUNCTION            1
               38 POP_TOP
  
    8          40 LOAD_CONST               0 (None)
               42 RETURN_VALUE
  </code></pre>