diff --git a/src/python_introspect/__init__.py b/src/python_introspect/__init__.py index 7eebd2b..b592866 100644 --- a/src/python_introspect/__init__.py +++ b/src/python_introspect/__init__.py @@ -19,6 +19,7 @@ # Plugin registration register_namespace_provider, register_type_resolver, + resolve_declared_type, set_signature_analysis_target, signature_analysis_target, ) @@ -42,6 +43,7 @@ from .choices import AnnotationChoices, declared_annotation_choices from .field_label import FieldLabel, declared_field_label from .validation import ( + DataclassPostInit, AnnotatedDataclassValidationMixin, AnnotationValidationError, overlay_non_none_dataclass, @@ -102,6 +104,7 @@ # Plugin registration "register_namespace_provider", "register_type_resolver", + "resolve_declared_type", "set_signature_analysis_target", "signature_analysis_target", # Unified analysis @@ -119,6 +122,7 @@ "is_enableable", "mark_enableable", # Runtime annotation validation + "DataclassPostInit", "AnnotatedDataclassValidationMixin", "AnnotationValidationError", "overlay_non_none_dataclass", diff --git a/src/python_introspect/signature_analyzer.py b/src/python_introspect/signature_analyzer.py index 8f32713..ec94652 100644 --- a/src/python_introspect/signature_analyzer.py +++ b/src/python_introspect/signature_analyzer.py @@ -111,7 +111,7 @@ def _get_extended_namespace() -> Dict[str, Any]: return result -def _resolve_type(t: type) -> type: +def resolve_declared_type(t: type) -> type: """Resolve a type through registered resolvers, returning the unwrapped type.""" for resolver in _type_resolvers: resolved = resolver(t) @@ -502,7 +502,7 @@ def _resolve_lazy_target(target: Union[Callable, type]) -> Union[Callable, type] """Resolve a proxy type through the registered type authority.""" if not inspect.isclass(target): return target - return _resolve_type(target) + return resolve_declared_type(target) @staticmethod def _parse_docstring(docstring: str) -> DocstringInfo: @@ -1045,7 +1045,7 @@ def _analyze_dataclass(dataclass_type: type) -> Dict[str, ParameterInfo]: 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): + if not field.init: continue param_type = type_hints[field.name] if field.default is not dataclasses.MISSING: @@ -1286,7 +1286,7 @@ def _extract_field_type_docs(dataclass_type: type) -> Dict[str, str]: # If the field type is a dataclass, extract its docstring as field documentation if dataclasses.is_dataclass(field_type): # ENHANCEMENT: Resolve lazy dataclasses to their base classes for documentation - resolved_field_type = _resolve_type(field_type) + resolved_field_type = resolve_declared_type(field_type) docstring_info = DocstringExtractor.extract(resolved_field_type) if docstring_info.summary: @@ -1373,7 +1373,7 @@ def extract_field_documentation(dataclass_type: type, field_name: str) -> Option # ENHANCEMENT: Resolve lazy dataclasses to their base classes # Frameworks can register explicit proxy-to-public type resolvers. - resolved_type = _resolve_type(dataclass_type) + resolved_type = resolve_declared_type(dataclass_type) # Check cache first for performance cache_key = (resolved_type.__name__, resolved_type.__module__) diff --git a/src/python_introspect/validation.py b/src/python_introspect/validation.py index bb3e816..2ba6c4f 100644 --- a/src/python_introspect/validation.py +++ b/src/python_introspect/validation.py @@ -29,7 +29,18 @@ class AnnotationValidationError(ValueError): DataclassT = TypeVar("DataclassT") -class AnnotatedDataclassValidationMixin: +class DataclassPostInit: + """Terminal owner for cooperative dataclass post-init capabilities. + + Every participating mixin inherits this endpoint. C3 puts it after the + participating capabilities, so none needs a missing-method fallback. + """ + + def __post_init__(self) -> None: + pass + + +class AnnotatedDataclassValidationMixin(DataclassPostInit): """Validate a dataclass instance from its resolved annotations after construction. Dataclass declarations inherit this mixin when their annotations are the @@ -39,6 +50,7 @@ class AnnotatedDataclassValidationMixin: """ def __post_init__(self) -> None: + super().__post_init__() validate_annotated_dataclass(self) @@ -77,7 +89,12 @@ def overlay_non_none_dataclass( def validate_annotated_dataclass(instance: object) -> None: - """Validate every field from the instance's resolved dataclass annotations.""" + """Validate declared field values through their owning access semantics. + + A field descriptor may supply an effective value (for example inherited + configuration data). Bypassing it validates private storage instead of the + declared value. Literal null remains subject to the annotation. + """ owner_type = type(instance) if not is_dataclass(instance) or isinstance(instance, type): @@ -88,8 +105,8 @@ def validate_annotated_dataclass(instance: object) -> None: annotations = resolved_class_annotations(owner_type) for declared_field in fields(instance): - annotation = annotations.get(declared_field.name, declared_field.type) - value = object.__getattribute__(instance, declared_field.name) + annotation = annotations[declared_field.name] + value = getattr(instance, declared_field.name) validate_annotation_value( annotation, value, diff --git a/tests/test_declaration_failures.py b/tests/test_declaration_failures.py index 5eaad67..6cb2092 100644 --- a/tests/test_declaration_failures.py +++ b/tests/test_declaration_failures.py @@ -4,7 +4,7 @@ import pytest -from python_introspect import UnifiedParameterAnalyzer +from python_introspect import UnifiedParameterAnalyzer, register_type_resolver, resolve_declared_type from python_introspect.signature_analyzer import SignatureAnalyzer @@ -44,3 +44,15 @@ class BrokenFactory: UnifiedParameterAnalyzer.analyze(BrokenFactory) assert raised.value is error assert BrokenFactory not in SignatureAnalyzer._dataclass_analysis_cache + + +def test_public_declared_type_resolver_uses_the_registered_owner(): + class Declared: + pass + + class Generated: + pass + + register_type_resolver(lambda candidate: Declared if candidate is Generated else None) + assert resolve_declared_type(Generated) is Declared + assert resolve_declared_type(Declared) is Declared diff --git a/tests/test_signature_analyzer.py b/tests/test_signature_analyzer.py index c4ce29d..0d3cd5d 100644 --- a/tests/test_signature_analyzer.py +++ b/tests/test_signature_analyzer.py @@ -312,18 +312,18 @@ def __init__(self, name: str, value: int = 10): assert params["name"].is_required is True assert params["value"].default_value == 10 - def test_skip_dunder_parameters(self): - """Test that dunder parameters are skipped.""" + def test_constructor_fields_are_authoritative(self): + """Init capability, not spelling, defines constructor parameters.""" @dataclass class Config: name: str = "test" - __internal__: int = 0 + __declared__: int = 1 + __internal__: int = field(default=0, init=False) + computed: int = field(default=2, init=False) - analyzer = SignatureAnalyzer() - params = analyzer.analyze(Config) + params = SignatureAnalyzer.analyze(Config) - assert "name" in params - assert "__internal__" not in params + assert set(params) == {"name", "__declared__"} def test_analyze_with_optional_types(self): """Test analyzing functions with Optional types.""" diff --git a/tests/test_validation_field_access.py b/tests/test_validation_field_access.py new file mode 100644 index 0000000..0943ed1 --- /dev/null +++ b/tests/test_validation_field_access.py @@ -0,0 +1,71 @@ +"""Annotation validation consumes the field owner's public value semantics.""" +from dataclasses import dataclass + +import pytest + +from python_introspect import ( + AnnotatedDataclassValidationMixin, DataclassPostInit, validate_annotated_dataclass, +) + + +DEFERRED = object() + + +@dataclass +class EffectiveValue(AnnotatedDataclassValidationMixin): + value: int + + def __getattribute__(self, name): + value = object.__getattribute__(self, name) + return 7 if name == "value" and value is DEFERRED else value + + +def test_validation_reads_effective_values_without_rewriting_storage(): + instance = EffectiveValue(DEFERRED) + assert instance.value == 7 + assert object.__getattribute__(instance, "value") is DEFERRED + validate_annotated_dataclass(instance) + + +@pytest.mark.parametrize("value", [None, "invalid", False]) +def test_literal_null_and_invalid_concrete_values_are_not_masked(value): + with pytest.raises(TypeError, match="must be int"): + EffectiveValue(value) + + +def test_field_descriptor_failure_propagates_its_original_identity(): + failure = RuntimeError("field accessor failed") + + @dataclass + class FailedRead(AnnotatedDataclassValidationMixin): + value: int + + def __getattribute__(self, name): + if name == "value": + raise failure + return object.__getattribute__(self, name) + + with pytest.raises(RuntimeError) as raised: + FailedRead(3) + assert raised.value is failure + + +def test_validator_cooperates_with_the_next_post_init_capability(): + events = [] + + class PrepareValue(DataclassPostInit): + def __post_init__(self): + events.append("prepare") + self.value = int(self.value) + super().__post_init__() + + @dataclass + class PreparedValue(AnnotatedDataclassValidationMixin, PrepareValue): + value: int + label: str = "ok" + + assert PreparedValue("7").value == 7 + assert events == ["prepare"] + with pytest.raises(TypeError, match="label must be str"): + PreparedValue("7", label=None) + assert events == ["prepare", "prepare"]