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
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +0000
1"""
2Step Parameter Editor Widget for PyQt6 GUI.
4Mirrors the Textual TUI StepParameterEditorWidget with type hint-based form generation.
5Handles FunctionStep parameter editing with nested dataclass support.
6"""
8import logging
9from typing import Any, Optional
10from pathlib import Path
11from contextlib import contextmanager
13from PyQt6.QtWidgets import (
14 QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
15 QScrollArea, QFrame, QSizePolicy
16)
17from PyQt6.QtCore import Qt, pyqtSignal
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
29logger = logging.getLogger(__name__)
32class StepParameterEditorWidget(QWidget):
33 """
34 Step parameter editor using dynamic form generation.
36 Mirrors Textual TUI implementation - builds forms based on FunctionStep
37 constructor signature with nested dataclass support.
38 """
40 # Signals
41 step_parameter_changed = pyqtSignal()
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)
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()
51 self.step = step
52 self.service_adapter = service_adapter
53 self.pipeline_config = pipeline_config # Store pipeline config for context hierarchy
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")
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__)
68 # Get current parameter values from step instance
69 parameters = {}
70 parameter_types = {}
71 param_defaults = {}
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)
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
91 parameters[name] = current_value
92 parameter_types[name] = info.param_type
94 # SIMPLIFIED: Create parameter form manager using dual-axis resolution
95 from openhcs.core.config import GlobalPipelineConfig
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 )
107 self.setup_ui()
108 self.setup_connections()
110 logger.debug(f"Step parameter editor initialized for step: {getattr(step, 'name', 'Unknown')}")
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.
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
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
127 # Check if parameter is Optional[dataclass]
128 if not ParameterTypeUtils.is_optional_dataclass(param_type):
129 return False
131 # Get the inner dataclass type
132 inner_type = ParameterTypeUtils.get_optional_inner_type(param_type)
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
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
148 def _find_pipeline_field_by_type(self, target_type):
149 """
150 Find the field in PipelineConfig that matches the target type.
152 This is type-based discovery - no manual mappings needed.
153 """
154 from openhcs.core.config import PipelineConfig
155 import dataclasses
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
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.)
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)
180 # Header with controls (like FunctionListEditorWidget)
181 header_layout = QHBoxLayout()
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)
188 header_layout.addStretch()
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)
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)
203 layout.addLayout(header_layout)
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 """)
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)
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
228 # Set size policy to allow form frame to expand
229 form_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
231 form_layout = QVBoxLayout(form_frame)
233 # Add parameter form manager
234 form_layout.addWidget(self.form_manager)
236 # Add form frame with stretch factor to make it expand
237 container_layout.addWidget(form_frame, 1) # stretch factor = 1
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)
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 """
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)
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)
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}")
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
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()
299 except Exception as e:
300 logger.error(f"Error updating step parameter {param_name}: {e}")
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
308 from openhcs.core.path_cache import PathCacheKey
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 )
317 if file_path:
318 self._load_step_settings_from_file(file_path)
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
326 from openhcs.core.path_cache import PathCacheKey
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 )
335 if file_path:
336 self._save_step_settings_to_file(file_path)
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)
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)
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}")
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}")
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}")
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}")
378 def get_current_step(self) -> FunctionStep:
379 """Get the current step with all parameter values."""
380 return self.step
382 def update_step(self, step: FunctionStep):
383 """Update the step and refresh the form."""
384 self.step = step
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)
391 logger.debug(f"Updated step parameter editor for step: {getattr(step, 'name', 'Unknown')}")