Coverage for src/rhiza_tools/commands/analyze_benchmarks.py: 100%

47 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-30 13:37 +0000

1"""Command to analyze pytest-benchmark results and visualize them. 

2 

3This module reads a local ``benchmarks.json`` file produced by pytest-benchmark, 

4prints a reduced table with benchmark name, mean milliseconds, and operations 

5per second, and renders an interactive Plotly bar chart of mean runtimes. 

6 

7Note: This command requires pandas and plotly, which are available in the dev 

8dependency group. Install with: uv pip install -e ".[dev]" 

9 

10Example: 

11 Analyze benchmarks with default path:: 

12 

13 from rhiza_tools.commands.analyze_benchmarks import analyze_benchmarks_command 

14 analyze_benchmarks_command() 

15 

16 Analyze benchmarks with custom path:: 

17 

18 from pathlib import Path 

19 analyze_benchmarks_command(benchmarks_json=Path("custom/benchmarks.json")) 

20""" 

21 

22# /// script 

23# dependencies = [ 

24# "pandas", 

25# "plotly", 

26# ] 

27# /// 

28 

29import json 

30import sys 

31from pathlib import Path 

32 

33from rhiza_tools import console 

34 

35 

36class BenchmarkError(Exception): 

37 """Base exception for benchmark analysis errors.""" 

38 

39 

40def analyze_benchmarks_command( 

41 benchmarks_json: Path | None = None, 

42 output_html: Path | None = None, 

43 show: bool = False, 

44) -> None: 

45 """Analyze pytest-benchmark results and visualize them. 

46 

47 This command reads a benchmarks.json file produced by pytest-benchmark, 

48 prints a reduced table with benchmark name, mean milliseconds, and operations 

49 per second, and renders an interactive Plotly bar chart of mean runtimes. 

50 

51 Args: 

52 benchmarks_json: Path to the benchmarks.json file. Defaults to _benchmarks/benchmarks.json. 

53 output_html: Path to save the HTML visualization. Defaults to _benchmarks/benchmarks.html. 

54 show: If True, open the interactive chart in a browser after saving. Defaults to False. 

55 

56 Raises: 

57 SystemExit: If benchmarks.json is missing, invalid, or has no valid benchmarks. 

58 

59 Example: 

60 Analyze benchmarks with default paths:: 

61 

62 analyze_benchmarks_command() 

63 

64 Use custom paths:: 

65 

66 analyze_benchmarks_command( 

67 benchmarks_json=Path("tests/benchmarks.json"), 

68 output_html=Path("reports/benchmarks.html") 

69 ) 

70 

71 Open the chart in a browser after saving:: 

72 

73 analyze_benchmarks_command(show=True) 

74 """ 

75 # Import pandas and plotly here to avoid requiring them as hard dependencies 

76 try: 

77 import pandas as pd 

78 import plotly.express as px 

79 except ImportError: 

80 console.error( 

81 "pandas and plotly are required for this command. " 

82 "Install them with: uv pip install -e '.[dev]' or pip install 'rhiza-tools[dev]'" 

83 ) 

84 sys.exit(1) 

85 

86 # Set default paths 

87 if benchmarks_json is None: 

88 benchmarks_json = Path("_benchmarks/benchmarks.json") 

89 

90 if output_html is None: 

91 output_html = Path("_benchmarks/benchmarks.html") 

92 

93 # Check if the file exists 

94 if not benchmarks_json.exists(): 

95 console.warning(f"benchmarks.json not found at {benchmarks_json}; skipping analysis and exiting successfully.") 

96 sys.exit(0) 

97 

98 # Load pytest-benchmark JSON 

99 try: 

100 with benchmarks_json.open() as f: 

101 data = json.load(f) 

102 except json.JSONDecodeError: 

103 console.warning( 

104 f"benchmarks.json at {benchmarks_json} is invalid or empty; skipping analysis and exiting successfully." 

105 ) 

106 sys.exit(0) 

107 

108 # Validate structure: require a 'benchmarks' list 

109 if not isinstance(data, dict) or "benchmarks" not in data or not isinstance(data["benchmarks"], list): 

110 console.warning( 

111 f"benchmarks.json at {benchmarks_json} missing valid 'benchmarks' list; " 

112 "skipping analysis and exiting successfully." 

113 ) 

114 sys.exit(0) 

115 

116 # Check if benchmarks list is empty 

117 if not data["benchmarks"]: 

118 console.warning( 

119 f"benchmarks.json at {benchmarks_json} contains no benchmarks; skipping analysis and exiting successfully." 

120 ) 

121 sys.exit(0) 

122 

123 # Extract relevant info: Benchmark name, Mean (ms), OPS 

124 benchmarks = [] 

125 for bench in data["benchmarks"]: 

126 mean_s = bench["stats"]["mean"] 

127 benchmarks.append( 

128 { 

129 "Benchmark": bench["name"], 

130 "Mean_ms": mean_s * 1000, # convert seconds → milliseconds 

131 "OPS": 1 / mean_s, 

132 } 

133 ) 

134 

135 # Create DataFrame and sort fastest → slowest 

136 df = pd.DataFrame(benchmarks) 

137 df = df.sort_values("Mean_ms") 

138 

139 # Display reduced table 

140 console.info("Benchmark Results:") 

141 print(df[["Benchmark", "Mean_ms", "OPS"]].to_string(index=False, float_format="%.3f")) 

142 

143 # Create interactive Plotly bar chart 

144 fig = px.bar( 

145 df, 

146 x="Benchmark", 

147 y="Mean_ms", 

148 color="Mean_ms", 

149 color_continuous_scale="Viridis_r", 

150 title="Benchmark Mean Runtime (ms) per Test", 

151 text="Mean_ms", 

152 ) 

153 

154 fig.update_traces(texttemplate="%{text:.2f} ms", textposition="outside") 

155 fig.update_layout( 

156 xaxis_tickangle=-45, 

157 yaxis_title="Mean Runtime (ms)", 

158 coloraxis_colorbar={"title": "ms"}, 

159 height=600, 

160 margin={"t": 100, "b": 200}, 

161 ) 

162 

163 # Create output directory if it doesn't exist 

164 output_html.parent.mkdir(parents=True, exist_ok=True) 

165 

166 # Save HTML visualization 

167 fig.write_html(output_html) 

168 console.success(f"Visualization saved to {output_html}") 

169 

170 # Optionally open the interactive plot in a browser 

171 if show: 

172 fig.show()