Skip to content

Commit 6f039fa

Browse files
committed
Add owned registry subscriptions
1 parent 3c206e3 commit 6f039fa

3 files changed

Lines changed: 205 additions & 42 deletions

File tree

src/objectstate/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,10 @@
222222

223223

224224
# Object state management
225-
from objectstate.object_state_registry import ObjectStateRegistry
225+
from objectstate.object_state_registry import (
226+
ObjectStateRegistry,
227+
ObjectStateRegistrySubscription,
228+
)
226229
from objectstate.object_state import ObjectState
227230
from objectstate.object_state_metadata import (
228231
ObjectStateMetadataContract,
@@ -259,6 +262,7 @@
259262
# Object state
260263
'ObjectState',
261264
'ObjectStateRegistry',
265+
'ObjectStateRegistrySubscription',
262266
'ObjectStateMetadataContract',
263267
'ObjectStateMetadataContractRegistry',
264268
'ObjectStateMetadataStore',

src/objectstate/object_state_registry.py

Lines changed: 112 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,19 @@
55
from contextlib import contextmanager
66
from dataclasses import dataclass, is_dataclass
77
import logging
8-
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, TYPE_CHECKING, Generator, TypeAlias
8+
from typing import (
9+
TYPE_CHECKING,
10+
Any,
11+
Callable,
12+
Dict,
13+
Generator,
14+
List,
15+
Optional,
16+
Set,
17+
Tuple,
18+
TypeAlias,
19+
TypeVar,
20+
)
921
import copy
1022

1123
from objectstate.object_state_metadata import ObjectStateMetadataStore
@@ -18,9 +30,26 @@
1830

1931
TimeTravelStateEntry: TypeAlias = Tuple[str, 'ObjectState']
2032
TimeTravelCompleteCallback: TypeAlias = Callable[[List[TimeTravelStateEntry], Optional[str]], None]
33+
RegistryCallback = TypeVar("RegistryCallback", bound=Callable[..., None])
2134
logger = logging.getLogger(__name__)
2235

2336

37+
class ObjectStateRegistrySubscription:
38+
"""Idempotent ownership handle for one registry callback registration."""
39+
40+
def __init__(self, release: Callable[[], bool] | None) -> None:
41+
self._release = release
42+
43+
def release(self) -> bool:
44+
"""Release this registration once, returning whether it was active."""
45+
46+
release = self._release
47+
if release is None:
48+
return False
49+
self._release = None
50+
return release()
51+
52+
2453
@dataclass(frozen=True)
2554
class TimeTravelTransaction:
2655
"""Nominal request to move the registry to one snapshot."""
@@ -139,57 +168,96 @@ class ObjectStateRegistry:
139168
_on_history_changed_callbacks: List[Callable[[], None]] = []
140169

141170
@classmethod
142-
def add_register_callback(cls, callback: Callable[[str, 'ObjectState'], None]) -> None:
143-
"""Subscribe to ObjectState registration events."""
144-
if callback not in cls._on_register_callbacks:
145-
cls._on_register_callbacks.append(callback)
171+
def add_register_callback(
172+
cls,
173+
callback: Callable[[str, 'ObjectState'], None],
174+
) -> ObjectStateRegistrySubscription:
175+
"""Subscribe to registration events and return its ownership handle."""
176+
return cls._subscribe_callback(cls._on_register_callbacks, callback)
146177

147178
@classmethod
148-
def remove_register_callback(cls, callback: Callable[[str, 'ObjectState'], None]) -> None:
179+
def remove_register_callback(
180+
cls,
181+
callback: Callable[[str, 'ObjectState'], None],
182+
) -> bool:
149183
"""Unsubscribe from ObjectState registration events."""
150-
if callback in cls._on_register_callbacks:
151-
cls._on_register_callbacks.remove(callback)
184+
return cls._remove_callback(cls._on_register_callbacks, callback)
152185

153186
@classmethod
154-
def add_unregister_callback(cls, callback: Callable[[str, 'ObjectState'], None]) -> None:
155-
"""Subscribe to ObjectState unregistration events."""
156-
if callback not in cls._on_unregister_callbacks:
157-
cls._on_unregister_callbacks.append(callback)
187+
def add_unregister_callback(
188+
cls,
189+
callback: Callable[[str, 'ObjectState'], None],
190+
) -> ObjectStateRegistrySubscription:
191+
"""Subscribe to unregistration events and return its ownership handle."""
192+
return cls._subscribe_callback(cls._on_unregister_callbacks, callback)
158193

159194
@classmethod
160-
def remove_unregister_callback(cls, callback: Callable[[str, 'ObjectState'], None]) -> None:
195+
def remove_unregister_callback(
196+
cls,
197+
callback: Callable[[str, 'ObjectState'], None],
198+
) -> bool:
161199
"""Unsubscribe from ObjectState unregistration events."""
162-
if callback in cls._on_unregister_callbacks:
163-
cls._on_unregister_callbacks.remove(callback)
200+
return cls._remove_callback(cls._on_unregister_callbacks, callback)
164201

165202
@classmethod
166-
def add_time_travel_complete_callback(cls, callback: TimeTravelCompleteCallback) -> None:
203+
def add_time_travel_complete_callback(
204+
cls,
205+
callback: TimeTravelCompleteCallback,
206+
) -> ObjectStateRegistrySubscription:
167207
"""Subscribe to time-travel completion events.
168208
169209
Callback receives (dirty_states, triggering_scope) where:
170210
- dirty_states: list of (scope_id, ObjectState) tuples with unsaved changes
171211
- triggering_scope: scope_id that triggered the snapshot (may be None)
172212
"""
173-
if callback not in cls._on_time_travel_complete_callbacks:
174-
cls._on_time_travel_complete_callbacks.append(callback)
213+
return cls._subscribe_callback(
214+
cls._on_time_travel_complete_callbacks,
215+
callback,
216+
)
175217

176218
@classmethod
177-
def remove_time_travel_complete_callback(cls, callback: TimeTravelCompleteCallback) -> None:
219+
def remove_time_travel_complete_callback(
220+
cls,
221+
callback: TimeTravelCompleteCallback,
222+
) -> bool:
178223
"""Unsubscribe from time-travel completion events."""
179-
if callback in cls._on_time_travel_complete_callbacks:
180-
cls._on_time_travel_complete_callbacks.remove(callback)
224+
return cls._remove_callback(cls._on_time_travel_complete_callbacks, callback)
181225

182226
@classmethod
183-
def add_history_changed_callback(cls, callback: Callable[[], None]) -> None:
184-
"""Subscribe to history change events (snapshot added or time-travel)."""
185-
if callback not in cls._on_history_changed_callbacks:
186-
cls._on_history_changed_callbacks.append(callback)
227+
def add_history_changed_callback(
228+
cls,
229+
callback: Callable[[], None],
230+
) -> ObjectStateRegistrySubscription:
231+
"""Subscribe to history changes and return its ownership handle."""
232+
return cls._subscribe_callback(cls._on_history_changed_callbacks, callback)
187233

188234
@classmethod
189-
def remove_history_changed_callback(cls, callback: Callable[[], None]) -> None:
235+
def remove_history_changed_callback(cls, callback: Callable[[], None]) -> bool:
190236
"""Unsubscribe from history change events."""
191-
if callback in cls._on_history_changed_callbacks:
192-
cls._on_history_changed_callbacks.remove(callback)
237+
return cls._remove_callback(cls._on_history_changed_callbacks, callback)
238+
239+
@classmethod
240+
def _subscribe_callback(
241+
cls,
242+
callbacks: List[RegistryCallback],
243+
callback: RegistryCallback,
244+
) -> ObjectStateRegistrySubscription:
245+
if callback in callbacks:
246+
return ObjectStateRegistrySubscription(None)
247+
callbacks.append(callback)
248+
return ObjectStateRegistrySubscription(
249+
lambda: cls._remove_callback(callbacks, callback)
250+
)
251+
252+
@staticmethod
253+
def _remove_callback(
254+
callbacks: List[RegistryCallback],
255+
callback: RegistryCallback,
256+
) -> bool:
257+
if callback not in callbacks:
258+
return False
259+
callbacks.remove(callback)
260+
return True
193261

194262
@classmethod
195263
def _fire_history_changed_callbacks(cls) -> None:
@@ -474,39 +542,42 @@ def _notify_change(cls) -> None:
474542
cls._change_callbacks.remove(cb)
475543

476544
@classmethod
477-
def connect_listener(cls, callback: Callable[[], None]) -> None:
545+
def connect_listener(
546+
cls,
547+
callback: Callable[[], None],
548+
) -> ObjectStateRegistrySubscription:
478549
"""Connect a listener callback that's called on any change.
479550
480551
The callback should debounce and call collect() to get fresh values.
481552
"""
482-
if callback not in cls._change_callbacks:
483-
cls._change_callbacks.append(callback)
553+
subscription = cls._subscribe_callback(cls._change_callbacks, callback)
554+
if callback in cls._change_callbacks:
484555
logger.debug(f"Connected change listener: {callback}")
556+
return subscription
485557

486558
@classmethod
487-
def disconnect_listener(cls, callback: Callable[[], None]) -> None:
559+
def disconnect_listener(cls, callback: Callable[[], None]) -> bool:
488560
"""Disconnect a change listener."""
489-
if callback in cls._change_callbacks:
490-
cls._change_callbacks.remove(callback)
561+
removed = cls._remove_callback(cls._change_callbacks, callback)
562+
if removed:
491563
logger.debug(f"Disconnected change listener: {callback}")
564+
return removed
492565

493566
@classmethod
494567
def add_resolved_changed_callback(
495568
cls,
496569
callback: Callable[[str, Set[str]], None],
497-
) -> None:
498-
"""Subscribe to resolved-value changes from any registered ObjectState."""
499-
if callback not in cls._resolved_changed_callbacks:
500-
cls._resolved_changed_callbacks.append(callback)
570+
) -> ObjectStateRegistrySubscription:
571+
"""Subscribe to resolved changes and return its ownership handle."""
572+
return cls._subscribe_callback(cls._resolved_changed_callbacks, callback)
501573

502574
@classmethod
503575
def remove_resolved_changed_callback(
504576
cls,
505577
callback: Callable[[str, Set[str]], None],
506-
) -> None:
578+
) -> bool:
507579
"""Unsubscribe from registry-wide resolved-value changes."""
508-
if callback in cls._resolved_changed_callbacks:
509-
cls._resolved_changed_callbacks.remove(callback)
580+
return cls._remove_callback(cls._resolved_changed_callbacks, callback)
510581

511582
@classmethod
512583
def notify_resolved_changed(
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Ownership contracts for ObjectState registry callback subscriptions."""
2+
3+
import pytest
4+
5+
from objectstate import ObjectStateRegistry, ObjectStateRegistrySubscription
6+
7+
8+
@pytest.mark.parametrize(
9+
("add", "remove", "callback"),
10+
(
11+
(
12+
ObjectStateRegistry.add_register_callback,
13+
ObjectStateRegistry.remove_register_callback,
14+
lambda _scope, _state: None,
15+
),
16+
(
17+
ObjectStateRegistry.add_unregister_callback,
18+
ObjectStateRegistry.remove_unregister_callback,
19+
lambda _scope, _state: None,
20+
),
21+
(
22+
ObjectStateRegistry.add_time_travel_complete_callback,
23+
ObjectStateRegistry.remove_time_travel_complete_callback,
24+
lambda _states, _scope: None,
25+
),
26+
(
27+
ObjectStateRegistry.add_history_changed_callback,
28+
ObjectStateRegistry.remove_history_changed_callback,
29+
lambda: None,
30+
),
31+
(
32+
ObjectStateRegistry.add_resolved_changed_callback,
33+
ObjectStateRegistry.remove_resolved_changed_callback,
34+
lambda _scope, _paths: None,
35+
),
36+
(
37+
ObjectStateRegistry.connect_listener,
38+
ObjectStateRegistry.disconnect_listener,
39+
lambda: None,
40+
),
41+
),
42+
)
43+
def test_registry_callback_axes_return_one_release_handle(
44+
add,
45+
remove,
46+
callback,
47+
) -> None:
48+
subscription = add(callback)
49+
try:
50+
assert isinstance(subscription, ObjectStateRegistrySubscription)
51+
assert subscription.release()
52+
assert not subscription.release()
53+
finally:
54+
remove(callback)
55+
56+
57+
def test_history_callback_subscription_releases_exact_registration() -> None:
58+
observed = []
59+
60+
def observe() -> None:
61+
observed.append(True)
62+
63+
subscription = ObjectStateRegistry.add_history_changed_callback(observe)
64+
try:
65+
assert isinstance(subscription, ObjectStateRegistrySubscription)
66+
ObjectStateRegistry._fire_history_changed_callbacks()
67+
assert observed == [True]
68+
69+
assert subscription.release()
70+
assert not subscription.release()
71+
ObjectStateRegistry._fire_history_changed_callbacks()
72+
assert observed == [True]
73+
finally:
74+
ObjectStateRegistry.remove_history_changed_callback(observe)
75+
76+
77+
def test_duplicate_registration_does_not_transfer_existing_ownership() -> None:
78+
def observe() -> None:
79+
pass
80+
81+
owner = ObjectStateRegistry.add_history_changed_callback(observe)
82+
duplicate = ObjectStateRegistry.add_history_changed_callback(observe)
83+
try:
84+
assert not duplicate.release()
85+
ObjectStateRegistry._fire_history_changed_callbacks()
86+
assert owner.release()
87+
finally:
88+
ObjectStateRegistry.remove_history_changed_callback(observe)

0 commit comments

Comments
 (0)