Coverage for openhcs/textual_tui/widgets/shared/parameter_form_manager.py: 0.0%
458 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
1# File: openhcs/textual_tui/widgets/shared/parameter_form_manager.py
3import dataclasses
4import ast
5import logging
6from enum import Enum
7from typing import Any, Dict, get_origin, get_args, Union, Optional, Type
9logger = logging.getLogger(__name__)
10from textual.containers import Vertical, Horizontal
11from textual.widgets import Static, Button, Collapsible
12from textual.app import ComposeResult
14from .typed_widget_factory import TypedWidgetFactory
15from .signature_analyzer import SignatureAnalyzer
16from .clickable_help_label import ClickableParameterLabel, HelpIndicator
17from ..different_values_input import DifferentValuesInput
19# Import simplified abstraction layer
20from openhcs.ui.shared.parameter_form_abstraction import (
21 ParameterFormAbstraction, apply_lazy_default_placeholder
22)
23from openhcs.ui.shared.widget_creation_registry import create_textual_registry
24from openhcs.ui.shared.textual_widget_strategies import create_different_values_widget
26class ParameterFormManager:
27 """Mathematical: (parameters, types, field_id) → parameter form"""
29 def __init__(self, parameters: Dict[str, Any], parameter_types: Dict[str, type], field_id: str, parameter_info: Dict = None, is_global_config_editing: bool = False, global_config_type: Optional[Type] = None, placeholder_prefix: str = "Pipeline default"):
30 # Initialize simplified abstraction layer
31 self.form_abstraction = ParameterFormAbstraction(
32 parameters, parameter_types, field_id, create_textual_registry(), parameter_info
33 )
35 # Maintain backward compatibility
36 self.parameters = parameters.copy()
37 self.parameter_types = parameter_types
38 self.field_id = field_id
39 self.parameter_info = parameter_info or {}
40 self.is_global_config_editing = is_global_config_editing
41 self.global_config_type = global_config_type
42 self.placeholder_prefix = placeholder_prefix
44 def build_form(self) -> ComposeResult:
45 """Build parameter form - pure function with recursive dataclass support."""
46 with Vertical() as form:
47 form.styles.height = "auto"
49 for param_name, param_type in self.parameter_types.items():
50 current_value = self.parameters[param_name]
52 # Handle Optional[dataclass] types with checkbox wrapper
53 if self._is_optional_dataclass(param_type):
54 inner_dataclass_type = self._get_optional_inner_type(param_type)
55 yield from self._build_optional_dataclass_form(param_name, inner_dataclass_type, current_value)
56 # Handle nested dataclasses recursively
57 elif dataclasses.is_dataclass(param_type):
58 yield from self._build_nested_dataclass_form(param_name, param_type, current_value)
59 else:
60 yield from self._build_regular_parameter_form(param_name, param_type, current_value)
62 def _build_nested_dataclass_form(self, param_name: str, param_type: type, current_value: Any) -> ComposeResult:
63 """Build form for nested dataclass parameter."""
64 # Create collapsible widget (no ID - just structure)
65 collapsible = TypedWidgetFactory.create_widget(param_type, current_value, None)
67 # Analyze nested dataclass
68 nested_param_info = SignatureAnalyzer.analyze(param_type)
70 # Get current values from nested dataclass instance
71 nested_parameters = {}
72 nested_parameter_types = {}
74 for nested_name, nested_info in nested_param_info.items():
75 if current_value:
76 # For lazy dataclasses, preserve None values for placeholder behavior
77 if hasattr(current_value, '_resolve_field_value'):
78 nested_current_value = object.__getattribute__(current_value, nested_name) if hasattr(current_value, nested_name) else nested_info.default_value
79 else:
80 nested_current_value = getattr(current_value, nested_name, nested_info.default_value)
81 else:
82 nested_current_value = nested_info.default_value
83 nested_parameters[nested_name] = nested_current_value
84 nested_parameter_types[nested_name] = nested_info.param_type
86 # Create nested form manager with hierarchical underscore notation and parameter info
87 nested_field_id = f"{self.field_id}_{param_name}"
88 nested_form_manager = ParameterFormManager(nested_parameters, nested_parameter_types, nested_field_id, nested_param_info)
90 # Store the parent dataclass type for proper lazy resolution detection
91 nested_form_manager._parent_dataclass_type = param_type
93 # Store reference to nested form manager for updates
94 if not hasattr(self, 'nested_managers'):
95 self.nested_managers = {}
96 self.nested_managers[param_name] = nested_form_manager
98 # Build nested form and add to collapsible
99 with collapsible:
100 yield from nested_form_manager.build_form()
102 yield collapsible
104 def _is_optional_dataclass(self, param_type: type) -> bool:
105 """Check if parameter type is Optional[dataclass]."""
106 from typing import get_origin, get_args, Union
107 if get_origin(param_type) is Union:
108 args = get_args(param_type)
109 if len(args) == 2 and type(None) in args:
110 inner_type = next(arg for arg in args if arg is not type(None))
111 return dataclasses.is_dataclass(inner_type)
112 return False
114 def _get_optional_inner_type(self, param_type: type) -> type:
115 """Extract the inner type from Optional[T]."""
116 from typing import get_origin, get_args, Union
117 if get_origin(param_type) is Union:
118 args = get_args(param_type)
119 if len(args) == 2 and type(None) in args:
120 return next(arg for arg in args if arg is not type(None))
121 return param_type
123 def _build_optional_dataclass_form(self, param_name: str, dataclass_type: type, current_value: Any) -> ComposeResult:
124 """Build form for Optional[dataclass] parameter with checkbox toggle."""
125 from textual.widgets import Checkbox
127 # Checkbox
128 checkbox_id = f"{self.field_id}_{param_name}_enabled"
129 checkbox = Checkbox(
130 value=current_value is not None,
131 label=f"Enable {param_name.replace('_', ' ').title()}",
132 id=checkbox_id,
133 compact=True
134 )
135 yield checkbox
137 # Collapsible dataclass widget
138 collapsible = TypedWidgetFactory.create_widget(dataclass_type, current_value, None)
139 collapsible.collapsed = (current_value is None)
141 # Setup nested form
142 nested_param_info = SignatureAnalyzer.analyze(dataclass_type)
143 nested_parameters = {}
144 for name, info in nested_param_info.items():
145 if current_value:
146 # For lazy dataclasses, preserve None values for placeholder behavior
147 if hasattr(current_value, '_resolve_field_value'):
148 value = object.__getattribute__(current_value, name) if hasattr(current_value, name) else info.default_value
149 else:
150 value = getattr(current_value, name, info.default_value)
151 else:
152 value = info.default_value
153 nested_parameters[name] = value
154 nested_parameter_types = {name: info.param_type for name, info in nested_param_info.items()}
156 nested_form_manager = ParameterFormManager(
157 nested_parameters, nested_parameter_types, f"{self.field_id}_{param_name}", nested_param_info,
158 is_global_config_editing=self.is_global_config_editing
159 )
161 # Store the parent dataclass type for proper lazy resolution detection
162 nested_form_manager._parent_dataclass_type = dataclass_type
164 # Store references
165 if not hasattr(self, 'nested_managers'):
166 self.nested_managers = {}
167 if not hasattr(self, 'optional_checkboxes'):
168 self.optional_checkboxes = {}
169 self.nested_managers[param_name] = nested_form_manager
170 self.optional_checkboxes[param_name] = checkbox
172 with collapsible:
173 yield from nested_form_manager.build_form()
174 yield collapsible
176 def _build_regular_parameter_form(self, param_name: str, param_type: type, current_value: Any) -> ComposeResult:
177 """Build form for regular (non-dataclass) parameter."""
178 # Check if this field has different values across orchestrators
179 config_analysis = getattr(self, 'config_analysis', {})
180 field_analysis = config_analysis.get(param_name, {})
182 # Create widget using hierarchical underscore notation
183 widget_id = f"{self.field_id}_{param_name}"
185 # Handle different values or create normal widget
186 if field_analysis.get('type') == 'different':
187 default_value = field_analysis.get('default')
188 input_widget = create_different_values_widget(param_name, param_type, default_value, widget_id)
189 else:
190 # Use registry for widget creation and apply placeholder
191 widget_value = current_value.value if hasattr(current_value, 'value') else current_value
192 input_widget = self.form_abstraction.create_widget_for_parameter(param_name, param_type, widget_value)
193 apply_lazy_default_placeholder(input_widget, param_name, current_value, self.parameter_types, 'textual',
194 is_global_config_editing=self.is_global_config_editing,
195 global_config_type=self.global_config_type,
196 placeholder_prefix=self.placeholder_prefix)
198 # Get parameter info for help functionality
199 param_info = self._get_parameter_info(param_name)
200 param_description = param_info.description if param_info else None
202 # 3-column layout: label + input + reset
203 with Horizontal() as row:
204 row.styles.height = "auto"
206 # Parameter label with help (auto width - sizes to content)
207 # Always use clickable label with help - provide default description if none exists
208 description = param_description or f"Parameter: {param_name.replace('_', ' ')}"
209 label = ClickableParameterLabel(
210 param_name,
211 description,
212 param_type,
213 classes="param-label clickable"
214 )
216 label.styles.width = "auto"
217 label.styles.text_align = "left"
218 label.styles.height = "1"
219 yield label
221 # Input widget (flexible width, left aligned, with left margin for spacing)
222 input_widget.styles.width = "1fr"
223 input_widget.styles.text_align = "left"
224 input_widget.styles.margin = (0, 0, 0, 1) # top, right, bottom, left margin
225 yield input_widget
227 # Reset button (auto width)
228 reset_btn = Button("Reset", id=f"reset_{widget_id}", compact=True)
229 reset_btn.styles.width = "auto"
230 yield reset_btn
232 def update_parameter(self, param_name: str, value: Any):
233 """Update parameter value with centralized enum conversion and nested dataclass support."""
234 # Debug: Check if None values are being received and processed (path_planning only)
235 if param_name == 'output_dir_suffix' or param_name == 'path_planning':
236 logger.info(f"*** TEXTUAL UPDATE DEBUG *** {param_name} update_parameter called with: {value} (type: {type(value)})")
237 if param_name == 'path_planning':
238 import traceback
239 logger.info(f"*** PATH_PLANNING SOURCE *** Call stack:")
240 for line in traceback.format_stack()[-5:]:
241 logger.info(f"*** PATH_PLANNING SOURCE *** {line.strip()}")
242 # Parse hierarchical parameter name (e.g., "path_planning_global_output_folder")
243 # Split and check if this is a nested parameter
244 parts = param_name.split('_')
245 if len(parts) >= 2: # nested_field format
246 # Try to find nested manager by checking all possible prefixes
247 for i in range(1, len(parts)):
248 potential_nested = '_'.join(parts[:i])
249 if potential_nested in self.parameters and hasattr(self, 'nested_managers') and potential_nested in self.nested_managers:
250 # Reconstruct the nested field name from remaining parts
251 nested_field = '_'.join(parts[i:])
253 # Update nested form manager
254 if potential_nested == 'path_planning':
255 logger.info(f"*** NESTED MANAGER UPDATE *** Updating {potential_nested}.{nested_field} = {value}")
256 self.nested_managers[potential_nested].update_parameter(nested_field, value)
258 # Rebuild nested dataclass instance with lazy/concrete mixed behavior
259 nested_values = self.nested_managers[potential_nested].get_current_values()
261 # Debug: Check what values the nested manager is returning
262 if potential_nested == 'path_planning':
263 logger.info(f"*** NESTED VALUES DEBUG *** nested_values from {potential_nested}: {nested_values}")
264 if 'output_dir_suffix' in nested_values:
265 logger.info(f"*** NESTED VALUES DEBUG *** output_dir_suffix in nested_values: {nested_values['output_dir_suffix']} (type: {type(nested_values['output_dir_suffix'])})")
267 # Also check what's in the nested manager's parameters directly
268 nested_params = self.nested_managers[potential_nested].parameters
269 logger.info(f"*** NESTED VALUES DEBUG *** nested_manager.parameters: {nested_params}")
270 if 'output_dir_suffix' in nested_params:
271 logger.info(f"*** NESTED VALUES DEBUG *** output_dir_suffix in nested_manager.parameters: {nested_params['output_dir_suffix']} (type: {type(nested_params['output_dir_suffix'])})")
273 nested_type = self.parameter_types[potential_nested]
275 # Resolve Union types (like Optional[DataClass]) to the actual dataclass type
276 if self._is_optional_dataclass(nested_type):
277 nested_type = self._get_optional_inner_type(nested_type)
279 # Create lazy dataclass instance with mixed concrete/lazy fields
280 if self.is_global_config_editing:
281 # Global config editing: use concrete dataclass
282 self.parameters[potential_nested] = nested_type(**nested_values)
283 else:
284 # Lazy context: always create lazy instance for thread-local resolution
285 # Even if all values are None (especially after reset), we want lazy resolution
286 from openhcs.core.lazy_config import LazyDataclassFactory
288 # Determine the correct field path using type inspection
289 field_path = self._get_field_path_for_nested_type(nested_type)
291 lazy_nested_type = LazyDataclassFactory.make_lazy_thread_local(
292 base_class=nested_type,
293 field_path=field_path,
294 lazy_class_name=f"Mixed{nested_type.__name__}"
295 )
296 # Pass ALL fields: concrete values for edited fields, None for lazy resolution
297 self.parameters[potential_nested] = lazy_nested_type(**nested_values)
298 return
300 # Handle regular parameters (direct match)
301 if param_name in self.parameters:
302 # Handle literal "None" string - convert back to Python None
303 if isinstance(value, str) and value == "None":
304 value = None
306 # Convert string back to proper type (comprehensive conversion)
307 # Skip type conversion for None values (preserve for lazy placeholder behavior)
308 if param_name in self.parameter_types and value is not None:
309 param_type = self.parameter_types[param_name]
310 if hasattr(param_type, '__bases__') and Enum in param_type.__bases__:
311 value = param_type(value) # Convert string → enum
312 elif self._is_list_of_enums(param_type):
313 # Handle List[Enum] types (like List[VariableComponents])
314 enum_type = self._get_enum_from_list(param_type)
315 if enum_type:
316 # Convert string value to enum, then wrap in list
317 enum_value = enum_type(value)
318 value = [enum_value]
319 elif param_type == float:
320 # Convert string → float, handle empty based on parameter requirements
321 try:
322 if value == "":
323 # For empty values, we need to check if parameter is required
324 # This requires access to parameter info, but we don't have it here
325 # For now, convert empty to None (safer than 0.0)
326 value = None
327 else:
328 value = float(value)
329 except (ValueError, TypeError):
330 value = None # Use None instead of 0.0 for failed conversions
331 elif param_type == int:
332 # Convert string → int, handle empty based on parameter requirements
333 try:
334 if value == "":
335 # For empty values, convert to None (safer than 0)
336 value = None
337 else:
338 value = int(value)
339 except (ValueError, TypeError):
340 value = None # Use None instead of 0 for failed conversions
341 elif param_type == bool:
342 # Convert string → bool
343 if isinstance(value, str):
344 value = value.lower() in ('true', '1', 'yes', 'on')
345 # Add more type conversions as needed
347 self.parameters[param_name] = value
349 # FALLBACK: If this is a nested field that bypassed the nested logic, update the nested manager
350 if param_name == 'output_dir_suffix':
351 logger.info(f"*** FALLBACK DEBUG *** Checking fallback for {param_name}")
352 logger.info(f"*** FALLBACK DEBUG *** hasattr nested_managers: {hasattr(self, 'nested_managers')}")
353 if hasattr(self, 'nested_managers'):
354 logger.info(f"*** FALLBACK DEBUG *** nested_managers keys: {list(self.nested_managers.keys())}")
355 for nested_name, nested_manager in self.nested_managers.items():
356 logger.info(f"*** FALLBACK DEBUG *** Checking {nested_name}, parameter_types: {list(nested_manager.parameter_types.keys())}")
357 if param_name in nested_manager.parameter_types:
358 logger.info(f"*** FALLBACK UPDATE *** Updating nested manager {nested_name}.{param_name} = {value}")
359 nested_manager.parameters[param_name] = value
360 break
361 else:
362 logger.info(f"*** FALLBACK DEBUG *** {param_name} not found in {nested_name}")
363 else:
364 logger.info(f"*** FALLBACK DEBUG *** No nested_managers attribute")
365 elif hasattr(self, 'nested_managers'):
366 for nested_name, nested_manager in self.nested_managers.items():
367 if param_name in nested_manager.parameter_types:
368 nested_manager.parameters[param_name] = value
369 break
371 # Debug: Check what was actually stored (path_planning only)
372 if param_name == 'output_dir_suffix' or param_name == 'path_planning':
373 stored_value = self.parameters.get(param_name)
374 logger.info(f"*** TEXTUAL UPDATE DEBUG *** {param_name} stored as: {stored_value} (type: {type(stored_value)})")
376 def reset_parameter(self, param_name: str, default_value: Any = None):
377 """Reset parameter to appropriate default value based on lazy vs concrete dataclass context."""
378 # Determine the correct reset value if not provided
379 if default_value is None:
380 default_value = self._get_reset_value_for_parameter(param_name)
382 # Parse hierarchical parameter name for nested parameters
383 parts = param_name.split('_')
384 if len(parts) >= 2: # nested_field format
385 # Try to find nested manager by checking all possible prefixes
386 for i in range(1, len(parts)):
387 potential_nested = '_'.join(parts[:i])
388 if potential_nested in self.parameters and hasattr(self, 'nested_managers') and potential_nested in self.nested_managers:
389 # Reconstruct the nested field name
390 nested_field = '_'.join(parts[i:])
392 # Get appropriate reset value for nested field
393 nested_reset_value = self._get_reset_value_for_nested_parameter(potential_nested, nested_field)
395 # Reset in nested form manager
396 self.nested_managers[potential_nested].reset_parameter(nested_field, nested_reset_value)
398 # Rebuild nested dataclass instance
399 nested_values = self.nested_managers[potential_nested].get_current_values()
401 # Resolve Union types (like Optional[DataClass]) to the actual dataclass type
402 if self._is_optional_dataclass(self.parameter_types[potential_nested]):
403 nested_type = self._get_optional_inner_type(self.parameter_types[potential_nested])
404 else:
405 nested_type = self.parameter_types[potential_nested]
407 # Create lazy dataclass instance with mixed concrete/lazy fields
408 if self.is_global_config_editing:
409 # Global config editing: use concrete dataclass
410 self.parameters[potential_nested] = nested_type(**nested_values)
411 else:
412 # Lazy context: always create lazy instance for thread-local resolution
413 # Even if all values are None (especially after reset), we want lazy resolution
414 from openhcs.core.lazy_config import LazyDataclassFactory
416 # Determine the correct field path using type inspection
417 field_path = self._get_field_path_for_nested_type(nested_type)
419 lazy_nested_type = LazyDataclassFactory.make_lazy_thread_local(
420 base_class=nested_type,
421 field_path=field_path,
422 lazy_class_name=f"Mixed{nested_type.__name__}"
423 )
424 # Pass ALL fields: concrete values for edited fields, None for lazy resolution
425 self.parameters[potential_nested] = lazy_nested_type(**nested_values)
426 return
428 # Handle regular parameters
429 if param_name in self.parameters:
430 self.parameters[param_name] = default_value
432 # Handle special reset behavior for DifferentValuesInput widgets
433 self._handle_different_values_reset(param_name)
435 # Re-apply placeholder styling if value is None (for reset functionality)
436 if default_value is None:
437 self._reapply_placeholder_if_needed(param_name)
439 def _reapply_placeholder_if_needed(self, param_name: str):
440 """Re-apply placeholder styling to a widget when its value is set to None."""
441 # For Textual, we need to find the widget and re-apply placeholder
442 # This is more complex than PyQt since Textual widgets are reactive
443 # For now, we'll rely on the reactive nature of Textual widgets
444 # The placeholder should be re-applied automatically when the value changes to None
445 pass
447 def _get_reset_value_for_parameter(self, param_name: str) -> Any:
448 """
449 Get the appropriate reset value for a parameter based on lazy vs concrete dataclass context.
451 For concrete dataclasses (like GlobalPipelineConfig):
452 - Reset to static class defaults
454 For lazy dataclasses (like PipelineConfig for orchestrator configs):
455 - Reset to None to preserve placeholder behavior and inheritance hierarchy
456 """
457 if param_name not in self.parameter_info:
458 return None
460 param_info = self.parameter_info[param_name]
461 param_type = self.parameter_types[param_name]
463 # For global config editing, always use static defaults
464 if self.is_global_config_editing:
465 return param_info.default_value
467 # For nested dataclass fields, check if we should use concrete values
468 if hasattr(param_type, '__dataclass_fields__'):
469 # This is a dataclass field - determine if it should be concrete or None
470 current_value = self.parameters.get(param_name)
471 if self._should_use_concrete_nested_values(current_value):
472 # Use static default for concrete nested dataclass
473 return param_info.default_value
474 else:
475 # Use None for lazy nested dataclass to preserve placeholder behavior
476 return None
478 # For non-dataclass fields in lazy context, use None to preserve placeholder behavior
479 # This allows the field to inherit from the parent config hierarchy
480 if not self.is_global_config_editing:
481 return None
483 # Fallback to static default
484 return param_info.default_value
486 def _get_reset_value_for_nested_parameter(self, nested_param_name: str, nested_field_name: str) -> Any:
487 """Get appropriate reset value for a nested parameter field."""
488 nested_type = self.parameter_types[nested_param_name]
489 nested_param_info = SignatureAnalyzer.analyze(nested_type)
491 if nested_field_name not in nested_param_info:
492 return None
494 nested_field_info = nested_param_info[nested_field_name]
496 # For global config editing, always use static defaults
497 if self.is_global_config_editing:
498 return nested_field_info.default_value
500 # For lazy context, check if nested dataclass should use concrete values
501 current_nested_value = self.parameters.get(nested_param_name)
502 if self._should_use_concrete_nested_values(current_nested_value):
503 return nested_field_info.default_value
504 else:
505 return None
507 def _get_field_path_for_nested_type(self, nested_type: Type) -> Optional[str]:
508 """
509 Automatically determine the field path for a nested dataclass type using type inspection.
511 This method examines the GlobalPipelineConfig fields and their type annotations
512 to find which field corresponds to the given nested_type. This eliminates the need
513 for hardcoded string mappings and automatically works with new nested dataclass fields.
515 Args:
516 nested_type: The dataclass type to find the field path for
518 Returns:
519 The field path string (e.g., 'path_planning', 'vfs') or None if not found
520 """
521 try:
522 from openhcs.core.config import GlobalPipelineConfig
523 from dataclasses import fields
524 import typing
526 # Get all fields from GlobalPipelineConfig
527 global_config_fields = fields(GlobalPipelineConfig)
529 for field in global_config_fields:
530 field_type = field.type
532 # Handle Optional types (Union[Type, None])
533 if hasattr(typing, 'get_origin') and typing.get_origin(field_type) is typing.Union:
534 # Get the non-None type from Optional[Type]
535 args = typing.get_args(field_type)
536 if len(args) == 2 and type(None) in args:
537 field_type = args[0] if args[1] is type(None) else args[1]
539 # Check if the field type matches our nested type
540 if field_type == nested_type:
541 return field.name
545 return None
547 except Exception as e:
548 # Fallback to None if type inspection fails
549 import logging
550 logger = logging.getLogger(__name__)
551 logger.debug(f"Failed to determine field path for {nested_type.__name__}: {e}")
552 return None
554 def _should_use_concrete_nested_values(self, current_value: Any) -> bool:
555 """
556 Determine if nested dataclass fields should use concrete values or None for placeholders.
557 This mirrors the logic from the PyQt form manager.
559 Returns True if:
560 1. Global config editing (always concrete)
561 2. Regular concrete dataclass (always concrete)
563 Returns False if:
564 1. Lazy dataclass (supports mixed lazy/concrete states per field)
565 2. None values (show placeholders)
567 Note: This method now supports mixed states within nested dataclasses.
568 Individual fields can be lazy (None) or concrete within the same dataclass.
569 """
570 # Global config editing always uses concrete values
571 if self.is_global_config_editing:
572 return True
574 # If current_value is None, use placeholders
575 if current_value is None:
576 return False
578 # If current_value is a concrete dataclass instance, use its values
579 if hasattr(current_value, '__dataclass_fields__') and not hasattr(current_value, '_resolve_field_value'):
580 return True
582 # For lazy dataclasses, always return False to enable mixed lazy/concrete behavior
583 # Individual field values will be checked separately in the nested form creation
584 if hasattr(current_value, '_resolve_field_value'):
585 return False
587 # Default to placeholder behavior for lazy contexts
588 return False
590 def handle_optional_checkbox_change(self, param_name: str, enabled: bool):
591 """Handle checkbox change for Optional[dataclass] parameters."""
592 if param_name in self.parameter_types and self._is_optional_dataclass(self.parameter_types[param_name]):
593 dataclass_type = self._get_optional_inner_type(self.parameter_types[param_name])
594 nested_managers = getattr(self, 'nested_managers', {})
595 self.parameters[param_name] = (
596 dataclass_type(**nested_managers[param_name].get_current_values())
597 if enabled and param_name in nested_managers
598 else dataclass_type() if enabled
599 else None
600 )
602 def _handle_different_values_reset(self, param_name: str):
603 """Handle reset behavior for DifferentValuesInput widgets."""
604 # Check if this field has different values across orchestrators
605 config_analysis = getattr(self, 'config_analysis', {})
606 field_analysis = config_analysis.get(param_name, {})
608 if field_analysis.get('type') == 'different':
609 # For different values fields, reset means go back to "DIFFERENT VALUES" state
610 # We need to find the widget and call its reset method
611 widget_id = f"{self.field_id}_{param_name}"
613 # This will be handled by the screen/container that manages the widgets
614 # The widget itself will handle the reset via its reset_to_different() method
615 # We just need to ensure the parameter value reflects the "different" state
616 pass # Widget-level reset will be handled by the containing screen
618 def reset_all_parameters(self, defaults: Dict[str, Any] = None):
619 """Reset all parameters to appropriate defaults based on lazy vs concrete dataclass context."""
620 # If no defaults provided, generate them based on context
621 if defaults is None:
622 defaults = {}
623 for param_name in self.parameters.keys():
624 defaults[param_name] = self._get_reset_value_for_parameter(param_name)
626 for param_name, default_value in defaults.items():
627 if param_name in self.parameters:
628 # Handle nested dataclasses
629 if dataclasses.is_dataclass(self.parameter_types.get(param_name)):
630 if hasattr(self, 'nested_managers') and param_name in self.nested_managers:
631 # Generate appropriate reset values for nested parameters
632 nested_type = self.parameter_types[param_name]
633 nested_param_info = SignatureAnalyzer.analyze(nested_type)
635 # Use lazy-aware reset logic for nested parameters with mixed state support
636 nested_defaults = {}
637 for nested_field_name in nested_param_info.keys():
638 # For nested fields in lazy contexts, always reset to None to preserve lazy behavior
639 # This ensures individual fields can maintain placeholder behavior regardless of other field states
640 if not self.is_global_config_editing:
641 nested_defaults[nested_field_name] = None
642 else:
643 nested_defaults[nested_field_name] = self._get_reset_value_for_nested_parameter(param_name, nested_field_name)
645 self.nested_managers[param_name].reset_all_parameters(nested_defaults)
647 # Rebuild nested dataclass instance
648 nested_values = self.nested_managers[param_name].get_current_values()
650 # Resolve Union types (like Optional[DataClass]) to the actual dataclass type
651 if self._is_optional_dataclass(nested_type):
652 nested_type = self._get_optional_inner_type(nested_type)
654 # Create lazy dataclass instance with mixed concrete/lazy fields
655 if self.is_global_config_editing:
656 # Global config editing: use concrete dataclass
657 self.parameters[param_name] = nested_type(**nested_values)
658 else:
659 # Lazy context: always create lazy instance for thread-local resolution
660 # Even if all values are None (especially after reset), we want lazy resolution
661 from openhcs.core.lazy_config import LazyDataclassFactory
663 # Determine the correct field path using type inspection
664 field_path = self._get_field_path_for_nested_type(nested_type)
666 lazy_nested_type = LazyDataclassFactory.make_lazy_thread_local(
667 base_class=nested_type,
668 field_path=field_path,
669 lazy_class_name=f"Mixed{nested_type.__name__}"
670 )
671 # Pass ALL fields: concrete values for edited fields, None for lazy resolution
672 self.parameters[param_name] = lazy_nested_type(**nested_values)
673 else:
674 self.parameters[param_name] = default_value
675 else:
676 self.parameters[param_name] = default_value
678 def reset_parameter_by_path(self, parameter_path: str):
679 """Reset a parameter by its full path (supports nested parameters).
681 Args:
682 parameter_path: Either a simple parameter name (e.g., 'num_workers')
683 or a nested path (e.g., 'path_planning.output_dir_suffix')
684 """
685 if '.' in parameter_path:
686 # Handle nested parameter
687 parts = parameter_path.split('.', 1)
688 nested_name = parts[0]
689 nested_param = parts[1]
691 if hasattr(self, 'nested_managers') and nested_name in self.nested_managers:
692 nested_manager = self.nested_managers[nested_name]
693 if '.' in nested_param:
694 # Further nesting
695 nested_manager.reset_parameter_by_path(nested_param)
696 else:
697 # Direct nested parameter
698 nested_manager.reset_parameter(nested_param)
699 else:
700 logger.warning(f"Nested manager '{nested_name}' not found for parameter path '{parameter_path}'")
701 else:
702 # Handle top-level parameter
703 self.reset_parameter(parameter_path)
705 def _is_list_of_enums(self, param_type) -> bool:
706 """Check if parameter type is List[Enum]."""
707 try:
708 # Check if it's a generic type (like List[Something])
709 origin = get_origin(param_type)
710 if origin is list:
711 # Get the type arguments (e.g., VariableComponents from List[VariableComponents])
712 args = get_args(param_type)
713 if args and len(args) > 0:
714 inner_type = args[0]
715 # Check if the inner type is an enum
716 return hasattr(inner_type, '__bases__') and Enum in inner_type.__bases__
717 return False
718 except Exception:
719 return False
721 def _get_enum_from_list(self, param_type):
722 """Extract enum type from List[Enum] type."""
723 return self._get_enum_from_list_static(param_type)
725 @staticmethod
726 def _is_list_of_enums_static(param_type) -> bool:
727 """Static version of _is_list_of_enums for use in convert_string_to_type."""
728 try:
729 # Check if it's a generic type (like List[Something])
730 origin = get_origin(param_type)
731 if origin is list:
732 # Get the type arguments (e.g., VariableComponents from List[VariableComponents])
733 args = get_args(param_type)
734 if args and len(args) > 0:
735 inner_type = args[0]
736 # Check if the inner type is an enum
737 return hasattr(inner_type, '__bases__') and Enum in inner_type.__bases__
738 return False
739 except Exception:
740 return False
742 @staticmethod
743 def _get_enum_from_list_static(param_type):
744 """Static version of _get_enum_from_list for use in convert_string_to_type."""
745 try:
746 args = get_args(param_type)
747 if args and len(args) > 0:
748 return args[0] # Return the enum type (e.g., VariableComponents)
749 return None
750 except Exception:
751 return None
753 def get_current_values(self) -> Dict[str, Any]:
754 """Get current parameter values."""
755 return self.parameters.copy()
757 def _get_parameter_info(self, param_name: str):
758 """Get parameter info for help functionality."""
759 return self.parameter_info.get(param_name)
761 # Old placeholder methods removed - now using centralized abstraction layer
763 @staticmethod
764 def convert_string_to_type(string_value: str, param_type: type, strict: bool = False) -> Any:
765 """
766 Convert string input to expected type using existing type conversion logic.
768 Args:
769 string_value: The string value from user input
770 param_type: The expected type from function signature
771 strict: If True, raise errors on conversion failure. If False, return None.
773 Returns:
774 Converted value of the expected type
776 Raises:
777 ValueError: If strict=True and conversion fails with specific error message
778 """
779 # Handle empty/None values - let compiler validate if required
780 if string_value == "" or string_value is None:
781 return None
783 try:
784 # Handle Union types (like Optional[List[float]] which is Union[List[float], None])
785 origin = get_origin(param_type)
786 if origin is Union:
787 # Try each type in the Union until one works
788 union_args = get_args(param_type)
789 last_error = None
791 for union_type in union_args:
792 # Skip NoneType - we handle None separately
793 if union_type is type(None):
794 continue
796 try:
797 # Recursively try to convert to this union member type
798 return ParameterFormManager.convert_string_to_type(string_value, union_type, strict=True)
799 except (ValueError, TypeError, SyntaxError) as e:
800 last_error = e
801 continue
803 # If no union type worked, raise the last error
804 if last_error:
805 raise last_error
806 else:
807 raise ValueError(f"No valid conversion found for Union type {param_type}")
809 # Use existing type conversion logic from update_parameter
810 elif hasattr(param_type, '__bases__') and Enum in param_type.__bases__:
811 return param_type(string_value) # Convert string → enum
812 elif ParameterFormManager._is_list_of_enums_static(param_type):
813 # Handle List[Enum] types (like List[VariableComponents])
814 enum_type = ParameterFormManager._get_enum_from_list_static(param_type)
815 if enum_type:
816 # Convert string value to enum, then wrap in list
817 enum_value = enum_type(string_value)
818 return [enum_value]
819 elif param_type == float:
820 return float(string_value)
821 elif param_type == int:
822 return int(string_value)
823 elif param_type == bool:
824 # Convert string → bool
825 return string_value.lower() in ('true', '1', 'yes', 'on')
826 elif param_type in (list, tuple, dict):
827 # Use ast.literal_eval for complex types like [1,2,3], (1,2), {"a":1}
828 return ast.literal_eval(string_value)
829 elif get_origin(param_type) in (list, tuple, dict):
830 # Handle generic types like List[float], Tuple[int, int], Dict[str, int]
831 # Use ast.literal_eval since List("[1]") doesn't work, but ast.literal_eval("[1]") does
832 return ast.literal_eval(string_value)
833 elif param_type is Any:
834 # No type hints available - try ast.literal_eval for Python literals
835 try:
836 return ast.literal_eval(string_value)
837 except (ValueError, SyntaxError):
838 # If literal_eval fails, return as string
839 return string_value
840 else:
841 # For everything else, try calling the type directly
842 return param_type(string_value)
844 except (ValueError, TypeError, SyntaxError) as e:
845 if strict:
846 # Provide specific error message for user
847 raise ValueError(f"Cannot convert '{string_value}' to {param_type.__name__}: {e}")
848 else:
849 # Silent failure - return None (existing behavior)
850 return None
852 def _create_nested_managers_for_testing(self):
853 """Create nested managers without building widgets (for testing)."""
854 for param_name, param_type in self.parameter_types.items():
855 current_value = self.parameters[param_name]
857 # Handle nested dataclasses
858 if dataclasses.is_dataclass(param_type):
859 # Analyze nested dataclass
860 nested_param_info = SignatureAnalyzer.analyze(param_type)
862 # Get current values from nested dataclass instance
863 nested_parameters = {}
864 nested_parameter_types = {}
866 for nested_name, nested_info in nested_param_info.items():
867 if current_value:
868 # For lazy dataclasses, preserve None values for placeholder behavior
869 if hasattr(current_value, '_resolve_field_value'):
870 nested_current_value = object.__getattribute__(current_value, nested_name) if hasattr(current_value, nested_name) else nested_info.default_value
871 else:
872 nested_current_value = getattr(current_value, nested_name, nested_info.default_value)
873 else:
874 nested_current_value = nested_info.default_value
875 nested_parameters[nested_name] = nested_current_value
876 nested_parameter_types[nested_name] = nested_info.param_type
878 # Create nested form manager with hierarchical underscore notation and parameter info
879 nested_field_id = f"{self.field_id}_{param_name}"
880 nested_form_manager = ParameterFormManager(nested_parameters, nested_parameter_types, nested_field_id, nested_param_info)
882 # Store reference to nested form manager for updates
883 if not hasattr(self, 'nested_managers'):
884 self.nested_managers = {}
885 self.nested_managers[param_name] = nested_form_manager