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
funora/bot/_runtime.py ADDED
@@ -0,0 +1,447 @@
1
+ """Рантайм бота: наблюдение и разбор очереди исходящих в одном потоке.
2
+
3
+ Здесь нет ни своего цикла, ни своей политики повторов, ни своего порядка шагов.
4
+ Всё это живёт в ядре и достаётся готовым: рантайм только подставляет разбор
5
+ очереди в паузу между опросами - через крючок on_idle у драйвера.
6
+
7
+ Одно правило, ради которого класс и написан: ПЛОЩАДКУ ТРОГАЕТ ОДИН ПОТОК.
8
+ Посторонний кладёт задание в очередь и ждёт квитанцию; отправляет тот же поток,
9
+ что ведёт наблюдение. Иначе ограничитель исходящих недосчитывает предел, и
10
+ узнаёте вы об этом от площадки.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from collections.abc import Callable
17
+ from contextlib import nullcontext
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, Final
20
+
21
+ from .._client import Client
22
+ from .._fileio import file_lock
23
+ from .._poll import Schedule
24
+ from .._watch import Router
25
+ from ..errors import (
26
+ ConfigurationError,
27
+ FunoraError,
28
+ HandlerError,
29
+ UsageError,
30
+ ValidationError,
31
+ )
32
+ from ._outbox import Outbox, SendCommand, SendTicket
33
+ from ._spool import Spool
34
+
35
+ if TYPE_CHECKING:
36
+ from ._delivery import AutoDelivery, DeliveryDecision, DeliveryPlan
37
+
38
+ __all__ = ["Bot", "MAX_SENDS_PER_IDLE"]
39
+
40
+ _log = logging.getLogger("funora.bot")
41
+
42
+ #: Сколько отправок разбирать за одну паузу.
43
+ #:
44
+ #: Предел нужен, и он не про вежливость. Одна отправка - это чтение страницы
45
+ #: диалога плюс сама отправка, а при неоднозначном ответе ещё и сверка: три
46
+ #: чтения переписки с паузами 1, 3 и 8 секунд. Три задания подряд в худшем
47
+ #: случае растягивают паузу на минуту, и всё это время наблюдение стоит.
48
+ MAX_SENDS_PER_IDLE: Final[int] = 3
49
+
50
+ #: Приставка ключа идемпотентности, которой автовыдача помечает свои задания.
51
+ #:
52
+ #: Единственный носитель связи между заданием в очереди и заказом: очередь про
53
+ #: заказы не знает и знать не должна, а реестру выдач нужно узнать, чем кончилась
54
+ #: отправка. Ключ чужого задания этой приставки не несёт и пропускается.
55
+ _DELIVERY_KEY_PREFIX: Final[str] = "delivery:"
56
+
57
+
58
+ class Bot:
59
+ """Наблюдение с очередью исходящих.
60
+
61
+ Args:
62
+ client (Client): Клиент. Рантайм становится его единственным
63
+ пользователем: звать операции клиента из других потоков нельзя.
64
+ router (Router): Реестр обработчиков событий.
65
+ max_sends_per_idle (int): Сколько отправок разбирать за одну паузу.
66
+ """
67
+
68
+ __slots__ = (
69
+ "_client",
70
+ "_router",
71
+ "_outbox",
72
+ "_spool",
73
+ "_limit",
74
+ "_sent",
75
+ "_refused",
76
+ )
77
+
78
+ def __init__(
79
+ self,
80
+ client: Client,
81
+ router: Router,
82
+ *,
83
+ max_sends_per_idle: int = MAX_SENDS_PER_IDLE,
84
+ spool_path: Path | str | None = None,
85
+ ) -> None:
86
+ # Ноль означал бы «не разбирать очередь никогда»: take(0) отдаёт
87
+ # пустой перечень, задания копятся, квитанции не закрываются, и
88
+ # положивший их ждёт вечно. Молча.
89
+ if max_sends_per_idle < 1:
90
+ raise ValidationError(
91
+ f"предел отправок за паузу {max_sends_per_idle} не годится: при "
92
+ "нём очередь не разбирается никогда, задания копятся, а "
93
+ "положивший их ждёт закрытия квитанции, которого не будет"
94
+ )
95
+
96
+ self._client = client
97
+ self._router = router
98
+ self._outbox = Outbox()
99
+
100
+ #: Очередь между ПРОЦЕССАМИ. None означает, что её не просили.
101
+ #:
102
+ #: Она не заменяет очередь в памяти, а дополняет её: та решает
103
+ #: задачу про потоки, эта - про процессы. Телеграм-бот, поднятый
104
+ #: отдельной командой, до очереди в памяти не дотягивается ничем.
105
+ self._spool = Spool(spool_path) if spool_path is not None else None
106
+ self._limit = max_sends_per_idle
107
+ self._sent = 0
108
+ self._refused = 0
109
+
110
+ @property
111
+ def spool(self) -> Spool | None:
112
+ """Очередь исходящих между процессами.
113
+
114
+ Returns:
115
+ Spool | None: Очередь либо None, если её не просили.
116
+ """
117
+ return self._spool
118
+
119
+ @property
120
+ def outbox(self) -> Outbox:
121
+ """Очередь исходящих.
122
+
123
+ Returns:
124
+ Outbox: Очередь, в которую можно класть из любого потока.
125
+ """
126
+ return self._outbox
127
+
128
+ @property
129
+ def sent(self) -> int:
130
+ """Сколько заданий отправлено с начала работы.
131
+
132
+ Returns:
133
+ int: Число отправок, дошедших до площадки.
134
+ """
135
+ return self._sent
136
+
137
+ @property
138
+ def refused(self) -> int:
139
+ """Сколько заданий отвергнуто отказом.
140
+
141
+ Returns:
142
+ int: Число заданий, на которых отправка не состоялась.
143
+ """
144
+ return self._refused
145
+
146
+ def send(
147
+ self,
148
+ chat_id: str,
149
+ text: str,
150
+ *,
151
+ idempotency_key: str,
152
+ declared_cold: bool = False,
153
+ ) -> SendTicket:
154
+ """Просит отправить сообщение.
155
+
156
+ Звать можно из любого потока - в этом весь смысл. Сама отправка
157
+ произойдёт в потоке наблюдения, в ближайшей паузе.
158
+
159
+ Args:
160
+ chat_id (str): Числовой идентификатор диалога.
161
+ text (str): Текст сообщения.
162
+ idempotency_key (str): Ключ, по которому повтор узнаётся повтором.
163
+ declared_cold (bool): Признание, что переписка холодная.
164
+
165
+ Returns:
166
+ SendTicket: Квитанция, по которой можно дождаться исхода.
167
+
168
+ Raises:
169
+ UsageError: Если очередь переполнена.
170
+ """
171
+ return self._outbox.put(
172
+ SendCommand(
173
+ chat_id=chat_id,
174
+ text=text,
175
+ idempotency_key=idempotency_key,
176
+ declared_cold=declared_cold,
177
+ )
178
+ )
179
+
180
+ def run(
181
+ self,
182
+ *,
183
+ account_id: str = "self",
184
+ max_iterations: int | None = None,
185
+ schedule: Schedule | None = None,
186
+ state_path: str | Path | None = None,
187
+ use_channel: bool = True,
188
+ max_threads_per_step: int = 5,
189
+ on_handler_error: Callable[[HandlerError], None] | None = None,
190
+ ) -> None:
191
+ """Ведёт наблюдение и разбирает очередь исходящих.
192
+
193
+ Метод блокирующий. Он и есть главный цикл бота.
194
+
195
+ Args:
196
+ account_id (str): Идентификатор аккаунта для отпечатков событий.
197
+ max_iterations (int | None): Сколько шагов сделать.
198
+ schedule (Schedule | None): Расписание опроса.
199
+ state_path (str | Path | None): Файл состояния.
200
+ use_channel (bool): Слушать ли канал обновлений площадки. По
201
+ умолчанию да: покупатель получает ответ за секунды, а не за
202
+ десятки секунд. Выключение возвращает прежний опрос страниц.
203
+ max_threads_per_step (int): Сколько переписок дочитывать за шаг.
204
+ on_handler_error (Callable[[HandlerError], None] | None): Что делать
205
+ с отказом обработчика.
206
+
207
+ Returns:
208
+ None
209
+
210
+ Raises:
211
+ FunoraError: Любая ошибка чтения, которую не удалось повторить.
212
+ """
213
+ worker = (
214
+ file_lock(self._spool.root / ".worker.lock", blocking=False)
215
+ if self._spool is not None
216
+ else nullcontext()
217
+ )
218
+ with worker:
219
+ self._outbox.claim()
220
+ try:
221
+ # Застрявшее разбирается ОДИН РАЗ, на старте, и до первого опроса.
222
+ # Задание, взятое умершим процессом, могло уйти на площадку и могло не
223
+ # уйти; повторять его нельзя, и молчать о нём нельзя тоже.
224
+ if self._spool is not None:
225
+ self._spool.recover()
226
+
227
+ self._client.run(
228
+ self._client.engine.watch(
229
+ self._router,
230
+ account_id=account_id,
231
+ max_iterations=max_iterations,
232
+ schedule=schedule,
233
+ state_path=state_path,
234
+ max_threads_per_step=max_threads_per_step,
235
+ use_channel=use_channel,
236
+ ),
237
+ router=self._router,
238
+ on_handler_error=on_handler_error,
239
+ on_idle=self._drain,
240
+ )
241
+ finally:
242
+ self._outbox.release()
243
+
244
+ def _note_delivery(self, idempotency_key: str, outcome: str) -> None:
245
+ """Проставляет реестру выдач настоящий исход отправки.
246
+
247
+ ЗАЧЕМ ЭТО ЗДЕСЬ. Автовыдача заводит запись исходом ``queued`` и уходит:
248
+ отправка случится позже и в другом месте. Прежде исход так и оставался
249
+ ``queued`` навсегда, и успешная выдача была неотличима от потерянной -
250
+ а найти потерянные не было способа вовсе.
251
+
252
+ Заказ узнаётся по ключу идемпотентности: автовыдача составляет его как
253
+ ``delivery:<заказ>``, и другого носителя связи между заданием и заказом
254
+ нет. Ключ чужого задания сюда просто не подходит и молча пропускается.
255
+
256
+ Запись не заводится, если её нет: см. DeliveryLedger.settle.
257
+
258
+ Args:
259
+ idempotency_key (str): Ключ задания.
260
+ outcome (str): Исход отправки либо имя отказа.
261
+
262
+ Returns:
263
+ None
264
+ """
265
+ if not idempotency_key.startswith(_DELIVERY_KEY_PREFIX):
266
+ return
267
+ order_id = idempotency_key[len(_DELIVERY_KEY_PREFIX) :]
268
+ engine = self._client.engine
269
+ engine.delivered.settle(order_id, outcome)
270
+ # Сохранение сразу: исход, оставшийся только в памяти, теряется тем же
271
+ # перезапуском, ради которого реестр и заведён долговечным.
272
+ engine.save_delivery()
273
+
274
+ def _drain(self, pause_ms: int) -> None:
275
+ """Разбирает очередь исходящих.
276
+
277
+ Вызывается драйвером в паузе между опросами и потому исполняется в
278
+ потоке наблюдения - том единственном, которому позволено трогать
279
+ площадку.
280
+
281
+ Отказ ОДНОГО задания не отменяет остальные и не роняет наблюдение: он
282
+ уходит в квитанцию, и ждать его будет тот, кто задание положил. Уронить
283
+ цикл из-за одного неудачного сообщения значило бы поставить наблюдение в
284
+ зависимость от чужой кнопки.
285
+
286
+ Args:
287
+ pause_ms (int): Длительность паузы в миллисекундах. Не используется:
288
+ предел ставится числом заданий, а не временем. Время предсказать
289
+ нельзя - сверка отправки сама спит до двенадцати секунд.
290
+
291
+ Returns:
292
+ None
293
+ """
294
+ left = self._limit
295
+ for _ in range(left):
296
+ batch = self._outbox.take(1)
297
+ if not batch:
298
+ break
299
+ ticket = batch[0]
300
+ left -= 1
301
+ command = ticket.command
302
+ try:
303
+ result = self._client.chats.send_text(
304
+ command.chat_id,
305
+ command.text,
306
+ declared_cold=command.declared_cold,
307
+ )
308
+ except FunoraError as exc:
309
+ self._refused += 1
310
+ _log.warning(
311
+ "задание %s не отправлено: %s",
312
+ command.idempotency_key,
313
+ type(exc).__name__,
314
+ )
315
+ ticket.settle(error=exc)
316
+ self._note_delivery(command.idempotency_key, type(exc).__name__)
317
+ continue
318
+ except Exception as exc:
319
+ ticket.settle(error=exc)
320
+ self._note_delivery(command.idempotency_key, "unexpected_error")
321
+ raise
322
+ self._sent += 1
323
+ ticket.settle(result=result)
324
+ self._note_delivery(command.idempotency_key, result.outcome.value)
325
+
326
+ # Каталог разбирается ПОСЛЕ памяти и в остаток того же предела. Предел
327
+ # общий нарочно: он бережёт не очередь, а паузу между опросами, и две
328
+ # очереди с собственными пределами вдвое удлинили бы её.
329
+ #
330
+ # Реестр выдач при этом уже поправлен: исход проставляется сразу за
331
+ # квитанцией, а не в конце разбора. Разбор может оборваться на середине,
332
+ # и заданиям, которые успели уйти, незачем оставаться неразобранными
333
+ # из-за тех, которые не успели.
334
+ if self._spool is None:
335
+ return
336
+
337
+ for _ in range(left):
338
+ entries = self._spool.take(1)
339
+ if not entries:
340
+ break
341
+ entry = entries[0]
342
+ command = entry.command
343
+ try:
344
+ result = self._client.chats.send_text(
345
+ command.chat_id,
346
+ command.text,
347
+ declared_cold=command.declared_cold,
348
+ )
349
+ except FunoraError as exc:
350
+ self._refused += 1
351
+ _log.warning(
352
+ "задание %s из каталога не отправлено: %s",
353
+ command.idempotency_key,
354
+ type(exc).__name__,
355
+ )
356
+ self._spool.settle(entry, state="refused", detail=type(exc).__name__)
357
+ continue
358
+ self._sent += 1
359
+ self._spool.settle(entry, state="sent", detail=result.outcome.value)
360
+
361
+ def deliveries(
362
+ self,
363
+ plan: DeliveryPlan,
364
+ on_hold: Callable[[DeliveryDecision], None] | None = None,
365
+ ) -> AutoDelivery:
366
+ """Собирает автовыдачу, связанную с файлом состояния клиента.
367
+
368
+ СОБИРАТЬ ЕЁ РУКАМИ НЕ НАДО, и это не вежливость. Реестр выданного
369
+ обязан переживать перезапуск: обнулившись, он выдаст товар второй раз
370
+ по заказу, который в списке продаж всё ещё оплачен. Связать его с
371
+ файлом состояния можно только через движок, который этот файл открыл.
372
+
373
+ Собранная руками автовыдача про файл не знает и знать не может, а
374
+ выглядит рабочей: первый прогон отдаёт товар, второй отдаёт его снова.
375
+
376
+ Args:
377
+ plan (DeliveryPlan): Что и кому выдавать.
378
+ on_hold (Callable[[DeliveryDecision], None] | None): Что делать с
379
+ заказом, который сам не выдаётся.
380
+
381
+ Returns:
382
+ AutoDelivery: Автовыдача с долговечным реестром.
383
+
384
+ Raises:
385
+ ConfigurationError: Если у клиента нет файла состояния. Реестр в
386
+ памяти здесь хуже отсутствия реестра: он выглядит защитой и ею
387
+ не является.
388
+ """
389
+ from ._delivery import AutoDelivery
390
+
391
+ engine = self._client.engine
392
+ if engine._ledger is None:
393
+ raise ConfigurationError(
394
+ "автовыдача без файла состояния невозможна: реестр выданного "
395
+ "обнулится при перезапуске, и товар уйдёт второй раз по заказу, "
396
+ "который в списке продаж всё ещё оплачен. Передайте клиенту "
397
+ "state_path"
398
+ )
399
+
400
+ return AutoDelivery(
401
+ plan,
402
+ engine.delivered,
403
+ lambda chat, text, key, cold: self.send(
404
+ chat, text, idempotency_key=key, declared_cold=cold
405
+ ),
406
+ on_hold=on_hold,
407
+ persist=engine.save_delivery,
408
+ )
409
+
410
+ def send_now(
411
+ self,
412
+ chat_id: str,
413
+ text: str,
414
+ *,
415
+ declared_cold: bool = False,
416
+ ) -> object:
417
+ """Отправляет сообщение немедленно, минуя очередь.
418
+
419
+ Звать МОЖНО ТОЛЬКО из потока наблюдения - из обработчика события. Вызов
420
+ из чужого потока отвергается вслух.
421
+
422
+ Отказ громкий, а не молчаливая гонка, потому что гонка здесь не роняет
423
+ ничего и не бросает исключений: она портит счёт ограничителя исходящих.
424
+ Проявится это превышением настоящего предела площадки, и объяснит вам
425
+ это площадка.
426
+
427
+ Args:
428
+ chat_id (str): Числовой идентификатор диалога.
429
+ text (str): Текст сообщения.
430
+ declared_cold (bool): Признание, что переписка холодная.
431
+
432
+ Returns:
433
+ object: Квитанция отправки - SendResult.
434
+
435
+ Raises:
436
+ UsageError: Если вызов идёт не из потока наблюдения.
437
+ FunoraError: Если отправка не состоялась.
438
+ """
439
+ if not self._outbox.is_owner():
440
+ raise UsageError(
441
+ "send_now зовут не из потока наблюдения. Клиент не защищён ни "
442
+ "одной блокировкой: у ограничителя исходящих проверка и запись "
443
+ "не атомарны, и второй поток недосчитывает предел - то есть "
444
+ "превышает настоящий предел площадки. Кладите задание в очередь "
445
+ "методом send: его разберёт тот же поток, что ведёт наблюдение"
446
+ )
447
+ return self._client.chats.send_text(chat_id, text, declared_cold=declared_cold)