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.
For a self-study learner, the debug printer is also useful because it looks harmless. That is exactly why it teaches so well. Many weak runtime tools begin with a sentence like "it just prints fields," and then quietly become accidental execution engines.
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.
Use the incident as a review packet, not just as a code sample:
| Reported symptom | Hidden boundary mistake |
|---|---|
| properties execute during debugging | dynamic lookup was treated as passive inspection |
| fallback hooks run unexpectedly | the helper asked for resolved values too early |
| slotted objects feel inconsistent | the helper assumed dictionary-backed storage |
| recursive graphs loop forever | the tool crossed into object walking without an explicit policy |
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.
If you remember only one thing from the worked example, remember that sentence. Most runtime-inspection bugs are policy bugs before they are implementation bugs.
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.
It also changes the user contract. The tool can now say:
- "by default I will show structure"
- "with an explicit flag I will evaluate selected runtime values"
That is a much better educational and operational promise than "I print everything."
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.
That sentence should shape your review of any future serializer, admin panel, inspector, or object browser. They all face the same temptation to resolve values too early.
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.
A good learner-facing rewrite is to make the policy visible in a table:
| Surface encountered | Default behavior | Optional behavior |
|---|---|---|
| plain stored value | show it | same |
| property | show the property object | evaluate only behind an explicit flag |
| slot descriptor | identify the descriptor | read the slot value deliberately |
| fallback hook | do not trigger it by default | trigger only in a deliberate runtime mode |
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.
Start with the executable non-recursive baseline¶
Before studying the larger mixin below, run the course implementation:
$ make observation-debug-view
[
{
"display": "<routine>",
"kind": "routine",
"name": "acknowledge",
"owner": "IncidentObservationTarget"
},
{
"display": "<property>",
"kind": "property",
"name": "risk_score",
"owner": "IncidentObservationTarget"
},
{
"display": "'queue lag'",
"kind": "stored-value",
"name": "title",
"owner": "instance"
}
]
The implementation in labs/runtime_observation/debug_view.py deliberately does less
than the final mixin:
- it discovers instance and class attachments without calling custom
__dir__ - it reads each candidate with
inspect.getattr_static - it labels properties, routines, slots, and stored scalar values
- it enforces a maximum number of names
- it does not recurse or evaluate descriptors
That smaller contract is executable and tested. Read
test_debug_view_does_not_evaluate_property_or_fallback before modifying it: both
side-effect counters must remain zero.
What the baseline proves¶
The output proves that this tool can show attachment ownership without resolving the property or triggering fallback lookup. It also proves that the output bound is enforced and slot attachments are reported as structure.
It does not prove that repr() is safe for arbitrary values, that class namespaces never
change concurrently, or that recursive observation is safe. The implementation avoids
calling arbitrary repr() by displaying only scalar stored values directly.
Why the larger version still matters¶
The mixin below explores opt-in property evaluation, slot reads, and bounded recursion. Those features introduce new policies and failure paths. Treat it as an extension design to review against the executable baseline:
| Added capability | New cost to review |
|---|---|
| property evaluation | descriptor code can run |
| slot value reads | descriptor access can fail |
| recursion | cycles, depth, and output growth need policy |
| general representation | user-defined __repr__ may execute |
Do not copy the larger implementation until you can say which added capability your tool actually needs.
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.
How to read the implementation without getting lost¶
If the code block feels dense, walk it in this order:
- locate the visited-set guard and note that recursion has an explicit boundary
- find the
inspect.getattr_staticcall and mark it as the default read path - find the property branch and notice that evaluation is opt-in
- find the slot-descriptor branch and notice that slot reads are treated specially
- find the recursion branch and notice that only specific non-primitive values recurse
That reading order turns one long code listing into five policy decisions.
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.
A review packet to leave behind after this example¶
When you finish the worked example, you should be able to write these five short notes without rereading the page:
- one sentence explaining why
diris only a discovery step - one sentence explaining why
inspect.getattr_staticis the default read path - one sentence explaining when the helper intentionally crosses into behavior
- one sentence explaining why slot-backed objects need special handling
- one sentence explaining how the recursion policy protects the tool from becoming an object walker
If you cannot write those notes cleanly, the code probably felt more convincing than it was actually understood.
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
One final pressure question:
If a team says "our debug helper is safe because it only runs in development," which risk boundary from this example are they failing to name?
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