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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 5.0.0

Field addresses own their structure (#61). `DottedFieldPath` gains `parent` and
`lineage`; `ParameterStructure` gains `path`, `leaf_paths` and `ancestors`.
Readers that split dotted strings for the parent or leaf name now use these.

Removed: `ObjectState.signature_default`. Read the default from the admitted
declaration in `parameter_structure.declarations` instead.

## 4.0.1

Requires `python-introspect>=0.3.0,<0.4` (was `<0.3`). ObjectState does not call
Expand Down
2 changes: 1 addition & 1 deletion src/objectstate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@
'ObjectStateEditSession',
]

__version__ = "4.0.1"
__version__ = "5.0.0"
__author__ = 'OpenHCS Team'
__description__ = 'Generic configuration framework for lazy dataclass resolution'

Expand Down
25 changes: 20 additions & 5 deletions src/objectstate/field_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,23 @@

return self.parts[-1] if self.parts else ""

@property
def parent(self) -> "DottedFieldPath":

Check failure on line 33 in src/objectstate/field_access.py

View workflow job for this annotation

GitHub Actions / code-quality

Ruff UP037

Remove quotes from type annotation
"""The immediate container; a root field and the root both have an empty parent."""

return DottedFieldPath(self.value.rpartition(".")[0])

@property
def lineage(self) -> tuple["DottedFieldPath", ...]:

Check failure on line 39 in src/objectstate/field_access.py

View workflow job for this annotation

GitHub Actions / code-quality

Ruff UP037

Remove quotes from type annotation
"""This field followed by its parents, nearest first, excluding the empty root."""

paths = []
path = self
while path.value:
paths.append(path)
path = path.parent
return tuple(paths)

def child(self, field_name: str) -> "DottedFieldPath":
if self.value == "":
return DottedFieldPath(field_name)
Expand Down Expand Up @@ -59,12 +76,10 @@
"""Return the direct dotted child field owned by this path, when one exists."""

candidate_value = candidate.value if isinstance(candidate, DottedFieldPath) else candidate
if candidate_value == self.value or not self.contains_path(candidate_value):
return None
remainder = candidate_value[len(self.value):]
if not remainder.startswith("."):
prefix = f"{self.value}." if self.value else ""
if not candidate_value.startswith(prefix):
return None
child_remainder = remainder[1:]
child_remainder = candidate_value[len(prefix):]
child_name = child_remainder.split(".", 1)[0].split("[", 1)[0]
return child_name or None

Expand Down
7 changes: 4 additions & 3 deletions src/objectstate/inspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from collections.abc import Callable
from dataclasses import dataclass

from objectstate.field_access import DottedFieldPath
from objectstate.object_state import ObjectState
from objectstate.object_state_registry import ObjectStateRegistry

Expand Down Expand Up @@ -96,7 +97,7 @@ def of(cls, state: ObjectState, field_path: str):
provenance = FieldProvenance(
source_scope_id=source_scope_id,
source_type=source_type,
field_name=field_path.rsplit(".", 1)[-1],
field_name=DottedFieldPath(field_path).field_name,
)
return cls(
state=state,
Expand Down Expand Up @@ -141,11 +142,11 @@ def semantic_markers(self) -> tuple[str, ...]:

@property
def field_name(self) -> str:
return self.field_path.rsplit(".", 1)[-1]
return DottedFieldPath(self.field_path).field_name

@property
def container_path(self) -> str:
return self.field_path.rsplit(".", 1)[0] if "." in self.field_path else ""
return DottedFieldPath(self.field_path).parent.value

@property
def raw_value_type(self) -> str:
Expand Down
172 changes: 60 additions & 112 deletions src/objectstate/object_state.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/objectstate/object_state_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -877,7 +877,7 @@ def _invalidate_field_in_matching_states(
# DEBUG: Log entry into type matching
if _debug:
logger.debug(f"🔍 _invalidate_field_in_matching_states: state_scope={state.scope_id}, target_base_type={target_base_type.__name__}, field_name={field_name}")
logger.debug(f"🔍 state._path_to_type has {len(state._path_to_type)} entries")
logger.debug(f"🔍 state.parameter_structure.owner_paths has {len(state.parameter_structure.owner_paths)} entries")

for dotted_path in state.inheritance_field_paths(
target_base_type,
Expand Down
24 changes: 24 additions & 0 deletions src/objectstate/parameter_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import copy
from collections.abc import Iterator
from dataclasses import dataclass, field, fields, is_dataclass, replace
from typing import Any

Expand Down Expand Up @@ -36,6 +37,29 @@ def descriptions(self) -> dict[str, str | None]:

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

def path(self, name: str) -> DottedFieldPath:
"""Resolve a name to its admitted path, without analyzing declarations."""

requested = DottedFieldPath(name)
for path in self.direct_paths.get(requested.parent, ()):
if path.value == name:
return path
raise KeyError(f"ObjectState path is not registered: {name!r}")

def ancestors(self, path: DottedFieldPath) -> tuple[DottedFieldPath, ...]:
"""The admitted fields down to this one, itself last."""

return tuple(self.path(step.value) for step in reversed(path.lineage))

def leaf_paths(self, parent: str = "") -> Iterator[DottedFieldPath]:
"""Enumerate leaves through the extracted topology, never the source type."""

for path in self.direct_paths.get(DottedFieldPath(parent), ()):
if self.direct_paths.get(path):
yield from self.leaf_paths(path.value)
else:
yield path

def is_container_parameter(
self, name: str, value: Any, parameters: dict[str, Any]
) -> bool:
Expand Down
4 changes: 2 additions & 2 deletions src/objectstate/transaction_checkpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def capture(
object_instance=state.object_instance,
construction=state.construction_binding(),
parent_state=state._parent_state,
structure=state._parameter_structure.clone(),
structure=state.parameter_structure.clone(),
preserve_value_identity=preserve_value_identity,
parameters=copied["parameters"],
saved_parameters=copied["saved_parameters"],
Expand Down Expand Up @@ -122,7 +122,7 @@ def restore(self, state: ObjectState, *, emit_notifications: bool = True) -> Non
self.construction.attach(state)
state._parent_state = self.parent_state
state._parent_field_name = self.construction.parent_field
state._parameter_structure = self.structure.clone()
state.parameter_structure = self.structure.clone()
state.parameters = copied["parameters"]
state._index_parameter_paths()
state._saved_parameters = copied["saved_parameters"]
Expand Down
2 changes: 1 addition & 1 deletion tests/test_callable_baseline.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def test_initial_values_flatten_nested_dataclass_overrides():
assert state.parameters["options.threshold"] == 7.0
assert state.parameters["options.bounds"] == options.bounds
assert state.parameters["options.bounds.minimum"] == 2.0
assert state.signature_default("options") == NestedOptions()
assert state.parameter_structure.declarations["options"].default_value == NestedOptions()
assert state.reconstruct_top_level_parameters() == {
"image": None,
"options": options,
Expand Down
6 changes: 3 additions & 3 deletions tests/test_construction_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def test_fresh_load_constructs_absent_scopes_and_exact_parent_exclusions(
restored_parent = ObjectStateRegistry.get_by_scope("z-parent")
restored_child = ObjectStateRegistry.get_by_scope("a-child")
assert restored_child._parent_state is restored_parent
assert restored_child._exclude_param_names == ["excluded"]
assert restored_child.parameter_structure.exclusions == ["excluded"]
assert restored_child.to_object().excluded == child.to_object().excluded
assert restored_child.parameters["value"] is None
assert restored_child._saved_parameters["value"] is None
Expand All @@ -148,8 +148,8 @@ def test_same_scope_callable_owner_and_schema_restore_without_replacing_state():
assert state.object_instance is function
assert set(state.parameters) == parameters
for name in parameters:
state.signature_default(name)
assert set(state._path_to_type) == parameters
state.parameter_structure.declarations[name].default_value
assert set(state.parameter_structure.owner_paths) == parameters


def test_equal_signature_owner_change_records_and_notifies_without_fake_value_changes():
Expand Down
37 changes: 37 additions & 0 deletions tests/test_field_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,43 @@ class SlottedAbstractConfig(AbstractFieldConfig):
value: int = 11


@pytest.mark.parametrize(
("value", "parent", "lineage", "field_name"),
[
("", "", (), ""),
("name", "", ("name",), "name"),
("child.value", "child", ("child.value", "child"), "value"),
("outer.child.value", "outer.child", ("outer.child.value", "outer.child", "outer"), "value"),
],
)
def test_dotted_path_owns_parent_lineage_and_leaf_name(value, parent, lineage, field_name) -> None:
path = DottedFieldPath(value)

assert path.field_name == field_name
assert path.parent == DottedFieldPath(parent)
assert tuple(step.value for step in path.lineage) == lineage
assert path.parent.child(path.field_name) == path


@pytest.mark.parametrize(
("owner", "candidate", "child"),
[
("", "", None),
("", "value", "value"),
("", "source_bindings.source_filters[0].match_type", "source_bindings"),
("processing_config", "processing_config.variable_components[1]", "variable_components"),
("processing_config", "processing_config", None),
("processing_config", "processing_config[0].value", None),
("processing_config", "unrelated.value", None),
("processing_config", "processing_config_extra.value", None),
],
)
def test_dotted_path_owns_direct_child_selection(owner, candidate, child) -> None:
path = DottedFieldPath(owner)
assert path.direct_child_name(candidate) == child
assert path.direct_child_name(DottedFieldPath(candidate)) == child


def test_raw_value_reads_regular_dataclass_field() -> None:
config = RegularConfig(child=NestedConfig())

Expand Down
12 changes: 12 additions & 0 deletions tests/test_object_state_update_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,17 @@ def test_direct_parameter_paths_project_canonical_flat_topology_without_values()
assert state.direct_parameter_paths("nested.threshold") == ()
assert state.has_parameter_descendants("nested") is True
assert state.has_parameter_descendants("nested.threshold") is False
structure = state.parameter_structure
nested = state.direct_parameter_paths()[1]
threshold = state.direct_parameter_paths("nested")[0]
assert structure.path("nested.threshold") is threshold
assert structure.ancestors(threshold) == (nested, threshold)
assert structure.ancestors(DottedFieldPath("")) == ()
assert tuple(structure.leaf_paths()) == (
DottedFieldPath("enabled"), threshold, DottedFieldPath("nested.label")
)
with pytest.raises(KeyError, match="not registered"):
structure.path("nested.unknown")

state.update_parameter("nested.threshold", 7)

Expand All @@ -178,6 +189,7 @@ def test_direct_parameter_paths_project_canonical_flat_topology_without_values()
DottedFieldPath("nested.label"),
)
assert state.parameters["nested.threshold"] == 7
assert state.parameters["nested"].threshold == 7


def test_resolved_snapshot_rejects_partial_parameter_state_without_recovery():
Expand Down
4 changes: 4 additions & 0 deletions tests/test_resolved_values_follow_registered_ancestors.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
)

from objectstate.context_manager import get_context_layer_stack
from objectstate.inspection import FieldInspection


@dataclass
Expand Down Expand Up @@ -175,6 +176,9 @@ def step_and_plate(request):
def test_ancestor_registered_after_the_descendant_was_built_supplies_the_value(step_and_plate):
step, _ = step_and_plate
assert _reads(step) == _inherited("plate", PLATE)
inspection = FieldInspection.of(step, PATH)
assert inspection.field_name == "value"
assert inspection.provenance.field_name == "value"


def test_both_orders_of_building_give_the_same_reads(step_and_plate):
Expand Down
Loading