aiogram-nav 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.
- aiogram_nav/__init__.py +128 -0
- aiogram_nav/actions.py +374 -0
- aiogram_nav/clock.py +58 -0
- aiogram_nav/codec.py +217 -0
- aiogram_nav/config.py +50 -0
- aiogram_nav/context.py +147 -0
- aiogram_nav/effects.py +195 -0
- aiogram_nav/errors.py +80 -0
- aiogram_nav/events.py +17 -0
- aiogram_nav/identity.py +36 -0
- aiogram_nav/inputs/__init__.py +14 -0
- aiogram_nav/inputs/bindings.py +246 -0
- aiogram_nav/inputs/callbacks.py +19 -0
- aiogram_nav/inputs/commands.py +5 -0
- aiogram_nav/inputs/inline.py +6 -0
- aiogram_nav/inputs/reply.py +5 -0
- aiogram_nav/inputs/tokens.py +53 -0
- aiogram_nav/integration.py +465 -0
- aiogram_nav/isolation.py +150 -0
- aiogram_nav/json.py +90 -0
- aiogram_nav/models.py +386 -0
- aiogram_nav/navigation.py +326 -0
- aiogram_nav/protocols.py +24 -0
- aiogram_nav/py.typed +0 -0
- aiogram_nav/recovery.py +27 -0
- aiogram_nav/registry.py +153 -0
- aiogram_nav/rendering/__init__.py +6 -0
- aiogram_nav/rendering/base.py +76 -0
- aiogram_nav/rendering/cleanup.py +39 -0
- aiogram_nav/rendering/delivery.py +152 -0
- aiogram_nav/rendering/text.py +125 -0
- aiogram_nav/storage/__init__.py +6 -0
- aiogram_nav/storage/base.py +72 -0
- aiogram_nav/storage/memory.py +485 -0
- aiogram_nav/storage/redis.py +197 -0
- aiogram_nav/storage/redis_codec.py +222 -0
- aiogram_nav/storage/redis_coordinator.py +310 -0
- aiogram_nav/storage/redis_scripts.py +65 -0
- aiogram_nav/storage/redis_store.py +247 -0
- aiogram_nav/storage/redis_transport.py +194 -0
- aiogram_nav/storage/state.py +86 -0
- aiogram_nav/storage/tokens.py +176 -0
- aiogram_nav/version.py +10 -0
- aiogram_nav-0.1.0.dist-info/METADATA +123 -0
- aiogram_nav-0.1.0.dist-info/RECORD +47 -0
- aiogram_nav-0.1.0.dist-info/WHEEL +4 -0
- aiogram_nav-0.1.0.dist-info/licenses/LICENSE +22 -0
aiogram_nav/__init__.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Standalone navigation for aiogram 3; no idle or Redis dependency."""
|
|
2
|
+
|
|
3
|
+
from .clock import Clock, ManualClock, SystemClock
|
|
4
|
+
from .config import NavigationConfig
|
|
5
|
+
from .context import NavigationContext
|
|
6
|
+
from .errors import (
|
|
7
|
+
AccessDenied,
|
|
8
|
+
BackendInconsistent,
|
|
9
|
+
CommitUnknown,
|
|
10
|
+
ConfigurationError,
|
|
11
|
+
InvalidParameters,
|
|
12
|
+
NavigationError,
|
|
13
|
+
NodeNotFound,
|
|
14
|
+
OperationIdConflict,
|
|
15
|
+
OperationPending,
|
|
16
|
+
Overloaded,
|
|
17
|
+
OwnershipLost,
|
|
18
|
+
PendingAction,
|
|
19
|
+
PersistenceUnavailable,
|
|
20
|
+
RenderFailed,
|
|
21
|
+
RenderUnknown,
|
|
22
|
+
SchemaUnsupported,
|
|
23
|
+
SessionExpired,
|
|
24
|
+
StaleView,
|
|
25
|
+
UnsupportedContext,
|
|
26
|
+
VersionConflict,
|
|
27
|
+
)
|
|
28
|
+
from .identity import IdentityResolver, private_identity
|
|
29
|
+
from .inputs import CallbackEntry, CommandEntry, InputBindings, ReplyEntry, prepare_token
|
|
30
|
+
from .isolation import AiogramMemoryCoordinator, MemoryCoordinator
|
|
31
|
+
from .json import JsonObject, JsonValue, timestamp_ms
|
|
32
|
+
from .models import (
|
|
33
|
+
ActionStatus,
|
|
34
|
+
CommitResult,
|
|
35
|
+
ConversationKey,
|
|
36
|
+
EffectCheckpoint,
|
|
37
|
+
HistoryEntry,
|
|
38
|
+
MessageRef,
|
|
39
|
+
NavigationAction,
|
|
40
|
+
NavigationEvent,
|
|
41
|
+
NavigationIntent,
|
|
42
|
+
NavigationMutation,
|
|
43
|
+
NavigationSnapshot,
|
|
44
|
+
NavigationToken,
|
|
45
|
+
Node,
|
|
46
|
+
PreparedAction,
|
|
47
|
+
RenderRequest,
|
|
48
|
+
RenderResult,
|
|
49
|
+
VersionedSnapshot,
|
|
50
|
+
ViewState,
|
|
51
|
+
)
|
|
52
|
+
from .navigation import Navigation
|
|
53
|
+
from .protocols import Coordinator, EffectRecorder
|
|
54
|
+
from .registry import NavigationRegistry
|
|
55
|
+
from .rendering.base import CleanupRequest, CleanupResult, NavigationPresenter, ScreenSpec
|
|
56
|
+
from .rendering.text import TextPresenter
|
|
57
|
+
from .storage.base import NavigationRepository
|
|
58
|
+
from .storage.memory import MemoryNavigationRepository
|
|
59
|
+
from .version import __version__ as __version__
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"AccessDenied",
|
|
63
|
+
"BackendInconsistent",
|
|
64
|
+
"ActionStatus",
|
|
65
|
+
"AiogramMemoryCoordinator",
|
|
66
|
+
"CallbackEntry",
|
|
67
|
+
"CleanupRequest",
|
|
68
|
+
"CleanupResult",
|
|
69
|
+
"Clock",
|
|
70
|
+
"CommandEntry",
|
|
71
|
+
"CommitResult",
|
|
72
|
+
"CommitUnknown",
|
|
73
|
+
"ConfigurationError",
|
|
74
|
+
"ConversationKey",
|
|
75
|
+
"Coordinator",
|
|
76
|
+
"EffectCheckpoint",
|
|
77
|
+
"EffectRecorder",
|
|
78
|
+
"HistoryEntry",
|
|
79
|
+
"IdentityResolver",
|
|
80
|
+
"InputBindings",
|
|
81
|
+
"InvalidParameters",
|
|
82
|
+
"JsonObject",
|
|
83
|
+
"JsonValue",
|
|
84
|
+
"ManualClock",
|
|
85
|
+
"MemoryCoordinator",
|
|
86
|
+
"MemoryNavigationRepository",
|
|
87
|
+
"MessageRef",
|
|
88
|
+
"Navigation",
|
|
89
|
+
"NavigationAction",
|
|
90
|
+
"NavigationConfig",
|
|
91
|
+
"NavigationContext",
|
|
92
|
+
"NavigationError",
|
|
93
|
+
"NavigationEvent",
|
|
94
|
+
"NavigationIntent",
|
|
95
|
+
"NavigationMutation",
|
|
96
|
+
"NavigationPresenter",
|
|
97
|
+
"NavigationRegistry",
|
|
98
|
+
"NavigationRepository",
|
|
99
|
+
"NavigationSnapshot",
|
|
100
|
+
"NavigationToken",
|
|
101
|
+
"Node",
|
|
102
|
+
"NodeNotFound",
|
|
103
|
+
"OperationIdConflict",
|
|
104
|
+
"OperationPending",
|
|
105
|
+
"Overloaded",
|
|
106
|
+
"OwnershipLost",
|
|
107
|
+
"PendingAction",
|
|
108
|
+
"PersistenceUnavailable",
|
|
109
|
+
"PreparedAction",
|
|
110
|
+
"RenderFailed",
|
|
111
|
+
"RenderRequest",
|
|
112
|
+
"RenderResult",
|
|
113
|
+
"RenderUnknown",
|
|
114
|
+
"ReplyEntry",
|
|
115
|
+
"SchemaUnsupported",
|
|
116
|
+
"ScreenSpec",
|
|
117
|
+
"SessionExpired",
|
|
118
|
+
"StaleView",
|
|
119
|
+
"SystemClock",
|
|
120
|
+
"TextPresenter",
|
|
121
|
+
"UnsupportedContext",
|
|
122
|
+
"VersionConflict",
|
|
123
|
+
"VersionedSnapshot",
|
|
124
|
+
"ViewState",
|
|
125
|
+
"prepare_token",
|
|
126
|
+
"private_identity",
|
|
127
|
+
"timestamp_ms",
|
|
128
|
+
]
|
aiogram_nav/actions.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""One authorized prepare/effect/complete path for every navigation transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .effects import NavigationEffects, normalize_render
|
|
10
|
+
from .errors import (
|
|
11
|
+
AccessDenied,
|
|
12
|
+
ConfigurationError,
|
|
13
|
+
InvalidParameters,
|
|
14
|
+
NavigationError,
|
|
15
|
+
NodeNotFound,
|
|
16
|
+
OperationIdConflict,
|
|
17
|
+
OwnershipLost,
|
|
18
|
+
RenderUnknown,
|
|
19
|
+
StaleView,
|
|
20
|
+
)
|
|
21
|
+
from .events import emit
|
|
22
|
+
from .json import fingerprint
|
|
23
|
+
from .models import (
|
|
24
|
+
ActionKind,
|
|
25
|
+
CommitResult,
|
|
26
|
+
ConversationKey,
|
|
27
|
+
EffectCheckpoint,
|
|
28
|
+
HistoryMode,
|
|
29
|
+
NavigationEvent,
|
|
30
|
+
NavigationIntent,
|
|
31
|
+
NavigationMutation,
|
|
32
|
+
NavigationSnapshot,
|
|
33
|
+
PreparedAction,
|
|
34
|
+
RenderResult,
|
|
35
|
+
ViewState,
|
|
36
|
+
)
|
|
37
|
+
from .rendering.base import CleanupRequest
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from .navigation import Navigation
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def as_render(snapshot: NavigationSnapshot) -> RenderResult:
|
|
44
|
+
view = snapshot.view
|
|
45
|
+
return RenderResult(
|
|
46
|
+
view.view_id,
|
|
47
|
+
view.messages,
|
|
48
|
+
view.reply_keyboard_key,
|
|
49
|
+
view.locale,
|
|
50
|
+
view.reply_keyboard_locale,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def root_target(nav: Navigation, current: NavigationSnapshot | None) -> str:
|
|
55
|
+
if current is not None:
|
|
56
|
+
return nav.registry.root_for(nav.registry.resolve(current.node_id).id)
|
|
57
|
+
if nav.config.root is not None:
|
|
58
|
+
return nav.config.root
|
|
59
|
+
if len(nav.registry.roots) == 1:
|
|
60
|
+
return next(iter(nav.registry.roots))
|
|
61
|
+
raise ConfigurationError("Select an explicit root when the registry has multiple roots")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def prepare(
|
|
65
|
+
nav: Navigation,
|
|
66
|
+
key: ConversationKey,
|
|
67
|
+
node_id: str,
|
|
68
|
+
*,
|
|
69
|
+
operation_id: str,
|
|
70
|
+
params: Mapping[str, object] | None = None,
|
|
71
|
+
source: str = "application",
|
|
72
|
+
kind: ActionKind = "go",
|
|
73
|
+
history: HistoryMode = "push",
|
|
74
|
+
accepted_activity: bool = True,
|
|
75
|
+
view_id: str | None = None,
|
|
76
|
+
history_pop: int = 1,
|
|
77
|
+
) -> PreparedAction:
|
|
78
|
+
node = nav.registry.get(node_id)
|
|
79
|
+
validated = nav.registry.validate_params(node.id, params)
|
|
80
|
+
await nav.allowed(key, node, validated, source)
|
|
81
|
+
loaded = await nav.repository.load(key)
|
|
82
|
+
intent = NavigationIntent(
|
|
83
|
+
operation_id,
|
|
84
|
+
node.id,
|
|
85
|
+
validated,
|
|
86
|
+
action=kind,
|
|
87
|
+
history=history,
|
|
88
|
+
source=source,
|
|
89
|
+
accepted_activity=accepted_activity,
|
|
90
|
+
view_id=view_id,
|
|
91
|
+
history_pop=history_pop,
|
|
92
|
+
)
|
|
93
|
+
return await nav.repository.prepare(
|
|
94
|
+
key,
|
|
95
|
+
expected_version=loaded.version if loaded else None,
|
|
96
|
+
intent=intent,
|
|
97
|
+
coordination_scope=nav.coordinator.scope_token(key),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def target(
|
|
102
|
+
nav: Navigation,
|
|
103
|
+
key: ConversationKey,
|
|
104
|
+
kind: ActionKind,
|
|
105
|
+
current: NavigationSnapshot | None,
|
|
106
|
+
requested: str | None,
|
|
107
|
+
params: Mapping[str, object] | None,
|
|
108
|
+
history: HistoryMode,
|
|
109
|
+
source: str,
|
|
110
|
+
) -> tuple[str, Mapping[str, object] | None, HistoryMode, int]:
|
|
111
|
+
if kind in {"go", "track"}:
|
|
112
|
+
if requested is None:
|
|
113
|
+
raise NodeNotFound("An explicit destination is required")
|
|
114
|
+
return requested, params, history, 1
|
|
115
|
+
if kind == "home":
|
|
116
|
+
destination = requested or root_target(nav, current)
|
|
117
|
+
if destination not in nav.registry.roots:
|
|
118
|
+
raise ConfigurationError("home requires an explicitly configured root")
|
|
119
|
+
return destination, {}, "clear", 1
|
|
120
|
+
if current is None:
|
|
121
|
+
return root_target(nav, current), {}, "none", 1
|
|
122
|
+
node = nav.registry.resolve(current.node_id)
|
|
123
|
+
if kind == "refresh":
|
|
124
|
+
preserved = (
|
|
125
|
+
current.node_id in nav.registry.nodes or current.node_id in nav.registry.migrations
|
|
126
|
+
)
|
|
127
|
+
return node.id, current.params if preserved else {}, "none", 1
|
|
128
|
+
if kind == "cancel":
|
|
129
|
+
return nav.registry.cancel_target(node.id), {}, "clear", 1
|
|
130
|
+
if node.id in nav.registry.roots:
|
|
131
|
+
return node.id, current.params, "none", 1
|
|
132
|
+
back = nav.registry.back_target(node.id)
|
|
133
|
+
if back is not None:
|
|
134
|
+
return back, {}, "pop", 1
|
|
135
|
+
for count, entry in enumerate(reversed(current.history), 1):
|
|
136
|
+
try:
|
|
137
|
+
old = nav.registry.resolve(entry.node_id)
|
|
138
|
+
if nav.registry.root_for(old.id) != nav.registry.root_for(node.id):
|
|
139
|
+
continue
|
|
140
|
+
preserved = (
|
|
141
|
+
entry.node_id in nav.registry.nodes or entry.node_id in nav.registry.migrations
|
|
142
|
+
)
|
|
143
|
+
validated = nav.registry.validate_params(old.id, entry.params if preserved else {})
|
|
144
|
+
await nav.allowed(key, old, validated, source)
|
|
145
|
+
except (NodeNotFound, InvalidParameters, AccessDenied):
|
|
146
|
+
continue
|
|
147
|
+
return old.id, validated, "pop", count
|
|
148
|
+
return root_target(nav, current), {}, "clear", 1
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def retry_matches(
|
|
152
|
+
prepared: PreparedAction,
|
|
153
|
+
kind: ActionKind,
|
|
154
|
+
requested: str | None,
|
|
155
|
+
params: Mapping[str, object] | None,
|
|
156
|
+
history: HistoryMode,
|
|
157
|
+
source: str,
|
|
158
|
+
accepted_activity: bool,
|
|
159
|
+
nav: Navigation,
|
|
160
|
+
) -> None:
|
|
161
|
+
intent = prepared.intent
|
|
162
|
+
if (
|
|
163
|
+
intent.action != kind
|
|
164
|
+
or intent.source != source
|
|
165
|
+
or intent.accepted_activity != accepted_activity
|
|
166
|
+
):
|
|
167
|
+
raise OperationIdConflict("Operation ID belongs to a different navigation request")
|
|
168
|
+
if kind in {"go", "track"}:
|
|
169
|
+
if (
|
|
170
|
+
intent.node_id != requested
|
|
171
|
+
or fingerprint(intent.params)
|
|
172
|
+
!= fingerprint(nav.registry.validate_params(intent.node_id, params))
|
|
173
|
+
or intent.history != history
|
|
174
|
+
):
|
|
175
|
+
raise OperationIdConflict("Operation ID belongs to different navigation parameters")
|
|
176
|
+
if kind == "home" and requested is not None and requested != intent.node_id:
|
|
177
|
+
raise OperationIdConflict("Operation ID belongs to another home target")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def perform(
|
|
181
|
+
nav: Navigation,
|
|
182
|
+
key: ConversationKey,
|
|
183
|
+
kind: ActionKind,
|
|
184
|
+
*,
|
|
185
|
+
operation_id: str,
|
|
186
|
+
node_id: str | None = None,
|
|
187
|
+
params: Mapping[str, object] | None = None,
|
|
188
|
+
history: HistoryMode = "push",
|
|
189
|
+
source: str = "application",
|
|
190
|
+
accepted_activity: bool = True,
|
|
191
|
+
) -> RenderResult:
|
|
192
|
+
if type(accepted_activity) is not bool:
|
|
193
|
+
raise ConfigurationError("accepted_activity must be a boolean")
|
|
194
|
+
async with nav.coordinator.hold(key):
|
|
195
|
+
existing = await nav.repository.lookup_operation(key, operation_id=operation_id)
|
|
196
|
+
if existing.prepared is not None:
|
|
197
|
+
prepared = existing.prepared
|
|
198
|
+
retry_matches(prepared, kind, node_id, params, history, source, accepted_activity, nav)
|
|
199
|
+
await nav.allowed(key, nav.registry.get(prepared.node_id), prepared.params, source)
|
|
200
|
+
if existing.result is not None:
|
|
201
|
+
return as_render(existing.result.snapshot)
|
|
202
|
+
return await execute(nav, prepared)
|
|
203
|
+
loaded = await nav.repository.load(key)
|
|
204
|
+
current = loaded.snapshot if loaded else None
|
|
205
|
+
pending = await nav.repository.pending(key)
|
|
206
|
+
if kind == "refresh" and pending is not None:
|
|
207
|
+
cancellation = pending.effects.get("cancel")
|
|
208
|
+
if cancellation is not None and cancellation.state in {"started", "unknown"}:
|
|
209
|
+
raise RenderUnknown("Unknown business cancellation requires explicit recovery")
|
|
210
|
+
if cancellation is not None and cancellation.state == "confirmed":
|
|
211
|
+
node_id, params = pending.node_id, pending.params
|
|
212
|
+
if kind == "refresh" and node_id is not None:
|
|
213
|
+
destination, values, mode, count = node_id, params, "none", 1
|
|
214
|
+
else:
|
|
215
|
+
destination, values, mode, count = await target(
|
|
216
|
+
nav, key, kind, current, node_id, params, history, source
|
|
217
|
+
)
|
|
218
|
+
if kind == "refresh" and pending is not None:
|
|
219
|
+
validated = nav.registry.validate_params(destination, values)
|
|
220
|
+
await nav.allowed(key, nav.registry.get(destination), validated, source)
|
|
221
|
+
await nav.repository.abandon(
|
|
222
|
+
key, pending.operation_id, expected_session_id=pending.session_id
|
|
223
|
+
)
|
|
224
|
+
prepared = await prepare(
|
|
225
|
+
nav,
|
|
226
|
+
key,
|
|
227
|
+
destination,
|
|
228
|
+
operation_id=operation_id,
|
|
229
|
+
params=values,
|
|
230
|
+
source=source,
|
|
231
|
+
kind=kind,
|
|
232
|
+
history=mode,
|
|
233
|
+
accepted_activity=accepted_activity,
|
|
234
|
+
history_pop=count,
|
|
235
|
+
)
|
|
236
|
+
return await execute(nav, prepared)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def validate_render(prepared: PreparedAction, rendered: RenderResult) -> None:
|
|
240
|
+
if isinstance(rendered, ViewState) and rendered.render_status != "complete":
|
|
241
|
+
raise RenderUnknown("Partial rendering cannot complete a navigation action")
|
|
242
|
+
if rendered.view_id != prepared.view_id or any(
|
|
243
|
+
ref.chat_id != prepared.key.chat_id for ref in rendered.messages
|
|
244
|
+
):
|
|
245
|
+
raise StaleView("Render receipt does not match the prepared conversation/view")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def execute(nav: Navigation, prepared: PreparedAction) -> RenderResult:
|
|
249
|
+
effects = NavigationEffects(nav, prepared)
|
|
250
|
+
request = await nav.request(prepared, effects=effects)
|
|
251
|
+
if (
|
|
252
|
+
prepared.intent.action == "cancel"
|
|
253
|
+
and nav.cancel_hook is not None
|
|
254
|
+
and request.current is not None
|
|
255
|
+
):
|
|
256
|
+
|
|
257
|
+
async def cancel() -> object:
|
|
258
|
+
try:
|
|
259
|
+
return await nav.cancel_hook(request) # type: ignore[misc]
|
|
260
|
+
except Exception as error:
|
|
261
|
+
raise RenderUnknown("Cancellation outcome requires recovery") from error
|
|
262
|
+
|
|
263
|
+
await effects.run(
|
|
264
|
+
"cancel", cancel, replay_safe=getattr(nav.cancel_hook, "replay_safe", False) is True
|
|
265
|
+
)
|
|
266
|
+
replay_safe = getattr(nav.presenter, "replay_safe", False) is True
|
|
267
|
+
|
|
268
|
+
async def render() -> RenderResult:
|
|
269
|
+
try:
|
|
270
|
+
result = await nav.presenter.render(request)
|
|
271
|
+
except Exception as error:
|
|
272
|
+
if not replay_safe:
|
|
273
|
+
raise RenderUnknown("Custom presenter outcome requires recovery") from error
|
|
274
|
+
raise
|
|
275
|
+
validate_render(prepared, result)
|
|
276
|
+
return normalize_render(result)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
rendered = await effects.run("render", render, replay_safe=replay_safe)
|
|
280
|
+
validate_render(prepared, rendered)
|
|
281
|
+
committed = await complete(nav, prepared, rendered)
|
|
282
|
+
except NavigationError:
|
|
283
|
+
raise
|
|
284
|
+
except Exception as error:
|
|
285
|
+
raise RenderUnknown("Rendering did not complete; inspect the retained action") from error
|
|
286
|
+
previous = request.current.view if request.current else None
|
|
287
|
+
scope = nav.coordinator.scope_token(prepared.key)
|
|
288
|
+
if scope is None or not nav.coordinator.scope_matches(prepared.key, scope):
|
|
289
|
+
# A slow observational hook can outlive its lease. The committed result
|
|
290
|
+
# remains valid, but lost ownership cannot authorize another Telegram call.
|
|
291
|
+
return rendered
|
|
292
|
+
try:
|
|
293
|
+
latest = await nav.repository.load(prepared.key)
|
|
294
|
+
if (
|
|
295
|
+
latest is None
|
|
296
|
+
or latest.snapshot.session_id != committed.snapshot.session_id
|
|
297
|
+
or latest.snapshot.view.view_id != committed.snapshot.view.view_id
|
|
298
|
+
):
|
|
299
|
+
# An observational hook may navigate reentrantly under this same lock.
|
|
300
|
+
return rendered
|
|
301
|
+
|
|
302
|
+
def ensure_owned() -> None:
|
|
303
|
+
if not nav.coordinator.scope_matches(prepared.key, scope):
|
|
304
|
+
raise OwnershipLost("Post-commit cleanup ownership expired")
|
|
305
|
+
|
|
306
|
+
ensure_owned()
|
|
307
|
+
await nav.presenter.cleanup(
|
|
308
|
+
CleanupRequest(prepared.key, previous, committed.snapshot.view, nav.bot, ensure_owned)
|
|
309
|
+
)
|
|
310
|
+
except Exception:
|
|
311
|
+
from .navigation import logger
|
|
312
|
+
|
|
313
|
+
logger.warning("Generation-specific navigation cleanup failed after commit")
|
|
314
|
+
return rendered
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
async def complete(
|
|
318
|
+
nav: Navigation, prepared: PreparedAction, rendered: RenderResult
|
|
319
|
+
) -> CommitResult:
|
|
320
|
+
validate_render(prepared, rendered)
|
|
321
|
+
rendered = normalize_render(rendered)
|
|
322
|
+
status = await nav.repository.lookup_operation(prepared.key, operation_id=prepared.operation_id)
|
|
323
|
+
canonical = status.prepared
|
|
324
|
+
if (
|
|
325
|
+
canonical is None
|
|
326
|
+
or canonical.fingerprint != prepared.fingerprint
|
|
327
|
+
or canonical.intent != prepared.intent
|
|
328
|
+
or fingerprint(canonical.params) != fingerprint(prepared.params)
|
|
329
|
+
or canonical.session_id != prepared.session_id
|
|
330
|
+
or canonical.view_id != prepared.view_id
|
|
331
|
+
or canonical.key != prepared.key
|
|
332
|
+
):
|
|
333
|
+
raise StaleView("The prepared action is no longer retained or its identity changed")
|
|
334
|
+
prepared = canonical
|
|
335
|
+
if status.result is not None:
|
|
336
|
+
if as_render(status.result.snapshot) != rendered:
|
|
337
|
+
raise OperationIdConflict("Completion receipt differs from the retained result")
|
|
338
|
+
return replace(status.result, duplicate=True)
|
|
339
|
+
request = await nav.request(prepared)
|
|
340
|
+
await nav.repository.checkpoint(
|
|
341
|
+
prepared.key,
|
|
342
|
+
operation_id=prepared.operation_id,
|
|
343
|
+
effect=EffectCheckpoint("render", "confirmed", rendered),
|
|
344
|
+
coordination_scope=nav.coordinator.scope_token(prepared.key),
|
|
345
|
+
)
|
|
346
|
+
loaded = await nav.repository.load(prepared.key)
|
|
347
|
+
committed = await nav.repository.commit(
|
|
348
|
+
prepared.key,
|
|
349
|
+
expected_version=loaded.version if loaded else None,
|
|
350
|
+
mutation=NavigationMutation(
|
|
351
|
+
prepared.operation_id,
|
|
352
|
+
prepared.session_id,
|
|
353
|
+
rendered,
|
|
354
|
+
accepted_activity=prepared.intent.accepted_activity,
|
|
355
|
+
source=prepared.intent.source,
|
|
356
|
+
),
|
|
357
|
+
coordination_scope=nav.coordinator.scope_token(prepared.key),
|
|
358
|
+
)
|
|
359
|
+
if not committed.duplicate:
|
|
360
|
+
await emit(
|
|
361
|
+
nav.on_event,
|
|
362
|
+
NavigationEvent(
|
|
363
|
+
prepared.operation_id,
|
|
364
|
+
prepared.key,
|
|
365
|
+
request.current.node_id if request.current else None,
|
|
366
|
+
committed.snapshot.node_id,
|
|
367
|
+
committed.snapshot.nav_revision,
|
|
368
|
+
committed.snapshot.activity_revision,
|
|
369
|
+
prepared.intent.source,
|
|
370
|
+
"committed",
|
|
371
|
+
nav.clock.now(),
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
return committed
|
aiogram_nav/clock.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Injectable epoch-millisecond and monotonic clocks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Clock(Protocol):
|
|
12
|
+
def now(self) -> int: ...
|
|
13
|
+
def now_ms(self) -> int: ...
|
|
14
|
+
def monotonic(self) -> float: ...
|
|
15
|
+
async def sleep(self, seconds: float) -> None: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SystemClock:
|
|
19
|
+
def now(self) -> int:
|
|
20
|
+
return time.time_ns() // 1_000_000
|
|
21
|
+
|
|
22
|
+
def now_ms(self) -> int:
|
|
23
|
+
return self.now()
|
|
24
|
+
|
|
25
|
+
def monotonic(self) -> float:
|
|
26
|
+
return time.monotonic()
|
|
27
|
+
|
|
28
|
+
async def sleep(self, seconds: float) -> None:
|
|
29
|
+
await asyncio.sleep(seconds)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ManualClock:
|
|
33
|
+
def __init__(self, initial_ms: int = 0) -> None:
|
|
34
|
+
if type(initial_ms) is not int:
|
|
35
|
+
raise ValueError("Initial time must be integer milliseconds")
|
|
36
|
+
self._now = initial_ms
|
|
37
|
+
self._mono = 0.0
|
|
38
|
+
|
|
39
|
+
def now(self) -> int:
|
|
40
|
+
return self._now
|
|
41
|
+
|
|
42
|
+
def now_ms(self) -> int:
|
|
43
|
+
return self.now()
|
|
44
|
+
|
|
45
|
+
def monotonic(self) -> float:
|
|
46
|
+
return self._mono
|
|
47
|
+
|
|
48
|
+
def advance(self, milliseconds: int) -> None:
|
|
49
|
+
if type(milliseconds) is not int or milliseconds < 0:
|
|
50
|
+
raise ValueError("Advance must be nonnegative integer milliseconds")
|
|
51
|
+
self._now += milliseconds
|
|
52
|
+
self._mono += milliseconds / 1000
|
|
53
|
+
|
|
54
|
+
async def sleep(self, seconds: float) -> None:
|
|
55
|
+
if not math.isfinite(seconds) or seconds < 0:
|
|
56
|
+
raise ValueError("Sleep duration must be finite and nonnegative")
|
|
57
|
+
self.advance(int(seconds * 1000))
|
|
58
|
+
await asyncio.sleep(0)
|