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
« 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.
3The output goes to `vibefpmgui.window.output`; Test and Run's steps are in
4`vibefpmgui.window.testing`.
5"""
7from __future__ import annotations
9import html
10import os
11import shutil
12import tomllib
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)
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
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]
57STOP_COLOR = "#e03131"
60BUILTIN_PROFILES = ["(default)", "debug", "release"]
63class RunningMixin:
64 """Running fpm (a part of `vibefpmgui.main_window.MainWindow`)."""
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)
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()
151 return options
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"
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
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)
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()
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 """)
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
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)
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"
259 def _browse_fpm(self):
260 filename, _ = QFileDialog.getOpenFileName(self, "Select fpm executable")
261 if filename:
262 self.fpm_edit.setText(filename)
264 def _fpm_executable(self) -> str:
265 return self.fpm_edit.text().strip() or shutil.which("fpm") or "fpm"
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
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()
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)
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)
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()
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()
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()
374 def _clear_features(self):
375 self.selected_features = []
376 self._save_features()
377 self._update_features_ui()
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())
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)
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}"
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)
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)
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()
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)
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)
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)
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())
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
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)
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()
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()