Coverage for /builds/svesch/cikit/src/cikit/command.py: 88%

149 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"""Command helpers for cikit. 

3 

4This module describes external commands and provides a small execution helper 

5that can stream stdout and stderr live while keeping both streams separated 

6for later inspection. 

7 

8Author: 

9 Sven Schlender 

10License: 

11 SPDX-License-Identifier: GPL-3.0-or-later 

12Date: 

13 2026-02-22 

14""" 

15 

16import logging 

17import os 

18import queue 

19import signal 

20import subprocess 

21import threading 

22import time 

23from pathlib import Path 

24from dataclasses import dataclass, field 

25 

26from cikit.errors import CiToolsError 

27 

28 

29LOGGER = logging.getLogger(__name__) 

30READER_JOIN_TIMEOUT = 1.0 

31PROCESS_STOP_TIMEOUT = 5.0 

32 

33 

34class CommandArgv(list): 

35 """Command arguments with an additional masked representation. 

36 

37 The object behaves like the executable command argument list itself. 

38 A second representation with masked secret values is available through 

39 the ``masked`` attribute for logging and persisted reports. 

40 

41 Args: 

42 exec_argv: Fully resolved command arguments used for execution. 

43 masked_argv: Display-safe command arguments with secret values masked. 

44 """ 

45 

46 def __init__(self, exec_argv: list[str], masked_argv: list[str]): 

47 """Initialize command arguments. 

48 

49 Args: 

50 exec_argv: Fully resolved command arguments used for execution. 

51 masked_argv: Display-safe command arguments with secret values masked. 

52 """ 

53 super().__init__(exec_argv) 

54 self.masked = masked_argv 

55 

56 @property 

57 def exec(self) -> list[str]: 

58 """Return executable command arguments. 

59 

60 Returns: 

61 Executable command argument list. 

62 """ 

63 return self 

64 

65 

66class Command: 

67 """Describe a command builder for one executable with optional subcommands. 

68 

69 This class stores metadata for one executable and optional subcommands. 

70 It can build a plain argument list for direct execution. Environment 

71 placeholders are intentionally not resolved here. 

72 

73 Argument strings may contain "${VAR}" placeholders. These placeholders are 

74 resolved by the environment-aware execution layer. 

75 

76 Args: 

77 name: Executable name, e.g. "ruff". 

78 common_args: Arguments applied to every invocation unless disabled. 

79 use_exec_tool: If True, CiEnvironment should prefix EXEC_TOOL. 

80 """ 

81 def __init__(self, name: str, common_args: list = None, *, use_exec_tool: bool = False): 

82 """Initialize a Command definition. 

83 

84 The constructor stores metadata for one executable and prepares 

85 internal copies of common arguments to avoid accidental external 

86 mutation. Subcommands can be registered with add_subcommand(). 

87 

88 Args: 

89 name: Executable name, e.g. "ruff". 

90 common_args: Arguments applied to every invocation unless disabled. 

91 use_exec_tool: If True, CiEnvironment should prefix EXEC_TOOL. 

92 """ 

93 self.name = name 

94 self.common_args = list(common_args or []) 

95 self.use_exec_tool = use_exec_tool 

96 self._subcommands = {} 

97 

98 def add_subcommand(self, name: str, args: list = None): 

99 """Register a subcommand. 

100 

101 Args: 

102 name: Subcommand name, e.g. "check". 

103 args: Argument list for this subcommand (may be empty). 

104 

105 """ 

106 self._subcommands[name] = list(args or []) 

107 

108 def argv(self, subcommand: str = None, extra_args: list = None, 

109 no_common: bool = False) -> list[str]: 

110 """Build the argv list for this command invocation. 

111 

112 The resulting argv is suitable for subprocess execution (argv[0] is the 

113 executable). Argument strings may contain "${VAR}" placeholders which are 

114 expanded later by the execution layer (e.g. CiEnvironment.run()). 

115 

116 Args: 

117 subcommand: Optional subcommand name registered via add_subcommand(). 

118 extra_args: Optional list of additional arguments appended at the end. 

119 no_common: If True, do not include common_args. 

120 

121 Returns: 

122 List of argv items (strings), starting with the executable name. 

123 

124 Raises: 

125 CiToolsError: If the specified subcommand is unknown. 

126 """ 

127 argv = [self.name] 

128 

129 if not no_common: 

130 argv.extend([str(x) for x in self.common_args]) 

131 

132 if subcommand: 

133 if subcommand not in self._subcommands: 

134 raise CiToolsError(f"Unknown subcommand for {self.name}: {subcommand}") 

135 argv.extend([str(x) for x in self._subcommands[subcommand]]) 

136 

137 if extra_args: 

138 argv.extend([str(x) for x in extra_args]) 

139 

140 return argv 

141 

142 

143@dataclass 

144class CommandOutput: 

145 """Store one line of command output. 

146 

147 Args: 

148 stream: Stream name, usually ``stdout`` or ``stderr``. 

149 line: Output line without trailing newline. 

150 """ 

151 

152 stream: str 

153 line: str 

154 timestamp: float = field(default_factory=time.monotonic) 

155 

156 

157@dataclass 

158class CommandResult: 

159 """Store the completed result of a command execution. 

160 

161 Args: 

162 argv: Executed command argument list object. 

163 returncode: Process return code. 

164 output: Optional list of CommandOutput events. 

165 """ 

166 

167 argv: CommandArgv 

168 returncode: int 

169 output: list = field(default_factory=list) 

170 

171 def __post_init__(self): 

172 """Build derived output helper strings. 

173 

174 The command output is stored as ordered CommandOutput events. This method 

175 derives separated stdout and stderr strings once after initialization so 

176 callers can inspect both streams directly. 

177 """ 

178 self.stdout = self._stream_text("stdout") 

179 self.stderr = self._stream_text("stderr") 

180 

181 def _stream_text(self, stream: str) -> str: 

182 """Return all lines for one stream as text. 

183 

184 Args: 

185 stream: Stream name to extract. 

186 

187 Returns: 

188 Joined stream output. 

189 """ 

190 return "\n".join(item.line for item in self.output if item.stream == stream) 

191 

192 def check_returncode(self): 

193 """Raise CiToolsError if the command failed. 

194 

195 Raises: 

196 CiToolsError: If the return code is non-zero. 

197 """ 

198 if not self.returncode: 

199 return 

200 

201 message = [ 

202 f"Command '{self.argv[0]}' failed with exit code {self.returncode}.", 

203 f"Command: {' '.join(self.argv.masked)}", 

204 ] 

205 

206 if self.stdout: 

207 message.extend(["", "STDOUT:", self.stdout]) 

208 

209 if self.stderr: 

210 message.extend(["", "STDERR:", self.stderr]) 

211 

212 raise CiToolsError("\n".join(message), exit_code=self.returncode) 

213 

214 def write_report(self, output_file: Path, mark_stderr: bool = True, 

215 include_header: bool = True): 

216 """Write captured command output to a text report. 

217 

218 Output events are written in their captured order so stdout and stderr 

219 remain interleaved as they occurred during command execution. Standard 

220 error lines can optionally be marked, and a header with the masked command 

221 line and exit code can be included. 

222 

223 Args: 

224 output_file: Target report file. 

225 mark_stderr: Prefix standard error lines with ``[stderr]``. 

226 include_header: Include masked command arguments and exit code before 

227 the captured output. 

228 

229 Raises: 

230 CiToolsError: If the report file cannot be written. 

231 """ 

232 lines = [] 

233 

234 if include_header: 

235 lines.extend([f"Command: {' '.join(self.argv.masked)}", f"Exit code: {self.returncode}", 

236 ""]) 

237 

238 for item in self.output: 

239 line = item.line 

240 if mark_stderr and item.stream == "stderr": 

241 line = f"[stderr] {line}" 

242 lines.append(line) 

243 

244 try: 

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

246 output_file.write_text("\n".join(lines) + "\n", encoding="utf-8") 

247 except OSError as exp: 

248 raise CiToolsError(f"Failed to write command report '{output_file}': {exp}") from exp 

249 

250 LOGGER.debug("Written %s", output_file) 

251 

252 

253class CommandStreamReader(threading.Thread): 

254 """Read one command output stream in a daemon thread. 

255 

256 The reader continuously consumes one subprocess output stream and forwards 

257 received lines as CommandOutput events into a shared queue. If a logging 

258 level is configured, lines are also logged immediately for live output. 

259 

260 Args: 

261 stream: Text stream returned by subprocess.Popen. 

262 stream_name: Logical stream name, e.g. ``stdout`` or ``stderr``. 

263 output_queue: Queue receiving CommandOutput events. 

264 log_level: Optional logging level for live output. If None, no 

265 live logging is performed. 

266 """ 

267 

268 def __init__(self, stream, stream_name: str, output_queue: queue.Queue, log_level: int = None): 

269 """Initialize a command stream reader. 

270 

271 The reader starts itself immediately as a daemon thread and forwards 

272 received stream lines as CommandOutput events into the shared output 

273 queue. Live logging is enabled if a log level is configured. 

274 

275 Args: 

276 stream: Text stream returned by subprocess.Popen. 

277 stream_name: Logical stream name, e.g. ``stdout`` or ``stderr``. 

278 output_queue: Queue receiving CommandOutput events. 

279 log_level: Optional logging level for live output. If None, 

280 live logging is disabled. 

281 """ 

282 super().__init__(name=f"CommandStreamReader-{stream_name}", daemon=True) 

283 self.stream = stream 

284 self.stream_name = stream_name 

285 self.output_queue = output_queue 

286 self.log_level = log_level 

287 self.start() 

288 

289 def run(self): 

290 """Consume the assigned subprocess stream. 

291 

292 The method continuously reads lines from the configured stream until 

293 the stream reaches EOF. Every received line is converted into a 

294 CommandOutput event and forwarded into the shared queue. 

295 

296 Live logging is performed immediately if a log level is configured. 

297 The stream is always closed before the thread terminates. 

298 """ 

299 try: 

300 for line in iter(self.stream.readline, ""): 

301 item = CommandOutput(self.stream_name, line.rstrip("\n")) 

302 self.output_queue.put(item) 

303 

304 if self.log_level is not None: 

305 LOGGER.log(self.log_level, "%s", item.line) 

306 finally: 

307 self.stream.close() 

308 

309 

310def _terminate_process(process): 

311 """Terminate a process and kill it if required. 

312 

313 The process is first asked to terminate gracefully. On POSIX systems the 

314 signal is sent to the whole process group, because run_command() starts the 

315 child in a new session. If the process does not stop in time, it is killed. 

316 

317 Args: 

318 process: Active subprocess instance. 

319 """ 

320 try: 

321 if os.name == "nt": 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true

322 process.terminate() 

323 else: 

324 os.killpg(process.pid, signal.SIGTERM) 

325 except ProcessLookupError: 

326 return 

327 

328 try: 

329 process.wait(timeout=PROCESS_STOP_TIMEOUT) 

330 return 

331 except subprocess.TimeoutExpired: 

332 pass 

333 

334 try: 

335 if os.name == "nt": 

336 process.kill() 

337 else: 

338 os.killpg(process.pid, signal.SIGKILL) 

339 except ProcessLookupError: 

340 return 

341 

342 try: 

343 process.wait(timeout=PROCESS_STOP_TIMEOUT) 

344 except subprocess.TimeoutExpired: 

345 LOGGER.warning("Process did not terminate after SIGKILL: %s", process.pid) 

346 

347 

348def _drain_queue(output_queue: queue.Queue) -> list: 

349 """Return all currently queued output events. 

350 

351 The stream reader threads push CommandOutput events into the shared queue. 

352 This helper drains the queue after the process has finished and the readers 

353 had a chance to stop, preserving queue order for the result object. 

354 

355 Args: 

356 output_queue: Queue containing CommandOutput events. 

357 

358 Returns: 

359 List of collected CommandOutput events. 

360 """ 

361 output = [] 

362 while True: 

363 try: 

364 output.append(output_queue.get_nowait()) 

365 except queue.Empty: 

366 break 

367 return output 

368 

369 

370def run_command(argv: list, *, cwd: Path = None, timeout: int = None, 

371 check_exit_code: bool = True, live_output: bool = True) -> CommandResult: 

372 """Run a command and return its completed result. 

373 

374 The function starts the process, reads stdout and stderr in separate 

375 reader threads and stores all output as CommandOutput events. On POSIX 

376 systems the command is started in a new session so that interrupts can 

377 terminate the whole process group. 

378 

379 Args: 

380 argv: Command argument list. 

381 cwd: Optional working directory. 

382 timeout: Optional timeout in seconds. 

383 check_exit_code: If True, non-zero exit codes raise CiToolsError. 

384 live_output: If True, log process output while running. 

385 

386 Returns: 

387 Completed command result. 

388 

389 Raises: 

390 CiToolsError: If the process cannot be started, times out, is 

391 interrupted, or returns a failing exit code. 

392 """ 

393 if not isinstance(argv, CommandArgv): 

394 argv = CommandArgv(argv, argv) 

395 

396 LOGGER.debug("Running command <%s> (cwd=%s)", " ".join(argv), cwd or os.getcwd()) 

397 

398 try: 

399 with subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, 

400 stdin=subprocess.DEVNULL, text=True, cwd=cwd, 

401 start_new_session=True) as process: 

402 output_queue = queue.Queue() 

403 stdout = CommandStreamReader(process.stdout, "stdout", output_queue, 

404 logging.DEBUG if live_output else None) 

405 stderr = CommandStreamReader(process.stderr, "stderr", output_queue, 

406 logging.WARNING if live_output else None) 

407 

408 try: 

409 process.wait(timeout=timeout) 

410 except subprocess.TimeoutExpired as exp: 

411 _terminate_process(process) 

412 raise CiToolsError(f"Command '{argv[0]}' timed out.") from exp 

413 except KeyboardInterrupt as exp: 

414 _terminate_process(process) 

415 raise CiToolsError(f"Command '{argv[0]}' interrupted.") from exp 

416 

417 stdout.join(timeout=READER_JOIN_TIMEOUT) 

418 stderr.join(timeout=READER_JOIN_TIMEOUT) 

419 

420 result = CommandResult(argv, process.returncode, _drain_queue(output_queue)) 

421 

422 except OSError as exp: 

423 raise CiToolsError(f"Failed to start command '{argv[0]}': {exp}") from exp 

424 

425 if check_exit_code: 

426 result.check_returncode() 

427 

428 return result