"""Unified parameter analysis interface for all parameter sources in OpenHCS TUI.
This module provides a single, consistent interface for analyzing parameters from:
- Functions and methods
- Dataclasses and their fields
- Nested dataclass structures
- Any callable or type with parameters
Replaces the fragmented approach of SignatureAnalyzer vs FieldIntrospector.
"""
import inspect
import dataclasses
from typing import Dict, Union, Callable, Type, Any, Optional
from dataclasses import dataclass
from .signature_analyzer import SignatureAnalyzer, ParameterInfo
[docs]
@dataclass
class UnifiedParameterInfo:
"""Unified parameter information that works for all parameter sources."""
name: str
param_type: Type
default_value: Any
is_required: bool
description: Optional[str] = None
source_type: str = "unknown" # "function", "dataclass", "nested"
[docs]
@classmethod
def from_parameter_info(cls, param_info: ParameterInfo, source_type: str = "function") -> "UnifiedParameterInfo":
"""Convert from existing ParameterInfo to unified format."""
return cls(
name=param_info.name,
param_type=param_info.param_type,
default_value=param_info.default_value,
is_required=param_info.is_required,
description=param_info.description,
source_type=source_type
)
[docs]
class UnifiedParameterAnalyzer:
"""Single interface for analyzing parameters from any source.
This class provides a unified way to extract parameter information
from functions, dataclasses, and other parameter sources, ensuring
consistent behavior across the entire application.
"""
[docs]
@staticmethod
def analyze(target: Union[Callable, Type, object], exclude_params: Optional[list] = None) -> Dict[str, UnifiedParameterInfo]:
"""Analyze parameters from any source.
Args:
target: Function, method, dataclass type, or instance to analyze
exclude_params: Optional list of parameter names to exclude from analysis
Returns:
Dictionary mapping parameter names to UnifiedParameterInfo objects
Examples:
# Function analysis
param_info = UnifiedParameterAnalyzer.analyze(my_function)
# Dataclass analysis
param_info = UnifiedParameterAnalyzer.analyze(MyDataclass)
# Instance analysis
param_info = UnifiedParameterAnalyzer.analyze(my_instance)
# Instance analysis with exclusions (e.g., exclude 'func' from FunctionStep)
param_info = UnifiedParameterAnalyzer.analyze(step_instance, exclude_params=['func'])
"""
if target is None:
return {}
# Determine the type of target and route to appropriate analyzer
if inspect.isfunction(target) or inspect.ismethod(target):
result = UnifiedParameterAnalyzer._analyze_callable(target)
elif inspect.isclass(target):
if dataclasses.is_dataclass(target):
result = UnifiedParameterAnalyzer._analyze_dataclass_type(target)
else:
# For classes, use _analyze_object_instance to traverse MRO
# Create a dummy instance just to get the class hierarchy analyzed
try:
dummy_instance = target.__new__(target)
result = UnifiedParameterAnalyzer._analyze_object_instance(dummy_instance)
except:
# If we can't create a dummy instance, fall back to just analyzing __init__
result = UnifiedParameterAnalyzer._analyze_callable(target.__init__)
elif dataclasses.is_dataclass(target):
# Instance of dataclass
result = UnifiedParameterAnalyzer._analyze_dataclass_instance(target)
else:
# Try to analyze as callable
if callable(target):
# Check if it has a __call__ method (callable object)
if hasattr(target, '__call__') and not inspect.isfunction(target):
# It's a callable object, analyze its __call__ method
result = UnifiedParameterAnalyzer._analyze_callable(target.__call__)
else:
result = UnifiedParameterAnalyzer._analyze_callable(target)
else:
# For regular object instances (like step instances), analyze their class constructor
result = UnifiedParameterAnalyzer._analyze_object_instance(target)
# Apply exclusions if specified
if exclude_params:
result = {name: info for name, info in result.items() if name not in exclude_params}
return result
@staticmethod
def _analyze_callable(callable_obj: Callable) -> Dict[str, UnifiedParameterInfo]:
"""Analyze a callable (function, method, etc.)."""
# Use existing SignatureAnalyzer for callables
param_info_dict = SignatureAnalyzer.analyze(callable_obj)
# Convert to unified format
unified_params = {}
for name, param_info in param_info_dict.items():
unified_params[name] = UnifiedParameterInfo.from_parameter_info(
param_info,
source_type="function"
)
return unified_params
@staticmethod
def _analyze_dataclass_type(dataclass_type: Type) -> Dict[str, UnifiedParameterInfo]:
"""Analyze a dataclass type using existing SignatureAnalyzer infrastructure."""
# CRITICAL FIX: Use existing SignatureAnalyzer._analyze_dataclass method
# which already handles all the docstring extraction properly
param_info_dict = SignatureAnalyzer._analyze_dataclass(dataclass_type)
# Convert to unified format
unified_params = {}
for name, param_info in param_info_dict.items():
unified_params[name] = UnifiedParameterInfo.from_parameter_info(
param_info,
source_type="dataclass"
)
return unified_params
@staticmethod
def _analyze_object_instance(instance: object) -> Dict[str, UnifiedParameterInfo]:
"""Analyze a regular object instance by examining its full inheritance hierarchy.
Always returns CLASS signature defaults (not instance values).
ObjectState extracts instance values separately via object.__getattribute__.
For dynamic containers like SimpleNamespace (which use **kwargs in __init__),
falls back to inspecting __dict__ to discover attributes and their types.
Args:
instance: Object instance to analyze
"""
from types import SimpleNamespace
import logging
_logger = logging.getLogger(__name__)
# Use MRO to get all constructor parameters from the inheritance chain
instance_class = type(instance)
all_params = {}
found_kwargs_only = False
_logger.debug(f"🔧 _analyze_object_instance: instance_class={instance_class.__name__}, MRO={[c.__name__ for c in instance_class.__mro__]}")
# Traverse MRO from most specific to most general (like dual-axis resolver)
for cls in instance_class.__mro__:
if cls == object:
continue
# Skip classes without custom __init__
if not hasattr(cls, '__init__') or cls.__init__ == object.__init__:
continue
try:
# Analyze this class's constructor
class_params = UnifiedParameterAnalyzer._analyze_callable(cls.__init__)
# Remove 'self' parameter
if 'self' in class_params:
del class_params['self']
_logger.debug(f"🔧 _analyze_object_instance: cls={cls.__name__}, class_params after removing self={list(class_params.keys())}")
# Special handling for *args/**kwargs - if params are only args/kwargs, skip this class
# This handles dynamic containers like SimpleNamespace(self, /, *args, **kwargs)
variadic_only = set(class_params.keys()) <= {'args', 'kwargs'}
if variadic_only and class_params:
# This class uses only *args/**kwargs, skip it and use __dict__ fallback
found_kwargs_only = True
_logger.debug(f"🔧 _analyze_object_instance: cls={cls.__name__} has only variadic params {list(class_params.keys())}, skipping, found_kwargs_only=True")
continue
# Add parameters that haven't been seen yet (most specific wins)
for param_name, param_info in class_params.items():
if param_name not in all_params and param_name != 'kwargs':
# Always use signature defaults - ObjectState extracts instance values separately
all_params[param_name] = UnifiedParameterInfo(
name=param_name,
param_type=param_info.param_type,
default_value=param_info.default_value,
is_required=param_info.is_required,
description=param_info.description,
source_type="object_instance"
)
except Exception:
# Skip classes that can't be analyzed - this is legitimate since some classes
# in MRO might not have analyzable constructors (e.g., ABC, object)
continue
# Fallback for dynamic containers (SimpleNamespace, etc.): inspect __dict__
# This handles objects that store attrs via **kwargs and have no static signature
_logger.debug(f"🔧 _analyze_object_instance: after MRO loop, all_params={list(all_params.keys())}, found_kwargs_only={found_kwargs_only}")
if not all_params and found_kwargs_only and hasattr(instance, '__dict__'):
_logger.debug(f"🔧 _analyze_object_instance: FALLBACK triggered, inspecting __dict__={list(instance.__dict__.keys())}")
for attr_name, attr_value in instance.__dict__.items():
if attr_name.startswith('_'):
continue
# Infer type from value
attr_type = type(attr_value) if attr_value is not None else type(None)
all_params[attr_name] = UnifiedParameterInfo(
name=attr_name,
param_type=attr_type,
default_value=attr_value,
is_required=False,
description=None,
source_type="dynamic_attr"
)
_logger.debug(f"🔧 _analyze_object_instance: after fallback, all_params={list(all_params.keys())}")
return all_params
@staticmethod
def _analyze_dataclass_instance(instance: object) -> Dict[str, UnifiedParameterInfo]:
"""Analyze a dataclass instance.
Always returns CLASS signature defaults (not instance values).
ObjectState extracts instance values separately via object.__getattribute__.
"""
# Get the type and analyze it - returns CLASS signature defaults
dataclass_type = type(instance)
return UnifiedParameterAnalyzer._analyze_dataclass_type(dataclass_type)
[docs]
@staticmethod
def analyze_nested(target: Union[Callable, Type, object], parent_info: Dict[str, UnifiedParameterInfo] = None) -> Dict[str, UnifiedParameterInfo]:
"""Analyze parameters with nested dataclass support.
This method provides enhanced analysis that can handle nested dataclasses
and maintain parent context information.
Args:
target: The target to analyze
parent_info: Optional parent parameter information for context
Returns:
Dictionary of unified parameter information with nested support
"""
base_params = UnifiedParameterAnalyzer.analyze(target)
# For each parameter, check if it's a nested dataclass
enhanced_params = {}
for name, param_info in base_params.items():
enhanced_params[name] = param_info
# If this parameter is a dataclass, mark it as having nested structure
if dataclasses.is_dataclass(param_info.param_type):
# Update source type to indicate nesting capability
enhanced_params[name] = UnifiedParameterInfo(
name=param_info.name,
param_type=param_info.param_type,
default_value=param_info.default_value,
is_required=param_info.is_required,
description=param_info.description,
source_type=f"{param_info.source_type}_nested"
)
return enhanced_params
# Backward compatibility aliases
# These allow existing code to continue working while migration happens
ParameterAnalyzer = UnifiedParameterAnalyzer
analyze_parameters = UnifiedParameterAnalyzer.analyze