feat(debug): add Python debug visualizer and enhance gdb integration

This commit is contained in:
mynameisdeleted
2026-07-09 16:08:01 -04:00
parent 67e9a0d488
commit 4f693f8cff
9 changed files with 17 additions and 11 deletions

View File

@@ -1,196 +0,0 @@
"""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))