Coverage for openhcs/pyqt_gui/widgets/step_parameter_editor.py: 0.0%

180 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-01 18:33 +0000

1""" 

2Step Parameter Editor Widget for PyQt6 GUI. 

3 

4Mirrors the Textual TUI StepParameterEditorWidget with type hint-based form generation. 

5Handles FunctionStep parameter editing with nested dataclass support. 

6""" 

7 

8import logging 

9from typing import Any, Optional 

10from pathlib import Path 

11from contextlib import contextmanager 

12 

13from PyQt6.QtWidgets import ( 

14 QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, 

15 QScrollArea, QFrame, QSizePolicy 

16) 

17from PyQt6.QtCore import Qt, pyqtSignal 

18 

19from openhcs.core.steps.function_step import FunctionStep 

20from openhcs.textual_tui.widgets.shared.signature_analyzer import SignatureAnalyzer 

21from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import ParameterFormManager 

22from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

23from openhcs.pyqt_gui.config import PyQtGUIConfig, get_default_pyqt_gui_config 

24# REMOVED: LazyDataclassFactory import - no longer needed since step editor 

25# uses existing lazy dataclass instances from the step 

26from openhcs.core.config import GlobalPipelineConfig 

27from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

28 

29logger = logging.getLogger(__name__) 

30 

31 

32class StepParameterEditorWidget(QWidget): 

33 """ 

34 Step parameter editor using dynamic form generation. 

35  

36 Mirrors Textual TUI implementation - builds forms based on FunctionStep  

37 constructor signature with nested dataclass support. 

38 """ 

39 

40 # Signals 

41 step_parameter_changed = pyqtSignal() 

42 

43 def __init__(self, step: FunctionStep, service_adapter=None, color_scheme: Optional[PyQt6ColorScheme] = None, 

44 gui_config: Optional[PyQtGUIConfig] = None, parent=None, pipeline_config=None): 

45 super().__init__(parent) 

46 

47 # Initialize color scheme and GUI config 

48 self.color_scheme = color_scheme or PyQt6ColorScheme() 

49 self.gui_config = gui_config or get_default_pyqt_gui_config() 

50 

51 self.step = step 

52 self.service_adapter = service_adapter 

53 self.pipeline_config = pipeline_config # Store pipeline config for context hierarchy 

54 

55 # Live placeholder updates not yet ready - disable for now 

56 self._step_editor_coordinator = None 

57 # TODO: Re-enable when live updates feature is fully implemented 

58 # if hasattr(self.gui_config, 'enable_live_step_parameter_updates') and self.gui_config.enable_live_step_parameter_updates: 

59 # from openhcs.config_framework.lazy_factory import ContextEventCoordinator 

60 # self._step_editor_coordinator = ContextEventCoordinator() 

61 # logger.debug("🔍 STEP EDITOR: Created step-editor-specific coordinator for live step parameter updates") 

62 

63 # Analyze AbstractStep signature to get all inherited parameters (mirrors Textual TUI) 

64 from openhcs.core.steps.abstract import AbstractStep 

65 # Auto-detection correctly identifies constructors and includes all parameters 

66 param_info = SignatureAnalyzer.analyze(AbstractStep.__init__) 

67 

68 # Get current parameter values from step instance 

69 parameters = {} 

70 parameter_types = {} 

71 param_defaults = {} 

72 

73 for name, info in param_info.items(): 

74 # All AbstractStep parameters are relevant for editing 

75 # ParameterFormManager will automatically route lazy dataclass parameters to LazyDataclassEditor 

76 current_value = getattr(self.step, name, info.default_value) 

77 

78 # CRITICAL FIX: For lazy dataclass parameters, leave current_value as None 

79 # This allows the UI to show placeholders and use lazy resolution properly 

80 if current_value is None and self._is_optional_lazy_dataclass_in_pipeline(info.param_type, name): 

81 # Don't create concrete instances - leave as None for placeholder resolution 

82 # The UI will handle lazy resolution and show appropriate placeholders 

83 param_defaults[name] = None 

84 # Mark this as a step-level config for special handling 

85 if not hasattr(self, '_step_level_configs'): 

86 self._step_level_configs = {} 

87 self._step_level_configs[name] = True 

88 else: 

89 param_defaults[name] = info.default_value 

90 

91 parameters[name] = current_value 

92 parameter_types[name] = info.param_type 

93 

94 # SIMPLIFIED: Create parameter form manager using dual-axis resolution 

95 from openhcs.core.config import GlobalPipelineConfig 

96 

97 # CRITICAL FIX: Use pipeline_config as context_obj (parent for inheritance) 

98 # The step is the overlay (what's being edited), not the parent context 

99 # Context hierarchy: GlobalPipelineConfig (thread-local) -> PipelineConfig (context_obj) -> Step (overlay) 

100 self.form_manager = ParameterFormManager( 

101 object_instance=self.step, # Step instance being edited (overlay) 

102 field_id="step", # Use "step" as field identifier 

103 parent=self, # Pass self as parent widget 

104 context_obj=self.pipeline_config # Pipeline config as parent context for inheritance 

105 ) 

106 

107 self.setup_ui() 

108 self.setup_connections() 

109 

110 logger.debug(f"Step parameter editor initialized for step: {getattr(step, 'name', 'Unknown')}") 

111 

112 def _is_optional_lazy_dataclass_in_pipeline(self, param_type, param_name): 

113 """ 

114 Check if parameter is an optional lazy dataclass that exists in PipelineConfig. 

115 

116 This enables automatic step-level config creation for any parameter that: 

117 1. Is Optional[SomeDataclass] 

118 2. SomeDataclass exists as a field type in PipelineConfig (type-based matching) 

119 3. The dataclass has lazy resolution capabilities 

120 

121 No manual mappings needed - uses type-based discovery. 

122 """ 

123 from openhcs.core.config import PipelineConfig 

124 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

125 import dataclasses 

126 

127 # Check if parameter is Optional[dataclass] 

128 if not ParameterTypeUtils.is_optional_dataclass(param_type): 

129 return False 

130 

131 # Get the inner dataclass type 

132 inner_type = ParameterTypeUtils.get_optional_inner_type(param_type) 

133 

134 # Find if this type exists as a field in PipelineConfig (type-based matching) 

135 pipeline_field_name = self._find_pipeline_field_by_type(inner_type) 

136 if not pipeline_field_name: 

137 return False 

138 

139 # Check if the dataclass has lazy resolution capabilities 

140 try: 

141 # Try to create an instance to see if it's a lazy dataclass 

142 test_instance = inner_type() 

143 # Check for lazy dataclass methods 

144 return hasattr(test_instance, '_resolve_field_value') or hasattr(test_instance, '_lazy_resolution_config') 

145 except: 

146 return False 

147 

148 def _find_pipeline_field_by_type(self, target_type): 

149 """ 

150 Find the field in PipelineConfig that matches the target type. 

151 

152 This is type-based discovery - no manual mappings needed. 

153 """ 

154 from openhcs.core.config import PipelineConfig 

155 import dataclasses 

156 

157 for field in dataclasses.fields(PipelineConfig): 

158 # Use string comparison to handle type identity issues 

159 if str(field.type) == str(target_type): 

160 return field.name 

161 return None 

162 

163 # REMOVED: _create_step_level_config method - dead code 

164 # The step editor should use the existing lazy dataclass instances from the step, 

165 # not create new "StepLevel" versions. The AbstractStep already has the correct 

166 # lazy dataclass types (LazyNapariStreamingConfig, LazyStepMaterializationConfig, etc.) 

167 

168 

169 

170 

171 

172 

173 def setup_ui(self): 

174 """Setup the user interface (matches FunctionListEditorWidget structure).""" 

175 # Main layout directly on self (like FunctionListEditorWidget) 

176 layout = QVBoxLayout(self) 

177 layout.setContentsMargins(0, 0, 0, 0) 

178 layout.setSpacing(8) 

179 

180 # Header with controls (like FunctionListEditorWidget) 

181 header_layout = QHBoxLayout() 

182 

183 # Header label 

184 header_label = QLabel("Step Parameters") 

185 header_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)}; font-weight: bold; font-size: 14px;") 

186 header_layout.addWidget(header_label) 

187 

188 header_layout.addStretch() 

189 

190 # Action buttons in header (preserving functionality) 

191 load_btn = QPushButton("Load .step") 

192 load_btn.setMaximumWidth(100) 

193 load_btn.setStyleSheet(self._get_button_style()) 

194 load_btn.clicked.connect(self.load_step_settings) 

195 header_layout.addWidget(load_btn) 

196 

197 save_btn = QPushButton("Save .step As") 

198 save_btn.setMaximumWidth(120) 

199 save_btn.setStyleSheet(self._get_button_style()) 

200 save_btn.clicked.connect(self.save_step_settings) 

201 header_layout.addWidget(save_btn) 

202 

203 layout.addLayout(header_layout) 

204 

205 # Scrollable parameter form (like FunctionListEditorWidget) 

206 self.scroll_area = QScrollArea() 

207 self.scroll_area.setWidgetResizable(True) 

208 self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

209 self.scroll_area.setStyleSheet(f""" 

210 QScrollArea {{ 

211 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)}; 

212 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)}; 

213 border-radius: 4px; 

214 }} 

215 """) 

216 

217 # Parameter form container (like FunctionListEditorWidget) 

218 self.parameter_container = QWidget() 

219 container_layout = QVBoxLayout(self.parameter_container) 

220 container_layout.setContentsMargins(10, 10, 10, 10) 

221 container_layout.setSpacing(15) 

222 

223 # Parameter form (using shared form manager) 

224 # ParameterFormManager automatically routes lazy dataclass parameters to LazyDataclassEditor 

225 form_frame = QFrame() 

226 # Use centralized styling - no custom frame styling 

227 

228 # Set size policy to allow form frame to expand 

229 form_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 

230 

231 form_layout = QVBoxLayout(form_frame) 

232 

233 # Add parameter form manager 

234 form_layout.addWidget(self.form_manager) 

235 

236 # Add form frame with stretch factor to make it expand 

237 container_layout.addWidget(form_frame, 1) # stretch factor = 1 

238 

239 # Set container in scroll area and add to main layout (like FunctionListEditorWidget) 

240 self.scroll_area.setWidget(self.parameter_container) 

241 layout.addWidget(self.scroll_area) 

242 

243 def _get_button_style(self) -> str: 

244 """Get consistent button styling.""" 

245 return """ 

246 QPushButton { 

247 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)}; 

248 color: white; 

249 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_light)}; 

250 border-radius: 3px; 

251 padding: 6px 12px; 

252 font-size: 11px; 

253 } 

254 QPushButton:hover { 

255 background-color: {self.color_scheme.to_hex(self.color_scheme.button_hover_bg)}; 

256 } 

257 QPushButton:pressed { 

258 background-color: {self.color_scheme.to_hex(self.color_scheme.button_pressed_bg)}; 

259 } 

260 """ 

261 

262 def setup_connections(self): 

263 """Setup signal/slot connections.""" 

264 # Connect form manager parameter changes 

265 self.form_manager.parameter_changed.connect(self._handle_parameter_change) 

266 

267 def _handle_parameter_change(self, param_name: str, value: Any): 

268 """Handle parameter change from form manager (mirrors Textual TUI).""" 

269 try: 

270 # Get the properly converted value from the form manager 

271 # The form manager handles all type conversions including List[Enum] 

272 final_value = self.form_manager.get_current_values().get(param_name, value) 

273 

274 # Debug: Check what we're actually saving 

275 if param_name == 'materialization_config': 

276 print(f"DEBUG: Saving materialization_config, type: {type(final_value)}") 

277 print(f"DEBUG: Raw value from form manager: {value}") 

278 print(f"DEBUG: Final value from get_current_values(): {final_value}") 

279 if hasattr(final_value, '__dataclass_fields__'): 

280 from dataclasses import fields 

281 for field_obj in fields(final_value): 

282 raw_value = object.__getattribute__(final_value, field_obj.name) 

283 print(f"DEBUG: Field {field_obj.name} = {raw_value}") 

284 

285 # CRITICAL FIX: For function parameters, use fresh imports to avoid unpicklable registry wrappers 

286 if param_name == 'func' and callable(final_value) and hasattr(final_value, '__module__'): 

287 try: 

288 import importlib 

289 module = importlib.import_module(final_value.__module__) 

290 final_value = getattr(module, final_value.__name__) 

291 except Exception: 

292 pass # Use original if refresh fails 

293 

294 # Update step attribute 

295 setattr(self.step, param_name, final_value) 

296 logger.debug(f"Updated step parameter {param_name}={final_value}") 

297 self.step_parameter_changed.emit() 

298 

299 except Exception as e: 

300 logger.error(f"Error updating step parameter {param_name}: {e}") 

301 

302 def load_step_settings(self): 

303 """Load step settings from .step file (mirrors Textual TUI).""" 

304 if not self.service_adapter: 

305 logger.warning("No service adapter available for file dialog") 

306 return 

307 

308 from openhcs.core.path_cache import PathCacheKey 

309 

310 file_path = self.service_adapter.show_cached_file_dialog( 

311 cache_key=PathCacheKey.STEP_SETTINGS, 

312 title="Load Step Settings (.step)", 

313 file_filter="Step Files (*.step);;All Files (*)", 

314 mode="open" 

315 ) 

316 

317 if file_path: 

318 self._load_step_settings_from_file(file_path) 

319 

320 def save_step_settings(self): 

321 """Save step settings to .step file (mirrors Textual TUI).""" 

322 if not self.service_adapter: 

323 logger.warning("No service adapter available for file dialog") 

324 return 

325 

326 from openhcs.core.path_cache import PathCacheKey 

327 

328 file_path = self.service_adapter.show_cached_file_dialog( 

329 cache_key=PathCacheKey.STEP_SETTINGS, 

330 title="Save Step Settings (.step)", 

331 file_filter="Step Files (*.step);;All Files (*)", 

332 mode="save" 

333 ) 

334 

335 if file_path: 

336 self._save_step_settings_to_file(file_path) 

337 

338 def _load_step_settings_from_file(self, file_path: Path): 

339 """Load step settings from file.""" 

340 try: 

341 import dill as pickle 

342 with open(file_path, 'rb') as f: 

343 step_data = pickle.load(f) 

344 

345 # Update form manager with loaded values 

346 for param_name, value in step_data.items(): 

347 if hasattr(self.form_manager, 'update_parameter'): 

348 self.form_manager.update_parameter(param_name, value) 

349 # Also update the step object 

350 if hasattr(self.step, param_name): 

351 setattr(self.step, param_name, value) 

352 

353 # Refresh the form to show loaded values 

354 self.form_manager._refresh_all_placeholders() 

355 logger.debug(f"Loaded {len(step_data)} parameters from {file_path.name}") 

356 

357 except Exception as e: 

358 logger.error(f"Failed to load step settings from {file_path}: {e}") 

359 if self.service_adapter: 

360 self.service_adapter.show_error_dialog(f"Failed to load step settings: {e}") 

361 

362 def _save_step_settings_to_file(self, file_path: Path): 

363 """Save step settings to file.""" 

364 try: 

365 import dill as pickle 

366 # Get current values from form manager 

367 step_data = self.form_manager.get_current_values() 

368 with open(file_path, 'wb') as f: 

369 pickle.dump(step_data, f) 

370 logger.debug(f"Saved {len(step_data)} parameters to {file_path.name}") 

371 

372 except Exception as e: 

373 logger.error(f"Failed to save step settings to {file_path}: {e}") 

374 if self.service_adapter: 

375 self.service_adapter.show_error_dialog(f"Failed to save step settings: {e}") 

376 

377 

378 def get_current_step(self) -> FunctionStep: 

379 """Get the current step with all parameter values.""" 

380 return self.step 

381 

382 def update_step(self, step: FunctionStep): 

383 """Update the step and refresh the form.""" 

384 self.step = step 

385 

386 # Update form manager with new values 

387 for param_name in self.form_manager.parameters.keys(): 

388 current_value = getattr(self.step, param_name, None) 

389 self.form_manager.update_parameter(param_name, current_value) 

390 

391 logger.debug(f"Updated step parameter editor for step: {getattr(step, 'name', 'Unknown')}")