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/_calc.py ADDED
@@ -0,0 +1,182 @@
1
+ """Расчёт цены покупателя по цене продавца.
2
+
3
+ ЗАЧЕМ ЭТО НУЖНО. Цена, которую ставит продавец, и цена, которую платит
4
+ покупатель, - РАЗНЫЕ ВЕЛИЧИНЫ: между ними комиссия площадки, и зависит она от
5
+ способа оплаты.
6
+
7
+ Разрыв этот наблюдаем нашим же снимком формы правки лота - там лежит таблица
8
+ .table-buyers-prices, которую эта точка и перерисовывает, - а в контракте до
9
+ 31.08.2026 не назывался нигде. Молчание читалось как «цена одна».
10
+
11
+ ЦЕНЫ ОТДАЮТСЯ ТЕКСТОМ, и это не лень. Разделитель дробной части нам не
12
+ наблюдался, и перевод «1 234,56» в число зависит от локали.
13
+
14
+ Сторонняя реализация, у которой взят состав запроса, здесь убирает пробелы и
15
+ зовёт float. На локали с запятой это отказ САМОГО ЯЗЫКА - падение вместо ответа,
16
+ и падение у вызывающего, который ничего такого не просил.
17
+
18
+ Наблюдено нами: таблица итога на странице правки лота.
19
+ Известно от FunPayAPI (FunPayCardinal, account.py, calc): адреса, имена полей
20
+ запроса и ключи ответа.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ from datetime import datetime
27
+ from typing import Any, Final
28
+
29
+ from ._observed import Observed
30
+ from .errors import ProtocolChangedError
31
+
32
+ __all__ = [
33
+ "PaymentMethod",
34
+ "PriceCalculation",
35
+ "parse_calculation",
36
+ "LOTS_CALC_PATH",
37
+ "CHIPS_CALC_PATH",
38
+ ]
39
+
40
+ #: Адрес расчёта для обычных разделов. Известен от сторонней реализации.
41
+ LOTS_CALC_PATH: Final[str] = "/lots/calc"
42
+
43
+ #: Адрес расчёта для рынка по количеству. Оттуда же.
44
+ CHIPS_CALC_PATH: Final[str] = "/chips/calc"
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class PaymentMethod:
49
+ """Один способ оплаты и цена покупателя при нём.
50
+
51
+ Attributes:
52
+ name (str): Название способа на локали интерфейса, КАК ЕСТЬ. Не
53
+ разбирается и не классифицируется: выводить из него способ значило
54
+ бы строить разбор на переводе.
55
+ price_text (str): Цена покупателя текстом. Числом не отдаётся -
56
+ разделитель дробной части не наблюдался.
57
+ currency_symbol (Observed[str]): Знак валюты этой цены. Перевод в код
58
+ делает funora.currency_of_symbol.
59
+ sort (Observed[int]): Порядковое число, которым площадка сортирует
60
+ способы.
61
+ """
62
+
63
+ name: str
64
+ price_text: str
65
+ currency_symbol: Observed[str]
66
+ sort: Observed[int]
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class PriceCalculation:
71
+ """Что заплатит покупатель за названную цену продавца.
72
+
73
+ Attributes:
74
+ methods (tuple[PaymentMethod, ...]): Способы оплаты и цены при них.
75
+ Пустой перечень означает наблюдение «способов не предложено», а не
76
+ неудачу разбора: последнюю выражает отказ.
77
+ min_price_text (Observed[str]): Наименьшая цена, которую площадка
78
+ разрешает поставить, КАК ОНА ЕЁ НАЗВАЛА - со знаком валюты и
79
+ целиком. Делить эту строку мы не станем: сторонняя реализация делит
80
+ её по последнему пробелу и зовёт float на первой половине.
81
+ asked_price (str): Цена продавца, о которой спрашивали.
82
+ observed_at (datetime): Момент получения ответа.
83
+ """
84
+
85
+ methods: tuple[PaymentMethod, ...]
86
+ min_price_text: Observed[str]
87
+ asked_price: str
88
+ observed_at: datetime
89
+
90
+
91
+ def _sort_of(raw: Any) -> Observed[int]:
92
+ """Читает порядковое число способа оплаты.
93
+
94
+ Аргументы:
95
+ raw (Any): Значение из ответа.
96
+
97
+ Возвращает:
98
+ Observed[int]: Число либо причина отсутствия.
99
+ """
100
+ # Логическое исключается отдельно: истина в Python - это единица, и порядок
101
+ # True встал бы между нулевым и вторым, ни разу не будучи числом.
102
+ if isinstance(raw, bool):
103
+ return Observed.missing("sort_not_a_number")
104
+ if isinstance(raw, int):
105
+ return Observed.present(raw)
106
+ if isinstance(raw, str) and raw.strip().lstrip("-").isdigit():
107
+ return Observed.present(int(raw.strip()))
108
+ return Observed.missing("sort_not_a_number")
109
+
110
+
111
+ def parse_calculation(payload: Any, *, asked_price: str, observed_at: datetime) -> PriceCalculation:
112
+ """Разбирает ответ площадки на расчёт цены.
113
+
114
+ Аргументы:
115
+ payload (Any): Разобранное тело ответа.
116
+ asked_price (str): Цена продавца, о которой спрашивали.
117
+ observed_at (datetime): Момент получения.
118
+
119
+ Возвращает:
120
+ PriceCalculation: Расчёт.
121
+
122
+ Raises:
123
+ ProtocolChangedError: Если ответ непригоден для чтения.
124
+ """
125
+ if not isinstance(payload, dict):
126
+ raise ProtocolChangedError(f"ответ расчёта не объект, а {type(payload).__name__}")
127
+
128
+ error = payload.get("error")
129
+ if error:
130
+ raise ProtocolChangedError(
131
+ f"площадка отказала в расчёте: {error!r}. Что именно ей не подошло - "
132
+ "неизвестно, а толковать её текст мы не станем"
133
+ )
134
+
135
+ raw_methods = payload.get("methods")
136
+ if not isinstance(raw_methods, list):
137
+ raise ProtocolChangedError(
138
+ "в ответе расчёта нет перечня способов оплаты. Пустой перечень и "
139
+ "отсутствующий - разные вещи: первый означает «способов не "
140
+ "предложено», второй - что мы читаем не тот ответ"
141
+ )
142
+
143
+ methods: list[PaymentMethod] = []
144
+ for one in raw_methods:
145
+ if not isinstance(one, dict):
146
+ raise ProtocolChangedError(f"способ оплаты не объект, а {type(one).__name__}")
147
+
148
+ raw_price = one.get("price")
149
+ if not isinstance(raw_price, str) or not raw_price.strip():
150
+ raise ProtocolChangedError(
151
+ "у способа оплаты нет цены строкой. Подставить сюда ноль значило "
152
+ "бы сказать продавцу, что покупатель заплатит ничего"
153
+ )
154
+
155
+ raw_name = one.get("name")
156
+ raw_unit = one.get("unit")
157
+ methods.append(
158
+ PaymentMethod(
159
+ name=raw_name if isinstance(raw_name, str) else "",
160
+ price_text=raw_price.strip(),
161
+ currency_symbol=(
162
+ Observed.present(raw_unit.strip())
163
+ if isinstance(raw_unit, str) and raw_unit.strip()
164
+ else Observed.missing("unit_not_in_response")
165
+ ),
166
+ sort=_sort_of(one.get("sort")),
167
+ )
168
+ )
169
+
170
+ raw_min = payload.get("minPrice")
171
+ minimum = (
172
+ Observed.present(raw_min.strip())
173
+ if isinstance(raw_min, str) and raw_min.strip()
174
+ else Observed.missing("min_price_not_in_response")
175
+ )
176
+
177
+ return PriceCalculation(
178
+ methods=tuple(methods),
179
+ min_price_text=minimum,
180
+ asked_price=asked_price,
181
+ observed_at=observed_at,
182
+ )
funora/_canonical.py ADDED
@@ -0,0 +1,235 @@
1
+ """Каноническая сериализация JSON.
2
+
3
+ Правила объявлены в docs/canonical-json.md спецификации и до сих пор не
4
+ выполнялись ни одной строкой кода. Файл состояния при этом штамповал в себя
5
+ `canonical_form_version` - то есть утверждал про себя то, чего никто не делал:
6
+ он писался с пробелами после двоеточия и запятой, с числами с плавающей точкой
7
+ и без нормализации Unicode.
8
+
9
+ Зачем форма нужна. Отпечаток события - ключ идемпотентности, общий для шести
10
+ реализаций. Если байты, от которых считается хэш, зависят от того, каким
11
+ сериализатором пользуется язык, то две реализации дадут одному событию два
12
+ разных идентификатора, и гашение повторов у человека, подключившего обе, не
13
+ сработает ни разу. Для обработчика выдачи это выданный дважды товар.
14
+
15
+ Форма нарочно узкая. Она не универсальный сериализатор и не должна им быть:
16
+ всё, чего она не умеет выразить однозначно, она отвергает вслух. Число с
17
+ плавающей точкой не имеет единственного десятичного вида, поэтому запрещено;
18
+ ключ не-строка не имеет порядка по кодовым точкам, поэтому запрещён.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import math
25
+ import unicodedata
26
+ from datetime import UTC, datetime
27
+ from typing import Any, Final, NoReturn
28
+
29
+ from ._money import Money
30
+ from .errors import ValidationError
31
+
32
+ __all__ = [
33
+ "canonical_dumps",
34
+ "canonical_instant",
35
+ "canonical_normalize",
36
+ ]
37
+
38
+ #: Разделители без единого незначащего пробела.
39
+ #:
40
+ #: Правило 5. Умолчание Python - ``(", ", ": ")``, и именно оно делало файл
41
+ #: состояния неканоническим, оставаясь незаметным: JSON от этого не перестаёт
42
+ #: разбираться, расходятся только байты - то есть ровно то, от чего берут хэш.
43
+ _SEPARATORS: Final[tuple[str, str]] = (",", ":")
44
+
45
+
46
+ def canonical_normalize(text: str) -> str:
47
+ """Приводит строку к форме Unicode NFC.
48
+
49
+ Правило 2. Одна и та же буква записывается по-разному: «ё» бывает одним
50
+ знаком и парой «е» с диакритикой. Глазами не отличить, байтами - разные
51
+ строки, и хэш от них разный.
52
+
53
+ Откуда берётся разница: страница приходит из сети, проходит через стек
54
+ HTTP и разборщик разметки, и каждый из них вправе нормализовать текст
55
+ по-своему. Две реализации на разных языках получат из одной страницы разные
56
+ байты одного и того же идентификатора.
57
+
58
+ Args:
59
+ text (str): Исходная строка.
60
+
61
+ Returns:
62
+ str: Та же строка в форме NFC.
63
+ """
64
+ return unicodedata.normalize("NFC", text)
65
+
66
+
67
+ def canonical_instant(value: datetime) -> str:
68
+ """Приводит момент к канонической форме.
69
+
70
+ Правило 13: RFC 3339 в UTC, суффикс Z, ровно три знака миллисекунд. Три, а
71
+ не шесть и не сколько получится: длина части после точки меняет байты, а
72
+ байты входят в хэш.
73
+
74
+ Доля миллисекунды ОТБРАСЫВАЕТСЯ, а не округляется. Округление вправе
75
+ сдвинуть момент вперёд - объявить, что наблюдение случилось позже, чем
76
+ случилось. Отбрасывание сдвигает назад, то есть говорит «не позже чем», и
77
+ это единственная сторона, в которую метке времени ошибаться безопасно.
78
+
79
+ Args:
80
+ value (datetime): Момент. Со смещением, отличным от Z, переводится в
81
+ UTC: смещение - законная запись того же момента.
82
+
83
+ Returns:
84
+ str: Каноническая запись момента.
85
+
86
+ Raises:
87
+ ValidationError: Если у момента нет часового пояса. Счесть его UTC либо
88
+ местным - домысел, а цена домысла здесь до суток разницы:
89
+ наблюдение окажется в будущем либо в прошлом, и отпечаток события
90
+ разойдётся у двух реализаций, работающих в разных поясах.
91
+ """
92
+ if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
93
+ raise ValidationError(
94
+ f"момент {value!r} без часового пояса. Счесть его UTC либо местным - "
95
+ "домысел ценой до суток разницы; передайте момент с поясом"
96
+ )
97
+
98
+ moment = value.astimezone(UTC)
99
+ stamp = moment.strftime("%Y-%m-%dT%H:%M:%S")
100
+ return f"{stamp}.{moment.microsecond // 1000:03d}Z"
101
+
102
+
103
+ def _reject(value: Any, where: str) -> NoReturn:
104
+ """Отвергает значение, которое каноническая форма выразить не может.
105
+
106
+ Args:
107
+ value (Any): Значение.
108
+ where (str): Путь до значения, для сообщения.
109
+
110
+ Returns:
111
+ None
112
+
113
+ Raises:
114
+ ValidationError: Всегда.
115
+ """
116
+ raise ValidationError(
117
+ f"каноническая форма не выражает {type(value).__name__} однозначно "
118
+ f"(путь {where}). Значение: {value!r}"
119
+ )
120
+
121
+
122
+ def _prepare(value: Any, where: str) -> Any:
123
+ """Готовит значение к сериализации: нормализует и проверяет.
124
+
125
+ Args:
126
+ value (Any): Значение любого поддерживаемого вида.
127
+ where (str): Путь до значения, для сообщения об отказе.
128
+
129
+ Returns:
130
+ Any: Значение, годное для json.dumps в канонической форме.
131
+
132
+ Raises:
133
+ ValidationError: Если значение невыразимо: число с плавающей точкой,
134
+ ключ не-строка, множество, произвольный объект.
135
+ """
136
+ if value is None or isinstance(value, bool):
137
+ return value
138
+
139
+ if isinstance(value, int):
140
+ # Правило 7: целое выводится без дробной части и без экспоненты - так
141
+ # json.dumps и делает. Правило 9 про -0 здесь не нужно: в Python -0
142
+ # целое неотличимо от 0.
143
+ return value
144
+
145
+ if isinstance(value, float):
146
+ # Правило 8. Отвергать, а не округлять: округление - это молчаливая
147
+ # подмена значения, а её последствие всплывёт в чужом хэше через
148
+ # неделю. Отдельно называем не-числа, потому что json.dumps пишет их
149
+ # как NaN и Infinity - и получившийся файл перестаёт быть JSON, хотя
150
+ # Python читает его обратно как ни в чём не бывало.
151
+ if math.isnan(value) or math.isinf(value):
152
+ raise ValidationError(
153
+ f"каноническая форма запрещает {value!r} (путь {where}): "
154
+ "JSON такого значения не знает, и файл с ним перестаёт быть "
155
+ "разбираемым чем-либо, кроме Python"
156
+ )
157
+ raise ValidationError(
158
+ f"каноническая форма запрещает числа с плавающей точкой "
159
+ f"(путь {where}, значение {value!r}). У них нет единственного "
160
+ "десятичного вида, и две реализации запишут их по-разному. "
161
+ "Выразите величину целым: деньги - минорными единицами, время - "
162
+ "миллисекундами"
163
+ )
164
+
165
+ if isinstance(value, Money):
166
+ # Порядок ключей задаёт правило 3, а не это перечисление: словарь
167
+ # сортируется по кодовым точкам при выводе.
168
+ return {
169
+ "amount_minor": value.amount_minor,
170
+ "currency": value.currency,
171
+ "scale": value.scale,
172
+ }
173
+
174
+ if isinstance(value, datetime):
175
+ # Момент выражается строкой канонической формы, а не отвергается: он
176
+ # объявлен доменным типом, и правило его вида - часть той же формы.
177
+ return canonical_instant(value)
178
+
179
+ if isinstance(value, str):
180
+ return canonical_normalize(value)
181
+
182
+ if isinstance(value, dict):
183
+ prepared: dict[str, Any] = {}
184
+ for key, item in value.items():
185
+ if not isinstance(key, str):
186
+ raise ValidationError(
187
+ f"каноническая форма требует строковых ключей "
188
+ f"(путь {where}, ключ {key!r}). Порядок ключей задан по "
189
+ "кодовым точкам, а у не-строки кодовых точек нет"
190
+ )
191
+ # Нормализация ДО сортировки, а не после: NFC меняет кодовые
192
+ # точки, и порядок, снятый с ненормализованных ключей, разошёлся
193
+ # бы с порядком, снятым с нормализованных.
194
+ canonical_key = canonical_normalize(key)
195
+ if canonical_key in prepared:
196
+ raise ValidationError(
197
+ f"после нормализации ключи {key!r} и другой ключ совпали "
198
+ f"(путь {where}). Каноническая форма не решает, какой из "
199
+ "них оставить, - это должен решить вызывающий"
200
+ )
201
+ prepared[canonical_key] = _prepare(item, f"{where}.{canonical_key}")
202
+ return prepared
203
+
204
+ if isinstance(value, (list, tuple)):
205
+ # Правило 4: порядок элементов значим и не сортируется.
206
+ return [_prepare(item, f"{where}[{index}]") for index, item in enumerate(value)]
207
+
208
+ _reject(value, where)
209
+
210
+
211
+ def canonical_dumps(value: Any) -> str:
212
+ """Сериализует значение в каноническую форму.
213
+
214
+ Выполняются правила 1-9 документа: UTF-8 без BOM, NFC, ключи по кодовым
215
+ точкам, порядок массива сохраняется, никаких незначащих пробелов,
216
+ минимальное экранирование, целые без экспоненты, никаких чисел с
217
+ плавающей точкой, никакого -0.
218
+
219
+ Args:
220
+ value (Any): Значение: словарь, список, строка, целое, булево, None.
221
+
222
+ Returns:
223
+ str: Каноническое представление. Строка, а не байты: кодировка UTF-8
224
+ применяется при записи, и делать это дважды незачем.
225
+
226
+ Raises:
227
+ ValidationError: Если значение невыразимо канонической формой.
228
+ """
229
+ return json.dumps(
230
+ _prepare(value, "$"),
231
+ ensure_ascii=False,
232
+ sort_keys=True,
233
+ separators=_SEPARATORS,
234
+ allow_nan=False,
235
+ )