diff --git a/src/python_introspect/annotation_types.py b/src/python_introspect/annotation_types.py index 09e09a8..919b132 100644 --- a/src/python_introspect/annotation_types.py +++ b/src/python_introspect/annotation_types.py @@ -25,7 +25,9 @@ def resolved_class_annotations(owner: type) -> dict[str, object]: reference is left as a ``ForwardRef``, the form ``get_type_hints`` produces for it on Python 3.11 and later. """ - annotations = get_type_hints(owner, include_extras=True) + annotations = get_type_hints( + owner, localns={owner.__name__: owner}, include_extras=True + ) unresolved = set(annotations) for declaration in owner.__mro__: class_namespace = dict(vars(declaration)) diff --git a/src/python_introspect/signature_analyzer.py b/src/python_introspect/signature_analyzer.py index aa920e4..8f32713 100644 --- a/src/python_introspect/signature_analyzer.py +++ b/src/python_introspect/signature_analyzer.py @@ -21,7 +21,7 @@ from dataclasses import dataclass, field from metaclass_registry import AutoRegisterMeta -from .annotation_types import optional_member_type +from .annotation_types import optional_member_type, resolved_class_annotations from .docstring_annotations import infer_parameter_annotation # ============================================================================= @@ -1042,48 +1042,30 @@ def _analyze_dataclass(dataclass_type: type) -> Dict[str, ParameterInfo]: logger.debug(f"❌ CACHE MISS for {dataclass_type.__name__} (id={id(dataclass_type)}), cache has {len(SignatureAnalyzer._dataclass_analysis_cache)} entries") - try: - # Try to get type hints, fall back to __annotations__ if resolution fails - try: - type_hints = get_type_hints(dataclass_type, include_extras=True) - except Exception: - type_hints = inspect.get_annotations(dataclass_type, eval_str=False) - - parameters = {} - - for field in dataclasses.fields(dataclass_type): - # Skip dunder fields (internal/reserved fields) - if field.name.startswith(CONSTANTS.DUNDER_PREFIX) and field.name.endswith(CONSTANTS.DUNDER_SUFFIX): - continue - - param_type = type_hints.get(field.name, str) - - # Get default value - if field.default != dataclasses.MISSING: - default_value = field.default - is_required = False - elif field.default_factory != dataclasses.MISSING: - default_value = field.default_factory() - is_required = False - else: - default_value = None - is_required = True - - parameters[field.name] = ParameterInfo( - name=field.name, - param_type=param_type, - default_value=default_value, - is_required=is_required, - description_owner=dataclass_type, - ) - - # PERFORMANCE: Cache the result to avoid re-parsing - SignatureAnalyzer._dataclass_analysis_cache[cache_key] = parameters - return parameters - - except Exception: - # Return empty dict on error (don't cache errors) - return {} + type_hints = resolved_class_annotations(dataclass_type) + parameters = {} + for field in dataclasses.fields(dataclass_type): + if field.name.startswith(CONSTANTS.DUNDER_PREFIX) and field.name.endswith(CONSTANTS.DUNDER_SUFFIX): + continue + param_type = type_hints[field.name] + if field.default is not dataclasses.MISSING: + default_value = field.default + is_required = False + elif field.default_factory is not dataclasses.MISSING: + default_value = field.default_factory() + is_required = False + else: + default_value = None + is_required = True + parameters[field.name] = ParameterInfo( + name=field.name, + param_type=param_type, + default_value=default_value, + is_required=is_required, + description_owner=dataclass_type, + ) + SignatureAnalyzer._dataclass_analysis_cache[cache_key] = parameters + return parameters @staticmethod def _materialize_dataclass_descriptions(dataclass_type: type, requested: ParameterInfo) -> None: diff --git a/tests/test_declaration_failures.py b/tests/test_declaration_failures.py new file mode 100644 index 0000000..5eaad67 --- /dev/null +++ b/tests/test_declaration_failures.py @@ -0,0 +1,46 @@ +"""Broken declarations fail at the existing analysis owner, not as empty forms.""" + +from dataclasses import dataclass, field + +import pytest + +from python_introspect import UnifiedParameterAnalyzer +from python_introspect.signature_analyzer import SignatureAnalyzer + + +def test_recursive_declaration_resolves_before_module_binding(): + @dataclass + class Recursive: + child: "Recursive | None" = None + + parameters = UnifiedParameterAnalyzer.analyze(Recursive) + assert parameters["child"].param_type == Recursive | None + assert parameters["child"].default_value is None + assert not parameters["child"].is_required + assert UnifiedParameterAnalyzer.analyze(Recursive) is parameters + + +def test_unknown_annotation_raises_without_caching_an_empty_form(): + @dataclass + class Broken: + value: "MissingDeclaration" + + with pytest.raises(NameError, match="MissingDeclaration"): + UnifiedParameterAnalyzer.analyze(Broken) + assert Broken not in SignatureAnalyzer._dataclass_analysis_cache + + +def test_default_factory_exception_identity_is_not_hidden(): + error = RuntimeError("declared factory failed") + + def fail(): + raise error + + @dataclass + class BrokenFactory: + value: int = field(default_factory=fail) + + with pytest.raises(RuntimeError) as raised: + UnifiedParameterAnalyzer.analyze(BrokenFactory) + assert raised.value is error + assert BrokenFactory not in SignatureAnalyzer._dataclass_analysis_cache