Skip to content
Draft
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: 4 additions & 0 deletions src/python_introspect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
# Plugin registration
register_namespace_provider,
register_type_resolver,
resolve_declared_type,
set_signature_analysis_target,
signature_analysis_target,
)
Expand All @@ -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,
Expand Down Expand Up @@ -102,6 +104,7 @@
# Plugin registration
"register_namespace_provider",
"register_type_resolver",
"resolve_declared_type",
"set_signature_analysis_target",
"signature_analysis_target",
# Unified analysis
Expand All @@ -119,6 +122,7 @@
"is_enableable",
"mark_enableable",
# Runtime annotation validation
"DataclassPostInit",
"AnnotatedDataclassValidationMixin",
"AnnotationValidationError",
"overlay_non_none_dataclass",
Expand Down
10 changes: 5 additions & 5 deletions src/python_introspect/signature_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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__)
Expand Down
25 changes: 21 additions & 4 deletions src/python_introspect/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,6 +50,7 @@ class AnnotatedDataclassValidationMixin:
"""

def __post_init__(self) -> None:
super().__post_init__()
validate_annotated_dataclass(self)


Expand Down Expand Up @@ -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):
Expand All @@ -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,
Expand Down
14 changes: 13 additions & 1 deletion tests/test_declaration_failures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
14 changes: 7 additions & 7 deletions tests/test_signature_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
71 changes: 71 additions & 0 deletions tests/test_validation_field_access.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading