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
87 changes: 87 additions & 0 deletions docs/source/derived-field-decoding.rst
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ signature defaults through one extensible API.

extensions
api
derived-field-decoding
development

Quick start
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/python_introspect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 26 additions & 8 deletions src/python_introspect/dataclass_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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:
Expand All @@ -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}",
Expand All @@ -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


Expand Down
78 changes: 77 additions & 1 deletion tests/test_dataclass_projection.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import asdict, dataclass, field
from enum import Enum
from collections.abc import Sequence

Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest
import python_introspect
from packaging.version import Version


class TestPackageImports:
Expand All @@ -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."""
Expand Down
Loading