|
5 | 5 | from contextlib import contextmanager |
6 | 6 | from dataclasses import dataclass, is_dataclass |
7 | 7 | 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 | +) |
9 | 21 | import copy |
10 | 22 |
|
11 | 23 | from objectstate.object_state_metadata import ObjectStateMetadataStore |
|
18 | 30 |
|
19 | 31 | TimeTravelStateEntry: TypeAlias = Tuple[str, 'ObjectState'] |
20 | 32 | TimeTravelCompleteCallback: TypeAlias = Callable[[List[TimeTravelStateEntry], Optional[str]], None] |
| 33 | +RegistryCallback = TypeVar("RegistryCallback", bound=Callable[..., None]) |
21 | 34 | logger = logging.getLogger(__name__) |
22 | 35 |
|
23 | 36 |
|
| 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 | + |
24 | 53 | @dataclass(frozen=True) |
25 | 54 | class TimeTravelTransaction: |
26 | 55 | """Nominal request to move the registry to one snapshot.""" |
@@ -139,57 +168,96 @@ class ObjectStateRegistry: |
139 | 168 | _on_history_changed_callbacks: List[Callable[[], None]] = [] |
140 | 169 |
|
141 | 170 | @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) |
146 | 177 |
|
147 | 178 | @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: |
149 | 183 | """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) |
152 | 185 |
|
153 | 186 | @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) |
158 | 193 |
|
159 | 194 | @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: |
161 | 199 | """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) |
164 | 201 |
|
165 | 202 | @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: |
167 | 207 | """Subscribe to time-travel completion events. |
168 | 208 |
|
169 | 209 | Callback receives (dirty_states, triggering_scope) where: |
170 | 210 | - dirty_states: list of (scope_id, ObjectState) tuples with unsaved changes |
171 | 211 | - triggering_scope: scope_id that triggered the snapshot (may be None) |
172 | 212 | """ |
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 | + ) |
175 | 217 |
|
176 | 218 | @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: |
178 | 223 | """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) |
181 | 225 |
|
182 | 226 | @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) |
187 | 233 |
|
188 | 234 | @classmethod |
189 | | - def remove_history_changed_callback(cls, callback: Callable[[], None]) -> None: |
| 235 | + def remove_history_changed_callback(cls, callback: Callable[[], None]) -> bool: |
190 | 236 | """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 |
193 | 261 |
|
194 | 262 | @classmethod |
195 | 263 | def _fire_history_changed_callbacks(cls) -> None: |
@@ -474,39 +542,42 @@ def _notify_change(cls) -> None: |
474 | 542 | cls._change_callbacks.remove(cb) |
475 | 543 |
|
476 | 544 | @classmethod |
477 | | - def connect_listener(cls, callback: Callable[[], None]) -> None: |
| 545 | + def connect_listener( |
| 546 | + cls, |
| 547 | + callback: Callable[[], None], |
| 548 | + ) -> ObjectStateRegistrySubscription: |
478 | 549 | """Connect a listener callback that's called on any change. |
479 | 550 |
|
480 | 551 | The callback should debounce and call collect() to get fresh values. |
481 | 552 | """ |
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: |
484 | 555 | logger.debug(f"Connected change listener: {callback}") |
| 556 | + return subscription |
485 | 557 |
|
486 | 558 | @classmethod |
487 | | - def disconnect_listener(cls, callback: Callable[[], None]) -> None: |
| 559 | + def disconnect_listener(cls, callback: Callable[[], None]) -> bool: |
488 | 560 | """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: |
491 | 563 | logger.debug(f"Disconnected change listener: {callback}") |
| 564 | + return removed |
492 | 565 |
|
493 | 566 | @classmethod |
494 | 567 | def add_resolved_changed_callback( |
495 | 568 | cls, |
496 | 569 | 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) |
501 | 573 |
|
502 | 574 | @classmethod |
503 | 575 | def remove_resolved_changed_callback( |
504 | 576 | cls, |
505 | 577 | callback: Callable[[str, Set[str]], None], |
506 | | - ) -> None: |
| 578 | + ) -> bool: |
507 | 579 | """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) |
510 | 581 |
|
511 | 582 | @classmethod |
512 | 583 | def notify_resolved_changed( |
|
0 commit comments