From ee0b65f0c35a753ba7b026f2ea1cbcdf73e8a0cf Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 3 Oct 2026 23:22:00 -0400 Subject: [PATCH 1/3] Fix declared derived fields in dataclass mapping decode --- docs/source/derived-field-decoding.rst | 58 ++++++++++++++ src/python_introspect/dataclass_projection.py | 34 ++++++-- tests/test_dataclass_projection.py | 78 ++++++++++++++++++- 3 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 docs/source/derived-field-decoding.rst diff --git a/docs/source/derived-field-decoding.rst b/docs/source/derived-field-decoding.rst new file mode 100644 index 0000000..eb85668 --- /dev/null +++ b/docs/source/derived-field-decoding.rst @@ -0,0 +1,58 @@ +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. 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( From fad3cbb2f3f83512359595460ebaafef0c22fa7a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 3 Oct 2026 23:27:58 -0400 Subject: [PATCH 2/3] Record receiving DTO acceptance and remove stale version golden --- docs/source/derived-field-decoding.rst | 23 +++++++++++++++++++++++ docs/source/index.rst | 1 + tests/test_init.py | 3 ++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/source/derived-field-decoding.rst b/docs/source/derived-field-decoding.rst index eb85668..bec7ff5 100644 --- a/docs/source/derived-field-decoding.rst +++ b/docs/source/derived-field-decoding.rst @@ -56,3 +56,26 @@ 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. 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/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.""" From ac7b2388ed800649a8d0157f00056471a466dd20 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 3 Oct 2026 23:29:38 -0400 Subject: [PATCH 3/3] Prepare patch release 0.1.16 with shared decoder fix --- docs/source/derived-field-decoding.rst | 6 ++++++ pyproject.toml | 2 +- src/python_introspect/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/source/derived-field-decoding.rst b/docs/source/derived-field-decoding.rst index bec7ff5..db79aac 100644 --- a/docs/source/derived-field-decoding.rst +++ b/docs/source/derived-field-decoding.rst @@ -79,3 +79,9 @@ suite passed: 144 tests in 0.80 seconds using the existing Python 3.12 environme 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/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,