Coverage for main/ui/pipeline_execution_page.py: 89%

333 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-03 15:51 +0000

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:"))