vibefpmgui.window.running
Running fpm: the Build options, the commands and the features.
The output goes to vibefpmgui.window.output; Test and Run's steps are in
vibefpmgui.window.testing.
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) 157 self.command_icons[self.palette_action] = "palette" 158 if hasattr(self, "go_to_file_action"): # (Edit → Go To → Go to File…: the same) 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: 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: 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(): 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(): 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: 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: 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: 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: 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(): 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(): 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: 489 kill_process_tree(self.process) 490 self.process.waitForFinished(msecs) 491 if self.parallel_tests is not None: 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: 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: 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: 542 handle = self._line_handler() 543 self._append(handle(self._partial), colored=True) 544 if self._held_blank: 545 self._append("\n") 546 self._partial = "" 547 self._held_blank = False 548 if self._monitoring: 549 if self._partial: 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": 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: 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: 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()
COMMANDS =
[('Build', ['build'], True, True, 'build', 'Ctrl+B', 'Compile the project'), ('Build Tests', ['build', '--tests'], True, True, 'build-tests', 'Ctrl+Shift+B', 'Compile the project and all its tests, without running them'), ('Run', ['run'], True, True, 'run', 'Ctrl+R', 'Build and run the program(s)'), ('Test', ['test'], True, True, 'test', 'Ctrl+U', 'Build and run the tests'), ('Update deps', ['update'], False, True, 'update', '', 'Fetch or update the dependencies'), ('Clean', ['clean', '--skip'], False, False, 'clean', '', 'Delete the build output, keeping downloaded dependencies'), ('Clean all', ['clean', '--all'], False, True, 'clean-all', '', 'Delete the build output including downloaded dependencies')]
STOP_COLOR =
'#e03131'
BUILTIN_PROFILES =
['(default)', 'debug', 'release']
class
RunningMixin:
64class RunningMixin: 65 """Running fpm (a part of `vibefpmgui.main_window.MainWindow`).""" 66 67 def _build_options_panel(self): 68 """The Build options panel's fields (each one's tooltip: the fpm flag it becomes).""" 69 options = QWidget() # (in the "Build options" panel) 70 form = QFormLayout(options) 71 # macOS defaults to fields staying at their size hint; let them widen with the panel. 72 form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow) 73 self.profile_combo = QComboBox() 74 self.profile_combo.addItems(BUILTIN_PROFILES) 75 self._build_config_row(form) # (see vibefpmgui.window.configs) 76 form.addRow("Profile:", self.profile_combo) 77 self.features_button = MenuButton() # (its menu: see _build_features_menu) 78 form.addRow("Features:", self.features_button) 79 self.compiler_edit = QLineEdit() 80 env_fc = os.environ.get("FPM_FC", "").strip() 81 self.compiler_edit.setPlaceholderText(f"{env_fc} (from FPM_FC)" if env_fc else "gfortran (default)") 82 form.addRow("Compiler:", self.compiler_edit) 83 self.flag_edit = QLineEdit() 84 self.flag_edit.setPlaceholderText("e.g. -O3 -fcheck=all") 85 form.addRow("Fortran flags:", self.flag_edit) 86 self.target_edit = QLineEdit() 87 self.target_edit.setPlaceholderText("all targets") 88 form.addRow("Target:", self.target_edit) 89 self.extra_edit = QLineEdit() 90 self.extra_edit.setPlaceholderText("extra fpm arguments") 91 form.addRow("Extra args:", self.extra_edit) 92 self.runner_args_edit = QLineEdit() 93 self.runner_args_edit.setPlaceholderText("arguments passed to the program") 94 form.addRow("Program args:", self.runner_args_edit) 95 self.test_jobs_spin = QSpinBox() 96 self.test_jobs_spin.setRange(1, 256) 97 self.test_jobs_spin.setSpecialValueText("off (one at a time)") 98 self.test_jobs_spin.setSuffix(" at a time") 99 form.addRow("Parallel tests:", self.test_jobs_spin) 100 self.coverage_check = QCheckBox("Coverage") 101 form.addRow(self.coverage_check) 102 self.verbose_check = QCheckBox("Verbose") 103 form.addRow(self.verbose_check) 104 fpm_row = QHBoxLayout() 105 self.fpm_edit = QLineEdit() 106 self.fpm_edit.setPlaceholderText(shutil.which("fpm") or "fpm") 107 fpm_browse = QToolButton() 108 fpm_browse.setText("…") 109 fpm_browse.clicked.connect(self._browse_fpm) 110 fpm_row.addWidget(self.fpm_edit) 111 fpm_row.addWidget(fpm_browse) 112 # A line above it: the fpm executable isn't part of a build configuration (a path on this computer). 113 divider = QFrame() 114 divider.setFrameShape(QFrame.HLine) 115 divider.setFrameShadow(QFrame.Sunken) 116 form.addRow(divider) 117 form.addRow("fpm executable:", fpm_row) 118 119 # Hovering an option (or its label) shows the fpm flag it becomes. 120 for widget, flag, text in [ 121 (self.profile_combo, "--profile PROF", 122 ("Build profile: debug or release. (default) passes none: fpm then uses no profile's flags " 123 "(no -g; Start Debugging uses debug).")), 124 (self.features_button, "--features LIST", 125 "Features to enable (the project's fpm.toml declares them). Can't be combined with a profile."), 126 (self.compiler_edit, "--compiler NAME", "Fortran compiler to use. fpm uses gfortran by default."), 127 (self.flag_edit, "--flag FFLAGS", "Extra Fortran compiler flags."), 128 (self.target_edit, "fpm run|test NAME…", 129 "Names of the programs or tests to run (wildcards allowed). Used by Run and Test."), 130 (self.extra_edit, "(added as typed)", 131 "Any other fpm options, e.g. --runner \"mpirun -np 4\" or --c-compiler gcc."), 132 (self.runner_args_edit, "-- ARGS", 133 "Arguments passed to the program or tests by Run and Test."), 134 (self.test_jobs_spin, "(not a flag)", 135 (f"Run this many tests at once (this computer has {os.cpu_count() or 1} cores). fpm builds " 136 "them; the app runs them, each with its own output. Off: fpm runs them one after another.")), 137 (self.coverage_check, "--flag --coverage --link-flag --coverage", 138 ("Build with gfortran's coverage counting (a build of its own): after Test, the Coverage panel " 139 "and the editor's gutter show which lines the tests ran.")), 140 (self.verbose_check, "--verbose", "Print more detail about what fpm is doing."), 141 (self.fpm_edit, "(not a flag)", 142 ("The fpm program to run. Leave empty to use the fpm on your PATH. Not part of a build " 143 "configuration: it's kept for this computer.")), 144 ]: 145 tip = f"<b><code>{html.escape(flag)}</code></b><br>{html.escape(text)}" 146 widget.setToolTip(tip) 147 label = form.labelForField(widget) or form.labelForField(fpm_row) 148 if label is not None and widget is not self.verbose_check: 149 label.setToolTip(tip) 150 self._watch_option_fields() 151 152 return options 153 154 def _build_commands(self): 155 """The fpm commands as actions, shown in a toolbar and a Commands menu.""" 156 self.command_icons: dict[QAction, str] = {} 157 if hasattr(self, "palette_action"): # (View → Command Palette…: its icon, redrawn with the theme's colors too) 158 self.command_icons[self.palette_action] = "palette" 159 if hasattr(self, "go_to_file_action"): # (Edit → Go To → Go to File…: the same) 160 self.command_icons[self.go_to_file_action] = "find-file" 161 162 def make(label, icon, shortcut, description, slot): 163 action = QAction(label, self) 164 if shortcut: 165 action.setShortcut(QKeySequence(shortcut)) 166 keys = QKeySequence(shortcut).toString(QKeySequence.NativeText) 167 action.setToolTip(f"{label} ({keys}) — {description}") 168 else: 169 action.setToolTip(f"{label} — {description}") 170 action.setStatusTip(description) 171 action.triggered.connect(slot) 172 self.command_icons[action] = icon 173 return action 174 175 actions = {} 176 for label, args, uses_options, refresh, icon, shortcut, description in COMMANDS: 177 if args == ["test"]: 178 slot = self.run_tests 179 elif args == ["run"]: 180 slot = self.run_programs 181 else: 182 slot = lambda _=False, a=args, o=uses_options, r=refresh: self.run_fpm(a, o, r) 183 actions[icon] = make(label, icon, shortcut, description, slot) 184 self.command_actions.append(actions[icon]) 185 self.stop_action = make("Stop", "stop", "Ctrl+.", "Stop the running fpm command", self.stop) 186 self.stop_action.setEnabled(False) 187 188 groups = [[actions["build"], actions["build-tests"], actions["run"], actions["test"], self.stop_action], 189 [actions["update"]], 190 [actions["clean"], actions["clean-all"]]] 191 toolbar = QToolBar("Commands") 192 toolbar.setObjectName("CommandsToolbar") 193 toolbar.setMovable(False) 194 toolbar.setFloatable(False) 195 toolbar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) 196 toolbar.setIconSize(QSize(22, 22)) 197 toolbar.toggleViewAction().setEnabled(False) # don't let it be hidden by accident 198 menu = self.menuBar().addMenu("&Commands") 199 for i, group in enumerate(groups): 200 if i: 201 toolbar.addSeparator() 202 menu.addSeparator() 203 toolbar.addActions(group) 204 menu.addActions(group) 205 menu.addSeparator() 206 self.check_updates_action = menu.addAction("Check for Dependency Updates…") 207 self.check_updates_action.setStatusTip( 208 "Ask each git dependency's repository for newer tags and commits") 209 self.check_updates_action.triggered.connect(self.check_dependency_updates) 210 self.addToolBar(Qt.TopToolBarArea, toolbar) 211 self.setUnifiedTitleAndToolBarOnMac(True) 212 self.command_toolbar = toolbar 213 # Right-click: the toolbar's look (View → Toolbar's choices; Qt's own menu would list the panels). 214 toolbar.setContextMenuPolicy(Qt.CustomContextMenu) 215 toolbar.customContextMenuRequested.connect( 216 lambda pos: self.toolbar_menu.popup(toolbar.mapToGlobal(pos))) 217 self._update_command_icons() 218 219 def _update_command_icons(self): 220 """(Re)draw the toolbar icons in the current text color.""" 221 text = QApplication.palette().color(QPalette.WindowText) 222 for action, name in self.command_icons.items(): 223 action.setIcon(make_icon(name, STOP_COLOR if name == "stop" else text)) 224 # The ">>" button shown when the window is too narrow for every 225 # button. Qt 6.11 draws its default arrow with a macOS symbol image, 226 # which crashes in AppKit on macOS 27, so give it our own icon. 227 extension = self.command_toolbar.findChild(QToolButton, "qt_toolbar_ext_button") 228 if extension is not None: 229 extension.setIcon(make_icon("more", text)) 230 hover = "rgba(128,128,128,45)" 231 self.command_toolbar.setStyleSheet(f""" 232 QToolBar#CommandsToolbar {{ border: none; spacing: 2px; padding: 2px 6px; }} 233 QToolBar#CommandsToolbar QToolButton {{ border: none; border-radius: 7px; 234 padding: 4px 10px; }} 235 QToolBar#CommandsToolbar QToolButton:hover {{ background: {hover}; }} 236 QToolBar#CommandsToolbar QToolButton:pressed {{ background: rgba(128,128,128,80); }} 237 QToolBar#CommandsToolbar QToolButton#qt_toolbar_ext_button {{ padding: 0px; }} 238 """) 239 240 def busy(self) -> bool: 241 """A command is running (fpm, a program, or tests run in parallel).""" 242 return self.process is not None or self.parallel_tests is not None 243 244 def _set_project_enabled(self, enabled: bool): 245 running = self.busy() 246 for action in self.command_actions: 247 action.setEnabled(enabled and not running) 248 249 def effective_compiler(self) -> tuple[str, str]: 250 """(compiler, where the choice comes from), the way fpm picks it: 251 --compiler (the Compiler field), else $FPM_FC, else gfortran.""" 252 typed = self.compiler_edit.text().strip() 253 if typed: 254 return typed, "Compiler field in Build options" 255 env = os.environ.get("FPM_FC", "").strip() 256 if env: 257 return env, "FPM_FC environment variable" 258 return "gfortran", "fpm's default" 259 260 def _browse_fpm(self): 261 filename, _ = QFileDialog.getOpenFileName(self, "Select fpm executable") 262 if filename: 263 self.fpm_edit.setText(filename) 264 265 def _fpm_executable(self) -> str: 266 return self.fpm_edit.text().strip() or shutil.which("fpm") or "fpm" 267 268 def build_arguments(self, sub: list[str], uses_options: bool, 269 program_args: bool = True, targets: list[str] | None = None) -> list[str]: 270 """fpm's arguments for ``sub``; ``targets``: these programs or tests 271 (instead of the Target field's).""" 272 args = list(sub) 273 if uses_options: 274 if self.selected_features: # fpm rejects --features together with --profile 275 args += ["--features", ",".join(self.selected_features)] 276 elif self.profile_combo.currentIndex() > 0: 277 args += ["--profile", self.profile_combo.currentText()] 278 if self.compiler_edit.text().strip(): 279 args += ["--compiler", self.compiler_edit.text().strip()] 280 coverage = self.coverage_check.isChecked() and sub[0] in ("build", "run", "test", "install") 281 flags = " ".join(f for f in (self.flag_edit.text().strip(), cov.FLAG if coverage else "") if f) 282 if flags: 283 args += ["--flag", flags] 284 if self.verbose_check.isChecked(): 285 args.append("--verbose") 286 names = split_args(self.target_edit.text()) if targets is None else targets 287 if sub[0] in ("run", "test") and names: 288 args += names 289 extra = split_args(self.extra_edit.text()) 290 if sub[0] in ("test", "run"): 291 extra, _ = split_runner(extra) # (the user's --runner: run inside ours, see _start_marked) 292 if coverage: # (linked with it too: the user's own --link-flag gets it added) 293 extra = with_link_flag(extra, cov.FLAG) 294 args += extra 295 if program_args and sub[0] in ("run", "test") and self.runner_args_edit.text().strip(): 296 args += ["--"] + split_args(self.runner_args_edit.text()) 297 elif sub[0] == "update" and self.verbose_check.isChecked(): 298 args.append("--verbose") 299 return args 300 301 def _build_features_menu(self): 302 """The Features button's menu in Build options (filled from the project's manifest).""" 303 self.features_menu = StayOpenMenu(self) 304 self.features_button.setMenu(self.features_menu) 305 self.project_features: dict[str, list[str]] = {} 306 self.selected_features: list[str] = [] 307 self._wanted_profile = "(default)" 308 self._rebuild_features_menu() 309 310 def _load_features_and_profiles(self): 311 """Read [features] and [profiles] from the project's fpm.toml.""" 312 try: 313 data = m.load_toml(self.manifest_path) 314 except (OSError, tomllib.TOMLDecodeError): 315 data = {} 316 self.project_features = m.project_features(data) 317 profiles = m.project_profiles(data) 318 319 # Profile list: built-in ones plus the project's feature profiles. 320 current = self.profile_combo.currentText() if self.profile_combo.count() else self._wanted_profile 321 self.profile_combo.blockSignals(True) 322 self.profile_combo.clear() 323 self.profile_combo.addItems(BUILTIN_PROFILES) 324 for name, features in profiles.items(): 325 if name not in BUILTIN_PROFILES: 326 self.profile_combo.addItem(name) 327 index = self.profile_combo.count() - 1 328 self.profile_combo.setItemData( 329 index, f"Feature profile: {', '.join(features) or '(no features)'}", Qt.ToolTipRole) 330 if self.profile_combo.findText(current) >= 0: 331 self.profile_combo.setCurrentText(current) 332 self.profile_combo.blockSignals(False) 333 334 if not self.config_shown(): # (a configuration's own features stay; see vibefpmgui.window.configs) 335 saved = self._saved_free_options().features 336 self.selected_features = [f for f in saved if f in self.project_features] 337 self._rebuild_features_menu() 338 339 def _rebuild_features_menu(self): 340 menu = self.features_menu 341 menu.clear() 342 if not self.project_features: 343 none = menu.addAction("No features defined in fpm.toml") 344 none.setEnabled(False) 345 self.feature_actions: dict[str, QAction] = {} 346 for name, props in self.project_features.items(): 347 action = menu.addAction(name) 348 action.setCheckable(True) 349 action.setChecked(name in self.selected_features) 350 self.feature_actions[name] = action 351 shown = ", ".join(props[:8]) + (" …" if len(props) > 8 else "") 352 action.setToolTip(f"Sets: {shown}" if props else name) 353 action.setStatusTip(f"Feature {name} — sets {shown}" if props else f"Feature {name}") 354 action.toggled.connect(lambda on, n=name: self._toggle_feature(n, on)) 355 menu.setToolTipsVisible(True) 356 self.clear_features_action = None 357 if self.project_features: 358 menu.addSeparator() 359 self.clear_features_action = menu.addAction("Clear all features") 360 self.clear_features_action.triggered.connect(self._clear_features) 361 self._update_features_ui() 362 363 def _toggle_feature(self, name: str, on: bool): 364 if on and name not in self.selected_features: 365 # Keep the order they're declared in fpm.toml. 366 chosen = set(self.selected_features) | {name} 367 self.selected_features = [f for f in self.project_features if f in chosen] 368 elif not on and name in self.selected_features: 369 self.selected_features.remove(name) 370 self._save_features() 371 # Only update the existing actions: rebuilding the menu here would 372 # delete the action whose signal is being handled. 373 self._update_features_ui() 374 375 def _clear_features(self): 376 self.selected_features = [] 377 self._save_features() 378 self._update_features_ui() 379 380 def _save_features(self): 381 """New configuration's features (not a saved configuration's, being edited).""" 382 if self.manifest_path and not self.config_shown(): 383 self._keep_free_options(self.current_options()) 384 385 def _update_features_ui(self): 386 """Show the selection, and disable Profile while features are on.""" 387 locked = self.options_locked() # (a saved configuration's: read-only) 388 for name, action in getattr(self, "feature_actions", {}).items(): 389 if action.isChecked() != (name in self.selected_features): 390 action.blockSignals(True) 391 action.setChecked(name in self.selected_features) 392 action.blockSignals(False) 393 action.setEnabled(not locked) 394 if getattr(self, "clear_features_action", None) is not None: 395 self.clear_features_action.setEnabled(bool(self.selected_features) and not locked) 396 if self.selected_features: 397 shown = ", ".join(self.selected_features) 398 else: 399 shown = "none" if self.project_features else "none defined in fpm.toml" 400 self.features_button.set_full_text(shown) 401 self.features_button.setEnabled(bool(self.project_features) and not locked) 402 self.features_button.setToolTip(f"<b><code>--features LIST</code></b><br>{html.escape(shown)}<br>" 403 "Features to enable (the project's fpm.toml declares them). Can't be " 404 "combined with a profile.") 405 using = bool(self.selected_features) 406 self.profile_combo.setEnabled(not using and not locked) 407 self.profile_combo.setToolTip( 408 "<b><code>--profile PROF</code></b><br>" 409 + ("Disabled while features are selected: fpm can't combine --profile and --features." 410 if using else "Build profile: debug, release, or a feature profile from fpm.toml.")) 411 self._options_changed() # (the features are options too) 412 413 def _build_description(self) -> str: 414 compiler, _source = self.effective_compiler() 415 if self.selected_features: 416 return f"features: {', '.join(self.selected_features)} · {compiler}" 417 profile = self.profile_combo.currentText() if self.profile_combo.currentIndex() > 0 else "debug" 418 return f"{profile} · {compiler}" 419 420 def run_fpm(self, sub: list[str], uses_options: bool, refresh: bool, 421 phase: str | None = None, args: list[str] | None = None, 422 env_extra: dict[str, str] | None = None): 423 if not self.manifest_path or self.busy(): 424 return 425 if args is None: 426 try: 427 args = self.build_arguments(sub, uses_options) 428 except ValueError as exc: # unbalanced quotes in a text field 429 QMessageBox.warning(self, "Invalid arguments", str(exc)) 430 return 431 program = self._fpm_executable() 432 self.process = QProcess(self) 433 self.process.setWorkingDirectory(str(self.manifest_path.parent)) 434 self.process.setProcessChannelMode(QProcess.MergedChannels) 435 env = QProcessEnvironment.systemEnvironment() 436 env.insert("NO_COLOR", "1") 437 for key, value in (env_extra or {}).items(): 438 env.insert(key, value) 439 self.process.setProcessEnvironment(env) 440 self.process.readyReadStandardOutput.connect(self._read_output) 441 self.process.finished.connect(self._finished) 442 self.process.errorOccurred.connect(self._process_error) 443 self._refresh_after = refresh 444 self._partial = "" 445 self._phase = phase 446 self._captured = [] 447 self._held_blank = False 448 # Commands that compile get the per-file build grid. "fpm test --list" 449 # doesn't build; "fpm test" builds first, so its output goes to the 450 # build grid until the first test starts (see _test_line). 451 self._monitoring = sub[0] in ("build", "run", "test") and phase != "list" 452 self._test_building = phase in ("marked", "collect", "debug") # fpm builds before running 453 self._process_label = "fpm" 454 self._revs_before = (dep_updates.read_cache_revs(self.manifest_path.parent) 455 if sub[0] == "update" else None) 456 # Note which build folders exist now, to record which ones this 457 # build writes (see _record_build_folders). 458 self._folder_snapshot = (bo.snapshot(self.manifest_path.parent) 459 if self._monitoring else None) 460 if self._monitoring: 461 self.build_monitor.start(self.graph, self.manifest_path.parent, self._build_description()) 462 if phase is None: 463 self.show_all_output() # any file shown on its own is gone 464 # Show the build's progress (Test switches to its tab once the 465 # tests themselves start). 466 self.show_panel(self.build_monitor) 467 468 self._append(f"\n$ cd {quote_arg(str(self.manifest_path.parent))}\n" 469 f"$ {join_args([program] + args)}\n", bold=True) 470 self.stop_action.setEnabled(True) 471 self._set_project_enabled(True) 472 self.statusBar().showMessage(f"Running fpm {' '.join(sub)}…") 473 self._esc_tail = "" 474 self.console_writer.reset() 475 self._begin_output() 476 self.process.start(program, args) 477 478 def stop(self): 479 if self.process is not None: 480 # (Remembered: the process may still end normally. Its children go first, 481 # and fpm, seeing its test program killed, can exit with its code before 482 # it's killed too: the run is the one stopped, not a failure.) 483 self._stop_requested = True 484 kill_process_tree(self.process) 485 if self.parallel_tests is not None: 486 self.parallel_tests.stop() 487 488 def _stop_and_wait(self, msecs: int): 489 if self.process is not None: 490 kill_process_tree(self.process) 491 self.process.waitForFinished(msecs) 492 if self.parallel_tests is not None: 493 self.parallel_tests.stop() 494 self.parallel_tests.wait(msecs) 495 496 def _read_output(self, final: bool = False): 497 """New output from the running command; ``final``: it has finished 498 (bytes of an unfinished character are shown as "�").""" 499 if self.process is None: 500 return 501 data = self._decoder.decode(bytes(self.process.readAllStandardOutput()), final=final) 502 if not data: 503 return 504 data, self._esc_tail = ansi.split_partial(self._esc_tail + data) 505 # fpm redraws progress lines with \r; keep only the final state of each line. 506 data = data.replace("\r\n", "\n") 507 data = "\n".join(line.rsplit("\r", 1)[-1] for line in data.split("\n")) 508 if self._phase in ("marked", "direct", "collect", "debug"): 509 # Line-buffered so the test markers can be taken out of the output. 510 lines = (self._partial + data).split("\n") 511 self._partial = lines.pop() 512 handle = self._line_handler() 513 self._append("".join(handle(line) for line in lines), colored=True) 514 return 515 self._append(data, colored=True) 516 plain = ansi.strip(data) # color codes would get in the way of parsing 517 if self._phase in ("list", "valgrind"): 518 self._captured.append(plain) 519 if self._monitoring: 520 # Output can arrive mid-line; only parse complete lines. 521 lines = (self._partial + plain).split("\n") 522 self._partial = lines.pop() 523 for line in lines: 524 self.build_monitor.feed_line(line) 525 526 def _line_handler(self): 527 """What reads each line of a run's output, per phase.""" 528 return {"collect": self._collect_line, "debug": self._debug_line}.get(self._phase, self._test_line) 529 530 def _build_part_done(self): 531 """The build part of "fpm test" is over: show the tests.""" 532 self._test_building = False 533 if self._monitoring: 534 self.build_monitor.finish(0) 535 self._update_tree_marks() 536 self._monitoring = False 537 self._marker_monitor().build_finished() 538 self.show_panel(self._marker_monitor()) 539 540 def _flush_monitor(self, exit_code: int, stopped: bool): 541 if self._phase in ("marked", "direct", "collect", "debug"): 542 if self._partial: 543 handle = self._line_handler() 544 self._append(handle(self._partial), colored=True) 545 if self._held_blank: 546 self._append("\n") 547 self._partial = "" 548 self._held_blank = False 549 if self._monitoring: 550 if self._partial: 551 self.build_monitor.feed_line(self._partial) 552 self._partial = "" 553 self.build_monitor.finish(exit_code, stopped) 554 self._update_tree_marks() 555 self._monitoring = False 556 557 def _finished(self, code: int, status): 558 self._read_output(final=True) 559 crashed = status == QProcess.CrashExit or self._stop_requested # (stopped) 560 self._flush_monitor(code, crashed) 561 self._end_output() 562 if crashed: 563 self._append("\n[process was stopped]\n", bold=True) 564 else: 565 self._append(f"\n[{self._process_label} exited with code {code}]\n", bold=True) 566 self._record_build_folders() 567 phase, self._phase = self._phase, None 568 self._cleanup_process() 569 self.statusBar().showMessage("Stopped" if crashed else 570 ("Finished successfully" if code == 0 else f"Failed (exit code {code})")) 571 self._report_dependency_changes() # after the generic message, so it shows 572 if phase == "list": 573 self._after_listing(code, crashed) 574 elif phase in ("marked", "direct"): 575 self._after_marked(code, crashed) 576 elif phase == "collect": 577 self._after_collect(code, crashed) 578 elif phase == "debug": 579 self._after_debug_build(code, crashed) 580 elif phase == "valgrind": 581 self._after_valgrind(code, crashed) 582 583 def _process_error(self, error): 584 if error == QProcess.FailedToStart: 585 if getattr(self, "_process_label", "fpm") == "fpm": 586 self._append(f"\nCould not start '{self._fpm_executable()}'. " 587 "Set the fpm executable in Build options.\n", bold=True) 588 else: 589 self._append(f"\nCould not start '{self._process_label}'.\n", bold=True) 590 self.statusBar().showMessage(f"{getattr(self, '_process_label', 'fpm')} failed to start") 591 self._flush_monitor(-1, True) 592 self._end_output() 593 if self._phase is not None: 594 self._marker_monitor().finish(-1, stopped=True) 595 self._phase = None 596 self._cleanup_process() 597 598 def _cleanup_process(self): 599 if self.process is None: 600 return 601 self.process.deleteLater() 602 self.process = None 603 self._stop_requested = False 604 self.stop_action.setEnabled(False) 605 self._set_project_enabled(self.graph is not None) 606 if self._refresh_after: 607 self.reload()
Running fpm (a part of vibefpmgui.main_window.MainWindow).
def
busy(self) -> bool:
240 def busy(self) -> bool: 241 """A command is running (fpm, a program, or tests run in parallel).""" 242 return self.process is not None or self.parallel_tests is not None
A command is running (fpm, a program, or tests run in parallel).
def
effective_compiler(self) -> tuple[str, str]:
249 def effective_compiler(self) -> tuple[str, str]: 250 """(compiler, where the choice comes from), the way fpm picks it: 251 --compiler (the Compiler field), else $FPM_FC, else gfortran.""" 252 typed = self.compiler_edit.text().strip() 253 if typed: 254 return typed, "Compiler field in Build options" 255 env = os.environ.get("FPM_FC", "").strip() 256 if env: 257 return env, "FPM_FC environment variable" 258 return "gfortran", "fpm's default"
(compiler, where the choice comes from), the way fpm picks it: --compiler (the Compiler field), else $FPM_FC, else gfortran.
def
build_arguments( self, sub: list[str], uses_options: bool, program_args: bool = True, targets: list[str] | None = None) -> list[str]:
268 def build_arguments(self, sub: list[str], uses_options: bool, 269 program_args: bool = True, targets: list[str] | None = None) -> list[str]: 270 """fpm's arguments for ``sub``; ``targets``: these programs or tests 271 (instead of the Target field's).""" 272 args = list(sub) 273 if uses_options: 274 if self.selected_features: # fpm rejects --features together with --profile 275 args += ["--features", ",".join(self.selected_features)] 276 elif self.profile_combo.currentIndex() > 0: 277 args += ["--profile", self.profile_combo.currentText()] 278 if self.compiler_edit.text().strip(): 279 args += ["--compiler", self.compiler_edit.text().strip()] 280 coverage = self.coverage_check.isChecked() and sub[0] in ("build", "run", "test", "install") 281 flags = " ".join(f for f in (self.flag_edit.text().strip(), cov.FLAG if coverage else "") if f) 282 if flags: 283 args += ["--flag", flags] 284 if self.verbose_check.isChecked(): 285 args.append("--verbose") 286 names = split_args(self.target_edit.text()) if targets is None else targets 287 if sub[0] in ("run", "test") and names: 288 args += names 289 extra = split_args(self.extra_edit.text()) 290 if sub[0] in ("test", "run"): 291 extra, _ = split_runner(extra) # (the user's --runner: run inside ours, see _start_marked) 292 if coverage: # (linked with it too: the user's own --link-flag gets it added) 293 extra = with_link_flag(extra, cov.FLAG) 294 args += extra 295 if program_args and sub[0] in ("run", "test") and self.runner_args_edit.text().strip(): 296 args += ["--"] + split_args(self.runner_args_edit.text()) 297 elif sub[0] == "update" and self.verbose_check.isChecked(): 298 args.append("--verbose") 299 return args
fpm's arguments for sub; targets: these programs or tests
(instead of the Target field's).
def
run_fpm( self, sub: list[str], uses_options: bool, refresh: bool, phase: str | None = None, args: list[str] | None = None, env_extra: dict[str, str] | None = None):
420 def run_fpm(self, sub: list[str], uses_options: bool, refresh: bool, 421 phase: str | None = None, args: list[str] | None = None, 422 env_extra: dict[str, str] | None = None): 423 if not self.manifest_path or self.busy(): 424 return 425 if args is None: 426 try: 427 args = self.build_arguments(sub, uses_options) 428 except ValueError as exc: # unbalanced quotes in a text field 429 QMessageBox.warning(self, "Invalid arguments", str(exc)) 430 return 431 program = self._fpm_executable() 432 self.process = QProcess(self) 433 self.process.setWorkingDirectory(str(self.manifest_path.parent)) 434 self.process.setProcessChannelMode(QProcess.MergedChannels) 435 env = QProcessEnvironment.systemEnvironment() 436 env.insert("NO_COLOR", "1") 437 for key, value in (env_extra or {}).items(): 438 env.insert(key, value) 439 self.process.setProcessEnvironment(env) 440 self.process.readyReadStandardOutput.connect(self._read_output) 441 self.process.finished.connect(self._finished) 442 self.process.errorOccurred.connect(self._process_error) 443 self._refresh_after = refresh 444 self._partial = "" 445 self._phase = phase 446 self._captured = [] 447 self._held_blank = False 448 # Commands that compile get the per-file build grid. "fpm test --list" 449 # doesn't build; "fpm test" builds first, so its output goes to the 450 # build grid until the first test starts (see _test_line). 451 self._monitoring = sub[0] in ("build", "run", "test") and phase != "list" 452 self._test_building = phase in ("marked", "collect", "debug") # fpm builds before running 453 self._process_label = "fpm" 454 self._revs_before = (dep_updates.read_cache_revs(self.manifest_path.parent) 455 if sub[0] == "update" else None) 456 # Note which build folders exist now, to record which ones this 457 # build writes (see _record_build_folders). 458 self._folder_snapshot = (bo.snapshot(self.manifest_path.parent) 459 if self._monitoring else None) 460 if self._monitoring: 461 self.build_monitor.start(self.graph, self.manifest_path.parent, self._build_description()) 462 if phase is None: 463 self.show_all_output() # any file shown on its own is gone 464 # Show the build's progress (Test switches to its tab once the 465 # tests themselves start). 466 self.show_panel(self.build_monitor) 467 468 self._append(f"\n$ cd {quote_arg(str(self.manifest_path.parent))}\n" 469 f"$ {join_args([program] + args)}\n", bold=True) 470 self.stop_action.setEnabled(True) 471 self._set_project_enabled(True) 472 self.statusBar().showMessage(f"Running fpm {' '.join(sub)}…") 473 self._esc_tail = "" 474 self.console_writer.reset() 475 self._begin_output() 476 self.process.start(program, args)
def
stop(self):
478 def stop(self): 479 if self.process is not None: 480 # (Remembered: the process may still end normally. Its children go first, 481 # and fpm, seeing its test program killed, can exit with its code before 482 # it's killed too: the run is the one stopped, not a failure.) 483 self._stop_requested = True 484 kill_process_tree(self.process) 485 if self.parallel_tests is not None: 486 self.parallel_tests.stop()