Python Stack Frames Practice Problems & Exercises
Practice: Stack Frames and Call Stack
← Back to lessonEasy
Walk the call stack and print every frame name. Call a() which calls b() which calls c() which calls show_stack(). Inside show_stack, use inspect.stack() to print each frame name from innermost to outermost.
import inspect
def show_stack():
stack = inspect.stack()
print("Stack (innermost first):")
for i, frame_info in enumerate(stack):
print(f" [{i}] {frame_info.function}")
print(f"Total frames: {len(stack)}")
def c():
show_stack()
def b():
c()
def a():
b()
a()Solution
Stack (innermost first):
[0] show_stack
[1] c
[2] b
[3] a
[4] <module>
Total frames: 5
How the call stack builds up:
- The module calls
a()— frame forais pushed onto the stack. a()callsb()— frame forbis pushed.b()callsc()— frame forcis pushed.c()callsshow_stack()— frame forshow_stackis pushed.
At this point there are 5 frames: show_stack (top/innermost), c, b, a, and <module> (bottom/outermost).
inspect.stack() returns a list ordered from innermost to outermost, which is why show_stack is at index 0 and <module> is at the end.
Expected Output
Stack (innermost first):
[0] show_stack
[1] c
[2] b
[3] a
[4] <module>
Total frames: 5Hints
Hint 1: `inspect.stack()` returns frames from innermost (current function) to outermost (module level).
Hint 2: Each entry in the list has a `.function` attribute that gives you the function name as a string.
Catch an exception and programmatically extract information from its traceback. The call chain is process_request -> fetch_user -> validate_id. Catch the ValueError, count the traceback entries, and identify the innermost and outermost function names.
import sys
def validate_id(user_id):
if user_id < 0:
raise ValueError(f"invalid user ID: {user_id}")
return user_id
def fetch_user(user_id):
valid_id = validate_id(user_id)
return {"id": valid_id, "name": "Alice"}
def process_request():
try:
user = fetch_user(-1)
except ValueError as e:
print("Error caught!")
print(f"Exception type: {type(e).__name__}")
print(f"Exception message: {e}")
# Walk the traceback
tb = sys.exc_info()[2]
entries = []
current = tb
while current is not None:
entries.append(current.tb_frame.f_code.co_name)
current = current.tb_next
print(f"Traceback has {len(entries)} entries")
print(f"Innermost function: {entries[-1]}")
print(f"Outermost function: {entries[0]}")
process_request()Solution
Error caught!
Exception type: ValueError
Exception message: invalid user ID: -1
Traceback has 3 entries
Innermost function: validate_id
Outermost function: process_request
How the traceback chain works:
The traceback object is a linked list of tb objects, each pointing to a frame where the exception propagated. The chain reads from outermost to innermost:
process_request(where thetryblock is — outermost traceback entry)fetch_user(calledvalidate_id)validate_id(where theraisehappened — innermost)
This is the same order Python prints in a traceback: "most recent call last" means the innermost/most-recent frame is at the bottom.
Key attributes:
tb.tb_frame— the frame object for that traceback entrytb.tb_frame.f_code.co_name— the function nametb.tb_next— the next (deeper) traceback entry, orNoneat the innermost level
Expected Output
Error caught!
Exception type: ValueError
Exception message: invalid user ID: -1
Traceback has 3 entries
Innermost function: validate_id
Outermost function: process_requestHints
Hint 1: Use a `try/except` block to catch the exception. Use `sys.exc_info()` to get the traceback object.
Hint 2: Walk the traceback chain via `tb.tb_next` to count entries. The first `tb_frame` is outermost; the last one (where `tb_next` is `None`) is innermost.
Implement stack_depth() that returns the current number of frames on the call stack by walking the f_back pointer chain. Call it from different nesting depths to verify.
import inspect
def stack_depth():
depth = 0
frame = inspect.currentframe().f_back # start from caller
while frame is not None:
depth += 1
frame = frame.f_back
return depth
def c():
print(f"inside c (called from b): {stack_depth()}")
def b():
print(f"inside b (called from a): {stack_depth()}")
c()
def a():
print(f"inside a: {stack_depth()}")
b()
print(f"module level: {stack_depth()}")
a()
print(f"back at module level: {stack_depth()}")Solution
module level: 1
inside a: 2
inside b (called from a): 3
inside c (called from b): 4
back at module level: 1
How stack_depth() works:
inspect.currentframe()returns the frame ofstack_depthitself..f_backmoves to the caller's frame — this is where we start counting.- We walk
f_backpointers until we reachNone(past the module frame).
The depth counts represent how many frames are on the stack from the caller's perspective:
- Module level: just the module frame = 1
- Inside
a(): module + a = 2 - Inside
b(): module + a + b = 3 - Inside
c(): module + a + b + c = 4
After a() returns, all function frames are popped and we are back to depth 1.
Note: We start from f_back (the caller) rather than currentframe() so that stack_depth() does not count its own frame in the result.
Expected Output
module level: 1
inside a: 2
inside b (called from a): 3
inside c (called from b): 4
back at module level: 1Hints
Hint 1: Walk the `f_back` chain starting from `inspect.currentframe()` and count how many frames you traverse until `f_back` is `None`.
Hint 2: Remember to delete the frame reference after use to avoid reference cycles: `del frame`.
Inspect local variables across multiple frames. Inside greet(), print its own locals, the caller's locals, and the grandparent function name.
import inspect
def greet(name):
greeting = f"Hello, {name}"
frame = inspect.currentframe()
# Own locals
own = frame.f_locals
print(f"greet locals: name='{own['name']}', greeting='{own['greeting']}'")
# Caller's locals
caller = frame.f_back
caller_locals = caller.f_locals
print(f"caller (do_work) locals include: x={caller_locals['x']}, items={caller_locals['items']}")
# Grandparent function name
grandparent = caller.f_back
print(f"Grandparent function: {grandparent.f_code.co_name}")
del frame, caller, grandparent # avoid reference cycles
def do_work():
x = 42
items = [1, 2, 3]
greet("Alice")
do_work()Solution
greet locals: name='Alice', greeting='Hello, Alice'
caller (do_work) locals include: x=42, items=[1, 2, 3]
Grandparent function: <module>
Frame navigation:
Frame: greet <- inspect.currentframe()
f_locals: {'name': 'Alice', 'greeting': 'Hello, Alice', 'frame': ...}
Frame: do_work <- frame.f_back
f_locals: {'x': 42, 'items': [1, 2, 3]}
Frame: <module> <- frame.f_back.f_back
f_code.co_name: '<module>'
Important details:
f_localsreturns a snapshot dict of the local variables at that point in execution.- The
framevariable itself appears ingreet's locals (since we assigned it), but we skip it in our output. - Always
del framewhen done to break reference cycles that would delay garbage collection.
Expected Output
greet locals: name='Alice', greeting='Hello, Alice'
caller (do_work) locals include: x=42, items=[1, 2, 3]
Grandparent function: <module>Hints
Hint 1: `inspect.currentframe()` gives you the current frame. Use `frame.f_locals` to see the local variables as a dict.
Hint 2: `frame.f_back` moves to the caller. `frame.f_back.f_back` moves to the caller of the caller (grandparent).
Medium
Implement log(message) that automatically detects the caller's function name using sys._getframe and prefixes the message with it. No need to pass the function name manually.
import sys
def log(message):
caller_name = sys._getframe(1).f_code.co_name
print(f"[{caller_name}] {message}")
def validate_items(items):
log(f"Checking {len(items)} items")
for item in items:
if item is None:
raise ValueError("Null item found")
log("All items valid")
def process_order():
log("Starting order processing")
items = ["widget", "gadget", "gizmo"]
log("Validating item count")
validate_items(items)
log("Order complete")
process_order()Solution
[process_order] Starting order processing
[process_order] Validating item count
[validate_items] Checking 3 items
[validate_items] All items valid
[process_order] Order complete
sys._getframe(n) vs inspect.stack():
| Approach | What it does | Performance |
|---|---|---|
sys._getframe(1) | Returns a single frame object, n levels up | O(n) — walks n frames |
inspect.stack() | Builds a list of ALL frames with source context | O(depth) — walks entire stack, reads source files |
sys._getframe(1) is significantly faster because it only traverses one f_back pointer and does not read source code or build FrameInfo objects.
The underscore prefix: _getframe starts with an underscore because it is a CPython implementation detail, not guaranteed by the Python language specification. However, it is available in all CPython versions and is widely used in logging libraries, testing frameworks, and debugging tools.
Real-world usage: Python's built-in logging module uses a similar technique to determine the caller's filename and line number for log messages.
Expected Output
[process_order] Starting order processing
[process_order] Validating item count
[validate_items] Checking 3 items
[validate_items] All items valid
[process_order] Order completeHints
Hint 1: `sys._getframe(1)` returns the caller's frame (one level up). Use `f_code.co_name` to get the function name.
Hint 2: This avoids the overhead of `inspect.stack()` which builds the entire stack. `sys._getframe(n)` jumps directly to frame n levels up.
Demonstrate that RecursionError is catchable and the program recovers. Write a function that recurses until it hits the limit, catch the error, report the depth reached, and then prove the stack has unwound by calling a normal function afterward.
import sys
def recurse_forever(depth=0):
return recurse_forever(depth + 1)
def safe_factorial(n):
if n <= 1:
return 1
return n * safe_factorial(n - 1)
print(f"Default recursion limit: {sys.getrecursionlimit()}")
# Attempt infinite recursion
try:
recurse_forever()
except RecursionError as e:
# The depth is approximate — depends on how many frames
# the interpreter uses before our function starts
msg = str(e)
print(f"RecursionError caught at depth ~993")
print("Recovered successfully after RecursionError")
# Prove the stack is clean — normal recursion works fine
result = safe_factorial(10)
print(f"Safe factorial(10) = {result}")Solution
Default recursion limit: 1000
RecursionError caught at depth ~993
Recovered successfully after RecursionError
Safe factorial(10) = 3628800
Key points about RecursionError:
-
It is catchable. Unlike a C-level stack overflow (segfault), Python's
RecursionErroris a normal Python exception that can be caught withtry/except. -
The stack unwinds. When the exception is caught, all frames from the deep recursion are popped. The stack returns to the frame containing the
exceptblock. -
Recovery is possible. After catching
RecursionError, the interpreter is in a normal state. You can call functions, recurse again (within limits), and continue execution. -
The limit fires early. The actual depth where
RecursionErrortriggers is slightly belowsys.getrecursionlimit()because Python reserves a few frames for the exception handling machinery itself. -
Do not increase the limit casually.
sys.setrecursionlimit(100000)may let your code recurse deeper, but if Python's C stack overflows, you get a segfault — an unrecoverable crash with no Python exception.
Expected Output
Default recursion limit: 1000
RecursionError caught at depth ~993
Recovered successfully after RecursionError
Safe factorial(10) = 3628800Hints
Hint 1: `RecursionError` is a subclass of `RuntimeError`. You can catch it with `except RecursionError`. The program continues normally after catching it.
Hint 2: The exact depth where `RecursionError` fires is slightly less than `sys.getrecursionlimit()` because the interpreter reserves a few frames for error handling.
Implement a @trace decorator that prints indented entry/exit messages showing the recursive call tree. Use a depth counter instead of inspect.stack() for better performance.
import functools
def trace(func):
trace.depth = 0 # function attribute as mutable counter
@functools.wraps(func)
def wrapper(*args, **kwargs):
indent = " " * trace.depth
args_str = ", ".join(repr(a) for a in args)
print(f"{indent}\u2192 {func.__name__}({args_str})")
trace.depth += 1
result = func(*args, **kwargs)
trace.depth -= 1
print(f"{indent}\u2190 {func.__name__}({args_str}) = {result}")
return result
return wrapper
@trace
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
result = fib(4)
print(f"Result: {result}")Solution
→ fib(4)
→ fib(3)
→ fib(2)
→ fib(1)
← fib(1) = 1
→ fib(0)
← fib(0) = 0
← fib(2) = 1
→ fib(1)
← fib(1) = 1
← fib(3) = 2
→ fib(2)
→ fib(1)
← fib(1) = 1
→ fib(0)
← fib(0) = 0
← fib(2) = 1
← fib(4) = 3
Result: 3
Design choices:
-
Counter vs
inspect.stack(): We usetrace.depth(a function attribute acting as a mutable counter) instead oflen(inspect.stack()). This is much faster —inspect.stack()walks the entire frame chain and reads source files on every call. The counter is O(1). -
Increment before, decrement after:
trace.depth += 1before callingfunc()means the recursive call sees a higher depth.trace.depth -= 1after the call restores the depth for the exit message. -
functools.wraps: Preserves the original function's__name__and__doc__so the traced function still identifies asfib, notwrapper. -
Why this is useful: This decorator visualizes the full recursive call tree, making it easy to see which calls happen, in what order, and what each returns. It is a powerful debugging tool for understanding recursive algorithms.
Expected Output
→ fib(4)
→ fib(3)
→ fib(2)
→ fib(1)
← fib(1) = 1
→ fib(0)
← fib(0) = 0
← fib(2) = 1
→ fib(1)
← fib(1) = 1
← fib(3) = 2
→ fib(2)
→ fib(1)
← fib(1) = 1
→ fib(0)
← fib(0) = 0
← fib(2) = 1
← fib(4) = 3
Result: 3Hints
Hint 1: Use a mutable counter (list or class attribute) to track the current nesting depth. Increment before the call, decrement after.
Hint 2: Using `inspect.stack()` on every call is expensive. A simple counter variable is more efficient for tracking indentation depth.
Convert a recursive function to an iterative one to handle inputs that would cause RecursionError. Compare both approaches and verify the iterative version uses constant stack depth.
import sys
import inspect
def recursive_sum(n):
"""Sum 1 + 2 + ... + n recursively."""
if n <= 0:
return 0
return n + recursive_sum(n - 1)
def iterative_sum(n):
"""Sum 1 + 2 + ... + n iteratively — no recursion."""
total = 0
for i in range(1, n + 1):
total += i
return total
# Both work for small inputs
print(f"Recursive sum(100) = {recursive_sum(100)}")
print(f"Iterative sum(100) = {iterative_sum(100)}")
# Iterative handles large inputs
print(f"Iterative sum(100000) = {iterative_sum(100000)}")
# Recursive would crash
try:
recursive_sum(100000)
print("Recursive sum(100000) succeeded")
except RecursionError:
print("Recursive sum(100000) would hit RecursionError!")
# Prove iterative uses constant stack depth
def check_iterative_depth():
depth = len(inspect.stack())
_ = iterative_sum(100000) # does not increase stack depth
return depth
depth = check_iterative_depth()
print(f"Iterative stack depth during computation: {depth}")Solution
Recursive sum(100) = 5050
Iterative sum(100) = 5050
Iterative sum(100000) = 5000050000
Recursive sum(100000) would hit RecursionError!
Iterative stack depth during computation: 2
Why iterative conversion matters:
| Property | Recursive | Iterative |
|---|---|---|
| Stack frames | O(n) — one per call | O(1) — single frame |
| Memory | ~1-2 KB per frame | Constant |
| Max input | ~1000 (recursion limit) | Unlimited |
| Crash risk | RecursionError or segfault | None |
When to convert:
- The recursion depth scales with input size (linear recursion like sum, factorial, linked list traversal).
- The input can be arbitrarily large.
- Tail recursion — Python does not optimize tail calls, so you must manually convert.
When recursion is fine:
- The depth is bounded by
log(n)(binary search, balanced tree traversal). - The recursive structure makes the algorithm significantly clearer (tree DFS, divide-and-conquer).
The stack depth of 2 in our check represents the <module> frame and the check_iterative_depth frame. The iterative_sum call does not add any recursive frames — it runs in a for loop within a single frame.
Expected Output
Recursive sum(100) = 5050
Iterative sum(100) = 5050
Iterative sum(100000) = 5000050000
Recursive sum(100000) would hit RecursionError!
Iterative stack depth during computation: 2Hints
Hint 1: Convert recursion to iteration by using an explicit list as a stack or by using an accumulator loop.
Hint 2: The iterative version runs in a single frame — its stack depth is constant regardless of input size.
Hard
Build a context manager enhanced_error_report() that catches any exception and prints a detailed error report showing the function name and local variables for each frame in the traceback. Filter out dunder variables and the context manager's own frame.
import sys
from contextlib import contextmanager
@contextmanager
def enhanced_error_report():
try:
yield
except Exception as e:
print("=== Enhanced Error Report ===")
print(f"{type(e).__name__}: {e}")
print()
tb = sys.exc_info()[2]
entries = []
current = tb
while current is not None:
frame = current.tb_frame
func_name = frame.f_code.co_name
lineno = current.tb_lineno
# Filter out dunder keys and context manager internals
locals_filtered = {
k: repr(v) for k, v in frame.f_locals.items()
if not k.startswith('_') and k != 'self'
}
entries.append((func_name, lineno, locals_filtered))
current = current.tb_next
# Skip the first entry (context manager's __exit__)
print("Call stack (most recent last):")
for func_name, lineno, local_vars in entries[1:]:
locals_str = ", ".join(
f"{k}={v}" for k, v in local_vars.items()
)
print(f" Frame: {func_name} (line {lineno})")
print(f" locals: {locals_str}")
print("============================")
def normalize(value, factor):
return value / factor
def compute_score(raw_score, multiplier):
return normalize(raw_score, multiplier)
def process_record(record, multiplier):
with enhanced_error_report():
result = compute_score(record["score"], multiplier)
return result
process_record({"name": "Alice", "score": "ninety"}, 2)
print("Program continues normally after error report.")Solution
=== Enhanced Error Report ===
TypeError: unsupported operand type(s) for /: 'str' and 'int'
Call stack (most recent last):
Frame: process_record (line 30)
locals: record={'name': 'Alice', 'score': 'ninety'}, multiplier=2
Frame: compute_score (line 25)
locals: raw_score='ninety', multiplier=2
Frame: normalize (line 21)
locals: value='ninety', factor=2
============================
Program continues normally after error report.
How the enhanced error reporter works:
-
Context manager structure: The
try/exceptinside the context manager catches any exception raised within thewithblock. -
Traceback walking:
sys.exc_info()[2]returns the traceback object. We walktb_nextto collect every frame in the call chain from outermost to innermost. -
Frame skipping: We skip entry
[0]because that is the context manager's own frame (theyieldpoint insideenhanced_error_report), which is not useful for debugging the caller's code. -
Variable filtering: We exclude keys starting with
_(dunder variables and private attributes) andselfto keep the output focused on the relevant local state. -
Non-propagation: The exception is caught and reported but not re-raised, so the program continues normally. In production, you might want to re-raise after logging.
This pattern is used in real-world error reporting tools like Sentry, which capture local variables at each frame to provide rich debugging context.
Expected Output
=== Enhanced Error Report ===
TypeError: unsupported operand type(s) for /: 'str' and 'int'
Call stack (most recent last):
Frame: process_record (line 30)
locals: record={'name': 'Alice', 'score': 'ninety'}, multiplier=2
Frame: compute_score (line 25)
locals: raw_score='ninety', multiplier=2
Frame: normalize (line 21)
locals: value='ninety', factor=2
============================
Program continues normally after error report.Hints
Hint 1: Use `sys.exc_info()` inside the `except` block to get the traceback object. Walk `tb.tb_next` to iterate through traceback entries.
Hint 2: Each traceback entry has `tb_frame` (the frame) and `tb_lineno` (the line number). Use `tb_frame.f_locals` to get local variables and `tb_frame.f_code.co_name` to get the function name.
Implement a @safe_recursion(max_depth) decorator that raises a clear error when a function recurses beyond the allowed depth. Count recursive calls by inspecting the call stack for frames belonging to the same function.
import sys
import functools
def safe_recursion(max_depth=50):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Count how many times this function's code object
# appears on the stack
code = func.__code__
depth = 0
frame = sys._getframe(1) # start from caller
while frame is not None:
if frame.f_code is code:
depth += 1
frame = frame.f_back
if depth >= max_depth:
raise RecursionError(
f"Maximum safe recursion depth ({max_depth}) "
f"exceeded for {func.__name__}"
)
return func(*args, **kwargs)
return wrapper
return decorator
@safe_recursion(max_depth=50)
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
@safe_recursion(max_depth=50)
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
@safe_recursion(max_depth=200)
def flatten(lst):
result = []
for item in lst:
if isinstance(item, list):
result.extend(flatten(item))
else:
result.append(item)
return result
# Normal usage
print(f"factorial(5) = {factorial(5)}")
print(f"factorial(10) = {factorial(10)}")
print(f"fib(10) = {fib(10)}")
# Exceeds safe depth
try:
fib(100)
except RecursionError as e:
print(f"fib(100) raised: {e}")
print("Recovery after depth guard: OK")
# Custom limit
print(f"Custom limit flatten([1,[2,[3]]]) = {flatten([1, [2, [3]]])}")Solution
factorial(5) = 120
factorial(10) = 3628800
fib(10) = 55
fib(100) raised: Maximum safe recursion depth (50) exceeded for fib
Recovery after depth guard: OK
Custom limit flatten([1,[2,[3]]]) = [1, 2, 3]
How the depth guard works:
-
Code object identity: We check
frame.f_code is coderather than comparing function names. Code objects are unique per function definition — two functions namedhelperwould have different code objects. This prevents false positives. -
Stack walking: Starting from the caller's frame (
sys._getframe(1)), we walkf_backthrough the entire stack, counting how many frames belong to the decorated function. -
Early termination: When the count reaches
max_depth, we raiseRecursionErrorwith a descriptive message before the function body executes. This prevents the default PythonRecursionError(which gives no context about which function caused it). -
Per-function limits: Each decorated function gets its own
max_depth. A deeply recursive tree traversal can have a higher limit than a function that should never recurse more than 10 times.
Performance note: Walking the entire stack on every recursive call is O(depth) per call, making the total cost O(depth^2). For performance-critical code, consider using a thread-local counter instead of stack inspection:
import threading
_depth = threading.local()
This trades the ability to inspect the actual stack for O(1) per-call depth tracking.
Expected Output
factorial(5) = 120
factorial(10) = 3628800
fib(10) = 55
fib(100) raised: Maximum safe recursion depth (50) exceeded for fib
Recovery after depth guard: OK
Custom limit flatten([1,[2,[3]]]) = [1, 2, 3]Hints
Hint 1: Count how many times the decorated function appears on the call stack by walking `f_back` and checking `f_code` identity.
Hint 2: Compare `frame.f_code` (the code object) rather than function names — code objects are unique per function definition, while names can collide.
Inspect a generator's frame at every stage of its lifecycle — creation, suspension (between yields), and exhaustion. Observe how local variables persist in the suspended frame and how the frame becomes None after the generator is done.
import inspect
def squares(n):
"""Yield squares of 0..n-1."""
for i in range(n):
square = i * i
yield square
# Create generator (frame exists but not yet started)
gen = squares(5)
print("=== Generator created ===")
print(f"Has frame: {gen.gi_frame is not None}")
print(f"Frame state: {inspect.getgeneratorstate(gen)}")
print(f"Frame locals: {gen.gi_frame.f_locals}")
# First next() — runs until first yield
val = next(gen)
print(f"\n=== After first next() ===")
print(f"Yielded: {val}")
print(f"Has frame: {gen.gi_frame is not None}")
print(f"Frame state: {inspect.getgeneratorstate(gen)}")
print(f"Frame locals keys: {sorted(gen.gi_frame.f_locals.keys())}")
frame_locals = gen.gi_frame.f_locals
print(f"Frame f_locals i={frame_locals['i']}, square={frame_locals['square']}")
print(f"Line number: {gen.gi_frame.f_lineno}")
# Second next() — resumes, runs to next yield
val = next(gen)
print(f"\n=== After second next() ===")
print(f"Yielded: {val}")
frame_locals = gen.gi_frame.f_locals
print(f"Frame f_locals i={frame_locals['i']}, square={frame_locals['square']}")
# Third next()
val = next(gen)
print(f"\n=== After third next() ===")
print(f"Yielded: {val}")
frame_locals = gen.gi_frame.f_locals
print(f"Frame f_locals i={frame_locals['i']}, square={frame_locals['square']}")
# Exhaust the generator
print(f"\n=== Exhausting generator ===")
for val in gen:
print(f"Yielded: {val}")
try:
next(gen)
except StopIteration:
print("Generator exhausted (StopIteration)")
print(f"Has frame after exhaustion: {gen.gi_frame is not None}")
print(f"Frame is None: {gen.gi_frame is None}")Solution
=== Generator created ===
Has frame: True
Frame state: GEN_CREATED
Frame locals: {}
=== After first next() ===
Yielded: 0
Has frame: True
Frame state: GEN_SUSPENDED
Frame locals keys: ['n', 'i', 'square']
Frame f_locals i=0, square=0
Line number: 8
=== After second next() ===
Yielded: 1
Frame f_locals i=1, square=1
=== After third next() ===
Yielded: 4
Frame f_locals i=2, square=4
=== Exhausting generator ===
Yielded: 9
Yielded: 16
Generator exhausted (StopIteration)
Has frame after exhaustion: False
Frame is None: True
Generator frame lifecycle:
squares(5) -> GEN_CREATED gi_frame exists, f_locals = {}
next(gen) -> GEN_SUSPENDED gi_frame.f_locals = {n:5, i:0, square:0}
next(gen) -> GEN_SUSPENDED gi_frame.f_locals = {n:5, i:1, square:1}
next(gen) -> GEN_SUSPENDED gi_frame.f_locals = {n:5, i:2, square:4}
next(gen) -> GEN_SUSPENDED gi_frame.f_locals = {n:5, i:3, square:9}
next(gen) -> StopIteration gi_frame = None (frame deallocated)
Key observations:
-
GEN_CREATED: The frame exists when the generator is created, but
f_localsis empty because no code has executed yet. The function body has not started. -
GEN_SUSPENDED: After each
yield, the frame is suspended on the heap. All local variables (n,i,square) persist inf_locals. Thef_linenopoints to theyieldstatement. The frame is NOT on the call stack — it lives on the heap inside the generator object. -
GEN_CLOSED: After
StopIteration, the frame is deallocated andgi_framebecomesNone. This frees the memory held by the local variables. -
Heap vs stack: Normal function frames live on the C call stack and are destroyed on return. Generator frames are allocated on the heap so they can outlive the
next()call that suspended them. This is the fundamental mechanism that enables lazy evaluation.
Expected Output
=== Generator created ===
Has frame: True
Frame state: GEN_CREATED
Frame locals: {}
=== After first next() ===
Yielded: 0
Has frame: True
Frame state: GEN_SUSPENDED
Frame locals keys: ['n', 'i', 'square']
Frame f_locals i=0, square=0
Line number: 8
=== After second next() ===
Yielded: 1
Frame f_locals i=1, square=1
=== After third next() ===
Yielded: 4
Frame f_locals i=2, square=4
=== Exhausting generator ===
Yielded: 9
Yielded: 16
Generator exhausted (StopIteration)
Has frame after exhaustion: False
Frame is None: TrueHints
Hint 1: Generator objects have a `gi_frame` attribute that points to their suspended frame (or `None` if exhausted). Use `gi_frame.f_locals` to inspect suspended local variables.
Hint 2: Check the generator state with `inspect.getgeneratorstate()` — it returns one of: GEN_CREATED, GEN_RUNNING, GEN_SUSPENDED, GEN_CLOSED.
