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.
Files changed (47) hide show
  1. aiogram_nav/__init__.py +128 -0
  2. aiogram_nav/actions.py +374 -0
  3. aiogram_nav/clock.py +58 -0
  4. aiogram_nav/codec.py +217 -0
  5. aiogram_nav/config.py +50 -0
  6. aiogram_nav/context.py +147 -0
  7. aiogram_nav/effects.py +195 -0
  8. aiogram_nav/errors.py +80 -0
  9. aiogram_nav/events.py +17 -0
  10. aiogram_nav/identity.py +36 -0
  11. aiogram_nav/inputs/__init__.py +14 -0
  12. aiogram_nav/inputs/bindings.py +246 -0
  13. aiogram_nav/inputs/callbacks.py +19 -0
  14. aiogram_nav/inputs/commands.py +5 -0
  15. aiogram_nav/inputs/inline.py +6 -0
  16. aiogram_nav/inputs/reply.py +5 -0
  17. aiogram_nav/inputs/tokens.py +53 -0
  18. aiogram_nav/integration.py +465 -0
  19. aiogram_nav/isolation.py +150 -0
  20. aiogram_nav/json.py +90 -0
  21. aiogram_nav/models.py +386 -0
  22. aiogram_nav/navigation.py +326 -0
  23. aiogram_nav/protocols.py +24 -0
  24. aiogram_nav/py.typed +0 -0
  25. aiogram_nav/recovery.py +27 -0
  26. aiogram_nav/registry.py +153 -0
  27. aiogram_nav/rendering/__init__.py +6 -0
  28. aiogram_nav/rendering/base.py +76 -0
  29. aiogram_nav/rendering/cleanup.py +39 -0
  30. aiogram_nav/rendering/delivery.py +152 -0
  31. aiogram_nav/rendering/text.py +125 -0
  32. aiogram_nav/storage/__init__.py +6 -0
  33. aiogram_nav/storage/base.py +72 -0
  34. aiogram_nav/storage/memory.py +485 -0
  35. aiogram_nav/storage/redis.py +197 -0
  36. aiogram_nav/storage/redis_codec.py +222 -0
  37. aiogram_nav/storage/redis_coordinator.py +310 -0
  38. aiogram_nav/storage/redis_scripts.py +65 -0
  39. aiogram_nav/storage/redis_store.py +247 -0
  40. aiogram_nav/storage/redis_transport.py +194 -0
  41. aiogram_nav/storage/state.py +86 -0
  42. aiogram_nav/storage/tokens.py +176 -0
  43. aiogram_nav/version.py +10 -0
  44. aiogram_nav-0.1.0.dist-info/METADATA +123 -0
  45. aiogram_nav-0.1.0.dist-info/RECORD +47 -0
  46. aiogram_nav-0.1.0.dist-info/WHEEL +4 -0
  47. aiogram_nav-0.1.0.dist-info/licenses/LICENSE +22 -0
@@ -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)