ui.pipeline_execution_page
1import os 2import json 3import re 4import sys 5 6from PyQt6.QtGui import QFont 7from PyQt6.QtWidgets import ( 8 QWidget, QVBoxLayout, QLabel, QPushButton, 9 QTextEdit, QFrame, QHBoxLayout, QScrollArea, QGridLayout, 10 QMessageBox 11) 12from PyQt6.QtCore import Qt, QTimer, QProcess, QCoreApplication 13 14from components.circular_progress_bar import CircularProgress 15from components.folder_card import FolderCard 16from utils import get_bin_path 17from page import Page 18from logger import get_logger 19 20log = get_logger() 21 22 23class PipelineExecutionPage(Page): 24 """ 25 GUI page that manages the **execution of a processing pipeline**. 26 27 This class runs an external pipeline process, tracks its progress, 28 captures log output in real time, and provides user controls 29 (e.g., stop execution, monitor folders, show progress). 30 31 It extends :class:`Page` and is typically part of a multi-page 32 PyQt-based GUI workflow. 33 """ 34 35 # ───────────────────────────────────────────── 36 # INITIALIZATION 37 # ───────────────────────────────────────────── 38 def __init__(self, context=None, previous_page=None): 39 """ 40 Initialize the pipeline execution page. 41 42 Args: 43 context (dict): Application context (e.g., workspace path, signals). 44 previous_page (Page): Reference to the previous page in the workflow. 45 """ 46 super().__init__() 47 self.context = context 48 self.workspace_path = context["workspace_path"] 49 self.previous_page = previous_page 50 self.next_page = None 51 52 # Pipeline state 53 self.pipeline_process = None 54 self.pipeline_completed = False 55 self.pipeline_error = None 56 57 # Load the most recent pipeline configuration file 58 self.config_path = self._find_latest_config() 59 60 # Extract pipeline ID and output directory 61 config_filename = os.path.basename(self.config_path) 62 try: 63 config_id = config_filename.split('_')[0] 64 self.pipeline_output_dir = os.path.join( 65 self.workspace_path, "pipeline", f"{config_id}_output" 66 ) 67 os.makedirs(self.pipeline_output_dir, exist_ok=True) 68 except (IndexError, ValueError): 69 log.debug("Failed to create pipeline_output_dir") 70 self.pipeline_output_dir = os.path.join(self.workspace_path, "pipeline") 71 72 # Resolve path to pipeline executable 73 try: 74 self.pipeline_bin_path = get_bin_path("pipeline_runner") 75 except FileNotFoundError: 76 log.error("Pipeline runner binary not found") 77 raise RuntimeError("Pipeline runner executable missing.") 78 79 log.debug(f"Pipeline binary path: {self.pipeline_bin_path}") 80 81 self.folder_cards = {} 82 83 # Build UI 84 self._setup_ui() 85 self._translate_ui() 86 87 # Connect translation handler if needed 88 if context and "language_changed" in context: 89 context["language_changed"].connect(self._translate_ui) 90 91 # ───────────────────────────────────────────── 92 # CONFIGURATION FILE HANDLING 93 # ───────────────────────────────────────────── 94 def _find_latest_config(self): 95 """Find the config file with the highest numeric ID in the pipeline directory.""" 96 import glob 97 pipeline_dir = os.path.join(self.workspace_path, "pipeline") 98 99 if not os.path.exists(pipeline_dir): 100 return os.path.join(pipeline_dir, "pipeline_config.json") 101 102 config_pattern = os.path.join(pipeline_dir, "*_config.json") 103 config_files = glob.glob(config_pattern) 104 105 if not config_files: 106 return os.path.join(pipeline_dir, "pipeline_config.json") 107 108 max_id = 0 109 latest_config = None 110 111 # Parse filenames to detect the latest config ID 112 for config_file in config_files: 113 filename = os.path.basename(config_file) 114 try: 115 config_id = int(filename.split('_')[0]) 116 if config_id > max_id: 117 max_id = config_id 118 latest_config = config_file 119 except (ValueError, IndexError): 120 continue 121 122 return latest_config or os.path.join(pipeline_dir, "pipeline_config.json") 123 124 # ───────────────────────────────────────────── 125 # UI SETUP 126 # ───────────────────────────────────────────── 127 def _setup_ui(self): 128 """Constructs the page layout and visual elements.""" 129 main_layout = QVBoxLayout(self) 130 131 # Header 132 self.header = QLabel("Pipeline Execution") 133 self.header.setFont(QFont("Arial", 18, QFont.Weight.Bold)) 134 self.header.setAlignment(Qt.AlignmentFlag.AlignCenter) 135 main_layout.addWidget(self.header) 136 137 # Subheader: current operation label 138 self.current_operation = QLabel( 139 QCoreApplication.translate("PipelineExecutionPage", "Preparing to start...") 140 ) 141 self.current_operation.setStyleSheet(""" 142 font-size: 13px; 143 color: #7f8c8d; 144 margin-top: 8px; 145 """) 146 self.current_operation.setAlignment(Qt.AlignmentFlag.AlignCenter) 147 main_layout.addWidget(self.current_operation) 148 149 # Content grid (progress bar + folders + logs) 150 content_layout = QGridLayout() 151 main_layout.addLayout(content_layout, stretch=1) 152 153 # Left: progress indicator 154 left_layout = QVBoxLayout() 155 self.progress_bar = CircularProgress() 156 left_layout.addWidget(self.progress_bar, alignment=Qt.AlignmentFlag.AlignCenter) 157 content_layout.addLayout(left_layout, 0, 0) 158 159 # Right: folder cards (output directories) 160 scroll_area = QScrollArea() 161 scroll_area.setWidgetResizable(True) 162 scroll_content = QWidget() 163 self.scroll_layout = QVBoxLayout(scroll_content) 164 scroll_area.setWidget(scroll_content) 165 content_layout.addWidget(scroll_area, 0, 1) 166 167 # Grid layout proportions 168 content_layout.setColumnStretch(0, 1) 169 content_layout.setColumnStretch(1, 2) 170 content_layout.setRowStretch(0, 1) 171 content_layout.setRowStretch(1, 0) 172 content_layout.setRowStretch(2, 1) 173 174 # Log section 175 self.log_label = QLabel("Execution Log:") 176 self.log_label.setStyleSheet(""" 177 font-size: 16px; 178 font-weight: bold; 179 color: #2c3e50; 180 margin-top: 15px; 181 margin-bottom: 5px; 182 """) 183 content_layout.addWidget(self.log_label, 1, 0, 1, 2) 184 185 # Log text box 186 self.log_text = QTextEdit() 187 self.log_text.setStyleSheet(""" 188 QTextEdit { 189 background-color: #2c3e50; 190 color: #ecf0f1; 191 font-family: 'Courier New', monospace; 192 font-size: 11px; 193 border: 2px solid #34495e; 194 border-radius: 8px; 195 padding: 8px; 196 } 197 """) 198 self.log_text.setMaximumHeight(200) 199 self.log_text.setReadOnly(True) 200 content_layout.addWidget(self.log_text, 2, 0, 1, 2) 201 202 # Stop button 203 button_frame = QFrame() 204 button_layout = QHBoxLayout(button_frame) 205 button_layout.setContentsMargins(0, 15, 0, 0) 206 207 self.stop_button = QPushButton("Stop Pipeline") 208 self.stop_button.setStyleSheet(""" 209 QPushButton { 210 font-size: 14px; 211 font-weight: bold; 212 background-color: #e74c3c; 213 color: white; 214 border-radius: 8px; 215 padding: 10px 20px; 216 min-width: 140px; 217 } 218 QPushButton:hover { background-color: #c0392b; } 219 QPushButton:disabled { 220 background-color: #bdc3c7; 221 color: #7f8c8d; 222 } 223 """) 224 self.stop_button.clicked.connect(self._on_stop_clicked) 225 button_layout.addStretch() 226 button_layout.addWidget(self.stop_button) 227 button_layout.addStretch() 228 229 main_layout.addWidget(button_frame) 230 231 def _setup_folder_cards(self): 232 # Load folder cards dynamically from configuration 233 self.watch_dirs = self.get_sub_list(self.config_path) 234 self.watch_dirs = [os.path.join(self.pipeline_output_dir, d) for d in self.watch_dirs] 235 self.dir_completed = 0 236 for d in self.watch_dirs: 237 card = FolderCard(self.context, d) 238 card.open_folder_requested.connect(self.context["tree_view"]._open_in_explorer) 239 self.scroll_layout.addWidget(card) 240 self.folder_cards[d] = card 241 242 # ───────────────────────────────────────────── 243 # PIPELINE PROCESS HANDLING 244 # ───────────────────────────────────────────── 245 def _start_pipeline(self): 246 """Start the external pipeline process and monitor its output.""" 247 if self.pipeline_process: 248 return # Already running 249 250 self.pipeline_process = QProcess() 251 self.pipeline_process.finished.connect(self._on_process_finished) 252 self.pipeline_process.errorOccurred.connect(self._on_process_error) 253 self.pipeline_process.readyReadStandardOutput.connect(self._on_stdout_ready) 254 self.pipeline_process.readyReadStandardError.connect(self._on_stderr_ready) 255 256 self.progress_bar.setValue(0) 257 self.stop_button.setEnabled(True) 258 self._log_message(QCoreApplication.translate("PipelineExecutionPage", "Starting pipeline execution...")) 259 260 cmd = [ 261 self.pipeline_bin_path, 262 "--config", self.config_path, 263 "--work-dir", self.workspace_path, 264 "--out-dir", self.pipeline_output_dir 265 ] 266 log.debug(f"{self.pipeline_bin_path} --config {self.config_path} --work-dir {self.workspace_path} --out-dir {self.pipeline_output_dir}") 267 268 self.pipeline_process.start(cmd[0], cmd[1:]) 269 270 if not self.pipeline_process.waitForStarted(3000): 271 self._log_message( 272 QCoreApplication.translate("PipelineExecutionPage", "ERROR: Failed to start pipeline process")) 273 self._on_pipeline_error( 274 QCoreApplication.translate("PipelineExecutionPage", "Failed to start pipeline process")) 275 276 # ───────────────────────────────────────────── 277 # PROCESS SIGNAL HANDLERS 278 # ───────────────────────────────────────────── 279 def _on_process_finished(self, exit_code, exit_status): 280 """Called when the process terminates.""" 281 if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit: 282 self._on_pipeline_finished() 283 else: 284 self._on_pipeline_error( 285 QCoreApplication.translate( 286 "PipelineExecutionPage", 287 "Process exited with code {exit_code}" 288 ).format(exit_code=exit_code) 289 ) 290 291 def _on_process_error(self, error): 292 """Called when an error occurs in the process.""" 293 error_messages = { 294 QProcess.ProcessError.FailedToStart: QCoreApplication.translate("PipelineExecutionPage", 295 "Failed to start process"), 296 QProcess.ProcessError.Crashed: QCoreApplication.translate("PipelineExecutionPage", "Process crashed"), 297 QProcess.ProcessError.Timedout: QCoreApplication.translate("PipelineExecutionPage", "Process timed out"), 298 QProcess.ProcessError.WriteError: QCoreApplication.translate("PipelineExecutionPage", "Write error"), 299 QProcess.ProcessError.ReadError: QCoreApplication.translate("PipelineExecutionPage", "Read error"), 300 QProcess.ProcessError.UnknownError: QCoreApplication.translate("PipelineExecutionPage", "Unknown error") 301 } 302 error_msg = error_messages.get( 303 error, 304 QCoreApplication.translate("PipelineExecutionPage", 305 "Process error: {error}").format(error=error) 306 ) 307 self._on_pipeline_error(error_msg) 308 309 # ───────────────────────────────────────────── 310 # STDOUT / STDERR MONITORING 311 # ───────────────────────────────────────────── 312 def _on_stdout_ready(self): 313 """Called when data is available on stdout.""" 314 if self.pipeline_process: 315 data = self.pipeline_process.readAllStandardOutput() 316 output = data.data().decode('utf-8').strip() 317 318 for line in output.split('\n'): 319 if line.strip(): 320 self._process_pipeline_output(line.strip()) 321 322 def _on_stderr_ready(self): 323 """Called when data is available on stderr.""" 324 if self.pipeline_process: 325 data = self.pipeline_process.readAllStandardError() 326 error_output = data.data().decode('utf-8').strip() 327 328 for line in error_output.split('\n'): 329 if line.strip(): 330 self._log_message(f"STDERR: {line.strip()}") 331 # ───────────────────────────────────────────── 332 # OUTPUT INTERPRETATION 333 # ───────────────────────────────────────────── 334 def _process_pipeline_output(self, line): 335 """Process the pipeline output logs""" 336 if line.startswith("LOG: "): 337 message = line[5:] 338 self._log_message(message) 339 self._update_current_operation(message) 340 elif line.startswith("ERROR: "): 341 error_msg = line[7:] 342 self._log_message( 343 QCoreApplication.translate("PipelineExecutionPage", "ERROR: {error}").format(error=error_msg)) 344 elif line.startswith("PROGRESS: "): 345 progress_info = line[10:] 346 self._update_progress(progress_info) 347 elif line.startswith("PATIENT: "): 348 patient_info = line[9:] 349 self._log_message( 350 QCoreApplication.translate("PipelineExecutionPage", "Pipeline finished for patient: {message}").format(message=patient_info)) 351 self._update_progress(patient_info) 352 elif line.startswith("FINISHED: "): 353 message = line[10:] 354 self._log_message(QCoreApplication.translate("PipelineExecutionPage", "FINISHED for: {message}").format(message=message)) 355 else: 356 self._log_message(line) 357 358 # ───────────────────────────────────────────── 359 # STATE MANAGEMENT 360 # ───────────────────────────────────────────── 361 def _on_pipeline_finished(self): 362 """Called when the pipeline finishes successfully.""" 363 self.pipeline_completed = True 364 self.current_operation.setText(QCoreApplication.translate("PipelineExecutionPage", "All patients processed successfully.")) 365 self.progress_bar.setValue(100) 366 self.stop_button.setEnabled(False) 367 self._log_message( 368 QCoreApplication.translate("PipelineExecutionPage", "Pipeline execution completed successfully!")) 369 self._log_message( 370 QCoreApplication.translate("PipelineExecutionPage", "Results saved in: {pipeline_output_dir}").format( 371 pipeline_output_dir=self.pipeline_output_dir)) 372 self.pipeline_process = None 373 for card in self.folder_cards.values(): 374 card.set_finished_state() 375 self.context["update_main_buttons"]() 376 377 def _on_pipeline_error(self, error_message): 378 """Called when the pipeline terminates due to an error.""" 379 self.pipeline_error = error_message 380 self.current_operation.setText( 381 QCoreApplication.translate("PipelineExecutionPage", "An error occurred during execution.")) 382 self.current_operation.setStyleSheet(""" 383 color: #c0392b; 384 font-weight: bold; 385 """) 386 self.progress_bar.setColor("#c0392b") 387 self.stop_button.setEnabled(False) 388 self._log_message(QCoreApplication.translate("PipelineExecutionPage", "ERROR: {error_message}").format(error_message=error_message)) 389 self.pipeline_process = None 390 self.context["update_main_buttons"]() 391 392 # ───────────────────────────────────────────── 393 # PROGRESS AND UI UPDATES 394 # ───────────────────────────────────────────── 395 def _update_progress(self, progress_info): 396 """ 397 Update the circular progress bar based on progress information. 398 399 Expected format from pipeline: `"PROGRESS: X/Y"` 400 401 Args: 402 progress_info (str): Progress data string (e.g., `"3/10"`). 403 """ 404 try: 405 if '/' in progress_info: 406 current, total = map(int, progress_info.split('/')) 407 percentage = int((current / total) * 100) 408 self.progress_bar.setValue(percentage) 409 410 self.check_new_files() # Update folder views 411 elif "sub" in progress_info: 412 match = re.search(r"(sub-[a-zA-Z0-9_]+)", progress_info) 413 if match: 414 sub_id = match.group(1) 415 log.debug(f"Detected progress completion for: {sub_id}") 416 417 for folder_path, card in self.folder_cards.items(): 418 folder_name = os.path.basename(folder_path) 419 if sub_id in folder_name: 420 log.debug(f"Setting finished state for card: {folder_name}") 421 card.set_finished_state() 422 break 423 else: 424 log.warning(f"No matching FolderCard found for {sub_id}") 425 else: 426 log.warning(f"Could not extract sub-id from progress info: {progress_info}") 427 428 except ValueError: 429 log.warning("Failed to parse progress info") 430 431 def _update_current_operation(self, message): 432 """ 433 Update the current operation label based on pipeline output message. 434 435 Args: 436 message (str): Message received from the pipeline log. 437 """ 438 if "Starting pipeline" in message: 439 self.current_operation.setText( 440 QCoreApplication.translate("PipelineExecutionPage", "Initializing pipeline...") 441 ) 442 elif "Processing patient" in message or "sub-" in message: 443 patient_id = self._extract_patient_id_from_log(message) 444 if patient_id: 445 self.current_operation.setText( 446 QCoreApplication.translate( 447 "PipelineExecutionPage", 448 "Processing patient: {patient_id}" 449 ).format(patient_id=patient_id) 450 ) 451 else: 452 self.current_operation.setText( 453 QCoreApplication.translate("PipelineExecutionPage", "Processing patient data...") 454 ) 455 elif "analysis" in message.lower(): 456 self.current_operation.setText( 457 QCoreApplication.translate("PipelineExecutionPage", "Performing statistical analysis...") 458 ) 459 elif "saving" in message.lower() or "csv" in message.lower(): 460 self.current_operation.setText( 461 QCoreApplication.translate("PipelineExecutionPage", "Saving results...") 462 ) 463 464 def _extract_patient_id_from_log(self, message): 465 """ 466 Extract a patient ID (e.g., 'sub-001') from a log message. 467 468 Args: 469 message (str): Log message. 470 471 Returns: 472 str | None: The patient ID if extracted, otherwise None. 473 """ 474 import re 475 match = re.search(r'sub-(\w+)', message) 476 return f"sub-{match.group(1)}" if match else None 477 478 # ───────────────────────────────────────────── 479 # LOGGING AND FILE MONITORING 480 # ───────────────────────────────────────────── 481 def _log_message(self, message): 482 """ 483 Append a message to the on-screen log with a timestamp. 484 485 Args: 486 message (str): The message text to append. 487 """ 488 from datetime import datetime 489 timestamp = datetime.now().strftime("%H:%M:%S") 490 formatted_message = f"[{timestamp}] {message}" 491 self.log_text.append(formatted_message) 492 493 # Auto-scroll to the newest entry 494 scrollbar = self.log_text.verticalScrollBar() 495 scrollbar.setValue(scrollbar.maximum()) 496 497 def check_new_files(self): 498 """Check each FolderCard for newly created files.""" 499 for card in self.folder_cards.values(): 500 card.check_new_files() 501 502 # ───────────────────────────────────────────── 503 # USER INTERACTION: STOP / NAVIGATION 504 # ───────────────────────────────────────────── 505 def _on_stop_clicked(self): 506 """ 507 Handle a user click on the "Stop Pipeline" button. 508 509 Attempts graceful termination, followed by forced termination if necessary. 510 """ 511 if self.pipeline_process and self.pipeline_process.state() == QProcess.ProcessState.Running: 512 self._log_message(QCoreApplication.translate("PipelineExecutionPage", "Stopping pipeline...")) 513 514 # Attempt graceful termination first 515 self.pipeline_process.terminate() 516 517 # Force termination if not stopped within 5 seconds 518 if not self.pipeline_process.waitForFinished(5000): 519 self.pipeline_process.kill() 520 self.pipeline_process.waitForFinished(3000) 521 522 self._log_message(QCoreApplication.translate("PipelineExecutionPage", "Pipeline stopped by user.")) 523 524 self.current_operation.setText( 525 QCoreApplication.translate("PipelineExecutionPage", "Execution was interrupted by user.")) 526 527 self.progress_bar.setValue(0) 528 self.stop_button.setEnabled(False) 529 530 self.pipeline_process = None 531 self.context["update_main_buttons"]() 532 533 def _return_to_import(self): 534 """ 535 Return to the import page if no pipeline is running. 536 537 Prevents navigation while the pipeline is still active. 538 """ 539 if self.pipeline_process and self.pipeline_process.state() == QProcess.ProcessState.Running: 540 return # Navigation disabled while pipeline is running 541 542 if self.context and "return_to_import" in self.context: 543 self.context["return_to_import"]() 544 545 # ───────────────────────────────────────────── 546 # PAGE NAVIGATION (from Page base class) 547 # ───────────────────────────────────────────── 548 def on_enter(self): 549 """ 550 Called automatically when this page becomes visible. 551 552 Starts the pipeline automatically after the UI is fully rendered. 553 """ 554 # Load the most recent pipeline configuration file 555 self.config_path = self._find_latest_config() 556 557 # Extract pipeline ID and output directory 558 config_filename = os.path.basename(self.config_path) 559 try: 560 config_id = config_filename.split('_')[0] 561 self.pipeline_output_dir = os.path.join( 562 self.workspace_path, "pipeline", f"{config_id}_output" 563 ) 564 os.makedirs(self.pipeline_output_dir, exist_ok=True) 565 except (IndexError, ValueError): 566 log.debug("Failed to create pipeline_output_dir") 567 self.pipeline_output_dir = os.path.join(self.workspace_path, "pipeline") 568 569 # Resolve path to pipeline executable 570 try: 571 self.pipeline_bin_path = get_bin_path("pipeline_runner") 572 except FileNotFoundError: 573 log.error("Pipeline runner binary not found") 574 raise RuntimeError("Pipeline runner executable missing.") 575 576 if not self.pipeline_completed and self.pipeline_process is None: 577 QTimer.singleShot(500, self._start_pipeline) 578 579 self._setup_folder_cards() 580 581 def back(self): 582 """ 583 Navigate back to the previous page, resetting this page first. 584 585 Returns: 586 Page | None: The previous page, if available. 587 """ 588 self.reset_page() 589 if self.previous_page: 590 self.previous_page.on_enter() 591 return self.previous_page 592 return None 593 594 def next(self, context): 595 """ 596 Called when the user presses "Next". 597 598 Displays a confirmation dialog asking whether to return to the import page. 599 600 Args: 601 context (dict): Application context. 602 603 Returns: 604 Page | None: The next page to navigate to. 605 """ 606 msg_box = QMessageBox() 607 msg_box.setWindowTitle(QCoreApplication.translate("PipelineExecutionPage", "Confirm")) 608 msg_box.setText(QCoreApplication.translate("PipelineExecutionPage", "You want to return to the import page?")) 609 msg_box.setIcon(QMessageBox.Icon.Question) 610 msg_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel) 611 612 result = msg_box.exec() 613 614 if result == QMessageBox.StandardButton.Ok: 615 self.reset_page() 616 self._return_to_import() 617 return self.context["return_to_import"]() 618 return None 619 620 def is_ready_to_go_back(self): 621 """Allow going back only when the pipeline has finished or encountered an error.""" 622 return self.pipeline_completed or self.pipeline_error is not None 623 624 def is_ready_to_advance(self): 625 """Allow advancing only when the pipeline has finished or encountered an error.""" 626 return self.pipeline_completed or self.pipeline_error is not None 627 628 # ───────────────────────────────────────────── 629 # JSON UTILITIES 630 # ───────────────────────────────────────────── 631 def get_sub_list(self, json_path: str) -> list: 632 """ 633 Read a pipeline configuration JSON file and return the list of subjects. 634 635 Args: 636 json_path (str): Path to the JSON configuration file. 637 638 Returns: 639 list[str]: A list of keys that begin with `"sub-"`. 640 """ 641 with open(json_path, "r", encoding="utf-8") as f: 642 data = json.load(f) 643 644 return [key for key in data.keys() if key.startswith("sub-")] 645 646 # ───────────────────────────────────────────── 647 # STATE RESET / CLEANUP 648 # ───────────────────────────────────────────── 649 def reset_page(self): 650 """ 651 Reset the page state to its initial condition. 652 653 Clears logs, progress, and error flags. 654 """ 655 if hasattr(self, "scroll_layout"): 656 while self.scroll_layout.count(): 657 item = self.scroll_layout.takeAt(0) 658 widget = item.widget() 659 if widget is not None: 660 widget.setParent(None) 661 widget.deleteLater() 662 self.folder_cards = {} 663 664 self.progress_bar.setValue(0) 665 self.progress_bar.setColor("#3498DB") 666 self.stop_button.setEnabled(False) 667 self.current_operation.setText("Preparing to start...") 668 self.current_operation.setStyleSheet(""" 669 font-size: 13px; 670 color: #7f8c8d; 671 margin-top: 8px; 672 """) 673 self.log_text.clear() 674 self.pipeline_process = None 675 self.pipeline_error = None 676 self.pipeline_completed = False 677 678 def __del__(self): 679 """Ensure that any running pipeline process is terminated when this object is destroyed.""" 680 if self.pipeline_process and self.pipeline_process.state() == QProcess.ProcessState.Running: 681 self.pipeline_process.kill() 682 self.pipeline_process.waitForFinished(1000) 683 684 def _translate_ui(self): 685 """Update all translatable UI text (used for multilingual support).""" 686 self.header.setText(QCoreApplication.translate("PipelineExecutionPage", "Pipeline Execution")) 687 self.stop_button.setText(QCoreApplication.translate("PipelineExecutionPage", "Stop Pipeline")) 688 self.log_text.setText(QCoreApplication.translate("PipelineExecutionPage", "Execution Log:")) 689 self.log_label.setText(QCoreApplication.translate("PipelineExecutionPage", "Execution Log:"))
24class PipelineExecutionPage(Page): 25 """ 26 GUI page that manages the **execution of a processing pipeline**. 27 28 This class runs an external pipeline process, tracks its progress, 29 captures log output in real time, and provides user controls 30 (e.g., stop execution, monitor folders, show progress). 31 32 It extends :class:`Page` and is typically part of a multi-page 33 PyQt-based GUI workflow. 34 """ 35 36 # ───────────────────────────────────────────── 37 # INITIALIZATION 38 # ───────────────────────────────────────────── 39 def __init__(self, context=None, previous_page=None): 40 """ 41 Initialize the pipeline execution page. 42 43 Args: 44 context (dict): Application context (e.g., workspace path, signals). 45 previous_page (Page): Reference to the previous page in the workflow. 46 """ 47 super().__init__() 48 self.context = context 49 self.workspace_path = context["workspace_path"] 50 self.previous_page = previous_page 51 self.next_page = None 52 53 # Pipeline state 54 self.pipeline_process = None 55 self.pipeline_completed = False 56 self.pipeline_error = None 57 58 # Load the most recent pipeline configuration file 59 self.config_path = self._find_latest_config() 60 61 # Extract pipeline ID and output directory 62 config_filename = os.path.basename(self.config_path) 63 try: 64 config_id = config_filename.split('_')[0] 65 self.pipeline_output_dir = os.path.join( 66 self.workspace_path, "pipeline", f"{config_id}_output" 67 ) 68 os.makedirs(self.pipeline_output_dir, exist_ok=True) 69 except (IndexError, ValueError): 70 log.debug("Failed to create pipeline_output_dir") 71 self.pipeline_output_dir = os.path.join(self.workspace_path, "pipeline") 72 73 # Resolve path to pipeline executable 74 try: 75 self.pipeline_bin_path = get_bin_path("pipeline_runner") 76 except FileNotFoundError: 77 log.error("Pipeline runner binary not found") 78 raise RuntimeError("Pipeline runner executable missing.") 79 80 log.debug(f"Pipeline binary path: {self.pipeline_bin_path}") 81 82 self.folder_cards = {} 83 84 # Build UI 85 self._setup_ui() 86 self._translate_ui() 87 88 # Connect translation handler if needed 89 if context and "language_changed" in context: 90 context["language_changed"].connect(self._translate_ui) 91 92 # ───────────────────────────────────────────── 93 # CONFIGURATION FILE HANDLING 94 # ───────────────────────────────────────────── 95 def _find_latest_config(self): 96 """Find the config file with the highest numeric ID in the pipeline directory.""" 97 import glob 98 pipeline_dir = os.path.join(self.workspace_path, "pipeline") 99 100 if not os.path.exists(pipeline_dir): 101 return os.path.join(pipeline_dir, "pipeline_config.json") 102 103 config_pattern = os.path.join(pipeline_dir, "*_config.json") 104 config_files = glob.glob(config_pattern) 105 106 if not config_files: 107 return os.path.join(pipeline_dir, "pipeline_config.json") 108 109 max_id = 0 110 latest_config = None 111 112 # Parse filenames to detect the latest config ID 113 for config_file in config_files: 114 filename = os.path.basename(config_file) 115 try: 116 config_id = int(filename.split('_')[0]) 117 if config_id > max_id: 118 max_id = config_id 119 latest_config = config_file 120 except (ValueError, IndexError): 121 continue 122 123 return latest_config or os.path.join(pipeline_dir, "pipeline_config.json") 124 125 # ───────────────────────────────────────────── 126 # UI SETUP 127 # ───────────────────────────────────────────── 128 def _setup_ui(self): 129 """Constructs the page layout and visual elements.""" 130 main_layout = QVBoxLayout(self) 131 132 # Header 133 self.header = QLabel("Pipeline Execution") 134 self.header.setFont(QFont("Arial", 18, QFont.Weight.Bold)) 135 self.header.setAlignment(Qt.AlignmentFlag.AlignCenter) 136 main_layout.addWidget(self.header) 137 138 # Subheader: current operation label 139 self.current_operation = QLabel( 140 QCoreApplication.translate("PipelineExecutionPage", "Preparing to start...") 141 ) 142 self.current_operation.setStyleSheet(""" 143 font-size: 13px; 144 color: #7f8c8d; 145 margin-top: 8px; 146 """) 147 self.current_operation.setAlignment(Qt.AlignmentFlag.AlignCenter) 148 main_layout.addWidget(self.current_operation) 149 150 # Content grid (progress bar + folders + logs) 151 content_layout = QGridLayout() 152 main_layout.addLayout(content_layout, stretch=1) 153 154 # Left: progress indicator 155 left_layout = QVBoxLayout() 156 self.progress_bar = CircularProgress() 157 left_layout.addWidget(self.progress_bar, alignment=Qt.AlignmentFlag.AlignCenter) 158 content_layout.addLayout(left_layout, 0, 0) 159 160 # Right: folder cards (output directories) 161 scroll_area = QScrollArea() 162 scroll_area.setWidgetResizable(True) 163 scroll_content = QWidget() 164 self.scroll_layout = QVBoxLayout(scroll_content) 165 scroll_area.setWidget(scroll_content) 166 content_layout.addWidget(scroll_area, 0, 1) 167 168 # Grid layout proportions 169 content_layout.setColumnStretch(0, 1) 170 content_layout.setColumnStretch(1, 2) 171 content_layout.setRowStretch(0, 1) 172 content_layout.setRowStretch(1, 0) 173 content_layout.setRowStretch(2, 1) 174 175 # Log section 176 self.log_label = QLabel("Execution Log:") 177 self.log_label.setStyleSheet(""" 178 font-size: 16px; 179 font-weight: bold; 180 color: #2c3e50; 181 margin-top: 15px; 182 margin-bottom: 5px; 183 """) 184 content_layout.addWidget(self.log_label, 1, 0, 1, 2) 185 186 # Log text box 187 self.log_text = QTextEdit() 188 self.log_text.setStyleSheet(""" 189 QTextEdit { 190 background-color: #2c3e50; 191 color: #ecf0f1; 192 font-family: 'Courier New', monospace; 193 font-size: 11px; 194 border: 2px solid #34495e; 195 border-radius: 8px; 196 padding: 8px; 197 } 198 """) 199 self.log_text.setMaximumHeight(200) 200 self.log_text.setReadOnly(True) 201 content_layout.addWidget(self.log_text, 2, 0, 1, 2) 202 203 # Stop button 204 button_frame = QFrame() 205 button_layout = QHBoxLayout(button_frame) 206 button_layout.setContentsMargins(0, 15, 0, 0) 207 208 self.stop_button = QPushButton("Stop Pipeline") 209 self.stop_button.setStyleSheet(""" 210 QPushButton { 211 font-size: 14px; 212 font-weight: bold; 213 background-color: #e74c3c; 214 color: white; 215 border-radius: 8px; 216 padding: 10px 20px; 217 min-width: 140px; 218 } 219 QPushButton:hover { background-color: #c0392b; } 220 QPushButton:disabled { 221 background-color: #bdc3c7; 222 color: #7f8c8d; 223 } 224 """) 225 self.stop_button.clicked.connect(self._on_stop_clicked) 226 button_layout.addStretch() 227 button_layout.addWidget(self.stop_button) 228 button_layout.addStretch() 229 230 main_layout.addWidget(button_frame) 231 232 def _setup_folder_cards(self): 233 # Load folder cards dynamically from configuration 234 self.watch_dirs = self.get_sub_list(self.config_path) 235 self.watch_dirs = [os.path.join(self.pipeline_output_dir, d) for d in self.watch_dirs] 236 self.dir_completed = 0 237 for d in self.watch_dirs: 238 card = FolderCard(self.context, d) 239 card.open_folder_requested.connect(self.context["tree_view"]._open_in_explorer) 240 self.scroll_layout.addWidget(card) 241 self.folder_cards[d] = card 242 243 # ───────────────────────────────────────────── 244 # PIPELINE PROCESS HANDLING 245 # ───────────────────────────────────────────── 246 def _start_pipeline(self): 247 """Start the external pipeline process and monitor its output.""" 248 if self.pipeline_process: 249 return # Already running 250 251 self.pipeline_process = QProcess() 252 self.pipeline_process.finished.connect(self._on_process_finished) 253 self.pipeline_process.errorOccurred.connect(self._on_process_error) 254 self.pipeline_process.readyReadStandardOutput.connect(self._on_stdout_ready) 255 self.pipeline_process.readyReadStandardError.connect(self._on_stderr_ready) 256 257 self.progress_bar.setValue(0) 258 self.stop_button.setEnabled(True) 259 self._log_message(QCoreApplication.translate("PipelineExecutionPage", "Starting pipeline execution...")) 260 261 cmd = [ 262 self.pipeline_bin_path, 263 "--config", self.config_path, 264 "--work-dir", self.workspace_path, 265 "--out-dir", self.pipeline_output_dir 266 ] 267 log.debug(f"{self.pipeline_bin_path} --config {self.config_path} --work-dir {self.workspace_path} --out-dir {self.pipeline_output_dir}") 268 269 self.pipeline_process.start(cmd[0], cmd[1:]) 270 271 if not self.pipeline_process.waitForStarted(3000): 272 self._log_message( 273 QCoreApplication.translate("PipelineExecutionPage", "ERROR: Failed to start pipeline process")) 274 self._on_pipeline_error( 275 QCoreApplication.translate("PipelineExecutionPage", "Failed to start pipeline process")) 276 277 # ───────────────────────────────────────────── 278 # PROCESS SIGNAL HANDLERS 279 # ───────────────────────────────────────────── 280 def _on_process_finished(self, exit_code, exit_status): 281 """Called when the process terminates.""" 282 if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit: 283 self._on_pipeline_finished() 284 else: 285 self._on_pipeline_error( 286 QCoreApplication.translate( 287 "PipelineExecutionPage", 288 "Process exited with code {exit_code}" 289 ).format(exit_code=exit_code) 290 ) 291 292 def _on_process_error(self, error): 293 """Called when an error occurs in the process.""" 294 error_messages = { 295 QProcess.ProcessError.FailedToStart: QCoreApplication.translate("PipelineExecutionPage", 296 "Failed to start process"), 297 QProcess.ProcessError.Crashed: QCoreApplication.translate("PipelineExecutionPage", "Process crashed"), 298 QProcess.ProcessError.Timedout: QCoreApplication.translate("PipelineExecutionPage", "Process timed out"), 299 QProcess.ProcessError.WriteError: QCoreApplication.translate("PipelineExecutionPage", "Write error"), 300 QProcess.ProcessError.ReadError: QCoreApplication.translate("PipelineExecutionPage", "Read error"), 301 QProcess.ProcessError.UnknownError: QCoreApplication.translate("PipelineExecutionPage", "Unknown error") 302 } 303 error_msg = error_messages.get( 304 error, 305 QCoreApplication.translate("PipelineExecutionPage", 306 "Process error: {error}").format(error=error) 307 ) 308 self._on_pipeline_error(error_msg) 309 310 # ───────────────────────────────────────────── 311 # STDOUT / STDERR MONITORING 312 # ───────────────────────────────────────────── 313 def _on_stdout_ready(self): 314 """Called when data is available on stdout.""" 315 if self.pipeline_process: 316 data = self.pipeline_process.readAllStandardOutput() 317 output = data.data().decode('utf-8').strip() 318 319 for line in output.split('\n'): 320 if line.strip(): 321 self._process_pipeline_output(line.strip()) 322 323 def _on_stderr_ready(self): 324 """Called when data is available on stderr.""" 325 if self.pipeline_process: 326 data = self.pipeline_process.readAllStandardError() 327 error_output = data.data().decode('utf-8').strip() 328 329 for line in error_output.split('\n'): 330 if line.strip(): 331 self._log_message(f"STDERR: {line.strip()}") 332 # ───────────────────────────────────────────── 333 # OUTPUT INTERPRETATION 334 # ───────────────────────────────────────────── 335 def _process_pipeline_output(self, line): 336 """Process the pipeline output logs""" 337 if line.startswith("LOG: "): 338 message = line[5:] 339 self._log_message(message) 340 self._update_current_operation(message) 341 elif line.startswith("ERROR: "): 342 error_msg = line[7:] 343 self._log_message( 344 QCoreApplication.translate("PipelineExecutionPage", "ERROR: {error}").format(error=error_msg)) 345 elif line.startswith("PROGRESS: "): 346 progress_info = line[10:] 347 self._update_progress(progress_info) 348 elif line.startswith("PATIENT: "): 349 patient_info = line[9:] 350 self._log_message( 351 QCoreApplication.translate("PipelineExecutionPage", "Pipeline finished for patient: {message}").format(message=patient_info)) 352 self._update_progress(patient_info) 353 elif line.startswith("FINISHED: "): 354 message = line[10:] 355 self._log_message(QCoreApplication.translate("PipelineExecutionPage", "FINISHED for: {message}").format(message=message)) 356 else: 357 self._log_message(line) 358 359 # ───────────────────────────────────────────── 360 # STATE MANAGEMENT 361 # ───────────────────────────────────────────── 362 def _on_pipeline_finished(self): 363 """Called when the pipeline finishes successfully.""" 364 self.pipeline_completed = True 365 self.current_operation.setText(QCoreApplication.translate("PipelineExecutionPage", "All patients processed successfully.")) 366 self.progress_bar.setValue(100) 367 self.stop_button.setEnabled(False) 368 self._log_message( 369 QCoreApplication.translate("PipelineExecutionPage", "Pipeline execution completed successfully!")) 370 self._log_message( 371 QCoreApplication.translate("PipelineExecutionPage", "Results saved in: {pipeline_output_dir}").format( 372 pipeline_output_dir=self.pipeline_output_dir)) 373 self.pipeline_process = None 374 for card in self.folder_cards.values(): 375 card.set_finished_state() 376 self.context["update_main_buttons"]() 377 378 def _on_pipeline_error(self, error_message): 379 """Called when the pipeline terminates due to an error.""" 380 self.pipeline_error = error_message 381 self.current_operation.setText( 382 QCoreApplication.translate("PipelineExecutionPage", "An error occurred during execution.")) 383 self.current_operation.setStyleSheet(""" 384 color: #c0392b; 385 font-weight: bold; 386 """) 387 self.progress_bar.setColor("#c0392b") 388 self.stop_button.setEnabled(False) 389 self._log_message(QCoreApplication.translate("PipelineExecutionPage", "ERROR: {error_message}").format(error_message=error_message)) 390 self.pipeline_process = None 391 self.context["update_main_buttons"]() 392 393 # ───────────────────────────────────────────── 394 # PROGRESS AND UI UPDATES 395 # ───────────────────────────────────────────── 396 def _update_progress(self, progress_info): 397 """ 398 Update the circular progress bar based on progress information. 399 400 Expected format from pipeline: `"PROGRESS: X/Y"` 401 402 Args: 403 progress_info (str): Progress data string (e.g., `"3/10"`). 404 """ 405 try: 406 if '/' in progress_info: 407 current, total = map(int, progress_info.split('/')) 408 percentage = int((current / total) * 100) 409 self.progress_bar.setValue(percentage) 410 411 self.check_new_files() # Update folder views 412 elif "sub" in progress_info: 413 match = re.search(r"(sub-[a-zA-Z0-9_]+)", progress_info) 414 if match: 415 sub_id = match.group(1) 416 log.debug(f"Detected progress completion for: {sub_id}") 417 418 for folder_path, card in self.folder_cards.items(): 419 folder_name = os.path.basename(folder_path) 420 if sub_id in folder_name: 421 log.debug(f"Setting finished state for card: {folder_name}") 422 card.set_finished_state() 423 break 424 else: 425 log.warning(f"No matching FolderCard found for {sub_id}") 426 else: 427 log.warning(f"Could not extract sub-id from progress info: {progress_info}") 428 429 except ValueError: 430 log.warning("Failed to parse progress info") 431 432 def _update_current_operation(self, message): 433 """ 434 Update the current operation label based on pipeline output message. 435 436 Args: 437 message (str): Message received from the pipeline log. 438 """ 439 if "Starting pipeline" in message: 440 self.current_operation.setText( 441 QCoreApplication.translate("PipelineExecutionPage", "Initializing pipeline...") 442 ) 443 elif "Processing patient" in message or "sub-" in message: 444 patient_id = self._extract_patient_id_from_log(message) 445 if patient_id: 446 self.current_operation.setText( 447 QCoreApplication.translate( 448 "PipelineExecutionPage", 449 "Processing patient: {patient_id}" 450 ).format(patient_id=patient_id) 451 ) 452 else: 453 self.current_operation.setText( 454 QCoreApplication.translate("PipelineExecutionPage", "Processing patient data...") 455 ) 456 elif "analysis" in message.lower(): 457 self.current_operation.setText( 458 QCoreApplication.translate("PipelineExecutionPage", "Performing statistical analysis...") 459 ) 460 elif "saving" in message.lower() or "csv" in message.lower(): 461 self.current_operation.setText( 462 QCoreApplication.translate("PipelineExecutionPage", "Saving results...") 463 ) 464 465 def _extract_patient_id_from_log(self, message): 466 """ 467 Extract a patient ID (e.g., 'sub-001') from a log message. 468 469 Args: 470 message (str): Log message. 471 472 Returns: 473 str | None: The patient ID if extracted, otherwise None. 474 """ 475 import re 476 match = re.search(r'sub-(\w+)', message) 477 return f"sub-{match.group(1)}" if match else None 478 479 # ───────────────────────────────────────────── 480 # LOGGING AND FILE MONITORING 481 # ───────────────────────────────────────────── 482 def _log_message(self, message): 483 """ 484 Append a message to the on-screen log with a timestamp. 485 486 Args: 487 message (str): The message text to append. 488 """ 489 from datetime import datetime 490 timestamp = datetime.now().strftime("%H:%M:%S") 491 formatted_message = f"[{timestamp}] {message}" 492 self.log_text.append(formatted_message) 493 494 # Auto-scroll to the newest entry 495 scrollbar = self.log_text.verticalScrollBar() 496 scrollbar.setValue(scrollbar.maximum()) 497 498 def check_new_files(self): 499 """Check each FolderCard for newly created files.""" 500 for card in self.folder_cards.values(): 501 card.check_new_files() 502 503 # ───────────────────────────────────────────── 504 # USER INTERACTION: STOP / NAVIGATION 505 # ───────────────────────────────────────────── 506 def _on_stop_clicked(self): 507 """ 508 Handle a user click on the "Stop Pipeline" button. 509 510 Attempts graceful termination, followed by forced termination if necessary. 511 """ 512 if self.pipeline_process and self.pipeline_process.state() == QProcess.ProcessState.Running: 513 self._log_message(QCoreApplication.translate("PipelineExecutionPage", "Stopping pipeline...")) 514 515 # Attempt graceful termination first 516 self.pipeline_process.terminate() 517 518 # Force termination if not stopped within 5 seconds 519 if not self.pipeline_process.waitForFinished(5000): 520 self.pipeline_process.kill() 521 self.pipeline_process.waitForFinished(3000) 522 523 self._log_message(QCoreApplication.translate("PipelineExecutionPage", "Pipeline stopped by user.")) 524 525 self.current_operation.setText( 526 QCoreApplication.translate("PipelineExecutionPage", "Execution was interrupted by user.")) 527 528 self.progress_bar.setValue(0) 529 self.stop_button.setEnabled(False) 530 531 self.pipeline_process = None 532 self.context["update_main_buttons"]() 533 534 def _return_to_import(self): 535 """ 536 Return to the import page if no pipeline is running. 537 538 Prevents navigation while the pipeline is still active. 539 """ 540 if self.pipeline_process and self.pipeline_process.state() == QProcess.ProcessState.Running: 541 return # Navigation disabled while pipeline is running 542 543 if self.context and "return_to_import" in self.context: 544 self.context["return_to_import"]() 545 546 # ───────────────────────────────────────────── 547 # PAGE NAVIGATION (from Page base class) 548 # ───────────────────────────────────────────── 549 def on_enter(self): 550 """ 551 Called automatically when this page becomes visible. 552 553 Starts the pipeline automatically after the UI is fully rendered. 554 """ 555 # Load the most recent pipeline configuration file 556 self.config_path = self._find_latest_config() 557 558 # Extract pipeline ID and output directory 559 config_filename = os.path.basename(self.config_path) 560 try: 561 config_id = config_filename.split('_')[0] 562 self.pipeline_output_dir = os.path.join( 563 self.workspace_path, "pipeline", f"{config_id}_output" 564 ) 565 os.makedirs(self.pipeline_output_dir, exist_ok=True) 566 except (IndexError, ValueError): 567 log.debug("Failed to create pipeline_output_dir") 568 self.pipeline_output_dir = os.path.join(self.workspace_path, "pipeline") 569 570 # Resolve path to pipeline executable 571 try: 572 self.pipeline_bin_path = get_bin_path("pipeline_runner") 573 except FileNotFoundError: 574 log.error("Pipeline runner binary not found") 575 raise RuntimeError("Pipeline runner executable missing.") 576 577 if not self.pipeline_completed and self.pipeline_process is None: 578 QTimer.singleShot(500, self._start_pipeline) 579 580 self._setup_folder_cards() 581 582 def back(self): 583 """ 584 Navigate back to the previous page, resetting this page first. 585 586 Returns: 587 Page | None: The previous page, if available. 588 """ 589 self.reset_page() 590 if self.previous_page: 591 self.previous_page.on_enter() 592 return self.previous_page 593 return None 594 595 def next(self, context): 596 """ 597 Called when the user presses "Next". 598 599 Displays a confirmation dialog asking whether to return to the import page. 600 601 Args: 602 context (dict): Application context. 603 604 Returns: 605 Page | None: The next page to navigate to. 606 """ 607 msg_box = QMessageBox() 608 msg_box.setWindowTitle(QCoreApplication.translate("PipelineExecutionPage", "Confirm")) 609 msg_box.setText(QCoreApplication.translate("PipelineExecutionPage", "You want to return to the import page?")) 610 msg_box.setIcon(QMessageBox.Icon.Question) 611 msg_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel) 612 613 result = msg_box.exec() 614 615 if result == QMessageBox.StandardButton.Ok: 616 self.reset_page() 617 self._return_to_import() 618 return self.context["return_to_import"]() 619 return None 620 621 def is_ready_to_go_back(self): 622 """Allow going back only when the pipeline has finished or encountered an error.""" 623 return self.pipeline_completed or self.pipeline_error is not None 624 625 def is_ready_to_advance(self): 626 """Allow advancing only when the pipeline has finished or encountered an error.""" 627 return self.pipeline_completed or self.pipeline_error is not None 628 629 # ───────────────────────────────────────────── 630 # JSON UTILITIES 631 # ───────────────────────────────────────────── 632 def get_sub_list(self, json_path: str) -> list: 633 """ 634 Read a pipeline configuration JSON file and return the list of subjects. 635 636 Args: 637 json_path (str): Path to the JSON configuration file. 638 639 Returns: 640 list[str]: A list of keys that begin with `"sub-"`. 641 """ 642 with open(json_path, "r", encoding="utf-8") as f: 643 data = json.load(f) 644 645 return [key for key in data.keys() if key.startswith("sub-")] 646 647 # ───────────────────────────────────────────── 648 # STATE RESET / CLEANUP 649 # ───────────────────────────────────────────── 650 def reset_page(self): 651 """ 652 Reset the page state to its initial condition. 653 654 Clears logs, progress, and error flags. 655 """ 656 if hasattr(self, "scroll_layout"): 657 while self.scroll_layout.count(): 658 item = self.scroll_layout.takeAt(0) 659 widget = item.widget() 660 if widget is not None: 661 widget.setParent(None) 662 widget.deleteLater() 663 self.folder_cards = {} 664 665 self.progress_bar.setValue(0) 666 self.progress_bar.setColor("#3498DB") 667 self.stop_button.setEnabled(False) 668 self.current_operation.setText("Preparing to start...") 669 self.current_operation.setStyleSheet(""" 670 font-size: 13px; 671 color: #7f8c8d; 672 margin-top: 8px; 673 """) 674 self.log_text.clear() 675 self.pipeline_process = None 676 self.pipeline_error = None 677 self.pipeline_completed = False 678 679 def __del__(self): 680 """Ensure that any running pipeline process is terminated when this object is destroyed.""" 681 if self.pipeline_process and self.pipeline_process.state() == QProcess.ProcessState.Running: 682 self.pipeline_process.kill() 683 self.pipeline_process.waitForFinished(1000) 684 685 def _translate_ui(self): 686 """Update all translatable UI text (used for multilingual support).""" 687 self.header.setText(QCoreApplication.translate("PipelineExecutionPage", "Pipeline Execution")) 688 self.stop_button.setText(QCoreApplication.translate("PipelineExecutionPage", "Stop Pipeline")) 689 self.log_text.setText(QCoreApplication.translate("PipelineExecutionPage", "Execution Log:")) 690 self.log_label.setText(QCoreApplication.translate("PipelineExecutionPage", "Execution Log:"))
GUI page that manages the execution of a processing pipeline.
This class runs an external pipeline process, tracks its progress, captures log output in real time, and provides user controls (e.g., stop execution, monitor folders, show progress).
It extends Page and is typically part of a multi-page
PyQt-based GUI workflow.
39 def __init__(self, context=None, previous_page=None): 40 """ 41 Initialize the pipeline execution page. 42 43 Args: 44 context (dict): Application context (e.g., workspace path, signals). 45 previous_page (Page): Reference to the previous page in the workflow. 46 """ 47 super().__init__() 48 self.context = context 49 self.workspace_path = context["workspace_path"] 50 self.previous_page = previous_page 51 self.next_page = None 52 53 # Pipeline state 54 self.pipeline_process = None 55 self.pipeline_completed = False 56 self.pipeline_error = None 57 58 # Load the most recent pipeline configuration file 59 self.config_path = self._find_latest_config() 60 61 # Extract pipeline ID and output directory 62 config_filename = os.path.basename(self.config_path) 63 try: 64 config_id = config_filename.split('_')[0] 65 self.pipeline_output_dir = os.path.join( 66 self.workspace_path, "pipeline", f"{config_id}_output" 67 ) 68 os.makedirs(self.pipeline_output_dir, exist_ok=True) 69 except (IndexError, ValueError): 70 log.debug("Failed to create pipeline_output_dir") 71 self.pipeline_output_dir = os.path.join(self.workspace_path, "pipeline") 72 73 # Resolve path to pipeline executable 74 try: 75 self.pipeline_bin_path = get_bin_path("pipeline_runner") 76 except FileNotFoundError: 77 log.error("Pipeline runner binary not found") 78 raise RuntimeError("Pipeline runner executable missing.") 79 80 log.debug(f"Pipeline binary path: {self.pipeline_bin_path}") 81 82 self.folder_cards = {} 83 84 # Build UI 85 self._setup_ui() 86 self._translate_ui() 87 88 # Connect translation handler if needed 89 if context and "language_changed" in context: 90 context["language_changed"].connect(self._translate_ui)
Initialize the pipeline execution page.
Args: context (dict): Application context (e.g., workspace path, signals). previous_page (Page): Reference to the previous page in the workflow.
498 def check_new_files(self): 499 """Check each FolderCard for newly created files.""" 500 for card in self.folder_cards.values(): 501 card.check_new_files()
Check each FolderCard for newly created files.
549 def on_enter(self): 550 """ 551 Called automatically when this page becomes visible. 552 553 Starts the pipeline automatically after the UI is fully rendered. 554 """ 555 # Load the most recent pipeline configuration file 556 self.config_path = self._find_latest_config() 557 558 # Extract pipeline ID and output directory 559 config_filename = os.path.basename(self.config_path) 560 try: 561 config_id = config_filename.split('_')[0] 562 self.pipeline_output_dir = os.path.join( 563 self.workspace_path, "pipeline", f"{config_id}_output" 564 ) 565 os.makedirs(self.pipeline_output_dir, exist_ok=True) 566 except (IndexError, ValueError): 567 log.debug("Failed to create pipeline_output_dir") 568 self.pipeline_output_dir = os.path.join(self.workspace_path, "pipeline") 569 570 # Resolve path to pipeline executable 571 try: 572 self.pipeline_bin_path = get_bin_path("pipeline_runner") 573 except FileNotFoundError: 574 log.error("Pipeline runner binary not found") 575 raise RuntimeError("Pipeline runner executable missing.") 576 577 if not self.pipeline_completed and self.pipeline_process is None: 578 QTimer.singleShot(500, self._start_pipeline) 579 580 self._setup_folder_cards()
Called automatically when this page becomes visible.
Starts the pipeline automatically after the UI is fully rendered.
582 def back(self): 583 """ 584 Navigate back to the previous page, resetting this page first. 585 586 Returns: 587 Page | None: The previous page, if available. 588 """ 589 self.reset_page() 590 if self.previous_page: 591 self.previous_page.on_enter() 592 return self.previous_page 593 return None
Navigate back to the previous page, resetting this page first.
Returns: Page | None: The previous page, if available.
595 def next(self, context): 596 """ 597 Called when the user presses "Next". 598 599 Displays a confirmation dialog asking whether to return to the import page. 600 601 Args: 602 context (dict): Application context. 603 604 Returns: 605 Page | None: The next page to navigate to. 606 """ 607 msg_box = QMessageBox() 608 msg_box.setWindowTitle(QCoreApplication.translate("PipelineExecutionPage", "Confirm")) 609 msg_box.setText(QCoreApplication.translate("PipelineExecutionPage", "You want to return to the import page?")) 610 msg_box.setIcon(QMessageBox.Icon.Question) 611 msg_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel) 612 613 result = msg_box.exec() 614 615 if result == QMessageBox.StandardButton.Ok: 616 self.reset_page() 617 self._return_to_import() 618 return self.context["return_to_import"]() 619 return None
Called when the user presses "Next".
Displays a confirmation dialog asking whether to return to the import page.
Args: context (dict): Application context.
Returns: Page | None: The next page to navigate to.
621 def is_ready_to_go_back(self): 622 """Allow going back only when the pipeline has finished or encountered an error.""" 623 return self.pipeline_completed or self.pipeline_error is not None
Allow going back only when the pipeline has finished or encountered an error.
625 def is_ready_to_advance(self): 626 """Allow advancing only when the pipeline has finished or encountered an error.""" 627 return self.pipeline_completed or self.pipeline_error is not None
Allow advancing only when the pipeline has finished or encountered an error.
632 def get_sub_list(self, json_path: str) -> list: 633 """ 634 Read a pipeline configuration JSON file and return the list of subjects. 635 636 Args: 637 json_path (str): Path to the JSON configuration file. 638 639 Returns: 640 list[str]: A list of keys that begin with `"sub-"`. 641 """ 642 with open(json_path, "r", encoding="utf-8") as f: 643 data = json.load(f) 644 645 return [key for key in data.keys() if key.startswith("sub-")]
Read a pipeline configuration JSON file and return the list of subjects.
Args: json_path (str): Path to the JSON configuration file.
Returns:
list[str]: A list of keys that begin with "sub-".
650 def reset_page(self): 651 """ 652 Reset the page state to its initial condition. 653 654 Clears logs, progress, and error flags. 655 """ 656 if hasattr(self, "scroll_layout"): 657 while self.scroll_layout.count(): 658 item = self.scroll_layout.takeAt(0) 659 widget = item.widget() 660 if widget is not None: 661 widget.setParent(None) 662 widget.deleteLater() 663 self.folder_cards = {} 664 665 self.progress_bar.setValue(0) 666 self.progress_bar.setColor("#3498DB") 667 self.stop_button.setEnabled(False) 668 self.current_operation.setText("Preparing to start...") 669 self.current_operation.setStyleSheet(""" 670 font-size: 13px; 671 color: #7f8c8d; 672 margin-top: 8px; 673 """) 674 self.log_text.clear() 675 self.pipeline_process = None 676 self.pipeline_error = None 677 self.pipeline_completed = False
Reset the page state to its initial condition.
Clears logs, progress, and error flags.