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
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +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
12from PyQt6.QtWidgets import (
13 QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
14 QScrollArea
15)
16from PyQt6.QtCore import Qt, pyqtSignal
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
27logger = logging.getLogger(__name__)
30class StepParameterEditorWidget(QWidget):
31 """
32 Step parameter editor using dynamic form generation.
34 Mirrors Textual TUI implementation - builds forms based on FunctionStep
35 constructor signature with nested dataclass support.
36 """
38 # Signals
39 step_parameter_changed = pyqtSignal()
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)
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()
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
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")
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__)
67 # Get current parameter values from step instance
68 parameters = {}
69 parameter_types = {}
70 param_defaults = {}
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)
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
90 parameters[name] = current_value
91 parameter_types[name] = info.param_type
93 # SIMPLIFIED: Create parameter form manager using dual-axis resolution
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 )
108 self.setup_ui()
109 self.setup_connections()
111 logger.debug(f"Step parameter editor initialized for step: {getattr(step, 'name', 'Unknown')}")
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.
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
122 No manual mappings needed - uses type-based discovery.
123 """
125 # Check if parameter is Optional[dataclass]
126 if not ParameterTypeUtils.is_optional_dataclass(param_type):
127 return False
129 # Get the inner dataclass type
130 inner_type = ParameterTypeUtils.get_optional_inner_type(param_type)
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
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
146 def _find_pipeline_field_by_type(self, target_type):
147 """
148 Find the field in PipelineConfig that matches the target type.
150 This is type-based discovery - no manual mappings needed.
151 """
152 from openhcs.core.config import PipelineConfig
153 import dataclasses
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
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.)
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)
178 # Header with controls (like FunctionListEditorWidget)
179 header_layout = QHBoxLayout()
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)
186 header_layout.addStretch()
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)
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)
201 layout.addLayout(header_layout)
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
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)
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 """
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)
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)
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}")
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
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()
270 except Exception as e:
271 logger.error(f"Error updating step parameter {param_name}: {e}")
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
279 from openhcs.core.path_cache import PathCacheKey
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 )
288 if file_path:
289 self._load_step_settings_from_file(file_path)
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
297 from openhcs.core.path_cache import PathCacheKey
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 )
306 if file_path:
307 self._save_step_settings_to_file(file_path)
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)
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)
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}")
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}")
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}")
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}")
349 def get_current_step(self) -> FunctionStep:
350 """Get the current step with all parameter values."""
351 return self.step
353 def update_step(self, step: FunctionStep):
354 """Update the step and refresh the form."""
355 self.step = step
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)
362 logger.debug(f"Updated step parameter editor for step: {getattr(step, 'name', 'Unknown')}")