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
« 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.
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.
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.
14Author:
15 Sven Schlender
16Date:
17 2026-09-13
18"""
20from pathlib import Path
21from tempfile import TemporaryDirectory
23import pygal
25from cikit.artifacts import Artifact, ArtifactType
26from cikit.errors import CiToolsError
28from .presets import PRESET_CONFIGS, TimelinePreset
31TIMELINE_FILE_PATTERN = "chart_%s.svg"
34class TimelineChart:
35 """Represent a validated and configurable timeline chart.
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 """
42 def __init__(self, timeline: dict, title: str = "Timeline",
43 preset: TimelinePreset = TimelinePreset.LINE):
44 """Initialize a timeline chart from timeline data.
46 Args:
47 timeline: Timeline data containing ``refs`` and ``series``.
48 title: Title of the generated chart.
49 preset: Complete visual preset applied initially.
51 Raises:
52 CiToolsError: If the timeline structure or preset is invalid.
53 """
54 refs = timeline.get("refs")
55 series_map = timeline.get("series")
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'.")
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.")
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 = {}
78 self.set_preset(preset)
80 def set_preset(self, preset: TimelinePreset):
81 """Apply a complete visual preset to the chart.
83 Per-series options are intentionally preserved so individual series
84 configuration survives a preset change.
86 Args:
87 preset: Preset to apply.
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
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"])
102 def set_chart_class(self, chart_cls):
103 """Override the pygal chart class selected by the preset.
105 Args:
106 chart_cls: Callable pygal chart class such as ``pygal.StackedBar``.
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.")
114 self._chart_cls = chart_cls
116 def set_chart_options(self, **options):
117 """Update chart-wide pygal rendering options.
119 Args:
120 **options: Keyword arguments passed to the pygal chart constructor.
121 """
122 self._chart_options.update(options)
124 def set_style(self, style):
125 """Set the pygal style used for rendering.
127 Args:
128 style: Pygal style object.
129 """
130 self._chart_options["style"] = style
132 def set_series_options(self, key: str, **options):
133 """Update rendering options for one timeline series.
135 Args:
136 key: Name of the target series.
137 **options: Keyword arguments passed to ``chart.add()``.
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}'.")
145 current = self._series_options.get(key, {})
146 current.update(options)
147 self._series_options[key] = current
149 def to_dict(self) -> dict:
150 """Return a compact dictionary representation of the chart.
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 }
165 def __repr__(self) -> str:
166 """Return a concise string representation of the chart.
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)})")
175 def render(self, output_file: Path) -> Path:
176 """Render the configured chart to an SVG file.
178 Args:
179 output_file: Target SVG file.
181 Returns:
182 Path to the generated SVG file.
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
191 for key, values in self._series_map.items():
192 chart.add(key, values, **self._series_options.get(key, {}))
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
201 return output_file
203 def create(self, env, name: str) -> Artifact:
204 """Render the chart and register it as a cikit timeline artifact.
206 Args:
207 env: Active CI run environment.
208 name: Base name of the output file without extension.
210 Returns:
211 Created and registered timeline artifact.
213 Raises:
214 CiToolsError: If the SVG file cannot be written or registered.
215 """
216 filename = TIMELINE_FILE_PATTERN % name
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)
223 def set_min_y_range(self, minimum: float, maximum: float):
224 """Set a minimum visible range for the Y axis.
226 The configured range is expanded when timeline values exceed either
227 boundary.
229 Args:
230 minimum: Minimum lower Y-axis boundary.
231 maximum: Minimum upper Y-axis boundary.
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.")
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)
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)
249 self._chart_options["range"] = (minimum, maximum)