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.
- django_aiogram/__init__.py +64 -0
- django_aiogram/_singleton.py +22 -0
- django_aiogram/admin.py +380 -0
- django_aiogram/api.py +38 -0
- django_aiogram/apps.py +56 -0
- django_aiogram/config/__init__.py +15 -0
- django_aiogram/config/checks.py +759 -0
- django_aiogram/config/defaults.py +102 -0
- django_aiogram/config/enums.py +108 -0
- django_aiogram/config/settings.py +266 -0
- django_aiogram/consumer/__init__.py +13 -0
- django_aiogram/consumer/delivery.py +625 -0
- django_aiogram/consumer/routers.py +19 -0
- django_aiogram/consumer/webhook.py +154 -0
- django_aiogram/context.py +34 -0
- django_aiogram/eventlog/__init__.py +16 -0
- django_aiogram/eventlog/dbrouter.py +55 -0
- django_aiogram/eventlog/events.py +120 -0
- django_aiogram/eventlog/instrumentation.py +231 -0
- django_aiogram/eventlog/recorder.py +922 -0
- django_aiogram/eventlog/signals.py +84 -0
- django_aiogram/eventlog/writer.py +231 -0
- django_aiogram/exceptions.py +60 -0
- django_aiogram/healthcheck.py +412 -0
- django_aiogram/management/__init__.py +1 -0
- django_aiogram/management/commands/__init__.py +1 -0
- django_aiogram/management/commands/start_tgbot.py +308 -0
- django_aiogram/management/commands/tgbot_healthcheck.py +57 -0
- django_aiogram/management/commands/tgbot_prune_events.py +144 -0
- django_aiogram/management/commands/tgbot_reclaim.py +135 -0
- django_aiogram/management/commands/tgbot_webhook.py +87 -0
- django_aiogram/migrations/0001_initial.py +50 -0
- django_aiogram/migrations/0002_kind_id_index.py +32 -0
- django_aiogram/migrations/__init__.py +1 -0
- django_aiogram/models.py +79 -0
- django_aiogram/producer/__init__.py +13 -0
- django_aiogram/producer/client.py +1540 -0
- django_aiogram/producer/throttling.py +336 -0
- django_aiogram/py.typed +0 -0
- django_aiogram/redis.py +394 -0
- django_aiogram/wire/__init__.py +14 -0
- django_aiogram/wire/envelope.py +146 -0
- django_aiogram/wire/payloads.py +195 -0
- django_aiogram/wire/serializers.py +533 -0
- django_aiogram-4.0.0.dev0.dist-info/METADATA +145 -0
- django_aiogram-4.0.0.dev0.dist-info/RECORD +48 -0
- django_aiogram-4.0.0.dev0.dist-info/WHEEL +4 -0
- 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()]
|