diff --git a/docs/docs/cli-reference.md b/docs/docs/cli-reference.md index 5bc8a55..a08116c 100644 --- a/docs/docs/cli-reference.md +++ b/docs/docs/cli-reference.md @@ -11,7 +11,7 @@ Complete command reference for OracleTrace. ## Command syntax ```bash -oracletrace [--json OUTPUT.json] [--csv OUTPUT.csv] [--compare BASELINE.json] +oracletrace [--json OUTPUT.json] [--csv OUTPUT.csv] [--html OUTPUT.html] [--compare BASELINE.json] oracletrace [--ignore REGEX [REGEX ...]] oracletrace [--top NUMBER] oracletrace [--compare BASELINE.json] [--fail-on-regression] [--threshold PERCENT] [--only-regressions] @@ -50,6 +50,16 @@ Exports the trace results to a csv file. oracletrace my_app.py --csv run.csv ``` +### `--html` + +Exports the trace results to an interactive HTML file. + +```bash +oracletrace my_app.py --html report.html +``` + +The generated report includes a sortable table with function timing data and a call graph visualization. + ### `--compare` Compares the current run against a previous JSON trace. diff --git a/oracletrace/cli.py b/oracletrace/cli.py index 55aa3fe..f95d9b4 100644 --- a/oracletrace/cli.py +++ b/oracletrace/cli.py @@ -4,14 +4,14 @@ import json import runpy import csv -from .tracer import Tracer, TracerData -from .compare import compare_traces, ComparisonData +from dataclasses import asdict from typing import List, Dict, Any, Optional from re import Pattern from argparse import ArgumentParser, Namespace -from pathlib import Path -from dataclasses import asdict from importlib.metadata import version +from .tracer import Tracer, TracerData +from .compare import compare_traces, ComparisonData +from .reporters import generate_html_report def main() -> int: @@ -24,6 +24,7 @@ def main() -> int: parser.add_argument("--json", help="Export trace result to JSON file") parser.add_argument("--compare", help="Compare against previous trace JSON") parser.add_argument("--csv", help="Export trace result to CSV file") + parser.add_argument("--html", help="Export trace result to interactive HTML file") parser.add_argument( "--ignore", metavar="REGEX", @@ -126,6 +127,11 @@ def main() -> int: "avg_time": fn.avg_time, }) + # Generare a report as html + if args.html: + generate_html_report(data, args.html) + print(f"HTML report generated: {os.path.abspath(args.html)}") + comparison_result: Optional[ComparisonData] = None # Compare jsons diff --git a/oracletrace/reporters/__init__.py b/oracletrace/reporters/__init__.py new file mode 100644 index 0000000..afa0a28 --- /dev/null +++ b/oracletrace/reporters/__init__.py @@ -0,0 +1,3 @@ +from .html import generate_html_report + +__all__ = ["generate_html_report"] diff --git a/oracletrace/reporters/html.py b/oracletrace/reporters/html.py new file mode 100644 index 0000000..1daa60a --- /dev/null +++ b/oracletrace/reporters/html.py @@ -0,0 +1,273 @@ +import json +from datetime import datetime +from string import Template +from typing import List +from ..tracer import TracerData, FunctionData + + +class _JSTemplate(Template): + delimiter = "@" + + +def generate_html_report(data: TracerData, output_path: str) -> None: + functions_json = _serialize_functions(data.functions) + metadata = data.metadata + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + html = _JSTemplate(_HTML_TEMPLATE).substitute( + root_path=metadata.root_path, + total_time=f"{metadata.total_execution_time:.4f}s", + total_functions=str(metadata.total_functions), + timestamp=timestamp, + functions_json=functions_json, + ) + + with open(output_path, "w", encoding="utf-8") as f: + f.write(html) + + +def _serialize_functions(functions: List[FunctionData]) -> str: + serialized = [] + for fn in functions: + serialized.append({ + "name": fn.name, + "total_time": fn.total_time, + "call_count": fn.call_count, + "avg_time": fn.avg_time * 1000, + "callees": fn.callees, + }) + return json.dumps(serialized) + + +_HTML_TEMPLATE = ''' + + + + + OracleTrace Report + + + +
+
+

OracleTrace Report

+
+
+ Project + @root_path +
+
+ Total Time + @total_time +
+
+ Functions + @total_functions +
+
+ Generated + @timestamp +
+
+
+
+ +
+
+ + + + + + + + + + + + +
FunctionTotal Time (s)CallsAvg Time (ms)Callees
+
+ +
+ + + +''' \ No newline at end of file diff --git a/tests/test_html_reporter.py b/tests/test_html_reporter.py new file mode 100644 index 0000000..d053d6a --- /dev/null +++ b/tests/test_html_reporter.py @@ -0,0 +1,125 @@ +import pytest +from oracletrace.tracer import TracerData, TracerMetadata, FunctionData +from oracletrace.reporters.html import generate_html_report + + +@pytest.fixture +def sample_trace_data(): + return TracerData( + metadata=TracerMetadata( + root_path="/test/project", + total_functions=3, + total_execution_time=1.5 + ), + functions=[ + FunctionData( + name="main.py:main", + total_time=1.0, + call_count=1, + avg_time=1.0, + callees=["main.py:helper"] + ), + FunctionData( + name="main.py:helper", + total_time=0.4, + call_count=3, + avg_time=0.133, + callees=["main.py:compute"] + ), + FunctionData( + name="main.py:compute", + total_time=0.1, + call_count=10, + avg_time=0.01, + callees=[] + ) + ] + ) + + +def test_generate_html_report(sample_trace_data, tmp_path): + output_path = str(tmp_path / "report.html") + generate_html_report(sample_trace_data, output_path) + + content = tmp_path.joinpath("report.html").read_text(encoding="utf-8") + + assert "" in content + assert "OracleTrace Report" in content + assert "/test/project" in content + assert "1.5" in content + assert "3" in content + + +def test_html_report_contains_table_columns(sample_trace_data, tmp_path): + output_path = str(tmp_path / "report.html") + generate_html_report(sample_trace_data, output_path) + + content = tmp_path.joinpath("report.html").read_text(encoding="utf-8") + + assert "Function" in content + assert "Total Time (s)" in content + assert "Calls" in content + assert "Avg Time (ms)" in content + assert "Callees" in content + + +def test_html_report_is_standalone(sample_trace_data, tmp_path): + output_path = str(tmp_path / "report.html") + generate_html_report(sample_trace_data, output_path) + + content = tmp_path.joinpath("report.html").read_text(encoding="utf-8") + + assert "https://" not in content and "http://" not in content + + +def test_html_report_contains_function_names(sample_trace_data, tmp_path): + output_path = str(tmp_path / "report.html") + generate_html_report(sample_trace_data, output_path) + + content = tmp_path.joinpath("report.html").read_text(encoding="utf-8") + + assert "main.py:main" in content + assert "main.py:helper" in content + assert "main.py:compute" in content + + +def test_html_report_callees_rendered(sample_trace_data, tmp_path): + output_path = str(tmp_path / "report.html") + generate_html_report(sample_trace_data, output_path) + + content = tmp_path.joinpath("report.html").read_text(encoding="utf-8") + + assert "main.py:helper" in content + assert "main.py:compute" in content + assert "callees" in content.lower() or "callee" in content.lower() + + +def test_html_report_uses_template_delimiter(sample_trace_data, tmp_path): + output_path = str(tmp_path / "report.html") + generate_html_report(sample_trace_data, output_path) + + content = tmp_path.joinpath("report.html").read_text(encoding="utf-8") + + # Placeholders should be replaced - @ should not appear as a placeholder + # (it would only remain if substitution failed) + assert "@root_path" not in content + assert "@total_time" not in content + assert "@functions_json" not in content + + +def test_html_report_empty_functions(sample_trace_data, tmp_path): + empty_data = TracerData( + metadata=TracerMetadata( + root_path="/empty", + total_functions=0, + total_execution_time=0.0 + ), + functions=[] + ) + output_path = str(tmp_path / "report.html") + generate_html_report(empty_data, output_path) + + content = tmp_path.joinpath("report.html").read_text(encoding="utf-8") + + assert "[]" in content + assert "/empty" in content