feat(debug): enhance type hinting in gdb and Python debug visualizers

This commit is contained in:
mynameisdeleted
2026-07-09 16:55:16 -04:00
parent 4f693f8cff
commit b93b7f204f
3 changed files with 136 additions and 34 deletions

View File

@@ -28,10 +28,28 @@ This mirrors debuggers/gdb_debug_graph.py's auto-discovery: it's what lets
show the whole list, because `lst` (a sibling local) gets auto-walked too.
"""
import builtins
import json
import sys
import types
# On gdb (C++/Rust), typing bare `root` into the Watch box happens to work
# as a throwaway "just show me the locals/globals" expression -- gdb
# resolves it to a real (if coincidental) always-null `void*` symbol from
# libc.so.6, so $debug_graph(root) contributes no primary node of its own
# but still triggers full auto-discovery of the frame's locals/globals.
# debugpy has no such accident to lean on -- evaluating a name that isn't
# actually bound anywhere raises NameError before our code ever runs -- so
# this deliberately recreates the same "always-resolvable, always-None"
# spelling as a builtin. This runs as an import-time side effect, but that's
# safe to rely on even the *first* time someone types `root`: evaluating a
# call expression like `__import__('py_debug_graph').debug_graph(root)`
# resolves the callable (which imports this module and runs this line)
# before it evaluates the `root` argument, so the patch is always in place
# by the time `root` itself gets looked up.
if not hasattr(builtins, "root"):
builtins.root = None
def _addr_str(obj_id):
return hex(obj_id)
@@ -67,9 +85,15 @@ def _append_field(fields_out, name, value, is_pointer=False, type_hint=None):
fields_out.append(field)
def _describe_struct(val, node_id, nodes, edges, visited):
def _fields_from_items(items, node_id, nodes, edges, visited):
"""Builds one field row per (name, value) pair, recursing into
describable values as edges exactly like a struct's own members --
shared between _describe_struct's vars(obj).items() and the
globals()-as-root special case's plain dict.items() below, so a
"container of named values" only has one field-building rule
regardless of whether the container is an object or a dict."""
fields_out = []
for name, member_val in vars(val).items():
for name, member_val in 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))
@@ -78,8 +102,16 @@ def _describe_struct(val, node_id, nodes, edges, 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
# Every field gets a type hint, not just the describable ones --
# a plain int/str/None member is just as worth labeling as a
# pointer-like one.
_append_field(fields_out, name, _format_scalar(member_val), type_hint=_type_name(member_val))
return fields_out
def _describe_struct(val, node_id, nodes, edges, visited):
fields_out = _fields_from_items(vars(val).items(), node_id, nodes, edges, visited)
nodes[node_id] = {"fields": fields_out, "type": _type_name(val)}
def _walk_primary_root(root_val, nodes, edges, visited):
@@ -89,7 +121,13 @@ def _walk_primary_root(root_val, nodes, edges, visited):
_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}]
nodes["root"] = {
"fields": [{
"name": "value", "value": _format_scalar(root_val), "isPointer": False,
"typeHint": _type_name(root_val),
}],
"type": _type_name(root_val),
}
return None
@@ -135,7 +173,26 @@ def build_graph_json(root_val, watched_roots, frame):
skip_ids = set()
used_names = set()
primary_id = _walk_primary_root(root_val, nodes, edges, visited)
if frame is not None and root_val is globals:
# Bare `globals` (no parens, e.g. typing "globals" into the Watch
# box) evaluates to the *builtin function itself*, not its result --
# walked normally that's just an opaque "<function globals at ...>"
# scalar. Calling it here would also be wrong: globals() returns
# whatever frame *calls* it, which would be this module's own
# globals, not the paused frame's. Substituting the frame we
# already have (passed in from debug_graph()'s sys._getframe(1))
# is what actually shows the debuggee's global variables. Filtered
# through the same _skip_global used for auto-discovered globals
# below, so this node shows the same clean set, not every dunder,
# module and class in the namespace.
primary_id = "globals()"
fields_out = _fields_from_items(
[(k, v) for k, v in frame.f_globals.items() if not _skip_global(k, v)],
primary_id, nodes, edges, visited,
)
nodes[primary_id] = {"fields": fields_out, "type": "dict"}
else:
primary_id = _walk_primary_root(root_val, nodes, edges, visited)
if primary_id is not None:
skip_ids.add(primary_id)
@@ -166,7 +223,12 @@ def build_graph_json(root_val, watched_roots, frame):
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()]
nodes_json = [
{"id": nid, "fields": node["fields"], "type": node["type"]}
if node.get("type")
else {"id": nid, "fields": node["fields"]}
for nid, node in nodes.items()
]
edges_json = [
{"from": src, "to": dst, "label": label}
for (src, dst, label) in edges