feat(debug): add Python debug visualizer and integrate with existing tools

This commit is contained in:
mynameisdeleted
2026-07-09 14:17:12 -04:00
parent 9b75caba10
commit 7fbdb51be7
7 changed files with 540 additions and 16 deletions

Binary file not shown.

Binary file not shown.

196
python/debug_graph.py Normal file
View File

@@ -0,0 +1,196 @@
"""Generic "reference-graph" -> Debug Visualizer JSON walker, the debugpy
analog of gdb/debug_graph.py.
Works on any plain Python object reachable from an expression, purely by
walking `vars(obj)` -- no changes to the debuggee's source are required.
Rule: every attribute in `vars(obj)` becomes one row in that node's field
table. An attribute whose value is itself a "describable" object (has its
own `vars()`) becomes an edge (and is recursed into); everything else
(None, numbers, strings, ...) is just stringified in place. A visited-id
set makes this safe for cycles, so it handles lists, trees, and arbitrary
(possibly cyclic) graphs uniformly.
Meant to be called via a debugger's "evaluate" request (VS Code's Debug
Visualizer expressionTemplate, or a Watch box) while stopped at a
breakpoint, e.g.:
debug_graph(lst)
debug_graph(top, lst=lst) # extra name=value watched roots
Besides the primary root and any explicit keyword roots, every call also
walks and labels, as additional roots:
- every other local variable visible in the caller's frame -- kind "local"
- every module-level global visible from the caller's frame, skipping
dunders, modules, classes and functions -- kind "global"
This mirrors gdb/debug_graph.py's auto-discovery: it's what lets
`debug_graph(top)` -- a bare int with no object graph of its own -- still
show the whole list, because `lst` (a sibling local) gets auto-walked too.
"""
import json
import sys
import types
def _addr_str(obj_id):
return hex(obj_id)
def _is_describable(val):
if val is None or isinstance(val, (bool, int, float, complex, str, bytes, type)):
return False
if isinstance(val, (types.ModuleType, types.FunctionType, types.BuiltinFunctionType)):
return False
try:
vars(val)
except TypeError:
return False
return True
def _format_scalar(val):
try:
return str(val)
except Exception as e: # noqa: BLE001 - mirror gdb's catch-all here
return "<error: %s>" % e
def _type_name(val):
return type(val).__name__
def _append_field(fields_out, name, value, is_pointer=False, type_hint=None):
field = {"name": name, "value": value, "isPointer": is_pointer}
if type_hint:
field["typeHint"] = type_hint
fields_out.append(field)
def _describe_struct(val, node_id, nodes, edges, visited):
fields_out = []
for name, member_val in vars(val).items():
if _is_describable(member_val):
member_id = id(member_val)
_append_field(fields_out, name, _addr_str(member_id), is_pointer=True, type_hint=_type_name(member_val))
edges.append((node_id, _addr_str(member_id), name))
if member_id not in visited:
visited.add(member_id)
_describe_struct(member_val, _addr_str(member_id), nodes, edges, visited)
else:
_append_field(fields_out, name, _format_scalar(member_val))
nodes[node_id] = fields_out
def _walk_primary_root(root_val, nodes, edges, visited):
if _is_describable(root_val):
node_id = _addr_str(id(root_val))
visited.add(id(root_val))
_describe_struct(root_val, node_id, nodes, edges, visited)
return node_id
if root_val is not None:
nodes["root"] = [{"name": "value", "value": _format_scalar(root_val), "isPointer": False}]
return None
def _add_named_root(name, val, kind, nodes, edges, visited, roots_out, skip_ids):
if _is_describable(val):
obj_id = id(val)
addr = _addr_str(obj_id)
if addr in skip_ids:
return
if obj_id not in visited:
visited.add(obj_id)
_describe_struct(val, addr, nodes, edges, visited)
roots_out.append((name, addr, kind, _type_name(val)))
else:
roots_out.append((name, _format_scalar(val), kind, _type_name(val) if val is not None else None))
def _is_noise_value(val):
if isinstance(val, (types.ModuleType, types.FunctionType, types.BuiltinFunctionType, type)):
return True
# Anything whose *class* comes from the standard library (typing
# constructs, __future__'s _Feature, etc.) is machinery, not user data --
# but only applies to describable objects: "builtins" (int, str, ...)
# is itself in sys.stdlib_module_names, and plain scalars are never noise.
if _is_describable(val) and type(val).__module__ in sys.stdlib_module_names:
return True
return False
def _skip_local(name, val):
return name.startswith("_") or _is_noise_value(val)
def _skip_global(name, val):
return name.startswith("__") or _is_noise_value(val)
def build_graph_json(root_val, watched_roots, frame):
nodes = {}
edges = []
visited = set()
roots = []
skip_ids = set()
used_names = set()
primary_id = _walk_primary_root(root_val, nodes, edges, visited)
if primary_id is not None:
skip_ids.add(primary_id)
for name, val in watched_roots.items():
used_names.add(name)
_add_named_root(name, val, "watched", nodes, edges, visited, roots, skip_ids)
if frame is not None:
# A debugger's "evaluate" often runs the expression with locals and
# globals merged into one dict (so bare names resolve either way),
# which collapses frame.f_locals is frame.f_globals as a way to
# separate them. Cross-referencing the real module namespace by
# identity recovers an accurate local/global split regardless.
module = sys.modules.get(frame.f_globals.get("__name__"))
module_dict = module.__dict__ if module is not None else {}
for name, val in frame.f_locals.items():
if name in used_names or name in module_dict and module_dict[name] is val:
continue # a true module global that leaked into f_locals; handled below
if _skip_local(name, val):
continue
used_names.add(name)
_add_named_root(name, val, "local", nodes, edges, visited, roots, skip_ids)
for name, val in module_dict.items():
if name in used_names or _skip_global(name, val):
continue
used_names.add(name)
_add_named_root(name, val, "global", nodes, edges, visited, roots, skip_ids)
nodes_json = [{"id": nid, "fields": fields} for nid, fields in nodes.items()]
edges_json = [
{"from": src, "to": dst, "label": label}
for (src, dst, label) in edges
if dst in nodes
]
roots_json = [
{"name": name, "value": value, "kind": kind, "type": type_name}
if type_name
else {"name": name, "value": value, "kind": kind}
for (name, value, kind, type_name) in roots
]
return {
"kind": {"nodeTable": True},
"nodes": nodes_json,
"edges": edges_json,
"roots": roots_json,
}
def debug_graph(root, **watched):
"""$debug_graph(EXPR[, name=value]...) -- render EXPR's reference graph
as Debug Visualizer JSON. Called from a debugger's evaluate/Watch box
while stopped at a breakpoint; auto-discovers sibling locals/globals in
the paused frame (see module docstring)."""
frame = sys._getframe(1)
return json.dumps(build_graph_json(root, watched, frame))

63
python/main.py Normal file
View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Generic, Optional, TypeVar
T = TypeVar("T")
# A single node containing data and a reference to the next node
@dataclass
class Node(Generic[T]):
data: T
next: Optional["Node[T]"]
# The main wrapper for the linked list tracking the head node
class LinkedList(Generic[T]):
def __init__(self) -> None:
self._head: Optional[Node[T]] = None
# Add a new element to the front of the list
def push_front(self, data: T) -> None:
self._head = Node(data, self._head)
# Remove and return the front element of the list
def pop_front(self) -> Optional[T]:
if self._head is None:
return None
data = self._head.data
self._head = self._head.next
return data
# Read the front element without removing it
def peek_front(self) -> Optional[T]:
return self._head.data if self._head else None
def main() -> None:
lst: LinkedList[int] = LinkedList()
count = 0
message1 = "hello world"
# Demonstrate pushing items
lst.push_front(10)
count += 1
lst.push_front(20)
count += 1
lst.push_front(30)
count += 1
# Demonstrate peeking at the top item
top = lst.peek_front()
if top is not None:
print(f"Top element: {top}\ncount: {count}") # Output: 30
print(message1)
# Demonstrate popping items
while (value := lst.pop_front()) is not None:
print(f"Popped: {value}")
if __name__ == "__main__":
main()