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/_poll.py ADDED
@@ -0,0 +1,410 @@
1
+ """Расписание опроса и гашение повторов.
2
+
3
+ Оба механизма чистые и времени не знают: момент приходит числом. Расписание,
4
+ смотрящее на часы, проверяется настоящими минутами, а такая проверка живёт до
5
+ первого раза, когда она мешает.
6
+
7
+ Расписание отвечает на вопрос «через сколько спросить снова». Оно растёт в покое
8
+ и сбрасывается при событии данных. Управляющие события активностью не считаются:
9
+ иначе сообщение о деградации само себя продлевало бы, и клиент опрашивал бы
10
+ площадку часто именно потому, что у него что-то сломалось.
11
+
12
+ Нижний предел интервала обычной настройкой не понижается. Это единственное
13
+ число, которое защищает площадку от слишком уверенного пользователя, а аккаунт
14
+ пользователя - от него самого. Понизить его можно только именованным аргументом
15
+ с приставкой unsafe_, и факт понижения виден снаружи.
16
+
17
+ Гашение повторов работает в пределах ключа упорядочивания, а не глобально.
18
+ Глобальный кэш склеивал бы события разных диалогов при совпадении отпечатка, а
19
+ разъехавшиеся события двух аккаунтов гасили бы друг друга.
20
+
21
+ Проверка и запись разделены намеренно, и это не удобство вызова. Записав событие
22
+ в момент проверки, мы объявили бы его доставленным до того, как обработчик его
23
+ увидел. Обработчик падает - база не сдвигается, событие приходит снова и гасится
24
+ как повтор. Оно исчезает навсегда, причём именно то, которое обработчику не
25
+ далось, то есть самое важное. Запись выполняется после обработчиков, тем же
26
+ правилом, что и продвижение курсора.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from collections import OrderedDict
32
+ from dataclasses import dataclass, field
33
+ from time import time
34
+ from typing import Final
35
+
36
+ from ._diff import Event
37
+ from .budget import SCHEDULING, Scheduling
38
+ from .errors import StateSchemaIncompatibleError
39
+ from .events import DEDUP_TTL_MS, EVENT_LANE, MIN_ENTRIES_PER_KEY
40
+
41
+ __all__ = ["Schedule", "Deduplicator", "UNSAFE_FLOOR_MARK"]
42
+
43
+ #: Пометка, которой отмечается понижение нижнего предела интервала.
44
+ #:
45
+ #: Появляется в состоянии клиента и в журнале, чтобы при разборе блокировки было
46
+ #: видно: опрос шёл чаще, чем позволяет спецификация, и это сделали намеренно.
47
+ UNSAFE_FLOOR_MARK: Final[str] = "unsafe_interval_floor_lowered"
48
+
49
+
50
+ #: Полоса событий о самом наблюдении.
51
+ #:
52
+ #: Имя взято из спецификации, где полоса объявлена вместе с правилом: события о
53
+ #: состоянии системы не выбрасываются никогда, потому что выбросить сообщение о
54
+ #: потере событий значит потерять и сам факт потери.
55
+ _CONTROL_PLANE: Final[str] = "control_plane"
56
+
57
+
58
+ def _is_data(event: Event) -> bool:
59
+ """Сообщает, несёт ли событие данные площадки.
60
+
61
+ Полоса объявлена спецификацией, а не выведена здесь по именам: перечень
62
+ имён разошёлся бы с контрактом молча, а полоса решает ещё и то, можно ли
63
+ выбросить событие при переполнении.
64
+
65
+ Args:
66
+ event (Event): Событие.
67
+
68
+ Returns:
69
+ bool: True, если событие о площадке, а не о самом наблюдении.
70
+ """
71
+ return EVENT_LANE.get(event.type) != _CONTROL_PLANE
72
+
73
+
74
+ @dataclass
75
+ class Schedule:
76
+ """Адаптивный интервал опроса.
77
+
78
+ Args:
79
+ numbers (Scheduling): Числа расписания. По умолчанию из спецификации.
80
+ unsafe_floor_ms (int | None): Понижённый нижний предел. Обычной
81
+ настройкой не задаётся: приставка unsafe_ в имени намеренная, а сам
82
+ факт понижения отмечается в :attr:`marks`.
83
+ """
84
+
85
+ numbers: Scheduling = SCHEDULING
86
+ unsafe_floor_ms: int | None = None
87
+ marks: set[str] = field(default_factory=set)
88
+ _interval_ms: int = 0
89
+ _last_data_event_at: float | None = None
90
+
91
+ def __post_init__(self) -> None:
92
+ """Задаёт начальный интервал и отмечает небезопасное понижение.
93
+
94
+ Returns:
95
+ None
96
+ """
97
+ self._interval_ms = self.numbers.active_interval_ms
98
+ if self.unsafe_floor_ms is not None:
99
+ self.marks.add(UNSAFE_FLOOR_MARK)
100
+
101
+ @property
102
+ def floor_ms(self) -> int:
103
+ """Возвращает действующий нижний предел интервала.
104
+
105
+ Returns:
106
+ int: Предел в миллисекундах.
107
+ """
108
+ if self.unsafe_floor_ms is None:
109
+ return self.numbers.min_floor_ms
110
+ return max(1, self.unsafe_floor_ms)
111
+
112
+ @property
113
+ def interval_ms(self) -> int:
114
+ """Возвращает текущий интервал опроса.
115
+
116
+ Returns:
117
+ int: Интервал в миллисекундах, не ниже действующего предела.
118
+ """
119
+ return max(self.floor_ms, min(self._interval_ms, self.numbers.max_interval_ms))
120
+
121
+ def is_active(self, now: float) -> bool:
122
+ """Сообщает, считается ли аккаунт активным.
123
+
124
+ Args:
125
+ now (float): Текущий момент, монотонные секунды.
126
+
127
+ Returns:
128
+ bool: True, если событие данных наблюдалось внутри окна активности.
129
+ """
130
+ if self._last_data_event_at is None:
131
+ return False
132
+ elapsed_ms = (now - self._last_data_event_at) * 1000
133
+ return elapsed_ms <= self.numbers.activity_window_ms
134
+
135
+ def note(self, events: tuple[Event, ...], now: float) -> int:
136
+ """Учитывает итог опроса и возвращает интервал до следующего.
137
+
138
+ Признаком активности считаются только события о данных. События о
139
+ самом наблюдении - приветствие, жалоба на неполноту, сообщение о потере
140
+ - данными не являются, и прежде считались наравне с ними.
141
+
142
+ Стоило это дорого и молча. Страница, разбирающаяся неполно, порождает
143
+ жалобу на каждом шаге; жалоба считалась активностью, интервал не рос, и
144
+ клиент стучался в площадку с минимальным интервалом бесконечно - из-за
145
+ собственного состояния, а не из-за чужого. Это ровно тот путь, которым
146
+ приходят к ограничению частоты.
147
+
148
+ Args:
149
+ events (tuple[Event, ...]): События, порождённые этим опросом.
150
+ now (float): Текущий момент, монотонные секунды.
151
+
152
+ Returns:
153
+ int: Через сколько миллисекунд спрашивать снова.
154
+ """
155
+ if any(_is_data(event) for event in events):
156
+ self._last_data_event_at = now
157
+ self._interval_ms = self.numbers.active_interval_ms
158
+ return self.interval_ms
159
+
160
+ if self.is_active(now):
161
+ # Внутри окна активности интервал не растёт: событие только что
162
+ # было, и следующее вероятно рядом.
163
+ self._interval_ms = self.numbers.active_interval_ms
164
+ return self.interval_ms
165
+
166
+ self._interval_ms = min(
167
+ int(self._interval_ms * self.numbers.idle_step_multiplier),
168
+ self.numbers.max_interval_ms,
169
+ )
170
+ return self.interval_ms
171
+
172
+
173
+ def _now_ms() -> int:
174
+ """Возвращает текущий момент по стенным часам.
175
+
176
+ Args:
177
+ Нет.
178
+
179
+ Returns:
180
+ int: Миллисекунды от эпохи, целое число. Целое, а не дробное, потому
181
+ что каноническая форма запрещает числа с плавающей точкой: величина
182
+ уходит в файл состояния, а он объявляет себя канонической формой.
183
+ """
184
+ return int(time() * 1000)
185
+
186
+
187
+ class Deduplicator:
188
+ """Гасит повторную выдачу одного и того же события.
189
+
190
+ Гарантия доставки - не менее одного раза, поэтому повторы неизбежны и
191
+ гасить их обязан получатель. Здесь это делается в пределах ключа
192
+ упорядочивания: глобальный кэш склеивал бы события разных диалогов при
193
+ совпадении отпечатка.
194
+
195
+ Args:
196
+ ttl_ms (int): Сколько хранится запись о доставленном событии.
197
+ entries_per_key (int): Сколько записей хранится на один ключ.
198
+ """
199
+
200
+ __slots__ = ("_ttl_ms", "_entries_per_key", "_seen", "_suppressed")
201
+
202
+ def __init__(
203
+ self,
204
+ ttl_ms: int = DEDUP_TTL_MS,
205
+ entries_per_key: int = MIN_ENTRIES_PER_KEY,
206
+ ) -> None:
207
+ self._ttl_ms = ttl_ms
208
+ self._entries_per_key = entries_per_key
209
+ self._seen: dict[str, OrderedDict[str, float]] = {}
210
+ self._suppressed = 0
211
+
212
+ @property
213
+ def suppressed(self) -> int:
214
+ """Возвращает число погашенных повторов.
215
+
216
+ Величина обязана быть наблюдаемой. Ложное гашение - когда два разных
217
+ события схлопнулись в одно - иначе невидимо: событие просто не приходит,
218
+ и найти причину не по чему.
219
+
220
+ Returns:
221
+ int: Сколько событий было погашено за время жизни объекта.
222
+ """
223
+ return self._suppressed
224
+
225
+ def filter(self, events: tuple[Event, ...], now: float) -> tuple[Event, ...]:
226
+ """Отбрасывает события, которые уже были доставлены.
227
+
228
+ Метод ничего не запоминает. Запись выполняет :meth:`commit` после того,
229
+ как обработчики отработали: событие, помеченное доставленным до
230
+ обработчика, при его отказе погасится как повтор и исчезнет навсегда.
231
+
232
+ Повторы внутри одного набора схлопываются здесь же: два одинаковых
233
+ события в одном опросе - это одно событие, и ждать обработчиков, чтобы
234
+ это понять, незачем.
235
+
236
+ Args:
237
+ events (tuple[Event, ...]): События одного опроса.
238
+ now (float): Текущий момент, монотонные секунды.
239
+
240
+ Returns:
241
+ tuple[Event, ...]: События, которые вызывающий ещё не видел.
242
+ """
243
+ fresh: list[Event] = []
244
+ in_batch: set[tuple[str, str]] = set()
245
+
246
+ for event in events:
247
+ bucket = self._seen.setdefault(event.ordering_key, OrderedDict())
248
+ self._evict(bucket, now)
249
+
250
+ key = (event.ordering_key, event.id)
251
+ if event.id in bucket or key in in_batch:
252
+ if event.id in bucket:
253
+ bucket.move_to_end(event.id)
254
+ self._suppressed += 1
255
+ continue
256
+
257
+ in_batch.add(key)
258
+ fresh.append(event)
259
+
260
+ return tuple(fresh)
261
+
262
+ def commit(self, events: tuple[Event, ...], now: float) -> None:
263
+ """Запоминает события как доставленные.
264
+
265
+ Вызывается после обработчиков. Событие, на котором обработчик упал, сюда
266
+ не попадает и потому придёт снова - в этом и смысл разделения.
267
+
268
+ Args:
269
+ events (tuple[Event, ...]): События, дошедшие до обработчиков.
270
+ now (float): Текущий момент, монотонные секунды.
271
+
272
+ Returns:
273
+ None
274
+ """
275
+ for event in events:
276
+ bucket = self._seen.setdefault(event.ordering_key, OrderedDict())
277
+ bucket[event.id] = now
278
+ bucket.move_to_end(event.id)
279
+ while len(bucket) > self._entries_per_key:
280
+ bucket.popitem(last=False)
281
+
282
+ def snapshot(self, now: float, *, wall_ms: int | None = None) -> dict[str, dict[str, int]]:
283
+ """Отдаёт состояние гашения в виде обычных значений.
284
+
285
+ Метки переводятся в момент по стенным часам - целое число миллисекунд
286
+ от эпохи. Внутри объект по-прежнему считает монотонными секундами, и
287
+ это не прихоть: монотонные часы не прыгают от синхронизации времени и
288
+ не идут назад, а значит срок гашения нельзя ни продлить, ни обнулить,
289
+ подведя системные часы.
290
+
291
+ Наружу же монотонные секунды отдавать нельзя, и прежде отдавались.
292
+ Начало их отсчёта своё в каждом запуске, поэтому сохранённая метка
293
+ после перезапуска означала не то, что значила при записи. Исходов было
294
+ два, оба плохие. Машину перезагрузили - показание малое, разность с
295
+ меткой отрицательная, и запись не истекала НИКОГДА: срок в час не
296
+ работал вовсе. Машина работает давно - показание огромное, и весь кэш
297
+ выбрасывался разом на первом же чтении. Второе хуже: гашение
298
+ сохраняется затем, чтобы пережить перезапуск, а оно ровно перезапуска и
299
+ не переживало - всё доставленное приходило заново. Для обработчика
300
+ выдачи это выданный дважды товар.
301
+
302
+ Нужно для сохранения между запусками. Спецификация требует, чтобы кэш
303
+ переживал перезапуск: кэш только в памяти означает, что после любого
304
+ перезапуска повторно приходит всё, что успело прийти до него.
305
+
306
+ Args:
307
+ now (float): Текущий момент, монотонные секунды.
308
+ wall_ms (int | None): Момент по стенным часам, миллисекунды от
309
+ эпохи. Задаётся проверками; по умолчанию берётся системный.
310
+
311
+ Returns:
312
+ dict[str, dict[str, int]]: Ключ упорядочивания, идентификатор
313
+ события и момент его доставки - миллисекунды от эпохи.
314
+ """
315
+ moment = _now_ms() if wall_ms is None else wall_ms
316
+ return {
317
+ key: {
318
+ event_id: moment - round((now - stamp) * 1000) for event_id, stamp in bucket.items()
319
+ }
320
+ for key, bucket in self._seen.items()
321
+ if bucket
322
+ }
323
+
324
+ def snapshot_order(self) -> dict[str, list[str]]:
325
+ """Сохраняет LRU отдельно от меток TTL: JSON сортирует ключи объектов."""
326
+ return {key: list(bucket) for key, bucket in self._seen.items() if bucket}
327
+
328
+ def restore(
329
+ self,
330
+ state: dict[str, dict[str, int]],
331
+ now: float,
332
+ *,
333
+ wall_ms: int | None = None,
334
+ ordering: dict[str, list[str]] | None = None,
335
+ ) -> int:
336
+ """Восстанавливает состояние гашения из сохранённого.
337
+
338
+ Метки приходят моментами по стенным часам и переводятся обратно в
339
+ монотонные секунды текущего запуска: сколько прошло по стенным часам,
340
+ столько же отнимается от нынешнего показания секундомера.
341
+
342
+ Записи с истёкшим сроком отбрасываются здесь же. Иначе после длинной
343
+ паузы восстановился бы кэш, который всё равно нельзя использовать, и
344
+ память тратилась бы на записи, гасящие уже ничего.
345
+
346
+ Args:
347
+ state (dict[str, dict[str, int]]): Сохранённое состояние.
348
+ now (float): Текущий момент, монотонные секунды.
349
+ wall_ms (int | None): Момент по стенным часам, миллисекунды от
350
+ эпохи. Задаётся проверками; по умолчанию берётся системный.
351
+
352
+ Returns:
353
+ int: Сколько записей восстановлено.
354
+ """
355
+ if not isinstance(state, dict) or any(
356
+ not isinstance(key, str)
357
+ or not isinstance(bucket, dict)
358
+ or any(
359
+ not isinstance(event_id, str) or type(stamp) is not int
360
+ for event_id, stamp in bucket.items()
361
+ )
362
+ for key, bucket in state.items()
363
+ ):
364
+ raise StateSchemaIncompatibleError("непригодное состояние гашения повторов")
365
+ if ordering is not None and (
366
+ not isinstance(ordering, dict)
367
+ or any(
368
+ not isinstance(key, str)
369
+ or not isinstance(values, list)
370
+ or any(not isinstance(value, str) for value in values)
371
+ or len(set(values)) != len(values)
372
+ for key, values in ordering.items()
373
+ )
374
+ ):
375
+ raise StateSchemaIncompatibleError("непригодный порядок LRU")
376
+ moment = _now_ms() if wall_ms is None else wall_ms
377
+ restored = 0
378
+ for key, bucket in state.items():
379
+ target: OrderedDict[str, float] = OrderedDict()
380
+ for event_id, stamp in sorted(bucket.items(), key=lambda item: item[1]):
381
+ # Метка из будущего означает, что системные часы подвели назад.
382
+ # Считать её свежей безопаснее, чем просроченной: лишнее
383
+ # гашение задержит событие, недостающее выдаст товар дважды.
384
+ elapsed = max(0.0, (moment - stamp) / 1000)
385
+ target[event_id] = now - elapsed
386
+ self._evict(target, now)
387
+ for event_id in (ordering or {}).get(key, []):
388
+ if event_id in target:
389
+ target.move_to_end(event_id)
390
+ while len(target) > self._entries_per_key:
391
+ target.popitem(last=False)
392
+ if target:
393
+ self._seen[key] = target
394
+ restored += len(target)
395
+ return restored
396
+
397
+ def _evict(self, bucket: OrderedDict[str, float], now: float) -> None:
398
+ """Убирает записи, у которых вышел срок.
399
+
400
+ Args:
401
+ bucket (OrderedDict[str, float]): Записи одного ключа.
402
+ now (float): Текущий момент, монотонные секунды.
403
+
404
+ Returns:
405
+ None
406
+ """
407
+ deadline = now - self._ttl_ms / 1000
408
+ expired = [key for key, stamp in bucket.items() if stamp < deadline]
409
+ for key in expired:
410
+ del bucket[key]