feat(debug): add Python debug visualizer and enhance gdb integration
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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))
|
||||
Reference in New Issue
Block a user