Skip to content

Frame Objects

Mental Model

Each function call creates a frame -- a snapshot of the function's local world containing its variables, the code it is running, and a pointer back to the caller's frame. Frames stack on top of each other like trays in a cafeteria; when a function returns, its tray is removed and you are back to the caller's context.

Call Stack

1. Function Calls

Each call creates frame:

```python def outer(): return inner()

def inner(): return deepest()

def deepest(): import inspect return inspect.stack()

Three frames stacked

```

2. Frame Structure

```python import inspect

def example(): x = 10 frame = inspect.currentframe()

print(frame.f_locals)   # Local vars
print(frame.f_globals)  # Global vars
print(frame.f_code)     # Code object

```

Frame Contents

1. Local Namespace

python def function(): a = 1 b = 2 frame = inspect.currentframe() print(frame.f_locals) # {'a': 1, 'b': 2, 'frame': ...}

2. Global Namespace

```python GLOBAL_VAR = 100

def function(): frame = inspect.currentframe() print('GLOBAL_VAR' in frame.f_globals) # True ```

Stack Inspection

1. Current Frame

```python import inspect

def show_frame(): frame = inspect.currentframe() print(f"Function: {frame.f_code.co_name}") print(f"Line: {frame.f_lineno}")

show_frame() ```

2. Call Stack

```python def outer(): middle()

def middle(): inner()

def inner(): import inspect for frame_info in inspect.stack(): print(frame_info.function) # inner, middle, outer ```

Frame Lifetime

1. Creation

python def function(): # Frame created at call x = 10 # Frame active return x # Frame destroyed after return

2. Nested Calls

```python def a(): return b() # a's frame stays

def b(): return c() # b's frame stays

def c(): return 42 # c returns, frames unwind ```

Practical Uses

1. Debugging

```python def debug_info(): frame = inspect.currentframe() caller = frame.f_back

print(f"Called from: {caller.f_code.co_name}")
print(f"At line: {caller.f_lineno}")

```

2. Introspection

```python def get_caller_locals(): frame = inspect.currentframe() caller_frame = frame.f_back return caller_frame.f_locals

def example(): x = 10 y = 20 caller_vars = get_caller_locals() print(caller_vars) ```

Frame Attributes

1. Key Attributes

```python import inspect

def show_attributes(): f = inspect.currentframe()

print(f.f_locals)    # Local vars
print(f.f_globals)   # Global vars  
print(f.f_code)      # Code object
print(f.f_lineno)    # Current line
print(f.f_back)      # Caller frame

```

Summary

1. Frame Object

  • Created per function call
  • Contains local namespace
  • Links to globals
  • Stack forms call chain

2. Uses

  • Debugging
  • Introspection
  • Stack traces
  • Context tracking

Runnable Example: frame_introspection.py

```python """ Frame Object Introspection: Generators, f_lasti, and Zombie Frames

Deep exploration of CPython frame objects using generators as an introspection tool. Generators freeze execution mid-function, letting us inspect the frame's internal state at each step.

Topics covered: 1. Generator-based frame stepping (gi_frame, f_lasti) 2. Frame back chain (f_back linked list) 3. Frame locals and code object relationship 4. Zombie frame reuse (memory optimization) 5. Block stack observation via try/except in generators

Based on CPython-Internals Interpreter/frame/frame.md examples. """

import dis import inspect import sys

=============================================================================

1. Generator Frame Stepping with f_lasti

=============================================================================

def demo_generator_frame_stepping(): """Use a generator to observe frame state at each yield point.

Every generator object has a gi_frame attribute pointing to
its execution frame. f_lasti tracks the bytecode offset of
the last executed instruction.
"""
print("=== Generator Frame Stepping ===\n")

def gen(a, b=1, c=2):
    yield a
    c = str(b + c)
    yield c
    new_g = range(3)
    yield from new_g

# Show bytecode so we can correlate f_lasti values
print("--- Bytecode for gen() ---")
dis.dis(gen)
print()

gg = gen("param_a")

# Before first next(): frame exists but hasn't executed
print(f"Before first next():")
print(f"  gi_frame: {gg.gi_frame}")
print(f"  f_lasti:  {gg.gi_frame.f_lasti}")
print(f"  f_locals: {gg.gi_frame.f_locals}")
print()

# Step through and observe f_lasti advancing
step = 0
for val in gg:
    step += 1
    frame = gg.gi_frame
    if frame is not None:
        print(f"Step {step}: yielded {val!r}")
        print(f"  f_lasti:    {frame.f_lasti}")
        print(f"  f_locals:   {frame.f_locals}")
        print(f"  f_lineno:   {frame.f_lineno}")
    else:
        print(f"Step {step}: yielded {val!r}")
        print(f"  gi_frame is None (generator exhausted)")
    print()

# After exhaustion
print(f"After StopIteration:")
print(f"  gi_frame: {gg.gi_frame}")
print()

=============================================================================

2. Frame Back Chain (f_back Linked List)

=============================================================================

def demo_frame_back_chain(): """Each frame's f_back points to the caller's frame, forming a singly linked list (the call stack). """ print("=== Frame Back Chain ===\n")

def show_stack(depth):
    """Recursively descend, then print the frame chain."""
    if depth > 0:
        show_stack(depth - 1)
    else:
        # Walk the f_back chain from current frame
        frame = inspect.currentframe()
        chain = []
        while frame is not None:
            chain.append(
                f"{frame.f_code.co_name}() "
                f"[line {frame.f_lineno}]"
            )
            frame = frame.f_back

        print("Call stack (current -> outermost):")
        for i, entry in enumerate(chain):
            indent = "  " * i
            print(f"  {indent}{entry}")
        print()

show_stack(3)

=============================================================================

3. Frame Locals vs Code Object

=============================================================================

def demo_frame_code_relationship(): """The frame's f_code points to the code object being executed. We can inspect code object metadata through the frame. """ print("=== Frame and Code Object Relationship ===\n")

def example_func(x, y, *args, key=None, **kwargs):
    """A function with various parameter types."""
    local_var = x + y
    frame = inspect.currentframe()

    code = frame.f_code
    print(f"Function: {code.co_name}")
    print(f"  co_argcount:      {code.co_argcount}")
    print(f"  co_varnames:      {code.co_varnames}")
    print(f"  co_nlocals:       {code.co_nlocals}")
    print(f"  co_stacksize:     {code.co_stacksize}")
    print(f"  co_firstlineno:   {code.co_firstlineno}")
    print()
    print(f"  f_locals keys:    {list(frame.f_locals.keys())}")
    print(f"  f_lineno:         {frame.f_lineno}")
    print()

example_func(10, 20, 30, 40, key="test", extra=99)

=============================================================================

4. Zombie Frame Reuse

=============================================================================

def demo_zombie_frame_reuse(): """CPython reuses frame objects for the same code object.

The first frame created for a code object becomes a 'zombie'
frame after execution. Next call to the same function reuses
that frame, saving malloc overhead.

We can observe this with generators: after a generator is
exhausted, the next generator from the same function often
gets a frame at the same memory address.
"""
print("=== Zombie Frame Reuse ===\n")

def simple_gen():
    yield 1

# First generator
g1 = simple_gen()
frame1_id = id(g1.gi_frame)
print(f"g1 frame id: {frame1_id}")

# Exhaust it
list(g1)
print(f"g1 exhausted, gi_frame: {g1.gi_frame}")

# Second generator from same function
g2 = simple_gen()
frame2_id = id(g2.gi_frame)
print(f"g2 frame id: {frame2_id}")

if frame1_id == frame2_id:
    print("  -> Same frame object reused (zombie frame)!")
else:
    print("  -> Different frame object (may vary by Python version)")

# Exhaust g2 and try a third
list(g2)
g3 = simple_gen()
frame3_id = id(g3.gi_frame)
print(f"g3 frame id: {frame3_id}")

if frame3_id == frame1_id:
    print("  -> Same zombie frame reused again!")
print()

=============================================================================

5. Block Stack via Generator Try/Except

=============================================================================

def demo_block_stack(): """Generators with try/except blocks demonstrate how the frame manages exception handling context.

Each try/except/finally creates entries on the frame's
internal block stack (f_iblock in CPython).
"""
print("=== Block Stack via Try/Except in Generator ===\n")

def error_gen():
    """Generator that steps through nested exception handlers."""
    try:
        yield "in try block"
        1 / 0
    except ZeroDivisionError:
        yield "in except ZeroDivisionError"
        try:
            yield "in nested try"
            import nonexistent_module_xyz  # noqa: F401
        except ModuleNotFoundError:
            yield "in except ModuleNotFoundError"
        finally:
            yield "in finally"

gg = error_gen()
step = 0
for val in gg:
    step += 1
    frame = gg.gi_frame
    info = ""
    if frame is not None:
        info = f"  f_lasti={frame.f_lasti}, f_lineno={frame.f_lineno}"
    print(f"  Step {step}: {val!r}{info}")

print(f"\n  After exhaustion: gi_frame = {gg.gi_frame}")
print()

=============================================================================

6. sys._current_frames() — All Thread Frames

=============================================================================

def demo_current_frames(): """sys._current_frames() returns a dict mapping thread IDs to their current frame objects. Useful for debugging hangs. """ print("=== sys._current_frames() ===\n")

frames = sys._current_frames()
print(f"Active threads: {len(frames)}")
for tid, frame in frames.items():
    chain = []
    f = frame
    while f is not None:
        chain.append(f.f_code.co_name)
        f = f.f_back
    print(f"  Thread {tid}: {' -> '.join(chain)}")
print()

=============================================================================

Main

=============================================================================

if name == 'main': demo_generator_frame_stepping() demo_frame_back_chain() demo_frame_code_relationship() demo_zombie_frame_reuse() demo_block_stack() demo_current_frames() ```

Exercises

Exercise 1. Write a function caller_info() that uses inspect.currentframe() to return a dictionary containing the caller's function name, filename, and line number. Test it by calling caller_info() from two different functions and printing the results.

Solution to Exercise 1
```python
import inspect

def caller_info():
    frame = inspect.currentframe()
    caller = frame.f_back
    return {
        "function": caller.f_code.co_name,
        "filename": caller.f_code.co_filename,
        "lineno": caller.f_lineno,
    }

def func_a():
    return caller_info()

def func_b():
    return caller_info()

print(func_a())
print(func_b())
```

Exercise 2. Write a recursive function sum_to(n) that computes 1 + 2 + ... + n. At the base case (n == 0), use inspect.stack() to print the full call chain showing each frame's function name and the value of n at that level.

Solution to Exercise 2
```python
import inspect

def sum_to(n):
    if n == 0:
        print("Call chain at base case:")
        for info in inspect.stack():
            if info.function == "sum_to":
                local_n = info.frame.f_locals.get("n", "?")
                print(f"  sum_to(n={local_n})")
        return 0
    return n + sum_to(n - 1)

result = sum_to(5)
print(f"\nsum_to(5) = {result}")
```

Exercise 3. Write a decorator @trace_locals that, after the decorated function returns, prints all local variables that were in the function's frame (using inspect.currentframe().f_back.f_locals). Apply it to a function that computes and stores intermediate results, and verify the output includes all local variable names and values.

Solution to Exercise 3
```python
import functools
import inspect

def trace_locals(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        # Get the frame of the wrapper to access locals
        # captured after the call; use a different approach:
        return result
    return wrapper

# Alternative approach using sys.settrace:
def trace_locals(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        local_vars = {}
        def tracer(frame, event, arg):
            if event == 'return' and frame.f_code == func.__code__:
                local_vars.update(frame.f_locals)
            return tracer
        import sys
        old_trace = sys.gettrace()
        sys.settrace(tracer)
        try:
            result = func(*args, **kwargs)
        finally:
            sys.settrace(old_trace)
        print(f"Locals in {func.__name__}:")
        for k, v in local_vars.items():
            print(f"  {k} = {v}")
        return result
    return wrapper

@trace_locals
def compute(x, y):
    total = x + y
    product = x * y
    average = total / 2
    return average

compute(10, 20)
```