197 lines
7.1 KiB
Python
197 lines
7.1 KiB
Python
"""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))
|