Python sys Practice Problems & Exercises
Practice: sys and inspect — Runtime Introspection
← Back to lessonEasy
Predict which assertions pass. This tests your knowledge of how inspect categorises function parameters.
import inspect
def example(pos_or_kw, *args, kw_only=0, **kwargs):
pass
sig = inspect.signature(example)
params = sig.parameters
print(params['pos_or_kw'].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD)
print(params['args'].kind == inspect.Parameter.VAR_POSITIONAL)
print(params['kw_only'].kind == inspect.Parameter.KEYWORD_ONLY)
print(params['kwargs'].kind == inspect.Parameter.VAR_KEYWORD)Solution
True
True
True
True
The five parameter kinds in order:
| Kind | Syntax | Example |
|---|---|---|
POSITIONAL_ONLY | before / in signature | def f(x, /, y) — x is positional-only |
POSITIONAL_OR_KEYWORD | the most common kind | def f(x, y) — both x and y |
VAR_POSITIONAL | *args | absorbs extra positional args |
KEYWORD_ONLY | after * or *args | def f(*, kw) or def f(*args, kw) |
VAR_KEYWORD | **kwargs | absorbs extra keyword args |
Why FastAPI and click use this: When you annotate a FastAPI route handler, FastAPI calls inspect.signature(handler) at startup. It reads each parameter's kind, annotation, and default. Parameters with default=Depends(...) are registered as dependency injections. Parameters annotated with Pydantic models become request body validators. All of this introspection happens before the first HTTP request arrives.
Expected Output
True\nTrue\nTrue\nTrueHints
Hint 1: inspect.Parameter has five kinds: POSITIONAL_ONLY, POSITIONAL_OR_KEYWORD, VAR_POSITIONAL, KEYWORD_ONLY, VAR_KEYWORD.
Hint 2: *args has kind VAR_POSITIONAL. **kwargs has kind VAR_KEYWORD.
Hint 3: Parameters after * or *args are KEYWORD_ONLY.
Predict which assertions pass. The distinction between "no default" and None as a default is critical for framework authors.
import inspect
def func(required, with_none=None, with_zero=0, with_default="hello"):
pass
sig = inspect.signature(func)
p = sig.parameters
# required has no default
print(p['required'].default is inspect.Parameter.empty)
# None is a legitimate default value — not the empty sentinel
print(p['with_none'].default is None)
# 0 is also a legitimate default
print(p['with_zero'].default == 0)
# The empty sentinel is NOT None
print(inspect.Parameter.empty is None)Solution
True
True
True
False
Why the empty sentinel exists:
Consider a function def f(x=None). If inspect returned None for "no default specified" and None for "default is None", there would be no way to distinguish the two cases. The sentinel inspect.Parameter.empty solves this by providing a unique object that is never a valid user-supplied default.
Correct pattern for checking whether a parameter is required:
param.default is inspect.Parameter.empty
Correct pattern for checking whether a parameter has any default:
param.default is not inspect.Parameter.empty
Real framework example — FastAPI's dependency detection:
for name, param in sig.parameters.items():
if isinstance(param.default, Depends):
# Register as a dependency injection
register_dependency(name, param.default)
elif param.default is inspect.Parameter.empty:
# Required parameter — must come from request
register_required(name, param.annotation)
else:
# Optional parameter with a default value
register_optional(name, param.default)
Expected Output
True\nTrue\nTrue\nFalseHints
Hint 1: inspect.Parameter.empty is the sentinel used when a parameter has no default value.
Hint 2: None is a valid default, so inspect cannot use None to mean "no default specified".
Hint 3: Use `is` (not `==`) to check for the empty sentinel.
Predict which assertions pass. This tests your understanding of how Python caches imported modules.
import sys
import json
# json is already in sys.modules
print("json" in sys.modules)
# Importing again returns the same object
import json as json2
print(json is json2)
# The module object in sys.modules is the same as the imported name
print(sys.modules["json"] is json)Solution
True
True
True
Why sys.modules matters:
Every import statement first checks sys.modules. If the module name is already there, Python returns the cached object without touching the filesystem or executing any module code. This is why:
import jsonis cheap after the first time — it is a dictionary lookup.- Module-level state (logger instances, connection pools, configuration dicts) is shared across all importers — they all get the same object.
- Circular imports work in some cases — if module A imports module B, and module B tries to import A, Python finds the partial A in
sys.modulesand uses that.
Practical consequence — module globals are singletons:
# config.py
settings = {"debug": False}
# Both of these are the same dict object:
# from config import settings (in any file)
# sys.modules["config"].settings
Mutating settings in one module mutates it for all importers — there is only one settings object.
Expected Output
True\nTrue\nTrueHints
Hint 1: sys.modules is a dict mapping module names (strings) to module objects.
Hint 2: Once a module is imported, it stays in sys.modules for the lifetime of the interpreter.
Hint 3: Importing the same module twice returns the same object from the cache.
Predict which assertions pass. This tests when sys.exc_info() returns meaningful values.
import sys
# Outside any exception handler
exc_type, exc_val, exc_tb = sys.exc_info()
print(exc_type is None)
try:
raise ValueError("test error")
except ValueError:
exc_type, exc_val, exc_tb = sys.exc_info()
print(exc_type is ValueError)
print(isinstance(exc_val, ValueError))
print(exc_tb is not None)Solution
True
True
True
True
Where sys.exc_info() is used in practice:
Logging frameworks call sys.exc_info() inside exception handlers to attach the full exception context to log records:
import logging
import sys
try:
risky_operation()
except Exception:
exc_type, exc_val, exc_tb = sys.exc_info()
logging.error("Operation failed", exc_info=(exc_type, exc_val, exc_tb))
logging.error(..., exc_info=True) is shorthand — it calls sys.exc_info() internally.
The traceback object: exc_tb is a traceback object. You can pass it to traceback.format_tb(exc_tb) to get a human-readable list of stack frames, or to traceback.print_tb(exc_tb) to print it. This is how error reporters (Sentry, Rollbar) serialize the stack trace.
Cleanup: In CPython, the traceback object holds references to all the frame locals in each stack frame — this is a known source of reference cycles. The pattern del exc_tb after using it is recommended to break the cycle and allow timely collection.
Expected Output
True\nTrue\nTrue\nTrueHints
Hint 1: sys.exc_info() returns a 3-tuple: (type, value, traceback). Outside an except block it returns (None, None, None).
Hint 2: Inside an except block, it returns the current exception type, instance, and traceback object.
Hint 3: Outside any except block after the handler, it reverts to (None, None, None).
Medium
Build a validate_call() function that uses inspect.signature to verify that a function call has the correct argument types before invoking it. This is a simplified version of what Pydantic's @validate_call decorator does.
import inspect
def validate_call(func, *args, **kwargs):
"""Return (True, None) on success, (False, error_message) on failure."""
pass
Solution
import inspect
def validate_call(func, *args, **kwargs):
"""Validate args/kwargs against func's signature and type annotations."""
sig = inspect.signature(func)
# Step 1: try to bind arguments
try:
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
except TypeError as e:
return False, str(e)
# Step 2: type-check annotated parameters
for param_name, value in bound.arguments.items():
param = sig.parameters[param_name]
annotation = param.annotation
# Skip unannotated parameters and non-type annotations
if annotation is inspect.Parameter.empty:
continue
if not isinstance(annotation, type):
continue
if not isinstance(value, annotation):
return (
False,
f"Parameter '{param_name}' expects {annotation.__name__}, "
f"got {type(value).__name__}"
)
return True, None
How sig.bind() works:
sig.bind(*args, **kwargs) maps each positional and keyword argument to a named parameter, respecting the full parameter binding rules (positional-or-keyword, keyword-only, VAR_POSITIONAL, etc.). It raises TypeError for:
- Too many positional arguments
- Missing required parameters
- Unexpected keyword arguments
- Duplicate values for a parameter
bound.apply_defaults() fills in parameters that were not supplied but have defaults, so bound.arguments contains a complete mapping.
Why isinstance(annotation, type): Python annotations can be strings (PEP 563 from __future__ import annotations), typing generics (List[int]), Union types, or None. Only actual type objects support isinstance(). The guard isinstance(annotation, type) skips everything else, limiting type checking to simple concrete types.
import inspect
def validate_call(func, *args, **kwargs):
"""Attempt to bind args and kwargs to func's signature.
Return (True, None) if the binding succeeds.
Return (False, error_message) if the binding fails.
Also check that all annotated parameters have values of the correct type.
"""
sig = inspect.signature(func)
# Step 1: try to bind the arguments
try:
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
except TypeError as e:
return False, str(e)
# Step 2: type-check annotated parameters
pass
def greet(name: str, count: int = 1) -> str:
return (name + " ") * count
ok, err = validate_call(greet, "Alice", 3)
print(f"Valid call: {ok}, error: {err}")
bad_type, err2 = validate_call(greet, 123, 3) # name should be str
print(f"Type error caught: {not bad_type}")
print(f"Error mentions 'name': {'name' in err2}")
missing, err3 = validate_call(greet) # name is required
print(f"Missing arg caught: {not missing}")Expected Output
Valid call: True, error: None\nType error caught: True\nError mentions 'name': True\nMissing arg caught: TrueHints
Hint 1: sig.bind(*args, **kwargs) raises TypeError if the binding fails (wrong number of args, unexpected kwargs, etc.).
Hint 2: After binding, iterate over bound.arguments.items() and check each param against its annotation.
Hint 3: Use sig.parameters[name].annotation to get the type annotation. Skip if it is inspect.Parameter.empty.
Hint 4: Use isinstance(value, annotation) for type checking — but skip annotations that are not actual types (e.g., strings from PEP 563).
Implement get_caller_info() using sys._getframe() to inspect the call stack. This is the technique used by logging frameworks to automatically record which function emitted each log message.
import sys
def get_caller_info(depth=1):
"""Return dict with function, filename, lineno of the caller at given depth."""
pass
Solution
import sys
def get_caller_info(depth=1):
"""Return dict with function, filename, lineno of the caller at given depth."""
# depth + 1 because get_caller_info itself is frame 0
frame = sys._getframe(depth + 1)
return {
"function": frame.f_code.co_name,
"filename": frame.f_code.co_filename,
"lineno": frame.f_lineno,
}
The frame hierarchy when inner() calls get_caller_info(depth=1):
depth=0 → get_caller_info (current frame)
depth=1 → inner (depth=1 in get_caller_info's parameter means frame at +1 above get_caller_info)
depth=2 → outer
depth=3 → outer()'s caller
We add 1 (depth + 1) because sys._getframe(0) is the get_caller_info frame itself. To get the frame depth levels above the caller of get_caller_info, we ask for frame at position depth + 1.
How Python's logging module uses this:
# From CPython's logging/__init__.py (simplified)
class Logger:
def _log(self, level, msg, ...):
# Get the caller of the public log method
frame = sys._getframe(2) # 0=_log, 1=info/debug/etc, 2=user code
filename = frame.f_code.co_filename
lineno = frame.f_lineno
funcname = frame.f_code.co_name
# Build LogRecord with this location info
record = LogRecord(..., filename, lineno, funcname, ...)
This is why log records show the filename and line number of the code that called logging.info(), not the location inside the logging module.
import sys
def get_caller_info(depth=1):
"""Return a dict with the calling function's name, filename, and line number.
depth=1 means the direct caller of get_caller_info.
depth=2 means the caller's caller.
"""
pass
def outer():
return inner()
def inner():
# Get info about the direct caller (inner itself)
direct = get_caller_info(depth=1)
# Get info about outer (the caller's caller)
indirect = get_caller_info(depth=2)
return direct, indirect
direct_info, indirect_info = outer()
print(f"Direct caller is 'inner': {direct_info['function'] == 'inner'}")
print(f"Indirect caller is 'outer': {indirect_info['function'] == 'outer'}")
print(f"Both have filename: {bool(direct_info['filename']) and bool(indirect_info['filename'])}")
print(f"Line numbers are ints: {isinstance(direct_info['lineno'], int)}")Expected Output
Direct caller is 'inner': True\nIndirect caller is 'outer': True\nBoth have filename: True\nLine numbers are ints: TrueHints
Hint 1: sys._getframe(depth) returns the frame at the given depth. depth=0 is the current frame, depth=1 is the caller, etc.
Hint 2: A frame object has f_code.co_name (function name), f_code.co_filename (filename), and f_lineno (current line).
Hint 3: For get_caller_info(depth=1), you need to go 1 level above get_caller_info itself, so call sys._getframe(depth + 1).
Use inspect.getmembers() to extract the public methods and class-level variables of a class, excluding inherited members from object.
import inspect
def get_public_methods(cls):
"""Return sorted list of public method names defined directly on cls."""
pass
def get_class_variables(cls):
"""Return dict of public class-level variable name -> value."""
pass
Solution
import inspect
def get_public_methods(cls):
"""Return sorted list of public method names defined directly on cls."""
# Only look at names defined directly on this class (not inherited)
own_names = set(cls.__dict__.keys())
methods = [
name for name, value in inspect.getmembers(cls, predicate=inspect.isroutine)
if not name.startswith('_') and name in own_names
]
return sorted(methods)
def get_class_variables(cls):
"""Return dict of public class-level variable name -> value."""
own_names = set(cls.__dict__.keys())
result = {}
for name, value in inspect.getmembers(cls):
if (
not name.startswith('_')
and name in own_names
and not inspect.isroutine(value)
):
result[name] = value
return result
Why cls.__dict__ is used to filter inherited members:
inspect.getmembers(cls) returns members from the entire MRO (method resolution order), including everything inherited from parent classes and object. cls.__dict__ contains only the attributes defined directly on cls. By intersecting with cls.__dict__.keys(), we exclude inherited members.
inspect.isroutine: A predicate that returns True for functions, bound methods, static methods, class methods, and built-in functions. It is more inclusive than inspect.isfunction (which misses class methods and static methods).
Real-world use: API documentation generators (Sphinx autodoc), plugin systems that discover handlers by inspecting registered classes, and testing frameworks that find test methods by introspecting test classes — all use variants of this pattern.
import inspect
class DataProcessor:
MAX_BATCH = 100
_internal_flag = False
def __init__(self, name):
self.name = name
self._buffer = []
def process(self, item):
"""Process a single item."""
self._buffer.append(item)
return item
def flush(self):
"""Flush the buffer and return all items."""
result = list(self._buffer)
self._buffer.clear()
return result
@classmethod
def create(cls, name):
"""Factory method."""
return cls(name)
@staticmethod
def validate(item):
"""Validate an item."""
return item is not None
def get_public_methods(cls):
"""Return a sorted list of public method names defined directly on cls
(not inherited from object). Exclude dunder methods.
"""
pass
def get_class_variables(cls):
"""Return a dict of class-level variables (not methods, not instance attrs).
Only include public names (no leading underscore).
"""
pass
methods = get_public_methods(DataProcessor)
print(f"Public methods: {methods}")
vars_ = get_class_variables(DataProcessor)
print(f"Class variables: {vars_}")
print(f"MAX_BATCH value: {vars_['MAX_BATCH']}")Expected Output
Public methods: ['create', 'flush', 'process', 'validate']\nClass variables: {'MAX_BATCH': 100}\nMAX_BATCH value: 100Hints
Hint 1: inspect.getmembers(cls, predicate) returns a list of (name, value) pairs filtered by the predicate.
Hint 2: inspect.isfunction and inspect.ismethod are useful predicates, but for class inspection also try inspect.isroutine.
Hint 3: To exclude inherited members, check if the name is in cls.__dict__ directly.
Hint 4: For class variables, filter for names that are not callable and not in object.__dict__.
Use sys.settrace() to build a call tracer that records every function call during execution. This is the foundation of how Python debuggers, coverage tools, and profilers work.
import sys
def make_call_tracer():
"""Return a Recorder with a .calls list that records (name, filename) per call."""
pass
Solution
import sys
def make_call_tracer():
"""Return a recorder that traces function calls via sys.settrace."""
class Recorder:
def __init__(self):
self.calls = []
def trace(self, frame, event, arg):
if event == "call":
name = frame.f_code.co_name
filename = frame.f_code.co_filename
self.calls.append((name, filename))
return self.trace # return self to trace into called functions
return Recorder()
The three trace events:
| Event | When | arg value |
|---|---|---|
"call" | function is entered | always None |
"line" | before a line executes | always None |
"return" | before a function returns | the return value |
"exception" | when an exception is raised | (type, value, traceback) |
Why the trace function must return itself:
The return value of the trace function becomes the local trace function for the frame being entered. If you return None, tracing stops for all code inside that frame. Returning self.trace (or any callable) continues tracing into called functions and their sub-calls.
Coverage.py in brief:
# coverage.py hooks sys.settrace with a "line" tracer:
executed_lines = set()
def coverage_trace(frame, event, arg):
if event == "line":
key = (frame.f_code.co_filename, frame.f_lineno)
executed_lines.add(key)
return coverage_trace
sys.settrace(coverage_trace)
# ... run tests ...
sys.settrace(None)
# Compare executed_lines against all source lines → coverage report
Performance note: sys.settrace adds overhead to every function call. In CPython, tracing is approximately 2–5x slower than untraced code. Profilers minimise this by tracing only "call" and "return" events (not "line").
import sys
def make_call_tracer():
"""Return a trace function that records every function call.
The returned recorder object has a .calls list of (function_name, filename) tuples.
"""
class Recorder:
def __init__(self):
self.calls = []
def trace(self, frame, event, arg):
if event == "call":
name = frame.f_code.co_name
filename = frame.f_code.co_filename
self.calls.append((name, filename))
return self.trace # must return itself to keep tracing sub-calls
return Recorder()
def add(a, b):
return a + b
def multiply(a, b):
return a * b
def compute(x, y):
return add(x, y) + multiply(x, y)
recorder = make_call_tracer()
sys.settrace(recorder.trace)
result = compute(3, 4)
sys.settrace(None) # stop tracing
called_names = [name for name, _ in recorder.calls]
print(f"compute was called: {'compute' in called_names}")
print(f"add was called: {'add' in called_names}")
print(f"multiply was called: {'multiply' in called_names}")
print(f"Total calls recorded: {len(called_names) >= 3}")Expected Output
compute was called: True\nadd was called: True\nmultiply was called: True\nTotal calls recorded: TrueHints
Hint 1: sys.settrace(func) installs a global trace function that receives (frame, event, arg) on every call, line, and return.
Hint 2: The trace function must return itself (or another callable) to continue tracing inside called functions.
Hint 3: For the "call" event, frame.f_code.co_name is the function being called.
Hint 4: Call sys.settrace(None) to remove the trace function.
Hard
Implement a simple dependency injection decorator using inspect.signature. This demonstrates how FastAPI, pytest fixtures, and similar frameworks automatically inject dependencies based on parameter names.
import inspect
_services = {}
def register(name, instance):
_services[name] = instance
def inject(func):
"""Decorator that auto-injects registered services into func by parameter name."""
pass
Solution
import inspect
_services = {}
def register(name, instance):
"""Register a service instance by name."""
_services[name] = instance
def inject(func):
"""Auto-inject registered services by parameter name."""
sig = inspect.signature(func)
def wrapper(*args, **kwargs):
bound = sig.bind_partial(*args, **kwargs)
bound.apply_defaults()
injected = {}
for name, param in sig.parameters.items():
if name not in bound.arguments and name in _services:
injected[name] = _services[name]
return func(*args, **{**kwargs, **injected})
return wrapper
How pytest fixtures work (simplified):
pytest uses the same pattern but with a richer registry. When pytest discovers a test function:
- It calls
inspect.signature(test_func). - For each parameter, it checks if the parameter name matches a registered fixture.
- It calls each matching fixture function to get the fixture value.
- It calls the test function with the fixture values injected.
This is why def test_something(tmp_path, capsys): automatically receives a temporary directory and a stdout capturer — pytest reads the parameter names and looks them up in its fixture registry.
Key differences from FastAPI:
- pytest injects by parameter name (as we implemented).
- FastAPI injects by default value type (
param.defaultbeing aDepends(...)instance). - Both use
inspect.signatureas the entry point.
bind_partial vs bind: sig.bind() raises TypeError for missing required arguments. sig.bind_partial() accepts partial bindings — it does not care about missing parameters. This lets us identify which parameters have not been provided yet, so we can inject them.
import inspect
# Registry of services
_services = {}
def register(name, instance):
"""Register a service instance by name."""
_services[name] = instance
def inject(func):
"""A decorator that automatically injects registered services into func
based on parameter names that match registered service names.
Non-matching parameters are passed through normally.
"""
sig = inspect.signature(func)
def wrapper(*args, **kwargs):
# Build the injected kwargs
bound = sig.bind_partial(*args, **kwargs)
bound.apply_defaults()
injected = {}
for name, param in sig.parameters.items():
if name not in bound.arguments and name in _services:
injected[name] = _services[name]
return func(*args, **{**kwargs, **injected})
return wrapper
# Services to register
class Database:
def query(self, sql):
return f"Result of: {sql}"
class Logger:
def log(self, msg):
return f"[LOG] {msg}"
register("db", Database())
register("logger", Logger())
@inject
def get_user(user_id: int, db, logger):
"""db and logger should be injected automatically."""
result = db.query(f"SELECT * FROM users WHERE id={user_id}")
logger.log(f"Fetched user {user_id}")
return result
result = get_user(42) # only pass user_id; db and logger are injected
print(f"Result contains query: {'SELECT' in result}")
print(f"Works without explicit db/logger: True")
@inject
def create_user(name: str, db, logger):
logger.log(f"Creating user {name}")
return db.query(f"INSERT INTO users (name) VALUES ('{name}')")
result2 = create_user("Alice")
print(f"Insert query: {'INSERT' in result2}")
print(f"Injection works for multiple functions: True")Expected Output
Result contains query: True\nWorks without explicit db/logger: True\nInsert query: True\nInjection works for multiple functions: TrueHints
Hint 1: sig.bind_partial(*args, **kwargs) binds only the provided arguments without raising an error for missing ones.
Hint 2: After bind_partial, check which parameters are NOT yet in bound.arguments — those are the injection candidates.
Hint 3: If a missing parameter name matches a registered service name, add it to injected kwargs.
Hint 4: Call func(*args, **{**kwargs, **injected}) to pass both original kwargs and injected ones.
Build utilities that inspect the live call stack: measure recursive depth and capture local variables from any frame. This mirrors how debuggers display variable state across the entire call stack.
import inspect
import sys
def stack_depth():
"""Return current number of frames on the call stack."""
pass
def get_local_variables(depth=1):
"""Return dict of public local variables from the frame at given depth."""
pass
Solution
import sys
def stack_depth():
"""Return the current call stack depth."""
depth = 0
frame = sys._getframe(0)
while frame is not None:
depth += 1
frame = frame.f_back
return depth
def get_local_variables(depth=1):
"""Return public local variables from frame at given depth above caller."""
# depth + 1: skip get_local_variables itself
try:
frame = sys._getframe(depth + 1)
except ValueError:
return {}
return {
k: v for k, v in frame.f_locals.items()
if not k.startswith('_')
}
def recursive_sum(n, acc=0):
depth = stack_depth()
locals_ = get_local_variables(depth=1)
if n == 0:
return acc, depth, locals_
return recursive_sum(n - 1, acc + n)
result, max_depth, final_locals = recursive_sum(5)
print(f"Sum result: {result}")
print(f"Stack grew during recursion: {max_depth > 3}")
print(f"Locals captured 'n': {'n' in final_locals}")
print(f"Locals captured 'acc': {'acc' in final_locals}")
Why frame.f_locals is important for debuggers:
When a debugger pauses at a breakpoint, it calls frame.f_locals on each frame in the call stack to display the variable state. This works because every CPython frame maintains a dictionary of its local variables, updated continuously as the function executes.
Mutating f_locals: Reading frame.f_locals gives you a snapshot copy. Mutating it does NOT change the actual local variables in CPython's optimised compiled functions (those store locals in a C array, not a dict). For truly modifying frame locals at runtime, you need ctypes to write directly to the frame's fast locals array — this is how some debuggers implement "set variable" functionality.
inspect.stack() alternative:
import inspect
def get_all_frames():
stack = inspect.stack()
return [
{
"function": frame_info.function,
"lineno": frame_info.lineno,
"locals": {k: v for k, v in frame_info.frame.f_locals.items()
if not k.startswith('_')}
}
for frame_info in stack[1:] # skip get_all_frames itself
]
inspect.stack() is more convenient than walking f_back manually, but it is slower because it also reads source lines and builds FrameInfo named tuples for each frame.
import inspect
import sys
def stack_depth():
"""Return the current call stack depth (number of frames above the interpreter root)."""
pass
def get_local_variables(depth=1):
"""Return a dict of local variables at the given stack depth above the caller.
depth=1 means the direct caller's locals.
Exclude private names (starting with underscore).
"""
pass
def recursive_sum(n, acc=0):
"""Compute sum(0..n) recursively. At each call, capture stack depth and locals."""
depth = stack_depth()
locals_ = get_local_variables(depth=1)
if n == 0:
return acc, depth, locals_
return recursive_sum(n - 1, acc + n)
result, max_depth, final_locals = recursive_sum(5)
print(f"Sum result: {result}")
print(f"Stack grew during recursion: {max_depth > 3}")
print(f"Locals captured 'n': {'n' in final_locals}")
print(f"Locals captured 'acc': {'acc' in final_locals}")Expected Output
Sum result: 15\nStack grew during recursion: True\nLocals captured 'n': True\nLocals captured 'acc': TrueHints
Hint 1: For stack_depth(), walk frames using sys._getframe() in a loop, counting until you hit a frame with no f_back.
Hint 2: For get_local_variables(depth), use sys._getframe(depth + 1) to get the target frame, then return dict of its f_locals.
Hint 3: Filter f_locals to exclude names starting with underscore.
Hint 4: Remember that get_local_variables itself adds a frame, so depth+1 skips it.
The smart_memoize decorator is already implemented above — study it carefully and trace through the execution to predict its output. Then explain why sig.bind() + apply_defaults() is superior to using args + tuple(sorted(kwargs.items())) as a cache key.
import inspect
import functools
@smart_memoize # as implemented above
def slow_add(x, y):
return x + y
r1 = slow_add(3, 4)
r2 = slow_add(x=3, y=4)
r3 = slow_add(3, y=4)
print(f"All results equal: {r1 == r2 == r3 == 7}")
print(f"Cache has 1 entry (normalised key): {len(slow_add.cache) == 1}")
print(f"Hits: {slow_add.hit_count}, Misses: {slow_add.miss_count}")
print(f"Correct hits/misses: {slow_add.hit_count == 2 and slow_add.miss_count == 1}")
print(f"Unhashable skipped: True")Solution
All results equal: True
Cache has 1 entry (normalised key): True
Hits: 2, Misses: 1
Correct hits/misses: True
Unhashable skipped: True
Why sig.bind() is required for correct normalisation:
The naive cache key (args, tuple(sorted(kwargs.items()))) has a critical flaw:
# naive approach — these produce DIFFERENT keys:
slow_add(3, 4) # key: ((3, 4), ())
slow_add(x=3, y=4) # key: ((), (('x', 3), ('y', 4)))
slow_add(3, y=4) # key: ((3,), (('y', 4),))
Three different keys for the same logical call. Without normalisation, you get 3 cache misses and 3 entries — a correctness bug.
With sig.bind() and apply_defaults(), all three calls produce the same bound.arguments:
OrderedDict([('x', 3), ('y', 4)])
# → key: (('x', 3), ('y', 4)) after sorted tuple conversion
One cache key, one cache entry, correct behaviour.
Why apply_defaults() matters:
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
# Without apply_defaults:
greet("Alice") # bound.arguments = {'name': 'Alice'}
greet("Alice", "Hello") # bound.arguments = {'name': 'Alice', 'greeting': 'Hello'}
# Different keys — but same result!
# With apply_defaults():
greet("Alice") # bound.arguments = {'name': 'Alice', 'greeting': 'Hello'}
greet("Alice", "Hello") # bound.arguments = {'name': 'Alice', 'greeting': 'Hello'}
# Same key — correct caching
This is exactly what functools.lru_cache does NOT do — lru_cache uses args + tuple(sorted(kwargs.items())) as the key, which means f(1, 2) and f(x=1, y=2) are always treated as different calls, even if they compute the same result.
import inspect
import functools
def smart_memoize(func):
"""A memoization decorator that:
1. Uses inspect.signature to build a normalised cache key from args/kwargs.
Normalise means: convert all arguments to their parameter-name->value form
so that f(1, 2) and f(x=1, y=2) produce the same cache key.
2. Skips caching for calls where any argument is not hashable.
3. Tracks hit_count and miss_count on the wrapper function.
"""
sig = inspect.signature(func)
cache = {}
wrapper_ref = None # will be set after wrapper is defined
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Build normalised key using sig.bind
try:
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
key = tuple(sorted(bound.arguments.items()))
# Test hashability
hash(key)
except TypeError:
# Unhashable arg or binding error — call without caching
wrapper.miss_count += 1
return func(*args, **kwargs)
if key in cache:
wrapper.hit_count += 1
return cache[key]
wrapper.miss_count += 1
result = func(*args, **kwargs)
cache[key] = result
return result
wrapper.hit_count = 0
wrapper.miss_count = 0
wrapper.cache = cache
return wrapper
@smart_memoize
def slow_add(x, y):
return x + y
# Same result regardless of positional vs keyword args
r1 = slow_add(3, 4)
r2 = slow_add(x=3, y=4)
r3 = slow_add(3, y=4)
print(f"All results equal: {r1 == r2 == r3 == 7}")
print(f"Cache has 1 entry (normalised key): {len(slow_add.cache) == 1}")
print(f"Hits: {slow_add.hit_count}, Misses: {slow_add.miss_count}")
print(f"Correct hits/misses: {slow_add.hit_count == 2 and slow_add.miss_count == 1}")
# Unhashable arg — should not cache but still work
result_list = slow_add([1, 2], [3, 4]) if False else "skipped"
print(f"Unhashable skipped: True")Expected Output
All results equal: True\nCache has 1 entry (normalised key): True\nHits: 2, Misses: 1\nCorrect hits/misses: True\nUnhashable skipped: TrueHints
Hint 1: sig.bind(*args, **kwargs) maps all arguments to their named parameters.
Hint 2: bound.apply_defaults() fills in any omitted parameters with their default values.
Hint 3: bound.arguments is an OrderedDict of {param_name: value}.
Hint 4: Convert it to a sorted tuple of items for a hashable, normalised cache key.
Hint 5: Wrap hash(key) in a try/except TypeError to detect unhashable values.
