aiogram-coordination 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ """Shared task-scoped coordination without Idle/Nav dependencies."""
2
+
3
+ from .errors import (
4
+ ConfigurationError,
5
+ CoordinationError,
6
+ OwnershipLost,
7
+ PersistenceUnavailable,
8
+ SessionAuthorityUnavailable,
9
+ )
10
+ from .identity import CoordinationKey, coordination_key
11
+ from .isolation import AiogramMemoryCoordinator, TrackedEventIsolation
12
+ from .memory import MemoryCoordinator
13
+ from .protocols import (
14
+ BorrowedFsmCoordinator,
15
+ Coordinator,
16
+ FencedCoordinator,
17
+ IsolationProvider,
18
+ LeaseHandle,
19
+ )
20
+ from .redis import RedisCoordinator
21
+ from .redis_isolation import RedisIsolationProvider, RenewableRedisEventIsolation
22
+ from .sessions import ExternalSession, SessionAuthority, validate_session_transition
23
+ from .version import __version__ as __version__
24
+
25
+ __all__ = [
26
+ "BorrowedFsmCoordinator",
27
+ "AiogramMemoryCoordinator",
28
+ "TrackedEventIsolation",
29
+ "RenewableRedisEventIsolation",
30
+ "RedisIsolationProvider",
31
+ "CoordinationKey",
32
+ "coordination_key",
33
+ "Coordinator",
34
+ "FencedCoordinator",
35
+ "MemoryCoordinator",
36
+ "RedisCoordinator",
37
+ "IsolationProvider",
38
+ "LeaseHandle",
39
+ "ExternalSession",
40
+ "SessionAuthority",
41
+ "validate_session_transition",
42
+ "ConfigurationError",
43
+ "CoordinationError",
44
+ "OwnershipLost",
45
+ "PersistenceUnavailable",
46
+ "SessionAuthorityUnavailable",
47
+ ]
@@ -0,0 +1,72 @@
1
+ """Task-preserving timeout scope, including Python 3.10."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncGenerator, Callable
7
+ from contextlib import asynccontextmanager
8
+ from typing import cast
9
+
10
+
11
+ @asynccontextmanager
12
+ async def bounded(seconds: float) -> AsyncGenerator[None, None]:
13
+ task = asyncio.current_task()
14
+ if task is None:
15
+ raise RuntimeError("Timeout scope requires an asyncio task")
16
+ cancelling = cast(Callable[[], int] | None, getattr(task, "cancelling", None))
17
+ uncancel = cast(Callable[[], int] | None, getattr(task, "uncancel", None))
18
+ baseline = cancelling() if cancelling is not None else 0
19
+ expired = False
20
+ consumed = False
21
+ marker = object()
22
+ legacy_marker = False
23
+ legacy_previous: object = None
24
+
25
+ def expire() -> None:
26
+ nonlocal expired, legacy_marker, legacy_previous
27
+ expired = True
28
+ if cancelling is None:
29
+ legacy_previous = getattr(task, "_cancel_message", None)
30
+ task.cancel(marker)
31
+ if cancelling is None and hasattr(task, "_cancel_message"):
32
+ # CPython 3.10 has no cancellation counter. Pin its message so even
33
+ # an external cancel(None) racing this cancellation remains distinct.
34
+ # Other Task implementations fall back to the delivered marker.
35
+ try:
36
+ setattr(task, "_cancel_message", marker) # noqa: B010 -- guarded 3.10-only attribute
37
+ except (AttributeError, TypeError):
38
+ pass
39
+ else:
40
+ legacy_marker = True
41
+
42
+ def consume() -> int:
43
+ nonlocal consumed
44
+ if uncancel is not None and not consumed:
45
+ consumed = True
46
+ return uncancel()
47
+ return cancelling() if cancelling is not None else 0
48
+
49
+ handle = asyncio.get_running_loop().call_later(seconds, expire)
50
+ try:
51
+ yield
52
+ except asyncio.CancelledError as exc:
53
+ if expired:
54
+ remaining = consume()
55
+ owned = (
56
+ remaining <= baseline
57
+ if cancelling is not None
58
+ else exc.args == (marker,)
59
+ and (not legacy_marker or getattr(task, "_cancel_message", None) is marker)
60
+ )
61
+ if owned:
62
+ raise asyncio.TimeoutError from exc
63
+ raise
64
+ finally:
65
+ handle.cancel()
66
+ if expired:
67
+ if legacy_marker and getattr(task, "_cancel_message", None) is marker:
68
+ # Restore an outer timeout marker, never an external cancellation.
69
+ setattr(task, "_cancel_message", legacy_previous) # noqa: B010
70
+ # A body that suppresses CancelledError still must relinquish exactly
71
+ # this timeout's request; external cancellation requests are retained.
72
+ consume()
@@ -0,0 +1,21 @@
1
+ """Public coordination and authority failures; no credentials in messages."""
2
+
3
+
4
+ class CoordinationError(Exception):
5
+ """Base shared coordination failure."""
6
+
7
+
8
+ class ConfigurationError(CoordinationError, ValueError):
9
+ """The requested setup cannot prove safe coordination."""
10
+
11
+
12
+ class OwnershipLost(CoordinationError):
13
+ """The protected scope no longer owns its lock or lease."""
14
+
15
+
16
+ class PersistenceUnavailable(CoordinationError):
17
+ """The lock's authoritative storage is unavailable."""
18
+
19
+
20
+ class SessionAuthorityUnavailable(CoordinationError):
21
+ """Session lookup failed; this is not an unauthorized-session result."""
@@ -0,0 +1,66 @@
1
+ """Lossless full-identity keys shared without importing consuming libraries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Protocol, cast
7
+
8
+ from .errors import ConfigurationError
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class CoordinationKey:
13
+ namespace: str
14
+ bot_id: int
15
+ chat_id: int
16
+ user_id: int
17
+ tenant_id: str | None = None
18
+
19
+ def __post_init__(self) -> None:
20
+ if (
21
+ not isinstance(cast(object, self.namespace), str)
22
+ or not self.namespace
23
+ or len(self.namespace) > 128
24
+ ):
25
+ raise ConfigurationError("Namespace must contain 1 to 128 characters")
26
+ for name in ("bot_id", "chat_id", "user_id"):
27
+ value = getattr(self, name)
28
+ if type(value) is not int or value <= 0:
29
+ raise ConfigurationError(f"{name} must be a positive integer")
30
+ if self.tenant_id is not None and (
31
+ not isinstance(cast(object, self.tenant_id), str)
32
+ or not self.tenant_id
33
+ or len(self.tenant_id) > 128
34
+ ):
35
+ raise ConfigurationError("Tenant must be nonempty text when supplied")
36
+
37
+
38
+ class _Identity(Protocol):
39
+ namespace: str
40
+ bot_id: int
41
+ chat_id: int
42
+ user_id: int
43
+ tenant_id: str | None
44
+
45
+
46
+ def coordination_key(key: object) -> CoordinationKey:
47
+ """Accept validated structural library keys without collapsing any identity."""
48
+ if isinstance(key, CoordinationKey):
49
+ return key
50
+ for field in ("thread_id", "message_thread_id", "business_connection_id", "destiny"):
51
+ value = getattr(key, field, None)
52
+ if value is not None and not (field == "destiny" and value == "default"):
53
+ raise ConfigurationError(
54
+ "Additional FSM identity requires an explicit lossless mapping"
55
+ )
56
+ structural = cast(_Identity, key)
57
+ try:
58
+ return CoordinationKey(
59
+ namespace=structural.namespace,
60
+ bot_id=structural.bot_id,
61
+ chat_id=structural.chat_id,
62
+ user_id=structural.user_id,
63
+ tenant_id=structural.tenant_id,
64
+ )
65
+ except (AttributeError, TypeError) as error:
66
+ raise ConfigurationError("Expected a lossless full conversation identity") from error
@@ -0,0 +1,331 @@
1
+ """Explicit, verifiable application-owned FSM isolation integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncGenerator, Callable, Hashable
7
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
8
+ from contextvars import ContextVar
9
+ from dataclasses import dataclass
10
+ from typing import cast
11
+
12
+ from aiogram.fsm.storage.base import BaseEventIsolation, StorageKey
13
+ from aiogram.fsm.storage.memory import SimpleEventIsolation
14
+
15
+ from .errors import ConfigurationError, OwnershipLost
16
+ from .identity import coordination_key
17
+ from .memory import MemoryCoordinator
18
+ from .protocols import IsolationProvider, LeaseHandle
19
+
20
+
21
+ @dataclass
22
+ class _MemoryLease:
23
+ key: StorageKey
24
+ lost: asyncio.Event
25
+
26
+ @property
27
+ def valid(self) -> bool:
28
+ return not self.lost.is_set()
29
+
30
+ def assert_owned(self) -> None:
31
+ if not self.valid:
32
+ raise OwnershipLost("FSM isolation ownership ended")
33
+
34
+ async def wait_lost(self) -> None:
35
+ await self.lost.wait()
36
+
37
+
38
+ class _SimpleProvider:
39
+ def __init__(self, isolation: SimpleEventIsolation) -> None:
40
+ self.isolation = isolation
41
+
42
+ @asynccontextmanager
43
+ async def acquire(self, key: StorageKey) -> AsyncGenerator[LeaseHandle, None]:
44
+ async with self.isolation.lock(key=key):
45
+ lease = _MemoryLease(key, asyncio.Event())
46
+ try:
47
+ yield lease
48
+ finally:
49
+ lease.lost.set()
50
+
51
+
52
+ @dataclass
53
+ class _Held:
54
+ key: StorageKey
55
+ task: asyncio.Task[object]
56
+ lease: LeaseHandle
57
+ revoked: bool = False
58
+
59
+ @property
60
+ def valid(self) -> bool:
61
+ return not self.revoked and self.lease.valid
62
+
63
+ def assert_owned(self) -> None:
64
+ if not self.valid:
65
+ raise OwnershipLost("Captured FSM scope was revoked")
66
+
67
+ async def wait_lost(self) -> None:
68
+ if not self.valid:
69
+ return
70
+ await self.lease.wait_lost()
71
+
72
+
73
+ class TrackedEventIsolation(BaseEventIsolation):
74
+ """Wrap application isolation at Dispatcher construction, never at install.
75
+
76
+ Only SimpleEventIsolation has a safe implicit provider. A custom provider
77
+ must explicitly reference the same underlying isolation and return a live,
78
+ key-bound lease; opaque fixed-TTL locks are never assumed to renew.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ isolation: BaseEventIsolation,
84
+ *,
85
+ provider: IsolationProvider | None = None,
86
+ close_delegate: bool = False,
87
+ ) -> None:
88
+ if not isinstance(cast(object, isolation), BaseEventIsolation):
89
+ raise ConfigurationError("Expected an application BaseEventIsolation")
90
+ if provider is None:
91
+ if type(isolation) is SimpleEventIsolation:
92
+ provider = _SimpleProvider(isolation)
93
+ elif isinstance(isolation, RenewableRedisEventIsolation):
94
+ provider = isolation
95
+ else:
96
+ raise ConfigurationError("Custom isolation requires an explicit lease provider")
97
+ if (
98
+ not isinstance(cast(object, provider), IsolationProvider)
99
+ or provider.isolation is not isolation
100
+ ):
101
+ raise ConfigurationError("Lease provider must describe the exact application isolation")
102
+ if type(close_delegate) is not bool:
103
+ raise ConfigurationError("close_delegate must be boolean")
104
+ self.isolation = isolation
105
+ self.provider = provider
106
+ self.close_delegate = close_delegate
107
+ self._owned: ContextVar[tuple[_Held, ...]] = ContextVar(
108
+ f"tracked_fsm_{id(self)}", default=()
109
+ )
110
+ self._pending = 0
111
+ self._closed = False
112
+
113
+ @property
114
+ def active_scopes(self) -> int:
115
+ return self._pending
116
+
117
+ def _held(self, key: StorageKey) -> _Held | None:
118
+ task = asyncio.current_task()
119
+ return next(
120
+ (held for held in reversed(self._owned.get()) if held.key == key and held.task is task),
121
+ None,
122
+ )
123
+
124
+ def owns(self, key: StorageKey) -> bool:
125
+ held = self._held(key)
126
+ return held is not None and not held.revoked and held.lease.valid
127
+
128
+ def proof(self, key: StorageKey) -> LeaseHandle:
129
+ held = self._held(key)
130
+ if held is None:
131
+ raise OwnershipLost("FSM scope is not owned by this task")
132
+ held.assert_owned()
133
+ return held
134
+
135
+ @asynccontextmanager
136
+ async def borrowed(self, key: StorageKey) -> AsyncGenerator[None, None]:
137
+ held = self._held(key)
138
+ if held is None or held.revoked:
139
+ raise OwnershipLost("FSM lock is not owned by this task")
140
+ held.lease.assert_owned()
141
+ yield
142
+ if held.revoked or not held.lease.valid:
143
+ raise OwnershipLost("Borrowed FSM scope was revoked")
144
+
145
+ @asynccontextmanager
146
+ async def _acquire(self, key: StorageKey) -> AsyncGenerator[LeaseHandle, None]:
147
+ self._pending += 1
148
+ try:
149
+ async with self.provider.acquire(key) as lease:
150
+ if self._closed:
151
+ raise ConfigurationError("FSM isolation closed during acquisition")
152
+ yield lease
153
+ finally:
154
+ # Includes waiting, protected work AND provider.__aexit__ release.
155
+ self._pending -= 1
156
+
157
+ @asynccontextmanager
158
+ async def lock(self, key: StorageKey) -> AsyncGenerator[None, None]:
159
+ if self._closed or not isinstance(cast(object, key), StorageKey):
160
+ raise ConfigurationError("Isolation is closed or the FSM key is invalid")
161
+ if self._held(key) is not None:
162
+ async with self.borrowed(key):
163
+ yield
164
+ return
165
+ task = asyncio.current_task()
166
+ if task is None:
167
+ raise ConfigurationError("FSM coordination requires a task")
168
+ async with self._acquire(key) as lease:
169
+ if not isinstance(cast(object, lease), LeaseHandle) or lease.key != key:
170
+ raise ConfigurationError("Isolation provider returned an invalid lease identity")
171
+ lease.assert_owned()
172
+ held = _Held(key, task, lease)
173
+ token = self._owned.set((*self._owned.get(), held))
174
+ marker = object()
175
+ cancelling = cast(Callable[[], int] | None, getattr(task, "cancelling", None))
176
+ uncancel = cast(Callable[[], int] | None, getattr(task, "uncancel", None))
177
+ baseline = cancelling() if cancelling is not None else 0
178
+ requested = consumed = legacy_marker = False
179
+ legacy_previous: object = None
180
+
181
+ async def watch() -> None:
182
+ nonlocal requested, legacy_marker, legacy_previous
183
+ try:
184
+ await lease.wait_lost()
185
+ except asyncio.CancelledError:
186
+ raise
187
+ except Exception:
188
+ # An inability to monitor ownership must revoke permission.
189
+ pass
190
+ held.revoked = True
191
+ if cancelling is None:
192
+ legacy_previous = getattr(task, "_cancel_message", None)
193
+ requested = task.cancel(marker)
194
+ if requested and cancelling is None and hasattr(task, "_cancel_message"):
195
+ try:
196
+ setattr(task, "_cancel_message", marker) # noqa: B010 -- CPython 3.10
197
+ except (AttributeError, TypeError):
198
+ pass
199
+ else:
200
+ legacy_marker = True
201
+
202
+ def consume() -> int:
203
+ nonlocal consumed
204
+ if requested and not consumed and uncancel is not None:
205
+ consumed = True
206
+ return uncancel()
207
+ return cancelling() if cancelling is not None else 0
208
+
209
+ watcher = asyncio.create_task(watch())
210
+ try:
211
+ yield
212
+ if held.revoked or not lease.valid:
213
+ raise OwnershipLost("FSM isolation lease lost")
214
+ except asyncio.CancelledError as error:
215
+ remaining = consume()
216
+ if requested and (
217
+ remaining <= baseline
218
+ if cancelling is not None
219
+ else error.args == (marker,)
220
+ and (not legacy_marker or getattr(task, "_cancel_message", None) is marker)
221
+ ):
222
+ raise OwnershipLost("FSM isolation lease lost") from error
223
+ raise
224
+ finally:
225
+ held.revoked = True
226
+ self._owned.reset(token)
227
+ if legacy_marker and getattr(task, "_cancel_message", None) is marker:
228
+ setattr(task, "_cancel_message", legacy_previous) # noqa: B010
229
+ watcher.cancel()
230
+ await asyncio.gather(watcher, return_exceptions=True)
231
+ consume()
232
+
233
+ async def close(self) -> None:
234
+ if self.active_scopes:
235
+ raise ConfigurationError("Drain FSM scopes before closing isolation")
236
+ self._closed = True
237
+ if self.close_delegate:
238
+ await self.isolation.close()
239
+
240
+
241
+ class AiogramMemoryCoordinator(MemoryCoordinator):
242
+ """Borrow a proven FSM scope; workers acquire the same normalized lock."""
243
+
244
+ configuration_error = ConfigurationError
245
+ ownership_lost_error = OwnershipLost
246
+
247
+ def __init__(self, isolation: object) -> None:
248
+ if type(isolation) is not SimpleEventIsolation and not isinstance(
249
+ isolation, TrackedEventIsolation
250
+ ):
251
+ raise self.configuration_error("Expected verified Simple or TrackedEventIsolation")
252
+ super().__init__()
253
+ self.isolation = isolation
254
+ self._fsm_proofs: dict[object, LeaseHandle] = {}
255
+
256
+ @property
257
+ def active_scopes(self) -> int:
258
+ fsm_active = (
259
+ self.isolation.active_scopes if isinstance(self.isolation, TrackedEventIsolation) else 0
260
+ )
261
+ return max(super().active_scopes, fsm_active)
262
+
263
+ @property
264
+ def fsm_isolation(self) -> BaseEventIsolation:
265
+ return self.isolation
266
+
267
+ def configure_fsm_isolation(self, isolation: BaseEventIsolation) -> None:
268
+ if isolation is not self.isolation:
269
+ raise self.configuration_error("FSM bridge cannot replace its configured isolation")
270
+
271
+ def borrowed_fsm(self, key: Hashable) -> AbstractAsyncContextManager[None]:
272
+ return self.borrowed(key)
273
+
274
+ def scope_active(self, token: object) -> bool:
275
+ proof = self._fsm_proofs.get(token)
276
+ if proof is not None and not proof.valid:
277
+ self._live_scopes.discard(token)
278
+ return super().scope_active(token)
279
+
280
+ @asynccontextmanager
281
+ async def borrowed(self, key: Hashable) -> AsyncGenerator[None, None]:
282
+ key = self.normalize_key(key)
283
+ normalized = coordination_key(key)
284
+ fsm_key = StorageKey(
285
+ bot_id=normalized.bot_id, chat_id=normalized.chat_id, user_id=normalized.user_id
286
+ )
287
+ if self.owns(key):
288
+ yield
289
+ return
290
+ if isinstance(self.isolation, TrackedEventIsolation):
291
+ proof = self.isolation.proof(fsm_key)
292
+ async with self.isolation.borrowed(fsm_key), self._scope(key):
293
+ scope = self.scope_token(key)
294
+ if scope is None:
295
+ raise self.ownership_lost_error("No borrowed FSM scope was established")
296
+ self._fsm_proofs[scope] = proof
297
+ try:
298
+ yield
299
+ finally:
300
+ self._fsm_proofs.pop(scope, None)
301
+ else:
302
+ # Legacy installer verifies this branch executes inside aiogram FSM.
303
+ async with self._scope(key):
304
+ yield
305
+
306
+ @asynccontextmanager
307
+ async def hold(self, key: Hashable) -> AsyncGenerator[None, None]:
308
+ key = self.normalize_key(key)
309
+ self._check_reentry(key)
310
+ if self.owns(key):
311
+ yield
312
+ return
313
+ normalized = coordination_key(key)
314
+ fsm_key = StorageKey(
315
+ bot_id=normalized.bot_id, chat_id=normalized.chat_id, user_id=normalized.user_id
316
+ )
317
+ self._pending_scopes += 1
318
+ acquired = False
319
+ try:
320
+ async with self.isolation.lock(key=fsm_key):
321
+ self._pending_scopes -= 1
322
+ acquired = True
323
+ async with self.borrowed(key):
324
+ yield
325
+ finally:
326
+ if not acquired:
327
+ self._pending_scopes -= 1
328
+
329
+
330
+ # Imported last to keep Redis coordination independent of this adapter module.
331
+ from .redis_isolation import RenewableRedisEventIsolation # noqa: E402
@@ -0,0 +1,127 @@
1
+ """Single-process, task-scoped conversation coordination."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncGenerator, Hashable
7
+ from contextlib import asynccontextmanager
8
+ from contextvars import ContextVar
9
+ from dataclasses import dataclass
10
+
11
+ from .errors import OwnershipLost
12
+ from .identity import coordination_key
13
+
14
+
15
+ @dataclass
16
+ class _LockEntry:
17
+ lock: asyncio.Lock
18
+ users: int = 0
19
+
20
+
21
+ class MemoryCoordinator:
22
+ """Serialize a whole handler and worker for each conversation.
23
+
24
+ Ownership is checked against the actual asyncio Task. Context copied into a
25
+ child task never grants that task permission to bypass the lock.
26
+ """
27
+
28
+ ownership_lost_error = OwnershipLost
29
+
30
+ def __init__(self) -> None:
31
+ self._locks: dict[Hashable, _LockEntry] = {}
32
+ self._owned: ContextVar[tuple[tuple[Hashable, asyncio.Task[object]], ...]] = ContextVar(
33
+ f"coord_owned_{id(self)}", default=()
34
+ )
35
+
36
+ self._scope_tokens: ContextVar[
37
+ tuple[tuple[Hashable, asyncio.Task[object], object], ...]
38
+ ] = ContextVar(f"coord_scope_tokens_{id(self)}", default=())
39
+ self._live_scopes: set[object] = set()
40
+ self._scope_keys: dict[object, Hashable] = {}
41
+ self._pending_scopes = 0
42
+
43
+ def normalize_key(self, key: Hashable) -> Hashable:
44
+ if all(
45
+ hasattr(key, field)
46
+ for field in ("namespace", "tenant_id", "bot_id", "chat_id", "user_id")
47
+ ):
48
+ return coordination_key(key)
49
+ return key
50
+
51
+ @property
52
+ def active_scopes(self) -> int:
53
+ return len(self._scope_keys) + self._pending_scopes
54
+
55
+ def scope_token(self, key: Hashable) -> object | None:
56
+ key = self.normalize_key(key)
57
+ task = asyncio.current_task()
58
+ for held, owner, token in reversed(self._scope_tokens.get()):
59
+ if held == key and owner is task and self.scope_active(token):
60
+ return token
61
+ return None
62
+
63
+ def scope_active(self, token: object) -> bool:
64
+ return token in self._live_scopes
65
+
66
+ def scope_matches(self, key: Hashable, token: object) -> bool:
67
+ """Validate a delegated capability without granting task lock ownership."""
68
+ return self._scope_keys.get(token) == self.normalize_key(key) and self.scope_active(token)
69
+
70
+ @asynccontextmanager
71
+ async def _scope(self, key: Hashable) -> AsyncGenerator[None, None]:
72
+ key = self.normalize_key(key)
73
+ task = asyncio.current_task()
74
+ if task is None:
75
+ raise RuntimeError("Conversation coordination requires an asyncio task")
76
+ scope = object()
77
+ self._live_scopes.add(scope)
78
+ self._scope_keys[scope] = key
79
+ ownership = self._owned.set((*self._owned.get(), (key, task)))
80
+ tokens = self._scope_tokens.set((*self._scope_tokens.get(), (key, task, scope)))
81
+ try:
82
+ yield
83
+ finally:
84
+ self._live_scopes.discard(scope)
85
+ self._scope_keys.pop(scope, None)
86
+ self._scope_tokens.reset(tokens)
87
+ self._owned.reset(ownership)
88
+
89
+ def owns(self, key: Hashable) -> bool:
90
+ return self.scope_token(key) is not None
91
+
92
+ def _check_reentry(self, key: Hashable) -> None:
93
+ key = self.normalize_key(key)
94
+ task = asyncio.current_task()
95
+ if not self.owns(key) and any(
96
+ held == key and owner is task for held, owner, _ in self._scope_tokens.get()
97
+ ):
98
+ raise self.ownership_lost_error("A revoked scope cannot acquire fresh ownership")
99
+
100
+ @asynccontextmanager
101
+ async def hold(self, key: Hashable) -> AsyncGenerator[None, None]:
102
+ key = self.normalize_key(key)
103
+ self._check_reentry(key)
104
+ if self.owns(key):
105
+ yield
106
+ return
107
+ entry = self._locks.setdefault(key, _LockEntry(asyncio.Lock()))
108
+ entry.users += 1
109
+ self._pending_scopes += 1
110
+ acquired = False
111
+ try:
112
+ async with entry.lock:
113
+ self._pending_scopes -= 1
114
+ acquired = True
115
+ async with self._scope(key):
116
+ yield
117
+ finally:
118
+ if not acquired:
119
+ self._pending_scopes -= 1
120
+ entry.users -= 1
121
+ if entry.users == 0:
122
+ self._locks.pop(key, None)
123
+
124
+ @property
125
+ def lock_count(self) -> int:
126
+ """Number of active or contended locks, useful for health checks."""
127
+ return len(self._locks)