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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ keywords = ["configuration", "dataclass", "lazy", "inheritance", "contextvars",

dependencies = [
"dill>=0.4.0",
"python-introspect>=0.1.6",
"python-introspect>=0.2.0,<0.3",
]

[project.optional-dependencies]
Expand Down
2 changes: 1 addition & 1 deletion src/objectstate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@
'ObjectStateEditSession',
]

__version__ = "1.2.0"
__version__ = "1.3.0"
__author__ = 'OpenHCS Team'
__description__ = 'Generic configuration framework for lazy dataclass resolution'

Expand Down
70 changes: 15 additions & 55 deletions src/objectstate/object_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,7 @@ def parameter_descriptions(self) -> dict[str, str | None]:
Dictionary mapping dotted parameter paths to their descriptions (value may be None).
E.g., {'well_filter_config.well_filter': 'Filter wells by...'}
"""
return dict(self._parameter_descriptions)
return self._parameter_structure.descriptions

def type_for_path(self, field_path: str) -> ParameterOwner:
"""Return the ObjectState-recorded type for a dotted field path.
Expand Down Expand Up @@ -769,27 +769,13 @@ def _analyze_parameters(

Returns dict mapping param_name -> info object with .param_type, .default_value, and .description attributes.
"""
from types import SimpleNamespace

exclude_params = exclude_params or []
result = {}

# NOTE: python_introspect is a required dependency; fail loud if missing.
# UnifiedParameterAnalyzer is responsible for correctness guarantees:
# - defaults come from type/signature (never from instance)
# - current instance values are accessed in a lazy-safe way (if needed)
from python_introspect import UnifiedParameterAnalyzer

ua_info = UnifiedParameterAnalyzer.analyze(obj, exclude_params=exclude_params)
for name, info in ua_info.items():
if name in exclude_params:
continue
result[name] = SimpleNamespace(
param_type=getattr(info, "param_type", Any),
default_value=getattr(info, "default_value", None),
description=getattr(info, "description", None),
)
return result
return UnifiedParameterAnalyzer.analyze(obj, exclude_params=exclude_params)

@staticmethod
def _get_nested_dataclass_type(param_type: Any) -> type | None:
Expand Down Expand Up @@ -1219,7 +1205,10 @@ def subfield_semantics(
raw_value = self.parameters.get(path.value, MISSING)
resolved_value = self.get_resolved_value(path.value)
saved_resolved_value = self.get_saved_resolved_value(path.value)
signature_default_value = self._signature_defaults.get(path.value, MISSING)
declaration = self._parameter_structure.declarations.get(path.value)
signature_default_value = (
declaration.default_value if declaration is not None else MISSING
)

return build_subfield_semantic_index(
owner_field_path=path,
Expand Down Expand Up @@ -1557,22 +1546,6 @@ def _direct_parameter_paths(
) -> None:
self._parameter_structure.direct_paths = value

@property
def _signature_defaults(self) -> dict[str, Any]:
return self._parameter_structure.defaults

@_signature_defaults.setter
def _signature_defaults(self, value: dict[str, Any]) -> None:
self._parameter_structure.defaults = value

@property
def _parameter_descriptions(self) -> dict[str, str | None]:
return self._parameter_structure.descriptions

@_parameter_descriptions.setter
def _parameter_descriptions(self, value: dict[str, str | None]) -> None:
self._parameter_structure.descriptions = value

@property
def _exclude_param_names(self) -> list[str]:
return self._parameter_structure.exclusions
Expand Down Expand Up @@ -1893,16 +1866,17 @@ def reset_parameter(self, param_name: str) -> None:

# Use signature defaults (CLASS defaults), not instance values
# This ensures reset goes back to None for lazy fields, not saved concrete values
default_value = self._signature_defaults.get(param_name)
declaration = self._parameter_structure.declarations.get(param_name)
default_value = declaration.default_value if declaration is not None else None
self.update_parameter(param_name, default_value)

def signature_default(self, param_name: str) -> Any:
"""Return the signature default recorded for one flat parameter path."""
if param_name not in self._signature_defaults:
if param_name not in self._parameter_structure.declarations:
raise KeyError(
f"No signature default recorded for parameter {param_name!r}."
)
return self._signature_defaults[param_name]
return self._parameter_structure.declarations[param_name].default_value

def get_current_values(self) -> dict[str, Any]:
"""
Expand Down Expand Up @@ -2014,14 +1988,13 @@ def _compute_signature_diff_fields(self) -> set[str]:
"""Compute signature-diff set from parameters vs defaults.

Any field that differs from its signature default is included.
Nested dataclass container fields are implicitly excluded since
they don't have entries in _signature_defaults (only leaf fields do).
Defaults come from the same declarations used for reset, including
declared nested containers.
"""
result = set()
for k, v in self.parameters.items():
if k in self._signature_defaults:
# Direct dict key access - no special behavior to avoid
sig_default = self._signature_defaults[k]
if k in self._parameter_structure.declarations:
sig_default = self._parameter_structure.declarations[k].default_value
is_diff = v != sig_default
if is_diff:
result.add(k)
Expand Down Expand Up @@ -2577,10 +2550,7 @@ def _extract_all_parameters_flat(
except AttributeError:
current_value = info.default_value

# Store description entry for the dotted path. Even if the specific
# parameter has no description, ensure the dotted key exists so
# callers can rely on presence of the full path (value may be None).
structure.descriptions[dotted_path] = getattr(info, 'description', None)
structure.declarations[dotted_path] = info

# Check if this is a nested dataclass
# First try from type annotation, then fall back to checking actual value
Expand All @@ -2599,13 +2569,6 @@ def _extract_all_parameters_flat(
# Store the nested dataclass instance in parameters (needed for UI rendering)
parameters[dotted_path] = current_value

# Container resets follow the same declared-default authority as
# leaf resets. Lazy dataclass fields retain their intentional
# ``None`` default, while callable parameters with concrete
# dataclass defaults can be restored after an authored override
# is removed.
structure.defaults[dotted_path] = info.default_value

# Recurse into nested dataclass for child fields
cls._extract_all_parameters_flat(
current_value, parameters, structure, prefix=dotted_path, exclude_params=[]
Expand All @@ -2615,9 +2578,6 @@ def _extract_all_parameters_flat(
parameters[dotted_path] = current_value
# Store the owner target that has this field.
structure.owner_paths[dotted_path] = owner_target
# Store signature default for reset functionality (flattened)
# info.default_value is now guaranteed to be the CLASS signature default
structure.defaults[dotted_path] = info.default_value

def _replace_parameter_structure(
self,
Expand Down
17 changes: 15 additions & 2 deletions src/objectstate/parameter_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from dataclasses import dataclass, field, fields, is_dataclass, replace
from typing import Any

from python_introspect import ParameterInfo

from objectstate.field_access import DottedFieldPath
from objectstate.parameter_owner import ParameterOwner

Expand All @@ -18,11 +20,22 @@ class ParameterStructure:
direct_paths: dict[DottedFieldPath, tuple[DottedFieldPath, ...]] = field(
default_factory=dict
)
defaults: dict[str, Any] = field(default_factory=dict)
descriptions: dict[str, str | None] = field(default_factory=dict)
declarations: dict[str, ParameterInfo] = field(default_factory=dict)
exclusions: list[str] = field(default_factory=list)
excluded_values: dict[str, Any] = field(default_factory=dict)

@property
def defaults(self) -> dict[str, Any]:
"""Derive editor reset values from admitted parameter declarations."""

return {path: info.default_value for path, info in self.declarations.items()}

@property
def descriptions(self) -> dict[str, str | None]:
"""Request presentation help only when a consumer reads it."""

return {path: info.description for path, info in self.declarations.items()}

def is_container_parameter(
self, name: str, value: Any, parameters: dict[str, Any]
) -> bool:
Expand Down
3 changes: 2 additions & 1 deletion tests/test_construction_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ def test_same_scope_callable_owner_and_schema_restore_without_replacing_state():
assert ObjectStateRegistry.get_by_scope("callable") is state
assert state.object_instance is function
assert set(state.parameters) == parameters
assert set(state._signature_defaults) == parameters
for name in parameters:
state.signature_default(name)
assert set(state._path_to_type) == parameters


Expand Down
Loading