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,759 @@
1
+ """Django system checks for the package settings.
2
+
3
+ Every check is a row in :data:`CHECKS`: an id, the setting it guards and a rule.
4
+ The id is spelled out in the row, so grepping ``E019`` finds both the check and
5
+ the ``docs/wiki/Settings.md`` entry that explains it.
6
+
7
+ Check ids are ``django_aiogram.EXXX``, and an id is never reused once its
8
+ setting is gone: a project silencing ``E013`` must not silently start silencing
9
+ whatever came after it.
10
+ """
11
+
12
+ import math
13
+ import os
14
+ import re
15
+ import socket
16
+ from collections.abc import Callable, Collection, Mapping
17
+ from dataclasses import dataclass, fields
18
+ from functools import partial
19
+ from typing import Any
20
+
21
+ from django.core.checks import CheckMessage, Error, Info
22
+ from django.core.checks import Warning as CheckWarning
23
+ from django.core.exceptions import ImproperlyConfigured
24
+ from django.utils.module_loading import import_string
25
+
26
+ from django_aiogram.config.defaults import DEFAULTS
27
+ from django_aiogram.config.enums import (
28
+ KNOWN_RATE_LIMIT_KEYS,
29
+ DeliveryKind,
30
+ PayloadDetail,
31
+ SerializerKind,
32
+ StorageKind,
33
+ UpdateMode,
34
+ choices,
35
+ )
36
+ from django_aiogram.config.settings import SETTINGS_NAME, blpop_ceiling, coerce_bool, conf
37
+ from django_aiogram.eventlog.events import known_kinds
38
+
39
+ DELIVERY_CHOICES = choices(DeliveryKind)
40
+ MODE_CHOICES = choices(UpdateMode)
41
+ SERIALIZER_CHOICES = choices(SerializerKind)
42
+ PAYLOAD_CHOICES = choices(PayloadDetail)
43
+
44
+ _STORAGE_CHOICES = choices(StorageKind)
45
+ #: what Docker generates when a container is started without `hostname:`
46
+ _EPHEMERAL_HOSTNAME = re.compile(r'[0-9a-f]{12}')
47
+ #: the first letter of a check id decides how loudly it reports; see :class:`Check`
48
+ _LEVELS = {'E': Error, 'W': CheckWarning, 'I': Info}
49
+ _ID_PREFIX = 'django_aiogram'
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Problem:
54
+ """What a rule found: the tail of the message, and where it belongs.
55
+
56
+ ``key`` names the setting to blame when it is not the one the check guards —
57
+ one webhook rule reports against both WEBHOOK_URL and WEBHOOK_SECRET.
58
+ """
59
+
60
+ message: str
61
+ key: str | None = None
62
+ hint: str | None = None
63
+
64
+
65
+ Validator = Callable[[str], list[Problem]]
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Check:
70
+ """One row of the registry: the id it reports under, the setting, the rule.
71
+
72
+ The id's first letter picks the level. ``E`` is an error and fails
73
+ ``manage.py check`` outright. ``W`` is a warning: it does not fail a plain ``check``,
74
+ but it *does* fail ``check --fail-level WARNING``, which projects run in CI and in
75
+ container entrypoints — so a warning has to be something the project can actually act
76
+ on, in every process that runs checks. ``I`` is information: worth printing, but about
77
+ a condition this check cannot decide from where it stands, so failing a build on it
78
+ would be a guess.
79
+ """
80
+
81
+ code: str
82
+ key: str
83
+ validate: Validator
84
+
85
+ def run(self) -> list[CheckMessage]:
86
+ """Turn everything the rule found into Django check messages."""
87
+ return [self._message(problem) for problem in self.validate(self.key)]
88
+
89
+ def _message(self, problem: Problem) -> CheckMessage:
90
+ """Label one problem with the setting it is about and this row's id."""
91
+ key = self.key if problem.key is None else problem.key
92
+ # an empty key means the check is about the settings dict as a whole
93
+ label = f"{SETTINGS_NAME}['{key}']" if key else SETTINGS_NAME
94
+ report = _LEVELS.get(self.code[0], Error)
95
+ return report(f'{label} {problem.message}', hint=problem.hint, id=f'{_ID_PREFIX}.{self.code}')
96
+
97
+
98
+ def _a_readable_boolean(key: str) -> list[Problem]:
99
+ """Accept whatever ``coerce_bool`` accepts, and report what it would refuse.
100
+
101
+ This rule used to demand a real ``bool``, which had it backwards in both
102
+ directions. ``{'ENABLED': 'true'}`` is documented, boots, sends — and failed
103
+ ``manage.py check``; while the values ``coerce_bool`` genuinely refuses raise
104
+ ``ImproperlyConfigured`` out of ``apps.ready()`` before any check runs, so the
105
+ error could never fire on the case it was written for. The package's own fixtures
106
+ tripped it on working settings.
107
+
108
+ Asked by trying the coercion rather than by reimplementing its rules, so the check
109
+ and the runtime cannot disagree — and the message is the one the runtime would
110
+ have raised, which is the sentence a reader needs.
111
+ """
112
+ try:
113
+ coerce_bool(conf.get(key), f"{SETTINGS_NAME}['{key}']")
114
+ except ImproperlyConfigured as error:
115
+ # the message already names the setting, and `Check._message` prefixes it
116
+ # again — so hand back only the tail
117
+ return [Problem(str(error).replace(f"{SETTINGS_NAME}['{key}'] ", '', 1))]
118
+ return []
119
+
120
+
121
+ def _an_integer(key: str, *, minimum: int | None = None) -> list[Problem]:
122
+ """Require an integer, at or above ``minimum`` when one is given."""
123
+ value = conf.get(key)
124
+ # bool is a subclass of int, so it has to be rejected explicitly
125
+ if isinstance(value, bool) or not isinstance(value, int):
126
+ return [Problem(f'must be an integer, got {type(value).__name__}.')]
127
+ if minimum is not None and value < minimum:
128
+ return [Problem(f'must be >= {minimum}, got {value}.')]
129
+ return []
130
+
131
+
132
+ def _a_number(key: str, *, minimum: float | None = None) -> list[Problem]:
133
+ """Require a finite number, at or above ``minimum`` when one is given.
134
+
135
+ Wider than :func:`_an_integer` because seconds are a place a fraction is a
136
+ reasonable thing to write. `nan` is refused with the rest: comparisons against
137
+ it are all false, so it would slip past the bound and then make every deadline
138
+ built from it expire immediately.
139
+ """
140
+ value = conf.get(key)
141
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
142
+ return [Problem(f'must be a number, got {type(value).__name__}.')]
143
+ if not math.isfinite(value):
144
+ return [Problem(f'must be a finite number, got {value}.')]
145
+ if minimum is not None and value < minimum:
146
+ return [Problem(f'must be >= {minimum}, got {value}.')]
147
+ return []
148
+
149
+
150
+ def _a_string(key: str, *, allowed: Collection[str] | None = None) -> list[Problem]:
151
+ """Require a string, one of ``allowed`` when the setting is an enumeration."""
152
+ value = conf.get(key)
153
+ if not isinstance(value, str):
154
+ return [Problem(f'must be a string, got {type(value).__name__}.')]
155
+ if allowed is not None and value not in allowed:
156
+ return [Problem(f'must be one of {sorted(allowed)}, got {value!r}.')]
157
+ return []
158
+
159
+
160
+ def _a_callable(key: str) -> list[Problem]:
161
+ """Require something callable."""
162
+ value = conf.get(key)
163
+ if callable(value):
164
+ return []
165
+ return [Problem(f'must be callable, got {type(value).__name__}.')]
166
+
167
+
168
+ def _a_mapping(key: str) -> list[Problem]:
169
+ """Require a mapping."""
170
+ value = conf.get(key)
171
+ if isinstance(value, Mapping):
172
+ return []
173
+ return [Problem(f'must be a mapping, got {type(value).__name__}.')]
174
+
175
+
176
+ def _known_bot_properties(key: str) -> list[Problem]:
177
+ """Reject names ``DefaultBotProperties`` does not have, which it would drop."""
178
+ value = conf.get(key)
179
+ if not isinstance(value, Mapping):
180
+ return []
181
+ # before the import, not after: the default is {}, which is a Mapping, so without
182
+ # this every `manage.py check` in every project would pay for aiogram
183
+ if not value:
184
+ return []
185
+ # deferred: aiogram costs most of a second, and checks only run on demand
186
+ from aiogram.client.default import DefaultBotProperties # noqa: PLC0415 - as above
187
+
188
+ known = {field.name for field in fields(DefaultBotProperties)}
189
+ # keys may be anything a project typed into settings, so stringify before joining
190
+ unknown = sorted(str(name) for name in value if name not in known)
191
+ if not unknown:
192
+ return []
193
+ return [Problem(f'has unknown properties: {", ".join(unknown)}. Known: {", ".join(sorted(known))}.')]
194
+
195
+
196
+ def _importable_storage(key: str) -> list[Problem]:
197
+ """Resolve a dotted path here, so a typo fails before the first message."""
198
+ value = conf.get(key)
199
+ if not isinstance(value, str):
200
+ return []
201
+ if value in _STORAGE_CHOICES:
202
+ return []
203
+ if '.' not in value:
204
+ return [Problem(f"must be 'redis', 'memory', or a dotted path, got {value!r}.")]
205
+ # deferred like the other aiogram imports: a disabled boot must not pay for it
206
+ from aiogram.fsm.storage.base import BaseStorage # noqa: PLC0415 - as above
207
+
208
+ try:
209
+ storage = import_string(value)
210
+ except ImportError as error:
211
+ return [Problem(f'cannot be imported: {error}')]
212
+ if not (isinstance(storage, type) and issubclass(storage, BaseStorage)):
213
+ return [Problem(f'must point to a BaseStorage subclass, got {value!r}.')]
214
+ return []
215
+
216
+
217
+ def _sane_rate_limits(key: str) -> list[Problem]:
218
+ """Require known budget names holding non-negative numbers."""
219
+ value = conf.get(key)
220
+ if value is None:
221
+ return []
222
+ if not isinstance(value, Mapping):
223
+ return [Problem(f'must be a mapping or None, got {type(value).__name__}.')]
224
+ unknown = sorted(str(name) for name in value if name not in KNOWN_RATE_LIMIT_KEYS)
225
+ if unknown:
226
+ known = ', '.join(sorted(KNOWN_RATE_LIMIT_KEYS))
227
+ return [Problem(f'has unknown keys: {", ".join(unknown)}. Known: {known}.')]
228
+ for name, rate in value.items():
229
+ if isinstance(rate, bool) or not isinstance(rate, (int, float)) or rate < 0:
230
+ return [Problem(f'{name} must be a non-negative number, got {rate!r}.')]
231
+ return []
232
+
233
+
234
+ def _readable_serializer(key: str) -> list[Problem]:
235
+ """Refuse to write pickle the reader would throw away: sends would vanish."""
236
+ # coerced like the reader coerces it: from the environment this is a string
237
+ if conf.get(key) != SerializerKind.PICKLE:
238
+ return []
239
+ try:
240
+ # coerced like the reader coerces it: from the environment this is a string
241
+ allowed = coerce_bool(conf.get('ALLOW_PICKLE'), f"{SETTINGS_NAME}['ALLOW_PICKLE']")
242
+ except ImproperlyConfigured:
243
+ # unreadable is E017's finding; this check cannot say anything about it
244
+ return []
245
+ if allowed:
246
+ return []
247
+ return [
248
+ Problem(
249
+ "is 'pickle' while ALLOW_PICKLE is False, so queued messages would be "
250
+ 'written and then refused on read. Set ALLOW_PICKLE to True, or use '
251
+ "the 'json' serializer.",
252
+ )
253
+ ]
254
+
255
+
256
+ def _a_url_pickle_can_survive(key: str) -> list[Problem]:
257
+ """Refuse a decoding URL where pickle may be read: the pair cannot work at all.
258
+
259
+ Decoding is otherwise supported — one REDIS_URL is often shared with a cache
260
+ backend that wants it, and :func:`~django_aiogram.redis.as_bytes` is there
261
+ for it. Pickle is the exception, and it fails in the one place nothing can
262
+ recover from: redis-py decodes inside its own parser, so a blocking pop raises
263
+ `UnicodeDecodeError` *after* the server has moved the message to the in-flight
264
+ list, and each later start trips over it once before carrying on — measured on
265
+ Redis 8: one error per restart, then the queue drains around it. Not a wedged
266
+ consumer, which is what the wording used to imply, and the difference matters:
267
+ an operator who believes the queue is dead drains it by hand.
268
+ """
269
+ try:
270
+ allowed = coerce_bool(conf.get('ALLOW_PICKLE'), f"{SETTINGS_NAME}['ALLOW_PICKLE']")
271
+ except ImproperlyConfigured:
272
+ # unreadable is E017's finding; this check cannot say anything about it
273
+ return []
274
+ if not allowed:
275
+ return []
276
+ # deferred: this module is imported at every enabled boot, and redis-py is not
277
+ from django_aiogram.redis import url_decodes_responses # noqa: PLC0415 - as above
278
+
279
+ if not url_decodes_responses(str(conf.get(key) or '')):
280
+ return []
281
+ return [
282
+ Problem(
283
+ 'sets decode_responses while ALLOW_PICKLE is True. A pickled payload is not '
284
+ 'valid text, so the consumer raises inside redis-py after the message has '
285
+ 'already left the queue: that message is stranded in the in-flight list, and '
286
+ 'each restart trips over it once more before carrying on.',
287
+ hint=(
288
+ 'Drop decode_responses from the URL, or turn ALLOW_PICKLE off and use the '
289
+ "'json' serializer. Give the cache its own URL if it needs decoding."
290
+ ),
291
+ )
292
+ ]
293
+
294
+
295
+ def _a_worker_that_keeps_its_name(key: str) -> list[Problem]:
296
+ """Say when the name a worker's in-flight list is keyed on cannot survive a restart.
297
+
298
+ Crash safety rests on a restarted worker recognizing its own list. With
299
+ ``WORKER_NAME`` unset the name is the hostname — which is fine on a host, and
300
+ is not fine in a container started without ``hostname:``, where Docker invents
301
+ a fresh twelve-character hex name for each container it creates. Restarting
302
+ one in place keeps it; replacing it does not, and a redeploy replaces it. What
303
+ the old container was sending is then stranded where nothing will look again.
304
+
305
+ Narrow on purpose. An unset ``WORKER_NAME`` is the documented default and
306
+ correct almost everywhere, so warning about it as such would fire on every
307
+ untouched installation and teach people to stop reading warnings. This fires
308
+ only on the shape that is actually broken.
309
+ """
310
+ # the same test `worker_identity()` makes. Stripping here would warn about a
311
+ # hostname the worker does not use: a padded name is a poor one, but it is
312
+ # stable, and stability is the only thing this check is about
313
+ if conf.get(key):
314
+ return []
315
+ hostname = os.environ.get('HOSTNAME') or socket.gethostname()
316
+ if not _EPHEMERAL_HOSTNAME.fullmatch(hostname):
317
+ return []
318
+ return [
319
+ Problem(
320
+ f"is empty and this container's hostname ({hostname}) is one Docker generated, so a "
321
+ 'replacement container gets a different one. The in-flight list is keyed on that name, '
322
+ 'so a worker killed mid-send would never find its own message again.',
323
+ hint=(
324
+ 'Set WORKER_NAME, or give the container a fixed `hostname:`. This matters only '
325
+ 'where `start_tgbot` runs, which is why it is information rather than a warning: '
326
+ 'nothing here can tell that from a web process. '
327
+ '`manage.py tgbot_reclaim --worker <name>` is the way back from a list already '
328
+ 'stranded, run from a process whose own name differs.'
329
+ ),
330
+ )
331
+ ]
332
+
333
+
334
+ def _serviceable_webhook(key: str) -> list[Problem]:
335
+ """Reject a webhook Telegram cannot reach, or one anybody could post to."""
336
+ url = str(conf.get(key) or '').strip()
337
+ webhook_mode = str(conf.get('MODE') or '').strip().lower() == UpdateMode.WEBHOOK
338
+ if not url:
339
+ if webhook_mode:
340
+ return [
341
+ Problem(
342
+ "is required when MODE is 'webhook': Telegram has to be told where to "
343
+ "post updates. Switch MODE back to 'polling' if you cannot serve one.",
344
+ )
345
+ ]
346
+ return []
347
+
348
+ problems: list[Problem] = []
349
+ if not str(conf.get('WEBHOOK_SECRET') or '').strip():
350
+ problems.append(
351
+ Problem(
352
+ 'is required when WEBHOOK_URL is set: the view compares it with the header '
353
+ 'Telegram echoes back, and without it anyone who finds the URL can feed '
354
+ 'your bot updates.',
355
+ key='WEBHOOK_SECRET',
356
+ )
357
+ )
358
+ if not url.startswith('https://'):
359
+ problems.append(Problem(f'must be https, got {url!r} — Telegram refuses anything else.'))
360
+ return problems
361
+
362
+
363
+ def _known_update_types(key: str) -> list[Problem]:
364
+ """Require a real collection: a string would reach Telegram as single characters."""
365
+ allowed = conf.get(key)
366
+ if not allowed:
367
+ return []
368
+ if isinstance(allowed, (str, bytes)) or not isinstance(allowed, Collection):
369
+ return [Problem(f'must be a list or tuple of update types, got {type(allowed).__name__}.')]
370
+
371
+ # deferred for the same reason as DefaultBotProperties above
372
+ from aiogram.enums import UpdateType # noqa: PLC0415 - as above
373
+
374
+ known = {member.value for member in UpdateType}
375
+ # anything unhashable would raise out of the membership test below, so the
376
+ # type is settled first and reported by repr rather than by value
377
+ invalid = [repr(name) for name in allowed if not isinstance(name, str)]
378
+ invalid += [repr(name) for name in allowed if isinstance(name, str) and name not in known]
379
+ if invalid:
380
+ return [
381
+ Problem(f'contains update types Telegram does not have: {sorted(invalid)}. Valid ones are {sorted(known)}.')
382
+ ]
383
+ return []
384
+
385
+
386
+ def _known_keys(_key: str) -> list[Problem]:
387
+ """Warn about keys nothing reads: settings keeps them, so a typo is silent."""
388
+ # a non-string key would raise out of join and sorting mixed types raises
389
+ # too, so everything unknown is rendered through repr's eyes first
390
+ unknown = sorted(repr(key) for key in set(conf) - set(DEFAULTS))
391
+ if not unknown:
392
+ return []
393
+ return [
394
+ Problem(
395
+ f'contains unknown keys: {", ".join(unknown)}.',
396
+ hint=f'Known keys are: {", ".join(sorted(DEFAULTS))}.',
397
+ )
398
+ ]
399
+
400
+
401
+ def _a_pop_inside_the_deadline(key: str) -> list[Problem]:
402
+ """Warn when BLPOP is asked to wait longer than the consumer will let it.
403
+
404
+ The consumer caps the pop rather than letting it raise, so a setting above the cap
405
+ is quietly ignored — and the operator who raised it goes on believing it took.
406
+
407
+ Compared against the whole cap, not the read deadline alone, which is what this
408
+ rule used to do. ``HEARTBEAT_INTERVAL`` binds it just as hard, so
409
+ ``BLPOP_TIMEOUT=30, HEARTBEAT_INTERVAL=10, REDIS_TIMEOUT=60`` was silent while the
410
+ pop ran at ten — and when the rule *did* fire, its hint named ``REDIS_TIMEOUT``
411
+ whether or not that was the term doing the binding. It now reports the cap the
412
+ consumer actually computes, from the same helper the consumer uses, and names
413
+ whichever setting produced it.
414
+ """
415
+ try:
416
+ asked = int(conf[key])
417
+ ceiling = blpop_ceiling()
418
+ except (ImproperlyConfigured, TypeError, ValueError):
419
+ return [] # E014, E023 and E030 own the type complaints
420
+ if asked <= ceiling.seconds:
421
+ return []
422
+ named = ' and '.join(f"{SETTINGS_NAME}['{key}']" for key in ceiling.bound_by)
423
+ binds = 'which is what binds it' if len(ceiling.bound_by) == 1 else 'which both bind it, so both have to move'
424
+ return [
425
+ Problem(
426
+ f'is {asked}, which the consumer caps at {ceiling.seconds}.',
427
+ hint=f'Raise {named}, {binds}, or lower this.',
428
+ )
429
+ ]
430
+
431
+
432
+ def _a_collection_of_strings(key: str) -> list[Problem]:
433
+ """Require a real collection: a string would be read one character per item."""
434
+ value = conf.get(key)
435
+ if not value:
436
+ return []
437
+ if isinstance(value, (str, bytes)) or not isinstance(value, Collection):
438
+ return [Problem(f'must be a list or tuple, got {type(value).__name__}.')]
439
+ # anything unhashable would raise out of a membership test later, so the
440
+ # element type is settled here and reported through repr's eyes
441
+ invalid = sorted(repr(name) for name in value if not isinstance(name, str))
442
+ if invalid:
443
+ return [Problem(f'contains names that are not strings: {", ".join(invalid)}.')]
444
+ return []
445
+
446
+
447
+ def _kinds_this_version_records(key: str) -> list[Problem]:
448
+ """Warn about a kind nothing writes: a typo here silently records nothing."""
449
+ value = conf.get(key)
450
+ if not value or isinstance(value, (str, bytes)) or not isinstance(value, Collection):
451
+ return [] # E032 owns the shape complaint
452
+ known = known_kinds()
453
+ unknown = sorted(repr(name) for name in value if isinstance(name, str) and name not in known)
454
+ if not unknown:
455
+ return []
456
+ return [
457
+ Problem(
458
+ f'names kinds nothing records: {", ".join(unknown)}.',
459
+ hint=f'Known kinds are: {", ".join(sorted(known))}.',
460
+ )
461
+ ]
462
+
463
+
464
+ def _the_log_is_on() -> bool:
465
+ """Whether events are recorded, coerced the way the recorder coerces it."""
466
+ try:
467
+ return coerce_bool(conf['EVENT_LOG'], f"{SETTINGS_NAME}['EVENT_LOG']")
468
+ except ImproperlyConfigured:
469
+ # unreadable is E031's finding; assume off, so the rest stays quiet
470
+ return False
471
+
472
+
473
+ def _a_configured_log_database(key: str) -> list[Problem]:
474
+ """Resolve the alias here: the writer runs on a thread nobody is watching.
475
+
476
+ An alias missing from DATABASES raises ConnectionDoesNotExist inside the
477
+ writer thread, where the only trace is a log line in a container nobody
478
+ reads and a queue that quietly fills and drops.
479
+ """
480
+ value = conf.get(key)
481
+ if not isinstance(value, str):
482
+ return [] # E040 owns the type complaint
483
+ alias = value.strip()
484
+ if not alias:
485
+ return []
486
+ # deferred like the aiogram imports: a boot that records nothing must not
487
+ # pay for the connection handler
488
+ from django.db import connections # noqa: PLC0415 - as above
489
+
490
+ if alias in connections:
491
+ return []
492
+ return [
493
+ Problem(
494
+ f'names {alias!r}, which is not in DATABASES.',
495
+ hint=f'Configured aliases are: {", ".join(sorted(connections))}.',
496
+ )
497
+ ]
498
+
499
+
500
+ def _somewhere_to_write_the_log(key: str) -> list[Problem]:
501
+ """Warn, never error, when the log is on with no database behind it.
502
+
503
+ A project may legitimately boot without one — this package's own suite does
504
+ — so this must not be able to fail ``manage.py check``.
505
+
506
+ The engine is what gets asked, not whether DATABASES is empty: Django fills
507
+ an empty setting in with the dummy backend the first time anything touches
508
+ connections, so by the time checks run the dict is never empty.
509
+ """
510
+ if not _the_log_is_on():
511
+ return []
512
+ # deferred for the same reason as the alias check above
513
+ from django.db import DEFAULT_DB_ALIAS, connections # noqa: PLC0415 - as above
514
+
515
+ alias = str(conf.get('EVENT_LOG_DATABASE') or '').strip() or DEFAULT_DB_ALIAS
516
+ if alias not in connections:
517
+ return [] # E041 owns the missing alias
518
+ engine = str(connections[alias].settings_dict.get('ENGINE') or '')
519
+ if engine and engine != 'django.db.backends.dummy':
520
+ return []
521
+ return [
522
+ Problem(
523
+ f'is on while {alias!r} has no database engine, so every event is dropped.',
524
+ hint=f'Configure a database, or leave {key} off in processes that have none.',
525
+ )
526
+ ]
527
+
528
+
529
+ def worker_name_problems() -> list[Problem]:
530
+ """Return the worker-name problems, for a caller that knows it *is* the consumer.
531
+
532
+ `I001` reports this as information because a system check cannot tell which process it
533
+ is running in. `start_tgbot` can, and warns. One rule, two audiences.
534
+ """
535
+ return _a_worker_that_keeps_its_name('WORKER_NAME')
536
+
537
+
538
+ def _a_routed_log_database(key: str) -> list[Problem]:
539
+ """Say when the log is pointed at its own alias with nothing routing it there.
540
+
541
+ ``EVENT_LOG_DATABASE`` names where the rows belong; ``TelegramEventLogRouter`` is
542
+ what puts them there. Set the first and forget the second and every existing check
543
+ passes — E040 sees a string, E041 sees a configured alias with a real engine, W005
544
+ sees a database — while a plain ``migrate`` does not create the table on it and the
545
+ writer logs ``no such table`` once per batch for ever. ``migrate --database=<alias>``
546
+ still would, which is why this is information: someone may be doing exactly that.
547
+
548
+ I002 rather than a warning, because a project may route this app by hand: a router
549
+ of its own that returns the same alias is a legitimate way to do it, and this rule
550
+ cannot see inside one. The hint says so too, and `Settings.md` lists it under the
551
+ information ids.
552
+
553
+ Compared through ``import_string`` so both spellings count. ``DATABASE_ROUTERS``
554
+ accepts dotted paths and instances alike, and a project mixing the two — a path
555
+ for ours, an instance for its own — is exactly the case a string comparison gets
556
+ wrong.
557
+ """
558
+ if not _the_log_is_on():
559
+ return []
560
+ alias = str(conf.get(key) or '').strip()
561
+ if not alias:
562
+ return [] # nothing was pointed anywhere, so nothing needs routing
563
+ from django.conf import settings as django_settings # noqa: PLC0415 - as above
564
+
565
+ from django_aiogram.eventlog.dbrouter import TelegramEventLogRouter # noqa: PLC0415 - no django.db at import
566
+
567
+ for entry in getattr(django_settings, 'DATABASE_ROUTERS', ()) or ():
568
+ candidate = entry
569
+ if isinstance(entry, str):
570
+ try:
571
+ candidate = import_string(entry)
572
+ except ImportError:
573
+ continue # a router Django itself will complain about
574
+ if candidate is TelegramEventLogRouter or isinstance(candidate, TelegramEventLogRouter):
575
+ return []
576
+ return [
577
+ Problem(
578
+ f'is {alias!r}, and this check cannot see a router that sends this app there.',
579
+ hint=(
580
+ "Add 'django_aiogram.eventlog.dbrouter.TelegramEventLogRouter' to DATABASE_ROUTERS, "
581
+ f'or leave {key} unset so the log uses the default database. A router of your own '
582
+ 'returning that alias is equally correct and is what this cannot read, which is why '
583
+ 'this is information rather than a warning.'
584
+ ),
585
+ )
586
+ ]
587
+
588
+
589
+ def _a_log_that_is_pruned(key: str) -> list[Problem]:
590
+ """Warn when nothing will ever delete a row, so the table only grows."""
591
+ if not _the_log_is_on():
592
+ return []
593
+ try:
594
+ days = int(conf[key])
595
+ except (TypeError, ValueError):
596
+ return [] # E039 owns the type complaint
597
+ if days > 0:
598
+ return []
599
+ return [
600
+ Problem(
601
+ 'is 0 while the log is on, so nothing ever deletes a row.',
602
+ hint='Set it and schedule `manage.py tgbot_prune_events`, or accept unbounded growth.',
603
+ )
604
+ ]
605
+
606
+
607
+ def _a_batch_the_buffer_can_hold(key: str) -> list[Problem]:
608
+ """Warn when the batch can never fill, so the interval paces every write.
609
+
610
+ The writer stops collecting at the buffer's size, which makes a larger batch
611
+ not a bigger write but a partial one every flush interval.
612
+ """
613
+ try:
614
+ batch = int(conf[key])
615
+ buffer = int(conf['EVENT_LOG_BUFFER_SIZE'])
616
+ except (TypeError, ValueError):
617
+ return [] # E036 and E037 own the type complaints
618
+ if batch <= buffer:
619
+ return []
620
+ return [
621
+ Problem(
622
+ f'is {batch}, which the buffer caps at {buffer}.',
623
+ hint=f"Raise {SETTINGS_NAME}['EVENT_LOG_BUFFER_SIZE'] above it, or lower this.",
624
+ )
625
+ ]
626
+
627
+
628
+ def _a_writer_that_does_not_block(key: str) -> list[Problem]:
629
+ """Warn that synchronous recording puts a database round trip in the send.
630
+
631
+ The whole design rests on recording never making a caller wait. This
632
+ setting deliberately breaks that for tests, so the trade is stated rather
633
+ than left to be discovered under load.
634
+
635
+ Silent while the log is off, because `record()` returns before it ever
636
+ reads this one: warning there would describe a cost nobody is paying.
637
+ """
638
+ if not _the_log_is_on():
639
+ return []
640
+ try:
641
+ if not coerce_bool(conf[key], f"{SETTINGS_NAME}['{key}']"):
642
+ return []
643
+ except ImproperlyConfigured:
644
+ return [] # E042 owns the type complaint
645
+ return [
646
+ Problem(
647
+ 'is on, so every recorded event is written on the calling thread and a send waits for the database.',
648
+ hint='Leave it off outside tests.',
649
+ )
650
+ ]
651
+
652
+
653
+ def _bot_is_enabled() -> bool:
654
+ """Whether the bot is on, coerced the way startup and sending coerce it."""
655
+ try:
656
+ return coerce_bool(conf['ENABLED'], f"{SETTINGS_NAME}['ENABLED']")
657
+ except ImproperlyConfigured:
658
+ # unreadable is E001's finding; assume on, so the credential warnings show
659
+ return True
660
+
661
+
662
+ def _filled_in_when_enabled(key: str, *, hint: str) -> list[Problem]:
663
+ """Warn, never error, when an enabled bot has nothing to connect with.
664
+
665
+ A project may legitimately boot without credentials — during migrations or
666
+ image builds — so this must not be able to fail ``manage.py check``.
667
+ """
668
+ if not _bot_is_enabled() or str(conf.get(key) or '').strip():
669
+ return []
670
+ return [Problem('is empty while the bot is enabled.', hint=hint)]
671
+
672
+
673
+ CHECKS: tuple[Check, ...] = (
674
+ Check('E001', 'ENABLED', _a_readable_boolean),
675
+ Check('E002', 'AUTODISCOVER', _a_readable_boolean),
676
+ Check('E003', 'RAISE_EXCEPTION', _a_readable_boolean),
677
+ Check('E017', 'ALLOW_PICKLE', _a_readable_boolean),
678
+ Check('E004', 'TOKEN', _a_string),
679
+ Check('E005', 'REDIS_URL', _a_string),
680
+ Check('E006', 'MODULE_NAME', _a_string),
681
+ Check('E007', 'REDIS_MESSAGES_KEY', _a_string),
682
+ Check('E021', 'WORKER_NAME', _a_string),
683
+ Check('E009', 'DELIVERY', partial(_a_string, allowed=DELIVERY_CHOICES)),
684
+ Check('E010', 'SERIALIZER', partial(_a_string, allowed=SERIALIZER_CHOICES)),
685
+ Check('E011', 'FSM_STORAGE', _a_string),
686
+ Check('E012', 'MAX_RETRIES', partial(_an_integer, minimum=1)),
687
+ Check('E014', 'BLPOP_TIMEOUT', partial(_an_integer, minimum=1)),
688
+ # 2, not 1: the consumer's blocking pop is capped one second inside this, and at 1
689
+ # the subtraction clamps back to 1 — so the pop's own timeout equals the read
690
+ # deadline and the deadline always wins. Every idle second then costs a
691
+ # `TimeoutError`, a traceback and a reconnect, on a healthy server, for ever
692
+ Check('E030', 'REDIS_TIMEOUT', partial(_an_integer, minimum=2)),
693
+ Check('W004', 'BLPOP_TIMEOUT', _a_pop_inside_the_deadline),
694
+ Check('E023', 'HEARTBEAT_INTERVAL', partial(_an_integer, minimum=1)),
695
+ Check('E024', 'HEALTHCHECK_MAX_QUEUE', partial(_an_integer, minimum=0)),
696
+ Check('E028', 'MODE', partial(_a_string, allowed=MODE_CHOICES)),
697
+ Check('E025', 'WEBHOOK_URL', _a_string),
698
+ Check('E026', 'WEBHOOK_SECRET', _a_string),
699
+ Check('E027', 'WEBHOOK_URL', _serviceable_webhook),
700
+ Check('E029', 'WEBHOOK_ALLOWED_UPDATES', _known_update_types),
701
+ Check('E015', 'DEFAULT_KWARGS', _a_callable),
702
+ Check('E016', 'DEFAULT_BOT_PROPERTIES', _a_mapping),
703
+ Check('E018', 'DEFAULT_BOT_PROPERTIES', _known_bot_properties),
704
+ Check('E020', 'RATE_LIMIT', _sane_rate_limits),
705
+ Check('E022', 'SERIALIZER', _readable_serializer),
706
+ Check('E019', 'FSM_STORAGE', _importable_storage),
707
+ Check('E031', 'EVENT_LOG', _a_readable_boolean),
708
+ Check('E032', 'EVENT_LOG_KINDS', _a_collection_of_strings),
709
+ Check('E033', 'EVENT_LOG_PAYLOAD', partial(_a_string, allowed=PAYLOAD_CHOICES)),
710
+ Check('E034', 'EVENT_LOG_MAX_PAYLOAD_BYTES', partial(_an_integer, minimum=0)),
711
+ Check('E035', 'EVENT_LOG_REDACT_KEYS', _a_collection_of_strings),
712
+ Check('E036', 'EVENT_LOG_BUFFER_SIZE', partial(_an_integer, minimum=1)),
713
+ Check('E037', 'EVENT_LOG_BATCH_SIZE', partial(_an_integer, minimum=1)),
714
+ Check('E038', 'EVENT_LOG_FLUSH_INTERVAL', partial(_an_integer, minimum=1)),
715
+ Check('E039', 'EVENT_LOG_RETENTION_DAYS', partial(_an_integer, minimum=0)),
716
+ Check('E040', 'EVENT_LOG_DATABASE', _a_string),
717
+ Check('E041', 'EVENT_LOG_DATABASE', _a_configured_log_database),
718
+ # I, not W: this cannot see inside a router, so a project whose own router returns
719
+ # the alias is correctly configured and would still be reported. Information the
720
+ # reader can act on, not a condition worth failing `check --fail-level WARNING`
721
+ Check('I002', 'EVENT_LOG_DATABASE', _a_routed_log_database),
722
+ Check('E042', 'EVENT_LOG_SYNC', _a_readable_boolean),
723
+ Check('E043', 'REDIS_URL', _a_url_pickle_can_survive),
724
+ Check('E044', 'DRAIN_TIMEOUT', partial(_a_number, minimum=0)),
725
+ Check('E045', 'MAX_IN_FLIGHT', partial(_an_integer, minimum=0)),
726
+ Check('E046', 'REQUIRE_CRASH_SAFE', _a_readable_boolean),
727
+ Check('W005', 'EVENT_LOG', _somewhere_to_write_the_log),
728
+ Check('W006', 'EVENT_LOG_RETENTION_DAYS', _a_log_that_is_pruned),
729
+ Check('W007', 'EVENT_LOG_BATCH_SIZE', _a_batch_the_buffer_can_hold),
730
+ Check('W008', 'EVENT_LOG_KINDS', _kinds_this_version_records),
731
+ Check('W009', 'EVENT_LOG_SYNC', _a_writer_that_does_not_block),
732
+ # I, not W: a check cannot tell a consumer from the web tier, and every container
733
+ # without `hostname:` matches — so as a warning it failed `check --fail-level WARNING`
734
+ # in processes that own no in-flight list. `start_tgbot` warns for itself, where being
735
+ # the consumer is known
736
+ Check('I001', 'WORKER_NAME', _a_worker_that_keeps_its_name),
737
+ Check('W003', '', _known_keys),
738
+ Check(
739
+ 'W001',
740
+ 'TOKEN',
741
+ partial(
742
+ _filled_in_when_enabled,
743
+ hint='Set it, or set ENABLED to False in processes that never reach Telegram.',
744
+ ),
745
+ ),
746
+ Check(
747
+ 'W002',
748
+ 'REDIS_URL',
749
+ partial(
750
+ _filled_in_when_enabled,
751
+ hint='Set it, or set ENABLED to False in processes that never reach Redis.',
752
+ ),
753
+ ),
754
+ )
755
+
756
+
757
+ def check_settings(**kwargs: Any) -> list[CheckMessage]:
758
+ """Run every registered check and return everything it reported."""
759
+ return [message for check in CHECKS for message in check.run()]