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/_aclient.py ADDED
@@ -0,0 +1,1484 @@
1
+ """Асинхронный клиент: тот же способ, но через ожидание.
2
+
3
+ Файл читается рядом с [_client.py], и это не совпадение, а условие. Оба -
4
+ драйверы одного ядра из [_engine.py]: на просьбу сходить отвечают обращением, на
5
+ просьбу подождать - паузой, на просьбу раздать события - раздачей. Отличаются
6
+ ровно тремя строками, в которых стоит ``await``.
7
+
8
+ Нормативного порядка шагов здесь нет. Политики повторов нет. Расхода бюджета,
9
+ сдвига курсора, правил гашения - нет. Всё это написано один раз и проверено один
10
+ раз; сюда оно попадает готовым.
11
+
12
+ Обработчики принимаются и обычные, и асинхронные. Обычный вызывается как есть,
13
+ сопрограмма дожидается. Обратное - асинхронный обработчик в синхронном клиенте -
14
+ отвергается вслух: промолчать значило бы зарегистрировать обработчик, который
15
+ никогда не выполнится.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ from collections.abc import Awaitable, Callable, Generator
23
+ from dataclasses import replace
24
+ from pathlib import Path
25
+ from time import monotonic
26
+ from typing import TYPE_CHECKING, NoReturn, TypeVar
27
+
28
+ from ._account import BalancePage
29
+ from ._budget import Budget
30
+ from ._calc import PriceCalculation
31
+ from ._catalog import CatalogPage
32
+ from ._chat_history import ChatHistory
33
+ from ._chats import ChatsPage
34
+ from ._chips import ChipsPage
35
+ from ._currency_switch import CurrencySwitch
36
+ from ._engine import (
37
+ Ask,
38
+ Deliver,
39
+ Engine,
40
+ Fetch,
41
+ Pause,
42
+ Query,
43
+ Reply,
44
+ Request,
45
+ Submit,
46
+ Upload,
47
+ public_read_request,
48
+ )
49
+ from ._field_schema import FieldSchema
50
+ from ._host import host_of
51
+ from ._identity import REGISTRY
52
+ from ._lot_form import LotForm
53
+ from ._market import MarketPage
54
+ from ._monitoring import MarketWatch, MonitoringPlan
55
+ from ._observed import Observed
56
+ from ._order import OrderView
57
+ from ._order_details import OrderDetailsBatch
58
+ from ._orders import OrdersPage
59
+ from ._own_lots import OwnLotsPage
60
+ from ._poll import Schedule
61
+ from ._proxies import DEFAULT_ACCOUNT, Proxy, ProxyPool
62
+ from ._raise import RaiseResult
63
+ from ._refund import RefundResult
64
+ from ._review_write import ReviewResult
65
+ from ._reviews import ReviewsCursor, ReviewsPage
66
+ from ._runner import SendResult
67
+ from ._secret import Secret, SecretProvider
68
+ from ._showcase import ShowcasePage
69
+ from ._snapshot import MarketSnapshot
70
+ from ._thread import Thread
71
+ from ._transport import AsyncFetcher, TransportSettings
72
+ from ._viewing import BuyerViewing
73
+ from ._watch import Router, adispatch
74
+ from ._whoami import Account, CapabilityProfile, SessionHealth
75
+ from .budget import MARKET_HISTORY_LIMIT
76
+ from .capabilities import Capability, CapabilityState
77
+ from .errors import ConfigurationError, FunoraError, HandlerError, NotImplementedOperationError
78
+ from .extraction import OrderStatus
79
+ from .operations import OPERATIONS
80
+
81
+ if TYPE_CHECKING:
82
+ from ._transport import Observation
83
+
84
+ __all__ = ["AsyncClient", "AsyncOrdersService", "AsyncChatsService"]
85
+
86
+ _log = logging.getLogger("funora.client")
87
+
88
+ #: Тип, которым завершается сопрограмма ядра.
89
+ T = TypeVar("T")
90
+
91
+
92
+ class AsyncOrdersService:
93
+ """Операции над заказами.
94
+
95
+ Args:
96
+ client (AsyncClient): Клиент, которому принадлежит сервис.
97
+ """
98
+
99
+ __slots__ = ("_client",)
100
+
101
+ def __init__(self, client: AsyncClient) -> None:
102
+ self._client = client
103
+
104
+ async def get(self, order_id: str) -> OrderView:
105
+ """Читает страницу одного заказа.
106
+
107
+ Args:
108
+ order_id (str): Номер заказа. Тот самый, что стоит в адресе.
109
+
110
+ Returns:
111
+ OrderView: Заказ в том виде, в каком его отдала страница.
112
+
113
+ Raises:
114
+ ValidationError: Если номер непригоден для подстановки.
115
+ FunoraError: Если ответ непригоден либо разметка изменилась.
116
+ """
117
+ return await self._client.run(self._client.engine.read_order(order_id))
118
+
119
+ async def list(
120
+ self,
121
+ *,
122
+ order_id: str | None = None,
123
+ buyer: str | None = None,
124
+ status: OrderStatus | str | None = None,
125
+ game_id: str | None = None,
126
+ section: str | None = None,
127
+ ) -> OrdersPage:
128
+ """Читает продажи с необязательными серверными фильтрами.
129
+
130
+ Args:
131
+ order_id: Номер заказа без символа #.
132
+ buyer: Поиск по имени покупателя средствами FunPay.
133
+ status: paid, closed или refunded, в том числе OrderStatus.
134
+ game_id: Номер игры из формы фильтра.
135
+ section: Значение категории из формы, например lot-1908.
136
+ Требует game_id: категории принадлежат выбранной игре.
137
+
138
+ Returns:
139
+ OrdersPage: Разобранная страница. Записи выдаются через `rows()`:
140
+ без accept_incomplete он требует полноты, с ним отдаёт что есть.
141
+ Полнота относится к выбранным фильтрам. Вызов без аргументов
142
+ читает общий список, как и цикл наблюдения за заказами.
143
+
144
+ Raises:
145
+ ValidationError: Если фильтры непригодны. Запрос не выполняется.
146
+ FunoraError: Если ответ непригоден либо разметка изменилась.
147
+ """
148
+ return await self._client.run(
149
+ self._client.engine.read_orders(
150
+ order_id=order_id, buyer=buyer, status=status, game_id=game_id, section=section
151
+ )
152
+ )
153
+
154
+ async def details(
155
+ self, *order_ids: str, include: tuple[str, ...] = ("details", "users")
156
+ ) -> OrderDetailsBatch:
157
+ """Читает подробности заказов пачкой, структурно.
158
+
159
+ ЧТО ЭТО ДАЁТ СВЕРХ ЧТЕНИЯ СТРАНИЦЫ: сумму числом, код валюты и
160
+ РАЗДЕЛЕНИЕ покупателя с продавцом. Страница заказа показывает одного
161
+ контрагента и не помечает, на которой стороне вы сами.
162
+
163
+ НАБЛЮДЕНО НЕ НАМИ. Ни одного живого ответа этой точки мы не видели;
164
+ состав полей известен от независимой реализации того же протокола.
165
+ Поэтому всё, что может отсутствовать, приходит наблюдением с причиной, а
166
+ состояние - строкой, не приведённой к нашему перечню насильно.
167
+
168
+ Args:
169
+ order_ids (str): Идентификаторы заказов, от одного до десяти.
170
+ include (tuple[str, ...]): Какие разделы ответа запрашивать.
171
+
172
+ Returns:
173
+ OrderDetailsBatch: Спрошенное, полученное и недостающее - порознь.
174
+
175
+ Raises:
176
+ ValidationError: Если пачка пуста, велика либо несёт непригодный
177
+ идентификатор.
178
+ FunoraError: Если ответ непригоден.
179
+ """
180
+ return await self._client.run(
181
+ self._client.engine.read_order_details(tuple(order_ids), include=include)
182
+ )
183
+
184
+ async def refund(self, order_id: str) -> RefundResult:
185
+ """Возвращает средства покупателю по заказу.
186
+
187
+ ДЕНЬГИ УХОДЯТ ПОКУПАТЕЛЮ, И ВЕРНУТЬ ИХ ОБРАТНО ПЛОЩАДКА НЕ ПРЕДЛАГАЕТ
188
+ НИЧЕМ. Требует явного согласия.
189
+
190
+ Перед отправкой читается страница заказа: не показывает площадка формы
191
+ возврата - запрос не уходит вовсе.
192
+
193
+ ПОВТОРА НЕТ. При неоднозначном исходе положена сверка - прочитайте
194
+ заказ и посмотрите, - а не второй запрос: второй запрос это второй
195
+ возврат.
196
+
197
+ Args:
198
+ order_id (str): Номер заказа.
199
+
200
+ Returns:
201
+ RefundResult: Исход. Отказ площадки - тоже исход, и он несёт
202
+ причину текстом. Суммы здесь нет: её не называет ни запрос, ни
203
+ ответ.
204
+
205
+ Raises:
206
+ ValidationError: Если номер заказа непригоден.
207
+ UsageError: Если согласия не дано.
208
+ PreconditionFailedError: Если площадка возврата не предлагает.
209
+ FunoraError: Если страница либо ответ непригодны.
210
+ """
211
+ return await self._client.run(self._client.engine.refund_order(order_id))
212
+
213
+
214
+ class AsyncReviewsService:
215
+ """Операции над отзывами.
216
+
217
+ Args:
218
+ client (AsyncClient): Клиент, которому принадлежит сервис.
219
+ """
220
+
221
+ __slots__ = ("_client",)
222
+
223
+ def __init__(self, client: AsyncClient) -> None:
224
+ self._client = client
225
+
226
+ async def get(
227
+ self,
228
+ user_id: str,
229
+ *,
230
+ rating: int | None = None,
231
+ cursor: ReviewsCursor | str | None = None,
232
+ ) -> ReviewsPage:
233
+ """Читает отзывы с профиля продавца.
234
+
235
+ Следующую страницу запрашивают с next_cursor предыдущего результата.
236
+ rating выбирает оценку 1..5; None читает все оценки. При продолжении
237
+ передавайте ту же оценку, с которой получен курсор.
238
+ Отсутствие курсора само по себе не означает полноту: проверяйте completeness.
239
+
240
+ Args:
241
+ user_id (str): Идентификатор продавца. Тот самый, что стоит в адресе
242
+ профиля.
243
+
244
+ Returns:
245
+ ReviewsPage: Разобранная страница. Отзывы выдаются через `rows()`.
246
+
247
+ Raises:
248
+ ValidationError: Если идентификатор непригоден для подстановки.
249
+ FunoraError: Если ответ непригоден либо разметка изменилась.
250
+ """
251
+ return await self._client.run(
252
+ self._client.engine.read_reviews(user_id, rating=rating, cursor=cursor)
253
+ )
254
+
255
+ async def leave(self, order_id: str, *, rating: int, text: str = "") -> ReviewResult:
256
+ """Пишет отзыв к заказу либо правит уже написанный.
257
+
258
+ ТРЕБУЕТ ЯВНОГО СОГЛАСИЯ: состав полей запроса известен от независимой
259
+ реализации того же протокола. Отзыв виден покупателю и всем посетителям
260
+ профиля.
261
+
262
+ Args:
263
+ order_id (str): Номер заказа.
264
+ rating (int): Оценка от одного до пяти.
265
+ text (str): Текст отзыва. Пустой допустим.
266
+
267
+ Returns:
268
+ ReviewResult: Исход. Поле applied означает «подтверждено», а не
269
+ «получилось»: ложь требует посмотреть заказ, а не повторить вслепую.
270
+
271
+ Raises:
272
+ ValidationError: Если номер либо оценка непригодны.
273
+ UsageError: Если согласия не дано.
274
+ FunoraError: Если страница либо ответ непригодны.
275
+ """
276
+ return await self._client.run(
277
+ self._client.engine.leave_review(order_id, rating=rating, text=text)
278
+ )
279
+
280
+ async def remove(self, order_id: str) -> ReviewResult:
281
+ """Снимает свой отзыв к заказу.
282
+
283
+ ПРЕЖНЕГО ТЕКСТА НИКТО НЕ ВЕРНЁТ. Прочитайте отзыв прежде, если он вам
284
+ нужен: реализация его не сохраняет.
285
+
286
+ Args:
287
+ order_id (str): Номер заказа.
288
+
289
+ Returns:
290
+ ReviewResult: Исход. Подтверждением служит отсутствие оценки в
291
+ перерисованном виджете.
292
+
293
+ Raises:
294
+ ValidationError: Если номер непригоден.
295
+ UsageError: Если согласия не дано.
296
+ FunoraError: Если страница либо ответ непригодны.
297
+ """
298
+ return await self._client.run(self._client.engine.remove_review(order_id))
299
+
300
+
301
+ class AsyncChatsService:
302
+ """Операции над перепиской.
303
+
304
+ Args:
305
+ client (AsyncClient): Клиент, которому принадлежит сервис.
306
+ """
307
+
308
+ __slots__ = ("_client",)
309
+
310
+ def __init__(self, client: AsyncClient) -> None:
311
+ self._client = client
312
+
313
+ async def list(self) -> ChatsPage:
314
+ """Читает список диалогов.
315
+
316
+ Returns:
317
+ ChatsPage: Разобранная страница.
318
+
319
+ Raises:
320
+ FunoraError: Если ответ непригоден либо разметка изменилась.
321
+ """
322
+ return await self._client.run(self._client.engine.read_chats())
323
+
324
+ async def send_text(
325
+ self, node_id: str, text: str, *, declared_cold: bool = False
326
+ ) -> SendResult:
327
+ """Отправляет текстовое сообщение в переписку.
328
+
329
+ ИСКЛЮЧЕНИЕ ОЗНАЧАЕТ, ЧТО СООБЩЕНИЕ НЕ УШЛО. Всё, что случилось после
330
+ ухода запроса, возвращается исходом: у неоднозначного исхода есть своё
331
+ значение, и брошенное исключение прочиталось бы как неудача.
332
+
333
+ ИСХОДА ТРИ, и третий - честное незнание. Читать его надо признаком
334
+ is_confirmed, а не истинностью самой квитанции: у неё три значения, и
335
+ `if result` прочло бы неподтверждённое как успех.
336
+
337
+ Args:
338
+ node_id (str): Числовой идентификатор диалога.
339
+ text (str): Текст сообщения.
340
+ declared_cold (bool): Признание, что переписка холодная и вы пишете
341
+ первым. Без него холодное обращение отвергается: отсутствие
342
+ входящего в окне - положительный признак холода.
343
+
344
+ Returns:
345
+ SendResult: Исход, причина и прочитанное из ответа.
346
+
347
+ Raises:
348
+ FunoraError: Если отправка не состоялась - страница непригодна,
349
+ упёрлись в предел, отказала сеть до ухода запроса.
350
+ """
351
+ return await self._client.run(
352
+ self._client.engine.send_text(node_id, text, declared_cold=declared_cold)
353
+ )
354
+
355
+ async def thread(self, node_id: str) -> Thread:
356
+ """Читает переписку одного диалога.
357
+
358
+ Args:
359
+ node_id (str): Идентификатор диалога. Тот самый, что стоит в адресе
360
+ после `?node=`.
361
+
362
+ Returns:
363
+ Thread: Разобранная переписка.
364
+
365
+ Raises:
366
+ ValidationError: Если идентификатор непригоден для подстановки.
367
+ FunoraError: Если ответ непригоден либо разметка изменилась.
368
+ """
369
+ return await self._client.run(self._client.engine.read_thread(node_id))
370
+
371
+ async def history_before(
372
+ self, node_id: str, *, before_message_id: str | None = None, cursor: str | None = None
373
+ ) -> ChatHistory:
374
+ """Догружает сообщения переписки СТАРШЕ указанного.
375
+
376
+ ЗАПРОС ЗАИМСТВОВАН ЦЕЛИКОМ - и адрес, и оба имени параметров, и форма
377
+ ответа. Своего наблюдения этой точки нет ни одного.
378
+
379
+ СОГЛАСИЯ НЕ ТРЕБУЕТ: это чтение, а ошибка чтения на чужом знании видна
380
+ сразу и следа не оставляет.
381
+
382
+ НАПРАВЛЕНИЕ СВЕРЯЕТСЯ. Пришедшие идентификаторы обязаны быть строго
383
+ меньше курсора; иначе - отказ, а не молча отданный список.
384
+
385
+ Args:
386
+ node_id (str): Идентификатор диалога.
387
+ before_message_id (str | None): Идентификатор сообщения для первого
388
+ запроса назад. Только цифры ASCII; не передаётся вместе с cursor.
389
+ cursor (str | None): Сохранённый next_cursor предыдущей страницы.
390
+
391
+ Returns:
392
+ ChatHistory: Догруженные сообщения вместе с признаком конца.
393
+
394
+ Raises:
395
+ ValidationError: Если идентификатор либо курсор непригодны.
396
+ CursorIncompatibleError: Если токен несовместим или принадлежит
397
+ другой переписке либо площадка вернула не ту сторону.
398
+ FunoraError: Если ответ непригоден.
399
+ """
400
+ return await self._client.run(
401
+ self._client.engine.read_history_before(
402
+ node_id, before_message_id=before_message_id, cursor=cursor
403
+ )
404
+ )
405
+
406
+ async def mark_read(self, node_id: str) -> None:
407
+ """Помечает диалог прочитанным.
408
+
409
+ ОТДЕЛЬНОГО ЗАПРОСА У ЭТОГО ДЕЙСТВИЯ НЕТ: диалог помечается прочитанным
410
+ тем, что его узел попал в подписку обычного опроса канала обновлений.
411
+
412
+ ТРЕБУЕТ ЯВНОГО СОГЛАСИЯ. Форма запроса наша, а вывод о том, что подписка
413
+ снимает пометку непрочитанного, - от независимой реализации того же
414
+ протокола. Проверить его мы не могли: непрочитанность видна у
415
+ покупателя, а не у нас.
416
+
417
+ Args:
418
+ node_id (str): Числовой идентификатор диалога.
419
+
420
+ Returns:
421
+ None: Подтверждения площадка не даёт, и выдумывать его нечем.
422
+
423
+ Raises:
424
+ ValidationError: Если идентификатор непригоден.
425
+ UsageError: Если согласия не дано.
426
+ FunoraError: Если страница диалога непригодна.
427
+ """
428
+ await self._client.run(self._client.engine.mark_chat_read(node_id))
429
+
430
+ async def send_image(
431
+ self,
432
+ node_id: str,
433
+ content: bytes,
434
+ *,
435
+ filename: str,
436
+ content_type: str = "image/png",
437
+ declared_cold: bool = False,
438
+ ) -> SendResult:
439
+ """Отправляет изображение в переписку.
440
+
441
+ ДВА ШАГА, И ОБА НАБЛЮДЕНЫ НАМИ: файл уходит отдельным обращением и
442
+ получает номер, затем номер отправляется обычным действием канала.
443
+ Чужого знания здесь нет, и согласия операция не спрашивает.
444
+
445
+ ПОБОЧНОЕ ДЕЙСТВИЕ ТО ЖЕ, ЧТО У ОТПРАВКИ ТЕКСТА: переписка помечается
446
+ прочитанной. Иначе ответ канала не подтвердит отправку.
447
+
448
+ Args:
449
+ node_id (str): Числовой идентификатор диалога.
450
+ content (bytes): Содержимое файла.
451
+ filename (str): Имя файла, как его увидит площадка.
452
+ content_type (str): Тип содержимого.
453
+
454
+ Returns:
455
+ SendResult: Исход, причина и прочитанное из ответа.
456
+
457
+ Raises:
458
+ ValidationError: Если идентификатор, имя либо содержимое непригодны.
459
+ UsageError: Если файл больше объявленного площадкой предела.
460
+ FunoraError: Если страница непригодна либо ответ загрузки непонятен.
461
+ """
462
+ return await self._client.run(
463
+ self._client.engine.send_image(
464
+ node_id,
465
+ content,
466
+ filename=filename,
467
+ content_type=content_type,
468
+ declared_cold=declared_cold,
469
+ )
470
+ )
471
+
472
+ async def buyer_viewing(self, node_id: str, *buyer_ids: str) -> tuple[BuyerViewing, ...]:
473
+ """Читает, что покупатели смотрят прямо сейчас.
474
+
475
+ ЗАЧЕМ ЭТО ПРОДАВЦУ: увидеть, что покупатель, с которым идёт переписка,
476
+ смотрит именно ваш лот - и что именно.
477
+
478
+ РАСКОЛ НАБЛЮДЕНИЯ. Подписка наблюдена нами; ответ на неё - нет. Поэтому
479
+ разметка блока сохраняется КАК ЕСТЬ: не разберись наши поля, у вас
480
+ останется то, из чего вы поймёте сами.
481
+
482
+ Args:
483
+ node_id (str): Диалог, со страницы которого берётся защитный токен.
484
+ buyer_ids (str): Идентификаторы покупателей.
485
+
486
+ Returns:
487
+ tuple[BuyerViewing, ...]: По записи на каждого спрошенного, В ТОМ ЖЕ
488
+ ПОРЯДКЕ. Не ответившие получают наблюдение «не смотрит».
489
+
490
+ Raises:
491
+ ValidationError: Если идентификатор непригоден либо перечень пуст.
492
+ FunoraError: Если страница либо ответ непригодны.
493
+ """
494
+ return await self._client.run(
495
+ self._client.engine.read_buyer_viewing(node_id, tuple(buyer_ids))
496
+ )
497
+
498
+
499
+ class AsyncAccountService:
500
+ """Операции с аккаунтом.
501
+
502
+ Args:
503
+ client (AsyncClient): Клиент, которому принадлежит сервис.
504
+ """
505
+
506
+ __slots__ = ("_client",)
507
+
508
+ def __init__(self, client: AsyncClient) -> None:
509
+ self._client = client
510
+
511
+ def __getattr__(self, name: str) -> NoReturn:
512
+ if name == "withdraw":
513
+ raise NotImplementedOperationError(
514
+ "вывод не реализован: "
515
+ "spec/conformance/not-implemented.yaml#withdraw_stays_unwritten"
516
+ )
517
+ raise AttributeError(name)
518
+
519
+ async def get(self) -> Account:
520
+ """Читает собственный аккаунт: идентификатор, имя и метку языка.
521
+
522
+ Балансов не читает - они на другой странице, и брать её ради профиля
523
+ значило бы ходить на площадку дважды за одним ответом.
524
+
525
+ Returns:
526
+ Account: Сведения о собственном аккаунте.
527
+
528
+ Raises:
529
+ FunoraError: Если ответ непригоден либо разметка изменилась.
530
+ """
531
+ return await self._client.run(self._client.engine.read_account())
532
+
533
+ async def refresh(self) -> Account:
534
+ """Перечитывает собственный аккаунт.
535
+
536
+ ДЕЛАЕТ РОВНО ТО ЖЕ, что и get, и это сказано прямо. Кэша у чтения
537
+ аккаунта нет, а значит и обходить нечего: операция объявлена контрактом
538
+ отдельно, и молча свести её к первой значило бы обещать разницу, которой
539
+ нет.
540
+
541
+ Returns:
542
+ Account: Сведения о собственном аккаунте.
543
+
544
+ Raises:
545
+ FunoraError: Если ответ непригоден либо разметка изменилась.
546
+ """
547
+ return await self._client.run(self._client.engine.read_account())
548
+
549
+ async def health(self) -> SessionHealth:
550
+ """Проверяет пригодность сессии.
551
+
552
+ ОТЧИТЫВАЕТСЯ, А НЕ ПАДАЕТ: отказ площадки здесь - это ответ, а не
553
+ происшествие. Результат держится в кэше на объявленный срок.
554
+
555
+ Returns:
556
+ SessionHealth: Класс ответа, годность сессии и признак кэша.
557
+ """
558
+ return await self._client.run(self._client.engine.read_health())
559
+
560
+ async def capabilities(self) -> CapabilityProfile:
561
+ """Возвращает профиль возможностей.
562
+
563
+ Собирается БЕЗ СЕТИ - из того, что уже наблюдалось.
564
+
565
+ Returns:
566
+ CapabilityProfile: Состояние каждой возможности контракта.
567
+ """
568
+ return self._client._capability_profile()
569
+
570
+ async def balance(self) -> BalancePage:
571
+ """Читает баланс аккаунта и операции по счёту.
572
+
573
+ Возвращает ПЕРЕЧЕНЬ балансов, а не одно значение: страница показывает
574
+ три узла значения, по одному на валюту. Кода валюты не даёт ни одному из
575
+ них - страница несёт только знак.
576
+
577
+ Returns:
578
+ BalancePage: Балансы полем, операции через `transactions()`.
579
+
580
+ Raises:
581
+ FunoraError: Если ответ непригоден либо разметка изменилась.
582
+ """
583
+ return await self._client.run(self._client.engine.read_balance())
584
+
585
+ async def switch_currency(self, currency: str) -> CurrencySwitch:
586
+ """Меняет валюту, в которой площадка показывает суммы.
587
+
588
+ ПОБОЧНОЕ ДЕЙСТВИЕ ГЛОБАЛЬНО: после смены КАЖДАЯ страница отдаёт другие
589
+ числа. Снимки рынка, снятые по разные стороны от смены, сравнивать
590
+ нельзя - сменившейся окажется каждая цена.
591
+
592
+ ТРЕБУЕТ ЯВНОГО СОГЛАСИЯ.
593
+
594
+ Args:
595
+ currency (str): Код валюты по ISO 4217. Регистр не важен.
596
+
597
+ Returns:
598
+ CurrencySwitch: Исход. Вернула площадка окно подтверждения - смены
599
+ НЕ БЫЛО, и подтверждать за вас реализация не станет.
600
+
601
+ Raises:
602
+ ValidationError: Если код не из наблюдённого набора.
603
+ UsageError: Если согласия не дано.
604
+ FunoraError: Если страница либо ответ непригодны.
605
+ """
606
+ return await self._client.run(self._client.engine.switch_currency(currency))
607
+
608
+
609
+ class AsyncLotsService:
610
+ """Операции с лотами.
611
+
612
+ Args:
613
+ client (AsyncClient): Клиент, которому принадлежит сервис.
614
+ """
615
+
616
+ __slots__ = ("_client",)
617
+
618
+ def __init__(self, client: AsyncClient) -> None:
619
+ self._client = client
620
+
621
+ async def form(self, node_id: str, offer_id: str) -> LotForm:
622
+ """Читает форму правки одного предложения.
623
+
624
+ ЕДИНСТВЕННОЕ МЕСТО, где виден признак показа лота в выдаче.
625
+
626
+ Args:
627
+ node_id (str): Идентификатор раздела.
628
+ offer_id (str): Идентификатор предложения.
629
+
630
+ Returns:
631
+ LotForm: Прочитанная форма.
632
+
633
+ Raises:
634
+ ValidationError: Если идентификатор непригоден.
635
+ FunoraError: Если ответ непригоден либо разметка изменилась.
636
+ """
637
+ return await self._client.run(self._client.engine.read_lot_form(node_id, offer_id))
638
+
639
+ async def update_price(
640
+ self, node_id: str, offer_id: str, price: str, *, expected_revision: str
641
+ ) -> LotForm:
642
+ """Меняет цену предложения, не трогая ничего другого.
643
+
644
+ Args:
645
+ node_id (str): Идентификатор раздела.
646
+ offer_id (str): Идентификатор предложения.
647
+ price (str): Новая цена.
648
+ expected_revision (str): Отпечаток, полученный через `form()`.
649
+
650
+ Returns:
651
+ LotForm: Форма, перечитанная после сохранения.
652
+
653
+ Raises:
654
+ PreconditionFailedError: Если лот успели изменить.
655
+ UsageError: Если лот выключен либо отпечаток не передан.
656
+ ConfigurationError: Если долговечного журнала правок нет.
657
+ FunoraError: Если сохранение не состоялось.
658
+ """
659
+ return await self._client.run(
660
+ self._client.engine.update_price(
661
+ node_id, offer_id, price, expected_revision=expected_revision
662
+ )
663
+ )
664
+
665
+ async def list_own(self, node_id: str) -> OwnLotsPage:
666
+ """Читает собственные лоты продавца в одном разделе.
667
+
668
+ РАДИ ИДЕНТИФИКАТОРА ПРЕДЛОЖЕНИЯ. Витрина показывает те же лоты и даже
669
+ больше полей - количество и признак автовыдачи, - но идентификатора не
670
+ даёт: там он лежит в строке запроса ссылки.
671
+
672
+ ПРИЗНАКА ПОКАЗА ЛОТА В ВЫДАЧЕ ЗДЕСЬ НЕТ, и это не пробел разбора: его
673
+ нет на самой странице. Все строки структурно одинаковы, различающего
674
+ признака ни одного, а узел с говорящим именем .tc-visible-inside есть и
675
+ на публичной витрине - значит признаком видимости он быть не может.
676
+
677
+ Args:
678
+ node_id (str): Номер раздела. Управление лотами живёт по одному
679
+ адресу на раздел, а не по одному на аккаунт.
680
+
681
+ Returns:
682
+ OwnLotsPage: Лоты раздела и доводы кнопки поднятия.
683
+
684
+ Raises:
685
+ FunoraError: Если ответ непригоден либо разметка изменилась.
686
+ """
687
+ return await self._client.run(self._client.engine.read_own_lots(node_id))
688
+
689
+ async def showcase(self, user_id: str) -> ShowcasePage:
690
+ """Читает публичную витрину продавца.
691
+
692
+ Возвращает то, что видит покупатель: разделы и предложения. Ни признака
693
+ включённости, ни средств правки на витрине нет - для них нужна страница
694
+ управления лотами, которая пока не наблюдалась.
695
+
696
+ Args:
697
+ user_id (str): Идентификатор продавца.
698
+
699
+ Returns:
700
+ ShowcasePage: Разделы через `sections()`. Полным чтение не
701
+ объявляется ни разу, и признание неполноты требуется всегда.
702
+
703
+ Raises:
704
+ ValidationError: Если идентификатор непригоден для подстановки.
705
+ FunoraError: Если ответ непригоден либо разметка изменилась.
706
+ """
707
+ return await self._client.run(self._client.engine.read_showcase(user_id))
708
+
709
+ async def promote(self, game_id: str, node_id: str) -> RaiseResult:
710
+ """Поднимает в выдаче ВСЕ предложения раздела.
711
+
712
+ НЕОБРАТИМО И ТРАТИТ СУТОЧНЫЙ ПРЕДЕЛ. Повтора нет: при неоднозначном
713
+ исходе положена сверка, а не второй запрос.
714
+
715
+ Args:
716
+ game_id (str): Игра. Атрибут data-game у кнопки поднятия.
717
+ node_id (str): Раздел. Атрибут data-node у той же кнопки.
718
+
719
+ Returns:
720
+ RaiseResult: Исход. Отказ площадки - тоже исход, и он несёт срок
721
+ следующего поднятия.
722
+
723
+ Raises:
724
+ ValidationError: Если идентификатор непригоден для подстановки.
725
+ FunoraError: Если ответ непригоден либо разметка изменилась.
726
+ """
727
+ return await self._client.run(self._client.engine.promote_lots(game_id, node_id))
728
+
729
+ async def activate(self, node_id: str, offer_id: str, *, expected_revision: str) -> LotForm:
730
+ """Включает лот в выдачу.
731
+
732
+ ТРЕБУЕТ ЯВНОГО СОГЛАСИЯ. Вид запроса при снятом флажке нами не
733
+ наблюдался - он известен от независимой реализации того же протокола.
734
+ Без включённой возможности `lots.activate` операция отказывает до сети.
735
+
736
+ Args:
737
+ node_id (str): Идентификатор раздела.
738
+ offer_id (str): Идентификатор предложения.
739
+ expected_revision (str): Отпечаток, полученный чтением формы.
740
+ Обязателен: уходит вся форма, и без него параллельная правка
741
+ перетёрла бы описание лота.
742
+
743
+ Returns:
744
+ LotForm: Форма, перечитанная после сохранения. Состояние показа в
745
+ ней сверено с тем, которого просили.
746
+
747
+ Raises:
748
+ UsageError: Если согласия не дано либо отпечаток не передан.
749
+ PreconditionFailedError: Если лот успели изменить.
750
+ FunoraError: Если сохранение не состоялось.
751
+ """
752
+ return await self._client.run(
753
+ self._client.engine.set_lot_visible(
754
+ node_id, offer_id, visible=True, expected_revision=expected_revision
755
+ )
756
+ )
757
+
758
+ async def deactivate(self, node_id: str, offer_id: str, *, expected_revision: str) -> LotForm:
759
+ """Снимает лот с выдачи - продажи по нему прекращаются.
760
+
761
+ ТРЕБУЕТ ЯВНОГО СОГЛАСИЯ. Вид запроса при снятом флажке нами не
762
+ наблюдался - он известен от независимой реализации того же протокола.
763
+ Без включённой возможности `lots.deactivate` операция отказывает до сети.
764
+
765
+ Args:
766
+ node_id (str): Идентификатор раздела.
767
+ offer_id (str): Идентификатор предложения.
768
+ expected_revision (str): Отпечаток, полученный чтением формы.
769
+ Обязателен: уходит вся форма, и без него параллельная правка
770
+ перетёрла бы описание лота.
771
+
772
+ Returns:
773
+ LotForm: Форма, перечитанная после сохранения. Состояние показа в
774
+ ней сверено с тем, которого просили.
775
+
776
+ Raises:
777
+ UsageError: Если согласия не дано либо отпечаток не передан.
778
+ PreconditionFailedError: Если лот успели изменить.
779
+ FunoraError: Если сохранение не состоялось.
780
+ """
781
+ return await self._client.run(
782
+ self._client.engine.set_lot_visible(
783
+ node_id, offer_id, visible=False, expected_revision=expected_revision
784
+ )
785
+ )
786
+
787
+ async def calculate_prices(self, node_id: str, price: str) -> PriceCalculation:
788
+ """Считает, сколько заплатит покупатель за названную цену продавца.
789
+
790
+ ЦЕНА ПРОДАВЦА И ЦЕНА ПОКУПАТЕЛЯ - РАЗНЫЕ ВЕЛИЧИНЫ: между ними комиссия
791
+ площадки, и зависит она от способа оплаты.
792
+
793
+ Args:
794
+ node_id (str): Идентификатор раздела.
795
+ price (str): Цена продавца, как её пишут в поле.
796
+
797
+ Returns:
798
+ PriceCalculation: Способы оплаты и цены покупателя при них. Цены
799
+ текстом: разделитель дробной части нам не наблюдался.
800
+
801
+ Raises:
802
+ ValidationError: Если цена пуста либо раздел непригоден.
803
+ FunoraError: Если ответ непригоден.
804
+ """
805
+ return await self._client.run(
806
+ self._client.engine.calculate_prices(node_id=node_id, price=price)
807
+ )
808
+
809
+
810
+ class AsyncMonitoring:
811
+ """Планирование и наблюдение публичных выдач без авторизации."""
812
+
813
+ __slots__ = ("_client",)
814
+
815
+ def __init__(self, client: AsyncClient) -> None:
816
+ self._client = client
817
+
818
+ def plan(self, *watches: MarketWatch) -> MonitoringPlan:
819
+ """Проверяет прогноз набора вместе с действующими наблюдениями, без HTTP."""
820
+ return self._client._public_engine._budget.monitoring_plan(watches, monotonic())
821
+
822
+ async def watch(
823
+ self,
824
+ router: Router,
825
+ *watches: MarketWatch,
826
+ state_path: str | Path | None = None,
827
+ max_iterations: int | None = None,
828
+ history_limit: int = MARKET_HISTORY_LIMIT,
829
+ on_handler_error: Callable[[HandlerError], None] | None = None,
830
+ ) -> None:
831
+ """Регистрирует набор до выхода из цикла; повторяет сохранённые события.
832
+
833
+ Файл состояния отдельный от личного watch. Без файла история живёт
834
+ только в текущем вызове. Набор и интервалы в файле неизменны.
835
+ max_iterations ограничивает число шагов, включая повтор без HTTP.
836
+ history_limit ограничивает записи предложений во всём наборе (100000
837
+ по умолчанию). При превышении ConfigurationError сохраняет прежний
838
+ курсор; предел можно увеличить при продолжении с тем же файлом.
839
+ """
840
+ engine = self._client._public_engine
841
+ await self._client.run(
842
+ engine.monitor_market(
843
+ watches,
844
+ account_id=self._client._account_id,
845
+ state_path=state_path,
846
+ max_iterations=max_iterations,
847
+ history_limit=history_limit,
848
+ ),
849
+ engine=engine,
850
+ router=router,
851
+ on_handler_error=on_handler_error,
852
+ )
853
+
854
+
855
+ class AsyncMarketService:
856
+ """Публичные предложения раздела.
857
+
858
+ То, что видит ПОКУПАТЕЛЬ. Вход в переоценку: прочитать цены соседей,
859
+ решить, поменять свою через `lots.update_price`.
860
+
861
+ Args:
862
+ client (AsyncClient): Клиент, которому принадлежит сервис.
863
+ """
864
+
865
+ __slots__ = ("_client",)
866
+
867
+ def __init__(self, client: AsyncClient) -> None:
868
+ self._client = client
869
+
870
+ async def offers(self, node_id: str) -> MarketPage:
871
+ """Читает публичный список предложений раздела.
872
+
873
+ Args:
874
+ node_id (str): Номер раздела. Тот самый, что стоит в адресе.
875
+
876
+ Returns:
877
+ MarketPage: Разобранный список. Предложения выдаются через
878
+ `offers()`, и неполноту он требует признать: неполный список
879
+ неотличим от короткого, а решение о цене по нему - неверное.
880
+
881
+ Raises:
882
+ ValidationError: Если номер непригоден для подстановки.
883
+ FunoraError: Если ответ непригоден либо разметка изменилась.
884
+ """
885
+ return await self._client._read("market.offers", lambda engine: engine.read_market(node_id))
886
+
887
+ async def snapshot(self, node_id: str) -> MarketSnapshot:
888
+ """Снимает состояние выдачи для сравнения во времени.
889
+
890
+ Args:
891
+ node_id (str): Номер раздела.
892
+
893
+ Returns:
894
+ MarketSnapshot: Снимок. Сравнивать его можно только с другим
895
+ снимком того же запроса - это делает `funora.compare`.
896
+
897
+ Raises:
898
+ ValidationError: Если номер непригоден для подстановки.
899
+ FunoraError: Если ответ непригоден либо разметка изменилась.
900
+ """
901
+ return await self._client._read(
902
+ "market.snapshot", lambda engine: engine.read_market_snapshot(node_id)
903
+ )
904
+
905
+ async def chips(self, node_id: str) -> ChipsPage:
906
+ """Читает публичные предложения раздела ЧИПОВ - второго рынка.
907
+
908
+ Здесь продаётся количество, а не вещь: цена стоит за единицу, описания
909
+ у предложения нет.
910
+
911
+ Args:
912
+ node_id (str): Номер раздела чипов.
913
+
914
+ Returns:
915
+ ChipsPage: Разобранный список.
916
+
917
+ Raises:
918
+ ValidationError: Если номер непригоден для подстановки.
919
+ FunoraError: Если ответ непригоден либо разметка изменилась.
920
+ """
921
+ return await self._client._read("chips.offers", lambda engine: engine.read_chips(node_id))
922
+
923
+ async def calculate_chip_prices(self, game_id: str, price: str) -> PriceCalculation:
924
+ """Считает цену покупателя на рынке по количеству.
925
+
926
+ ДОВОД ЗДЕСЬ - ИГРА, А НЕ РАЗДЕЛ, и это отличие от обычных лотов. У чипов
927
+ на странице лежат оба, и который ждёт площадка - мы не проверяли.
928
+
929
+ Args:
930
+ game_id (str): Идентификатор игры.
931
+ price (str): Цена продавца, как её пишут в поле.
932
+
933
+ Returns:
934
+ PriceCalculation: Способы оплаты и цены покупателя при них.
935
+
936
+ Raises:
937
+ ValidationError: Если цена пуста либо игра непригодна.
938
+ FunoraError: Если ответ непригоден.
939
+ """
940
+ return await self._client.run(
941
+ self._client.engine.calculate_prices(game_id=game_id, price=price)
942
+ )
943
+
944
+
945
+ class AsyncCatalogService:
946
+ """Операции с каталогом.
947
+
948
+ Args:
949
+ client (AsyncClient): Клиент, которому принадлежит сервис.
950
+ """
951
+
952
+ __slots__ = ("_client", "_lock")
953
+
954
+ def __init__(self, client: AsyncClient) -> None:
955
+ self._client = client
956
+ self._lock = asyncio.Lock()
957
+
958
+ async def categories(self, *, refresh: bool = False) -> CatalogPage:
959
+ """Читает каталог: игры, их варианты и разделы каждого.
960
+
961
+ Читается только основной список. Избранное повторяет его целиком -
962
+ наблюдено, восемь карточек из восьми, - и новых сведений не даёт.
963
+
964
+ Returns:
965
+ CatalogPage: Игры через `games()`.
966
+
967
+ Raises:
968
+ FunoraError: Если ответ непригоден либо разметка изменилась.
969
+ """
970
+ async with self._lock:
971
+ return await self._client.run(self._client.engine.read_catalog(refresh=refresh))
972
+
973
+ async def search(self, query: str) -> CatalogPage:
974
+ """Ищет игры без секрета; games() требует явного принятия неполной выдачи."""
975
+ return await self._client._read(
976
+ "catalog.search", lambda engine: engine.read_catalog_search(query)
977
+ )
978
+
979
+ async def field_schema(self, section_id: str) -> FieldSchema:
980
+ """Читает поля фильтра раздела; неполнота требует явного принятия."""
981
+ return await self._client.run(self._client.engine.read_field_schema(section_id))
982
+
983
+
984
+ class AsyncClient:
985
+ """Асинхронный клиент площадки.
986
+
987
+ Args:
988
+ secret (Secret | SecretProvider | None): Сессионный секрет либо его
989
+ источник. Не нужен, если передан готовый транспорт.
990
+ settings (TransportSettings | None): Настройки транспорта.
991
+ experimental (frozenset[Capability] | None): Возможности, которые
992
+ вызывающий включает явно, соглашаясь на возможную смену контракта.
993
+ transport (AsyncFetcher | None): Готовый транспорт. Нужен там, где
994
+ вызывающий собирает его сам, и в проверках.
995
+ public_transport (AsyncFetcher | None): Отдельный транспорт рынка без секрета.
996
+ При подставном transport передаётся явно; иначе создаётся лениво.
997
+ public_only (bool): Работа без секрета, только market.offers,
998
+ market.snapshot, market.chips и catalog.search.
999
+ Личный транспорт и файл состояния не принимаются.
1000
+ account_id (str): Устойчивый ключ аккаунта для квоты и привязки прокси.
1001
+ По умолчанию self: клиенты без ключа делят персональную квоту.
1002
+ Ключ не подтверждает авторизацию; её проверяет ответ площадки.
1003
+ budget (Budget | None): Общий бюджет запросов. Передаётся, когда в одном
1004
+ процессе живут несколько клиентов: площадке видна сетевая
1005
+ идентичность, а не то, сколько клиентов мы завели у себя.
1006
+ proxies (tuple[Proxy, ...]): Выходы, между которыми распределяются
1007
+ аккаунты. Пустой набор означает прямое соединение.
1008
+ state_path (str | Path | None): Файл, в котором реестр отправок, реестр
1009
+ выданного и журнал правок цены переживают перезапуск. Без него
1010
+ отправка и правка цены ОТКАЗЫВАЮТ: обе защиты держатся памятью
1011
+ процесса, а память обнуляется.
1012
+ unsafe_sends_without_ledger (bool): Разрешает отправку без долговечного
1013
+ реестра. Оставляет отметку в состоянии здоровья: снять защиту
1014
+ можно, снять её незаметно нельзя.
1015
+ unsafe_price_changes_without_audit (bool): Разрешает правку цены без
1016
+ долговечного журнала. Отметку оставляет так же. Цена послабления
1017
+ здесь - потерянная прежняя цена: истории цен у площадки нет.
1018
+
1019
+ Raises:
1020
+ ConfigurationError: Если параметры несовместимы или личному клиенту
1021
+ не передано ни секрета, ни транспорта. Повтор
1022
+ здесь не поможет, исправлять надо вызов.
1023
+ """
1024
+
1025
+ __slots__ = (
1026
+ "_fetcher",
1027
+ "_public_fetcher",
1028
+ "_public_engine",
1029
+ "_custom_transport",
1030
+ "_closed",
1031
+ "account",
1032
+ "catalog",
1033
+ "chats",
1034
+ "engine",
1035
+ "lots",
1036
+ "market",
1037
+ "monitoring",
1038
+ "_account_id",
1039
+ "orders",
1040
+ "pool",
1041
+ "reviews",
1042
+ )
1043
+
1044
+ def __init__(
1045
+ self,
1046
+ secret: Secret | SecretProvider | None = None,
1047
+ *,
1048
+ settings: TransportSettings | None = None,
1049
+ experimental: frozenset[Capability] | None = None,
1050
+ transport: AsyncFetcher | None = None,
1051
+ public_transport: AsyncFetcher | None = None,
1052
+ public_only: bool = False,
1053
+ account_id: str = DEFAULT_ACCOUNT,
1054
+ budget: Budget | None = None,
1055
+ proxies: tuple[Proxy, ...] = (),
1056
+ state_path: str | Path | None = None,
1057
+ unsafe_sends_without_ledger: bool = False,
1058
+ unsafe_price_changes_without_audit: bool = False,
1059
+ ) -> None:
1060
+ resolved_settings = settings or TransportSettings()
1061
+ if not isinstance(account_id, str) or not account_id.strip():
1062
+ raise ConfigurationError("account_id должен быть непустой строкой")
1063
+ if public_only and (secret is not None or transport is not None or state_path is not None):
1064
+ raise ConfigurationError(
1065
+ "public_only не принимает секрет, личный транспорт или файл состояния"
1066
+ )
1067
+ if public_transport is not None and public_transport is transport:
1068
+ raise ConfigurationError("публичный и личный транспорт должны быть разными")
1069
+ if isinstance(public_transport, AsyncFetcher) and public_transport._secret is not None:
1070
+ raise ConfigurationError("публичный транспорт не должен содержать секрет")
1071
+ if not public_only and secret is None and transport is None:
1072
+ raise ConfigurationError(
1073
+ "клиенту нужен либо секрет, либо готовый транспорт; для рынка есть public_only=True"
1074
+ )
1075
+ self.pool = ProxyPool(
1076
+ proxies, host=host_of(resolved_settings.base_url) or resolved_settings.base_url
1077
+ )
1078
+ identity_name, proxy_url = self.pool.choose(account_id)
1079
+ identity = REGISTRY.get(identity_name)
1080
+ if proxy_url is not None:
1081
+ resolved_settings = replace(resolved_settings, proxy_url=proxy_url)
1082
+ root_budget = budget or identity.budget
1083
+ self.engine = Engine(
1084
+ resolved_settings,
1085
+ budget
1086
+ if budget is not None and account_id == DEFAULT_ACCOUNT
1087
+ else root_budget.for_account("account:" + account_id),
1088
+ experimental or frozenset(),
1089
+ identity,
1090
+ state_path=state_path,
1091
+ unsafe_sends_without_ledger=unsafe_sends_without_ledger,
1092
+ unsafe_price_changes_without_audit=unsafe_price_changes_without_audit,
1093
+ )
1094
+ self._public_engine = Engine(
1095
+ resolved_settings,
1096
+ root_budget.for_account("public_read"),
1097
+ experimental or frozenset(),
1098
+ identity,
1099
+ )
1100
+ self._closed = False
1101
+ self._custom_transport = transport is not None
1102
+ self._public_fetcher = public_transport
1103
+ self._fetcher: AsyncFetcher | None = None
1104
+ if transport is not None:
1105
+ self._fetcher = transport
1106
+ elif secret is not None:
1107
+ resolved = secret if isinstance(secret, Secret) else secret.get("golden_key")
1108
+ self._fetcher = AsyncFetcher(resolved, settings=resolved_settings)
1109
+
1110
+ self.orders = AsyncOrdersService(self)
1111
+ self.chats = AsyncChatsService(self)
1112
+ self.reviews = AsyncReviewsService(self)
1113
+ self.account = AsyncAccountService(self)
1114
+ self.lots = AsyncLotsService(self)
1115
+ self.catalog = AsyncCatalogService(self)
1116
+ self._account_id = account_id
1117
+ self.monitoring = AsyncMonitoring(self)
1118
+ self.market = AsyncMarketService(self)
1119
+
1120
+ async def _read(
1121
+ self, operation: str, build: Callable[[Engine], Generator[Request, Reply, T]]
1122
+ ) -> T:
1123
+ engine = (
1124
+ self._public_engine
1125
+ if OPERATIONS[operation].transport_lane == "public_read"
1126
+ else self.engine
1127
+ )
1128
+ return await self.run(build(engine), engine=engine)
1129
+
1130
+ async def __aenter__(self) -> AsyncClient:
1131
+ """Входит в асинхронный контекстный менеджер.
1132
+
1133
+ Returns:
1134
+ AsyncClient: Сам объект.
1135
+ """
1136
+ return self
1137
+
1138
+ async def __aexit__(self, *exc: object) -> None:
1139
+ """Закрывает соединения при выходе.
1140
+
1141
+ Args:
1142
+ *exc (object): Сведения об исключении. Не используются.
1143
+
1144
+ Returns:
1145
+ None
1146
+ """
1147
+ await self.close()
1148
+
1149
+ async def close(self) -> None:
1150
+ """Закрывает пул соединений.
1151
+
1152
+ Returns:
1153
+ None
1154
+ """
1155
+ self._closed = True
1156
+ try:
1157
+ if self._fetcher is not None:
1158
+ await self._fetcher.close()
1159
+ finally:
1160
+ if self._public_fetcher is not None:
1161
+ await self._public_fetcher.close()
1162
+
1163
+ @property
1164
+ def locale(self) -> Observed[str]:
1165
+ """Возвращает локаль интерфейса, как её отдала площадка.
1166
+
1167
+ Локаль привязана к аккаунту, а не к адресу: переключить её запросом
1168
+ нельзя. Разбор от смены языка не ломается - он структурный, - но поля,
1169
+ приходящие текстом (описание заказа, подпись времени, имя собеседника),
1170
+ возвращаются на этом языке.
1171
+
1172
+ Returns:
1173
+ Observed[str]: Локаль либо причина, по которой её не видно. До
1174
+ первого чтения - не наблюдалась.
1175
+ """
1176
+ engine = self._public_engine if self._fetcher is None else self.engine
1177
+ return engine._state.locale
1178
+
1179
+ @property
1180
+ def stopped(self) -> FunoraError | None:
1181
+ """Возвращает ошибку, остановившую клиента.
1182
+
1183
+ Полная остановка наступает по признаку fail_closed у политики повторов:
1184
+ сегодня это отказ в доступе и страница проверки. Обе - ответ площадки
1185
+ на поведение клиента, а не сбой связи.
1186
+
1187
+ Returns:
1188
+ FunoraError | None: Ошибка либо None, если клиент работает.
1189
+ """
1190
+ engine = self._public_engine if self._fetcher is None else self.engine
1191
+ return engine.stopped
1192
+
1193
+ def resume(self) -> None:
1194
+ """Снимает полную остановку и разрешает снова ходить на площадку.
1195
+
1196
+ Решение принимает человек: он один знает, разобрался ли с причиной.
1197
+ Сама по себе остановка не истекает и по времени не снимается -
1198
+ истекающая означала бы возврат на площадку, которая отказала в доступе,
1199
+ без чьего-либо ведома.
1200
+
1201
+ Returns:
1202
+ None
1203
+ """
1204
+ self.engine.resume()
1205
+ self._public_engine.resume()
1206
+
1207
+ def _capability_profile(self) -> CapabilityProfile:
1208
+ profile = self.engine.capability_profile()
1209
+ public = self._public_engine.capability_profile()
1210
+ return replace(
1211
+ profile,
1212
+ observed_at=max(profile.observed_at, public.observed_at),
1213
+ _evaluations={
1214
+ capability: public.evaluation_of(capability)
1215
+ if (operation := OPERATIONS.get(capability.value))
1216
+ and operation.transport_lane == "public_read"
1217
+ else evaluation
1218
+ for capability, evaluation in profile.evaluations().items()
1219
+ },
1220
+ )
1221
+
1222
+ def capability(self, capability: Capability) -> CapabilityState:
1223
+ """Возвращает текущее состояние возможности.
1224
+
1225
+ Args:
1226
+ capability (Capability): Возможность.
1227
+
1228
+ Returns:
1229
+ CapabilityState: Состояние, каким его видит клиент сейчас.
1230
+ """
1231
+ operation = OPERATIONS.get(capability.value)
1232
+ engine = (
1233
+ self._public_engine
1234
+ if operation and operation.transport_lane == "public_read"
1235
+ else self.engine
1236
+ )
1237
+ return engine.capability(capability)
1238
+
1239
+ async def watch(
1240
+ self,
1241
+ router: Router,
1242
+ *,
1243
+ account_id: str = "self",
1244
+ max_iterations: int | None = None,
1245
+ schedule: Schedule | None = None,
1246
+ state_path: str | Path | None = None,
1247
+ max_threads_per_step: int = 5,
1248
+ use_channel: bool = True,
1249
+ concurrency: int = 1,
1250
+ on_handler_error: Callable[[HandlerError], None] | None = None,
1251
+ ) -> None:
1252
+ """Ведёт наблюдение: опрашивает площадку и раздаёт события обработчикам.
1253
+
1254
+ Метод не блокирует поток: между опросами он отдаёт управление циклу
1255
+ событий. Сам цикл наблюдения целиком описан ядром и совпадает с
1256
+ синхронным до строки.
1257
+
1258
+ Args:
1259
+ router (Router): Реестр обработчиков. Обработчики могут быть как
1260
+ обычными функциями, так и сопрограммами.
1261
+ account_id (str): Идентификатор аккаунта для отпечатков событий.
1262
+ max_iterations (int | None): Сколько шагов сделать. None означает
1263
+ бесконечно; ограничение нужно проверкам и разовым прогонам.
1264
+ schedule (Schedule | None): Расписание опроса. По умолчанию из
1265
+ спецификации.
1266
+ state_path (str | Path | None): Файл, в котором состояние гашения повторов
1267
+ переживает перезапуск.
1268
+ use_channel (bool): Слушать ли канал обновлений площадки.
1269
+
1270
+ ПО УМОЛЧАНИЮ ДА. Канал отвечает за секунды; опрос страниц
1271
+ замечал изменение от трёх секунд до двух минут. События при этом
1272
+ по-прежнему собираются чтением страниц - из канала берётся одно
1273
+ решение «изменилось или нет», - и достоверность не меняется.
1274
+
1275
+ Выключение возвращает прежнее поведение целиком.
1276
+ max_threads_per_step (int): Сколько переписок дочитывать за один
1277
+ шаг. Изменившийся диалог говорит, что в нём что-то произошло, но
1278
+ само сообщение видно только на странице переписки. Предел нужен:
1279
+ изменись разом полсотни диалогов, шаг превратился бы в полсотни
1280
+ запросов. Непрочитанные не теряются - они ждут в очереди.
1281
+ concurrency (int): Сколько ключей упорядочивания раздавать
1282
+ одновременно. Единица - последовательно, как в синхронном
1283
+ клиенте. Больше единицы означает, что обработчики могут
1284
+ выполняться одновременно: счётчик, дописывание в файл или
1285
+ соединение с базой перестают быть в единоличном пользовании, и
1286
+ просить об этом надо явно. Порядок внутри одного ключа
1287
+ сохраняется в любом случае.
1288
+
1289
+ Returns:
1290
+ None
1291
+
1292
+ Raises:
1293
+ FunoraError: Любая ошибка чтения, которую не удалось повторить.
1294
+ """
1295
+ await self.run(
1296
+ self.engine.watch(
1297
+ router,
1298
+ account_id=account_id,
1299
+ max_iterations=max_iterations,
1300
+ schedule=schedule,
1301
+ state_path=state_path,
1302
+ max_threads_per_step=max_threads_per_step,
1303
+ use_channel=use_channel,
1304
+ ),
1305
+ router=router,
1306
+ concurrency=concurrency,
1307
+ on_handler_error=on_handler_error,
1308
+ )
1309
+
1310
+ async def run(
1311
+ self,
1312
+ core: Generator[Request, Reply, T],
1313
+ *,
1314
+ engine: Engine | None = None,
1315
+ router: Router | None = None,
1316
+ concurrency: int = 1,
1317
+ on_handler_error: Callable[[HandlerError], None] | None = None,
1318
+ on_idle: Callable[[int], object] | None = None,
1319
+ ) -> T:
1320
+ """Прокручивает ядро, выполняя то, о чём оно просит.
1321
+
1322
+ Отказ сети не возвращается ядру значением, а бросается внутрь. Иначе
1323
+ политику повторов пришлось бы писать здесь второй раз - а она в ядре
1324
+ написана и проверена.
1325
+
1326
+ Args:
1327
+ core (Generator[Request, Reply, T]): Сопрограмма ядра.
1328
+ engine (Engine | None): Принадлежащее клиенту ядро выбранной полосы.
1329
+ По умолчанию личное; публичное допускает только чтение рынка.
1330
+ router (Router | None): Реестр обработчиков. Нужен только тем
1331
+ сопрограммам, которые просят раздать события.
1332
+ concurrency (int): Сколько ключей упорядочивания раздавать
1333
+ одновременно.
1334
+ on_handler_error (Callable[[HandlerError], None] | None): Что делать
1335
+ с отказом обработчика. Причина отказа живёт только здесь.
1336
+ on_idle (Callable[[int], None] | None): Что делать в паузе между
1337
+ опросами. Вызывается ДО сна и получает длительность паузы в
1338
+ миллисекундах; потраченное вычитается из сна.
1339
+
1340
+ Крючок объявлен и у синхронного клиента, и обещание у обоих
1341
+ одно. Обещание это держится не само собой: watch однажды уже
1342
+ принимал on_handler_error и не передавал его дальше - у
1343
+ синхронного клиента отказ обработчика доходил до вызывающего, у
1344
+ асинхронного пропадал молча.
1345
+
1346
+ Returns:
1347
+ T: То, чем ядро завершилось.
1348
+
1349
+ Raises:
1350
+ FunoraError: Любая ошибка, которую ядро не погасило повтором.
1351
+ """
1352
+ active = self.engine if engine is None else engine
1353
+ if self._closed or active not in (self.engine, self._public_engine):
1354
+ core.close()
1355
+ raise ConfigurationError("клиент закрыт либо ядро принадлежит другому клиенту")
1356
+ if active is self._public_engine and self._public_fetcher is None:
1357
+ if self._custom_transport:
1358
+ core.close()
1359
+ raise ConfigurationError(
1360
+ "для подставного клиента передайте отдельный public_transport"
1361
+ )
1362
+ self._public_fetcher = AsyncFetcher(None, settings=active._settings)
1363
+ fetcher = self._public_fetcher if active is self._public_engine else self._fetcher
1364
+ if fetcher is None:
1365
+ core.close()
1366
+ raise ConfigurationError("public_only разрешает только операции публичной полосы")
1367
+ reply: Reply = None
1368
+ failure: FunoraError | None = None
1369
+ try:
1370
+ while True:
1371
+ try:
1372
+ request = core.throw(failure) if failure is not None else core.send(reply)
1373
+ except StopIteration as stop:
1374
+ result: T = stop.value
1375
+ return result
1376
+ except FunoraError as exc:
1377
+ active.note_operation_error(exc)
1378
+ raise
1379
+ if active is self._public_engine and not public_read_request(request):
1380
+ core.close()
1381
+ raise ConfigurationError("публичная полоса допускает только чтение")
1382
+ failure = None
1383
+ reply = None
1384
+
1385
+ if isinstance(request, Pause):
1386
+ spent = 0.0
1387
+ if on_idle is not None:
1388
+ started = monotonic()
1389
+ # Сопрограмму НАДО ДОЖДАТЬСЯ. Прежде она вызывалась и не
1390
+ # ожидалась: возвращённая сопрограмма выбрасывалась, тело
1391
+ # крючка не выполнялось ни разу, и Python сообщал об этом
1392
+ # предупреждением в поток ошибок - то есть никак.
1393
+ #
1394
+ # Обещание у двух фасадов одно, и держаться оно обязано в
1395
+ # обе стороны: обычная функция здесь работает так же.
1396
+ outcome = on_idle(request.ms)
1397
+ if isinstance(outcome, Awaitable):
1398
+ await outcome
1399
+ spent = (monotonic() - started) * 1000
1400
+ remaining = request.ms - spent
1401
+ if remaining > 0:
1402
+ await asyncio.sleep(remaining / 1000)
1403
+ elif isinstance(request, Fetch):
1404
+ try:
1405
+ reply = await (
1406
+ self._fetch(request.path)
1407
+ if active is self.engine
1408
+ else fetcher.fetch(request.path)
1409
+ )
1410
+ except FunoraError as exc:
1411
+ failure = exc
1412
+ elif isinstance(request, Submit):
1413
+ # Отправка идёт мимо _fetch нарочно: у записи своё правило -
1414
+ # переход в ответ на неё не повторяется.
1415
+ try:
1416
+ reply = await fetcher.submit(request.path, request.fields, request.headers)
1417
+ except FunoraError as exc:
1418
+ failure = exc
1419
+ elif isinstance(request, Upload):
1420
+ # Загрузка идёт мимо _fetch по той же причине, что и отправка
1421
+ # формы: переход в ответ на запись не повторяется.
1422
+ try:
1423
+ reply = await fetcher.upload(
1424
+ request.path,
1425
+ field=request.field,
1426
+ filename=request.filename,
1427
+ content=request.content,
1428
+ content_type=request.content_type,
1429
+ headers=request.headers,
1430
+ )
1431
+ except FunoraError as exc:
1432
+ failure = exc
1433
+ elif isinstance(request, Query):
1434
+ # Структурный вопрос идёт мимо _fetch: тело у него JSON, а не
1435
+ # поля формы. Правило перехода при этом ЧТЕНИЯ, а не записи -
1436
+ # повтор здесь безвреден.
1437
+ try:
1438
+ reply = await fetcher.query(request.path, request.payload, request.headers)
1439
+ except FunoraError as exc:
1440
+ failure = exc
1441
+ elif isinstance(request, Ask):
1442
+ # Вопрос методом GET с ответом объектом. Мимо _fetch: переходы
1443
+ # здесь не выполняются - переход отсюда означает не «страница
1444
+ # переехала», а «нас выкинуло на страницу», и разбирать её как
1445
+ # объект нельзя.
1446
+ try:
1447
+ reply = await fetcher.ask(request.path, request.headers)
1448
+ except FunoraError as exc:
1449
+ failure = exc
1450
+ elif isinstance(request, Deliver):
1451
+ if router is None:
1452
+ raise ConfigurationError(
1453
+ "ядро просит раздать события, но реестр обработчиков не передан"
1454
+ )
1455
+ reply = await adispatch(router, request.events, concurrency=concurrency)
1456
+ # Итог раздачи дальше уходит ядру, а ядро читает у него
1457
+ # delivered, advance, fatal и длину failed. Причина отказа
1458
+ # живёт только здесь, и не отдать её сейчас значит потерять
1459
+ # насовсем.
1460
+ if on_handler_error is not None:
1461
+ # Имя намеренно не failure: так зовут переменную, которой
1462
+ # цикл бросает ошибку ВНУТРЬ ядра. Затерев её здесь, мы
1463
+ # отправили бы отказ обработчика в ядро как условие
1464
+ # площадки и уронили бы наблюдение вместо жалобы.
1465
+ for handler_error in reply.errors:
1466
+ on_handler_error(handler_error)
1467
+ finally:
1468
+ core.close()
1469
+
1470
+ async def _fetch(self, path: str) -> Observation:
1471
+ """Выполняет одно обращение к площадке.
1472
+
1473
+ Args:
1474
+ path (str): Путь страницы.
1475
+
1476
+ Returns:
1477
+ Observation: Результат обращения.
1478
+
1479
+ Raises:
1480
+ FunoraError: При сетевом отказе либо непригодном ответе.
1481
+ """
1482
+ if self._fetcher is None:
1483
+ raise ConfigurationError("личный транспорт недоступен")
1484
+ return await self._fetcher.fetch(path)