Coverage for vibefpmgui/window/running.py: 91%

434 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""Running fpm: the Build options, the commands and the features. 

2 

3The output goes to `vibefpmgui.window.output`; Test and Run's steps are in 

4`vibefpmgui.window.testing`. 

5""" 

6 

7from __future__ import annotations 

8 

9import html 

10import os 

11import shutil 

12import tomllib 

13 

14from qtpy.QtCore import QProcess, QProcessEnvironment, QSize, Qt 

15from qtpy.QtGui import QAction, QKeySequence, QPalette 

16from qtpy.QtWidgets import ( 

17 QApplication, 

18 QCheckBox, 

19 QComboBox, 

20 QFileDialog, 

21 QFormLayout, 

22 QFrame, 

23 QHBoxLayout, 

24 QLineEdit, 

25 QMessageBox, 

26 QSpinBox, 

27 QToolBar, 

28 QToolButton, 

29 QWidget, 

30) 

31 

32from .. import ansi, dep_updates 

33from .. import build_outputs as bo 

34from .. import coverage as cov 

35from .. import manifest as m 

36from ..compat import join_args, quote_arg, split_args 

37from ..fpm_args import split_runner, with_link_flag 

38from ..icons import make_icon 

39from ..processes import kill_process_tree 

40from ..widgets import MenuButton, StayOpenMenu 

41 

42# (label, fpm sub-command, uses build options, refresh graph afterwards, icon, shortcut, description) 

43COMMANDS = [ 

44 ("Build", ["build"], True, True, "build", "Ctrl+B", "Compile the project"), 

45 ("Build Tests", ["build", "--tests"], True, True, "build-tests", "Ctrl+Shift+B", 

46 "Compile the project and all its tests, without running them"), 

47 ("Run", ["run"], True, True, "run", "Ctrl+R", "Build and run the program(s)"), 

48 ("Test", ["test"], True, True, "test", "Ctrl+U", "Build and run the tests"), 

49 ("Update deps", ["update"], False, True, "update", "", "Fetch or update the dependencies"), 

50 ("Clean", ["clean", "--skip"], False, False, "clean", "", 

51 "Delete the build output, keeping downloaded dependencies"), 

52 ("Clean all", ["clean", "--all"], False, True, "clean-all", "", 

53 "Delete the build output including downloaded dependencies"), 

54] 

55 

56 

57STOP_COLOR = "#e03131" 

58 

59 

60BUILTIN_PROFILES = ["(default)", "debug", "release"] 

61 

62 

63class RunningMixin: 

64 """Running fpm (a part of `vibefpmgui.main_window.MainWindow`).""" 

65 

66 def _build_options_panel(self): 

67 """The Build options panel's fields (each one's tooltip: the fpm flag it becomes).""" 

68 options = QWidget() # (in the "Build options" panel) 

69 form = QFormLayout(options) 

70 # macOS defaults to fields staying at their size hint; let them widen with the panel. 

71 form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow) 

72 self.profile_combo = QComboBox() 

73 self.profile_combo.addItems(BUILTIN_PROFILES) 

74 self._build_config_row(form) # (see vibefpmgui.window.configs) 

75 form.addRow("Profile:", self.profile_combo) 

76 self.features_button = MenuButton() # (its menu: see _build_features_menu) 

77 form.addRow("Features:", self.features_button) 

78 self.compiler_edit = QLineEdit() 

79 env_fc = os.environ.get("FPM_FC", "").strip() 

80 self.compiler_edit.setPlaceholderText(f"{env_fc} (from FPM_FC)" if env_fc else "gfortran (default)") 

81 form.addRow("Compiler:", self.compiler_edit) 

82 self.flag_edit = QLineEdit() 

83 self.flag_edit.setPlaceholderText("e.g. -O3 -fcheck=all") 

84 form.addRow("Fortran flags:", self.flag_edit) 

85 self.target_edit = QLineEdit() 

86 self.target_edit.setPlaceholderText("all targets") 

87 form.addRow("Target:", self.target_edit) 

88 self.extra_edit = QLineEdit() 

89 self.extra_edit.setPlaceholderText("extra fpm arguments") 

90 form.addRow("Extra args:", self.extra_edit) 

91 self.runner_args_edit = QLineEdit() 

92 self.runner_args_edit.setPlaceholderText("arguments passed to the program") 

93 form.addRow("Program args:", self.runner_args_edit) 

94 self.test_jobs_spin = QSpinBox() 

95 self.test_jobs_spin.setRange(1, 256) 

96 self.test_jobs_spin.setSpecialValueText("off (one at a time)") 

97 self.test_jobs_spin.setSuffix(" at a time") 

98 form.addRow("Parallel tests:", self.test_jobs_spin) 

99 self.coverage_check = QCheckBox("Coverage") 

100 form.addRow(self.coverage_check) 

101 self.verbose_check = QCheckBox("Verbose") 

102 form.addRow(self.verbose_check) 

103 fpm_row = QHBoxLayout() 

104 self.fpm_edit = QLineEdit() 

105 self.fpm_edit.setPlaceholderText(shutil.which("fpm") or "fpm") 

106 fpm_browse = QToolButton() 

107 fpm_browse.setText("…") 

108 fpm_browse.clicked.connect(self._browse_fpm) 

109 fpm_row.addWidget(self.fpm_edit) 

110 fpm_row.addWidget(fpm_browse) 

111 # A line above it: the fpm executable isn't part of a build configuration (a path on this computer). 

112 divider = QFrame() 

113 divider.setFrameShape(QFrame.HLine) 

114 divider.setFrameShadow(QFrame.Sunken) 

115 form.addRow(divider) 

116 form.addRow("fpm executable:", fpm_row) 

117 

118 # Hovering an option (or its label) shows the fpm flag it becomes. 

119 for widget, flag, text in [ 

120 (self.profile_combo, "--profile PROF", 

121 ("Build profile: debug or release. (default) passes none: fpm then uses no profile's flags " 

122 "(no -g; Start Debugging uses debug).")), 

123 (self.features_button, "--features LIST", 

124 "Features to enable (the project's fpm.toml declares them). Can't be combined with a profile."), 

125 (self.compiler_edit, "--compiler NAME", "Fortran compiler to use. fpm uses gfortran by default."), 

126 (self.flag_edit, "--flag FFLAGS", "Extra Fortran compiler flags."), 

127 (self.target_edit, "fpm run|test NAME…", 

128 "Names of the programs or tests to run (wildcards allowed). Used by Run and Test."), 

129 (self.extra_edit, "(added as typed)", 

130 "Any other fpm options, e.g. --runner \"mpirun -np 4\" or --c-compiler gcc."), 

131 (self.runner_args_edit, "-- ARGS", 

132 "Arguments passed to the program or tests by Run and Test."), 

133 (self.test_jobs_spin, "(not a flag)", 

134 (f"Run this many tests at once (this computer has {os.cpu_count() or 1} cores). fpm builds " 

135 "them; the app runs them, each with its own output. Off: fpm runs them one after another.")), 

136 (self.coverage_check, "--flag --coverage --link-flag --coverage", 

137 ("Build with gfortran's coverage counting (a build of its own): after Test, the Coverage panel " 

138 "and the editor's gutter show which lines the tests ran.")), 

139 (self.verbose_check, "--verbose", "Print more detail about what fpm is doing."), 

140 (self.fpm_edit, "(not a flag)", 

141 ("The fpm program to run. Leave empty to use the fpm on your PATH. Not part of a build " 

142 "configuration: it's kept for this computer.")), 

143 ]: 

144 tip = f"<b><code>{html.escape(flag)}</code></b><br>{html.escape(text)}" 

145 widget.setToolTip(tip) 

146 label = form.labelForField(widget) or form.labelForField(fpm_row) 

147 if label is not None and widget is not self.verbose_check: 

148 label.setToolTip(tip) 

149 self._watch_option_fields() 

150 

151 return options 

152 

153 def _build_commands(self): 

154 """The fpm commands as actions, shown in a toolbar and a Commands menu.""" 

155 self.command_icons: dict[QAction, str] = {} 

156 if hasattr(self, "palette_action"): # (View → Command Palette…: its icon, redrawn with the theme's colors too) 156 ↛ 158line 156 didn't jump to line 158 because the condition on line 156 was always true

157 self.command_icons[self.palette_action] = "palette" 

158 if hasattr(self, "go_to_file_action"): # (Edit → Go To → Go to File…: the same) 158 ↛ 161line 158 didn't jump to line 161 because the condition on line 158 was always true

159 self.command_icons[self.go_to_file_action] = "find-file" 

160 

161 def make(label, icon, shortcut, description, slot): 

162 action = QAction(label, self) 

163 if shortcut: 

164 action.setShortcut(QKeySequence(shortcut)) 

165 keys = QKeySequence(shortcut).toString(QKeySequence.NativeText) 

166 action.setToolTip(f"{label} ({keys}) — {description}") 

167 else: 

168 action.setToolTip(f"{label} — {description}") 

169 action.setStatusTip(description) 

170 action.triggered.connect(slot) 

171 self.command_icons[action] = icon 

172 return action 

173 

174 actions = {} 

175 for label, args, uses_options, refresh, icon, shortcut, description in COMMANDS: 

176 if args == ["test"]: 

177 slot = self.run_tests 

178 elif args == ["run"]: 

179 slot = self.run_programs 

180 else: 

181 slot = lambda _=False, a=args, o=uses_options, r=refresh: self.run_fpm(a, o, r) 

182 actions[icon] = make(label, icon, shortcut, description, slot) 

183 self.command_actions.append(actions[icon]) 

184 self.stop_action = make("Stop", "stop", "Ctrl+.", "Stop the running fpm command", self.stop) 

185 self.stop_action.setEnabled(False) 

186 

187 groups = [[actions["build"], actions["build-tests"], actions["run"], actions["test"], self.stop_action], 

188 [actions["update"]], 

189 [actions["clean"], actions["clean-all"]]] 

190 toolbar = QToolBar("Commands") 

191 toolbar.setObjectName("CommandsToolbar") 

192 toolbar.setMovable(False) 

193 toolbar.setFloatable(False) 

194 toolbar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) 

195 toolbar.setIconSize(QSize(22, 22)) 

196 toolbar.toggleViewAction().setEnabled(False) # don't let it be hidden by accident 

197 menu = self.menuBar().addMenu("&Commands") 

198 for i, group in enumerate(groups): 

199 if i: 

200 toolbar.addSeparator() 

201 menu.addSeparator() 

202 toolbar.addActions(group) 

203 menu.addActions(group) 

204 menu.addSeparator() 

205 self.check_updates_action = menu.addAction("Check for Dependency Updates…") 

206 self.check_updates_action.setStatusTip( 

207 "Ask each git dependency's repository for newer tags and commits") 

208 self.check_updates_action.triggered.connect(self.check_dependency_updates) 

209 self.addToolBar(Qt.TopToolBarArea, toolbar) 

210 self.setUnifiedTitleAndToolBarOnMac(True) 

211 self.command_toolbar = toolbar 

212 # Right-click: the toolbar's look (View → Toolbar's choices; Qt's own menu would list the panels). 

213 toolbar.setContextMenuPolicy(Qt.CustomContextMenu) 

214 toolbar.customContextMenuRequested.connect( 

215 lambda pos: self.toolbar_menu.popup(toolbar.mapToGlobal(pos))) 

216 self._update_command_icons() 

217 

218 def _update_command_icons(self): 

219 """(Re)draw the toolbar icons in the current text color.""" 

220 text = QApplication.palette().color(QPalette.WindowText) 

221 for action, name in self.command_icons.items(): 

222 action.setIcon(make_icon(name, STOP_COLOR if name == "stop" else text)) 

223 # The ">>" button shown when the window is too narrow for every 

224 # button. Qt 6.11 draws its default arrow with a macOS symbol image, 

225 # which crashes in AppKit on macOS 27, so give it our own icon. 

226 extension = self.command_toolbar.findChild(QToolButton, "qt_toolbar_ext_button") 

227 if extension is not None: 227 ↛ 229line 227 didn't jump to line 229 because the condition on line 227 was always true

228 extension.setIcon(make_icon("more", text)) 

229 hover = "rgba(128,128,128,45)" 

230 self.command_toolbar.setStyleSheet(f""" 

231 QToolBar#CommandsToolbar {{ border: none; spacing: 2px; padding: 2px 6px; }} 

232 QToolBar#CommandsToolbar QToolButton {{ border: none; border-radius: 7px; 

233 padding: 4px 10px; }} 

234 QToolBar#CommandsToolbar QToolButton:hover {{ background: {hover}; }} 

235 QToolBar#CommandsToolbar QToolButton:pressed {{ background: rgba(128,128,128,80); }} 

236 QToolBar#CommandsToolbar QToolButton#qt_toolbar_ext_button {{ padding: 0px; }} 

237 """) 

238 

239 def busy(self) -> bool: 

240 """A command is running (fpm, a program, or tests run in parallel).""" 

241 return self.process is not None or self.parallel_tests is not None 

242 

243 def _set_project_enabled(self, enabled: bool): 

244 running = self.busy() 

245 for action in self.command_actions: 

246 action.setEnabled(enabled and not running) 

247 

248 def effective_compiler(self) -> tuple[str, str]: 

249 """(compiler, where the choice comes from), the way fpm picks it: 

250 --compiler (the Compiler field), else $FPM_FC, else gfortran.""" 

251 typed = self.compiler_edit.text().strip() 

252 if typed: 

253 return typed, "Compiler field in Build options" 

254 env = os.environ.get("FPM_FC", "").strip() 

255 if env: 255 ↛ 256line 255 didn't jump to line 256 because the condition on line 255 was never true

256 return env, "FPM_FC environment variable" 

257 return "gfortran", "fpm's default" 

258 

259 def _browse_fpm(self): 

260 filename, _ = QFileDialog.getOpenFileName(self, "Select fpm executable") 

261 if filename: 

262 self.fpm_edit.setText(filename) 

263 

264 def _fpm_executable(self) -> str: 

265 return self.fpm_edit.text().strip() or shutil.which("fpm") or "fpm" 

266 

267 def build_arguments(self, sub: list[str], uses_options: bool, 

268 program_args: bool = True, targets: list[str] | None = None) -> list[str]: 

269 """fpm's arguments for ``sub``; ``targets``: these programs or tests 

270 (instead of the Target field's).""" 

271 args = list(sub) 

272 if uses_options: 

273 if self.selected_features: # fpm rejects --features together with --profile 

274 args += ["--features", ",".join(self.selected_features)] 

275 elif self.profile_combo.currentIndex() > 0: 

276 args += ["--profile", self.profile_combo.currentText()] 

277 if self.compiler_edit.text().strip(): 

278 args += ["--compiler", self.compiler_edit.text().strip()] 

279 coverage = self.coverage_check.isChecked() and sub[0] in ("build", "run", "test", "install") 

280 flags = " ".join(f for f in (self.flag_edit.text().strip(), cov.FLAG if coverage else "") if f) 

281 if flags: 

282 args += ["--flag", flags] 

283 if self.verbose_check.isChecked(): 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true

284 args.append("--verbose") 

285 names = split_args(self.target_edit.text()) if targets is None else targets 

286 if sub[0] in ("run", "test") and names: 

287 args += names 

288 extra = split_args(self.extra_edit.text()) 

289 if sub[0] in ("test", "run"): 

290 extra, _ = split_runner(extra) # (the user's --runner: run inside ours, see _start_marked) 

291 if coverage: # (linked with it too: the user's own --link-flag gets it added) 

292 extra = with_link_flag(extra, cov.FLAG) 

293 args += extra 

294 if program_args and sub[0] in ("run", "test") and self.runner_args_edit.text().strip(): 

295 args += ["--"] + split_args(self.runner_args_edit.text()) 

296 elif sub[0] == "update" and self.verbose_check.isChecked(): 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true

297 args.append("--verbose") 

298 return args 

299 

300 def _build_features_menu(self): 

301 """The Features button's menu in Build options (filled from the project's manifest).""" 

302 self.features_menu = StayOpenMenu(self) 

303 self.features_button.setMenu(self.features_menu) 

304 self.project_features: dict[str, list[str]] = {} 

305 self.selected_features: list[str] = [] 

306 self._wanted_profile = "(default)" 

307 self._rebuild_features_menu() 

308 

309 def _load_features_and_profiles(self): 

310 """Read [features] and [profiles] from the project's fpm.toml.""" 

311 try: 

312 data = m.load_toml(self.manifest_path) 

313 except (OSError, tomllib.TOMLDecodeError): 

314 data = {} 

315 self.project_features = m.project_features(data) 

316 profiles = m.project_profiles(data) 

317 

318 # Profile list: built-in ones plus the project's feature profiles. 

319 current = self.profile_combo.currentText() if self.profile_combo.count() else self._wanted_profile 

320 self.profile_combo.blockSignals(True) 

321 self.profile_combo.clear() 

322 self.profile_combo.addItems(BUILTIN_PROFILES) 

323 for name, features in profiles.items(): 

324 if name not in BUILTIN_PROFILES: 324 ↛ 323line 324 didn't jump to line 323 because the condition on line 324 was always true

325 self.profile_combo.addItem(name) 

326 index = self.profile_combo.count() - 1 

327 self.profile_combo.setItemData( 

328 index, f"Feature profile: {', '.join(features) or '(no features)'}", Qt.ToolTipRole) 

329 if self.profile_combo.findText(current) >= 0: 329 ↛ 331line 329 didn't jump to line 331 because the condition on line 329 was always true

330 self.profile_combo.setCurrentText(current) 

331 self.profile_combo.blockSignals(False) 

332 

333 if not self.config_shown(): # (a configuration's own features stay; see vibefpmgui.window.configs) 

334 saved = self._saved_free_options().features 

335 self.selected_features = [f for f in saved if f in self.project_features] 

336 self._rebuild_features_menu() 

337 

338 def _rebuild_features_menu(self): 

339 menu = self.features_menu 

340 menu.clear() 

341 if not self.project_features: 

342 none = menu.addAction("No features defined in fpm.toml") 

343 none.setEnabled(False) 

344 self.feature_actions: dict[str, QAction] = {} 

345 for name, props in self.project_features.items(): 

346 action = menu.addAction(name) 

347 action.setCheckable(True) 

348 action.setChecked(name in self.selected_features) 

349 self.feature_actions[name] = action 

350 shown = ", ".join(props[:8]) + (" …" if len(props) > 8 else "") 

351 action.setToolTip(f"Sets: {shown}" if props else name) 

352 action.setStatusTip(f"Feature {name} — sets {shown}" if props else f"Feature {name}") 

353 action.toggled.connect(lambda on, n=name: self._toggle_feature(n, on)) 

354 menu.setToolTipsVisible(True) 

355 self.clear_features_action = None 

356 if self.project_features: 

357 menu.addSeparator() 

358 self.clear_features_action = menu.addAction("Clear all features") 

359 self.clear_features_action.triggered.connect(self._clear_features) 

360 self._update_features_ui() 

361 

362 def _toggle_feature(self, name: str, on: bool): 

363 if on and name not in self.selected_features: 363 ↛ 367line 363 didn't jump to line 367 because the condition on line 363 was always true

364 # Keep the order they're declared in fpm.toml. 

365 chosen = set(self.selected_features) | {name} 

366 self.selected_features = [f for f in self.project_features if f in chosen] 

367 elif not on and name in self.selected_features: 

368 self.selected_features.remove(name) 

369 self._save_features() 

370 # Only update the existing actions: rebuilding the menu here would 

371 # delete the action whose signal is being handled. 

372 self._update_features_ui() 

373 

374 def _clear_features(self): 

375 self.selected_features = [] 

376 self._save_features() 

377 self._update_features_ui() 

378 

379 def _save_features(self): 

380 """New configuration's features (not a saved configuration's, being edited).""" 

381 if self.manifest_path and not self.config_shown(): 

382 self._keep_free_options(self.current_options()) 

383 

384 def _update_features_ui(self): 

385 """Show the selection, and disable Profile while features are on.""" 

386 locked = self.options_locked() # (a saved configuration's: read-only) 

387 for name, action in getattr(self, "feature_actions", {}).items(): 

388 if action.isChecked() != (name in self.selected_features): 

389 action.blockSignals(True) 

390 action.setChecked(name in self.selected_features) 

391 action.blockSignals(False) 

392 action.setEnabled(not locked) 

393 if getattr(self, "clear_features_action", None) is not None: 

394 self.clear_features_action.setEnabled(bool(self.selected_features) and not locked) 

395 if self.selected_features: 

396 shown = ", ".join(self.selected_features) 

397 else: 

398 shown = "none" if self.project_features else "none defined in fpm.toml" 

399 self.features_button.set_full_text(shown) 

400 self.features_button.setEnabled(bool(self.project_features) and not locked) 

401 self.features_button.setToolTip(f"<b><code>--features LIST</code></b><br>{html.escape(shown)}<br>" 

402 "Features to enable (the project's fpm.toml declares them). Can't be " 

403 "combined with a profile.") 

404 using = bool(self.selected_features) 

405 self.profile_combo.setEnabled(not using and not locked) 

406 self.profile_combo.setToolTip( 

407 "<b><code>--profile PROF</code></b><br>" 

408 + ("Disabled while features are selected: fpm can't combine --profile and --features." 

409 if using else "Build profile: debug, release, or a feature profile from fpm.toml.")) 

410 self._options_changed() # (the features are options too) 

411 

412 def _build_description(self) -> str: 

413 compiler, _source = self.effective_compiler() 

414 if self.selected_features: 414 ↛ 415line 414 didn't jump to line 415 because the condition on line 414 was never true

415 return f"features: {', '.join(self.selected_features)} · {compiler}" 

416 profile = self.profile_combo.currentText() if self.profile_combo.currentIndex() > 0 else "debug" 

417 return f"{profile} · {compiler}" 

418 

419 def run_fpm(self, sub: list[str], uses_options: bool, refresh: bool, 

420 phase: str | None = None, args: list[str] | None = None, 

421 env_extra: dict[str, str] | None = None): 

422 if not self.manifest_path or self.busy(): 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true

423 return 

424 if args is None: 

425 try: 

426 args = self.build_arguments(sub, uses_options) 

427 except ValueError as exc: # unbalanced quotes in a text field 

428 QMessageBox.warning(self, "Invalid arguments", str(exc)) 

429 return 

430 program = self._fpm_executable() 

431 self.process = QProcess(self) 

432 self.process.setWorkingDirectory(str(self.manifest_path.parent)) 

433 self.process.setProcessChannelMode(QProcess.MergedChannels) 

434 env = QProcessEnvironment.systemEnvironment() 

435 env.insert("NO_COLOR", "1") 

436 for key, value in (env_extra or {}).items(): 436 ↛ 437line 436 didn't jump to line 437 because the loop on line 436 never started

437 env.insert(key, value) 

438 self.process.setProcessEnvironment(env) 

439 self.process.readyReadStandardOutput.connect(self._read_output) 

440 self.process.finished.connect(self._finished) 

441 self.process.errorOccurred.connect(self._process_error) 

442 self._refresh_after = refresh 

443 self._partial = "" 

444 self._phase = phase 

445 self._captured = [] 

446 self._held_blank = False 

447 # Commands that compile get the per-file build grid. "fpm test --list" 

448 # doesn't build; "fpm test" builds first, so its output goes to the 

449 # build grid until the first test starts (see _test_line). 

450 self._monitoring = sub[0] in ("build", "run", "test") and phase != "list" 

451 self._test_building = phase in ("marked", "collect", "debug") # fpm builds before running 

452 self._process_label = "fpm" 

453 self._revs_before = (dep_updates.read_cache_revs(self.manifest_path.parent) 

454 if sub[0] == "update" else None) 

455 # Note which build folders exist now, to record which ones this 

456 # build writes (see _record_build_folders). 

457 self._folder_snapshot = (bo.snapshot(self.manifest_path.parent) 

458 if self._monitoring else None) 

459 if self._monitoring: 

460 self.build_monitor.start(self.graph, self.manifest_path.parent, self._build_description()) 

461 if phase is None: 

462 self.show_all_output() # any file shown on its own is gone 

463 # Show the build's progress (Test switches to its tab once the 

464 # tests themselves start). 

465 self.show_panel(self.build_monitor) 

466 

467 self._append(f"\n$ cd {quote_arg(str(self.manifest_path.parent))}\n" 

468 f"$ {join_args([program] + args)}\n", bold=True) 

469 self.stop_action.setEnabled(True) 

470 self._set_project_enabled(True) 

471 self.statusBar().showMessage(f"Running fpm {' '.join(sub)}…") 

472 self._esc_tail = "" 

473 self.console_writer.reset() 

474 self._begin_output() 

475 self.process.start(program, args) 

476 

477 def stop(self): 

478 if self.process is not None: 

479 # (Remembered: the process may still end normally. Its children go first, 

480 # and fpm, seeing its test program killed, can exit with its code before 

481 # it's killed too: the run is the one stopped, not a failure.) 

482 self._stop_requested = True 

483 kill_process_tree(self.process) 

484 if self.parallel_tests is not None: 

485 self.parallel_tests.stop() 

486 

487 def _stop_and_wait(self, msecs: int): 

488 if self.process is not None: 488 ↛ 491line 488 didn't jump to line 491 because the condition on line 488 was always true

489 kill_process_tree(self.process) 

490 self.process.waitForFinished(msecs) 

491 if self.parallel_tests is not None: 491 ↛ 492line 491 didn't jump to line 492 because the condition on line 491 was never true

492 self.parallel_tests.stop() 

493 self.parallel_tests.wait(msecs) 

494 

495 def _read_output(self, final: bool = False): 

496 """New output from the running command; ``final``: it has finished 

497 (bytes of an unfinished character are shown as "�").""" 

498 if self.process is None: 498 ↛ 499line 498 didn't jump to line 499 because the condition on line 498 was never true

499 return 

500 data = self._decoder.decode(bytes(self.process.readAllStandardOutput()), final=final) 

501 if not data: 

502 return 

503 data, self._esc_tail = ansi.split_partial(self._esc_tail + data) 

504 # fpm redraws progress lines with \r; keep only the final state of each line. 

505 data = data.replace("\r\n", "\n") 

506 data = "\n".join(line.rsplit("\r", 1)[-1] for line in data.split("\n")) 

507 if self._phase in ("marked", "direct", "collect", "debug"): 

508 # Line-buffered so the test markers can be taken out of the output. 

509 lines = (self._partial + data).split("\n") 

510 self._partial = lines.pop() 

511 handle = self._line_handler() 

512 self._append("".join(handle(line) for line in lines), colored=True) 

513 return 

514 self._append(data, colored=True) 

515 plain = ansi.strip(data) # color codes would get in the way of parsing 

516 if self._phase in ("list", "valgrind"): 

517 self._captured.append(plain) 

518 if self._monitoring: 

519 # Output can arrive mid-line; only parse complete lines. 

520 lines = (self._partial + plain).split("\n") 

521 self._partial = lines.pop() 

522 for line in lines: 

523 self.build_monitor.feed_line(line) 

524 

525 def _line_handler(self): 

526 """What reads each line of a run's output, per phase.""" 

527 return {"collect": self._collect_line, "debug": self._debug_line}.get(self._phase, self._test_line) 

528 

529 def _build_part_done(self): 

530 """The build part of "fpm test" is over: show the tests.""" 

531 self._test_building = False 

532 if self._monitoring: 532 ↛ 536line 532 didn't jump to line 536 because the condition on line 532 was always true

533 self.build_monitor.finish(0) 

534 self._update_tree_marks() 

535 self._monitoring = False 

536 self._marker_monitor().build_finished() 

537 self.show_panel(self._marker_monitor()) 

538 

539 def _flush_monitor(self, exit_code: int, stopped: bool): 

540 if self._phase in ("marked", "direct", "collect", "debug"): 

541 if self._partial: 541 ↛ 542line 541 didn't jump to line 542 because the condition on line 541 was never true

542 handle = self._line_handler() 

543 self._append(handle(self._partial), colored=True) 

544 if self._held_blank: 544 ↛ 545line 544 didn't jump to line 545 because the condition on line 544 was never true

545 self._append("\n") 

546 self._partial = "" 

547 self._held_blank = False 

548 if self._monitoring: 

549 if self._partial: 549 ↛ 550line 549 didn't jump to line 550 because the condition on line 549 was never true

550 self.build_monitor.feed_line(self._partial) 

551 self._partial = "" 

552 self.build_monitor.finish(exit_code, stopped) 

553 self._update_tree_marks() 

554 self._monitoring = False 

555 

556 def _finished(self, code: int, status): 

557 self._read_output(final=True) 

558 crashed = status == QProcess.CrashExit or self._stop_requested # (stopped) 

559 self._flush_monitor(code, crashed) 

560 self._end_output() 

561 if crashed: 

562 self._append("\n[process was stopped]\n", bold=True) 

563 else: 

564 self._append(f"\n[{self._process_label} exited with code {code}]\n", bold=True) 

565 self._record_build_folders() 

566 phase, self._phase = self._phase, None 

567 self._cleanup_process() 

568 self.statusBar().showMessage("Stopped" if crashed else 

569 ("Finished successfully" if code == 0 else f"Failed (exit code {code})")) 

570 self._report_dependency_changes() # after the generic message, so it shows 

571 if phase == "list": 

572 self._after_listing(code, crashed) 

573 elif phase in ("marked", "direct"): 

574 self._after_marked(code, crashed) 

575 elif phase == "collect": 

576 self._after_collect(code, crashed) 

577 elif phase == "debug": 

578 self._after_debug_build(code, crashed) 

579 elif phase == "valgrind": 

580 self._after_valgrind(code, crashed) 

581 

582 def _process_error(self, error): 

583 if error == QProcess.FailedToStart: 

584 if getattr(self, "_process_label", "fpm") == "fpm": 584 ↛ 588line 584 didn't jump to line 588 because the condition on line 584 was always true

585 self._append(f"\nCould not start '{self._fpm_executable()}'. " 

586 "Set the fpm executable in Build options.\n", bold=True) 

587 else: 

588 self._append(f"\nCould not start '{self._process_label}'.\n", bold=True) 

589 self.statusBar().showMessage(f"{getattr(self, '_process_label', 'fpm')} failed to start") 

590 self._flush_monitor(-1, True) 

591 self._end_output() 

592 if self._phase is not None: 592 ↛ 593line 592 didn't jump to line 593 because the condition on line 592 was never true

593 self._marker_monitor().finish(-1, stopped=True) 

594 self._phase = None 

595 self._cleanup_process() 

596 

597 def _cleanup_process(self): 

598 if self.process is None: 598 ↛ 599line 598 didn't jump to line 599 because the condition on line 598 was never true

599 return 

600 self.process.deleteLater() 

601 self.process = None 

602 self._stop_requested = False 

603 self.stop_action.setEnabled(False) 

604 self._set_project_enabled(self.graph is not None) 

605 if self._refresh_after: 

606 self.reload() 

607 

608 

609 

610 

611 

612 

613 

614