funora 0.0.1.dev2__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 (87) hide show
  1. funora/__init__.py +265 -0
  2. funora/_account.py +661 -0
  3. funora/_aclient.py +1484 -0
  4. funora/_budget.py +579 -0
  5. funora/_calc.py +182 -0
  6. funora/_canonical.py +235 -0
  7. funora/_catalog.py +473 -0
  8. funora/_chat_history.py +367 -0
  9. funora/_chats.py +392 -0
  10. funora/_chips.py +403 -0
  11. funora/_classify.py +466 -0
  12. funora/_client.py +1490 -0
  13. funora/_currency_switch.py +130 -0
  14. funora/_cursor.py +120 -0
  15. funora/_delivered.py +233 -0
  16. funora/_diff.py +752 -0
  17. funora/_engine.py +4795 -0
  18. funora/_extract.py +124 -0
  19. funora/_field_schema.py +112 -0
  20. funora/_fileio.py +58 -0
  21. funora/_gate.py +69 -0
  22. funora/_hops.py +101 -0
  23. funora/_host.py +120 -0
  24. funora/_identity.py +284 -0
  25. funora/_json.py +31 -0
  26. funora/_listen.py +312 -0
  27. funora/_lot_form.py +331 -0
  28. funora/_market.py +406 -0
  29. funora/_matching.py +147 -0
  30. funora/_money.py +279 -0
  31. funora/_monitoring.py +396 -0
  32. funora/_observed.py +237 -0
  33. funora/_order.py +552 -0
  34. funora/_order_details.py +281 -0
  35. funora/_orders.py +807 -0
  36. funora/_outbound.py +453 -0
  37. funora/_own_lots.py +301 -0
  38. funora/_poll.py +410 -0
  39. funora/_price_audit.py +281 -0
  40. funora/_proxies.py +232 -0
  41. funora/_raise.py +150 -0
  42. funora/_refund.py +102 -0
  43. funora/_result.py +157 -0
  44. funora/_retry.py +213 -0
  45. funora/_review_write.py +138 -0
  46. funora/_reviews.py +584 -0
  47. funora/_runner.py +651 -0
  48. funora/_secret.py +385 -0
  49. funora/_showcase.py +362 -0
  50. funora/_signals.py +375 -0
  51. funora/_skeleton.py +752 -0
  52. funora/_snapshot.py +276 -0
  53. funora/_state.py +279 -0
  54. funora/_stock.py +32 -0
  55. funora/_thread.py +574 -0
  56. funora/_transport.py +1023 -0
  57. funora/_updates.py +292 -0
  58. funora/_verdicts.py +91 -0
  59. funora/_viewing.py +143 -0
  60. funora/_watch.py +825 -0
  61. funora/_watch_state.py +237 -0
  62. funora/_whoami.py +546 -0
  63. funora/bot/__init__.py +52 -0
  64. funora/bot/_delivery.py +341 -0
  65. funora/bot/_outbox.py +261 -0
  66. funora/bot/_runtime.py +447 -0
  67. funora/bot/_spool.py +534 -0
  68. funora/budget.py +311 -0
  69. funora/capabilities.py +297 -0
  70. funora/conformance.py +758 -0
  71. funora/contract.py +73 -0
  72. funora/errors.py +996 -0
  73. funora/events.py +189 -0
  74. funora/extraction.py +420 -0
  75. funora/observe.py +560 -0
  76. funora/operations.py +671 -0
  77. funora/py.typed +0 -0
  78. funora/reconciliation.py +51 -0
  79. funora/response_classes.py +155 -0
  80. funora/retry.py +238 -0
  81. funora/send_outcome.py +78 -0
  82. funora/skeleton_format.py +77 -0
  83. funora-0.0.1.dev2.dist-info/METADATA +294 -0
  84. funora-0.0.1.dev2.dist-info/RECORD +87 -0
  85. funora-0.0.1.dev2.dist-info/WHEEL +4 -0
  86. funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
  87. funora-0.0.1.dev2.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,341 @@
1
+ """Автовыдача товара: от события о заказе до сообщения покупателю.
2
+
3
+ ЧЕРТА, ВОКРУГ КОТОРОЙ ВСЁ УСТРОЕНО. Выдача идёт сама только там, где ВСЕ условия
4
+ выполнены положительно. Любое незнание - не повод действовать осторожнее, а
5
+ повод не действовать: вопрос уходит человеку.
6
+
7
+ Условий пять, и каждое отвечает на свой вопрос:
8
+
9
+ 1. Состояние заказа прочитано и равно «оплачен». Не «не закрыт»: состояний
10
+ известно два из скольких-то, и заказ в возврате даёт ненаблюдённое значение.
11
+ Правило «если не закрыт, значит ждёт выдачи» на нём и ломается.
12
+ 2. По этому заказу ещё не выдавали. Реестр выдач переживает перезапуск.
13
+ 3. Чтение списка продаж полно. Неполное чтение означает, что часть строк не
14
+ разобрана, а не что их нет.
15
+ 4. Заказ сопоставлен с ОДНИМ собственным лотом. Двусмысленность - отказ.
16
+ 5. Для этого лота задан товар.
17
+
18
+ Что здесь СОЗНАТЕЛЬНО не делается: выдача не отменяется. Состояние возврата не
19
+ наблюдалось ни разу, отменять нечем, и механизм односторонний. Продавец, которому
20
+ это важно, обязан знать заранее.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from collections.abc import Callable
27
+ from dataclasses import dataclass
28
+ from datetime import UTC, datetime
29
+ from typing import Final
30
+
31
+ from .._delivered import QUEUED_OUTCOME, Delivery, DeliveryLedger
32
+ from .._matching import match_offer
33
+ from .._orders import OrderListEntry
34
+ from .._own_lots import OwnLot
35
+ from .._result import Completeness
36
+ from ..extraction import OrderStatus
37
+ from ._outbox import SendTicket
38
+
39
+ __all__ = ["DeliveryPlan", "DeliveryDecision", "AutoDelivery", "HOLD_FOR_OPERATOR"]
40
+
41
+ _log = logging.getLogger("funora.bot.delivery")
42
+
43
+ #: Решение: выдать нечего, вопрос уходит человеку.
44
+ HOLD_FOR_OPERATOR: Final[str] = "hold_for_operator"
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class DeliveryDecision:
49
+ """Что решено по заказу.
50
+
51
+ Attributes:
52
+ order_id (str): Заказ.
53
+ will_deliver (bool): Выдаётся ли сам.
54
+ reason (str): Машиночитаемая причина решения.
55
+ offer_id (str): Опознанное предложение. Пустая строка, если не
56
+ опознано.
57
+ candidates (tuple[str, ...]): Подошедшие предложения. Больше одного
58
+ означает, что различить их нечем.
59
+ """
60
+
61
+ order_id: str
62
+ will_deliver: bool
63
+ reason: str
64
+ offer_id: str = ""
65
+ candidates: tuple[str, ...] = ()
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class DeliveryPlan:
70
+ """Что и кому выдавать.
71
+
72
+ Attributes:
73
+ goods (dict[str, str]): Товар по идентификатору предложения. Что именно
74
+ отправить покупателю, когда заказ опознан.
75
+ chat_of (Callable[[str], str]): Как узнать узел диалога по заказу.
76
+ Отдельной функцией, а не полем: узел лежит на странице заказа, идти
77
+ за ним - запрос, и делать этот запрос стоит только тогда, когда всё
78
+ остальное сошлось.
79
+ declared_cold (bool): Объявлять ли обращение холодным.
80
+
81
+ ПО УМОЛЧАНИЮ ДА, и это не послабление, а исправление. Холодной
82
+ считается переписка, в которой собеседник нам не писал, а покупатель,
83
+ купивший и промолчавший, - самый обычный случай, а не редкий.
84
+ Автовыдача прежде звала отправку без этого признака, ограничитель
85
+ отвергал её с cold_outreach_not_declared, и товар не уходил ровно
86
+ тому, кто вёл себя тише всех. Заказ при этом уже был помечен
87
+ выданным.
88
+
89
+ Признание здесь честное: обращение вправду холодное. Покупка
90
+ переписку не греет - это записано в spec/runtime/budget.yaml и
91
+ остаётся верным. Но выдача оплаченного товара и есть тот случай, ради
92
+ которого квота холодных обращений существует.
93
+
94
+ ПОМНИТЕ ПРО ПОТОЛОК: холодных обращений три в сутки. Продавцу, у
95
+ которого продаж больше, придётся либо здороваться с покупателем
96
+ первым по событию о заказе - тогда переписка станет тёплой его
97
+ ответом, - либо выдавать руками. Молча упереться в потолок нельзя:
98
+ отправка откажет вслух, и заказ уйдёт в on_hold.
99
+ """
100
+
101
+ goods: dict[str, str]
102
+ chat_of: Callable[[str], str]
103
+ declared_cold: bool = True
104
+
105
+
106
+ class AutoDelivery:
107
+ """Автовыдача: решает по заказу и кладёт задание в очередь.
108
+
109
+ Сама ничего не отправляет и в сеть не ходит. Отправку выполняет очередь
110
+ исходящих, а значит - тот же поток, что ведёт наблюдение, и с теми же
111
+ пределами.
112
+
113
+ Args:
114
+ plan (DeliveryPlan): Что и кому выдавать.
115
+ ledger (DeliveryLedger): Реестр уже выданного.
116
+ send (Callable[[str, str, str, bool], SendTicket]): Как поставить
117
+ отправку в очередь: узел, текст, ключ идемпотентности, признание
118
+ холодного обращения.
119
+ on_hold (Callable[[DeliveryDecision], None] | None): Что делать с
120
+ заказом, который сам не выдаётся. Без него такой заказ виден только
121
+ в журнале.
122
+ persist (Callable[[], None] | None): Как сохранить реестр на диск.
123
+
124
+ Без него реестр живёт в памяти процесса, и каждый перезапуск
125
+ обнуляет память о выданном: заказ, который в списке продаж всё ещё
126
+ оплачен, выдаётся второй раз.
127
+
128
+ Собирать автовыдачу руками поэтому не стоит - есть Bot.deliveries(),
129
+ который связывает всё правильно по построению.
130
+ """
131
+
132
+ __slots__ = ("_plan", "_ledger", "_send", "_on_hold", "_persist", "_decisions")
133
+
134
+ def __init__(
135
+ self,
136
+ plan: DeliveryPlan,
137
+ ledger: DeliveryLedger,
138
+ send: Callable[[str, str, str, bool], SendTicket],
139
+ on_hold: Callable[[DeliveryDecision], None] | None = None,
140
+ persist: Callable[[], None] | None = None,
141
+ ) -> None:
142
+ self._plan = plan
143
+ self._ledger = ledger
144
+ self._send = send
145
+ self._on_hold = on_hold
146
+ self._persist = persist
147
+ self._decisions: list[DeliveryDecision] = []
148
+
149
+ @property
150
+ def decisions(self) -> tuple[DeliveryDecision, ...]:
151
+ """Решения, принятые с начала работы.
152
+
153
+ Returns:
154
+ tuple[DeliveryDecision, ...]: Все решения по порядку.
155
+ """
156
+ return tuple(self._decisions)
157
+
158
+ def decide(
159
+ self,
160
+ order: OrderListEntry,
161
+ lots: tuple[OwnLot, ...],
162
+ *,
163
+ page_completeness: Completeness,
164
+ ) -> DeliveryDecision:
165
+ """Решает, выдавать ли по заказу, и НИЧЕГО не отправляет.
166
+
167
+ Метод чистый нарочно: решение проверяется без сети и без очереди, а
168
+ решение здесь - самое дорогое место всего механизма.
169
+
170
+ Args:
171
+ order (OrderListEntry): Строка списка продаж.
172
+ lots (tuple[OwnLot, ...]): Собственные лоты раздела.
173
+ page_completeness (Completeness): Полнота чтения списка продаж.
174
+
175
+ Returns:
176
+ DeliveryDecision: Что решено и почему.
177
+ """
178
+ decision = self._decide(order, lots, page_completeness=page_completeness)
179
+ self._decisions.append(decision)
180
+ return decision
181
+
182
+ def _decide(
183
+ self,
184
+ order: OrderListEntry,
185
+ lots: tuple[OwnLot, ...],
186
+ *,
187
+ page_completeness: Completeness,
188
+ ) -> DeliveryDecision:
189
+ """Проверяет пять условий по порядку.
190
+
191
+ Порядок дешёвых проверок вперёд: сперва то, что не требует разбора
192
+ текста.
193
+
194
+ Args:
195
+ order (OrderListEntry): Строка списка продаж.
196
+ lots (tuple[OwnLot, ...]): Собственные лоты раздела.
197
+ page_completeness (Completeness): Полнота чтения списка продаж.
198
+
199
+ Returns:
200
+ DeliveryDecision: Что решено и почему.
201
+ """
202
+ if self._ledger.seen(order.order_id):
203
+ return DeliveryDecision(order.order_id, False, "already_delivered")
204
+
205
+ if page_completeness is not Completeness.COMPLETE:
206
+ # Неполное чтение означает, что часть строк не разобрана. Выдавать
207
+ # по такому списку значило бы решать по данным, которых нет.
208
+ return DeliveryDecision(order.order_id, False, "orders_page_incomplete")
209
+
210
+ if not order.status.is_observed:
211
+ # Состояние не прочитано. Это НЕ «ещё не закрыт»: заказ в возврате
212
+ # или споре даёт ровно такое значение.
213
+ return DeliveryDecision(order.order_id, False, "status_not_observed")
214
+
215
+ if order.status.value is not OrderStatus.PAID:
216
+ return DeliveryDecision(order.order_id, False, "status_not_paid")
217
+
218
+ matched = match_offer(
219
+ order.description_text,
220
+ {one.offer_id.value: one.description_text for one in lots if one.offer_id.is_observed},
221
+ )
222
+ if not matched.offer_id.is_observed:
223
+ return DeliveryDecision(
224
+ order.order_id,
225
+ False,
226
+ str(matched.offer_id.reason),
227
+ candidates=matched.candidates,
228
+ )
229
+
230
+ offer_id = matched.offer_id.value
231
+ if offer_id not in self._plan.goods:
232
+ return DeliveryDecision(order.order_id, False, "no_goods_for_offer", offer_id=offer_id)
233
+
234
+ return DeliveryDecision(
235
+ order.order_id,
236
+ True,
237
+ "ready",
238
+ offer_id=offer_id,
239
+ candidates=matched.candidates,
240
+ )
241
+
242
+ def handle(
243
+ self,
244
+ order: OrderListEntry,
245
+ lots: tuple[OwnLot, ...],
246
+ *,
247
+ page_completeness: Completeness,
248
+ ) -> SendTicket | None:
249
+ """Решает и, если можно, ставит выдачу в очередь.
250
+
251
+ Запись в реестр идёт ВПЕРЕДИ постановки в очередь. Порядок тот же и по
252
+ той же причине, что у реестра отправок: «запишем, когда подтвердится»
253
+ означает не записать ровно те выдачи, которые могли уйти.
254
+
255
+ Args:
256
+ order (OrderListEntry): Строка списка продаж.
257
+ lots (tuple[OwnLot, ...]): Собственные лоты раздела.
258
+ page_completeness (Completeness): Полнота чтения списка продаж.
259
+
260
+ Returns:
261
+ SendTicket | None: Квитанция отправки либо None, если выдача не
262
+ состоялась.
263
+ """
264
+ decision = self.decide(order, lots, page_completeness=page_completeness)
265
+ if not decision.will_deliver:
266
+ _log.info(
267
+ "заказ %s не выдаётся сам: %s%s",
268
+ decision.order_id,
269
+ decision.reason,
270
+ f", кандидаты {list(decision.candidates)}" if decision.candidates else "",
271
+ )
272
+ if self._on_hold is not None:
273
+ self._on_hold(decision)
274
+ return None
275
+
276
+ # УЗЕЛ ДИАЛОГА БЕРЁТСЯ ДО ЗАПИСИ В РЕЕСТР, и порядок здесь важнее, чем
277
+ # кажется. chat_of - это СЕТЕВОЕ чтение страницы заказа, а не поле:
278
+ # именно поэтому оно и сделано функцией.
279
+ #
280
+ # Прежде запись стояла впереди него, и всякий отказ этого чтения -
281
+ # оборванная сессия, исчерпанный бюджет, изменившаяся вёрстка - оставлял
282
+ # заказ НАВСЕГДА помеченным выданным при нуле отправок. Повторно он не
283
+ # выдаётся никогда, а on_hold не срабатывает: решение-то было
284
+ # положительным.
285
+ #
286
+ # Что довод «пишем впереди отправки» защищает - это окно между записью и
287
+ # ОТПРАВКОЙ. Сетевое чтение адреса в это окно не входит: оно происходит
288
+ # до того, как принято решение действовать, и его отказ означает «мы ещё
289
+ # ничего не сделали», а не «мы, возможно, уже отправили».
290
+ try:
291
+ chat_id = self._plan.chat_of(decision.order_id)
292
+ except Exception as exc:
293
+ _log.warning(
294
+ "заказ %s: узел диалога не прочитан (%s), выдача не состоялась и в реестр "
295
+ "не записана",
296
+ decision.order_id,
297
+ type(exc).__name__,
298
+ )
299
+ if self._on_hold is not None:
300
+ self._on_hold(
301
+ DeliveryDecision(
302
+ decision.order_id,
303
+ False,
304
+ "chat_not_read",
305
+ offer_id=decision.offer_id,
306
+ )
307
+ )
308
+ return None
309
+
310
+ self._ledger.record(
311
+ Delivery(
312
+ order_id=decision.order_id,
313
+ offer_id=decision.offer_id,
314
+ at_ms=int(datetime.now(UTC).timestamp() * 1000),
315
+ outcome=QUEUED_OUTCOME,
316
+ )
317
+ )
318
+ # Сохранение НА ДИСК тоже впереди отправки, и по тому же доводу:
319
+ # перезапуск между постановкой в очередь и записью выдал бы товар
320
+ # второй раз.
321
+ if self._persist is not None:
322
+ self._persist()
323
+
324
+ try:
325
+ return self._send(
326
+ chat_id,
327
+ self._plan.goods[decision.offer_id],
328
+ f"delivery:{decision.order_id}",
329
+ self._plan.declared_cold,
330
+ )
331
+ except Exception:
332
+ self._ledger.settle(decision.order_id, "queue_failed")
333
+ if self._persist is not None:
334
+ self._persist()
335
+ if self._on_hold is not None:
336
+ self._on_hold(
337
+ DeliveryDecision(
338
+ decision.order_id, False, "queue_failed", offer_id=decision.offer_id
339
+ )
340
+ )
341
+ raise
funora/bot/_outbox.py ADDED
@@ -0,0 +1,261 @@
1
+ """Очередь исходящих: как попросить об отправке из чужого потока.
2
+
3
+ ЗАЧЕМ ОНА ВООБЩЕ НУЖНА. Клиент не защищён ни одной блокировкой. Бюджет,
4
+ ограничитель исходящих и состояние сессии - обычные изменяемые объекты, и у
5
+ двух из них проверка с последующей записью не атомарна: ограничитель сперва
6
+ спрашивает `check`, потом пишет `record`, а бюджет сперва резервирует, потом
7
+ списывает.
8
+
9
+ Второй поток, зовущий отправку напрямую, эти пары разрывает. Проявится это не
10
+ отказом и не исключением, а НЕДОСЧЁТОМ: ограничитель решит, что за час ушло
11
+ меньше сообщений, чем ушло на самом деле. Узнаете вы об этом от площадки.
12
+
13
+ Поэтому здесь заведён единственный порядок, при котором посторонний поток может
14
+ попросить об отправке: положить задание в очередь. Разбирает её тот же поток,
15
+ что ведёт наблюдение, - в паузе между опросами.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import threading
21
+ from dataclasses import dataclass, field
22
+ from queue import Empty, Full, Queue
23
+ from typing import Final
24
+
25
+ from .._runner import SendResult
26
+
27
+ __all__ = ["SendCommand", "SendTicket", "Outbox"]
28
+
29
+ #: Сколько заданий очередь принимает, прежде чем отказывать.
30
+ #:
31
+ #: Предел нужен: телеграм-бот, у которого что-то заклинило, наполняет очередь
32
+ #: быстрее, чем наблюдение её вычерпывает - три отправки за шаг против
33
+ #: неограниченного числа нажатий. Очередь без предела съела бы память, а
34
+ #: сообщения всё равно ушли бы с опозданием на часы.
35
+ MAX_PENDING: Final[int] = 256
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class SendCommand:
40
+ """Просьба отправить сообщение.
41
+
42
+ Attributes:
43
+ chat_id (str): Числовой идентификатор диалога.
44
+ text (str): Текст сообщения.
45
+ idempotency_key (str): Ключ, по которому повтор узнаётся повтором.
46
+ Задание с уже виденным ключом не отправляется второй раз.
47
+
48
+ Обязателен НАРОЧНО, умолчания у него нет. Отправка необратима:
49
+ лишнее сообщение покупателю не отменить, а перезапуск процесса,
50
+ повтор события и нажатие кнопки дважды - обычные вещи. Ключ, который
51
+ вызывающий обязан придумать сам, заставляет его хотя бы раз подумать
52
+ о том, что считается тем же самым сообщением.
53
+ declared_cold (bool): Признание, что переписка холодная и вы пишете
54
+ первым.
55
+ """
56
+
57
+ chat_id: str
58
+ text: str
59
+ idempotency_key: str
60
+ declared_cold: bool = False
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class SendTicket:
65
+ """Квитанция о принятом задании: чем оно кончилось.
66
+
67
+ Ждать её можно из любого потока. Отправка при этом происходит не здесь, а в
68
+ потоке наблюдения.
69
+
70
+ Attributes:
71
+ command (SendCommand): Задание, о котором квитанция.
72
+ """
73
+
74
+ command: SendCommand
75
+ _done: threading.Event = field(default_factory=threading.Event)
76
+ _result: SendResult | None = None
77
+ _error: Exception | None = None
78
+ _duplicate: bool = False
79
+
80
+ @property
81
+ def duplicate(self) -> bool:
82
+ """Сообщает, что задание отброшено как повтор.
83
+
84
+ Returns:
85
+ bool: True, если ключ идемпотентности уже встречался.
86
+ """
87
+ return self._duplicate
88
+
89
+ def settle(
90
+ self,
91
+ *,
92
+ result: SendResult | None = None,
93
+ error: Exception | None = None,
94
+ duplicate: bool = False,
95
+ ) -> None:
96
+ """Закрывает квитанцию.
97
+
98
+ Args:
99
+ result (SendResult | None): Исход отправки.
100
+ error (FunoraError | None): Отказ, если отправка не состоялась.
101
+ duplicate (bool): Отброшено ли задание как повтор.
102
+
103
+ Returns:
104
+ None
105
+ """
106
+ self._result = result
107
+ self._error = error
108
+ self._duplicate = duplicate
109
+ self._done.set()
110
+
111
+ def wait(self, timeout: float | None = None) -> SendResult | None:
112
+ """Ждёт исхода задания.
113
+
114
+ Args:
115
+ timeout (float | None): Сколько секунд ждать. None означает без
116
+ предела.
117
+
118
+ Returns:
119
+ SendResult | None: Исход отправки. None означает, что задание
120
+ отброшено как повтор либо ожидание истекло, - различить это можно
121
+ признаками `duplicate` и `ready`.
122
+
123
+ Raises:
124
+ FunoraError: Тот самый отказ, что случился в потоке наблюдения.
125
+ Бросается ЗДЕСЬ, в потоке вызывающего: иначе он о нём не узнал
126
+ бы вовсе - в потоке наблюдения его некому ловить.
127
+ """
128
+ self._done.wait(timeout)
129
+ if self._error is not None:
130
+ raise self._error
131
+ return self._result
132
+
133
+ @property
134
+ def ready(self) -> bool:
135
+ """Сообщает, закрыта ли квитанция.
136
+
137
+ Returns:
138
+ bool: True, если задание уже отработано.
139
+ """
140
+ return self._done.is_set()
141
+
142
+
143
+ class Outbox:
144
+ """Очередь исходящих сообщений.
145
+
146
+ Класть задания можно из любого потока. Разбирает очередь тот, кто ведёт
147
+ наблюдение, и только он.
148
+
149
+ Args:
150
+ max_pending (int): Сколько заданий держать, прежде чем отказывать.
151
+ """
152
+
153
+ __slots__ = ("_queue", "_seen", "_lock", "_owner")
154
+
155
+ def __init__(self, max_pending: int = MAX_PENDING) -> None:
156
+ # Ноль и отрицательное у очереди стандартной библиотеки означают БЕЗ
157
+ # ПРЕДЕЛА - ровно наоборот тому, что читается в имени. Единственный
158
+ # предел, объявленный здесь обязательным, снимался бы значением,
159
+ # которое выглядит как «не принимать ничего».
160
+ if max_pending < 1:
161
+ from ..errors import ValidationError
162
+
163
+ raise ValidationError(
164
+ f"предел очереди исходящих {max_pending} не годится: очередь "
165
+ "стандартной библиотеки понимает ноль и отрицательное как "
166
+ "«без предела», то есть снимает защиту, а не ужесточает её"
167
+ )
168
+ self._queue: Queue[SendTicket] = Queue(maxsize=max_pending)
169
+ # Ключи, которые уже отработаны. Множество растёт, и это осознанно:
170
+ # забыть ключ значит разрешить повтор, а повтор здесь - второе сообщение
171
+ # покупателю. Память тут дешевле.
172
+ self._seen: set[str] = set()
173
+ self._lock = threading.Lock()
174
+ self._owner: int | None = None
175
+
176
+ def put(self, command: SendCommand) -> SendTicket:
177
+ """Кладёт задание в очередь.
178
+
179
+ Звать можно из любого потока.
180
+
181
+ Args:
182
+ command (SendCommand): Просьба отправить сообщение.
183
+
184
+ Returns:
185
+ SendTicket: Квитанция, по которой можно дождаться исхода.
186
+
187
+ Raises:
188
+ UsageError: Если очередь переполнена.
189
+ """
190
+ from ..errors import UsageError
191
+
192
+ ticket = SendTicket(command=command)
193
+
194
+ with self._lock:
195
+ if command.idempotency_key in self._seen:
196
+ # Повтор узнаётся ДО очереди: иначе он занял бы место и дождался
197
+ # бы своей отбраковки только у исполнителя.
198
+ ticket.settle(duplicate=True)
199
+ return ticket
200
+ try:
201
+ self._queue.put_nowait(ticket)
202
+ except Full as exc:
203
+ raise UsageError(
204
+ f"очередь исходящих переполнена: {self._queue.maxsize} заданий ждут отправки"
205
+ ) from exc
206
+ self._seen.add(command.idempotency_key)
207
+
208
+ return ticket
209
+
210
+ def take(self, limit: int) -> list[SendTicket]:
211
+ """Забирает из очереди до указанного числа заданий.
212
+
213
+ Args:
214
+ limit (int): Сколько заданий забрать.
215
+
216
+ Returns:
217
+ list[SendTicket]: Взятые задания в порядке поступления.
218
+ """
219
+ taken: list[SendTicket] = []
220
+ for _ in range(max(0, limit)):
221
+ try:
222
+ taken.append(self._queue.get_nowait())
223
+ except Empty:
224
+ break
225
+ return taken
226
+
227
+ def claim(self) -> None:
228
+ """Запоминает поток, который разбирает очередь.
229
+
230
+ Returns:
231
+ None
232
+ """
233
+ from ..errors import UsageError
234
+
235
+ with self._lock:
236
+ if self._owner is not None:
237
+ raise UsageError("очередь уже принадлежит запущенному циклу бота")
238
+ self._owner = threading.get_ident()
239
+
240
+ def release(self) -> None:
241
+ """Снимает владение после выхода из цикла."""
242
+ with self._lock:
243
+ if self._owner == threading.get_ident():
244
+ self._owner = None
245
+
246
+ def is_owner(self) -> bool:
247
+ """Сообщает, тот ли это поток, что разбирает очередь.
248
+
249
+ Returns:
250
+ bool: True, если вызов идёт из потока наблюдения.
251
+ """
252
+ return self._owner is not None and self._owner == threading.get_ident()
253
+
254
+ @property
255
+ def pending(self) -> int:
256
+ """Сколько заданий ждёт отправки.
257
+
258
+ Returns:
259
+ int: Длина очереди.
260
+ """
261
+ return self._queue.qsize()