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

166 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-04 02:09 +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 

11 

12from PyQt6.QtWidgets import ( 

13 QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, 

14 QScrollArea 

15) 

16from PyQt6.QtCore import Qt, pyqtSignal 

17 

18from openhcs.core.steps.function_step import FunctionStep 

19from openhcs.introspection.signature_analyzer import SignatureAnalyzer 

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

21from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

22from openhcs.pyqt_gui.config import PyQtGUIConfig, get_default_pyqt_gui_config 

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

24# uses existing lazy dataclass instances from the step 

25from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

26 

27logger = logging.getLogger(__name__) 

28 

29 

30class StepParameterEditorWidget(QWidget): 

31 """ 

32 Step parameter editor using dynamic form generation. 

33  

34 Mirrors Textual TUI implementation - builds forms based on FunctionStep  

35 constructor signature with nested dataclass support. 

36 """ 

37 

38 # Signals 

39 step_parameter_changed = pyqtSignal() 

40 

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

42 gui_config: Optional[PyQtGUIConfig] = None, parent=None, pipeline_config=None, scope_id: Optional[str] = None): 

43 super().__init__(parent) 

44 

45 # Initialize color scheme and GUI config 

46 self.color_scheme = color_scheme or PyQt6ColorScheme() 

47 self.gui_config = gui_config or get_default_pyqt_gui_config() 

48 

49 self.step = step 

50 self.service_adapter = service_adapter 

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

52 self.scope_id = scope_id # Store scope_id for cross-window update scoping 

53 

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

55 self._step_editor_coordinator = None 

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

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

58 # from openhcs.config_framework.lazy_factory import ContextEventCoordinator 

59 # self._step_editor_coordinator = ContextEventCoordinator() 

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

61 

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

63 from openhcs.core.steps.abstract import AbstractStep 

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

65 param_info = SignatureAnalyzer.analyze(AbstractStep.__init__) 

66 

67 # Get current parameter values from step instance 

68 parameters = {} 

69 parameter_types = {} 

70 param_defaults = {} 

71 

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

73 # All AbstractStep parameters are relevant for editing 

74 # ParameterFormManager will automatically route lazy dataclass parameters to LazyDataclassEditor 

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

76 

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

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

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

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

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

82 param_defaults[name] = None 

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

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

85 self._step_level_configs = {} 

86 self._step_level_configs[name] = True 

87 else: 

88 param_defaults[name] = info.default_value 

89 

90 parameters[name] = current_value 

91 parameter_types[name] = info.param_type 

92 

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

94 

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

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

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

98 # CRITICAL FIX: Exclude 'func' parameter - it's handled by the Function Pattern tab 

99 self.form_manager = ParameterFormManager( 

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

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

102 parent=self, # Pass self as parent widget 

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

104 exclude_params=['func'], # Exclude func - it has its own dedicated tab 

105 scope_id=self.scope_id # Pass scope_id to limit cross-window updates to same orchestrator 

106 ) 

107 

108 self.setup_ui() 

109 self.setup_connections() 

110 

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

112 

113 def _is_optional_lazy_dataclass_in_pipeline(self, param_type, param_name): 

114 """ 

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

116 

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

118 1. Is Optional[SomeDataclass] 

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

120 3. The dataclass has lazy resolution capabilities 

121 

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

123 """ 

124 

125 # Check if parameter is Optional[dataclass] 

126 if not ParameterTypeUtils.is_optional_dataclass(param_type): 

127 return False 

128 

129 # Get the inner dataclass type 

130 inner_type = ParameterTypeUtils.get_optional_inner_type(param_type) 

131 

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

133 pipeline_field_name = self._find_pipeline_field_by_type(inner_type) 

134 if not pipeline_field_name: 

135 return False 

136 

137 # Check if the dataclass has lazy resolution capabilities 

138 try: 

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

140 test_instance = inner_type() 

141 # Check for lazy dataclass methods 

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

143 except: 

144 return False 

145 

146 def _find_pipeline_field_by_type(self, target_type): 

147 """ 

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

149 

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

151 """ 

152 from openhcs.core.config import PipelineConfig 

153 import dataclasses 

154 

155 for field in dataclasses.fields(PipelineConfig): 

156 # Use string comparison to handle type identity issues 

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

158 return field.name 

159 return None 

160 

161 # REMOVED: _create_step_level_config method - dead code 

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

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

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

165 

166 

167 

168 

169 

170 

171 def setup_ui(self): 

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

173 # Main layout directly on self (like FunctionListEditorWidget) 

174 layout = QVBoxLayout(self) 

175 layout.setContentsMargins(0, 0, 0, 0) 

176 layout.setSpacing(8) 

177 

178 # Header with controls (like FunctionListEditorWidget) 

179 header_layout = QHBoxLayout() 

180 

181 # Header label 

182 header_label = QLabel("Step Parameters") 

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

184 header_layout.addWidget(header_label) 

185 

186 header_layout.addStretch() 

187 

188 # Action buttons in header (preserving functionality) 

189 load_btn = QPushButton("Load .step") 

190 load_btn.setMaximumWidth(100) 

191 load_btn.setStyleSheet(self._get_button_style()) 

192 load_btn.clicked.connect(self.load_step_settings) 

193 header_layout.addWidget(load_btn) 

194 

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

196 save_btn.setMaximumWidth(120) 

197 save_btn.setStyleSheet(self._get_button_style()) 

198 save_btn.clicked.connect(self.save_step_settings) 

199 header_layout.addWidget(save_btn) 

200 

201 layout.addLayout(header_layout) 

202 

203 # Scrollable parameter form (matches config window pattern) 

204 self.scroll_area = QScrollArea() 

205 self.scroll_area.setWidgetResizable(True) 

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

207 self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 

208 # No explicit styling - let it inherit from parent 

209 

210 # Add form manager directly to scroll area (like config window) 

211 self.scroll_area.setWidget(self.form_manager) 

212 layout.addWidget(self.scroll_area) 

213 

214 def _get_button_style(self) -> str: 

215 """Get consistent button styling.""" 

216 return """ 

217 QPushButton { 

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

219 color: white; 

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

221 border-radius: 3px; 

222 padding: 6px 12px; 

223 font-size: 11px; 

224 } 

225 QPushButton:hover { 

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

227 } 

228 QPushButton:pressed { 

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

230 } 

231 """ 

232 

233 def setup_connections(self): 

234 """Setup signal/slot connections.""" 

235 # Connect form manager parameter changes 

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

237 

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

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

240 try: 

241 # Get the properly converted value from the form manager 

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

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

244 

245 # Debug: Check what we're actually saving 

246 if param_name == 'materialization_config': 

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

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

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

250 if hasattr(final_value, '__dataclass_fields__'): 

251 from dataclasses import fields 

252 for field_obj in fields(final_value): 

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

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

255 

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

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

258 try: 

259 import importlib 

260 module = importlib.import_module(final_value.__module__) 

261 final_value = getattr(module, final_value.__name__) 

262 except Exception: 

263 pass # Use original if refresh fails 

264 

265 # Update step attribute 

266 setattr(self.step, param_name, final_value) 

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

268 self.step_parameter_changed.emit() 

269 

270 except Exception as e: 

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

272 

273 def load_step_settings(self): 

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

275 if not self.service_adapter: 

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

277 return 

278 

279 from openhcs.core.path_cache import PathCacheKey 

280 

281 file_path = self.service_adapter.show_cached_file_dialog( 

282 cache_key=PathCacheKey.STEP_SETTINGS, 

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

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

285 mode="open" 

286 ) 

287 

288 if file_path: 

289 self._load_step_settings_from_file(file_path) 

290 

291 def save_step_settings(self): 

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

293 if not self.service_adapter: 

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

295 return 

296 

297 from openhcs.core.path_cache import PathCacheKey 

298 

299 file_path = self.service_adapter.show_cached_file_dialog( 

300 cache_key=PathCacheKey.STEP_SETTINGS, 

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

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

303 mode="save" 

304 ) 

305 

306 if file_path: 

307 self._save_step_settings_to_file(file_path) 

308 

309 def _load_step_settings_from_file(self, file_path: Path): 

310 """Load step settings from file.""" 

311 try: 

312 import dill as pickle 

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

314 step_data = pickle.load(f) 

315 

316 # Update form manager with loaded values 

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

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

319 self.form_manager.update_parameter(param_name, value) 

320 # Also update the step object 

321 if hasattr(self.step, param_name): 

322 setattr(self.step, param_name, value) 

323 

324 # Refresh the form to show loaded values 

325 self.form_manager._refresh_all_placeholders() 

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

327 

328 except Exception as e: 

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

330 if self.service_adapter: 

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

332 

333 def _save_step_settings_to_file(self, file_path: Path): 

334 """Save step settings to file.""" 

335 try: 

336 import dill as pickle 

337 # Get current values from form manager 

338 step_data = self.form_manager.get_current_values() 

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

340 pickle.dump(step_data, f) 

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

342 

343 except Exception as e: 

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

345 if self.service_adapter: 

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

347 

348 

349 def get_current_step(self) -> FunctionStep: 

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

351 return self.step 

352 

353 def update_step(self, step: FunctionStep): 

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

355 self.step = step 

356 

357 # Update form manager with new values 

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

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

360 self.form_manager.update_parameter(param_name, current_value) 

361 

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