diff --git a/README.md b/README.md index b23b2f2..3d47ff9 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,19 @@ changes are checked by the repository's [documentation workflow](https://github.com/OpenHCSDev/python-introspect/actions/workflows/docs.yml); the local warnings-as-errors build command is documented in [`development.rst`](docs/source/development.rst). + +### Parameter declarations in 0.2 + +`UnifiedParameterAnalyzer` now returns the original `ParameterInfo` declarations +from `SignatureAnalyzer`. `UnifiedParameterInfo`, its `source_type` tag, and +`analyze_nested` were removed. Use `analyze` and the declared `param_type` for +parameter topology. + +`ParameterInfo` retains its five public fields, constructor and `_replace` +operation, and is now a frozen declaration rather than a tuple. Tuple unpacking +and NamedTuple helpers are no longer supported. Dataclass descriptions are +derived on first help access; value/default overlays preserve that deferred +source, and renaming preserves the original field's help. Callable descriptions +still participate eagerly in inferred parameter types. A presentation extraction +error yields absent help without discarding valid types/defaults. Factory errors +remain uncached and retryable. diff --git a/pyproject.toml b/pyproject.toml index 0c446c5..75ca28d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-introspect" -version = "0.1.16" +version = "0.2.0" description = "Pure Python introspection toolkit for function signatures, dataclasses, and type hints" readme = "README.md" requires-python = ">=3.10" diff --git a/src/python_introspect/__init__.py b/src/python_introspect/__init__.py index b655b59..ae06f9b 100644 --- a/src/python_introspect/__init__.py +++ b/src/python_introspect/__init__.py @@ -9,7 +9,7 @@ type resolution for framework-specific types (lazy configs, proxies, etc.) """ -__version__ = "0.1.16" +__version__ = "0.2.0" from .signature_analyzer import ( SignatureAnalyzer, @@ -24,7 +24,6 @@ ) from .unified_parameter_analyzer import ( UnifiedParameterAnalyzer, - UnifiedParameterInfo, add_parameter_exclusions, set_parameter_exclusions, parameter_exclusions, @@ -89,7 +88,6 @@ "signature_analysis_target", # Unified analysis "UnifiedParameterAnalyzer", - "UnifiedParameterInfo", "add_parameter_exclusions", "set_parameter_exclusions", "parameter_exclusions", diff --git a/src/python_introspect/signature_analyzer.py b/src/python_introspect/signature_analyzer.py index f48f58f..40b3ca5 100644 --- a/src/python_introspect/signature_analyzer.py +++ b/src/python_introspect/signature_analyzer.py @@ -15,7 +15,7 @@ import dataclasses import re from abc import ABC, abstractmethod -from typing import Annotated, Any, Dict, Callable, get_type_hints, NamedTuple, Union, Optional, Type, List, ClassVar, Tuple, get_args, get_origin +from typing import Annotated, Any, Dict, Callable, get_type_hints, NamedTuple, Union, Optional, Type, List, ClassVar, Tuple, get_args, get_origin, Iterable from weakref import WeakKeyDictionary from dataclasses import dataclass, field @@ -146,13 +146,71 @@ class AnalysisConstants: CONSTANTS = AnalysisConstants() -class ParameterInfo(NamedTuple): - """Information about a parameter.""" +@dataclass(frozen=True, init=False, eq=False) +class ParameterInfo: + """Admitted parameter values with presentation derived from their declaration. + + Callable descriptions remain concrete because they can determine inferred + types. Dataclass descriptions are requested independently of value admission. + """ + name: str param_type: type default_value: Any is_required: bool - description: Optional[str] = None # Add parameter description from docstring + _description: Optional[str] + _description_owner: Optional[type] + + def __init__( + self, name: str, param_type: type, default_value: Any, is_required: bool, + description: Optional[str] = None, *, description_owner: Optional[type] = None, + ): + object.__setattr__(self, "name", name) + object.__setattr__(self, "param_type", param_type) + object.__setattr__(self, "default_value", default_value) + object.__setattr__(self, "is_required", is_required) + object.__setattr__(self, "_description", description) + object.__setattr__(self, "_description_owner", description_owner) + + @property + def description(self) -> Optional[str]: + if self._description_owner is not None: + SignatureAnalyzer._materialize_dataclass_descriptions(self._description_owner, self) + return self._description + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ParameterInfo): + return NotImplemented + return ( + self.name, self.param_type, self.default_value, self.is_required, self.description, + ) == ( + other.name, other.param_type, other.default_value, other.is_required, other.description, + ) + + def __hash__(self) -> int: + return hash((self.name, self.param_type, self.default_value, self.is_required, self.description)) + + def _replace(self, **changes: Any) -> "ParameterInfo": + """Replace declared fields through the same public parameter owner.""" + values = dict( + name=self.name, param_type=self.param_type, + default_value=self.default_value, is_required=self.is_required, + description=self._description, + ) + unknown = changes.keys() - values.keys() + if unknown: + raise ValueError(f"Got unexpected field names: {sorted(unknown)!r}") + owner = self._description_owner + if "description" in changes: + owner = None + elif "name" in changes and changes["name"] != self.name: + # A renamed field keeps the original declaration's help, rather + # than querying a different field in the same source class. + values["description"] = self.description + owner = None + values.update(changes) + return type(self)(**values, description_owner=owner) + class DocstringInfo(NamedTuple): """Information extracted from a docstring.""" @@ -713,6 +771,70 @@ def type_hints(self) -> Dict[str, Any]: ) +class ClassSourceDeclaration(NamedTuple): + """Immutable located class source and its source-only documentation views.""" + + source: str + inline_docs: Tuple[Tuple[str, Tuple[Tuple[str, str], ...]], ...] + + @classmethod + def from_block(cls, source: str, tree: ast.AST, first_line: int = 1) -> "ClassSourceDeclaration": + # The public extractor historically parses the selected block without + # dedenting. Keep nested/local blocks' empty inline-documentation view. + first = next((line for line in source.splitlines() if line.strip()), '') + if first[:1].isspace(): + return cls(source, ()) + source_lines = source.split('\n') + declarations = [] + for class_node in ast.walk(tree): + if not isinstance(class_node, ast.ClassDef): + continue + field_docs = {} + + # Method 1: Look for field assignments followed by string literals (next line) + for i, node in enumerate(class_node.body): + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + field_name = node.target.id + + # Check if the next node is a string literal (documentation) + if i + 1 < len(class_node.body): + next_node = class_node.body[i + 1] + if isinstance(next_node, ast.Expr): + if isinstance(next_node.value, ast.Constant) and isinstance(next_node.value.value, str): + field_docs[field_name] = next_node.value.value.strip() + continue + + # Method 2: Check for inline comments on the same line + # Get the line number of the field definition + field_line_num = node.lineno - first_line + if 0 <= field_line_num < len(source_lines): + line = source_lines[field_line_num] + + # Look for string literals in comments on the same line + # Pattern: field: type = value # """Documentation""" + comment_match = re.search(r'#\s*["\']([^"\']+)["\']', line) + if comment_match: + field_docs[field_name] = comment_match.group(1).strip() + continue + + # Look for triple-quoted strings on the same line + # Pattern: field: type = value """Documentation""" + triple_quote_match = re.search(r'"""([^"]+)"""|\'\'\'([^\']+)\'\'\'', line) + if triple_quote_match: + doc_text = triple_quote_match.group(1) or triple_quote_match.group(2) + field_docs[field_name] = doc_text.strip() + + declarations.append((class_node.name, tuple(field_docs.items()))) + return cls(source, tuple(declarations)) + + def field_documentation(self, class_name: str) -> Dict[str, str]: + """Return a fresh view selected by the caller's current public name.""" + for name, entries in self.inline_docs: + if name == class_name: + return dict(entries) + return {} + + class SignatureAnalyzer: """Universal analyzer for extracting parameter information from any target.""" @@ -907,16 +1029,6 @@ def _analyze_dataclass(dataclass_type: type) -> Dict[str, ParameterInfo]: except Exception: type_hints = inspect.get_annotations(dataclass_type, eval_str=False) - # Extract docstring information from dataclass - docstring_info = DocstringExtractor.extract(dataclass_type) - - # Extract inline field documentation using AST - inline_docs = SignatureAnalyzer._extract_inline_field_docs(dataclass_type) - - # ENHANCEMENT: For dataclasses modified by decorators, - # also extract field documentation from the field types themselves - field_type_docs = SignatureAnalyzer._extract_field_type_docs(dataclass_type) - parameters = {} for field in dataclasses.fields(dataclass_type): @@ -937,31 +1049,12 @@ def _analyze_dataclass(dataclass_type: type) -> Dict[str, ParameterInfo]: default_value = None is_required = True - # Get field description from multiple sources (priority order) - field_description = None - - # 1. Field metadata (highest priority) - if 'description' in field.metadata: - field_description = field.metadata['description'] - # 2. Inline documentation strings (from AST parsing) - elif field.name in inline_docs: - field_description = inline_docs[field.name] - # 3. Field type documentation (for decorator-modified classes) - elif field.name in field_type_docs: - field_description = field_type_docs[field.name] - # 4. Docstring parameters (fallback) - elif docstring_info.parameters and field.name in docstring_info.parameters: - field_description = docstring_info.parameters.get(field.name) - # 5. CRITICAL FIX: Use inheritance-aware field documentation extraction - else: - field_description = SignatureAnalyzer.extract_field_documentation(dataclass_type, field.name) - parameters[field.name] = ParameterInfo( name=field.name, param_type=param_type, default_value=default_value, is_required=is_required, - description=field_description + description_owner=dataclass_type, ) # PERFORMANCE: Cache the result to avoid re-parsing @@ -972,21 +1065,110 @@ def _analyze_dataclass(dataclass_type: type) -> Dict[str, ParameterInfo]: # Return empty dict on error (don't cache errors) return {} + @staticmethod + def _materialize_dataclass_descriptions(dataclass_type: type, requested: ParameterInfo) -> None: + """Fill presentation on the existing admitted declarations, once requested. + + Presentation extraction errors no longer discard usable value/type + declarations. They yield absent help, matching the existing help fallback. + """ + parameters = SignatureAnalyzer._dataclass_analysis_cache.get(dataclass_type, {}) + admitted = parameters.get(requested.name) + if admitted is not None and admitted is not requested: + object.__setattr__(requested, "_description", admitted.description) + object.__setattr__(requested, "_description_owner", None) + return + if admitted is not requested: + parameters = {requested.name: requested} + pending = [info for info in parameters.values() if info._description_owner is not None] + if not pending: + return + try: + docstring_info = DocstringExtractor.extract(dataclass_type) + inline_docs = SignatureAnalyzer._extract_inline_field_docs(dataclass_type) + field_type_docs = SignatureAnalyzer._extract_field_type_docs(dataclass_type) + descriptions = {} + for field in dataclasses.fields(dataclass_type): + if field.name not in parameters: + continue + if 'description' in field.metadata: + description = field.metadata['description'] + elif field.name in inline_docs: + description = inline_docs[field.name] + elif field.name in field_type_docs: + description = field_type_docs[field.name] + elif field.name in docstring_info.parameters_dict: + description = docstring_info.parameters_dict[field.name] + else: + description = SignatureAnalyzer.extract_field_documentation(dataclass_type, field.name) + descriptions[field.name] = description + except Exception: + descriptions = {} + for info in pending: + object.__setattr__(info, "_description", descriptions.get(info.name)) + object.__setattr__(info, "_description_owner", None) + + @staticmethod + def prepare_dataclass_declarations(declarations: Iterable[type]) -> None: + """Prepare source facts for declared schemas without resolving values. + + MRO, raw annotations and declared dataclass defaults expose schema + relationships. Unresolved strings/ForwardRefs remain live for analysis; + factories, namespace providers and type resolvers are never invoked. + """ + pending = list(declarations) + visited = set() + while pending: + declaration = pending.pop() + if not inspect.isclass(declaration) or declaration in visited: + continue + visited.add(declaration) + if declaration is object: + continue + if not dataclasses.is_dataclass(declaration): + continue + SignatureAnalyzer._extract_inline_field_docs(declaration) + pending.extend( + base for base in inspect.getmro(declaration)[1:] + if dataclasses.is_dataclass(base) + ) + for declared_field in dataclasses.fields(declaration): + field_types = [declared_field.type] + while field_types: + field_type = field_types.pop() + if inspect.isclass(field_type) and dataclasses.is_dataclass(field_type): + pending.append(field_type) + field_types.extend(get_args(field_type)) + if dataclasses.is_dataclass(declared_field.default): + default = declared_field.default + pending.append(default if inspect.isclass(default) else type(default)) + factory = declared_field.default_factory + if inspect.isclass(factory) and dataclasses.is_dataclass(factory): + pending.append(factory) + @staticmethod def prepare_dataclass_declaration(dataclass_type: type) -> None: - """Warm pure source lookup without evaluating fields or default factories.""" - SignatureAnalyzer._extract_inline_field_docs(dataclass_type) + """Prepare one declaration's source closure without evaluating defaults.""" + SignatureAnalyzer.prepare_dataclass_declarations((dataclass_type,)) @staticmethod - def _dataclass_source(dataclass_type: type) -> str: + def _dataclass_source(dataclass_type: type) -> ClassSourceDeclaration: """Read current loader source, deriving its class block by immutable content.""" # Python 3.13+ locates class source from __firstlineno__ directly; # its native locator does not perform the repeated module AST traversal. if sys.version_info >= (3, 13): - return inspect.getsource(dataclass_type) + source = inspect.getsource(dataclass_type) + if source[:1].isspace(): + return ClassSourceDeclaration(source, ()) + tree = SignatureAnalyzer._module_source_tree(source) + class_node = next((node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)), None) + if class_node is None: + return ClassSourceDeclaration(source, ()) + return SignatureAnalyzer._qualified_class_source(source, class_node.name) target = inspect.unwrap(dataclass_type) if not inspect.isclass(target): - return inspect.getsource(target) + source = inspect.getsource(target) + return ClassSourceDeclaration.from_block(source, SignatureAnalyzer._module_source_tree(source)) file = inspect.getsourcefile(target) if file: linecache.checkcache(file) @@ -1011,7 +1193,7 @@ def _module_source_tree(source: str) -> ast.AST: @staticmethod @lru_cache(maxsize=256) - def _qualified_class_source(source: str, qualname: str) -> Optional[str]: + def _qualified_class_source(source: str, qualname: str) -> Optional[ClassSourceDeclaration]: """Use Python's qualified class/decorator locator on current source content.""" tree = SignatureAnalyzer._module_source_tree(source) finder = inspect._ClassFinder(qualname) @@ -1019,7 +1201,14 @@ def _qualified_class_source(source: str, qualname: str) -> Optional[str]: finder.visit(tree) except inspect.ClassFoundException as found: lines = source.splitlines(keepends=True) - return ''.join(inspect.getblock(lines[found.args[0]:])) + first_line = found.args[0] + 1 + block = ''.join(inspect.getblock(lines[found.args[0]:])) + class_node = next( + node for node in ast.walk(tree) + if isinstance(node, ast.ClassDef) + and min([node.lineno, *(decorator.lineno for decorator in node.decorator_list)]) == first_line + ) + return ClassSourceDeclaration.from_block(block, class_node, first_line) return None @staticmethod @@ -1049,84 +1238,22 @@ class Config: field_name: str = "default" """ try: - import ast - import re - - # Try to get source code - handle cases where it might not be available - source = None try: - source = SignatureAnalyzer._dataclass_source(dataclass_type) + declaration = SignatureAnalyzer._dataclass_source(dataclass_type) except (OSError, TypeError): - try: - source_file = inspect.getfile(dataclass_type) - with open(source_file, 'r', encoding='utf-8') as f: - file_content = f.read() - source = SignatureAnalyzer._extract_class_source_from_file(file_content, dataclass_type.__name__) - except Exception: - pass - - if not source: - return {} - - tree = ast.parse(source) - - # Find the class definition - be more flexible with class name matching - class_node = None - target_class_name = dataclass_type.__name__ - - # Handle cases where the class might have been renamed or modified - for node in ast.walk(tree): - if isinstance(node, ast.ClassDef): - # Try exact match first - if node.name == target_class_name: - class_node = node - break - # Also try without common prefixes/suffixes that decorators might add - - if not class_node: - return {} - - field_docs = {} - source_lines = source.split('\n') - - # Method 1: Look for field assignments followed by string literals (next line) - for i, node in enumerate(class_node.body): - if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): - field_name = node.target.id - - # Check if the next node is a string literal (documentation) - if i + 1 < len(class_node.body): - next_node = class_node.body[i + 1] - if isinstance(next_node, ast.Expr): - if isinstance(next_node.value, ast.Constant) and isinstance(next_node.value.value, str): - field_docs[field_name] = next_node.value.value.strip() - continue - - # Method 2: Check for inline comments on the same line - # Get the line number of the field definition - field_line_num = node.lineno - 1 # Convert to 0-based indexing - if 0 <= field_line_num < len(source_lines): - line = source_lines[field_line_num] - - # Look for string literals in comments on the same line - # Pattern: field: type = value # """Documentation""" - comment_match = re.search(r'#\s*["\']([^"\']+)["\']', line) - if comment_match: - field_docs[field_name] = comment_match.group(1).strip() - continue - - # Look for triple-quoted strings on the same line - # Pattern: field: type = value """Documentation""" - triple_quote_match = re.search(r'"""([^"]+)"""|\'\'\'([^\']+)\'\'\'', line) - if triple_quote_match: - doc_text = triple_quote_match.group(1) or triple_quote_match.group(2) - field_docs[field_name] = doc_text.strip() - - return field_docs - + source_file = inspect.getfile(dataclass_type) + with open(source_file, 'r', encoding='utf-8') as source_stream: + source = SignatureAnalyzer._extract_class_source_from_file( + source_stream.read(), dataclass_type.__name__ + ) + if not source: + return {} + declaration = SignatureAnalyzer._qualified_class_source(source, dataclass_type.__name__) + if declaration is None: + return {} + return declaration.field_documentation(dataclass_type.__name__) except Exception: - # Return empty dict if AST parsing fails - # Could add logging here for debugging: logger.debug(f"AST parsing failed: {e}") + # Preserve the extractor's unavailable/unparsable-source boundary. return {} @staticmethod diff --git a/src/python_introspect/unified_parameter_analyzer.py b/src/python_introspect/unified_parameter_analyzer.py index de7dbba..148a3c0 100644 --- a/src/python_introspect/unified_parameter_analyzer.py +++ b/src/python_introspect/unified_parameter_analyzer.py @@ -13,7 +13,6 @@ import dataclasses from abc import ABC, abstractmethod from typing import Dict, Union, Callable, Type, Any, Optional, ClassVar -from dataclasses import dataclass from weakref import WeakKeyDictionary from metaclass_registry import AutoRegisterMeta @@ -59,29 +58,6 @@ def add_parameter_exclusions( set_parameter_exclusions(target, (*parameter_exclusions(target), *normalized)) -@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" - - @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 - ) - - class UnifiedParameterTargetAnalyzer(ABC, metaclass=AutoRegisterMeta): """Nominal target-kind family for unified parameter analysis.""" @@ -91,7 +67,7 @@ class UnifiedParameterTargetAnalyzer(ABC, metaclass=AutoRegisterMeta): target_kind: ClassVar[Optional[str]] = None @classmethod - def analyze_target(cls, target: Union[Callable, Type, object]) -> Dict[str, UnifiedParameterInfo]: + def analyze_target(cls, target: Union[Callable, Type, object]) -> Dict[str, ParameterInfo]: """Analyze a target using the first registered target-kind analyzer.""" for analyzer_cls in cls.__registry__.values(): analyzer = analyzer_cls() @@ -104,7 +80,7 @@ def matches(self, target: Union[Callable, Type, object]) -> bool: """Return whether this analyzer owns the target.""" @abstractmethod - def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, UnifiedParameterInfo]: + def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, ParameterInfo]: """Analyze the target.""" @@ -116,7 +92,7 @@ class CallableTargetAnalyzer(UnifiedParameterTargetAnalyzer): def matches(self, target: Union[Callable, Type, object]) -> bool: return callable(target) and not inspect.isclass(target) - def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, UnifiedParameterInfo]: + def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, ParameterInfo]: return UnifiedParameterAnalyzer._analyze_callable(target) @@ -128,7 +104,7 @@ class DataclassTypeTargetAnalyzer(UnifiedParameterTargetAnalyzer): def matches(self, target: Union[Callable, Type, object]) -> bool: return inspect.isclass(target) and dataclasses.is_dataclass(target) - def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, UnifiedParameterInfo]: + def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, ParameterInfo]: return UnifiedParameterAnalyzer._analyze_dataclass_type(target) @@ -140,7 +116,7 @@ class ClassTargetAnalyzer(UnifiedParameterTargetAnalyzer): def matches(self, target: Union[Callable, Type, object]) -> bool: return inspect.isclass(target) - def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, UnifiedParameterInfo]: + def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, ParameterInfo]: return UnifiedParameterAnalyzer._analyze_callable(target.__init__) @@ -152,7 +128,7 @@ class DataclassInstanceTargetAnalyzer(UnifiedParameterTargetAnalyzer): def matches(self, target: Union[Callable, Type, object]) -> bool: return dataclasses.is_dataclass(target) - def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, UnifiedParameterInfo]: + def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, ParameterInfo]: return UnifiedParameterAnalyzer._analyze_dataclass_instance(target) @@ -164,7 +140,7 @@ class ObjectInstanceTargetAnalyzer(UnifiedParameterTargetAnalyzer): def matches(self, target: Union[Callable, Type, object]) -> bool: return True - def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, UnifiedParameterInfo]: + def analyze(self, target: Union[Callable, Type, object]) -> Dict[str, ParameterInfo]: return UnifiedParameterAnalyzer._analyze_object_instance(target) @@ -177,7 +153,7 @@ class UnifiedParameterAnalyzer: """ @staticmethod - def analyze(target: Union[Callable, Type, object], exclude_params: Optional[list] = None) -> Dict[str, UnifiedParameterInfo]: + def analyze(target: Union[Callable, Type, object], exclude_params: Optional[list] = None) -> Dict[str, ParameterInfo]: """Analyze parameters from any source. Args: @@ -185,7 +161,7 @@ def analyze(target: Union[Callable, Type, object], exclude_params: Optional[list exclude_params: Optional list of parameter names to exclude from analysis Returns: - Dictionary mapping parameter names to UnifiedParameterInfo objects + Dictionary mapping parameter names to ParameterInfo objects Examples: # Function analysis @@ -229,40 +205,17 @@ def _excluded_parameter_names( return frozenset(names) @staticmethod - def _analyze_callable(callable_obj: Callable) -> Dict[str, UnifiedParameterInfo]: + def _analyze_callable(callable_obj: Callable) -> Dict[str, ParameterInfo]: """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 - + return SignatureAnalyzer.analyze(callable_obj) + @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 + def _analyze_dataclass_type(dataclass_type: Type) -> Dict[str, ParameterInfo]: + """Analyze a dataclass through its authoritative field declarations.""" + return SignatureAnalyzer._analyze_dataclass(dataclass_type) @staticmethod - def _analyze_object_instance(instance: object) -> Dict[str, UnifiedParameterInfo]: + def _analyze_object_instance(instance: object) -> Dict[str, ParameterInfo]: """Analyze a regular object instance by examining its full inheritance hierarchy. For dynamic containers like SimpleNamespace (which use **kwargs in __init__), @@ -295,8 +248,9 @@ def _analyze_object_instance(instance: object) -> Dict[str, UnifiedParameterInfo class_params = UnifiedParameterAnalyzer._analyze_callable(cls.__init__) # Remove 'self' parameter - if 'self' in class_params: - del class_params['self'] + class_params = { + name: info for name, info in class_params.items() if name != 'self' + } _logger.debug(f"🔧 _analyze_object_instance: cls={cls.__name__}, class_params after removing self={list(class_params.keys())}") @@ -312,14 +266,7 @@ def _analyze_object_instance(instance: object) -> Dict[str, UnifiedParameterInfo # 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': - 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" - ) + all_params[param_name] = param_info except Exception: # Skip classes that can't be analyzed - this is legitimate since some classes @@ -337,67 +284,21 @@ def _analyze_object_instance(instance: object) -> Dict[str, UnifiedParameterInfo continue # Infer type from value attr_type = type(attr_value) if attr_value is not None else type(None) - all_params[attr_name] = UnifiedParameterInfo( + all_params[attr_name] = ParameterInfo( name=attr_name, param_type=attr_type, default_value=attr_value, is_required=False, - description=None, - source_type="dynamic_attr" + description=None ) _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]: + def _analyze_dataclass_instance(instance: object) -> Dict[str, ParameterInfo]: """Analyze a dataclass instance. Uses current instance values as defaults. """ - param_info_dict = SignatureAnalyzer.analyze(instance) - return { - name: UnifiedParameterInfo.from_parameter_info( - param_info, - source_type="dataclass_instance", - ) - for name, param_info in param_info_dict.items() - } - - @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 + return SignatureAnalyzer.analyze(instance) diff --git a/tests/test_dataclass_source_preparation.py b/tests/test_dataclass_source_preparation.py index 6e80103..6645644 100644 --- a/tests/test_dataclass_source_preparation.py +++ b/tests/test_dataclass_source_preparation.py @@ -138,7 +138,13 @@ class Inner: module = _load_module(monkeypatch, "qualified_source_preparation", path) for declaration in (module.Outer, module.Outer.Inner, module.factory()): SignatureAnalyzer.prepare_dataclass_declaration(declaration) - assert SignatureAnalyzer._dataclass_source(declaration) == inspect.getsource(declaration) + assert SignatureAnalyzer._dataclass_source(declaration).source == inspect.getsource(declaration) + docs = SignatureAnalyzer._extract_inline_field_docs(module.Outer) + docs.clear() + assert SignatureAnalyzer._extract_inline_field_docs(module.Outer) == {"value": "Outer documentation"} + module.Outer.__name__ = "Inner" + assert SignatureAnalyzer._extract_inline_field_docs(module.Outer) == {"value": "Inner documentation"} + module.Outer.__name__ = "Outer" # The original inline extractor does not dedent nested blocks before parsing. # Source preparation preserves that behavior rather than repairing it here. assert SignatureAnalyzer._extract_inline_field_docs(module.Outer.Inner) == {} @@ -161,7 +167,7 @@ class Second: module = _load_module(monkeypatch, "live_source_preparation", path) SignatureAnalyzer.prepare_dataclass_declaration(module.First) module.First.__qualname__ = "Second" - assert SignatureAnalyzer._dataclass_source(module.First) == inspect.getsource(module.First) + assert SignatureAnalyzer._dataclass_source(module.First).source == inspect.getsource(module.First) module.First.__qualname__ = "First" path.write_text(source.replace("First documentation", "Changed documentation")) os.utime(path, (path.stat().st_atime, path.stat().st_mtime + 2)) @@ -184,7 +190,7 @@ def get_source(self, name): SignatureAnalyzer.prepare_dataclass_declaration(module.Loaded) loader.source = loader.source.replace("Original documentation", "Current documentation") linecache.cache.pop(module.__file__, None) - assert SignatureAnalyzer._dataclass_source(module.Loaded) == inspect.getsource(module.Loaded) + assert SignatureAnalyzer._dataclass_source(module.Loaded).source == inspect.getsource(module.Loaded) assert SignatureAnalyzer._extract_inline_field_docs(module.Loaded) == {"value": "Current documentation"} @@ -212,3 +218,74 @@ def test_missing_source_keeps_original_error(): assert str(prepared.value) == str(original.value) SignatureAnalyzer.prepare_dataclass_declaration(int) assert SignatureAnalyzer._extract_inline_field_docs(int) == {} + + +def test_preparation_derives_raw_schema_closure_without_resolving_forward_refs(monkeypatch): + calls = [] + + @dataclasses.dataclass + class Child: + value: int = 1 + + @dataclasses.dataclass + class Parent: + child: "Child" = dataclasses.field(default_factory=Child) + children: tuple[Child, ...] = () + effect: int = dataclasses.field(default_factory=lambda: calls.append("factory")) + + seen = [] + monkeypatch.setattr(SignatureAnalyzer, "_extract_inline_field_docs", lambda declaration: seen.append(declaration)) + SignatureAnalyzer.prepare_dataclass_declarations((Parent, Parent)) + assert seen == [Parent, Child] + assert calls == [] + assert Parent.__annotations__["child"] == "Child" + assert Parent not in SignatureAnalyzer._dataclass_analysis_cache + + +def test_help_failure_does_not_discard_admitted_values_or_repeat_factories(monkeypatch): + from python_introspect import DocstringExtractor + + calls = [] + + def default(): + calls.append("factory") + return [3] + + @dataclasses.dataclass + class Declaration: + value: list = dataclasses.field(default_factory=default) + + def help_failure(target): + calls.append("help") + raise RuntimeError("presentation unavailable") + + monkeypatch.setattr(DocstringExtractor, "extract", help_failure) + parameters = SignatureAnalyzer.analyze(Declaration) + assert calls == ["factory"] + assert parameters["value"].default_value == [3] + assert SignatureAnalyzer.analyze(Declaration) is parameters + overlay = parameters["value"]._replace(default_value=[4]) + assert overlay.default_value == [4] + assert calls == ["factory"] + assert overlay.description is None + assert parameters["value"].description is None + assert calls == ["factory", "help"] + assert parameters["value"]._replace(default_value=[4]).default_value == [4] + assert calls == ["factory", "help"] + + +def test_retained_parameter_help_survives_analysis_cache_clear(monkeypatch): + from python_introspect import DocstringExtractor + + @dataclasses.dataclass + class Declaration: + value: int = dataclasses.field(default=3, metadata={"description": "field help"}) + + info = SignatureAnalyzer.analyze(Declaration)["value"] + monkeypatch.delitem(SignatureAnalyzer._dataclass_analysis_cache, Declaration) + replacement = info._replace(name="renamed") + assert replacement.description == "field help" + assert info == replacement._replace(name="value") + assert hash(info) == hash(replacement._replace(name="value")) + assert info.description == "field help" + assert Declaration not in SignatureAnalyzer._dataclass_analysis_cache diff --git a/tests/test_init.py b/tests/test_init.py index 9e9519a..e6501a4 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -49,13 +49,6 @@ def test_unified_parameter_analyzer_import(self): assert UnifiedParameterAnalyzer is not None - def test_unified_parameter_info_import(self): - """Test UnifiedParameterInfo is importable.""" - assert hasattr(python_introspect, "UnifiedParameterInfo") - from python_introspect import UnifiedParameterInfo - - assert UnifiedParameterInfo is not None - def test_exceptions_import(self): """Test all exception classes are importable.""" from python_introspect import ( @@ -85,7 +78,6 @@ def test_all_exports(self): "set_signature_analysis_target", "signature_analysis_target", "UnifiedParameterAnalyzer", - "UnifiedParameterInfo", "set_parameter_exclusions", "parameter_exclusions", "IntrospectionError", diff --git a/tests/test_unified_parameter_analyzer.py b/tests/test_unified_parameter_analyzer.py index 4c8f18f..af83446 100644 --- a/tests/test_unified_parameter_analyzer.py +++ b/tests/test_unified_parameter_analyzer.py @@ -6,7 +6,8 @@ from python_introspect import ( add_parameter_exclusions, UnifiedParameterAnalyzer, - UnifiedParameterInfo, + ParameterInfo, + SignatureAnalyzer, set_parameter_exclusions, ) @@ -30,11 +31,10 @@ def test_func(name: str, age: int = 25): assert "name" in params assert "age" in params - assert isinstance(params["name"], UnifiedParameterInfo) + assert isinstance(params["name"], ParameterInfo) assert params["name"].param_type == str assert params["name"].is_required is True assert params["age"].default_value == 25 - assert params["age"].source_type == "function" def test_analyze_dataclass_type(self): """Test analyzing a dataclass type.""" @@ -54,8 +54,8 @@ class Config: assert "name" in params assert "value" in params - assert params["name"].source_type == "dataclass" assert params["value"].default_value == 10 + assert params["value"] is SignatureAnalyzer.analyze(Config)["value"] def test_analyze_dataclass_instance(self): """Test analyzing a dataclass instance.""" @@ -70,7 +70,6 @@ class Config: assert params["name"].default_value == "default" assert params["value"].default_value == 10 - assert params["name"].source_type == "dataclass_instance" def test_analyze_with_exclusions(self): """Test analyzing with parameter exclusions.""" @@ -176,24 +175,6 @@ def __call__(self, runtime_context=None, **kwargs): assert "image" not in params assert "runtime_context" not in params - def test_analyze_nested(self): - """Test nested analysis for dataclass fields.""" - @dataclass - class Inner: - value: int = 5 - - @dataclass - class Outer: - name: str - inner: Inner = field(default_factory=Inner) - - analyzer = UnifiedParameterAnalyzer() - params = analyzer.analyze_nested(Outer) - - assert "name" in params - assert "inner" in params - assert "nested" in params["inner"].source_type - def test_analyze_method(self): """Test analyzing instance methods.""" class MyClass: @@ -249,52 +230,6 @@ def func( assert "optional" in params -class TestUnifiedParameterInfo: - """Test UnifiedParameterInfo dataclass.""" - - def test_create_unified_parameter_info(self): - """Test creating UnifiedParameterInfo.""" - info = UnifiedParameterInfo( - name="test", - param_type=str, - default_value="default", - is_required=False, - description="Test param", - source_type="function" - ) - - assert info.name == "test" - assert info.param_type == str - assert info.default_value == "default" - assert info.is_required is False - assert info.description == "Test param" - assert info.source_type == "function" - - def test_from_parameter_info(self): - """Test converting from ParameterInfo.""" - from python_introspect import ParameterInfo - - param_info = ParameterInfo( - name="test", - param_type=int, - default_value=10, - is_required=True, - description="Test" - ) - - unified = UnifiedParameterInfo.from_parameter_info( - param_info, - source_type="test" - ) - - assert unified.name == "test" - assert unified.param_type == int - assert unified.default_value == 10 - assert unified.is_required is True - assert unified.description == "Test" - assert unified.source_type == "test" - - class TestObjectInstanceAnalysis: """Test analysis of regular object instances."""