django-aiogram 4.0.0.dev0__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 (48) hide show
  1. django_aiogram/__init__.py +64 -0
  2. django_aiogram/_singleton.py +22 -0
  3. django_aiogram/admin.py +380 -0
  4. django_aiogram/api.py +38 -0
  5. django_aiogram/apps.py +56 -0
  6. django_aiogram/config/__init__.py +15 -0
  7. django_aiogram/config/checks.py +759 -0
  8. django_aiogram/config/defaults.py +102 -0
  9. django_aiogram/config/enums.py +108 -0
  10. django_aiogram/config/settings.py +266 -0
  11. django_aiogram/consumer/__init__.py +13 -0
  12. django_aiogram/consumer/delivery.py +625 -0
  13. django_aiogram/consumer/routers.py +19 -0
  14. django_aiogram/consumer/webhook.py +154 -0
  15. django_aiogram/context.py +34 -0
  16. django_aiogram/eventlog/__init__.py +16 -0
  17. django_aiogram/eventlog/dbrouter.py +55 -0
  18. django_aiogram/eventlog/events.py +120 -0
  19. django_aiogram/eventlog/instrumentation.py +231 -0
  20. django_aiogram/eventlog/recorder.py +922 -0
  21. django_aiogram/eventlog/signals.py +84 -0
  22. django_aiogram/eventlog/writer.py +231 -0
  23. django_aiogram/exceptions.py +60 -0
  24. django_aiogram/healthcheck.py +412 -0
  25. django_aiogram/management/__init__.py +1 -0
  26. django_aiogram/management/commands/__init__.py +1 -0
  27. django_aiogram/management/commands/start_tgbot.py +308 -0
  28. django_aiogram/management/commands/tgbot_healthcheck.py +57 -0
  29. django_aiogram/management/commands/tgbot_prune_events.py +144 -0
  30. django_aiogram/management/commands/tgbot_reclaim.py +135 -0
  31. django_aiogram/management/commands/tgbot_webhook.py +87 -0
  32. django_aiogram/migrations/0001_initial.py +50 -0
  33. django_aiogram/migrations/0002_kind_id_index.py +32 -0
  34. django_aiogram/migrations/__init__.py +1 -0
  35. django_aiogram/models.py +79 -0
  36. django_aiogram/producer/__init__.py +13 -0
  37. django_aiogram/producer/client.py +1540 -0
  38. django_aiogram/producer/throttling.py +336 -0
  39. django_aiogram/py.typed +0 -0
  40. django_aiogram/redis.py +394 -0
  41. django_aiogram/wire/__init__.py +14 -0
  42. django_aiogram/wire/envelope.py +146 -0
  43. django_aiogram/wire/payloads.py +195 -0
  44. django_aiogram/wire/serializers.py +533 -0
  45. django_aiogram-4.0.0.dev0.dist-info/METADATA +145 -0
  46. django_aiogram-4.0.0.dev0.dist-info/RECORD +48 -0
  47. django_aiogram-4.0.0.dev0.dist-info/WHEEL +4 -0
  48. django_aiogram-4.0.0.dev0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,394 @@
1
+ """The one Redis connection this package shares.
2
+
3
+ Senders, consumers, the FSM heartbeat and the management commands all go through
4
+ :func:`get_redis`, so a process opens a single connection pool however many of
5
+ them are running. The connection is built on first use rather than at import,
6
+ because Django settings are not readable while the app registry is loading.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ import threading
12
+ import weakref
13
+ from collections.abc import Callable
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from django.core.exceptions import ImproperlyConfigured
17
+ from django.core.signals import setting_changed
18
+ from redis import Redis
19
+ from redis.asyncio import Redis as AsyncRedis
20
+ from redis.connection import parse_url as _parse_url
21
+
22
+ from django_aiogram.config.settings import SETTINGS_NAME, conf
23
+ from django_aiogram.eventlog.events import worker_identity
24
+
25
+ #: redis-py ships py.typed but leaves parse_url unannotated, and strict mode refuses
26
+ #: to call it. Naming the shape here keeps the call site honest without an ignore
27
+ parse_url: Callable[[str], dict[str, Any]] = _parse_url
28
+
29
+
30
+ #: the package logger, so a contained failure here is still visible
31
+ logger = logging.getLogger('django_aiogram')
32
+
33
+
34
+ def read_timeout() -> int:
35
+ """How long any single Redis call may take before the server is dead to us."""
36
+ return max(1, int(conf['REDIS_TIMEOUT']))
37
+
38
+
39
+ def connection_kwargs() -> dict[str, Any]:
40
+ """How every client this package builds is configured.
41
+
42
+ Its own, rather than a detail of :func:`build_client`, because the FSM storage
43
+ builds a second client that this package never touches again — and it went
44
+ without any deadline at all until it was handed these.
45
+
46
+ Note that redis-py resolves a URL *after* keyword arguments and documents that
47
+ "querystring arguments always win", so anything here is a default a project can
48
+ still override with a query string on ``REDIS_URL``.
49
+ """
50
+ timeout = read_timeout()
51
+ return {'socket_connect_timeout': timeout, 'socket_timeout': timeout}
52
+
53
+
54
+ def queue_key() -> str:
55
+ """Return the list queued messages are written to and read from."""
56
+ return str(conf['REDIS_MESSAGES_KEY'])
57
+
58
+
59
+ def processing_key(worker: str | None = None) -> str:
60
+ """Where one worker keeps the message it is sending.
61
+
62
+ Per worker, so a restarting one reclaims only its own: a shared list would let
63
+ a starting worker pull a message out from under another that is still sending
64
+ it. Takes a name so `tgbot_reclaim` can address a worker that is gone.
65
+ """
66
+ return f'{queue_key()}:processing:{worker or worker_identity()}'
67
+
68
+
69
+ def processing_pattern() -> str:
70
+ """Match every worker's in-flight list, this one included.
71
+
72
+ Derived from :func:`processing_key` rather than spelled out again: the healthcheck
73
+ scans for these, and a probe that re-wrote the scheme by hand would keep scanning the
74
+ old one after a rename — silently reporting no stranded messages for ever, with the
75
+ suite green because the tests that hold the literal would have been updated.
76
+
77
+ The key is escaped because ``SCAN MATCH`` takes a glob, and a queue key is an operator's
78
+ string: ``REDIS_MESSAGES_KEY = 'tg[staging]'`` turned into a character class that
79
+ matches nothing this package ever writes, so the sweep reported zero stranded lists —
80
+ and reported the scan as *complete*, which is the one answer a wrong pattern must not
81
+ give. Only the key is escaped; the ``*`` is the wildcard this function exists for.
82
+ """
83
+ return f'{_escaped(queue_key())}:processing:*'
84
+
85
+
86
+ def _escaped(literal: str) -> str:
87
+ """Quote the glob metacharacters Redis honours in a ``MATCH`` pattern.
88
+
89
+ ``^`` is in the set and cannot change an outcome here, which is worth writing down
90
+ rather than rediscovering: it is special only as the first character inside an
91
+ *unescaped* ``[...]``, and ``[`` is always escaped one line above. Measured both ways
92
+ against ``TG[^x]`` and ``TG^x`` — same match, escaped or not. Kept because the set is
93
+ the one Redis documents, and a future caller that builds a class deliberately would
94
+ want it; not covered by a test, because no test could fail.
95
+ """
96
+ return ''.join(f'\\{character}' if character in '*?[]^\\' else character for character in literal)
97
+
98
+
99
+ def heartbeat_key(worker: str | None = None) -> str:
100
+ """Where one worker says it is still turning. Per worker, like the list above."""
101
+ return f'{queue_key()}:heartbeat:{worker or worker_identity()}'
102
+
103
+
104
+ def heartbeat_interval() -> int:
105
+ """How often the consumer refreshes that key, never below a second."""
106
+ return max(1, int(conf['HEARTBEAT_INTERVAL']))
107
+
108
+
109
+ def heartbeat_ttl(interval: int | None = None) -> int:
110
+ """How long the key survives without a refresh: three intervals.
111
+
112
+ One place, because it is also a ceiling on what any reader can observe. A probe given
113
+ `--max-age` above this can never see a stale heartbeat — the key is simply gone — so
114
+ the two numbers have to be derived from each other rather than agreed on by hand in
115
+ the writer, the probe and three pages of the wiki. Takes an interval so a caller that
116
+ has already read and vetted the setting does not read it twice.
117
+ """
118
+ return (heartbeat_interval() if interval is None else interval) * 3
119
+
120
+
121
+ def build_client() -> Redis:
122
+ """Build a client bounded in time, so no call can hang for ever.
123
+
124
+ redis-py only started defaulting to a read deadline in 8.0; on the 6.2 floor
125
+ a server that accepts the connection and then stops answering blocks the
126
+ caller until the process is killed. Blocking reads stay inside the deadline
127
+ by asking for less than it — see :class:`~django_aiogram.consumer.delivery.BlpopDelivery`.
128
+
129
+ Commands are deliberately **not** retried. ``Redis.from_url`` builds the pool
130
+ first, so redis-py's client-level retry default never reaches the connection
131
+ and every command runs with ``Retry(NoBackoff(), 0)``; that was an accident,
132
+ and this docstring is what makes it a decision. Neither command on the hot path
133
+ is idempotent — a connection dropped after the server applied an ``RPUSH`` but
134
+ before the reply arrived would queue the message twice, and the consumer would
135
+ send a real person two of them. The connection-drop case is already handled
136
+ where it can be handled safely: the consumer logs and goes round its loop
137
+ again, and a failed ``send_redis`` records the drop and raises so the caller
138
+ knows nothing was queued.
139
+ """
140
+ url = conf['REDIS_URL']
141
+ if not url:
142
+ msg = f"{SETTINGS_NAME}['REDIS_URL'] is required to talk to Redis."
143
+ raise ImproperlyConfigured(msg)
144
+ return Redis.from_url(url, **connection_kwargs())
145
+
146
+
147
+ def build_async_client() -> AsyncRedis:
148
+ """Build the same client for a caller that is already on an event loop.
149
+
150
+ Same URL, same deadlines from :func:`connection_kwargs`, same deliberate lack
151
+ of retries — everything :func:`build_client` says applies here too, and
152
+ ``redis.asyncio`` ships inside redis-py, so this needs no extra dependency.
153
+
154
+ What differs is ownership: these connections are **loop-affine**, so one
155
+ client cannot be shared the way the synchronous one is. :func:`aget_redis`
156
+ keeps one per loop.
157
+ """
158
+ url = conf['REDIS_URL']
159
+ if not url:
160
+ msg = f"{SETTINGS_NAME}['REDIS_URL'] is required to talk to Redis."
161
+ raise ImproperlyConfigured(msg)
162
+ return AsyncRedis.from_url(url, **connection_kwargs())
163
+
164
+
165
+ def url_decodes_responses(url: str) -> bool:
166
+ """Whether ``url`` asks redis-py to hand back ``str`` instead of ``bytes``.
167
+
168
+ Tolerated everywhere else — :func:`as_bytes` exists for it, because one
169
+ ``REDIS_URL`` is often shared with a cache backend that wants decoding — but
170
+ pickled payloads cannot survive it, so check E043 refuses that one pairing.
171
+
172
+ Asked of redis-py rather than parsed here, because the answer is surprising
173
+ and any reimplementation would drift from it. ``decode_responses`` has no
174
+ entry in ``URL_QUERY_ARGUMENT_PARSERS``, so it never goes through a boolean
175
+ parser: it arrives as a raw string and reaches the connection on plain
176
+ truthiness. ``?decode_responses=false`` and ``?decode_responses=0`` both
177
+ **enable** decoding; only an empty value leaves it off, and only because the
178
+ query parser drops blanks before redis-py sees them.
179
+ """
180
+ try:
181
+ return bool(parse_url(url).get('decode_responses'))
182
+ except (AttributeError, TypeError, ValueError):
183
+ # a URL redis-py cannot read is not this check's finding: W002 covers an
184
+ # empty one, and anything else fails at the first real connection
185
+ return False
186
+
187
+
188
+ class _SharedConnection:
189
+ """Holds the shared client, together with the lock that keeps it single."""
190
+
191
+ def __init__(self) -> None:
192
+ """Start with an empty slot; nothing connects until someone asks."""
193
+ self._lock = threading.Lock()
194
+ self._client: Redis | None = None
195
+
196
+ @property
197
+ def is_open(self) -> bool:
198
+ """Whether a client has been built and not reset since."""
199
+ return self._client is not None
200
+
201
+ def get(self) -> Redis:
202
+ """Return the client, building it at most once."""
203
+ # one read, kept local: a reset() between two reads of the attribute
204
+ # would otherwise let this return None
205
+ client = self._client
206
+ if client is None:
207
+ with self._lock:
208
+ client = self._client
209
+ if client is None:
210
+ client = self._client = build_client()
211
+ return client
212
+
213
+ def reset(self) -> None:
214
+ """Empty the slot, then close whatever was in it."""
215
+ with self._lock:
216
+ client, self._client = self._client, None
217
+ if client is not None:
218
+ # closing talks to the socket: a caller waiting to build a
219
+ # replacement should not be held up by it
220
+ client.close()
221
+
222
+
223
+ class _LoopConnections:
224
+ """One async client per event loop, and the generation that invalidates them.
225
+
226
+ ``redis.asyncio`` connections belong to the loop that created them, so the
227
+ single shared client the rest of this module keeps would be wrong here: two
228
+ loops sharing one would interleave reads on the same socket.
229
+
230
+ **A weak key cannot bound this on its own, and measuring said so.** A connected
231
+ client holds its loop — through the connection, its writer, its transport — so
232
+ the value keeps the key alive and the entry never dies. Measured against a real
233
+ server: three loops used through :func:`aget_redis` and abandoned left three
234
+ live loops and three live clients, where the same three clients dropped on the
235
+ floor left none. That is the whole argument for the sweep in :meth:`get`, and
236
+ the reason no death-triggered cleanup can work here: holding a loop-affine
237
+ client at all is holding its loop.
238
+
239
+ The weak keys stay as the cheaper half of the same job — a client built and
240
+ never used holds nothing, so that entry does go by itself — but the sweep is
241
+ what keeps a process that runs a loop per unit of work from accumulating a
242
+ client, and its sockets, for every one of them.
243
+
244
+ Invalidation cannot close: ``setting_changed`` is synchronous, ``aclose()`` is
245
+ a coroutine, and closing a client belonging to another loop from another
246
+ thread is exactly what these connections forbid. So a reset only bumps a
247
+ counter, and the next caller *on that loop* awaits the close itself.
248
+ """
249
+
250
+ def __init__(self) -> None:
251
+ """Start empty, at generation zero."""
252
+ self._guard = threading.Lock()
253
+ self._clients: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, tuple[int, AsyncRedis]]
254
+ self._clients = weakref.WeakKeyDictionary()
255
+ self._generation = 0
256
+
257
+ def _forget_closed(self) -> list[AsyncRedis]:
258
+ """Drop the entries whose loop is closed, and hand back their clients.
259
+
260
+ Held under the guard. The clients are returned rather than dropped here so
261
+ that whatever their collection costs happens outside it.
262
+
263
+ They cannot be closed properly: ``aclose()`` would have to be awaited on
264
+ the loop that is gone. Letting go is the whole of what is available, and it
265
+ is what would have happened anyway had this registry never held them.
266
+ """
267
+ closed = [loop for loop in list(self._clients.keys()) if loop.is_closed()]
268
+ return [self._clients.pop(loop)[1] for loop in closed]
269
+
270
+ async def get(self) -> AsyncRedis:
271
+ """Return this loop's client, building or replacing it as needed."""
272
+ loop = _running_loop()
273
+ # no await inside the lock, so a coroutine cannot be suspended holding it
274
+ # and another thread's loop is only ever held off for a dict lookup
275
+ with self._guard:
276
+ abandoned = self._forget_closed()
277
+ entry = self._clients.get(loop)
278
+ if entry is not None and entry[0] == self._generation:
279
+ stale, client = None, entry[1]
280
+ else:
281
+ stale = None if entry is None else entry[1]
282
+ client = build_async_client()
283
+ self._clients[loop] = (self._generation, client)
284
+ # outside the guard: letting go of the abandoned clients runs whatever
285
+ # their collection runs, and no other loop should wait behind it
286
+ abandoned.clear()
287
+ if stale is not None:
288
+ # on its own loop, which is the only place it may be closed. Contained,
289
+ # because the replacement is already cached and healthy: a client whose socket
290
+ # is broken can raise on close, and this path runs right after a settings
291
+ # change — so letting it out would fail the first `asend` after every reset
292
+ # for something that has already been replaced
293
+ try:
294
+ await stale.aclose()
295
+ except Exception:
296
+ logger.exception('could not close the client a settings change replaced')
297
+ return client
298
+
299
+ async def close(self) -> None:
300
+ """Close and forget this loop's client, if it has one."""
301
+ loop = _running_loop('aclose_redis()')
302
+ with self._guard:
303
+ entry = self._clients.pop(loop, None)
304
+ if entry is not None:
305
+ await entry[1].aclose()
306
+
307
+ def invalidate(self) -> None:
308
+ """Mark every client stale without touching any of them."""
309
+ with self._guard:
310
+ self._generation += 1
311
+
312
+
313
+ def _running_loop(caller: str = 'aget_redis()') -> asyncio.AbstractEventLoop:
314
+ """Return the loop the caller is on, or refuse with what to call instead."""
315
+ try:
316
+ return asyncio.get_running_loop()
317
+ except RuntimeError as error:
318
+ msg = (
319
+ f'{caller} needs a running event loop; call get_redis() from '
320
+ 'synchronous code instead. The async client exists for callers that '
321
+ 'are already on a loop — an ASGI view, say — and a client built off '
322
+ 'one could not be used from it.'
323
+ )
324
+ raise RuntimeError(msg) from error
325
+
326
+
327
+ _loops = _LoopConnections()
328
+
329
+
330
+ async def aget_redis() -> AsyncRedis:
331
+ """Return the async client for the loop this coroutine is running on."""
332
+ return await _loops.get()
333
+
334
+
335
+ async def aclose_redis() -> None:
336
+ """Close this loop's async client. Django has no hook that does it for you."""
337
+ await _loops.close()
338
+
339
+
340
+ _shared = _SharedConnection()
341
+
342
+
343
+ def get_redis() -> Redis:
344
+ """Return the shared connection, creating it on first use."""
345
+ return _shared.get()
346
+
347
+
348
+ def reset_redis() -> None:
349
+ """Drop the shared connection so the next call reconnects."""
350
+ _shared.reset()
351
+
352
+
353
+ def as_bytes(value: bytes | str) -> bytes:
354
+ """Redis hands back str when the URL enables decode_responses."""
355
+ return value if isinstance(value, bytes) else value.encode('utf-8')
356
+
357
+
358
+ class RedisProxy:
359
+ """Forwards attribute access to the lazily created connection.
360
+
361
+ Exists so `from django_aiogram import redis_conn` stays a plain
362
+ module-level import without connecting at import time.
363
+ """
364
+
365
+ def __getattr__(self, item: str) -> Any: # noqa: ANN401 - a forwarded Redis method may return anything
366
+ """Hand the attribute over to the shared client, connecting if needed."""
367
+ return getattr(get_redis(), item)
368
+
369
+ def __repr__(self) -> str:
370
+ """Say whether the connection behind the proxy exists yet."""
371
+ state = 'connected' if _shared.is_open else 'not connected'
372
+ return f'<RedisProxy {state}>'
373
+
374
+
375
+ if TYPE_CHECKING:
376
+ #: the proxy forwards everything through __getattr__, which types as Any — so
377
+ #: `redis_conn.ping()` was unchecked while `get_redis().ping()` was not, and
378
+ #: this annotation is what tells a consumer's mypy the difference
379
+ redis_conn: Redis
380
+ else:
381
+ redis_conn = RedisProxy()
382
+
383
+
384
+ def _reset_on_setting_change(setting: str, **_kwargs: object) -> None:
385
+ """Reconnect after the settings change, since REDIS_URL may have moved."""
386
+ if setting == SETTINGS_NAME:
387
+ reset_redis()
388
+ # marked, not closed: this runs on whatever thread changed the setting,
389
+ # and an async client may belong to a loop on another one. The next
390
+ # caller on that loop closes the stale client itself
391
+ _loops.invalidate()
392
+
393
+
394
+ setting_changed.connect(_reset_on_setting_change, dispatch_uid='django_aiogram.redis')
@@ -0,0 +1,14 @@
1
+ """Bytes on the queue, and back: everything about how a message is represented.
2
+
3
+ `serializers` turns a payload into bytes and back; `envelope` wraps one with the version
4
+ and correlation id a consumer needs to read it; `payloads` shapes and redacts what the
5
+ event log is allowed to keep.
6
+
7
+ None of it knows which transport carries the result, which is what lets the same envelope
8
+ travel a Redis list, a stream, a queue or a topic.
9
+ """
10
+
11
+ #: deliberately empty: callers import from the modules in this package, not from the
12
+ #: package itself. A re-export here would make a second path to every name, and the one
13
+ #: nobody chose is the one that cannot be moved later
14
+ __all__: tuple[str, ...] = ()
@@ -0,0 +1,146 @@
1
+ """What a queued call looks like on the wire.
2
+
3
+ 2.x wrote ``{'function': name, **kwargs}`` and the consumer splatted it straight
4
+ back into aiogram, so there was nowhere to put an identifier, a timestamp or a
5
+ version without it arriving at Telegram as an unexpected argument. 3.0 nests the
6
+ arguments instead.
7
+
8
+ The tagged-JSON serializer needs no change for this. ``encode`` recurses through
9
+ mappings unconditionally, and ``decode`` only reacts to one when a codec tag is a
10
+ key in it — ``__envelope__`` is not a tag, so the envelope decodes as a plain
11
+ mapping and its ``kwargs`` still become real aiogram objects.
12
+
13
+ **Deployment order matters because of this.** The reader below accepts the old
14
+ flat shape, but a 2.x reader handed a new payload calls the Telegram method with
15
+ ``__envelope__`` as a keyword, raises ``TypeError``, logs it and swallows it —
16
+ the message is lost silently. Deploy the bot container before the web tier.
17
+ """
18
+
19
+ import math
20
+ import uuid
21
+ from collections.abc import Mapping
22
+ from dataclasses import dataclass
23
+ from typing import Any
24
+
25
+ from django_aiogram.exceptions import DjangoRedisAiogramError
26
+
27
+ #: marks a payload as nested, and says which shape to read it as
28
+ ENVELOPE_KEY = '__envelope__'
29
+ ENVELOPE_VERSION = 1
30
+
31
+
32
+ class MalformedEnvelopeError(DjangoRedisAiogramError, ValueError):
33
+ """A payload decoded, but is not a shape any version of this reads.
34
+
35
+ Distinct from an unknown *version*, and the difference decides the message's
36
+ fate: a newer version is left in flight for an upgraded consumer to deliver,
37
+ while nothing will ever make sense of this one, so it is recorded and
38
+ acknowledged instead of coming back for ever.
39
+ """
40
+
41
+ def __init__(self, found: object) -> None:
42
+ """Name what arrived, never its content: this came off an untrusted queue."""
43
+ super().__init__(f'Queued payload is not a readable envelope: {found}.')
44
+
45
+
46
+ class UnknownEnvelopeVersionError(DjangoRedisAiogramError, ValueError):
47
+ """A payload was written by a newer version than this consumer understands."""
48
+
49
+ def __init__(self, version: object) -> None:
50
+ """Name the version found and the newest one this consumer can read."""
51
+ super().__init__(
52
+ f'Queued payload declares envelope version {version!r}, but this consumer '
53
+ f'reads up to {ENVELOPE_VERSION}. Upgrade the bot container first.',
54
+ )
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Envelope:
59
+ """One queued call, as the consumer needs it."""
60
+
61
+ function: str
62
+ kwargs: dict[str, Any]
63
+ correlation_id: uuid.UUID | None = None
64
+ #: a float, not a datetime: it survives both serializers without a codec
65
+ queued_at: float = 0.0
66
+
67
+
68
+ def pack(
69
+ function: str,
70
+ kwargs: dict[str, Any],
71
+ correlation_id: uuid.UUID,
72
+ queued_at: float,
73
+ ) -> dict[str, Any]:
74
+ """Build the payload that goes on the list."""
75
+ return {
76
+ ENVELOPE_KEY: ENVELOPE_VERSION,
77
+ 'correlation_id': correlation_id.hex,
78
+ 'queued_at': queued_at,
79
+ 'function': function,
80
+ 'kwargs': kwargs,
81
+ }
82
+
83
+
84
+ def _as_uuid(value: object) -> uuid.UUID | None:
85
+ """Read the identifier back, tolerating a payload that carries nonsense."""
86
+ if isinstance(value, uuid.UUID):
87
+ return value
88
+ if isinstance(value, str) and value:
89
+ try:
90
+ return uuid.UUID(value)
91
+ except ValueError:
92
+ return None
93
+ return None
94
+
95
+
96
+ def _as_time(value: object) -> float:
97
+ """Read a timestamp, or settle for none.
98
+
99
+ A figure this cannot read costs the queue latency, not the message, which
100
+ may otherwise be perfectly deliverable. `nan` and the infinities are in that
101
+ class too: `float()` accepts them, arithmetic on them produces more of them,
102
+ and `nan` is not even valid JSON to a strict reader.
103
+ """
104
+ try:
105
+ seconds = float(value or 0.0) # type: ignore[arg-type]
106
+ except (TypeError, ValueError):
107
+ return 0.0
108
+ return seconds if math.isfinite(seconds) else 0.0
109
+
110
+
111
+ def unpack(payload: object) -> Envelope:
112
+ """Read either shape, from whatever the queue actually held.
113
+
114
+ A rolling upgrade leaves 2.x payloads on the list for as long as the backlog
115
+ lasts, and refusing them would drop real messages. Everything else this
116
+ cannot read raises, because the consumer thread is on the far side of a
117
+ trust boundary and an exception escaping it ends the worker.
118
+ """
119
+ if not isinstance(payload, Mapping):
120
+ found = f'a decoded {type(payload).__name__}, not a mapping'
121
+ raise MalformedEnvelopeError(found)
122
+ version = payload.get(ENVELOPE_KEY)
123
+ if version is None:
124
+ return Envelope(
125
+ function=str(payload.get('function', '')),
126
+ kwargs={key: value for key, value in payload.items() if key != 'function'},
127
+ )
128
+ # exactly an int: `int()` reads True, 1.0 and 1.5 as version 1, and this
129
+ # package writes an integer. The message names the type and never the
130
+ # value, which came off an untrusted queue and ends up in a log line
131
+ if type(version) is not int:
132
+ unreadable = f'an envelope version of type {type(version).__name__}'
133
+ raise MalformedEnvelopeError(unreadable)
134
+ if version > ENVELOPE_VERSION:
135
+ raise UnknownEnvelopeVersionError(version)
136
+ if version < ENVELOPE_VERSION:
137
+ # not a future shape somebody can deliver later, so it is not kept
138
+ older = f'envelope version {version}'
139
+ raise MalformedEnvelopeError(older)
140
+ arguments = payload.get('kwargs')
141
+ return Envelope(
142
+ function=str(payload.get('function', '')),
143
+ kwargs=dict(arguments) if isinstance(arguments, Mapping) else {},
144
+ correlation_id=_as_uuid(payload.get('correlation_id')),
145
+ queued_at=_as_time(payload.get('queued_at')),
146
+ )