From 6d395a790a9ca17339d3e223692ff6c6d753f92e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 30 Jul 2026 15:42:23 -0400 Subject: [PATCH] Preserve lazy runtime types during reconstruction --- pyproject.toml | 2 +- src/objectstate/object_state.py | 16 ++++++++++++---- tests/test_object_state_update_instance.py | 22 ++++++++++++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d61eb0f..62f6f5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "objectstate" -version = "1.0.21" +version = "1.0.22" description = "Generic lazy dataclass configuration framework with dual-axis inheritance and contextvars-based resolution" authors = [{name = "Tristan Simas", email = "tristan.simas@mail.mcgill.ca"}] license = {text = "MIT"} diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index f6a921f..2b7f734 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -2483,12 +2483,20 @@ def _extract_all_parameters_flat(self, obj: Any, prefix: str = '', exclude_param # First try from type annotation, then fall back to checking actual value nested_type = self._get_nested_dataclass_type(info.param_type) - # For functions with injected params, param_type may be Any but value is dataclass - # Use is_dataclass on the TYPE, not the value (to avoid triggering lazy resolution) - if nested_type is None and current_value is not None: + # A registered lazy runtime value owns its raw reconstruction type. + # The enclosing annotation commonly names the concrete base config, + # but reconstructing unresolved ``None`` sentinels through that base + # would run concrete validation before inheritance resolution. + if current_value is not None: value_type = type(current_value) if is_dataclass(value_type): - nested_type = value_type + from objectstate.lazy_factory import get_base_type_for_lazy + + if ( + nested_type is None + or get_base_type_for_lazy(value_type) is not None + ): + nested_type = value_type if nested_type is not None and current_value is not None: # Store the nested config type reference at this path diff --git a/tests/test_object_state_update_instance.py b/tests/test_object_state_update_instance.py index 3080f02..86e5541 100644 --- a/tests/test_object_state_update_instance.py +++ b/tests/test_object_state_update_instance.py @@ -1,8 +1,11 @@ """ObjectState replacement lifecycle tests.""" from dataclasses import dataclass, field +from typing import Annotated import pytest +from annotated_types import Gt +from python_introspect import AnnotatedDataclassValidationMixin from objectstate import ( DottedFieldPath, @@ -218,6 +221,25 @@ class RootConfig: assert state.get_resolved_value("child_config.leaf") == value +def test_raw_reconstruction_preserves_registered_lazy_runtime_identity(): + @dataclass + class ViewerConfig(AnnotatedDataclassValidationMixin): + port: Annotated[int, Gt(0)] = 5555 + + lazy_viewer_config_type = LazyDataclassFactory.make_lazy_simple(ViewerConfig) + + @dataclass + class RootConfig: + viewer_config: ViewerConfig = field(default_factory=lazy_viewer_config_type) + + state = ObjectState(RootConfig(), scope_id="config") + + reconstructed = state.to_object() + + assert isinstance(reconstructed.viewer_config, lazy_viewer_config_type) + assert object.__getattribute__(reconstructed.viewer_config, "port") is None + + def test_delegate_replacement_refreshes_saved_resolved_and_default_diff(): host = DelegatedHost(PlainConfig()) state = ObjectState(host, scope_id="host")