Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/python_introspect/annotation_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
68 changes: 25 additions & 43 deletions src/python_introspect/signature_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# =============================================================================
Expand Down Expand Up @@ -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:
Expand Down
46 changes: 46 additions & 0 deletions tests/test_declaration_failures.py
Original file line number Diff line number Diff line change
@@ -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
Loading