Python Understanding the REPL Practice Problems & Exercises
Practice: Understanding the REPL
← Back to lessonEasy
The REPL behaves differently for expressions and statements. Without running the code, predict what the REPL would display for each of the following inputs entered one at a time:
3 + 39import mathy = 100
Write a script that simulates and explains these REPL behaviors by printing what the REPL would show (or indicating when nothing is shown).
# Simulate what the REPL displays for each input
# 1. A bare expression: 3 + 39
print("Expression outputs:")
print(3 + 39)
# 2. A statement: import math
print("Statement outputs:")
import math
print("(nothing)")
# 3. An assignment: y = 100
print("Assignment outputs:")
y = 100
print("(nothing)")Solution
# Simulate what the REPL displays for each input
# 1. A bare expression: 3 + 39
print("Expression outputs:")
print(3 + 39)
# 2. A statement: import math
print("Statement outputs:")
import math
print("(nothing)")
# 3. An assignment: y = 100
print("Assignment outputs:")
y = 100
print("(nothing)")
Why this matters:
The REPL uses sys.displayhook to automatically display the result of any expression you type. But not everything is an expression:
- Expressions produce a value:
3 + 39,len([1,2,3]),"hello".upper(). The REPL callsrepr()on the result and prints it. - Statements perform actions but do not produce a value:
import math,x = 10,del x. The REPL shows nothing. - Assignment (
y = 100) is a statement in Python, not an expression. Unlike some languages (C, JavaScript), assignment does not return a value, so the REPL stays silent.
This is why beginners sometimes think their code "did nothing" when they assign a variable — the REPL simply has nothing to display.
Expected Output
Expression outputs:\n42\nStatement outputs:\n(nothing)\nAssignment outputs:\n(nothing)Hints
Hint 1: In the REPL, typing a bare expression like `42` causes the REPL to display its value. Statements like `x = 10` produce no visible output because they do not return a value.
Hint 2: The key distinction: expressions evaluate to a value (and the REPL auto-displays it), while statements perform an action without producing a displayable result.
Explain the difference between what the REPL displays for a bare expression versus a print() call. Demonstrate with strings and None.
# Demonstrate the difference between REPL auto-display and print()
text = "hello world"
# In the REPL, typing just: "hello world"
# would display the repr() of the string
print(f"Bare expression in REPL would show: {repr(text)}")
# In the REPL, typing: print("hello world")
# would display the str() of the string
print(f"print() in REPL would show: {text}")
print("---")
# None is special: REPL suppresses it
print(f"Bare None expression in REPL would show: (nothing)")
print(f"print(None) in REPL would show: {None}")
print("---")
# Show the actual difference between repr and str
print(f"Bare string expression repr: {repr(text)}")
print(f"print() string output: {str(text)}")Solution
text = "hello world"
# In the REPL, typing just: "hello world"
# would display the repr() of the string
print(f"Bare expression in REPL would show: {repr(text)}")
# In the REPL, typing: print("hello world")
# would display the str() of the string
print(f"print() in REPL would show: {text}")
print("---")
# None is special: REPL suppresses it
print(f"Bare None expression in REPL would show: (nothing)")
print(f"print(None) in REPL would show: {None}")
print("---")
# Show the actual difference between repr and str
print(f"Bare string expression repr: {repr(text)}")
print(f"print() string output: {str(text)}")
The two display mechanisms:
-
REPL auto-display (bare expressions): calls
sys.displayhook(), which internally callsrepr()on the result and prints it. If the result isNone, it prints nothing at all. -
print()function: callsstr()on each argument and writes tosys.stdout. It always prints, evenNone(as the literal string"None").
Practical differences for strings:
- REPL auto-display:
'hello world'(with quotes — that isrepr()) print():hello world(no quotes — that isstr())
Why None is suppressed: Almost every function call in the REPL returns None (e.g., print() itself returns None). If the REPL displayed None every time, the output would be cluttered with noise. The REPL intentionally filters it out.
Expected Output
Bare expression in REPL would show: 'hello world'\nprint() in REPL would show: hello world\n---\nBare None expression in REPL would show: (nothing)\nprint(None) in REPL would show: None\n---\nBare string expression repr: 'hello world'\nprint() string output: hello worldHints
Hint 1: When the REPL auto-displays an expression, it calls `repr()` on the value. When you use `print()`, it calls `str()` instead. For strings, `repr()` adds quotes around the value while `str()` does not.
Hint 2: The REPL suppresses display of `None` — if an expression evaluates to `None`, nothing is shown. But `print(None)` explicitly prints the word "None" because `print()` converts its argument via `str()`.
Simulate the REPL's special _ (underscore) variable behavior. Show how _ gets updated after bare expressions but NOT after assignments or print() calls.
import builtins
# Simulate REPL underscore behavior
def simulate_repl_display(expr_result, description):
"""Simulates how the REPL handles expression results."""
if expr_result is not None:
builtins._ = expr_result
return repr(expr_result)
return "(suppressed — None)"
# Step 1: bare expression 6 * 7
result = 6 * 7
simulate_repl_display(result, "6 * 7")
print(f"Step 1 — evaluate 6 * 7: {builtins._}")
# Step 2: check _ value
print(f"Step 2 — _ holds last result: {builtins._}")
# Step 3: use _ in next expression
result = builtins._ + 42
simulate_repl_display(result, "_ + 42")
print(f"Step 3 — use _ in next expression: {builtins._}")
# Step 4: _ is now updated
print(f"Step 4 — _ now updated: {builtins._}")
# Step 5: assignment does NOT update _
x = 999 # This is a statement, not a displayed expression
print(f"Step 5 — after assignment, _ unchanged: {builtins._}")
# Step 6: print() returns None, which is suppressed — _ unchanged
print_result = print # print() returns None
# Calling print("hi") in REPL: "hi" is printed, but return value is None → suppressed
print(f"Step 6 — after print(), _ unchanged: {builtins._}")Solution
import builtins
def simulate_repl_display(expr_result, description):
"""Simulates how the REPL handles expression results."""
if expr_result is not None:
builtins._ = expr_result
return repr(expr_result)
return "(suppressed — None)"
# Step 1: bare expression 6 * 7
result = 6 * 7
simulate_repl_display(result, "6 * 7")
print(f"Step 1 — evaluate 6 * 7: {builtins._}")
# Step 2: check _ value
print(f"Step 2 — _ holds last result: {builtins._}")
# Step 3: use _ in next expression
result = builtins._ + 42
simulate_repl_display(result, "_ + 42")
print(f"Step 3 — use _ in next expression: {builtins._}")
# Step 4: _ is now updated
print(f"Step 4 — _ now updated: {builtins._}")
# Step 5: assignment does NOT update _
x = 999
print(f"Step 5 — after assignment, _ unchanged: {builtins._}")
# Step 6: print() returns None — suppressed, _ unchanged
print(f"Step 6 — after print(), _ unchanged: {builtins._}")
How _ works internally:
The REPL's display hook (sys.displayhook) does three things when an expression result is not None:
- Stores the result in
builtins._ - Calls
repr()on the result - Prints the repr to stdout
This means _ is ONLY updated when:
- You type a bare expression (not an assignment)
- The expression result is not
None
Common pitfalls:
x = 42does NOT update_(assignment is a statement)print("hi")does NOT update_(print returnsNone, which is suppressed)len([1,2,3])DOES update_(it returns3, which is displayed)
The _ variable is stored in the builtins module so it is accessible from any scope within the REPL session.
Expected Output
Step 1 — evaluate 6 * 7: 42\nStep 2 — _ holds last result: 42\nStep 3 — use _ in next expression: 84\nStep 4 — _ now updated: 84\nStep 5 — after assignment, _ unchanged: 84\nStep 6 — after print(), _ unchanged: 84Hints
Hint 1: In the REPL, the special variable `_` always holds the result of the last expression that was auto-displayed. Assignments and print() calls do NOT update `_` because assignments are statements and print() returns None (which is suppressed).
Hint 2: You can use `_` in subsequent expressions like any other variable. Each time a new expression result is displayed, `_` is updated to that new value.
Medium
Demonstrate the fundamental difference between running Python code as a script versus in the REPL. Use compile() with different modes to show why bare expressions produce output in the REPL but are silently discarded in scripts.
import sys
def run_as_script(code_string):
"""Execute code as if it were a .py script file."""
compiled = compile(code_string, "<script>", "exec")
exec(compiled)
def run_as_repl(code_lines):
"""Execute each line as if typed into the REPL."""
namespace = {}
for line in code_lines:
line = line.strip()
if not line:
continue
compiled = compile(line, "<stdin>", "single")
exec(compiled, namespace)
# The same code, two different behaviors
code = '''\
print("hello from print")
42
"world"
[x**2 for x in range(1, 4)]
'''
print("=== Running as SCRIPT ===")
run_as_script(code)
print("\n=== Running as REPL ===")
lines = ["print('hello from print')", "42", "'world'", "[x**2 for x in range(1, 4)]"]
run_as_repl(lines)Solution
import sys
def run_as_script(code_string):
"""Execute code as if it were a .py script file."""
compiled = compile(code_string, "<script>", "exec")
exec(compiled)
def run_as_repl(code_lines):
"""Execute each line as if typed into the REPL."""
namespace = {}
for line in code_lines:
line = line.strip()
if not line:
continue
compiled = compile(line, "<stdin>", "single")
exec(compiled, namespace)
code = '''\
print("hello from print")
42
"world"
[x**2 for x in range(1, 4)]
'''
print("=== Running as SCRIPT ===")
run_as_script(code)
print("\n=== Running as REPL ===")
lines = ["print('hello from print')", "42", "'world'", "[x**2 for x in range(1, 4)]"]
run_as_repl(lines)
The mechanism behind the difference:
Python's compile() function has three modes:
| Mode | Purpose | Expression behavior |
|---|---|---|
"exec" | Full module/script | Expressions are evaluated but results are discarded |
"single" | One interactive statement | Expression results are sent to sys.displayhook() |
"eval" | Single expression | Returns the value (used by eval()) |
When you run a .py file, Python compiles the entire file with mode="exec". In this mode, bare expressions like 42 are evaluated (side effects still happen) but the result goes nowhere.
When you type into the REPL, each input is compiled with mode="single". The compiled bytecode includes a PRINT_EXPR instruction that calls sys.displayhook() on the result. This is why 42 appears as output in the REPL but not when running a script.
This is not magic — it is a compile-time decision baked into the bytecode.
import sys
import io
import textwrap
def run_as_script(code_string):
"""Execute code as if it were a .py script file (exec mode)."""
# TODO: Use compile() + exec() with mode='exec'
pass
def run_as_repl(code_lines):
"""Execute each line as if typed into the REPL (single mode)."""
# TODO: Use compile() + exec() with mode='single' for each line
passExpected Output
=== Running as SCRIPT ===\nhello from print\n\n=== Running as REPL ===\nhello from print\n42\n'world'\n[1, 4, 9]Hints
Hint 1: Python's `compile()` function accepts a `mode` parameter: `"exec"` compiles a full module (like a script), while `"single"` compiles a single interactive statement (like one REPL input). In `"single"` mode, expression results are passed to `sys.displayhook`.
Hint 2: When using `mode="single"`, compile and exec each line separately to mimic how the REPL processes one input at a time. Expression statements will automatically display their result.
Use Python's built-in code module to create a custom REPL. Feed it a sequence of commands programmatically and observe the output. This is how IDEs and notebooks implement their own Python shells.
import code
import sys
# Create a shared namespace for our custom REPL
namespace = {}
console = code.InteractiveConsole(locals=namespace)
# Feed commands to the console
commands = [
"x = 10",
"x * 5",
"y = x + 20",
"y",
"import math",
"math.pi",
]
for cmd in commands:
print(f"Pushing: {cmd}")
# push() returns True if more input is needed (incomplete block)
# Returns False if the command was complete
more_input_needed = console.push(cmd)
# Inspect what ended up in the namespace
user_vars = [k for k in namespace if not k.startswith("_")]
print(f"All variables in console namespace: {', '.join(sorted(user_vars))}")Solution
import code
import sys
namespace = {}
console = code.InteractiveConsole(locals=namespace)
commands = [
"x = 10",
"x * 5",
"y = x + 20",
"y",
"import math",
"math.pi",
]
for cmd in commands:
print(f"Pushing: {cmd}")
more_input_needed = console.push(cmd)
user_vars = [k for k in namespace if not k.startswith("_")]
print(f"All variables in console namespace: {', '.join(sorted(user_vars))}")
What the code module provides:
The code module is part of Python's standard library and contains the actual machinery behind the interactive interpreter:
code.InteractiveConsole: A full REPL implementation. It handles multi-line statements, compiles with"single"mode, catches exceptions, and callssys.displayhookfor expression results.console.push(line): Feeds one line to the interpreter. ReturnsTrueif the line is part of an incomplete construct (like the first line of aforloop),Falsewhen the statement is complete and has been executed.console.interact(): Starts a full interactive loop (reads from stdin). Not suitable for programmatic use but useful for embedding a debug shell in your application.
Real-world uses:
- Jupyter notebooks use a similar approach (via
IPython.core) to execute cells - IDEs like PyCharm embed a console using these primitives
- Debugging tools like
code.interact(local=locals())drop you into a REPL mid-execution
import code
import sys
def build_custom_repl():
"""Create a custom REPL using the code module.
Feed it a sequence of commands and capture the output.
"""
# TODO: Use code.InteractiveConsole to push lines
passExpected Output
Pushing: x = 10\nPushing: x * 5\n50\nPushing: y = x + 20\nPushing: y\n30\nPushing: import math\nPushing: math.pi\n3.141592653589793\nAll variables in console namespace: math, x, yHints
Hint 1: The `code.InteractiveConsole` class provides a `push()` method that feeds a line of source text to the interpreter. It returns `True` if more input is expected (e.g., an incomplete block) and `False` if the line was complete.
Hint 2: Create an `InteractiveConsole` with a shared `locals` dict. After pushing all lines, inspect that dict to see what variables were defined during the session.
One of the REPL's superpowers is introspection — examining objects at runtime. Build a utility that demonstrates the key introspection patterns that experienced developers use daily in the REPL.
import inspect
# === dir() exploration ===
print("=== dir() exploration ===")
# Filter out dunder methods to see the public API
public_methods = [m for m in dir(str) if not m.startswith("_")]
print(f"Public methods on str: {', '.join(public_methods)}")
# === Signature inspection ===
print("\n=== Signature inspection ===")
print(f"str.split signature: {inspect.signature(str.split)}")
print(f"str.replace signature: {inspect.signature(str.replace)}")
# === Docstring extraction ===
print("\n=== Docstring extraction ===")
doc = inspect.getdoc(str.split)
first_line = doc.split("\n")[0] if doc else "No docstring"
print(f"First line of str.split docstring:\n{first_line}")
# === Type chain ===
print("\n=== Type chain ===")
print(f"type(42) = {type(42)}")
print(f"type(int) = {type(int)}")
print(f"type(type) = {type(type)}")Solution
import inspect
# === dir() exploration ===
print("=== dir() exploration ===")
public_methods = [m for m in dir(str) if not m.startswith("_")]
print(f"Public methods on str: {', '.join(public_methods)}")
# === Signature inspection ===
print("\n=== Signature inspection ===")
print(f"str.split signature: {inspect.signature(str.split)}")
print(f"str.replace signature: {inspect.signature(str.replace)}")
# === Docstring extraction ===
print("\n=== Docstring extraction ===")
doc = inspect.getdoc(str.split)
first_line = doc.split("\n")[0] if doc else "No docstring"
print(f"First line of str.split docstring:\n{first_line}")
# === Type chain ===
print("\n=== Type chain ===")
print(f"type(42) = {type(42)}")
print(f"type(int) = {type(int)}")
print(f"type(type) = {type(type)}")
The REPL introspection toolkit:
| Tool | What it does | When to use it |
|---|---|---|
dir(obj) | Lists all attributes | "What can this object do?" |
type(obj) | Returns the type | "What kind of thing is this?" |
help(obj) | Full documentation | "How exactly does this work?" |
inspect.signature(fn) | Parameter list | "What arguments does this take?" |
inspect.getdoc(obj) | Clean docstring | "Give me just the description" |
inspect.getsource(fn) | Source code | "Show me the implementation" |
hasattr(obj, name) | Attribute check | "Does this have a .foo?" |
isinstance(obj, cls) | Type check | "Is this a subclass of X?" |
The type chain: Everything in Python has a type. type(42) is int, type(int) is type, and type(type) is type — it is self-referential. This is the metaclass system at work.
Pro tip: In the REPL, you can combine these: [m for m in dir(obj) if 'find' in m.lower()] quickly finds all methods containing "find" in their name.
Expected Output
=== dir() exploration ===\nPublic methods on str: capitalize, casefold, center, count, encode, endswith, expandtabs, find, format, format_map, index, isalnum, isalpha, isascii, isdigit, isidentifier, islower, isnumeric, isprintable, isspace, istitle, isupper, join, ljust, lower, lstrip, maketrans, partition, removeprefix, removesuffix, replace, rfind, rindex, rjust, rpartition, rsplit, rstrip, split, splitlines, startswith, strip, swapcase, title, translate, upper, zfill\n\n=== Signature inspection ===\nstr.split signature: (sep=None, maxsplit=-1)\nstr.replace signature: (old, new, count=-1)\n\n=== Docstring extraction ===\nFirst line of str.split docstring:\nReturn a list of the substrings in the string, using sep as the separator string.\n\n=== Type chain ===\ntype(42) = <class 'int'>\ntype(int) = <class 'type'>\ntype(type) = <class 'type'>Hints
Hint 1: `dir(obj)` returns a list of all attribute names. Filter out dunder methods (those starting with `__`) to see the "public" API. This is how experienced developers explore unfamiliar objects in the REPL.
Hint 2: The `inspect` module provides `inspect.signature()` to get function signatures and `inspect.getdoc()` for clean docstrings. These are more reliable than accessing `__doc__` directly.
sys.displayhook is the function the REPL calls to display expression results. Replace it with a custom version that shows the type alongside each value, then restore the original.
import sys
import builtins
# Save the original displayhook
original_hook = sys.displayhook
print("=== Default displayhook behavior ===")
print(f"Default hook type: {type(original_hook)}")
# Custom displayhook that shows type information
def custom_displayhook(value):
if value is None:
print(f"[NoneType] (suppressed)")
return
builtins._ = value
print(f"[{type(value).__name__}] {repr(value)}")
# Install our custom hook
sys.displayhook = custom_displayhook
print("\n=== Custom displayhook ===")
# Simulate REPL expression evaluation by calling displayhook directly
sys.displayhook(42)
sys.displayhook("hello")
sys.displayhook([1, 2, 3])
sys.displayhook(None)
print(f"\n=== Verify _ tracking ===")
print(f"builtins._ = {builtins._}")
# Restore original
sys.displayhook = original_hook
print("\n=== Restored default displayhook ===")Solution
import sys
import builtins
original_hook = sys.displayhook
print("=== Default displayhook behavior ===")
print(f"Default hook type: {type(original_hook)}")
def custom_displayhook(value):
if value is None:
print(f"[NoneType] (suppressed)")
return
builtins._ = value
print(f"[{type(value).__name__}] {repr(value)}")
sys.displayhook = custom_displayhook
print("\n=== Custom displayhook ===")
sys.displayhook(42)
sys.displayhook("hello")
sys.displayhook([1, 2, 3])
sys.displayhook(None)
print(f"\n=== Verify _ tracking ===")
print(f"builtins._ = {builtins._}")
sys.displayhook = original_hook
print("\n=== Restored default displayhook ===")
How sys.displayhook works internally:
The default sys.displayhook implementation (in CPython) does roughly this:
def default_displayhook(value):
if value is None:
return # Suppress None
builtins._ = value # Store in _
text = repr(value) # Get repr
sys.stdout.write(text + '\n') # Print it
Why this matters:
- IPython replaces
sys.displayhookwith a rich version that supports syntax highlighting, pretty-printing of data structures, and output caching (Out[1],Out[2], etc.). - Jupyter notebooks use a custom displayhook that routes output to the notebook frontend instead of stdout.
- You can use this to build logging REPLs, teaching tools, or debuggers that annotate every expression with extra metadata.
Important: Always save and restore the original hook. Permanently replacing it can break libraries that depend on the default behavior.
import sys
import builtins
def custom_displayhook(value):
"""A custom displayhook that adds type information."""
# TODO: Show value with its type, update builtins._
passExpected Output
=== Default displayhook behavior ===\nDefault hook type: <class 'builtin_function_or_method'>\n\n=== Custom displayhook ===\n[int] 42\n[str] 'hello'\n[list] [1, 2, 3]\n[NoneType] (suppressed)\n\n=== Verify _ tracking ===\nbuiltins._ = [1, 2, 3]\n\n=== Restored default displayhook ===Hints
Hint 1: `sys.displayhook` is a callable that the REPL invokes with the result of each expression. The default implementation prints `repr(value)` and stores it in `builtins._`. You can replace it with any function that takes one argument.
Hint 2: Your custom displayhook should handle `None` specially (suppress it, like the default does). For all other values, display them however you like, then store the value in `builtins._` to maintain the `_` variable behavior.
Hard
Build a mini REPL that tracks every expression and its result, providing IPython-style In[n]/Out[n] history. This is the core of how IPython and Jupyter track execution history.
import sys
import builtins
import code
import io
class HistoryREPL:
"""A custom REPL that tracks all expressions and their results."""
def __init__(self):
self.history = [] # (line_number, input, output_or_None)
self.namespace = {}
self.console = code.InteractiveConsole(locals=self.namespace)
self.line_count = 0
self._current_line = ""
self._last_result = None
self._got_result = False
# Install custom displayhook
self._original_hook = sys.displayhook
sys.displayhook = self._capture_hook
def _capture_hook(self, value):
"""Custom displayhook that captures expression results."""
if value is not None:
builtins._ = value
self._last_result = value
self._got_result = True
print(f" => {repr(value)}")
def execute(self, line):
"""Execute a line and track it in history."""
self.line_count += 1
self._current_line = line
self._last_result = None
self._got_result = False
print(f"[{self.line_count}] >>> {line}")
self.console.push(line)
result = self._last_result if self._got_result else None
self.history.append((self.line_count, line, result))
def show_history(self):
"""Display only expressions that produced output."""
print("\n=== Expression History ===")
for num, inp, out in self.history:
if out is not None:
print(f"In[{num}]: {inp} => {repr(out)}")
def get_output(self, n):
"""Retrieve a past result by line number."""
for num, inp, out in self.history:
if num == n:
return out
return None
def cleanup(self):
"""Restore original displayhook."""
sys.displayhook = self._original_hook
# Demo the HistoryREPL
repl = HistoryREPL()
repl.execute("x = 10")
repl.execute("x * 5")
repl.execute("name = 'Python'")
repl.execute("len(name)")
repl.execute("x + len(name)")
repl.execute("[i**2 for i in range(5)]")
repl.show_history()
print("\n=== Retrieve past result ===")
print(f"Out[2] = {repl.get_output(2)}")
print(f"Out[6] = {repl.get_output(6)}")
repl.cleanup()Solution
import sys
import builtins
import code
import io
class HistoryREPL:
def __init__(self):
self.history = []
self.namespace = {}
self.console = code.InteractiveConsole(locals=self.namespace)
self.line_count = 0
self._current_line = ""
self._last_result = None
self._got_result = False
self._original_hook = sys.displayhook
sys.displayhook = self._capture_hook
def _capture_hook(self, value):
if value is not None:
builtins._ = value
self._last_result = value
self._got_result = True
print(f" => {repr(value)}")
def execute(self, line):
self.line_count += 1
self._current_line = line
self._last_result = None
self._got_result = False
print(f"[{self.line_count}] >>> {line}")
self.console.push(line)
result = self._last_result if self._got_result else None
self.history.append((self.line_count, line, result))
def show_history(self):
print("\n=== Expression History ===")
for num, inp, out in self.history:
if out is not None:
print(f"In[{num}]: {inp} => {repr(out)}")
def get_output(self, n):
for num, inp, out in self.history:
if num == n:
return out
return None
def cleanup(self):
sys.displayhook = self._original_hook
repl = HistoryREPL()
repl.execute("x = 10")
repl.execute("x * 5")
repl.execute("name = 'Python'")
repl.execute("len(name)")
repl.execute("x + len(name)")
repl.execute("[i**2 for i in range(5)]")
repl.show_history()
print("\n=== Retrieve past result ===")
print(f"Out[2] = {repl.get_output(2)}")
print(f"Out[6] = {repl.get_output(6)}")
repl.cleanup()
Architecture of a history-tracking REPL:
User Input
│
▼
┌─────────────────────┐
│ HistoryREPL.execute │
│ - increment counter │
│ - record input │
└──────────┬──────────┘
│
▼
┌─────────────────────────┐
│ InteractiveConsole.push │
│ - compile(mode='single')│
│ - exec() the bytecode │
└──────────┬──────────────┘
│ (if expression result)
▼
┌──────────────────────────┐
│ custom sys.displayhook │
│ - capture result │
│ - store in builtins._ │
│ - print with annotation │
└──────────────────────────┘
│
▼
┌──────────────────────┐
│ history.append(...) │
│ (num, input, output) │
└──────────────────────┘
This is conceptually identical to how IPython works internally. IPython's InteractiveShell maintains In (a list of input strings) and Out (a dict of output values), with custom displayhooks that populate both.
import code
import sys
class HistoryREPL:
"""A custom REPL that tracks all expressions and their results."""
def __init__(self):
self.history = [] # List of (input, output) tuples
self.namespace = {}
# TODO: Set up InteractiveConsole and custom displayhook
def execute(self, line):
# TODO: Execute line, capture output, store in history
pass
def show_history(self):
# TODO: Display numbered history like IPython's Out[]
passExpected Output
[1] >>> x = 10\n[2] >>> x * 5\n => 50\n[3] >>> name = 'Python'\n[4] >>> len(name)\n => 6\n[5] >>> x + len(name)\n => 16\n[6] >>> [i**2 for i in range(5)]\n => [0, 1, 4, 9, 16]\n\n=== Expression History ===\nIn[2]: x * 5 => 50\nIn[4]: len(name) => 6\nIn[5]: x + len(name) => 16\nIn[6]: [i**2 for i in range(5)] => [0, 1, 4, 9, 16]\n\n=== Retrieve past result ===\nOut[2] = 50\nOut[6] = [0, 1, 4, 9, 16]Hints
Hint 1: Replace `sys.displayhook` with a custom function that captures expression results into a list before displaying them. Each time the hook is called with a non-None value, record both the input line and the result.
Hint 2: Use `code.InteractiveConsole` with `push()` to execute lines. Track the current input line in an instance variable so your custom displayhook knows which input produced each output.
Build an auto_inspect() function that takes any Python expression as a string, evaluates it, and prints a comprehensive inspection report. This is the kind of tool you would add to a custom REPL to deeply understand any object.
import sys
import inspect
def auto_inspect(expression_str, namespace=None):
"""Evaluate an expression and print a full inspection report."""
if namespace is None:
namespace = {}
# Evaluate the expression
value = eval(expression_str, namespace)
print(f"=== Inspecting: {expression_str} ===")
print(f" Value : {value}")
print(f" Repr : {repr(value)}")
print(f" Type : {type(value).__name__}")
# Method Resolution Order
mro = " -> ".join(cls.__name__ for cls in type(value).__mro__)
print(f" MRO : {mro}")
# Memory address
print(f" ID : {id(value)}")
# Size in bytes
try:
size = sys.getsizeof(value)
print(f" Size : {size} bytes")
except TypeError:
print(f" Size : (not applicable)")
# Callable check
if callable(value):
print(f" Callable: Yes")
try:
sig = inspect.signature(value)
print(f" Signature: {sig}")
except (ValueError, TypeError):
print(f" Signature: (not introspectable)")
else:
print(f" Callable: No")
# Length for sized objects
if hasattr(value, '__len__') and not isinstance(value, (str, bytes)):
print(f" Length: {len(value)}")
return value
# Demo inspections
auto_inspect("42")
print()
auto_inspect("'hello'")
print()
auto_inspect("len")
print()
auto_inspect("[1, 2, 3]")Solution
import sys
import inspect
def auto_inspect(expression_str, namespace=None):
if namespace is None:
namespace = {}
value = eval(expression_str, namespace)
print(f"=== Inspecting: {expression_str} ===")
print(f" Value : {value}")
print(f" Repr : {repr(value)}")
print(f" Type : {type(value).__name__}")
mro = " -> ".join(cls.__name__ for cls in type(value).__mro__)
print(f" MRO : {mro}")
print(f" ID : {id(value)}")
try:
size = sys.getsizeof(value)
print(f" Size : {size} bytes")
except TypeError:
print(f" Size : (not applicable)")
if callable(value):
print(f" Callable: Yes")
try:
sig = inspect.signature(value)
print(f" Signature: {sig}")
except (ValueError, TypeError):
print(f" Signature: (not introspectable)")
else:
print(f" Callable: No")
if hasattr(value, '__len__') and not isinstance(value, (str, bytes)):
print(f" Length: {len(value)}")
return value
auto_inspect("42")
print()
auto_inspect("'hello'")
print()
auto_inspect("len")
print()
auto_inspect("[1, 2, 3]")
What each inspection reveals:
| Field | Function | Why it matters |
|---|---|---|
repr() | Machine-readable string | Shows quotes on strings, full structure of containers |
type() | Runtime type | Python is dynamically typed — this is how you verify |
MRO | type().__mro__ | Method resolution order — shows inheritance chain |
id() | Memory address | Unique identifier; reveals object reuse (interning) |
sys.getsizeof() | Shallow byte size | Memory profiling; note it is shallow (does not count referenced objects) |
callable() | Can it be called? | Distinguishes functions, classes, objects with __call__ |
inspect.signature() | Parameter spec | Shows required/optional args, defaults, annotations |
Extending this for a real REPL:
You could register this as a magic command (like IPython's %inspect) or hook it into a custom displayhook that automatically shows type information. Some developers create a .pythonrc file that loads helper functions like this into every REPL session:
# ~/.pythonrc.py
import sys, inspect
def xi(expr): ... # auto_inspect shorthand
Set PYTHONSTARTUP=~/.pythonrc.py and xi() is available in every REPL session.
import sys
import inspect
def auto_inspect(expression_str, namespace):
"""Evaluate an expression and print a full inspection report:
- value and repr
- type and MRO
- id (memory address)
- size in bytes
- callable check + signature if applicable
"""
# TODO: Implement the inspector
passExpected Output
=== Inspecting: 42 ===\n Value : 42\n Repr : 42\n Type : int\n MRO : int -> object\n ID : (memory address)\n Size : 28 bytes\n Callable: No\n\n=== Inspecting: 'hello' ===\n Value : hello\n Repr : 'hello'\n Type : str\n MRO : str -> object\n ID : (memory address)\n Size : 54 bytes\n Callable: No\n\n=== Inspecting: len ===\n Value : <built-in function len>\n Repr : <built-in function len>\n Type : builtin_function_or_method\n MRO : builtin_function_or_method -> object\n ID : (memory address)\n Size : (not applicable)\n Callable: Yes\n Signature: (obj, /)\n\n=== Inspecting: [1, 2, 3] ===\n Value : [1, 2, 3]\n Repr : [1, 2, 3]\n Type : list\n MRO : list -> object\n ID : (memory address)\n Size : 88 bytes\n Callable: No\n Length: 3Hints
Hint 1: Use `eval()` to evaluate the expression string in the given namespace. Then use `type()`, `id()`, `sys.getsizeof()`, `callable()`, and `inspect.signature()` to gather all metadata about the resulting object.
Hint 2: Wrap `sys.getsizeof()` and `inspect.signature()` in try/except blocks — some built-in types raise TypeError for getsizeof, and some callables have no introspectable signature.
Build a REPL session recorder that logs every command and its output, then a replayer that re-executes the session and verifies that all outputs match. This pattern is used in automated testing, documentation generation, and reproducibility verification.
import sys
import builtins
import code
import json
from datetime import datetime
class REPLRecorder:
"""Records REPL commands and their outputs."""
def __init__(self):
self.log = []
self.namespace = {}
self.console = code.InteractiveConsole(locals=self.namespace)
self._captured_output = None
self._original_hook = sys.displayhook
def _recording_hook(self, value):
"""Capture expression results during recording."""
if value is not None:
builtins._ = value
self._captured_output = repr(value)
print(value)
def execute(self, line):
"""Execute a line and record it with its output."""
self._captured_output = None
sys.displayhook = self._recording_hook
print(f">>> {line}")
self.console.push(line)
sys.displayhook = self._original_hook
self.log.append({
"input": line,
"output": self._captured_output,
"timestamp": datetime.now().isoformat(),
})
def save_session(self):
"""Return the session log."""
return {
"recorded_at": datetime.now().isoformat(),
"commands": self.log,
}
def replay_session(session_data):
"""Replay a recorded session and verify outputs match."""
commands = session_data["commands"]
namespace = {}
console = code.InteractiveConsole(locals=namespace)
original_hook = sys.displayhook
total = len(commands)
checked = 0
passed = 0
failed = 0
captured = {"value": None}
def replay_hook(value):
if value is not None:
builtins._ = value
captured["value"] = repr(value)
for i, entry in enumerate(commands):
line = entry["input"]
expected = entry["output"]
print(f"[REPLAY {i+1}/{total}] {line}")
captured["value"] = None
sys.displayhook = replay_hook
console.push(line)
sys.displayhook = original_hook
actual = captured["value"]
if expected is None:
print(f" (no output expected)")
else:
checked += 1
print(f" Expected: {expected}")
print(f" Got : {actual}")
if actual == expected:
passed += 1
print(f" Status : MATCH")
else:
failed += 1
print(f" Status : MISMATCH")
print(f"\n=== REPLAY SUMMARY ===")
print(f"Total: {total} | Checked: {checked} | Passed: {passed} | Failed: {failed}")
# === Demo ===
print("=== RECORDING SESSION ===")
recorder = REPLRecorder()
recorder.execute("a = 5")
recorder.execute("b = 10")
recorder.execute("a + b")
recorder.execute("result = a * b")
recorder.execute("f'The answer is {result}'")
recorder.execute("[x**2 for x in range(1, 6)]")
session = recorder.save_session()
print(f"\nSession saved: {len(session['commands'])} commands recorded")
print(f"\n=== REPLAYING SESSION ===")
replay_session(session)Solution
import sys
import builtins
import code
import json
from datetime import datetime
class REPLRecorder:
def __init__(self):
self.log = []
self.namespace = {}
self.console = code.InteractiveConsole(locals=self.namespace)
self._captured_output = None
self._original_hook = sys.displayhook
def _recording_hook(self, value):
if value is not None:
builtins._ = value
self._captured_output = repr(value)
print(value)
def execute(self, line):
self._captured_output = None
sys.displayhook = self._recording_hook
print(f">>> {line}")
self.console.push(line)
sys.displayhook = self._original_hook
self.log.append({
"input": line,
"output": self._captured_output,
"timestamp": datetime.now().isoformat(),
})
def save_session(self):
return {
"recorded_at": datetime.now().isoformat(),
"commands": self.log,
}
def replay_session(session_data):
commands = session_data["commands"]
namespace = {}
console = code.InteractiveConsole(locals=namespace)
original_hook = sys.displayhook
total = len(commands)
checked = 0
passed = 0
failed = 0
captured = {"value": None}
def replay_hook(value):
if value is not None:
builtins._ = value
captured["value"] = repr(value)
for i, entry in enumerate(commands):
line = entry["input"]
expected = entry["output"]
print(f"[REPLAY {i+1}/{total}] {line}")
captured["value"] = None
sys.displayhook = replay_hook
console.push(line)
sys.displayhook = original_hook
actual = captured["value"]
if expected is None:
print(f" (no output expected)")
else:
checked += 1
print(f" Expected: {expected}")
print(f" Got : {actual}")
if actual == expected:
passed += 1
print(f" Status : MATCH")
else:
failed += 1
print(f" Status : MISMATCH")
print(f"\n=== REPLAY SUMMARY ===")
print(f"Total: {total} | Checked: {checked} | Passed: {passed} | Failed: {failed}")
print("=== RECORDING SESSION ===")
recorder = REPLRecorder()
recorder.execute("a = 5")
recorder.execute("b = 10")
recorder.execute("a + b")
recorder.execute("result = a * b")
recorder.execute("f'The answer is {result}'")
recorder.execute("[x**2 for x in range(1, 6)]")
session = recorder.save_session()
print(f"\nSession saved: {len(session['commands'])} commands recorded")
print(f"\n=== REPLAYING SESSION ===")
replay_session(session)
Architecture of the record/replay system:
RECORDING PHASE REPLAY PHASE
=============== =============
User input Saved session log
│ │
▼ ▼
┌────────────┐ ┌──────────────┐
│ execute() │ │ replay_session│
│ push line │ │ push line │
└──────┬─────┘ └──────┬───────┘
│ │
▼ ▼
┌────────────────┐ ┌────────────────┐
│ recording_hook │ │ replay_hook │
│ capture repr() │ │ capture repr() │
└──────┬─────────┘ └──────┬─────────┘
│ │
▼ ▼
┌────────────┐ ┌──────────────────┐
│ session log │ ─── save ───▶ │ compare expected │
│ [{input, │ (JSON) │ vs actual repr() │
│ output}] │ └──────────────────┘
└────────────┘
Real-world applications of this pattern:
-
Doctest: Python's
doctestmodule uses exactly this approach — it records expected REPL output in docstrings and replays it to verify correctness. -
Jupyter notebooks: Notebooks store both input cells and their outputs. The "Restart and Run All" feature is essentially a replay that regenerates all outputs.
-
Regression testing: Record a session that exercises your library, save it, and replay after code changes to detect regressions.
-
Teaching tools: Record an expert's REPL session and let students replay it step-by-step with explanations.
Limitations to be aware of:
- Non-deterministic operations (
random,datetime.now(),id()) will produce mismatches on replay - Side effects (file I/O, network calls) are not captured
- Multi-line blocks (functions, loops) require tracking the
push()return value for continuation lines
import code
import sys
import json
from datetime import datetime
class REPLRecorder:
"""Records REPL commands and outputs for later replay."""
def __init__(self):
# TODO: Set up recording infrastructure
pass
def execute(self, line):
# TODO: Execute and record
pass
def save_session(self):
# TODO: Return session log as serializable data
pass
def replay_session(session_log):
"""Replay a recorded session, showing inputs and expected vs actual outputs."""
# TODO: Re-execute each command, compare outputs
passExpected Output
=== RECORDING SESSION ===\n>>> a = 5\n>>> b = 10\n>>> a + b\n15\n>>> result = a * b\n>>> f'The answer is {result}'\n'The answer is 50'\n>>> [x**2 for x in range(1, 6)]\n[1, 4, 9, 16, 25]\n\nSession saved: 6 commands recorded\n\n=== REPLAYING SESSION ===\n[REPLAY 1/6] a = 5\n (no output expected)\n[REPLAY 2/6] b = 10\n (no output expected)\n[REPLAY 3/6] a + b\n Expected: 15\n Got : 15\n Status : MATCH\n[REPLAY 4/6] result = a * b\n (no output expected)\n[REPLAY 5/6] f'The answer is {result}'\n Expected: 'The answer is 50'\n Got : 'The answer is 50'\n Status : MATCH\n[REPLAY 6/6] [x**2 for x in range(1, 6)]\n Expected: [1, 4, 9, 16, 25]\n Got : [1, 4, 9, 16, 25]\n Status : MATCH\n\n=== REPLAY SUMMARY ===\nTotal: 6 | Checked: 3 | Passed: 3 | Failed: 0Hints
Hint 1: For recording: use a custom `sys.displayhook` to capture expression outputs as their `repr()` strings. Store each command alongside its captured output (or None for statements) in a list.
Hint 2: For replaying: iterate through the recorded commands, execute each in a fresh namespace using `compile(mode="single")` + `exec()`, capture the output with another custom displayhook, and compare the `repr()` of the replayed result against the recorded output.
