Coverage for /builds/svesch/cikit/src/cikit/macros/platformio.py: 99%

109 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"""PlatformIO macro helpers for cikit. 

3 

4This module builds PlatformIO projects, extracts build metrics from command 

5output, stores command logs, and generates build reports. Project configuration 

6remains entirely in platformio.ini. 

7 

8Author: 

9 Sven Schlender 

10Date: 

11 2026-09-13 

12""" 

13 

14import json 

15import re 

16import time 

17import shutil 

18from pathlib import Path 

19from tempfile import TemporaryDirectory 

20 

21from cikit.artifacts import Artifact, ArtifactType 

22from cikit.command import Command, run_command 

23from cikit.errors import CiToolsError 

24from .reports import write_build_report 

25 

26 

27PLATFORMIO_BUILD_LOG = "platformio-build.log" 

28 

29PLATFORMIO_COMPILE_COMMANDS = "compile_commands.json" 

30 

31ERROR_PATTERN = re.compile(r"\berror:\s", re.IGNORECASE) 

32 

33MEMORY_PATTERN = re.compile( 

34 r"^(?P<region>[A-Za-z0-9_ -]+):\s+" 

35 r"\[[^\]]*\]\s+" 

36 r"(?P<percent>\d+(?:\.\d+)?)%\s+" 

37 r"\(used\s+(?P<used>\d+)\s+bytes\s+from\s+(?P<total>\d+)\s+bytes\)$" 

38) 

39 

40WARNING_PATTERN = re.compile(r"\bwarning:\s", re.IGNORECASE) 

41 

42 

43def _normalize_memory_region(region: str) -> str: 

44 """Normalize a PlatformIO memory region name for cikit data keys. 

45 

46 Region names are converted to lowercase and non-alphanumeric separators 

47 are replaced with underscores. This keeps generated run data keys stable 

48 and suitable for timeline queries. 

49 

50 Args: 

51 region: Memory region name reported by PlatformIO. 

52 

53 Returns: 

54 Normalized memory region name. 

55 """ 

56 region = region.strip().lower() 

57 region = re.sub(r"[^a-z0-9]+", "_", region) 

58 

59 return region.strip("_") 

60 

61 

62def _parse_build_output(stdout: str, stderr: str) -> dict: 

63 """Extract build metrics from PlatformIO output. 

64 

65 Standard output is parsed for compiled files and memory usage. Standard 

66 error is parsed separately for compiler warnings and errors. File, 

67 warning, and error counts are always returned, while memory metrics are 

68 only added when detected. 

69 

70 Args: 

71 stdout: Captured PlatformIO standard output. 

72 stderr: Captured PlatformIO standard error. 

73 

74 Returns: 

75 Dictionary containing the extracted build metrics. 

76 """ 

77 data = {} 

78 build_files = 0 

79 

80 for line in stdout.splitlines(): 

81 stripped = line.strip() 

82 

83 if stripped.startswith("Compiling "): 

84 build_files += 1 

85 

86 match = MEMORY_PATTERN.match(stripped) 

87 if not match: 

88 continue 

89 

90 region = _normalize_memory_region(match.group("region")) 

91 prefix = f"memory_{region}" 

92 

93 data[f"{prefix}_used"] = int(match.group("used")) 

94 data[f"{prefix}_total"] = int(match.group("total")) 

95 data[f"{prefix}_percent"] = float(match.group("percent")) 

96 

97 data["build_files"] = build_files 

98 

99 data["warnings"] = sum(1 for line in stderr.splitlines() if WARNING_PATTERN.search(line)) 

100 data["errors"] = sum(1 for line in stderr.splitlines() if ERROR_PATTERN.search(line)) 

101 

102 return data 

103 

104 

105def _get_project_environment(env, project_dir: Path) -> dict: 

106 """Return metadata for the resolved PlatformIO environment. 

107 

108 Args: 

109 env: Resolved CI run environment. 

110 project_dir: PlatformIO project directory. 

111 

112 Returns: 

113 Metadata of the resolved PlatformIO environment. 

114 

115 Raises: 

116 CiToolsError: If PlatformIO metadata cannot be queried or parsed, or 

117 the build environment cannot be determined unambiguously. 

118 """ 

119 cmd = Command("pio", use_exec_tool=True) 

120 cmd.add_subcommand("metadata", ["project", "metadata", "--json-output"]) 

121 

122 result = run_command(env.argv(cmd, "metadata"), cwd=project_dir, live_output=False, 

123 check_exit_code=False) 

124 

125 result.check_returncode() 

126 

127 try: 

128 metadata = json.loads(result.stdout) 

129 except json.JSONDecodeError as exp: 

130 raise CiToolsError("Invalid PlatformIO project metadata") from exp 

131 

132 if not isinstance(metadata, dict) or len(metadata) != 1: 

133 raise CiToolsError("PlatformIO build environment cannot be determined unambiguously") 

134 

135 return next(iter(metadata.values())) 

136 

137 

138def gather_compiler_meta(env, project_dir: Path = None): 

139 """Gather compiler metadata for a PlatformIO project. 

140 

141 PlatformIO project metadata is used to determine the compiler selected for 

142 the resolved build environment. The compiler executable is queried for its 

143 actual version. 

144 

145 Detected metadata is stored directly in the run data. No data is added if 

146 the compiler cannot be determined unambiguously. 

147 

148 Args: 

149 env: Resolved CI run environment. 

150 project_dir: Optional PlatformIO project directory. 

151 """ 

152 project_dir = project_dir or env["PROJECT_ROOT"] 

153 

154 try: 

155 environment = _get_project_environment(env, project_dir) 

156 except CiToolsError: 

157 return 

158 

159 compiler_path = environment.get("cc_path") 

160 if not compiler_path: 

161 return 

162 

163 compiler_path = Path(compiler_path) 

164 

165 compiler_cmd = Command(str(compiler_path), ["-dumpfullversion", "-dumpversion"]) 

166 

167 version_result = run_command(env.argv(compiler_cmd), cwd=project_dir, live_output=False, 

168 check_exit_code=False) 

169 

170 if version_result.returncode != 0: 

171 return 

172 

173 compiler_version = version_result.stdout.strip() 

174 if not compiler_version: 

175 return 

176 

177 compiler_meta = {"compiler": compiler_path.name.removesuffix(".exe"), 

178 "compiler_version": compiler_version} 

179 

180 compiler_type = environment.get("compiler_type") 

181 if compiler_type: 181 ↛ 184line 181 didn't jump to line 184 because the condition on line 181 was always true

182 compiler_meta["compiler_type"] = compiler_type 

183 

184 env.data_update(compiler_meta) 

185 

186 

187def get_build_dir(env, project_dir: Path = None) -> Path: 

188 """Return the build directory of the resolved PlatformIO environment. 

189 

190 Args: 

191 env: Resolved CI run environment. 

192 project_dir: Optional PlatformIO project directory. 

193 

194 Returns: 

195 Absolute path to the PlatformIO environment build directory. 

196 

197 Raises: 

198 CiToolsError: If PlatformIO metadata cannot be queried or the program 

199 path cannot be determined. 

200 """ 

201 project_dir = project_dir or env["PROJECT_ROOT"] 

202 

203 environment = _get_project_environment(env, project_dir) 

204 

205 program_path = environment.get("prog_path") 

206 if not program_path: 

207 raise CiToolsError("PlatformIO metadata does not contain a program path") 

208 

209 return Path(program_path).parent 

210 

211 

212def generate_compile_commands(env, project_dir: Path = None) -> Artifact: 

213 """Generate and register the PlatformIO compilation database. 

214 

215 PlatformIO generates ``compile_commands.json`` from the configured build 

216 environment. The resulting compilation database is registered as a 

217 metadata artifact for use by static analysis tools. 

218 

219 Args: 

220 env: Resolved CI run environment. 

221 project_dir: Optional PlatformIO project directory. 

222 

223 Returns: 

224 Created and registered compilation database artifact. 

225 

226 Raises: 

227 CiToolsError: If PlatformIO cannot be started, generation fails, or the 

228 compilation database was not created. 

229 """ 

230 project_dir = project_dir or env["PROJECT_ROOT"] 

231 

232 cmd = Command("pio", use_exec_tool=True) 

233 cmd.add_subcommand("compiledb", ["run", "-t", "compiledb"]) 

234 

235 result = run_command(env.argv(cmd, "compiledb"), cwd=project_dir, live_output=True, 

236 check_exit_code=False) 

237 

238 result.check_returncode() 

239 

240 compile_commands = project_dir / PLATFORMIO_COMPILE_COMMANDS 

241 

242 if not compile_commands.is_file(): 

243 raise CiToolsError(f"PlatformIO did not generate {PLATFORMIO_COMPILE_COMMANDS}") 

244 

245 return env.copy_artifact(compile_commands, ArtifactType.METADATA) 

246 

247 

248def build(env, project_dir: Path = None, clean: bool = False): 

249 """Build a PlatformIO project and store extracted build metrics. 

250 

251 PlatformIO resolves build environments, boards, frameworks, libraries, 

252 and all other project settings from platformio.ini. The optional clean 

253 mode removes the local PlatformIO build directory before building. 

254 

255 Args: 

256 env: Resolved CI run environment. 

257 project_dir: Optional PlatformIO project directory. 

258 clean: Remove the PlatformIO build directory before building. 

259 

260 Raises: 

261 CiToolsError: If PlatformIO cannot be started or the build fails. 

262 """ 

263 project_dir = project_dir or env["PROJECT_ROOT"] 

264 

265 if clean: 

266 build_dir = project_dir / ".pio" 

267 shutil.rmtree(build_dir, ignore_errors=True) 

268 

269 cmd = Command("pio", use_exec_tool=True) 

270 cmd.add_subcommand("run", ["run"]) 

271 

272 start_time = time.monotonic() 

273 result = run_command(env.argv(cmd, "run"), cwd=project_dir, live_output=True, 

274 check_exit_code=False) 

275 build_time = round(time.monotonic() - start_time, 1) 

276 

277 metrics = _parse_build_output(result.stdout, result.stderr) 

278 metrics["build_time"] = build_time 

279 

280 env.data_update(metrics) 

281 

282 with TemporaryDirectory() as temp_dir: 

283 log_file = Path(temp_dir) / PLATFORMIO_BUILD_LOG 

284 result.write_report(log_file) 

285 env.copy_artifact(log_file, ArtifactType.LOG) 

286 

287 result.check_returncode() 

288 

289 

290def write_rst(env, name: str = None, report_title: str = None) -> Artifact: 

291 """Create an RST report for a PlatformIO build. 

292 

293 Args: 

294 env: Resolved CI run environment. 

295 name: Output file basename without extension. If omitted, the run name 

296 is used. 

297 report_title: Report title. If omitted, ``PlatformIO Build Report`` is 

298 used. 

299 

300 Returns: 

301 Created and registered RST artifact. 

302 """ 

303 return write_build_report(env, name=name, 

304 report_title=report_title or "PlatformIO Build Report")