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.
- funora/__init__.py +265 -0
- funora/_account.py +661 -0
- funora/_aclient.py +1484 -0
- funora/_budget.py +579 -0
- funora/_calc.py +182 -0
- funora/_canonical.py +235 -0
- funora/_catalog.py +473 -0
- funora/_chat_history.py +367 -0
- funora/_chats.py +392 -0
- funora/_chips.py +403 -0
- funora/_classify.py +466 -0
- funora/_client.py +1490 -0
- funora/_currency_switch.py +130 -0
- funora/_cursor.py +120 -0
- funora/_delivered.py +233 -0
- funora/_diff.py +752 -0
- funora/_engine.py +4795 -0
- funora/_extract.py +124 -0
- funora/_field_schema.py +112 -0
- funora/_fileio.py +58 -0
- funora/_gate.py +69 -0
- funora/_hops.py +101 -0
- funora/_host.py +120 -0
- funora/_identity.py +284 -0
- funora/_json.py +31 -0
- funora/_listen.py +312 -0
- funora/_lot_form.py +331 -0
- funora/_market.py +406 -0
- funora/_matching.py +147 -0
- funora/_money.py +279 -0
- funora/_monitoring.py +396 -0
- funora/_observed.py +237 -0
- funora/_order.py +552 -0
- funora/_order_details.py +281 -0
- funora/_orders.py +807 -0
- funora/_outbound.py +453 -0
- funora/_own_lots.py +301 -0
- funora/_poll.py +410 -0
- funora/_price_audit.py +281 -0
- funora/_proxies.py +232 -0
- funora/_raise.py +150 -0
- funora/_refund.py +102 -0
- funora/_result.py +157 -0
- funora/_retry.py +213 -0
- funora/_review_write.py +138 -0
- funora/_reviews.py +584 -0
- funora/_runner.py +651 -0
- funora/_secret.py +385 -0
- funora/_showcase.py +362 -0
- funora/_signals.py +375 -0
- funora/_skeleton.py +752 -0
- funora/_snapshot.py +276 -0
- funora/_state.py +279 -0
- funora/_stock.py +32 -0
- funora/_thread.py +574 -0
- funora/_transport.py +1023 -0
- funora/_updates.py +292 -0
- funora/_verdicts.py +91 -0
- funora/_viewing.py +143 -0
- funora/_watch.py +825 -0
- funora/_watch_state.py +237 -0
- funora/_whoami.py +546 -0
- funora/bot/__init__.py +52 -0
- funora/bot/_delivery.py +341 -0
- funora/bot/_outbox.py +261 -0
- funora/bot/_runtime.py +447 -0
- funora/bot/_spool.py +534 -0
- funora/budget.py +311 -0
- funora/capabilities.py +297 -0
- funora/conformance.py +758 -0
- funora/contract.py +73 -0
- funora/errors.py +996 -0
- funora/events.py +189 -0
- funora/extraction.py +420 -0
- funora/observe.py +560 -0
- funora/operations.py +671 -0
- funora/py.typed +0 -0
- funora/reconciliation.py +51 -0
- funora/response_classes.py +155 -0
- funora/retry.py +238 -0
- funora/send_outcome.py +78 -0
- funora/skeleton_format.py +77 -0
- funora-0.0.1.dev2.dist-info/METADATA +294 -0
- funora-0.0.1.dev2.dist-info/RECORD +87 -0
- funora-0.0.1.dev2.dist-info/WHEEL +4 -0
- funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
- funora-0.0.1.dev2.dist-info/licenses/LICENSE +201 -0
funora/_order_details.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Структурное чтение подробностей заказа.
|
|
2
|
+
|
|
3
|
+
ЕДИНСТВЕННАЯ ТОЧКА, ОТДАЮЩАЯ ЗАКАЗ СТРУКТУРНО, а не разметкой. Она даёт то,
|
|
4
|
+
чего страница не даёт ни в каком виде: сумму ЧИСЛОМ, код валюты, разделение
|
|
5
|
+
покупателя и продавца, состояние строкой и раздел парой «тип - номер».
|
|
6
|
+
|
|
7
|
+
СВОЕГО НАБЛЮДЕНИЯ У НАС ЗДЕСЬ НЕТ НИ ОДНОГО, и это отличает точку от прочих на
|
|
8
|
+
вторичном источнике. У поднятия наблюдён запрос; у отзыва - два поля из четырёх;
|
|
9
|
+
у расчёта цены - итог на странице. Здесь не наблюдено ничего.
|
|
10
|
+
|
|
11
|
+
ЭТО МОГЛО БЫ СОБРАТЬ Order, И НЕ СОБИРАЕТ. Модель Order не строится ни одной
|
|
12
|
+
реализацией из-за трёх причин сразу: сумма текстом, валюта знаком, покупатель и
|
|
13
|
+
продавец неразделимы. Эта точка снимает все три.
|
|
14
|
+
|
|
15
|
+
И всё же Order из неё не собирается. Модель контракта, построенная на ответе,
|
|
16
|
+
которого никто не видел, - худший вид обещания: второй SDK прочтёт её как
|
|
17
|
+
описание наблюдённого. Возвращается OrderDetails - проекция, отличимая по типу.
|
|
18
|
+
|
|
19
|
+
Первое живое наблюдение это изменит, и будет оно самой крупной разблокировкой
|
|
20
|
+
контракта.
|
|
21
|
+
|
|
22
|
+
Известно от FunPayAPI (FunPayCardinal, account.py, get_orders_by_ids).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from typing import Any, Final
|
|
30
|
+
|
|
31
|
+
from ._observed import Observed
|
|
32
|
+
from .errors import ProtocolChangedError, ValidationError
|
|
33
|
+
from .extraction import OrderStatus
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"OrderDetails",
|
|
37
|
+
"OrderDetailsBatch",
|
|
38
|
+
"parse_order_details",
|
|
39
|
+
"ORDER_DETAILS_PATH",
|
|
40
|
+
"BATCH_MAX",
|
|
41
|
+
"DEFAULT_INCLUDE",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
#: Адрес структурного чтения. Известен от сторонней реализации.
|
|
45
|
+
#:
|
|
46
|
+
#: Полной ссылкой он там и записан: языковой префикс к нему не применяется, а
|
|
47
|
+
#: язык передаётся заголовком. У нас путь - хост подставляет транспорт, и это
|
|
48
|
+
#: то же самое, только без возможности уйти на чужой.
|
|
49
|
+
ORDER_DETAILS_PATH: Final[str] = "/api/orders/get"
|
|
50
|
+
|
|
51
|
+
#: Наибольший размер пачки.
|
|
52
|
+
#:
|
|
53
|
+
#: Взят у стороннего источника, и откуда там - из ответа площадки либо из
|
|
54
|
+
#: осторожности автора - по его коду не видно. Соблюдаем: нарушать неизвестное
|
|
55
|
+
#: ограничение дороже, чем сделать два запроса вместо одного.
|
|
56
|
+
BATCH_MAX: Final[int] = 10
|
|
57
|
+
|
|
58
|
+
#: Разделы ответа, которые запрашиваются по умолчанию.
|
|
59
|
+
DEFAULT_INCLUDE: Final[tuple[str, ...]] = ("details", "users")
|
|
60
|
+
|
|
61
|
+
#: Наши состояния по имени. Перечень НАШ и НАБЛЮДЁННЫЙ.
|
|
62
|
+
#:
|
|
63
|
+
#: Сторонний источник называет пять значений; мы наблюдали три - в разметке
|
|
64
|
+
#: списка продаж. Дописать сюда остальные по чужому слову значило бы выдать
|
|
65
|
+
#: чужое знание за своё наблюдение, и проверка на это стоит отдельно.
|
|
66
|
+
_KNOWN_STATUS: Final[dict[str, OrderStatus]] = {one.value: one for one in OrderStatus}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class OrderDetails:
|
|
71
|
+
"""Подробности одного заказа, прочитанные структурно.
|
|
72
|
+
|
|
73
|
+
Attributes:
|
|
74
|
+
order_uid (str): Идентификатор заказа.
|
|
75
|
+
status_text (str): Состояние СТРОКОЙ, как назвала площадка. Всегда и
|
|
76
|
+
как есть.
|
|
77
|
+
status (Observed[OrderStatus]): Состояние значением НАШЕГО перечня -
|
|
78
|
+
если оно там есть. Не совпало - остаётся ненаблюдённым.
|
|
79
|
+
amount (Observed[str]): Сумма, приведённая к строке. Числом она здесь и
|
|
80
|
+
приходит; строкой отдаётся, чтобы не потерять точность на двоичной
|
|
81
|
+
дроби.
|
|
82
|
+
currency (Observed[str]): Код валюты - кодом, а не знаком.
|
|
83
|
+
buyer_id (Observed[str]): Идентификатор покупателя.
|
|
84
|
+
buyer_name (Observed[str]): Имя покупателя.
|
|
85
|
+
seller_id (Observed[str]): Идентификатор продавца.
|
|
86
|
+
seller_name (Observed[str]): Имя продавца.
|
|
87
|
+
section_type (Observed[str]): Вид раздела.
|
|
88
|
+
section_id (Observed[str]): Номер раздела.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
order_uid: str
|
|
92
|
+
status_text: str
|
|
93
|
+
status: Observed[OrderStatus]
|
|
94
|
+
amount: Observed[str]
|
|
95
|
+
currency: Observed[str]
|
|
96
|
+
buyer_id: Observed[str]
|
|
97
|
+
buyer_name: Observed[str]
|
|
98
|
+
seller_id: Observed[str]
|
|
99
|
+
seller_name: Observed[str]
|
|
100
|
+
section_type: Observed[str]
|
|
101
|
+
section_id: Observed[str]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class OrderDetailsBatch:
|
|
106
|
+
"""Итог чтения заказов пачкой.
|
|
107
|
+
|
|
108
|
+
Attributes:
|
|
109
|
+
asked (tuple[str, ...]): О чём спрашивали.
|
|
110
|
+
found (tuple[OrderDetails, ...]): Что вернулось.
|
|
111
|
+
missing (tuple[str, ...]): Спрошенное, но не вернувшееся.
|
|
112
|
+
|
|
113
|
+
СПРОШЕННОЕ И ПОЛУЧЕННОЕ РАЗВЕДЕНЫ НАРОЧНО. Площадка вправе вернуть
|
|
114
|
+
не все заказы, и молчаливая потеря одного из десяти выглядела бы
|
|
115
|
+
как «такого заказа нет».
|
|
116
|
+
observed_at (datetime): Момент получения ответа.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
asked: tuple[str, ...]
|
|
120
|
+
found: tuple[OrderDetails, ...]
|
|
121
|
+
missing: tuple[str, ...]
|
|
122
|
+
observed_at: datetime
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _text(source: dict[str, Any], *path: str) -> Observed[str]:
|
|
126
|
+
"""Достаёт вложенное значение и приводит его к строке.
|
|
127
|
+
|
|
128
|
+
ЧИСЛО ПРИВОДИТСЯ К СТРОКЕ, А НЕ К ДРОБНОМУ. Сумма приходит числом, и
|
|
129
|
+
сохранить её дробным значило бы потерять точность там, где считают деньги:
|
|
130
|
+
0.1 + 0.2 даёт 0.30000000000000004 всюду, где считают двоичной дробью.
|
|
131
|
+
|
|
132
|
+
Аргументы:
|
|
133
|
+
source (dict[str, Any]): Объект ответа.
|
|
134
|
+
path (str): Путь до значения по ключам.
|
|
135
|
+
|
|
136
|
+
Возвращает:
|
|
137
|
+
Observed[str]: Значение строкой либо причина отсутствия.
|
|
138
|
+
"""
|
|
139
|
+
current: Any = source
|
|
140
|
+
for key in path:
|
|
141
|
+
if not isinstance(current, dict) or key not in current:
|
|
142
|
+
return Observed.missing(f"{'.'.join(path)}_not_in_response")
|
|
143
|
+
current = current[key]
|
|
144
|
+
|
|
145
|
+
# Логическое исключается: истина в Python - это единица, и сумма True
|
|
146
|
+
# прочиталась бы как «один».
|
|
147
|
+
if isinstance(current, bool) or current is None:
|
|
148
|
+
return Observed.missing(f"{'.'.join(path)}_not_a_value")
|
|
149
|
+
if isinstance(current, str):
|
|
150
|
+
value = current.strip()
|
|
151
|
+
return Observed.present(value) if value else Observed.empty("")
|
|
152
|
+
if isinstance(current, int | float):
|
|
153
|
+
return Observed.present(repr(current) if isinstance(current, float) else str(current))
|
|
154
|
+
return Observed.missing(f"{'.'.join(path)}_not_a_value")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _one(record: Any, order_uid: str) -> OrderDetails:
|
|
158
|
+
"""Собирает подробности одного заказа.
|
|
159
|
+
|
|
160
|
+
Аргументы:
|
|
161
|
+
record (Any): Запись из ответа.
|
|
162
|
+
order_uid (str): Идентификатор заказа.
|
|
163
|
+
|
|
164
|
+
Возвращает:
|
|
165
|
+
OrderDetails: Подробности.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
ProtocolChangedError: Если запись непригодна.
|
|
169
|
+
"""
|
|
170
|
+
if not isinstance(record, dict):
|
|
171
|
+
raise ProtocolChangedError(
|
|
172
|
+
f"запись заказа {order_uid} не объект, а {type(record).__name__}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
raw_status = record.get("status")
|
|
176
|
+
if not isinstance(raw_status, str) or not raw_status.strip():
|
|
177
|
+
raise ProtocolChangedError(
|
|
178
|
+
f"у заказа {order_uid} нет состояния строкой. Считать заказ "
|
|
179
|
+
"оплаченным без признака нельзя: по этому признаку выдают товар"
|
|
180
|
+
)
|
|
181
|
+
status_text = raw_status.strip()
|
|
182
|
+
|
|
183
|
+
# СОСТОЯНИЕ НЕ ПРИВОДИТСЯ К НАШЕМУ ПЕРЕЧНЮ НАСИЛЬНО. Совпало со знакомым -
|
|
184
|
+
# отдаётся и строкой, и значением. Не совпало - только строкой.
|
|
185
|
+
known = _KNOWN_STATUS.get(status_text)
|
|
186
|
+
status = (
|
|
187
|
+
Observed.present(known)
|
|
188
|
+
if known is not None
|
|
189
|
+
else Observed.missing(f"status_not_in_observed_set:{status_text}")
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return OrderDetails(
|
|
193
|
+
order_uid=order_uid,
|
|
194
|
+
status_text=status_text,
|
|
195
|
+
status=status,
|
|
196
|
+
amount=_text(record, "amount"),
|
|
197
|
+
currency=_text(record, "currency"),
|
|
198
|
+
buyer_id=_text(record, "buyer", "user_id"),
|
|
199
|
+
buyer_name=_text(record, "buyer", "name"),
|
|
200
|
+
seller_id=_text(record, "seller", "user_id"),
|
|
201
|
+
seller_name=_text(record, "seller", "name"),
|
|
202
|
+
section_type=_text(record, "section", "type_id"),
|
|
203
|
+
section_id=_text(record, "section", "local_id"),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def parse_order_details(
|
|
208
|
+
payload: Any, *, asked: tuple[str, ...], observed_at: datetime
|
|
209
|
+
) -> OrderDetailsBatch:
|
|
210
|
+
"""Разбирает ответ структурного чтения заказов.
|
|
211
|
+
|
|
212
|
+
Аргументы:
|
|
213
|
+
payload (Any): Разобранное тело ответа.
|
|
214
|
+
asked (tuple[str, ...]): О чём спрашивали.
|
|
215
|
+
observed_at (datetime): Момент получения.
|
|
216
|
+
|
|
217
|
+
Возвращает:
|
|
218
|
+
OrderDetailsBatch: Итог.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
ProtocolChangedError: Если ответ непригоден для чтения.
|
|
222
|
+
"""
|
|
223
|
+
if not isinstance(payload, dict):
|
|
224
|
+
raise ProtocolChangedError(f"ответ чтения заказов не объект, а {type(payload).__name__}")
|
|
225
|
+
|
|
226
|
+
raw_status = payload.get("status")
|
|
227
|
+
if raw_status != "SUCCESS":
|
|
228
|
+
raise ProtocolChangedError(
|
|
229
|
+
f"ответ чтения заказов несёт признак исхода {raw_status!r}, а успех "
|
|
230
|
+
"обозначается строкой SUCCESS. Прочитать заказы по такому ответу "
|
|
231
|
+
"нельзя: неизвестно даже, о тех ли он заказах"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
raw_data = payload.get("data")
|
|
235
|
+
if not isinstance(raw_data, dict):
|
|
236
|
+
raise ProtocolChangedError(
|
|
237
|
+
"в ответе чтения заказов нет словаря data. Пустой словарь и "
|
|
238
|
+
"отсутствующий - разные вещи: первый означает «ни один не найден», "
|
|
239
|
+
"второй - что мы читаем не тот ответ"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
found: list[OrderDetails] = []
|
|
243
|
+
for order_uid in sorted(raw_data):
|
|
244
|
+
found.append(_one(raw_data[order_uid], order_uid))
|
|
245
|
+
|
|
246
|
+
returned = {one.order_uid for one in found}
|
|
247
|
+
return OrderDetailsBatch(
|
|
248
|
+
asked=asked,
|
|
249
|
+
found=tuple(found),
|
|
250
|
+
missing=tuple(one for one in asked if one not in returned),
|
|
251
|
+
observed_at=observed_at,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def check_batch(order_ids: tuple[str, ...]) -> None:
|
|
256
|
+
"""Проверяет пачку до обращения к сети.
|
|
257
|
+
|
|
258
|
+
Аргументы:
|
|
259
|
+
order_ids (tuple[str, ...]): Идентификаторы заказов.
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
ValidationError: Если пачка пуста, велика либо несёт непригодный
|
|
263
|
+
идентификатор.
|
|
264
|
+
"""
|
|
265
|
+
if not order_ids:
|
|
266
|
+
raise ValidationError("пачка пуста: спрашивать не о чем")
|
|
267
|
+
if len(order_ids) > BATCH_MAX:
|
|
268
|
+
raise ValidationError(
|
|
269
|
+
f"в пачке {len(order_ids)} заказов, а наибольший наблюдённый размер "
|
|
270
|
+
f"{BATCH_MAX}. Граница взята у стороннего источника, и откуда она "
|
|
271
|
+
"там - неизвестно; нарушать неизвестное ограничение дороже, чем "
|
|
272
|
+
"сделать два запроса"
|
|
273
|
+
)
|
|
274
|
+
if len(set(order_ids)) != len(order_ids):
|
|
275
|
+
raise ValidationError(
|
|
276
|
+
"в пачке есть повторы. Ответ приходит словарём по идентификатору, и "
|
|
277
|
+
"повтор занял бы место в пачке, ничего не добавив"
|
|
278
|
+
)
|
|
279
|
+
for one in order_ids:
|
|
280
|
+
if not one.strip() or not one.strip().isalnum():
|
|
281
|
+
raise ValidationError(f"идентификатор заказа {one!r} обязан состоять из букв и цифр")
|