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

1""" 

2Shared service layer for parameter form managers. 

3 

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""" 

8 

9import dataclasses 

10from dataclasses import dataclass 

11from typing import Dict, Any, Type, Optional, List, Tuple 

12 

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 

18 

19 

20@dataclass 

21class ParameterInfo: 

22 """ 

23 Information about a parameter for form generation. 

24  

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 

43 

44 

45@dataclass 

46class FormStructure: 

47 """ 

48 Structure information for a parameter form. 

49  

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 

60 

61 

62class ParameterFormService: 

63 """ 

64 Framework-agnostic service for parameter form business logic. 

65  

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 """ 

70 

71 def __init__(self): 

72 """ 

73 Initialize the parameter form service. 

74 """ 

75 self._type_utils = ParameterTypeUtils() 

76 

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. 

82 

83 This method analyzes the parameters and their types to create a complete 

84 form structure that can be used by any UI framework. 

85 

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 

92 

93 Returns: 

94 Complete form structure information 

95 """ 

96 debug_param("analyze_parameters", f"field_id={field_id}, parameter_count={len(parameters)}") 

97 

98 param_infos = [] 

99 nested_forms = {} 

100 has_optional_dataclasses = False 

101 

102 for param_name, param_type in parameter_types.items(): 

103 current_value = parameters.get(param_name) 

104 

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 

109 

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) 

115 

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 

121 

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) 

127 

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 

132 

133 # Check for optional dataclasses 

134 if param_info.is_optional and param_info.is_nested: 

135 has_optional_dataclasses = True 

136 

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 ) 

143 

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. 

147 

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 

152 

153 Returns: 

154 True if the parameter should be hidden from UI 

155 """ 

156 import dataclasses 

157 

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 

165 

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 

173 

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 

179 

180 return False 

181 

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. 

185 

186 This method provides centralized type conversion logic that can be 

187 used by any UI framework. 

188 

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) 

194 

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)}") 

199 

200 if value is None: 

201 return None 

202 

203 # Handle string "None" literal 

204 if isinstance(value, str) and value == CONSTANTS.NONE_STRING_LITERAL: 

205 return None 

206 

207 # Handle enum types 

208 if self._type_utils.is_enum_type(param_type): 

209 return param_type(value) 

210 

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)] 

219 

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)] 

227 

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 

245 

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 

256 

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 

261 

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 

265 

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 

271 

272 return value 

273 

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. 

278  

279 Args: 

280 param_name: The parameter name 

281 param_type: The parameter type 

282 description: Optional parameter description 

283  

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 } 

295 

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}" 

299 

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 

303 

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 

309 

310 # Fallback: use class name as field name (common pattern) 

311 field_name = param_type.__name__.lower().replace('config', '') 

312 return field_name 

313 

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 } 

322 

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 

327 

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)} 

331 

332 print("Context fields:", context_fields) 

333 # Should include: well_filter_config, zarr_config, step_materialization_config, etc. 

334 

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 

338 

339 return True 

340 

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. 

344  

345 Args: 

346 current_value: The current parameter value 

347 is_global_editing: Whether in global configuration editing mode 

348  

349 Returns: 

350 True if concrete values should be used 

351 """ 

352 if current_value is None: 

353 return False 

354 

355 if is_global_editing: 

356 return True 

357 

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 

361 

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 

365 

366 return False 

367 

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. 

372 

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 {}, {} 

379 

380 parameters = {} 

381 parameter_types = {} 

382 

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 

390 

391 parameters[field.name] = current_value 

392 parameter_types[field.name] = field.type 

393 

394 return parameters, parameter_types 

395 

396 

397 

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 

402 

403 field_name = field.name 

404 

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) 

411 

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) 

422 

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) 

434 

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 ) 

443 

444 # Class-level cache for nested dataclass parameter info (descriptions only) 

445 _nested_param_info_cache = {} 

446 

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 

455 

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 ) 

460 

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 

474 

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 ) 

482 

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. 

487 

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. 

491 

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 

496 

497 Returns: 

498 Formatted placeholder text or None if no resolution possible 

499 

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 

506 

507 # Service just resolves placeholders, caller manages context 

508 return LazyDefaultPlaceholderService.get_lazy_resolved_placeholder( 

509 dataclass_type, param_name, placeholder_prefix 

510 ) 

511 

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() 

518 

519 

520 

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. 

525 

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) 

531 

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. 

539 

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) 

545 

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 

556 

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. 

560 

561 Works uniformly for dataclasses, functions, and any other object type. 

562 Always returns None for non-existent fields (fail-soft for dynamic properties). 

563 

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 

572 

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 

580 

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) 

584 

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 

590 

591 field_info = dataclass_fields[param_name] 

592 

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 

599 

600 # Handle field with explicit default 

601 if field_info.default is not MISSING: 

602 return field_info.default 

603 

604 # Field has no default (should not happen in practice) 

605 return None 

606 

607 # For non-dataclass types: return None (dynamic property) 

608 return None