methodgraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ """
2
+ methodgraph - Graphical visual flow debugger for Python method calls & passed values.
3
+ """
4
+
5
+ from .tracer import trace, trace_class, TraceSession, get_active_collector, reset_active_collector
6
+ from .renderer import render_and_save, open_in_browser
7
+
8
+ __version__ = "0.1.0"
9
+ __all__ = [
10
+ "trace",
11
+ "trace_class",
12
+ "TraceSession",
13
+ "save_report",
14
+ "show",
15
+ "__version__",
16
+ ]
17
+
18
+ def save_report(output_path: str = "methodgraph_report.html") -> str:
19
+ """Saves current active execution trace to an interactive HTML report file."""
20
+ collector = get_active_collector()
21
+ summary = collector.build_summary()
22
+ return render_and_save(summary, output_path)
23
+
24
+ def show(output_path: str = "methodgraph_report.html"):
25
+ """Saves current trace and opens the graphical report in default browser."""
26
+ path = save_report(output_path)
27
+ open_in_browser(path)
28
+
methodgraph/cli.py ADDED
@@ -0,0 +1,79 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+
5
+ from .tracer import SystemTracer, get_active_collector
6
+ from .renderer import render_and_save, open_in_browser
7
+ from .server import serve_report
8
+
9
+ def main():
10
+ parser = argparse.ArgumentParser(
11
+ prog="methodgraph",
12
+ description="methodgraph: Graphical style visual debugger and flow tracer for Python method calls & passed values."
13
+ )
14
+ subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
15
+
16
+ # Command: run
17
+ run_parser = subparsers.add_parser("run", help="Run and trace a Python script dynamically.")
18
+ run_parser.add_argument("script", help="Path to Python script to trace.")
19
+ run_parser.add_argument("-o", "--output", default="methodgraph_report.html", help="Report output file path (default: methodgraph_report.html).")
20
+ run_parser.add_argument("--open", action="store_true", help="Automatically open report in browser when finished.")
21
+ run_parser.add_argument("--include-stdlib", action="store_true", help="Include Python standard library calls in trace.")
22
+ run_parser.add_argument("script_args", nargs=argparse.REMAINDER, help="Arguments to pass to target script.")
23
+
24
+ # Command: view / serve
25
+ view_parser = subparsers.add_parser("view", help="View/Serve an existing report file.")
26
+ view_parser.add_argument("report", nargs="?", default="methodgraph_report.html", help="Path to report HTML file.")
27
+ view_parser.add_argument("-p", "--port", type=int, default=8080, help="Port to serve report on (default: 8080).")
28
+
29
+ args = parser.parse_args()
30
+
31
+ if not args.command:
32
+ parser.print_help()
33
+ sys.exit(1)
34
+
35
+ if args.command == "run":
36
+ script_path = os.path.abspath(args.script)
37
+ if not os.path.exists(script_path):
38
+ print(f"[methodgraph] Error: Script '{script_path}' not found.")
39
+ sys.exit(1)
40
+
41
+ sys.argv = [script_path] + (args.script_args or [])
42
+
43
+ sys_tracer = SystemTracer(include_stdlib=args.include_stdlib)
44
+ collector = get_active_collector()
45
+
46
+ print(f"[methodgraph] Tracing script: {script_path}...")
47
+ sys.settrace(sys_tracer.trace_dispatch)
48
+
49
+ try:
50
+ with open(script_path, "r", encoding="utf-8") as f:
51
+ code_content = f.read()
52
+ global_scope = {
53
+ "__file__": script_path,
54
+ "__name__": "__main__",
55
+ "__doc__": None,
56
+ "__package__": None,
57
+ }
58
+ compiled_code = compile(code_content, script_path, 'exec')
59
+ exec(compiled_code, global_scope)
60
+ except BaseException as e:
61
+ if not isinstance(e, SystemExit):
62
+ print(f"[methodgraph] Script execution error: {e}")
63
+ finally:
64
+ sys.settrace(None)
65
+
66
+ summary = collector.build_summary()
67
+ output_file = render_and_save(summary, args.output)
68
+ print(f"[methodgraph] Visual trace saved to: {output_file} ({summary['total_calls']} method calls captured)")
69
+
70
+ if args.open:
71
+ open_in_browser(output_file)
72
+
73
+ elif args.command == "view":
74
+ serve_report(args.report, port=args.port, open_browser=True)
75
+
76
+
77
+ if __name__ == "__main__":
78
+ main()
79
+
@@ -0,0 +1,307 @@
1
+ import inspect
2
+ import sys
3
+ import time
4
+ import traceback
5
+ from typing import Any, Dict, List, Optional, Union
6
+
7
+ def safe_serialize_val(val: Any, max_depth: int = 3, max_length: int = 500) -> Dict[str, Any]:
8
+ """Safely converts any Python value into a serializable summary with type and string representation."""
9
+ type_name = type(val).__name__
10
+
11
+ if val is None:
12
+ return {"type": "NoneType", "value": "None"}
13
+ if isinstance(val, (int, float, bool)):
14
+ return {"type": type_name, "value": str(val)}
15
+ if isinstance(val, str):
16
+ val_str = repr(val)
17
+ if len(val_str) > max_length:
18
+ val_str = val_str[:max_length] + " ... [truncated]"
19
+ return {"type": type_name, "value": val_str}
20
+
21
+ try:
22
+ if max_depth <= 0:
23
+ return {"type": type_name, "value": f"<{type_name} instance>"}
24
+
25
+ if isinstance(val, (list, tuple, set)):
26
+ items_summary = [safe_serialize_val(x, max_depth - 1, max_length // 2) for x in list(val)[:10]]
27
+ suffix = f" ... ({len(val)} items total)" if len(val) > 10 else ""
28
+ return {
29
+ "type": f"{type_name}[{len(val)}]",
30
+ "value": str([item["value"] for item in items_summary]) + suffix,
31
+ "items": items_summary
32
+ }
33
+ elif isinstance(val, dict):
34
+ items_summary = {
35
+ str(k): safe_serialize_val(v, max_depth - 1, max_length // 2)
36
+ for k, v in list(val.items())[:10]
37
+ }
38
+ suffix = f" ... ({len(val)} keys total)" if len(val) > 10 else ""
39
+ return {
40
+ "type": f"dict[{len(val)}]",
41
+ "value": str({k: v["value"] for k, v in items_summary.items()}) + suffix,
42
+ "items": items_summary
43
+ }
44
+ except Exception as e:
45
+ pass
46
+
47
+ try:
48
+ val_repr = repr(val)
49
+ except Exception:
50
+ val_repr = f"<{type_name} object at {hex(id(val))}>"
51
+
52
+ if len(val_repr) > max_length:
53
+ val_repr = val_repr[:max_length] + " ... [truncated]"
54
+
55
+ return {"type": type_name, "value": val_repr}
56
+
57
+
58
+ class CallRecord:
59
+ """Represents a single method execution event."""
60
+
61
+ def __init__(
62
+ self,
63
+ call_id: int,
64
+ parent_id: Optional[int],
65
+ depth: int,
66
+ module: str,
67
+ qualname: str,
68
+ func_name: str,
69
+ file_path: str,
70
+ line_no: int,
71
+ args_data: List[Dict[str, Any]],
72
+ kwargs_data: Dict[str, Dict[str, Any]],
73
+ start_time: float,
74
+ ):
75
+ self.call_id = call_id
76
+ self.parent_id = parent_id
77
+ self.depth = depth
78
+ self.module = module
79
+ self.qualname = qualname
80
+ self.func_name = func_name
81
+ self.file_path = file_path
82
+ self.line_no = line_no
83
+ self.args_data = args_data
84
+ self.kwargs_data = kwargs_data
85
+ self.start_time = start_time
86
+
87
+ self.end_time: Optional[float] = None
88
+ self.duration_ms: Optional[float] = None
89
+ self.return_val: Optional[Dict[str, Any]] = None
90
+ self.exception: Optional[Dict[str, Any]] = None
91
+ self.children: List['CallRecord'] = []
92
+
93
+ def complete(self, return_val: Any = None, exception: Optional[BaseException] = None):
94
+ self.end_time = time.perf_counter()
95
+ self.duration_ms = round((self.end_time - self.start_time) * 1000, 4)
96
+
97
+ if exception is not None:
98
+ self.exception = {
99
+ "type": type(exception).__name__,
100
+ "message": str(exception),
101
+ "traceback": "".join(traceback.format_exception(type(exception), exception, exception.__traceback__))
102
+ }
103
+ else:
104
+ self.return_val = safe_serialize_val(return_val)
105
+
106
+ def to_dict(self) -> Dict[str, Any]:
107
+ return {
108
+ "call_id": self.call_id,
109
+ "parent_id": self.parent_id,
110
+ "depth": self.depth,
111
+ "module": self.module,
112
+ "qualname": self.qualname,
113
+ "func_name": self.func_name,
114
+ "file_path": self.file_path,
115
+ "line_no": self.line_no,
116
+ "args_data": self.args_data,
117
+ "kwargs_data": self.kwargs_data,
118
+ "start_time": round(self.start_time, 6),
119
+ "end_time": round(self.end_time, 6) if self.end_time else None,
120
+ "duration_ms": self.duration_ms,
121
+ "return_val": self.return_val,
122
+ "exception": self.exception,
123
+ "children": [c.to_dict() for c in self.children],
124
+ }
125
+
126
+
127
+ class ExecutionTraceCollector:
128
+ """Collects and organizes records into trees and graph networks."""
129
+
130
+ def __init__(self):
131
+ self.records: Dict[int, CallRecord] = {}
132
+ self.root_records: List[CallRecord] = []
133
+ self._next_id = 1
134
+
135
+ def generate_id(self) -> int:
136
+ cid = self._next_id
137
+ self._next_id += 1
138
+ return cid
139
+
140
+ def add_call(
141
+ self,
142
+ parent_id: Optional[int],
143
+ depth: int,
144
+ module: str,
145
+ qualname: str,
146
+ func_name: str,
147
+ file_path: str,
148
+ line_no: int,
149
+ args_data: List[Dict[str, Any]],
150
+ kwargs_data: Dict[str, Dict[str, Any]],
151
+ ) -> CallRecord:
152
+ call_id = self.generate_id()
153
+ record = CallRecord(
154
+ call_id=call_id,
155
+ parent_id=parent_id,
156
+ depth=depth,
157
+ module=module,
158
+ qualname=qualname,
159
+ func_name=func_name,
160
+ file_path=file_path,
161
+ line_no=line_no,
162
+ args_data=args_data,
163
+ kwargs_data=kwargs_data,
164
+ start_time=time.perf_counter(),
165
+ )
166
+
167
+ self.records[call_id] = record
168
+ if parent_id and parent_id in self.records:
169
+ self.records[parent_id].children.append(record)
170
+ else:
171
+ self.root_records.append(record)
172
+
173
+ return record
174
+
175
+ def build_summary(self) -> Dict[str, Any]:
176
+ """Builds graph nodes, edges, statistics, execution steps, and tree hierarchy."""
177
+ all_records_list = [r.to_dict() for r in sorted(self.records.values(), key=lambda x: x.start_time)]
178
+ tree = [r.to_dict() for r in self.root_records]
179
+
180
+ # Build DAG nodes & edges
181
+ nodes_dict: Dict[str, Dict[str, Any]] = {}
182
+ edges_dict: Dict[str, Dict[str, Any]] = {}
183
+
184
+ min_start = min((r.start_time for r in self.records.values()), default=0.0)
185
+ max_end = max(((r.end_time or r.start_time) for r in self.records.values()), default=0.0)
186
+ total_session_ms = round((max_end - min_start) * 1000, 4) if max_end > min_start else 0.0
187
+
188
+ for r in self.records.values():
189
+ func_key = f"{r.module}.{r.qualname}" if r.module else r.qualname
190
+ if func_key not in nodes_dict:
191
+ nodes_dict[func_key] = {
192
+ "id": func_key,
193
+ "label": r.qualname,
194
+ "module": r.module,
195
+ "func_name": r.func_name,
196
+ "file_path": r.file_path,
197
+ "line_no": r.line_no,
198
+ "count": 0,
199
+ "total_duration_ms": 0.0,
200
+ "max_duration_ms": 0.0,
201
+ "min_duration_ms": float("inf"),
202
+ "exceptions_count": 0,
203
+ "latest_call_id": r.call_id,
204
+ "sample_args": r.args_data,
205
+ "sample_kwargs": r.kwargs_data,
206
+ "sample_return": r.return_val,
207
+ "sample_exception": r.exception,
208
+ }
209
+
210
+ node = nodes_dict[func_key]
211
+ node["count"] += 1
212
+ node["latest_call_id"] = r.call_id
213
+ dur = r.duration_ms or 0.0
214
+ node["total_duration_ms"] += dur
215
+ if dur > node["max_duration_ms"]:
216
+ node["max_duration_ms"] = dur
217
+ if dur < node["min_duration_ms"]:
218
+ node["min_duration_ms"] = dur
219
+ if r.exception:
220
+ node["exceptions_count"] += 1
221
+
222
+ # Group records by parent_id to build Flow Graph edges later
223
+ from collections import defaultdict
224
+ children_by_parent = defaultdict(list)
225
+ for r in self.records.values():
226
+ children_by_parent[r.parent_id].append(r)
227
+
228
+ for parent_id, children in children_by_parent.items():
229
+ # Sort children chronologically by start time
230
+ children.sort(key=lambda x: x.start_time)
231
+
232
+ # 1. Connect Parent to First Child
233
+ if parent_id and parent_id in self.records:
234
+ parent_record = self.records[parent_id]
235
+ parent_key = f"{parent_record.module}.{parent_record.qualname}" if parent_record.module else parent_record.qualname
236
+ first_child = children[0]
237
+ fc_key = f"{first_child.module}.{first_child.qualname}" if first_child.module else first_child.qualname
238
+
239
+ edge_key = f"{parent_key} -> {fc_key}"
240
+ if edge_key not in edges_dict:
241
+ edges_dict[edge_key] = {"source": parent_key, "target": fc_key, "count": 0}
242
+ edges_dict[edge_key]["count"] += 1
243
+
244
+ # 2. Connect Siblings Sequentially (Flow Edges)
245
+ for i in range(len(children) - 1):
246
+ curr_c = children[i]
247
+ next_c = children[i + 1]
248
+ curr_key = f"{curr_c.module}.{curr_c.qualname}" if curr_c.module else curr_c.qualname
249
+ next_key = f"{next_c.module}.{next_c.qualname}" if next_c.module else next_c.qualname
250
+
251
+ edge_key = f"{curr_key} -> {next_key}"
252
+ if edge_key not in edges_dict:
253
+ edges_dict[edge_key] = {"source": curr_key, "target": next_key, "count": 0}
254
+ edges_dict[edge_key]["count"] += 1
255
+
256
+ for node in nodes_dict.values():
257
+ node["avg_duration_ms"] = round(node["total_duration_ms"] / node["count"], 4) if node["count"] else 0.0
258
+ node["total_duration_ms"] = round(node["total_duration_ms"], 4)
259
+ if node["min_duration_ms"] == float("inf"):
260
+ node["min_duration_ms"] = 0.0
261
+
262
+ # Build chronological execution steps
263
+ steps = []
264
+ for idx, r in enumerate(all_records_list, start=1):
265
+ parent_key = None
266
+ if r["parent_id"] and r["parent_id"] in self.records:
267
+ pr = self.records[r["parent_id"]]
268
+ parent_key = f"{pr.module}.{pr.qualname}" if pr.module else pr.qualname
269
+
270
+ func_key = f"{r['module']}.{r['qualname']}" if r["module"] else r["qualname"]
271
+ steps.append({
272
+ "step_idx": idx,
273
+ "call_id": r["call_id"],
274
+ "node_id": func_key,
275
+ "parent_node_id": parent_key,
276
+ "qualname": r["qualname"],
277
+ "func_name": r["func_name"],
278
+ "module": r["module"],
279
+ "depth": r["depth"],
280
+ "duration_ms": r["duration_ms"],
281
+ "args_data": r["args_data"],
282
+ "kwargs_data": r["kwargs_data"],
283
+ "return_val": r["return_val"],
284
+ "exception": r["exception"],
285
+ "file_path": r["file_path"],
286
+ "line_no": r["line_no"],
287
+ })
288
+
289
+ # Identify roots and leaves
290
+ all_sources = {e["source"] for e in edges_dict.values()}
291
+ all_targets = {e["target"] for e in edges_dict.values()}
292
+ root_nodes = [nid for nid in nodes_dict.keys() if nid not in all_targets]
293
+ leaf_nodes = [nid for nid in nodes_dict.keys() if nid not in all_sources]
294
+
295
+ return {
296
+ "total_calls": len(self.records),
297
+ "total_session_ms": total_session_ms,
298
+ "tree": tree,
299
+ "flat_records": all_records_list,
300
+ "steps": steps,
301
+ "graph": {
302
+ "nodes": list(nodes_dict.values()),
303
+ "edges": list(edges_dict.values()),
304
+ "root_nodes": root_nodes if root_nodes else list(nodes_dict.keys())[:1],
305
+ "leaf_nodes": leaf_nodes if leaf_nodes else list(nodes_dict.keys())[-1:],
306
+ }
307
+ }
@@ -0,0 +1,42 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import webbrowser
5
+ from typing import Dict, Any
6
+
7
+ def render_and_save(summary_data: Dict[str, Any], output_path: str = "methodgraph_report.html") -> str:
8
+ """Renders the HTML report template with execution summary JSON data."""
9
+ template_dir = os.path.join(os.path.dirname(__file__), "templates")
10
+ template_path = os.path.join(template_dir, "report.html")
11
+
12
+ if not os.path.exists(template_path):
13
+ raise FileNotFoundError(f"Template file missing: {template_path}")
14
+
15
+ with open(template_path, "r", encoding="utf-8") as f:
16
+ template_content = f.read()
17
+
18
+ # Convert summary data to JSON
19
+ json_str = json.dumps(summary_data, default=str, indent=2)
20
+ rendered_html = template_content.replace("__DATA_PLACEHOLDER__", json_str)
21
+
22
+ abs_output_path = os.path.abspath(output_path)
23
+ output_dir = os.path.dirname(abs_output_path)
24
+ if output_dir and not os.path.exists(output_dir):
25
+ os.makedirs(output_dir, exist_ok=True)
26
+
27
+ with open(abs_output_path, "w", encoding="utf-8") as f:
28
+ f.write(rendered_html)
29
+
30
+ return abs_output_path
31
+
32
+
33
+ def open_in_browser(html_path: str):
34
+ """Opens the generated HTML report in the user's default browser."""
35
+ abs_path = os.path.abspath(html_path)
36
+ if os.path.exists(abs_path):
37
+ url = f"file://{abs_path}"
38
+ print(f"[methodgraph] Opening graphical report: {abs_path}")
39
+ webbrowser.open(url)
40
+ else:
41
+ print(f"[methodgraph] Report file not found: {abs_path}")
42
+
methodgraph/server.py ADDED
@@ -0,0 +1,33 @@
1
+ import http.server
2
+ import os
3
+ import socketserver
4
+ import webbrowser
5
+
6
+ def serve_report(html_path: str, port: int = 8080, open_browser: bool = True):
7
+ """Starts a local HTTP server serving the graphical report."""
8
+ abs_path = os.path.abspath(html_path)
9
+ if not os.path.exists(abs_path):
10
+ print(f"[methodgraph] Error: Report path '{abs_path}' does not exist.")
11
+ return
12
+
13
+ directory = os.path.dirname(abs_path)
14
+ filename = os.path.basename(abs_path)
15
+
16
+ os.chdir(directory)
17
+
18
+ class CustomHandler(http.server.SimpleHTTPRequestHandler):
19
+ def do_GET(self):
20
+ if self.path == "/" or self.path == "":
21
+ self.path = f"/{filename}"
22
+ return super().do_GET()
23
+
24
+ with socketserver.TCPServer(("", port), CustomHandler) as httpd:
25
+ url = f"http://localhost:{port}/"
26
+ print(f"[methodgraph] Serving graphical trace viewer at {url}")
27
+ if open_browser:
28
+ webbrowser.open(url)
29
+ try:
30
+ httpd.serve_forever()
31
+ except KeyboardInterrupt:
32
+ print("\n[methodgraph] Server stopped.")
33
+