Worked Example: Building a Safer Debug Printer¶
Page Maps¶
graph LR
family["Python Programming"]
program["Python Meta-Programming"]
section["Runtime Observation Inspection"]
page["Worked Example: Building a Safer Debug Printer"]
capstone["Capstone evidence"]
family --> program --> section --> page
page -.applies in.-> capstone
flowchart LR
orient["Orient on the page map"] --> read["Read the main claim and examples"]
read --> inspect["Inspect the related code, proof, or capstone surface"]
inspect --> verify["Run or review the verification path"]
verify --> apply["Apply the idea back to the module and capstone"]
The five core lessons in Module 02 become much easier to trust when they all appear in one realistic tool.
This example uses a debugging helper because it creates exactly the right pressure:
- the tool wants to inspect runtime state
- the caller expects observation, not business behavior
- ordinary attribute access would quietly execute descriptors and fallback hooks
That is the right place to make the static-versus-dynamic boundary concrete.
The incident¶
Assume a team wants a helper called debug_print() for quick runtime inspection during an
incident review.
The original helper does what many first versions do:
- it loops through names from
dir(self) - it reads values with
getattr(self, name) - it recursively prints nested objects
The team reports four problems:
- properties execute during debugging
- dynamic fallback hooks run even when nobody wanted business behavior
- slotted objects are awkward to inspect
- recursive object graphs can loop forever
Every one of those problems is a Module 02 problem, not just a formatting problem.
The first mistake: treating value resolution as observation¶
The inherited sketch looks plausible:
That helper looks observational, but it is already executing the attribute protocol.
So the first repair is conceptual:
a debug printer should not use dynamic reads by default unless executing runtime behavior is the stated goal.
That is the same boundary the module has been drawing all along.
Step 1: separate name discovery from value resolution¶
dir(self) can still be useful for candidate names, with one caveat: it may call a
custom __dir__ implementation.
That is acceptable as a lower-risk discovery step, but it should not be confused with stored state or resolved values.
The workflow becomes:
- discover candidate names
- inspect attached objects statically
- evaluate dynamic values only when the tool is explicitly configured to do so
That one shift changes the honesty of the whole helper.
Step 2: switch the default read path to static lookup¶
The most important repair is using inspect.getattr_static for default reads.
Why this is better:
- properties stay as property objects unless explicitly evaluated
__getattr__is not triggered during default inspection- class-attached descriptors remain visible as attached objects
This is the core of the worked example:
debugging tools usually want attachment truth first, not execution truth.
Step 3: decide what to do with descriptors¶
Once static lookup is the default, the tool still needs policy.
For example:
- show property objects without evaluating them
- optionally evaluate properties behind an explicit flag
- read slot descriptors carefully when you want actual slot values
That policy is clearer than pretending every attribute read is harmless.
Step 4: make recursion explicit and bounded¶
Naive debug printers often recurse into everything, which creates two kinds of trouble:
- giant unreadable output
- infinite loops on cyclic graphs
A safer design makes recursion explicit:
- recurse only into known safe opt-in objects
- keep a visited set
- enforce a maximum depth
Those are not cosmetic concerns. They are part of making the tool behave like a debugging tool instead of like an accidental object walker with side effects.
A healthier implementation¶
import inspect
from types import MemberDescriptorType
from typing import Any
class DebugMixin:
def debug_print(
self,
*,
max_depth: int = 3,
_depth: int = 0,
_visited: set[int] | None = None,
indent: int = 0,
eval_properties: bool = False,
show_dunder: bool = False,
) -> None:
if _visited is None:
_visited = set()
obj_id = id(self)
if obj_id in _visited:
print(" " * indent + f"<Revisited id={obj_id}>")
return
_visited.add(obj_id)
t = type(self)
print(" " * indent + f"{t.__name__}(id={obj_id}) " + "{")
if _depth >= max_depth:
print(" " * (indent + 2) + "[Max depth reached]")
print(" " * indent + "}")
return
for name in sorted(dir(self)):
if not show_dunder and name.startswith("__"):
continue
if name == "debug_print":
try:
raw_dbg = inspect.getattr_static(self, name)
if raw_dbg is DebugMixin.debug_print:
continue
except Exception:
pass
try:
raw = inspect.getattr_static(self, name)
except AttributeError:
print(" " * (indent + 2) + f"{name}: <missing>")
continue
value: Any
if isinstance(raw, MemberDescriptorType):
try:
value = raw.__get__(self, t)
except Exception as exc:
value = f"<slot read error {type(exc).__name__}: {exc}>"
elif isinstance(raw, property):
if eval_properties:
try:
value = raw.__get__(self, t)
except Exception as exc:
value = f"<property error {type(exc).__name__}: {exc}>"
else:
value = raw
else:
value = raw
is_primitive = isinstance(value, (int, float, str, bool, type(None)))
rep = repr(value)
rep = rep if len(rep) <= 80 else rep[:77] + "..."
prefix = "" if is_primitive else f"{type(value).__name__} "
print(" " * (indent + 2) + f"{name}: {prefix}{rep}")
if (
not is_primitive
and not callable(value)
and isinstance(value, DebugMixin)
):
value.debug_print(
max_depth=max_depth,
_depth=_depth + 1,
_visited=_visited,
indent=indent + 4,
eval_properties=eval_properties,
show_dunder=show_dunder,
)
print(" " * indent + "}")
Why this version is better¶
The repaired helper is stronger because it makes its observation policy explicit:
- discovery comes from
dir - default reads come from
inspect.getattr_static - property execution is opt-in
- slot values are handled deliberately
- recursion is bounded and cycle-aware
It is still not magic. It is just honest about what it is observing and when it crosses into execution.
What this example teaches about Module 02¶
This worked example ties the module together:
- names are not the same as stored state or resolved values
- dynamic reads execute runtime behavior
- static lookup is the right default for many tools
- callability still matters because recursion and display policy should not blindly invoke values
- disciplined observation is a workflow, not one builtin
That is the real win. A safer debug printer is just one concrete place where the module's observation rules prove their value.
The review loop to keep¶
When you inherit a runtime-inspection helper, run this loop:
- identify whether it discovers names, reads state, or resolves values
- mark every dynamic read that may execute code
- move default inspection paths toward static lookup where appropriate
- make evaluation, recursion, and display policy explicit
If you can do that here, Module 02 has done its job and Module 03 can build on a much cleaner observation discipline.
Continue through Module 02¶
- Previous: Static Lookup and Disciplined Observation
- Next: Exercises
- Reference: Exercise Answers
- Terms: Glossary