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,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, ...] = ()