diff --git a/docs/source/derived-field-decoding.rst b/docs/source/derived-field-decoding.rst new file mode 100644 index 0000000..db79aac --- /dev/null +++ b/docs/source/derived-field-decoding.rst @@ -0,0 +1,87 @@ +Declared derived-field decoding +=============================== + +The dataclass declaration owns field membership. Its constructor, defaults and +post-init behavior own non-init field values. ``dataclass_from_mapping`` admits +all fields from ``dataclasses.fields`` and decodes supplied values through the +existing annotation decoder. It passes only init fields to the constructor. +Supplied non-init fields must equal the constructed owner's values. It never +assigns supplied values to derived fields. Unknown keys remain errors; omitted +derived fields are computed normally. ``project_dataclass`` remains an init-only +projection, not a wire decoder. + +Original failure +---------------- + +OpenHCS issue 578 observed successful native registration and status readback, +but the normal MCP CLI exited with a decode error. The canonical serializer +emitted ``CustomFunctionRegistrationObservation.outcome``. That declared +``init=False`` field was rejected as undeclared because the shared decoder used +the constructor subset as field membership. OpenHCS PR 579 independently fixes +the visibility of this client rejection. No operation is replayed here. + +Ownership and source coverage +----------------------------- + +The change starts at dependency main +``ed1e4eb15f7a0bf8315274d3e47858fa2b4372bb``. The existing decoder and annotation +validator remain the only owners. No OpenHCS-specific key list, alternate codec, +raw fallback, outcome branch or field-state copy is introduced. The applicable +refactor-audit patterns are BOUND-1, BOUND-2, BOUND-3 and MEMB-5: derive membership +from declarations and decode once at the boundary. + +Before editing, the existing refactor-audit ``Package`` / ``Repository`` AST +loader covered current OpenHCS (687 modules), the dependency (12 modules), and +all eight OpenHCS dependency roots (354 modules). There were zero parse omissions. +This is source evidence, not a complete NRA detector scan or behavioral proof. +The query found the decoder, recursive annotation call and validation owner; +imports/calls in viewer DTOs, UI bridge service, runtime image values, source +provenance, MCP client/server and desktop updater. Existing consumers still use +the same public decoder. Constructor projections, overlay operations and native +post-init derived values retain their existing authority. The receiving version +adds the registration-observation DTO and the declaration-driven client result +decoder; it uses this same dependency entrypoint, not a second decoder. + +Annotation resolution uses ``get_type_hints(..., include_extras=True)`` and the +existing recursive converter. Dynamic annotation namespaces are not statically +proved by this AST query. Equality uses the constructed field value's Python +equality contract; this change does not introduce framework-specific array +comparison semantics. + +Verification scope +------------------ + +The focused family exercises inherited/frozen and slotted dataclasses, enum and +tuple conversion, nested derived DTOs, omitted derived fields, factory defaults, +contradictory derived values, invalid annotated values, unknown keys and missing +constructor fields. Verification results and actual OpenHCS receiving acceptance +are recorded separately; dependency tests alone do not establish CLI readiness. + +The first focused run passed 37 tests. The first entire dependency run passed +143 tests with one stale test failure: ``test_version_available`` still pinned +0.1.12 although dependency main had already released source version 0.1.15. +That test now checks the canonical PEP 440 version through ``packaging.Version`` +instead of adding a third literal version authority. The existing immutable +release action remains responsible for agreement between release metadata, +artifacts and tag; no release assertion is weakened here. + +The original saved OpenHCS registration-status reply from PR 567's receiving +attempt decoded as ``registered`` with zero errors and one published source. +Contradictory outcome, boolean outcome and undeclared keys were rejected. This +was a read-only decode of the original canonical receipt, not a registration +replay or a new native runtime. The qualified OpenHCS import unexpectedly peaked +at 7,490,312 KiB; that process exited normally and the integration workers were +informed of the actual cost. It is not a lightweight dependency-only check. +Full client entrypoint acceptance remains separately assigned to PR 579's owner. + +After that stale internal-version assertion was migrated, the entire dependency +suite passed: 144 tests in 0.80 seconds using the existing Python 3.12 environment +and this checkout's ``src`` on ``PYTHONPATH``. No environment or installed +package was changed. This is dependency source acceptance plus the real saved +OpenHCS DTO acceptance, not fresh installed-client or registry publication proof. + +Release source follows the existing version convention: project metadata and +runtime declaration are advanced together to 0.1.16. Remote 0.1.15 is merged +source but has no public tag or published distribution; the new patch release +includes it. The existing immutable-tag publication workflow remains the +publication owner. A version edit or merged PR is not registry publication. diff --git a/docs/source/index.rst b/docs/source/index.rst index 0ca3d8c..8214542 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -10,6 +10,7 @@ signature defaults through one extensible API. extensions api + derived-field-decoding development Quick start diff --git a/pyproject.toml b/pyproject.toml index e10fc07..0c446c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-introspect" -version = "0.1.15" +version = "0.1.16" 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 133ee34..b655b59 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.15" +__version__ = "0.1.16" from .signature_analyzer import ( SignatureAnalyzer, diff --git a/src/python_introspect/dataclass_projection.py b/src/python_introspect/dataclass_projection.py index a0c52ae..a7cd39a 100644 --- a/src/python_introspect/dataclass_projection.py +++ b/src/python_introspect/dataclass_projection.py @@ -26,7 +26,11 @@ def dataclass_from_mapping( target_type: type[DataclassT], values: Mapping[str, object], ) -> DataclassT: - """Construct one dataclass from the fields declared by its class.""" + """Construct declared init fields and verify supplied constructor-owned fields. + + Non-init fields belong to the dataclass's defaults or post-init behavior, + not the input mapping. If supplied, they must agree with that owner. + """ if not isinstance(target_type, type) or not is_dataclass(target_type): raise TypeError( @@ -41,9 +45,7 @@ def dataclass_from_mapping( f"got {non_text_keys!r}." ) - declared_fields = tuple( - declared_field for declared_field in fields(target_type) if declared_field.init - ) + declared_fields = fields(target_type) declared_names = {declared_field.name for declared_field in declared_fields} extras = tuple(sorted(set(values) - declared_names)) if extras: @@ -52,18 +54,19 @@ def dataclass_from_mapping( ) annotations = get_type_hints(target_type, include_extras=True) - constructor_values: dict[str, object] = {} + decoded_values: dict[str, object] = {} missing: list[str] = [] for declared_field in declared_fields: if declared_field.name not in values: if ( - declared_field.default is MISSING + declared_field.init + and declared_field.default is MISSING and declared_field.default_factory is MISSING ): missing.append(declared_field.name) continue annotation = annotations.get(declared_field.name, declared_field.type) - constructor_values[declared_field.name] = _mapping_value_for_annotation( + decoded_values[declared_field.name] = _mapping_value_for_annotation( annotation, values[declared_field.name], path=f"{target_type.__name__}.{declared_field.name}", @@ -73,8 +76,23 @@ def dataclass_from_mapping( f"{target_type.__name__} is missing required field(s): {', '.join(missing)}." ) - result = target_type(**constructor_values) + result = target_type( + **{ + declared_field.name: decoded_values[declared_field.name] + for declared_field in declared_fields + if declared_field.init and declared_field.name in decoded_values + } + ) validate_annotated_dataclass(result) + for declared_field in declared_fields: + if not declared_field.init and declared_field.name in decoded_values: + if decoded_values[declared_field.name] != object.__getattribute__( + result, declared_field.name + ): + raise ValueError( + f"{target_type.__name__}.{declared_field.name} disagrees with " + "its constructed value." + ) return result diff --git a/tests/test_dataclass_projection.py b/tests/test_dataclass_projection.py index 3ef476a..7e6018e 100644 --- a/tests/test_dataclass_projection.py +++ b/tests/test_dataclass_projection.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import asdict, dataclass, field from enum import Enum from collections.abc import Sequence @@ -48,6 +48,82 @@ class SequenceEnvelope: nodes: Sequence[RecursiveSequenceNode] +@dataclass(frozen=True) +class ConnectionWithSummary(Connection): + summary: tuple[str, int] = field(init=False) + effective_mode: Mode = field(init=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "summary", (self.host, self.port)) + object.__setattr__(self, "effective_mode", self.mode or Mode.IPC) + + +@dataclass(frozen=True, slots=True) +class DerivedDefaults: + count: int + label: str = field(init=False, default="counter") + connections: list[Connection] = field(init=False, default_factory=list) + + +@dataclass(frozen=True) +class DerivedEnvelope: + connection: ConnectionWithSummary + + +def test_dataclass_mapping_verifies_inherited_post_init_fields() -> None: + original = ConnectionWithSummary("localhost", 7888, Mode.TCP) + values = asdict(original) + values.update(mode="tcp", effective_mode="tcp", summary=["localhost", 7888]) + assert dataclass_from_mapping(ConnectionWithSummary, values) == original + assert dataclass_from_mapping( + ConnectionWithSummary, {"host": "localhost", "port": 7888} + ) == ConnectionWithSummary("localhost", 7888) + + +@pytest.mark.parametrize( + "overrides,error_type,match", + ( + ({"summary": ["elsewhere", 7888]}, ValueError, "summary.*disagrees"), + ({"effective_mode": "tcp"}, ValueError, "effective_mode.*disagrees"), + ({"summary": ["localhost", True]}, TypeError, "must be int"), + ({"effective_mode": "invalid"}, ValueError, "must be one of"), + ({"extra": 1}, ValueError, "undeclared.*extra"), + ), +) +def test_dataclass_mapping_cannot_override_derived_owner(overrides, error_type, match): + values = {"host": "localhost", "port": 7888, **overrides} + with pytest.raises(error_type, match=match): + dataclass_from_mapping(ConnectionWithSummary, values) + + +def test_dataclass_mapping_verifies_non_init_defaults_and_nested_derived_fields() -> None: + original = DerivedDefaults(3) + assert dataclass_from_mapping(DerivedDefaults, asdict(original)) == original + assert dataclass_from_mapping(DerivedDefaults, {"count": 3}) == original + with pytest.raises(ValueError, match="connections.*disagrees"): + dataclass_from_mapping( + DerivedDefaults, + {"count": 3, "connections": [{"host": "localhost", "port": 7888}]}, + ) + values = { + "connection": { + "host": "localhost", "port": 7888, + "summary": ["localhost", 7888], "effective_mode": "ipc", + } + } + assert dataclass_from_mapping(DerivedEnvelope, values) == DerivedEnvelope( + ConnectionWithSummary("localhost", 7888) + ) + values["connection"]["effective_mode"] = "tcp" + with pytest.raises(ValueError, match="effective_mode.*disagrees"): + dataclass_from_mapping(DerivedEnvelope, values) + + +def test_dataclass_mapping_derived_defaults_do_not_supply_missing_init_fields() -> None: + with pytest.raises(ValueError, match="missing required field.*count"): + dataclass_from_mapping(DerivedDefaults, {"label": "counter"}) + + @pytest.mark.parametrize("container", (list, tuple)) def test_dataclass_mapping_recurses_from_abstract_sequence_annotations(container): result = dataclass_from_mapping( diff --git a/tests/test_init.py b/tests/test_init.py index 82c3698..9e9519a 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -2,6 +2,7 @@ import pytest import python_introspect +from packaging.version import Version class TestPackageImports: @@ -11,7 +12,7 @@ def test_version_available(self): """Test that __version__ is available.""" assert hasattr(python_introspect, "__version__") assert isinstance(python_introspect.__version__, str) - assert python_introspect.__version__ == "0.1.12" + assert str(Version(python_introspect.__version__)) == python_introspect.__version__ def test_signature_analyzer_import(self): """Test SignatureAnalyzer is importable."""