Coverage for openhcs/ui/shared/parameter_form_service.py: 12.8%
229 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"""
2Shared service layer for parameter form managers.
4This module provides a framework-agnostic service layer that eliminates the
5architectural dependency between PyQt and Textual implementations by providing
6shared business logic and data management.
7"""
9import dataclasses
10from dataclasses import dataclass
11from typing import Dict, Any, Type, Optional, List, Tuple
13from openhcs.core.lazy_placeholder import LazyDefaultPlaceholderService
14# Old field path detection removed - using simple field name matching
15from openhcs.ui.shared.parameter_form_constants import CONSTANTS
16from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils
17from openhcs.ui.shared.ui_utils import debug_param, format_param_name
20@dataclass
21class ParameterInfo:
22 """
23 Information about a parameter for form generation.
25 Attributes:
26 name: Parameter name
27 type: Parameter type
28 current_value: Current parameter value
29 default_value: Default parameter value
30 description: Parameter description
31 is_required: Whether the parameter is required
32 is_nested: Whether the parameter is a nested dataclass
33 is_optional: Whether the parameter is Optional[T]
34 """
35 name: str
36 type: Type
37 current_value: Any
38 default_value: Any = None
39 description: Optional[str] = None
40 is_required: bool = True
41 is_nested: bool = False
42 is_optional: bool = False
45@dataclass
46class FormStructure:
47 """
48 Structure information for a parameter form.
50 Attributes:
51 field_id: Unique identifier for the form
52 parameters: List of parameter information
53 nested_forms: Dictionary of nested form structures
54 has_optional_dataclasses: Whether form has optional dataclass parameters
55 """
56 field_id: str
57 parameters: List[ParameterInfo]
58 nested_forms: Dict[str, 'FormStructure']
59 has_optional_dataclasses: bool = False
62class ParameterFormService:
63 """
64 Framework-agnostic service for parameter form business logic.
66 This service provides shared functionality for both PyQt and Textual
67 parameter form managers, eliminating the need for cross-framework
68 dependencies and providing a clean separation of concerns.
69 """
71 def __init__(self):
72 """
73 Initialize the parameter form service.
74 """
75 self._type_utils = ParameterTypeUtils()
77 def analyze_parameters(self, parameters: Dict[str, Any], parameter_types: Dict[str, Type],
78 field_id: str, parameter_info: Optional[Dict] = None,
79 parent_dataclass_type: Optional[Type] = None) -> FormStructure:
80 """
81 Analyze parameters and create form structure.
83 This method analyzes the parameters and their types to create a complete
84 form structure that can be used by any UI framework.
86 Args:
87 parameters: Dictionary of parameter names to current values
88 parameter_types: Dictionary of parameter names to types
89 field_id: Unique identifier for the form
90 parameter_info: Optional parameter information dictionary
91 parent_dataclass_type: Optional parent dataclass type for context
93 Returns:
94 Complete form structure information
95 """
96 debug_param("analyze_parameters", f"field_id={field_id}, parameter_count={len(parameters)}")
98 param_infos = []
99 nested_forms = {}
100 has_optional_dataclasses = False
102 for param_name, param_type in parameter_types.items():
103 current_value = parameters.get(param_name)
105 # Check if this parameter should be hidden from UI
106 if self._should_hide_from_ui(parent_dataclass_type, param_name, param_type):
107 debug_param("analyze_parameters", f"Hiding parameter {param_name} from UI (ui_hidden=True)")
108 continue
110 # Create parameter info
111 param_info = self._create_parameter_info(
112 param_name, param_type, current_value, parameter_info
113 )
114 param_infos.append(param_info)
116 # Check for nested dataclasses
117 if param_info.is_nested:
118 # Get actual field path from FieldPathDetector (no artificial "nested_" prefix)
119 # Unwrap Optional types to get the actual dataclass type for field path detection
120 unwrapped_param_type = self._type_utils.get_optional_inner_type(param_type) if self._type_utils.is_optional_dataclass(param_type) else param_type
122 # For function parameters (no parent dataclass), use parameter name directly
123 if parent_dataclass_type is None:
124 nested_field_id = param_name
125 else:
126 nested_field_id = self.get_field_path_with_fail_loud(parent_dataclass_type, unwrapped_param_type)
128 nested_structure = self._analyze_nested_dataclass(
129 param_name, param_type, current_value, nested_field_id, parent_dataclass_type
130 )
131 nested_forms[param_name] = nested_structure
133 # Check for optional dataclasses
134 if param_info.is_optional and param_info.is_nested:
135 has_optional_dataclasses = True
137 return FormStructure(
138 field_id=field_id,
139 parameters=param_infos,
140 nested_forms=nested_forms,
141 has_optional_dataclasses=has_optional_dataclasses
142 )
144 def _should_hide_from_ui(self, parent_dataclass_type: Optional[Type], param_name: str, param_type: Type) -> bool:
145 """
146 Check if a parameter should be hidden from the UI.
148 Args:
149 parent_dataclass_type: The parent dataclass type (None for function parameters)
150 param_name: Name of the parameter
151 param_type: Type of the parameter
153 Returns:
154 True if the parameter should be hidden from UI
155 """
156 import dataclasses
158 # If no parent dataclass, can't check field metadata
159 if parent_dataclass_type is None:
160 # Still check if the type itself has _ui_hidden
161 unwrapped_type = self._type_utils.get_optional_inner_type(param_type) if self._type_utils.is_optional_dataclass(param_type) else param_type
162 if hasattr(unwrapped_type, '__dict__') and '_ui_hidden' in unwrapped_type.__dict__ and unwrapped_type._ui_hidden:
163 return True
164 return False
166 # Check field metadata for ui_hidden flag
167 try:
168 field_obj = next(f for f in dataclasses.fields(parent_dataclass_type) if f.name == param_name)
169 if field_obj.metadata.get('ui_hidden', False):
170 return True
171 except (StopIteration, TypeError, AttributeError):
172 pass
174 # Check if type itself has _ui_hidden attribute
175 # IMPORTANT: Check __dict__ directly to avoid inheriting _ui_hidden from parent classes
176 unwrapped_type = self._type_utils.get_optional_inner_type(param_type) if self._type_utils.is_optional_dataclass(param_type) else param_type
177 if hasattr(unwrapped_type, '__dict__') and '_ui_hidden' in unwrapped_type.__dict__ and unwrapped_type._ui_hidden:
178 return True
180 return False
182 def convert_value_to_type(self, value: Any, param_type: Type, param_name: str, dataclass_type: Type = None) -> Any:
183 """
184 Convert a value to the appropriate type for a parameter.
186 This method provides centralized type conversion logic that can be
187 used by any UI framework.
189 Args:
190 value: The value to convert
191 param_type: The target parameter type
192 param_name: The parameter name (for debugging)
193 dataclass_type: The dataclass type (for sibling inheritance checks)
195 Returns:
196 The converted value
197 """
198 debug_param("convert_value", f"param={param_name}, input_type={type(value).__name__}, target_type={param_type.__name__ if hasattr(param_type, '__name__') else str(param_type)}")
200 if value is None:
201 return None
203 # Handle string "None" literal
204 if isinstance(value, str) and value == CONSTANTS.NONE_STRING_LITERAL:
205 return None
207 # Handle enum types
208 if self._type_utils.is_enum_type(param_type):
209 return param_type(value)
211 # Handle list of enums
212 if self._type_utils.is_list_of_enums(param_type):
213 # If value is already a list (from checkbox group widget), return as-is
214 if isinstance(value, list):
215 return value
216 enum_type = self._type_utils.get_enum_from_list_type(param_type)
217 if enum_type:
218 return [enum_type(value)]
220 # Handle Union types (e.g., Union[List[str], str, int])
221 # Try to convert to the most specific type that matches
222 from typing import get_origin, get_args, Union
223 if get_origin(param_type) is Union:
224 union_args = get_args(param_type)
225 # Filter out NoneType
226 non_none_types = [t for t in union_args if t is not type(None)]
228 # If value is a string, try to convert to int first, then keep as str
229 if isinstance(value, str) and value != CONSTANTS.EMPTY_STRING:
230 # Try int conversion first
231 if int in non_none_types:
232 try:
233 return int(value)
234 except (ValueError, TypeError):
235 pass
236 # Try float conversion
237 if float in non_none_types:
238 try:
239 return float(value)
240 except (ValueError, TypeError):
241 pass
242 # Keep as string if str is in the union
243 if str in non_none_types:
244 return value
246 # Handle basic types
247 if param_type == bool and isinstance(value, str):
248 return self._type_utils.convert_string_to_bool(value)
249 if param_type in (int, float) and isinstance(value, str):
250 if value == CONSTANTS.EMPTY_STRING:
251 return None
252 try:
253 return param_type(value)
254 except (ValueError, TypeError):
255 return None
257 # Handle empty strings in lazy context - convert to None for all parameter types
258 # This is critical for lazy dataclass behavior where None triggers placeholder resolution
259 if isinstance(value, str) and value == CONSTANTS.EMPTY_STRING:
260 return None
262 # Handle string types - also convert empty strings to None for consistency
263 if param_type == str and isinstance(value, str) and value == CONSTANTS.EMPTY_STRING:
264 return None
266 # Handle sibling-inheritable fields - allow None even for non-Optional types
267 if value is None and dataclass_type is not None:
268 from openhcs.core.config import is_field_sibling_inheritable
269 if is_field_sibling_inheritable(dataclass_type, param_name):
270 return None
272 return value
274 def get_parameter_display_info(self, param_name: str, param_type: Type,
275 description: Optional[str] = None) -> Dict[str, str]:
276 """
277 Get display information for a parameter.
279 Args:
280 param_name: The parameter name
281 param_type: The parameter type
282 description: Optional parameter description
284 Returns:
285 Dictionary with display information
286 """
287 return {
288 'display_name': format_param_name(param_name),
289 'field_label': f"{format_param_name(param_name)}:",
290 'checkbox_label': f"Enable {format_param_name(param_name)}",
291 'group_title': format_param_name(param_name),
292 'description': description or f"Parameter: {format_param_name(param_name)}",
293 'tooltip': f"{format_param_name(param_name)} ({param_type.__name__ if hasattr(param_type, '__name__') else str(param_type)})"
294 }
296 def format_widget_name(self, field_path: str, param_name: str) -> str:
297 """Convert field path to widget name - replaces generate_field_ids() complexity"""
298 return f"{field_path}_{param_name}"
300 def get_field_path_with_fail_loud(self, parent_type: Type, param_type: Type) -> str:
301 """Get field path using simple field name matching."""
302 import dataclasses
304 # Simple approach: find field by type matching
305 if dataclasses.is_dataclass(parent_type):
306 for field in dataclasses.fields(parent_type):
307 if field.type == param_type:
308 return field.name
310 # Fallback: use class name as field name (common pattern)
311 field_name = param_type.__name__.lower().replace('config', '')
312 return field_name
314 def generate_field_ids_direct(self, base_field_id: str, param_name: str) -> Dict[str, str]:
315 """Generate field IDs directly without artificial complexity."""
316 widget_id = f"{base_field_id}_{param_name}"
317 return {
318 'widget_id': widget_id,
319 'reset_button_id': f"reset_{widget_id}",
320 'optional_checkbox_id': f"{base_field_id}_{param_name}_enabled"
321 }
323 def validate_field_path_mapping(self):
324 """Ensure all form field_ids map correctly to context fields"""
325 from openhcs.core.config import GlobalPipelineConfig
326 import dataclasses
328 # Get all dataclass fields from GlobalPipelineConfig
329 context_fields = {f.name for f in dataclasses.fields(GlobalPipelineConfig)
330 if dataclasses.is_dataclass(f.type)}
332 print("Context fields:", context_fields)
333 # Should include: well_filter_config, zarr_config, step_materialization_config, etc.
335 # Verify form managers use these exact field names (no "nested_" prefix)
336 assert "well_filter_config" in context_fields
337 assert "nested_well_filter_config" not in context_fields # Should not exist
339 return True
341 def should_use_concrete_values(self, current_value: Any, is_global_editing: bool = False) -> bool:
342 """
343 Determine whether to use concrete values for a dataclass parameter.
345 Args:
346 current_value: The current parameter value
347 is_global_editing: Whether in global configuration editing mode
349 Returns:
350 True if concrete values should be used
351 """
352 if current_value is None:
353 return False
355 if is_global_editing:
356 return True
358 # If current_value is a concrete dataclass instance, use its values
359 if self._type_utils.is_concrete_dataclass(current_value):
360 return True
362 # For lazy dataclasses, return True so we can extract raw values from them
363 if self._type_utils.is_lazy_dataclass(current_value):
364 return True
366 return False
368 def extract_nested_parameters(self, dataclass_instance: Any, dataclass_type: Type,
369 parent_dataclass_type: Optional[Type] = None) -> Tuple[Dict[str, Any], Dict[str, Type]]:
370 """
371 Extract parameters and types from a dataclass instance.
373 This method always preserves concrete field values when a dataclass instance exists,
374 regardless of parent context. Placeholder behavior is handled at the widget level,
375 not by discarding concrete values during parameter extraction.
376 """
377 if not dataclasses.is_dataclass(dataclass_type):
378 return {}, {}
380 parameters = {}
381 parameter_types = {}
383 for field in dataclasses.fields(dataclass_type):
384 # Always extract actual field values when dataclass instance exists
385 # This preserves concrete user-entered values in nested lazy dataclass forms
386 if dataclass_instance is not None:
387 current_value = self._get_field_value(dataclass_instance, field)
388 else:
389 current_value = None # Only use None when no instance exists
391 parameters[field.name] = current_value
392 parameter_types[field.name] = field.type
394 return parameters, parameter_types
398 def _get_field_value(self, dataclass_instance: Any, field: Any) -> Any:
399 """Extract a single field value from a dataclass instance."""
400 if dataclass_instance is None:
401 return field.default
403 field_name = field.name
405 if self._type_utils.has_resolve_field_value(dataclass_instance):
406 # Lazy dataclass - get raw value
407 return object.__getattribute__(dataclass_instance, field_name) if hasattr(dataclass_instance, field_name) else field.default
408 else:
409 # Concrete dataclass - get attribute value
410 return getattr(dataclass_instance, field_name, field.default)
412 def _create_parameter_info(self, param_name: str, param_type: Type, current_value: Any,
413 parameter_info: Optional[Dict] = None) -> ParameterInfo:
414 """Create parameter information object."""
415 # Check if it's any optional type
416 is_optional = self._type_utils.is_optional(param_type)
417 if is_optional:
418 inner_type = self._type_utils.get_optional_inner_type(param_type)
419 is_nested = dataclasses.is_dataclass(inner_type)
420 else:
421 is_nested = dataclasses.is_dataclass(param_type)
423 # Get description from parameter info
424 description = None
425 if parameter_info and param_name in parameter_info:
426 info_obj = parameter_info[param_name]
427 # CRITICAL FIX: Handle both object-style and string-style parameter info
428 if isinstance(info_obj, str):
429 # Simple string description
430 description = info_obj
431 else:
432 # Object with description attribute
433 description = getattr(info_obj, 'description', None)
435 return ParameterInfo(
436 name=param_name,
437 type=param_type,
438 current_value=current_value,
439 description=description,
440 is_nested=is_nested,
441 is_optional=is_optional
442 )
444 # Class-level cache for nested dataclass parameter info (descriptions only)
445 _nested_param_info_cache = {}
447 def _analyze_nested_dataclass(self, param_name: str, param_type: Type, current_value: Any,
448 nested_field_id: str, parent_dataclass_type: Type = None) -> FormStructure:
449 """Analyze a nested dataclass parameter."""
450 # Get the actual dataclass type
451 if self._type_utils.is_optional_dataclass(param_type):
452 dataclass_type = self._type_utils.get_optional_inner_type(param_type)
453 else:
454 dataclass_type = param_type
456 # Extract nested parameters using parent context
457 nested_params, nested_types = self.extract_nested_parameters(
458 current_value, dataclass_type, parent_dataclass_type
459 )
461 # OPTIMIZATION: Cache parameter info (descriptions) by dataclass type
462 # We only need descriptions, not instance values, so analyze the type once and reuse
463 cache_key = dataclass_type
464 if cache_key in self._nested_param_info_cache:
465 nested_param_info = self._nested_param_info_cache[cache_key]
466 else:
467 # Recursively analyze nested structure with proper descriptions for nested fields
468 # Use existing infrastructure to extract field descriptions for the nested dataclass
469 from openhcs.introspection.unified_parameter_analyzer import UnifiedParameterAnalyzer
470 # OPTIMIZATION: Always analyze the TYPE, not the instance
471 # This allows caching and avoids extracting field values we don't need
472 nested_param_info = UnifiedParameterAnalyzer.analyze(dataclass_type)
473 self._nested_param_info_cache[cache_key] = nested_param_info
475 return self.analyze_parameters(
476 nested_params,
477 nested_types,
478 nested_field_id,
479 parameter_info=nested_param_info,
480 parent_dataclass_type=dataclass_type,
481 )
483 def get_placeholder_text(self, param_name: str, dataclass_type: Type,
484 placeholder_prefix: str = "Pipeline default") -> Optional[str]:
485 """
486 Get placeholder text using existing OpenHCS infrastructure.
488 Context must be established by the caller using config_context() before calling this method.
489 This allows the caller to build proper context stacks (parent + overlay) for accurate
490 placeholder resolution.
492 Args:
493 param_name: Name of the parameter to get placeholder for
494 dataclass_type: The specific dataclass type (GlobalPipelineConfig or PipelineConfig)
495 placeholder_prefix: Prefix for the placeholder text
497 Returns:
498 Formatted placeholder text or None if no resolution possible
500 The editing mode is automatically derived from the dataclass type's lazy resolution capabilities:
501 - Has lazy resolution (PipelineConfig) → orchestrator config editing
502 - No lazy resolution (GlobalPipelineConfig) → global config editing
503 """
504 # Use the simplified placeholder service - caller manages context
505 from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService
507 # Service just resolves placeholders, caller manages context
508 return LazyDefaultPlaceholderService.get_lazy_resolved_placeholder(
509 dataclass_type, param_name, placeholder_prefix
510 )
512 def reset_nested_managers(self, nested_managers: Dict[str, Any],
513 dataclass_type: Type, current_config: Any) -> None:
514 """Reset all nested managers - fail loud, no defensive programming."""
515 for nested_manager in nested_managers.values():
516 # All nested managers must have reset_all_parameters method
517 nested_manager.reset_all_parameters()
521 def get_reset_value_for_parameter(self, param_name: str, param_type: Type,
522 dataclass_type: Type, is_global_config_editing: Optional[bool] = None) -> Any:
523 """
524 Get appropriate reset value using existing OpenHCS patterns.
526 Args:
527 param_name: Name of the parameter to reset
528 param_type: Type of the parameter (int, str, bool, etc.)
529 dataclass_type: The specific dataclass type
530 is_global_config_editing: Whether we're in global config editing mode (auto-detected if None)
532 Returns:
533 - For global config editing: Actual default values
534 - For lazy config editing: None to show placeholder text
535 """
536 # Context-driven behavior: Use the editing context to determine reset behavior
537 # This follows the architectural principle that behavior is determined by context
538 # of usage rather than intrinsic properties of the dataclass.
540 # Context-driven behavior: Use explicit context when provided
541 # Auto-detect editing mode if not explicitly provided
542 if is_global_config_editing is None:
543 # Fallback: Use existing lazy resolution detection for backward compatibility
544 is_global_config_editing = not LazyDefaultPlaceholderService.has_lazy_resolution(dataclass_type)
546 # Context-driven behavior: Reset behavior depends on editing context
547 if is_global_config_editing:
548 # Global config editing: Reset to actual default values
549 # Users expect to see concrete defaults when editing global configuration
550 return self._get_actual_dataclass_field_default(param_name, dataclass_type)
551 else:
552 # CRITICAL FIX: For lazy config editing, always return None
553 # This ensures reset shows inheritance chain values (like compiler resolution)
554 # instead of concrete values from thread-local context
555 return None
557 def _get_actual_dataclass_field_default(self, param_name: str, dataclass_type: Type) -> Any:
558 """
559 Get the actual default value for a parameter.
561 Works uniformly for dataclasses, functions, and any other object type.
562 Always returns None for non-existent fields (fail-soft for dynamic properties).
564 Returns:
565 - If class attribute is None → return None (show placeholder)
566 - If class attribute has concrete value → return that value
567 - If field(default_factory) → call default_factory and return result
568 - If field doesn't exist → return None (dynamic property)
569 """
570 from dataclasses import fields, MISSING, is_dataclass
571 import inspect
573 # For pure functions: get default from signature
574 if callable(dataclass_type) and not is_dataclass(dataclass_type) and not hasattr(dataclass_type, '__mro__'):
575 sig = inspect.signature(dataclass_type)
576 if param_name in sig.parameters:
577 default = sig.parameters[param_name].default
578 return None if default is inspect.Parameter.empty else default
579 return None # Dynamic property, not in signature
581 # For all other types (dataclasses, ABCs, classes): check class attribute first
582 if hasattr(dataclass_type, param_name):
583 return getattr(dataclass_type, param_name)
585 # For dataclasses: check if it's a field(default_factory=...) field
586 if is_dataclass(dataclass_type):
587 dataclass_fields = {f.name: f for f in fields(dataclass_type)}
588 if param_name not in dataclass_fields:
589 return None # Dynamic property, not a dataclass field
591 field_info = dataclass_fields[param_name]
593 # Handle field(default_factory=...) case
594 if field_info.default_factory is not MISSING:
595 try:
596 return field_info.default_factory()
597 except Exception as e:
598 raise ValueError(f"Failed to call default_factory for field '{param_name}': {e}") from e
600 # Handle field with explicit default
601 if field_info.default is not MISSING:
602 return field_info.default
604 # Field has no default (should not happen in practice)
605 return None
607 # For non-dataclass types: return None (dynamic property)
608 return None