Coverage for vibefpmgui/window/build_outputs.py: 84%

175 statements  

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

1"""The Build outputs panel: what fpm keeps in build/, running a built program, 

2and the command that compiled a file.""" 

3 

4from __future__ import annotations 

5 

6import html 

7import os 

8import shutil 

9import sys 

10from pathlib import Path 

11 

12from qtpy.QtCore import QProcess 

13from qtpy.QtWidgets import ( 

14 QApplication, 

15 QDialog, 

16 QHBoxLayout, 

17 QLabel, 

18 QMessageBox, 

19 QPlainTextEdit, 

20 QPushButton, 

21 QVBoxLayout, 

22) 

23 

24from .. import build_outputs as bo 

25from ..compat import join_args, quote_arg, split_args 

26from ..fpm_args import TEST_RUNNER, VALGRIND_RUNNER, split_runner 

27from ..project_tree import reveal_in_file_manager 

28from ..widgets import run_dialog 

29 

30 

31class BuildOutputsMixin: 

32 """The Build outputs panel (a part of `vibefpmgui.main_window.MainWindow`).""" 

33 

34 def _build_outputs_view(self): 

35 """The Build outputs panel.""" 

36 self.outputs_view = bo.BuildOutputsView() 

37 self.outputs_view.run_requested.connect(self.run_program) 

38 self.outputs_view.run_in_terminal_requested.connect(self.run_program_in_terminal) 

39 self.outputs_view.debug_requested.connect(self.debug_named) # (built again: see window.debugging) 

40 self.outputs_view.valgrind_requested.connect(self.run_valgrind) 

41 self.outputs_view.reveal_requested.connect(reveal_in_file_manager) 

42 self.outputs_view.delete_requested.connect(self.delete_configurations) 

43 self.outputs_view.refresh_requested.connect(self.refresh_build_outputs) 

44 

45 def _config_records(self) -> bo.ConfigRecords: 

46 return bo.ConfigRecords(self.settings, self._project_key("build_configs")) 

47 

48 def _record_build_folders(self): 

49 """Remember which build folders the finished build wrote, and with 

50 which settings, so the Build outputs tab can label them.""" 

51 before, self._folder_snapshot = getattr(self, "_folder_snapshot", None), None 

52 if before is None or not self.manifest_path: 

53 return 

54 changed = bo.changed_folders(before, bo.snapshot(self.manifest_path.parent)) 

55 flags = " ".join(x for x in (self.flag_edit.text().strip(), 

56 " ".join(split_runner(split_args(self.extra_edit.text()))[0])) 

57 if x) 

58 if changed: 

59 self._config_records().record_build(self._build_description(), flags, changed) 

60 else: # up to date: these settings are still the current configuration 

61 self._config_records().mark_used(self._build_description(), flags) 

62 self.refresh_build_outputs() 

63 

64 def refresh_build_outputs(self): 

65 if not self.manifest_path: 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true

66 self.outputs_view.set_configurations([], None) 

67 return 

68 records = self._config_records() 

69 configs = bo.configurations(self.manifest_path.parent, records.load(), records.last_used()) 

70 self.outputs_view.set_configurations(configs, self.manifest_path.parent) 

71 

72 def delete_configurations(self, configs: list): 

73 if not configs: 73 ↛ 74line 73 didn't jump to line 74 because the condition on line 73 was never true

74 return 

75 if self.busy(): 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true

76 QMessageBox.information(self, "Build outputs", "Wait for the running command to finish first.") 

77 return 

78 size = bo.human_size(sum(c.size for c in configs)) 

79 names = "\n".join(f"• {c.label} ({bo.human_size(c.size)})" for c in configs) 

80 answer = QMessageBox.question( 

81 self, "Delete build outputs", 

82 f"Delete {len(configs)} configuration{'s' * (len(configs) != 1)} ({size})?\n\n{names}\n\n" 

83 "They are rebuilt automatically when needed.") 

84 if answer != QMessageBox.Yes: 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true

85 return 

86 folders = [f.path for c in configs for f in c.folders] 

87 errors = bo.delete_folders(folders) 

88 self._config_records().forget([f.name for f in folders]) 

89 self.refresh_build_outputs() 

90 if errors: 90 ↛ 91line 90 didn't jump to line 91 because the condition on line 90 was never true

91 QMessageBox.warning(self, "Build outputs", "Some folders could not be deleted:\n" + "\n".join(errors)) 

92 else: 

93 self.statusBar().showMessage(f"Deleted {size} of build outputs", 5000) 

94 

95 def run_valgrind(self, kind: str, name: str): 

96 """Build a program, test or example (``kind``) with the Build options 

97 and run it under Valgrind's memory check, through fpm (``--runner``): 

98 its report goes to the Output panel.""" 

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

100 return 

101 if shutil.which("valgrind") is None: 

102 QMessageBox.information(self, "Valgrind", bo.NO_VALGRIND) 

103 return 

104 args = self.debug_arguments(kind, [name], VALGRIND_RUNNER) # (debug information: files and lines) 

105 if args is None: 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true

106 return 

107 self._valgrind_name = name 

108 self.run_fpm(args[:1], True, False, phase="valgrind", args=args) 

109 if self.process is not None: 

110 self.show_all_output() 

111 self.statusBar().showMessage(f"Building {name}, to run it under Valgrind…") 

112 

113 def _after_valgrind(self, code: int, stopped: bool): 

114 """Valgrind's run is over: say what it found (the report's in Output). 

115 Its summary says, not the exit code: fpm's is 1 for a failed build too.""" 

116 name = self._valgrind_name 

117 if stopped: 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true

118 return 

119 errors = bo.valgrind_errors("".join(self._captured)) 

120 if errors is None: 

121 message = (f"{name} wasn't run under Valgrind: the build failed, or Valgrind couldn't start " 

122 "(see Build and Output)" if code else f"fpm ran nothing called {name}") 

123 elif errors: 

124 message = f"Valgrind found {errors} error{'s' * (errors != 1)} in {name} (see Output)" 

125 elif code: 

126 message = f"Valgrind: no memory errors in {name}, but it failed (exit code {code})" 

127 else: 

128 message = f"Valgrind: no errors in {name}" 

129 self._append(f"[{message}]\n", bold=True) 

130 self.statusBar().showMessage(message) 

131 self.show_panel(self._content(self.docks["output"])) 

132 

133 def run_program_in_terminal(self, path: str): 

134 """Type the command to run an already-built program (with the Program 

135 args) in the Terminal panel: there it can read what you type.""" 

136 try: 

137 args = split_args(self.runner_args_edit.text()) 

138 except ValueError as exc: 

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

140 return 

141 self.show_panel(self.terminal_panel) 

142 self.terminal_panel.type_command(join_args([path, *args])) 

143 self.terminal_panel.focus() 

144 

145 def run_program(self, path: str): 

146 """Run an already-built program directly (no fpm, no rebuild).""" 

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

148 return 

149 try: 

150 args = split_args(self.runner_args_edit.text()) 

151 except ValueError as exc: 

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

153 return 

154 self.process = QProcess(self) 

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

156 self.process.setProcessChannelMode(QProcess.MergedChannels) 

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

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

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

160 self._refresh_after = False 

161 self._partial = "" 

162 self._esc_tail = "" 

163 self.console_writer.reset() 

164 self._begin_output() 

165 self._held_blank = False 

166 # Through the same marker wrapper as Test, so the Run tab can follow it. 

167 self._phase = "direct" 

168 self._marker_kind = "run" 

169 self._monitoring = False 

170 self._test_building = False 

171 self._folder_snapshot = None 

172 name = Path(path).name 

173 if name.lower().endswith(".exe"): 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true

174 name = name[:-4] 

175 self._process_label = name 

176 self.show_all_output() 

177 self.run_monitor.begin_listing(f"{Path(path).parent.parent.name} · run directly, no rebuild") 

178 self.run_monitor.start([name], self._load_durations("run")) 

179 self.show_panel(self.run_monitor) 

180 rel = os.path.relpath(path, self.manifest_path.parent) 

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

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

183 self.stop_action.setEnabled(True) 

184 self._set_project_enabled(True) 

185 self.statusBar().showMessage(f"Running {name}…") 

186 self.process.start(sys.executable, ["-I", TEST_RUNNER, path] + args) 

187 

188 def show_compile_command(self, source: str): 

189 """The exact compiler command fpm used for a source file.""" 

190 if not self.manifest_path: 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true

191 return 

192 commands, _folder = bo.read_compile_commands(self.manifest_path.parent) 

193 args = commands.get(str(Path(source).resolve())) 

194 name = Path(source).name 

195 if args is None: 

196 QMessageBox.information( 

197 self, "Compile command", 

198 f"No compile command for {name} in build/compile_commands.json.\n\n" 

199 "That file lists the files of the most recent build; build the " 

200 "project (with this file in it) and try again.") 

201 return 

202 dialog = QDialog(self) 

203 dialog.setWindowTitle(f"Compile command — {name}") 

204 layout = QVBoxLayout(dialog) 

205 note = QLabel("From <code>build/compile_commands.json</code>, which fpm writes when " 

206 "it compiles something — so this is the last build that compiled files, " 

207 "which may have used other settings than the current ones. " 

208 "Run from the project folder.") 

209 note.setWordWrap(True) 

210 layout.addWidget(note) 

211 text = QPlainTextEdit(bo.format_command(args)) 

212 text.setReadOnly(True) 

213 text.setFont(self._mono_font()) 

214 text.setLineWrapMode(QPlainTextEdit.NoWrap) 

215 layout.addWidget(text, 1) 

216 repeated = sorted({a for a in args if a.startswith("-") and args.count(a) > 1}) 

217 if repeated: 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true

218 hint = QLabel("<span style='color:gray'>fpm passes some flags more than once: " 

219 + ", ".join(f"<code>{html.escape(r)}</code> ×{args.count(r)}" for r in repeated) 

220 + " (harmless).</span>") 

221 hint.setWordWrap(True) 

222 layout.addWidget(hint) 

223 buttons = QHBoxLayout() 

224 buttons.addStretch(1) 

225 copy = QPushButton("Copy") 

226 copy.setToolTip("Copy as one line") 

227 copy.clicked.connect(lambda: (QApplication.clipboard().setText(join_args(args)), 

228 copy.setText("Copied"))) 

229 close = QPushButton("Close") 

230 close.setDefault(True) 

231 close.clicked.connect(dialog.accept) 

232 buttons.addWidget(copy) 

233 buttons.addWidget(close) 

234 layout.addLayout(buttons) 

235 dialog.resize(720, 420) 

236 run_dialog(dialog)