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.
- methodgraph/__init__.py +28 -0
- methodgraph/cli.py +79 -0
- methodgraph/collector.py +307 -0
- methodgraph/renderer.py +42 -0
- methodgraph/server.py +33 -0
- methodgraph/templates/report.html +2350 -0
- methodgraph/tracer.py +297 -0
- methodgraph-0.1.0.dist-info/METADATA +154 -0
- methodgraph-0.1.0.dist-info/RECORD +13 -0
- methodgraph-0.1.0.dist-info/WHEEL +5 -0
- methodgraph-0.1.0.dist-info/entry_points.txt +2 -0
- methodgraph-0.1.0.dist-info/licenses/LICENSE +20 -0
- methodgraph-0.1.0.dist-info/top_level.txt +1 -0
methodgraph/__init__.py
ADDED
|
@@ -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
|
+
|
methodgraph/collector.py
ADDED
|
@@ -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
|
+
}
|
methodgraph/renderer.py
ADDED
|
@@ -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
|
+
|