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:"))
log = <Logger GliAAns-UI (WARNING)>
class PipelineExecutionPage(page.Page):
 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.

PipelineExecutionPage(context=None, previous_page=None)
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.

context
workspace_path
previous_page
next_page
pipeline_process
pipeline_completed
pipeline_error
config_path
folder_cards
def check_new_files(self):
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.

def on_enter(self):
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.

def back(self):
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.

def next(self, context):
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.

def is_ready_to_go_back(self):
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.

def is_ready_to_advance(self):
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.

def get_sub_list(self, json_path: str) -> list:
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-".

def reset_page(self):
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.