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,102 @@
|
|
|
1
|
+
"""Every setting the package reads, with its default and the reason for it."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def no_default_kwargs(_function: str, /) -> dict[str, Any]:
|
|
7
|
+
"""Return no extra kwargs, whatever aiogram function is asked about."""
|
|
8
|
+
return {}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULTS: dict[str, Any] = {
|
|
12
|
+
# whether this process should talk to Telegram or Redis at all
|
|
13
|
+
'ENABLED': True,
|
|
14
|
+
'TOKEN': '',
|
|
15
|
+
'REDIS_URL': '',
|
|
16
|
+
# import <app>.<MODULE_NAME> for every installed app on startup
|
|
17
|
+
'AUTODISCOVER': True,
|
|
18
|
+
'MODULE_NAME': 'tg_router',
|
|
19
|
+
# blpop; the keyspace consumer 1.x used was removed in 3.0
|
|
20
|
+
'DELIVERY': 'blpop',
|
|
21
|
+
# either json or pickle, json recommended
|
|
22
|
+
'SERIALIZER': 'json',
|
|
23
|
+
# the escape hatch for payloads JSON cannot describe. Off by default because
|
|
24
|
+
# unpickling queued data lets whoever writes the queue execute code
|
|
25
|
+
'ALLOW_PICKLE': False,
|
|
26
|
+
# 'redis', 'memory', or a dotted path to a BaseStorage subclass
|
|
27
|
+
'FSM_STORAGE': 'redis',
|
|
28
|
+
# forwarded to aiogram's DefaultBotProperties, e.g. {'parse_mode': 'HTML'}
|
|
29
|
+
'DEFAULT_BOT_PROPERTIES': {},
|
|
30
|
+
# per-function extras for what DefaultBotProperties cannot express
|
|
31
|
+
'DEFAULT_KWARGS': no_default_kwargs,
|
|
32
|
+
# stay under Telegram's published limits instead of waiting to be refused;
|
|
33
|
+
# set to None to disable. Budgets are per bot, so a second token gets its own
|
|
34
|
+
'RATE_LIMIT': {
|
|
35
|
+
'overall_per_second': 30,
|
|
36
|
+
'per_chat_per_second': 1,
|
|
37
|
+
'group_per_minute': 20,
|
|
38
|
+
},
|
|
39
|
+
'MAX_RETRIES': 10,
|
|
40
|
+
'RAISE_EXCEPTION': False,
|
|
41
|
+
'REDIS_MESSAGES_KEY': 'TELEGRAM_BOT_MESSAGE',
|
|
42
|
+
# names this worker's in-flight list; defaults to the hostname. Set it when
|
|
43
|
+
# several workers share a host, so they cannot reclaim each other's messages
|
|
44
|
+
'WORKER_NAME': '',
|
|
45
|
+
# how long a blocking pop waits before re-checking the shutdown flag
|
|
46
|
+
'BLPOP_TIMEOUT': 5,
|
|
47
|
+
# seconds close() gives in-flight sends to finish before canceling them. A float,
|
|
48
|
+
# which is what makes `DJANGO_AIOGRAM_DRAIN_TIMEOUT=0.5` readable: the
|
|
49
|
+
# environment coerces on the default's type, and an int default sent a fraction to
|
|
50
|
+
# the integer branch, where it raised out of apps.ready() and stopped every command
|
|
51
|
+
'DRAIN_TIMEOUT': 5.0,
|
|
52
|
+
# how many sends the consumer will leave in flight before it stops taking
|
|
53
|
+
# messages. 0 means no bound, which is what shipped before the consumer
|
|
54
|
+
# waited for a send to finish. Acknowledging scans the in-flight list, so an
|
|
55
|
+
# unbounded one turns draining a backlog into quadratic work
|
|
56
|
+
'MAX_IN_FLIGHT': 0,
|
|
57
|
+
# refuse to start where a message cannot survive the worker being killed
|
|
58
|
+
# mid-send, rather than running at-most-once without saying so
|
|
59
|
+
'REQUIRE_CRASH_SAFE': False,
|
|
60
|
+
'REDIS_TIMEOUT': 10,
|
|
61
|
+
# how often the consumer refreshes the key the healthcheck reads. The key lives
|
|
62
|
+
# three times as long, so one missed refresh is not a failure — and that TTL is
|
|
63
|
+
# also the most `--max-age` can ever observe
|
|
64
|
+
'HEARTBEAT_INTERVAL': 10,
|
|
65
|
+
# a queue longer than this fails the healthcheck; 0 turns the check off
|
|
66
|
+
'HEALTHCHECK_MAX_QUEUE': 0,
|
|
67
|
+
# where updates come from: 'polling' (a process calling getUpdates) or
|
|
68
|
+
# 'webhook' (Telegram posting them to a URL you serve). Both are supported;
|
|
69
|
+
# polling is the default because it needs nothing but an outbound connection
|
|
70
|
+
'MODE': 'polling',
|
|
71
|
+
# webhook mode: where Telegram posts updates, and the secret it echoes back
|
|
72
|
+
# in X-Telegram-Bot-Api-Secret-Token so the view can tell it is Telegram
|
|
73
|
+
'WEBHOOK_URL': '',
|
|
74
|
+
'WEBHOOK_SECRET': '',
|
|
75
|
+
# which update types to receive; empty means Telegram's own default set
|
|
76
|
+
'WEBHOOK_ALLOWED_UPDATES': (),
|
|
77
|
+
# record what the bot did to a table, one row per event, insert only. Off by
|
|
78
|
+
# default: it is a table whose size is set by traffic, so turning it on is a
|
|
79
|
+
# decision, and it needs a retention job to go with it
|
|
80
|
+
'EVENT_LOG': False,
|
|
81
|
+
# which kinds to keep; empty means every kind this version knows. Naming any
|
|
82
|
+
# also opts out of the kinds a later release adds
|
|
83
|
+
'EVENT_LOG_KINDS': (),
|
|
84
|
+
# 'none', 'summary' (argument names and sizes) or 'full' (message bodies).
|
|
85
|
+
# The default keeps personal data out of the table until you ask for it
|
|
86
|
+
'EVENT_LOG_PAYLOAD': 'summary',
|
|
87
|
+
'EVENT_LOG_MAX_PAYLOAD_BYTES': 8192,
|
|
88
|
+
# values under these keys are blanked before a row is written
|
|
89
|
+
'EVENT_LOG_REDACT_KEYS': ('token', 'secret', 'password', 'authorization', 'api_key', 'session'),
|
|
90
|
+
# events held in memory while the writer is behind; a full buffer drops the
|
|
91
|
+
# event rather than making a send wait on the database
|
|
92
|
+
'EVENT_LOG_BUFFER_SIZE': 1000,
|
|
93
|
+
'EVENT_LOG_BATCH_SIZE': 200,
|
|
94
|
+
'EVENT_LOG_FLUSH_INTERVAL': 1,
|
|
95
|
+
# days a row is kept; 0 keeps them for ever. Nothing on the write path
|
|
96
|
+
# deletes anything — `manage.py tgbot_prune_events` is what reads this
|
|
97
|
+
'EVENT_LOG_RETENTION_DAYS': 0,
|
|
98
|
+
# a DATABASES alias for the log; empty means the default one
|
|
99
|
+
'EVENT_LOG_DATABASE': '',
|
|
100
|
+
# write on the calling thread instead of the writer's: tests only
|
|
101
|
+
'EVENT_LOG_SYNC': False,
|
|
102
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Named constants for the strings this package treats as data.
|
|
2
|
+
|
|
3
|
+
Every value here is frozen: queued payloads carry serialization tags and user
|
|
4
|
+
settings carry delivery, serializer, storage and mode names, so changing a value
|
|
5
|
+
would break in-flight messages and every deployment's ``TELEGRAM_BOT`` block.
|
|
6
|
+
The classes subclass ``str`` so that a member is interchangeable with the string
|
|
7
|
+
it names, which is what keeps existing settings and payloads readable as-is.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from enum import Enum, unique
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@unique
|
|
14
|
+
class DeliveryKind(str, Enum):
|
|
15
|
+
"""How the consumer learns that a message is waiting."""
|
|
16
|
+
|
|
17
|
+
BLPOP = 'blpop'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@unique
|
|
21
|
+
class SerializerKind(str, Enum):
|
|
22
|
+
"""Which encoding writes and reads the queue."""
|
|
23
|
+
|
|
24
|
+
JSON = 'json'
|
|
25
|
+
PICKLE = 'pickle'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@unique
|
|
29
|
+
class StorageKind(str, Enum):
|
|
30
|
+
"""Built-in aiogram FSM storage backends."""
|
|
31
|
+
|
|
32
|
+
REDIS = 'redis'
|
|
33
|
+
MEMORY = 'memory'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@unique
|
|
37
|
+
class UpdateMode(str, Enum):
|
|
38
|
+
"""Where Telegram updates come from."""
|
|
39
|
+
|
|
40
|
+
POLLING = 'polling'
|
|
41
|
+
WEBHOOK = 'webhook'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@unique
|
|
45
|
+
class SerializationTag(str, Enum):
|
|
46
|
+
"""Keys that mark a decoded JSON object as something richer than a mapping."""
|
|
47
|
+
|
|
48
|
+
MODEL = '__model__'
|
|
49
|
+
DEFAULT = '__default__'
|
|
50
|
+
DATETIME = '__datetime__'
|
|
51
|
+
DATE = '__date__'
|
|
52
|
+
DECIMAL = '__decimal__'
|
|
53
|
+
BYTES = '__bytes__'
|
|
54
|
+
INPUT_FILE = '__input_file__'
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@unique
|
|
58
|
+
class RateLimitKey(str, Enum):
|
|
59
|
+
"""Budget names inside the ``RATE_LIMIT`` setting."""
|
|
60
|
+
|
|
61
|
+
OVERALL_PER_SECOND = 'overall_per_second'
|
|
62
|
+
PER_CHAT_PER_SECOND = 'per_chat_per_second'
|
|
63
|
+
GROUP_PER_MINUTE = 'group_per_minute'
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@unique
|
|
67
|
+
class EventKind(str, Enum):
|
|
68
|
+
"""What one row of the event log records.
|
|
69
|
+
|
|
70
|
+
Namespaced by direction and dotted, so a project registering its own kinds
|
|
71
|
+
has an obvious convention to follow. These land in a database column and in
|
|
72
|
+
saved admin filters, which is why the values are frozen like the rest.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
OUTBOUND_QUEUED = 'outbound.queued'
|
|
76
|
+
OUTBOUND_CONSUMED = 'outbound.consumed'
|
|
77
|
+
OUTBOUND_SENT = 'outbound.sent'
|
|
78
|
+
OUTBOUND_RETRIED = 'outbound.retried'
|
|
79
|
+
OUTBOUND_FAILED = 'outbound.failed'
|
|
80
|
+
OUTBOUND_DROPPED = 'outbound.dropped'
|
|
81
|
+
INBOUND_RECEIVED = 'inbound.received'
|
|
82
|
+
INBOUND_HANDLED = 'inbound.handled'
|
|
83
|
+
INBOUND_FAILED = 'inbound.failed'
|
|
84
|
+
FSM_TRANSITION = 'fsm.transition'
|
|
85
|
+
QUEUE_UNDECODABLE = 'queue.undecodable'
|
|
86
|
+
QUEUE_REJECTED = 'queue.rejected'
|
|
87
|
+
LOG_DROPPED = 'log.dropped'
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@unique
|
|
91
|
+
class PayloadDetail(str, Enum):
|
|
92
|
+
"""How much of a call's arguments the event log keeps."""
|
|
93
|
+
|
|
94
|
+
NONE = 'none'
|
|
95
|
+
SUMMARY = 'summary'
|
|
96
|
+
FULL = 'full'
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def choices(kind: type[Enum]) -> frozenset[str]:
|
|
100
|
+
"""Return the values of ``kind`` as a frozenset, for membership checks."""
|
|
101
|
+
return frozenset(member.value for member in kind)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
#: here rather than beside the limiter that enforces them: `config.checks` needs the names
|
|
105
|
+
#: to judge the setting, and reaching into `producer` for them would make configuration
|
|
106
|
+
#: depend on the thing it configures — and would pay for whatever the limiter imports on
|
|
107
|
+
#: every boot that registers a check
|
|
108
|
+
KNOWN_RATE_LIMIT_KEYS: frozenset[str] = choices(RateLimitKey)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Resolve the package's settings, lazily, from three sources.
|
|
2
|
+
|
|
3
|
+
A project configures the package through one ``TELEGRAM_BOT`` dict in its Django
|
|
4
|
+
settings. Anything it leaves out is looked for in the environment, and anything
|
|
5
|
+
the environment leaves out comes from :mod:`django_aiogram.config.defaults`.
|
|
6
|
+
|
|
7
|
+
Nothing here reads Django settings at import time: before 2.0 it did, which took
|
|
8
|
+
the whole project down — its test suite included — whenever the token or Redis
|
|
9
|
+
was absent.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import math
|
|
14
|
+
import os
|
|
15
|
+
from collections.abc import Iterator, Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from django.conf import settings as django_settings
|
|
20
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
21
|
+
from django.core.signals import setting_changed
|
|
22
|
+
|
|
23
|
+
from django_aiogram.config.defaults import DEFAULTS
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger('django_aiogram')
|
|
26
|
+
|
|
27
|
+
SETTINGS_NAME = 'TELEGRAM_BOT'
|
|
28
|
+
ENV_PREFIX = 'DJANGO_AIOGRAM_'
|
|
29
|
+
|
|
30
|
+
_TRUTHY = frozenset({'1', 'true', 'yes', 'on'})
|
|
31
|
+
_FALSY = frozenset({'0', 'false', 'no', 'off'})
|
|
32
|
+
|
|
33
|
+
_MISSING = object()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_bool(value: str, source: str) -> bool:
|
|
37
|
+
"""Parse a human-written boolean, rejecting anything ambiguous.
|
|
38
|
+
|
|
39
|
+
``source`` names the setting or variable in the error, so a typo is
|
|
40
|
+
traceable to the place that holds it.
|
|
41
|
+
"""
|
|
42
|
+
normalized = value.strip().lower()
|
|
43
|
+
if normalized in _TRUTHY:
|
|
44
|
+
return True
|
|
45
|
+
if normalized in _FALSY:
|
|
46
|
+
return False
|
|
47
|
+
msg = f'{source} must be one of {sorted(_TRUTHY | _FALSY)}, got {value!r}.'
|
|
48
|
+
raise ImproperlyConfigured(msg)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def coerce_bool(value: object, source: str) -> bool:
|
|
52
|
+
"""Accept the shapes a settings file realistically holds.
|
|
53
|
+
|
|
54
|
+
Plain bool(value) would read the string 'false' as True and quietly enable
|
|
55
|
+
a bot the project meant to switch off.
|
|
56
|
+
"""
|
|
57
|
+
if isinstance(value, bool):
|
|
58
|
+
return value
|
|
59
|
+
if isinstance(value, int):
|
|
60
|
+
return bool(value)
|
|
61
|
+
if isinstance(value, str):
|
|
62
|
+
return parse_bool(value, source)
|
|
63
|
+
msg = f'{source} must be a boolean, got {type(value).__name__}.'
|
|
64
|
+
raise ImproperlyConfigured(msg)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _from_env(key: str, default: object) -> object:
|
|
68
|
+
"""Read a setting from the environment, coercing it to the default's type.
|
|
69
|
+
|
|
70
|
+
Returns the ``_MISSING`` sentinel when the variable is unset, which is what
|
|
71
|
+
lets an empty string through as a deliberate value.
|
|
72
|
+
|
|
73
|
+
Only scalars are supported: callables and containers have no sane textual
|
|
74
|
+
form, so they stay settings-only.
|
|
75
|
+
"""
|
|
76
|
+
name = ENV_PREFIX + key
|
|
77
|
+
raw = os.environ.get(name)
|
|
78
|
+
if raw is None:
|
|
79
|
+
return _MISSING
|
|
80
|
+
if isinstance(default, bool):
|
|
81
|
+
return parse_bool(raw, name)
|
|
82
|
+
if isinstance(default, int):
|
|
83
|
+
try:
|
|
84
|
+
return int(raw)
|
|
85
|
+
except ValueError:
|
|
86
|
+
msg = f'{name} must be an integer, got {raw!r}.'
|
|
87
|
+
raise ImproperlyConfigured(msg) from None
|
|
88
|
+
if isinstance(default, float):
|
|
89
|
+
# a setting whose check accepts a number has to accept one from here too. Without
|
|
90
|
+
# this branch a float-defaulted setting fell through to `_MISSING` and the variable
|
|
91
|
+
# was *silently ignored*, and while `DRAIN_TIMEOUT` defaulted to an int the same
|
|
92
|
+
# value raised out of `apps.ready()` — so `DRAIN_TIMEOUT: 0.5` was valid in
|
|
93
|
+
# settings and stopped every `manage.py` command from the environment
|
|
94
|
+
try:
|
|
95
|
+
number = float(raw)
|
|
96
|
+
except ValueError:
|
|
97
|
+
msg = f'{name} must be a number, got {raw!r}.'
|
|
98
|
+
raise ImproperlyConfigured(msg) from None
|
|
99
|
+
if not math.isfinite(number):
|
|
100
|
+
# `float()` accepts 'nan', 'inf' and '-inf', and every consumer of these
|
|
101
|
+
# settings is a deadline: `nan` compares false against everything, so a wait
|
|
102
|
+
# bounded by it never expires, and `sleep(nan)` raises from inside a thread
|
|
103
|
+
# nobody is watching. E044 reports it, but only when `check` runs — and the
|
|
104
|
+
# environment reaches every process, including the ones that never run checks
|
|
105
|
+
msg = f'{name} must be a finite number, got {raw!r}.'
|
|
106
|
+
raise ImproperlyConfigured(msg)
|
|
107
|
+
return number
|
|
108
|
+
if isinstance(default, str):
|
|
109
|
+
return raw
|
|
110
|
+
# a container or a callable has no textual form, so the variable cannot be honoured —
|
|
111
|
+
# and being silently ignored is the worst of the three answers. An operator throttling
|
|
112
|
+
# the bot with DJANGO_AIOGRAM_RATE_LIMIT got the default rate and no word about
|
|
113
|
+
# it, from a page that promises an environment twin for every scalar
|
|
114
|
+
logger.warning(
|
|
115
|
+
'ignoring an environment variable for a setting that has no textual form',
|
|
116
|
+
extra={'tg_setting': key, 'tg_variable': name},
|
|
117
|
+
)
|
|
118
|
+
return _MISSING
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Settings(Mapping[str, Any]):
|
|
122
|
+
"""Package settings resolved on first access.
|
|
123
|
+
|
|
124
|
+
Resolution order is Django settings, then environment, then defaults.
|
|
125
|
+
Reading Django settings lazily is what keeps importing this package free of
|
|
126
|
+
side effects, so nothing here may run at import time.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self) -> None:
|
|
130
|
+
"""Start with nothing resolved; the first lookup does the work."""
|
|
131
|
+
self._cache: dict[str, Any] | None = None
|
|
132
|
+
|
|
133
|
+
def _resolve(self) -> dict[str, Any]:
|
|
134
|
+
"""Build the whole settings dict once, in the documented order of precedence.
|
|
135
|
+
|
|
136
|
+
Every key at once rather than per lookup, so a process cannot end up holding one
|
|
137
|
+
value from Django settings and another from the environment because the two were
|
|
138
|
+
resolved at different moments.
|
|
139
|
+
|
|
140
|
+
The mapping check earns its place: ``key in overrides`` is a membership test, and
|
|
141
|
+
against a ``TELEGRAM_BOT`` that is a list it answers False for every key — so
|
|
142
|
+
without it the whole setting would be *silently* ignored and every value taken
|
|
143
|
+
from the environment or the defaults, including the token. A misconfiguration that
|
|
144
|
+
loudly refuses is worth more than one that runs as though unconfigured.
|
|
145
|
+
|
|
146
|
+
Only ``None`` and an absent setting mean *not configured*. Folding every falsy
|
|
147
|
+
value into ``{}`` first, which is what ``or {}`` did, let ``[]``, ``()`` and ``''``
|
|
148
|
+
past the check that exists to catch them — the empty ones, which are exactly what
|
|
149
|
+
a mistaken assignment produces.
|
|
150
|
+
"""
|
|
151
|
+
overrides = getattr(django_settings, SETTINGS_NAME, None)
|
|
152
|
+
if overrides is None:
|
|
153
|
+
overrides = {}
|
|
154
|
+
if not isinstance(overrides, Mapping):
|
|
155
|
+
msg = f'{SETTINGS_NAME} must be a mapping, got {type(overrides).__name__}.'
|
|
156
|
+
raise ImproperlyConfigured(msg)
|
|
157
|
+
resolved = dict(DEFAULTS)
|
|
158
|
+
for key, default in DEFAULTS.items():
|
|
159
|
+
if key in overrides:
|
|
160
|
+
resolved[key] = overrides[key]
|
|
161
|
+
continue
|
|
162
|
+
value = _from_env(key, default)
|
|
163
|
+
if value is not _MISSING:
|
|
164
|
+
resolved[key] = value
|
|
165
|
+
# unknown keys are kept rather than dropped; checks.py warns about them
|
|
166
|
+
for key, value in overrides.items():
|
|
167
|
+
resolved.setdefault(key, value)
|
|
168
|
+
return resolved
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def resolved(self) -> dict[str, Any]:
|
|
172
|
+
"""Every setting, resolved once and then cached until reset."""
|
|
173
|
+
# one read, kept local: a reset() between two reads would return None
|
|
174
|
+
cache = self._cache
|
|
175
|
+
if cache is None:
|
|
176
|
+
cache = self._cache = self._resolve()
|
|
177
|
+
return cache
|
|
178
|
+
|
|
179
|
+
def reset(self) -> None:
|
|
180
|
+
"""Drop the cache, so the next read picks up changed settings."""
|
|
181
|
+
self._cache = None
|
|
182
|
+
|
|
183
|
+
def __getitem__(self, key: str) -> Any: # noqa: ANN401 - a setting holds whatever the project put there
|
|
184
|
+
"""Return one resolved setting, resolving them all on the first ask."""
|
|
185
|
+
return self.resolved[key]
|
|
186
|
+
|
|
187
|
+
def __iter__(self) -> Iterator[str]:
|
|
188
|
+
"""Iterate over the resolved setting names."""
|
|
189
|
+
return iter(self.resolved)
|
|
190
|
+
|
|
191
|
+
def __len__(self) -> int:
|
|
192
|
+
"""Return how many settings are resolved."""
|
|
193
|
+
return len(self.resolved)
|
|
194
|
+
|
|
195
|
+
def __repr__(self) -> str:
|
|
196
|
+
"""Describe the cache without resolving it: repr() must stay cheap."""
|
|
197
|
+
state = 'unresolved' if self._cache is None else f'{len(self._cache)} keys'
|
|
198
|
+
return f'<{type(self).__name__} {state}>'
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
conf = Settings()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(frozen=True)
|
|
205
|
+
class PopCeiling:
|
|
206
|
+
"""How long a blocking pop may actually wait, and which settings decided that.
|
|
207
|
+
|
|
208
|
+
``bound_by`` is a tuple because the limits can tie: ``HEARTBEAT_INTERVAL`` at 9
|
|
209
|
+
beside ``REDIS_TIMEOUT`` at 10 both produce 9, and naming one of them sends an
|
|
210
|
+
operator to raise it and meet the same warning again, unchanged.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
seconds: int
|
|
214
|
+
bound_by: tuple[str, ...]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def blpop_ceiling() -> PopCeiling:
|
|
218
|
+
"""Return the real cap on a blocking pop, which is not ``BLPOP_TIMEOUT`` alone.
|
|
219
|
+
|
|
220
|
+
Two bounds are weighed here and the smallest wins: the ``HEARTBEAT_INTERVAL`` — a
|
|
221
|
+
worker that popped for longer than that would let its own heartbeat key expire and
|
|
222
|
+
look dead — and one second inside ``REDIS_TIMEOUT``, so the pop returns before the
|
|
223
|
+
read deadline fires. The configured ``BLPOP_TIMEOUT`` is the third, applied by the
|
|
224
|
+
caller against this ceiling, which is why ``bound_by`` can never name it.
|
|
225
|
+
|
|
226
|
+
One second inside ``REDIS_TIMEOUT`` is only possible from 2 upwards, which is what
|
|
227
|
+
``E030``'s floor is for: at 1 the subtraction clamps back to 1, the pop's timeout
|
|
228
|
+
equals the read deadline, and every idle pop raises instead of returning empty.
|
|
229
|
+
|
|
230
|
+
Lives here rather than beside the consumer because ``checks.py`` needs it too, and
|
|
231
|
+
importing :mod:`django_aiogram.consumer.delivery` would pull in aiogram through
|
|
232
|
+
:mod:`django_aiogram.api` — which is the whole reason ``manage.py check``
|
|
233
|
+
costs nothing.
|
|
234
|
+
|
|
235
|
+
``bound_by`` is what makes a hint actionable: told only that the pop is capped, an
|
|
236
|
+
operator raises ``REDIS_TIMEOUT`` when it was the heartbeat that bound it — and
|
|
237
|
+
when the two tie, raising either one alone changes nothing at all.
|
|
238
|
+
"""
|
|
239
|
+
limits = {
|
|
240
|
+
'HEARTBEAT_INTERVAL': max(1, int(conf['HEARTBEAT_INTERVAL'])),
|
|
241
|
+
'REDIS_TIMEOUT': max(1, max(1, int(conf['REDIS_TIMEOUT'])) - 1),
|
|
242
|
+
}
|
|
243
|
+
seconds = min(limits.values())
|
|
244
|
+
# every setting sitting at the minimum, not the first one found: a tie means both
|
|
245
|
+
# have to move, and a hint naming one of them is a round trip that achieves nothing
|
|
246
|
+
return PopCeiling(seconds=seconds, bound_by=tuple(key for key, value in limits.items() if value == seconds))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _reset_on_setting_change(
|
|
250
|
+
sender: object, # noqa: ARG001 - Django sends this to every receiver, named
|
|
251
|
+
setting: str,
|
|
252
|
+
**kwargs: Any,
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Drop the resolved cache when the setting it was built from changes.
|
|
255
|
+
|
|
256
|
+
This is what makes ``override_settings`` work on a lazily cached mapping, and it is
|
|
257
|
+
a receiver rather than a test helper because a project may legitimately change the
|
|
258
|
+
setting at runtime. Filtered on the name: every other setting in the project sends
|
|
259
|
+
this signal too.
|
|
260
|
+
"""
|
|
261
|
+
if setting == SETTINGS_NAME:
|
|
262
|
+
conf.reset()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# dispatch_uid keeps autoreload from stacking duplicate receivers
|
|
266
|
+
setting_changed.connect(_reset_on_setting_change, dispatch_uid='django_aiogram.config.settings')
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The receive side: what turns a queued message or an update into a call.
|
|
2
|
+
|
|
3
|
+
`delivery` consumes the broker and hands each message to a handler; `webhook` is the view
|
|
4
|
+
an update arrives at; `routers` finds the handler modules a project wrote.
|
|
5
|
+
|
|
6
|
+
The consumer talks to a transport only through `broker`. That boundary is why 4.0 can add a
|
|
7
|
+
transport without touching this package.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
#: deliberately empty: callers import from the modules in this package, not from the
|
|
11
|
+
#: package itself. A re-export here would make a second path to every name, and the one
|
|
12
|
+
#: nobody chose is the one that cannot be moved later
|
|
13
|
+
__all__: tuple[str, ...] = ()
|