Skip to content

Commit 8476eb7

Browse files
committed
feat: add undo/redo support for cascade-deleted states and axes ergonomics
- object_state_registry: preserve cascade-deleted states in graveyard for history-enabled undo/redo support - parametric_axes: add with_axes decorator and AxesBase class for more ergonomic axes metadata attachment - tests: add test_undo_redo_cascade.py for cascade delete preservation and conftest.py fixtures
1 parent 3596504 commit 8476eb7

File tree

4 files changed

+103
-2
lines changed

4 files changed

+103
-2
lines changed

src/objectstate/object_state_registry.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,16 @@ def unregister_scope_and_descendants(cls, scope_id: Optional[str], _skip_snapsho
216216
scopes_to_delete.append(key)
217217

218218
# Delete all matching scopes and fire callbacks
219+
# NOTE: Unlike plain unregister(), this is often used for hierarchical deletes.
220+
# When history is enabled we must preserve removed states for time-travel (undo/redo),
221+
# otherwise snapshots may reference scopes that can no longer be resurrected.
219222
for key in scopes_to_delete:
220223
state = cls._states.pop(key)
221-
logger.debug(f"Unregistered ObjectState (cascade): scope={key}")
224+
if cls._history_enabled:
225+
cls._graveyard[key] = state
226+
logger.debug(f"Unregistered ObjectState (cascade): scope={key} (moved to graveyard)")
227+
else:
228+
logger.debug(f"Unregistered ObjectState (cascade): scope={key}")
222229
# Fire callbacks for UI binding
223230
cls._fire_unregister_callbacks(key, state)
224231

src/objectstate/parametric_axes.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class ChildStep(MyStep, axes={"priority": 1}):
3131
ChildStep.__axes__["priority"] # 1 (defined here)
3232
"""
3333

34-
from typing import Dict, Any, Tuple, Optional, Type
34+
from typing import Dict, Any, Tuple, Optional, Callable
3535
from types import MappingProxyType
3636
import weakref
3737

@@ -97,6 +97,37 @@ def __repr__(cls) -> str:
9797
return super().__repr__()
9898

9999

100+
# =============================================================================
101+
# DECORATOR + BASE CLASS (used by tests / ergonomic API)
102+
# =============================================================================
103+
104+
105+
def with_axes(**axes: Any) -> Callable[[type], type]:
106+
"""Class decorator that attaches axes metadata.
107+
108+
The decorated class is recreated using :class:`AxesMeta` so it gains:
109+
- ``__axes__`` (MappingProxyType)
110+
- convenience attributes (``__scope__``, ``__priority__``, etc.)
111+
"""
112+
113+
def _decorator(cls: type) -> type:
114+
# Recreate class using AxesMeta. Filter out internal descriptors.
115+
namespace = {
116+
k: v
117+
for k, v in cls.__dict__.items()
118+
if k not in {"__dict__", "__weakref__"}
119+
}
120+
return AxesMeta(cls.__name__, cls.__bases__, namespace, axes=axes)
121+
122+
return _decorator
123+
124+
125+
class AxesBase(metaclass=AxesMeta):
126+
"""Opt-in base class enabling ``class Foo(AxesBase, axes={...})`` syntax."""
127+
128+
pass
129+
130+
100131
# =============================================================================
101132
# FACTORY FUNCTION - Mimics extended type() signature
102133
# =============================================================================

tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
"""Pytest configuration and shared fixtures."""
2+
import sys
3+
from pathlib import Path
4+
5+
# Ensure tests exercise the in-repo ObjectState implementation (external/ObjectState/src)
6+
# rather than any globally installed `objectstate` package.
7+
_SRC_DIR = Path(__file__).resolve().parents[1] / "src"
8+
if str(_SRC_DIR) not in sys.path:
9+
sys.path.insert(0, str(_SRC_DIR))
10+
211
import pytest
312
from dataclasses import dataclass
413
from objectstate import set_base_config_type

tests/test_undo_redo_cascade.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
5+
from objectstate.object_state import ObjectState
6+
from objectstate.object_state_registry import ObjectStateRegistry
7+
8+
9+
def _reset_registry_and_history() -> None:
10+
"""Hard reset for tests.
11+
12+
ObjectStateRegistry has global process-level state (registry + snapshot DAG).
13+
Existing test suite doesn't currently exercise history, so we reset it here.
14+
"""
15+
ObjectStateRegistry._states.clear()
16+
ObjectStateRegistry._time_travel_limbo.clear()
17+
ObjectStateRegistry._graveyard.clear()
18+
ObjectStateRegistry._snapshots.clear()
19+
ObjectStateRegistry._timelines.clear()
20+
ObjectStateRegistry._current_timeline = "main"
21+
ObjectStateRegistry._current_head = None
22+
ObjectStateRegistry._in_time_travel = False
23+
ObjectStateRegistry._atomic_depth = 0
24+
ObjectStateRegistry._atomic_label = None
25+
26+
27+
@dataclass
28+
class Dummy:
29+
x: int = 1
30+
31+
32+
def test_cascade_unregister_preserves_states_for_time_travel_restore() -> None:
33+
_reset_registry_and_history()
34+
35+
plate_scope = "/tmp/plate"
36+
step_scope = f"{plate_scope}::functionstep_4"
37+
func_scope = f"{step_scope}::function_0"
38+
39+
step_state = ObjectState(Dummy(), scope_id=step_scope)
40+
func_state = ObjectState(Dummy(), scope_id=func_scope, parent_state=step_state)
41+
42+
ObjectStateRegistry.register(step_state)
43+
ObjectStateRegistry.register(func_state)
44+
ObjectStateRegistry.record_snapshot("before delete", scope_id=step_scope)
45+
before_id = ObjectStateRegistry.get_branch_history()[-1].id
46+
47+
# User delete path in OpenHCS uses cascade unregister for step + descendants.
48+
ObjectStateRegistry.unregister_scope_and_descendants(step_scope)
49+
50+
# Undo should resurrect both scopes.
51+
ok = ObjectStateRegistry.time_travel_to_snapshot(before_id)
52+
assert ok
53+
assert ObjectStateRegistry.get_by_scope(step_scope) is not None
54+
assert ObjectStateRegistry.get_by_scope(func_scope) is not None

0 commit comments

Comments
 (0)