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/_watch_state.py ADDED
@@ -0,0 +1,237 @@
1
+ """Непринятая партия и позиция, которую разрешено подтвердить после неё.
2
+
3
+ Партия записывается до вызова пользовательского кода. Повтор читает её, а не
4
+ площадку: новый снимок не может восстановить уже исчезнувшее изменение.
5
+ Вложенный ASCII JSON сохраняет непрозрачные позиции и payload без NFC;
6
+ внешний StateFile по-прежнему имеет каноническую форму.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections.abc import Iterator
13
+ from contextlib import ExitStack, contextmanager
14
+ from dataclasses import asdict, dataclass
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from threading import Lock
18
+ from typing import Any
19
+
20
+ from ._diff import Delivery, Event
21
+ from ._fileio import file_lock
22
+ from ._json import load_json
23
+ from ._monitoring import MarketWatch, validate_market_cursor, validate_market_payload
24
+ from ._watch import PRODUCIBLE
25
+ from .errors import ConfigurationError, CursorIncompatibleError, StateSchemaIncompatibleError
26
+ from .events import ORDERING_KEY, EventType
27
+
28
+
29
+ @contextmanager
30
+ def watch_lease(lock: Lock, path: Path | None) -> Iterator[None]:
31
+ """Один цикл владеет ядром и файлом; остальные получают отказ без ожидания."""
32
+ if not lock.acquire(blocking=False):
33
+ raise ConfigurationError("на этом ядре уже работает watch")
34
+ try:
35
+ with ExitStack() as stack:
36
+ if path is not None:
37
+ try:
38
+ stack.enter_context(
39
+ file_lock(path.with_suffix(path.suffix + ".watch.lock"), blocking=False)
40
+ )
41
+ except OSError as exc:
42
+ raise ConfigurationError(
43
+ "не удалось получить исключительное владение файлом watch"
44
+ ) from exc
45
+ yield
46
+ finally:
47
+ lock.release()
48
+
49
+
50
+ def _text(value: Any) -> bool:
51
+ return isinstance(value, str) and bool(value)
52
+
53
+
54
+ def validate_cursor(value: Any) -> dict[str, Any]:
55
+ """Проверяет курсор партии до сети и вызова обработчиков."""
56
+ if isinstance(value, dict) and "market" in value:
57
+ return validate_market_cursor(value)
58
+ if not isinstance(value, dict) or set(value) != {
59
+ "orders",
60
+ "chats",
61
+ "threads",
62
+ "pending_threads",
63
+ }:
64
+ raise ValueError("неверный состав курсора партии")
65
+ for name in ("orders", "chats"):
66
+ entries = value[name]
67
+ if entries is not None and (
68
+ not isinstance(entries, dict)
69
+ or not all(_text(key) and isinstance(item, str) for key, item in entries.items())
70
+ ):
71
+ raise ValueError("неверный курсор списка")
72
+ threads = value["threads"]
73
+ if not isinstance(threads, dict) or not all(
74
+ _text(key) and isinstance(ids, list) and all(_text(item) for item in ids)
75
+ for key, ids in threads.items()
76
+ ):
77
+ raise ValueError("неверный курсор переписок")
78
+ pending = value["pending_threads"]
79
+ if not isinstance(pending, list) or not all(_text(item) for item in pending):
80
+ raise ValueError("неверная очередь переписок")
81
+ return value
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class PendingBatch:
86
+ """События, ещё не принятые обработчиками, и целевой курсор всей партии."""
87
+
88
+ events: tuple[Event, ...]
89
+ cursor: dict[str, Any]
90
+ greeted: bool
91
+
92
+ def encode(self) -> str:
93
+ """Снимает независимую копию до передачи изменяемого payload обработчику."""
94
+ records = []
95
+ for event in self.events:
96
+ record = asdict(event)
97
+ record["observed_at"] = event.observed_at.isoformat()
98
+ records.append(record)
99
+ return json.dumps(
100
+ {"version": 1, "events": records, "cursor": self.cursor, "greeted": self.greeted},
101
+ ensure_ascii=True,
102
+ allow_nan=False,
103
+ sort_keys=True,
104
+ separators=(",", ":"),
105
+ )
106
+
107
+ @classmethod
108
+ def decode(cls, raw: Any, *, account_id: str) -> PendingBatch:
109
+ """Отвергает повреждённый журнал вместо молчаливого нового чтения."""
110
+ try:
111
+ if not isinstance(raw, str) or not raw.isascii():
112
+ raise ValueError("журнал должен быть ASCII JSON")
113
+ value = load_json(raw)
114
+ if not isinstance(value, dict) or set(value) != {
115
+ "version",
116
+ "events",
117
+ "cursor",
118
+ "greeted",
119
+ }:
120
+ raise ValueError("неверный состав журнала")
121
+ if type(value["version"]) is not int or value["version"] != 1:
122
+ raise ValueError("неизвестная версия журнала")
123
+ if type(value["greeted"]) is not bool or not isinstance(value["events"], list):
124
+ raise ValueError("неверные поля журнала")
125
+ cursor = validate_cursor(value["cursor"])
126
+ events: list[Event] = []
127
+ seen: set[str] = set()
128
+ for record in value["events"]:
129
+ if not isinstance(record, dict) or set(record) != {
130
+ "id",
131
+ "type",
132
+ "account_id",
133
+ "ordering_key",
134
+ "entity_id",
135
+ "observed_at",
136
+ "origin",
137
+ "payload",
138
+ "delivery",
139
+ }:
140
+ raise ValueError("неверный состав события")
141
+ if not all(
142
+ _text(record[key])
143
+ for key in (
144
+ "id",
145
+ "type",
146
+ "account_id",
147
+ "ordering_key",
148
+ "entity_id",
149
+ "observed_at",
150
+ "origin",
151
+ )
152
+ ):
153
+ raise ValueError("неверное поле события")
154
+ if record["account_id"] != account_id:
155
+ raise CursorIncompatibleError(
156
+ "непринятая партия принадлежит другому account_id"
157
+ )
158
+ kind = EventType(record["type"])
159
+ if kind not in PRODUCIBLE or record["id"] in seen:
160
+ raise ValueError("неизвестное либо повторное событие")
161
+ seen.add(record["id"])
162
+ key_value = record["entity_id"]
163
+ if "market" in cursor:
164
+ payload = record["payload"]
165
+ if (
166
+ not isinstance(payload, dict)
167
+ or payload.get("watch_id") not in cursor["market"]
168
+ ):
169
+ raise ValueError("событие другого наблюдения")
170
+ key_value = payload["watch_id"]
171
+ market_kinds = {
172
+ EventType.MARKET_OFFER_APPEARED,
173
+ EventType.MARKET_OFFER_DISAPPEARED,
174
+ EventType.MARKET_PRICE_CHANGED,
175
+ }
176
+ if kind in market_kinds:
177
+ if payload.get("offer_id") != record["entity_id"]:
178
+ raise ValueError("событие другого предложения")
179
+ elif (
180
+ kind
181
+ not in {
182
+ EventType.WATCH_PRIMED,
183
+ EventType.SNAPSHOT_INCOMPLETE,
184
+ EventType.WATCH_DEGRADED,
185
+ }
186
+ or record["entity_id"] != key_value
187
+ ):
188
+ raise ValueError("чужой вид события в партии рынка")
189
+ if kind in market_kinds or kind is EventType.WATCH_DEGRADED:
190
+ validate_market_payload(
191
+ kind, payload, MarketWatch(**cursor["market"][key_value]["config"])
192
+ )
193
+ elif kind in {
194
+ EventType.MARKET_OFFER_APPEARED,
195
+ EventType.MARKET_OFFER_DISAPPEARED,
196
+ EventType.MARKET_PRICE_CHANGED,
197
+ EventType.WATCH_DEGRADED,
198
+ }:
199
+ raise ValueError("рыночное событие в личной партии")
200
+ if record["ordering_key"] != ORDERING_KEY[kind].format(
201
+ **dict.fromkeys(
202
+ ("order_id", "chat_id", "watch_id", "account_id"),
203
+ key_value,
204
+ )
205
+ ):
206
+ raise ValueError("неверный ключ упорядочивания")
207
+ moment = datetime.fromisoformat(record["observed_at"])
208
+ if moment.tzinfo is None or moment.utcoffset() is None:
209
+ raise ValueError("момент события без часового пояса")
210
+ delivery = record["delivery"]
211
+ if not isinstance(delivery, dict) or set(delivery) != {"attempt", "coalesced"}:
212
+ raise ValueError("неверные метаданные доставки")
213
+ if (
214
+ type(delivery["attempt"]) is not int
215
+ or delivery["attempt"] < 0
216
+ or delivery["coalesced"] is not False
217
+ ):
218
+ raise ValueError("неверный номер попытки либо сжатая партия")
219
+ if not isinstance(record["payload"], dict):
220
+ raise ValueError("нагрузка должна быть объектом")
221
+ # Запрещены NaN/Infinity и другие непереносимые значения JSON.
222
+ json.dumps(record["payload"], allow_nan=False)
223
+ events.append(
224
+ Event(
225
+ **{
226
+ **record,
227
+ "type": kind,
228
+ "observed_at": moment,
229
+ "delivery": Delivery(**delivery),
230
+ }
231
+ )
232
+ )
233
+ return cls(tuple(events), cursor, value["greeted"])
234
+ except (ValueError, TypeError, KeyError, OverflowError, RecursionError) as exc:
235
+ raise StateSchemaIncompatibleError(
236
+ "непринятая партия watch повреждена; её нельзя заменить новым снимком"
237
+ ) from exc