feat(debug): add Python debug visualizer and integrate with existing tools
This commit is contained in:
BIN
python/__pycache__/debug_graph.cpython-314.pyc
Normal file
BIN
python/__pycache__/debug_graph.cpython-314.pyc
Normal file
Binary file not shown.
BIN
python/__pycache__/main.cpython-314.pyc
Normal file
BIN
python/__pycache__/main.cpython-314.pyc
Normal file
Binary file not shown.
196
python/debug_graph.py
Normal file
196
python/debug_graph.py
Normal 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
63
python/main.py
Normal 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()
|
||||
Reference in New Issue
Block a user