Coverage for /builds/svesch/cikit/src/cikit/timeline/timeline_chart.py: 97%

88 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-10-09 14:17 +0000

1# SPDX-License-Identifier: GPL-3.0-or-later 

2"""Timeline chart utilities for cikit. 

3 

4This module provides a configurable wrapper around pygal for rendering timeline 

5data as SVG charts. Timeline data is validated during initialization, while 

6predefined presets provide reusable chart classes, styles, and rendering 

7options that can be overridden by callers. 

8 

9Charts can be rendered directly to arbitrary file-system locations or created 

10as cikit timeline artifacts through a ``CiRunEnvironment``. Artifact creation 

11is delegated to the environment so this module does not depend on the public 

12artifact directory layout. 

13 

14Author: 

15 Sven Schlender 

16Date: 

17 2026-09-13 

18""" 

19 

20from pathlib import Path 

21from tempfile import TemporaryDirectory 

22 

23import pygal 

24 

25from cikit.artifacts import Artifact, ArtifactType 

26from cikit.errors import CiToolsError 

27 

28from .presets import PRESET_CONFIGS, TimelinePreset 

29 

30 

31TIMELINE_FILE_PATTERN = "chart_%s.svg" 

32 

33 

34class TimelineChart: 

35 """Represent a validated and configurable timeline chart. 

36 

37 Timeline data is validated once during initialization. A preset supplies 

38 the initial chart class and options while callers may override chart-wide 

39 or per-series settings before rendering. 

40 """ 

41 

42 def __init__(self, timeline: dict, title: str = "Timeline", 

43 preset: TimelinePreset = TimelinePreset.LINE): 

44 """Initialize a timeline chart from timeline data. 

45 

46 Args: 

47 timeline: Timeline data containing ``refs`` and ``series``. 

48 title: Title of the generated chart. 

49 preset: Complete visual preset applied initially. 

50 

51 Raises: 

52 CiToolsError: If the timeline structure or preset is invalid. 

53 """ 

54 refs = timeline.get("refs") 

55 series_map = timeline.get("series") 

56 

57 if not isinstance(refs, list): 

58 raise CiToolsError("Timeline data must contain a list field 'refs'.") 

59 if not isinstance(series_map, dict): 

60 raise CiToolsError("Timeline data must contain a dict field 'series'.") 

61 

62 for key, values in series_map.items(): 

63 if not isinstance(key, str): 

64 raise CiToolsError("Each timeline series name must be a string.") 

65 if not isinstance(values, list): 

66 raise CiToolsError(f"Timeline series '{key}' must contain a list of values.") 

67 if len(values) != len(refs): 

68 raise CiToolsError(f"Timeline series '{key}' does not match refs length.") 

69 

70 self._refs = [str(ref) for ref in refs] 

71 self._series_map = dict(series_map) 

72 self._title = title 

73 self._preset = TimelinePreset.LINE 

74 self._chart_cls = pygal.Line 

75 self._chart_options = {} 

76 self._series_options = {} 

77 

78 self.set_preset(preset) 

79 

80 def set_preset(self, preset: TimelinePreset): 

81 """Apply a complete visual preset to the chart. 

82 

83 Per-series options are intentionally preserved so individual series 

84 configuration survives a preset change. 

85 

86 Args: 

87 preset: Preset to apply. 

88 

89 Raises: 

90 CiToolsError: If the preset value is invalid. 

91 """ 

92 try: 

93 resolved_preset = TimelinePreset(preset) 

94 except (TypeError, ValueError) as exp: 

95 raise CiToolsError(f"Unknown timeline preset '{preset}'.") from exp 

96 

97 config = PRESET_CONFIGS[resolved_preset] 

98 self._preset = resolved_preset 

99 self._chart_cls = config["chart_cls"] 

100 self._chart_options = dict(config["options"]) 

101 

102 def set_chart_class(self, chart_cls): 

103 """Override the pygal chart class selected by the preset. 

104 

105 Args: 

106 chart_cls: Callable pygal chart class such as ``pygal.StackedBar``. 

107 

108 Raises: 

109 CiToolsError: If the provided object is not callable. 

110 """ 

111 if not callable(chart_cls): 

112 raise CiToolsError("chart_cls must be a callable pygal chart class.") 

113 

114 self._chart_cls = chart_cls 

115 

116 def set_chart_options(self, **options): 

117 """Update chart-wide pygal rendering options. 

118 

119 Args: 

120 **options: Keyword arguments passed to the pygal chart constructor. 

121 """ 

122 self._chart_options.update(options) 

123 

124 def set_style(self, style): 

125 """Set the pygal style used for rendering. 

126 

127 Args: 

128 style: Pygal style object. 

129 """ 

130 self._chart_options["style"] = style 

131 

132 def set_series_options(self, key: str, **options): 

133 """Update rendering options for one timeline series. 

134 

135 Args: 

136 key: Name of the target series. 

137 **options: Keyword arguments passed to ``chart.add()``. 

138 

139 Raises: 

140 CiToolsError: If the requested series is unknown. 

141 """ 

142 if key not in self._series_map: 

143 raise CiToolsError(f"Unknown timeline series '{key}'.") 

144 

145 current = self._series_options.get(key, {}) 

146 current.update(options) 

147 self._series_options[key] = current 

148 

149 def to_dict(self) -> dict: 

150 """Return a compact dictionary representation of the chart. 

151 

152 Returns: 

153 Compact chart description suitable for diagnostic output. 

154 """ 

155 return { 

156 "title": self._title, 

157 "preset": self._preset.value, 

158 "chart_type": getattr(self._chart_cls, "__name__", str(self._chart_cls)), 

159 "refs": len(self._refs), 

160 "series": list(self._series_map.keys()), 

161 "chart_options": dict(self._chart_options), 

162 "series_options": {key: dict(options) for key, options in self._series_options.items()} 

163 } 

164 

165 def __repr__(self) -> str: 

166 """Return a concise string representation of the chart. 

167 

168 Returns: 

169 Human-readable chart summary for debugging. 

170 """ 

171 chart_type = getattr(self._chart_cls, "__name__", "?") 

172 return (f"TimelineChart(title={self._title!r}, preset={self._preset.value!r}, " 

173 f"type={chart_type}, series={len(self._series_map)})") 

174 

175 def render(self, output_file: Path) -> Path: 

176 """Render the configured chart to an SVG file. 

177 

178 Args: 

179 output_file: Target SVG file. 

180 

181 Returns: 

182 Path to the generated SVG file. 

183 

184 Raises: 

185 CiToolsError: If the SVG file cannot be written. 

186 """ 

187 chart = self._chart_cls(**self._chart_options) 

188 chart.title = self._title 

189 chart.x_labels = self._refs 

190 

191 for key, values in self._series_map.items(): 

192 chart.add(key, values, **self._series_options.get(key, {})) 

193 

194 try: 

195 output_file.parent.mkdir(parents=True, exist_ok=True) 

196 chart.render_to_file(str(output_file)) 

197 except OSError as exp: 

198 raise CiToolsError("Failed to write timeline chart to " 

199 f"'{output_file}': {exp}") from exp 

200 

201 return output_file 

202 

203 def create(self, env, name: str) -> Artifact: 

204 """Render the chart and register it as a cikit timeline artifact. 

205 

206 Args: 

207 env: Active CI run environment. 

208 name: Base name of the output file without extension. 

209 

210 Returns: 

211 Created and registered timeline artifact. 

212 

213 Raises: 

214 CiToolsError: If the SVG file cannot be written or registered. 

215 """ 

216 filename = TIMELINE_FILE_PATTERN % name 

217 

218 with TemporaryDirectory() as temp_dir: 

219 output_file = Path(temp_dir) / filename 

220 self.render(output_file) 

221 return env.copy_artifact(output_file, ArtifactType.TIMELINE) 

222 

223 def set_min_y_range(self, minimum: float, maximum: float): 

224 """Set a minimum visible range for the Y axis. 

225 

226 The configured range is expanded when timeline values exceed either 

227 boundary. 

228 

229 Args: 

230 minimum: Minimum lower Y-axis boundary. 

231 maximum: Minimum upper Y-axis boundary. 

232 

233 Raises: 

234 CiToolsError: If minimum is not smaller than maximum. 

235 """ 

236 if minimum >= maximum: 

237 raise CiToolsError("Minimum Y range must have minimum < maximum.") 

238 

239 values = [] 

240 for series in self._series_map.values(): 

241 for value in series: 

242 if isinstance(value, (int, float)): 242 ↛ 241line 242 didn't jump to line 241 because the condition on line 242 was always true

243 values.append(value) 

244 

245 if values: 245 ↛ 249line 245 didn't jump to line 249 because the condition on line 245 was always true

246 minimum = min(minimum, *values) 

247 maximum = max(maximum, *values) 

248 

249 self._chart_options["range"] = (minimum, maximum)